Merge pull request #62 from rosskouk/feature-scrobbling

- Bumped versions of Actions modules in use
- Fix incorrect field name preventing playing Albums by Artist]
- Added multi threading to all intents that enqueue songs, skill responds much faster and combats issues with timeouts
- Resolves #61
- Added scrobbling functionality, tracks will now be scrobbled if you have enabled the feature on your Navidrome instance.  This both scrobbles the track and updates the play count and last played date on Navidrome
- Resolves #52 
- Resolves #33 
- Fixed comments and whitespace
This commit is contained in:
rosskouk
2025-09-08 13:15:12 +01:00
committed by GitHub
4 changed files with 199 additions and 44 deletions

View File

@@ -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: |

View File

@@ -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.

View File

@@ -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

View File

@@ -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
@@ -64,6 +64,19 @@ class SubsonicConnection:
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')