-
Notifications
You must be signed in to change notification settings - Fork 1
/
player.py
1486 lines (1248 loc) · 56.9 KB
/
player.py
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
"""
BAPSicle Server
Next-gen audio playout server for University Radio York playout,
based on WebStudio interface.
Audio Player
Authors:
Matthew Stratford
Michael Grace
Date:
October, November 2020
"""
# This is the player. It does everything regarding playing sound.
# Reliability is critical here, so we're catching literally every exception possible and handling it.
# It is key that whenever the clients tells us to do something
# that we respond with something, FAIL or OKAY. They don't like to be kept waiting/ignored.
# Stop the Pygame Hello message.
import os
os.environ["PYGAME_HIDE_SUPPORT_PROMPT"] = "hide"
from helpers.os_environment import isLinux
# It's the only one we could get to work.
if isLinux():
os.putenv('SDL_AUDIODRIVER', 'pulseaudio')
from queue import Empty
import multiprocessing
import setproctitle
import copy
import json
import time
from typing import Any, Callable, Dict, List, Optional
from pygame import mixer, error
from mutagen.mp3 import MP3
from syncer import sync
from threading import Timer
from datetime import datetime
from helpers.normalisation import get_normalised_filename_if_available, get_original_filename_from_normalised
from helpers.myradio_api import MyRadioAPI
from helpers.state_manager import StateManager
from helpers.logging_manager import LoggingManager
from baps_types.plan import PlanItem
from baps_types.marker import Marker
import package
# TODO ENUM
VALID_MESSAGE_SOURCES = ["WEBSOCKET", "UI", "CONTROLLER", "TEST", "ALL"]
TRACKLISTING_DELAYED_S = 20
class Player:
out_q: multiprocessing.Queue
last_msg: str
last_msg_source: str
last_time_update = None
state: StateManager
logger: LoggingManager
api: MyRadioAPI
running: bool = False
stopped_manually: bool = False
tracklist_start_timer: Optional[Timer] = None
tracklist_end_timer: Optional[Timer] = None
# The default state that should be set if there is no previous state info.
__default_state = {
"initialised": False,
"loaded_item": None,
"channel": -1,
"playing": False,
"paused": False,
"loaded": False,
"pos": 0,
"pos_offset": 0,
"pos_true": 0,
"remaining": 0,
"length": 0,
"auto_advance": True,
"repeat": "none", # none, one or all
"play_on_load": False,
"output": None,
"show_plan": [],
"live": True,
"tracklist_mode": "off",
"tracklist_id": None,
}
# These tell the StateManager which variables we don't care about really accurate history for.
# This means that the internal running state of the player will have quickly updating info (multiple times a sec)
# But we will rate limit (a few secs) saving updates to these variables to the state JSON file.
__rate_limited_params = ["pos", "pos_offset", "pos_true", "remaining"]
# Checks if the mixer is init'd. It will throw an exception if not.
@property
def isInit(self):
try:
mixer.music.get_busy()
except Exception:
return False
return True
@property
def isPlaying(self) -> bool:
if self.isInit:
return not self.isPaused and mixer.music.get_busy()
return False
@property
def isPaused(self) -> bool:
return self.state.get()["paused"]
@property
def isLoaded(self):
return self.state.get()["loaded"]
# Checks if a file has been loaded
# This should be run with a long test before client requests for status etc.
# Short tests are used in other logic in the player, without fussing over full playback tests.
def _checkIsLoaded(self, short_test: bool = False):
loaded = True
if not self.state.get()["loaded_item"] or not self.isInit:
loaded = False
elif not self.isPlaying:
# Because this function can be called very often, only some (less
# frequent) checks will initiate a full trial of loading success, for
# efficiency.
if not short_test:
# We're not playing now, so we can quickly test run
# If that works, we're truely loaded.
try:
mixer.music.set_volume(0)
mixer.music.play(0)
except Exception:
try:
mixer.music.set_volume(1)
except Exception:
self.logger.log.exception(
"Failed to reset volume after attempting loaded test."
)
pass
loaded = False
finally:
mixer.music.stop()
mixer.music.set_volume(1)
self.state.update("loaded", loaded)
return loaded
# Is the player at a cue marker point?
@property
def isCued(self):
if not self.isLoaded:
return False
return (
self.state.get()["pos_true"] == self.state.get()["loaded_item"].cue
and not self.isPlaying
)
# Returns the state of the player as a nice friendly JSON dump.
@property
def status(self):
# Get a copy of the server state.
state = self.state.state
# Not the biggest fan of this, but maybe I'll get a better solution for this later
# Convert objects to a nice JSON friendly dicts.
state["loaded_item"] = (
state["loaded_item"].__dict__ if state["loaded_item"] else None
)
state["show_plan"] = [repr.__dict__ for repr in state["show_plan"]]
res = json.dumps(state)
return res
# Audio Playout Related Methods
# Loads a plan item into the player, ready for playing.
# This includes some retry logic to try and double-down on ensuring it plays successfully.
def load(self, weight: int):
if not self.isPlaying:
# If we have something loaded already, unload it first.
self.unload()
loaded_state = self.state.get()
# Sometimes (at least on windows), the pygame player will lose output to the sound output after a while.
# It's odd, but essentially, to stop / recover from this, we de-init the pygame mixer and init it again.
self.logger.log.info(
"Resetting output (in case of sound output gone silent somehow) to "
+ str(loaded_state["output"])
)
self.set_output(loaded_state["output"])
showplan = loaded_state["show_plan"]
loaded_item: Optional[PlanItem] = None
# Go find the show plan item of the weight we've been asked to load.
for i in range(len(showplan)):
if showplan[i].weight == weight:
loaded_item = showplan[i]
break
# If we didn't find it, exit.
if loaded_item is None:
self.logger.log.error(
"Failed to find weight: {}".format(weight))
return False
# This item exists, so we're comitting to load this item.
self.state.update("loaded_item", loaded_item)
# The file_manager helper may have pre-downloaded the file already, or we've played it before.
reload = False
if loaded_item.filename == "" or loaded_item.filename is None:
self.logger.log.info(
"Filename is not specified, loading from API.")
reload = True
elif not os.path.exists(loaded_item.filename):
self.logger.log.warn(
"Filename given doesn't exist. Re-loading from API."
)
reload = True
# Ask the API for the file if we need it.
if reload:
file = sync(self.api.get_filename(item=loaded_item))
loaded_item.filename = str(file) if file else None
# If the API still couldn't get the file, RIP.
if not loaded_item.filename:
return False
# Swap with a normalised version if it's ready, else returns original.
loaded_item.filename = get_normalised_filename_if_available(
loaded_item.filename
)
# Given we've just messed around with filenames etc, update the item again.
self.state.update("loaded_item", loaded_item)
for i in range(len(showplan)):
if showplan[i].weight == weight:
self.state.update("show_plan", index=i, value=loaded_item)
break
load_attempt = 0
# Let's have 5 attempts at loading the item audio
while load_attempt < 5:
load_attempt += 1
original_file = None
if load_attempt == 3:
# Ok, we tried twice already to load the file.
# Let's see if we can recover from this.
# Try swapping the normalised version out for the original.
original_file = get_original_filename_from_normalised(
loaded_item.filename
)
self.logger.log.warning("3rd attempt. Trying the non-normalised file: {}".format(original_file))
if load_attempt == 4:
# well, we've got so far that the normalised and original files didn't load.
# Take a last ditch effort to download the original file again.
file = sync(self.api.get_filename(item=loaded_item, redownload=True))
if file:
original_file = str(file)
self.logger.log.warning("4rd attempt. Trying to redownload the file, got: {}".format(original_file))
if original_file:
loaded_item.filename = original_file
try:
self.logger.log.info(
"Attempt {} Loading file: {}".format(load_attempt, loaded_item.filename))
mixer.music.load(loaded_item.filename)
except Exception:
# We couldn't load that file.
self.logger.log.exception(
"Couldn't load file: " + str(loaded_item.filename)
)
continue # Try loading again.
try:
if loaded_item.filename.endswith(".mp3"):
song = MP3(loaded_item.filename)
self.state.update("length", song.info.length)
else:
# WARNING! Pygame / SDL can't seek .wav files :/
self.state.update(
"length",
mixer.Sound(
loaded_item.filename).get_length() / 1000,
)
except Exception:
self.logger.log.exception(
"Failed to update the length of item.")
continue # Try loading again.
# Everything worked, we made it!
# Write the loaded item again once more, to confirm the filename if we've reattempted.
self.state.update("loaded_item", loaded_item)
# Now just double check that pygame could actually play it (silently)
self._checkIsLoaded()
if not self.isLoaded:
self.logger.log.error(
"Pygame loaded file without error, but never actually loaded."
)
continue # Try loading again.
# If the track has a cue point, let's jump to that, ready.
if loaded_item.cue > 0:
self.seek(loaded_item.cue)
else:
self.seek(0)
if loaded_state["play_on_load"]:
self.unpause()
return True
# Even though we failed, make sure state is up to date with latest failure.
# We're comitting to load this item.
self.state.update("loaded_item", loaded_item)
self._checkIsLoaded()
return False
# Remove the currently loaded item from the player.
# Not much reason to do this, but if it makes you happy.
def unload(self):
if not self.isPlaying:
try:
mixer.music.unload()
self.state.update("paused", False)
self.state.update("loaded_item", None)
except Exception:
self.logger.log.exception("Failed to unload channel.")
return False
# self._potentially_end_tracklist()
# If we unloaded successfully, reset the tracklist_id, ready for the next item.
if not self.isLoaded:
self.state.update("tracklist_id", None)
# If we successfully unloaded, this will return true, for success!
return not self.isLoaded
# Starts playing the loaded item, from a given position (secs)
def play(self, pos: float = 0):
self.logger.log.info("Playing from pos: " + str(pos))
if not self.isLoaded:
self.logger.log.warning("Player is not loaded.")
return False
try:
mixer.music.play(0, pos)
self.state.update("pos_offset", pos)
except Exception:
self.logger.log.exception("Failed to play at pos: " + str(pos))
return False
self.state.update("paused", False)
self._potentially_tracklist()
self.stopped_manually = False
return True
# Pauses the player
def pause(self):
# Because the player's position is stored by a event from pygame while playing only,
# the current playback position state will remain, in case we unpause later.
try:
mixer.music.stop()
except Exception:
self.logger.log.exception("Failed to pause.")
return False
self.stopped_manually = True
self.state.update("paused", True)
return True
# Plays the player, from the playback position it was already at.
def unpause(self):
if not self.isPlaying:
state = self.state.get()
position: float = state["pos_true"]
if not self.play(position):
self.logger.log.exception(
"Failed to unpause from pos: " + str(position)
)
return False
self.state.update("paused", False)
# Increment Played count
loaded_item = state["loaded_item"]
if loaded_item:
loaded_item.play_count_increment()
self.state.update("loaded_item", loaded_item)
return True
return False
# Stop the player.
def stop(self, user_initiated: bool = False):
try:
mixer.music.stop()
except Exception:
self.logger.log.exception("Failed to stop playing.")
return False
self.state.update("paused", False)
# When it wasn't _ended() calling this, end the tracklist.
# _ended() already calls this, but user stops won't have.
if user_initiated:
self._potentially_end_tracklist()
self.stopped_manually = True
if not self.state.get()["loaded_item"]:
self.logger.log.warning("Tried to stop without a loaded item.")
return True
# This lets users toggle (using the stop button) between cue point and 0.
if user_initiated and not self.isCued:
# if there's a cue point ant we're not at it, go there.
self.seek(self.state.get()["loaded_item"].cue)
else:
# Otherwise, let's go to 0.
self.seek(0)
return True
# Move the audio position (secs) of the player
def seek(self, pos: float) -> bool:
self.logger.log.info("Seeking to pos:" + str(pos))
if self.isPlaying:
# If we're playing, just start playing directly from that position
try:
self.play(pos)
except Exception:
self.logger.log.exception("Failed to seek to pos: " + str(pos))
return False
return True
else:
# If we're not actually playing at the moment, set the player to be paused at the new position
self.logger.log.debug(
"Not playing during seek, setting pos state for next play."
)
self.stopped_manually = True # Don't trigger _ended() on seeking.
if pos > 0:
self.state.update("paused", True)
self._updateState(pos=pos)
return True
# Set the output device name and initialise the pygame audio mixer.
def set_output(self, name: Optional[str] = None):
wasPlaying = self.isPlaying
state = self.state.get()
oldPos = state["pos_true"]
name = None if (not name or name.lower() == "none") else name
# Stop the mixer if it's already init'd.
self.quit()
self.state.update("output", name)
try:
# Setup the mixer.
# Sample rate of 44100Hz (44.1KHz) (matching the MP3's and typical CD/online source material)
# 16 bits per sample
# 2 channels (stereo)
# sample buffer of 1024 samples
if name:
mixer.init(44100, -16, 2, 1024, devicename=name)
else:
# Use the default system output
mixer.init(44100, -16, 2, 1024)
except Exception:
self.logger.log.exception(
"Failed to init mixer with device name: " + str(name)
)
return False
# If we had something loaded before, load it back in and play it.
loadedItem = state["loaded_item"]
if loadedItem:
self.logger.log.info("Reloading after output change.")
self.load(loadedItem.weight)
if wasPlaying:
self.logger.log.info("Resuming playback after output change.")
self.play(oldPos)
return True
# De-initialises the pygame mixer.
def quit(self):
try:
mixer.quit()
self.state.update("paused", False)
self.logger.log.info("Quit mixer.")
except Exception:
self.logger.log.exception("Failed to quit mixer.")
# Sets whether auto advance is on or off
# Auto advance is where the next item in the list is selected after the current item is finished playing.
def set_auto_advance(self, message: bool) -> bool:
self.state.update("auto_advance", message)
return True
# As you'd expect, all rotates around all of the items in the channel plan, and loops to the first from the last.
# One plays the same item over and over again
def set_repeat(self, message: str) -> bool:
if message in ["all", "one", "none"]:
self.state.update("repeat", message)
return True
else:
return False
# Set whether the player should play the item as soon as it's been selected.
def set_play_on_load(self, message: bool) -> bool:
self.state.update("play_on_load", message)
return True
# Show Plan Related Methods
def _check_ghosts(self, item: PlanItem):
# Webstudio returns intermediate "I" objects when dragging in from the media sidebar.
if isinstance(item.timeslotitemid, str) and item.timeslotitemid.startswith("I"):
# Kinda a bodge for the moment, each "Ghost" (item which is not saved in the database showplan yet)
# needs to have a unique temporary item.
# To do this, we'll start with the channel number the item was originally added to
# (to stop items somehow simultaneously added to different channels from having the same id)
# And chuck in the unix epoch in ns for good measure.
item.timeslotitemid = "GHOST-{}-{}".format(
self.state.get()["channel"], time.time_ns()
)
return item
# Pull in from the API the show plan items for this player channel.
def get_plan(self, show_plan_id: int):
# Call the API
# sync turns the asyncronous API into syncronous.
plan = sync(self.api.get_showplan(show_plan_id))
# Empty the channel plan so we can put the updated items in.
self.clear_channel_plan()
channel = self.state.get()["channel"]
self.logger.log.debug(plan)
# If there isn't a show plan for the required show, return failure without filling in the plan.
if not isinstance(plan, dict):
return False
# Add the items, if this channel has any.
if str(channel) in plan.keys():
plan_items = plan[str(channel)]
try:
self.add_to_plan(plan_items)
except Exception as e:
self.logger.log.error(
"Failed to add items to show plan: {}".format(e)
)
return False
return True
# Add a list of new show plan items to the channel.
# These will be in dict format, we'll validate them and turn them into proper plan objects.
# TODO Allow just moving an item inside the channel instead of removing and adding.
def add_to_plan(self, new_items: List[Dict[str, Any]]) -> bool:
plan_copy: List[PlanItem] = copy.copy(self.state.get()["show_plan"])
for new_item in new_items:
new_item_obj = PlanItem(new_item)
new_item_obj = self._check_ghosts(new_item_obj)
# Shift any plan items after the new position down one to make space.
for item in plan_copy:
if item.weight >= new_item_obj.weight:
item.weight += 1
plan_copy += [new_item_obj] # Add the new item.
loaded_item = self.state.get()["loaded_item"]
if loaded_item:
# Right. So this may be confusing.
# So... If the user has just moved the loaded item in the channel (by removing above and readding)
# Then we want to re-associate the loaded_item object reference with the new one.
# The loaded item object before this change is now an orphan, which was
# kept around while the loaded item was potentially moved to another
# channel.
if loaded_item.timeslotitemid == new_item_obj.timeslotitemid:
self.state.update("loaded_item", new_item_obj)
# NOPE NOPE NOPE
# THIS IS AN EXAMPLE OF WHAT NOT TO DO!
# ONCE AGAIN, THE LOADED ITEM IS THE SAME OBJECT INSTANCE AS THE ONE IN
# THE SHOW PLAN (AS LONG AS IT HASN'T BEEN RE/MOVED)
# loaded_item.weight = new_item_obj.weight
# Bump the loaded_item's weight if we just added a new item above it.
# elif loaded_item.weight >= new_item_obj.weight:
# loaded_item.weight += 1
# Else, new weight stays the same.
# else:
# return True
# self.state.update("loaded_item", loaded_item)
# Just in case somehow we've ended up with items with the same weights (or gaps)
# We'll correct them.
# This function also orders and saves the updated plan copy we've given it.
self._fix_and_update_weights(plan_copy)
return True
# Removes an item from the show plan with the given weight (index)
def remove_from_plan(self, weight: int) -> bool:
plan_copy: List[PlanItem] = copy.copy(self.state.get()["show_plan"])
found: Optional[PlanItem] = None
# Give some helpful debug
before = []
for item in plan_copy:
before += (item.weight, item.name)
self.logger.log.debug(
"Weights before removing weight {}:\n{}".format(weight, before)
)
# Look for the item with the correct weight
for i in plan_copy:
if i.weight == weight:
found = i
plan_copy.remove(i)
if found:
self._fix_and_update_weights(plan_copy)
# If we removed the loaded item from this channel, update it's weight
# So we know how/not to autoadvance.
loaded_item = self.state.get()["loaded_item"]
if loaded_item == found:
# Loaded_item is actually the same PlanItem instance as in the show_plan.
# So if it's still in the show plan, we'll have corrected it's weight already.
# If it was removed above, fix_weights won't have done anything
# So we'll want to update the weight.
# We're removing the loaded item from the channel.
# if loaded_item.weight == weight:
loaded_item.weight = -1
# If loaded_item wasn't the same instance, we'd want to do the below.
# We removed an item above it. Shift it up.
# elif loaded_item.weight > weight:
# loaded_item.weight -= 1
# Else, new weight stays the same.
# else:
# return True
self.state.update("loaded_item", loaded_item)
return True
return False
# Empties the channel's plan.
def clear_channel_plan(self) -> bool:
self.state.update("show_plan", [])
return True
def load(self, weight: int):
if not self.isPlaying:
loaded_state = self.state.get()
self.unload()
self.logger.log.info("Resetting output (in case of sound output gone silent somehow) to " + str(loaded_state["output"]))
self.output(loaded_state["output"])
showplan = loaded_state["show_plan"]
loaded_item: Optional[PlanItem] = None
for i in range(len(showplan)):
if showplan[i].weight == weight:
loaded_item = showplan[i]
break
if loaded_item is None:
self.logger.log.error(
"Failed to find weight: {}".format(weight))
return False
reload = False
if loaded_item.filename == "" or loaded_item.filename is None:
self.logger.log.info(
"Filename is not specified, loading from API.")
reload = True
elif not os.path.exists(loaded_item.filename):
self.logger.log.warn(
"Filename given doesn't exist. Re-loading from API."
)
reload = True
if reload:
loaded_item.filename = sync(self.api.get_filename(item=loaded_item))
if not loaded_item.filename:
return False
self.state.update("loaded_item", loaded_item)
for i in range(len(showplan)):
if showplan[i].weight == weight:
self.state.update("show_plan", index=i, value=loaded_item)
break
# TODO: Update the show plan filenames???
load_attempt = 0
while load_attempt < 5:
load_attempt += 1
try:
self.logger.log.info("Loading file: " +
str(loaded_item.filename))
mixer.music.load(loaded_item.filename)
except Exception:
# We couldn't load that file.
self.logger.log.exception(
"Couldn't load file: " + str(loaded_item.filename)
)
time.sleep(1)
continue # Try loading again.
if not self.isLoaded:
self.logger.log.error("Pygame loaded file without error, but never actually loaded.")
time.sleep(1)
continue # Try loading again.
try:
if ".mp3" in loaded_item.filename:
song = MP3(loaded_item.filename)
self.state.update("length", song.info.length)
else:
self.state.update(
"length", mixer.Sound(
loaded_item.filename).get_length() / 1000
)
except Exception:
self.logger.log.exception(
"Failed to update the length of item.")
time.sleep(1)
continue # Try loading again.
# Everything worked, we made it!
if loaded_item.cue > 0:
self.seek(loaded_item.cue)
else:
self.seek(0)
if self.state.get()["play_on_load"]:
self.unpause()
return True
self.logger.log.error("Failed to load track after numerous retries.")
return False
return False
def unload(self):
if not self.isPlaying:
try:
mixer.music.unload()
self.state.update("paused", False)
self.state.update("loaded_item", None)
except Exception:
self.logger.log.exception("Failed to unload channel.")
return False
self._potentially_end_tracklist()
# If we unloaded successfully, reset the tracklist_id, ready for the next item.
if not self.isLoaded:
self.state.update("tracklist_id", None)
return not self.isLoaded
def quit(self):
try:
mixer.quit()
self.state.update("paused", False)
self.logger.log.info("Quit mixer.")
except Exception:
self.logger.log.exception("Failed to quit mixer.")
def output(self, name: Optional[str] = None):
wasPlaying = self.state.get()["playing"]
oldPos = self.state.get()["pos_true"]
name = None if (not name or name.lower() == "none") else name
self.quit()
self.state.update("output", name)
try:
if name:
mixer.init(44100, -16, 2, 1024, devicename=name)
else:
mixer.init(44100, -16, 2, 1024)
except Exception:
self.logger.log.exception(
"Failed to init mixer with device name: " + str(name)
)
return False
loadedItem = self.state.get()["loaded_item"]
if loadedItem:
self.logger.log.info("Reloading after output change.")
self.load(loadedItem.weight)
if wasPlaying:
self.logger.log.info("Resuming playback after output change.")
self.play(oldPos)
return True
# PlanItems can have markers. These are essentially bookmarked positions in the audio.
# Timeslotitemid can be a ghost (un-submitted item), so may be "IXXX", hence str.
def set_marker(self, timeslotitemid: str, marker_str: str):
set_loaded = False
success = True
try:
# Take a string representation of the marker (from clients)
marker = Marker(marker_str)
except Exception as e:
self.logger.log.error(
"Failed to create Marker instance with {} {}: {}".format(
timeslotitemid, marker_str, e
)
)
return False
# Allow setting a marker for the currently loaded item.
if timeslotitemid == "-1":
set_loaded = True
if not self.isLoaded:
return False
timeslotitemid = self.state.get()["loaded_item"].timeslotitemid
elif (
self.isLoaded
and self.state.get()["loaded_item"].timeslotitemid == timeslotitemid
):
set_loaded = True
# Loop over the show plan items. When you find the timeslotitemid the marker is for, update it.
# This is instead of weight, since the client asking doesn't know the weight of the item (or which channel it is)
# So all channels will look and update if necessary.
plan_copy: List[PlanItem] = copy.copy(self.state.get()["show_plan"])
for i in range(len(self.state.get()["show_plan"])):
item = plan_copy[i]
if str(item.timeslotitemid) == str(timeslotitemid):
try:
new_item = item.set_marker(marker)
self.state.update("show_plan", new_item, index=i)
except Exception as e:
self.logger.log.error(
"Failed to set marker on item {}: {} with marker \n{}".format(
timeslotitemid, e, marker
)
)
success = False
# If the item to update was the loaded item, update it.
if set_loaded:
try:
self.state.update(
"loaded_item", self.state.get(
)["loaded_item"].set_marker(marker)
)
except Exception as e:
self.logger.log.error(
"Failed to set marker on loaded_item {}: {} with marker \n{}".format(
timeslotitemid, e, marker
)
)
success = False
return success
# This marks an item as played, or not.
# A weight of -1 will affect all items in the channel
def set_played(self, weight: int, played: bool):
plan: List[PlanItem] = self.state.get()["show_plan"]
if weight == -1:
for item in plan:
item.play_count_increment() if played else item.play_count_reset()
self.state.update("show_plan", plan)
elif len(plan) > weight:
plan[weight].play_count_increment() if played else plan[
weight
].play_count_reset()
self.state.update("show_plan", plan[weight], weight)
else:
return False
return True
# Tells the player that the fader is live on-air, so it can tell tracklisting from PFL
def set_live(self, live: bool):
self.state.update("live", live)
# If we're going to live (potentially from not live/PFL), potentially tracklist if it's playing.
if live:
self._potentially_tracklist()
# If the fader is now not live, don't bother stopping the tracklist,
# incase it's faded up again during the same playback.
return True
# Helper functions
# This essentially allows the tracklist start API call to happen in a separate thread, to avoid hanging playout/loading.
def _potentially_tracklist(self):
mode = self.state.get()["tracklist_mode"]
time: int = -1
if mode == "on":
time = 0 # Let's do it pretty quickly.
if mode == "fader-live":
time = 4 # Give presenter a bit of a grace period in case they accidentally fade up the wrong one.
elif mode == "delayed":
# Let's do it in a bit, once we're sure it's been playing. (Useful if we've got no idea if it's live or cueing.)
time = TRACKLISTING_DELAYED_S
if time >= 0 and not self.tracklist_start_timer:
self.logger.log.info(
"Setting timer for tracklisting in {} secs due to Mode: {}".format(
time, mode
)
)
self.tracklist_start_timer = Timer(time, self._tracklist_start)
self.tracklist_start_timer.start()
elif self.tracklist_start_timer:
self.logger.log.error(
"Failed to potentially tracklist, timer already busy."
)
# This essentially allows the tracklist end API call to happen in a separate thread, to avoid hanging playout/loading.
def _potentially_end_tracklist(self):
if self.tracklist_start_timer:
self.logger.log.info(
"A tracklist start timer was running, cancelling.")
self.tracklist_start_timer.cancel()
self.tracklist_start_timer = None
# Decrement Played count on track we didn't play enough of to tracklist.
state = self.state.get()
loaded_item = state["loaded_item"]
if loaded_item and loaded_item.type == "central":
loaded_item.play_count_decrement()
self.state.update("loaded_item", loaded_item)
# Make a copy of the tracklist_id, it will get reset as we load the next item.
tracklist_id = self.state.get()["tracklist_id"]
if not tracklist_id:
self.logger.log.info("No tracklist to end.")
return
if tracklist_id:
self.logger.log.info(
"Attempting to end tracklist_id '{}'".format(tracklist_id)
)
if self.tracklist_end_timer:
self.logger.log.error(
"Failed to potentially end tracklist, timer already busy."
)
return
self.state.update("tracklist_id", None)
# This threads it, so it won't hang track loading if it fails.
self.tracklist_end_timer = Timer(
1, self._tracklist_end, [tracklist_id])
self.tracklist_end_timer.start()
else:
self.logger.log.warning(