From 58885be096e4497c3a922dc8842b907d315b5141 Mon Sep 17 00:00:00 2001 From: Ross Stewart Date: Fri, 5 Aug 2022 16:37:56 +0100 Subject: [PATCH] Move to GitHub --- .github/workflows/build_image.yml | 41 + .github/workflows/build_sphinx_docs.yml | 58 ++ .gitignore | 9 + .vscode/launch.json | 33 + Dockerfile | 32 + LICENSE | 21 + README.md | 1 + docs/.gitkeep | 0 skill/app.py | 968 ++++++++++++++++++++++++ skill/asknavidrome/__init__.py | 1 + skill/asknavidrome/controller.py | 195 +++++ skill/asknavidrome/media_queue.py | 214 ++++++ skill/asknavidrome/subsonic_api.py | 399 ++++++++++ skill/asknavidrome/track.py | 41 + skill/requirements-docker.txt | 4 + skill/requirements-full.txt | 11 + skill/templates/base.html | 22 + skill/templates/table.html | 42 + 18 files changed, 2092 insertions(+) create mode 100644 .github/workflows/build_image.yml create mode 100644 .github/workflows/build_sphinx_docs.yml create mode 100644 .gitignore create mode 100644 .vscode/launch.json create mode 100644 Dockerfile create mode 100644 LICENSE create mode 100644 README.md create mode 100644 docs/.gitkeep create mode 100755 skill/app.py create mode 100644 skill/asknavidrome/__init__.py create mode 100644 skill/asknavidrome/controller.py create mode 100644 skill/asknavidrome/media_queue.py create mode 100644 skill/asknavidrome/subsonic_api.py create mode 100644 skill/asknavidrome/track.py create mode 100644 skill/requirements-docker.txt create mode 100644 skill/requirements-full.txt create mode 100644 skill/templates/base.html create mode 100644 skill/templates/table.html diff --git a/.github/workflows/build_image.yml b/.github/workflows/build_image.yml new file mode 100644 index 0000000..aac2db4 --- /dev/null +++ b/.github/workflows/build_image.yml @@ -0,0 +1,41 @@ +name: publish_container +'on': + push: + tags: + - '*' + +env: + REGISTRY: ghcr.io + IMAGE_NAME: ${{ github.repository }} + +jobs: + build-and-push-image: + runs-on: ubuntu-latest + permissions: + contents: read + packages: write + + steps: + - name: Checkout repository + uses: actions/checkout@v3 + + - name: Log in to the Container registry + uses: docker/login-action@f054a8b539a109f9f41c372932f1ae047eff08c9 + with: + registry: ${{ env.REGISTRY }} + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Extract metadata (tags, labels) for Docker + id: meta + uses: docker/metadata-action@98669ae865ea3cffbcbaa878cf57c20bbf1c6c38 + with: + images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} + + - name: Build and push Docker image + uses: docker/build-push-action@ad44023a93711e3deb337508980b4b5e9bcdc5dc + with: + context: . + push: true + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} diff --git a/.github/workflows/build_sphinx_docs.yml b/.github/workflows/build_sphinx_docs.yml new file mode 100644 index 0000000..aa4e47b --- /dev/null +++ b/.github/workflows/build_sphinx_docs.yml @@ -0,0 +1,58 @@ +name: build_sphinx_docs +on: + workflow_dispatch: + +jobs: + build-documentation: + name: Build Sphinx documentation + runs-on: ubuntu-latest + steps: + # https://github.com/marketplace/actions/checkout + - name: Checkout the repository + uses: actions/checkout@v3 + + # https://github.com/marketplace/actions/setup-python + - name: Install Python + uses: actions/setup-python@v4 + with: + python-version: '3.10' + + - name: Setup Python environment + run: | + python3 -m pip install --upgrade pip + pip3 install -r skill/requirements-full.txt + + - name: Prepare for new documentation + run: | + rm -rf sphinx/_build + + - name: Generate documentation + run: > + NAVI_SKILL_ID=${{ secrets.ALEXA_SKILL_ID }} + NAVI_SONG_COUNT=50 + NAVI_URL=${{ secrets.NAVIDROME_URL }} + NAVI_USER=${{ secrets.NAVIDROME_USER }} + NAVI_PASS=${{ secrets.NAVIDROME_PASSWORD }} + NAVI_PORT=443 + NAVI_API_PATH=/rest + NAVI_API_VER=1.16.1 + NAVI_DEBUG=1 + make -C sphinx html + + - name: Prepare documentation directory and insert files + run: | + rm -rf docs + mkdir docs + touch docs/.gitkeep + touch docs/.nojekyll + mv sphinx/_build/html/* docs + + - name: Commit changes + run: | + git config --global user.name 'Ross Stewart' + git config --global user.email 'rosskouk@users.noreply.github.com' + git add docs/.gitkeep + git add docs/.nojekyll + git add * + git commit -am "Documentation update" + git push diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..6df66b9 --- /dev/null +++ b/.gitignore @@ -0,0 +1,9 @@ +__pycache__ +env +sphinx/_build +sphinx/resources/*.bkp +sphinx/resources/*.dtmp +asknavidrome.log +test.py +NOTES.md +sphinx \ No newline at end of file diff --git a/.vscode/launch.json b/.vscode/launch.json new file mode 100644 index 0000000..91e1ff6 --- /dev/null +++ b/.vscode/launch.json @@ -0,0 +1,33 @@ +{ + // Use IntelliSense to learn about possible attributes. + // Hover to view descriptions of existing attributes. + // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 + "version": "0.2.0", + "configurations": [ + { + "name": "Python: Current File", + "type": "python", + "request": "launch", + "program": "${file}", + "console": "integratedTerminal", + "justMyCode": true + }, + { + "name": "Python: Flask", + "type": "python", + "request": "launch", + "module": "flask", + "env": { + "FLASK_APP": "skill/app.py", + "FLASK_ENV": "development", + "FLASK_DEBUG": "0", + }, + "args": [ + "run", + "--no-debugger", + "--no-reload" + ], + "jinja": true + } + ] +} \ No newline at end of file diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..cd99b40 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,32 @@ +FROM alpine:3.15.0 as build +LABEL maintainer="Ross Stewart " + +RUN apk add python3 py3-pip git build-base python3-dev libffi-dev openssl-dev + +WORKDIR /opt + +RUN python3 -m venv env + +RUN git clone https://github.com/rosskouk/asknavidrome.git + +WORKDIR /opt/asknavidrome + +RUN source ../env/bin/activate && pip --no-cache-dir install wheel && pip --no-cache-dir install -r skill/requirements-docker.txt + + +FROM alpine:3.15.0 +LABEL maintainer="Ross Stewart " + +RUN apk add python3 + +COPY --from=build /opt/env /opt/env +COPY --from=build /opt/asknavidrome/skill /opt/asknavidrome/ + +WORKDIR /opt/asknavidrome + +# Activate Python Virtual Environment +ENV PATH="/opt/env/bin:$PATH" + +EXPOSE 5000 + +ENTRYPOINT ["python3", "app.py"] \ No newline at end of file diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..908c59a --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2022 Ross Stewart + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..f7c03e6 --- /dev/null +++ b/README.md @@ -0,0 +1 @@ +# AskNavidrome \ No newline at end of file diff --git a/docs/.gitkeep b/docs/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/skill/app.py b/skill/app.py new file mode 100755 index 0000000..6d3f375 --- /dev/null +++ b/skill/app.py @@ -0,0 +1,968 @@ +from flask import Flask, render_template +import logging +import os +import random +import sys + +from ask_sdk_core.skill_builder import SkillBuilder +from ask_sdk_core.dispatch_components import AbstractRequestHandler, AbstractRequestInterceptor, AbstractResponseInterceptor +from ask_sdk_core.utils import is_request_type, is_intent_name, get_slot_value_v2, get_intent_name, get_request_type +from ask_sdk_core.handler_input import HandlerInput +from ask_sdk_model import Response +from ask_sdk_core.dispatch_components import AbstractExceptionHandler +from flask_ask_sdk.skill_adapter import SkillAdapter + +import asknavidrome.subsonic_api as api +import asknavidrome.media_queue as queue +import asknavidrome.controller as controller + +# Create web service +app = Flask(__name__) + +# Create skill object +sb = SkillBuilder() + +# Setup Logging +logger = logging.getLogger() # Create logger +level = logging.getLevelName('DEBUG') +logger.setLevel(level) # Set logger log level + +log_formatter = logging.Formatter('%(asctime)s - %(name)s - %(levelname)s - %(message)s') + +handler = logging.StreamHandler(sys.stdout) +handler.setLevel(level) +handler.setFormatter(log_formatter) + +logger.addHandler(handler) + +# +# Get service configuration +# + +logger.debug('Getting configutration from the environment...') + +try: + if 'NAVI_SKILL_ID' in os.environ: + # Set skill ID, this is available on the Alexa Developer Console + # if this is not set the web service will respond to any skill. + sb.skill_id = os.getenv('NAVI_SKILL_ID') + + logger.info(f'Skill ID set to: {sb.skill_id}') + + else: + raise NameError +except NameError as err: + logger.error(f'The Alexa skill ID was not found! {err}') + raise + +try: + if 'NAVI_SONG_COUNT' in os.environ: + min_song_count = os.getenv('NAVI_SONG_COUNT') + + logger.info(f'Minimum song count is set to: {min_song_count}') + + else: + raise NameError +except NameError as err: + logger.error(f'The minimum song count was not found! {err}') + raise + +try: + if 'NAVI_URL' in os.environ: + navidrome_url = os.getenv('NAVI_URL') + + logger.info(f'The URL for Navidrome is set to: {navidrome_url}') + + else: + raise NameError +except NameError as err: + logger.error(f'The URL of the Navidrome server was not found! {err}') + raise + +try: + if 'NAVI_USER' in os.environ: + navidrome_user = os.getenv('NAVI_USER') + + logger.info(f'The Navidrome user name is set to: {navidrome_user}') + + else: + raise NameError +except NameError as err: + logger.error(f'The Navidrome user name was not found! {err}') + raise + +try: + if 'NAVI_PASS' in os.environ: + navidrome_passwd = os.getenv('NAVI_PASS') + + logger.info('The Navidrome password is set') + + else: + raise NameError +except NameError as err: + logger.error(f'The Navidrome password was not found! {err}') + raise + +try: + if 'NAVI_PORT' in os.environ: + navidrome_port = os.getenv('NAVI_PORT') + + logger.info(f'The Navidrome port is set to: {navidrome_port}') + + else: + raise NameError +except NameError as err: + logger.error(f'The Navidrome port was not found! {err}') + raise + +try: + if 'NAVI_API_PATH' in os.environ: + navidrome_api_location = os.getenv('NAVI_API_PATH') + + logger.info(f'The Navidrome API path is set to: {navidrome_api_location}') + + else: + raise NameError +except NameError as err: + logger.error(f'The Navidrome API path was not found! {err}') + raise + +try: + if 'NAVI_API_VER' in os.environ: + navidrome_api_version = os.getenv('NAVI_API_VER') + + logger.info(f'The Navidrome API version is set to: {navidrome_api_version}') + + else: + raise NameError +except NameError as err: + logger.error(f'The Navidrome API version was not found! {err}') + raise + +logger.debug('Configuration has been successfully loaded') + +# Create a queue +play_queue = queue.MediaQueue() +logger.debug('MediaQueue object created...') + +# Connect to Navidrome +connection = api.SubsonicConnection(navidrome_url, + navidrome_user, + navidrome_passwd, + navidrome_port, + navidrome_api_location, + navidrome_api_version) + +try: + connection.ping() + +except: + raise RuntimeError('Could not connect to SubSonic API!') + +logger.info('AskNavidrome Web Service is ready to start!') + + +# +# Handler Classes +# + +class LaunchRequestHandler(AbstractRequestHandler): + """Handle LaunchRequest and NavigateHomeIntent""" + + def can_handle(self, handler_input: HandlerInput) -> bool: + return ( + is_request_type('LaunchRequest')(handler_input) or + is_intent_name('AMAZON.NavigateHomeIntent')(handler_input) + ) + + def handle(self, handler_input: HandlerInput) -> Response: + logger.debug('In LaunchRequestHandler') + + connection.ping() + speech = 'Ready!' + + handler_input.response_builder.speak(speech).ask(speech) + return handler_input.response_builder.response + + +class CheckAudioInterfaceHandler(AbstractRequestHandler): + """Check if device supports audio play. + + This can be used as the first handler to be checked, before invoking + other handlers, thus making the skill respond to unsupported devices + without doing much processing. + """ + + def can_handle(self, handler_input: HandlerInput) -> bool: + if handler_input.request_envelope.context.system.device: + # Since skill events won't have device information + return handler_input.request_envelope.context.system.device.supported_interfaces.audio_player is None + else: + return False + + def handle(self, handler_input: HandlerInput) -> Response: + logger.debug('In CheckAudioInterfaceHandler') + + _ = handler_input.attributes_manager.request_attributes['_'] + handler_input.response_builder.speak('This device is not supported').set_should_end_session(True) + + return handler_input.response_builder.response + + +class SkillEventHandler(AbstractRequestHandler): + """Close session for skill events or when session ends. + + Handler to handle session end or skill events (SkillEnabled, + SkillDisabled etc.) + """ + + def can_handle(self, handler_input: HandlerInput) -> bool: + return (handler_input.request_envelope.request.object_type.startswith( + 'AlexaSkillEvent') or + is_request_type('SessionEndedRequest')(handler_input)) + + def handle(self, handler_input: HandlerInput) -> Response: + logger.debug('In SkillEventHandler') + + return handler_input.response_builder.response + + +class HelpHandler(AbstractRequestHandler): + """Handle HelpIntent""" + + def can_handle(self, handler_input: HandlerInput) -> bool: + return is_intent_name('AMAZON.HelpIntent')(handler_input) + + def handle(self, handler_input: HandlerInput) -> Response: + logger.debug('In HelpHandler') + + text = 'AskNavidrome lets you interact with media servers that offer a Subsonic compatible A.P.I.' + handler_input.response_builder.speak(text) + + return handler_input.response_builder.response + + +class NaviSonicPlayMusicByArtist(AbstractRequestHandler): + """Handle NaviSonicPlayMusicByArtist + + Play a selection of songs for the given artist + """ + + def can_handle(self, handler_input: HandlerInput) -> bool: + return is_intent_name('NaviSonicPlayMusicByArtist')(handler_input) + + def handle(self, handler_input: HandlerInput) -> Response: + logger.debug('In NaviSonicPlayMusicByArtist') + + # Get the requested artist + artist = get_slot_value_v2(handler_input, 'artist') + + # Search for an artist + artist_lookup = connection.search_artist(artist.value) + + if artist_lookup is None: + text = f"I couldn't find the artist {artist.value} in the collection." + handler_input.response_builder.speak(text).ask(text) + + return handler_input.response_builder.response + + else: + # Get a list of albums by the artist + artist_album_lookup = connection.albums_by_artist(artist_lookup[0].get('id')) + + # 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) + speech = f'Playing music by: {artist.value}' + logger.info(speech) + + card = {'title': 'AskNavidrome', + 'text': speech + } + + play_queue.shuffle() + track_details = play_queue.get_next_track() + return controller.start_playback('play', speech, card, track_details, handler_input) + + +class NaviSonicPlayAlbumByArtist(AbstractRequestHandler): + """Handle NaviSonicPlayAlbumByArtist + + Play a given album by a given artist + """ + + def can_handle(self, handler_input: HandlerInput) -> bool: + return is_intent_name('NaviSonicPlayAlbumByArtist')(handler_input) + + def handle(self, handler_input: HandlerInput) -> Response: + logger.debug('In NaviSonicPlayAlbumByArtist') + + # Get variables from intent + artist = get_slot_value_v2(handler_input, 'artist') + album = get_slot_value_v2(handler_input, 'album') + + if artist is not None and album is not None: + # Play album by artist method + logger.debug(f'Searching for the album {album.value} by {artist.value}') + + # Search for an artist + artist_lookup = connection.search_artist(artist.value) + + if artist_lookup is None: + text = f"I couldn't find the artist {artist.value} in the collection." + handler_input.response_builder.speak(text).ask(text) + + return handler_input.response_builder.response + + else: + artist_album_lookup = connection.albums_by_artist(artist_lookup[0].get('id')) + + # 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()] + + if not result: + text = f"I couldn't find an album called {album.value} by {artist.value} in the collection." + handler_input.response_builder.speak(text).ask(text) + + 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) + play_queue.clear() + controller.enqueue_songs(connection, play_queue, songs) + + speech = f'Playing {album.value} by: {artist.value}' + logger.info(speech) + card = {'title': 'AskNavidrome', + 'text': speech + } + track_details = play_queue.get_next_track() + + return controller.start_playback('play', speech, card, track_details, handler_input) + + elif artist is None and album: + # Play album method + logger.debug(f'Searching for the album {album.value}') + + result = connection.search_album(album.value) + + if result is None: + text = f"I couldn't find the album {album.value} in the collection." + handler_input.response_builder.speak(text).ask(text) + + return handler_input.response_builder.response + + else: + songs = connection.build_song_list_from_albums(result, -1) + play_queue.clear() + controller.enqueue_songs(connection, play_queue, songs) + + speech = f'Playing {album.value}' + logger.info(speech) + card = {'title': 'AskNavidrome', + 'text': speech + } + track_details = play_queue.get_next_track() + + return controller.start_playback('play', speech, card, track_details, handler_input) + + +class NaviSonicPlaySongByArtist(AbstractRequestHandler): + """Handle the NaviSonicPlaySongByArtist intent + + Play the given song by the given artist if it exists in the + collection. + """ + + def can_handle(self, handler_input: HandlerInput) -> bool: + return is_intent_name('NaviSonicPlaySongByArtist')(handler_input) + + def handle(self, handler_input: HandlerInput) -> Response: + logger.debug('In NaviSonicPlaySongByArtist') + + # Get variables from intent + artist = get_slot_value_v2(handler_input, 'artist') + song = get_slot_value_v2(handler_input, 'song') + + logger.debug(f'Searching for the song {song.value} by {artist.value}') + + # Search for the artist + artist_lookup = connection.search_artist(artist.value) + + if artist_lookup is None: + text = f"I couldn't find the artist {artist.value} in the collection." + handler_input.response_builder.speak(text).ask(text) + + return handler_input.response_builder.response + + else: + artist_id = artist_lookup[0].get('id') + + # Search for song + song_list = connection.search_song(song.value) + + # Search for song by given artist. + song_dets = [item.get('id') for item in song_list if item.get('artistId') == artist_id] + + if not song_dets: + text = f"I couldn't find a song called {song.value} by {artist.value} in the collection." + handler_input.response_builder.speak(text).ask(text) + + return handler_input.response_builder.response + + play_queue.clear() + controller.enqueue_songs(connection, play_queue, song_dets) + + speech = f'Playing {song.value} by {artist.value}' + logger.info(speech) + card = {'title': 'AskNavidrome', + 'text': speech + } + track_details = play_queue.get_next_track() + + return controller.start_playback('play', speech, card, track_details, handler_input) + + +class NaviSonicPlayPlaylist(AbstractRequestHandler): + """Handle NaviSonicPlayPlaylist + + Play the given playlist + """ + + def can_handle(self, handler_input: HandlerInput) -> bool: + return is_intent_name('NaviSonicPlayPlaylist')(handler_input) + + def handle(self, handler_input: HandlerInput) -> Response: + logger.debug('In NaviSonicPlayPlaylist') + + # Get the requested playlist + playlist = get_slot_value_v2(handler_input, 'playlist') + + # Search for a playlist + playlist_id = connection.search_playlist(playlist.value) + + if playlist_id is None: + text = "I couldn't find the playlist " + str(playlist.value) + ' in the collection.' + handler_input.response_builder.speak(text).ask(text) + + return handler_input.response_builder.response + + else: + song_id_list = connection.build_song_list_from_playlist(playlist_id) + play_queue.clear() + controller.enqueue_songs(connection, play_queue, song_id_list) + + speech = 'Playing playlist ' + str(playlist.value) + logger.info(speech) + card = {'title': 'AskNavidrome', + 'text': speech + } + track_details = play_queue.get_next_track() + + return controller.start_playback('play', speech, card, track_details, handler_input) + + +class NaviSonicPlayMusicByGenre(AbstractRequestHandler): + """ Play songs from the given genere + + 50 tracks from the given genere 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: + logger.debug('In NaviSonicPlayMusicByGenre') + + # Get the requested genre + genre = get_slot_value_v2(handler_input, 'genre') + + song_id_list = connection.build_song_list_from_genre(genre.value, min_song_count) + + if song_id_list is None: + text = f"I couldn't find any {genre.value} songs in the collection." + handler_input.response_builder.speak(text).ask(text) + + return handler_input.response_builder.response + + else: + random.shuffle(song_id_list) + play_queue.clear() + controller.enqueue_songs(connection, play_queue, song_id_list) + + speech = f'Playing {genre.value} music' + logger.info(speech) + card = {'title': 'AskNavidrome', + 'text': speech + } + track_details = play_queue.get_next_track() + + return controller.start_playback('play', speech, card, track_details, handler_input) + + +class NaviSonicPlayMusicRandom(AbstractRequestHandler): + """Handle the NaviSonicPlayMusicRandom intent + + Play a random selection of music. + """ + + def can_handle(self, handler_input: HandlerInput) -> bool: + return is_intent_name('NaviSonicPlayMusicRandom')(handler_input) + + def handle(self, handler_input: HandlerInput) -> Response: + logger.debug('In NaviSonicPlayMusicRandom') + + song_id_list = connection.build_random_song_list(min_song_count) + + if song_id_list is None: + text = "I couldn't find any songs in the collection." + handler_input.response_builder.speak(text).ask(text) + + return handler_input.response_builder.response + + else: + random.shuffle(song_id_list) + play_queue.clear() + controller.enqueue_songs(connection, play_queue, song_id_list) + + speech = 'Playing random music' + logger.info(speech) + card = {'title': 'AskNavidrome', + 'text': speech + } + track_details = play_queue.get_next_track() + + return controller.start_playback('play', speech, card, track_details, handler_input) + + +class NaviSonicPlayFavouriteSongs(AbstractRequestHandler): + """Handle the NaviSonicPlayFavouriteSongs intent + + Play all starred / liked songs, songs are automatically shuffled. + """ + + def can_handle(self, handler_input: HandlerInput) -> bool: + return is_intent_name('NaviSonicPlayFavouriteSongs')(handler_input) + + def handle(self, handler_input: HandlerInput) -> Response: + logger.debug('In NaviSonicPlayFavouriteSongs') + + song_id_list = connection.build_song_list_from_favourites() + + if song_id_list is None: + text = "You don't have any favourite songs in the collection." + handler_input.response_builder.speak(text).ask(text) + + return handler_input.response_builder.response + + else: + random.shuffle(song_id_list) + play_queue.clear() + controller.enqueue_songs(connection, play_queue, song_id_list) + + speech = 'Playing your favourite tracks.' + logger.info(speech) + card = {'title': 'AskNavidrome', + 'text': speech + } + track_details = play_queue.get_next_track() + + return controller.start_playback('play', speech, card, track_details, handler_input) + + +class NaviSonicSongDetails(AbstractRequestHandler): + """Handle NaviSonicSongDetails Intent + + Returns information on the track that is currently playing + """ + + def can_handle(self, handler_input: HandlerInput) -> bool: + return is_intent_name('NaviSonicSongDetails')(handler_input) + + 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 + + text = f'This is {title} by {artist}, from the album {album}' + handler_input.response_builder.speak(text) + + return handler_input.response_builder.response + + +class NaviSonicStarSong(AbstractRequestHandler): + """Handle NaviSonicStarSong Intent + + Star / favourite the current song + """ + + def can_handle(self, handler_input: HandlerInput) -> bool: + return is_intent_name('NaviSonicStarSong')(handler_input) + + def handle(self, handler_input: HandlerInput) -> Response: + logger.debug('In NaviSonicStarSong Handler') + + song_id = play_queue.current_track.id + connection.star_entry(song_id, 'song') + + return handler_input.response_builder.response + + +class NaviSonicUnstarSong(AbstractRequestHandler): + """Handle NaviSonicUnstarSong Intent + + Star / favourite the current song + """ + + def can_handle(self, handler_input: HandlerInput) -> bool: + return is_intent_name('NaviSonicUnstarSong')(handler_input) + + def handle(self, handler_input: HandlerInput) -> Response: + logger.debug('In NaviSonicUnstarSong Handler') + + song_id = play_queue.current_track.id + connection.star_entry(song_id, 'song') + connection.unstar_entry(song_id, 'song') + + return handler_input.response_builder.response + + +# +# AudioPlayer Handlers +# + + +class PlaybackStartedHandler(AbstractRequestHandler): + """AudioPlayer.PlaybackStarted Directive received. + + Confirming that the requested audio file began playing. + Do not send any specific response. + """ + + def can_handle(self, handler_input: HandlerInput) -> bool: + return is_request_type('AudioPlayer.PlaybackStarted')(handler_input) + + def handle(self, handler_input: HandlerInput) -> Response: + logger.debug('In PlaybackStartedHandler') + logger.info('Playback started') + + return handler_input.response_builder.response + + +class PlaybackStoppedHandler(AbstractRequestHandler): + """AudioPlayer.PlaybackStopped Directive received. + + Confirming that the requested audio file stopped playing. + Do not send any specific response. + """ + + def can_handle(self, handler_input: HandlerInput) -> bool: + return is_request_type('AudioPlayer.PlaybackStopped')(handler_input) + + def handle(self, handler_input: HandlerInput) -> Response: + 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}') + logger.info('Playback stopped') + + return handler_input.response_builder.response + + +class PlaybackNearlyFinishedHandler(AbstractRequestHandler): + """AudioPlayer.PlaybackNearlyFinished Directive received. + + Replacing queue with the URL again. This should not happen on live streams. + """ + + def can_handle(self, handler_input: HandlerInput) -> bool: + return is_request_type('AudioPlayer.PlaybackNearlyFinished')(handler_input) + + def handle(self, handler_input: HandlerInput) -> Response: + logger.debug('In PlaybackNearlyFinishedHandler') + logger.info('Queuing next track...') + track_details = play_queue.enqueue_next_track() + + return controller.start_playback('continue', None, None, track_details, handler_input) + + +class PlaybackFinishedHandler(AbstractRequestHandler): + """AudioPlayer.PlaybackFinished Directive received. + + Confirming that the requested audio file completed playing. + Do not send any specific response. + """ + + def can_handle(self, handler_input: HandlerInput) -> bool: + return is_request_type('AudioPlayer.PlaybackFinished')(handler_input) + + def handle(self, handler_input: HandlerInput) -> Response: + logger.debug('In PlaybackFinishedHandler') + play_queue.get_next_track() + + return handler_input.response_builder.response + + +class PausePlaybackHandler(AbstractRequestHandler): + """Handler for stopping audio. + + Handles Stop, Cancel and Pause Intents and PauseCommandIssued event. + """ + + def can_handle(self, handler_input: HandlerInput) -> bool: + return (is_intent_name('AMAZON.StopIntent')(handler_input) or + is_intent_name('AMAZON.CancelIntent')(handler_input) or + is_intent_name('AMAZON.PauseIntent')(handler_input)) + + def handle(self, handler_input: HandlerInput) -> Response: + logger.debug('In PausePlaybackHandler') + play_queue.sync() + + return controller.stop(handler_input) + + +class ResumePlaybackHandler(AbstractRequestHandler): + """Handler for resuming audio on different events. + + Handles PlayAudio Intent, Resume Intent. + """ + + def can_handle(self, handler_input: HandlerInput) -> bool: + return (is_intent_name('AMAZON.ResumeIntent')(handler_input) or + is_intent_name('PlayAudio')(handler_input)) + + def handle(self, handler_input: HandlerInput) -> Response: + logger.debug('In ResumePlaybackHandler') + + if play_queue.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)) + + return controller.start_playback('play', None, None, play_queue.current_track, handler_input) + + elif play_queue.get_queue_count() > 0 and play_queue.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() + + return controller.start_playback('play', None, None, track_details, handler_input) + + +class NextPlaybackHandler(AbstractRequestHandler): + """Handle NextIntent""" + + def can_handle(self, handler_input: HandlerInput) -> bool: + return is_intent_name('AMAZON.NextIntent')(handler_input) + + def handle(self, handler_input: HandlerInput) -> Response: + logger.debug('In NextPlaybackHandler') + + track_details = play_queue.get_next_track() + + # Set the offset to 0 as we are skipping we want to start at the beginning + track_details.offset = 0 + + return controller.start_playback('play', None, None, track_details, handler_input) + + +class PreviousPlaybackHandler(AbstractRequestHandler): + """Handle PreviousIntent""" + + def can_handle(self, handler_input: HandlerInput) -> bool: + return is_intent_name('AMAZON.PreviousIntent')(handler_input) + + def handle(self, handler_input: HandlerInput) -> Response: + logger.debug('In PreviousPlaybackHandler') + track_details = play_queue.get_prevous_track() + + # Set the offset to 0 as we are skipping we want to start at the beginning + track_details.offset = 0 + + return controller.start_playback('play', None, None, track_details, handler_input) + + +class PlaybackFailedEventHandler(AbstractRequestHandler): + """AudioPlayer.PlaybackFailed Directive received. + + Logging the error and restarting playing with no output speech. + """ + + def can_handle(self, handler_input: HandlerInput) -> bool: + return is_request_type('AudioPlayer.PlaybackFailed')(handler_input) + + def handle(self, handler_input: HandlerInput) -> Response: + logger.debug('In PlaybackFailedHandler') + logger.error(f'Playback Failed: {handler_input.request_envelope.request.error}') + + return handler_input.response_builder.response + + +# +# Exception Handers +# + + +class SystemExceptionHandler(AbstractExceptionHandler): + """Handle System.ExceptionEncountered + + Handles exceptions and prints error information + in the log + """ + + def can_handle(self, handler_input: HandlerInput, exception: Exception) -> bool: + return is_request_type('System.ExceptionEncountered')(handler_input) + + def handle(self, handler_input: HandlerInput, exception: Exception) -> Response: + logger.debug('In SystemExceptionHandler') + + # Log the exception + logger.error(f'System Exception: {exception}') + logger.error(f'Request Type Was: {get_request_type(handler_input)}') + error = handler_input.request_envelope.request.to_dict() + logger.error(f"Details: {error.get('error').get('message')}") + + if get_request_type(handler_input) == 'IntentRequest': + logger.error(f'Intent Name Was: {get_intent_name(handler_input)}') + + speech = "Sorry, I didn't get that. Can you please say it again!!" + handler_input.response_builder.speak(speech).ask(speech) + + return handler_input.response_builder.response + + +class GeneralExceptionHandler(AbstractExceptionHandler): + """Handle general exceptions + + Handles exceptions and prints error information + in the log + """ + + def can_handle(self, handler_input: HandlerInput, exception: Exception) -> bool: + return True + + def handle(self, handler_input: HandlerInput, exception: Exception) -> Response: + logger.debug('In GeneralExceptionHandler') + + # Log the exception + logger.error(f'General Exception: {exception}') + logger.error(f'Request Type Was: {get_request_type(handler_input)}') + + if get_request_type(handler_input) == 'IntentRequest': + logger.error(f'Intent Name Was: {get_intent_name(handler_input)}') + + speech = "Sorry, I didn't get that. Can you please say it again!!" + handler_input.response_builder.speak(speech).ask(speech) + + return handler_input.response_builder.response + + +# +# Request Interceptors +# + + +class LoggingRequestInterceptor(AbstractRequestInterceptor): + """Intercept all requests + + Intercepts all requests sent to the skill and prints them in the log + """ + + def process(self, handler_input: HandlerInput): + logger.debug(f'Request received: {handler_input.request_envelope.request}') + + +class LoggingResponseInterceptor(AbstractResponseInterceptor): + """Intercept all responses + + Intercepts all responses sent from the skill and prints them in the log + """ + + def process(self, handler_input: HandlerInput, response: Response): + logger.debug(f'Response sent: {response}') + + +# Register Intent Handlers +sb.add_request_handler(LaunchRequestHandler()) +sb.add_request_handler(CheckAudioInterfaceHandler()) +sb.add_request_handler(SkillEventHandler()) +sb.add_request_handler(HelpHandler()) +sb.add_request_handler(NaviSonicPlayMusicByArtist()) +sb.add_request_handler(NaviSonicPlayAlbumByArtist()) +sb.add_request_handler(NaviSonicPlaySongByArtist()) +sb.add_request_handler(NaviSonicPlayPlaylist()) +sb.add_request_handler(NaviSonicPlayFavouriteSongs()) +sb.add_request_handler(NaviSonicPlayMusicByGenre()) +sb.add_request_handler(NaviSonicPlayMusicRandom()) +sb.add_request_handler(NaviSonicSongDetails()) +sb.add_request_handler(NaviSonicStarSong()) +sb.add_request_handler(NaviSonicUnstarSong()) + +# Register AutoPlayer Handlers +sb.add_request_handler(PlaybackStartedHandler()) +sb.add_request_handler(PlaybackStoppedHandler()) +sb.add_request_handler(PlaybackNearlyFinishedHandler()) +sb.add_request_handler(PlaybackFinishedHandler()) +sb.add_request_handler(PausePlaybackHandler()) +sb.add_request_handler(NextPlaybackHandler()) +sb.add_request_handler(PreviousPlaybackHandler()) +sb.add_request_handler(ResumePlaybackHandler()) +sb.add_request_handler(PlaybackFailedEventHandler()) + + +# Register Exception Handlers +sb.add_exception_handler(SystemExceptionHandler()) +sb.add_exception_handler(GeneralExceptionHandler()) + +# Register Interceptors (log all requests) +# sb.add_global_request_interceptor(LoggingRequestInterceptor()) +# sb.add_global_response_interceptor(LoggingResponseInterceptor()) + +sa = SkillAdapter(skill=sb.create(), skill_id='test', app=app) +sa.register(app=app, route='/') + +# Enable queue and history diagnostics +if 'NAVI_DEBUG' in os.environ: + logger.warning('AskNavidrome debugging has been enabled, this should only be used when testing!') + logger.warning('The /buffer, /queue and /history http endpoints are available publicly!') + + @app.route('/queue') + def view_queue(): + """View the contents of play_queue.queue + + Creates a tabulated page contining the contents of the play_queue.queue deque. + """ + + return render_template('table.html', title='AskNavidrome - Queued Tracks', + tracks=play_queue.queue, current=play_queue.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. + """ + + return render_template('table.html', title='AskNavidrome - Track History', + tracks=play_queue.history, current=play_queue.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. + """ + + return render_template('table.html', title='AskNavidrome - Buffered Tracks', + tracks=play_queue.buffer, current=play_queue.current_track) + + +# Run web app by default when file is executed. +if __name__ == '__main__': + # Start the web service + app.run(host='0.0.0.0') diff --git a/skill/asknavidrome/__init__.py b/skill/asknavidrome/__init__.py new file mode 100644 index 0000000..fadc57f --- /dev/null +++ b/skill/asknavidrome/__init__.py @@ -0,0 +1 @@ +"""The AskNavidrome Module!""" diff --git a/skill/asknavidrome/controller.py b/skill/asknavidrome/controller.py new file mode 100644 index 0000000..adfb01f --- /dev/null +++ b/skill/asknavidrome/controller.py @@ -0,0 +1,195 @@ +import logging +from typing import Union + +from ask_sdk_core.handler_input import HandlerInput +from ask_sdk_model import Response +from ask_sdk_model.ui import StandardCard +from ask_sdk_model.interfaces.audioplayer import ( + PlayDirective, PlayBehavior, AudioItem, Stream, AudioItemMetadata, + StopDirective) +from ask_sdk_model.interfaces import display + +from .track import Track +from .subsonic_api import SubsonicConnection +from .media_queue import MediaQueue + +logger = logging.getLogger(__name__) + +# +# Functions +# + + +def start_playback(mode: str, text: str, card_data: dict, track_details: Track, handler_input: HandlerInput) -> Response: + """Function to play audio. + + Begin playing audio when: + + - Play Audio Intent is invoked. + - Resuming audio when stopped / paused. + - Next / Previous commands issues. + + .. note :: + - https://developer.amazon.com/docs/custom-skills/audioplayer-interface-reference.html#play + - REPLACE_ALL: Immediately begin playback of the specified stream, + and replace current and enqueued streams. + + :param str mode: play | continue - Play immediately or enqueue a track + :param str text: Text which should be spoken before playback starts + :param dict card_data: Data to display on a card + :param Track track_details: A Track object containing details of the track to use + :param HandlerInput handler_input: The Amazon Alexa HandlerInput object + :return: Amazon Alexa Response class + :rtype: Response + """ + + if mode == 'play': + # Starting playback + logger.debug('In start_playback() - play mode') + + if card_data: + # Cards are only supported if we are starting a new session + handler_input.response_builder.set_card( + StandardCard( + title=card_data['title'], text=card_data['text'], + # image=Image( + # small_image_url=card_data['small_image_url'], + # large_image_url=card_data['large_image_url']) + ) + ) + + handler_input.response_builder.add_directive( + PlayDirective( + play_behavior=PlayBehavior.REPLACE_ALL, + audio_item=AudioItem( + stream=Stream( + token=track_details.id, + url=track_details.uri, + offset_in_milliseconds=track_details.offset, + expected_previous_token=None), + metadata=add_screen_background(card_data) if card_data else None + ) + ) + ).set_should_end_session(True) + + if text: + # Text is not supported if we are continuing an existing play list + handler_input.response_builder.speak(text) + + logger.debug(f'Track ID: {track_details.id}') + logger.debug(f'Track Previous ID: {track_details.previous_id}') + logger.info(f'Playing track: {track_details.title} by: {track_details.artist}') + + elif mode == 'continue': + # Continuing Playback + logger.debug('In start_playback() - continue mode') + + handler_input.response_builder.add_directive( + PlayDirective( + play_behavior=PlayBehavior.ENQUEUE, + audio_item=AudioItem( + stream=Stream( + token=track_details.id, + url=track_details.uri, + # Offset is 0 to allow playing of the next track from the beginning + # if the Previous intent is used + offset_in_milliseconds=0, + expected_previous_token=track_details.previous_id), + metadata=None + ) + ) + ).set_should_end_session(True) + + logger.debug(f'Track ID: {track_details.id}') + logger.debug(f'Track Previous ID: {track_details.previous_id}') + logger.info(f'Enqueuing track: {track_details.title} by: {track_details.artist}') + + return handler_input.response_builder.response + + +def stop(handler_input: HandlerInput) -> Response: + """Stop playback + + :param HandlerInput handler_input: The Amazon Alexa HandlerInput object + :return: Amazon Alexa Response class + :rtype: Response + """ + logger.debug('In stop()') + + handler_input.response_builder.add_directive(StopDirective()) + + return handler_input.response_builder.response + + +def add_screen_background(card_data: dict) -> Union[AudioItemMetadata, None]: + """Add background to card. + + Cards are viewable on devices with screens and in the Alexa + app. + + :param dict card_data: Dictionary containing card data + :return: An Amazon AudioItemMetadata object or None if card data is not present + :rtype: AudioItemMetadata | None + """ + logger.debug('In add_screen_background()') + + if card_data: + metadata = AudioItemMetadata( + title=card_data['title'], + subtitle=card_data['text'], + art=display.Image( + content_description=card_data['title'], + sources=[ + display.ImageInstance( + url='https://github.com/navidrome/navidrome/raw/master/resources/logo-192x192.png' + ) + ] + ), + background_image=display.Image( + content_description=card_data['title'], + sources=[ + display.ImageInstance( + url='https://github.com/navidrome/navidrome/raw/master/resources/logo-192x192.png' + ) + ] + ) + ) + + return metadata + else: + return None + + +def enqueue_songs(api: SubsonicConnection, queue: MediaQueue, song_id_list: list) -> None: + """Enqueue songs + + Add Track objects to the queue deque + + :param SubsonicConnection api: A SubsonicConnection object to allow access to the Navidrome API + :param MediaQueue queue: A MediaQueue object + :param list song_id_list: A list of song IDs to enqueue + :return: None + """ + + for song_id in song_id_list: + song_details = api.get_song_details(song_id) + song_uri = api.get_song_uri(song_id) + + # Create track object from song details + new_track = Track(song_details.get('song').get('id'), + song_details.get('song').get('title'), + song_details.get('song').get('artist'), + song_details.get('song').get('artistId'), + song_details.get('song').get('album'), + song_details.get('song').get('albumId'), + song_details.get('song').get('track'), + song_details.get('song').get('year'), + song_details.get('song').get('genre'), + song_details.get('song').get('duration'), + song_details.get('song').get('bitRate'), + song_uri, + 0, + None) + + # Add track object to queue + queue.add_track(new_track) diff --git a/skill/asknavidrome/media_queue.py b/skill/asknavidrome/media_queue.py new file mode 100644 index 0000000..d40bbab --- /dev/null +++ b/skill/asknavidrome/media_queue.py @@ -0,0 +1,214 @@ +from collections import deque +from copy import deepcopy +import logging +import random + +from .track import Track + + +class MediaQueue: + """ The MediaQueue class + + This class provides a queue based on a Python deque. This is used to store + the tracks in the current play queue + """ + + def __init__(self) -> None: + """ + :return: None + """ + + self.logger = logging.getLogger(__name__) + """Logger""" + + self.queue: deque = deque() + """Deque containing tracks still to be played""" + + self.history: deque = deque() + """Deque to hold tracks that have already been played""" + + self.buffer: deque = deque() + """Deque to contain the list of tracks to be enqueued + + This deque is created from self.queue when actions such as next or + previous are performed. This is because Amazon can send the + PlaybackNearlyFinished request early. Without self.buffer, this would + change self.current_track causing us to lose the real position of the + queue. + """ + + self.current_track: Track = Track() + """Property to hold the current track object""" + + def add_track(self, track: Track) -> None: + """Add tracks to the queue + + :param Track track: A Track object containing details of the track to be played + :return: None + """ + + self.logger.debug('In add_track()') + + if not self.queue: + # This is the first track in the queue + self.queue.append(track) + else: + # There are already tracks in the queue, ensure previous_id is set + + # Get the last track from the deque + prev_track = self.queue.pop() + + # Set the previous_id attribute + track.previous_id = prev_track.id + + # Return the previous track to the deque + self.queue.append(prev_track) + + # Add the new track to the deque + self.queue.append(track) + + self.logger.debug(f'In add_track() - there are {len(self.queue)} tracks in the queue') + + def shuffle(self) -> None: + """Shuffle the queue + + Shuffles the queue and resets the previous track IDs required for the ENQUEUE PlayBehaviour + + :return: None + """ + + self.logger.debug('In shuffle()') + + # Copy the original queue + orig = self.queue + new_queue = deque() + + # Randomise the queue + random.shuffle(orig) + + track_id = None + + for t in orig: + if not new_queue: + # This is the first track, get the ID and add it + track_id = t.id + new_queue.append(t) + else: + # Set the tracks previous_id + t.previous_id = track_id + + # Get the track ID to use as the next previous_id + track_id = t.id + + # Add the track to the queue + new_queue.append(t) + + # Replace the original queue with the new shuffled one + self.queue = new_queue + + def get_next_track(self) -> Track: + """Get the next track + + Get the next track from self.queue and add it to the history deque + + :return: The next track object + :rtype: Track + """ + + self.logger.debug('In get_next_track()') + + if self.current_track.id == '' or self.current_track.id is None: + # This is the first track + self.current_track = self.queue.popleft() + else: + # This is not the first track + self.history.append(self.current_track) + self.current_track = self.queue.popleft() + + # Set the buffer to match the queue + self.sync() + + return self.current_track + + def get_prevous_track(self) -> Track: + """Get the previous track + + Get the last track added to the history deque and + add it to the front of the play queue + + :return: The previous track object + :rtype: Track + """ + + self.logger.debug('In get_prevous_track()') + + # Return the current track to the queue + self.queue.appendleft(self.current_track) + + # Set the new current track + self.current_track = self.history.pop() + + # Set the buffer to match the queue + self.sync() + + return self.current_track + + def enqueue_next_track(self) -> Track: + """Get the next buffered track + + Get the next track from the buffer without updating the current track + attribute. This allows Amazon to send the PlaybackNearlyFinished + request early to queue the next track while maintaining the playlist + + :return: The next track to be played + :rtype: Track + """ + + self.logger.debug('In enqueue_next_track()') + + return self.buffer.popleft() + + def clear(self) -> None: + """Clear queue, history and buffer deques + + :return: None + """ + + self.logger.debug('In clear()') + self.queue.clear() + self.history.clear() + self.buffer.clear() + + def get_queue_count(self) -> int: + """Get the number of tracks in the queue + + :return: The number of tracks in the queue deque + :rtype: int + """ + + self.logger.debug('In get_queue_count()') + return len(self.queue) + + def get_history_count(self) -> int: + """Get the number of tracks in the history deque + + :return: The number of tracks in the history deque + :rtype: int + """ + + self.logger.debug('In get_history_count()') + return len(self.history) + + def sync(self) -> None: + """Syncronise the buffer with the queue + + Overwrite the buffer with the current queue. + This is useful when pausing or stopping to ensure + the resulting PlaybackNearlyFinished request gets + the correct. In practice this will have already + queued and there for missing from the current buffer + + :return: None + """ + + self.buffer = deepcopy(self.queue) diff --git a/skill/asknavidrome/subsonic_api.py b/skill/asknavidrome/subsonic_api.py new file mode 100644 index 0000000..8790ce9 --- /dev/null +++ b/skill/asknavidrome/subsonic_api.py @@ -0,0 +1,399 @@ +from hashlib import md5 +from typing import Union +import logging +import random +import secrets + +import libsonic + + +class SubsonicConnection: + """Class with methods to interact with Subsonic API compatible media servers + """ + + def __init__(self, server_url: str, user: str, passwd: str, port: int, api_location: str, api_version: str) -> None: + """ + :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 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 + """ + + self.logger = logging.getLogger(__name__) + + self.server_url = server_url + self.user = user + self.passwd = passwd + self.port = port + self.api_location = api_location + self.api_version = api_version + + self.conn = libsonic.Connection(self.server_url, + self.user, + self.passwd, + self.port, + self.api_location, + 'AskNavidrome', + self.api_version, + False) + + self.logger.debug('Connected to Navidrome') + + def ping(self) -> bool: + """Ping a Subsonic API server + + Verify the connection to a Subsonic compatible API server + is working + + :return: True if the connection works, False if it does not + :rtype: bool + """ + + self.logger.debug('In function ping()') + status = self.conn.ping() + + if status: + # Success + self.logger.info('Successfully connected to Navidrome') + else: + # Fail + self.logger.error('Failed to connect to Navidrome') + + return self.conn.ping() + + def search_playlist(self, term: str) -> Union[str, None]: + """Search the media server for the given playlist + + :param str term: The name of the playlist + :return: The ID of the playlist or None if the playlist is not found + :rtype: str | None + """ + + self.logger.debug('In function search_playlist()') + + playlist_dict = self.conn.getPlaylists() + + # Search the list of dictionaries for a playlist with a name that matches the search term + playlist_id_list = [item.get('id') for item in playlist_dict['playlists']['playlist'] if item.get('name').lower() == term.lower()] + + if len(playlist_id_list) == 1: + # We have matched the playlist return it + self.logger.debug(f'Found playlist {playlist_id_list[0]}') + + return playlist_id_list[0] + + elif len(playlist_id_list) > 1: + # More than one result was returned, this should not be possible + self.logger.error(f'More than one playlist called {term} was found, multiple playlists with the same name are not supported') + + return None + + elif len(playlist_id_list) == 0: + self.logger.error(f'No playlist matching the name {term} was found!') + + return None + + def search_artist(self, term: str) -> Union[dict, None]: + """Search the media server for the given artist + + :param str term: The name of the artist + :return: A dictionary of artists or None if no results are found + :rtype: dict | None + """ + + self.logger.debug('In function search_artist()') + + result_dict = self.conn.search3(term) + + if len(result_dict['searchResult3']) > 0: + # Results found + result_count = len(result_dict['searchResult3']['artist']) + + self.logger.debug(f'Searching artists for term: {term} found {result_count} entries.') + + if result_count > 0: + # Results were found + return result_dict['searchResult3']['artist'] + + # No results were found + return None + + def search_album(self, term: str) -> Union[dict, None]: + """Search the media server for the given album + + :param str term: The name of the album + :return: A dictionary of albums or None if no results are found + :rtype: dict | None + """ + + self.logger.debug('In function search_album()') + + result_dict = self.conn.search3(term) + + if len(result_dict['searchResult3']) > 0: + # Results found + result_count = len(result_dict['searchResult3']['album']) + + self.logger.debug(f'Searching albums for term: {term} found {result_count} entries.') + + if result_count > 0: + # Results were found + return result_dict['searchResult3']['album'] + + # No results were found + return None + + def search_song(self, term: str) -> Union[dict, None]: + """Search the media server for the given song + + :param str term: The name of the song + :return: A dictionary of songs or None if no results are found + :rtype: dict | None + """ + + self.logger.debug('In function search_song()') + + result_dict = self.conn.search3(term) + + if len(result_dict['searchResult3']) > 0: + # Results found + result_count = len(result_dict['searchResult3']['song']) + + self.logger.debug(f'Searching songs for term: {term}, found {result_count} entries.') + + if result_count > 0: + # Results were found + return result_dict['searchResult3']['song'] + + # No results were found + return None + + def albums_by_artist(self, id: str) -> 'list[dict]': + """Get the albums for a given artist + + :param str id: The artist ID + :return: A list of albums + :rtype: list of dict + """ + + self.logger.debug('In function albums_by_artist()') + + result_dict = self.conn.getArtist(id) + album_list = result_dict['artist'].get('album') + + # Shuffle the album list to keep generic requests fresh + random.shuffle(album_list) + + return album_list + + def build_song_list_from_albums(self, albums: 'list[dict]', length: int) -> list: + """Get a list of songs from given albums + + Build a list of songs from the given albums, keep adding tracks + until song_count is greater than of equal to length + + :param list[dict] albums: A list of dictionaries containing album information + :param int length: The minimum number of songs that should be returned, if -1 there is no limit + :return: A list of song IDs + :rtype: list + """ + + self.logger.debug('In function build_song_list_from_albums()') + + song_id_list = [] + + if length != -1: + song_count = 0 + album_id_list = [] + + # The list of songs should be limited by length + for album in albums: + if song_count < int(length): + # We need more songs + album_id_list.append(album.get('id')) + song_count = song_count + album.get('songCount') + else: + # We have enough songs, stop iterating + break + else: + # The list of songs should not be limited + album_id_list = [album.get('id') for album in albums] + + # Get a song listing for each album + for album_id in album_id_list: + album_details = self.conn.getAlbum(album_id) + + for song_detail in album_details['album']['song']: + # Capture the song ID + song_id_list.append(song_detail.get('id')) + + return song_id_list + + def build_song_list_from_playlist(self, id: str) -> list: + """Build a list of songs from a given playlist + + :param str id: The playlist ID + :return: A list of song IDs + :rtype: list + """ + + self.logger.debug('In function build_song_list_from_playlist()') + + song_id_list = [] + playlist_details = self.conn.getPlaylist(id) + + song_id_list = [song_detail.get('id') for song_detail in playlist_details.get('playlist').get('entry')] + + return song_id_list + + def build_song_list_from_favourites(self) -> Union[list, None]: + """Build a shuffled list favourite songs + + :return: A list of song IDs or None if no favourite tracks are found. + :rtype: list | None + """ + + self.logger.debug('In function build_song_list_from_favourites()') + + favourite_songs = self.conn.getStarred2().get('starred2').get('song') + + if len(favourite_songs) > 0: + song_id_list = [song.get('id') for song in favourite_songs] + + return song_id_list + + else: + return None + + 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 int count: The number of songs to return + :return: A list of song IDs or None if no tracks are found. + :rtype: list | None + """ + + 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 + # 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') + + if len(songs_from_genre) > 0: + song_id_list = [song.get('id') for song in songs_from_genre] + + return song_id_list + + else: + return None + + def build_random_song_list(self, count: int) -> Union[list, None]: + """Build a shuffled list of random songs + + :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 + """ + + self.logger.debug('In function build_random_song_list()') + random_songs = self.conn.getRandomSongs(count).get('randomSongs').get('song') + + if len(random_songs) > 0: + song_id_list = [song.get('id') for song in random_songs] + + return song_id_list + + else: + return None + + def get_song_details(self, id: str) -> dict: + """Get details about a given song ID + + :param str id: A song ID + :return: A dictionary of details about the given song. + :rtype: dict + """ + + self.logger.debug('In function get_song_details()') + + song_details = self.conn.getSong(id) + + return song_details + + def get_song_uri(self, id: str) -> str: + """Create a URI for a given song + + Creates a URI for the song represented by the given ID. Authentication details are + embedded in the URI + + :param str id: A song ID + :return: A properly formatted URI + :rtype: str + """ + + self.logger.debug('In function get_song_uri()') + + salt = secrets.token_hex(16) + auth_token = md5(self.passwd.encode() + salt.encode()) + + # This creates a multiline f string, uri contains a single line with both + # f strings. + uri = ( + f'{self.server_url}:{self.port}{self.api_location}/stream.view?f=json&v={self.api_version}&c=AskNavidrome&u=' + f'{self.user}&s={salt}&t={auth_token.hexdigest()}&id={id}' + ) + + return uri + + def star_entry(self, id: str, mode: str) -> None: + """Add a star to the given entity + + :param str id: The Navidrome ID of the entity. + :param str mode: The type of entity, must be song, artist or album + :return: None. + """ + + # Convert id to list + id_list = [id] + + if mode == 'song': + self.conn.star(id_list, None, None) + + return None + elif mode == 'album': + self.conn.star(None, id_list, None) + + return None + elif mode == 'artist': + self.conn.star(None, None, id_list) + + return None + + def unstar_entry(self, id: str, mode: str) -> None: + """Remove a star from the given entity + + :param str id: The Navidrome ID of the entity. + :param str mode: The type of entity, must be song, artist or album + :return: None. + """ + + # Convert id to list + id_list = [id] + + if mode == 'song': + self.conn.unstar(id_list, None, None) + + return None + elif mode == 'album': + self.conn.unstar(None, id_list, None) + + return None + elif mode == 'artist': + self.conn.unstar(None, None, id_list) + + return None diff --git a/skill/asknavidrome/track.py b/skill/asknavidrome/track.py new file mode 100644 index 0000000..c072fa0 --- /dev/null +++ b/skill/asknavidrome/track.py @@ -0,0 +1,41 @@ +class Track: + """An object that represents an audio track + """ + + def __init__(self, + id: str = '', title: str = '', artist: str = '', artist_id: str = '', + album: str = '', album_id: str = '', track_no: int = 0, year: int = 0, + genre: str = '', duration: int = 0, bitrate: int = 0, uri: str = '', + offset: int = 0, previous_id: str = '') -> None: + """ + :param str id: The song ID. Defaults to '' + :param str title: The song title. Defaults to '' + :param str artist: The artist name. Defaults to '' + :param str artist_id: The artist ID. Defaults to '' + :param str album: The album name. Defaults to '' + :param str album_id: The album ID. Defaults to '' + :param int track_no: The track number. Defaults to 0 + :param int year: The release year. Defaults to 0 + :param str genre: The music genre. Defaults to '' + :param int duration: The length of the track in seconds. Defaults to 0 + :param int bitrate: The bit rate in kbps. Defaults to 0 + :param str uri: The song's URI for streaming. Defaults to '' + :param int offset: The position in the track to start playback in milliseconds. Defaults to 0 + :param str previous_id: The ID of the previous song in the playlist. Defaults to '' + :return: None + """ + + self.id: str = id + self.artist: str = artist + self.artist_id: str = artist_id + self.title: str = title + self.album: str = album + self.album_id: str = album_id + self.track_no: int = track_no + self.year: int = year + self.genre: str = genre + self.duration: int = duration + self.bitrate: int = bitrate + self.uri: str = uri + self.offset: int = offset + self.previous_id: str = previous_id diff --git a/skill/requirements-docker.txt b/skill/requirements-docker.txt new file mode 100644 index 0000000..8e791c9 --- /dev/null +++ b/skill/requirements-docker.txt @@ -0,0 +1,4 @@ +# App +ask-sdk +flask-ask-sdk +py-sonic \ No newline at end of file diff --git a/skill/requirements-full.txt b/skill/requirements-full.txt new file mode 100644 index 0000000..8d99752 --- /dev/null +++ b/skill/requirements-full.txt @@ -0,0 +1,11 @@ +# App +ask-sdk +flask-ask-sdk +py-sonic + +# Dev +sphinx +autodocsumm +groundwork-sphinx-theme +rinohtype +flake8 \ No newline at end of file diff --git a/skill/templates/base.html b/skill/templates/base.html new file mode 100644 index 0000000..ee77a12 --- /dev/null +++ b/skill/templates/base.html @@ -0,0 +1,22 @@ + + + + {{ title }} + + + + +
+

{{ title }}

+
+

Now playing {{ current.title }} by {{ current.artist }}

+

Track ID: {{ current.id }}

+
+ {% block content %}{% endblock %} +
+ + + + {% block scripts %}{% endblock %} + + \ No newline at end of file diff --git a/skill/templates/table.html b/skill/templates/table.html new file mode 100644 index 0000000..27d8a48 --- /dev/null +++ b/skill/templates/table.html @@ -0,0 +1,42 @@ +{% extends "base.html" %} + +{% block content %} + + + + + + + + + + + + + + + + {% for track in tracks %} + + + + + + + + + + + + {% endfor %} + +
IDArtistTitleAlbumYearGenreDurationBit RateURI
{{ track.id }}{{ track.artist }}{{ track.title }}{{ track.album }}{{ track.year }}{{ track.genre }}{{ track.duration }}{{ track.bitrate }}{{ track.uri }}
+{% endblock %} + +{% block scripts %} + +{% endblock %} \ No newline at end of file