From 82e5c9963f5796901c8bb06e9635ce855edf84af Mon Sep 17 00:00:00 2001 From: rusketh Date: Sun, 21 Dec 2025 16:48:08 +0000 Subject: [PATCH 01/10] Fixed #29. Fixed #30. --- src/skill/album-intents.js | 2 +- src/skill/genre-intent.js | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/skill/album-intents.js b/src/skill/album-intents.js index c2cb401..bc87123 100644 --- a/src/skill/album-intents.js +++ b/src/skill/album-intents.js @@ -46,7 +46,7 @@ const Processer = async function(handlerInput, action = "play", buildQueue, subm if (!artists.status || !artists.items[0]) { - const speach2 = `I didn't find an artist called ${slots.artistname.value}.`; + const speach = `I didn't find an artist called ${slots.artistname.value}.`; return [{status: false, speach}]; } diff --git a/src/skill/genre-intent.js b/src/skill/genre-intent.js index 529161a..7b3a612 100644 --- a/src/skill/genre-intent.js +++ b/src/skill/genre-intent.js @@ -103,7 +103,7 @@ const PlayGenreIntent = CreateQueueIntent( const ShuffleGenreIntent = CreateQueueIntent( "ShuffleGenreIntent", "shuffling", Processer, - function (handlerInput, {genres, songs}, data) + function (handlerInput, {genres, item}, data) { const { responseBuilder, requestEnvelope } = handlerInput; From e5de7e506f8a36426771f071ddb283d33753d551 Mon Sep 17 00:00:00 2001 From: rusketh Date: Sun, 21 Dec 2025 18:06:26 +0000 Subject: [PATCH 02/10] Fixed a small mistake. --- src/skill/genre-intent.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/skill/genre-intent.js b/src/skill/genre-intent.js index 7b3a612..140b6a4 100644 --- a/src/skill/genre-intent.js +++ b/src/skill/genre-intent.js @@ -103,7 +103,7 @@ const PlayGenreIntent = CreateQueueIntent( const ShuffleGenreIntent = CreateQueueIntent( "ShuffleGenreIntent", "shuffling", Processer, - function (handlerInput, {genres, item}, data) + function (handlerInput, {genres, items}, data) { const { responseBuilder, requestEnvelope } = handlerInput; From 8a22a8398debecf8812f09de5497019d8db7bfa5 Mon Sep 17 00:00:00 2001 From: rusketh Date: Mon, 22 Dec 2025 14:44:45 +0000 Subject: [PATCH 03/10] Added skill id verification. --- ReadMe.md | 182 ++++++++++++++++++++++++++++++++++++++ src/init.js | 5 +- src/skill/alexa-helper.js | 22 ++++- 3 files changed, 206 insertions(+), 3 deletions(-) diff --git a/ReadMe.md b/ReadMe.md index cf29283..3017faa 100644 --- a/ReadMe.md +++ b/ReadMe.md @@ -1,172 +1,354 @@ + # Jelly Music + + ## What is Jelly Music? + + Jelly Music is a **self-hosted Alexa Skill** designed for your Jellyfin server. It allows for you to stream and play music on your Alexa devices using voice commands like: + + * "Alexa, ask Jelly Music to play album **Creeping Death** by Metallica." + + * "Alexa, ask Jelly Music to shuffle songs by **Black Sabbath**." + + --- +# IMPORTANT CHANGE: + For additonal security, your container now requires a new enviroment value **Skill_ID** (see: Installation Instructions for more information). + + **If you do not set this your skill will no longer function after updating.** + ## Features + + * Per device music queues. + + * Play music from your Jellyfin server. + + * Support to search by, **Song Name**, **Album Name**, **Artist Name**, **Playlist** and **Genre***. + + --- + + ## Alpha Software Disclaimer + + Please note that **Jelly Music** is a side project I'm developing in my spare time. + + * There's **no guarantee** of ongoing maintenance or updates. + + * Not all features will be thoroughly tested upon release, so exercise **caution when updating**. + + * This software comes with **no guarantees of functionality**. I am not an experienced programmer; I'm simply an enthusiast with a hobby. + + --- + + ## Requirements + + To run a Jelly Music instance, you will need: + + * A basic understanding of setting up **Docker containers**. + + * The ability to log into the **Alexa Developer Dashboard** (creating an account is straightforward). + + * A self-hosted Jellyfin server accessible via a **public address** and secured with a **trusted SSL certificate**. + + * A Docker container capable of running the Jelly Music server, also accessible via a **public address** and secured with a **trusted SSL certificate**. + + * **Recommendation:** Set up an **NGINX reverse proxy server** and **Dynamic DNS (DDNS)** to achieve the public accessibility and trusted certificates. + + --- + + ## Installation Instructions + + 1. Set up the Docker container using the latest [**Docker image**](https://ghcr.io/rusketh/jellymusic/jellymusic). + + 2. Ensure you **enable the necessary port** in your Docker configuration. + + 3. Configure the **environment variables** as decribed bellow. + + 4. Map or bind a **Data Directory** ``/data`` + + Here's an example of a `docker run` command: + + ``docker run -e JELLYFIN_HOST="https://jellyfin.example.com" -e JELLYFIN_KEY="replace_with_your_api_key" -e SKILL_NAME="jelly music" -e PORT="60648" -p 60648:60648 -v "data:/data" ghcr.io/rusketh/jellymusic/jellymusic:main`` + + ### Enviroment Variables + + The following environment variables are **required**: + + * **`JELLYFIN_HOST`**: The public address of your Jellyfin server. + + * **`JELLYFIN_KEY`**: The API key for your Jellyfin server. + + * **`SKILL_NAME`**: The two-word name Alexa will use to refer to your Jellyfin server (e.g., "Jelly Music"). + + +* **`SKILL_ID`**: The full application id of your alexa skill (e.g amzn1.ask.skill.00000000-0000-0000-0000-000000000000) + + + * **`PORT`**: The Port your server will use (remember to enable this port on Docker). + + **Note:** Your public address for the server needs to run on port `443`, and your reverse proxy should handle the redirect to the specified `PORT`. + + ### Data Directory + + The **configuration file** for Jelly Music, as well as the **SQLite database** used to store the song queue for each connected device, are saved within the `/data` directory. + + **Important Considerations:** + + - This directory **must be mapped as a volume** in your Docker container to ensure data persistence. Without it, your configuration will be lost when the container stops or is removed. + + --- + + ## Alexa Skill Setup + + 1. Create an account on the **Alexa Developer Console** using your Amazon account: [https://developer.amazon.com](https://developer.amazon.com) + + 2. Create a **new skill** named `Jelly Music`. + + 3. For "Type of experience," select **"Music & Audio"**. + + 4. For the "Model," choose **"Custom"**. + + 5. For "Hosting Service," choose **"Provision your own"**. + + **Note:** This container will **not** work with Alexa-Hosted skills. + + Once your Alexa Skill is created, you need to configure it. This might seem daunting, but it's quite simple as most of the work has been done for you. + + 1. Navigate to **"Endpoint"** and select **"HTTPS"**. Set "Default Region" to the **public address of your Jelly Music server**. + + 2. Navigate to **"Interfaces"** and **enable "Audio Player"**. This is crucial for audio streaming, and the skill will not function without it. + + 3. Grab the preconfigured Alexa Skill JSON file from here: [https://github.com/Rusketh/JellyMusic/blob/main/src/skill/skill.json](https://github.com/Rusketh/JellyMusic/blob/main/src/skill/skill.json) + + 4. Change `interactionModel.languageModel.invocationName` (line 4 in the JSON file) to the name you used for the **`SKILL_NAME` environment variable**, then save the file. + + 5. Navigate to **"Interaction Model"** and open the **"JSON Editor"**. Drag your saved JSON file into the editor (or copy and paste its contents). + + +6. Set the **SKILL_ID** enviroment value of your docker container to match your skills full id. You can find this developer console where your skills are listed ``You can find this on the developer portal. Click the Back button to go to your list of skills, then click Copy Skill ID.`` + + That's it! Click **"Save"** at the top, then hit **"Build Skill"**. Hopefully, after a few minutes, it will build successfully, and you'll be ready to go and install the skill. Start the container and enjoy listening to your music! + + **Note:** When upgrading to new versions **New Features** will require you to update your ``skill.json`` as described in steps 3 - 4. + + ### Enabling Your Skill in the Alexa App + + Once your Docker container is running and your Alexa Skill is successfully built in the Alexa Developer Console, you need to enable it within your Alexa app on your mobile device. + + 1. Open the **Alexa app** on your smartphone or tablet. + + 2. Navigate to the **"More"** menu (usually represented by three lines or dots). + + 3. Tap on **"Skills & Games"**. + + 4. **Scroll to the bottom** of the "Skills & Games" screen and tap on **"Your Skills"**. + + 5. You should see a small dropdown menu, typically labeled "Enabled". Tap on this dropdown and select **"Dev"** (for Development skills). + + 6. Locate and tap on your **"Jelly Music"** skill from the list. + + 7. Tap the **"Enable"** or **"Enable to Use"** button. + + Your Jelly Music skill should now be active and ready for use on your Alexa devices! + ## Commands + You can use the following commands with Alexa: + "Alexa, ask Jelly Music to..." + * **Play/Shuffle/Queue** the album `{album name}` [by `{artist name}`] + * **Play/Shuffle/Queue** songs [by `{artist name}`] + * **Play/Queue** `{song name}` [by `{artist name}`] + * **Play/Queue/Shuffle** playlist `{playlist name}` + * **Play/Queue/Shuffle** `{genre}` music + * **Clear the queue** + As well as using simple commands like stop, play, pause, resume and next. + + --- + ### Intents + + Here is a list of all the current none standard intents used, the example commands and what they do. + | Intent | Command | Description | + |--|--|--| + | PlaySongIntent | Play song {song} by {artist} | Plays {song} ``(clears queue)``. | + | PlayAlbumIntent | Play the album {album} by {artist} | Plays {album} ``(clears queue)``. | + | PlayArtistIntent | Play songs by {artist} | Plays songs by {artist} ``(clears queue)``. | + | PlayPlaylistIntent | Play playlist {playlist} | Plays songs from {playlist} ``(clears queue)``. | + | PlayGenreIntent | Play {genre} music | Plays music of {genre} ``(clears queue)``. | + | ShuffleAlbumIntent | Shuffle the album {album} by {artist} | Plays & shuffles {album}``(clears queue)``. | + | ShuffleArtistIntent | Shuffle songs by {artist} | Plays & shuffles songs by {artist} ``(clears queue)``. + | ShufflePlaylistIntent | Shuffle playlist {playlist} | Plays & shuffles songs from {playlist} ``(clears queue)``. | + | ShuffleGenreIntent | Shuffle {genre} music | Plays & shuffles music of {genre} ``(clears queue)``. | + | QueueSongIntent | Add {song} to the queue | Adds {song} to queue | + | QueueAlbumIntent | Add the album {album} by {artist} to the queue | Adds {album} to the queue. | + | QueueArtistIntent | Add songs by {artistname} to the queue | Adds songs by {artistname} to the queue. | + | QueuePlaylistIntent | Add playlist {playlist} to the queue | Adds songs from {playlist} to the queue. | + | QueueGenreIntent | Queue {genre} music | Adds {genre} music to the queue. | + --- + + ## Help Developing + As mentioned, this is a work in progress, and I'm not the most skilled programmer. I would be very grateful to anyone who contributes a **Pull Request (PR)** to this project. All I ask is that you try to adhere to my coding style and **test your work** before committing. diff --git a/src/init.js b/src/init.js index 971754c..9464066 100644 --- a/src/init.js +++ b/src/init.js @@ -20,6 +20,7 @@ CONFIG = { }, skill: { + id: null, name: "Jelly Music" }, server: @@ -92,7 +93,9 @@ assert(CONFIG.skill.name, `No skill name defined.\nThis can set in CONFIG under //TODO: Skill name needs to be 2 words. -//TODO: CONFIG.skill.id maybe needed if I ever add automatic skill building. +CONFIG.skill.id = process.env.SKILL_ID || CONFIG.skill.id; + +assert(CONFIG.skill.id, `No skill id defined.\nThis can set in CONFIG under "skill.id" or as enviroment value SKILL_ID.`); /********************************************************************************* * Validate Server CONFIG diff --git a/src/skill/alexa-helper.js b/src/skill/alexa-helper.js index affd122..b721727 100644 --- a/src/skill/alexa-helper.js +++ b/src/skill/alexa-helper.js @@ -12,7 +12,16 @@ const CreateHandler = function(type, callback) { try { - return Alexa.getRequestType(handlerInput.requestEnvelope) === type; + const {requestEnvelope, responseBuilder} = handlerInput; + + if (CONFIG.skill.id != requestEnvelope.context.System.application.applicationId) + { + console.error("Unauthorized request blocked."); + responseBuilder.withShouldEndSession(true); + return false; + } + + return Alexa.getRequestType(requestEnvelope) === type; } catch(err) { @@ -56,7 +65,16 @@ const CreateIntent = function(intent, callback) { try { - return Alexa.getRequestType(handlerInput.requestEnvelope) === 'IntentRequest' && Alexa.getIntentName(handlerInput.requestEnvelope) === intent; + const {requestEnvelope, responseBuilder} = handlerInput; + + if (CONFIG.skill.id != requestEnvelope.context.System.application.applicationId) + { + console.error("Unauthorized request blocked."); + responseBuilder.withShouldEndSession(true); + return false; + } + + return Alexa.getRequestType(requestEnvelope) === 'IntentRequest' && Alexa.getIntentName(requestEnvelope) === intent; } catch(err) { From 182f499ab9f7c83d574af7bfe178d75f3bc46445 Mon Sep 17 00:00:00 2001 From: rusketh Date: Mon, 22 Dec 2025 14:44:45 +0000 Subject: [PATCH 04/10] Added skill id verification as suggested in #28. --- ReadMe.md | 182 ++++++++++++++++++++++++++++++++++++++ src/init.js | 5 +- src/skill/alexa-helper.js | 7 +- src/skill/skill.js | 6 +- 4 files changed, 193 insertions(+), 7 deletions(-) diff --git a/ReadMe.md b/ReadMe.md index cf29283..3017faa 100644 --- a/ReadMe.md +++ b/ReadMe.md @@ -1,172 +1,354 @@ + # Jelly Music + + ## What is Jelly Music? + + Jelly Music is a **self-hosted Alexa Skill** designed for your Jellyfin server. It allows for you to stream and play music on your Alexa devices using voice commands like: + + * "Alexa, ask Jelly Music to play album **Creeping Death** by Metallica." + + * "Alexa, ask Jelly Music to shuffle songs by **Black Sabbath**." + + --- +# IMPORTANT CHANGE: + For additonal security, your container now requires a new enviroment value **Skill_ID** (see: Installation Instructions for more information). + + **If you do not set this your skill will no longer function after updating.** + ## Features + + * Per device music queues. + + * Play music from your Jellyfin server. + + * Support to search by, **Song Name**, **Album Name**, **Artist Name**, **Playlist** and **Genre***. + + --- + + ## Alpha Software Disclaimer + + Please note that **Jelly Music** is a side project I'm developing in my spare time. + + * There's **no guarantee** of ongoing maintenance or updates. + + * Not all features will be thoroughly tested upon release, so exercise **caution when updating**. + + * This software comes with **no guarantees of functionality**. I am not an experienced programmer; I'm simply an enthusiast with a hobby. + + --- + + ## Requirements + + To run a Jelly Music instance, you will need: + + * A basic understanding of setting up **Docker containers**. + + * The ability to log into the **Alexa Developer Dashboard** (creating an account is straightforward). + + * A self-hosted Jellyfin server accessible via a **public address** and secured with a **trusted SSL certificate**. + + * A Docker container capable of running the Jelly Music server, also accessible via a **public address** and secured with a **trusted SSL certificate**. + + * **Recommendation:** Set up an **NGINX reverse proxy server** and **Dynamic DNS (DDNS)** to achieve the public accessibility and trusted certificates. + + --- + + ## Installation Instructions + + 1. Set up the Docker container using the latest [**Docker image**](https://ghcr.io/rusketh/jellymusic/jellymusic). + + 2. Ensure you **enable the necessary port** in your Docker configuration. + + 3. Configure the **environment variables** as decribed bellow. + + 4. Map or bind a **Data Directory** ``/data`` + + Here's an example of a `docker run` command: + + ``docker run -e JELLYFIN_HOST="https://jellyfin.example.com" -e JELLYFIN_KEY="replace_with_your_api_key" -e SKILL_NAME="jelly music" -e PORT="60648" -p 60648:60648 -v "data:/data" ghcr.io/rusketh/jellymusic/jellymusic:main`` + + ### Enviroment Variables + + The following environment variables are **required**: + + * **`JELLYFIN_HOST`**: The public address of your Jellyfin server. + + * **`JELLYFIN_KEY`**: The API key for your Jellyfin server. + + * **`SKILL_NAME`**: The two-word name Alexa will use to refer to your Jellyfin server (e.g., "Jelly Music"). + + +* **`SKILL_ID`**: The full application id of your alexa skill (e.g amzn1.ask.skill.00000000-0000-0000-0000-000000000000) + + + * **`PORT`**: The Port your server will use (remember to enable this port on Docker). + + **Note:** Your public address for the server needs to run on port `443`, and your reverse proxy should handle the redirect to the specified `PORT`. + + ### Data Directory + + The **configuration file** for Jelly Music, as well as the **SQLite database** used to store the song queue for each connected device, are saved within the `/data` directory. + + **Important Considerations:** + + - This directory **must be mapped as a volume** in your Docker container to ensure data persistence. Without it, your configuration will be lost when the container stops or is removed. + + --- + + ## Alexa Skill Setup + + 1. Create an account on the **Alexa Developer Console** using your Amazon account: [https://developer.amazon.com](https://developer.amazon.com) + + 2. Create a **new skill** named `Jelly Music`. + + 3. For "Type of experience," select **"Music & Audio"**. + + 4. For the "Model," choose **"Custom"**. + + 5. For "Hosting Service," choose **"Provision your own"**. + + **Note:** This container will **not** work with Alexa-Hosted skills. + + Once your Alexa Skill is created, you need to configure it. This might seem daunting, but it's quite simple as most of the work has been done for you. + + 1. Navigate to **"Endpoint"** and select **"HTTPS"**. Set "Default Region" to the **public address of your Jelly Music server**. + + 2. Navigate to **"Interfaces"** and **enable "Audio Player"**. This is crucial for audio streaming, and the skill will not function without it. + + 3. Grab the preconfigured Alexa Skill JSON file from here: [https://github.com/Rusketh/JellyMusic/blob/main/src/skill/skill.json](https://github.com/Rusketh/JellyMusic/blob/main/src/skill/skill.json) + + 4. Change `interactionModel.languageModel.invocationName` (line 4 in the JSON file) to the name you used for the **`SKILL_NAME` environment variable**, then save the file. + + 5. Navigate to **"Interaction Model"** and open the **"JSON Editor"**. Drag your saved JSON file into the editor (or copy and paste its contents). + + +6. Set the **SKILL_ID** enviroment value of your docker container to match your skills full id. You can find this developer console where your skills are listed ``You can find this on the developer portal. Click the Back button to go to your list of skills, then click Copy Skill ID.`` + + That's it! Click **"Save"** at the top, then hit **"Build Skill"**. Hopefully, after a few minutes, it will build successfully, and you'll be ready to go and install the skill. Start the container and enjoy listening to your music! + + **Note:** When upgrading to new versions **New Features** will require you to update your ``skill.json`` as described in steps 3 - 4. + + ### Enabling Your Skill in the Alexa App + + Once your Docker container is running and your Alexa Skill is successfully built in the Alexa Developer Console, you need to enable it within your Alexa app on your mobile device. + + 1. Open the **Alexa app** on your smartphone or tablet. + + 2. Navigate to the **"More"** menu (usually represented by three lines or dots). + + 3. Tap on **"Skills & Games"**. + + 4. **Scroll to the bottom** of the "Skills & Games" screen and tap on **"Your Skills"**. + + 5. You should see a small dropdown menu, typically labeled "Enabled". Tap on this dropdown and select **"Dev"** (for Development skills). + + 6. Locate and tap on your **"Jelly Music"** skill from the list. + + 7. Tap the **"Enable"** or **"Enable to Use"** button. + + Your Jelly Music skill should now be active and ready for use on your Alexa devices! + ## Commands + You can use the following commands with Alexa: + "Alexa, ask Jelly Music to..." + * **Play/Shuffle/Queue** the album `{album name}` [by `{artist name}`] + * **Play/Shuffle/Queue** songs [by `{artist name}`] + * **Play/Queue** `{song name}` [by `{artist name}`] + * **Play/Queue/Shuffle** playlist `{playlist name}` + * **Play/Queue/Shuffle** `{genre}` music + * **Clear the queue** + As well as using simple commands like stop, play, pause, resume and next. + + --- + ### Intents + + Here is a list of all the current none standard intents used, the example commands and what they do. + | Intent | Command | Description | + |--|--|--| + | PlaySongIntent | Play song {song} by {artist} | Plays {song} ``(clears queue)``. | + | PlayAlbumIntent | Play the album {album} by {artist} | Plays {album} ``(clears queue)``. | + | PlayArtistIntent | Play songs by {artist} | Plays songs by {artist} ``(clears queue)``. | + | PlayPlaylistIntent | Play playlist {playlist} | Plays songs from {playlist} ``(clears queue)``. | + | PlayGenreIntent | Play {genre} music | Plays music of {genre} ``(clears queue)``. | + | ShuffleAlbumIntent | Shuffle the album {album} by {artist} | Plays & shuffles {album}``(clears queue)``. | + | ShuffleArtistIntent | Shuffle songs by {artist} | Plays & shuffles songs by {artist} ``(clears queue)``. + | ShufflePlaylistIntent | Shuffle playlist {playlist} | Plays & shuffles songs from {playlist} ``(clears queue)``. | + | ShuffleGenreIntent | Shuffle {genre} music | Plays & shuffles music of {genre} ``(clears queue)``. | + | QueueSongIntent | Add {song} to the queue | Adds {song} to queue | + | QueueAlbumIntent | Add the album {album} by {artist} to the queue | Adds {album} to the queue. | + | QueueArtistIntent | Add songs by {artistname} to the queue | Adds songs by {artistname} to the queue. | + | QueuePlaylistIntent | Add playlist {playlist} to the queue | Adds songs from {playlist} to the queue. | + | QueueGenreIntent | Queue {genre} music | Adds {genre} music to the queue. | + --- + + ## Help Developing + As mentioned, this is a work in progress, and I'm not the most skilled programmer. I would be very grateful to anyone who contributes a **Pull Request (PR)** to this project. All I ask is that you try to adhere to my coding style and **test your work** before committing. diff --git a/src/init.js b/src/init.js index 971754c..9464066 100644 --- a/src/init.js +++ b/src/init.js @@ -20,6 +20,7 @@ CONFIG = { }, skill: { + id: null, name: "Jelly Music" }, server: @@ -92,7 +93,9 @@ assert(CONFIG.skill.name, `No skill name defined.\nThis can set in CONFIG under //TODO: Skill name needs to be 2 words. -//TODO: CONFIG.skill.id maybe needed if I ever add automatic skill building. +CONFIG.skill.id = process.env.SKILL_ID || CONFIG.skill.id; + +assert(CONFIG.skill.id, `No skill id defined.\nThis can set in CONFIG under "skill.id" or as enviroment value SKILL_ID.`); /********************************************************************************* * Validate Server CONFIG diff --git a/src/skill/alexa-helper.js b/src/skill/alexa-helper.js index affd122..c388118 100644 --- a/src/skill/alexa-helper.js +++ b/src/skill/alexa-helper.js @@ -12,7 +12,8 @@ const CreateHandler = function(type, callback) { try { - return Alexa.getRequestType(handlerInput.requestEnvelope) === type; + const {requestEnvelope} = handlerInput; + return Alexa.getRequestType(requestEnvelope) === type; } catch(err) { @@ -56,7 +57,9 @@ const CreateIntent = function(intent, callback) { try { - return Alexa.getRequestType(handlerInput.requestEnvelope) === 'IntentRequest' && Alexa.getIntentName(handlerInput.requestEnvelope) === intent; + const {requestEnvelope} = handlerInput; + + return Alexa.getRequestType(requestEnvelope) === 'IntentRequest' && Alexa.getIntentName(requestEnvelope) === intent; } catch(err) { diff --git a/src/skill/skill.js b/src/skill/skill.js index 138f8fd..e5b4612 100644 --- a/src/skill/skill.js +++ b/src/skill/skill.js @@ -90,12 +90,10 @@ const skill = Alexa.SkillBuilders.custom() SongIntents.QueueSongIntent, GenreIntents.QueueGenreIntent, - QueryIntents.WhatThisIntent, - - ErrorHandler + QueryIntents.WhatThisIntent ).addErrorHandlers( ErrorHandler - ).create(); + ).withSkillId(CONFIG.skill.id).create(); /********************************************************************************* * Exports From 85e5b7924be944e6935e21219fde8d9ffd55a7bc Mon Sep 17 00:00:00 2001 From: rusketh Date: Fri, 26 Dec 2025 14:33:43 +0000 Subject: [PATCH 05/10] Added Discord badge to the read me. Moved the skill.json to a new location ready for language support. --- ReadMe.md | 4 ++-- src/skill/skill.json => skill-json/skill-EN.json | 0 2 files changed, 2 insertions(+), 2 deletions(-) rename src/skill/skill.json => skill-json/skill-EN.json (100%) diff --git a/ReadMe.md b/ReadMe.md index 3017faa..12a51e6 100644 --- a/ReadMe.md +++ b/ReadMe.md @@ -1,7 +1,7 @@ # Jelly Music - +[![demo](https://img.shields.io/discord/1454115231814127626?logo=discord)](https://discord.gg/CacbSg2Mza) ## What is Jelly Music? @@ -222,7 +222,7 @@ Once your Alexa Skill is created, you need to configure it. This might seem daun -3. Grab the preconfigured Alexa Skill JSON file from here: [https://github.com/Rusketh/JellyMusic/blob/main/src/skill/skill.json](https://github.com/Rusketh/JellyMusic/blob/main/src/skill/skill.json) +3. Grab the a preconfigured Alexa Skill JSON file in your language from here: [https://github.com/Rusketh/JellyMusic/blob/main/skill-json](https://github.com/Rusketh/JellyMusic/blob/main/src/skill/skill.json) diff --git a/src/skill/skill.json b/skill-json/skill-EN.json similarity index 100% rename from src/skill/skill.json rename to skill-json/skill-EN.json From 66ff2071a78df6024ac3dd1d9b4be44586dc6114 Mon Sep 17 00:00:00 2001 From: rusketh Date: Fri, 26 Dec 2025 14:33:43 +0000 Subject: [PATCH 06/10] Added Discord badge to the read me. Moved the skill.json to a new location ready for language support. --- ReadMe.md | 4 ++-- src/skill/skill.json => skill-json/skill-EN.json | 0 2 files changed, 2 insertions(+), 2 deletions(-) rename src/skill/skill.json => skill-json/skill-EN.json (100%) diff --git a/ReadMe.md b/ReadMe.md index 3017faa..7021550 100644 --- a/ReadMe.md +++ b/ReadMe.md @@ -1,7 +1,7 @@ # Jelly Music - +[![demo](https://img.shields.io/discord/1454115231814127626?logo=discord)](https://discord.gg/CacbSg2Mza) ## What is Jelly Music? @@ -222,7 +222,7 @@ Once your Alexa Skill is created, you need to configure it. This might seem daun -3. Grab the preconfigured Alexa Skill JSON file from here: [https://github.com/Rusketh/JellyMusic/blob/main/src/skill/skill.json](https://github.com/Rusketh/JellyMusic/blob/main/src/skill/skill.json) +3. Grab the a preconfigured Alexa Skill JSON file in your language from here: [https://github.com/Rusketh/JellyMusic/blob/main/skill-json](https://github.com/Rusketh/JellyMusic/blob/main/skill-json) diff --git a/src/skill/skill.json b/skill-json/skill-EN.json similarity index 100% rename from src/skill/skill.json rename to skill-json/skill-EN.json From 287f9e0d386eecd11b2c47dda04beb05d6336ef4 Mon Sep 17 00:00:00 2001 From: rusketh Date: Mon, 29 Dec 2025 14:20:28 +0000 Subject: [PATCH 07/10] Updated readme with new instructions. Added multiple language support, more languages wanted (PRs please). Added better logging and redid logging messages. Added APL player for echo devices with screens (WIP). Added transcoding to MP3 support by changing the API endpoint for music streams (Thank you @Daniel-Boluda). Improved search for genres & playlists using fuzzy search. Added local API access for better security. --- ReadMe.md | 153 +++++++++----- skill-json/skill-ES.json | 384 ++++++++++++++++++++++++++++++++++ src/apl/audio-player.json | 356 +++++++++++++++++++++++++++++++ src/init.js | 42 ++-- src/jellyfin-api.js | 107 +++++----- src/language.js | 52 +++++ src/languages/EN.json | 61 ++++++ src/languages/NL.json | 62 ++++++ src/logger.js | 59 ++++++ src/package-lock.json | 112 +++++++--- src/package.json | 18 +- src/players/audio-player.js | 60 ++++++ src/players/player.js | 50 +++++ src/players/visual-player.js | 138 ++++++++++++ src/playlist/devices.js | 151 ++++++++----- src/playlist/item.js | 50 +---- src/playlist/playlist.js | 29 ++- src/playlist/save-file.js | 6 +- src/server.js | 2 +- src/skill/album-intents.js | 118 ++++++++--- src/skill/alexa-helper.js | 90 ++++++-- src/skill/apl-handlers.js | 44 ++++ src/skill/artist-intents.js | 56 +++-- src/skill/control-handlers.js | 12 +- src/skill/genre-intent.js | 63 ++++-- src/skill/playlist-intents.js | 50 +++-- src/skill/query-intents.js | 8 +- src/skill/skill.js | 19 +- src/skill/song-intents.js | 92 ++++++-- 29 files changed, 2045 insertions(+), 399 deletions(-) create mode 100644 skill-json/skill-ES.json create mode 100644 src/apl/audio-player.json create mode 100644 src/language.js create mode 100644 src/languages/EN.json create mode 100644 src/languages/NL.json create mode 100644 src/logger.js create mode 100644 src/players/audio-player.js create mode 100644 src/players/player.js create mode 100644 src/players/visual-player.js create mode 100644 src/skill/apl-handlers.js diff --git a/ReadMe.md b/ReadMe.md index 960cf32..1e01bbc 100644 --- a/ReadMe.md +++ b/ReadMe.md @@ -22,10 +22,14 @@ Jelly Music is a **self-hosted Alexa Skill** designed for your Jellyfin server. --- # IMPORTANT CHANGE: - For additonal security, your container now requires a new enviroment value **Skill_ID** (see: Installation Instructions for more information). + For additional security, your container now requires a new environment value **SKILL_ID** (see: Installation Instructions for more information). **If you do not set this your skill will no longer function after updating.** + Jellyfin music now requires the **"Alexa Presentation Language" Interface** (see: Installation Instructions for more information). + + **If you do not enable this your skill will no longer function after updating on echo devices.** + ## Features @@ -46,75 +50,71 @@ Jelly Music is a **self-hosted Alexa Skill** designed for your Jellyfin server. -## Alpha Software Disclaimer - - - -Please note that **Jelly Music** is a side project I'm developing in my spare time. +## Requirements -* There's **no guarantee** of ongoing maintenance or updates. +To run a Jelly Music instance, you will need: -* Not all features will be thoroughly tested upon release, so exercise **caution when updating**. +* A basic understanding of setting up **Docker containers**. -* This software comes with **no guarantees of functionality**. I am not an experienced programmer; I'm simply an enthusiast with a hobby. +* The ability to log into the **Alexa Developer Dashboard** (creating an account is straightforward). ---- +* A self-hosted Jellyfin server accessible via a **public address** and secured with a **trusted SSL certificate**. -## Requirements +* A Docker container capable of running the Jelly Music server, also accessible via a **public address** and secured with a **trusted SSL certificate**. -To run a Jelly Music instance, you will need: +* **Recommendation:** Set up an **NGINX reverse proxy server** and **Dynamic DNS (DDNS)** to achieve the public accessibility and trusted certificates. +--- -* A basic understanding of setting up **Docker containers**. +## Alpha Software Disclaimer -* The ability to log into the **Alexa Developer Dashboard** (creating an account is straightforward). +Please note that **Jelly Music** is a side project I'm developing in my spare time. -* A self-hosted Jellyfin server accessible via a **public address** and secured with a **trusted SSL certificate**. +* There's **no guarantee** of ongoing maintenance or updates. -* A Docker container capable of running the Jelly Music server, also accessible via a **public address** and secured with a **trusted SSL certificate**. +* Not all features will be thoroughly tested upon release, so exercise **caution when updating**. -* **Recommendation:** Set up an **NGINX reverse proxy server** and **Dynamic DNS (DDNS)** to achieve the public accessibility and trusted certificates. +* This software comes with **no guarantees of functionality**. I am not an experienced programmer; I'm simply an enthusiast with a hobby. --- - - ## Installation Instructions 1. Set up the Docker container using the latest [**Docker image**](https://ghcr.io/rusketh/jellymusic/jellymusic). + *For stability use the main branch ``ghcr.io/rusketh/jellymusic/jellymusic:main``* 2. Ensure you **enable the necessary port** in your Docker configuration. -3. Configure the **environment variables** as decribed bellow. +3. Configure the **environment variables** as described below. @@ -126,11 +126,11 @@ Here's an example of a `docker run` command: -``docker run -e JELLYFIN_HOST="https://jellyfin.example.com" -e JELLYFIN_KEY="replace_with_your_api_key" -e SKILL_NAME="jelly music" -e PORT="60648" -p 60648:60648 -v "data:/data" ghcr.io/rusketh/jellymusic/jellymusic:main`` +``docker run -e JELLYFIN_HOST="https://Jellyfin.example.com" -e JELLYFIN_KEY="replace_with_your_api_key" -e SKILL_NAME="jelly music" -e PORT="60648" -e SKILL_ID="amzn1.ask.skill.00000000-0000-0000-0000-000000000000" -p 60648:60648 -v "data:/data" ghcr.io/rusketh/jellymusic/jellymusic:main`` -### Enviroment Variables +### Environment Variables @@ -140,6 +140,8 @@ The following environment variables are **required**: * **`JELLYFIN_HOST`**: The public address of your Jellyfin server. + +* **`JELLYFIN_LOCAL`**: **Optional** The full local address and port of your Jellyfin server, used when making API calls. * **`JELLYFIN_KEY`**: The API key for your Jellyfin server. @@ -154,19 +156,22 @@ The following environment variables are **required**: -* **`PORT`**: The Port your server will use (remember to enable this port on Docker). +* **`PORT`**: The Port your server will use (remember to enable this port on Docker). *Defaults to 60648*. +* **`LANGUAGE`**: Sets the language Alexa will respond in; *Defaults to EN*. + +* **`JELLYFIN_LIMIT`**: The maximum items requested from the Jellyfin API per request. *Defaults to 50*. + **Note:** Your public address for the server needs to run on port `443`, and your reverse proxy should handle the redirect to the specified `PORT`. - +*Alexa will only communicate with HTTPS endpoints on port 443.* ### Data Directory - +The **configuration file** for Jelly Music, as well as the cached song queue for each connected device, are saved within the `/data` directory. -The **configuration file** for Jelly Music, as well as the **SQLite database** used to store the song queue for each connected device, are saved within the `/data` directory. @@ -218,7 +223,7 @@ Once your Alexa Skill is created, you need to configure it. This might seem daun -2. Navigate to **"Interfaces"** and **enable "Audio Player"**. This is crucial for audio streaming, and the skill will not function without it. +2. Navigate to **"Interfaces"** and **enable "Audio Player"** and **enable "Alexa Presentation Language"**. This is crucial for audio streaming & diplaying information on echo devices. The skill will not function without these interfaces enabled. 3. Grab the a preconfigured Alexa Skill JSON file in your language from here: [https://github.com/Rusketh/JellyMusic/blob/main/skill-json](https://github.com/Rusketh/JellyMusic/blob/main/skill-json) @@ -231,7 +236,8 @@ Once your Alexa Skill is created, you need to configure it. This might seem daun -6. Set the **SKILL_ID** enviroment value of your docker container to match your skills full id. You can find this developer console where your skills are listed ``You can find this on the developer portal. Click the Back button to go to your list of skills, then click Copy Skill ID.`` +6. Set the **SKILL_ID** environment value of your docker container to match your skills full id. +*You can find this on the developer portal. Click the Back button to go to your list of skills, then click Copy Skill ID.* That's it! Click **"Save"** at the top, then hit **"Build Skill"**. Hopefully, after a few minutes, it will build successfully, and you'll be ready to go and install the skill. Start the container and enjoy listening to your music! @@ -280,6 +286,19 @@ Once your Docker container is running and your Alexa Skill is successfully built Your Jelly Music skill should now be active and ready for use on your Alexa devices! +--- + +## Languages + +This skill supports multiple languages using the **LANGUAGE** environment value. + +Currently supported languages are: + +- **EN** - English +- **NL** - Dutch + +--- + ## Commands You can use the following commands with Alexa: @@ -300,7 +319,28 @@ You can use the following commands with Alexa: As well as using simple commands like stop, play, pause, resume and next. - +--- + + +## Network Architecture + +Both the Jellyfin server and the Jelly Music container must be available via HTTPS (port 443). This is achieved by using a reverse proxy server such as NGINX with a valid SSL certificate (e.g Lets Encrypt). + +```mermaid +flowchart TB + A[("Alexa")] --(HTTPS: 443)--> B + B[Reverse Proxy] + B -- (HTTP: 8096) --> C + B -- (HTTP: 60648) --> D + C[Jellyfin] + D[Jelly Music] + D -- (HTTP: 8096) --> C +``` +Alexa will send commands via the reverse proxy to the Jelly Music container. + +Alexa will stream audio files from the Jellyfin server via the reverse proxy. + +Jelly Music needs access to the Jellyfin server and it is not recomended for Jelly Music to call the Jellyfin API requests though the internet via the reverse proxy. For this reason it is best practice to set the local address of the jellyfin server so that API requests are transmitted over the local network, this improves both latency and security. --- @@ -308,44 +348,63 @@ As well as using simple commands like stop, play, pause, resume and next. -Here is a list of all the current none standard intents used, the example commands and what they do. +Here is a list of all the current none-standard intents used, the example commands and what they do. | Intent | Command | Description | - |--|--|--| - | PlaySongIntent | Play song {song} by {artist} | Plays {song} ``(clears queue)``. | - | PlayAlbumIntent | Play the album {album} by {artist} | Plays {album} ``(clears queue)``. | - | PlayArtistIntent | Play songs by {artist} | Plays songs by {artist} ``(clears queue)``. | - | PlayPlaylistIntent | Play playlist {playlist} | Plays songs from {playlist} ``(clears queue)``. | - | PlayGenreIntent | Play {genre} music | Plays music of {genre} ``(clears queue)``. | - | ShuffleAlbumIntent | Shuffle the album {album} by {artist} | Plays & shuffles {album}``(clears queue)``. | - | ShuffleArtistIntent | Shuffle songs by {artist} | Plays & shuffles songs by {artist} ``(clears queue)``. - | ShufflePlaylistIntent | Shuffle playlist {playlist} | Plays & shuffles songs from {playlist} ``(clears queue)``. | - | ShuffleGenreIntent | Shuffle {genre} music | Plays & shuffles music of {genre} ``(clears queue)``. | - | QueueSongIntent | Add {song} to the queue | Adds {song} to queue | - | QueueAlbumIntent | Add the album {album} by {artist} to the queue | Adds {album} to the queue. | - | QueueArtistIntent | Add songs by {artistname} to the queue | Adds songs by {artistname} to the queue. | - | QueuePlaylistIntent | Add playlist {playlist} to the queue | Adds songs from {playlist} to the queue. | - | QueueGenreIntent | Queue {genre} music | Adds {genre} music to the queue. | - --- - + ## Help Developing As mentioned, this is a work in progress, and I'm not the most skilled programmer. I would be very grateful to anyone who contributes a **Pull Request (PR)** to this project. All I ask is that you try to adhere to my coding style and **test your work** before committing. + +***NO AI GENERATED CODE*** + +AI can be used to advise, review and debug but please do not make pull requests using AI generated code (no vibe coding). + +## Dockerless + +This project is designed for easy development outside of a docker container, by running it directly in `node.js`. + +- Git Pull the repository. +- Create the data folder `src/data`. +- Create a config file `src/data/config.json`. +- Run `npm install` from inside the `src` folder. +- Run `node init` from inside the `src` folder. + +Example config.json: +``` +{ + "Jellyfin": { + "host": "", + "key": "", + "local": "" + "limit": , + }, + "skill": { + "id": "", + "name": "" + }, + "server": { + "port": + }, + "log_level": 3, + "language": "" +} +``` diff --git a/skill-json/skill-ES.json b/skill-json/skill-ES.json new file mode 100644 index 0000000..43f1060 --- /dev/null +++ b/skill-json/skill-ES.json @@ -0,0 +1,384 @@ +{ + "interactionModel": { + "languageModel": { + "invocationName": "jelly music", + "intents": [ + { + "name": "AMAZON.CancelIntent", + "samples": [] + }, + { + "name": "AMAZON.HelpIntent", + "samples": [] + }, + { + "name": "AMAZON.StopIntent", + "samples": [] + }, + { + "name": "AMAZON.NavigateHomeIntent", + "samples": [] + }, + { + "name": "AMAZON.PauseIntent", + "samples": [] + }, + { + "name": "AMAZON.ResumeIntent", + "samples": [] + }, + { + "name": "AMAZON.NextIntent", + "samples": [] + }, + { + "name": "AMAZON.PreviousIntent", + "samples": [] + }, + { + "name": "PlayAlbumIntent", + "slots": [ + { + "name": "albumname", + "type": "AMAZON.MusicAlbum" + }, + { + "name": "artistname", + "type": "AMAZON.Musician" + } + ], + "samples": [ + "pon el álbum {albumname} de {artistname}", + "reproduce el álbum {albumname} de {artistname}", + "pon el álbum {albumname}", + "reproduce el álbum {albumname}", + "pon álbum {albumname} de {artistname}", + "reproduce álbum {albumname} de {artistname}", + "pon álbum {albumname}", + "reproduce álbum {albumname}" + ] + }, + { + "name": "PlayArtistIntent", + "slots": [ + { + "name": "artistname", + "type": "AMAZON.Musician" + } + ], + "samples": [ + "pon canciones de {artistname}", + "reproduce canciones de {artistname}", + "pon música de {artistname}", + "reproduce música de {artistname}", + "pon al artista {artistname}", + "reproduce al artista {artistname}", + "pon {artistname}", + "reproduce {artistname}" + ] + }, + { + "name": "PlaySongIntent", + "slots": [ + { + "name": "songname", + "type": "AMAZON.MusicRecording" + }, + { + "name": "artistname", + "type": "AMAZON.Musician" + } + ], + "samples": [ + "pon {songname} de {artistname}", + "reproduce {songname} de {artistname}", + "pon la canción {songname} de {artistname}", + "reproduce la canción {songname} de {artistname}", + "pon {songname}", + "reproduce {songname}", + "pon la canción {songname}", + "reproduce la canción {songname}" + ] + }, + { + "name": "PlayPlaylistIntent", + "slots": [ + { + "name": "playlistname", + "type": "AMAZON.SearchQuery" + } + ], + "samples": [ + "pon la lista {playlistname}", + "reproduce la lista {playlistname}", + "pon lista {playlistname}", + "reproduce lista {playlistname}", + "pon la playlist {playlistname}", + "reproduce la playlist {playlistname}" + ] + }, + { + "name": "ShuffleAlbumIntent", + "slots": [ + { + "name": "albumname", + "type": "AMAZON.MusicAlbum" + }, + { + "name": "artistname", + "type": "AMAZON.Musician" + } + ], + "samples": [ + "pon el álbum {albumname} en aleatorio", + "pon el álbum {albumname} de {artistname} en aleatorio", + "reproduce el álbum {albumname} en aleatorio", + "reproduce el álbum {albumname} de {artistname} en aleatorio", + "aleatorio el álbum {albumname}", + "aleatorio el álbum {albumname} de {artistname}", + "mezcla el álbum {albumname}", + "mezcla el álbum {albumname} de {artistname}" + ] + }, + { + "name": "QueueAlbumIntent", + "slots": [ + { + "name": "albumname", + "type": "AMAZON.MusicAlbum" + }, + { + "name": "artistname", + "type": "AMAZON.Musician" + } + ], + "samples": [ + "añade el álbum {albumname} de {artistname} a la cola", + "añade el álbum {albumname} a la cola", + "pon en cola el álbum {albumname} de {artistname}", + "pon en cola el álbum {albumname}", + "cola el álbum {albumname} de {artistname}", + "cola el álbum {albumname}" + ] + }, + { + "name": "ShuffleArtistIntent", + "slots": [ + { + "name": "artistname", + "type": "AMAZON.Musician" + } + ], + "samples": [ + "pon {artistname} en aleatorio", + "pon canciones de {artistname} en aleatorio", + "reproduce {artistname} en aleatorio", + "reproduce canciones de {artistname} en aleatorio", + "mezcla canciones de {artistname}", + "aleatorio canciones de {artistname}" + ] + }, + { + "name": "ShufflePlaylistIntent", + "slots": [ + { + "name": "playlistname", + "type": "AMAZON.SearchQuery" + } + ], + "samples": [ + "pon la lista {playlistname} en aleatorio", + "reproduce la lista {playlistname} en aleatorio", + "mezcla la lista {playlistname}", + "aleatorio la lista {playlistname}", + "mezcla playlist {playlistname}" + ] + }, + { + "name": "QueuePlaylistIntent", + "slots": [ + { + "name": "playlistname", + "type": "AMAZON.SearchQuery" + } + ], + "samples": [ + "añade la lista {playlistname} a la cola", + "añade lista {playlistname} a la cola", + "pon en cola la lista {playlistname}", + "cola la lista {playlistname}", + "pon en cola playlist {playlistname}" + ] + }, + { + "name": "QueueSongIntent", + "slots": [ + { + "name": "songname", + "type": "AMAZON.MusicRecording" + }, + { + "name": "artistname", + "type": "AMAZON.Musician" + } + ], + "samples": [ + "añade {songname} a la cola", + "añade la canción {songname} a la cola", + "añade {songname} de {artistname} a la cola", + "añade la canción {songname} de {artistname} a la cola", + "pon en cola {songname}", + "pon en cola la canción {songname}", + "pon en cola {songname} de {artistname}", + "pon en cola la canción {songname} de {artistname}" + ] + }, + { + "name": "ClearQueueIntent", + "slots": [], + "samples": [ + "limpia la cola", + "vacía la cola", + "borra la cola", + "quita todo de la cola" + ] + }, + { + "name": "PlayGenreIntent", + "slots": [ + { + "name": "genre", + "type": "AMAZON.Genre" + } + ], + "samples": [ + "pon música {genre}", + "reproduce música {genre}", + "pon género {genre}", + "reproduce género {genre}", + "pon {genre}", + "reproduce {genre}" + ] + }, + { + "name": "QueueGenreIntent", + "slots": [ + { + "name": "genre", + "type": "AMAZON.Genre" + } + ], + "samples": [ + "añade música {genre} a la cola", + "pon en cola música {genre}", + "cola música {genre}", + "añade género {genre} a la cola", + "pon en cola género {genre}" + ] + }, + { + "name": "ShuffleGenreIntent", + "slots": [ + { + "name": "genre", + "type": "AMAZON.Genre" + } + ], + "samples": [ + "pon música {genre} en aleatorio", + "reproduce música {genre} en aleatorio", + "mezcla música {genre}", + "aleatorio música {genre}", + "mezcla género {genre}", + "aleatorio género {genre}" + ] + }, + { + "name": "WhatThisIntent", + "slots": [], + "samples": [ + "qué está sonando", + "qué está sonando ahora", + "qué es esto", + "qué canción es esta", + "cómo se llama esta canción", + "dime el nombre de esta canción" + ] + }, + { + "name": "QueueArtistIntent", + "slots": [ + { + "name": "artistname", + "type": "AMAZON.Musician" + } + ], + "samples": [ + "añade canciones de {artistname} a la cola", + "pon en cola canciones de {artistname}", + "cola canciones de {artistname}", + "añade {artistname} a la cola", + "pon en cola {artistname}" + ] + }, + + { + "name": "PlayMovieIntent", + "slots": [ + { "name": "moviename", "type": "AMAZON.SearchQuery" }, + { "name": "devicename", "type": "PLAYBACK_DEVICE" } + ], + "samples": [ + "reproducir la película {moviename} en {devicename}", + "ver {moviename} en {devicename}", + "reproducir la película {moviename}" + ] + }, + { + "name": "PlaySeriesIntent", + "slots": [ + { "name": "seriesname", "type": "AMAZON.SearchQuery" }, + { "name": "season", "type": "AMAZON.NUMBER" }, + { "name": "episode", "type": "AMAZON.NUMBER" }, + { "name": "devicename", "type": "PLAYBACK_DEVICE" } + ], + "samples": [ + "reproducir la serie {seriesname} en {devicename}", + "reproducir {seriesname} en {devicename}", + "reproducir temporada {season} episodio {episode} de {seriesname} en {devicename}", + "reproducir temporada {season} de {seriesname} en {devicename}", + "reproducir la serie {seriesname}" + ] + }, + { + "name": "ListPlaybackDevicesIntent", + "slots": [], + "samples": [ + "lista de dispositivos", + "qué dispositivos hay", + "qué dispositivos están disponibles" + ] + } + ], + "types": [ + { + "name": "PLAYBACK_DEVICE", + "values": [ + { + "name": { + "value": "Fire TV Salon", + "synonyms": ["tele del salón", "tv del salón", "salón"] + } + }, + { + "name": { + "value": "Samsung TV Dormitorio", + "synonyms": ["tele del dormitorio", "tv del cuarto", "dormitorio"] + } + } + ] + } + ] + } + } +} diff --git a/src/apl/audio-player.json b/src/apl/audio-player.json new file mode 100644 index 0000000..9709f94 --- /dev/null +++ b/src/apl/audio-player.json @@ -0,0 +1,356 @@ +{ + "type": "APL", + "version": "2024.3", + "license": "Copyright 2024 Amazon.com, Inc. or its affiliates. All Rights Reserved.\nSPDX-License-Identifier: LicenseRef-.amazon.com.-AmznSL-1.0\nLicensed under the Amazon Software License http://aws.amazon.com/asl/", + "theme": "dark", + "import": [ + { + "name": "alexa-layouts", + "version": "1.7.0" + } + ], + "layouts": { + "AudioPlayer": { + "parameters": [ + { + "name": "audioControlType", + "description": "The type of audio control to use. Default is skip (foward and backwards). Other options are skip | jump10 | jump30 | none.", + "type": "string", + "default": "skip" + }, + { + "name": "audioSources", + "description": "Audio single source or an array of sources. Audios will be in a playlist if multiple sources are provided.", + "type": "any" + }, + { + "name": "backgroundImageSource", + "description": "URL for the background image source.", + "type": "string" + }, + { + "name": "coverImageSource", + "description": "URL for the cover image source. If not provided, text content will be left aligned.", + "type": "string" + }, + { + "name": "primaryText", + "description": "Primary text for the media.", + "type": "string" + }, + { + "name": "secondaryText", + "description": "Secondary text for the media.", + "type": "string" + } + ], + "item": [ + { + "type": "Container", + "bind": [ + { + "name": "sliderThumbPosition", + "type": "number", + "value": 0 + }, + { + "name": "sliderActive", + "type": "boolean", + "value": false + }, + { + "name": "videoProgressValue", + "type": "number", + "value": 0 + }, + { + "name": "videoTotalValue", + "type": "number", + "value": 0 + } + ], + "height": "100%", + "width": "100%", + "items": [ + { + "type": "AlexaBackground", + "id": "AlexaBackground", + "description": "Main backgroud", + "backgroundImageSource": "${backgroundImageSource}" + }, + { + "position": "absolute", + "alignItems": "center", + "justifyContent": "center", + "type": "Container", + "height": "100%", + "width": "100%", + "top": "@mainViewTopSpacing", + "items": [ + { + "direction": "column", + "items": [ + { + "items": [ + { + "justifyContent": "center", + "alignItems": "center", + "items": [ + { + "type": "AlexaImage", + "imageSource": "${coverImageSource}", + "imageScale": "best-fill", + "imageAspectRatio": "square", + "imageShadow": true, + "imageRoundedCorner": true + } + ], + "numbering": "skip", + "position": "absolute", + "type": "Container", + "height": "100%", + "width": "100%" + }, + { + "direction": "column", + "shrink": 1, + "justifyContent": "end", + "alignItems": "center", + "items": [ + { + "text": "${primaryText}", + "textAlign": "center", + "maxLines": 1, + "fontSize": "40dp", + "type": "Text", + "id": "Audio_PrimaryText", + "accessibilityLabel": "${primaryText}", + "style": "textStyleDisplay4", + "width": "100%" + }, + { + "text": "${secondaryText}", + "textAlign": "center", + "maxLines": 1, + "fontSize": "30dp", + "type": "Text", + "id": "Audio_SecondaryText", + "accessibilityLabel": "${secondaryText}", + "style": "textStyleBody", + "width": "100%" + } + ], + "numbering": "normal", + "description": "Track Info", + "type": "Container", + "height": "100%", + "width": "100%" + } + ], + "numbered": true, + "position": "relative", + "description": "Cover image", + "type": "Container", + "id": "Audio_CoverImage", + "height": "100%", + "width": "100%" + } + ], + "description": "Cover image and track info", + "type": "Container", + "height": "50%", + "width": "100%" + }, + { + "items": [ + { + "scale": "best-fill", + "autoplay": true, + "audioTrack": "background", + "onPause": [ + { + "type": "SendEvent", + "arguments": [ + "onPlaybackPaused", + "${event.duration}" + ] + } + ], + "onPlay": [ + { + "type": "SetValue", + "property": "videoTotalValue", + "value": "${event.duration}" + }, + { + "type": "SendEvent", + "arguments": [ + "onPlaybackStarted" + ] + } + ], + "onEnd": [ + { + "type": "SendEvent", + "arguments": [ "onPlaybackFinished" ] + } + ], + "onTrackUpdate": [ + { + "type": "SetValue", + "property": "videoTotalValue", + "value": "${event.duration}" + } + ], + "onTimeUpdate": [ + { + "type": "SetValue", + "property": "videoProgressValue", + "value": "${event.currentTime}" + }, + { + "type": "SetValue", + "componentId": "slider", + "property": "progressValue", + "value": "${videoProgressValue}" + }, + { + "type": "SetValue", + "property": "videoTotalValue", + "value": "${event.duration}" + } + ], + "onTrackReady": [ + { + "type": "SetValue", + "property": "videoTotalValue", + "value": "${event.duration}" + } + ], + "onTrackFail": [ + { + "type": "SetValue", + "property": "videoTotalValue", + "value": "0" + }, + { + "type": "SendEvent", + "arguments": [ + "onPlaybackFailed" + ] + } + ], + "sources": "${audioSources}", + "description": "A hidden Video component to play the audio.", + "type": "Video", + "id": "videoPlayer", + "height": 1, + "width": 1, + "position": "absolute" + }, + { + "description": "Determinate Slider Container", + "type": "Container", + "width": "100%", + "paddingTop": "@sliderPaddingTop", + "items": [ + { + "type": "Container", + "width": "100%", + "alignItems": "center", + "item": [ + { + "type": "AlexaSlider", + "id": "slider", + "progressValue": "${videoProgressValue}", + "totalValue": "${videoTotalValue}", + "positionPropertyName": "sliderThumbPosition", + "metadataDisplayed": true, + "metadataPosition": "above_right", + "width": "100%", + "theme": "${viewport.theme}", + "onUpCommand": [ + { + "type": "ControlMedia", + "componentId": "videoPlayer", + "command": "seek", + "value": "${sliderThumbPosition - videoProgressValue}" + } + ], + "onMoveCommand": [ + { + "type": "SetValue", + "property": "sliderActive", + "value": true + } + ], + "onDownCommand": [ + { + "type": "SetValue", + "property": "sliderActive", + "value": true + } + ] + }, + { + "type": "AlexaTransportControls", + "mediaComponentId": "videoPlayer", + "playPauseToggleButtonId": "playPauseToggleButtonId", + "primaryControlSize": "@primaryControlSize", + "secondaryControls": "${audioControlType}", + "secondaryControlSize": "@secondaryControlSize", + "autoplay": true, + "theme": "${viewport.theme}", + "secondaryControlsLeftAction": [ + { + "type": "SendEvent", + "arguments": [ + "onPlayPrevious" + ] + } + ], + "secondaryControlsRightAction": [ + { + "type": "SendEvent", + "arguments": [ + "onPlayNext" + ] + } + ], + "secondaryControlsAVGLeft": "M6 6H8V18H6V6ZM9.5 12L19 18V6L9.5 12Z", + "secondaryControlsAVGRight": "M5 6L14.5 12L5 18V6ZM16 6H18V18H16V6Z" + } + ] + } + ] + } + ], + "type": "Container", + "description": "Slider and controls.", + "id": "Audio_AudioInfo", + "height": "50%", + "width": "80%" + } + ] + } + ] + } + ] + } + }, + "mainTemplate": { + "parameters": [ + "payload" + ], + "items": [ + { + "type": "AudioPlayer", + "audioSources": "${payload.audioPlayerData.properties.audioSources}", + "backgroundImageSource": "${payload.audioPlayerData.properties.backgroundImage}", + "coverImageSource": "${payload.audioPlayerData.properties.coverImageSource}", + "primaryText": "${payload.audioPlayerData.properties.primaryText}", + "secondaryText": "${payload.audioPlayerData.properties.secondaryText}" + } + ] + } +} \ No newline at end of file diff --git a/src/init.js b/src/init.js index 9464066..f5709a1 100644 --- a/src/init.js +++ b/src/init.js @@ -25,11 +25,10 @@ CONFIG = { }, server: { - port: "4141" + port: "60648" } }; - /********************************************************************************* * */ @@ -69,6 +68,26 @@ catch(error) throw error; } +/********************************************************************************* + * Load Logging Module + */ + +Logger = require("./logger.js"); + +CONFIG.log_level = process.env.LOG_LEVEL || CONFIG.log_level || LOGLEVEL_WARN; + +assert(CONFIG.log_level >= LOGLEVEL_ERROR && CONFIG.log_level <= LOGLEVEL_DEBUG, `Invalid log level (must be set between ${LOGLEVEL_ERROR} and ${LOGLEVEL_DEBUG}); This can set in CONFIG under "jellyfin.log_level" or as enviroment value LOG_LEVEL.`) + +Logger.Info(`Logging level set to: ${CONFIG.log_level}`); + +/********************************************************************************* + * Language Setting + */ + +CONFIG.language = process.env.LANGUAGE || CONFIG.language || "EN"; + +LANGUAGE = require("./language.js"); + /********************************************************************************* * Validate JellyFin CONFIG */ @@ -77,11 +96,14 @@ CONFIG.jellyfin.host = process.env.JELLYFIN_HOST || CONFIG.jellyfin.host; assert(CONFIG.jellyfin.host, `No Jellyfin host address defined.\nThis can set in CONFIG under "jellyfin.host" or as enviroment value JELLYFIN_HOST.`); +//Used to query the local server over the network and not the internet. +CONFIG.jellyfin.local = process.env.JELLYFIN_LOCAL || CONFIG.jellyfin.local || CONFIG.jellyfin.host; + CONFIG.jellyfin.key = process.env.JELLYFIN_KEY || CONFIG.jellyfin.key; assert(CONFIG.jellyfin.key, `No Jellyfin api key defined.\nThis can set in CONFIG under "jellyfin.key" or as enviroment value JELLYFIN_KEY.`); -CONFIG.jellyfin.limit = process.env.JELLYFIN_LIMIT || CONFIG.jellyfin.limit || 10; +CONFIG.jellyfin.limit = process.env.JELLYFIN_LIMIT || CONFIG.jellyfin.limit || 50; /********************************************************************************* * Validate Skill CONFIG @@ -91,7 +113,7 @@ CONFIG.skill.name = process.env.SKILL_NAME || CONFIG.skill.name; assert(CONFIG.skill.name, `No skill name defined.\nThis can set in CONFIG under "skill.name" or as enviroment value SKILL_NAME.`); -//TODO: Skill name needs to be 2 words. +assert(CONFIG.skill.name.match(/^([a-zA-Z]+) ([a-zA-Z]+)$/), `Skill name must be two words.\nThis can set in CONFIG under "skill.name" or as enviroment value SKILL_NAME.`); CONFIG.skill.id = process.env.SKILL_ID || CONFIG.skill.id; @@ -105,24 +127,18 @@ CONFIG.server.port = process.env.PORT || CONFIG.server.port; assert(CONFIG.server.port, `No port defined.\nThis can set in CONFIG under "server.port" or as enviroment value PORT.`); -/********************************************************************************* - * Confirm CONFIG - */ - -//TODO: Validate Jellyfin API key - /********************************************************************************* * Save the CONFIG */ try { - console.log("Saving CONFIG File"); + Logger.Info("[Configuration]", "Saving."); fs.writeFileSync(CONFIG_FILE, JSON.stringify(CONFIG, undefined, 2)); } catch(error) { - console.error("Warning: Error saving CONFIG.json server will not start."); + Logger.Warn("[CRITICAL]", "Error saving CONFIG.json server will not start."); throw error; } @@ -137,7 +153,7 @@ Load().then( setInterval(Save, 60000); - console.log("Loading Server...."); + Logger.Info("[HTTP SERVER]", "Loading"); require("./server.js"); diff --git a/src/jellyfin-api.js b/src/jellyfin-api.js index b32a683..083a99a 100644 --- a/src/jellyfin-api.js +++ b/src/jellyfin-api.js @@ -1,3 +1,5 @@ +const fuzzball = require('fuzzball'); + /********************************************************************************* * Request Items from API * https://api.jellyfin.org/ @@ -5,7 +7,7 @@ const Request = async function(endpoint, params, ...args) { - const url = new URL(endpoint, CONFIG.jellyfin.host); + const url = new URL(endpoint, CONFIG.jellyfin.local); if (params) { @@ -15,7 +17,9 @@ const Request = async function(endpoint, params, ...args) { const value = params[key]; - if (value instanceof Array) + if (value == null) + continue; + else if (value instanceof Array) value.forEach(item => url.searchParams.append(key, String(item))); else url.searchParams.append(key, String(value)); @@ -36,35 +40,19 @@ const Request = async function(endpoint, params, ...args) ); if (!response.ok) + { + Logger.Error("[JellyFin API]", `No Response from Server (path: ${url.pathname}).`); + + //TODO: Trace Error Message? + return {status: false, error: await response.text()}; + } var result = await response.json(); return {status: true, items: result.Items, index: result.StartIndex, count: result.TotalRecordCount }; }; -/********************************************************************************* - * Paged Items. - * Returns the first page and process a function for all pages. - */ - -/*const PagedItems = async function(callback, api, limit, ...args) -{ - const result = await api(...args, {limit}) - - if (!result.status) return result; - - if (callback) - { - callback(res); - - for (let i = result.index; i < result.count; i += limit) - api(...args, {limit, startIndex: i}).then(callback); - }; - - return result; -};*/ - /********************************************************************************* * Request Artists */ @@ -82,11 +70,14 @@ Artists.AlbumArtists = async (...params) => await Request("/Artists/AlbumArtists const Items = async (...params) => await Request("/Items", { Recursive:true }, ...params); Items.Artists = Artists; + Items.Music = async (...params) => await Items({includeItemTypes: "Audio"}, ...params); + Items.Albums = async (...params) => await Items({includeItemTypes: "MusicAlbum"}, ...params); -Items.MusicGenres = async (...params) => await Items({includeItemTypes: "MusicGenre"}, ...params); -Items.Playlists = async (...params) => await Items({includeItemTypes: "Playlist"}, ...params); +Items.MusicGenres = async (...params) => await Request("/MusicGenres", ...params); + +Items.Playlists = async (...params) => await Items({includeItemTypes: "Playlist"}, ...params); /********************************************************************************* * Search Function @@ -122,18 +113,49 @@ const Search = async function(api, field, query, ...perams) }; Items.Music.Search = async (query, ...params) => await Search(Items.Music, "Name", query, ...params); + Items.Albums.Search = async (query, ...params) => await Search(Items.Albums, "Name", query, ...params); + Items.Artists.Search = async (query, ...params) => await Search(Items.Artists, "Name", query, ...params); + Items.MusicGenres.Search = async (query, ...params) => await Search(Items.MusicGenres, "Name", query, ...params); + Items.Playlists.Search = async (query, ...params) => await Search(Items.Playlists, "Name", query, ...params); +/********************************************************************************* + * Fuzzy Search Function: Warning this is SLOW! + */ + +const FuzzySearch = async function(api, field, query, ...perams) +{ + const result = await api(perams); + + if (!result.status) return result; + + result.items.forEach(item => item.score = fuzzball.token_sort_ratio(query, item[field])); + result.items = result.items.filter((item) => item.score > 60); + result.items.sort((a, b) => b.score - a.score); + + return result; +}; + +Items.Music.FuzzySearch = async (query, ...params) => await FuzzySearch(Items.Music, "Name", query, ...params); + +Items.Albums.FuzzySearch = async (query, ...params) => await FuzzySearch(Items.Albums, "Name", query, ...params); + +Items.Artists.FuzzySearch = async (query, ...params) => await FuzzySearch(Items.Artists, "Name", query, ...params); + +Items.MusicGenres.FuzzySearch = async (query, ...params) => await FuzzySearch(Items.MusicGenres, "Name", query, ...params); + +Items.Playlists.FuzzySearch = async (query, ...params) => await FuzzySearch(Items.Playlists, "Name", query, ...params); + /********************************************************************************* * Find Albums */ Items.Albums.ByGenre = async function(query, ...params) { - const result = await Items.MusicGenres.Search(query); + const result = await Items.MusicGenres.FuzzySearch(query); if (!result.status) return result; @@ -167,7 +189,7 @@ Items.Albums.ByArist = async function(query, ...params) Items.Artists.ByGenre = async function(query, ...params) { - const result = await Items.MusicGenres.Search(query); + const result = await Items.MusicGenres.FuzzySearch(query); if (!result.status) return result; @@ -186,7 +208,7 @@ Items.Artists.ByGenre = async function(query, ...params) Items.Music.ByGenre = async function(query, ...params) { - const result = await Items.MusicGenres.Search(query); + const result = await Items.MusicGenres.FuzzySearch(query); if (!result.status) return result; @@ -229,33 +251,6 @@ Items.Music.ByAlbum = async function(query, ...params) return result2; }; -/********************************************************************************* - * Find Songs by playlist - */ - -/*Items.Music.ByPlayList = async function(query, ...params) -{ - const result = await Items.Playlists.Search(query, {fields: "ItemIds"}); - - if (!result.status) return result; - - const items = { }; - - for(item of result.items) - { - const result2 = await Items({parentId: item.Id}, ...params); - - if (!result2.status) continue; - - result2.items.forEach(item => items[item.Id] = item); - } - - result.playlists = result.items; - - result.items = Object.values(items); - - return result; -};*/ /********************************************************************************* * Exports diff --git a/src/language.js b/src/language.js new file mode 100644 index 0000000..9957ac6 --- /dev/null +++ b/src/language.js @@ -0,0 +1,52 @@ +const assert = require("node:assert"); + +const fs = require("node:fs"); + +/****************************************************************************************** + * Validate Language File + */ + +const LANG_FILE = `./languages/${CONFIG.language}.json`; + +assert(fs.existsSync(LANG_FILE), `Invalid language selected (${CONFIG.language})\nThis can set in CONFIG under "language" or as enviroment value LANGUAGE.`) + +/****************************************************************************************** + * Load Language Files + */ + +const Language = require(LANG_FILE); + +if (CONFIG.language != "EN") + Object.apply(Language, require(`./languages/EN.json`)); + +/****************************************************************************************** + * Get Value + */ + +const Value = (key) => Language[key] || "Unkown response"; + +/****************************************************************************************** + * Parse + */ + +const Parse = function(key, values) +{ + var result = Value(key); + + result = result.replace("%skill_name%", CONFIG.skill.name); + + for (const key in values) + result = result.replace(`%${key}%`, values[key]); + + return result; +} + +/****************************************************************************************** + * Exports + */ + +module.exports = { + Language, + Value, + Parse +}; \ No newline at end of file diff --git a/src/languages/EN.json b/src/languages/EN.json new file mode 100644 index 0000000..6068c4b --- /dev/null +++ b/src/languages/EN.json @@ -0,0 +1,61 @@ +{ + "SONG_NO_NAME": "I didn't catch the song name.", + "SONG_NOTFOUND_BY_NAME": "I didn't find a song called %song_name%.", + "SONG_NOTFOUND_BY_NAME_AND_ARTIST": "I didn't find a song called %song_name% by %artist_name%.", + "SONG_NOTFOUND_SUGGEST": "I didn't find a track called %song_name% by %artist_name%, you might have meant %suggestion_name% by %suggestion_artist%.", + + "SONG_PLAYING": "Playing %song_name%, on %skill_name%.", + "SONG_PLAYING_BY_ARTIST": "Playing %song_name% by %artist_name%, on %skill_name%.", + "SONG_QUEUED": "Added %song_name% to the queue.", + "SONG_QUEUED_BY_ARTIST": "Added %song_name% by %artist_name% to the queue.", + + + + "ALBUM_NO_NAME": "I didn't catch the album name.", + "ALBUM_NOTFOUND_BY_NAME": "I didn't find an album called %album_name%", + "ALBUM_NOTFOUND_SUGGEST": "I didn't find an album called %album_name% by %artist_name%, you might have meant %suggestion_name% by %suggestion_artist%.", + "ALBUM_NOTFOUND_BY_NAME_AND_ARTIST": "I didn't find an album called %album_name% by %artist_name%.", + "ALBUM_NO_MUSIC": "I didn't find any music in the album %album_name%.", + + "ALBUM_PLAYING": "Playing album %album_name%, on %skill_name%.", + "ALBUM_PLAYING_BY_ARTIST": "Playing album %album_name% by %artist_name%, on %skill_name%.", + "ALBUM_SHUFFLE": "Shuffling album %album_name%, on %skill_name%.", + "ALBUM_SHUFFLE_BY_ARTIST": "Shuffling album %album_name% by %artist_name%, on %skill_name%.", + "ALBUM_QUEUED": "Added album %album_name% to the queue.", + "ALBUM_QUEUED_BY_ARTIST": "Added album %album_name% by %artist_name% to the queue.", + + + + "ARTIST_NO_NAME": "I didn't catch the song name.", + "ARTIST_NOTFOUND_BY_NAME": "I didn't find an artist called %artist_name%.", + "ARTIST_NO_MUSIC": "I didn't find any music by the artist %artist_name%.", + + "ARTIST_PLAYING": "Playing songs by artist %artist_name%, on %skill_name%.", + "ARTIST_SHUFFLE": "Shuffling songs by artist %artist_name%, on %skill_name%.", + "ARTIST_QUEUED": "Added %count% songs by artist %artist_name% to the queue.", + + + + "GENRE_NO_NAME": "I didn't catch the genre of music.", + "GENRE_NOTFOUND_BY_NAME": "I didn't find a genre called %genre_name%.", + "GENRE_NO_MUSIC": "No songs of %genre_name% where found.", + + "GENRE_PLAYING": "Playing %grene_name%, on %skill_name%.", + "GENRE_PLAYING_SIMULAR": "Playing %grene_name% and simular genres, on %skill_name%.", + "GENRE_SHUFFLE": "Shuffling %grene_name%, on %skill_name%.", + "GENRE_SHUFFLE_SIMULAR": "Shuffling %grene_name% and simular genres, on %skill_name%.", + "GENRE_QUEUED": "Added %count% %genre_name% songs to the queue.", + "GENRE_QUEUED_SIMULAR": "Added %count% %genre_name% and simular songs to the queue.", + + + + "PLAYLIST_NO_NAME": "I didn't catch the playlist name.", + "PLAYLIST_NOTFOUND_BY_NAME": "I didn't find a playlist called %playlist_name%.", + "PLAYLIST_NO_MUSIC": "I didn't find any music in the playlist %playlist_name%.", + + + "PLAYLIST_PLAYING": "Playing songs from playlist %playlist_name%, on %skill_name%.", + "PLAYLIST_SHUFFLE": "Shuffling songs from playlist %playlist_name%, on %skill_name%.", + "PLAYLIST_QUEUED": "Added %count% songs from playlist %playlist_name%, to the queue." + +} \ No newline at end of file diff --git a/src/languages/NL.json b/src/languages/NL.json new file mode 100644 index 0000000..a0324c7 --- /dev/null +++ b/src/languages/NL.json @@ -0,0 +1,62 @@ +{ + "SONG_NO_NAME": "Ik heb geen liedje met die titel gevonden.", + "SONG_NOTFOUND_BY_NAME": "Ik heb geen nummer gevonden met de naam %song_name%.", + "SONG_NOTFOUND_BY_NAME_AND_ARTIST": "Ik heb geen nummer gevonden met de titel %song_name% van %artist_name%.", + "SONG_NOTFOUND_SUGGEST": "Ik heb geen nummer gevonden met de titel %song_name% van %artist_name%, misschien bedoelde je %suggestion_name% van %suggestion_artist%.", + + "SONG_PLAYING": "Speeld %song_name% met %skill_name%.", + "SONG_PLAYING_BY_ARTIST": "speeld %song_name% van %artist_name% met %skill_name%.", + "SONG_QUEUED": "%song_name% Toegevoegd aan de wachtrij.", + "SONG_QUEUED_BY_ARTIST": "%song_name% van %artist_name% Toegevoegd aan de wachtrij.", + + + + "ALBUM_NO_NAME": "Ik verstond het album titel niet.", + "ALBUM_NOTFOUND_BY_NAME": "Ik heb geen album gevonden met de naam %album_name%", + "ALBUM_NOTFOUND_SUGGEST": "Ik heb geen album gevonden met de naam %album_name% van %artist_name%, misschien bedoelde je %suggestion_name% van %suggestion_artist%.", + "ALBUM_NOTFOUND_BY_NAME_AND_ARTIST": "Ik heb geen album gevonden met de naam %album_name% van %artist_name%.", + "ALBUM_NO_MUSIC": "ik heb geen muziek met het albumnaam %album_name% gevonden.", + + + "ALBUM_PLAYING": "speeld %album_name% met %skill_name%.", + "ALBUM_PLAYING_BY_ARTIST": "Speeld %album_name% van %artist_name% met %skill_name%.", + "ALBUM_SHUFFLE": "het album %album_name% wordt willekeurig afgespeeld met %skill_name%.", + "ALBUM_SHUFFLE_BY_ARTIST": "het album %album_name% van %artist_name% wordt willekeurig afgespeeld met %skill_name%.", + "ALBUM_QUEUED": "het album %album_name% is toegevoegt aan de wachtrij.", + "ALBUM_QUEUED_BY_ARTIST": "het album %album_name% van %artist_name% toegevoegt aan de wachtrij.", + + + + "ARTIST_NO_NAME": "Ik heb geen titel van het liedje gevonden.", + "ARTIST_NOTFOUND_BY_NAME": "Ik heb geen artiest gevonden die %artist_name% heet.", + "ARTIST_NO_MUSIC": "Ik heb geen muziek van de artiest %artist_name% kunnen vinden.", + + "ARTIST_PLAYING": "Speelt liedje van %artist_name% met %skill_name%.", + "ARTIST_SHUFFLE": "Willekeurig afspelen van %artist_name% met %skill_name%.", + "ARTIST_QUEUED": "%count% liedjes toegevoegt van artiest %artist_name% aan de wachtrij.", + + + + "GENRE_NO_NAME": "Ik heb het muziek genre niet goed verstaan.", + "GENRE_NOTFOUND_BY_NAME": "Ik heb geen genre gevonden met de naam %genre_name%.", + "GENRE_NO_MUSIC": "geen liedjes in het genre %genre_name% zijn gevonden.", + + "GENRE_PLAYING": "speelt %grene_name% met %skill_name%.", + "GENRE_PLAYING_SIMULAR": "Speeld %grene_name% en dezelfde soort genres met %skill_name%.", + "GENRE_SHUFFLE": "willekeurige volgorde van %grene_name% met %skill_name%.", + "GENRE_SHUFFLE_SIMULAR": "willekeurige volgorde %grene_name% en vergelijkbare genres met %skill_name%.", + "GENRE_QUEUED": "%count% %genre_name% toegevoegt aan de lijst met liedjes.", + "GENRE_QUEUED_SIMULAR": "%count% vergelijkbaar met %genre_name% toegevoegt aan de lijst met liedjes.", + + + + "PLAYLIST_NO_NAME": "Ik heb de naam van de afspeellijst niet verstaan..", + "PLAYLIST_NOTFOUND_BY_NAME": "Ik heb geen afspeellijst gevonden met %playlist_name%.", + "PLAYLIST_NO_MUSIC": "Ik heb geen muziek in de lijst %playlist_name% gevonden.", + + + "PLAYLIST_PLAYING": "Speelt liedjes van %playlist_name% met %skill_name%.", + "PLAYLIST_SHUFFLE": "willekeurig afspelen %playlist_name% met %skill_name%.", + "PLAYLIST_QUEUED": "%count% liedjes van speellijst %playlist_name% toegevoegt aan de wachtrij." + +} \ No newline at end of file diff --git a/src/logger.js b/src/logger.js new file mode 100644 index 0000000..a30c1d6 --- /dev/null +++ b/src/logger.js @@ -0,0 +1,59 @@ +/********************************************************************************* + * Global Log Levels + */ + +LOGLEVEL_ERROR = 0; +LOGLEVEL_WARN = 1; +LOGLEVEL_INFO = 2; +LOGLEVEL_DEBUG = 3; + +/********************************************************************************* + * Output Loggers + */ + +const Outputs = { + [LOGLEVEL_ERROR]: console.error, + [LOGLEVEL_WARN]: console.warn, + [LOGLEVEL_INFO]: console.info, + [LOGLEVEL_DEBUG]: console.debug +}; + +/********************************************************************************* + * + */ + +const Log = function(level = -1, ...args) +{ + //Check Logging Option. + if (level > CONFIG.log_level || level > LOGLEVEL_DEBUG) + return; + + //Log to correct output. + Outputs[level](...args); + + //Future option: Log out errors.txt +}; + +/********************************************************************************* + * Logger Wrappers for quicknes + */ + +const Error = (...args) => Log(LOGLEVEL_ERROR, ...args) + +const Warn = (...args) => Log(LOGLEVEL_WARN, ...args) + +const Info = (...args) => Log(LOGLEVEL_INFO, ...args) + +const Debug = (...args) => Log(LOGLEVEL_DEBUG, ...args) + +/********************************************************************************* + * Exports + */ + +module.exports = { + Log, + Error, + Warn, + Info, + Debug +} \ No newline at end of file diff --git a/src/package-lock.json b/src/package-lock.json index 89f5fd3..a1d8ca3 100644 --- a/src/package-lock.json +++ b/src/package-lock.json @@ -4,12 +4,14 @@ "requires": true, "packages": { "": { + "name": "src", "license": "GPL-3.0-or-later", "dependencies": { "ask-sdk": "^2.14.0", "ask-sdk-dynamodb-persistence-adapter": "^2.14.0", "ask-sdk-express-adapter": "^2.14.0", - "express": "^5.1.0" + "express": "^5.1.0", + "fuzzball": "^2.2.3" } }, "node_modules/accepts": { @@ -249,22 +251,27 @@ ] }, "node_modules/body-parser": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.0.tgz", - "integrity": "sha512-02qvAaxv8tp7fBa/mw1ga98OGm+eCbqzJOKoRt70sLmfEEi+jyBYVTDGfCL/k06/4EMk/z01gCe7HoCH/f2LTg==", + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.1.tgz", + "integrity": "sha512-nfDwkulwiZYQIGwxdy0RUmowMhKcFVcYXUU7m4QlKYim1rUtg83xm2yjZ40QjDuc291AJjjeSc9b++AWHSgSHw==", + "license": "MIT", "dependencies": { "bytes": "^3.1.2", "content-type": "^1.0.5", - "debug": "^4.4.0", + "debug": "^4.4.3", "http-errors": "^2.0.0", - "iconv-lite": "^0.6.3", + "iconv-lite": "^0.7.0", "on-finished": "^2.4.1", "qs": "^6.14.0", - "raw-body": "^3.0.0", - "type-is": "^2.0.0" + "raw-body": "^3.0.1", + "type-is": "^2.0.1" }, "engines": { "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" } }, "node_modules/buffer": { @@ -365,9 +372,10 @@ } }, "node_modules/debug": { - "version": "4.4.1", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.1.tgz", - "integrity": "sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ==", + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", "dependencies": { "ms": "^2.1.3" }, @@ -582,6 +590,17 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/fuzzball": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/fuzzball/-/fuzzball-2.2.3.tgz", + "integrity": "sha512-sQDb3kjI7auA4YyE1YgEW85MTparcSgRgcCweUK06Cn0niY5lN+uhFiRUZKN4MQVGGiHxlbrYCA4nL1QjOXBLQ==", + "license": "MIT", + "dependencies": { + "heap": ">=0.2.0", + "lodash": "^4.17.21", + "setimmediate": "^1.0.5" + } + }, "node_modules/get-intrinsic": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", @@ -675,6 +694,12 @@ "node": ">= 0.4" } }, + "node_modules/heap": { + "version": "0.2.7", + "resolved": "https://registry.npmjs.org/heap/-/heap-0.2.7.tgz", + "integrity": "sha512-2bsegYkkHO+h/9MGbn6KWcE45cHZgPANo5LXF7EvWdT0yT2EguSVO1nDgU5c8+ZOPwp2vMNa7YFsJhVcDR9Sdg==", + "license": "MIT" + }, "node_modules/http-errors": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.0.tgz", @@ -699,14 +724,19 @@ } }, "node_modules/iconv-lite": { - "version": "0.6.3", - "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", - "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", + "version": "0.7.1", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.1.tgz", + "integrity": "sha512-2Tth85cXwGFHfvRgZWszZSvdo+0Xsqmw8k8ZwxScfcBneNUraK+dxRxRm24nszx80Y0TVio8kKLt5sLE7ZCLlw==", + "license": "MIT", "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" }, "engines": { "node": ">=0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" } }, "node_modules/ieee754": { @@ -819,6 +849,12 @@ "node": ">= 0.6.0" } }, + "node_modules/lodash": { + "version": "4.17.21", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz", + "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==", + "license": "MIT" + }, "node_modules/math-intrinsics": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", @@ -879,9 +915,10 @@ } }, "node_modules/node-forge": { - "version": "1.3.1", - "resolved": "https://registry.npmjs.org/node-forge/-/node-forge-1.3.1.tgz", - "integrity": "sha512-dPEtOeMvF9VMcYV/1Wb8CPoVAXtp6MKMlcbAt4ddqmGqUJ6fQZFXkNZNkNlfevtNkGtaSoXf/vNNNSvgrdXwtA==", + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/node-forge/-/node-forge-1.3.3.tgz", + "integrity": "sha512-rLvcdSyRCyouf6jcOIPe/BgwG/d7hKjzMKOas33/pHEr6gbq18IK9zV7DiPvzsz0oBJPme6qr6H6kGZuI9/DZg==", + "license": "(BSD-3-Clause OR GPL-2.0)", "engines": { "node": ">= 6.13.0" } @@ -989,17 +1026,38 @@ } }, "node_modules/raw-body": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-3.0.0.tgz", - "integrity": "sha512-RmkhL8CAyCRPXCE28MMH0z2PNWQBNk2Q09ZdxM9IOOXwxwZbN+qbWaatPkdkWIKL2ZVDImrN/pK5HTRz2PcS4g==", + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-3.0.2.tgz", + "integrity": "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==", + "license": "MIT", "dependencies": { - "bytes": "3.1.2", - "http-errors": "2.0.0", - "iconv-lite": "0.6.3", - "unpipe": "1.0.0" + "bytes": "~3.1.2", + "http-errors": "~2.0.1", + "iconv-lite": "~0.7.0", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/raw-body/node_modules/http-errors": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", + "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", + "license": "MIT", + "dependencies": { + "depd": "~2.0.0", + "inherits": "~2.0.4", + "setprototypeof": "~1.2.0", + "statuses": "~2.0.2", + "toidentifier": "~1.0.1" }, "engines": { "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" } }, "node_modules/router": { @@ -1124,6 +1182,12 @@ "node": ">= 0.4" } }, + "node_modules/setimmediate": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/setimmediate/-/setimmediate-1.0.5.tgz", + "integrity": "sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA==", + "license": "MIT" + }, "node_modules/setprototypeof": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", diff --git a/src/package.json b/src/package.json index 7b7394d..ab83564 100644 --- a/src/package.json +++ b/src/package.json @@ -1,24 +1,18 @@ { "homepage": "https://github.com/Rusketh/JellyMusic/blob/main/ReadMe.md", - - "author": - { - "name":"Rusketh" + "author": { + "name": "Rusketh" }, - - "repository": - { + "repository": { "type": "git", "url": "https://github.com/Rusketh/JellyMusic.git" }, - "license": "GPL-3.0-or-later", - - "dependencies": - { + "dependencies": { "ask-sdk": "^2.14.0", "ask-sdk-dynamodb-persistence-adapter": "^2.14.0", "ask-sdk-express-adapter": "^2.14.0", - "express": "^5.1.0" + "express": "^5.1.0", + "fuzzball": "^2.2.3" } } diff --git a/src/players/audio-player.js b/src/players/audio-player.js new file mode 100644 index 0000000..f8048b8 --- /dev/null +++ b/src/players/audio-player.js @@ -0,0 +1,60 @@ +const PlayItem = function(handlerInput, playlist, item) +{ + if (!item || !item.Playback.Token) return false; + + if (playlist.playbackToken && playlist.playbackToken == item.Playback.Token) + return false; + + handlerInput.responseBuilder.addAudioPlayerPlayDirective( + 'REPLACE_ALL', + item.getStreamURL(), + item.Playback.Token, + item.Playback.Offset, + null, + item.getMetaData() + ); + + playlist.playbackToken = item.Playback.Token; + + return true; +}; + +const EnqueueItem = function(handlerInput, playlist, item) +{ + if (!item || !item.Token) return false; + + if (playlist.playbackToken && playlist.playbackToken == item.Playback.Token) + return false; + + handlerInput.responseBuilder.addAudioPlayerPlayDirective( + 'ENQUEUE', + item.getStreamURL(), + item.Playback.Token, + item.Playback.Offset, + playlist.playbackToken, + item.getMetaData() + ); + + playlist.playbackToken = item.Playback.Token; + + return true; +}; + +const StopPlayback = function(handlerInput) +{ + handlerInput.responseBuilder.addAudioPlayerStopDirective(); + + return true; +}; + +const EndSession = function(handlerInput, playlist) +{ + return true; +}; + +module.exports = { + PlayItem, + EnqueueItem, + StopPlayback, + EndSession +}; \ No newline at end of file diff --git a/src/players/player.js b/src/players/player.js new file mode 100644 index 0000000..915990e --- /dev/null +++ b/src/players/player.js @@ -0,0 +1,50 @@ +const Alexa = require('ask-sdk-core'); + +const AudioPlayer = require("./audio-player.js"); + +const VisualPlayer = require("./visual-player.js"); + +const SupportsAPL = function(handlerInput) +{ + return Alexa.getSupportedInterfaces(handlerInput.requestEnvelope)['Alexa.Presentation.APL']; +}; + +const PlayItem = function(handlerInput, playlist, item) +{ + if (SupportsAPL(handlerInput)) + return VisualPlayer.PlayItem(handlerInput, playlist, item); + + return AudioPlayer.PlayItem(handlerInput, playlist, item); +}; + +const EnqueueItem = function(handlerInput, playlist, item) +{ + if (SupportsAPL(handlerInput)) + return VisualPlayer.EnqueueItem(handlerInput, playlist, item); + + return AudioPlayer.EnqueueItem(handlerInput, playlist, item); +}; + +const StopPlayback = function(handlerInput) +{ + if (SupportsAPL(handlerInput)) + return VisualPlayer.StopPlayback(handlerInput); + + return AudioPlayer.StopPlayback(handlerInput); +} + +const EndSession = function(handlerInput, playlist) +{ + if (SupportsAPL(handlerInput)) + return VisualPlayer.EndSession(handlerInput, playlist); + + return AudioPlayer.EndSession(handlerInput, playlist); +} + +module.exports = { + SupportsAPL, + PlayItem, + EnqueueItem, + StopPlayback, + EndSession +}; \ No newline at end of file diff --git a/src/players/visual-player.js b/src/players/visual-player.js new file mode 100644 index 0000000..1759de3 --- /dev/null +++ b/src/players/visual-player.js @@ -0,0 +1,138 @@ +//For the Love of Sanity - Please sombody rewrite the APL Player for me. + +const APLDocument = require("../apl/audio-player.json"); + +const ShowAPL = function(handlerInput, playlist, item) +{ + //if (playlist.APLOpen) + // return false; + + handlerInput.responseBuilder.addDirective( + { + type: 'Alexa.Presentation.APL.RenderDocument', + token: "musicToken", + document: APLDocument, + datasources: + { + audioPlayerData: + { + type: "object", + properties: + { + backgroundImage: item.getArtURL(), + coverImageSource: item.getArtURL(), + primaryText: item.Item.Name, + secondaryText: item.Item.AlbumArtist, + audioSources: { + url: item.getStreamURL(), + offset: item.Playback.Offset, + duration: item.Playback.Runtime + } + } + } + } + } + ); + + Logger.Debug(`[Device ${playlist.Id}]`, "Showing APL Audioplayer"); + + playlist.APLOpen = true; + + return false; +} + +const PlayItem = function(handlerInput, playlist, item) +{ + if (!item || !item.Playback.Token) return false; + + ShowAPL(handlerInput, playlist, item); + + return true; +}; + + +/* + I FAILED TO GET THIS TO WORK SO FOR NOW JUST REFRESH + if (!ShowAPL(handlerInput, playlist, item)) + return true; + + Logger.Debug(`[Device ${playlist.Id}]`, "Updating APL Audioplayer"); + + handlerInput.responseBuilder.addDirective( + { + type: "Alexa.Presentation.APL.ExecuteCommands", + token: "musicToken", + commands: [ + { + "type": "SetValue", + "componentId": "Audio_PrimaryText", + "property": "text", + "value": item.Item.Name + }, + { + "type": "SetValue", + "componentId": "Audio_SecondaryText", + "property": "text", + "value": item.Item.AlbumArtist + }, + { + "type": "PlayMedia", + "componentId": "videoPlayer", + "source": item.getStreamURL(), + "audioTrack": "background" + }, + { + "type": "ControlMedia", + "componentId": "videoPlayer", + "command": "seek", + "value": item.Playback.Offset + }, + { + "type": "ControlMedia", + "componentId": "videoPlayer", + "command": "play" + } + ] + } + ); + + return true; +};*/ + +const EnqueueItem = function(handlerInput, playlist, item) +{ + return false; +}; + +const StopPlayback = function(handlerInput) +{ + handlerInput.responseBuilder.addDirective( + { + type: 'Alexa.Presentation.APL.ExecuteCommands', + token: "musicToken", + commands: [ + { + type: "ControlMedia", + componentId: "videoPlayer", + command: "pause" + } + ] + } + ); + + return true; +}; + +const EndSession = function(handlerInput, playlist) +{ + playlist.APLOpen = false; + + return true; +}; + +module.exports = { + PlayItem, + EnqueueItem, + StopPlayback, + EndSession +}; \ No newline at end of file diff --git a/src/playlist/devices.js b/src/playlist/devices.js index c4391a9..d1b675a 100644 --- a/src/playlist/devices.js +++ b/src/playlist/devices.js @@ -1,3 +1,5 @@ +const Player = require("../players/player.js"); + const PlayList = require("./playlist.js"); const Alexa = require('ask-sdk-core'); @@ -16,10 +18,10 @@ const getPlayList = function(device) { if (devices[device]) return devices[device]; - console.log(`Registering new device: ${device}`); - const playlist = PlayList.new(device); + Logger.Debug(`[Device ${playlist.Id}]`, "Created new device playlist."); + devices[device] = playlist; return playlist; @@ -54,13 +56,16 @@ const onPlaybackNearlyFinished = function(handlerInput) const playlist = getPlayList(deviceID); - console.debug("Playback nearly finished!"); + Logger.Debug(`[Device ${playlist.Id}]`, "Playback nearly finished!"); - const directive = playlist.getPlayNextDirective(handlerInput); + const item = playlist.getNextItem(); - if (!directive || !playlist.Validate(directive, handlerInput)) return responseBuilder.getResponse(); + if (!Player.EnqueueItem(handlerInput, playlist, item)) + return responseBuilder.getResponse(); - return responseBuilder.addAudioPlayerPlayDirective(...directive).getResponse(); + Logger.Info(`[Device ${playlist.Id}]`, `Queued ${item.Item.Name} by ${item.Item.AlbumArtist}`); + + return responseBuilder.getResponse(); }; /********************************************************************************* @@ -71,13 +76,13 @@ const onQueueFinished = function(handlerInput, speak) { const { responseBuilder } = handlerInput; - console.debug("Playback queue finished!"); + Logger.Debug(`[Device ${playlist.Id}]`, "Playback queue finished!"); if (speak) { - const speach = "There are no more songs left in the queue."; + const speech = "There are no more songs left in the queue."; - return responseBuilder.speak(speach).getResponse(); + return responseBuilder.speak(speech).getResponse(); } return responseBuilder.getResponse(); @@ -95,26 +100,23 @@ const onPlaybackFailed = function(handlerInput) const playlist = getPlayList(deviceID); - var directive = playlist.getPlayDirective(handlerInput); - - console.debug("Playback failed!"); + Logger.Debug(`[Device ${playlist.Id}]`, "Playback failed!"); - if (!directive) return responseBuilder.getResponse(); + var item = playlist.getCurrentItem(); - if (playlist.Validate(directive, handlerInput)) - return responseBuilder.addAudioPlayerPlayDirective(...directive).getResponse(); - - console.debug("Moving to next song."); - - const next = playlist.getNextItem(); + if (!Player.PlayItem(handlerInput, playlist, item)) + { + Logger.Debug(`[Device ${playlist.Id}]`, "Moving to next song."); - if (!next) return onQueueFinished(handlerInput); //responseBuilder.getResponse(); + item = playlist.getNextItem(); - directive = next.getPlayDirective(directive, handlerInput); + if (!playlist.nextItem() || !Player.PlayItem(handlerInput, playlist, item)) + return onQueueFinished(handlerInput); + } - if (!directive || !playlist.Validate()) return responseBuilder.getResponse(); + Logger.Info(`[Device ${playlist.Id}]`, `Playing ${item.Item.Name} by ${item.Item.AlbumArtist}`); - return responseBuilder.addAudioPlayerPlayDirective(...directive).getResponse(); + return responseBuilder.getResponse(); }; /********************************************************************************* @@ -131,7 +133,7 @@ const onPlaybackStarted = function(handlerInput) playlist.start(); - console.debug("Playback started!"); + Logger.Debug(`[Device ${playlist.Id}]`, "Playback started!"); return responseBuilder.getResponse(); }; @@ -150,16 +152,19 @@ const onPlaybackFinished = function(handlerInput) playlist.stop(); - console.debug("Playback finished!"); - - const directive = playlist.getPlayNextDirective(handlerInput); + Logger.Debug(`[Device ${playlist.Id}]`, "Playback finished!"); - if (!playlist.nextItem()) //Moves up the queue. + if (!playlist.nextItem()) return onQueueFinished(handlerInput); - if (!directive || !playlist.Validate(directive, handlerInput)) return responseBuilder.getResponse(); + const item = playlist.getNextItem(); - return responseBuilder.addAudioPlayerPlayDirective(...directive).getResponse(); + if (!Player.PlayItem(handlerInput, playlist, item)) + return responseBuilder.getResponse(); + + Logger.Info(`[Device ${playlist.Id}]`, `Playing ${item.Item.Name} by ${item.Item.AlbumArtist}`); + + return responseBuilder.getResponse(); }; /********************************************************************************* @@ -176,7 +181,7 @@ const onPlaybackStopped = function(handlerInput) playlist.stop(); - console.debug("Playback stopped!"); + Logger.Debug(`[Device ${playlist.Id}]`, "Playback stopped!"); return responseBuilder.getResponse(); }; @@ -195,6 +200,8 @@ const doClear = function(handlerInput) playlist.clearRemainingItems(); + Logger.Info(`[Device ${playlist.Id}]`, "Playback Queue Cleared."); + return responseBuilder.getResponse(); }; @@ -202,7 +209,7 @@ const doClear = function(handlerInput) * */ -const doPause = function(handlerInput) +const doPause = function(handlerInput, offset) { const { responseBuilder, requestEnvelope } = handlerInput; @@ -210,7 +217,11 @@ const doPause = function(handlerInput) const playlist = getPlayList(deviceID); - playlist.pause(requestEnvelope.context.AudioPlayer.offsetInMilliseconds); + playlist.pause(offset || requestEnvelope.context.AudioPlayer.offsetInMilliseconds); + + Logger.Info(`[Device ${playlist.Id}]`, "Playback Paused."); + + Player.StopPlayback(handlerInput); return responseBuilder.getResponse(); }; @@ -227,11 +238,14 @@ const doResume = function(handlerInput) const playlist = getPlayList(deviceID); - const directive = playlist.getPlayDirective(); + const item = playlist.getCurrentItem(); - if (!directive) return responseBuilder.getResponse(); + if (!Player.PlayItem(handlerInput, playlist, item)) + return responseBuilder.getResponse(); + + Logger.Info(`[Device ${playlist.Id}]`, `Resuming ${item.Item.Name} by ${item.Item.AlbumArtist}`); - return responseBuilder.addAudioPlayerPlayDirective(...directive).getResponse(); + return responseBuilder.getResponse(); }; /********************************************************************************* @@ -248,7 +262,11 @@ const doStop = function(handlerInput) playlist.clear(); - return responseBuilder.addAudioPlayerStopDirective().getResponse(); + Logger.Info(`[Device ${playlist.Id}]`, "Playback Stopped."); + + Player.StopPlayback(handlerInput); + + return responseBuilder.getResponse(); }; @@ -264,13 +282,14 @@ const doPlayCurrent = function(handlerInput) const playlist = getPlayList(deviceID); - const current = playlist.getCurrentItem(); + const item = playlist.getCurrentItem(); - if (!current) return responseBuilder.getResponse(); + if (!Player.PlayItem(handlerInput, playlist, item)) + return responseBuilder.getResponse(); - const directive = current.getPlayDirective(); + Logger.Info(`[Device ${playlist.Id}]`, `Playing ${item.Item.Name} by ${item.Item.AlbumArtist}`); - return responseBuilder.addAudioPlayerPlayDirective(...directive).getResponse(); + return responseBuilder.getResponse(); }; /********************************************************************************* @@ -285,14 +304,19 @@ const doPlayNext = function(handlerInput) const playlist = getPlayList(deviceID); - const next = playlist.getNextItem(); + const item = playlist.getNextItem(); - if (!next || !playlist.nextItem()) - return onQueueFinished(handlerInput, true); //responseBuilder.getResponse(); + if (!item || !playlist.nextItem()) + return onQueueFinished(handlerInput, true); + + if (!Player.PlayItem(handlerInput, playlist, item)) + return responseBuilder.getResponse(); - const directive = next.getPlayDirective(); + Logger.Debug(`[Device ${playlist.Id}]`, "Playing Next Item."); - return responseBuilder.addAudioPlayerPlayDirective(...directive).getResponse(); + Logger.Info(`[Device ${playlist.Id}]`, `Playing ${item.Item.Name} by ${item.Item.AlbumArtist}`); + + return responseBuilder.getResponse(); }; /********************************************************************************* @@ -307,14 +331,38 @@ const doPlayPrevious = function(handlerInput) const playlist = getPlayList(deviceID); - const previous = playlist.getPreviousItem(); + const item = playlist.getPreviousItem(); - if (!previous || !playlist.previousItem()) + if (!item || !playlist.previousItem()) return responseBuilder.getResponse(); - const directive = previous.getPlayDirective(); + if (!Player.PlayItem(handlerInput, playlist, item)) + return responseBuilder.getResponse(); - return responseBuilder.addAudioPlayerPlayDirective(...directive).getResponse(); + Logger.Debug(`[Device ${playlist.Id}]`, "Playing Previous Item."); + + Logger.Info(`[Device ${playlist.Id}]`, `Playing ${item.Item.Name} by ${item.Item.AlbumArtist}`); + + return responseBuilder.getResponse(); +}; + +/********************************************************************************* + * End the session + */ + +const doSessionEnded = function(handlerInput) +{ + const { responseBuilder, requestEnvelope } = handlerInput; + + const deviceID = Alexa.getDeviceId(requestEnvelope); + + const playlist = getPlayList(deviceID); + + Player.EndSession(handlerInput, playlist); + + Logger.Debug(`[Device ${playlist.Id}]`, "Session ended."); + + return responseBuilder.getResponse(); }; /********************************************************************************* @@ -336,5 +384,6 @@ module.exports = { doStop, doPlayCurrent, doPlayNext, - doPlayPrevious + doPlayPrevious, + doSessionEnded }; \ No newline at end of file diff --git a/src/playlist/item.js b/src/playlist/item.js index 02e4bd5..5a60b9c 100644 --- a/src/playlist/item.js +++ b/src/playlist/item.js @@ -15,7 +15,7 @@ PlayListItem.new = function(item, token) { Id: item.Id, Name: item.Name, - Album: item.Albu, + Album: item.Album, AlbumId: item.AlbumId, AlbumArtist: item.AlbumArtist, AlbumPrimaryImageTag: item.AlbumPrimaryImageTag @@ -23,6 +23,7 @@ PlayListItem.new = function(item, token) Playback: { Token: token || crypto.randomUUID(), + Runtime: item.RunTimeTicks / 10000, IsPlaying: false, IsPaused: false, Offset: 0 @@ -37,10 +38,15 @@ PlayListItem.new = function(item, token) PlayListItem.getStreamURL = function() { - const url = new URL(`/Items/${this.Item.Id}/Download`, CONFIG.jellyfin.host); + const url = new URL(`/Audio/${this.Item.Id}/stream`, CONFIG.jellyfin.host); url.searchParams.append("api_key", String(CONFIG.jellyfin.key)); - + url.searchParams.append("container", "mp3"); + url.searchParams.append("audioCodec", "mp3"); + url.searchParams.append("transcodingContainer", "mp3"); + url.searchParams.append("transcodingProtocol", "http"); + url.searchParams.append("maxStreamingBitrate", "192000"); + return url.toString(); }; @@ -77,44 +83,6 @@ PlayListItem.getMetaData = function() return object; }; -/********************************************************************************* - * getPlayDirective - * Gets the arguments for addAudioPlayerPlayDirective to play now - */ - -PlayListItem.getPlayDirective = function() -{ - console.debug(`getPlayDirective: ${this.Item.Name} -> ${this.Playback.Token}`); - - return [ - 'REPLACE_ALL', - this.getStreamURL(), - this.Playback.Token, - this.Playback.Offset, - null, - this.getMetaData() - ]; -}; - -/********************************************************************************* - * getPlayDirective - * Gets the arguments for addAudioPlayerPlayDirective to play now - */ - -PlayListItem.getPlayNextDirective = function({Token}) -{ - console.debug(`getPlayNextDirective: ${this.Item.Name} -> ${this.Playback.Token}`); - - return [ - 'ENQUEUE', - this.getStreamURL(), - this.Playback.Token, - this.Playback.Offset, - Token, - this.getMetaData() - ]; -}; - /********************************************************************************* * Start Playing */ diff --git a/src/playlist/playlist.js b/src/playlist/playlist.js index 9447497..d5b2f34 100644 --- a/src/playlist/playlist.js +++ b/src/playlist/playlist.js @@ -1,9 +1,13 @@ +const crypto = require("node:crypto"); + const PlayListItem = require("./item.js"); /********************************************************************************* * Playlist Item */ +var id = 0; + const PlayList = { }; PlayList.new = function(device) @@ -12,7 +16,8 @@ PlayList.new = function(device) Device: device, Dirty: false, Position: 0, - Queue: [ ] + Queue: [ ], + Id: id++ }, PlayList); }; @@ -189,25 +194,19 @@ PlayList.getPlayPreviousDirective = function(handlerInput) PlayList.Validate = function(directive, {attributesManager}) { var token = directive[2]; - /*let sessionAttributes = attributesManager.getSessionAttributes(); - - if (sessionAttributes.playbackToken == token) return false; - - sessionAttributes.playbackToken = token; - - attributesManager.setSessionAttributes(sessionAttributes);*/ + if (!token) return true; + if (this.playbackToken && this.playbackToken == token) { - console.log(`Repeating token: ${token}`); + Logger.Debug(`[Playlist ${this.Id}]`, "Existing playback token rejected."); return false; } - console.log(`None repeating token: ${token}`); + Logger.Debug(`[Playlist ${this.Id}]`, "New playback token consumed."); this.playbackToken = token; - return true; } @@ -221,11 +220,11 @@ PlayList.nextItem = function() if (index >= this.Queue.length - 1) { - console.debug("Reached end of queue!"); + Logger.Debug(`[Playlist ${this.Id}]`, "Reached end of queue!"); return false; } - console.debug("Playlist moved up by 1."); + Logger.Debug(`[Playlist ${this.Id}]`,"Moving to next item in queue."); this.Position = index; @@ -240,11 +239,11 @@ PlayList.previousItem = function() if (index < 0) { - console.debug("Reached start of queue!"); + Logger.Debug(`[Playlist ${this.Id}]`,"Reached start of queue!"); return false; } - console.debug("Playlist moved down by 1."); + Logger.Debug(`[Playlist ${this.Id}]`,"Moving to previous item in queue."); this.Position = index; diff --git a/src/playlist/save-file.js b/src/playlist/save-file.js index 2d181c2..85a3b3b 100644 --- a/src/playlist/save-file.js +++ b/src/playlist/save-file.js @@ -61,7 +61,7 @@ const Save = function() const promise = SaveDeviceQueue(playlist).then( () => { - console.log("Saved Device Queue: ", playlist.Device); + Logger.Debug(`[Playlist ${playlist.Id}]`,"Saved queue to local file."); playlist.Dirty = false; playlist.Saving = false; } @@ -80,8 +80,6 @@ const Save = function() const LoadDeviceQueue = async function({deviceID, position, token, queue}) { - console.log("Restoring Device Queue: ", deviceID); - const songs = { }; const itemIDs = queue.map(({id}) => id); @@ -120,7 +118,7 @@ const LoadDeviceQueue = async function({deviceID, position, token, queue}) playList.Queue.push(queued); } - console.log("Device Queue Restored: ", deviceID); + Logger.Debug(`[Playlist ?]`,"Restored queue from local file."); }; /********************************************************************************* diff --git a/src/server.js b/src/server.js index e5cd794..f2a9daa 100644 --- a/src/server.js +++ b/src/server.js @@ -12,4 +12,4 @@ app.post('/', adapter.getRequestHandlers()); app.get('/', (req, res) => { res.write("Server is running!"); res.send(); } ); -app.listen(CONFIG.server.port, () => console.log(`Listening on port: ${CONFIG.server.port}`)); \ No newline at end of file +app.listen(CONFIG.server.port, () => Logger.Debug(`Listening on port: ${CONFIG.server.port}`)); \ No newline at end of file diff --git a/src/skill/album-intents.js b/src/skill/album-intents.js index bc87123..f3c1f64 100644 --- a/src/skill/album-intents.js +++ b/src/skill/album-intents.js @@ -6,6 +6,8 @@ const JellyFin = require("../jellyfin-api"); const Devices = require("../playlist/devices.js"); +const Player = require("../players/player.js"); + const { CreateQueueIntent } = require("./alexa-helper.js"); /********************************************************************************* @@ -20,19 +22,22 @@ const Processer = async function(handlerInput, action = "play", buildQueue, subm if (!slots.albumname || !slots.albumname.value) { - const speach1 = `Which album would you like to ${action}?`; - const speach2 = `I didn't catch the album name. ${speach1}`; - return [{status: false, speach1, speach2}]; + const speech = LANGUAGE.Value("ALBUM_NO_NAME"); + + return [{status: false, speach1}]; } - console.log(`Requesting Album: ${slots.albumname.value}`); + Logger.Debug(`[Album Request]`, `Requested album ${slots.albumname.value}`); const albums = await JellyFin.Albums.Search(slots.albumname.value); if (!albums.status || !albums.items[0]) { - const speach = `I didn't find an album called ${slots.albumname.value}`; - return [{status: false, speach}]; + Logger.Debug(`[Album Request]`, "No album found."); + + const speech = LANGUAGE.Parse("ALBUM_NOTFOUND_BY_NAME", {album_name: slots.albumname.value}); + + return [{status: false, speech}]; } var artist = undefined; @@ -40,14 +45,17 @@ const Processer = async function(handlerInput, action = "play", buildQueue, subm if (slots.artistname && slots.artistname.value) { - console.log(`Requesting Artist for Album: ${slots.artistname.value}`); + Logger.Debug(`[Album Request]`, `Requested Artist ${slots.artistname.value}`); const artists = await JellyFin.Artists.Search(slots.artistname.value); if (!artists.status || !artists.items[0]) { - const speach = `I didn't find an artist called ${slots.artistname.value}.`; - return [{status: false, speach}]; + Logger.Debug(`[Album Request]`, "No artist found."); + + const speech = LANGUAGE.Parse("ARTIST_NOTFOUND_BY_NAME", {artist_name: slots.artistname.value}); + + return [{status: false, speech}]; } artist = artists.items[0]; @@ -72,16 +80,32 @@ const Processer = async function(handlerInput, action = "play", buildQueue, subm if (album && album.AlbumArtist) { - const suggestion = `${album.Name} by ${album.AlbumArtist}`; - - const speach = `I didn't find an album called ${slots.albumname.value} by ${artist.name || slots.artistname.value}, you might have meant ${suggestion}.`; - - return [{status: false, speach}]; + Logger.Debug(`[Album Request]`, `Returning suggestion ${album.Name} by ${album.AlbumArtist}`); + + const speech = LANGUAGE.Parse("ALBUM_NOTFOUND_SUGGEST", + { + album_name: slots.albumname.value, + artist_name: artist.name || slots.artistname.value, + suggestion_name: album.Name, + suggestion_artist: album.AlbumArtist + } + ); + + return [{status: false, speech}]; } else { - const speach = `I didn't find an album called ${slots.albumname.value} by ${artist.name || slots.artistname.value}.`; - return [{status: false, speach}]; + + Logger.Debug(`[Album Request]`, "Album not found."); + + const speech = LANGUAGE.Parse("ALBUM_NOTFOUND_BY_NAME_AND_ARTIST", + { + album_name: slots.albumname.value, + artist_name: artist.name || slots.artistname.value + } + ); + + return [{status: false, speech}]; } } } @@ -91,7 +115,8 @@ const Processer = async function(handlerInput, action = "play", buildQueue, subm if (!songs.status || !songs.items[0]) { - const speach = `I didn't find an music in the album ${slots.albumname.value}.`; + const speech = LANGUAGE.Parse("ALBUM_NO_MUSIC", { album_name: slots.albumname.value } ); + return [{status: false, speach1, speach2}]; } @@ -126,13 +151,24 @@ const PlayAlbumIntent = CreateQueueIntent( [data.first, data.last] = playlist.prefixItems(items); - const directive = playlist.getPlayDirective(); + const item = playlist.getCurrentItem(); - var speach = `Playing album ${album.Name}, on ${CONFIG.skill.name}`; - - if (album.AlbumArtist) speach = `Playing album ${album.Name} by ${album.AlbumArtist}, on ${CONFIG.skill.name}`; + if (!Player.PlayItem(handlerInput, playlist, item)) + return responseBuilder.getResponse(); + + Logger.Info(`[Device ${playlist.Id}]`, `Playing ${item.Item.Name} by ${item.Item.AlbumArtist}`); - return responseBuilder.speak(speach).addAudioPlayerPlayDirective(...directive).getResponse(); + var speech = LANGUAGE.Parse("ALBUM_PLAYING", { album_name: album.Name } ); + + if (album.AlbumArtist) + speech = LANGUAGE.Parse("ALBUM_PLAYING_BY_ARTIST", + { + album_name: album.Name, + album_artist: album.AlbumArtist + } + ); + + return responseBuilder.speak(speech).getResponse(); }, function({ requestEnvelope }, {status, items}, data) { @@ -168,13 +204,24 @@ const ShuffleAlbumIntent = CreateQueueIntent( [data.first, data.last] = playlist.prefixItems(items); - const directive = playlist.getPlayDirective(); + const item = playlist.getCurrentItem(); - var speach = `Shuffling album ${album.Name}, on ${CONFIG.skill.name}`; - - if (album.AlbumArtist) speach = `Shuffling album ${album.Name} by ${album.AlbumArtist}, on ${CONFIG.skill.name}`; + if (!Player.PlayItem(handlerInput, playlist, item)) + return responseBuilder.getResponse(); + + Logger.Info(`[Device ${playlist.Id}]`, `Playing ${item.Item.Name} by ${item.Item.AlbumArtist}`); + + var speech = LANGUAGE.Parse("ALBUM_SHUFFLE", { album_name: album.Name } ); - return responseBuilder.speak(speach).addAudioPlayerPlayDirective(...directive).getResponse(); + if (album.AlbumArtist) + speech = LANGUAGE.Parse("ALBUM_SHUFFLE_BY_ARTIST", + { + album_name: album.Name, + album_artist: album.AlbumArtist + } + ); + + return responseBuilder.speak(speech).getResponse(); }, function({ requestEnvelope }, {status, items}, data) { @@ -214,13 +261,22 @@ const QueueAlbumIntent = CreateQueueIntent( playlist.appendItems(items); - const directive = playlist.getPlayDirective(); + const item = playlist.getCurrentItem(); - var speach = `Adding album ${album.Name}, to the queue.`; + if (Player.PlayItem(handlerInput, playlist, item)) + Logger.Info(`[Device ${playlist.Id}]`, `Playing ${item.Item.Name} by ${item.Item.AlbumArtist}`); - if (album.AlbumArtist) speach = `Adding album ${album.Name} by ${album.AlbumArtist}, to the queue.`; + var speech = LANGUAGE.Parse("ALBUM_QUEUED", { album_name: album.Name } ); + + if (album.AlbumArtist) + speech = LANGUAGE.Parse("ALBUM_QUEUED_BY_ARTIST", + { + album_name: album.Name, + album_artist: album.AlbumArtist + } + ); - return responseBuilder.speak(speach).addAudioPlayerPlayDirective(...directive).getResponse(); + return responseBuilder.speak(speech).getResponse(); }, function({ requestEnvelope }, {status, items}, data) { diff --git a/src/skill/alexa-helper.js b/src/skill/alexa-helper.js index c388118..f82a09a 100644 --- a/src/skill/alexa-helper.js +++ b/src/skill/alexa-helper.js @@ -17,8 +17,8 @@ const CreateHandler = function(type, callback) } catch(err) { - console.error(`Error in handler canHandle("${type}"):`); - console.error(err); + Logger.Error(`Error in handler canHandle("${type}"):`); + Logger.Error(err); return false; } @@ -35,11 +35,11 @@ const CreateHandler = function(type, callback) } catch(err) { - console.error(`Error in handler handle("${type}"):`); - console.error(err); + Logger.Error(`Error in handler handle("${type}"):`); + Logger.Error(err); - const speach = `An interal error has occured.`; - return responseBuilder.speak(speach).getResponse(); + const speech = `An interal error has occured.`; + return responseBuilder.speak(speech).getResponse(); } } } @@ -63,8 +63,8 @@ const CreateIntent = function(intent, callback) } catch(err) { - console.error(`Error in intent canHandle("${intent}"):`); - console.error(err); + Logger.Error(`Error in intent canHandle("${intent}"):`); + Logger.Error(err); return false; } @@ -81,11 +81,11 @@ const CreateIntent = function(intent, callback) } catch(err) { - console.error(`Error in intent handle("${intent}"):`); - console.error(err); + Logger.Error(`Error in intent handle("${intent}"):`); + Logger.Error(err); - const speach = `An interal error has occured, request not processed.`; - return responseBuilder.speak(speach).getResponse(); + const speech = `An interal error has occured, request not processed.`; + return responseBuilder.speak(speech).getResponse(); } } } @@ -109,13 +109,13 @@ const CreateQueueIntent = function(intent, action, processor, responder, buildQu if (!result.status) { - var {speach, prompt} = result; + var {speech, prompt} = result; - if (speach && prompt) - return responseBuilder.speak(speach).reprompt(prompt).getResponse(); + if (speech && prompt) + return responseBuilder.speak(speech).reprompt(prompt).getResponse(); - if (speach) - return responseBuilder.speak(speach).getResponse(); + if (speech) + return responseBuilder.speak(speech).getResponse(); return responseBuilder.getResponse(); } @@ -130,6 +130,59 @@ const CreateQueueIntent = function(intent, action, processor, responder, buildQu ); }; + +/********************************************************************************* + * Create APL Event Handler + */ + + +const CreateAPLEventHandler = function(event, callback) +{ + return { + canHandle: + function (handlerInput) + { + try + { + const {requestEnvelope} = handlerInput; + + if (Alexa.getRequestType(requestEnvelope) != "Alexa.Presentation.APL.UserEvent") + return false; + + return handlerInput.requestEnvelope.request.arguments[0] === event; + } + catch(err) + { + Logger.Error(`Error in APL event handler canHandle("${event}"):`); + Logger.Error(err); + + return false; + } + }, + + handle: + async function (handlerInput) + { + const { responseBuilder } = handlerInput; + + try + { + const args = handlerInput.requestEnvelope.request.arguments; + + return await callback(handlerInput, ...args.slice(1)); + } + catch(err) + { + Logger.Error(`Error in APL event handler handle("${event}"):`); + Logger.Error(err); + + const speech = `An interal error has occured.`; + return responseBuilder.getResponse(); + } + } + } +}; + /********************************************************************************* * Exports */ @@ -137,5 +190,6 @@ const CreateQueueIntent = function(intent, action, processor, responder, buildQu module.exports = { CreateHandler, CreateIntent, - CreateQueueIntent + CreateQueueIntent, + CreateAPLEventHandler }; \ No newline at end of file diff --git a/src/skill/apl-handlers.js b/src/skill/apl-handlers.js new file mode 100644 index 0000000..5a7c689 --- /dev/null +++ b/src/skill/apl-handlers.js @@ -0,0 +1,44 @@ +const { CreateAPLEventHandler } = require("./alexa-helper.js"); + +const Devices = require("../playlist/devices.js"); + +const PlaybackPaused = CreateAPLEventHandler( + "onPlaybackPaused", + Devices.doPause +); + +const PlaybackStarted = CreateAPLEventHandler( + "onPlaybackStarted", + Devices.onPlaybackStarted +); + +const PlaybackFinished = CreateAPLEventHandler( + "onPlaybackFinished", + Devices.onPlaybackFinished +); + +const PlaybackFailed = CreateAPLEventHandler( + "onPlaybackFailed", + Devices.onPlaybackFailed +); + +const PlayPrevious = CreateAPLEventHandler( + "onPlayPrevious", + Devices.doPlayPrevious +); + +const PlayNext = CreateAPLEventHandler( + "onPlayNext", + Devices.doPlayNext +); + +//Devices.onPlaybackStopped + +module.exports = { + PlaybackPaused, + PlaybackStarted, + PlaybackFinished, + PlaybackFailed, + PlayPrevious, + PlayNext +}; diff --git a/src/skill/artist-intents.js b/src/skill/artist-intents.js index 1c9b663..a82e8d2 100644 --- a/src/skill/artist-intents.js +++ b/src/skill/artist-intents.js @@ -6,6 +6,8 @@ const JellyFin = require("../jellyfin-api.js"); const Devices = require("../playlist/devices.js"); +const Player = require("../players/player.js"); + const { CreateQueueIntent } = require("./alexa-helper.js"); /********************************************************************************* @@ -20,18 +22,22 @@ const Processer = async function(handlerInput, action = "play", buildQueue, subm if (!slots.artistname || !slots.artistname.value) { - const speach = `I didn't catch the artist name.`; - return [{status: false, speach}]; + const speech = LANGUAGE.Value("ARTIST_NO_NAME"); + + return [{status: false, speech}]; } - console.log(`Requesting Artist: ${slots.artistname.value}`); + Logger.Debug(`[Artist Request]`, `Requested Artist ${slots.artistname.value}`); const artists = await JellyFin.Artists.Search(slots.artistname.value); if (!artists.status || !artists.items[0]) { - const speach = `I didn't find an artist called ${slots.artistname.value}.`; - return [{status: false, speach}]; + Logger.Debug(`[Artist Request]`, "Artist not found."); + + const speech = LANGUAGE.Parse("ARTIST_NOTFOUND_BY_NAME", {artist_name: slots.artistname.value}); + + return [{status: false, speech}]; } const artist = artists.items[0]; @@ -40,8 +46,11 @@ const Processer = async function(handlerInput, action = "play", buildQueue, subm if (!songs.status || !songs.items[0]) { - const speach = `I didn't find an music by the artist ${slots.artistname.value}.`; - return [{status: false, speach}]; + Logger.Debug(`[Artist Request]`, "No music found."); + + const speech = LANGUAGE.Parse("ARTIST_NO_MUSIC", {artist_name: slots.artistname.value}); + + return [{status: false, speech}]; } const then = async function(data) @@ -75,11 +84,16 @@ const PlayArtistIntent = CreateQueueIntent( [data.first, data.last] = playlist.prefixItems(items); - const directive = playlist.getPlayDirective(); + const item = playlist.getCurrentItem(); + + if (!Player.PlayItem(handlerInput, playlist, item)) + return responseBuilder.getResponse(); - var speach = `Playing songs by artist ${artist.Name}, on ${CONFIG.skill.name}`; + Logger.Info(`[Device ${playlist.Id}]`, `Playing ${item.Item.Name} by ${item.Item.AlbumArtist}`); - return responseBuilder.speak(speach).addAudioPlayerPlayDirective(...directive).getResponse(); + var speech = LANGUAGE.Parse("ARTIST_PLAYING", { artist_name: artist.Name } ); + + return responseBuilder.speak(speech).getResponse(); }, function({ requestEnvelope }, {status, items}, data) { @@ -115,11 +129,16 @@ const ShuffleArtistIntent = CreateQueueIntent( [data.first, data.last] = playlist.prefixItems(items); - const directive = playlist.getPlayDirective(); + const item = playlist.getCurrentItem(); - var speach = `Shuffling songs by artist ${artist.Name}, on ${CONFIG.skill.name}`; - - return responseBuilder.speak(speach).addAudioPlayerPlayDirective(...directive).getResponse(); + if (!Player.PlayItem(handlerInput, playlist, item)) + return responseBuilder.getResponse(); + + Logger.Info(`[Device ${playlist.Id}]`, `Playing ${item.Item.Name} by ${item.Item.AlbumArtist}`); + + var speech = LANGUAGE.Parse("ARTIST_SHUFFLE", { artist_name: artist.Name } ); + + return responseBuilder.speak(speech).getResponse(); }, function({ requestEnvelope }, {status, items}, data) { @@ -159,11 +178,14 @@ const QueueArtistIntent = CreateQueueIntent( playlist.appendItems(items); - const directive = playlist.getPlayDirective(); + const item = playlist.getCurrentItem(); - var speach = `Added ${count} songs by artist ${artist.Name}, to the queue.`; + if (Player.PlayItem(handlerInput, playlist, item)) + Logger.Info(`[Device ${playlist.Id}]`, `Playing ${item.Item.Name} by ${item.Item.AlbumArtist}`); - return responseBuilder.speak(speach).addAudioPlayerPlayDirective(...directive).getResponse(); + var speech = LANGUAGE.Parse("ARTIST_QUEUED", { artist_name: artist.Name, count } ); + + return responseBuilder.speak(speech).getResponse(); }, function({ requestEnvelope }, {status, items}, data) { diff --git a/src/skill/control-handlers.js b/src/skill/control-handlers.js index 7cc2901..2e05d4a 100644 --- a/src/skill/control-handlers.js +++ b/src/skill/control-handlers.js @@ -92,6 +92,15 @@ const PreviousButtonHandler = CreateHandler( Devices.doPlayPrevious ); +/********************************************************************************* + * Session Ended Handler + */ + +const SessionEndedHandler = CreateHandler( + "SessionEndedRequest", + Devices.doSessionEnded +); + /********************************************************************************* * Exports */ @@ -106,5 +115,6 @@ module.exports = { PlayButtonHandler, PauseButtonHandler, NextButtonHandler, - PreviousButtonHandler + PreviousButtonHandler, + SessionEndedHandler }; \ No newline at end of file diff --git a/src/skill/genre-intent.js b/src/skill/genre-intent.js index 140b6a4..283a2f6 100644 --- a/src/skill/genre-intent.js +++ b/src/skill/genre-intent.js @@ -6,6 +6,8 @@ const JellyFin = require("../jellyfin-api"); const Devices = require("../playlist/devices.js"); +const Player = require("../players/player.js"); + const { CreateQueueIntent } = require("./alexa-helper.js"); /********************************************************************************* @@ -20,18 +22,22 @@ const Processer = async function(handlerInput, action = "play", buildQueue, subm if (!slots.genre || !slots.genre.value) { - const speach = `I didn't catch the genre of music.`; - return [{status: false, speach}]; + const speech = LANGUAGE.Value("GENRE_NO_NAME"); + + return [{status: false, speech}]; } - console.log(`Requesting Genre: ${slots.genre.value}`); + Logger.Debug(`[Genre Request]`, `Requesting genre ${slots.genre.value}.`); const genres = await JellyFin.MusicGenres.Search(slots.genre.value); if (!genres.status || !genres.items[0]) { - const speach = `I didn't find a genre called ${slots.genre.value}.`; - return [{status: false, speach}]; + Logger.Debug(`[Genre Request]`, `Genre not found.`); + + const speech = LANGUAGE.Parse("GENRE_NOTFOUND_BY_NAME", {genre_name: slots.genre.value}); + + return [{status: false, speech}]; } const genreIds = genres.items.map(item => item.Id).join("|"); @@ -39,8 +45,11 @@ const Processer = async function(handlerInput, action = "play", buildQueue, subm if (!songs.status || !songs.items[0]) { - const speach = `No songs of ${slots.genre.value} where found.`; - return [{status: false, speach}]; + Logger.Debug(`[Genre Request]`, `No music found.`); + + const speech = LANGUAGE.Parse("GENRE_NO_MUSIC", {genre_name: slots.genre.value}); + + return [{status: false, speech}]; } const then = async function(data) @@ -74,14 +83,20 @@ const PlayGenreIntent = CreateQueueIntent( [data.first, data.last] = playlist.prefixItems(items); - const directive = playlist.getPlayDirective(); + const item = playlist.getCurrentItem(); + + if (!Player.PlayItem(handlerInput, playlist, item)) + return responseBuilder.getResponse(); + + Logger.Info(`[Device ${playlist.Id}]`, `Playing ${item.Item.Name} by ${item.Item.AlbumArtist}`); + + + var speech = LANGUAGE.Parse("GENRE_PLAYING", {genre_name: genres[0].Name}); - var speach = `Playing ${genres[0].Name}, on ${CONFIG.skill.name}`; - if (genres.length > 1) - var speach = `Playing ${genres[0].Name} and simular genre's, on ${CONFIG.skill.name}`; + var speech = LANGUAGE.Parse("GENRE_PLAYING_SIMULAR", {genre_name: genres[0].Name}); - return responseBuilder.speak(speach).addAudioPlayerPlayDirective(...directive).getResponse(); + return responseBuilder.speak(speech).getResponse(); }, function({ requestEnvelope }, {status, items}, data) { @@ -117,14 +132,19 @@ const ShuffleGenreIntent = CreateQueueIntent( [data.first, data.last] = playlist.prefixItems(items); - const directive = playlist.getPlayDirective(); + const item = playlist.getCurrentItem(); - var speach = `Shuffling ${genres[0].Name}, on ${CONFIG.skill.name}`; + if (!Player.PlayItem(handlerInput, playlist, item)) + return responseBuilder.getResponse(); + + Logger.Info(`[Device ${playlist.Id}]`, `Playing ${item.Item.Name} by ${item.Item.AlbumArtist}`); + + var speech = LANGUAGE.Parse("GENRE_SHUFFLE", {genre_name: genres[0].Name}); if (genres.length > 1) - var speach = `Shuffling ${genres[0].Name} and simular genre's, on ${CONFIG.skill.name}`; + var speech = LANGUAGE.Parse("GENRE_SHUFFLE_SIMULAR", {genre_name: genres[0].Name}); - return responseBuilder.speak(speach).addAudioPlayerPlayDirective(...directive).getResponse(); + return responseBuilder.speak(speech).getResponse(); }, function({ requestEnvelope }, {status, items}, data) { @@ -164,14 +184,17 @@ const QueueGenreIntent = CreateQueueIntent( playlist.appendItems(items); - const directive = playlist.getPlayDirective(); + const item = playlist.getCurrentItem(); + + if (Player.PlayItem(handlerInput, playlist, item)) + Logger.Info(`[Device ${playlist.Id}]`, `Playing ${item.Item.Name} by ${item.Item.AlbumArtist}`); - var speach = `Added ${count}, ${genres[0].Name} songs, to the queue.`; + var speech = LANGUAGE.Parse("GENRE_QUEUED", {genre_name: genres[0].Name, count}); if (genres.length > 1) - var speach = `Added ${count}, ${genres[0].Name} and simular songs, to the queue.`; + var speech = LANGUAGE.Parse("GENRE_QUEUED_SIMULAR", {genre_name: genres[0].Name, count}); - return responseBuilder.speak(speach).addAudioPlayerPlayDirective(...directive).getResponse(); + return responseBuilder.speak(speech).getResponse(); }, function({ requestEnvelope }, {status, items}, data) { diff --git a/src/skill/playlist-intents.js b/src/skill/playlist-intents.js index 3e8323c..d036059 100644 --- a/src/skill/playlist-intents.js +++ b/src/skill/playlist-intents.js @@ -6,6 +6,8 @@ const JellyFin = require("../jellyfin-api"); const Devices = require("../playlist/devices.js"); +const Player = require("../players/player.js"); + const { CreateQueueIntent } = require("./alexa-helper.js"); /********************************************************************************* @@ -20,16 +22,18 @@ const Processer = async function(handlerInput, action = "play", buildQueue, subm if (!slots.playlistname || !slots.playlistname.value) { - const speach = `I didn't catch the playlist name.`; - return [{status: false, speach}]; + const speech = LANGUAGE.Value("PLAYLIST_NO_NAME"); + + return [{status: false, speech}]; } - const playlists = await JellyFin.Playlists.Search(slots.playlistname.value); + const playlists = await JellyFin.Playlists.FuzzySearch(slots.playlistname.value); if (!playlists.status || !playlists.items[0]) { - const speach = `I didn't find a playlist called ${slots.playlistname.value}.`; - return [{status: false, speach}]; + const speech = LANGUAGE.Parse("PLAYLIST_NOTFOUND_BY_NAME", {playlist_name: slots.playlistname.value}); + + return [{status: false, speech}]; } const playlist = playlists.items[0]; @@ -38,8 +42,9 @@ const Processer = async function(handlerInput, action = "play", buildQueue, subm if (!songs.status || !songs.items[0]) { - const speach = `The playlist ${slots.artistname.value} is empty.`; - return [{status: false, speach}]; + const speech = LANGUAGE.Parse("PLAYLIST_NO_MUSIC", {playlist_name: slots.playlistname.value}); + + return [{status: false, speech}]; } const then = async function(data) @@ -73,11 +78,16 @@ const PlayPlaylistIntent = CreateQueueIntent( [data.first, data.last] = _playlist.prefixItems(items); - const directive = _playlist.getPlayDirective(); + const item = _playlist.getCurrentItem(); + + if (!Player.PlayItem(handlerInput, _playlist, item)) + return responseBuilder.getResponse(); - var speach = `Playing songs from playlist ${playlist.Name}, on ${CONFIG.skill.name}`; + Logger.Info(`[Device ${_playlist.Id}]`, `Playing ${item.Item.Name} by ${item.Item.AlbumArtist}`); - return responseBuilder.speak(speach).addAudioPlayerPlayDirective(...directive).getResponse(); + const speech = LANGUAGE.Parse("PLAYLIST_PLAYING", {playlist_name: playlist.Name}); + + return responseBuilder.speak(speech).getResponse(); }, function({ requestEnvelope }, {status, items}, data) { @@ -113,11 +123,16 @@ const ShufflePlaylistIntent = CreateQueueIntent( [data.first, data.last] = _playlist.prefixItems(items); - const directive = _playlist.getPlayDirective(); + const item = _playlist.getCurrentItem(); + + if (!Player.PlayItem(handlerInput, _playlist, item)) + return responseBuilder.getResponse(); - var speach = `Shuffling songs from playlist ${playlist.Name}, on ${CONFIG.skill.name}`; + Logger.Info(`[Device ${_playlist.Id}]`, `Playing ${item.Item.Name} by ${item.Item.AlbumArtist}`); + + const speech = LANGUAGE.Parse("PLAYLIST_SHUFFLE", {playlist_name: playlist.Name}); - return responseBuilder.speak(speach).addAudioPlayerPlayDirective(...directive).getResponse(); + return responseBuilder.speak(speech).getResponse(); }, function({ requestEnvelope }, {status, items}, data) { @@ -157,11 +172,14 @@ const QueuePlaylistIntent = CreateQueueIntent( _playlist.appendItems(items); - const directive = _playlist.getPlayDirective(); + const item = _playlist.getCurrentItem(); + + if (Player.PlayItem(handlerInput, _playlist, item)) + Logger.Info(`[Device ${playlist.Id}]`, `Playing ${item.Item.Name} by ${item.Item.AlbumArtist}`); - var speach = `Added ${count} songs from playlist ${playlist.Name}, to the queue.`; + const speech = LANGUAGE.Parse("PLAYLIST_SHUFFLE", {playlist_name: playlist.Name, count}); - return responseBuilder.speak(speach).addAudioPlayerPlayDirective(...directive).getResponse(); + return responseBuilder.speak(speech).getResponse(); }, function({ requestEnvelope }, {status, items}, data) { diff --git a/src/skill/query-intents.js b/src/skill/query-intents.js index 0ccee88..17da8f4 100644 --- a/src/skill/query-intents.js +++ b/src/skill/query-intents.js @@ -20,14 +20,12 @@ const WhatThisIntent = CreateIntent( const item = playlist.getCurrentItem(); - var speach = "There is currently no song playing."; + var speech = "There is currently no song playing."; if (item) - speach = `The current song is ${item.Item.Name} by ${item.Item.AlbumArtist}`; - - console.log(speach); + speech = `The current song is ${item.Item.Name} by ${item.Item.AlbumArtist}`; - return responseBuilder.speak(speach).getResponse(); + return responseBuilder.speak(speech).getResponse(); } ); diff --git a/src/skill/skill.js b/src/skill/skill.js index e5b4612..33d4dfd 100644 --- a/src/skill/skill.js +++ b/src/skill/skill.js @@ -6,6 +6,8 @@ const Alexa = require('ask-sdk-core'); const ControlHandlers = require("./control-handlers.js"); +const APLHandlers = require("./apl-handlers.js"); + const ControlIntents = require("./control-intents.js"); const AlbumIntents = require("./album-intents.js"); @@ -34,15 +36,15 @@ const ErrorHandler = { error = requestEnvelope.request.error; const type = Alexa.getRequestType(handlerInput.requestEnvelope); - console.error(`Error handled: ${type}`); + Logger.Error(`Error handled: ${type}`); if (type == "IntentRequest") { const intent = Alexa.getIntentName(handlerInput.requestEnvelope); - console.error(`Intent: ${intent}`); + Logger.Error(`Intent: ${intent}`); } - console.error(error); + Logger.Error(error); return responseBuilder.getResponse(); } @@ -64,6 +66,14 @@ const skill = Alexa.SkillBuilders.custom() ControlHandlers.PauseButtonHandler, ControlHandlers.NextButtonHandler, ControlHandlers.PreviousButtonHandler, + ControlHandlers.SessionEndedHandler, + + APLHandlers.PlaybackPaused, + APLHandlers.PlaybackStarted, + APLHandlers.PlaybackFinished, + APLHandlers.PlaybackFailed, + APLHandlers.PlayPrevious, + APLHandlers.PlayNext, ControlIntents.StopIntent, ControlIntents.CancelIntent, @@ -90,7 +100,8 @@ const skill = Alexa.SkillBuilders.custom() SongIntents.QueueSongIntent, GenreIntents.QueueGenreIntent, - QueryIntents.WhatThisIntent + QueryIntents.WhatThisIntent, + ErrorHandler //This is for debugging, since not all errors are passed into addErrorHandlers(ErrorHandler) ).addErrorHandlers( ErrorHandler ).withSkillId(CONFIG.skill.id).create(); diff --git a/src/skill/song-intents.js b/src/skill/song-intents.js index cd815e1..1d982ec 100644 --- a/src/skill/song-intents.js +++ b/src/skill/song-intents.js @@ -4,6 +4,8 @@ const JellyFin = require("../jellyfin-api"); const Devices = require("../playlist/devices.js"); +const Player = require("../players/player.js"); + const { CreateQueueIntent } = require("./alexa-helper.js"); /********************************************************************************* @@ -18,18 +20,22 @@ const Processor = async function(handlerInput, action = "play") if (!slots.songname || !slots.songname.value) { - const speach = `I didn't catch the song name.`; - return [{status: false, speach}]; + const speech = LANGUAGE.Value("SONG_NO_NAME"); + + return [{status: false, speech}]; } - console.log(`Requesting Song: ${slots.songname.value}`); + Logger.Debug(`[Music Request]`, `Requesting music ${slots.songname.value}.`); const songs = await JellyFin.Music.Search(slots.songname.value); if (!songs.status || !songs.items[0]) { - const speach = `I didn't find a song called ${slots.songname.value}.`; - return [{status: false, speach}]; + Logger.Debug(`[Music Request]`, "Music not found."); + + const speech = LANGUAGE.Parse("SONG_NOTFOUND_BY_NAME", {song_name: slots.songname.value}); + + return [{status: false, speech}]; } var artist = undefined; @@ -37,14 +43,17 @@ const Processor = async function(handlerInput, action = "play") if (slots.artistname && slots.artistname.value) { - console.log(`Requesting Artist for Song: ${slots.artistname.value}`); + Logger.Debug(`[Music Request]`, `Requesting artist ${slots.artistname.value}.`); const artists = await JellyFin.Artists.Search(slots.artistname.value); if (!artists.status || !artists.items[0]) { - const speach = `I didn't find an artist called ${slots.artistname.value}.`; - return [{status: false, speach}]; + Logger.Debug(`[Music Request]`, "No artist found."); + + const speech = LANGUAGE.Parse("ARTIST_NOTFOUND_BY_NAME", {artist_name: slots.artistname.value}); + + return [{status: false, speech}]; } artist = artists.items[0]; @@ -58,7 +67,6 @@ const Processor = async function(handlerInput, action = "play") { if (item.AlbumArtist && item.AlbumArtist.toLowerCase() == artist.Name.toLowerCase()) { - console.log("Found ", item.Name); album = item; break; } @@ -72,14 +80,31 @@ const Processor = async function(handlerInput, action = "play") { const suggestion = `${song.Name} by ${song.AlbumArtist}`; - const speach = `I didn't find a track called ${slots.songname.value} by ${artist.name || slots.artistname.value}, you might have meant ${suggestion}.`; + Logger.Debug(`[Music Request]`, `Returning suggestion ${song.Name} by ${song.AlbumArtist}.`); + + const speech = LANGUAGE.Parse("SONG_NOTFOUND_SUGGEST", + { + song_name: slots.songname.value, + artist_name: artist.name || slots.artistname.value, + suggestion_name: song.Name, + suggestion_artist: song.AlbumArtist + } + ); - return [{status: false, speach}]; + return [{status: false, speech}]; } else { - const speach = `I didn't find a track called ${slots.albumname.value} by ${artist.name || slots.artistname.value}.`; - return [{status: false, speach}]; + Logger.Debug(`[Music Request]`, "Music not found."); + + const speech = LANGUAGE.Parse("SONG_NOTFOUND_BY_NAME_AND_ARTIST", + { + song_name: slots.songname.value, + artist_name: artist.name || slots.artistname.value + } + ); + + return [{status: false, speech}]; } } } @@ -106,13 +131,25 @@ const PlaySongIntent = CreateQueueIntent( playlist.prefixItems(items); - const directive = playlist.getPlayDirective(); + const item = playlist.getCurrentItem(); - var speach = `Playing ${items[0].Name}, on ${CONFIG.name}`; - - if (items[0].AlbumArtist) speach = `Playing ${items[0].Name} by ${items[0].AlbumArtist}, on ${CONFIG.skill.name}`; + if (!Player.PlayItem(handlerInput, playlist, item)) + return responseBuilder.getResponse(); + + Logger.Info(`[Device ${playlist.Id}]`, `Playing ${item.Item.Name} by ${item.Item.AlbumArtist}`); - return responseBuilder.speak(speach).addAudioPlayerPlayDirective(...directive).getResponse(); + + var speech = LANGUAGE.Parse("SONG_PLAYING", { song_name: items[0].Name } ); + + if (items[0].AlbumArtist) + speech = LANGUAGE.Parse("SONG_PLAYING_BY_ARTIST", + { + song_name: items[0].Name, + song_artist: items[0].AlbumArtist + } + ); + + return responseBuilder.speak(speech).getResponse(); } ); @@ -132,13 +169,22 @@ const QueueSongIntent = CreateQueueIntent( playlist.appendItems(items); - const directive = playlist.getPlayDirective(); + const item = playlist.getCurrentItem(); - var speach = `Added ${items[0].Name}, to the queue.`; - - if (items[0].AlbumArtist) speach = `Added ${items[0].Name} by ${items[0].AlbumArtist}, to the queue.`; + if (Player.PlayItem(handlerInput, playlist, item)) + Logger.Info(`[Device ${playlist.Id}]`, `Playing ${item.Item.Name} by ${item.Item.AlbumArtist}`); + + var speech = LANGUAGE.Parse("SONG_QUEUED", { song_name: items[0].Name } ); + + if (items[0].AlbumArtist) + speech = LANGUAGE.Parse("SONG_QUEUED_BY_ARTIST", + { + song_name: items[0].Name, + song_artist: items[0].AlbumArtist + } + ); - return responseBuilder.speak(speach).addAudioPlayerPlayDirective(...directive).getResponse(); + return responseBuilder.speak(speech).getResponse(); } ); From eeb47f38cb450f8b64d13a7fd8f22967fe3f97dd Mon Sep 17 00:00:00 2001 From: rusketh Date: Mon, 29 Dec 2025 21:13:49 +0000 Subject: [PATCH 08/10] Rewrote the Jellyfin API for feaster searching and reliability. Removed the cover image from the APL player, it was ugly. --- src/jellyfin-api.js | 281 +++++++++------------------------- src/languages/DE.json | 42 +++++ src/languages/EN.json | 27 +--- src/languages/NL.json | 28 +--- src/logger.js | 7 +- src/players/visual-player.js | 92 ++++------- src/playlist/devices.js | 68 ++++---- src/playlist/save-file.js | 47 +++--- src/skill/album-intents.js | 106 ++++++------- src/skill/apl-handlers.js | 8 + src/skill/artist-intents.js | 68 ++++---- src/skill/genre-intent.js | 78 ++++------ src/skill/playlist-intents.js | 70 ++++----- src/skill/song-intents.js | 79 ++++------ 14 files changed, 373 insertions(+), 628 deletions(-) create mode 100644 src/languages/DE.json diff --git a/src/jellyfin-api.js b/src/jellyfin-api.js index 083a99a..0fcab06 100644 --- a/src/jellyfin-api.js +++ b/src/jellyfin-api.js @@ -1,259 +1,118 @@ -const fuzzball = require('fuzzball'); +const https = require('https'); /********************************************************************************* - * Request Items from API - * https://api.jellyfin.org/ + * API Request */ -const Request = async function(endpoint, params, ...args) -{ - const url = new URL(endpoint, CONFIG.jellyfin.local); - - if (params) - { - params = Object.assign({}, params, ...args); - - for (const key in params) - { - const value = params[key]; - - if (value == null) - continue; - else if (value instanceof Array) - value.forEach(item => url.searchParams.append(key, String(item))); - else - url.searchParams.append(key, String(value)); - } - } - - const response = await fetch( - url, - { - method: 'GET', - headers: - { - Authorization: `MediaBrowser Token="${CONFIG.jellyfin.key}"`, - 'Content-Type': 'application/json', - 'Accept': 'application/json' - } - } - ); +const Agent = new https.Agent({ keepAlive: true }); - if (!response.ok) +const RequestOptions = { + method: 'GET', + agent: Agent, + timeout: 5000, + headers: { - Logger.Error("[JellyFin API]", `No Response from Server (path: ${url.pathname}).`); - - //TODO: Trace Error Message? - - return {status: false, error: await response.text()}; + Authorization: `MediaBrowser Token="${CONFIG.jellyfin.key}"`, + 'Content-Type': 'application/json', + 'Accept': 'application/json' } - - var result = await response.json(); - - return {status: true, items: result.Items, index: result.StartIndex, count: result.TotalRecordCount }; }; -/********************************************************************************* - * Request Artists - */ +const MakeAPIRequest = async function (url, { ids, query, albums, artists, genres, parent }, startIndex, limit) { -const Artists = async (...params) => await Request("/Artists", ...params); + url.searchParams.append("limit", limit || CONFIG.jellyfin.limit); -Artists.ByName = async (artist, ...params) => await Request(`/Artists/${artist}`, ...params); + if (query) url.searchParams.append("searchTerm", query); + if (startIndex) url.searchParams.append("startIndex", startIndex); + if (ids) url.searchParams.append("ids", ids.join(",")); + if (albums) url.searchParams.append("albumIds", albums.join(",")); + if (artists) url.searchParams.append("artistIds", artists.join(",")); + if (genres) url.searchParams.append("genreIds", genres.join(",")); + if (parent) url.searchParams.append("parentId", parent); -Artists.AlbumArtists = async (...params) => await Request("/Artists/AlbumArtists", ...params); + Logger.Debug("[JellyFin API]", `Requesting ${url.pathname} with ${url.searchParams.toString()}.`); -/********************************************************************************* - * Request Items - */ - -const Items = async (...params) => await Request("/Items", { Recursive:true }, ...params); - -Items.Artists = Artists; - -Items.Music = async (...params) => await Items({includeItemTypes: "Audio"}, ...params); - -Items.Albums = async (...params) => await Items({includeItemTypes: "MusicAlbum"}, ...params); - -Items.MusicGenres = async (...params) => await Request("/MusicGenres", ...params); - -Items.Playlists = async (...params) => await Items({includeItemTypes: "Playlist"}, ...params); - -/********************************************************************************* - * Search Function - */ + try { + const response = await fetch(url, RequestOptions); -const Search = async function(api, field, query, ...perams) -{ - const result = await api(...perams); - - if (!result.status) return result; - - query = query.toLowerCase(); - - const collator = new Intl.Collator(undefined, {sensitivity: "accent", usage: "search"}); - - result.items = result.items.filter((item) => { - const value = item[field]; - - if (value == null) return false; - - if (typeof value === 'string') - return collator.compare(value.toLowerCase(), query) === 0 || value.toLowerCase().includes(query); - - if (value instanceof Array) - for(v of value) - if (collator.compare(v.toLowerCase(), query) === 0 || v.toLowerCase().includes(query)) return true; - - return false; - }); - - - return result; -}; - -Items.Music.Search = async (query, ...params) => await Search(Items.Music, "Name", query, ...params); - -Items.Albums.Search = async (query, ...params) => await Search(Items.Albums, "Name", query, ...params); - -Items.Artists.Search = async (query, ...params) => await Search(Items.Artists, "Name", query, ...params); - -Items.MusicGenres.Search = async (query, ...params) => await Search(Items.MusicGenres, "Name", query, ...params); - -Items.Playlists.Search = async (query, ...params) => await Search(Items.Playlists, "Name", query, ...params); - -/********************************************************************************* - * Fuzzy Search Function: Warning this is SLOW! - */ + if (!response.ok) { + Logger.Error("[JellyFin API]", `No Response from Server (path: ${url.pathname}) ${await response.text()}.`); + return { status: false }; + } -const FuzzySearch = async function(api, field, query, ...perams) -{ - const result = await api(perams); + var result = await response.json(); - if (!result.status) return result; + Logger.Debug("[JellyFin API]", `Response returned ${result.Items.length} items.`); - result.items.forEach(item => item.score = fuzzball.token_sort_ratio(query, item[field])); - result.items = result.items.filter((item) => item.score > 60); - result.items.sort((a, b) => b.score - a.score); + return { status: true, items: result.Items, index: result.StartIndex, count: result.TotalRecordCount }; + } + catch (error) { + if (error.name == "SyntaxError") + Logger.Error("[JellyFin API]", `Failed to parse response from Server (path: ${url.pathname}).`); + else + Logger.Error("[JellyFin API]", `Failed to parse response from Server (path: ${url.pathname}).`); - return result; + return { status: false }; + } }; -Items.Music.FuzzySearch = async (query, ...params) => await FuzzySearch(Items.Music, "Name", query, ...params); - -Items.Albums.FuzzySearch = async (query, ...params) => await FuzzySearch(Items.Albums, "Name", query, ...params); - -Items.Artists.FuzzySearch = async (query, ...params) => await FuzzySearch(Items.Artists, "Name", query, ...params); - -Items.MusicGenres.FuzzySearch = async (query, ...params) => await FuzzySearch(Items.MusicGenres, "Name", query, ...params); - -Items.Playlists.FuzzySearch = async (query, ...params) => await FuzzySearch(Items.Playlists, "Name", query, ...params); - /********************************************************************************* - * Find Albums + * Query Endpoints */ -Items.Albums.ByGenre = async function(query, ...params) -{ - const result = await Items.MusicGenres.FuzzySearch(query); - - if (!result.status) return result; +const Music = async function (query, startIndex, limit) { + const url = new URL("/Items", CONFIG.jellyfin.local); - const result2 = await Items.Albums({genres: result.items.map(item => item.Name).join("|")}, ...params); + url.searchParams.append("Recursive", true); + url.searchParams.append("includeItemTypes", "Audio"); + url.searchParams.append("fields", "Id,Name,RunTimeTicks,Artists,Album,AlbumId,PrimaryImageTag,IndexNumber,Genres"); - if (!result2.status) return result2; - result2.genres = result.items; - - return result2; + return await MakeAPIRequest(url, query, startIndex, limit); }; -Items.Albums.ByArist = async function(query, ...params) -{ - const result = await Items.Artists.Search(query); - - if (!result.status) return result; - - const result2 = await Items.Albums({artistIds: result.items.map(item => item.Id).join("|")}, ...params); - - if (!result2.status) return result2; - - result2.albums = result.items; - - return result2; -}; - -/********************************************************************************* - * Find Artist - */ - -Items.Artists.ByGenre = async function(query, ...params) -{ - const result = await Items.MusicGenres.FuzzySearch(query); - - if (!result.status) return result; - - const result2 = await Items.Artists({genres: result.items.map(item => item.Name).join("|")}, ...params); - - if (!result2.status) return result2; +const Artists = async function (query, startIndex, limit) { + const url = new URL("/Artists", CONFIG.jellyfin.local); - result2.artists = result.items; + url.searchParams.append("fields", "Id,Name,PrimaryImageTag,Genres"); - return result2; + return await MakeAPIRequest(url, query, startIndex, limit); }; -/********************************************************************************* - * Find Songs - */ - -Items.Music.ByGenre = async function(query, ...params) -{ - const result = await Items.MusicGenres.FuzzySearch(query); - - if (!result.status) return result; +const Albums = async function (query, startIndex, limit) { + const url = new URL("/Items", CONFIG.jellyfin.local); - const result2 = await Items.Music({genres: result.items.map(item => item.Name).join("|")}, ...params); + url.searchParams.append("includeItemTypes", "MusicAlbum"); + url.searchParams.append("fields", "Id,Name,PrimaryImageTag,Genres"); - if (!result2.status) return result2; - - result2.genres = result.items; - - return result2; + return await MakeAPIRequest(url, query, startIndex, limit); }; -Items.Music.ByArist = async function(query, ...params) -{ - const result = await Items.Artists.Search(query); - - if (!result.status) return result; - - const result2 = await Items.Music({artistIds: result.items.map(item => item.Id).join("|")}, ...params); - - if (!result2.status) return result2; +const MusicGenres = async function (query, startIndex, limit) { + const url = new URL("/MusicGenres", CONFIG.jellyfin.local); - result2.artists = result.items; + url.searchParams.append("fields", "Id,Name"); - return result2; + return await MakeAPIRequest(url, query, startIndex, limit); }; -Items.Music.ByAlbum = async function(query, ...params) -{ - const result = await Items.Albums.Search(query); +const Playlists = async function (query, startIndex, limit) { + const url = new URL("/Items", CONFIG.jellyfin.local); - if (!result.status) return result; + url.searchParams.append("fields", "Id,Name"); + url.searchParams.append("includeItemTypes", "Playlist"); - const result2 = await Items.Music({albumIds: result.items.map(item => item.Id).join("|")}, ...params); - - if (!result2.status) return result2; - - result2.albums = result.items; - - return result2; + return await MakeAPIRequest(url, query, startIndex, limit); }; - /********************************************************************************* * Exports */ -module.exports = Items; \ No newline at end of file +module.exports = { + Music, + Artists, + Albums, + MusicGenres, + Playlists +}; \ No newline at end of file diff --git a/src/languages/DE.json b/src/languages/DE.json new file mode 100644 index 0000000..278da59 --- /dev/null +++ b/src/languages/DE.json @@ -0,0 +1,42 @@ +{ + "SONG_NO_NAME": "Ich habe den Songnamen nicht verstanden.", + "SONG_NOTFOUND_BY_NAME": "Ich habe keinen Song namens %song_name% gefunden.", + "SONG_NOTFOUND_BY_NAME_AND_ARTIST": "Ich habe keinen Song mit dem Titel %song_name% von %artist_name% gefunden.", + "SONG_NOTFOUND_SUGGEST": "Ich habe keinen Song mit dem Titel %song_name% von %artist_name% gefunden, vielleicht meinten Sie %suggestion_name% von %suggestion_artist%.", + "SONG_PLAYING": "Spiele %song_name% mit %skill_name%.", + "SONG_PLAYING_BY_ARTIST": "Spiele %song_name% von %artist_name% mit %skill_name%.", + "SONG_QUEUED": "%song_name% zur Warteschlange hinzugefügt.", + "SONG_QUEUED_BY_ARTIST": "%song_name% von %artist_name% zur Warteschlange hinzugefügt.", + "ALBUM_NO_NAME": "Ich habe den Albumtitel nicht verstanden.", + "ALBUM_NOTFOUND_BY_NAME": "Ich habe kein Album namens %album_name% gefunden.", + "ALBUM_NOTFOUND_SUGGEST": "Ich habe kein Album namens %album_name% von %artist_name% gefunden, vielleicht meinten Sie %suggestion_name% von %suggestion_artist%.", + "ALBUM_NOTFOUND_BY_NAME_AND_ARTIST": "Ich habe kein Album namens %album_name% von %artist_name% gefunden.", + "ALBUM_NO_MUSIC": "Ich habe keine Musik im Album %album_name% gefunden.", + "ALBUM_PLAYING": "Spiele das Album %album_name% mit %skill_name%.", + "ALBUM_PLAYING_BY_ARTIST": "Spiele das Album %album_name% von %artist_name% mit %skill_name%.", + "ALBUM_SHUFFLE": "Spiele das Album %album_name% in zufälliger Reihenfolge mit %skill_name%.", + "ALBUM_SHUFFLE_BY_ARTIST": "Spiele das Album %album_name% von %artist_name% in zufälliger Reihenfolge mit %skill_name%.", + "ALBUM_QUEUED": "Album %album_name% zur Warteschlange hinzugefügt.", + "ALBUM_QUEUED_BY_ARTIST": "Album %album_name% von %artist_name% zur Warteschlange hinzugefügt.", + "ARTIST_NO_NAME": "Ich habe den Namen des Künstlers nicht verstanden.", + "ARTIST_NOTFOUND_BY_NAME": "Ich habe keinen Künstler namens %artist_name% gefunden.", + "ARTIST_NO_MUSIC": "Ich habe keine Musik des Künstlers %artist_name% gefunden.", + "ARTIST_PLAYING": "Spiele Lieder von %artist_name% mit %skill_name%.", + "ARTIST_SHUFFLE": "Spiele zufällig Lieder von %artist_name% mit %skill_name%.", + "ARTIST_QUEUED": "%count% Lieder des Künstlers %artist_name% zur Warteschlange hinzugefügt.", + "GENRE_NO_NAME": "Ich habe das Musikgenre nicht verstanden.", + "GENRE_NOTFOUND_BY_NAME": "Ich habe kein Genre namens %genre_name% gefunden.", + "GENRE_NO_MUSIC": "Es wurden keine Lieder im Genre %genre_name% gefunden.", + "GENRE_PLAYING": "Spiele %genre_name% mit %skill_name%.", + "GENRE_PLAYING_SIMULAR": "Spiele %genre_name% und ähnliche Genres mit %skill_name%.", + "GENRE_SHUFFLE": "Spiele %genre_name% zufällig mit %skill_name%.", + "GENRE_SHUFFLE_SIMULAR": "Spiele %genre_name% und ähnliche Genres zufällig mit %skill_name%.", + "GENRE_QUEUED": "%count% %genre_name%-Lieder zur Warteschlange hinzugefügt.", + "GENRE_QUEUED_SIMULAR": "%count% ähnliche %genre_name%-Lieder zur Warteschlange hinzugefügt.", + "PLAYLIST_NO_NAME": "Ich habe den Namen der Wiedergabeliste nicht verstanden.", + "PLAYLIST_NOTFOUND_BY_NAME": "Ich habe keine Wiedergabeliste namens %playlist_name% gefunden.", + "PLAYLIST_NO_MUSIC": "Ich habe keine Musik in der Wiedergabeliste %playlist_name% gefunden.", + "PLAYLIST_PLAYING": "Spiele Lieder aus der Wiedergabeliste %playlist_name% mit %skill_name%.", + "PLAYLIST_SHUFFLE": "Spiele Lieder aus der Wiedergabeliste %playlist_name% zufällig mit %skill_name%.", + "PLAYLIST_QUEUED": "%count% Lieder aus der Wiedergabeliste %playlist_name% zur Warteschlange hinzugefügt." +} \ No newline at end of file diff --git a/src/languages/EN.json b/src/languages/EN.json index 6068c4b..85d87e5 100644 --- a/src/languages/EN.json +++ b/src/languages/EN.json @@ -3,59 +3,40 @@ "SONG_NOTFOUND_BY_NAME": "I didn't find a song called %song_name%.", "SONG_NOTFOUND_BY_NAME_AND_ARTIST": "I didn't find a song called %song_name% by %artist_name%.", "SONG_NOTFOUND_SUGGEST": "I didn't find a track called %song_name% by %artist_name%, you might have meant %suggestion_name% by %suggestion_artist%.", - "SONG_PLAYING": "Playing %song_name%, on %skill_name%.", "SONG_PLAYING_BY_ARTIST": "Playing %song_name% by %artist_name%, on %skill_name%.", "SONG_QUEUED": "Added %song_name% to the queue.", "SONG_QUEUED_BY_ARTIST": "Added %song_name% by %artist_name% to the queue.", - - - "ALBUM_NO_NAME": "I didn't catch the album name.", "ALBUM_NOTFOUND_BY_NAME": "I didn't find an album called %album_name%", "ALBUM_NOTFOUND_SUGGEST": "I didn't find an album called %album_name% by %artist_name%, you might have meant %suggestion_name% by %suggestion_artist%.", "ALBUM_NOTFOUND_BY_NAME_AND_ARTIST": "I didn't find an album called %album_name% by %artist_name%.", "ALBUM_NO_MUSIC": "I didn't find any music in the album %album_name%.", - "ALBUM_PLAYING": "Playing album %album_name%, on %skill_name%.", "ALBUM_PLAYING_BY_ARTIST": "Playing album %album_name% by %artist_name%, on %skill_name%.", "ALBUM_SHUFFLE": "Shuffling album %album_name%, on %skill_name%.", "ALBUM_SHUFFLE_BY_ARTIST": "Shuffling album %album_name% by %artist_name%, on %skill_name%.", "ALBUM_QUEUED": "Added album %album_name% to the queue.", "ALBUM_QUEUED_BY_ARTIST": "Added album %album_name% by %artist_name% to the queue.", - - - "ARTIST_NO_NAME": "I didn't catch the song name.", "ARTIST_NOTFOUND_BY_NAME": "I didn't find an artist called %artist_name%.", "ARTIST_NO_MUSIC": "I didn't find any music by the artist %artist_name%.", - "ARTIST_PLAYING": "Playing songs by artist %artist_name%, on %skill_name%.", "ARTIST_SHUFFLE": "Shuffling songs by artist %artist_name%, on %skill_name%.", "ARTIST_QUEUED": "Added %count% songs by artist %artist_name% to the queue.", - - - "GENRE_NO_NAME": "I didn't catch the genre of music.", "GENRE_NOTFOUND_BY_NAME": "I didn't find a genre called %genre_name%.", "GENRE_NO_MUSIC": "No songs of %genre_name% where found.", - - "GENRE_PLAYING": "Playing %grene_name%, on %skill_name%.", - "GENRE_PLAYING_SIMULAR": "Playing %grene_name% and simular genres, on %skill_name%.", - "GENRE_SHUFFLE": "Shuffling %grene_name%, on %skill_name%.", - "GENRE_SHUFFLE_SIMULAR": "Shuffling %grene_name% and simular genres, on %skill_name%.", + "GENRE_PLAYING": "Playing %genre_name%, on %skill_name%.", + "GENRE_PLAYING_SIMULAR": "Playing %genre_name% and simular genres, on %skill_name%.", + "GENRE_SHUFFLE": "Shuffling %genre_name%, on %skill_name%.", + "GENRE_SHUFFLE_SIMULAR": "Shuffling %genre_name% and simular genres, on %skill_name%.", "GENRE_QUEUED": "Added %count% %genre_name% songs to the queue.", "GENRE_QUEUED_SIMULAR": "Added %count% %genre_name% and simular songs to the queue.", - - - "PLAYLIST_NO_NAME": "I didn't catch the playlist name.", "PLAYLIST_NOTFOUND_BY_NAME": "I didn't find a playlist called %playlist_name%.", "PLAYLIST_NO_MUSIC": "I didn't find any music in the playlist %playlist_name%.", - - "PLAYLIST_PLAYING": "Playing songs from playlist %playlist_name%, on %skill_name%.", "PLAYLIST_SHUFFLE": "Shuffling songs from playlist %playlist_name%, on %skill_name%.", "PLAYLIST_QUEUED": "Added %count% songs from playlist %playlist_name%, to the queue." - } \ No newline at end of file diff --git a/src/languages/NL.json b/src/languages/NL.json index a0324c7..02157b8 100644 --- a/src/languages/NL.json +++ b/src/languages/NL.json @@ -3,60 +3,40 @@ "SONG_NOTFOUND_BY_NAME": "Ik heb geen nummer gevonden met de naam %song_name%.", "SONG_NOTFOUND_BY_NAME_AND_ARTIST": "Ik heb geen nummer gevonden met de titel %song_name% van %artist_name%.", "SONG_NOTFOUND_SUGGEST": "Ik heb geen nummer gevonden met de titel %song_name% van %artist_name%, misschien bedoelde je %suggestion_name% van %suggestion_artist%.", - "SONG_PLAYING": "Speeld %song_name% met %skill_name%.", "SONG_PLAYING_BY_ARTIST": "speeld %song_name% van %artist_name% met %skill_name%.", "SONG_QUEUED": "%song_name% Toegevoegd aan de wachtrij.", "SONG_QUEUED_BY_ARTIST": "%song_name% van %artist_name% Toegevoegd aan de wachtrij.", - - - "ALBUM_NO_NAME": "Ik verstond het album titel niet.", "ALBUM_NOTFOUND_BY_NAME": "Ik heb geen album gevonden met de naam %album_name%", "ALBUM_NOTFOUND_SUGGEST": "Ik heb geen album gevonden met de naam %album_name% van %artist_name%, misschien bedoelde je %suggestion_name% van %suggestion_artist%.", "ALBUM_NOTFOUND_BY_NAME_AND_ARTIST": "Ik heb geen album gevonden met de naam %album_name% van %artist_name%.", "ALBUM_NO_MUSIC": "ik heb geen muziek met het albumnaam %album_name% gevonden.", - - "ALBUM_PLAYING": "speeld %album_name% met %skill_name%.", "ALBUM_PLAYING_BY_ARTIST": "Speeld %album_name% van %artist_name% met %skill_name%.", "ALBUM_SHUFFLE": "het album %album_name% wordt willekeurig afgespeeld met %skill_name%.", "ALBUM_SHUFFLE_BY_ARTIST": "het album %album_name% van %artist_name% wordt willekeurig afgespeeld met %skill_name%.", "ALBUM_QUEUED": "het album %album_name% is toegevoegt aan de wachtrij.", "ALBUM_QUEUED_BY_ARTIST": "het album %album_name% van %artist_name% toegevoegt aan de wachtrij.", - - - "ARTIST_NO_NAME": "Ik heb geen titel van het liedje gevonden.", "ARTIST_NOTFOUND_BY_NAME": "Ik heb geen artiest gevonden die %artist_name% heet.", "ARTIST_NO_MUSIC": "Ik heb geen muziek van de artiest %artist_name% kunnen vinden.", - "ARTIST_PLAYING": "Speelt liedje van %artist_name% met %skill_name%.", "ARTIST_SHUFFLE": "Willekeurig afspelen van %artist_name% met %skill_name%.", "ARTIST_QUEUED": "%count% liedjes toegevoegt van artiest %artist_name% aan de wachtrij.", - - - "GENRE_NO_NAME": "Ik heb het muziek genre niet goed verstaan.", "GENRE_NOTFOUND_BY_NAME": "Ik heb geen genre gevonden met de naam %genre_name%.", "GENRE_NO_MUSIC": "geen liedjes in het genre %genre_name% zijn gevonden.", - - "GENRE_PLAYING": "speelt %grene_name% met %skill_name%.", - "GENRE_PLAYING_SIMULAR": "Speeld %grene_name% en dezelfde soort genres met %skill_name%.", - "GENRE_SHUFFLE": "willekeurige volgorde van %grene_name% met %skill_name%.", - "GENRE_SHUFFLE_SIMULAR": "willekeurige volgorde %grene_name% en vergelijkbare genres met %skill_name%.", + "GENRE_PLAYING": "speelt %genre_name% met %skill_name%.", + "GENRE_PLAYING_SIMULAR": "Speeld %genre_name% en dezelfde soort genres met %skill_name%.", + "GENRE_SHUFFLE": "willekeurige volgorde van %genre_name% met %skill_name%.", + "GENRE_SHUFFLE_SIMULAR": "willekeurige volgorde %genre_name% en vergelijkbare genres met %skill_name%.", "GENRE_QUEUED": "%count% %genre_name% toegevoegt aan de lijst met liedjes.", "GENRE_QUEUED_SIMULAR": "%count% vergelijkbaar met %genre_name% toegevoegt aan de lijst met liedjes.", - - - "PLAYLIST_NO_NAME": "Ik heb de naam van de afspeellijst niet verstaan..", "PLAYLIST_NOTFOUND_BY_NAME": "Ik heb geen afspeellijst gevonden met %playlist_name%.", "PLAYLIST_NO_MUSIC": "Ik heb geen muziek in de lijst %playlist_name% gevonden.", - - "PLAYLIST_PLAYING": "Speelt liedjes van %playlist_name% met %skill_name%.", "PLAYLIST_SHUFFLE": "willekeurig afspelen %playlist_name% met %skill_name%.", "PLAYLIST_QUEUED": "%count% liedjes van speellijst %playlist_name% toegevoegt aan de wachtrij." - } \ No newline at end of file diff --git a/src/logger.js b/src/logger.js index a30c1d6..08fba9a 100644 --- a/src/logger.js +++ b/src/logger.js @@ -19,11 +19,10 @@ const Outputs = { }; /********************************************************************************* - * + * Logger Function */ -const Log = function(level = -1, ...args) -{ +const Log = function (level = -1, ...args) { //Check Logging Option. if (level > CONFIG.log_level || level > LOGLEVEL_DEBUG) return; @@ -32,7 +31,7 @@ const Log = function(level = -1, ...args) Outputs[level](...args); //Future option: Log out errors.txt -}; +}; /********************************************************************************* * Logger Wrappers for quicknes diff --git a/src/players/visual-player.js b/src/players/visual-player.js index 1759de3..696a6ab 100644 --- a/src/players/visual-player.js +++ b/src/players/visual-player.js @@ -2,10 +2,11 @@ const APLDocument = require("../apl/audio-player.json"); -const ShowAPL = function(handlerInput, playlist, item) -{ - //if (playlist.APLOpen) - // return false; +/********************************************************************************* + * Show APL Player + */ + +const ShowAPL = function (handlerInput, playlist, item) { handlerInput.responseBuilder.addDirective( { @@ -20,13 +21,13 @@ const ShowAPL = function(handlerInput, playlist, item) properties: { backgroundImage: item.getArtURL(), - coverImageSource: item.getArtURL(), + //coverImageSource: item.getArtURL(), //For now this is ugly :D primaryText: item.Item.Name, secondaryText: item.Item.AlbumArtist, audioSources: { url: item.getStreamURL(), offset: item.Playback.Offset, - duration: item.Playback.Runtime + duration: item.Playback.Runtime } } } @@ -41,8 +42,11 @@ const ShowAPL = function(handlerInput, playlist, item) return false; } -const PlayItem = function(handlerInput, playlist, item) -{ +/********************************************************************************* + * Player Functions + */ + +const PlayItem = function (handlerInput, playlist, item) { if (!item || !item.Playback.Token) return false; ShowAPL(handlerInput, playlist, item); @@ -50,62 +54,19 @@ const PlayItem = function(handlerInput, playlist, item) return true; }; +/********************************************************************************* + * No Queue Support for current APL Player + */ -/* - I FAILED TO GET THIS TO WORK SO FOR NOW JUST REFRESH - if (!ShowAPL(handlerInput, playlist, item)) - return true; - - Logger.Debug(`[Device ${playlist.Id}]`, "Updating APL Audioplayer"); - - handlerInput.responseBuilder.addDirective( - { - type: "Alexa.Presentation.APL.ExecuteCommands", - token: "musicToken", - commands: [ - { - "type": "SetValue", - "componentId": "Audio_PrimaryText", - "property": "text", - "value": item.Item.Name - }, - { - "type": "SetValue", - "componentId": "Audio_SecondaryText", - "property": "text", - "value": item.Item.AlbumArtist - }, - { - "type": "PlayMedia", - "componentId": "videoPlayer", - "source": item.getStreamURL(), - "audioTrack": "background" - }, - { - "type": "ControlMedia", - "componentId": "videoPlayer", - "command": "seek", - "value": item.Playback.Offset - }, - { - "type": "ControlMedia", - "componentId": "videoPlayer", - "command": "play" - } - ] - } - ); - - return true; -};*/ - -const EnqueueItem = function(handlerInput, playlist, item) -{ +const EnqueueItem = function (handlerInput, playlist, item) { return false; }; -const StopPlayback = function(handlerInput) -{ +/********************************************************************************* + * Stop Playback + */ + +const StopPlayback = function (handlerInput) { handlerInput.responseBuilder.addDirective( { type: 'Alexa.Presentation.APL.ExecuteCommands', @@ -123,13 +84,20 @@ const StopPlayback = function(handlerInput) return true; }; -const EndSession = function(handlerInput, playlist) -{ +/********************************************************************************* + * End Session + */ + +const EndSession = function (handlerInput, playlist) { playlist.APLOpen = false; return true; }; +/********************************************************************************* + * Exports + */ + module.exports = { PlayItem, EnqueueItem, diff --git a/src/playlist/devices.js b/src/playlist/devices.js index d1b675a..57d55b0 100644 --- a/src/playlist/devices.js +++ b/src/playlist/devices.js @@ -10,12 +10,11 @@ const fs = require("fs"); * Device Playlists */ -const devices = { }; +const devices = {}; const All = () => Object.values(devices); -const getPlayList = function(device) -{ +const getPlayList = function (device) { if (devices[device]) return devices[device]; const playlist = PlayList.new(device); @@ -31,8 +30,7 @@ const getPlayList = function(device) * */ -const onLaunch = function(handlerInput) -{ +const onLaunch = function (handlerInput) { const { responseBuilder, requestEnvelope } = handlerInput; const deviceID = Alexa.getDeviceId(requestEnvelope); @@ -48,8 +46,7 @@ const onLaunch = function(handlerInput) * */ -const onPlaybackNearlyFinished = function(handlerInput) -{ +const onPlaybackNearlyFinished = function (handlerInput) { const { responseBuilder, requestEnvelope } = handlerInput; const deviceID = Alexa.getDeviceId(requestEnvelope); @@ -72,16 +69,18 @@ const onPlaybackNearlyFinished = function(handlerInput) * */ -const onQueueFinished = function(handlerInput, speak) -{ +const onQueueFinished = function (handlerInput, speak) { const { responseBuilder } = handlerInput; + const deviceID = Alexa.getDeviceId(requestEnvelope); + + const playlist = getPlayList(deviceID); + Logger.Debug(`[Device ${playlist.Id}]`, "Playback queue finished!"); - if (speak) - { + if (speak) { const speech = "There are no more songs left in the queue."; - + return responseBuilder.speak(speech).getResponse(); } @@ -92,8 +91,7 @@ const onQueueFinished = function(handlerInput, speak) * */ -const onPlaybackFailed = function(handlerInput) -{ +const onPlaybackFailed = function (handlerInput) { const { responseBuilder, requestEnvelope } = handlerInput; const deviceID = Alexa.getDeviceId(requestEnvelope); @@ -104,8 +102,7 @@ const onPlaybackFailed = function(handlerInput) var item = playlist.getCurrentItem(); - if (!Player.PlayItem(handlerInput, playlist, item)) - { + if (!Player.PlayItem(handlerInput, playlist, item)) { Logger.Debug(`[Device ${playlist.Id}]`, "Moving to next song."); item = playlist.getNextItem(); @@ -123,8 +120,7 @@ const onPlaybackFailed = function(handlerInput) * */ -const onPlaybackStarted = function(handlerInput) -{ +const onPlaybackStarted = function (handlerInput) { const { responseBuilder, requestEnvelope } = handlerInput; const deviceID = Alexa.getDeviceId(requestEnvelope); @@ -142,8 +138,7 @@ const onPlaybackStarted = function(handlerInput) * */ -const onPlaybackFinished = function(handlerInput) -{ +const onPlaybackFinished = function (handlerInput) { const { responseBuilder, requestEnvelope } = handlerInput; const deviceID = Alexa.getDeviceId(requestEnvelope); @@ -171,8 +166,7 @@ const onPlaybackFinished = function(handlerInput) * */ -const onPlaybackStopped = function(handlerInput) -{ +const onPlaybackStopped = function (handlerInput) { const { responseBuilder, requestEnvelope } = handlerInput; const deviceID = Alexa.getDeviceId(requestEnvelope); @@ -190,8 +184,7 @@ const onPlaybackStopped = function(handlerInput) * */ -const doClear = function(handlerInput) -{ +const doClear = function (handlerInput) { const { responseBuilder, requestEnvelope } = handlerInput; const deviceID = Alexa.getDeviceId(requestEnvelope); @@ -209,8 +202,7 @@ const doClear = function(handlerInput) * */ -const doPause = function(handlerInput, offset) -{ +const doPause = function (handlerInput, offset) { const { responseBuilder, requestEnvelope } = handlerInput; const deviceID = Alexa.getDeviceId(requestEnvelope); @@ -218,7 +210,7 @@ const doPause = function(handlerInput, offset) const playlist = getPlayList(deviceID); playlist.pause(offset || requestEnvelope.context.AudioPlayer.offsetInMilliseconds); - + Logger.Info(`[Device ${playlist.Id}]`, "Playback Paused."); Player.StopPlayback(handlerInput); @@ -230,8 +222,7 @@ const doPause = function(handlerInput, offset) * */ -const doResume = function(handlerInput) -{ +const doResume = function (handlerInput) { const { responseBuilder, requestEnvelope } = handlerInput; const deviceID = Alexa.getDeviceId(requestEnvelope); @@ -252,10 +243,9 @@ const doResume = function(handlerInput) * */ -const doStop = function(handlerInput) -{ +const doStop = function (handlerInput) { const { responseBuilder, requestEnvelope } = handlerInput; - + const deviceID = Alexa.getDeviceId(requestEnvelope); const playlist = getPlayList(deviceID); @@ -274,8 +264,7 @@ const doStop = function(handlerInput) * */ -const doPlayCurrent = function(handlerInput) -{ +const doPlayCurrent = function (handlerInput) { const { responseBuilder, requestEnvelope } = handlerInput; const deviceID = Alexa.getDeviceId(requestEnvelope); @@ -296,8 +285,7 @@ const doPlayCurrent = function(handlerInput) * */ -const doPlayNext = function(handlerInput) -{ +const doPlayNext = function (handlerInput) { const { responseBuilder, requestEnvelope } = handlerInput; const deviceID = Alexa.getDeviceId(requestEnvelope); @@ -323,8 +311,7 @@ const doPlayNext = function(handlerInput) * */ -const doPlayPrevious = function(handlerInput) -{ +const doPlayPrevious = function (handlerInput) { const { responseBuilder, requestEnvelope } = handlerInput; const deviceID = Alexa.getDeviceId(requestEnvelope); @@ -350,10 +337,9 @@ const doPlayPrevious = function(handlerInput) * End the session */ -const doSessionEnded = function(handlerInput) -{ +const doSessionEnded = function (handlerInput) { const { responseBuilder, requestEnvelope } = handlerInput; - + const deviceID = Alexa.getDeviceId(requestEnvelope); const playlist = getPlayList(deviceID); diff --git a/src/playlist/save-file.js b/src/playlist/save-file.js index 85a3b3b..e6d806f 100644 --- a/src/playlist/save-file.js +++ b/src/playlist/save-file.js @@ -1,4 +1,4 @@ -const {limit} = CONFIG.jellyfin; +const { limit } = CONFIG.jellyfin; const JellyFin = require("../jellyfin-api"); @@ -16,9 +16,8 @@ const fs = require("fs"); * Save Device Queue */ -const SaveDeviceQueue = async function(playlist) -{ - let queue = [ ]; +const SaveDeviceQueue = async function (playlist) { + let queue = []; for (let item of playlist.Queue) queue.push( @@ -31,7 +30,7 @@ const SaveDeviceQueue = async function(playlist) ); const hash = crypto.hash('sha1', playlist.Device); - + return fs.promises.writeFile( `${DATA_DIR}/${hash}.jmp`, JSON.stringify( @@ -50,18 +49,16 @@ const SaveDeviceQueue = async function(playlist) * Save All Device Queues */ -const Save = function() -{ +const Save = function () { const promises = []; - for(let playlist of Devices.All()) - { + for (let playlist of Devices.All()) { if (!playlist.Dirty || playlist.Saving) continue; const promise = SaveDeviceQueue(playlist).then( () => { - Logger.Debug(`[Playlist ${playlist.Id}]`,"Saved queue to local file."); + Logger.Debug(`[Playlist ${playlist.Id}]`, "Saved queue to local file."); playlist.Dirty = false; playlist.Saving = false; } @@ -78,15 +75,13 @@ const Save = function() * Load Device queue */ -const LoadDeviceQueue = async function({deviceID, position, token, queue}) -{ - const songs = { }; - const itemIDs = queue.map(({id}) => id); +const LoadDeviceQueue = async function ({ deviceID, position, token, queue }) { + const songs = {}; + const itemIDs = queue.map(({ id }) => id); + + for (let i = 0; i < itemIDs.length; i += limit) { + const { status, items } = await JellyFin.Music({ ids: itemIDs.splice(i, i + limit) }); - for (let i = 0; i < itemIDs.length; i += limit) - { - const {status, items} = await JellyFin.Music({ ids: itemIDs.splice(i, i + limit).join(",") }); - if (!status) continue; @@ -96,8 +91,7 @@ const LoadDeviceQueue = async function({deviceID, position, token, queue}) const playList = Devices.getPlayList(deviceID); playList.playbackToken = token; - for(let {id, token, isPaused, offset} of queue) - { + for (let { id, token, isPaused, offset } of queue) { let song = songs[id]; if (!song) @@ -107,8 +101,7 @@ const LoadDeviceQueue = async function({deviceID, position, token, queue}) queued.Playback.IsPaused = isPaused; queued.Playback.Offset = offset; - if (isPaused || token == playList.playbackToken) - { + if (isPaused || token == playList.playbackToken) { if (!isPaused) queued.Playback.IsPlaying = true; @@ -118,20 +111,18 @@ const LoadDeviceQueue = async function({deviceID, position, token, queue}) playList.Queue.push(queued); } - Logger.Debug(`[Playlist ?]`,"Restored queue from local file."); + Logger.Debug(`[Playlist ?]`, "Restored queue from local file."); }; /********************************************************************************* * Load Device queues */ -const Load = async function() -{ +const Load = async function () { const promises = []; const files = fs.readdirSync(DATA_DIR).filter(f => f.endsWith(".jmp")); - - for(let file of files) - { + + for (let file of files) { const promise = fs.promises.readFile(`${DATA_DIR}/${file}`).then( (data) => LoadDeviceQueue(JSON.parse(data)) ); diff --git a/src/skill/album-intents.js b/src/skill/album-intents.js index f3c1f64..42447d7 100644 --- a/src/skill/album-intents.js +++ b/src/skill/album-intents.js @@ -1,4 +1,4 @@ -const {limit} = CONFIG.jellyfin; +const { limit } = CONFIG.jellyfin; const Alexa = require('ask-sdk-core'); @@ -14,72 +14,62 @@ const { CreateQueueIntent } = require("./alexa-helper.js"); * Process Intent: Get Album by name & Artist */ -const Processer = async function(handlerInput, action = "play", buildQueue, submit) -{ +const Processer = async function (handlerInput, action = "play", buildQueue, submit) { const { requestEnvelope } = handlerInput; const intent = requestEnvelope.request.intent; const slots = intent.slots; - if (!slots.albumname || !slots.albumname.value) - { + if (!slots.albumname || !slots.albumname.value) { const speech = LANGUAGE.Value("ALBUM_NO_NAME"); - return [{status: false, speach1}]; + return [{ status: false, speach1 }]; } Logger.Debug(`[Album Request]`, `Requested album ${slots.albumname.value}`); - const albums = await JellyFin.Albums.Search(slots.albumname.value); - - if (!albums.status || !albums.items[0]) - { + const albums = await JellyFin.Albums({ query: slots.albumname.value }); + + if (!albums.status || !albums.items[0]) { Logger.Debug(`[Album Request]`, "No album found."); - const speech = LANGUAGE.Parse("ALBUM_NOTFOUND_BY_NAME", {album_name: slots.albumname.value}); + const speech = LANGUAGE.Parse("ALBUM_NOTFOUND_BY_NAME", { album_name: slots.albumname.value }); - return [{status: false, speech}]; + return [{ status: false, speech }]; } var artist = undefined; var album = albums.items[0]; - if (slots.artistname && slots.artistname.value) - { + if (slots.artistname && slots.artistname.value) { Logger.Debug(`[Album Request]`, `Requested Artist ${slots.artistname.value}`); - const artists = await JellyFin.Artists.Search(slots.artistname.value); - - if (!artists.status || !artists.items[0]) - { + const artists = await JellyFin.Artists({ query: slots.artistname.value }); + + if (!artists.status || !artists.items[0]) { Logger.Debug(`[Album Request]`, "No artist found."); - const speech = LANGUAGE.Parse("ARTIST_NOTFOUND_BY_NAME", {artist_name: slots.artistname.value}); + const speech = LANGUAGE.Parse("ARTIST_NOTFOUND_BY_NAME", { artist_name: slots.artistname.value }); - return [{status: false, speech}]; + return [{ status: false, speech }]; } artist = artists.items[0]; - if (album.AlbumArtist && album.AlbumArtist.toLowerCase() != artist.Name.toLowerCase()) - { + if (album.AlbumArtist && album.AlbumArtist.toLowerCase() != artist.Name.toLowerCase()) { album = undefined; - for (var item of albums.items) - { - if (item.AlbumArtist && item.AlbumArtist.toLowerCase() == artist.Name.toLowerCase()) - { + for (var item of albums.items) { + if (item.AlbumArtist && item.AlbumArtist.toLowerCase() == artist.Name.toLowerCase()) { album = item; break; } } - if (!album) - { + if (!album) { album = albums.items[0]; - - if (album && album.AlbumArtist) - { + + if (album && album.AlbumArtist) { Logger.Debug(`[Album Request]`, `Returning suggestion ${album.Name} by ${album.AlbumArtist}`); const speech = LANGUAGE.Parse("ALBUM_NOTFOUND_SUGGEST", @@ -91,10 +81,9 @@ const Processer = async function(handlerInput, action = "play", buildQueue, subm } ); - return [{status: false, speech}]; + return [{ status: false, speech }]; } - else - { + else { Logger.Debug(`[Album Request]`, "Album not found."); @@ -105,32 +94,30 @@ const Processer = async function(handlerInput, action = "play", buildQueue, subm } ); - return [{status: false, speech}]; + return [{ status: false, speech }]; } } } } - const songs = await JellyFin.Music({limit, albumIds: album.Id}); + const songs = await JellyFin.Music({ limit, albums: [album.Id] }); - if (!songs.status || !songs.items[0]) - { - const speech = LANGUAGE.Parse("ALBUM_NO_MUSIC", { album_name: slots.albumname.value } ); + if (!songs.status || !songs.items[0]) { + const speech = LANGUAGE.Parse("ALBUM_NO_MUSIC", { album_name: slots.albumname.value }); - return [{status: false, speach1, speach2}]; + return [{ status: false, speach1, speach2 }]; } - const then = async function(data) - { + const then = async function (data) { if (buildQueue) for (let i = songs.index + limit; i < songs.count; i += limit) - buildQueue(handlerInput, await JellyFin.Music({albumIds: album.Id, limit, startIndex: i}), data); + buildQueue(handlerInput, await JellyFin.Music({ albums: [album.Id] }, i), data); if (submit) return await submit(handlerInput, data); }; - return [{status: true, album, items: songs.items}, then]; + return [{ status: true, album, items: songs.items }, then]; }; /********************************************************************************* @@ -139,8 +126,7 @@ const Processer = async function(handlerInput, action = "play", buildQueue, subm const PlayAlbumIntent = CreateQueueIntent( "PlayAlbumIntent", "play", Processer, - function (handlerInput, {album, items}, data) - { + function (handlerInput, { album, items }, data) { const { responseBuilder, requestEnvelope } = handlerInput; const deviceID = Alexa.getDeviceId(requestEnvelope); @@ -158,7 +144,7 @@ const PlayAlbumIntent = CreateQueueIntent( Logger.Info(`[Device ${playlist.Id}]`, `Playing ${item.Item.Name} by ${item.Item.AlbumArtist}`); - var speech = LANGUAGE.Parse("ALBUM_PLAYING", { album_name: album.Name } ); + var speech = LANGUAGE.Parse("ALBUM_PLAYING", { album_name: album.Name }); if (album.AlbumArtist) speech = LANGUAGE.Parse("ALBUM_PLAYING_BY_ARTIST", @@ -170,8 +156,7 @@ const PlayAlbumIntent = CreateQueueIntent( return responseBuilder.speak(speech).getResponse(); }, - function({ requestEnvelope }, {status, items}, data) - { + function ({ requestEnvelope }, { status, items }, data) { if (!status) return; const deviceID = Alexa.getDeviceId(requestEnvelope); @@ -190,8 +175,7 @@ const PlayAlbumIntent = CreateQueueIntent( const ShuffleAlbumIntent = CreateQueueIntent( "ShuffleAlbumIntent", "shuffling", Processer, - function (handlerInput, {album, items}, data) - { + function (handlerInput, { album, items }, data) { const { responseBuilder, requestEnvelope } = handlerInput; const deviceID = Alexa.getDeviceId(requestEnvelope); @@ -199,7 +183,7 @@ const ShuffleAlbumIntent = CreateQueueIntent( const playlist = Devices.getPlayList(deviceID); playlist.clear(); - + items = playlist.shuffleItems(items); [data.first, data.last] = playlist.prefixItems(items); @@ -211,7 +195,7 @@ const ShuffleAlbumIntent = CreateQueueIntent( Logger.Info(`[Device ${playlist.Id}]`, `Playing ${item.Item.Name} by ${item.Item.AlbumArtist}`); - var speech = LANGUAGE.Parse("ALBUM_SHUFFLE", { album_name: album.Name } ); + var speech = LANGUAGE.Parse("ALBUM_SHUFFLE", { album_name: album.Name }); if (album.AlbumArtist) speech = LANGUAGE.Parse("ALBUM_SHUFFLE_BY_ARTIST", @@ -223,8 +207,7 @@ const ShuffleAlbumIntent = CreateQueueIntent( return responseBuilder.speak(speech).getResponse(); }, - function({ requestEnvelope }, {status, items}, data) - { + function ({ requestEnvelope }, { status, items }, data) { if (!status) return; const deviceID = Alexa.getDeviceId(requestEnvelope); @@ -235,8 +218,7 @@ const ShuffleAlbumIntent = CreateQueueIntent( data.last = last; }, - function({ requestEnvelope }, {first, last}) - { + function ({ requestEnvelope }, { first, last }) { const deviceID = Alexa.getDeviceId(requestEnvelope); const playlist = Devices.getPlayList(deviceID); @@ -251,8 +233,7 @@ const ShuffleAlbumIntent = CreateQueueIntent( const QueueAlbumIntent = CreateQueueIntent( "QueueAlbumIntent", "queue", Processer, - async function (handlerInput, {album, items}, data) - { + async function (handlerInput, { album, items }, data) { const { responseBuilder, requestEnvelope } = handlerInput; const deviceID = Alexa.getDeviceId(requestEnvelope); @@ -265,8 +246,8 @@ const QueueAlbumIntent = CreateQueueIntent( if (Player.PlayItem(handlerInput, playlist, item)) Logger.Info(`[Device ${playlist.Id}]`, `Playing ${item.Item.Name} by ${item.Item.AlbumArtist}`); - - var speech = LANGUAGE.Parse("ALBUM_QUEUED", { album_name: album.Name } ); + + var speech = LANGUAGE.Parse("ALBUM_QUEUED", { album_name: album.Name }); if (album.AlbumArtist) speech = LANGUAGE.Parse("ALBUM_QUEUED_BY_ARTIST", @@ -278,8 +259,7 @@ const QueueAlbumIntent = CreateQueueIntent( return responseBuilder.speak(speech).getResponse(); }, - function({ requestEnvelope }, {status, items}, data) - { + function ({ requestEnvelope }, { status, items }, data) { if (!status) return; const deviceID = Alexa.getDeviceId(requestEnvelope); diff --git a/src/skill/apl-handlers.js b/src/skill/apl-handlers.js index 5a7c689..87dbff5 100644 --- a/src/skill/apl-handlers.js +++ b/src/skill/apl-handlers.js @@ -2,6 +2,10 @@ const { CreateAPLEventHandler } = require("./alexa-helper.js"); const Devices = require("../playlist/devices.js"); +/********************************************************************************* + * APL Handlers: Device Control + */ + const PlaybackPaused = CreateAPLEventHandler( "onPlaybackPaused", Devices.doPause @@ -34,6 +38,10 @@ const PlayNext = CreateAPLEventHandler( //Devices.onPlaybackStopped +/********************************************************************************* + * Exports + */ + module.exports = { PlaybackPaused, PlaybackStarted, diff --git a/src/skill/artist-intents.js b/src/skill/artist-intents.js index a82e8d2..c4dba66 100644 --- a/src/skill/artist-intents.js +++ b/src/skill/artist-intents.js @@ -1,4 +1,4 @@ -const {limit} = CONFIG.jellyfin; +const { limit } = CONFIG.jellyfin; const Alexa = require('ask-sdk-core'); @@ -14,56 +14,51 @@ const { CreateQueueIntent } = require("./alexa-helper.js"); * Process Intent: Get Arist Intent */ -const Processer = async function(handlerInput, action = "play", buildQueue, submit) -{ +const Processer = async function (handlerInput, action = "play", buildQueue, submit) { const { requestEnvelope } = handlerInput; const intent = requestEnvelope.request.intent; const slots = intent.slots; - if (!slots.artistname || !slots.artistname.value) - { + if (!slots.artistname || !slots.artistname.value) { const speech = LANGUAGE.Value("ARTIST_NO_NAME"); - return [{status: false, speech}]; + return [{ status: false, speech }]; } Logger.Debug(`[Artist Request]`, `Requested Artist ${slots.artistname.value}`); - const artists = await JellyFin.Artists.Search(slots.artistname.value); - - if (!artists.status || !artists.items[0]) - { + const artists = await JellyFin.Artists({ query: slots.artistname.value }); + + if (!artists.status || !artists.items[0]) { Logger.Debug(`[Artist Request]`, "Artist not found."); - const speech = LANGUAGE.Parse("ARTIST_NOTFOUND_BY_NAME", {artist_name: slots.artistname.value}); + const speech = LANGUAGE.Parse("ARTIST_NOTFOUND_BY_NAME", { artist_name: slots.artistname.value }); - return [{status: false, speech}]; + return [{ status: false, speech }]; } const artist = artists.items[0]; - const songs = await JellyFin.Music({limit, artistIds: artist.Id}); + const songs = await JellyFin.Music({ artists: [artist.Id] }); - if (!songs.status || !songs.items[0]) - { + if (!songs.status || !songs.items[0]) { Logger.Debug(`[Artist Request]`, "No music found."); - const speech = LANGUAGE.Parse("ARTIST_NO_MUSIC", {artist_name: slots.artistname.value}); + const speech = LANGUAGE.Parse("ARTIST_NO_MUSIC", { artist_name: slots.artistname.value }); - return [{status: false, speech}]; + return [{ status: false, speech }]; } - const then = async function(data) - { + const then = async function (data) { if (buildQueue) for (let i = songs.index + limit; i < songs.count; i += limit) - buildQueue(handlerInput, await JellyFin.Music({artistIds: artist.Id, limit, startIndex: i}), data); + buildQueue(handlerInput, await JellyFin.Music({ artists: [artist.Id] }, i), data); if (submit) return await submit(handlerInput, data); }; - return [{status: true, artist, items: songs.items}, then]; + return [{ status: true, artist, items: songs.items }, then]; }; /********************************************************************************* @@ -72,8 +67,7 @@ const Processer = async function(handlerInput, action = "play", buildQueue, subm const PlayArtistIntent = CreateQueueIntent( "PlayArtistIntent", "play", Processer, - function (handlerInput, {artist, items}, data) - { + function (handlerInput, { artist, items }, data) { const { responseBuilder, requestEnvelope } = handlerInput; const deviceID = Alexa.getDeviceId(requestEnvelope); @@ -91,12 +85,11 @@ const PlayArtistIntent = CreateQueueIntent( Logger.Info(`[Device ${playlist.Id}]`, `Playing ${item.Item.Name} by ${item.Item.AlbumArtist}`); - var speech = LANGUAGE.Parse("ARTIST_PLAYING", { artist_name: artist.Name } ); + var speech = LANGUAGE.Parse("ARTIST_PLAYING", { artist_name: artist.Name }); return responseBuilder.speak(speech).getResponse(); }, - function({ requestEnvelope }, {status, items}, data) - { + function ({ requestEnvelope }, { status, items }, data) { if (!status) return; const deviceID = Alexa.getDeviceId(requestEnvelope); @@ -115,8 +108,7 @@ const PlayArtistIntent = CreateQueueIntent( const ShuffleArtistIntent = CreateQueueIntent( "ShuffleArtistIntent", "shuffling", Processer, - function (handlerInput, {artist, items}, data) - { + function (handlerInput, { artist, items }, data) { const { responseBuilder, requestEnvelope } = handlerInput; const deviceID = Alexa.getDeviceId(requestEnvelope); @@ -124,7 +116,7 @@ const ShuffleArtistIntent = CreateQueueIntent( const playlist = Devices.getPlayList(deviceID); playlist.clear(); - + items = playlist.shuffleItems(items); [data.first, data.last] = playlist.prefixItems(items); @@ -136,12 +128,11 @@ const ShuffleArtistIntent = CreateQueueIntent( Logger.Info(`[Device ${playlist.Id}]`, `Playing ${item.Item.Name} by ${item.Item.AlbumArtist}`); - var speech = LANGUAGE.Parse("ARTIST_SHUFFLE", { artist_name: artist.Name } ); + var speech = LANGUAGE.Parse("ARTIST_SHUFFLE", { artist_name: artist.Name }); return responseBuilder.speak(speech).getResponse(); }, - function({ requestEnvelope }, {status, items}, data) - { + function ({ requestEnvelope }, { status, items }, data) { if (!status) return; const deviceID = Alexa.getDeviceId(requestEnvelope); @@ -152,8 +143,7 @@ const ShuffleArtistIntent = CreateQueueIntent( data.last = last; }, - function({ requestEnvelope }, {first, last}) - { + function ({ requestEnvelope }, { first, last }) { const deviceID = Alexa.getDeviceId(requestEnvelope); const playlist = Devices.getPlayList(deviceID); @@ -168,8 +158,7 @@ const ShuffleArtistIntent = CreateQueueIntent( const QueueArtistIntent = CreateQueueIntent( "QueueArtistIntent", "queue", Processer, - function (handlerInput, {artist, items, count}, data) - { + function (handlerInput, { artist, items, count }, data) { const { responseBuilder, requestEnvelope } = handlerInput; const deviceID = Alexa.getDeviceId(requestEnvelope); @@ -183,12 +172,11 @@ const QueueArtistIntent = CreateQueueIntent( if (Player.PlayItem(handlerInput, playlist, item)) Logger.Info(`[Device ${playlist.Id}]`, `Playing ${item.Item.Name} by ${item.Item.AlbumArtist}`); - var speech = LANGUAGE.Parse("ARTIST_QUEUED", { artist_name: artist.Name, count } ); - + var speech = LANGUAGE.Parse("ARTIST_QUEUED", { artist_name: artist.Name, count }); + return responseBuilder.speak(speech).getResponse(); }, - function({ requestEnvelope }, {status, items}, data) - { + function ({ requestEnvelope }, { status, items }, data) { if (!status) return; const deviceID = Alexa.getDeviceId(requestEnvelope); diff --git a/src/skill/genre-intent.js b/src/skill/genre-intent.js index 283a2f6..5afe577 100644 --- a/src/skill/genre-intent.js +++ b/src/skill/genre-intent.js @@ -1,4 +1,4 @@ -const {limit} = CONFIG.jellyfin; +const { limit } = CONFIG.jellyfin; const Alexa = require('ask-sdk-core'); @@ -14,55 +14,50 @@ const { CreateQueueIntent } = require("./alexa-helper.js"); * Process Intent: Get Genre Intent */ -const Processer = async function(handlerInput, action = "play", buildQueue, submit) -{ +const Processer = async function (handlerInput, action = "play", buildQueue, submit) { const { requestEnvelope } = handlerInput; const intent = requestEnvelope.request.intent; const slots = intent.slots; - if (!slots.genre || !slots.genre.value) - { + if (!slots.genre || !slots.genre.value) { const speech = LANGUAGE.Value("GENRE_NO_NAME"); - return [{status: false, speech}]; + return [{ status: false, speech }]; } Logger.Debug(`[Genre Request]`, `Requesting genre ${slots.genre.value}.`); - const genres = await JellyFin.MusicGenres.Search(slots.genre.value); - - if (!genres.status || !genres.items[0]) - { + const genres = await JellyFin.MusicGenres({ query: slots.genre.value }); + + if (!genres.status || !genres.items[0]) { Logger.Debug(`[Genre Request]`, `Genre not found.`); - const speech = LANGUAGE.Parse("GENRE_NOTFOUND_BY_NAME", {genre_name: slots.genre.value}); + const speech = LANGUAGE.Parse("GENRE_NOTFOUND_BY_NAME", { genre_name: slots.genre.value }); - return [{status: false, speech}]; + return [{ status: false, speech }]; } - const genreIds = genres.items.map(item => item.Id).join("|"); + const genreIds = genres.items.map(item => item.Id); + + const songs = await JellyFin.Music({ genres: genreIds }); - const songs = await JellyFin.Music({genreIds, limit}); - - if (!songs.status || !songs.items[0]) - { + if (!songs.status || !songs.items[0]) { Logger.Debug(`[Genre Request]`, `No music found.`); - const speech = LANGUAGE.Parse("GENRE_NO_MUSIC", {genre_name: slots.genre.value}); + const speech = LANGUAGE.Parse("GENRE_NO_MUSIC", { genre_name: slots.genre.value }); - return [{status: false, speech}]; + return [{ status: false, speech }]; } - const then = async function(data) - { + const then = async function (data) { if (buildQueue) for (let i = songs.index + limit; i < songs.count; i += limit) - buildQueue(handlerInput, await JellyFin.Music({genreIds, limit, startIndex: i}), data); + buildQueue(handlerInput, await JellyFin.Music({ genres: genreIds }, i), data); if (submit) return await submit(handlerInput, data); }; - return [{status: true, genres: genres.items, items: songs.items}, then]; + return [{ status: true, genres: genres.items, items: songs.items }, then]; }; /********************************************************************************* @@ -71,8 +66,7 @@ const Processer = async function(handlerInput, action = "play", buildQueue, subm const PlayGenreIntent = CreateQueueIntent( "PlayGenreIntent", "play", Processer, - function (handlerInput, {genres, items}, data) - { + function (handlerInput, { genres, items }, data) { const { responseBuilder, requestEnvelope } = handlerInput; const deviceID = Alexa.getDeviceId(requestEnvelope); @@ -91,15 +85,14 @@ const PlayGenreIntent = CreateQueueIntent( Logger.Info(`[Device ${playlist.Id}]`, `Playing ${item.Item.Name} by ${item.Item.AlbumArtist}`); - var speech = LANGUAGE.Parse("GENRE_PLAYING", {genre_name: genres[0].Name}); + var speech = LANGUAGE.Parse("GENRE_PLAYING", { genre_name: genres[0].Name }); if (genres.length > 1) - var speech = LANGUAGE.Parse("GENRE_PLAYING_SIMULAR", {genre_name: genres[0].Name}); + var speech = LANGUAGE.Parse("GENRE_PLAYING_SIMULAR", { genre_name: genres[0].Name }); return responseBuilder.speak(speech).getResponse(); }, - function({ requestEnvelope }, {status, items}, data) - { + function ({ requestEnvelope }, { status, items }, data) { if (!status) return; const deviceID = Alexa.getDeviceId(requestEnvelope); @@ -118,8 +111,7 @@ const PlayGenreIntent = CreateQueueIntent( const ShuffleGenreIntent = CreateQueueIntent( "ShuffleGenreIntent", "shuffling", Processer, - function (handlerInput, {genres, items}, data) - { + function (handlerInput, { genres, items }, data) { const { responseBuilder, requestEnvelope } = handlerInput; const deviceID = Alexa.getDeviceId(requestEnvelope); @@ -127,7 +119,7 @@ const ShuffleGenreIntent = CreateQueueIntent( const playlist = Devices.getPlayList(deviceID); playlist.clear(); - + items = playlist.shuffleItems(items); [data.first, data.last] = playlist.prefixItems(items); @@ -139,15 +131,14 @@ const ShuffleGenreIntent = CreateQueueIntent( Logger.Info(`[Device ${playlist.Id}]`, `Playing ${item.Item.Name} by ${item.Item.AlbumArtist}`); - var speech = LANGUAGE.Parse("GENRE_SHUFFLE", {genre_name: genres[0].Name}); + var speech = LANGUAGE.Parse("GENRE_SHUFFLE", { genre_name: genres[0].Name }); if (genres.length > 1) - var speech = LANGUAGE.Parse("GENRE_SHUFFLE_SIMULAR", {genre_name: genres[0].Name}); + var speech = LANGUAGE.Parse("GENRE_SHUFFLE_SIMULAR", { genre_name: genres[0].Name }); return responseBuilder.speak(speech).getResponse(); }, - function({ requestEnvelope }, {status, items}, data) - { + function ({ requestEnvelope }, { status, items }, data) { if (!status) return; const deviceID = Alexa.getDeviceId(requestEnvelope); @@ -158,8 +149,7 @@ const ShuffleGenreIntent = CreateQueueIntent( data.last = last; }, - function({ requestEnvelope }, {first, last}) - { + function ({ requestEnvelope }, { first, last }) { const deviceID = Alexa.getDeviceId(requestEnvelope); const playlist = Devices.getPlayList(deviceID); @@ -174,8 +164,7 @@ const ShuffleGenreIntent = CreateQueueIntent( const QueueGenreIntent = CreateQueueIntent( "QueueGenreIntent", "queue", Processer, - function (handlerInput, {genres, items, count}, data) - { + function (handlerInput, { genres, items, count }, data) { const { responseBuilder, requestEnvelope } = handlerInput; const deviceID = Alexa.getDeviceId(requestEnvelope); @@ -189,15 +178,14 @@ const QueueGenreIntent = CreateQueueIntent( if (Player.PlayItem(handlerInput, playlist, item)) Logger.Info(`[Device ${playlist.Id}]`, `Playing ${item.Item.Name} by ${item.Item.AlbumArtist}`); - var speech = LANGUAGE.Parse("GENRE_QUEUED", {genre_name: genres[0].Name, count}); + var speech = LANGUAGE.Parse("GENRE_QUEUED", { genre_name: genres[0].Name, count }); if (genres.length > 1) - var speech = LANGUAGE.Parse("GENRE_QUEUED_SIMULAR", {genre_name: genres[0].Name, count}); - + var speech = LANGUAGE.Parse("GENRE_QUEUED_SIMULAR", { genre_name: genres[0].Name, count }); + return responseBuilder.speak(speech).getResponse(); }, - function({ requestEnvelope }, {status, items}, data) - { + function ({ requestEnvelope }, { status, items }, data) { if (!status) return; const deviceID = Alexa.getDeviceId(requestEnvelope); diff --git a/src/skill/playlist-intents.js b/src/skill/playlist-intents.js index d036059..4f10be1 100644 --- a/src/skill/playlist-intents.js +++ b/src/skill/playlist-intents.js @@ -1,4 +1,4 @@ -const {limit} = CONFIG.jellyfin; +const { limit } = CONFIG.jellyfin; const Alexa = require('ask-sdk-core'); @@ -14,50 +14,45 @@ const { CreateQueueIntent } = require("./alexa-helper.js"); * Process Intent: Get Arist Intent */ -const Processer = async function(handlerInput, action = "play", buildQueue, submit) -{ +const Processer = async function (handlerInput, action = "play", buildQueue, submit) { const { requestEnvelope } = handlerInput; const intent = requestEnvelope.request.intent; const slots = intent.slots; - if (!slots.playlistname || !slots.playlistname.value) - { + if (!slots.playlistname || !slots.playlistname.value) { const speech = LANGUAGE.Value("PLAYLIST_NO_NAME"); - return [{status: false, speech}]; + return [{ status: false, speech }]; } - const playlists = await JellyFin.Playlists.FuzzySearch(slots.playlistname.value); - - if (!playlists.status || !playlists.items[0]) - { - const speech = LANGUAGE.Parse("PLAYLIST_NOTFOUND_BY_NAME", {playlist_name: slots.playlistname.value}); + const playlists = await JellyFin.Playlists({ query: slots.playlistname.value }); - return [{status: false, speech}]; + if (!playlists.status || !playlists.items[0]) { + const speech = LANGUAGE.Parse("PLAYLIST_NOTFOUND_BY_NAME", { playlist_name: slots.playlistname.value }); + + return [{ status: false, speech }]; } const playlist = playlists.items[0]; - const songs = await JellyFin.Music({parentId: playlist.Id, limit}); + const songs = await JellyFin.Music({ parent: playlist.Id }); - if (!songs.status || !songs.items[0]) - { - const speech = LANGUAGE.Parse("PLAYLIST_NO_MUSIC", {playlist_name: slots.playlistname.value}); + if (!songs.status || !songs.items[0]) { + const speech = LANGUAGE.Parse("PLAYLIST_NO_MUSIC", { playlist_name: slots.playlistname.value }); - return [{status: false, speech}]; + return [{ status: false, speech }]; } - const then = async function(data) - { + const then = async function (data) { if (buildQueue) for (let i = songs.index + limit; i < songs.count; i += limit) - buildQueue(handlerInput, await JellyFin.Music({parentId: playlist.Id, limit, startIndex: i}), data); - + buildQueue(handlerInput, await JellyFin.Music({ parent: playlist.Id }, i), data); + if (submit) return await submit(handlerInput, data); }; - return [{status: true, playlist, items: songs.items}, then]; + return [{ status: true, playlist, items: songs.items }, then]; }; /********************************************************************************* @@ -66,8 +61,7 @@ const Processer = async function(handlerInput, action = "play", buildQueue, subm const PlayPlaylistIntent = CreateQueueIntent( "PlayPlaylistIntent", "play", Processer, - function (handlerInput, {playlist, items}, data) - { + function (handlerInput, { playlist, items }, data) { const { responseBuilder, requestEnvelope } = handlerInput; const deviceID = Alexa.getDeviceId(requestEnvelope); @@ -85,12 +79,11 @@ const PlayPlaylistIntent = CreateQueueIntent( Logger.Info(`[Device ${_playlist.Id}]`, `Playing ${item.Item.Name} by ${item.Item.AlbumArtist}`); - const speech = LANGUAGE.Parse("PLAYLIST_PLAYING", {playlist_name: playlist.Name}); + const speech = LANGUAGE.Parse("PLAYLIST_PLAYING", { playlist_name: playlist.Name }); return responseBuilder.speak(speech).getResponse(); }, - function({ requestEnvelope }, {status, items}, data) - { + function ({ requestEnvelope }, { status, items }, data) { if (!status) return; const deviceID = Alexa.getDeviceId(requestEnvelope); @@ -109,8 +102,7 @@ const PlayPlaylistIntent = CreateQueueIntent( const ShufflePlaylistIntent = CreateQueueIntent( "ShufflePlaylistIntent", "shuffling", Processer, - function (handlerInput, {playlist, items}, data) - { + function (handlerInput, { playlist, items }, data) { const { responseBuilder, requestEnvelope } = handlerInput; const deviceID = Alexa.getDeviceId(requestEnvelope); @@ -118,7 +110,7 @@ const ShufflePlaylistIntent = CreateQueueIntent( const _playlist = Devices.getPlayList(deviceID); _playlist.clear(); - + items = _playlist.shuffleItems(items); [data.first, data.last] = _playlist.prefixItems(items); @@ -130,12 +122,11 @@ const ShufflePlaylistIntent = CreateQueueIntent( Logger.Info(`[Device ${_playlist.Id}]`, `Playing ${item.Item.Name} by ${item.Item.AlbumArtist}`); - const speech = LANGUAGE.Parse("PLAYLIST_SHUFFLE", {playlist_name: playlist.Name}); - + const speech = LANGUAGE.Parse("PLAYLIST_SHUFFLE", { playlist_name: playlist.Name }); + return responseBuilder.speak(speech).getResponse(); }, - function({ requestEnvelope }, {status, items}, data) - { + function ({ requestEnvelope }, { status, items }, data) { if (!status) return; const deviceID = Alexa.getDeviceId(requestEnvelope); @@ -146,8 +137,7 @@ const ShufflePlaylistIntent = CreateQueueIntent( data.last = last; }, - function({ requestEnvelope }, {first, last}) - { + function ({ requestEnvelope }, { first, last }) { const deviceID = Alexa.getDeviceId(requestEnvelope); const playlist = Devices.getPlayList(deviceID); @@ -162,8 +152,7 @@ const ShufflePlaylistIntent = CreateQueueIntent( const QueuePlaylistIntent = CreateQueueIntent( "QueuePlaylistIntent", "queue", Processer, - function (handlerInput, {playlist, items, count}, data) - { + function (handlerInput, { playlist, items, count }, data) { const { responseBuilder, requestEnvelope } = handlerInput; const deviceID = Alexa.getDeviceId(requestEnvelope); @@ -177,12 +166,11 @@ const QueuePlaylistIntent = CreateQueueIntent( if (Player.PlayItem(handlerInput, _playlist, item)) Logger.Info(`[Device ${playlist.Id}]`, `Playing ${item.Item.Name} by ${item.Item.AlbumArtist}`); - const speech = LANGUAGE.Parse("PLAYLIST_SHUFFLE", {playlist_name: playlist.Name, count}); + const speech = LANGUAGE.Parse("PLAYLIST_SHUFFLE", { playlist_name: playlist.Name, count }); return responseBuilder.speak(speech).getResponse(); }, - function({ requestEnvelope }, {status, items}, data) - { + function ({ requestEnvelope }, { status, items }, data) { if (!status) return; const deviceID = Alexa.getDeviceId(requestEnvelope); diff --git a/src/skill/song-intents.js b/src/skill/song-intents.js index 1d982ec..e1770a8 100644 --- a/src/skill/song-intents.js +++ b/src/skill/song-intents.js @@ -12,72 +12,62 @@ const { CreateQueueIntent } = require("./alexa-helper.js"); * Process Intent: Get Song Intent */ -const Processor = async function(handlerInput, action = "play") -{ +const Processor = async function (handlerInput, action = "play") { const { requestEnvelope } = handlerInput; const intent = requestEnvelope.request.intent; const slots = intent.slots; - if (!slots.songname || !slots.songname.value) - { + if (!slots.songname || !slots.songname.value) { const speech = LANGUAGE.Value("SONG_NO_NAME"); - return [{status: false, speech}]; + return [{ status: false, speech }]; } Logger.Debug(`[Music Request]`, `Requesting music ${slots.songname.value}.`); - const songs = await JellyFin.Music.Search(slots.songname.value); + const songs = await JellyFin.Music({ query: slots.songname.value }); - if (!songs.status || !songs.items[0]) - { + if (!songs.status || !songs.items[0]) { Logger.Debug(`[Music Request]`, "Music not found."); - const speech = LANGUAGE.Parse("SONG_NOTFOUND_BY_NAME", {song_name: slots.songname.value}); + const speech = LANGUAGE.Parse("SONG_NOTFOUND_BY_NAME", { song_name: slots.songname.value }); - return [{status: false, speech}]; + return [{ status: false, speech }]; } var artist = undefined; var song = songs.items[0]; - if (slots.artistname && slots.artistname.value) - { + if (slots.artistname && slots.artistname.value) { Logger.Debug(`[Music Request]`, `Requesting artist ${slots.artistname.value}.`); - const artists = await JellyFin.Artists.Search(slots.artistname.value); - - if (!artists.status || !artists.items[0]) - { + const artists = await JellyFin.Artists({ query: slots.artistname.value }); + + if (!artists.status || !artists.items[0]) { Logger.Debug(`[Music Request]`, "No artist found."); - const speech = LANGUAGE.Parse("ARTIST_NOTFOUND_BY_NAME", {artist_name: slots.artistname.value}); + const speech = LANGUAGE.Parse("ARTIST_NOTFOUND_BY_NAME", { artist_name: slots.artistname.value }); - return [{status: false, speech}]; + return [{ status: false, speech }]; } artist = artists.items[0]; - if (song.AlbumArtist && song.AlbumArtist.toLowerCase() != artist.Name.toLowerCase()) - { + if (song.AlbumArtist && song.AlbumArtist.toLowerCase() != artist.Name.toLowerCase()) { song = undefined; - for (var item of songs.items) - { - if (item.AlbumArtist && item.AlbumArtist.toLowerCase() == artist.Name.toLowerCase()) - { + for (var item of songs.items) { + if (item.AlbumArtist && item.AlbumArtist.toLowerCase() == artist.Name.toLowerCase()) { album = item; break; } } - if (!song) - { + if (!song) { song = songs.items[0]; - - if (song && song.AlbumArtist) - { + + if (song && song.AlbumArtist) { const suggestion = `${song.Name} by ${song.AlbumArtist}`; Logger.Debug(`[Music Request]`, `Returning suggestion ${song.Name} by ${song.AlbumArtist}.`); @@ -90,11 +80,10 @@ const Processor = async function(handlerInput, action = "play") suggestion_artist: song.AlbumArtist } ); - - return [{status: false, speech}]; + + return [{ status: false, speech }]; } - else - { + else { Logger.Debug(`[Music Request]`, "Music not found."); const speech = LANGUAGE.Parse("SONG_NOTFOUND_BY_NAME_AND_ARTIST", @@ -103,14 +92,14 @@ const Processor = async function(handlerInput, action = "play") artist_name: artist.name || slots.artistname.value } ); - - return [{status: false, speech}]; + + return [{ status: false, speech }]; } } } } - return [{status: true, artist, items: [song]}]; + return [{ status: true, artist, items: [song] }]; }; /********************************************************************************* @@ -119,10 +108,9 @@ const Processor = async function(handlerInput, action = "play") const PlaySongIntent = CreateQueueIntent( "PlaySongIntent", "play", Processor, - function (handlerInput, {items}) - { + function (handlerInput, { items }) { const { responseBuilder, requestEnvelope } = handlerInput; - + const deviceID = Alexa.getDeviceId(requestEnvelope); const playlist = Devices.getPlayList(deviceID); @@ -139,8 +127,8 @@ const PlaySongIntent = CreateQueueIntent( Logger.Info(`[Device ${playlist.Id}]`, `Playing ${item.Item.Name} by ${item.Item.AlbumArtist}`); - var speech = LANGUAGE.Parse("SONG_PLAYING", { song_name: items[0].Name } ); - + var speech = LANGUAGE.Parse("SONG_PLAYING", { song_name: items[0].Name }); + if (items[0].AlbumArtist) speech = LANGUAGE.Parse("SONG_PLAYING_BY_ARTIST", { @@ -159,10 +147,9 @@ const PlaySongIntent = CreateQueueIntent( const QueueSongIntent = CreateQueueIntent( "QueueSongIntent", "queue", Processor, - function (handlerInput, {items}) - { + function (handlerInput, { items }) { const { responseBuilder, requestEnvelope } = handlerInput; - + const deviceID = Alexa.getDeviceId(requestEnvelope); const playlist = Devices.getPlayList(deviceID); @@ -174,8 +161,8 @@ const QueueSongIntent = CreateQueueIntent( if (Player.PlayItem(handlerInput, playlist, item)) Logger.Info(`[Device ${playlist.Id}]`, `Playing ${item.Item.Name} by ${item.Item.AlbumArtist}`); - var speech = LANGUAGE.Parse("SONG_QUEUED", { song_name: items[0].Name } ); - + var speech = LANGUAGE.Parse("SONG_QUEUED", { song_name: items[0].Name }); + if (items[0].AlbumArtist) speech = LANGUAGE.Parse("SONG_QUEUED_BY_ARTIST", { From ba30ee2755702543b2bf5985eddfc5a4d1d7c199 Mon Sep 17 00:00:00 2001 From: rusketh Date: Mon, 29 Dec 2025 23:37:47 +0000 Subject: [PATCH 09/10] Attempted to add a way to play a users favourate items. Unfortunatly this is not working because Alexa does not understand that the word favorites should mean use this intent. --- skill-json/skill-EN.json | 56 ++++++++++ src/jellyfin-api.js | 51 ++++++++- src/languages/EN.json | 8 +- src/playlist/devices.js | 4 +- src/skill/album-intents.js | 2 +- src/skill/favourite-intents.js | 190 +++++++++++++++++++++++++++++++++ src/skill/query-intents.js | 18 ++-- src/skill/skill.js | 17 +-- src/skill/song-intents.js | 4 +- 9 files changed, 328 insertions(+), 22 deletions(-) create mode 100644 src/skill/favourite-intents.js diff --git a/skill-json/skill-EN.json b/skill-json/skill-EN.json index 401de4a..51c7a90 100644 --- a/skill-json/skill-EN.json +++ b/skill-json/skill-EN.json @@ -3,6 +3,23 @@ "languageModel": { "invocationName": "jelly music", "intents": [ + { + "name": "PlayFavouritesIntent", + "slots": [ + { + "name": "username", + "type": "AMAZON.FirstName" + } + ], + "samples": [ + "play favourites for {username}", + "from {username} play favourites", + "play {username} favourite tracks", + "play {username} favourite songs", + "play {username} favourite music", + "play {username} favourites" + ] + }, { "name": "AMAZON.CancelIntent", "samples": [] @@ -272,6 +289,45 @@ "queue artist {artistname}", "queue songs by {artistname}" ] + }, + { + "name": "ShuffleFavouritesIntent", + "slots": [ + { + "name": "username", + "type": "AMAZON.FirstName" + } + ], + "samples": [ + "shuffle favourites for {username}", + "from {username} shuffle favourites", + "shuffle {username} favourite tracks", + "shuffle {username} favourite songs", + "shuffle {username} favourite music", + "shuffle {username} favourites" + ] + }, + { + "name": "QueueFavouritesIntent", + "slots": [ + { + "name": "username", + "type": "AMAZON.FirstName" + } + ], + "samples": [ + "queue favourites for {username}", + "from {username} queue favourites", + "queue {username} favourite tracks", + "queue {username} favourite songs", + "queue {username} favourite music", + "queue {username} favourites", + "add favourites for {username} to the queue", + "add {username} favourite tracks to the queue", + "add {username} favourite songs to the queue", + "add {username} favourite music to the queue", + "add {username} favourites to the queue" + ] } ], "types": [] diff --git a/src/jellyfin-api.js b/src/jellyfin-api.js index 0fcab06..469c16a 100644 --- a/src/jellyfin-api.js +++ b/src/jellyfin-api.js @@ -1,5 +1,7 @@ const https = require('https'); +const fuzzball = require('fuzzball'); + /********************************************************************************* * API Request */ @@ -30,6 +32,7 @@ const MakeAPIRequest = async function (url, { ids, query, albums, artists, genre if (genres) url.searchParams.append("genreIds", genres.join(",")); if (parent) url.searchParams.append("parentId", parent); + //isFavorite Logger.Debug("[JellyFin API]", `Requesting ${url.pathname} with ${url.searchParams.toString()}.`); try { @@ -50,7 +53,7 @@ const MakeAPIRequest = async function (url, { ids, query, albums, artists, genre if (error.name == "SyntaxError") Logger.Error("[JellyFin API]", `Failed to parse response from Server (path: ${url.pathname}).`); else - Logger.Error("[JellyFin API]", `Failed to parse response from Server (path: ${url.pathname}).`); + Logger.Error("[JellyFin API]", `Error getting response from Server (path: ${url.pathname}).`, error); return { status: false }; } @@ -105,6 +108,48 @@ const Playlists = async function (query, startIndex, limit) { return await MakeAPIRequest(url, query, startIndex, limit); }; +/********************************************************************************* + * Query Users + */ + +const Users = async function (query) { + const url = new URL("/Users", CONFIG.jellyfin.local); + + const response = await fetch(url, RequestOptions); + + if (!response.ok) { + Logger.Error("[JellyFin API]", `No Response from Server (path: ${url.pathname}).`); + return { status: false }; + } + + try { + var users = await response.json(); + + if (query) { + users.forEach(user => user.score = fuzzball.token_sort_ratio(query, user.Name)); + users = users.filter((user) => user.score > 60); + users.sort((a, b) => b.score - a.score); + } + + return { status: true, users }; + } + catch (error) { + Logger.Error("[JellyFin API]", `Error getting response from Server (path: ${url.pathname}).`, error); + return { status: false }; + } +}; + +const Favourites = async function (userID, startIndex, limit) { + const url = new URL(`/Users/${userID}/Items`, CONFIG.jellyfin.local); + + url.searchParams.append("isFavorite", true); + url.searchParams.append("Recursive", true); + url.searchParams.append("includeItemTypes", "Audio"); + url.searchParams.append("fields", "Id,Name,RunTimeTicks,Artists,Album,AlbumId,PrimaryImageTag,IndexNumber,Genres"); + + return await MakeAPIRequest(url, {}, startIndex, limit); +}; + /********************************************************************************* * Exports */ @@ -114,5 +159,7 @@ module.exports = { Artists, Albums, MusicGenres, - Playlists + Playlists, + Users, + Favourites }; \ No newline at end of file diff --git a/src/languages/EN.json b/src/languages/EN.json index 85d87e5..7ead339 100644 --- a/src/languages/EN.json +++ b/src/languages/EN.json @@ -38,5 +38,11 @@ "PLAYLIST_NO_MUSIC": "I didn't find any music in the playlist %playlist_name%.", "PLAYLIST_PLAYING": "Playing songs from playlist %playlist_name%, on %skill_name%.", "PLAYLIST_SHUFFLE": "Shuffling songs from playlist %playlist_name%, on %skill_name%.", - "PLAYLIST_QUEUED": "Added %count% songs from playlist %playlist_name%, to the queue." + "PLAYLIST_QUEUED": "Added %count% songs from playlist %playlist_name%, to the queue.", + "FAVOURITES_PLAYING": "Playing %user_name%'s favourites, on %skill_name%.", + "FAVOURITES_SHUFFLE": "Shuffling %user_name%'s favourites, on %skill_name%.", + "FAVOURITES_QUEUED": "Added %count% songs from %user_name%'s favourites, to the queue.", + "QUERY_SONGNAME": "The current song is %song_name% by %artist_name%.", + "QUERY_NO_SONG": "There is currently no song playing.", + "END_OF_QUEUE": "There are no more songs left in the queue." } \ No newline at end of file diff --git a/src/playlist/devices.js b/src/playlist/devices.js index 57d55b0..ed6dc47 100644 --- a/src/playlist/devices.js +++ b/src/playlist/devices.js @@ -70,7 +70,7 @@ const onPlaybackNearlyFinished = function (handlerInput) { */ const onQueueFinished = function (handlerInput, speak) { - const { responseBuilder } = handlerInput; + const { responseBuilder, requestEnvelope } = handlerInput; const deviceID = Alexa.getDeviceId(requestEnvelope); @@ -79,7 +79,7 @@ const onQueueFinished = function (handlerInput, speak) { Logger.Debug(`[Device ${playlist.Id}]`, "Playback queue finished!"); if (speak) { - const speech = "There are no more songs left in the queue."; + const speech = LANGUAGE.Value("END_OF_QUEUE"); return responseBuilder.speak(speech).getResponse(); } diff --git a/src/skill/album-intents.js b/src/skill/album-intents.js index 42447d7..c3e2041 100644 --- a/src/skill/album-intents.js +++ b/src/skill/album-intents.js @@ -22,7 +22,7 @@ const Processer = async function (handlerInput, action = "play", buildQueue, sub if (!slots.albumname || !slots.albumname.value) { const speech = LANGUAGE.Value("ALBUM_NO_NAME"); - return [{ status: false, speach1 }]; + return [{ status: false, speech }]; } Logger.Debug(`[Album Request]`, `Requested album ${slots.albumname.value}`); diff --git a/src/skill/favourite-intents.js b/src/skill/favourite-intents.js new file mode 100644 index 0000000..ede0654 --- /dev/null +++ b/src/skill/favourite-intents.js @@ -0,0 +1,190 @@ +const { limit } = CONFIG.jellyfin; + +const Alexa = require('ask-sdk-core'); + +const JellyFin = require("../jellyfin-api.js"); + +const Devices = require("../playlist/devices.js"); + +const Player = require("../players/player.js"); + +const { CreateQueueIntent } = require("./alexa-helper.js"); + +/********************************************************************************* + * Process Intent: Get favourites by user name + */ + +const Processer = async function (handlerInput, action = "play", buildQueue, submit) { + const { requestEnvelope } = handlerInput; + const intent = requestEnvelope.request.intent; + const slots = intent.slots; + + if (!slots.username || !slots.username.value) { + const speech = LANGUAGE.Value("USERNAME_NO_NAME"); + + return [{ status: false, speech }]; + } + + Logger.Debug(`[Favourite Request]`, `Requested user ${slots.username.value}`); + + const users = await JellyFin.Users(slots.username.value); + + if (!users.status || !users.items[0]) { + Logger.Debug(`[Favourite Request]`, "No user found."); + + const speech = LANGUAGE.Parse("USER_NOTFOUND_BY_NAME", { username: slots.username.value }); + + return [{ status: false, speech }]; + } + + const user = users.items[0]; + + const favourites = await JellyFin.Favourites(user.Id); + + const then = async function (data) { + if (buildQueue) + for (let i = favourites.index + limit; i < favourites.count; i += limit) + buildQueue(handlerInput, await JellyFin.Favourites(user.Id, i), data); + + if (submit) + return await submit(handlerInput, data); + }; + + return [{ status: true, user, items: favourites.items }, then]; +}; + +/********************************************************************************* + * Play Favourites Intent + */ + +const PlayFavouritesIntent = CreateQueueIntent( + "PlayFavouritesIntent", "play", Processer, + function (handlerInput, { user, items }, data) { + const { responseBuilder, requestEnvelope } = handlerInput; + + const deviceID = Alexa.getDeviceId(requestEnvelope); + + const playlist = Devices.getPlayList(deviceID); + + playlist.clear(); + + [data.first, data.last] = playlist.prefixItems(items); + + const item = playlist.getCurrentItem(); + + if (!Player.PlayItem(handlerInput, playlist, item)) + return responseBuilder.getResponse(); + + Logger.Info(`[Device ${playlist.Id}]`, `Playing ${user.Name}'s favourites.`); + + var speech = LANGUAGE.Parse("FAVOURITES_PLAYING", { user_name: user.Name }); + + return responseBuilder.speak(speech).getResponse(); + }, + function ({ requestEnvelope }, { status, items }, data) { + if (!status) return; + + const deviceID = Alexa.getDeviceId(requestEnvelope); + + const playlist = Devices.getPlayList(deviceID); + + const [first, last] = playlist.prefixItems(items, data.last + 1); + + data.last = last; + } +); + +/********************************************************************************* + * Shuffle Favourites Intent + */ + +const ShuffleFavouritesIntent = CreateQueueIntent( + "ShuffleFavouritesIntent", "shuffling", Processer, + function (handlerInput, { user, items }, data) { + const { responseBuilder, requestEnvelope } = handlerInput; + + const deviceID = Alexa.getDeviceId(requestEnvelope); + + const playlist = Devices.getPlayList(deviceID); + + playlist.clear(); + + items = playlist.shuffleItems(items); + + [data.first, data.last] = playlist.prefixItems(items); + + const item = playlist.getCurrentItem(); + + if (!Player.PlayItem(handlerInput, playlist, item)) + return responseBuilder.getResponse(); + + Logger.Info(`[Device ${playlist.Id}]`, `Playing ${user.Name}'s favourites.`); + + var speech = LANGUAGE.Parse("FAVOURITES_SHUFFLE", { user_name: user.Name }); + + return responseBuilder.speak(speech).getResponse(); + }, + function ({ requestEnvelope }, { status, items }, data) { + if (!status) return; + + const deviceID = Alexa.getDeviceId(requestEnvelope); + + const playlist = Devices.getPlayList(deviceID); + + const [first, last] = playlist.prefixItems(items, data.last + 1); + + data.last = last; + }, + function ({ requestEnvelope }, { first, last }) { + const deviceID = Alexa.getDeviceId(requestEnvelope); + + const playlist = Devices.getPlayList(deviceID); + + playlist.shuffleRemainingItems(first, last); + } +); + +/********************************************************************************* + * Queue Favourites Intent + */ + +const QueueFavouritesIntent = CreateQueueIntent( + "QueueFavouritesIntent", "queue", Processer, + function (handlerInput, { user, items, count }, data) { + const { responseBuilder, requestEnvelope } = handlerInput; + + const deviceID = Alexa.getDeviceId(requestEnvelope); + + const playlist = Devices.getPlayList(deviceID); + + playlist.appendItems(items); + + const item = playlist.getCurrentItem(); + + if (Player.PlayItem(handlerInput, playlist, item)) + Logger.Info(`[Device ${playlist.Id}]`, `Playing ${user.Name}'s favourites.`); + + var speech = LANGUAGE.Parse("FAVOURITES_QUEUED", { count, user_name: user.Name }); + + return responseBuilder.speak(speech).getResponse(); + }, + function ({ requestEnvelope }, { status, items }, data) { + if (!status) return; + + const deviceID = Alexa.getDeviceId(requestEnvelope); + + const playlist = Devices.getPlayList(deviceID); + + playlist.appendItems(items); + } +); + +/********************************************************************************* + * Exports + */ + +module.exports = { + PlayFavouritesIntent, + ShuffleFavouritesIntent, + QueueFavouritesIntent +}; diff --git a/src/skill/query-intents.js b/src/skill/query-intents.js index 17da8f4..2ee9a79 100644 --- a/src/skill/query-intents.js +++ b/src/skill/query-intents.js @@ -10,21 +10,25 @@ const { CreateIntent } = require("./alexa-helper.js"); const WhatThisIntent = CreateIntent( "WhatThisIntent", - function(handlerInput) - { + function (handlerInput) { const { responseBuilder, requestEnvelope } = handlerInput; const deviceID = Alexa.getDeviceId(requestEnvelope); - + const playlist = Devices.getPlayList(deviceID); - + const item = playlist.getCurrentItem(); - var speech = "There is currently no song playing."; + var speech = LANGUAGE.Value("QUERY_NO_SONG"); if (item) - speech = `The current song is ${item.Item.Name} by ${item.Item.AlbumArtist}`; - + speech = LANGUAGE.Parse("QUERY_SONGNAME", + { + song_name: item.Item.Name, + artist_name: item.Item.AlbumArtist + } + ); + return responseBuilder.speak(speech).getResponse(); } ); diff --git a/src/skill/skill.js b/src/skill/skill.js index 33d4dfd..19e65f8 100644 --- a/src/skill/skill.js +++ b/src/skill/skill.js @@ -18,6 +18,8 @@ const SongIntents = require("./song-intents.js"); const PlaylistIntents = require("./playlist-intents.js"); +const FavouriteIntents = require("./favourite-intents.js"); + const GenreIntents = require("./genre-intent.js"); const QueryIntents = require("./query-intents.js"); @@ -28,9 +30,8 @@ const QueryIntents = require("./query-intents.js"); const ErrorHandler = { canHandle: () => true, - handle: function (handlerInput, error) - { - const {responseBuilder, requestEnvelope} = handlerInput; + handle: function (handlerInput, error) { + const { responseBuilder, requestEnvelope } = handlerInput; if (!error && requestEnvelope.request) error = requestEnvelope.request.error; @@ -38,8 +39,7 @@ const ErrorHandler = { const type = Alexa.getRequestType(handlerInput.requestEnvelope); Logger.Error(`Error handled: ${type}`); - if (type == "IntentRequest") - { + if (type == "IntentRequest") { const intent = Alexa.getIntentName(handlerInput.requestEnvelope); Logger.Error(`Intent: ${intent}`); } @@ -100,8 +100,11 @@ const skill = Alexa.SkillBuilders.custom() SongIntents.QueueSongIntent, GenreIntents.QueueGenreIntent, - QueryIntents.WhatThisIntent, - ErrorHandler //This is for debugging, since not all errors are passed into addErrorHandlers(ErrorHandler) + FavouriteIntents.PlayFavouritesIntent, + FavouriteIntents.ShuffleFavouritesIntent, + FavouriteIntents.QueueFavouritesIntent, + + QueryIntents.WhatThisIntent ).addErrorHandlers( ErrorHandler ).withSkillId(CONFIG.skill.id).create(); diff --git a/src/skill/song-intents.js b/src/skill/song-intents.js index e1770a8..5305380 100644 --- a/src/skill/song-intents.js +++ b/src/skill/song-intents.js @@ -133,7 +133,7 @@ const PlaySongIntent = CreateQueueIntent( speech = LANGUAGE.Parse("SONG_PLAYING_BY_ARTIST", { song_name: items[0].Name, - song_artist: items[0].AlbumArtist + artist_name: items[0].AlbumArtist } ); @@ -167,7 +167,7 @@ const QueueSongIntent = CreateQueueIntent( speech = LANGUAGE.Parse("SONG_QUEUED_BY_ARTIST", { song_name: items[0].Name, - song_artist: items[0].AlbumArtist + artist_name: items[0].AlbumArtist } ); From 88a20e6d2056f2869ff801964fd783fc8132662e Mon Sep 17 00:00:00 2001 From: rusketh Date: Tue, 30 Dec 2025 17:41:58 +0000 Subject: [PATCH 10/10] Fixed bug with audio streaming. Improved english skill with more samples. Added missing entries to english language file. Added better error handaling to the skill. --- .gitignore | 3 +- skill-json/skill-EN.json | 363 ++++++++++++++++++++------------- src/language.js | 11 +- src/languages/EN.json | 2 + src/players/audio-player.js | 15 +- src/playlist/devices.js | 2 +- src/playlist/playlist.js | 45 ---- src/skill/favourite-intents.js | 6 +- src/skill/skill.js | 39 ++-- 9 files changed, 280 insertions(+), 206 deletions(-) diff --git a/.gitignore b/.gitignore index 890099f..0420736 100644 --- a/.gitignore +++ b/.gitignore @@ -6,4 +6,5 @@ build npm-debug.log .env .DS_Store -.devcontainer/.env \ No newline at end of file +.devcontainer/.env +.vscode/launch.json diff --git a/skill-json/skill-EN.json b/skill-json/skill-EN.json index 51c7a90..b0d7d63 100644 --- a/skill-json/skill-EN.json +++ b/skill-json/skill-EN.json @@ -4,38 +4,54 @@ "invocationName": "jelly music", "intents": [ { - "name": "PlayFavouritesIntent", - "slots": [ - { - "name": "username", - "type": "AMAZON.FirstName" - } - ], - "samples": [ - "play favourites for {username}", - "from {username} play favourites", - "play {username} favourite tracks", - "play {username} favourite songs", - "play {username} favourite music", - "play {username} favourites" - ] + "name": "AMAZON.CancelIntent", + "samples": [] }, { - "name": "AMAZON.CancelIntent", + "name": "AMAZON.StopIntent", "samples": [] }, { - "name": "AMAZON.HelpIntent", + "name": "AMAZON.PauseIntent", "samples": [] }, { - "name": "AMAZON.StopIntent", + "name": "AMAZON.ResumeIntent", "samples": [] }, { - "name": "AMAZON.NavigateHomeIntent", + "name": "AMAZON.NextIntent", "samples": [] }, + { + "name": "AMAZON.PreviousIntent", + "samples": [] + }, + { + "name": "PlayFavouritesIntent", + "slots": [ + { + "name": "username", + "type": "AMAZON.SearchQuery" + } + ], + "samples": [ + "play favourites for {username}", + "play favorites for {username}", + "from {username} play favourites", + "play {username} favourite tracks", + "play {username} favourite songs", + "play {username} favourite music", + "play {username} favourites", + "put on {username} favourites", + "listen to {username} favourites", + "start {username} favourites", + "play the favourites list for {username}", + "play {username} top tracks", + "play {username} liked songs", + "put on the favorites for {username}" + ] + }, { "name": "PlayAlbumIntent", "slots": [ @@ -51,26 +67,18 @@ "samples": [ "play the album {albumname} by {artistname}", "play the album {albumname}", - "play album {albumname} by {artistname} ", - "play album {albumname} " + "play album {albumname} by {artistname}", + "play album {albumname}", + "put on the album {albumname}", + "I want to listen to the album {albumname}", + "start the album {albumname}", + "spin the album {albumname}", + "play the record {albumname}", + "play the full album {albumname}", + "listen to the album {albumname} by {artistname}", + "can you play the album {albumname}" ] }, - { - "name": "AMAZON.PauseIntent", - "samples": [] - }, - { - "name": "AMAZON.ResumeIntent", - "samples": [] - }, - { - "name": "AMAZON.NextIntent", - "samples": [] - }, - { - "name": "AMAZON.PreviousIntent", - "samples": [] - }, { "name": "PlayArtistIntent", "slots": [ @@ -81,7 +89,18 @@ ], "samples": [ "play songs by {artistname}", - "play artist {artistname}" + "play artist {artistname}", + "play music by {artistname}", + "put on {artistname}", + "I want to hear {artistname}", + "listen to {artistname}", + "play {artistname}", + "start playing {artistname}", + "put on some {artistname}", + "give me some {artistname}", + "play a mix of {artistname}", + "shuffle music by {artistname}", + "put on music from {artistname}" ] }, { @@ -100,7 +119,14 @@ "play song {songname} by {artistname}", "play {songname} by {artistname}", "play {songname}", - "play song {songname}" + "play the song {songname}", + "play the track {songname}", + "put on the song {songname}", + "I want to hear the song {songname}", + "play the track {songname} by {artistname}", + "put on {songname} by {artistname}", + "start the song {songname}", + "let me hear {songname}" ] }, { @@ -112,30 +138,37 @@ } ], "samples": [ - "play playlist {playlistname}" + "play playlist {playlistname}", + "start playlist {playlistname}", + "load playlist {playlistname}", + "put on my {playlistname} playlist", + "play the {playlistname} playlist", + "listen to playlist {playlistname}", + "queue up playlist {playlistname}", + "play my playlist called {playlistname}", + "start the {playlistname} playlist" ] }, { - "name": "ShuffleAlbumIntent", + "name": "PlayGenreIntent", "slots": [ { - "name": "albumname", - "type": "AMAZON.MusicAlbum" - }, - { - "name": "artistname", - "type": "AMAZON.Musician" + "name": "genre", + "type": "AMAZON.Genre" } ], "samples": [ - "shuffle the album {albumname}", - "shuffle the album {albumname} by {artistname}", - "shuffle album {albumname} by {artistname}", - "shuffle album {albumname}" + "play {genre} music", + "play genre {genre}", + "play some {genre}", + "put on some {genre} music", + "start playing {genre}", + "I feel like listening to {genre}", + "play me some {genre}" ] }, { - "name": "QueueAlbumIntent", + "name": "ShuffleAlbumIntent", "slots": [ { "name": "albumname", @@ -147,14 +180,13 @@ } ], "samples": [ - "add the album {albumname} by {artistname} to the queue", - "add the album {albumname} to the queue", - "add album {albumname} by {artistname} to the queue", - "add album {albumname} to the queue", - "queue the album {albumname} by {artistname}", - "queue album {albumname} by {artistname}", - "queue album {albumname}", - "queue the album {albumname}" + "shuffle the album {albumname}", + "shuffle the album {albumname} by {artistname}", + "shuffle album {albumname} by {artistname}", + "shuffle album {albumname}", + "play album {albumname} on shuffle", + "mix the album {albumname}", + "play the album {albumname} shuffled" ] }, { @@ -167,7 +199,12 @@ ], "samples": [ "shuffle artist {artistname}", - "shuffle songs by {artistname}" + "shuffle songs by {artistname}", + "play {artistname} on shuffle", + "mix up songs by {artistname}", + "play a random mix of {artistname}", + "shuffle music from {artistname}", + "play random songs by {artistname}" ] }, { @@ -179,20 +216,91 @@ } ], "samples": [ - "shuffle playlist {playlistname}" + "shuffle playlist {playlistname}", + "play playlist {playlistname} on shuffle", + "mix up playlist {playlistname}", + "shuffle the {playlistname} playlist", + "play my {playlistname} playlist on shuffle" ] }, { - "name": "QueuePlaylistIntent", + "name": "ShuffleGenreIntent", "slots": [ { - "name": "playlistname", + "name": "genre", + "type": "AMAZON.Genre" + } + ], + "samples": [ + "shuffle {genre} music", + "shuffle genre {genre}", + "play {genre} on shuffle", + "play random {genre} songs", + "mix up some {genre}" + ] + }, + { + "name": "ShuffleFavouritesIntent", + "slots": [ + { + "name": "username", "type": "AMAZON.SearchQuery" } ], "samples": [ - "add playlist {playlistname} to the queue", - "queue playlist {playlistname}" + "shuffle favourites for {username}", + "from {username} shuffle favourites", + "shuffle {username} favourite tracks", + "shuffle {username} favourite songs", + "shuffle {username} favourites", + "play {username} favourites on shuffle", + "shuffle {username} liked songs", + "mix up {username} favourites", + "play {username} top songs shuffled" + ] + }, + { + "name": "QueueAlbumIntent", + "slots": [ + { + "name": "albumname", + "type": "AMAZON.MusicAlbum" + }, + { + "name": "artistname", + "type": "AMAZON.Musician" + } + ], + "samples": [ + "add the album {albumname} by {artistname} to the queue", + "add the album {albumname} to the queue", + "add album {albumname} by {artistname} to the queue", + "add album {albumname} to the queue", + "queue the album {albumname} by {artistname}", + "queue album {albumname} by {artistname}", + "queue album {albumname}", + "queue the album {albumname}", + "play album {albumname} next", + "put album {albumname} in the queue", + "add {albumname} to the up next list" + ] + }, + { + "name": "QueueArtistIntent", + "slots": [ + { + "name": "artistname", + "type": "AMAZON.Musician" + } + ], + "samples": [ + "add songs by {artistname} to the queue", + "queue artist {artistname}", + "queue songs by {artistname}", + "play {artistname} next", + "add {artistname} to the up next", + "put {artistname} in the queue", + "add music by {artistname} to the queue" ] }, { @@ -215,29 +323,25 @@ "queue {songname}", "queue song {songname}", "queue {songname} by {artistname}", - "queue song {songname} by {artistname}" + "queue song {songname} by {artistname}", + "play {songname} next", + "put {songname} on the queue", + "add the track {songname} to the queue" ] }, { - "name": "ClearQueueIntent", - "slots": [], - "samples": [ - "clear queue", - "empty the queue", - "clear the queue" - ] - }, - { - "name": "PlayGenreIntent", + "name": "QueuePlaylistIntent", "slots": [ { - "name": "genre", - "type": "AMAZON.Genre" + "name": "playlistname", + "type": "AMAZON.SearchQuery" } ], "samples": [ - "play {genre} music", - "play genre {genre}" + "add playlist {playlistname} to the queue", + "queue playlist {playlistname}", + "add the {playlistname} playlist to the queue", + "play playlist {playlistname} next" ] }, { @@ -250,84 +354,65 @@ ], "samples": [ "queue {genre} music", - "queue genre {genre}" + "queue genre {genre}", + "add {genre} music to the queue", + "play {genre} next" ] }, { - "name": "ShuffleGenreIntent", + "name": "QueueFavouritesIntent", "slots": [ { - "name": "genre", - "type": "AMAZON.Genre" + "name": "username", + "type": "AMAZON.SearchQuery" } ], "samples": [ - "shuffle {genre} music", - "shuffle genre {genre}" + "queue favourites for {username}", + "from {username} queue favourites", + "queue {username} favourite tracks", + "queue {username} favourite songs", + "queue {username} favourite music", + "queue {username} favourites", + "add favourites for {username} to the queue", + "add {username} favourite tracks to the queue", + "add {username} favourite songs to the queue", + "add {username} favourite music to the queue", + "add {username} favourites to the queue", + "play {username} favourites next" ] }, { - "name": "WhatThisIntent", + "name": "ClearQueueIntent", "slots": [], "samples": [ - "what is playing", - "what is this", - "what is the name of this song", - "What is this song" - ] - }, - { - "name": "QueueArtistIntent", - "slots": [ - { - "name": "artistname", - "type": "AMAZON.Musician" - } - ], - "samples": [ - "add songs by {artistname} to the queue", - "queue artist {artistname}", - "queue songs by {artistname}" + "clear queue", + "empty the queue", + "clear the queue", + "clear up next", + "wipe the queue", + "delete the queue", + "remove upcoming songs" ] }, { - "name": "ShuffleFavouritesIntent", - "slots": [ - { - "name": "username", - "type": "AMAZON.FirstName" - } - ], + "name": "WhatThisIntent", + "slots": [], "samples": [ - "shuffle favourites for {username}", - "from {username} shuffle favourites", - "shuffle {username} favourite tracks", - "shuffle {username} favourite songs", - "shuffle {username} favourite music", - "shuffle {username} favourites" + "what is playing", + "what is this", + "what is the name of this song", + "What is this song", + "who is singing this", + "what track is this", + "identify this song", + "who is this artist", + "tell me what is playing" ] }, { - "name": "QueueFavouritesIntent", - "slots": [ - { - "name": "username", - "type": "AMAZON.FirstName" - } - ], - "samples": [ - "queue favourites for {username}", - "from {username} queue favourites", - "queue {username} favourite tracks", - "queue {username} favourite songs", - "queue {username} favourite music", - "queue {username} favourites", - "add favourites for {username} to the queue", - "add {username} favourite tracks to the queue", - "add {username} favourite songs to the queue", - "add {username} favourite music to the queue", - "add {username} favourites to the queue" - ] + "name": "AMAZON.HelpIntent", + "samples": [] } ], "types": [] diff --git a/src/language.js b/src/language.js index 9957ac6..290afdc 100644 --- a/src/language.js +++ b/src/language.js @@ -23,7 +23,16 @@ if (CONFIG.language != "EN") * Get Value */ -const Value = (key) => Language[key] || "Unkown response"; +//const Value = (key) => Language[key] || "Unkown response"; + +const Value = function(key) +{ + if (Language[key]) + return Language[key]; + + Logger.Warn(`Missing language key: ${key}`); + return "Unkown response"; +} /****************************************************************************************** * Parse diff --git a/src/languages/EN.json b/src/languages/EN.json index 7ead339..43f5148 100644 --- a/src/languages/EN.json +++ b/src/languages/EN.json @@ -39,6 +39,8 @@ "PLAYLIST_PLAYING": "Playing songs from playlist %playlist_name%, on %skill_name%.", "PLAYLIST_SHUFFLE": "Shuffling songs from playlist %playlist_name%, on %skill_name%.", "PLAYLIST_QUEUED": "Added %count% songs from playlist %playlist_name%, to the queue.", + "NO_USERNAME": "I didn't catch the user name.", + "USER_NOTFOUND_BY_NAME": "I didn't find a user called %user_name%.", "FAVOURITES_PLAYING": "Playing %user_name%'s favourites, on %skill_name%.", "FAVOURITES_SHUFFLE": "Shuffling %user_name%'s favourites, on %skill_name%.", "FAVOURITES_QUEUED": "Added %count% songs from %user_name%'s favourites, to the queue.", diff --git a/src/players/audio-player.js b/src/players/audio-player.js index f8048b8..f7f2f18 100644 --- a/src/players/audio-player.js +++ b/src/players/audio-player.js @@ -2,8 +2,13 @@ const PlayItem = function(handlerInput, playlist, item) { if (!item || !item.Playback.Token) return false; + Logger.Debug(`[Device ${playlist.Id}]`, `PlayItem: ${item.Playback.Token} vs ${playlist.playbackToken}.`); + if (playlist.playbackToken && playlist.playbackToken == item.Playback.Token) + { + Logger.Debug(`[Device ${playlist.Id}]`, `Playback token ${item.Playback.Token} is already playing.`); return false; + } handlerInput.responseBuilder.addAudioPlayerPlayDirective( 'REPLACE_ALL', @@ -15,16 +20,21 @@ const PlayItem = function(handlerInput, playlist, item) ); playlist.playbackToken = item.Playback.Token; + Logger.Debug(`[Device ${playlist.Id}]`, `Playing playback token ${item.Playback.Token}.`); return true; }; const EnqueueItem = function(handlerInput, playlist, item) { - if (!item || !item.Token) return false; + if (!item || !item.Playback.Token) return false; - if (playlist.playbackToken && playlist.playbackToken == item.Playback.Token) + Logger.Debug(`[Device ${playlist.Id}]`, `EnqueueItem: ${item.Playback.Token} vs ${playlist.playbackToken}.`); + + if (playlist.playbackToken && playlist.playbackToken == item.Playback.Token) { + Logger.Debug(`[Device ${playlist.Id}]`, `Playback token ${item.Playback.Token} is already enqueued.`); return false; + } handlerInput.responseBuilder.addAudioPlayerPlayDirective( 'ENQUEUE', @@ -36,6 +46,7 @@ const EnqueueItem = function(handlerInput, playlist, item) ); playlist.playbackToken = item.Playback.Token; + Logger.Debug(`[Device ${playlist.Id}]`, `Enqueued playback token ${item.Playback.Token}.`); return true; }; diff --git a/src/playlist/devices.js b/src/playlist/devices.js index ed6dc47..20484b3 100644 --- a/src/playlist/devices.js +++ b/src/playlist/devices.js @@ -152,7 +152,7 @@ const onPlaybackFinished = function (handlerInput) { if (!playlist.nextItem()) return onQueueFinished(handlerInput); - const item = playlist.getNextItem(); + const item = playlist.getCurrentItem(); if (!Player.PlayItem(handlerInput, playlist, item)) return responseBuilder.getResponse(); diff --git a/src/playlist/playlist.js b/src/playlist/playlist.js index d5b2f34..3308850 100644 --- a/src/playlist/playlist.js +++ b/src/playlist/playlist.js @@ -152,31 +152,6 @@ PlayList.getNextItem = function() return this.Queue[this.Position + 1]; }; -/********************************************************************************* - * getPlayDirective - * Gets the arguments for addAudioPlayerPlayDirective to play a track - */ - -PlayList.getPlayDirective = function(handlerInput) -{ - const current = this.getCurrentItem(); - - if (!current) return null; - - return current.getPlayDirective(); -}; - -PlayList.getPlayNextDirective = function(handlerInput) -{ - const current = this.getCurrentItem(); - const next = this.getNextItem(); - - if (!next) return null; - - if (!current) return next.getPlayDirective(); - - return next.getPlayNextDirective(current.Playback); -}; PlayList.getPlayPreviousDirective = function(handlerInput) { @@ -190,26 +165,6 @@ PlayList.getPlayPreviousDirective = function(handlerInput) return previous.getPlayNextDirective(current.Playback); }; - -PlayList.Validate = function(directive, {attributesManager}) -{ - var token = directive[2]; - - if (!token) return true; - - if (this.playbackToken && this.playbackToken == token) - { - Logger.Debug(`[Playlist ${this.Id}]`, "Existing playback token rejected."); - return false; - } - - Logger.Debug(`[Playlist ${this.Id}]`, "New playback token consumed."); - - this.playbackToken = token; - - return true; -} - /********************************************************************************* * Next & Previous */ diff --git a/src/skill/favourite-intents.js b/src/skill/favourite-intents.js index ede0654..d348546 100644 --- a/src/skill/favourite-intents.js +++ b/src/skill/favourite-intents.js @@ -20,7 +20,7 @@ const Processer = async function (handlerInput, action = "play", buildQueue, sub const slots = intent.slots; if (!slots.username || !slots.username.value) { - const speech = LANGUAGE.Value("USERNAME_NO_NAME"); + const speech = LANGUAGE.Value("NO_USERNAME"); return [{ status: false, speech }]; } @@ -29,7 +29,7 @@ const Processer = async function (handlerInput, action = "play", buildQueue, sub const users = await JellyFin.Users(slots.username.value); - if (!users.status || !users.items[0]) { + if (!users.status || !users.users[0]) { Logger.Debug(`[Favourite Request]`, "No user found."); const speech = LANGUAGE.Parse("USER_NOTFOUND_BY_NAME", { username: slots.username.value }); @@ -37,7 +37,7 @@ const Processer = async function (handlerInput, action = "play", buildQueue, sub return [{ status: false, speech }]; } - const user = users.items[0]; + const user = users.users[0]; const favourites = await JellyFin.Favourites(user.Id); diff --git a/src/skill/skill.js b/src/skill/skill.js index 19e65f8..b095001 100644 --- a/src/skill/skill.js +++ b/src/skill/skill.js @@ -25,25 +25,34 @@ const GenreIntents = require("./genre-intent.js"); const QueryIntents = require("./query-intents.js"); /********************************************************************************* - * Error Handler + * Error Handlers */ -const ErrorHandler = { +const Unhandler = { canHandle: () => true, - handle: function (handlerInput, error) { + handle: function (handlerInput) { const { responseBuilder, requestEnvelope } = handlerInput; + const type = Alexa.getRequestType(requestEnvelope); + Logger.Warn(`[Alexa Skill] Unhandled request: ${type}`); + return responseBuilder.getResponse(); + } +}; - if (!error && requestEnvelope.request) - error = requestEnvelope.request.error; - - const type = Alexa.getRequestType(handlerInput.requestEnvelope); - Logger.Error(`Error handled: ${type}`); - - if (type == "IntentRequest") { - const intent = Alexa.getIntentName(handlerInput.requestEnvelope); - Logger.Error(`Intent: ${intent}`); - } +const ExceptionEncounteredHandler = { + canHandle: (handlerInput) => Alexa.getRequestType(handlerInput.requestEnvelope) === 'System.ExceptionEncountered', + handle: (handlerInput) => { + const { requestEnvelope, responseBuilder } = handlerInput; + const { cause, error } = requestEnvelope.request; + Logger.Error(`[Alexa Skill] Exception encountered: ${cause}`); + Logger.Error(error); + return responseBuilder.getResponse(); + } +}; +const ErrorHandler = { + canHandle: () => true, + handle: function (handlerInput, error) { + Logger.Error(`[Alexa Skill] Error handled: ${error.message}`); Logger.Error(error); return responseBuilder.getResponse(); @@ -104,7 +113,9 @@ const skill = Alexa.SkillBuilders.custom() FavouriteIntents.ShuffleFavouritesIntent, FavouriteIntents.QueueFavouritesIntent, - QueryIntents.WhatThisIntent + QueryIntents.WhatThisIntent, + ExceptionEncounteredHandler, + Unhandler ).addErrorHandlers( ErrorHandler ).withSkillId(CONFIG.skill.id).create();