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