Move to GitHub

This commit is contained in:
Ross Stewart
2022-08-05 16:37:56 +01:00
commit 58885be096
18 changed files with 2092 additions and 0 deletions

41
.github/workflows/build_image.yml vendored Normal file
View File

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

58
.github/workflows/build_sphinx_docs.yml vendored Normal file
View File

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

9
.gitignore vendored Normal file
View File

@@ -0,0 +1,9 @@
__pycache__
env
sphinx/_build
sphinx/resources/*.bkp
sphinx/resources/*.dtmp
asknavidrome.log
test.py
NOTES.md
sphinx

33
.vscode/launch.json vendored Normal file
View File

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

32
Dockerfile Normal file
View File

@@ -0,0 +1,32 @@
FROM alpine:3.15.0 as build
LABEL maintainer="Ross Stewart <rosskouk@gmail.com>"
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 <rosskouk@gmail.com>"
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"]

21
LICENSE Normal file
View File

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

1
README.md Normal file
View File

@@ -0,0 +1 @@
# AskNavidrome

0
docs/.gitkeep Normal file
View File

968
skill/app.py Executable file
View File

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

View File

@@ -0,0 +1 @@
"""The AskNavidrome Module!"""

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,4 @@
# App
ask-sdk
flask-ask-sdk
py-sonic

View File

@@ -0,0 +1,11 @@
# App
ask-sdk
flask-ask-sdk
py-sonic
# Dev
sphinx
autodocsumm
groundwork-sphinx-theme
rinohtype
flake8

22
skill/templates/base.html Normal file
View File

@@ -0,0 +1,22 @@
<!doctype html>
<html>
<head>
<title>{{ title }}</title>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.0.1/dist/css/bootstrap.min.css" rel="stylesheet" integrity="sha384-+0n0xVW2eSR5OomGNYDnhzAbDsOXxcvSN1TPprVMTNDbiYZCxYbOOl7+AMvyTG2x" crossorigin="anonymous">
<link rel="stylesheet" type="text/css" href="https://cdn.datatables.net/1.10.25/css/dataTables.bootstrap5.css">
</head>
<body>
<div class="container">
<h1>{{ title }}</h1>
<hr>
<h3>Now playing {{ current.title }} by {{ current.artist }}</h3>
<h4>Track ID: {{ current.id }}</h4>
<br>
{% block content %}{% endblock %}
</div>
<script type="text/javascript" charset="utf8" src="https://code.jquery.com/jquery-3.6.0.min.js"></script>
<script type="text/javascript" charset="utf8" src="https://cdn.datatables.net/1.10.25/js/jquery.dataTables.js"></script>
<script type="text/javascript" charset="utf8" src="https://cdn.datatables.net/1.10.25/js/dataTables.bootstrap5.js"></script>
{% block scripts %}{% endblock %}
</body>
</html>

View File

@@ -0,0 +1,42 @@
{% extends "base.html" %}
{% block content %}
<table id="data" class="table table-striped">
<thead>
<tr>
<th>ID</th>
<th>Artist</th>
<th>Title</th>
<th>Album</th>
<th>Year</th>
<th>Genre</th>
<th>Duration</th>
<th>Bit Rate</th>
<th>URI</th>
</tr>
</thead>
<tbody>
{% for track in tracks %}
<tr>
<td>{{ track.id }}</td>
<td>{{ track.artist }}</td>
<td>{{ track.title }}</td>
<td>{{ track.album }}</td>
<td>{{ track.year }}</td>
<td>{{ track.genre }}</td>
<td>{{ track.duration }}</td>
<td>{{ track.bitrate }}</td>
<td>{{ track.uri }}</td>
</tr>
{% endfor %}
</tbody>
</table>
{% endblock %}
{% block scripts %}
<script>
$(document).ready(function () {
$('#data').DataTable();
});
</script>
{% endblock %}