From cf645f1c879892f596107912bfb0d92ff95d8028 Mon Sep 17 00:00:00 2001 From: Ross Stewart Date: Mon, 8 Sep 2025 08:45:26 +0100 Subject: [PATCH 1/5] Bumped versions of Actions modules in use --- .github/workflows/build_sphinx_docs.yml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) 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: | From 39831cc32b03eee7c6b69382bde0f1d19e669702 Mon Sep 17 00:00:00 2001 From: Ross Stewart Date: Mon, 8 Sep 2025 09:02:40 +0100 Subject: [PATCH 2/5] Fix incorrect field name preventing playing Albums by Artist --- skill/app.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/skill/app.py b/skill/app.py index 2e9acd9..0c4beaa 100755 --- a/skill/app.py +++ b/skill/app.py @@ -362,7 +362,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." From 71176626dc184f0c45243527bb89a3624e6875ce Mon Sep 17 00:00:00 2001 From: Ross Stewart Date: Mon, 8 Sep 2025 13:04:23 +0100 Subject: [PATCH 3/5] - Added multi threading to all intents that enqueue songs, skill responds much faster and combats issues with timeouts - Resolves #61 --- skill/app.py | 129 ++++++++++++++++++++++++------ skill/asknavidrome/media_queue.py | 58 ++++++++++++++ 2 files changed, 161 insertions(+), 26 deletions(-) diff --git a/skill/app.py b/skill/app.py index 0c4beaa..8942dc1 100755 --- a/skill/app.py +++ b/skill/app.py @@ -1,6 +1,6 @@ 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 @@ -294,8 +294,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 +322,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 +349,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') @@ -371,9 +389,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 +419,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) @@ -531,8 +558,15 @@ class NaviSonicPlayMusicByGenre(AbstractRequestHandler): 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 +581,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 +607,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 +627,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 +653,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 +673,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 +719,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 +743,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 +763,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 +807,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 @@ -813,14 +881,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() @@ -873,7 +943,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,11 +1093,13 @@ 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(): @@ -1035,8 +1108,10 @@ if navidrome_log_level == 3: Creates a tabulated page contining 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(): @@ -1045,8 +1120,10 @@ if navidrome_log_level == 3: Creates a tabulated page contining 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..23a046b 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 From cc3ab62abe1ebede541989c314999940b4a05cf0 Mon Sep 17 00:00:00 2001 From: Ross Stewart Date: Mon, 8 Sep 2025 13:07:20 +0100 Subject: [PATCH 4/5] - Added scrobbling functionality, tracks will now be scrobbled if you have enabled the feature on your Navidrome instance - Resolves #52 - Resolves #33 --- skill/app.py | 6 ++++++ skill/asknavidrome/subsonic_api.py | 13 +++++++++++++ 2 files changed, 19 insertions(+) diff --git a/skill/app.py b/skill/app.py index 8942dc1..d5118eb 100755 --- a/skill/app.py +++ b/skill/app.py @@ -1,3 +1,4 @@ +from datetime import datetime from flask import Flask, render_template import logging from multiprocessing import Process @@ -845,6 +846,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 diff --git a/skill/asknavidrome/subsonic_api.py b/skill/asknavidrome/subsonic_api.py index 8790ce9..adf0d71 100644 --- a/skill/asknavidrome/subsonic_api.py +++ b/skill/asknavidrome/subsonic_api.py @@ -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 From 338ede40c0685e2b645b5eae76252aab242bbad8 Mon Sep 17 00:00:00 2001 From: Ross Stewart Date: Mon, 8 Sep 2025 13:10:45 +0100 Subject: [PATCH 5/5] - Fixed comments and whitespace --- skill/app.py | 17 +++++++++-------- skill/asknavidrome/media_queue.py | 6 +++--- skill/asknavidrome/subsonic_api.py | 6 +++--- 3 files changed, 15 insertions(+), 14 deletions(-) diff --git a/skill/app.py b/skill/app.py index d5118eb..78ab50f 100755 --- a/skill/app.py +++ b/skill/app.py @@ -43,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: @@ -175,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() @@ -184,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 @@ -534,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', @@ -550,9 +551,9 @@ 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: @@ -929,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 @@ -1111,7 +1112,7 @@ if navidrome_log_level == 3: 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() @@ -1123,7 +1124,7 @@ if navidrome_log_level == 3: 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() diff --git a/skill/asknavidrome/media_queue.py b/skill/asknavidrome/media_queue.py index 23a046b..d7e960e 100644 --- a/skill/asknavidrome/media_queue.py +++ b/skill/asknavidrome/media_queue.py @@ -188,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 @@ -198,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) @@ -258,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 adf0d71..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 @@ -284,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 @@ -292,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')