Move to GitHub
This commit is contained in:
41
.github/workflows/build_image.yml
vendored
Normal file
41
.github/workflows/build_image.yml
vendored
Normal 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
58
.github/workflows/build_sphinx_docs.yml
vendored
Normal 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
9
.gitignore
vendored
Normal 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
33
.vscode/launch.json
vendored
Normal 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
32
Dockerfile
Normal 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
21
LICENSE
Normal 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.
|
||||
0
docs/.gitkeep
Normal file
0
docs/.gitkeep
Normal file
968
skill/app.py
Executable file
968
skill/app.py
Executable 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')
|
||||
1
skill/asknavidrome/__init__.py
Normal file
1
skill/asknavidrome/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
"""The AskNavidrome Module!"""
|
||||
195
skill/asknavidrome/controller.py
Normal file
195
skill/asknavidrome/controller.py
Normal 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)
|
||||
214
skill/asknavidrome/media_queue.py
Normal file
214
skill/asknavidrome/media_queue.py
Normal 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)
|
||||
399
skill/asknavidrome/subsonic_api.py
Normal file
399
skill/asknavidrome/subsonic_api.py
Normal 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
|
||||
41
skill/asknavidrome/track.py
Normal file
41
skill/asknavidrome/track.py
Normal 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
|
||||
4
skill/requirements-docker.txt
Normal file
4
skill/requirements-docker.txt
Normal file
@@ -0,0 +1,4 @@
|
||||
# App
|
||||
ask-sdk
|
||||
flask-ask-sdk
|
||||
py-sonic
|
||||
11
skill/requirements-full.txt
Normal file
11
skill/requirements-full.txt
Normal 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
22
skill/templates/base.html
Normal 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>
|
||||
42
skill/templates/table.html
Normal file
42
skill/templates/table.html
Normal 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 %}
|
||||
Reference in New Issue
Block a user