diff --git a/.github/workflows/build_sphinx_docs.yml b/.github/workflows/build_sphinx_docs.yml index 865b331..f2c6272 100644 --- a/.github/workflows/build_sphinx_docs.yml +++ b/.github/workflows/build_sphinx_docs.yml @@ -12,13 +12,13 @@ jobs: steps: # https://github.com/marketplace/actions/checkout - name: Checkout the repository - uses: actions/checkout@v3 + uses: actions/checkout@v5 # https://github.com/marketplace/actions/setup-python - name: Install Python - uses: actions/setup-python@v4 + uses: actions/setup-python@v6 with: - python-version: '3.10' + python-version: '3.13' - name: Setup Python environment run: | diff --git a/skill/app.py b/skill/app.py index 2e9acd9..78ab50f 100755 --- a/skill/app.py +++ b/skill/app.py @@ -1,6 +1,7 @@ +from datetime import datetime from flask import Flask, render_template import logging -from multiprocessing import Process, Manager +from multiprocessing import Process from multiprocessing.managers import BaseManager import os import random @@ -42,7 +43,7 @@ logger.addHandler(handler) # logger.info('AskNavidrome 0.6!') -logger.debug('Getting configutration from the environment...') +logger.debug('Getting configuration from the environment...') try: if 'NAVI_SKILL_ID' in os.environ: @@ -174,7 +175,7 @@ if 'NAVI_DEBUG' in os.environ: logger.setLevel(logging.WARNING) logger.warning('Log level set to WARNING') -# Create a sharable queue than can be updated by multiple threads to enable larger playlists +# Create a shareable queue than can be updated by multiple threads to enable larger playlists # to be returned in the back ground avoiding the Amazon 8 second timeout BaseManager.register('MediaQueue', queue.MediaQueue) manager = BaseManager() @@ -183,7 +184,7 @@ play_queue = manager.MediaQueue() logger.debug('MediaQueue object created...') # Variable to store the additional thread used to populate large playlists -# this is used to avoid concurency issues if there is an attempt to load multiple playlists +# this is used to avoid concurrency issues if there is an attempt to load multiple playlists # at the same time. backgroundProcess = None @@ -294,8 +295,15 @@ class NaviSonicPlayMusicByArtist(AbstractRequestHandler): return is_intent_name('NaviSonicPlayMusicByArtist')(handler_input) def handle(self, handler_input: HandlerInput) -> Response: + global backgroundProcess logger.debug('In NaviSonicPlayMusicByArtist') + # Check if a background process is already running, if it is then terminate the process + # in favour of the new process. + if backgroundProcess != None: + backgroundProcess.terminate() + backgroundProcess.join() + # Get the requested artist artist = get_slot_value_v2(handler_input, 'artist') @@ -315,7 +323,11 @@ class NaviSonicPlayMusicByArtist(AbstractRequestHandler): # Build a list of songs to play song_id_list = connection.build_song_list_from_albums(artist_album_lookup, min_song_count) play_queue.clear() - controller.enqueue_songs(connection, play_queue, song_id_list) + + controller.enqueue_songs(connection, play_queue, [song_id_list[0], song_id_list[1]]) # When generating the playlist return the first two tracks. + backgroundProcess = Process(target=queueWorkerThread, args=(connection, play_queue, song_id_list[2:])) # Create a thread to enqueue the remaining tracks + backgroundProcess.start() # Start the additional thread + speech = f'Playing music by: {artist.value}' logger.info(speech) @@ -338,8 +350,15 @@ class NaviSonicPlayAlbumByArtist(AbstractRequestHandler): return is_intent_name('NaviSonicPlayAlbumByArtist')(handler_input) def handle(self, handler_input: HandlerInput) -> Response: + global backgroundProcess logger.debug('In NaviSonicPlayAlbumByArtist') + # Check if a background process is already running, if it is then terminate the process + # in favour of the new process. + if backgroundProcess != None: + backgroundProcess.terminate() + backgroundProcess.join() + # Get variables from intent artist = get_slot_value_v2(handler_input, 'artist') album = get_slot_value_v2(handler_input, 'album') @@ -362,7 +381,7 @@ class NaviSonicPlayAlbumByArtist(AbstractRequestHandler): # Search the list of dictionaries for the requested album # Strings are all converted to lower case to minimise matching errors - result = [album_result for album_result in artist_album_lookup if album_result.get('title').lower() == album.value.lower()] + result = [album_result for album_result in artist_album_lookup if album_result.get('name').lower() == album.value.lower()] if not result: text = f"I couldn't find an album called {album.value} by {artist.value} in the collection." @@ -371,9 +390,13 @@ class NaviSonicPlayAlbumByArtist(AbstractRequestHandler): return handler_input.response_builder.response # At this point we have found an album that matches - songs = connection.build_song_list_from_albums(result, -1) + song_id_list = connection.build_song_list_from_albums(result, -1) play_queue.clear() - controller.enqueue_songs(connection, play_queue, songs) + + # Work around the Amazon / Alexa 8 second timeout. + controller.enqueue_songs(connection, play_queue, [song_id_list[0], song_id_list[1]]) # When generating the playlist return the first two tracks. + backgroundProcess = Process(target=queueWorkerThread, args=(connection, play_queue, song_id_list[2:])) # Create a thread to enqueue the remaining tracks + backgroundProcess.start() # Start the additional thread speech = f'Playing {album.value} by: {artist.value}' logger.info(speech) @@ -397,9 +420,14 @@ class NaviSonicPlayAlbumByArtist(AbstractRequestHandler): return handler_input.response_builder.response else: - songs = connection.build_song_list_from_albums(result, -1) + song_id_list = connection.build_song_list_from_albums(result, -1) play_queue.clear() - controller.enqueue_songs(connection, play_queue, songs) + + # Work around the Amazon / Alexa 8 second timeout. + controller.enqueue_songs(connection, play_queue, [song_id_list[0], song_id_list[1]]) # When generating the playlist return the first two tracks. + backgroundProcess = Process(target=queueWorkerThread, args=(connection, play_queue, song_id_list[2:])) # Create a thread to enqueue the remaining tracks + backgroundProcess.start() # Start the additional thread + speech = f'Playing {album.value}' logger.info(speech) @@ -506,6 +534,7 @@ class NaviSonicPlayPlaylist(AbstractRequestHandler): controller.enqueue_songs(connection, play_queue, [song_id_list[0], song_id_list[1]]) # When generating the playlist return the first two tracks. backgroundProcess = Process(target=queueWorkerThread, args=(connection, play_queue, song_id_list[2:])) # Create a thread to enqueue the remaining tracks backgroundProcess.start() # Start the additional thread + speech = 'Playing playlist ' + str(playlist.value) logger.info(speech) card = {'title': 'AskNavidrome', @@ -522,17 +551,24 @@ def queueWorkerThread(connection, play_queue, song_id_list): logger.debug('Finished playlist processing!') class NaviSonicPlayMusicByGenre(AbstractRequestHandler): - """ Play songs from the given genere + """ Play songs from the given genre - 50 tracks from the given genere are shuffled and played + 50 tracks from the given genre are shuffled and played """ def can_handle(self, handler_input: HandlerInput) -> bool: return is_intent_name('NaviSonicPlayMusicByGenre')(handler_input) def handle(self, handler_input: HandlerInput) -> Response: + global backgroundProcess logger.debug('In NaviSonicPlayMusicByGenre') + # Check if a background process is already running, if it is then terminate the process + # in favour of the new process. + if backgroundProcess != None: + backgroundProcess.terminate() + backgroundProcess.join() + # Get the requested genre genre = get_slot_value_v2(handler_input, 'genre') @@ -547,7 +583,11 @@ class NaviSonicPlayMusicByGenre(AbstractRequestHandler): else: random.shuffle(song_id_list) play_queue.clear() - controller.enqueue_songs(connection, play_queue, song_id_list) + + # Work around the Amazon / Alexa 8 second timeout. + controller.enqueue_songs(connection, play_queue, [song_id_list[0], song_id_list[1]]) # When generating the playlist return the first two tracks. + backgroundProcess = Process(target=queueWorkerThread, args=(connection, play_queue, song_id_list[2:])) # Create a thread to enqueue the remaining tracks + backgroundProcess.start() # Start the additional thread speech = f'Playing {genre.value} music' logger.info(speech) @@ -569,8 +609,15 @@ class NaviSonicPlayMusicRandom(AbstractRequestHandler): return is_intent_name('NaviSonicPlayMusicRandom')(handler_input) def handle(self, handler_input: HandlerInput) -> Response: + global backgroundProcess logger.debug('In NaviSonicPlayMusicRandom') + # Check if a background process is already running, if it is then terminate the process + # in favour of the new process. + if backgroundProcess != None: + backgroundProcess.terminate() + backgroundProcess.join() + song_id_list = connection.build_random_song_list(min_song_count) if song_id_list is None: @@ -582,7 +629,11 @@ class NaviSonicPlayMusicRandom(AbstractRequestHandler): else: random.shuffle(song_id_list) play_queue.clear() - controller.enqueue_songs(connection, play_queue, song_id_list) + + # Work around the Amazon / Alexa 8 second timeout. + controller.enqueue_songs(connection, play_queue, [song_id_list[0], song_id_list[1]]) # When generating the playlist return the first two tracks. + backgroundProcess = Process(target=queueWorkerThread, args=(connection, play_queue, song_id_list[2:])) # Create a thread to enqueue the remaining tracks + backgroundProcess.start() # Start the additional thread speech = 'Playing random music' logger.info(speech) @@ -604,8 +655,15 @@ class NaviSonicPlayFavouriteSongs(AbstractRequestHandler): return is_intent_name('NaviSonicPlayFavouriteSongs')(handler_input) def handle(self, handler_input: HandlerInput) -> Response: + global backgroundProcess logger.debug('In NaviSonicPlayFavouriteSongs') + # Check if a background process is already running, if it is then terminate the process + # in favour of the new process. + if backgroundProcess != None: + backgroundProcess.terminate() + backgroundProcess.join() + song_id_list = connection.build_song_list_from_favourites() if song_id_list is None: @@ -617,7 +675,11 @@ class NaviSonicPlayFavouriteSongs(AbstractRequestHandler): else: random.shuffle(song_id_list) play_queue.clear() - controller.enqueue_songs(connection, play_queue, song_id_list) + + # Work around the Amazon / Alexa 8 second timeout. + controller.enqueue_songs(connection, play_queue, [song_id_list[0], song_id_list[1]]) # When generating the playlist return the first two tracks. + backgroundProcess = Process(target=queueWorkerThread, args=(connection, play_queue, song_id_list[2:])) # Create a thread to enqueue the remaining tracks + backgroundProcess.start() # Start the additional thread speech = 'Playing your favourite tracks.' logger.info(speech) @@ -659,9 +721,11 @@ class NaviSonicSongDetails(AbstractRequestHandler): def handle(self, handler_input: HandlerInput) -> Response: logger.debug('In NaviSonicSongDetails Handler') - title = play_queue.current_track.title - artist = play_queue.current_track.artist - album = play_queue.current_track.album + current_track = play_queue.get_current_track() + + title = current_track.title + artist = current_track.artist + album = current_track.album text = f'This is {title} by {artist}, from the album {album}' handler_input.response_builder.speak(text) @@ -681,7 +745,9 @@ class NaviSonicStarSong(AbstractRequestHandler): def handle(self, handler_input: HandlerInput) -> Response: logger.debug('In NaviSonicStarSong Handler') - song_id = play_queue.current_track.id + current_track = play_queue.get_current_track() + + song_id = current_track.id connection.star_entry(song_id, 'song') return handler_input.response_builder.response @@ -699,7 +765,9 @@ class NaviSonicUnstarSong(AbstractRequestHandler): def handle(self, handler_input: HandlerInput) -> Response: logger.debug('In NaviSonicUnstarSong Handler') - song_id = play_queue.current_track.id + current_track = play_queue.get_current_track() + + song_id = current_track.id connection.star_entry(song_id, 'song') connection.unstar_entry(song_id, 'song') @@ -741,8 +809,10 @@ class PlaybackStoppedHandler(AbstractRequestHandler): logger.debug('In PlaybackStoppedHandler') # store the current offset for later resumption - play_queue.current_track.offset = handler_input.request_envelope.request.offset_in_milliseconds - logger.debug(f'Stored track offset of: {play_queue.current_track.offset} ms for {play_queue.current_track.title}') + play_queue.set_current_track_offset(handler_input.request_envelope.request.offset_in_milliseconds) + + current_track = play_queue.get_current_track() + logger.debug(f'Stored track offset of: {current_track.offset} ms for {current_track.title}') logger.info('Playback stopped') return handler_input.response_builder.response @@ -777,6 +847,11 @@ class PlaybackFinishedHandler(AbstractRequestHandler): def handle(self, handler_input: HandlerInput) -> Response: logger.debug('In PlaybackFinishedHandler') + + # Generate a timestamp in milliseconds for scrobbling + timestamp_ms = datetime.now().timestamp() + current_track = play_queue.get_current_track() + connection.scrobble(current_track.id, timestamp_ms) play_queue.get_next_track() return handler_input.response_builder.response @@ -813,14 +888,16 @@ class ResumePlaybackHandler(AbstractRequestHandler): def handle(self, handler_input: HandlerInput) -> Response: logger.debug('In ResumePlaybackHandler') - if play_queue.current_track.offset > 0: + current_track = play_queue.get_current_track() + + if current_track.offset > 0: # There is a paused track, continue - logger.info('Resuming ' + str(play_queue.current_track.title)) - logger.info('Offset ' + str(play_queue.current_track.offset)) + logger.info('Resuming ' + str(current_track.title)) + logger.info('Offset ' + str(current_track.offset)) - return controller.start_playback('play', None, None, play_queue.current_track, handler_input) + return controller.start_playback('play', None, None, current_track, handler_input) - elif play_queue.get_queue_count() > 0 and play_queue.current_track.offset == 0: + elif play_queue.get_queue_count() > 0 and current_track.offset == 0: # No paused tracks but tracks in queue logger.info('Resuming - There was no paused track, getting next track from queue') track_details = play_queue.get_next_track() @@ -853,7 +930,7 @@ class PreviousPlaybackHandler(AbstractRequestHandler): def handle(self, handler_input: HandlerInput) -> Response: logger.debug('In PreviousPlaybackHandler') - track_details = play_queue.get_prevous_track() + track_details = play_queue.get_previous_track() # Set the offset to 0 as we are skipping we want to start at the beginning track_details.offset = 0 @@ -873,7 +950,8 @@ class PlaybackFailedEventHandler(AbstractRequestHandler): def handle(self, handler_input: HandlerInput) -> Response: logger.debug('In PlaybackFailedHandler') - song_id = play_queue.current_track.id + current_track = play_queue.get_current_track() + song_id = current_track.id # Log failure and track ID logger.error(f'Playback Failed: {handler_input.request_envelope.request.error}') @@ -1022,31 +1100,37 @@ if navidrome_log_level == 3: def view_queue(): """View the contents of play_queue.queue - Creates a tabulated page contining the contents of the play_queue.queue deque. + Creates a tabulated page containing the contents of the play_queue.queue deque. """ + current_track = play_queue.get_current_track() + return render_template('table.html', title='AskNavidrome - Queued Tracks', - tracks=play_queue.queue, current=play_queue.current_track) + tracks=play_queue.get_current_queue(), current=current_track) @app.route('/history') def view_history(): """View the contents of play_queue.history - Creates a tabulated page contining the contents of the play_queue.history deque. + Creates a tabulated page containing the contents of the play_queue.history deque. """ + current_track = play_queue.get_current_track() + return render_template('table.html', title='AskNavidrome - Track History', - tracks=play_queue.history, current=play_queue.current_track) + tracks=play_queue.get_history(), current=current_track) @app.route('/buffer') def view_buffer(): """View the contents of play_queue.buffer - Creates a tabulated page contining the contents of the play_queue.buffer deque. + Creates a tabulated page containing the contents of the play_queue.buffer deque. """ + current_track = play_queue.get_current_track() + return render_template('table.html', title='AskNavidrome - Buffered Tracks', - tracks=play_queue.buffer, current=play_queue.current_track) + tracks=play_queue.get_buffer(), current=current_track) # Run web app by default when file is executed. diff --git a/skill/asknavidrome/media_queue.py b/skill/asknavidrome/media_queue.py index d40bbab..d7e960e 100644 --- a/skill/asknavidrome/media_queue.py +++ b/skill/asknavidrome/media_queue.py @@ -40,6 +40,64 @@ class MediaQueue: self.current_track: Track = Track() """Property to hold the current track object""" + def get_current_track(self) -> Track: + """Method to return current_track attribute + + Added to allow access to the current_track object while using BaseManager + for multi threading, as BaseManager does not allow access to class + attributes / properties + + :return: A Track object representing the current playing audio track + :rtype: Track + """ + return self.current_track + + def set_current_track_offset(self, offset: int) -> None: + """Method to set the offset of the current track in milliseconds + + Set the offset for the current track in milliseconds. This is used + when resuming a paused track to ensure the track isn't played from + the beginning again. + + :param offset: The track offset in milliseconds + :type offset: int + """ + + self.current_track.offset = offset + + def get_current_queue(self) -> deque: + """Get the current queue + + Returns a deque containing the current queue of music to be played + + :return: The current queue + :rtype: deque + """ + + return self.queue + + def get_buffer(self) -> deque: + """Get the buffer + + Returns a deque containing the current buffer + + :return: The current buffer + :rtype: deque + """ + + return self.buffer + + def get_history(self) -> deque: + """Get history + + Returns a deque of tracks that have already been played + + :return: A deque container tracks that have already been played + :rtype: deque + """ + + return self.history + def add_track(self, track: Track) -> None: """Add tracks to the queue @@ -130,7 +188,7 @@ class MediaQueue: return self.current_track - def get_prevous_track(self) -> Track: + def get_previous_track(self) -> Track: """Get the previous track Get the last track added to the history deque and @@ -140,7 +198,7 @@ class MediaQueue: :rtype: Track """ - self.logger.debug('In get_prevous_track()') + self.logger.debug('In get_previous_track()') # Return the current track to the queue self.queue.appendleft(self.current_track) @@ -200,7 +258,7 @@ class MediaQueue: return len(self.history) def sync(self) -> None: - """Syncronise the buffer with the queue + """Synchronise the buffer with the queue Overwrite the buffer with the current queue. This is useful when pausing or stopping to ensure diff --git a/skill/asknavidrome/subsonic_api.py b/skill/asknavidrome/subsonic_api.py index 8790ce9..40dd60c 100644 --- a/skill/asknavidrome/subsonic_api.py +++ b/skill/asknavidrome/subsonic_api.py @@ -16,7 +16,7 @@ class SubsonicConnection: :param str server_url: The URL of the Subsonic API compatible media server :param str user: Username to authenticate against the API :param str passwd: Password to authenticate against the API - :param int port: Port the Subsonic compatibe server is listening on + :param int port: Port the Subsonic compatible server is listening on :param str api_location: Path to the API, this is appended to server_url :param str api_version: The version of the Subsonic API that is in use :return: None @@ -63,6 +63,19 @@ class SubsonicConnection: self.logger.error('Failed to connect to Navidrome') return self.conn.ping() + + def scrobble(self, track_id: str, time: int) -> None: + """Scrobble the given track + + :param str track_id: The ID of the track to scrobble + :param int time: UNIX timestamp of track play time + :return: None + """ + self.logger.debug('In function scrobble()') + + self.conn.scrobble(track_id, True, time) + + return None def search_playlist(self, term: str) -> Union[str, None]: """Search the media server for the given playlist @@ -271,7 +284,7 @@ class SubsonicConnection: def build_song_list_from_genre(self, genre: str, count: int) -> Union[list, None]: """Build a shuffled list songs of songs from the given genre. - :param str genre: The genre, acceptible values are with the getGenres Subsonic API call. + :param str genre: The genre, acceptable values are with the getGenres Subsonic API call. :param int count: The number of songs to return :return: A list of song IDs or None if no tracks are found. :rtype: list | None @@ -279,7 +292,7 @@ class SubsonicConnection: self.logger.debug('In function build_song_list_from_genre()') - # Note the use of title() to captalise the first letter of each word in the genre + # Note the use of title() to capitalise the first letter of each word in the genre # without this the genres do not match the strings returned by the API. self.logger.debug(f'Searching for {genre.title()} music') songs_from_genre = self.conn.getSongsByGenre(genre.title(), count).get('songsByGenre').get('song')