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/ReadMe.md b/ReadMe.md index cf29283..1e01bbc 100644 --- a/ReadMe.md +++ b/ReadMe.md @@ -1,155 +1,355 @@ + # Jelly Music +[![demo](https://img.shields.io/discord/1454115231814127626?logo=discord)](https://discord.gg/CacbSg2Mza) + ## 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**." + + --- -## Features +# IMPORTANT CHANGE: + 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.** -* Per device music queues. + Jellyfin music now requires the **"Alexa Presentation Language" Interface** (see: Installation Instructions for more information). -* Play music from your Jellyfin server. + **If you do not enable this your skill will no longer function after updating on echo devices.** -* Support to search by, **Song Name**, **Album Name**, **Artist Name**, **Playlist** and **Genre***. +## Features ---- + -## Alpha Software Disclaimer +* Per device music queues. -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. +* Play music from your Jellyfin server. -* 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. +* Support to search by, **Song Name**, **Album Name**, **Artist Name**, **Playlist** and **Genre***. + + --- + + ## 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. + +--- + +## 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. + + + --- ## 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. + + 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`` + + +``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 + + 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. + + * **`SKILL_NAME`**: The two-word name Alexa will use to refer to your Jellyfin server (e.g., "Jelly Music"). -* **`PORT`**: The Port your server will use (remember to enable this port on Docker). + + +* **`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). *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 **SQLite database** used to store the song queue for each connected device, are saved within the `/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. + + + **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. + + +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) -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** 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! + + **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! + +--- + +## 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: + "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. --- + + +## 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. + +--- + ### Intents -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)``. | @@ -168,5 +368,43 @@ Here is a list of all the current none standard intents used, the example comman | 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/src/skill/skill.json b/skill-json/skill-EN.json similarity index 52% rename from src/skill/skill.json rename to skill-json/skill-EN.json index 401de4a..b0d7d63 100644 --- a/src/skill/skill.json +++ b/skill-json/skill-EN.json @@ -8,17 +8,50 @@ "samples": [] }, { - "name": "AMAZON.HelpIntent", + "name": "AMAZON.StopIntent", "samples": [] }, { - "name": "AMAZON.StopIntent", + "name": "AMAZON.PauseIntent", + "samples": [] + }, + { + "name": "AMAZON.ResumeIntent", + "samples": [] + }, + { + "name": "AMAZON.NextIntent", "samples": [] }, { - "name": "AMAZON.NavigateHomeIntent", + "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": [ @@ -34,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": [ @@ -64,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}" ] }, { @@ -83,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}" ] }, { @@ -95,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", @@ -130,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" ] }, { @@ -150,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}" ] }, { @@ -162,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" ] }, { @@ -198,29 +323,25 @@ "queue {songname}", "queue song {songname}", "queue {songname} by {artistname}", - "queue song {songname} by {artistname}" - ] - }, - { - "name": "ClearQueueIntent", - "slots": [], - "samples": [ - "clear queue", - "empty the queue", - "clear the queue" + "queue song {songname} by {artistname}", + "play {songname} next", + "put {songname} on the queue", + "add the track {songname} to 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" ] }, { @@ -233,20 +354,45 @@ ], "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": "ClearQueueIntent", + "slots": [], + "samples": [ + "clear queue", + "empty the queue", + "clear the queue", + "clear up next", + "wipe the queue", + "delete the queue", + "remove upcoming songs" ] }, { @@ -256,22 +402,17 @@ "what is playing", "what is this", "what is the name of this song", - "What is 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": "QueueArtistIntent", - "slots": [ - { - "name": "artistname", - "type": "AMAZON.Musician" - } - ], - "samples": [ - "add songs by {artistname} to the queue", - "queue artist {artistname}", - "queue songs by {artistname}" - ] + "name": "AMAZON.HelpIntent", + "samples": [] } ], "types": [] 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 971754c..f5709a1 100644 --- a/src/init.js +++ b/src/init.js @@ -20,15 +20,15 @@ CONFIG = { }, skill: { + id: null, name: "Jelly Music" }, server: { - port: "4141" + port: "60648" } }; - /********************************************************************************* * */ @@ -68,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 */ @@ -76,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 @@ -90,9 +113,11 @@ 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; -//TODO: CONFIG.skill.id maybe needed if I ever add automatic skill building. +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 @@ -102,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; } @@ -134,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..469c16a 100644 --- a/src/jellyfin-api.js +++ b/src/jellyfin-api.js @@ -1,264 +1,165 @@ -/********************************************************************************* - * Request Items from API - * https://api.jellyfin.org/ - */ - -const Request = async function(endpoint, params, ...args) -{ - const url = new URL(endpoint, CONFIG.jellyfin.host); - - if (params) - { - params = Object.assign({}, params, ...args); - - for (const key in params) - { - const value = params[key]; - - 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' - } - } - ); - - if (!response.ok) - return {status: false, error: await response.text()}; +const https = require('https'); - var result = await response.json(); - - return {status: true, items: result.Items, index: result.StartIndex, count: result.TotalRecordCount }; -}; +const fuzzball = require('fuzzball'); /********************************************************************************* - * Paged Items. - * Returns the first page and process a function for all pages. + * API Request */ -/*const PagedItems = async function(callback, api, limit, ...args) -{ - const result = await api(...args, {limit}) +const Agent = new https.Agent({ keepAlive: true }); - if (!result.status) return result; - - if (callback) +const RequestOptions = { + method: 'GET', + agent: Agent, + timeout: 5000, + headers: { - callback(res); - - for (let i = result.index; i < result.count; i += limit) - api(...args, {limit, startIndex: i}).then(callback); - }; - - return result; -};*/ - -/********************************************************************************* - * Request Artists - */ - -const Artists = async (...params) => await Request("/Artists", ...params); - -Artists.ByName = async (artist, ...params) => await Request(`/Artists/${artist}`, ...params); - -Artists.AlbumArtists = async (...params) => await Request("/Artists/AlbumArtists", ...params); - -/********************************************************************************* - * 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 Items({includeItemTypes: "MusicGenre"}, ...params); -Items.Playlists = async (...params) => await Items({includeItemTypes: "Playlist"}, ...params); - - -/********************************************************************************* - * Search Function - */ - -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; + Authorization: `MediaBrowser Token="${CONFIG.jellyfin.key}"`, + 'Content-Type': 'application/json', + 'Accept': 'application/json' + } }; -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); - -/********************************************************************************* - * Find Albums - */ +const MakeAPIRequest = async function (url, { ids, query, albums, artists, genres, parent }, startIndex, limit) { -Items.Albums.ByGenre = async function(query, ...params) -{ - const result = await Items.MusicGenres.Search(query); + url.searchParams.append("limit", limit || CONFIG.jellyfin.limit); - if (!result.status) return result; + 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); - const result2 = await Items.Albums({genres: result.items.map(item => item.Name).join("|")}, ...params); + //isFavorite + Logger.Debug("[JellyFin API]", `Requesting ${url.pathname} with ${url.searchParams.toString()}.`); - if (!result2.status) return result2; + try { + const response = await fetch(url, RequestOptions); - result2.genres = result.items; - - return result2; -}; - -Items.Albums.ByArist = async function(query, ...params) -{ - const result = await Items.Artists.Search(query); - - if (!result.status) return result; + if (!response.ok) { + Logger.Error("[JellyFin API]", `No Response from Server (path: ${url.pathname}) ${await response.text()}.`); + return { status: false }; + } - const result2 = await Items.Albums({artistIds: result.items.map(item => item.Id).join("|")}, ...params); + var result = await response.json(); - if (!result2.status) return result2; + Logger.Debug("[JellyFin API]", `Response returned ${result.Items.length} items.`); - result2.albums = result.items; + 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]", `Error getting response from Server (path: ${url.pathname}).`, error); - return result2; + return { status: false }; + } }; /********************************************************************************* - * Find Artist + * Query Endpoints */ -Items.Artists.ByGenre = async function(query, ...params) -{ - const result = await Items.MusicGenres.Search(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.Artists({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.artists = result.items; - - return result2; + return await MakeAPIRequest(url, query, startIndex, limit); }; -/********************************************************************************* - * Find Songs - */ - -Items.Music.ByGenre = async function(query, ...params) -{ - const result = await Items.MusicGenres.Search(query); - - if (!result.status) return result; +const Artists = async function (query, startIndex, limit) { + const url = new URL("/Artists", CONFIG.jellyfin.local); - const result2 = await Items.Music({genres: result.items.map(item => item.Name).join("|")}, ...params); + 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); +const Albums = async function (query, startIndex, limit) { + const url = new URL("/Items", CONFIG.jellyfin.local); - if (!result2.status) return result2; + url.searchParams.append("includeItemTypes", "MusicAlbum"); + url.searchParams.append("fields", "Id,Name,PrimaryImageTag,Genres"); - result2.artists = result.items; - - return result2; + return await MakeAPIRequest(url, query, startIndex, limit); }; -Items.Music.ByAlbum = async function(query, ...params) -{ - const result = await Items.Albums.Search(query); +const MusicGenres = async function (query, startIndex, limit) { + const url = new URL("/MusicGenres", CONFIG.jellyfin.local); - if (!result.status) return result; + url.searchParams.append("fields", "Id,Name"); - const result2 = await Items.Music({albumIds: result.items.map(item => item.Id).join("|")}, ...params); + return await MakeAPIRequest(url, query, startIndex, limit); +}; - if (!result2.status) return result2; +const Playlists = async function (query, startIndex, limit) { + const url = new URL("/Items", CONFIG.jellyfin.local); - result2.albums = result.items; + url.searchParams.append("fields", "Id,Name"); + url.searchParams.append("includeItemTypes", "Playlist"); - return result2; + return await MakeAPIRequest(url, query, startIndex, limit); }; /********************************************************************************* - * Find Songs by playlist + * Query Users */ -/*Items.Music.ByPlayList = async function(query, ...params) -{ - const result = await Items.Playlists.Search(query, {fields: "ItemIds"}); +const Users = async function (query) { + const url = new URL("/Users", CONFIG.jellyfin.local); - if (!result.status) return result; + const response = await fetch(url, RequestOptions); - const items = { }; + if (!response.ok) { + Logger.Error("[JellyFin API]", `No Response from Server (path: ${url.pathname}).`); + return { status: false }; + } - for(item of result.items) - { - const result2 = await Items({parentId: item.Id}, ...params); + try { + var users = await response.json(); - if (!result2.status) continue; + 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); + } - result2.items.forEach(item => items[item.Id] = item); + return { status: true, users }; + } + catch (error) { + Logger.Error("[JellyFin API]", `Error getting response from Server (path: ${url.pathname}).`, error); + return { status: false }; } +}; - result.playlists = result.items; +const Favourites = async function (userID, startIndex, limit) { + const url = new URL(`/Users/${userID}/Items`, CONFIG.jellyfin.local); - result.items = Object.values(items); + 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 result; -};*/ + return await MakeAPIRequest(url, {}, startIndex, limit); +}; /********************************************************************************* * Exports */ -module.exports = Items; \ No newline at end of file +module.exports = { + Music, + Artists, + Albums, + MusicGenres, + Playlists, + Users, + Favourites +}; \ No newline at end of file diff --git a/src/language.js b/src/language.js new file mode 100644 index 0000000..290afdc --- /dev/null +++ b/src/language.js @@ -0,0 +1,61 @@ +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"; + +const Value = function(key) +{ + if (Language[key]) + return Language[key]; + + Logger.Warn(`Missing language key: ${key}`); + return "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/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 new file mode 100644 index 0000000..43f5148 --- /dev/null +++ b/src/languages/EN.json @@ -0,0 +1,50 @@ +{ + "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 %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_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.", + "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/languages/NL.json b/src/languages/NL.json new file mode 100644 index 0000000..02157b8 --- /dev/null +++ b/src/languages/NL.json @@ -0,0 +1,42 @@ +{ + "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 %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 new file mode 100644 index 0000000..08fba9a --- /dev/null +++ b/src/logger.js @@ -0,0 +1,58 @@ +/********************************************************************************* + * 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 +}; + +/********************************************************************************* + * Logger Function + */ + +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..f7f2f18 --- /dev/null +++ b/src/players/audio-player.js @@ -0,0 +1,71 @@ +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', + item.getStreamURL(), + item.Playback.Token, + item.Playback.Offset, + null, + item.getMetaData() + ); + + 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.Playback.Token) return false; + + 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', + item.getStreamURL(), + item.Playback.Token, + item.Playback.Offset, + playlist.playbackToken, + item.getMetaData() + ); + + playlist.playbackToken = item.Playback.Token; + Logger.Debug(`[Device ${playlist.Id}]`, `Enqueued playback token ${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..696a6ab --- /dev/null +++ b/src/players/visual-player.js @@ -0,0 +1,106 @@ +//For the Love of Sanity - Please sombody rewrite the APL Player for me. + +const APLDocument = require("../apl/audio-player.json"); + +/********************************************************************************* + * Show APL Player + */ + +const ShowAPL = function (handlerInput, playlist, item) { + + handlerInput.responseBuilder.addDirective( + { + type: 'Alexa.Presentation.APL.RenderDocument', + token: "musicToken", + document: APLDocument, + datasources: + { + audioPlayerData: + { + type: "object", + properties: + { + backgroundImage: 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 + } + } + } + } + } + ); + + Logger.Debug(`[Device ${playlist.Id}]`, "Showing APL Audioplayer"); + + playlist.APLOpen = true; + + return false; +} + +/********************************************************************************* + * Player Functions + */ + +const PlayItem = function (handlerInput, playlist, item) { + if (!item || !item.Playback.Token) return false; + + ShowAPL(handlerInput, playlist, item); + + return true; +}; + +/********************************************************************************* + * No Queue Support for current APL Player + */ + +const EnqueueItem = function (handlerInput, playlist, item) { + return false; +}; + +/********************************************************************************* + * Stop Playback + */ + +const StopPlayback = function (handlerInput) { + handlerInput.responseBuilder.addDirective( + { + type: 'Alexa.Presentation.APL.ExecuteCommands', + token: "musicToken", + commands: [ + { + type: "ControlMedia", + componentId: "videoPlayer", + command: "pause" + } + ] + } + ); + + return true; +}; + +/********************************************************************************* + * End Session + */ + +const EndSession = function (handlerInput, playlist) { + playlist.APLOpen = false; + + return true; +}; + +/********************************************************************************* + * Exports + */ + +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..20484b3 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'); @@ -8,18 +10,17 @@ 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]; - 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; @@ -29,8 +30,7 @@ const getPlayList = function(device) * */ -const onLaunch = function(handlerInput) -{ +const onLaunch = function (handlerInput) { const { responseBuilder, requestEnvelope } = handlerInput; const deviceID = Alexa.getDeviceId(requestEnvelope); @@ -46,38 +46,42 @@ const onLaunch = function(handlerInput) * */ -const onPlaybackNearlyFinished = function(handlerInput) -{ +const onPlaybackNearlyFinished = function (handlerInput) { const { responseBuilder, requestEnvelope } = handlerInput; const deviceID = Alexa.getDeviceId(requestEnvelope); 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(); }; /********************************************************************************* * */ -const onQueueFinished = function(handlerInput, speak) -{ - const { responseBuilder } = handlerInput; +const onQueueFinished = function (handlerInput, speak) { + const { responseBuilder, requestEnvelope } = handlerInput; + + const deviceID = Alexa.getDeviceId(requestEnvelope); + + const playlist = getPlayList(deviceID); - 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."; - - return responseBuilder.speak(speach).getResponse(); + if (speak) { + const speech = LANGUAGE.Value("END_OF_QUEUE"); + + return responseBuilder.speak(speech).getResponse(); } return responseBuilder.getResponse(); @@ -87,42 +91,36 @@ const onQueueFinished = function(handlerInput, speak) * */ -const onPlaybackFailed = function(handlerInput) -{ +const onPlaybackFailed = function (handlerInput) { const { responseBuilder, requestEnvelope } = handlerInput; const deviceID = Alexa.getDeviceId(requestEnvelope); const playlist = getPlayList(deviceID); - var directive = playlist.getPlayDirective(handlerInput); - - console.debug("Playback failed!"); - - if (!directive) return responseBuilder.getResponse(); + Logger.Debug(`[Device ${playlist.Id}]`, "Playback failed!"); - if (playlist.Validate(directive, handlerInput)) - return responseBuilder.addAudioPlayerPlayDirective(...directive).getResponse(); - - console.debug("Moving to next song."); + var item = playlist.getCurrentItem(); - 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(); }; /********************************************************************************* * */ -const onPlaybackStarted = function(handlerInput) -{ +const onPlaybackStarted = function (handlerInput) { const { responseBuilder, requestEnvelope } = handlerInput; const deviceID = Alexa.getDeviceId(requestEnvelope); @@ -131,7 +129,7 @@ const onPlaybackStarted = function(handlerInput) playlist.start(); - console.debug("Playback started!"); + Logger.Debug(`[Device ${playlist.Id}]`, "Playback started!"); return responseBuilder.getResponse(); }; @@ -140,8 +138,7 @@ const onPlaybackStarted = function(handlerInput) * */ -const onPlaybackFinished = function(handlerInput) -{ +const onPlaybackFinished = function (handlerInput) { const { responseBuilder, requestEnvelope } = handlerInput; const deviceID = Alexa.getDeviceId(requestEnvelope); @@ -150,24 +147,26 @@ const onPlaybackFinished = function(handlerInput) playlist.stop(); - console.debug("Playback finished!"); + Logger.Debug(`[Device ${playlist.Id}]`, "Playback finished!"); - const directive = playlist.getPlayNextDirective(handlerInput); - - 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.getCurrentItem(); - 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(); }; /********************************************************************************* * */ -const onPlaybackStopped = function(handlerInput) -{ +const onPlaybackStopped = function (handlerInput) { const { responseBuilder, requestEnvelope } = handlerInput; const deviceID = Alexa.getDeviceId(requestEnvelope); @@ -176,7 +175,7 @@ const onPlaybackStopped = function(handlerInput) playlist.stop(); - console.debug("Playback stopped!"); + Logger.Debug(`[Device ${playlist.Id}]`, "Playback stopped!"); return responseBuilder.getResponse(); }; @@ -185,8 +184,7 @@ const onPlaybackStopped = function(handlerInput) * */ -const doClear = function(handlerInput) -{ +const doClear = function (handlerInput) { const { responseBuilder, requestEnvelope } = handlerInput; const deviceID = Alexa.getDeviceId(requestEnvelope); @@ -195,6 +193,8 @@ const doClear = function(handlerInput) playlist.clearRemainingItems(); + Logger.Info(`[Device ${playlist.Id}]`, "Playback Queue Cleared."); + return responseBuilder.getResponse(); }; @@ -202,15 +202,18 @@ const doClear = function(handlerInput) * */ -const doPause = function(handlerInput) -{ +const doPause = function (handlerInput, offset) { const { responseBuilder, requestEnvelope } = handlerInput; const deviceID = Alexa.getDeviceId(requestEnvelope); 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(); }; @@ -219,36 +222,41 @@ const doPause = function(handlerInput) * */ -const doResume = function(handlerInput) -{ +const doResume = function (handlerInput) { const { responseBuilder, requestEnvelope } = handlerInput; const deviceID = Alexa.getDeviceId(requestEnvelope); 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(); - return responseBuilder.addAudioPlayerPlayDirective(...directive).getResponse(); + Logger.Info(`[Device ${playlist.Id}]`, `Resuming ${item.Item.Name} by ${item.Item.AlbumArtist}`); + + return responseBuilder.getResponse(); }; /********************************************************************************* * */ -const doStop = function(handlerInput) -{ +const doStop = function (handlerInput) { const { responseBuilder, requestEnvelope } = handlerInput; - + const deviceID = Alexa.getDeviceId(requestEnvelope); const playlist = getPlayList(deviceID); playlist.clear(); - return responseBuilder.addAudioPlayerStopDirective().getResponse(); + Logger.Info(`[Device ${playlist.Id}]`, "Playback Stopped."); + + Player.StopPlayback(handlerInput); + + return responseBuilder.getResponse(); }; @@ -256,65 +264,91 @@ const doStop = function(handlerInput) * */ -const doPlayCurrent = function(handlerInput) -{ +const doPlayCurrent = function (handlerInput) { const { responseBuilder, requestEnvelope } = handlerInput; const deviceID = Alexa.getDeviceId(requestEnvelope); 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(); }; /********************************************************************************* * */ -const doPlayNext = function(handlerInput) -{ +const doPlayNext = function (handlerInput) { const { responseBuilder, requestEnvelope } = handlerInput; const deviceID = Alexa.getDeviceId(requestEnvelope); const playlist = getPlayList(deviceID); - const next = playlist.getNextItem(); + const item = playlist.getNextItem(); + + if (!item || !playlist.nextItem()) + return onQueueFinished(handlerInput, true); + + if (!Player.PlayItem(handlerInput, playlist, item)) + return responseBuilder.getResponse(); - if (!next || !playlist.nextItem()) - return onQueueFinished(handlerInput, true); //responseBuilder.getResponse(); + Logger.Debug(`[Device ${playlist.Id}]`, "Playing Next Item."); - const directive = next.getPlayDirective(); + Logger.Info(`[Device ${playlist.Id}]`, `Playing ${item.Item.Name} by ${item.Item.AlbumArtist}`); - return responseBuilder.addAudioPlayerPlayDirective(...directive).getResponse(); + return responseBuilder.getResponse(); }; /********************************************************************************* * */ -const doPlayPrevious = function(handlerInput) -{ +const doPlayPrevious = function (handlerInput) { const { responseBuilder, requestEnvelope } = handlerInput; const deviceID = Alexa.getDeviceId(requestEnvelope); const playlist = getPlayList(deviceID); - const previous = playlist.getPreviousItem(); + const item = playlist.getPreviousItem(); + + if (!item || !playlist.previousItem()) + return responseBuilder.getResponse(); - if (!previous || !playlist.previousItem()) + if (!Player.PlayItem(handlerInput, playlist, item)) return responseBuilder.getResponse(); - const directive = previous.getPlayDirective(); + Logger.Debug(`[Device ${playlist.Id}]`, "Playing Previous Item."); - return responseBuilder.addAudioPlayerPlayDirective(...directive).getResponse(); + 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 +370,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..3308850 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); }; @@ -147,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) { @@ -185,32 +165,6 @@ PlayList.getPlayPreviousDirective = function(handlerInput) return previous.getPlayNextDirective(current.Playback); }; - -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 (this.playbackToken && this.playbackToken == token) - { - console.log(`Repeating token: ${token}`); - return false; - } - - console.log(`None repeating token: ${token}`); - - this.playbackToken = token; - - - return true; -} - /********************************************************************************* * Next & Previous */ @@ -221,11 +175,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 +194,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..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( () => { - console.log("Saved Device Queue: ", playlist.Device); + Logger.Debug(`[Playlist ${playlist.Id}]`, "Saved queue to local file."); playlist.Dirty = false; playlist.Saving = false; } @@ -78,17 +75,13 @@ const Save = function() * Load Device queue */ -const LoadDeviceQueue = async function({deviceID, position, token, queue}) -{ - console.log("Restoring Device Queue: ", deviceID); +const LoadDeviceQueue = async function ({ deviceID, position, token, queue }) { + const songs = {}; + const itemIDs = queue.map(({ id }) => id); - 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; @@ -98,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) @@ -109,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; @@ -120,20 +111,18 @@ 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."); }; /********************************************************************************* * 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/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 c2cb401..c3e2041 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'); @@ -6,106 +6,118 @@ const JellyFin = require("../jellyfin-api"); const Devices = require("../playlist/devices.js"); +const Player = require("../players/player.js"); + 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) - { - const speach1 = `Which album would you like to ${action}?`; - const speach2 = `I didn't catch the album name. ${speach1}`; - return [{status: false, speach1, speach2}]; + if (!slots.albumname || !slots.albumname.value) { + const speech = LANGUAGE.Value("ALBUM_NO_NAME"); + + return [{ status: false, speech }]; } - console.log(`Requesting Album: ${slots.albumname.value}`); + Logger.Debug(`[Album Request]`, `Requested album ${slots.albumname.value}`); + + const albums = await JellyFin.Albums({ query: slots.albumname.value }); + + if (!albums.status || !albums.items[0]) { + Logger.Debug(`[Album Request]`, "No album found."); - 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}]; + const speech = LANGUAGE.Parse("ALBUM_NOTFOUND_BY_NAME", { album_name: slots.albumname.value }); + + return [{ status: false, speech }]; } var artist = undefined; var album = albums.items[0]; - if (slots.artistname && slots.artistname.value) - { - console.log(`Requesting Artist for Album: ${slots.artistname.value}`); + if (slots.artistname && slots.artistname.value) { + Logger.Debug(`[Album Request]`, `Requested Artist ${slots.artistname.value}`); + + 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 artists = await JellyFin.Artists.Search(slots.artistname.value); - - if (!artists.status || !artists.items[0]) - { - const speach2 = `I didn't find an artist called ${slots.artistname.value}.`; - return [{status: false, speach}]; + 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) - { - 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}]; + if (album && album.AlbumArtist) { + 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}]; + else { + + 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 }]; } } } } - 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 speach = `I didn't find an music in the album ${slots.albumname.value}.`; - return [{status: false, speach1, speach2}]; + if (!songs.status || !songs.items[0]) { + const speech = LANGUAGE.Parse("ALBUM_NO_MUSIC", { album_name: slots.albumname.value }); + + 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]; }; /********************************************************************************* @@ -114,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); @@ -126,16 +137,26 @@ const PlayAlbumIntent = 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("ALBUM_PLAYING", { album_name: album.Name }); - 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 (album.AlbumArtist) + speech = LANGUAGE.Parse("ALBUM_PLAYING_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) - { + function ({ requestEnvelope }, { status, items }, data) { if (!status) return; const deviceID = Alexa.getDeviceId(requestEnvelope); @@ -154,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); @@ -163,21 +183,31 @@ const ShuffleAlbumIntent = CreateQueueIntent( const playlist = Devices.getPlayList(deviceID); playlist.clear(); - + items = playlist.shuffleItems(items); [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 album ${album.Name}, on ${CONFIG.skill.name}`; - - if (album.AlbumArtist) speach = `Shuffling album ${album.Name} by ${album.AlbumArtist}, 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("ALBUM_SHUFFLE", { album_name: album.Name }); + + 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) - { + function ({ requestEnvelope }, { status, items }, data) { if (!status) return; const deviceID = Alexa.getDeviceId(requestEnvelope); @@ -188,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); @@ -204,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); @@ -214,16 +242,24 @@ const QueueAlbumIntent = CreateQueueIntent( playlist.appendItems(items); - const directive = playlist.getPlayDirective(); + const item = playlist.getCurrentItem(); - var speach = `Adding album ${album.Name}, to the queue.`; - - if (album.AlbumArtist) speach = `Adding album ${album.Name} by ${album.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("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) - { + function ({ requestEnvelope }, { status, items }, data) { if (!status) return; const deviceID = Alexa.getDeviceId(requestEnvelope); diff --git a/src/skill/alexa-helper.js b/src/skill/alexa-helper.js index affd122..f82a09a 100644 --- a/src/skill/alexa-helper.js +++ b/src/skill/alexa-helper.js @@ -12,12 +12,13 @@ const CreateHandler = function(type, callback) { try { - return Alexa.getRequestType(handlerInput.requestEnvelope) === type; + const {requestEnvelope} = handlerInput; + return Alexa.getRequestType(requestEnvelope) === type; } catch(err) { - console.error(`Error in handler canHandle("${type}"):`); - console.error(err); + Logger.Error(`Error in handler canHandle("${type}"):`); + Logger.Error(err); return false; } @@ -34,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(); } } } @@ -56,12 +57,14 @@ 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) { - console.error(`Error in intent canHandle("${intent}"):`); - console.error(err); + Logger.Error(`Error in intent canHandle("${intent}"):`); + Logger.Error(err); return false; } @@ -78,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(); } } } @@ -106,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(); } @@ -127,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 */ @@ -134,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..87dbff5 --- /dev/null +++ b/src/skill/apl-handlers.js @@ -0,0 +1,52 @@ +const { CreateAPLEventHandler } = require("./alexa-helper.js"); + +const Devices = require("../playlist/devices.js"); + +/********************************************************************************* + * APL Handlers: Device Control + */ + +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 + +/********************************************************************************* + * Exports + */ + +module.exports = { + PlaybackPaused, + PlaybackStarted, + PlaybackFinished, + PlaybackFailed, + PlayPrevious, + PlayNext +}; diff --git a/src/skill/artist-intents.js b/src/skill/artist-intents.js index 1c9b663..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'); @@ -6,55 +6,59 @@ 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 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) - { - const speach = `I didn't catch the artist name.`; - return [{status: false, speach}]; + if (!slots.artistname || !slots.artistname.value) { + 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({ query: slots.artistname.value }); + + if (!artists.status || !artists.items[0]) { + Logger.Debug(`[Artist Request]`, "Artist not found."); - 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}]; + const speech = LANGUAGE.Parse("ARTIST_NOTFOUND_BY_NAME", { artist_name: slots.artistname.value }); + + 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]) { + Logger.Debug(`[Artist Request]`, "No music found."); + + const speech = LANGUAGE.Parse("ARTIST_NO_MUSIC", { artist_name: slots.artistname.value }); - if (!songs.status || !songs.items[0]) - { - const speach = `I didn't find an music by the artist ${slots.artistname.value}.`; - return [{status: false, speach}]; + 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]; }; /********************************************************************************* @@ -63,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); @@ -75,14 +78,18 @@ const PlayArtistIntent = CreateQueueIntent( [data.first, data.last] = playlist.prefixItems(items); - const directive = playlist.getPlayDirective(); + const item = playlist.getCurrentItem(); - var speach = `Playing songs by artist ${artist.Name}, on ${CONFIG.skill.name}`; + if (!Player.PlayItem(handlerInput, playlist, item)) + return responseBuilder.getResponse(); - return responseBuilder.speak(speach).addAudioPlayerPlayDirective(...directive).getResponse(); + Logger.Info(`[Device ${playlist.Id}]`, `Playing ${item.Item.Name} by ${item.Item.AlbumArtist}`); + + 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); @@ -101,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); @@ -110,19 +116,23 @@ const ShuffleArtistIntent = CreateQueueIntent( const playlist = Devices.getPlayList(deviceID); playlist.clear(); - + items = playlist.shuffleItems(items); [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 by artist ${artist.Name}, on ${CONFIG.skill.name}`; - - return responseBuilder.speak(speach).addAudioPlayerPlayDirective(...directive).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) - { + function ({ requestEnvelope }, { status, items }, data) { if (!status) return; const deviceID = Alexa.getDeviceId(requestEnvelope); @@ -133,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); @@ -149,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); @@ -159,14 +167,16 @@ const QueueArtistIntent = 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 by artist ${artist.Name}, to the queue.`; + var speech = LANGUAGE.Parse("ARTIST_QUEUED", { artist_name: artist.Name, count }); - return responseBuilder.speak(speach).addAudioPlayerPlayDirective(...directive).getResponse(); + 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/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/favourite-intents.js b/src/skill/favourite-intents.js new file mode 100644 index 0000000..d348546 --- /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("NO_USERNAME"); + + 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.users[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.users[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/genre-intent.js b/src/skill/genre-intent.js index 529161a..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'); @@ -6,54 +6,58 @@ const JellyFin = require("../jellyfin-api"); const Devices = require("../playlist/devices.js"); +const Player = require("../players/player.js"); + 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) - { - const speach = `I didn't catch the genre of music.`; - return [{status: false, speach}]; + if (!slots.genre || !slots.genre.value) { + 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({ 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 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}]; + return [{ status: false, speech }]; } - const genreIds = genres.items.map(item => item.Id).join("|"); - - const songs = await JellyFin.Music({genreIds, limit}); - - if (!songs.status || !songs.items[0]) - { - const speach = `No songs of ${slots.genre.value} where found.`; - return [{status: false, speach}]; + const genreIds = genres.items.map(item => item.Id); + + const songs = await JellyFin.Music({ genres: genreIds }); + + 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 }); + + 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]; }; /********************************************************************************* @@ -62,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); @@ -74,17 +77,22 @@ 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) - { + function ({ requestEnvelope }, { status, items }, data) { if (!status) return; const deviceID = Alexa.getDeviceId(requestEnvelope); @@ -103,8 +111,7 @@ const PlayGenreIntent = CreateQueueIntent( const ShuffleGenreIntent = CreateQueueIntent( "ShuffleGenreIntent", "shuffling", Processer, - function (handlerInput, {genres, songs}, data) - { + function (handlerInput, { genres, items }, data) { const { responseBuilder, requestEnvelope } = handlerInput; const deviceID = Alexa.getDeviceId(requestEnvelope); @@ -112,22 +119,26 @@ const ShuffleGenreIntent = CreateQueueIntent( const playlist = Devices.getPlayList(deviceID); playlist.clear(); - + items = playlist.shuffleItems(items); [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) - { + function ({ requestEnvelope }, { status, items }, data) { if (!status) return; const deviceID = Alexa.getDeviceId(requestEnvelope); @@ -138,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); @@ -154,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); @@ -164,17 +173,19 @@ const QueueGenreIntent = CreateQueueIntent( playlist.appendItems(items); - const directive = playlist.getPlayDirective(); + const item = playlist.getCurrentItem(); - var speach = `Added ${count}, ${genres[0].Name} songs, 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("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.`; - - return responseBuilder.speak(speach).addAudioPlayerPlayDirective(...directive).getResponse(); + 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 3e8323c..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'); @@ -6,53 +6,53 @@ const JellyFin = require("../jellyfin-api"); const Devices = require("../playlist/devices.js"); +const Player = require("../players/player.js"); + 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) - { - const speach = `I didn't catch the playlist name.`; - return [{status: false, speach}]; + if (!slots.playlistname || !slots.playlistname.value) { + const speech = LANGUAGE.Value("PLAYLIST_NO_NAME"); + + return [{ status: false, speech }]; } - const playlists = await JellyFin.Playlists.Search(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 playlists = await JellyFin.Playlists({ query: slots.playlistname.value }); + + 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 speach = `The playlist ${slots.artistname.value} is empty.`; - return [{status: false, speach}]; + if (!songs.status || !songs.items[0]) { + const speech = LANGUAGE.Parse("PLAYLIST_NO_MUSIC", { playlist_name: slots.playlistname.value }); + + 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]; }; /********************************************************************************* @@ -61,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); @@ -73,14 +72,18 @@ 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) - { + function ({ requestEnvelope }, { status, items }, data) { if (!status) return; const deviceID = Alexa.getDeviceId(requestEnvelope); @@ -99,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); @@ -108,19 +110,23 @@ const ShufflePlaylistIntent = CreateQueueIntent( const _playlist = Devices.getPlayList(deviceID); _playlist.clear(); - + items = _playlist.shuffleItems(items); [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}`; - - return responseBuilder.speak(speach).addAudioPlayerPlayDirective(...directive).getResponse(); + 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(speech).getResponse(); }, - function({ requestEnvelope }, {status, items}, data) - { + function ({ requestEnvelope }, { status, items }, data) { if (!status) return; const deviceID = Alexa.getDeviceId(requestEnvelope); @@ -131,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); @@ -147,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); @@ -157,14 +161,16 @@ 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) - { + function ({ requestEnvelope }, { status, items }, data) { if (!status) return; const deviceID = Alexa.getDeviceId(requestEnvelope); diff --git a/src/skill/query-intents.js b/src/skill/query-intents.js index 0ccee88..2ee9a79 100644 --- a/src/skill/query-intents.js +++ b/src/skill/query-intents.js @@ -10,24 +10,26 @@ 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 speach = "There is currently no song playing."; + var speech = LANGUAGE.Value("QUERY_NO_SONG"); if (item) - speach = `The current song is ${item.Item.Name} by ${item.Item.AlbumArtist}`; - - console.log(speach); - - return responseBuilder.speak(speach).getResponse(); + 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 138f8fd..b095001 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"); @@ -16,33 +18,42 @@ 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"); /********************************************************************************* - * Error Handler + * Error Handlers */ -const ErrorHandler = { +const Unhandler = { canHandle: () => true, - handle: function (handlerInput, error) - { - const {responseBuilder, requestEnvelope} = handlerInput; - - if (!error && requestEnvelope.request) - error = requestEnvelope.request.error; - - const type = Alexa.getRequestType(handlerInput.requestEnvelope); - console.error(`Error handled: ${type}`); + handle: function (handlerInput) { + const { responseBuilder, requestEnvelope } = handlerInput; + const type = Alexa.getRequestType(requestEnvelope); + Logger.Warn(`[Alexa Skill] Unhandled request: ${type}`); + return responseBuilder.getResponse(); + } +}; - if (type == "IntentRequest") - { - const intent = Alexa.getIntentName(handlerInput.requestEnvelope); - console.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(); + } +}; - console.error(error); +const ErrorHandler = { + canHandle: () => true, + handle: function (handlerInput, error) { + Logger.Error(`[Alexa Skill] Error handled: ${error.message}`); + Logger.Error(error); return responseBuilder.getResponse(); } @@ -64,6 +75,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,12 +109,16 @@ const skill = Alexa.SkillBuilders.custom() SongIntents.QueueSongIntent, GenreIntents.QueueGenreIntent, - QueryIntents.WhatThisIntent, + FavouriteIntents.PlayFavouritesIntent, + FavouriteIntents.ShuffleFavouritesIntent, + FavouriteIntents.QueueFavouritesIntent, - ErrorHandler + QueryIntents.WhatThisIntent, + ExceptionEncounteredHandler, + Unhandler ).addErrorHandlers( ErrorHandler - ).create(); + ).withSkillId(CONFIG.skill.id).create(); /********************************************************************************* * Exports diff --git a/src/skill/song-intents.js b/src/skill/song-intents.js index cd815e1..5305380 100644 --- a/src/skill/song-intents.js +++ b/src/skill/song-intents.js @@ -4,88 +4,102 @@ const JellyFin = require("../jellyfin-api"); const Devices = require("../playlist/devices.js"); +const Player = require("../players/player.js"); + 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) - { - const speach = `I didn't catch the song name.`; - return [{status: false, speach}]; + if (!slots.songname || !slots.songname.value) { + 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({ query: slots.songname.value }); + + if (!songs.status || !songs.items[0]) { + Logger.Debug(`[Music Request]`, "Music not found."); - const songs = await JellyFin.Music.Search(slots.songname.value); + const speech = LANGUAGE.Parse("SONG_NOTFOUND_BY_NAME", { song_name: 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}]; + return [{ status: false, speech }]; } var artist = undefined; var song = songs.items[0]; - if (slots.artistname && slots.artistname.value) - { - console.log(`Requesting Artist for Song: ${slots.artistname.value}`); + if (slots.artistname && slots.artistname.value) { + Logger.Debug(`[Music Request]`, `Requesting artist ${slots.artistname.value}.`); + + 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 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}]; + 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()) - { - console.log("Found ", item.Name); + 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}`; - const speach = `I didn't find a track called ${slots.songname.value} by ${artist.name || slots.artistname.value}, you might have meant ${suggestion}.`; - - return [{status: false, speach}]; + 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, speech }]; } - else - { - const speach = `I didn't find a track called ${slots.albumname.value} by ${artist.name || slots.artistname.value}.`; - return [{status: false, speach}]; + else { + 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 }]; } } } } - return [{status: true, artist, items: [song]}]; + return [{ status: true, artist, items: [song] }]; }; /********************************************************************************* @@ -94,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); @@ -106,13 +119,25 @@ const PlaySongIntent = CreateQueueIntent( 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 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}`; - 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, + artist_name: items[0].AlbumArtist + } + ); + + return responseBuilder.speak(speech).getResponse(); } ); @@ -122,23 +147,31 @@ 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); 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 ${items[0].Name}, to the queue.`; - - if (items[0].AlbumArtist) speach = `Added ${items[0].Name} by ${items[0].AlbumArtist}, to the queue.`; + 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, + artist_name: items[0].AlbumArtist + } + ); - return responseBuilder.speak(speach).addAudioPlayerPlayDirective(...directive).getResponse(); + return responseBuilder.speak(speech).getResponse(); } );