diff --git a/.gitignore b/.gitignore index fc3dc16..3b95b4a 100644 --- a/.gitignore +++ b/.gitignore @@ -8,4 +8,11 @@ lambda/node_modules hooks/ __pycache__ *.log -*.log.* \ No newline at end of file +*.log.* +venv +/.vscode/ +/.idea/ +/sonar-project.properties +/.scannerwork/report-task.txt +/.scannerwork/.sonar_lock +/.coverage diff --git a/README.markdown b/README.markdown index 50e12a0..20f793f 100644 --- a/README.markdown +++ b/README.markdown @@ -2,7 +2,7 @@ # Alexa Chromecast Skill -Allows Amazon Alexa to control Google Chromecast +Allows Amazon Alexa to control your Google Chromecast This skill supports controlling a single Chromecast or multiple Chromecasts in different rooms. Each Alexa device can be set to control a different room. This is done by matching the room name to your Chromecast device's name. @@ -17,25 +17,88 @@ You can control another room by saying something like: To change the room a particular Alexa device controls you can say: > Alexa, ask the Chromecast to set the room -Here are some example voice commands: -> Alexa, tell Chromecast to play - -> Alexa, tell Chromecast to play songs by Macklemore - -> Alexa, tell Chromecast to play maroon 5 playlist - -> Alexa, tell Chromecast to play The Matrix trailer - -> Alexa, tell Chromecast to set the volume to 5 - -> Alexa, tell Chromecast to stop - -Or: - -> Alexa, ask the Chromecast in the Media Room to stop +### Standard commands +The following media commands are available: +``` + VOICE COMMAND ACTION + -------------------------------------------------------------------------------- + pause -> Pause a playing item + play -> Play a paused item + stop -> Stop the currently playing item + set volume to 5 -> Change the volume between 0 and 10 + mute -> mute the volume + unmute -> unmute the volume + rewind -> Rewind back 15 seconds ("skip back" or "go back" also work) + rewind 30 seconds -> Rewind back 30 seconds + fast forward -> Fast forward 15 seconds ("skip forward" or "go forward" also work) + fast forward 1 minute -> Fast forward 1 minute + restart -> Restarts the media item from the beginning + next -> Play or show the next item + previous -> Play or show the previous item + open {app} -> Open a specific app. Plex and YouTube are supported. + ``` +NOTE: Stop doesn't work as expected on the Netflix app and will quit the app instead. + +### YouTube app commands +Play items on YouTube. +``` + VOICE COMMAND ACTION + -------------------------------------------------------------------------------- + play/find {title} -> Play videos matching the title + play/find videos of {title} -> Play videos matching the title + play/find the trailer for {title} -> Play trailers matching title + play/find the show {title} -> Play a Youtube show matching the title + play/find the movie {title} -> Play a Youtube movie matching the title + play/find the song {title} -> Play a song matching the title + play/find the album {title} -> Play an album matching the title + play/find the playlist {title} -> Play a playlist matching the title + play/find songs by {artist} -> Play songs by the specified artist +``` -> Alexa, ask the Chromecast to play in the Media Room +### Plex app commands +Find and play items on your Plex server. +``` + VOICE COMMAND ACTION + --------------------------------------------------------------------------------- + play -> Resumes from pause, or plays the displayed item + stop -> Stops playing and displays the item details + play/find {title} -> Play/Find the title + play/find the video {title} -> Play/Find the title + play/find the tv show {title} -> Play/Find a tv show matching the title + play/find the movie {title} -> Play/Find a movie matching the title + + play the song {title} -> Play a song matching the title + play/find the album {title} -> Play/Find an album matching the title + shuffle the album {title} -> Play an album matching the title in shuffled order + play/find songs by {artist} -> Play/Find songs by the specified artist + shuffle songs by {artist} -> Play songs by the specified artist in shuffled order + play/find the playlist {title} -> Play/Find a playlist matching the title + + play/shuffle photos from {year} -> Play/Shuffle photos from the specified year + play/shuffle photos from {month} {year} -> Play/Shuffle photos from the specified month and year + play/shuffle photos from {title} -> Play/Shuffle photos from albums matching the title + + set/change quality to {level} -> Transcode the media to "low" (480p), "medium" (720p), "high" (1080p) or "maximum". + raise/lower the quality -> Increase or lower the video quality from the current setting + turn on subtitles -> Turns on subtitles in the configurged language + turn off subtitles -> Turns off subtitles + switch audio -> Switches to another audio track if available (e.g. to the directors commentry) + + play/find the episode {title} of {show} -> Play/Find the specified show episode by the title + play/find season {#} episode {#} of {show} -> Play/Find the specified show episode by the season and episode number + ``` + +## Example Commands +> Alexa, ask Chromecast to pause +> +> Alexa, ask Chromecast to resume +> +> Alexa, ask Chromecast to rewind 2 minutes +> +> Alexa, ask Chromecast to play Mythic Quest on Plex +> +> Alexa, ask Chromecast to play The Matrix trailer ## How it works @@ -65,7 +128,7 @@ Installation requires a UNIX environment with: 3. Go to [ASK Console](https://developer.amazon.com/alexa/console/ask) and choose "Create Skill" 4. Select "Custom" and "Provision your own", then click "Create skill". On the template screen just use the "Hello World Skill" template 5. Click on "Interaction Model" in the left menu, then "JSON Editor" -6. Copy and paste the content from `config/interaction_model.json` into the editor, then click "Save Model" +6. Copy and paste the content from `config/en/interaction_model.json` into the editor, then click "Save Model" 7. Click on "Endpoint" in the left menu. Enter the Lambda function ARN by the aws-setup.sh. Click "Save Endpoints" 8. Click on "Invocation" in the left menu. Click on "Build Model" 9. Click on the "Test" tab. Enter @@ -90,10 +153,24 @@ When run you should see something like the following: 2020-07-12 11:10:47,344 - local.SkillSubscriber - INFO - Received subscription confirmation... 2020-07-12 11:10:47,431 - local.SkillSubscriber - INFO - Subscribed. ``` -### Finally +### Setup the Chromecast that Alexa will control 12. Say "Alexa ask Chromecast to play" The skill will take you through any required room setup. +### Setup connection to Youtube +After running the skill as below `.custom_env` file will be created. + +Fill out the required Youtube API Key to allow the skill to connect to Youtube. + +To get a key follow the instructions here: https://sns-sdks.lkhardy.cn/python-youtube/getting_started/ + +### Setup connection to Plex +Fill out the required Plex variables to allow the skill to connect to Plex. + +To get the required token https://support.plex.tv/articles/204059436-finding-an-authentication-token-x-plex-token/ + +## Running Alexa Chromecast Skill + ### Shell example `./start.sh` @@ -116,29 +193,25 @@ The skill subscriber (local) uses these environment variables: If you have run `aws configure`, you will not need to set AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY, or AWS_DEFAULT_REGION. -## Scripts +## Supporting Scripts ### aws-setup.sh - Sets up an AWS environment for the Alexa Skill: - 1. Creates an IAM role for Alexa (with permissions for SNS) 2. Creates an SNS topic to communicate over 3. Creates an S3 persistent store for persisting the room to Alexa device mapping 4. Creates a Lambda function ### build-lambda-bundle.sh - Creates a lambda-bundle.zip, which can be uploaded to an AWS Lambda function. ### aws-update-lambda.sh - Runs build-lambda-bundle and automatically uploads the bundle to AWS Lambda. ## FAQ -### "No Chromecasts found" +### No Chromecasts found When the local service starts it searches for Chromecasts on the network. If there are no ChromeCasts found, it will exit. To fix this, you must confirm that the Chromecast is on and working, make sure you can access it from your phone, and make sure that everything is on the same network. To debug, a tool to search and list found ChomeCasts is provided at `./search-chromecasts` (make sure to make it executable with `chmod +x ./search-chromecasts`). @@ -158,3 +231,6 @@ e.g. to use port 30000 run `./start.sh -p 30000` or `./docker-start.sh -p 30000` ### Alexa accepted the command but it didn't seem to work 1. Check the local listener output, it should show the received command and any error that was encountered 2. To check the docker service logs run something like `docker logs alexa_chromecast --since=30m`, which shows the logs for the last 30 minutes +3. If the command wasn't received then try restarting the service. Consider scheduling a daily restart if it's a common issue. +e.g. +`docker restart alexa_chromecast` diff --git a/aws-setup.sh b/aws-setup.sh index 2faaa75..adaa4a0 100755 --- a/aws-setup.sh +++ b/aws-setup.sh @@ -20,7 +20,7 @@ if [ -z "$(type zip)" ]; then exit 1 fi -source ./config/variables +source ./config/aws_variable_names if [ -z "$(type aws)" ]; then echo "aws not found. Installing AWS CLI tools." @@ -46,7 +46,7 @@ rm -f .env AWS_DEFAULT_REGION="$( /usr/bin/awk -F' = ' '$1 == "region" {print $2}' ~/.aws/config )" # Create Role echo "Creating $ROLE_NAME role." -role_response=$(aws iam create-role --role-name $ROLE_NAME --assume-role-policy-document file://$(pwd)/config/aws-lambda-role-policy.json) +role_response=$(aws iam create-role --role-name $ROLE_NAME --assume-role-policy-document file://config/aws-lambda-role-policy.json) role_arn=$(echo $role_response | python3 -c "import sys, json; print(json.load(sys.stdin)['Role']['Arn'])") aws iam attach-role-policy --role-name $ROLE_NAME --policy-arn arn:aws:iam::aws:policy/AmazonSNSFullAccess aws iam attach-role-policy --role-name $ROLE_NAME --policy-arn arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole diff --git a/aws-teardown.sh b/aws-teardown.sh index e1a20b4..89468a7 100755 --- a/aws-teardown.sh +++ b/aws-teardown.sh @@ -12,7 +12,7 @@ fi cd $(dirname $0) -source ./config/variables +source ./config/aws_variable_names if [ -f .env ]; then source .env diff --git a/aws-update-lambda.sh b/aws-update-lambda.sh index 9e045db..06f9dd5 100755 --- a/aws-update-lambda.sh +++ b/aws-update-lambda.sh @@ -4,7 +4,7 @@ set -e -o pipefail cd $(dirname $0) -source config/variables +source config/aws_variable_names ./build-lambda-bundle.sh diff --git a/config/variables b/config/aws_variable_names similarity index 100% rename from config/variables rename to config/aws_variable_names diff --git a/config/custom_variables b/config/custom_variables new file mode 100644 index 0000000..eea27bb --- /dev/null +++ b/config/custom_variables @@ -0,0 +1,13 @@ +############ Custom parameters ################# +# PLEX Config - configure if you want to find and play items on Plex +export PLEX_IP_ADDRESS= +export PLEX_PORT=32400 +# Refer here on steps to get your token +# https://support.plex.tv/articles/204059436-finding-an-authentication-token-x-plex-token/ +export PLEX_TOKEN= +export PLEX_SUBTITLE_LANG=eng + +# YouTube Config - configure if you want to find and play items on YouTube +# To get a key follow the instructions here: https://sns-sdks.lkhardy.cn/python-youtube/getting_started/ +export YOUTUBE_API_KEY= +################################################ diff --git a/config/en/interaction_model.json b/config/en/interaction_model.json new file mode 100644 index 0000000..113d369 --- /dev/null +++ b/config/en/interaction_model.json @@ -0,0 +1,1122 @@ +{ + "interactionModel": { + "languageModel": { + "invocationName": "chromecast", + "intents": [ + { + "name": "AMAZON.CancelIntent", + "samples": [] + }, + { + "name": "AMAZON.HelpIntent", + "samples": [] + }, + { + "name": "AMAZON.StopIntent", + "samples": [] + }, + { + "name": "AMAZON.NavigateHomeIntent", + "samples": [ + "open" + ] + }, + { + "name": "PlayIntent", + "slots": [ + { + "name": "room", + "type": "AMAZON.Room" + } + ], + "samples": [ + "in {room} to resume", + "in {room} to play", + "to resume in {room}", + "to play in {room}", + "to resume", + "to play", + "resume", + "play", + "resume in {room}", + "play in {room}" + ] + }, + { + "name": "PauseIntent", + "slots": [ + { + "name": "room", + "type": "AMAZON.Room" + } + ], + "samples": [ + "in {room} to pause", + "to pause in {room}", + "to pause", + "pause in {room}", + "pause" + ] + }, + { + "name": "SetRoomIntent", + "slots": [ + { + "name": "room", + "type": "AMAZON.Room", + "samples": [ + "the master bedroom", + "the media room" + ] + } + ], + "samples": [ + "set the room", + "change the room", + "set default room to the {room}", + "set devices room to {room}", + "set the room to {room}", + "set room to {room}" + ] + }, + { + "name": "StopIntent", + "slots": [ + { + "name": "room", + "type": "AMAZON.Room" + } + ], + "samples": [ + "in {room} to stop", + "to stop {room}", + "to stop in {room}" + ] + }, + { + "name": "PlayMediaIntent", + "slots": [ + { + "name": "room", + "type": "AMAZON.Room" + }, + { + "name": "app", + "type": "AppName" + }, + { + "name": "title", + "type": "titleType", + "samples": [ + "{title}" + ] + }, + { + "name": "play", + "type": "playType" + }, + { + "name": "type", + "type": "mediaType" + }, + { + "name": "tvshow", + "type": "titleType" + } + ], + "samples": [ + "{play} {type} {title} from the show {tvshow}", + "{play} {type} {title} from {tvshow}", + "{play} {title} from the show {tvshow}", + "{play} {title} of {tvshow} on {app}", + "{play} {title} of {tvshow} in {room}", + "{play} {title} of {tvshow}", + "{play} {type} {title} of {tvshow} in {room}", + "{play} {type} {title} of {tvshow} on {app}", + "{play} {type} {title} of {tvshow}", + "{play} {type} {title} on {app}", + "{play} {type} {title} in {room}", + "{play} {type} {title}", + "{play} {title} in {room}", + "{play} {title}", + "{play} {title} on {app}" + ] + }, + { + "name": "RestartIntent", + "slots": [], + "samples": [ + "restart song", + "restart episode", + "restart", + "restart show", + "restart movie" + ] + }, + { + "name": "NextIntent", + "slots": [ + { + "name": "action", + "type": "actionType" + } + ], + "samples": [ + "next", + "for next", + "for next {action}", + "next {action}" + ] + }, + { + "name": "PreviousIntent", + "slots": [ + { + "name": "action", + "type": "actionType" + } + ], + "samples": [ + "for previous", + "for previous {action}", + "previous {action}", + "previous" + ] + }, + { + "name": "OpenIntent", + "slots": [ + { + "name": "app", + "type": "AppName" + } + ], + "samples": [ + "open {app}" + ] + }, + { + "name": "RewindIntent", + "slots": [ + { + "name": "duration", + "type": "AMAZON.DURATION" + } + ], + "samples": [ + "go back {duration}", + "go back", + "rewind back {duration}", + "rewind {duration}", + "rewind" + ] + }, + { + "name": "ChangeAudioIntent", + "slots": [], + "samples": [ + "switch the audio", + "change the audio", + "change audio stream", + "switch audio stream", + "switch audio", + "change audio" + ] + }, + { + "name": "SubtitlesOnIntent", + "slots": [], + "samples": [ + "show subtitles", + "turn subtitles on" + ] + }, + { + "name": "SubtitlesOffIntent", + "slots": [], + "samples": [ + "hide subtitles", + "turn subtitles off" + ] + }, + { + "name": "FastForwardIntent", + "slots": [ + { + "name": "duration", + "type": "AMAZON.DURATION" + } + ], + "samples": [ + "go forward {duration}", + "go forward", + "fast forward", + "fast forward {duration}", + "seek forward", + "seek forward {duration}" + ] + }, + { + "name": "MuteIntent", + "slots": [], + "samples": [ + "mute", + "mute sound", + "mute volume" + ] + }, + { + "name": "UnMuteIntent", + "slots": [], + "samples": [ + "unmute sound", + "unmute volume", + "unmute" + ] + }, + { + "name": "PlayMusicIntent", + "slots": [ + { + "name": "song", + "type": "AMAZON.MusicRecording" + }, + { + "name": "album", + "type": "AMAZON.MusicAlbum" + }, + { + "name": "artist", + "type": "AMAZON.Artist" + }, + { + "name": "play", + "type": "playType" + }, + { + "name": "app", + "type": "AppName" + }, + { + "name": "room", + "type": "AMAZON.Room" + } + ], + "samples": [ + "{play} the song {song} in {room}", + "{play} the album {album} in {room}", + "{play} the album {album} on {app}", + "{play} the song {song} on {app}", + "{play} songs by {artist} in {room}", + "{play} songs by {artist} on {app}", + "{play} song {song} by {artist}", + "{play} album {album} by {artist}", + "{play} the album {album} by {artist}", + "{play} songs by {artist}", + "{play} the song {song} by {artist}", + "{play} album {album}", + "{play} song {song}", + "{play} the album {album}", + "{play} the song {song}" + ] + }, + { + "name": "AMAZON.ShuffleOnIntent", + "samples": [] + }, + { + "name": "AMAZON.ShuffleOffIntent", + "samples": [] + }, + { + "name": "AMAZON.LoopOffIntent", + "samples": [] + }, + { + "name": "AMAZON.LoopOnIntent", + "samples": [] + }, + { + "name": "PlayEpisodeIntent", + "slots": [ + { + "name": "play", + "type": "playType" + }, + { + "name": "tvshow", + "type": "titleType", + "samples": [ + "{tvshow}" + ] + }, + { + "name": "app", + "type": "AppName" + }, + { + "name": "room", + "type": "AMAZON.Room" + }, + { + "name": "seasnum", + "type": "AMAZON.NUMBER" + }, + { + "name": "epnum", + "type": "AMAZON.NUMBER" + } + ], + "samples": [ + "{play} season {seasnum} of {tvshow} in {room}", + "{play} season {seasnum} of {tvshow} on {app}", + "{play} season {seasnum} of {tvshow}", + "{play} season {seasnum} episode {epnum} of {tvshow} on {app}", + "{play} season {seasnum} episode {epnum} of {tvshow} in {room}", + "{play} episode {seasnum} of season {epnum} of {tvshow} on {app}", + "{play} episode {seasnum} of season {epnum} of {tvshow} in {room}", + "{play} episode {seasnum} of season {epnum} of {tvshow}", + "{play} episode {seasnum} season {epnum} of {tvshow}", + "{play} season {seasnum} episode {epnum} of {tvshow}" + ] + }, + { + "name": "VolumeChangeIntent", + "slots": [ + { + "name": "raise_lower", + "type": "volumeType" + }, + { + "name": "volume", + "type": "AMAZON.NUMBER" + } + ], + "samples": [ + "set volume to {volume}", + "change volume to {volume}", + "change the volume to {volume}", + "set the volume to {volume}", + "{raise_lower} the volume", + "turn {raise_lower} the volume", + "turn the volume {raise_lower}" + ] + }, + { + "name": "QualityIntent", + "slots": [ + { + "name": "raise_lower", + "type": "volumeType" + }, + { + "name": "quality", + "type": "qualityType" + } + ], + "samples": [ + "set the quality to {quality}", + "change the quality to {quality}", + "{raise_lower} the quality", + "{raise_lower} quality", + "change quality to {quality}", + "set quality to {quality}" + ] + }, + { + "name": "PlayPhotosIntent", + "slots": [ + { + "name": "year", + "type": "AMAZON.FOUR_DIGIT_NUMBER" + }, + { + "name": "play", + "type": "playType" + }, + { + "name": "title", + "type": "titleType" + }, + { + "name": "photo", + "type": "photoType" + }, + { + "name": "month", + "type": "AMAZON.Month" + } + ], + "samples": [ + "{play} {photo} from {title} {year}", + "{play} {photo} from {month} {year}", + "{play} {photo} of {title}", + "{play} {title} {photo}", + "{play} {photo} from {title}", + "{play} {photo} from {year}" + ] + } + ], + "types": [ + { + "name": "AppName", + "values": [ + { + "name": { + "value": "plex" + } + }, + { + "name": { + "value": "youtube" + } + } + ] + }, + { + "name": "ToggleType", + "values": [ + { + "name": { + "value": "off", + "synonyms": [ + "hide" + ] + } + }, + { + "name": { + "value": "on", + "synonyms": [ + "show" + ] + } + } + ] + }, + { + "name": "playType", + "values": [ + { + "name": { + "value": "shuffle" + } + }, + { + "name": { + "value": "find" + } + }, + { + "name": { + "value": "play" + } + } + ] + }, + { + "name": "mediaType", + "values": [ + { + "name": { + "value": "video", + "synonyms": [ + "videos of", + "the video", + "videos" + ] + } + }, + { + "name": { + "value": "trailer", + "synonyms": [ + "the trailer for", + "the trailer" + ] + } + }, + { + "name": { + "value": "channel", + "synonyms": [ + "the channel" + ] + } + }, + { + "name": { + "value": "episode", + "synonyms": [ + "the episode" + ] + } + }, + { + "name": { + "value": "playlist", + "synonyms": [ + "the playlist" + ] + } + }, + { + "name": { + "value": "movie", + "synonyms": [ + "the movie" + ] + } + }, + { + "name": { + "value": "music video", + "synonyms": [ + "the music video" + ] + } + }, + { + "name": { + "value": "show", + "synonyms": [ + "the t.v. show", + "t.v. show", + "the series", + "the show", + "series" + ] + } + } + ] + }, + { + "name": "actionType", + "values": [ + { + "name": { + "value": "episode" + } + }, + { + "name": { + "value": "match" + } + } + ] + }, + { + "name": "titleType", + "values": [ + { + "name": { + "value": "goldeneye" + } + }, + { + "name": { + "value": "dogs" + } + }, + { + "name": { + "value": "cats" + } + }, + { + "name": { + "value": "horses" + } + }, + { + "name": { + "value": "izombie" + } + }, + { + "name": { + "value": "firefly" + } + }, + { + "name": { + "value": "the big bang theory" + } + }, + { + "name": { + "value": "the one hundred" + } + }, + { + "name": { + "value": "twelve monkeys" + } + }, + { + "name": { + "value": "babylon five" + } + }, + { + "name": { + "value": "the force awakens" + } + }, + { + "name": { + "value": "return of the jedi" + } + }, + { + "name": { + "value": "the empire strikes back" + } + }, + { + "name": { + "value": "star wars" + } + }, + { + "name": { + "value": "deep space nine" + } + }, + { + "name": { + "value": "voyager" + } + }, + { + "name": { + "value": "star trek" + } + }, + { + "name": { + "value": "battlestar galactica" + } + }, + { + "name": { + "value": "mythic quest" + } + }, + { + "name": { + "value": "rise of the guardians" + } + }, + { + "name": { + "value": "indiana jones and the last crusade" + } + }, + { + "name": { + "value": "ninteen seventeen" + } + }, + { + "name": { + "value": "up" + } + }, + { + "name": { + "value": "mortal kombat" + } + }, + { + "name": { + "value": "wolverine" + } + }, + { + "name": { + "value": "deadpool" + } + }, + { + "name": { + "value": "godzilla vs kong" + } + }, + { + "name": { + "value": "I know what you did last summer" + } + }, + { + "name": { + "value": "sherlock holmes" + } + }, + { + "name": { + "value": "doctor who" + } + }, + { + "name": { + "value": "friends" + } + }, + { + "name": { + "value": "back to the future" + } + }, + { + "name": { + "value": "ant man" + } + }, + { + "name": { + "value": "spider man" + } + }, + { + "name": { + "value": "iron man" + } + }, + { + "name": { + "value": "captain america" + } + }, + { + "name": { + "value": "fantastic four" + } + }, + { + "name": { + "value": "alien" + } + }, + { + "name": { + "value": "guardians of the galaxy" + } + }, + { + "name": { + "value": "the matrix" + } + } + ] + }, + { + "name": "volumeType", + "values": [ + { + "name": { + "value": "down", + "synonyms": [ + "reduce", + "lower", + "decrease" + ] + } + }, + { + "name": { + "value": "up", + "synonyms": [ + "raise", + "increase" + ] + } + } + ] + }, + { + "name": "qualityType", + "values": [ + { + "name": { + "value": "480p" + } + }, + { + "name": { + "value": "1080p" + } + }, + { + "name": { + "value": "720p" + } + }, + { + "name": { + "value": "maximum", + "synonyms": [ + "max" + ] + } + }, + { + "name": { + "value": "low" + } + }, + { + "name": { + "value": "high" + } + }, + { + "name": { + "value": "medium" + } + } + ] + }, + { + "name": "photoType", + "values": [ + { + "name": { + "value": "photo", + "synonyms": [ + "images", + "image", + "pictures", + "picture", + "photos", + "photograph" + ] + } + } + ] + } + ] + }, + "dialog": { + "intents": [ + { + "name": "SetRoomIntent", + "confirmationRequired": false, + "prompts": {}, + "slots": [ + { + "name": "room", + "type": "AMAZON.Room", + "confirmationRequired": false, + "elicitationRequired": true, + "prompts": { + "elicitation": "Elicit.Slot.1252426455707.629937893162" + } + } + ] + }, + { + "name": "RestartIntent", + "confirmationRequired": false, + "prompts": {}, + "slots": [] + }, + { + "name": "PlayMediaIntent", + "confirmationRequired": false, + "prompts": {}, + "slots": [ + { + "name": "room", + "type": "AMAZON.Room", + "confirmationRequired": false, + "elicitationRequired": false, + "prompts": {} + }, + { + "name": "app", + "type": "AppName", + "confirmationRequired": false, + "elicitationRequired": false, + "prompts": {} + }, + { + "name": "title", + "type": "titleType", + "confirmationRequired": false, + "elicitationRequired": true, + "prompts": { + "elicitation": "Elicit.Slot.1241238591241.1271902973253" + } + }, + { + "name": "play", + "type": "playType", + "confirmationRequired": false, + "elicitationRequired": false, + "prompts": {} + }, + { + "name": "type", + "type": "mediaType", + "confirmationRequired": false, + "elicitationRequired": false, + "prompts": {} + }, + { + "name": "tvshow", + "type": "titleType", + "confirmationRequired": false, + "elicitationRequired": false, + "prompts": {} + } + ] + }, + { + "name": "SubtitlesOnIntent", + "confirmationRequired": false, + "prompts": {}, + "slots": [] + }, + { + "name": "VolumeChangeIntent", + "confirmationRequired": false, + "prompts": {}, + "slots": [ + { + "name": "raise_lower", + "type": "volumeType", + "confirmationRequired": false, + "elicitationRequired": false, + "prompts": {} + }, + { + "name": "volume", + "type": "AMAZON.NUMBER", + "confirmationRequired": false, + "elicitationRequired": false, + "prompts": {} + } + ] + }, + { + "name": "PlayEpisodeIntent", + "confirmationRequired": false, + "prompts": {}, + "slots": [ + { + "name": "play", + "type": "playType", + "confirmationRequired": false, + "elicitationRequired": false, + "prompts": {} + }, + { + "name": "tvshow", + "type": "titleType", + "confirmationRequired": false, + "elicitationRequired": true, + "prompts": { + "elicitation": "Elicit.Slot.643600941453.114558835716" + } + }, + { + "name": "app", + "type": "AppName", + "confirmationRequired": false, + "elicitationRequired": false, + "prompts": {} + }, + { + "name": "room", + "type": "AMAZON.Room", + "confirmationRequired": false, + "elicitationRequired": false, + "prompts": {} + }, + { + "name": "seasnum", + "type": "AMAZON.NUMBER", + "confirmationRequired": false, + "elicitationRequired": false, + "prompts": {} + }, + { + "name": "epnum", + "type": "AMAZON.NUMBER", + "confirmationRequired": false, + "elicitationRequired": false, + "prompts": {} + } + ] + } + ], + "delegationStrategy": "ALWAYS" + }, + "prompts": [ + { + "id": "Elicit.Slot.1252426455707.629937893162", + "variations": [ + { + "type": "PlainText", + "value": "For what room?" + } + ] + }, + { + "id": "Confirm.Intent.694744913486", + "variations": [ + { + "type": "PlainText", + "value": "Are you sure you want to restart the Chromecast?" + } + ] + }, + { + "id": "Elicit.Slot.954979396244.1430530224578", + "variations": [ + { + "type": "PlainText", + "value": "Please say a number between one and ten to set the volume to" + } + ] + }, + { + "id": "Elicit.Slot.112384560784.517706401128", + "variations": [ + { + "type": "PlainText", + "value": "What episode number" + } + ] + }, + { + "id": "Elicit.Slot.112384560784.571180358300", + "variations": [ + { + "type": "PlainText", + "value": "what season number" + } + ] + }, + { + "id": "Elicit.Slot.1241238591241.1271902973253", + "variations": [ + { + "type": "PlainText", + "value": "Please say a title?" + } + ] + }, + { + "id": "Elicit.Slot.643600941453.114558835716", + "variations": [ + { + "type": "PlainText", + "value": "{play} for what tv show?" + } + ] + } + ] + } +} diff --git a/config/interaction_model.json b/config/interaction_model.json deleted file mode 100644 index efa1b92..0000000 --- a/config/interaction_model.json +++ /dev/null @@ -1,294 +0,0 @@ -{ - "interactionModel": { - "languageModel": { - "invocationName": "chromecast", - "intents": [ - { - "name": "AMAZON.CancelIntent", - "samples": [] - }, - { - "name": "AMAZON.HelpIntent", - "samples": [] - }, - { - "name": "AMAZON.StopIntent", - "samples": [] - }, - { - "name": "AMAZON.NavigateHomeIntent", - "samples": [ - "open" - ] - }, - { - "name": "PlayIntent", - "slots": [ - { - "name": "room", - "type": "AMAZON.Room" - } - ], - "samples": [ - "in {room} to resume", - "in {room} to play", - "to resume in {room}", - "to play in {room}", - "to resume", - "to play", - "resume", - "play", - "resume in {room}", - "play in {room}" - ] - }, - { - "name": "PauseIntent", - "slots": [ - { - "name": "room", - "type": "AMAZON.Room" - } - ], - "samples": [ - "in {room} to pause", - "to pause in {room}", - "to pause", - "pause in {room}", - "pause" - ] - }, - { - "name": "SetRoomIntent", - "slots": [ - { - "name": "room", - "type": "AMAZON.Room", - "samples": [ - "the master bedroom", - "the media room" - ] - } - ], - "samples": [ - "set default room to the {room}", - "set devices room to {room}", - "set the room to {room}", - "set room to {room}" - ] - }, - { - "name": "StopIntent", - "slots": [ - { - "name": "room", - "type": "AMAZON.Room" - } - ], - "samples": [ - "in {room} to stop", - "to stop {room}", - "to stop in {room}" - ] - }, - { - "name": "PlayOnAppIntent", - "slots": [ - { - "name": "video", - "type": "AMAZON.Movie" - }, - { - "name": "room", - "type": "AMAZON.Room" - } - ], - "samples": [ - "play {video} in {room}", - "play {video}", - "in {room} play {video}" - ] - }, - { - "name": "PlayTrailerIntent", - "slots": [ - { - "name": "movie", - "type": "AMAZON.Movie" - }, - { - "name": "room", - "type": "AMAZON.Room" - } - ], - "samples": [ - "play the {movie} trailer", - "play the trailer for {movie}", - "in {room} play {movie} trailer", - "play {movie} trailer in {room}", - "play {movie} trailer", - "in {room} play trailer for {movie}", - "play trailer for {movie} in {room}", - "play trailer for {movie} " - ] - }, - { - "name": "RestartIntent", - "slots": [], - "samples": [ - "reboot", - "restart", - "to restart" - ] - }, - { - "name": "SetVolumeIntent", - "slots": [ - { - "name": "volume", - "type": "AMAZON.NUMBER", - "samples": [ - "one", - "ten", - "two" - ] - } - ], - "samples": [ - "set volume to {volume}" - ] - }, - { - "name": "NextIntent", - "slots": [], - "samples": [ - "next", - "to play next", - "play next" - ] - }, - { - "name": "PreviousIntent", - "slots": [], - "samples": [ - "to play previous", - "previous", - "play previous" - ] - } - ], - "types": [ - { - "name": "AppName", - "values": [ - { - "name": { - "value": "plex" - } - }, - { - "name": { - "value": "youtube" - } - } - ] - } - ] - }, - "dialog": { - "intents": [ - { - "name": "SetRoomIntent", - "confirmationRequired": false, - "prompts": {}, - "slots": [ - { - "name": "room", - "type": "AMAZON.Room", - "confirmationRequired": false, - "elicitationRequired": true, - "prompts": { - "elicitation": "Elicit.Slot.1252426455707.629937893162" - } - } - ] - }, - { - "name": "RestartIntent", - "confirmationRequired": true, - "prompts": { - "confirmation": "Confirm.Intent.694744913486" - }, - "slots": [] - }, - { - "name": "PlayOnAppIntent", - "confirmationRequired": false, - "prompts": {}, - "slots": [ - { - "name": "video", - "type": "AMAZON.Movie", - "confirmationRequired": false, - "elicitationRequired": false, - "prompts": {} - }, - { - "name": "room", - "type": "AMAZON.Room", - "confirmationRequired": false, - "elicitationRequired": false, - "prompts": {} - } - ] - }, - { - "name": "SetVolumeIntent", - "confirmationRequired": false, - "prompts": {}, - "slots": [ - { - "name": "volume", - "type": "AMAZON.NUMBER", - "confirmationRequired": false, - "elicitationRequired": true, - "prompts": { - "elicitation": "Elicit.Slot.954979396244.1430530224578" - } - } - ] - } - ], - "delegationStrategy": "ALWAYS" - }, - "prompts": [ - { - "id": "Elicit.Slot.1252426455707.629937893162", - "variations": [ - { - "type": "PlainText", - "value": "For what room?" - } - ] - }, - { - "id": "Confirm.Intent.694744913486", - "variations": [ - { - "type": "PlainText", - "value": "Are you sure you want to restart the Chromecast?" - } - ] - }, - { - "id": "Elicit.Slot.954979396244.1430530224578", - "variations": [ - { - "type": "PlainText", - "value": "Please say a number between one and ten to set the volume to" - } - ] - } - ] - } -} \ No newline at end of file diff --git a/docker-start.sh b/docker-start.sh index e034b19..7933a5c 100755 --- a/docker-start.sh +++ b/docker-start.sh @@ -5,6 +5,7 @@ SERVICE=0 EXTERNAL_IP= EXTERNAL_PORT= + while getopts "hdi:p:" opt; do case $opt in h) HELP=1 @@ -37,41 +38,42 @@ if [ ! -f .env ] || [ ! -d ~/.aws ]; then echo "Expected AWS settings not found. Please run the aws-setup script." exit 1 fi + +if [ ! -f .custom_env ]; then + cp ./config/custom_variables .custom_env +fi + source .env +source .custom_env AWS_ACCESS_KEY_ID="$( /usr/bin/awk -F' = ' '$1 == "aws_access_key_id" {print $2}' ~/.aws/credentials )" AWS_SECRET_ACCESS_KEY="$( /usr/bin/awk -F' = ' '$1 == "aws_secret_access_key" {print $2}' ~/.aws/credentials )" AWS_DEFAULT_REGION="$( /usr/bin/awk -F' = ' '$1 == "region" {print $2}' ~/.aws/config )" CONTAINER_NAME=alexa_chromecast -if [ "$( docker container inspect -f '{{.State.Status}}' $CONTAINER_NAME )" == "running" ]; then - docker stop $CONTAINER_NAME -fi - +docker stop $CONTAINER_NAME 2>/dev/null docker rm $CONTAINER_NAME 2>/dev/null docker build -t alexa-skill-chromecast . if [ $SERVICE -eq 1 ]; then - docker run -d --network="host" \ - --name alexa_chromecast \ - --restart always \ - -e "AWS_ACCESS_KEY_ID=$AWS_ACCESS_KEY_ID"\ - -e "AWS_SECRET_ACCESS_KEY=$AWS_SECRET_ACCESS_KEY"\ - -e "AWS_DEFAULT_REGION=$AWS_DEFAULT_REGION"\ - -e "AWS_SNS_TOPIC_ARN=$AWS_SNS_TOPIC_ARN"\ - -e "EXTERNAL_IP=$EXTERNAL_IP"\ - -e "EXTERNAL_PORT=$EXTERNAL_PORT"\ - alexa-skill-chromecast + OPTIONS='-d --restart always' else - docker run --network="host" -it\ - --name alexa_chromecast \ - -e "AWS_ACCESS_KEY_ID=$AWS_ACCESS_KEY_ID"\ - -e "AWS_SECRET_ACCESS_KEY=$AWS_SECRET_ACCESS_KEY"\ - -e "AWS_DEFAULT_REGION=$AWS_DEFAULT_REGION"\ - -e "AWS_SNS_TOPIC_ARN=$AWS_SNS_TOPIC_ARN"\ - -e "EXTERNAL_IP=$EXTERNAL_IP"\ - -e "EXTERNAL_PORT=$EXTERNAL_PORT"\ - alexa-skill-chromecast + OPTIONS='-it' fi +docker run --network="host" \ + --name $CONTAINER_NAME \ + $OPTIONS \ + -e "AWS_ACCESS_KEY_ID=$AWS_ACCESS_KEY_ID"\ + -e "AWS_SECRET_ACCESS_KEY=$AWS_SECRET_ACCESS_KEY"\ + -e "AWS_DEFAULT_REGION=$AWS_DEFAULT_REGION"\ + -e "AWS_SNS_TOPIC_ARN=$AWS_SNS_TOPIC_ARN"\ + -e "EXTERNAL_IP=$EXTERNAL_IP"\ + -e "EXTERNAL_PORT=$EXTERNAL_PORT"\ + -e "PLEX_IP_ADDRESS=$PLEX_IP_ADDRESS"\ + -e "PLEX_PORT=$PLEX_PORT"\ + -e "PLEX_TOKEN=$PLEX_TOKEN"\ + -e "PLEX_SUBTITLE_LANG=$PLEX_SUBTITLE_LANG"\ + -e "YOUTUBE_API_KEY=$YOUTUBE_API_KEY"\ + alexa-skill-chromecast diff --git a/run_coverage.bat b/run_coverage.bat new file mode 100644 index 0000000..09d66b9 --- /dev/null +++ b/run_coverage.bat @@ -0,0 +1,6 @@ +cd src +rem coverage run --omit */venv/*,*/tests/* -m unittest discover tests +coverage run --omit */venv/*,*/tests/* -m unittest discover tests +coverage xml +coverage html +cd .. diff --git a/search-chromecasts b/search-chromecasts index 935a688..fb3367c 100755 --- a/search-chromecasts +++ b/search-chromecasts @@ -2,7 +2,7 @@ import pychromecast -chromecasts = pychromecast.get_chromecasts() +chromecasts = pychromecast.get_chromecasts()[0] if len(chromecasts) > 0: for cc in chromecasts: diff --git a/src/lambda_function/lang/__init__.py b/src/lambda_function/lang/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/lambda_function/lang/enAU.py b/src/lambda_function/lang/enAU.py new file mode 100644 index 0000000..031dc44 --- /dev/null +++ b/src/lambda_function/lang/enAU.py @@ -0,0 +1,103 @@ +from lambda_function.lang.language import Key +''' +To add another language create a file using the code below, without the hyphen (-). +Use the same keys in the LANGUAGE dictionary and update the spoken values. + ar-SA: Arabic (SA) + de-DE: German (DE) + en-AU: English (AU) + en-CA: English (CA) + en-GB: English (UK) + en-IN: English (IN) + en-US: English (US) + es-ES: Spanish (ES) + es-MX: Spanish (MX) + es-US: Spanish (US) + fr-CA: French (CA) + fr-FR: French (FR) + hi-IN: Hindi (IN) + it-IT: Italian (IT) + ja-JP: Japanese (JP) + pt-BR: Portuguese (BR) +''' + +LANGUAGE = { + Key.CardTitle: 'Alexa Chromecast Controller', + Key.Help: ''' + Welcome to the Alexa Chromecast controller. This skill allows you to control your Chromecasts in different rooms. + An Alexa Device can be configured to control a Chromecast in a particular room. + Then you can say something like: Alexa, ask Chromecast to play, or: Alexa, ask Chromecast to pause. + Or you can control a specific room, by saying something like: Alexa, ask Chromecast to play in the media room. + ''', + Key.Ok: 'Ok', + Key.Goodbye: 'Goodbye!', + Key.ErrorGeneral: 'Sorry, I had trouble doing what you asked. Please try again.', + + # Set the room + Key.SetTheRoom: 'I need to set the room of the Chromecast that this Alexa device will control. ' + + 'Please say something like: set room to media room.', + Key.ShortSetTheRoom: 'Please set the Chromecast\'s room, by saying something like: set room to media room.', + Key.ControlRoom: 'Ok, this Alexa device will control the Chromecast in the {room}.', + + # Set Volume + Key.SetVolume: 'Ok, changing volume to {volume}.', + Key.IncreaseVolume: 'Ok, increasing volume.', + Key.DecreaseVolume: 'Ok, reducing volume.', + + # Subtitles + Key.SubtitlesOff: 'Ok, turning subtitles off', + Key.SubtitlesOn: 'Ok, turning subtitles on', + + # SNS Publish + Key.LogErrorSnsPublish: 'Sending command to the Chromecast failed', + Key.ErrorSnsPublish: 'Sorry, there was an error sending the command to the Chromecast. ', + + # Switch Audio + Key.SwitchAudio: 'Ok, changing the audio stream.', + + # QualityIntent + Key.ChangeQuality: 'Ok, changing media quality to {quality}.', + Key.IncreaseQuality: 'Ok, increasing media quality.', + Key.DecreaseQuality: 'Ok, reducing media quality.', + Key.ErrorChangeQuality: 'Sorry I was unable to change the quality. Please try again.', + + Key.ErrorEpisodeParams: 'I can\'t do that. You need to specify a season or an episode.', + Key.ErrorSetVolumeRange: 'Sorry, you can only set the volume between 0 and 10. Please try again.', + + # Play Media Types + Key.Playing: 'Playing', + Key.Finding: 'Finding', + Key.Shuffling: 'Shuffling', + + Key.InRoom: 'in {room}', + Key.OnApp: 'on {app}', + + # Play music + Key.PlayTitle: '{play} {title}', + Key.PlaySongsByArtist: '{play} songs by {artist}', + Key.PlaySong: '{play} song {title}', + Key.PlaySongsByAlbum: '{play} album {album}', + + # Play photos + Key.PlayPhotosByDate: '{play} photos from {month} {year}', + Key.PlayPhotosByEvent: '{play} photos from {title} {year}', + Key.PlayPhotosByTitle: '{play} photos from {title}', + Key.PlayPhotosByYear: '{play} photos from {year}', + + # Play media + Key.PlayPlaylist: '{play} playlist {title}', + Key.PlayMovie: '{play} movie {title}', + Key.PlayShow: '{play} the show {show}', + Key.PlayEpisode: '{play} episode {title} of {show}', + Key.PlayEpisodeNumber: '{play} episode {episode} of season {season} of {show}', + Key.PlaySeason: '{play} season {season} of {show}', + + # Speech to pronounce definitions like "1080p" + Key.Speak1080p: 'ten eighty pea', + Key.Speak720p: 'seven twenty pea', + Key.Speak480p: 'four eighty pea', + + # List of months based on Amazon Month slot type + Key.ListMonths: ['january', 'february', 'march', 'april', 'may', 'june', 'july', 'august', + 'september', 'october', 'november', 'december'] + +} diff --git a/src/lambda_function/lang/language.py b/src/lambda_function/lang/language.py new file mode 100644 index 0000000..bc88aef --- /dev/null +++ b/src/lambda_function/lang/language.py @@ -0,0 +1,83 @@ +import os +from enum import unique, Enum + +file_path = os.path.dirname(__file__) + + +@unique +class Key(Enum): + CardTitle = 1 + Help = 2 + Ok = 3 + Goodbye = 4 + ErrorGeneral = 5 + SetTheRoom = 6 + ShortSetTheRoom = 7 + ControlRoom = 8 + SubtitlesOff = 9 + SubtitlesOn = 10 + LogErrorSnsPublish = 11 + ErrorSnsPublish = 12 + SwitchAudio = 13 + ChangeQuality = 14 + IncreaseQuality = 15 + DecreaseQuality = 16 + ErrorChangeQuality = 17 + ErrorEpisodeParams = 18 + ErrorSetVolumeRange = 19 + Playing = 20 + Finding = 21 + Shuffling = 22 + InRoom = 23 + OnApp = 24 + PlayTitle = 25 + PlaySongsByArtist = 26 + PlaySong = 27 + PlaySongsByAlbum = 28 + PlayPhotosByDate = 29 + PlayPhotosByEvent = 30 + PlayPhotosByTitle = 31 + PlayPlaylist = 32 + PlayMovie = 33 + PlayShow = 34 + PlayEpisode = 35 + PlayEpisodeNumber = 36 + PlaySeason = 37 + Speak1080p = 38 + Speak720p = 39 + Speak480p = 40 + ListMonths = 41 + SetVolume = 42, + IncreaseVolume = 43, + DecreaseVolume = 44, + PlayPhotosByYear = 45 + + +class Language: + __LANGUAGES = {} + + @property + def locale(self): + return self.__locale + + def __init__(self, locale): + # If already loaded just return that + self.__locale = locale + if locale in self.__LANGUAGES.keys(): + return + + # Dynamically load language + filename = locale.replace('-', '') + if os.path.exists(file_path + os.path.sep + filename + '.py'): + lang = __import__('lambda_function.lang.' + filename, fromlist=['LANGUAGE']) + else: + from lambda_function.lang import enAU as lang + self.__LANGUAGES[locale] = lang.LANGUAGE + + def get(self, key: Key, **kwargs): + lang = self.__LANGUAGES[self.__locale] + result = lang[key] + if type(result) == str: + return result.format(**kwargs) + return result + diff --git a/src/lambda_function/main.py b/src/lambda_function/main.py index 7963685..6363bb1 100644 --- a/src/lambda_function/main.py +++ b/src/lambda_function/main.py @@ -1,11 +1,12 @@ import os import logging -import requests +from typing import Dict, List + import boto3 import json +from word2number import w2n import ask_sdk_core.utils as ask_utils -from ask_sdk_core.skill_builder import SkillBuilder from ask_sdk_core.dispatch_components import AbstractRequestHandler from ask_sdk_core.dispatch_components import AbstractExceptionHandler from ask_sdk_core.handler_input import HandlerInput @@ -16,52 +17,74 @@ from ask_sdk_model import Response from ask_sdk_model import ui import lambda_function.utils as utils +from lambda_function.lang.language import Language, Key logger = logging.getLogger(__name__) logger.setLevel(logging.INFO) AWS_SNS_ARN = os.getenv('AWS_SNS_ARN') AWS_S3_BUCKET = os.getenv('AWS_S3_BUCKET') -CARD_TITLE = 'Alexa Chromecast Controller' -HELP_TEXT = ''.join([ - "Welcome to the Alexa Chromecast controller. This skill allows you to control your Chromecasts in different rooms. ", - "An Alexa Device can be configured to control a Chromecast in a particular room. ", - "Then you can say something like: Alexa, ask Chromecast to play, or: Alexa, ask Chromecast to pause. ", - "Or you can control a specific room, by saying something like: Alexa, ask Chromecast to play in the media room." -]) + +def get_play_response(language, data): + play_type = data['play'] if 'play' in data else 'play' + result = language.get(Key.Playing) + if play_type == 'shuffle': + result = language.get(Key.Shuffling) + elif play_type == 'find': + result = language.get(Key.Finding) + return result + class SNSPublishError(Exception): - """ If something goes wrong with publishing to SNS """ - pass + """ + If something goes wrong with publishing to SNS + """ + + def __init__(self, message): + self.message = message + super().__init__(self.message) + class LaunchRequestHandler(AbstractRequestHandler): def can_handle(self, handler_input): - return ask_utils.is_request_type("LaunchRequest")(handler_input) or ask_utils.is_intent_name('AMAZON.NavigateHomeIntent')(handler_input) + return ask_utils.is_request_type("LaunchRequest")(handler_input) or ask_utils.is_intent_name( + 'AMAZON.NavigateHomeIntent')(handler_input) def handle(self, handler_input): - speak_output = HELP_TEXT + language = Language(handler_input.request_envelope.request.locale) + card_title = language.get(Key.CardTitle) + speak_output = language.get(Key.Help) return ( - handler_input.response_builder + handler_input + .response_builder .speak(speak_output) - .card(ui.SimpleCard(CARD_TITLE, speak_output)) + .card(ui.SimpleCard(card_title, speak_output)) .response ) + class BaseIntentHandler(AbstractRequestHandler): - ''' + """ Base handler for all intents - ''' + """ def get_action(self): raise NotImplementedError + @staticmethod + def get_slot_values(params: List[str], handler_input: HandlerInput): + results = { + param: utils.get_slot_value(handler_input, param, '') for param in params + } + return {key: value for key, value in results.items() if value} + def get_intent_name(self): - return self.__class__.__name__.replace('Handler','') + return self.__class__.__name__.replace('Handler', '') def match_other_intent_names(self): return [] - def can_handle(self, handler_input): + def can_handle(self, handler_input: HandlerInput): intents = [self.get_intent_name()] other_intents = self.match_other_intent_names() if other_intents: @@ -71,49 +94,62 @@ def can_handle(self, handler_input): return True return False - def get_data(self, handler_input): + def get_data(self, handler_input: HandlerInput): return {} - def get_response(self, data): - return 'Ok' + def get_response(self, language: Language, data: Dict): + return language.get(Key.Ok) + + def get_card_response(self, language: Language, data: Dict): + return self.get_response(language, data) + + def handle(self, handler_input: HandlerInput): + language = Language(handler_input.request_envelope.request.locale) + card_title = language.get(Key.CardTitle) + room = utils.get_slot_value(handler_input, 'room', '') + if room and room.lower().startswith('the '): + room = room[4:] - def handle(self, handler_input): - room = utils.get_slot_value(handler_input, 'room', False) device_id = handler_input.request_envelope.context.system.device.device_id if not room: - room = utils.get_persistent_session_attribute(handler_input, 'DEVICE_'+device_id, False) + room = utils.get_persistent_session_attribute(handler_input, 'DEVICE_' + device_id, False) if not room: - speak_output = 'I need to set the room of the Chromecast that this Alexa device will control. Please say something like: set room to media room.' + speak_output = language.get(Key.SetTheRoom) return ( - handler_input.response_builder + handler_input + .response_builder .speak(speak_output) - .ask('Please set the Chromecasts room, by saying something like: set room to media room.') - .set_card(ui.SimpleCard(CARD_TITLE, speak_output)) + .ask(language.get(Key.ShortSetTheRoom)) + .set_card(ui.SimpleCard(card_title, speak_output)) .response ) try: data = self.get_data(handler_input) self.publish_command_to_sns(room, self.get_action(), data) - speak_output = self.get_response(data) + speak_output = self.get_response(language, data) + card_output = self.get_card_response(language, data) return ( - handler_input.response_builder + handler_input + .response_builder .speak(speak_output) - .set_card(ui.SimpleCard(CARD_TITLE, speak_output)) + .set_card(ui.SimpleCard(card_title, card_output)) .response ) except SNSPublishError as error: - logger.error('Sending command to the Chromecast failed', exc_info=error) - speak_output = 'There was an error sending the command to the Chromecast' + logger.error(language.get(Key.LogErrorSnsPublish), exc_info=error) + speak_output = language.get(Key.ErrorSnsPublish) + error.message return ( - handler_input.response_builder + handler_input + .response_builder .speak(speak_output) - .set_card(ui.SimpleCard(CARD_TITLE, speak_output)) + .set_card(ui.SimpleCard(card_title, speak_output)) .response ) - def publish_command_to_sns(self, room, command, data): + @staticmethod + def publish_command_to_sns(room: str, command: str, data: Dict): message = { "handler_name": "chromecast", "room": room, @@ -134,21 +170,38 @@ def publish_command_to_sns(self, room, command, data): response["ResponseMetadata"]["HTTPStatusCode"]) raise SNSPublishError(message) + class SetRoomIntentHandler(BaseIntentHandler): + def get_action(self): + # Nothing to do + pass + def handle(self, handler_input): device_id = handler_input.request_envelope.context.system.device.device_id - room = utils.get_slot_value(handler_input, 'room') #Must have a value enforced by Alexa dialog - utils.set_persistent_session_attribute(handler_input, 'DEVICE_'+device_id, room) + room = utils.get_slot_value(handler_input, 'room') # Must have a value enforced by Alexa dialog + utils.set_persistent_session_attribute(handler_input, 'DEVICE_' + device_id, room) handler_input.attributes_manager.save_persistent_attributes() - speak_output = 'Ok, this Alexa device will control the Chromecast in the %s. To control another room you can say something like: Alexa, play in the media room.' % room + + language = Language(handler_input.request_envelope.request.locale) + speak_output = language.get(Key.ControlRoom, room=room) + card_title = language.get(Key.CardTitle) return ( handler_input.response_builder .speak(speak_output) - .set_card(ui.SimpleCard(CARD_TITLE, speak_output)) + .set_card(ui.SimpleCard(card_title, speak_output)) .response ) + +class OpenIntentHandler(BaseIntentHandler): + def get_action(self): + return 'open' + + def get_data(self, handler_input): + return self.get_slot_values(['app'], handler_input) + + class PlayIntentHandler(BaseIntentHandler): def match_other_intent_names(self): return ['AMAZON.ResumeIntent'] @@ -156,6 +209,7 @@ def match_other_intent_names(self): def get_action(self): return 'play' + class PauseIntentHandler(BaseIntentHandler): def match_other_intent_names(self): return ['AMAZON.PauseIntent'] @@ -163,6 +217,7 @@ def match_other_intent_names(self): def get_action(self): return 'pause' + class StopIntentHandler(BaseIntentHandler): def match_other_intent_names(self): return ['AMAZON.StopIntent'] @@ -170,15 +225,42 @@ def match_other_intent_names(self): def get_action(self): return 'stop' -class SetVolumeIntentHandler(BaseIntentHandler): + +class VolumeChangeIntentHandler(BaseIntentHandler): def get_action(self): return 'set-volume' def get_data(self, handler_input): - volume = int(utils.get_slot_value(handler_input, 'volume')) - if volume > 10 or volume < 0: - return "Sorry, you can only set the volume between 0 and 10." - return {"volume": volume} + result = self.get_slot_values(['volume', 'raise_lower'], handler_input) + if 'volume' in result.keys(): + result['volume'] = int(result['volume']) + # No raise_lower or volume - seems to occur on "turn the volume up" + if len(result) == 0: + result['raise_lower'] = 'up' + return result + + def get_response(self, language, data): + if 'volume' in data.keys(): + volume = data['volume'] + if volume > 10 or volume < 0: + return language.get(Key.ErrorSetVolumeRange) + return language.get(Key.SetVolume, volume=volume) + elif data['raise_lower'] == 'up': + return language.get(Key.IncreaseVolume) + else: + return language.get(Key.DecreaseVolume) + + +class PreviousIntentHandler(BaseIntentHandler): + def match_other_intent_names(self): + return ['AMAZON.PreviousIntent'] + + def get_action(self): + return 'play-previous' + + def get_data(self, handler_input): + return self.get_slot_values(['action'], handler_input) + class NextIntentHandler(BaseIntentHandler): def match_other_intent_names(self): @@ -187,63 +269,319 @@ def match_other_intent_names(self): def get_action(self): return 'play-next' -class PreviousIntentHandler(BaseIntentHandler): + def get_data(self, handler_input): + return self.get_slot_values(['action'], handler_input) + + +class ShuffleOnIntentHandler(BaseIntentHandler): def match_other_intent_names(self): - return ['AMAZON.PreviousIntent'] + return ['AMAZON.ShuffleOnIntent'] def get_action(self): - return 'play-previous' + return 'shuffle-on' + + +class ShuffleOffIntentHandler(BaseIntentHandler): + def match_other_intent_names(self): + return ['AMAZON.ShuffleOffIntent'] + + def get_action(self): + return 'shuffle-off' + + +class LoopOnIntentHandler(BaseIntentHandler): + def match_other_intent_names(self): + return ['AMAZON.LoopOnIntent'] + + def get_action(self): + return 'loop-on' + + +class LoopOffIntentHandler(BaseIntentHandler): + def match_other_intent_names(self): + return ['AMAZON.LoopOffIntent'] + + def get_action(self): + return 'loop-off' + + +class RewindIntentHandler(BaseIntentHandler): + + def get_action(self): + return 'rewind' + + def get_data(self, handler_input): + return { + "duration": utils.get_slot_value(handler_input, 'duration', 'PT15S') + } + + +class MuteIntentHandler(BaseIntentHandler): + + def get_action(self): + return 'mute' + + +class UnMuteIntentHandler(BaseIntentHandler): + + def get_action(self): + return 'unmute' + class RestartIntentHandler(BaseIntentHandler): def get_action(self): return 'restart' -class PlayTrailerIntentHandler(BaseIntentHandler): + +class FastForwardIntentHandler(BaseIntentHandler): def get_action(self): - return 'play-trailer' + return 'fast-forward' def get_data(self, handler_input): - return {"title": utils.get_slot_value(handler_input, 'movie')} + return { + "duration": utils.get_slot_value(handler_input, 'duration', 'PT15S'), # 15 seconds + } - def get_response(self, data): - return 'Playing trailer for %s' % data['title'] -class PlayOnAppIntentHandler(BaseIntentHandler): +class PlayEpisodeIntentHandler(BaseIntentHandler): def get_action(self): - return 'play-video' + return 'play-media' def get_data(self, handler_input): - #TODO: Support other apps in the future - return { - "title": utils.get_slot_value(handler_input, 'video'), - "app": 'youtube' - } + params = ['play', 'epnum', 'seasnum', 'tvshow', 'app', 'room'] + result = self.get_slot_values(params, handler_input) + result['type'] = 'episode' + return result + + def get_response(self, language, data): + if 'epnum' not in data and 'seasnum' not in data: + return language.get(Key.ErrorEpisodeParams) + play = get_play_response(language, data) + epnum = data['epnum'] if 'epnum' in data else '' + seasnum = data['seasnum'] if 'seasnum' in data else '' + tvshow = data["tvshow"] + if epnum and seasnum: + msg = language.get(Key.PlayEpisodeNumber, play=play, episode=epnum, season=seasnum, show=tvshow) + elif seasnum: + msg = language.get(Key.PlaySeason, play=play, season=seasnum, show=tvshow) + else: + msg = language.get(Key.PlayShow, play=play, show=tvshow) + if 'app' in data.keys() and data['app']: + msg += language.get(Key.OnApp, app=data['app']) + if 'room' in data.keys() and data['room']: + msg += language.get(Key.InRoom, room=data['room']) + return msg + + +class PlayPhotosIntentHandler(BaseIntentHandler): + def get_action(self): + return 'play-photos' + + def get_data(self, handler_input): + language = Language(handler_input.request_envelope.request.locale) + params = ['play', 'month', 'year', 'title'] + result = self.get_slot_values(params, handler_input) + month = result['month'] if 'month' in result else '' + title = result['title'] if 'title' in result else '' + if month and month.lower() not in language.get(Key.ListMonths): + title = month + (' ' + title if title else '') + del result['month'] + result['title'] = title + return result + + def get_response(self, language, data): + play = get_play_response(language, data) + year = data['year'] if 'year' in data else '' + title = data['title'] if 'title' in data else '' + month = data['month'] if 'month' in data else '' + if month and year: + return language.get(Key.PlayPhotosByDate, play=play, month=month, year=year) + elif title and year: + return language.get(Key.PlayPhotosByEvent, play=play, title=title, year=year) + elif title: + return language.get(Key.PlayPhotosByTitle, play=play, title=title) + elif year: + return language.get(Key.PlayPhotosByYear, play=play, year=year) + else: + return language.get(Key.ErrorGeneral) + + +class PlayMediaIntentHandler(BaseIntentHandler): + def get_action(self): + return 'play-media' + + def get_data(self, handler_input): + params = ['play', 'app', 'room', 'title', 'type', 'tvshow'] + result = { + param: + utils.get_slot_value(handler_input, param, '').lower() + for param in params + } + + if result['tvshow'] and 'season' in result['title'] and 'episode' in result['title']: + self.set_episode_and_season(result) + result['type'] = 'episode' + + return {key: value for key, value in result.items() if value} + + def get_response(self, language, data): + if 'epnum' in data or 'seasnum' in data: + return PlayEpisodeIntentHandler().get_response(language, data) + + title = data['title'] if 'title' in data.keys() else '' + media_type = data['type'] if 'type' in data.keys() else '' + tv_show = data['tvshow'] if 'tvshow' in data.keys() else '' + + play = get_play_response(language, data) + if media_type == 'playlist': + msg = language.get(Key.PlayPlaylist, play=play, title=title) + elif media_type == 'movie': + msg = language.get(Key.PlayMovie, play=play, title=title) + elif media_type == 'show': + msg = language.get(Key.PlayShow, play=play, show=title) + elif media_type == 'episode': + msg = language.get(Key.PlayEpisode, play=play, title=title, show=tv_show) + else: + msg = language.get(Key.PlayTitle, play=play, title=title) + if 'app' in data.keys() and data['app']: + msg += language.get(Key.OnApp, app=data['app']) + if 'room' in data.keys() and data['room']: + msg += language.get(Key.InRoom, room=data['room']) + return msg + + @staticmethod + def set_episode_and_season(result): + title = result['title'] + del result['title'] + result['type'] = 'episode' + if title.startswith('season'): + title = title.replace('season', '') + title = title.split('episode') + result['seasnum'] = str(w2n.word_to_num(title[0])) + result['epnum'] = str(w2n.word_to_num(title[1])) + else: + title = title.replace('episode', '') + title = title.split('season') + result['epnum'] = str(w2n.word_to_num(title[0])) + result['seasnum'] = str(w2n.word_to_num(title[1])) + + +class PlayMusicIntentHandler(BaseIntentHandler): + def get_action(self): + return 'play-media' + + def get_data(self, handler_input): + params = ['play', 'app', 'room', 'title', 'song', 'album', 'artist', 'type'] + result = { + param: + utils.get_slot_value(handler_input, param, '').lower() + for param in params + } + transform = ['song', 'album', 'artist'] + for trans in transform: + if result[trans]: + result['title'] = result[trans] + result['type'] = trans + return {key: value for key, value in result.items() if value} + + def get_response(self, language, data): + title = data['title'] if 'title' in data.keys() else '' + media_type = data['type'] if 'type' in data.keys() else '' + + play = get_play_response(language, data) + if media_type == 'song': + msg = language.get(Key.PlaySong, play=play, title=title) + elif media_type == 'album': + msg = language.get(Key.PlaySongsByAlbum, play=play, album=title) + elif media_type == 'artist': + msg = language.get(Key.PlaySongsByArtist, play=play, artist=title) + else: + msg = language.get(Key.PlayTitle, play=play, title=title) + if 'app' in data.keys() and data['app']: + msg += language.get(Key.OnApp, app=data['app']) + if 'room' in data.keys() and data['room']: + msg += language.get(Key.InRoom, room=data['room']) + return msg + + +class SubtitlesOnIntentHandler(BaseIntentHandler): + def get_action(self): + return 'subtitle-on' + + def get_response(self, language, data): + return language.get(Key.SubtitlesOn) + + +class SubtitlesOffIntentHandler(BaseIntentHandler): + def get_action(self): + return 'subtitle-off' + + def get_response(self, language, data): + return language.get(Key.SubtitlesOff) + + +class ChangeAudioIntentHandler(BaseIntentHandler): + def get_action(self): + return 'change-audio' + + def get_response(self, language, data): + return language.get(Key.SwitchAudio) + + +class QualityIntentHandler(BaseIntentHandler): + def get_action(self): + return 'transcode' + + def get_data(self, handler_input): + return self.get_slot_values(['raise_lower', 'quality'], handler_input) + + def get_card_response(self, language, data): + if 'quality' in data: + return language.get(Key.ChangeQuality, quality=data["quality"]) + return self.get_response(language, data) + + def get_response(self, language, data): + if 'raise_lower' in data: + if data['raise_lower'] == 'up': + return language.get(Key.IncreaseQuality) + else: + return language.get(Key.DecreaseQuality) + + if 'quality' in data: + quality = data['quality'] + if quality in ['1080p', '720p', '280p']: + quality = language.get(Key['Speak' + quality]) + return language.get(Key.ChangeQuality, quality=quality) + return language.get(Key.ErrorChangeQuality) - def get_response(self, data): - return 'Playing %s on YouTube' % data['title'] class HelpIntentHandler(AbstractRequestHandler): def can_handle(self, handler_input): return ask_utils.is_intent_name("AMAZON.HelpIntent")(handler_input) def handle(self, handler_input): - speak_output = HELP_TEXT + language = Language(handler_input.request_envelope.request.locale) + speak_output = language.get(Key.Help) + card_title = language.get(Key.CardTitle) return ( handler_input.response_builder .speak(speak_output) - .card(ui.SimpleCard(CARD_TITLE, speak_output)) + .card(ui.SimpleCard(card_title, speak_output)) .response ) + class CancelIntentHandler(AbstractRequestHandler): - """Single handler for Cancel and Stop Intent.""" - def can_handle(self, handler_input): - # type: (HandlerInput) -> bool + """ + Single handler for Cancel and Stop Intent. + """ + + def can_handle(self, handler_input: HandlerInput) -> bool: return ask_utils.is_intent_name("AMAZON.CancelIntent")(handler_input) - def handle(self, handler_input): - # type: (HandlerInput) -> Response - speak_output = "Goodbye!" + def handle(self, handler_input: HandlerInput) -> Response: + language = Language(handler_input.request_envelope.request.locale) + speak_output = language.get(Key.Goodbye) return ( handler_input.response_builder @@ -251,26 +589,31 @@ def handle(self, handler_input): .response ) + class SessionEndedRequestHandler(AbstractRequestHandler): - """Handler for Session End.""" + """ + Handler for Session End. + """ + def can_handle(self, handler_input): return ask_utils.is_request_type("SessionEndedRequest")(handler_input) def handle(self, handler_input): return handler_input.response_builder.response + class IntentReflectorHandler(AbstractRequestHandler): - """The intent reflector is used for interaction model testing and debugging. + """ + The intent reflector is used for interaction model testing and debugging. It will simply repeat the intent the user said. You can create custom handlers for your intents by defining them above, then also adding them to the request handler chain below. """ - def can_handle(self, handler_input): - # type: (HandlerInput) -> bool + + def can_handle(self, handler_input: HandlerInput) -> bool: return ask_utils.is_request_type("IntentRequest")(handler_input) - def handle(self, handler_input): - # type: (HandlerInput) -> Response + def handle(self, handler_input: HandlerInput) -> Response: intent_name = ask_utils.get_intent_name(handler_input) speak_output = "You just triggered " + intent_name + "." @@ -281,20 +624,21 @@ def handle(self, handler_input): .response ) + class CatchAllExceptionHandler(AbstractExceptionHandler): - """Generic error handling to capture any syntax or routing errors. If you receive an error + """ + Generic error handling to capture any syntax or routing errors. If you receive an error stating the request handler chain is not found, you have not implemented a handler for the intent being invoked or included it in the skill builder below. """ - def can_handle(self, handler_input, exception): - # type: (HandlerInput, Exception) -> bool + + def can_handle(self, handler_input: HandlerInput, exception: Exception) -> bool: return True - def handle(self, handler_input, exception): - # type: (HandlerInput, Exception) -> Response + def handle(self, handler_input: HandlerInput, exception: Exception) -> Response: logger.error(exception, exc_info=True) - - speak_output = "Sorry, I had trouble doing what you asked. Please try again." + language = Language(handler_input.request_envelope.request.locale) + speak_output = language.get(Key.ErrorGeneral) return ( handler_input.response_builder @@ -303,6 +647,7 @@ def handle(self, handler_input, exception): .response ) + # The SkillBuilder object acts as the entry point for your skill, routing all request and response # payloads to the handlers above. Make sure any new handlers or interceptors you've # defined are included below. The order matters - they're processed top to bottom. @@ -313,23 +658,40 @@ def handle(self, handler_input, exception): sb.add_request_handler(LaunchRequestHandler()) + # Chromecast standard sb.add_request_handler(SetRoomIntentHandler()) sb.add_request_handler(PauseIntentHandler()) sb.add_request_handler(PlayIntentHandler()) sb.add_request_handler(StopIntentHandler()) - sb.add_request_handler(SetVolumeIntentHandler()) + sb.add_request_handler(VolumeChangeIntentHandler()) sb.add_request_handler(PreviousIntentHandler()) sb.add_request_handler(NextIntentHandler()) - + sb.add_request_handler(OpenIntentHandler()) + sb.add_request_handler(RewindIntentHandler()) + sb.add_request_handler(FastForwardIntentHandler()) sb.add_request_handler(RestartIntentHandler()) - sb.add_request_handler(PlayTrailerIntentHandler()) - sb.add_request_handler(PlayOnAppIntentHandler()) + sb.add_request_handler(MuteIntentHandler()) + sb.add_request_handler(UnMuteIntentHandler()) + sb.add_request_handler(ShuffleOnIntentHandler()) + sb.add_request_handler(ShuffleOffIntentHandler()) + sb.add_request_handler(LoopOnIntentHandler()) + sb.add_request_handler(LoopOffIntentHandler()) + + # Plex specific + sb.add_request_handler(QualityIntentHandler()) + sb.add_request_handler(ChangeAudioIntentHandler()) + sb.add_request_handler(SubtitlesOnIntentHandler()) + sb.add_request_handler(SubtitlesOffIntentHandler()) + sb.add_request_handler(PlayMediaIntentHandler()) + sb.add_request_handler(PlayEpisodeIntentHandler()) + sb.add_request_handler(PlayMusicIntentHandler()) + sb.add_request_handler(PlayPhotosIntentHandler()) sb.add_request_handler(HelpIntentHandler()) sb.add_request_handler(CancelIntentHandler()) sb.add_request_handler(SessionEndedRequestHandler()) - sb.add_request_handler(IntentReflectorHandler()) # make sure IntentReflectorHandler is last so it doesn't override your custom intent handlers + sb.add_request_handler(IntentReflectorHandler()) sb.add_exception_handler(CatchAllExceptionHandler()) lambda_handler = sb.lambda_handler() diff --git a/src/lambda_function/requirements.txt b/src/lambda_function/requirements.txt index d420855..f48a63b 100644 --- a/src/lambda_function/requirements.txt +++ b/src/lambda_function/requirements.txt @@ -3,3 +3,4 @@ boto3==1.14.8 ask-sdk-core==1.13.0 botocore==1.17.8 requests==2.24.0 +word2number==1.1 diff --git a/src/lambda_function/utils.py b/src/lambda_function/utils.py index 71c7e11..c6b5edc 100644 --- a/src/lambda_function/utils.py +++ b/src/lambda_function/utils.py @@ -1,62 +1,70 @@ -import logging -import os -import boto3 -from botocore.exceptions import ClientError -from ask_sdk_model.slu.entityresolution import StatusCode - -def get_slot_value(handler_input, name, default=None): - #If it matched a canonical value return this - slots = handler_input.request_envelope.request.intent.slots - if not slots or not name in slots: - return default - slot = slots[name] - if slot.resolutions and slot.resolutions.resolutions_per_authority[0].status.code == StatusCode.ER_SUCCESS_MATCH: - return slot.resolutions.resolutions_per_authority[0].values[0].value.name - - #Otherwise return the actual spoken value - result = slot.value - if result == None: - return default - return result - -def get_persistent_session_attribute(handler_input, name, default=None): - attr = handler_input.attributes_manager.persistent_attributes - if not attr: - return default - if name in attr: - return attr[name] - return default - -def set_persistent_session_attribute(handler_input, name, value): - attr = handler_input.attributes_manager.persistent_attributes - attr[name] = value - -def set_session_attribute(handler_input, name, val): - attr = handler_input.attributes_manager.session_attributes - attr[name] = val - -def get_session_attribute(handler_input, name, default=None): - attr = handler_input.attributes_manager.session_attributes - if name in attr: - return attr[name] - return default - -def create_presigned_url(object_name): - """Generate a presigned URL to share an S3 object with a capped expiration of 60 seconds - - :param object_name: string - :return: Presigned URL as string. If error, returns None. - """ - s3_client = boto3.client('s3', config=boto3.session.Config(signature_version='s3v4',s3={'addressing_style': 'path'})) - try: - bucket_name = os.environ.get('S3_PERSISTENCE_BUCKET') - response = s3_client.generate_presigned_url('get_object', - Params={'Bucket': bucket_name, - 'Key': object_name}, - ExpiresIn=60*1) - except ClientError as e: - logging.error(e) - return None - - # The response contains the presigned URL - return response \ No newline at end of file +import logging +import os +import boto3 +from ask_sdk_model import Slot +from botocore.exceptions import ClientError +from ask_sdk_model.slu.entityresolution import StatusCode + +def get_slot_value(handler_input, name, default=None): + # If it matched a canonical value return this + slots = handler_input.request_envelope.request.intent.slots + if not slots or name not in slots: + return default + slot = slots[name] + if slot.resolutions and slot.resolutions.resolutions_per_authority[0].status.code == StatusCode.ER_SUCCESS_MATCH: + return slot.resolutions.resolutions_per_authority[0].values[0].value.name + + # Otherwise return the actual spoken value + result = slot.value + if not result: + return default + return result + + +def get_persistent_session_attribute(handler_input, name, default=None): + attr = handler_input.attributes_manager.persistent_attributes + if not attr: + return default + if name in attr: + return attr[name] + return default + + +def set_persistent_session_attribute(handler_input, name, value): + attr = handler_input.attributes_manager.persistent_attributes + attr[name] = value + + +def set_session_attribute(handler_input, name, val): + attr = handler_input.attributes_manager.session_attributes + attr[name] = val + + +def get_session_attribute(handler_input, name, default=None): + attr = handler_input.attributes_manager.session_attributes + if name in attr: + return attr[name] + return default + + +def create_presigned_url(object_name): + """ + Generate a presigned URL to share an S3 object with a capped expiration of 60 seconds + + :param object_name: string + :return: Presigned URL as string. If error, returns None. + """ + s3_client = boto3.client('s3', + config=boto3.session.Config(signature_version='s3v4', s3={'addressing_style': 'path'})) + try: + bucket_name = os.environ.get('S3_PERSISTENCE_BUCKET') + response = s3_client.generate_presigned_url('get_object', + Params={'Bucket': bucket_name, + 'Key': object_name}, + ExpiresIn=60 * 1) + except ClientError as e: + logging.error(e) + return None + + # The response contains the presigned URL + return response diff --git a/src/local/ChromecastSkill.py b/src/local/ChromecastSkill.py deleted file mode 100644 index 26d46b0..0000000 --- a/src/local/ChromecastSkill.py +++ /dev/null @@ -1,222 +0,0 @@ -import os -import sys -import threading -import time -import logging -import logging.handlers -from datetime import datetime, timedelta -import pychromecast -from pychromecast import Chromecast -from pychromecast.controllers.youtube import YouTubeController -from pychromecast.controllers.plex import PlexController -import subprocess -import requests -from enum import Enum -import local.youtube as youtube_search -import local.moviedb_search as moviedb_search - -logger = logging.getLogger(__name__) -logger.setLevel(logging.DEBUG) - -class MyYouTubeController(YouTubeController): - - def __init__(self): - self.play_list = {} - super().__init__() - - def receive_message(self, msg, data): - logger.debug('Received: %s %s' % (msg, data)) - return YouTubeController.receive_message(self, msg, data) - - def init_playlist(self): - self.playlist = {} - - def play_previous(self, current_id): - #This is not pretty.... it rebuilds the playlist on a previous command to make it work - #There should be a way to play from a position in the queue, but I couldn't find it - select_from_here = False - if len(self.playlist) == 0: - self.playlist = self._session.get_queue_videos() - - previous_id = False - self.clear_playlist() - for video in self.playlist: - - if video['data-video-id'] == current_id: - if previous_id: - self.play_video(previous_id) - select_from_here = True - else: - return - - if select_from_here: - self.add_to_queue(video['data-video-id']) - - previous_id = video['data-video-id'] - -class ChromecastWrapper: - @property - def cast(self) -> Chromecast: - return self.__cc - - @property - def media_controller(self): - return self.cast.media_controller - - @property - def name(self): - return self.__cc.device.friendly_name - - def __init__(self, cc): - self.__cc = cc - cc.media_controller.register_status_listener(self) - cc.register_status_listener(self) - self.youtube_controller = MyYouTubeController() - cc.register_handler(self.youtube_controller) - - def new_media_status(self, status:pychromecast.controllers.media.MediaStatus): - pass - - def new_cast_status(self, status): - pass - -class ChromecastState: - - @property - def count(self): - return len(self.__chromecasts) - - def stop(self): - self.running = False - self.thread.join(10) - - def __set_chromecasts(self): - with self.lock: - self.__chromecasts = {} - for cc in pychromecast.get_chromecasts(): - logger.info("Found %s" % cc.device.friendly_name) - cc.wait() - self.__chromecasts[cc.device.friendly_name] = ChromecastWrapper(cc) - self.expiry = datetime.now() - - def expire_chromecasts(self): - while self.running: - time.sleep(1) - refresh_period = timedelta(minutes=120) - if (self.expiry + refresh_period) < datetime.now(): - self.__set_chromecasts() - - def __init__(self): - self.running = True - self.expiry = datetime.now() - self.lock = threading.Lock() - self.__set_chromecasts() - self.thread = threading.Thread(target=self.expire_chromecasts) - self.thread.start() - - def match_chromecast(self, room) -> ChromecastWrapper: - with self.lock: - result = next((x for x in self.__chromecasts.values() if str.lower(room.strip()) in str.lower(x.name).replace(' the ', '')), False) - if result: - result.cast.wait() - return result - - def get_chromecast(self, name): - result = self.__chromecasts[name] - result.cast.wait() - return result - -class Skill(): - - def __init__(self): - logger.info("Finding Chromecasts...") - self.chromecast_controller = ChromecastState() - if self.chromecast_controller.count == 0: - logger.info("No Chromecasts found") - exit(1) - logger.info("%i Chromecasts found" % self.chromecast_controller.count) - - def get_chromecast(self, name) -> ChromecastWrapper: - return self.chromecast_controller.get_chromecast(name) - - def handle_command(self, room, command, data): - try: - chromecast = self.chromecast_controller.match_chromecast(room) - if not chromecast: - logger.warn('No Chromecast found matching: %s' % room) - return - func = command.replace('-','_') - logger.info('Sending %s command to Chromecast: %s' % (func, chromecast.name)) - - getattr(self, func)(data, chromecast.name) - except Exception: - logger.exception('Unexpected error') - - def resume(self, data, name): - self.play(data, name) - - def play(self, data, name): - self.get_chromecast(name).media_controller.play() - - def pause(self, data, name): - cc = self.get_chromecast(name) - cc.media_controller.pause() - - def stop(self, data, name): - self.get_chromecast(name).cast.quit_app() - - def set_volume(self, data, name): - volume = data['volume'] # volume as 0-10 - volume_normalized = float(volume) / 10.0 # volume as 0-1 - self.get_chromecast(name).cast.set_volume(volume_normalized) - - def play_next(self, data, name): - #mc.queue_next() didn't work - self.get_chromecast(name).media_controller.skip() - - def play_previous(self, data, name): - cc = self.get_chromecast(name) - current_id = cc.media_controller.status.content_id - cc.youtube_controller.play_previous(current_id) - - def play_video(self, data, name): - cc = self.get_chromecast(name) - yt = cc.youtube_controller - video_title = data['title'] - streaming_app = data['app'] - if streaming_app == 'youtube': - video_playlist = youtube_search.search(video_title) - if len(video_playlist) == 0: - logger.info('Unable to find youtube video for: %s' % video_title) - return - playing = False - yt.init_playlist() - for video in video_playlist: - if not playing: - if not video['playlist_id']: - #Youtube controller will clear for a playlist - yt.clear_playlist() - yt.play_video(video['id'], video['playlist_id']) - logger.debug('Currently playing: %s' % video['id']) - playing = True - else: - yt.add_to_queue(video['id']) - logger.info('Asked chromecast to play %i titles matching: %s on YouTube' % (len(video_playlist), video_title)) - - elif streaming_app == 'plex': - #TODO: Future support other apps - Not Implemented - logger.info('Asked chromecast to play title: %s on Plex' % video_title) - else: - logger.info('The streaming application %s is not supported' % streaming_app) - - def play_trailer(self, data, name): - cc = self.get_chromecast(name) - yt = cc.youtube_controller - moviedb_result = moviedb_search.get_movie_trailer_youtube_id(data['title']) - video_id = moviedb_result["youtube_id"] - yt.play_video(video_id) - logger.info('video sent to chromecast, id: %s' % video_id) - - def restart(self, data, name): - self.get_chromecast(name).cast.reboot() - diff --git a/src/local/SkillSubscriber.py b/src/local/SkillSubscriber.py deleted file mode 100644 index a3e2443..0000000 --- a/src/local/SkillSubscriber.py +++ /dev/null @@ -1,157 +0,0 @@ -import os -import sys -import signal -import json -from http.server import HTTPServer, BaseHTTPRequestHandler -from requests import get -import miniupnpc -import boto3 -import logging - -logger = logging.getLogger(__name__) -logger.setLevel(logging.DEBUG) - -""" -Generic Skill Subscription class to handle commands from an -Lambda Fucntion via SNS notifications. -""" -class Subscriber(BaseHTTPRequestHandler): - - def __init__(self, skills, ip, port, topic_arn=os.getenv('AWS_SNS_TOPIC_ARN')): - self.token = "" - if port: - self.manual_port_forward = True - else: - self.manual_port_forward = False - try: - self.initialize_upnp() - except Exception: - logger.exception('Failed to configure UPnP. Please map port manually and pass PORT environment variable.') - sys.exit(1) - - self.sns_client = boto3.client('sns') - self.skills = skills - self.topic_arn = topic_arn - instance = self - - class SNSRequestHandler(BaseHTTPRequestHandler): - def do_POST(self): - self.send_response(200) - self.send_header('content-type', 'text/html') - self.end_headers() - raw_data = self.rfile.read( - int(self.headers['Content-Length'])) - data = json.loads(raw_data) - topic_arn = self.headers.get('X-Amz-Sns-Topic-Arn') - type = data['Type'] - - if type == 'SubscriptionConfirmation': - logger.info('Received subscription confirmation...') - token = data['Token'] - instance.confirm_subscription(topic_arn, token) - - elif type == 'Notification': - logger.info('Received message...') - if data['Message']: - instance.dispatch_notification(json.loads(data['Message'])) - - def log_message(self, format, *args): - pass - - self.server = HTTPServer(('', int(port) if port else 0), SNSRequestHandler) - - port = self.server.server_port - if not ip: - ip = self.get_external_ip() - self.endpoint_url = 'http://{}:{}'.format(ip, port) - logger.info('Listening on {}'.format(self.endpoint_url)) - signal.signal(signal.SIGINT, - lambda signal, frame: self.unsubscribe()) - self.subscribe() - self.server.serve_forever() - - def initialize_upnp(self): - upnp = miniupnpc.UPnP() - upnp.discoverdelay = 10 - upnp.discover() - upnp.selectigd() - self.upnp = upnp - - def get_external_ip(self): - return get('https://api.ipify.org').text - - def subscribe(self): - if not self.manual_port_forward: - try: - self.upnp.addportmapping( - self.server.server_port, - 'TCP', - self.upnp.lanaddr, - self.server.server_port, - '', - '' - ) - except: - logger.error('Failed to automatically forward port.') - logger.error('Please set port as an environment variable and forward manually.') - sys.exit(1) - - try: - logger.info("Subscribing for Alexa commands...") - self.sns_client.subscribe( - TopicArn=self.topic_arn, - Protocol='http', - Endpoint=self.endpoint_url - ) - - except Exception: - logger.exception('SNS Topic ({}) is invalid. Please check in AWS.'.format(self.topic_arn)) - sys.exit(1) - - def confirm_subscription(self, topic_arn, token): - - try: - self.sns_client.confirm_subscription( - TopicArn=topic_arn, - Token=token, - AuthenticateOnUnsubscribe="false") - logger.info('Subscribed.') - - except Exception: - logger.exception('Failed to confirm subscription. Please check in AWS.') - sys.exit(1) - - def unsubscribe(self): - - if not self.manual_port_forward: - result = self.upnp.deleteportmapping(self.server.server_port, 'TCP') - - if result: - logger.debug('Removed forward for port {}.'.format(self.server.server_port)) - else: - raise RuntimeError( - 'Failed to remove port forward for {}.'.format(self.server.server_port)) - - subscription_arn = None - response = self.sns_client.list_subscriptions_by_topic(TopicArn=self.topic_arn) - for sub in response['Subscriptions']: - if sub['TopicArn'] == self.topic_arn and sub['Endpoint'] == self.endpoint_url: - subscription_arn = sub['SubscriptionArn'] - break - - if (subscription_arn is not None and - subscription_arn[:12] == 'arn:aws:sns:'): - self.sns_client.unsubscribe( - SubscriptionArn=subscription_arn - ) - - sys.exit(0) - - def dispatch_notification(self, notification): - try: - skill = self.skills.get(notification['handler_name']) - skill.handle_command(notification['room'], notification['command'], notification['data']) - except Exception: - logger.exception('Unexpected error handling message') - - diff --git a/src/local/apis/__init__.py b/src/local/apis/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/local/apis/stan.py b/src/local/apis/stan.py new file mode 100644 index 0000000..d9997cd --- /dev/null +++ b/src/local/apis/stan.py @@ -0,0 +1,464 @@ +''' +Code borrowed from XBMC plugin from here https://github.com/matthuisman/slyguy.addon +''' +import logging +import struct +import time +import hmac +import hashlib +import base64 +import json +from urllib.parse import quote_plus +from requests import Session + +HEADERS = { + 'User-Agent': 'Dalvik/2.1.0 (Linux; U; Android 8.1.0; MI 5 Build/OPM7.181005.003)', +} + +API_URL = 'https://api.stan.com.au{}' + +AUDIO_2CH = 'aac' +AUDIO_6CH = 'aac,ac3,eac,eac3' +AUDIO_QUALITY = [AUDIO_2CH, AUDIO_6CH] + +STAN_VERSION = '4.2.2.40832' + +WIDEVINE_UUID = bytearray([237, 239, 139, 169, 121, 214, 74, 206, 163, 200, 39, 220, 213, 29, 33, 237]) +WIDEVINE_PSSH = bytearray([112, 115, 115, 104]) + +DEFAULT_USERAGENT = 'okhttp/3.4.1' + +logger = logging.getLogger(__name__) + +DEFAULT_HEADERS = { + 'User-Agent': DEFAULT_USERAGENT, +} + +class UserData: + def __init__(self): + self.__data = {} + + def get(self, param, default=None): + return self.__data.get(param, default) + + def set(self, param, value): + self.__data[param] = value + + def delete(self, param): + if param in self.__data.keys(): + del self.__data[param] + +def jwt_data(token): + b64_string = token.split('.')[1] + b64_string += "=" * ((4 - len(b64_string) % 4) % 4) #fix padding + return json.loads(base64.b64decode(b64_string)) + +def cenc_init(data=None, uuid=None, kids=None): + data = data or bytearray() + uuid = uuid or WIDEVINE_UUID + kids = kids or [] + + length = len(data) + 32 + + if kids: + #each kid is 16 bytes (+ 4 for kid count) + length += (len(kids) * 16) + 4 + + init_data = bytearray(length) + pos = 0 + + # length (4 bytes) + r_uint32 = struct.pack(">I", length) + init_data[pos:pos+len(r_uint32)] = r_uint32 + pos += len(r_uint32) + + # pssh (4 bytes) + init_data[pos:pos+len(r_uint32)] = WIDEVINE_PSSH + pos += len(WIDEVINE_PSSH) + + # version (1 if kids else 0) + r_uint32 = struct.pack("I", len(kids)) + init_data[pos:pos+len(r_uint32)] = r_uint32 + pos += len(r_uint32) + + for kid in kids: + # each kid (16 bytes) + init_data[pos:pos+len(uuid)] = kid + pos += len(kid) + + # length of data (4 bytes) + r_uint32 = struct.pack(">I", len(data)) + init_data[pos:pos+len(r_uint32)] = r_uint32 + pos += len(r_uint32) + + # data (X bytes) + init_data[pos:pos+len(data)] = data + pos += len(data) + + return base64.b64encode(init_data).decode('utf8') + +class APIError(Exception): + pass + + +class API(object): + + def __init__(self): + self.userdata = UserData() + + def new_session(self): + self.logged_in = False + self._session = Session() + for key, value in HEADERS.items(): + self._session.headers[key] = value + self._set_authentication() + + def _set_authentication(self): + self.logged_in = self.userdata.get('token') != None + + def nav_items(self, key): + data = self.page('sitemap') + + for row in data['navs']['browse']: + if row['path'] == '/' + key: + return row['items'] + + return [] + + def page(self, key): + return self.url('/pages/v6/{}.json'.format(key)) + + def url(self, url): + self._check_token() + + params = { + 'feedTypes': 'posters,landscapes,hero', + 'jwToken': self.userdata.get('token'), + } + + return self._session.get(url, params=params).json() + + def search(self, query, page=1, limit=50): + self._check_token() + + params = { + 'q': query, + 'limit': limit, + 'offset': (page - 1) * limit, + 'jwToken': self.userdata.get('token'), + } + + if self.userdata.get('profile_kids', False): + url = '/search/v12/kids/search' + else: + url = '/search/v12/search' + + return self._session.get(url, params=params).json() + + def login(self, username, password): + self.logout() + + payload = { + 'email': username, + 'password': password, + 'rnd': str(int(time.time())), + 'stanName': 'Stan-Android', + 'type': 'mobile', + 'os': 'Android', + 'stanVersion': STAN_VERSION, + # 'clientId': '', + # 'model': '', + # 'sdk': '', + # 'manufacturer': '', + } + + payload['sign'] = self._get_sign(payload) + + self._login('https://api.stan.com.au/login/v1/sessions/mobile/account', payload) + + def _check_token(self, force=False): + if not force and self.userdata.get('expires') > time.time(): + return + + params = { + 'type': 'mobile', + 'os': 'Android', + 'stanVersion': STAN_VERSION, + } + + payload = { + 'jwToken': self.userdata.get('token'), + } + + self._login('/login/v1/sessions/mobile/app', payload, params) + + def _login(self, url, payload, params=None): + data = self._session.post(url, data=payload, params=params).json() + + if 'errors' in data: + try: + msg = data['errors'][0]['code'] + if msg == 'Streamco.Login.VPNDetected': + msg = 'IP_ADDRESS_ERROR' + except: + msg = '' + + raise APIError('LOGIN_ERROR: {msg}'.format(msg=msg)) + + self.userdata.set('token', data['jwToken']) + self.userdata.set('expires', int(time.time() + (data['renew'] - data['now']) - 30)) + self.userdata.set('user_id', data['userId']) + + self.userdata.set('profile_id', data['profile']['id']) + self.userdata.set('profile_name', data['profile']['name']) + self.userdata.set('profile_icon', data['profile']['iconImage']['url']) + self.userdata.set('profile_kids', int(data['profile'].get('isKidsProfile', False))) + + self._set_authentication() + + try: + logger.debug('Token Data: {}'.format(json.dumps(jwt_data(self.userdata.get('token'))))) + except: + pass + + def watchlist(self): + self._check_token() + + params = { + 'jwToken': self.userdata.get('token'), + } + + url = '/watchlist/v1/users/{user_id}/profiles/{profile_id}/watchlistitems'.format( + user_id=self.userdata.get('user_id'), profile_id=self.userdata.get('profile_id')) + return self._session.get(url, params=params).json() + + def history(self, program_ids=None): + self._check_token() + + params = { + 'jwToken': self.userdata.get('token'), + 'limit': 100, + } + + if program_ids: + params['programIds'] = program_ids + + url = '/history/v1/users/{user_id}/profiles/{profile_id}/history'.format(user_id=self.userdata.get('user_id'), + profile_id=self.userdata.get('profile_id')) + return self._session.get(url, params=params).json() + + # def resume_series(self, series_id): + # params = { + # 'jwToken': self.userdata.get('token'), + # } + + # url = '/resume/v1/users/{user_id}/profiles/{profile_id}/resumeSeries/{series_id}'.format(user_id=self.userdata.get('user_id'), profile_id=self.userdata.get('profile_id'), series_id=series_id) + # return self._session.get(url, params=params).json() + + # def resume_program(self, program_id): + # params = { + # 'jwToken': self.userdata.get('token'), + # } + + # url = '/resume/v1/users/{user_id}/profiles/{profile_id}/resume/{program_id}'.format(user_id=self.userdata.get('user_id'), profile_id=self.userdata.get('profile_id'), program_id=program_id) + # return self._session.get(url, params=params).json() + + def set_profile(self, profile_id): + self._check_token() + + params = { + 'type': 'mobile', + 'os': 'Android', + 'stanVersion': STAN_VERSION, + } + + payload = { + 'jwToken': self.userdata.get('token'), + 'profileId': profile_id, + } + + self._login('/login/v1/sessions/mobile/app', payload, params) + + def profiles(self): + self._check_token() + + params = { + 'jwToken': self.userdata.get('token'), + } + + return self._session.get('https://api.stan.com.au/accounts/v1/users/{user_id}/profiles'.format(user_id=self.userdata.get('user_id')), + params=params).json() + + def add_profile(self, name, icon_set, icon_index, kids=False): + self._check_token() + + payload = { + 'jwToken': self.userdata.get('token'), + 'name': name, + 'isKidsProfile': kids, + 'iconSet': icon_set, + 'iconIndex': icon_index, + } + + return self._session.post('/accounts/v1/users/{user_id}/profiles'.format(user_id=self.userdata.get('user_id')), + data=payload).json() + + def delete_profile(self, profile_id): + self._check_token() + + params = { + 'jwToken': self.userdata.get('token'), + 'profileId': profile_id, + } + + return self._session.delete('/accounts/v1/users/{user_id}/profiles'.format(user_id=self.userdata.get('user_id')), + params=params).ok + + def profile_icons(self): + self._check_token() + + params = { + 'jwToken': self.userdata.get('token'), + } + + return self._session.get('/accounts/v1/accounts/icons', params=params).json() + + def program(self, program_id): + self._check_token() + + params = { + 'jwToken': self.userdata.get('token'), + } + + if self.userdata.get('profile_kids', False): + url = '/cat/v12/kids/programs/{program_id}.json' + else: + url = '/cat/v12/programs/{program_id}.json' + + return self._session.get(url.format(program_id=program_id), params=params).json() + + def play(self, program_id): + self._check_token(force=True) + + program_data = self.program(program_id) + if 'errors' in program_data: + try: + msg = program_data['errors'][0]['code'] + if msg == 'Streamco.Concurrency.OutOfRegion': + msg = 'IP_ADDRESS_ERROR' + elif msg == 'Streamco.Catalogue.NOT_SAFE_FOR_KIDS': + msg = 'KIDS_PLAY_DENIED' + except: + msg = '' + + raise APIError('PLAYBACK_ERROR: {msg}'.format(msg=msg)) + + jw_token = self.userdata.get('token') + + params = { + 'programId': program_id, + 'jwToken': jw_token, + 'format': 'dash', + 'capabilities.drm': 'widevine', + 'quality': 'high', + } + + data = self._session.get('/concurrency/v1/streams', params=params).json() + + if 'errors' in data: + try: + msg = data['errors'][0]['code'] + if msg == 'Streamco.Concurrency.OutOfRegion': + msg = 'IP_ADDRESS_ERROR' + except: + msg = '' + + raise APIError('PLAYBACK_ERROR: {msg}'.format(msg=msg)) + + play_data = data['media'] + play_data['drm']['init_data'] = self._init_data(play_data['drm']['keyId']) + play_data['videoUrl'] = API_URL.format( + '/manifest/v1/dash/androidtv.mpd?url={url}&audioType=all&version=88'.format( + url=quote_plus(play_data['videoUrl']), + )) + + params = { + 'form': 'json', + 'schema': '1.0', + 'jwToken': jw_token, + '_id': data['concurrency']['lockID'], + '_sequenceToken': data['concurrency']['lockSequenceToken'], + '_encryptedLock': 'STAN', + } + + self._session.get('/concurrency/v1/unlock', params=params).json() + + return program_data, play_data + + def _init_data(self, key): + key = key.replace('-', '') + key_len = '{:x}'.format(len(bytearray.fromhex(key))) + key = '12{}{}'.format(key_len, key) + key = bytearray.fromhex(key) + + return cenc_init(key) + + def _get_sign(self, payload): + module_version = 214 + + f3757a = bytearray((144, 100, 149, 1, 2, 8, 36, 208, 209, 51, 103, 131, 240, 66, module_version, + 20, 195, 170, 44, 194, 17, 161, 118, 71, 105, 42, 76, 116, 230, 87, 227, 40, 115, + 5, 62, 199, 66, 7, 251, 125, 238, 123, 71, 220, 179, 29, 165, 136, 16, module_version, + 117, 10, 100, 222, 41, 60, 103, 2, 121, 130, 217, 75, 220, 100, 59, 35, 193, 22, 117, + 27, 74, 50, 85, 40, 39, 31, 180, 81, 34, 155, 172, 202, 71, 162, 202, 234, 91, 176, 199, + 207, 131, + 229, 125, 105, 9, 227, 188, 234, 61, 33, 17, 113, 222, 173, 182, 120, 34, 80, 135, 219, 8, + 97, 176, 62, + 137, 126, 222, 139, 136, 77, 243, 37, 11, 234, 82, 244, 222, 44)) + + f3758b = bytearray( + (120, 95, 52, 175, 139, 155, 151, 35, 39, 184, 141, 27, 55, 215, 102, 173, 2, 37, 141, 164, 236, 217, + 173, 194, 94, 67, 195, 24, 221, 66, 233, 11, 226, 91, 33, 249, 225, 54, 88, 54, 118, 101, 31, 248, 11, + 208, 206, 226, 68, 20, 143, 37, 104, 159, 184, 22, 53, 179, 104, 152, 170, 29, 26, 6, 163, 45, 87, 193, + 136, 226, 128, 245, 231, 238, 154, 211, 71, 134, 232, 99, 35, 54, 170, 128, 1, 218, 249, 70, 182, 145, + 125, 211, 16, 43, 118, 177, 64, 128, 111, 73, 234, 22, 21, 165, 67, 23, 15, 5, 11, 70, 48, 97, 134, 185, + 11, 28, 167, 140, 123, 81, 240, 247, 77, 187, 23, 243, 89, 54)) + + msg = '' + for key in sorted(payload.keys()): + if msg: msg += '&' + msg += key + '=' + quote_plus(payload[key], safe="_-!.~'()*") + + bArr = bytearray(len(f3757a)) + for i in range(len(bArr)): + bArr[i] = f3757a[i] ^ f3758b[i] + + bArr2 = bytearray(int(len(bArr) / 2)) + for i in range(len(bArr2)): + bArr2[i] = bArr[i] ^ bArr[len(bArr2) + i] + + signature = hmac.new(bArr2, msg=msg.encode('utf8'), digestmod=hashlib.sha256).digest() + + return base64.b64encode(signature).decode('utf8') + + def logout(self): + self.userdata.delete('token') + self.userdata.delete('expires') + self.userdata.delete('user_id') + + self.userdata.delete('profile_id') + self.userdata.delete('profile_icon') + self.userdata.delete('profile_name') + self.userdata.delete('profile_kids') + + self.new_session() diff --git a/src/local/constants.py b/src/local/constants.py new file mode 100644 index 0000000..b2e3d1d --- /dev/null +++ b/src/local/constants.py @@ -0,0 +1,7 @@ +# Environment variable names +ENV_YOUTUBE_API_KEY = 'YOUTUBE_API_KEY' + +ENV_PLEX_IP_ADDRESS = 'PLEX_IP_ADDRESS' +ENV_PLEX_PORT = 'PLEX_PORT' +ENV_PLEX_SUBTITLE_LANG = 'PLEX_SUBTITLE_LANG' +ENV_PLEX_TOKEN = 'PLEX_TOKEN' diff --git a/src/local/controllers/__init__.py b/src/local/controllers/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/local/controllers/chromecast_controller.py b/src/local/controllers/chromecast_controller.py new file mode 100644 index 0000000..54aec95 --- /dev/null +++ b/src/local/controllers/chromecast_controller.py @@ -0,0 +1,291 @@ +import logging +import os +import threading +import time +from datetime import datetime, timedelta +import pychromecast +from pychromecast.controllers.media import MediaStatusListener, MediaStatus +from pychromecast.controllers.receiver import CastStatusListener + +from local import utils, constants +from local.controllers.media_controller import MediaExtensions +from local.controllers.plex_controller import MyPlexController +from local.controllers.youtube_controller import MyYouTubeController + +logger = logging.getLogger(__name__) + +APP_YOUTUBE = 'youtube' +APP_PLEX = 'plex' + +APP_PLEX_ID = '9AC194DC' # Plex App Id in pychromecast seems to be wrong... +APP_YOUTUBE_ID = pychromecast.APP_YOUTUBE +APP_NETFLIX_ID = 'CA5E8412' + + +class ChromecastWrapper(MediaStatusListener, CastStatusListener): + """ + Thin wrapper to register controllers and listeners + """ + + @property + def cast(self) -> pychromecast.Chromecast: + return self.__cc + + @property + def media_controller(self): + return self.cast.media_controller + + @property + def name(self): + return self.__cc.device.friendly_name + + @property + def plex_controller(self) -> MyPlexController: + return self._plex_controller + + @property + def youtube_controller(self) -> MyYouTubeController: + return self._youtube_controller + + def __init__(self, cc: pychromecast.Chromecast): + self.__cc = cc + cc.media_controller.register_status_listener(self) + cc.register_status_listener(self) + + self._plex_controller = None + self._youtube_controller = None + + if os.environ.get(constants.ENV_YOUTUBE_API_KEY): + self._youtube_controller = MyYouTubeController(cc) + cc.register_handler(self._youtube_controller) + else: + logger.warning('Youtube controller not loaded. Please set your Youtube API Key.') + + if os.environ.get(constants.ENV_PLEX_IP_ADDRESS): + self._plex_controller = MyPlexController(cc) + cc.register_handler(self._plex_controller) + else: + logger.warning('Plex controller not loaded. Please set your Plex configuration.') + + def new_media_status(self, status: MediaStatus): + logger.debug('----') + logger.debug(f'New Media Status Event: {status}') + logger.debug('----') + + def new_cast_status(self, status: MediaStatus): + logger.debug('----') + logger.debug(f'New Cast Status Event: {status}') + logger.debug('----') + + def __get_controller(self, app=''): + if app == APP_YOUTUBE and self.youtube_controller: + return self.youtube_controller + if app == APP_PLEX and self.plex_controller: + return self.plex_controller + if app: + logger.error(f'Unable to process command, the streaming application {app} is not supported') + return None + return self.media_controller + + def get_controller(self, app='') -> MediaExtensions: + if not app: + # If no app is specified assume it's the active one + current_app_id = self.cast.app_id + if current_app_id == APP_YOUTUBE_ID: + app = APP_YOUTUBE + elif current_app_id == APP_PLEX_ID: + app = APP_PLEX + return self.__get_controller(app) + + +class ChromecastCollector: + """ + Stores available Chromecasts. + Every 2 hours it will check, and add any new Chromecasts + """ + + @property + def count(self): + return len(self.__chromecasts) + + def stop(self): + self.running = False + self.thread.join(10) + + def __set_chromecasts(self): + with self.lock: + for cc in pychromecast.get_chromecasts()[0]: + cc.wait() + if cc.device.friendly_name not in self.__chromecasts.keys(): + logger.info("Adding %s" % cc.device.friendly_name) + self.__chromecasts[cc.device.friendly_name] = ChromecastWrapper(cc) + self.expiry = datetime.now() + + def expire_chromecasts(self): + while self.running: + time.sleep(1) + refresh_period = timedelta(minutes=120) + if (self.expiry + refresh_period) < datetime.now(): + logger.info("Searching for new Chromecasts...") + self.__set_chromecasts() + logger.info("Search completed.") + + def __init__(self): + self.running = True + self.expiry = datetime.now() + self.lock = threading.Lock() + self.__chromecasts = {} + self.__set_chromecasts() + self.thread = threading.Thread(target=self.expire_chromecasts) + self.thread.start() + + def match_chromecast(self, room) -> ChromecastWrapper: + with self.lock: + result = next((x for x in self.__chromecasts.values() if + str.lower(room.strip()) in str.lower(x.name).replace(' the ', '')), None) + if result: + result.cast.wait() + return result + + def get_chromecast(self, name) -> ChromecastWrapper: + result = self.__chromecasts[name] + result.cast.wait() + return result + + +class ChromecastController: + """ + Wrapper to send commands to different named Chromecasts + """ + + def __init__(self): + + logger.info("Finding Chromecasts...") + self.chromecast_collector = ChromecastCollector() + if self.chromecast_collector.count == 0: + logger.info("No Chromecasts found") + exit(1) + logger.info("%i Chromecasts found" % self.chromecast_collector.count) + + def get_chromecast(self, name) -> ChromecastWrapper: + return self.chromecast_collector.get_chromecast(name) + + def handle_command(self, room, command, data=None): + if data is None: + data = {} + try: + chromecast = self.chromecast_collector.match_chromecast(room) + if not chromecast: + logger.warning('No Chromecast found matching: %s' % room) + return + func = command.replace('-', '_') + logger.info('Sending %s command to Chromecast: %s' % (func, chromecast.name)) + + getattr(self, func)(data, chromecast.name) + except Exception as err: + logger.exception(f'Unexpected error: {err}') + + def resume(self, data, name): + self.play(data, name) + + def play(self, data, name): + self.get_chromecast(name).get_controller().play() + + def pause(self, data, name): + self.get_chromecast(name).get_controller().pause() + + def shutdown(self, signum, frame): + logger.info('Shutting down periodic Chromecast scanning') + self.chromecast_collector.stop() + + def stop(self, data, name): + cc = self.get_chromecast(name) + if cc.cast.app_id == APP_NETFLIX_ID: + logger.warning("Stop doesn't work on Netflix. Stopping Netflix app.") + cc.cast.quit_app() + else: + self.get_chromecast(name).get_controller().stop() + + def open(self, data, name): + app = data['app'] + controller = self.get_chromecast(name).get_controller(app) + if controller: + controller.launch() + + def set_volume(self, data, name): + if 'volume' in data: + volume = data['volume'] # volume as 0-10 + volume_normalized = float(volume) / 10.0 # volume as 0-1 + else: + raise_lower = data['raise_lower'] + cast = self.get_chromecast(name).cast + vol = cast.status.volume_level + volume_normalized = vol + 0.1 if 'up' in raise_lower else vol - 0.1 + self.get_chromecast(name).cast.set_volume(volume_normalized) + + def mute(self, data, name): + self.get_chromecast(name).cast.set_volume_muted(True) + + def unmute(self, data, name): + self.get_chromecast(name).cast.set_volume_muted(False) + + def shuffle_on(self, data, name): + cc = self.get_chromecast(name) + cc.get_controller().shuffle_on() + + def shuffle_off(self, data, name): + cc = self.get_chromecast(name) + cc.get_controller().shuffle_off() + + def play_next(self, data, name): + # mc.queue_next() didn't work + cc = self.get_chromecast(name) + cc.get_controller().next() + + def play_previous(self, data, name): + cc = self.get_chromecast(name) + cc.get_controller().previous() + + def transcode(self, data, name): + cc = self.get_chromecast(name) + cc.get_controller().transcode(data) + + def rewind(self, data, name): + mc = self.get_chromecast(name).media_controller + duration = utils.get_dict_val(data, 'duration') + seconds = utils.parse_iso_duration(duration) + position = mc.status.current_time - seconds + mc.seek(position) + + def fast_forward(self, data, name): + mc = self.get_chromecast(name).media_controller + duration = utils.get_dict_val(data, 'duration') + seconds = utils.parse_iso_duration(duration) + position = mc.status.current_time + seconds + mc.seek(position) + + def play_media(self, data, name): + cc = self.get_chromecast(name) + streaming_app = data['app'] if 'app' in data.keys() else '' + cc.get_controller(streaming_app).play_item(data) + + def play_photos(self, data, name): + cc = self.get_chromecast(name) + cc.plex_controller.play_photos(data) + + def restart(self, data, name): + # Reboot is no longer supported + mc = self.get_chromecast(name).media_controller + mc.seek(0) + + def change_audio(self, data, name): + plex_c = self.get_chromecast(name).plex_controller + plex_c.change_audio_track() + + def subtitle_on(self, data, name): + plex_c = self.get_chromecast(name).plex_controller + plex_c.change_subtitle_track() + + def subtitle_off(self, data, name): + plex_c = self.get_chromecast(name).plex_controller + plex_c.turn_off_subtitles() diff --git a/src/local/controllers/media_controller.py b/src/local/controllers/media_controller.py new file mode 100644 index 0000000..d35d409 --- /dev/null +++ b/src/local/controllers/media_controller.py @@ -0,0 +1,52 @@ +from abc import ABC, abstractmethod + + +class MediaExtensions(ABC): + + @abstractmethod + def launch(self): + raise NotImplementedError() + + @abstractmethod + def previous(self): + raise NotImplementedError() + + @abstractmethod + def next(self): + raise NotImplementedError() + + @abstractmethod + def play_item(self, options): + raise NotImplementedError() + + @abstractmethod + def shuffle_on(self): + raise NotImplementedError() + + @abstractmethod + def shuffle_off(self): + raise NotImplementedError() + + @abstractmethod + def loop_on(self): + raise NotImplementedError() + + @abstractmethod + def loop_off(self): + raise NotImplementedError() + + @abstractmethod + def stop(self): + raise NotImplementedError() + + @abstractmethod + def transcode(self, data): + raise NotImplementedError() + + @abstractmethod + def play(self): + raise NotImplementedError() + + @abstractmethod + def pause(self): + raise NotImplementedError() diff --git a/src/local/controllers/plex_controller.py b/src/local/controllers/plex_controller.py new file mode 100644 index 0000000..921ffed --- /dev/null +++ b/src/local/controllers/plex_controller.py @@ -0,0 +1,563 @@ +from datetime import datetime +import json +import logging +import os +import socket +import ssl +import time +from typing import Optional, List + +import requests +from dateutil.relativedelta import relativedelta +from plexapi.exceptions import Unauthorized, NotFound +from plexapi.media import Media +from plexapi.playqueue import PlayQueue +from plexapi.server import PlexServer +from plexapi.video import Show, Episode +from pychromecast import Chromecast +from pychromecast.controllers.plex import PlexController, media_to_chromecast_command + +from local import utils, constants +from local.controllers.media_controller import MediaExtensions + +logger = logging.getLogger(__name__) + +# Transcode qualities +QUALITY_LIST = { + '240p': 320, + '320p': 720, + '480p': 1500, + '720p_low': 2000, + '720p_med': 3000, + '720p': 4000, + '1080p': 8000 +} + + +class PlexControllerError(Exception): + # Nothing more to do + pass + + +class MyPlexController(PlexController, MediaExtensions): + + @property + def plex_server(self) -> PlexServer: + if self.__plex_server: + return self.__plex_server + else: + self.__plex_server = self.__get_plex_server() + return self.__plex_server + + @property + def bitrate(self): + return self.__bitrate + + def __init__(self, cc: Chromecast): + self.__current_item = None + self.__playlist: Optional[PlayQueue] = None + self.__plex_server = None + self._subtitle_code = os.environ.get(constants.ENV_PLEX_SUBTITLE_LANG, 'eng') + self.__bitrate = 0 + self.cast_id = cc.uuid + self._load_settings() + super().__init__() + + def _get_content_id(self): + # On occasion content_id is not found + content_id = None + for _ in range(3): + content_id = self.status.content_id + if content_id: + break + time.sleep(1) + return content_id + + def get_playing_item(self): + content_id = self._get_content_id() + if content_id: + return self.plex_server.fetchItem(content_id) + return None + + def transcode(self, data): + raise_lower = utils.get_dict_val(data, 'raise_lower', '') + bitrate = 0 + max_bitrate = 20000 if self.__bitrate == 0 else self.__bitrate + if raise_lower == 'up': + bitrate = next((value for key, value in QUALITY_LIST.items() if value > max_bitrate), 0) + elif raise_lower == 'down': + keys = list(QUALITY_LIST.keys()) + keys.reverse() + bitrate = next((QUALITY_LIST[key] for key in keys if QUALITY_LIST[key] < max_bitrate), QUALITY_LIST['240p']) + + else: + quality = utils.get_dict_val(data, 'quality', '') + if quality == 'maximum': + bitrate = 0 + elif quality.lower() in ['high', '1080p']: + bitrate = QUALITY_LIST['1080p'] + elif quality.lower() in ['medium', '720p']: + bitrate = QUALITY_LIST['720p'] + elif quality.lower() in ['low', '480p']: + bitrate = QUALITY_LIST['480p'] + + if bitrate != self.__bitrate: + self.__bitrate = bitrate + self._save_settings() + self._resume_playing() + + def __shuffle(self, turn_on: bool): + self._start_playing(shuffle=turn_on) + + def shuffle_on(self): + self.__shuffle(True) + + def shuffle_off(self): + self.__shuffle(False) + + def is_shuffle_on(self): + return self.__playlist.playQueueShuffled if self.__playlist else False + + def __loop(self, turn_on: bool): + playlist = self.__playlist + if playlist: + self._start_playing(shuffle=playlist.playQueueShuffled, repeat=turn_on) + else: + logger.warning('No current item to repeat.') + + def loop_on(self): + self.__loop(True) + + def loop_off(self): + self.__loop(False) + + def previous(self): + # Previous doesn't go to the previous episode when it's part way through + self.rewind() + for _i in range(10): + if self.status.current_time < 10: + break + time.sleep(1) + super().previous() + + def play_photos(self, data): + title = utils.get_dict_val(data, 'title') + month: str = utils.get_dict_val(data, 'month') + year = utils.get_dict_val(data, 'year') + + photos = [] + if title: + photos = self.__get_photos_by_title(title, year) + elif year: + photos = self.__get_photos_by_date(month, year) + if not photos: + logger.info(f'Unable to find photos matching: {data}') + return + + self.__current_item = photos + play_type = utils.get_dict_val(data, 'play') + if play_type == 'find': + self.show_media() + else: + self._start_playing(shuffle=(play_type == 'shuffle')) + + def get_media_index(self): + return self.status.media_custom_data.get("mediaIndex", 0) + + def get_part_index(self): + return self.status.media_custom_data.get("partIndex", 0) + + def play(self): + if self.__current_item and not self.status.player_is_paused: + self._resume_playing() + else: + super().play() + + def stop(self): + super().stop() + self.show_media() + + @property + def current_item(self) -> Media: + return self.__current_item + + def _set_current_item(self, item): + self.__current_item = item + self.__playlist = None + + def search(self, title, media_type='', limit=10): + items = self.plex_server.search(title, mediatype=media_type, limit=limit) + return items + + def block_until_playing(self, media=None, timeout=None, **kwargs): + return super().block_until_playing(media=media, timeout=timeout, **kwargs) + + def _save_settings(self): + file_name = f'.plex_config_{self.cast_id}' + with open(file_name, "w") as write_file: + write_file.write(json.dumps({ + 'bitrate': self.__bitrate + })) + + def _load_settings(self): + file_name = f'.plex_config_{self.cast_id}' + if not os.path.exists(file_name): + return + with open(file_name, "r") as read_file: + content = json.loads(read_file.read()) + logger.info('Loaded settings:') + logger.info(json.dumps(content, indent=4, sort_keys=True)) + self.__bitrate = content['bitrate'] + + def get_audio_streams(self): + item = self.get_playing_item().reload() + if not item: + return [] + part_index = self.get_part_index() + media_index = self.get_media_index() + part = item.media[media_index].parts[part_index] + return part.audioStreams() + + def get_subtitle_streams(self): + item = self.get_playing_item().reload() + part_index = self.get_part_index() + media_index = self.get_media_index() + part = item.media[media_index].parts[part_index] + return [subtitle for subtitle in part.subtitleStreams() if subtitle.languageCode == self._subtitle_code] + + def set_audio_stream(self, audio_stream_id): + item = self.get_playing_item() + part_index = self.get_part_index() + media_index = self.get_media_index() + part = item.media[media_index].parts[part_index] + self.plex_server.query(f'/library/parts/{part.id}?audioStreamID={audio_stream_id}', requests.put) + + def set_subtitle_stream(self, subtitle_stream_id): + if str(subtitle_stream_id) == '1': + subtitle_stream_id = '' + item = self.get_playing_item() + part_index = self.get_part_index() + media_index = self.get_media_index() + part = item.media[media_index].parts[part_index] + self.plex_server.query(f'/library/parts/{part.id}?subtitleStreamID={subtitle_stream_id}', requests.put) + + def turn_off_subtitles(self): + item = self.get_playing_item() + part_index = self.get_part_index() + media_index = self.get_media_index() + part = item.media[media_index].parts[part_index] + self.plex_server.query(f'/library/parts/{part.id}?subtitleStreamID=0&allParts=1', requests.put) + self._resume_playing() + + def receive_message(self, message, data: dict): + self.logger.debug(data) + + def _send_start_play(self, media=None, bitrate=None, **kwargs): + """ + Override to allow more + """ + msg = media_to_chromecast_command( + media, requestiId=self._inc_request(), **kwargs + ) + if bitrate: + data = msg["media"]["customData"] + data['directStream'] = False + data['directPlay'] = False + data['bitrate'] = bitrate + quality = next((key for key, item in QUALITY_LIST.items() if item == bitrate), 'UNKNOWN') + logger.info(f'Transcoding media to {quality}, bitrate: {bitrate}') + + self.logger.debug("Create command: \n%r\n", json.dumps(msg, indent=4)) + self._last_play_msg = msg + self._send_cmd( + msg, + namespace="urn:x-cast:com.google.cast.media", + inc_session_id=True, + inc=False, + ) + + @classmethod + def __get_ssl_cert_name(cls, hostname, port): + context = ssl.create_default_context() + context.check_hostname = False + with context.wrap_socket( + socket.socket(socket.AF_INET), + server_hostname=hostname, + ) as conn: + # 5 second timeout + conn.settimeout(5.0) + try: + conn.connect((hostname, port)) + domain = conn.getpeercert()['subject'][0][0][1] + domain = domain.replace('*', '') + return domain + except Exception as err: + msg = f'Unable to retrieve Plex certificate from {hostname}:{port}' + logger.error(msg) + logger.error(err) + raise PlexControllerError(msg) + + @classmethod + def __get_plex_server(cls) -> Optional[PlexServer]: + ip_address = os.environ.get(constants.ENV_PLEX_IP_ADDRESS) + port = os.environ.get(constants.ENV_PLEX_PORT) + port = int(port) if port else False + token = os.environ.get(constants.ENV_PLEX_TOKEN) + if not ip_address or not port or not token: + msg = 'Plex config is not set, set these in .custom_env' + logger.warning(msg) + raise PlexControllerError(msg) + + logger.info(f'Plex IP: {ip_address}, Port: {port}') + logger.info('Looking up Plex server certificate...') + cert_common_name = cls.__get_ssl_cert_name(ip_address, port) + plex_address = f'https://{ip_address.replace(".", "-")}{cert_common_name}:{port}' + try: + return PlexServer(plex_address, token) + except Unauthorized: + msg = 'Authentication failed to Plex, check your token is correct in .custom_env' + logger.error(msg) + raise PlexControllerError(msg) + except requests.exceptions.ConnectionError: + msg = f'Failed to connect to Plex using determined address [{plex_address}]' + logger.error(msg) + raise PlexControllerError(msg) + + @staticmethod + def get_next_episode_to_watch(show): + """ + Get the last episode that was being watched, or the next unwatched one + Otherwise just return the last one + """ + episodes: List = show.episodes() + episodes.reverse() + pos = next((i for i, episode in enumerate(episodes) if episode.isWatched), 0) + if pos == 0: + # Return the last episode + return episodes[pos] + # Return the next episode, if there is one + pos = pos - 1 if pos > 0 else 0 + return episodes[pos] + + def play_item(self, options): + title = utils.get_dict_val(options, 'title', '') + tv_show = utils.get_dict_val(options, 'tv_show', '') + media_type = utils.get_dict_val(options, 'type') + + media_type = self._map_plex_type(media_type) + + if media_type == 'show': + show = self.__get_show_by_title(tv_show if tv_show else title) + if not show: + logger.warning(f'Unable to find a matching show for: {tv_show if tv_show else title}') + return + play_media = show + logger.info(f'Selected show: {show}') + + elif media_type == 'episode': + ep_season = self.__get_episode_or_season(options) + if not ep_season: + logger.warning(f'Unable to find a matching episode/season for: {options}') + return + play_media = ep_season + logger.info(f'Selected episode/season: {ep_season} for show: {ep_season.show()}') + + else: + items = self.search(title, media_type=media_type, limit=10) + if len(items) == 0: + logger.info(f'Unable to find any item in Plex matching title: {title}') + return + play_media = items[0] + + self._set_current_item(play_media) + play_command = utils.get_dict_val(options, 'play') + if play_command == 'find': + super().stop() + self.show_media() + else: + self._start_playing(shuffle=(play_command == 'shuffle')) + + def show_media(self, **kwargs): + item = self.__current_item + if type(item) == list: + item = item[0] + msg = media_to_chromecast_command( + item, type='SHOWDETAILS', requestId=self._inc_request(), **kwargs + ) + msg['media']['contentId'] = item.key + + def callback(): # pylint: disable=missing-docstring + self._send_cmd(msg, inc_session_id=True, inc=False) + + self.launch(callback) + + def _resume_playing(self): + self._start_playing(resume=True) + + def _start_playing(self, shuffle=False, repeat=False, resume=False): + if not self.current_item: + # Nothing to play + return + build_list = False + if not resume or not self.__playlist: + self.__playlist = self.build_play_list(shuffle, repeat) + build_list = True + + media = self.__playlist + if type(media) == PlayQueue: + play_item = media.selectedItem + else: + play_item = media + if not build_list: + play_item.reload() + if 'viewOffset' in vars(play_item): + offset = play_item.viewOffset / 1000 + self.block_until_playing(media, offset=offset, bitrate=self.__bitrate) + else: + self.block_until_playing(media, bitrate=self.__bitrate) + + def change_audio_track(self): + audio_streams = self.get_audio_streams() + if len(audio_streams) == 1: + # Nothing to do only 1 audio stream + return + + # Switch to next stream after the current selected + pos = next((i for i, x in enumerate(audio_streams) if x.selected), -1) + 1 + if pos >= len(audio_streams): + pos = 0 + self.set_audio_stream(audio_streams[pos].id) + self._resume_playing() + + def change_subtitle_track(self): + subtitle_streams = self.get_subtitle_streams() + pos = next((i for i, x in enumerate(subtitle_streams) if x.selected), -1) + 1 + if pos >= len(subtitle_streams): + pos = 0 + self.set_subtitle_stream(subtitle_streams[pos].id) + self._resume_playing() + + def __get_episode_or_season(self, options) -> Episode: + ep_num = utils.get_dict_val(options, 'epnum', '') + seas_num = utils.get_dict_val(options, 'seasnum', '') + tv_show = utils.get_dict_val(options, 'tvshow', '') + title = utils.get_dict_val(options, 'title', '') + result = None + if ep_num: + result = self.__get_episode_by_number(tv_show, ep_num, seas_num) + elif seas_num: + result = self.__get_season(tv_show, seas_num) + elif title: + result = self.__get_episode_by_title(tv_show, title) + return result + + def __get_show_by_title(self, title) -> Optional[Show]: + if not title: + return None + found_shows = self.search(title, media_type='show', limit=10) + if not found_shows: + # Try a broader search + items = self.search(title, limit=10) + for item in items: + if item.TYPE == 'episode': + found_shows = [item.show()] + break + return found_shows[0] if found_shows else None + + def __get_episode_by_number(self, tv_show, ep_num, seas_num) -> Optional[Episode]: + show = self.__get_show_by_title(tv_show) + try: + if show: + return show.get(season=seas_num, episode=ep_num) + except NotFound: + logger.warning(f'Unable to find Season {seas_num}, Episode {ep_num} for show: {show}') + return None + + def __get_season(self, tv_show, seas_num) -> Optional[Episode]: + show = self.__get_show_by_title(tv_show) + try: + if show: + return show.season(season=seas_num) + except NotFound: + logger.warning(f'Unable to find Season {seas_num} for show: {show}') + return None + + def __get_episode_by_title(self, tv_show, title) -> Optional[Episode]: + episode = None + found_episodes = self.search(title, media_type='episode', limit=10) + show = self.__get_show_by_title(tv_show) + if show: + for ep in found_episodes: + if ep.grandparentKey == show.key: + episode = ep + break + if not episode and found_episodes: + return found_episodes[0] + return episode + + def build_play_list(self, shuffle=False, repeat=False) -> PlayQueue: + item = self.__current_item + if type(item) == list or item.TYPE in ['artist', 'album', 'photo']: + # noinspection PyTypeChecker + play_list = self.plex_server.createPlayQueue(item, + shuffle=1 if shuffle else 0, + repeat=1 if repeat else 0) + play_list.playQueueShuffled = shuffle + elif item.TYPE == 'episode': + episodes = self.__get_episodes(item.show(), item, count=20) + play_list = self.plex_server.createPlayQueue(episodes, startItem=item) + elif item.TYPE == 'show': + episode = self.get_next_episode_to_watch(item) + episodes = self.__get_episodes(item, episode, count=20) + play_list = self.plex_server.createPlayQueue(episodes, startItem=episode) + else: + play_list = self.plex_server.createPlayQueue(item) + return play_list + + @staticmethod + def __get_episodes(show: Show, episode: Episode, count) -> List: + eps = show.episodes() + if len(eps) <= count: + return eps + pos = eps.index(episode) + length = len(eps) + end_pos = min(max(pos + count // 2 + 1, count + 1), length) + start_pos = max(pos - count // 2 - (count // 2 - min(length - end_pos, 0)), 0) + return eps[start_pos:end_pos] + + def __get_photos_by_title(self, title, year): + photos = [] + photo_library = self.plex_server.library.section('Photos') + title = f'{title} {year}' if year else title + albums = photo_library.searchAlbums(title=title) + if len(albums) == 0: + albums = photo_library.searchAlbums(title=title) + for album in albums: + photos.extend(album.photos()) + return photos + + def __get_photos_by_date(self, month, year): + # Default to entire year + day_from = '01' + month_from = '01' + day_to = '31' + month_to = '12' + + if month: + dte = datetime.strptime(f'1 {month} {year}', '%d %B %Y') + month_to = dte.month + month_from = dte.month + dte = dte + relativedelta(months=1) - relativedelta(days=1) + day_to = dte.day + + photo_library = self.plex_server.library.section('Photos') + return photo_library.search(filters={'originallyAvailableAt>>=': f'{year}-{month_from}-{day_from}', + 'originallyAvailableAt<<=': f'{year}-{month_to}-{day_to}'}) + + @staticmethod + def _map_plex_type(media_type): + if media_type == 'song': + return 'track' + if media_type == 'video': + return 'movie' + return media_type diff --git a/src/local/controllers/stan_controller.py b/src/local/controllers/stan_controller.py new file mode 100644 index 0000000..91e4133 --- /dev/null +++ b/src/local/controllers/stan_controller.py @@ -0,0 +1,78 @@ +import logging +import os + +from pychromecast.controllers import BaseController +from pychromecast.controllers.media import MediaStatus +from pychromecast.controllers.receiver import CastStatusListener, CastStatus + +from ..apis import stan + +APP_NAMESPACE = "urn:x-cast:au.com.streamco.media.chromecast" +APP_STAN = '08CAA3D4' + + +class StanController(BaseController, CastStatusListener): + """ Controller to interact with Supla namespace. """ + + def new_cast_status(self, status: CastStatus): + self.logger.debug('****') + self.logger.debug(status) + self.logger.debug('****') + + def new_media_status(self, status: MediaStatus): + self.logger.debug('****') + self.logger.debug(f'New Media Status Event: {status}') + self.logger.debug('****') + + def __init__(self): + super().__init__(APP_NAMESPACE, APP_STAN) + self.logger = logging.getLogger(__name__) + self.api = stan.API() + self.api.login(os.environ.get('STAN_USERNAME'), os.environ.get('STAN_PASSWORD')) + + def receive_message(self, message, data: dict): + self.logger.debug(message) + self.logger.debug(data) + + def play_media(self): + jw_token = self.api.userdata.get('token') + msg = { + 'type': 'LOAD', + 'requestId': 1, + 'media': { + 'contentId': 'https://api.stan.com.au/concurrency/v1/media/3019990/hd/dash/high/3538995', + 'streamType': 'BUFFERED', + 'contentType': 'application/dash+xml', + "autoplay": True, + "currentTime": 0, + "activeTrackIds": None, + 'customData': { + 'guid': '3019990', + 'jwToken': jw_token, + 'programId': '3019990', + 'programType': 'movie', + 'mainURL': 'https://api.stan.com.au/cat/v12/programs/3019990.json', + 'userId': 'd33a09cfb69947fbb6a9b33eebe72807', + 'profileId': 'd33a09cfb69947fbb6a9b33eebe72807', + 'time': 0, + 'preloadTime': 0, + 'totalDuration': 9137, + 'quality': 'auto', + 'audioLanguage': 'en', + 'audioType': 'main', + 'audioLayout': '', + 'autoCueEnabled': True, + 'userInactivityEnabled': True, + 'closedCaptionsEnabled': False, + 'chaptersEnabled': True, + 'textTracks': [], + 'activeTextTrack': None + } + } + } + self.namespace = 'urn:x-cast:com.google.cast.media' + try: + self.send_message(msg, inc_session_id=True) + finally: + self.namespace = APP_NAMESPACE + diff --git a/src/local/controllers/youtube_controller.py b/src/local/controllers/youtube_controller.py new file mode 100644 index 0000000..3bcdfc1 --- /dev/null +++ b/src/local/controllers/youtube_controller.py @@ -0,0 +1,121 @@ +import logging +import os +import random + +from pychromecast import Chromecast +from pychromecast.controllers.youtube import YouTubeController +from pyyoutube import api as youtube_api + +from local import utils, constants +from local.controllers.media_controller import MediaExtensions + +logger = logging.getLogger(__name__) +logger.setLevel(logging.INFO) + +# Categories for searching +CATEGORY_MUSIC = '10' +CATEGORY_SHOWS = '43' +CATEGORY_TRAILERS = '44' + +# How many Youtube results to return +SEARCH_LIMIT = 10 + + +class MyYouTubeController(YouTubeController, MediaExtensions): + """ + Youtube Controller extension + """ + + def pause(self): + self.chromecast.media_controller.pause() + + def __init__(self, chromecast: Chromecast): + self.chromecast = chromecast + self.__api = youtube_api.Api(api_key=os.environ.get(constants.ENV_YOUTUBE_API_KEY)) + self.__current_results = None + super().__init__() + + def receive_message(self, msg, data): + logger.debug('Received: %s %s' % (msg, data)) + return YouTubeController.receive_message(self, msg, data) + + def transcode(self, data): + # TODO: Change between HD and SD + pass + + def shuffle_on(self): + self.__play_videos(shuffle=True) + + def shuffle_off(self): + self.__play_videos(shuffle=False) + + def loop_on(self): + # TODO + pass + + def loop_off(self): + # TODO + pass + + def next(self): + self.chromecast.media_controller.queue_next() + + def previous(self): + self.chromecast.media_controller.queue_prev() + + def stop(self): + self.chromecast.media_controller.stop() + + def play(self): + self.chromecast.media_controller.play() + + def play_item(self, options): + self.launch() + title = utils.get_dict_val(options, 'title', '') + opt_type = utils.get_dict_val(options, 'type', '') + + # Set search params + yt_type = opt_type if opt_type == 'playlist' else None + yt_video_type = opt_type if opt_type in ['movie', 'episode'] else None + yt_category_id = None + if opt_type == 'show': + yt_category_id = CATEGORY_SHOWS + elif opt_type in ['song', 'album', 'artist']: + yt_category_id = CATEGORY_MUSIC + elif opt_type == 'trailer': + yt_category_id = CATEGORY_TRAILERS + elif opt_type == 'channel': + yt_type = opt_type + + yt_type = 'video' if yt_category_id or yt_video_type else yt_type + video_playlist = self.__api.search(q=title, + search_type=yt_type, + video_type=yt_video_type, + video_category_id=yt_category_id, + limit=SEARCH_LIMIT) + + self.__current_results = video_playlist + if len(video_playlist.items) == 0: + logger.info('Unable to find youtube media for: %s' % title) + return + logger.info( + 'Asked chromecast to play %i titles matching: %s on YouTube' % (len(video_playlist.items), title)) + play_command = utils.get_dict_val(options, 'play', 'play') + self.__play_videos(shuffle=(play_command == 'shuffle')) + + def __play_videos(self, shuffle=False, repeat=False): + playing = False + items = self.__current_results.items + if shuffle: + items = items.copy() + random.shuffle(items) + for video in items: + video_id = video.id.videoId + video_playlist_id = video.id.playlistId + if not playing: + self.clear_playlist() + self.play_video(video_id, video_playlist_id) + logger.debug('Currently playing: %s' % video_id) + playing = True + else: + self.add_to_queue(video_id) diff --git a/src/local/main.py b/src/local/main.py index 263d8ac..e23ad29 100755 --- a/src/local/main.py +++ b/src/local/main.py @@ -14,13 +14,17 @@ import os import sys +import signal import logging -from local.SkillSubscriber import Subscriber -from local.ChromecastSkill import Skill + +import pychromecast + +from local.skill_subscriber import Subscriber +from local.controllers.chromecast_controller import ChromecastController cwd = os.getcwd() -#Setup root logger to log to stdout and a file +# Setup root logger to log to stdout and a file formatter = logging.Formatter('%(asctime)s - %(name)s - %(levelname)s - %(message)s') handler = logging.StreamHandler(sys.stdout) handler.setFormatter(formatter) @@ -28,14 +32,35 @@ root_logger.setLevel(logging.INFO) root_logger.addHandler(handler) -handler = logging.handlers.TimedRotatingFileHandler(cwd+os.path.sep+'alexa-chromecast.log', when='D', interval=1, backupCount=5) +handler = logging.handlers.TimedRotatingFileHandler(cwd + os.path.sep + 'alexa-chromecast.log', when='D', interval=1, + backupCount=5) handler.setFormatter(formatter) root_logger.addHandler(handler) +# Logger set to allow changing to DEBUG when required +logger = logging.getLogger(pychromecast.__name__) +logger.setLevel(logging.INFO) + PORT = os.getenv('EXTERNAL_PORT') IP = os.getenv('EXTERNAL_IP') + +class Main(object): + + def __init__(self): + # Exit gracefully on docker/command-line stop + signal.signal(signal.SIGINT, self.shutdown) + signal.signal(signal.SIGTERM, self.shutdown) + self.chromecast_controller = ChromecastController() + self.subscriber = Subscriber({'chromecast': self.chromecast_controller}, IP, PORT) + self.subscriber.serve_forever() + + def shutdown(self, signum, frame): + root_logger.info('Shutdown in progress...') + self.chromecast_controller.shutdown(signum, frame) + self.subscriber.shutdown(signum, frame) + + if __name__ == "__main__": root_logger.info("Starting Alexa Chromecast listener...") - chromecast_skill = Skill() - Subscriber({'chromecast': chromecast_skill}, IP, PORT) + main = Main() diff --git a/src/local/moviedb_search.py b/src/local/moviedb_search.py deleted file mode 100644 index aec41bb..0000000 --- a/src/local/moviedb_search.py +++ /dev/null @@ -1,64 +0,0 @@ -import urllib -import json -import requests -import os -import logging - -MOVIEDB_API_KEY = os.getenv("MOVIEDB_API_KEY", False) -MOVIEDB_API_URI = "https://api.themoviedb.org/3" - -logger = logging.getLogger(__name__) -logger.setLevel(logging.DEBUG) -logger.propagate = False - -def moviedb_search_movies(movie): - if not MOVIEDB_API_KEY: - logger.error('You need to set a moviedb API key. e.g. export MOVIEDB_API_KEY=xxxxxx') - logger.error('You can request this at: %s' % MOVIEDB_API_URI) - raise Exception("No MovieDb API Key") - - query = { - "api_key": MOVIEDB_API_KEY, - "language": 'en-GB', - "query": movie, - "page": 1, - "include_adult": False - } - - uri = "{}/search/movie?{}".format(MOVIEDB_API_URI, urllib.parse.urlencode(query)) - - r = requests.get(uri) - response = r.json() - - if response["total_results"] > 0: - first_result = response["results"][0] - return first_result - else: - raise Exception("No Results") - -def moviedb_search_movie_videos(moviedb_id): - query = { - "api_key": MOVIEDB_API_KEY, - "language": 'en-GB' - } - url = "{}/movie/{}/videos?{}".format(MOVIEDB_API_URI, moviedb_id, urllib.parse.urlencode(query)) - r = requests.get(url) - response = r.json() - - print (url, response) - - try: - return response["results"][0]["key"] - except Exception as err: - logger.exception('Unexpected error parsing MovieDb response') - raise err - - -def get_movie_trailer_youtube_id(movie_name): - moviedb_movie = moviedb_search_movies(movie_name) - youtube_id = moviedb_search_movie_videos(moviedb_movie["id"]) - return { - "youtube_id": youtube_id, - "title": moviedb_movie["title"] - } - diff --git a/src/local/requirements.txt b/src/local/requirements.txt index 313b5eb..8e01362 100644 --- a/src/local/requirements.txt +++ b/src/local/requirements.txt @@ -1,6 +1,8 @@ boto3==1.14.8 miniupnpc>=2.0.1 -PyChromecast==6.0.0 +PyChromecast>=7.5.0 requests>=2.18.4 zeroconf>=0.19.1 -youtube-search>=1.1.0 +plexapi>=4.6.0 +python-youtube>=0.8.1 + diff --git a/src/local/skill_subscriber.py b/src/local/skill_subscriber.py new file mode 100644 index 0000000..c372664 --- /dev/null +++ b/src/local/skill_subscriber.py @@ -0,0 +1,235 @@ +import os +import sys +import time +import json +import threading +from http.server import HTTPServer, BaseHTTPRequestHandler +from typing import Optional + +from requests import get +import miniupnpc +import boto3 +import logging +from datetime import datetime + +logger = logging.getLogger(__name__) +logger.setLevel(logging.INFO) + +PING_SECS = 600 +UPNP_DISCOVERY_DELAY = 10 + + +class Subscriber(BaseHTTPRequestHandler): + """ + Generic Skill Subscription class to handle commands from an + Lambda Function via SNS notifications. + """ + + def __init__(self, skills, ip, port, topic_arn=os.getenv('AWS_SNS_TOPIC_ARN')): + self.last_ping_sent = False + self.last_ping_received: Optional[datetime] = None + self.ping_thread = threading.Thread(target=self.ping) + + self.token = "" + self.stopped = False + if port: + self.manual_port_forward = True + else: + self.manual_port_forward = False + try: + self.initialize_upnp() + except: + logger.exception( + 'Failed to configure UPnP. Please map port manually and pass PORT environment variable.') + sys.exit(1) + + self.sns_client = boto3.client('sns') + self.skills = skills + self.topic_arn = topic_arn + instance = self + + """ + HTTP Server implementation - receives messages from SNS + """ + + class SNSRequestHandler(BaseHTTPRequestHandler): + def do_POST(self): + self.send_response(200) + self.send_header('content-type', 'text/html') + self.end_headers() + raw_data = self.rfile.read( + int(self.headers['Content-Length'])) + data = json.loads(raw_data) + header_topic_arn = self.headers.get('X-Amz-Sns-Topic-Arn') + msg_type = data['Type'] + + if msg_type == 'SubscriptionConfirmation': + logger.info('Received subscription confirmation...') + token = data['Token'] + instance.confirm_subscription(header_topic_arn, token) + + elif msg_type == 'Notification' and data['Message']: + logger.info('Received message:') + logger.info(json.dumps(json.loads(data['Message']), indent=4, sort_keys=True)) + instance.dispatch_notification(json.loads(data['Message'])) + + def log_message(self, format, *args): + # Left in case required for debugging + pass + + self.server: HTTPServer = HTTPServer(('', int(port) if port else 0), SNSRequestHandler) + + port = self.server.server_port + if not ip: + ip = self.get_external_ip() + self.endpoint_url = 'http://{}:{}'.format(ip, port) + logger.info('Listening on {}'.format(self.endpoint_url)) + self.subscribe() + + def serve_forever(self): + try: + while not self.stopped: + # No timeout - so blocks while waiting for a request + self.server.handle_request() + except: + logger.exception('Unexpected error') + + def ping(self): + """ + Sends a simple ping message to SNS + """ + while not self.stopped: + if not self.last_ping_sent or (datetime.now() - self.last_ping_sent).total_seconds() > PING_SECS: + sns_client = boto3.client("sns") + response = sns_client.list_subscriptions_by_topic(TopicArn=self.topic_arn) + subscriptions = response['Subscriptions'] + if len(subscriptions) == 0: + logger.error('No clients are subscribed.') + else: + logger.info('%i clients are subscribed.' % len(subscriptions)) + + if (self.last_ping_received + and (datetime.now() - self.last_ping_received).total_seconds() > PING_SECS * 2): + logger.error(f'No ping received for {PING_SECS * 2} seconds. Restarting process...') + self.restart() + + logger.info('Sending ping...') + self.sns_client.publish(TopicArn=self.topic_arn, Message=json.dumps({'command': 'ping'})) + self.last_ping_sent = datetime.now() + else: + time.sleep(1) + + @staticmethod + def restart(): + os.execl(sys.executable, sys.executable, *['-m', 'local.main']) + + def shutdown(self, signum, frame): + """ + Performs a graceful shutdown stopping HTTP Server and Ping thread + """ + if self.stopped: return + self.stopped = True + logger.info('Shutting down HTTP listener') + self.unsubscribe() + self.server.shutdown() + self.ping_thread.join(5) + + def initialize_upnp(self): + upnp = miniupnpc.UPnP() + upnp.discoverdelay = UPNP_DISCOVERY_DELAY + upnp.discover() + upnp.selectigd() + self.upnp = upnp + + def get_external_ip(self): + return get('https://api.ipify.org').text + + def subscribe(self): + """ + Subscribes to receive message from SNS for the specified topic. + A subscription confirmation request should then be received from SNS. + """ + if not self.manual_port_forward: + try: + self.upnp.addportmapping( + self.server.server_port, + 'TCP', + self.upnp.lanaddr, + self.server.server_port, + '', + '' + ) + except: + logger.error('Failed to automatically forward port.') + logger.error('Please set port as an environment variable and forward manually.') + sys.exit(1) + + try: + logger.info("Subscribing for Alexa commands...") + self.sns_client.subscribe( + TopicArn=self.topic_arn, + Protocol='http', + Endpoint=self.endpoint_url + ) + + except: + logger.exception('SNS Topic ({}) is invalid. Please check in AWS.'.format(self.topic_arn)) + sys.exit(1) + + def confirm_subscription(self, topic_arn, token): + """ + Confirms a subscription based on the received subscription confirmation request from sNS + """ + + try: + self.sns_client.confirm_subscription( + TopicArn=topic_arn, + Token=token, + AuthenticateOnUnsubscribe="false") + logger.info('Subscribed.') + + # start ping + self.ping_thread.start() + + except: + logger.exception('Failed to confirm subscription. Please check in AWS.') + sys.exit(1) + + def unsubscribe(self): + """ + Unsubscribe from SNS Topic - stop receiving messages + """ + + if not self.manual_port_forward: + result = self.upnp.deleteportmapping(self.server.server_port, 'TCP') + + if result: + logger.debug('Removed forward for port {}.'.format(self.server.server_port)) + else: + raise RuntimeError( + 'Failed to remove port forward for {}.'.format(self.server.server_port)) + + subscription_arn = None + response = self.sns_client.list_subscriptions_by_topic(TopicArn=self.topic_arn) + for sub in response['Subscriptions']: + if sub['TopicArn'] == self.topic_arn and sub['Endpoint'] == self.endpoint_url: + subscription_arn = sub['SubscriptionArn'] + break + + if subscription_arn is not None and subscription_arn[:12] == 'arn:aws:sns:': + self.sns_client.unsubscribe(SubscriptionArn=subscription_arn) + sys.exit(0) + + def dispatch_notification(self, notification): + """ + Handle the notification + """ + try: + if notification['command'] == 'ping': + logger.info('Received ping.') + self.last_ping_received = datetime.now() + return + skill = self.skills.get(notification['handler_name']) + skill.handle_command(notification['room'], notification['command'], notification['data']) + except: + logger.exception('Unexpected error handling message') diff --git a/src/local/utils.py b/src/local/utils.py new file mode 100644 index 0000000..ec0a7e0 --- /dev/null +++ b/src/local/utils.py @@ -0,0 +1,31 @@ +import datetime + + +def get_iso_split(s, split): + if split in s: + n, s = s.split(split) + else: + n = 0 + return n, s + + +def parse_iso_duration(s): + # Remove prefix + s = s.split('P')[-1] + + # Step through letter dividers + days, s = get_iso_split(s, 'D') + _, s = get_iso_split(s, 'T') + hours, s = get_iso_split(s, 'H') + minutes, s = get_iso_split(s, 'M') + seconds, s = get_iso_split(s, 'S') + + # Convert all to seconds + dt = datetime.timedelta(days=int(days), hours=int(hours), minutes=int(minutes), seconds=int(seconds)) + return int(dt.total_seconds()) + + +def get_dict_val(items: dict, key, default=None): + if not items: + return default + return items[key] if key in items.keys() else default diff --git a/src/local/youtube.py b/src/local/youtube.py deleted file mode 100644 index 3aabace..0000000 --- a/src/local/youtube.py +++ /dev/null @@ -1,29 +0,0 @@ -import time -from youtube_search import YoutubeSearch - -def search(video_title): - attempts = 4 - results = [] - is_playlist = 'playlist' in video_title - - for _attempt in range(attempts): - #Not found - sometimes we get an empty list - so try again - results = YoutubeSearch(video_title, max_results=20) - if len(results.videos) > 0: - results = results.videos - break - time.sleep(2) - - for video in results: - if '&list' in video['id']: - vals = video['id'].split('&') - video['id'] = vals[0] - video['playlist_id'] = vals[1].replace('list=', '') - else: - video['playlist_id'] = None - - #Ok if the search was a playlist, or the the first result was a playlist just return this - if is_playlist or (len(results) > 0 and results[0]['playlist_id']): - results = next(([x] for x in results if x['playlist_id']), []) - - return results diff --git a/src/tests/alexa_voice_tests.txt b/src/tests/alexa_voice_tests.txt index bb1ce93..7e97304 100644 --- a/src/tests/alexa_voice_tests.txt +++ b/src/tests/alexa_voice_tests.txt @@ -1,18 +1,31 @@ -Alexa ask Chromecast to pause -Alexa ask Chromecast to play -Alexa ask Chromecast to pause in the media room -Alexa ask Chromecast to play in the media room -Alexa ask the Chromecast in the media room to pause -Alexa ask the Chromecast in the media room to play - -Alexa ask Chromecast to set volume to 5 - -Alexa ask Chromecast to stop -Alexa ask Chromecast to restart -Alexa ask Chromecast to reboot - -Alexa ask Chromecast to play songs by macklemore -Alexa ask Chromecast next -Alexa ask Chromecast previous - -Alexa ask Chromecast to play the trailer for the matrix +Alexa ask Chromecast to pause +Alexa ask Chromecast to play +Alexa ask Chromecast to pause in the media room +Alexa ask Chromecast to play in the media room +Alexa ask the Chromecast in the media room to pause +Alexa ask the Chromecast in the media room to play + +Alexa ask Chromecast to set volume to 5 + +Alexa ask Chromecast to stop +Alexa ask Chromecast to restart +Alexa ask Chromecast to reboot + +Alexa ask Chromecast to play songs by macklemore +Alexa ask Chromecast next +Alexa ask Chromecast previous + +play the trailer for the matrix + +#Plex +play the song the truth about love by pink +play the playlist greatest hits +play the album thriller +play the song thriller by michael jackson +play songs by pink +play the movie split +play the tv show mythic quest +play the tv series mythic quest +play the series mythic quest +play mythic quest on plex +play mythic quest in the living room diff --git a/src/tests/dummy_testenv b/src/tests/dummy_testenv new file mode 100644 index 0000000..af9dfa6 --- /dev/null +++ b/src/tests/dummy_testenv @@ -0,0 +1,13 @@ +################################################ +# PLEX Config - configure if you want to find and play items on plex +PLEX_IP_ADDRESS= +PLEX_PORT=32400 +# Refer here on steps to get your token +# https://support.plex.tv/articles/204059436-finding-an-authentication-token-x-plex-token/ +PLEX_TOKEN= +PLEX_SUBTITLE_LANG=eng + +# To get a key follow the instructions here: https://sns-sdks.lkhardy.cn/python-youtube/getting_started/ +YOUTUBE_API_KEY= +################################################ + diff --git a/src/tests/integration/__init__.py b/src/tests/integration/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/tests/integration/helpers.py b/src/tests/integration/helpers.py new file mode 100644 index 0000000..643e5e6 --- /dev/null +++ b/src/tests/integration/helpers.py @@ -0,0 +1,60 @@ +import logging +import time +from typing import Callable +from pychromecast.controllers.media import MEDIA_PLAYER_STATE_PLAYING +from tests import utils +import unittest + +# Test values +# TST_CHROMECAST_NAME = 'Media Room TV' +TST_CHROMECAST_NAME = 'Living Room TV' +TST_COMMAND_TIMEOUT = 60 + +logger = logging.getLogger(__name__) +logger.setLevel(logging.INFO) + + +class TestChromecast(unittest.TestCase): + + @classmethod + def setUpClass(cls) -> None: + utils.load_test_env() + from local.main import ChromecastController + cls.chromecast_controller = ChromecastController() + cls.cc_name = TST_CHROMECAST_NAME + cls.mc = cls.chromecast_controller.get_chromecast(cls.cc_name).media_controller + + @classmethod + def tearDownClass(cls) -> None: + cls.chromecast_controller.chromecast_collector.stop() + + def _wait_till_event(self, check: Callable): + logger.info(f'Waiting for event {check}...') + for _ in range(TST_COMMAND_TIMEOUT): + if check(): + logger.info('Event occurred.') + return + time.sleep(1) + logger.error('Timed out waiting for event.') + + def _wait_till_playing(self): + self._wait_till_event(lambda: self.mc.status.player_state == MEDIA_PLAYER_STATE_PLAYING) + self.assertTrue(self.mc.status.player_state == MEDIA_PLAYER_STATE_PLAYING) + + def _wait_till_paused(self): + self._wait_till_event(lambda: self.mc.is_paused) + self.assertTrue(self.mc.is_paused) + + def _wait_till_stopped(self): + self._wait_till_event(lambda: not self.mc.is_playing and not self.mc.is_paused) + self.assertTrue(not self.mc.is_playing and not self.mc.is_paused) + + def _command(self, command, params=None): + if not params: + params = {} + self.chromecast_controller.handle_command(self.cc_name, command, params) + time.sleep(5) + + def _stop(self): + self._command('stop') + self._wait_till_stopped() diff --git a/src/tests/integration/test_plex_find.py b/src/tests/integration/test_plex_find.py new file mode 100644 index 0000000..652c997 --- /dev/null +++ b/src/tests/integration/test_plex_find.py @@ -0,0 +1,112 @@ +import unittest +from unittest.mock import patch, Mock +from pychromecast.controllers.plex import PlexController + +from local.controllers.plex_controller import MyPlexController +from tests import utils +from tests.utils import patch_path + +# Test values, change to match your Plex library +TST_SONG_NAME = 'the truth about love' +TST_ARTIST_NAME = 'pink' +TST_ALBUM_NAME = 'bliss' + +TST_TV_SHOW_LARGE = 'doctor who' # Needs over 20 episodes, to test playlist creation +TST_TV_SHOW_LARGE_TITLE = 'the next doctor' + +TST_TV_SHOW_SMALL = 'firefly' # Needs less than 20 episodes, to test playlist creation +TST_TV_SHOW_SMALL_TITLE = 'heart of gold' +TST_TV_SHOW_SMALL_EPISODES = 14 + + +class TestPlexFind(unittest.TestCase): + + def setUp(self): + utils.load_test_env() + cc = Mock() + cc.uuid = 'test_cc_id' + self.pc = MyPlexController(cc) + + def test_find_song(self): + pc = self.pc + with patch(patch_path(PlexController.stop), Mock()): + with patch(patch_path(pc.show_media), Mock()): + pc.play_item({'play': 'find', 'type': 'song', 'title': TST_SONG_NAME}) + self.assertIsNotNone(pc.current_item) + self.assertEqual('track', pc.current_item.TYPE) + self.assertTrue(TST_SONG_NAME.lower() in pc.current_item.title.lower()) + + def test_find_artist(self): + pc = self.pc + with patch(patch_path(PlexController.stop), Mock()): + with patch(patch_path(pc.show_media), Mock()): + pc.play_item({'play': 'find', 'type': 'artist', 'title': TST_ARTIST_NAME}) + self.assertIsNotNone(pc.current_item) + self.assertEqual('artist', pc.current_item.TYPE) + self.assertTrue(TST_ARTIST_NAME.lower() in pc.current_item.title.lower()) + + def test_find_album(self): + pc = self.pc + with patch(patch_path(PlexController.stop), Mock()): + with patch(patch_path(pc.show_media), Mock()): + pc.play_item({'play': 'find', 'type': 'album', 'title': TST_ALBUM_NAME}) + self.assertIsNotNone(pc.current_item) + self.assertEqual('album', pc.current_item.TYPE) + self.assertTrue(TST_ALBUM_NAME.lower() in pc.current_item.title.lower()) + + def test_find_show(self): + pc = self.pc + with patch(patch_path(PlexController.stop), Mock()): + with patch(patch_path(pc.show_media), Mock()): + pc.play_item({'play': 'find', 'type': 'show', 'title': TST_TV_SHOW_LARGE}) + self.assertIsNotNone(pc.current_item) + self.assertEqual('show', pc.current_item.TYPE) + self.assertTrue(TST_TV_SHOW_LARGE.lower() in pc.current_item.title.lower()) + playlist = pc.build_play_list() + self.assertEqual(21, playlist.playQueueTotalCount) + + def test_find_show2(self): + pc = self.pc + with patch(patch_path(PlexController.stop), Mock()): + with patch(patch_path(pc.show_media), Mock()): + pc.play_item({'play': 'find', 'type': 'show', 'title': TST_TV_SHOW_SMALL}) + self.assertIsNotNone(pc.current_item) + self.assertEqual('show', pc.current_item.TYPE) + self.assertTrue(TST_TV_SHOW_SMALL.lower() in pc.current_item.title.lower()) + playlist = pc.build_play_list() + self.assertEqual(TST_TV_SHOW_SMALL_EPISODES, playlist.playQueueTotalCount) + + def test_find_episode(self): + pc = self.pc + with patch(patch_path(PlexController.stop), Mock()): + with patch(patch_path(pc.show_media), Mock()): + pc.play_item({'play': 'find', 'type': 'episode', 'title': TST_TV_SHOW_LARGE_TITLE, 'tvshow': TST_TV_SHOW_LARGE}) + self.assertIsNotNone(pc.current_item) + self.assertEqual('episode', pc.current_item.TYPE) + self.assertTrue(TST_TV_SHOW_LARGE_TITLE.lower() in pc.current_item.title.lower()) + playlist = pc.build_play_list() + self.assertEqual(21, playlist.playQueueTotalCount) + + def test_find_episode2(self): + pc = self.pc + with patch(patch_path(PlexController.stop), Mock()): + with patch(patch_path(pc.show_media), Mock()): + pc.play_item({'play': 'find', 'type': 'episode', 'title': TST_TV_SHOW_SMALL_TITLE, 'tvshow': TST_TV_SHOW_SMALL}) + self.assertIsNotNone(pc.current_item) + self.assertEqual('episode', pc.current_item.TYPE) + self.assertTrue(TST_TV_SHOW_SMALL_TITLE.lower() in pc.current_item.title.lower()) + playlist = pc.build_play_list() + self.assertEqual(TST_TV_SHOW_SMALL_EPISODES, playlist.playQueueTotalCount) + + def test_find_photo(self): + pc = self.pc + with patch(patch_path(PlexController.stop), Mock()): + with patch(patch_path(pc.block_until_playing), Mock()): + pc.play_photos({'type': 'photo', 'month': 'august', 'year': 2012}) + self.assertIsNotNone(pc.current_item) + + pc.play_photos({'type': 'photo', 'year': 2012}) + self.assertIsNotNone(pc.current_item) + + pc.play_photos({'type': 'photo', 'title': '2017 school camp'}) + self.assertIsNotNone(pc.current_item) diff --git a/src/tests/integration/test_plex_play.py b/src/tests/integration/test_plex_play.py new file mode 100644 index 0000000..14a6e70 --- /dev/null +++ b/src/tests/integration/test_plex_play.py @@ -0,0 +1,433 @@ +import time + +from local.controllers.chromecast_controller import APP_PLEX_ID, APP_YOUTUBE_ID +from local.controllers.plex_controller import MyPlexController +from tests.integration.helpers import TestChromecast + +# Test values, change to match your Plex library +TST_MOVIE_NAME = 'guardians of the galaxy' # Needs to have subtitles and at least 2 audio streams +TST_ARTIST_NAME = 'pink' +TST_SHOW_NAME = 'mythic quest' +TST_EPISODE_TITLE = 'breaking brad' # Needs to be the name or part of a name of one episode + +TST_EPISODE_SEASON = 2 # Provide a season number and episode number for a particular episode +TST_EPISODE_NUMBER = 4 + + +class TestPlexCommands(TestChromecast): + + def get_playing_bitrate(self, pc: MyPlexController): + # On occasion bitrate is not found + bitrate = None + for _ in range(10): + bitrate = pc.status.media_custom_data['bitrate'] if 'bitrate' in pc.status.media_custom_data else None + if bitrate: + break + time.sleep(1) + return bitrate + + def test_open(self): + cast = self.chromecast_controller.get_chromecast(self.cc_name).cast + + self._command('open', {'app': 'plex'}) + self._wait_till_event(lambda: cast.app_id == APP_PLEX_ID) + self.assertEqual(APP_PLEX_ID, cast.app_id) + + self._command('open', {'app': 'youtube'}) + self._wait_till_event(lambda: cast.app_id == APP_YOUTUBE_ID) + self.assertEqual(APP_YOUTUBE_ID, cast.app_id) + + def test_play_movie(self): + cc = self.chromecast_controller.get_chromecast(self.cc_name) + pc = cc.plex_controller + try: + self._command('play_media', + {'play': 'find', 'title': TST_MOVIE_NAME, 'app': 'plex'}) + + item = pc.current_item + self.assertEqual('movie', item.TYPE) + self.assertTrue(TST_MOVIE_NAME.lower() in item.title.lower()) + self._command('play') + self._wait_till_playing() + + current_time = pc.status.current_time + self._command('restart') + self._wait_till_playing() + self._wait_till_event(lambda: pc.status.current_time < 10) + + # Test Pause + self.assertFalse(pc.status.player_is_paused) + current_time = pc.status.current_time + self._command('pause') + self._wait_till_paused() + + self._command('play') + self._wait_till_playing() + self.assertLessEqual(current_time, pc.status.current_time) + + # Test Fast Forward worked + current_time = pc.status.current_time + self._command('fast_forward', {'duration': 'PT2M'}) + self._wait_till_event(lambda: pc.status.current_time > current_time + 30) + self.assertGreater(pc.status.current_time, current_time + 30) + + # Test Rewind worked + current_time = pc.status.current_time + self._command('rewind', {'duration': 'PT1M'}) + self._wait_till_event(lambda: pc.status.current_time < current_time - 30) + self.assertLess(pc.status.current_time, current_time - 30) + + # Turn on subtitles + self.assertFalse(next((sub for sub in pc.status.current_subtitle_tracks if sub.selected), False)) + self._command('subtitle_on') + self.assertTrue(pc.status.media_custom_data['subtitleStreamID'] != '0') + + # Turn off subtitles + self._command('subtitle_off') + self.assertTrue(pc.status.media_custom_data['subtitleStreamID'] == '0') + + # Change audio stream + current_audio = pc.status.media_custom_data['audioStreamID'] + self._command('change-audio') + self._wait_till_event(lambda: current_audio != pc.status.media_custom_data['audioStreamID']) + self.assertNotEqual(current_audio, pc.status.media_custom_data['audioStreamID']) + + # Play for 20 seconds + time.sleep(20) + + # Test resume + self._stop() + self._command('play') + self._wait_till_playing() + self.assertLess(20, pc.status.current_time) + time.sleep(10) + + finally: + self._stop() + + def test_play_artist(self): + cc = self.chromecast_controller.get_chromecast(self.cc_name) + pc = cc.plex_controller + try: + self._command('play_media', + {'play': 'find', 'type': 'artist', 'title': TST_ARTIST_NAME, + 'app': 'plex'}) + item = pc.current_item + self.assertEqual('artist', item.TYPE) + self.assertTrue(TST_ARTIST_NAME.lower() in item.title.lower()) + self.assertFalse(pc.status.player_is_playing) + + self._command('play') + self._wait_till_playing() + self.assertTrue(pc.status.player_is_playing) + + self._command('set_volume', {'volume': 5}) + self.assertEqual(0.5, cc.cast.status.volume_level) + + self._command('set_volume', {'raise_lower': 'up'}) + self.assertEqual(0.6, round(cc.cast.status.volume_level * 10) / 10) + + self._command('set_volume', {'raise_lower': 'down'}) + self.assertEqual(0.5, round(cc.cast.status.volume_level * 10) / 10) + + self.assertFalse(cc.cast.status.volume_muted) + self._command('mute') + self.assertTrue(cc.cast.status.volume_muted) + + self._command('unmute') + self.assertFalse(cc.cast.status.volume_muted) + + current_content_id = pc.status.content_id + + self._command('shuffle_on') + # There is a risk of collision on this test, it may randomly choose the first item... + self._wait_till_playing() + self.assertNotEqual(current_content_id, pc.status.content_id) + + self._command('shuffle_off') + self._wait_till_playing() + self.assertEqual(current_content_id, pc.status.content_id) + + current_content_id = self.mc.status.content_id + self._command('play_next') + self._wait_till_event(lambda: current_content_id != self.mc.status.content_id) + self.assertNotEqual(current_content_id, self.mc.status.content_id) + + current_content_id = self.mc.status.content_id + self._command('play_previous') + self._wait_till_event(lambda: current_content_id != self.mc.status.content_id) + self.assertNotEqual(current_content_id, self.mc.status.content_id) + finally: + self._stop() + + def _wait_till_episode(self, episode): + cc = self.chromecast_controller.get_chromecast(self.cc_name) + pc = cc.plex_controller + self._wait_till_event(lambda: pc.status.episode != episode) + self.assertNotEqual(episode, pc.status.episode) + + def _wait_till_bitrate(self, bitrate): + cc = self.chromecast_controller.get_chromecast(self.cc_name) + pc = cc.plex_controller + self._wait_till_event(lambda: bitrate != self.get_playing_bitrate(pc)) + self.assertNotEqual(bitrate, self.get_playing_bitrate(pc)) + + def test_play_show(self): + cc = self.chromecast_controller.get_chromecast(self.cc_name) + pc = cc.plex_controller + try: + self._command('play_media', + {'play': 'find', 'type': 'show', 'title': TST_SHOW_NAME, + 'app': 'plex'}) + item = pc.current_item + self.assertEqual('show', item.TYPE) + self.assertTrue(TST_SHOW_NAME.lower() in item.title.lower()) + + self.assertFalse(pc.status.player_is_playing) + self._command('play') + self._wait_till_playing() + self.assertTrue(pc.status.player_is_playing) + time.sleep(10) + + # Test play previous episode + current_episode = pc.status.episode + self._command('play_previous') + self._wait_till_episode(current_episode) + self.assertEqual(current_episode - 1, pc.status.episode) + + # Test play next episode + self._wait_till_playing() + current_episode = pc.status.episode + self._command('play_next') + self._wait_till_episode(current_episode) + self.assertEqual(current_episode + 1, pc.status.episode) + finally: + self._stop() + + def test_play_episode_by_title(self): + cc = self.chromecast_controller.get_chromecast(self.cc_name) + pc = cc.plex_controller + try: + # Retrieve by title + self._command('play_media', + {'play': 'find', 'type': 'episode', 'tvshow': TST_SHOW_NAME, + 'title': TST_EPISODE_TITLE, 'app': 'plex'}) + item = pc.current_item + self.assertEqual('episode', item.TYPE) + self.assertTrue(TST_EPISODE_TITLE.lower() in item.title.lower()) + self.assertTrue(TST_SHOW_NAME.lower() in item.show().title.lower()) + + self.assertFalse(pc.status.player_is_playing) + self._command('play') + self._wait_till_playing() + self.assertTrue(pc.status.player_is_playing) + + # Test play previous episode + current_episode = pc.status.episode + self._command('play_previous') + self._wait_till_episode(current_episode) + self.assertEqual(current_episode - 1, pc.status.episode) + + # Test play next episode + current_episode = pc.status.episode + self._command('play_next') + self._wait_till_episode(current_episode) + self.assertEqual(current_episode + 1, pc.status.episode) + + finally: + self._stop() + + def test_play_episode_by_number(self): + cc = self.chromecast_controller.get_chromecast(self.cc_name) + pc = cc.plex_controller + try: + # Retrieve by season and episode + self._command('play_media', + {'play': 'find', 'type': 'episode', 'seasnum': TST_EPISODE_SEASON, + 'epnum': TST_EPISODE_NUMBER, 'tvshow': TST_SHOW_NAME, + 'app': 'plex'}) + + item = pc.current_item + self.assertEqual('episode', item.TYPE) + self.assertEqual(TST_EPISODE_SEASON, item.seasonNumber) + self.assertEqual(TST_EPISODE_NUMBER, item.episodeNumber) + self.assertTrue(TST_SHOW_NAME.lower() in item.show().title.lower()) + + self.assertFalse(pc.status.player_is_playing) + self._command('play') + self._wait_till_playing() + self.assertTrue(pc.status.player_is_playing) + + # Test play previous episode + current_episode = pc.status.episode + self._command('play_previous') + self._wait_till_episode(current_episode) + self.assertEqual(current_episode - 1, pc.status.episode) + + # Test play next episode + current_episode = pc.status.episode + self._command('play_next') + self._wait_till_episode(current_episode) + self.assertEqual(current_episode + 1, pc.status.episode) + + finally: + self._stop() + + def test_play_first_episode(self): + cc = self.chromecast_controller.get_chromecast(self.cc_name) + pc = cc.plex_controller + try: + # Retrieve by season and episode + self._command('play_media', + {'play': 'find', 'type': 'episode', 'seasnum': 1, + 'epnum': 1, 'tvshow': TST_SHOW_NAME, + 'app': 'plex'}) + + item = pc.current_item + self.assertEqual('episode', item.TYPE) + self.assertEqual(1, item.seasonNumber) + self.assertEqual(1, item.episodeNumber) + self.assertTrue(TST_SHOW_NAME.lower() in item.show().title.lower()) + + self.assertFalse(pc.status.player_is_playing) + self._command('play') + self._wait_till_playing() + self.assertTrue(pc.status.player_is_playing) + + # Test play previous episode + self._command('play_previous') + time.sleep(20) + self.assertEqual(1, pc.status.episode) + + # Test play next episode + current_episode = pc.status.episode + self._command('play_next') + self._wait_till_episode(current_episode) + self.assertEqual(current_episode + 1, pc.status.episode) + + finally: + self._stop() + + def test_play_season(self): + cc = self.chromecast_controller.get_chromecast(self.cc_name) + pc = cc.plex_controller + try: + # Retrieve by season and episode + self._command('play_media', + {'play': 'find', 'type': 'episode', 'seasnum': 1, + 'tvshow': TST_SHOW_NAME, + 'app': 'plex'}) + + item = pc.current_item + self.assertEqual('season', item.TYPE) + self.assertEqual(1, item.seasonNumber) + self.assertTrue(TST_SHOW_NAME.lower() in item.show().title.lower()) + + self.assertFalse(pc.status.player_is_playing) + self._command('play') + self._wait_till_playing() + self.assertTrue(pc.status.player_is_playing) + self.assertEqual(1, pc.status.episode) + + # Test play previous episode + self._command('play_previous') + time.sleep(20) + self.assertEqual(1, pc.status.episode) + + # Test play next episode + current_episode = pc.status.episode + self._command('play_next') + self._wait_till_episode(current_episode) + self.assertEqual(current_episode + 1, pc.status.episode) + + finally: + self._stop() + + def test_transcode(self): + cc = self.chromecast_controller.get_chromecast(self.cc_name) + pc = cc.plex_controller + try: + self._command('play_media', + {'play': 'play', 'title': 'mythic quest', 'app': 'plex'}) + self._wait_till_playing() + + # Maximum quality + self._command('transcode', + {'quality': 'maximum'}) + self.assertEqual(0, self.get_playing_bitrate(pc)) + + # Medium quality + current_bitrate = self.get_playing_bitrate(pc) + self._command('transcode', + {'quality': 'medium'}) + self._wait_till_bitrate(current_bitrate) + self.assertEqual(4000, self.get_playing_bitrate(pc)) + + # Reduce quality + current_bitrate = self.get_playing_bitrate(pc) + self._command('transcode', + {'raise_lower': 'down'}) + self._wait_till_bitrate(current_bitrate) + self.assertEqual(3000, self.get_playing_bitrate(pc)) + + # Increase quality + current_bitrate = self.get_playing_bitrate(pc) + self._command('transcode', + {'raise_lower': 'up'}) + self._wait_till_bitrate(current_bitrate) + self.assertEqual(4000, self.get_playing_bitrate(pc)) + + # Maximum quality + current_bitrate = self.get_playing_bitrate(pc) + self._command('transcode', + {'quality': 'maximum'}) + self._wait_till_bitrate(current_bitrate) + self.assertEqual(0, self.get_playing_bitrate(pc)) + + # Increase quality from maximum + self._command('transcode', + {'raise_lower': 'up'}) + time.sleep(20) + self.assertEqual(0, self.get_playing_bitrate(pc)) + + # Reduce quality from maximum + current_bitrate = self.get_playing_bitrate(pc) + self._command('transcode', + {'raise_lower': 'down'}) + self._wait_till_bitrate(current_bitrate) + self.assertEqual(8000, self.get_playing_bitrate(pc)) + + finally: + self._stop() + + def test_play_photos(self): + cc = self.chromecast_controller.get_chromecast(self.cc_name) + pc = cc.plex_controller + try: + self._command('open', {'app': 'plex'}) + + self._command('play_photos', + {'play': 'play', 'year': 2012}) + self.assertIsNotNone(pc.current_item) + self.assertFalse(pc.is_shuffle_on()) + + # Shuffle play queue + self._command('shuffle-on') + self._wait_till_playing() + self.assertTrue(pc.is_shuffle_on()) + + # Unshuffle play queue + self._command('shuffle-off') + self._wait_till_playing() + self.assertFalse(pc.is_shuffle_on()) + + # Play shuffled + self._command('play_photos', + {'play': 'shuffle', 'year': 2012}) + self._wait_till_playing() + self.assertIsNotNone(pc.current_item) + self.assertTrue(pc.is_shuffle_on()) + + finally: + self._stop() + diff --git a/src/tests/integration/test_stan.py b/src/tests/integration/test_stan.py new file mode 100644 index 0000000..2ef39ad --- /dev/null +++ b/src/tests/integration/test_stan.py @@ -0,0 +1,31 @@ +import logging +import time +import unittest + +from local.controllers.stan_controller import StanController +from tests.integration.helpers import TestChromecast + + +class TestStan(TestChromecast): + + def test_stan(self): + cc = self.chromecast_controller.get_chromecast(self.cc_name) + try: + stan_cc = StanController() + stan_cc.logger.setLevel(logging.DEBUG) + + cc.cast.register_handler(stan_cc) + cc.media_controller.register_status_listener(stan_cc) + cc.cast.register_status_listener(stan_cc) + + stan_cc.launch() + time.sleep(20) + stan_cc.play_media() + time.sleep(20) + print(cc.media_controller.status) + finally: + self._stop() + + +if __name__ == '__main__': + unittest.main() diff --git a/src/tests/integration/test_youtube.py b/src/tests/integration/test_youtube.py new file mode 100644 index 0000000..0601bb9 --- /dev/null +++ b/src/tests/integration/test_youtube.py @@ -0,0 +1,120 @@ +import time +import unittest + +from tests.integration.helpers import TestChromecast + + +class TestYoutube(TestChromecast): + + def test_playlist(self): + try: + self.assertFalse(self.mc.is_playing) + self._command('play_media', + {'play': 'find', + 'title': '90s hits', + 'type': 'playlist', + 'app': 'youtube'}) + self._wait_till_playing() + + # Test next + current_content_id = self.mc.status.content_id + self._command('play_next', {}) + self._wait_till_event(lambda: current_content_id != self.mc.status.content_id) + self.assertNotEqual(current_content_id, self.mc.status.content_id) + + # Test previous + self._wait_till_playing() + new_content_id = self.mc.status.content_id + self._command('play_previous', {}) + self._wait_till_event(lambda: new_content_id != self.mc.status.content_id) + self.assertEqual(current_content_id, self.mc.status.content_id) + + # Test pause + self._wait_till_playing() + self.assertFalse(self.mc.is_paused) + self._command('pause', {}) + self._wait_till_paused() + + # Test play + self._command('play', {}) + self._wait_till_playing() + + # Test Fast Forward + current_time = self.mc.status.current_time + self._command('fast_forward', {'duration': 'PT1M'}) + self._wait_till_event(lambda: self.mc.status.current_time > current_time + 30) + self.assertGreater(self.mc.status.current_time, current_time + 30) + + # Test Rewind + current_time = self.mc.status.current_time + self._command('rewind', {'duration': 'PT1M'}) + self._wait_till_event(lambda: self.mc.status.current_time < current_time - 30) + self.assertLess(self.mc.status.current_time, current_time - 30) + + finally: + self._stop() + + def test_play_trailer(self): + try: + self.assertFalse(self.mc.is_playing) + self._command('play_media', { + 'title': 'The Matrix', + 'type': 'trailer', + 'app': 'youtube'}) + self._wait_till_playing() + + finally: + self._stop() + + def test_search(self): + try: + self.assertFalse(self.mc.is_playing) + self._command('play_media', {'play': 'find', + 'title': 'macklemore', + 'app': 'youtube'}) + self._wait_till_playing() + + # Test next + current_content_id = self.mc.status.content_id + self._command('play_next') + self._wait_till_event(lambda: current_content_id != self.mc.status.content_id) + self.assertNotEqual(current_content_id, self.mc.status.content_id) + + # Test previous + self._wait_till_playing() + new_content_id = self.mc.status.content_id + self._command('play_previous') + self._wait_till_event(lambda: new_content_id != self.mc.status.content_id) + self.assertEqual(current_content_id, self.mc.status.content_id) + + # Test pause + self._wait_till_playing() + self.assertFalse(self.mc.is_paused) + self._command('pause') + self._wait_till_paused() + + finally: + self._stop() + + def test_shuffle(self): + try: + self.assertFalse(self.mc.is_playing) + self._command('play_media', {'play': 'find', + 'title': 'macklemore', + 'app': 'youtube'}) + self._wait_till_playing() + current_content_id = self.mc.status.content_id + + self._command('shuffle_on') + self._wait_till_event(lambda: current_content_id != self.mc.status.content_id) + self.assertNotEqual(current_content_id, self.mc.status.content_id) + + self._command('shuffle_off') + self._wait_till_event(lambda: current_content_id == self.mc.status.content_id) + self.assertEqual(current_content_id, self.mc.status.content_id) + finally: + self._stop() + + +if __name__ == '__main__': + unittest.main() diff --git a/src/tests/test_lambda.py b/src/tests/test_lambda.py deleted file mode 100644 index 8e8371b..0000000 --- a/src/tests/test_lambda.py +++ /dev/null @@ -1,58 +0,0 @@ -import unittest -import pychromecast -from mock import Mock -from dotenv import load_dotenv -from os.path import join, dirname -import sys -import os -sys.path.append(os.path.dirname(os.path.realpath(__file__)) + "/../../src") - -class SlotValue: - def __init__(self, value, resolutions=False): - self.value = value - self.resolutions = resolutions - -class MockResponseBuilder: - - def __init__(self): - self.ask_text = '' - self.speak_text = '' - self.card = None - - def speak(self, text): - self.speak_text = text - return self - - def ask(self, text): - self.speak_text = text - return self - - def set_card(self, card): - self.card = card - return self - - @property - def response(self): - return self - -class TestChromecast(unittest.TestCase): - - def setUp(self): - dotenv_path = join(dirname(__file__), '.testenv') - # Load file from the path. - load_dotenv(dotenv_path) - - def test_play_trailer(self): - from lambda_function.main import PlayTrailerIntentHandler - req = PlayTrailerIntentHandler() - req.publish_command_to_sns = Mock() - - #Mock handler inputs - handler_input = Mock() - handler_input.response_builder = MockResponseBuilder() - handler_input.request_envelope.request.intent.slots = { - 'movie': SlotValue('The Matrix'), - 'room': SlotValue('Media Room') - } - response = req.handle(handler_input) - self.assertTrue('playing' in response.speak_text.lower()) diff --git a/src/tests/test_local.py b/src/tests/test_local.py deleted file mode 100644 index 8bea685..0000000 --- a/src/tests/test_local.py +++ /dev/null @@ -1,47 +0,0 @@ -import unittest -import pychromecast -from mock import Mock -from dotenv import load_dotenv -from os.path import join, dirname -import sys -import os -import time -sys.path.append(os.path.dirname(os.path.realpath(__file__)) + "/../../src") - -class TestLocal(unittest.TestCase): - - def setUp(self): - dotenv_path = join(dirname(__file__), '.testenv') - # Load file from the path. - load_dotenv(dotenv_path) - from local.main import Skill - self.skill = Skill() - self.cc_name = 'Living Room' - - def tearDown(self): - self.skill.chromecast_controller.stop() - - def test_play_trailer(self): - self.skill.handle_command(self.cc_name, 'play_trailer', {'title': 'The Matrix'}) - - def test_play_on_app(self): - self.skill.handle_command(self.cc_name, 'play_video', {'title': 'songs by Macklemore', 'app': 'youtube'}) - for _loops in range(5): - time.sleep(60) - - def test_playlist(self): - self.skill.handle_command(self.cc_name, 'play_video', {'title': 'macklemore playlist', 'app': 'youtube'}) - time.sleep(20) - self.skill.handle_command(self.cc_name, 'play_next', {}) - time.sleep(20) - self.skill.handle_command(self.cc_name, 'play_previous', {}) - time.sleep(60) - - def test_play_next(self): - self.skill.handle_command(self.cc_name, 'play_next', {}) - - def test_pause(self): - self.skill.handle_command(self.cc_name, 'pause', {}) - - def test_play_previous(self): - self.skill.handle_command(self.cc_name, 'play_previous', {}) diff --git a/src/tests/unit/__init__.py b/src/tests/unit/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/tests/unit/lambda_function/__init__.py b/src/tests/unit/lambda_function/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/tests/unit/lambda_function/lang/__init__.py b/src/tests/unit/lambda_function/lang/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/tests/unit/lambda_function/lang/test_lang.py b/src/tests/unit/lambda_function/lang/test_lang.py new file mode 100644 index 0000000..f761760 --- /dev/null +++ b/src/tests/unit/lambda_function/lang/test_lang.py @@ -0,0 +1,21 @@ +import unittest + +from lambda_function.lang.language import Language, Key + + +class TestLanguage(unittest.TestCase): + + def test_en_au(self): + language = Language('en-AU') + self.assertEqual('Ok', language.get(Key.Ok)) + self.assertEqual('en-AU', language.locale) + + def test_en_unknown(self): + # Defaults to en-AU + language = Language('UNKNOWN') + self.assertEqual('Ok', language.get(Key.Ok)) + self.assertEqual('UNKNOWN', language.locale) + + +if __name__ == '__main__': + unittest.main() diff --git a/src/tests/unit/lambda_function/test_main.py b/src/tests/unit/lambda_function/test_main.py new file mode 100644 index 0000000..b892fbf --- /dev/null +++ b/src/tests/unit/lambda_function/test_main.py @@ -0,0 +1,325 @@ +import unittest +from typing import Optional, Dict +from unittest.mock import Mock, patch + +from ask_sdk_model import Response + +from lambda_function import utils +from lambda_function.lang.language import Language, Key +from tests.utils import patch_path + + +class SlotValue: + def __init__(self, value, resolutions=False): + self.value = value + self.resolutions = resolutions + + +class MockResponseBuilder(Response): + + def __init__(self): + self.ask_text = '' + self.speak_text = '' + self.card = None + + def speak(self, text): + self.speak_text = text + return self + + def ask(self, text): + self.ask_text = text + return self + + def set_card(self, card): + self.card = card + return self + + @property + def response(self): + return self + + +class TestMain(unittest.TestCase): + + @staticmethod + def get_persistent_session_attribute(handler_input, name, default): + return 'test room' + + @staticmethod + def set_persistent_session_attribute(handler_input, name, value): + pass + + def setUp(self): + self.handler_input = Mock() + self.handler_input.response_builder = MockResponseBuilder() + self.handler_input.request_envelope.context.system.device.device_id = 'test_device_id' + self.handler_input.request_envelope.request.locale = 'en-AU' + self.language = Language('en-AU') + + @staticmethod + def __slot_to_dict(slot_values: Dict[str, SlotValue]): + return {key: slot.value for key, slot in slot_values.items()} + + def __test_dict_values(self, data, values): + self.assertEqual(len(values), len(data)) + for key, value in values.items(): + self.assertEqual(value, data[key]) + + def __test_speech_values(self, output, strings): + for a_string in strings: + self.assertTrue(a_string in output) + + def test_no_room(self): + from lambda_function.main import PlayPhotosIntentHandler + req = PlayPhotosIntentHandler() + req.publish_command_to_sns = Mock() + + # Mock handler inputs + self.handler_input.request_envelope.request.intent.slots = { + 'play': SlotValue('play'), + 'year': SlotValue('2012') + } + with patch(patch_path(utils.get_persistent_session_attribute), Mock(return_value=False)): + resp: Optional[MockResponseBuilder] = req.handle(self.handler_input) + self.assertTrue(self.language.get(Key.SetTheRoom) in resp.speak_text) + + def test_set_room(self): + from lambda_function.main import SetRoomIntentHandler + req = SetRoomIntentHandler() + req.publish_command_to_sns = Mock() + + # Mock handler inputs + self.handler_input.request_envelope.request.intent.slots = { + 'room': SlotValue('media room') + } + with patch(patch_path(utils.get_persistent_session_attribute), Mock(return_value='media room')): + with patch(patch_path(utils.set_persistent_session_attribute), Mock()): + resp: Optional[MockResponseBuilder] = req.handle(self.handler_input) + self.assertTrue(self.language.get(Key.ControlRoom, room='media room') in resp.speak_text) + + def test_photos_play_year(self): + from lambda_function.main import PlayPhotosIntentHandler + req = PlayPhotosIntentHandler() + slot_values = { + 'play': SlotValue('play'), + 'year': SlotValue('2012') + } + resp = self.__test_values_passed(req, slot_values, self.__slot_to_dict(slot_values)) + self.assertEqual(self.language.get(Key.PlayPhotosByYear, play='Playing', year='2012'), resp.speak_text) + + def test_photos_play_month_year(self): + from lambda_function.main import PlayPhotosIntentHandler + req = PlayPhotosIntentHandler() + slot_values = { + 'play': SlotValue('play'), + 'month': SlotValue('august'), + 'year': SlotValue('2012') + } + resp = self.__test_values_passed(req, slot_values, self.__slot_to_dict(slot_values)) + self.assertEqual(self.language.get(Key.PlayPhotosByDate, play='Playing', year='2012', month='august'), + resp.speak_text) + + def test_photos_play_bad_month_year(self): + from lambda_function.main import PlayPhotosIntentHandler + req = PlayPhotosIntentHandler() + slot_values = { + 'play': SlotValue('play'), + 'month': SlotValue('christmas'), + 'year': SlotValue('2012') + } + passed_values = self.__slot_to_dict(slot_values) + del passed_values['month'] + passed_values['title'] = 'christmas' + resp = self.__test_values_passed(req, slot_values, passed_values) + self.assertEqual(self.language.get(Key.PlayPhotosByEvent, play='Playing', year='2012', title='christmas'), + resp.speak_text) + + def test_photos_play_title(self): + from lambda_function.main import PlayPhotosIntentHandler + req = PlayPhotosIntentHandler() + slot_values = { + 'play': SlotValue('play'), + 'title': SlotValue('christmas') + } + resp = self.__test_values_passed(req, slot_values, self.__slot_to_dict(slot_values)) + self.assertEqual(self.language.get(Key.PlayPhotosByTitle, play='Playing', title='christmas'), resp.speak_text) + + def test_media_play_movie(self): + from lambda_function.main import PlayMediaIntentHandler + req = PlayMediaIntentHandler() + slot_values = { + 'play': SlotValue('play'), + 'type': SlotValue('movie'), + 'title': SlotValue('the matrix') + } + resp = self.__test_values_passed(req, slot_values, self.__slot_to_dict(slot_values)) + self.assertEqual(self.language.get(Key.PlayMovie, play='Playing', title='the matrix'), resp.speak_text) + + def test_media_play_show(self): + from lambda_function.main import PlayMediaIntentHandler + req = PlayMediaIntentHandler() + slot_values = { + 'play': SlotValue('play'), + 'type': SlotValue('show'), + 'title': SlotValue('mythic quest') + } + resp = self.__test_values_passed(req, slot_values, self.__slot_to_dict(slot_values)) + self.assertEqual(self.language.get(Key.PlayShow, play='Playing', show='mythic quest'), resp.speak_text) + + def test_media_play_episode(self): + from lambda_function.main import PlayMediaIntentHandler + req = PlayMediaIntentHandler() + slot_values = { + 'play': SlotValue('play'), + 'type': SlotValue('episode'), + 'title': SlotValue('breaking brad'), + 'tvshow': SlotValue('mythic quest') + } + resp = self.__test_values_passed(req, slot_values, self.__slot_to_dict(slot_values)) + self.assertEqual(self.language.get(Key.PlayEpisode, play='Playing', title='breaking brad', show='mythic quest'), + resp.speak_text) + + def test_media_play_episode_mix(self): + from lambda_function.main import PlayMediaIntentHandler + req = PlayMediaIntentHandler() + slot_values = { + 'play': SlotValue('play'), + 'type': SlotValue('episode'), + 'title': SlotValue('season one episode twenty three'), + 'tvshow': SlotValue('mythic quest') + } + passed_values = self.__slot_to_dict(slot_values) + del passed_values['title'] + passed_values['epnum'] = '23' + passed_values['seasnum'] = '1' + resp = self.__test_values_passed(req, slot_values, passed_values) + self.assertEqual(self.language.get(Key.PlayEpisodeNumber, play='Playing', episode='23', season='1', + show='mythic quest'), resp.speak_text) + + def test_media_play_season(self): + from lambda_function.main import PlayEpisodeIntentHandler + req = PlayEpisodeIntentHandler() + slot_values = { + 'play': SlotValue('play'), + 'type': SlotValue('episode'), + 'seasnum': SlotValue('1'), + 'tvshow': SlotValue('mythic quest') + } + resp = self.__test_values_passed(req, slot_values, self.__slot_to_dict(slot_values)) + self.assertEqual(self.language.get(Key.PlaySeason, play='Playing', season='1', + show='mythic quest'), resp.speak_text) + + def test_media_play_episode_number(self): + from lambda_function.main import PlayEpisodeIntentHandler + req = PlayEpisodeIntentHandler() + slot_values = { + 'play': SlotValue('play'), + 'type': SlotValue('episode'), + 'epnum': SlotValue('7'), + 'seasnum': SlotValue('3'), + 'tvshow': SlotValue('mythic quest') + } + resp = self.__test_values_passed(req, slot_values, self.__slot_to_dict(slot_values)) + self.assertEqual(self.language.get(Key.PlayEpisodeNumber, play='Playing', episode='7', season='3', + show='mythic quest'), resp.speak_text) + + def __test_values_passed(self, request, slot_values, passed_values): + request.publish_command_to_sns = Mock() + with patch(patch_path(utils.get_persistent_session_attribute), self.get_persistent_session_attribute): + self.handler_input.request_envelope.request.intent.slots = slot_values + response: Optional[MockResponseBuilder] = request.handle(self.handler_input) + data = request.get_data(self.handler_input) + self.__test_dict_values(data, passed_values) + return response + + def test_quality_low(self): + from lambda_function.main import QualityIntentHandler + req = QualityIntentHandler() + slot_values = { + 'quality': SlotValue('low') + } + self.__test_values_passed(req, slot_values, self.__slot_to_dict(slot_values)) + + def test_quality_720p(self): + from lambda_function.main import QualityIntentHandler + req = QualityIntentHandler() + slot_values = { + 'quality': SlotValue('720p') + } + resp = self.__test_values_passed(req, slot_values, self.__slot_to_dict(slot_values)) + self.__test_speech_values(resp.speak_text, [ + 'seven twenty pea' + ]) + self.assertTrue('720p' in resp.card.content) + + def test_quality_increase(self): + from lambda_function.main import QualityIntentHandler + req = QualityIntentHandler() + slot_values = { + 'raise_lower': SlotValue('up') + } + self.__test_values_passed(req, slot_values, self.__slot_to_dict(slot_values)) + + def test_quality_decrease(self): + from lambda_function.main import QualityIntentHandler + req = QualityIntentHandler() + slot_values = { + 'raise_lower': SlotValue('down') + } + self.__test_values_passed(req, slot_values, self.__slot_to_dict(slot_values)) + + def test_volume_set(self): + from lambda_function.main import VolumeChangeIntentHandler + req = VolumeChangeIntentHandler() + slot_values = { + 'volume': SlotValue('5') + } + resp = self.__test_values_passed(req, slot_values, {'volume': 5}) + self.assertEqual(self.language.get(Key.SetVolume, volume=5), resp.speak_text) + + def test_volume_too_high(self): + from lambda_function.main import VolumeChangeIntentHandler + req = VolumeChangeIntentHandler() + slot_values = { + 'volume': SlotValue('11') + } + resp = self.__test_values_passed(req, slot_values, {'volume': 11}) + self.assertEqual(self.language.get(Key.ErrorSetVolumeRange), resp.speak_text) + + def test_volume_too_low(self): + from lambda_function.main import VolumeChangeIntentHandler + req = VolumeChangeIntentHandler() + slot_values = { + 'volume': SlotValue('-1') + } + resp = self.__test_values_passed(req, slot_values, {'volume': -1}) + self.assertEqual(self.language.get(Key.ErrorSetVolumeRange), resp.speak_text) + + def test_volume_increase(self): + from lambda_function.main import VolumeChangeIntentHandler + req = VolumeChangeIntentHandler() + slot_values = { + 'raise_lower': SlotValue('up') + } + resp = self.__test_values_passed(req, slot_values, self.__slot_to_dict(slot_values)) + self.assertEqual(self.language.get(Key.IncreaseVolume), resp.speak_text) + + def test_volume_decrease(self): + from lambda_function.main import VolumeChangeIntentHandler + req = VolumeChangeIntentHandler() + slot_values = { + 'raise_lower': SlotValue('down') + } + resp = self.__test_values_passed(req, slot_values, self.__slot_to_dict(slot_values)) + self.assertEqual(self.language.get(Key.DecreaseVolume), resp.speak_text) + + def test_volume_none(self): + from lambda_function.main import VolumeChangeIntentHandler + req = VolumeChangeIntentHandler() + slot_values = {} + resp = self.__test_values_passed(req, slot_values, {'raise_lower': 'up'}) + self.assertEqual(self.language.get(Key.IncreaseVolume), resp.speak_text) + + +if __name__ == '__main__': + unittest.main() diff --git a/src/tests/unit/local/__init__.py b/src/tests/unit/local/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/tests/unit/local/controllers/__init__.py b/src/tests/unit/local/controllers/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/tests/unit/local/controllers/test_plex_controller.py b/src/tests/unit/local/controllers/test_plex_controller.py new file mode 100644 index 0000000..5a296e2 --- /dev/null +++ b/src/tests/unit/local/controllers/test_plex_controller.py @@ -0,0 +1,92 @@ +import unittest +from unittest.mock import Mock, patch + +from local.controllers.plex_controller import MyPlexController, QUALITY_LIST +from tests.utils import patch_path + + +class TestPlexController(unittest.TestCase): + + def setUp(self) -> None: + cc = Mock() + cc.uuid = 'test_cc_id' + self.pc = MyPlexController(cc) + + def test_transcode_level(self): + pc = self.pc + play_mock = Mock() + + with patch(patch_path(pc._start_playing), play_mock): + with patch(patch_path(pc.build_play_list), Mock()): + + # Test high quality + pc.transcode({'quality': 'high'}) + self.assertEqual(QUALITY_LIST['1080p'], pc.bitrate) + play_mock.assert_called() + + # Test medium quality + pc.transcode({'quality': 'medium'}) + self.assertEqual(QUALITY_LIST['720p'], pc.bitrate) + + # Test maximum quality + pc.transcode({'quality': 'maximum'}) + self.assertEqual(0, pc.bitrate) + + # Test low quality + pc.transcode({'quality': 'low'}) + self.assertEqual(QUALITY_LIST['480p'], pc.bitrate) + + def test_transcode_raise(self): + pc = self.pc + play_mock = Mock() + + with patch(patch_path(pc._start_playing), play_mock): + with patch(patch_path(pc.build_play_list), Mock()): + + pc.transcode({'quality': 'medium'}) + self.assertEqual(QUALITY_LIST['720p'], pc.bitrate) + + # Test increase from medium + pc.transcode({'raise_lower': 'up'}) + self.assertEqual(QUALITY_LIST['1080p'], pc.bitrate) + + # Test increase from high + pc.transcode({'raise_lower': 'up'}) + self.assertEqual(0, pc.bitrate) + + play_mock.reset_mock() + pc.transcode({'raise_lower': 'up'}) + self.assertEqual(0, pc.bitrate) + play_mock.assert_not_called() + + def test_transcode_lower(self): + pc = self.pc + play_mock = Mock() + + with patch(patch_path(pc._start_playing), play_mock): + with patch(patch_path(pc.build_play_list), Mock()): + pc.transcode({'quality': 'high'}) + self.assertEqual(QUALITY_LIST['1080p'], pc.bitrate) + play_mock.assert_called() + + # Test low quality + pc.transcode({'quality': 'low'}) + self.assertEqual(QUALITY_LIST['480p'], pc.bitrate) + + # Test decrease + pc.transcode({'raise_lower': 'down'}) + self.assertEqual(QUALITY_LIST['320p'], pc.bitrate) + + # Test decrease + pc.transcode({'raise_lower': 'down'}) + self.assertEqual(QUALITY_LIST['240p'], pc.bitrate) + + # Test decrease + play_mock.reset_mock() + pc.transcode({'raise_lower': 'down'}) + self.assertEqual(QUALITY_LIST['240p'], pc.bitrate) + play_mock.assert_not_called() + + +if __name__ == '__main__': + unittest.main() diff --git a/src/tests/utils.py b/src/tests/utils.py new file mode 100644 index 0000000..f9b5a7e --- /dev/null +++ b/src/tests/utils.py @@ -0,0 +1,15 @@ +import os +from dotenv import load_dotenv + + +def patch_path(func): + class_name = str(func).replace('