├── .gitignore
├── .github
├── FUNDING.yml
├── ISSUE_TEMPLATE
│ ├── config.yml
│ ├── feature_request.md
│ └── bug-or-issue.md
├── workflows
│ └── IssueResponse.yml
└── label-commenter-config.yml
├── hacs.json
├── Plex_Assistant_DialogFlow.zip
├── .deepsource.toml
├── custom_components
└── plex_assistant
│ ├── services.yaml
│ ├── const.py
│ ├── manifest.json
│ ├── intent.py
│ ├── plex_assistant.py
│ ├── translations
│ ├── en.json
│ ├── hu.json
│ ├── it.json
│ └── es.json
│ ├── config_flow.py
│ ├── __init__.py
│ ├── process_speech.py
│ ├── helpers.py
│ └── localize.py
├── info.md
├── troubleshooting.md
├── LICENSE
├── ver_one_update.md
├── translation.md
├── OLD_README.md
└── README.md
/.gitignore:
--------------------------------------------------------------------------------
1 | /.vscode
2 | /.dccache
--------------------------------------------------------------------------------
/.github/FUNDING.yml:
--------------------------------------------------------------------------------
1 | github: [maykar]
2 |
--------------------------------------------------------------------------------
/.github/ISSUE_TEMPLATE/config.yml:
--------------------------------------------------------------------------------
1 | blank_issues_enabled: false
2 |
--------------------------------------------------------------------------------
/hacs.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "Plex Assistant",
3 | "homeassistant": "2021.2.0"
4 | }
5 |
--------------------------------------------------------------------------------
/Plex_Assistant_DialogFlow.zip:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/maykar/plex_assistant/HEAD/Plex_Assistant_DialogFlow.zip
--------------------------------------------------------------------------------
/.deepsource.toml:
--------------------------------------------------------------------------------
1 | version = 1
2 |
3 | [[analyzers]]
4 | name = "python"
5 | enabled = true
6 |
7 | [analyzers.meta]
8 | runtime_version = "3.x.x"
9 |
--------------------------------------------------------------------------------
/.github/ISSUE_TEMPLATE/feature_request.md:
--------------------------------------------------------------------------------
1 | ---
2 | name: Feature request
3 | about: Suggest an idea for this project
4 | title: ''
5 | labels: enhancement
6 | assignees: ''
7 |
8 | ---
9 |
10 |
11 |
--------------------------------------------------------------------------------
/custom_components/plex_assistant/services.yaml:
--------------------------------------------------------------------------------
1 | command:
2 | description: Send command to Plex
3 | fields:
4 | command:
5 | description: '[Required] Command to send'
6 | example: Play Evil Dead on the Living Room TV
7 |
--------------------------------------------------------------------------------
/custom_components/plex_assistant/const.py:
--------------------------------------------------------------------------------
1 | import logging
2 |
3 | from homeassistant.const import __version__
4 | from awesomeversion import AwesomeVersion
5 |
6 | DOMAIN = "plex_assistant"
7 | HA_VER_SUPPORTED = AwesomeVersion(__version__) >= AwesomeVersion("2021.2.0")
8 | _LOGGER = logging.getLogger(__name__)
9 |
--------------------------------------------------------------------------------
/.github/ISSUE_TEMPLATE/bug-or-issue.md:
--------------------------------------------------------------------------------
1 | ---
2 | name: Bug or Issue
3 | about: 'Read before submitting: https://github.com/maykar/plex_assistant/blob/master/troubleshooting.md'
4 | title: ''
5 | labels: bug
6 | assignees: ''
7 |
8 | ---
9 |
10 |
14 |
--------------------------------------------------------------------------------
/custom_components/plex_assistant/manifest.json:
--------------------------------------------------------------------------------
1 | {
2 | "domain": "plex_assistant",
3 | "name": "Plex Assistant",
4 | "version": "1.1.9",
5 | "documentation": "https://github.com/maykar/plex_assistant",
6 | "dependencies": ["media_player"],
7 | "config_flow": true,
8 | "codeowners": ["@maykar"],
9 | "requirements": [
10 | "gTTs>=2.2.1",
11 | "pychromecast>=8.0.0",
12 | "rapidfuzz==1.1.1",
13 | "plexapi>=4.3.0",
14 | "awesomeversion>=21.2.2"
15 | ]
16 | }
17 |
--------------------------------------------------------------------------------
/.github/workflows/IssueResponse.yml:
--------------------------------------------------------------------------------
1 | on:
2 | issues:
3 | types:
4 | - labeled
5 | - unlabeled
6 | pull_request:
7 | types:
8 | - labeled
9 | - unlabeled
10 |
11 | jobs:
12 | comment:
13 | runs-on: ubuntu-18.04
14 | steps:
15 | - uses: actions/checkout@v2
16 | with:
17 | ref: master
18 |
19 | - name: Label Commenter
20 | uses: peaceiris/actions-label-commenter@v1
21 | with:
22 | github_token: ${{ secrets.GITHUB_TOKEN }}
23 |
--------------------------------------------------------------------------------
/info.md:
--------------------------------------------------------------------------------
1 | # ❱ Plex Assistant
2 |
3 | Plex Assistant is a Home Assistant integration for casting Plex media to Google devices, Sonos devices, and Plex clients with Google Assistant, HA's conversation integration, and more.
4 |
5 | Example: `"Hey Google, tell Plex to play The Walking Dead on the Downstairs TV."`
6 |
7 | **[Visit the readme for more info, config, setup, supported languages, and available commands.](https://github.com/maykar/plex_assistant)**
8 |
9 | #### Support Development
10 | - :coffee: [Buy me a coffee](https://www.buymeacoffee.com/FgwNR2l)
11 | - :heart: [Sponsor me on GitHub](https://github.com/sponsors/maykar)
12 | - :keyboard: Help with development, documentation, or [translation](https://github.com/maykar/plex_assistant/blob/master/translation.md)
13 |
--------------------------------------------------------------------------------
/troubleshooting.md:
--------------------------------------------------------------------------------
1 | ## Troubleshooting Plex Assistant
2 |
3 | Whenever posting an issue include as much of the following info as you can:
4 |
5 | * Any errors related to Plex Assistant or Plex in your logs along with debug info (see below on how to enable debug)
6 | * The trigger method you're using (IFTTT, DialogFlow, or HA Conversation)
7 | * The method used to install (HACS or manually)
8 | * If HA's Plex Integration works without issue
9 | * The command you are using (if the issue is command specific)
10 | * If using the `plex_assistant.command` service in HA's Developer Tools is working
11 | * Config options if relevant
12 | * If you're using advanced config, show the config you're using
13 |
14 | ## Enable debug logs for the component
15 |
16 | Add the following to your configuration.yaml file, restart, ask plex assistant to do something, go to your logs and view full logs.
17 |
18 | ```yaml
19 | logger:
20 | default: warning
21 | logs:
22 | custom_components.plex_assistant: debug
23 | ```
24 |
--------------------------------------------------------------------------------
/custom_components/plex_assistant/intent.py:
--------------------------------------------------------------------------------
1 | import homeassistant.helpers.config_validation as cv
2 | from homeassistant.helpers import intent
3 |
4 | from .const import DOMAIN
5 |
6 |
7 | async def async_setup_intents(hass):
8 | intent.async_register(hass, PlexAssistantIntent())
9 | hass.components.conversation.async_register(
10 | "Plex",
11 | ["Tell Plex to {command}", "{command} with Plex"],
12 | )
13 |
14 |
15 | class PlexAssistantIntent(intent.IntentHandler):
16 | intent_type = "Plex"
17 | slot_schema = {"command": cv.string}
18 |
19 | async def async_handle(self, intent_obj):
20 | slots = self.async_validate_slots(intent_obj.slots)
21 | if "initialize_plex_intent" in slots["command"]["value"]:
22 | return
23 | await intent_obj.hass.services.async_call(DOMAIN, "command", {"command": slots["command"]["value"]})
24 | response = intent_obj.create_response()
25 | response.async_set_speech("Sending command to Plex.")
26 | return response
27 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2020 Ryan Meek
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
22 |
--------------------------------------------------------------------------------
/.github/label-commenter-config.yml:
--------------------------------------------------------------------------------
1 | labels:
2 | - name: bug
3 | labeled:
4 | issue:
5 | body: |
6 | ## Important:
7 | **Issues that don't provide the information requested in the [troubleshooting docs](https://github.com/maykar/plex_assistant/blob/master/troubleshooting.md) may not get a reply and may be closed until the info is provided. Please, include as much of the requested info as possible so that I can replicate and investigate your issue.**
8 |
9 | **When pasting in your config/code/errors always place 3 backticks ``` above the first line and after the last line. Doing this will format it correctly.**
10 | - name: missing info
11 | labeled:
12 | issue:
13 | body: In order for me to recreate and troubleshoot your issue, please read the bot's previous reply. Follow the post's steps and provide the info requested. This issue will remain closed until more information is provided.
14 | action: close
15 | - name: stale
16 | labeled:
17 | issue:
18 | body: There has been no recent activity on this issue and/or more info was requested and not given. Please, ask for the issue to be reopened if it hasn't been resolved and provide the requested info.
19 | action: close
20 | - name: duplicate
21 | labeled:
22 | issue:
23 | body: This issue is a duplicate of another issue. Please, search open and closed issues before posting.
24 | action: close
25 |
--------------------------------------------------------------------------------
/custom_components/plex_assistant/plex_assistant.py:
--------------------------------------------------------------------------------
1 | from datetime import datetime
2 | from functools import lru_cache
3 |
4 |
5 | class PlexAssistant:
6 | def __init__(self, server, start_script_keys):
7 | self.server = server
8 | self.library = self.server.library
9 | self.devices = {}
10 | self.start_script_keys = start_script_keys
11 | self.tv_id = self.get_section_id("show")
12 | self.movie_id = self.get_section_id("movie")
13 | self.music_id = self.get_section_id("artist")
14 |
15 | @property
16 | def device_names(self):
17 | names = list(self.devices.keys()) + self.start_script_keys
18 | return list(dict.fromkeys(names))
19 |
20 | @property
21 | def section_id(self):
22 | return {
23 | "movie": self.movie_id,
24 | "show": self.tv_id,
25 | "season": self.tv_id,
26 | "episode": self.tv_id,
27 | "artist": self.music_id,
28 | "album": self.music_id,
29 | "track": self.music_id,
30 | }
31 |
32 | @property
33 | @lru_cache()
34 | def media(self):
35 | media_items = {"all_titles": []}
36 | for item in ["show", "movie", "artist", "album", "track"]:
37 | media_items[f"{item}_titles"] = [x.title for x in self.library.search(libtype=item, sort="addedAt:desc")]
38 | media_items["all_titles"] += media_items[f"{item}_titles"]
39 | media_items["playlist_titles"] = [x.title for x in self.server.playlists()]
40 | media_items["updated"] = datetime.now()
41 | return media_items
42 |
43 | def get_section_id(self, section):
44 | section = self.library.search(libtype=section, limit=1)
45 | return None if not section else section[0].librarySectionID
46 |
--------------------------------------------------------------------------------
/custom_components/plex_assistant/translations/en.json:
--------------------------------------------------------------------------------
1 | {
2 | "config": {
3 | "step": {
4 | "user": {
5 | "title": "❱ Plex Assistant Setup",
6 | "description": "[Documentation](https://github.com/maykar/plex_assistant) | [Forums](https://community.home-assistant.io/t/plex-assistant/) | [Troubleshooting](https://github.com/maykar/plex_assistant/blob/master/troubleshooting.md) | [Support Development](https://github.com/sponsors/maykar)",
7 | "data": {
8 | "server_name": "Select Plex server",
9 | "language": "Language",
10 | "default_cast": "Default cast device",
11 | "tts_errors": "Speak errors on cast device"
12 | }
13 | }
14 | },
15 | "abort": {
16 | "single_instance_allowed": "Only a single instance is allowed.",
17 | "no_plex_server": "No Plex servers were found, be sure that the Home Assistant Plex integration is setup and working.",
18 | "ha_ver_unsupported": "This Plex Assistant version requires Home Assistant 2021.2.0 or higher."
19 | }
20 | },
21 | "options": {
22 | "step": {
23 | "init": {
24 | "title": "❱ Plex Assistant Advanced Options",
25 | "description": "[Documentation](https://github.com/maykar/plex_assistant) | [Forums](https://community.home-assistant.io/t/plex-assistant/) | [Troubleshooting](https://github.com/maykar/plex_assistant/blob/master/troubleshooting.md) | [Support Development](https://github.com/sponsors/maykar)\n\n**Read the [Advanced Config Docs](https://github.com/maykar/plex_assistant/tree/master#advanced-configuration) for how to format of the next 2 options.**",
26 | "data": {
27 | "start_script": "Run a script to start an unavailable Plex client",
28 | "keyword_replace": "Replace keywords or phrases with your own",
29 | "jump_f": "Default jump forward amount in seconds.",
30 | "jump_b": "Default jump backward amount in seconds."
31 | }
32 | }
33 | }
34 | }
35 | }
--------------------------------------------------------------------------------
/custom_components/plex_assistant/translations/hu.json:
--------------------------------------------------------------------------------
1 | {
2 | "config": {
3 | "step": {
4 | "user": {
5 | "title": "❱ Plex Assistant Beállítás",
6 | "description": "[Dokumentáció](https://github.com/maykar/plex_assistant) | [Fórumok](https://community.home-assistant.io/t/plex-assistant/) | [Hibakeresés](https://github.com/maykar/plex_assistant/blob/master/troubleshooting.md) | [Fejlesztés Támogatása](https://github.com/sponsors/maykar)",
7 | "data": {
8 | "server_name": "Kérem válasszon Plex szervert",
9 | "language": "Nyelv",
10 | "default_cast": "Alapértelmezett cast eszköz",
11 | "tts_errors": "TTS (beszédszintézis) problémák a cast eszközön"
12 | }
13 | }
14 | },
15 | "abort": {
16 | "single_instance_allowed": "Csak egy példány engedélyezett.",
17 | "no_plex_server": "Nem található Plex szerver, ellenőrizze, hogy a Home Assistant Plex integrációja engedélyezve van és működik.",
18 | "ha_ver_unsupported": "Ezen Plex Assistant verzió futtatásához Home Assistant 2021.2.0 vagy magasabb verzió szükséges."
19 | }
20 | },
21 | "options": {
22 | "step": {
23 | "init": {
24 | "title": "❱ Plex Assistant Haladó Beállítások",
25 | "description": "[Dokumentáció](https://github.com/maykar/plex_assistant) | [Fórumok](https://community.home-assistant.io/t/plex-assistant/) | [Hibakeresés](https://github.com/maykar/plex_assistant/blob/master/troubleshooting.md) | [Fejlesztés Támogatása](https://github.com/sponsors/maykar)\n\n**Read the [Advanced Config Docs](https://github.com/maykar/plex_assistant/tree/master#advanced-configuration) for how to format of the next 2 options.**",
26 | "data": {
27 | "start_script": "Szkript futtatása egy elérhetetlen Plex kliens indításához",
28 | "keyword_replace": "Cseréld le a kulcsszavakat a sajátjaidra",
29 | "jump_f": "Alapértelmezett előretekerés másodpercben.",
30 | "jump_b": "Alapértelmezett visszatekerés másodpercben."
31 | }
32 | }
33 | }
34 | }
35 | }
36 |
--------------------------------------------------------------------------------
/custom_components/plex_assistant/translations/it.json:
--------------------------------------------------------------------------------
1 | {
2 | "config": {
3 | "step": {
4 | "user": {
5 | "title": "❱ Configurazione di Plex Assistant",
6 | "description": "[Documentazione](https://github.com/maykar/plex_assistant/) | [Forum](https://community.home-assistant.io/t/plex-assistant/) | [Soluzione ai problemi](https://github.com/maykar/plex_assistant/blob/master/troubleshooting.md) | [Supporto Sviluppo](https://github.com/sponsors/maykar)",
7 | "data": {
8 | "server_name": "Selezione il server Plex",
9 | "language": "Lingua",
10 | "default_cast": "Dispositivo per il cast predefinito",
11 | "tts_errors": "Errori sul dispositivo cast"
12 | }
13 | }
14 | },
15 | "abort": {
16 | "single_instance_allowed": "Permessa solo una istanza.",
17 | "no_plex_server": "ServeR Plex non trovato, assicurati che Plex Assistant sia configurato e funzionante.",
18 | "ha_ver_unsupported": "Questa versione di Plex Assistant richiede versione di Home Assistant 2021.2.0 o superiore."
19 | }
20 | },
21 | "options": {
22 | "step": {
23 | "init": {
24 | "title": "❱ Opzioni avanzate di Plex Assistant",
25 | "description": "[Documentazione](https://github.com/maykar/plex_assistant) | [Forum](https://community.home-assistant.io/t/plex-assistant/) | [Soluzione ai problemi](https://github.com/maykar/plex_assistant/blob/master/troubleshooting.md) | [Supporto Sviluppo](https://github.com/sponsors/maykar)\n\n**Read the [Manuale di configurazione avanzaao](https://github.com/maykar/plex_assistant/tree/master#advanced-configuration) for how to format of the next 2 options.**",
26 | "data": {
27 | "start_script": "Esegui uno script per far partire un Client Plex non disponibile",
28 | "keyword_replace": "Rimpiazza parole o frasi con le tue personali",
29 | "jump_f": "Quantita predefinita di secondi per l'avanzamento.",
30 | "jump_b": "Quantita predefinita di secondi per riavvolgere."
31 | }
32 | }
33 | }
34 | }
35 | }
36 |
--------------------------------------------------------------------------------
/custom_components/plex_assistant/translations/es.json:
--------------------------------------------------------------------------------
1 | {
2 | "config": {
3 | "step": {
4 | "user": {
5 | "title": "❱ Configuración del Plex Assistant",
6 | "description": "[Documentación](https://github.com/maykar/plex_assistant/) | [Foros](https://community.home-assistant.io/t/plex-assistant/) | [Solución de problemas](https://github.com/maykar/plex_assistant/blob/master/troubleshooting.md) | [Support Development](https://github.com/sponsors/maykar)",
7 | "data": {
8 | "server_name": "Seleccione el servidor Plex",
9 | "language": "Idioma",
10 | "default_cast": "Dispositivo cast predeterminado",
11 | "tts_errors": "Habla errores en el dispositivo cast"
12 | }
13 | }
14 | },
15 | "abort": {
16 | "single_instance_allowed": "Solo se permite una instancia.",
17 | "no_plex_server": "No se encontraron servidores Plex, asegúrese de que la integración de Plex en Home Assistant esté configurada y funcionando.",
18 | "ha_ver_unsupported": "Esta versión del Plex Assistant requiere una version 2021.2.0 o mayor de Home Assistant."
19 | }
20 | },
21 | "options": {
22 | "step": {
23 | "init": {
24 | "title": "❱ Opciones avanzadas del Plex Assistant",
25 | "description": "[Documentación](https://github.com/maykar/plex_assistant) | [Foros](https://community.home-assistant.io/t/plex-assistant/) | [Solución de problemas](https://github.com/maykar/plex_assistant/blob/master/troubleshooting.md) | [Support Development](https://github.com/sponsors/maykar)\n\n**Read the [Advanced Config Docs](https://github.com/maykar/plex_assistant/tree/master#advanced-configuration) for how to format of the next 2 options.**",
26 | "data": {
27 | "start_script": "Ejecute un script para iniciar un cliente Plex no disponible",
28 | "keyword_replace": "Reemplace palabras claves o frases con las suyas",
29 | "jump_f": "Cantidad predeterminada de segundos para avanzar.",
30 | "jump_b": "Cantidad predeterminada de segundos para retroceder."
31 | }
32 | }
33 | }
34 | }
35 | }
36 |
--------------------------------------------------------------------------------
/custom_components/plex_assistant/config_flow.py:
--------------------------------------------------------------------------------
1 | from homeassistant import config_entries
2 | from homeassistant.core import callback
3 |
4 | import voluptuous as vol
5 |
6 | from .const import DOMAIN, HA_VER_SUPPORTED
7 | from .localize import translations
8 |
9 |
10 | def get_devices(_self):
11 | devices = []
12 | for entity in list(_self.hass.data["media_player"].entities):
13 | info = str(entity.device_info["identifiers"]) if entity.device_info else ""
14 | if "plex" in info or "cast" in info:
15 | try:
16 | devices.append(_self.hass.states.get(entity.entity_id).attributes.get("friendly_name"))
17 | except AttributeError:
18 | continue
19 | else:
20 | continue
21 | return devices
22 |
23 |
24 | def get_servers(_self):
25 | try:
26 | return [x.title for x in _self.hass.config_entries.async_entries("plex")]
27 | except (KeyError, AttributeError):
28 | return []
29 |
30 |
31 | def get_schema(_self):
32 | multi_server_schema = {vol.Optional("server_name"): vol.In(_self.servers)}
33 | default_schema = {
34 | vol.Optional("language", default="en"): vol.In(translations.keys()),
35 | vol.Optional("default_cast"): vol.In(get_devices(_self)),
36 | vol.Optional("tts_errors", default=True): bool,
37 | }
38 | return {**multi_server_schema, **default_schema} if len(_self.servers) > 1 else default_schema
39 |
40 |
41 | class PlexAssistantFlowHandler(config_entries.ConfigFlow, domain=DOMAIN):
42 | VERSION = 1
43 | CONNECTION_CLASS = config_entries.CONN_CLASS_CLOUD_POLL
44 |
45 | @staticmethod
46 | @callback
47 | def async_get_options_flow(config_entry):
48 | return PlexAssistantOptionsFlowHandler(config_entry)
49 |
50 | def __init__(self):
51 | self.servers = None
52 |
53 | async def async_step_user(self, user_input=None):
54 | self.servers = get_servers(self)
55 |
56 | if self._async_current_entries():
57 | return self.async_abort(reason="single_instance_allowed")
58 | if not HA_VER_SUPPORTED:
59 | return self.async_abort(reason="ha_ver_unsupported")
60 | if len(self.servers) < 1:
61 | return self.async_abort(reason="no_plex_server")
62 | if user_input is not None:
63 | server = user_input["server_name"] if "server_name" in user_input else self.servers[0]
64 | return self.async_create_entry(title=server, data=user_input)
65 |
66 | return await self._show_config_form(user_input)
67 |
68 | async def _show_config_form(self, user_input):
69 | return self.async_show_form(
70 | step_id="user",
71 | data_schema=vol.Schema(get_schema(self)),
72 | )
73 |
74 |
75 | class PlexAssistantOptionsFlowHandler(config_entries.OptionsFlow):
76 | def __init__(self, config_entry):
77 | self.config_entry = config_entry
78 | self.options = dict(config_entry.options)
79 |
80 | async def async_step_init(self, user_input=None):
81 | if user_input is not None:
82 | self.options.update(user_input)
83 | return await self._update_options()
84 |
85 | return self.async_show_form(
86 | step_id="init",
87 | data_schema=vol.Schema(
88 | {
89 | vol.Optional(
90 | "start_script",
91 | description={"suggested_value": self.options.get("start_script", "")},
92 | default="",
93 | ): str,
94 | vol.Optional(
95 | "keyword_replace",
96 | description={"suggested_value": self.options.get("keyword_replace", "")},
97 | default="",
98 | ): str,
99 | vol.Required("jump_f", default=self.options.get("jump_f", 30)): int,
100 | vol.Required("jump_b", default=self.options.get("jump_b", 15)): int,
101 | }
102 | ),
103 | )
104 |
105 | async def _update_options(self):
106 | return self.async_create_entry(title="", data=self.options)
107 |
--------------------------------------------------------------------------------
/ver_one_update.md:
--------------------------------------------------------------------------------
1 | # Plex Assistant Version 1.0.0
2 | ### Version 1.0.0 requires Home Assistant 2021.2.0 or higher.
3 |
4 | **This release has many new features, improvements, and breaking changes.**
5 |
6 | # Breaking Changes
7 |
8 | The automation, intent, intent script, and sensor are no longer required to be setup by the user.
The component handles all of it automatically and there is no need for a sensor anymore.
9 | This means you need to remove those items from HA.
If you don't remove them it may cause issues and duplicate commands.
10 | Configuration is now handled in the UI so the old config method is no longer needed as well.
11 |
12 | ### Remove these things if they exist:
13 |
14 | Plex Assistant Config
16 |
17 | Remove your entire plex assistant config, including `plex_assistant:`
18 |
19 | ```yaml
20 | plex_assistant:
21 | url: 'http://192.168.1.3:32400'
22 | token: 'tH1s1Sy0uRT0k3n'
23 | default_cast: 'Downstairs TV'
24 | language: 'en'
25 | tts_errors: true
26 | aliases:
27 | Downstairs TV: TV0565124
28 | Upstairs TV: Samsung_66585
29 | ```
30 |
31 | Sensor
35 |
36 | Remove the `plex_assistant` sensor.
37 |
38 | ```yaml
39 | sensor: # Keep this line if other sensors are listed below it.
40 | - platform: plex_assistant
41 | ```
42 |
43 | IFTTT Automation
47 |
48 | ```yaml
49 | alias: Plex Assistant Automation
50 | trigger:
51 | - platform: event
52 | event_type: ifttt_webhook_received
53 | event_data:
54 | action: call_service
55 | condition:
56 | condition: template
57 | value_template: "{{ trigger.event.data.service == 'plex_assistant.command' }}"
58 | action:
59 | - service: "{{ trigger.event.data.service }}"
60 | data:
61 | command: "{{ trigger.event.data.command }}"
62 | ```
63 |
64 | Intent
68 |
69 | Keep `conversation:` and keep `intents:` if you have other intents.
70 |
71 | ```yaml
72 | conversation: #### Keep this line
73 | intents: #### and this one if you have other intents.
74 | Plex:
75 | - "Tell Plex to {command}"
76 | - "{command} with Plex"
77 | ```
78 |
79 | Intent Script
83 |
84 | ```yaml
85 | intent_script: # Keep this line if you have other intent scripts below
86 | Plex:
87 | speech:
88 | text: "Command sent to Plex."
89 | action:
90 | - service: plex_assistant.command
91 | data:
92 | command: "{{command}}"
93 | ```
94 |
95 |
101 | This will help with configuration and support, will allow for more improvements as Plex Assistant progresses, and requires less processing than the old method.
102 |
103 | Configuration is now handled in the UI. [Find the configuration docs in the updated readme.](https://github.com/maykar/plex_assistant#configuration)
104 |
105 |
106 | # New Features
107 |
108 | * Configuration through the UI
109 | * HA's `media_player` entities are now used for devices
110 | * The entity's friendly name is now used for it's device name
111 | * Media automatically resumes where you left off
112 | * Continuous play when playing a show, ondeck, latest, etc.
113 | * New commands "skip forward" and "skip back" to go to next or previous media item
114 | * New random command to play a random item or selected items in a random order
115 | * New "Start Script" feature to start Plex Clients that aren't currently open
116 | * Replace any word or phrase in command with a replacement of your preference
117 | * Jump forward/back works for all devices and amount (in seconds) is configurable
118 | * Component listens for IFTTT and DialogFlow calls automatically (no more intents, scripts, or automations needed)
119 | * Roman numerals are now handled
120 | * DialogFlow instructions improved with uploadable template action
121 | * Remote server support
122 |
123 | # Thank you
124 |
125 | For patiently answering my endless questions and putting up with my constant pestering/suggestions, I'd like to give a huge "Thank you!" to two individuals for their help in getting Plex Assistant to this point.
126 |
127 | [@jjlawren](https://github.com/jjlawren) for their guidance and their work on Python-PlexAPI and HA's Plex integration. The ability to use media player entities, continuous playing of media items, and more wouldn't be possible without them.
128 |
129 | [@ludeeus](https://github.com/ludeeus) for HACS, help with config flow, guidance on all things python, and always being willing to help.
130 |
--------------------------------------------------------------------------------
/custom_components/plex_assistant/__init__.py:
--------------------------------------------------------------------------------
1 | """
2 | Plex Assistant is a component for Home Assistant to add control of Plex to
3 | Google Assistant with a little help from IFTTT or DialogFlow.
4 |
5 | Play to Google Cast devices or Plex Clients using fuzzy searches for media and
6 | cast device names.
7 |
8 | https://github.com/maykar/plex_assistant
9 | """
10 |
11 | from homeassistant.config_entries import ConfigEntry
12 | from homeassistant.core import Config, HomeAssistant
13 | from homeassistant.components.zeroconf import async_get_instance
14 |
15 | import os
16 |
17 | from .const import DOMAIN, _LOGGER
18 | from .plex_assistant import PlexAssistant
19 | from .process_speech import ProcessSpeech
20 | from .localize import translations
21 | from .helpers import (
22 | filter_media,
23 | find_media,
24 | fuzzy,
25 | get_devices,
26 | get_server,
27 | listeners,
28 | media_error,
29 | media_service,
30 | no_device_error,
31 | play_tts_error,
32 | process_config_item,
33 | remote_control,
34 | run_start_script,
35 | seek_to_offset,
36 | )
37 |
38 |
39 | async def async_setup(hass: HomeAssistant, config: Config):
40 | if DOMAIN in config:
41 | changes_url = "https://github.com/maykar/plex_assistant/blob/master/ver_one_update.md"
42 | message = (
43 | "Configuration is now handled in the UI, please read the %s for how to migrate "
44 | "to the new version and more info.%s "
45 | )
46 | service_data = {
47 | "title": "Plex Assistant Breaking Changes",
48 | "message": message % (f"[change log]({changes_url})", "."),
49 | }
50 | await hass.services.async_call("persistent_notification", "create", service_data, False)
51 | _LOGGER.warning("Plex Assistant: " + message % ("change log", f". {changes_url}"))
52 | return True
53 |
54 |
55 | async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry):
56 | if hass.data.get(DOMAIN) is None:
57 | hass.data.setdefault(DOMAIN, {})
58 |
59 | server_name = entry.data.get("server_name")
60 | default_device = entry.data.get("default_cast")
61 | tts_errors = entry.data.get("tts_errors")
62 | lang = entry.data.get("language")
63 | localize = translations[lang]
64 | start_script = process_config_item(entry.options, "start_script")
65 | keyword_replace = process_config_item(entry.options, "keyword_replace")
66 | jump_amount = [entry.options.get("jump_f") or 30, entry.options.get("jump_b") or 15]
67 | zeroconf = await async_get_instance(hass)
68 |
69 | server = await get_server(hass, hass.config, server_name)
70 | if not server:
71 | return True
72 |
73 | def pa_executor(_server, start_script_keys):
74 | _pa = PlexAssistant(_server, start_script_keys)
75 | get_devices(hass, _pa)
76 | _LOGGER.debug(f"Media titles: {len(_pa.media['all_titles'])}")
77 | return _pa
78 |
79 | pa = await hass.async_add_executor_job(pa_executor, server, list(start_script.keys()))
80 |
81 | tts_dir = hass.config.path() + "/www/plex_assist_tts/"
82 | if tts_errors and not os.path.exists(tts_dir):
83 | os.makedirs(tts_dir, mode=0o777)
84 |
85 | ifttt_listener = await listeners(hass)
86 | hass.data[DOMAIN][entry.entry_id] = {"remove_listener": ifttt_listener}
87 | entry.add_update_listener(async_reload_entry)
88 |
89 | def handle_input(call):
90 | hass.services.async_call("plex", "scan_for_clients", blocking=False, limit=30)
91 | command = call.data.get("command").strip()
92 | media = None
93 |
94 | if not command:
95 | _LOGGER.warning(localize["no_call"])
96 | return
97 | _LOGGER.debug("Command: %s", command)
98 |
99 | command = command.lower()
100 | if keyword_replace and any(keyword.lower() in command for keyword in keyword_replace.keys()):
101 | for keyword in keyword_replace.keys():
102 | command = command.replace(keyword.lower(), keyword_replace[keyword].lower())
103 |
104 | get_devices(hass, pa)
105 | command = ProcessSpeech(pa, localize, command, default_device).results
106 | _LOGGER.debug("Processed Command: %s", {i: command[i] for i in command if i != "library" and command[i]})
107 |
108 | if not command["device"] and not default_device:
109 | no_device_error(localize)
110 | return
111 |
112 | if pa.media["updated"] < pa.library.search(sort="addedAt:desc", limit=1)[0].addedAt:
113 | type(pa).media.fget.cache_clear()
114 | _LOGGER.debug(f"Updated Library: {pa.media['updated']}")
115 |
116 | device = fuzzy(command["device"] or default_device, pa.device_names)
117 | device = run_start_script(hass, pa, command, start_script, device, default_device)
118 |
119 | _LOGGER.debug("PA Devices: %s", pa.devices)
120 | if device[1] < 60:
121 | no_device_error(localize, command["device"])
122 | return
123 | _LOGGER.debug("Device: %s", device[0])
124 |
125 | device = pa.devices[device[0]]
126 |
127 | if command["control"]:
128 | remote_control(hass, zeroconf, command["control"], device, jump_amount)
129 | return
130 |
131 | media, library = find_media(pa, command)
132 | media, offset = filter_media(pa, command, media, library)
133 |
134 | if not media:
135 | error = media_error(command, localize)
136 | _LOGGER.warning(error)
137 | if tts_errors and device["device_type"] != "plex":
138 | play_tts_error(hass, tts_dir, device["entity_id"], error, lang)
139 | return
140 |
141 | _LOGGER.debug("Media: %s", str(media.items))
142 |
143 | payload = '%s{"playqueue_id": %s, "type": "%s", "plex_server": "%s"}' % (
144 | "plex://" if device["device_type"] in ["cast", "sonos"] else "",
145 | media.playQueueID,
146 | media.playQueueType,
147 | server._server.friendlyName,
148 | )
149 |
150 | media_service(hass, device["entity_id"], "play_media", payload)
151 | seek_to_offset(hass, offset, device["entity_id"])
152 |
153 | hass.services.async_register(DOMAIN, "command", handle_input)
154 | return True
155 |
156 |
157 | async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
158 | try:
159 | hass.data[DOMAIN][entry.entry_id]["remove_listener"]()
160 | except KeyError:
161 | pass
162 | hass.services.async_remove(DOMAIN, "command")
163 | hass.data[DOMAIN].pop(entry.entry_id)
164 | return True
165 |
166 |
167 | async def async_reload_entry(hass: HomeAssistant, entry: ConfigEntry) -> None:
168 | await async_unload_entry(hass, entry)
169 | await async_setup_entry(hass, entry)
170 |
--------------------------------------------------------------------------------
/translation.md:
--------------------------------------------------------------------------------
1 | # Translation
2 |
3 | ### Contributing to translations
4 |
5 | Translations are located in the [localize.py](https://github.com/maykar/plex_assistant/blob/master/custom_components/plex_assistant/localize.py) file.
6 |
7 | Translations are held in a dictionary with the language code as the key (in this case "en"):
8 |
9 | Any item can accept a list of words/phrases like the example below. The only exceptions are oridnal numbers and error messages, they only accept on word/phrase:
10 |
11 | ```
12 | "next_track": [
13 | "go to next track",
14 | "go to next",
15 | "skip to next track",
16 | "skip to next",
17 | "skip forward",
18 | "next track",
19 | "next",
20 | "skip",
21 | ],
22 | ```
23 |
24 | ### Generic Terms
25 |
26 | The first grouping of "Generic Terms" are translations of generic words that would be used throughout.
27 | For example in `"play": "play"` the first "play" is the key and should not be changed and the second "play" is the translation of the word.
28 |
29 | The keys "movies" and "shows" contain a list of keywords that would inform us of the media type the user is looking for.
30 |
31 | ```
32 | # Generic Terms
33 | "play": [
34 | "play",
35 | ],
36 | "random": [
37 | "random",
38 | "shuffle",
39 | ],
40 | "movies": [
41 | "movie",
42 | "film",
43 | ],
44 | "shows": [
45 | "episode",
46 | "tv",
47 | "show"
48 | ],
49 | ```
50 |
51 | ### Controls
52 |
53 | These are the media player controls.
54 |
55 | ```
56 | # Controls
57 | "controls": {
58 | "play": [
59 | "play",
60 | ],
61 | "pause": [
62 | "pause",
63 | ],
64 | "stop": [
65 | "stop",
66 | ],
67 | "next_track": [
68 | "go to next track",
69 | "go to next",
70 | "skip to next track",
71 | "skip to next",
72 | "skip next",
73 | "skip forward",
74 | "next track",
75 | "next",
76 | "skip",
77 | ],
78 | "previous_track": [
79 | "go to previous track",
80 | "go back one track",
81 | "back one track",
82 | "go back",
83 | "back",
84 | ],
85 | "jump_forward": [
86 | "jump forward",
87 | "fast forward",
88 | "forward",
89 | ],
90 | "jump_back": [
91 | "jump back",
92 | "rewind",
93 | ],
94 | },
95 | ```
96 |
97 | ### Errors
98 |
99 | Text for errors, use only single items/strings for the translation like in the example below.
100 |
101 | ```
102 | "not_found": "not found",
103 | "cast_device": "cast device",
104 | "no_call": "No command was received.",
105 | ```
106 |
107 | ### Invoke Commands
108 |
109 | The next part is an array with the key "play_start". These are phrases of how someone could start the command.
110 | Each of these is tested against "movies" and "shows" from above to decide if the user is looking for a show or a movie.
111 | Once any of the play_start phrases are found they are removed from the command so that they don't add to other options like
112 | the media title, so it is important to have as many ways that it could be phrased included.
113 | ```
114 | # Invoke Command
115 | "play_start": [
116 | "play the movie",
117 | "play movie",
118 | "play the tv show",
119 | "play tv show",
120 | "play the show",
121 | "play tv",
122 | "play show",
123 | "play the",
124 | "play"
125 | ],
126 | ```
127 |
128 | ### Keywords, Pre, and Post
129 |
130 | The rest of the dictionary uses keywords, pre, and post.
131 | * "keywords" are the different ways that someone might say what they are looking for.
132 | * "pre" are words that might preceed the keywords.
133 | * "post" are words that might proceed the keywords.
134 |
135 | Pre and post should be ordered by proximity to the keyword. For for the example with a keyword "latest" and a command of `"play the very latest episode of"` the pre list should be in this order `"very", "the"` and the post list should be in this order `"episode", "of"` (the word "very" isn't actually handled, but just used as an example).
136 |
137 | For example, the english version for latest episode selection looks like this:
138 | ```
139 | "latest": {
140 | "keywords": [
141 | "latest",
142 | "recent",
143 | "new",
144 | ],
145 | "pre": [
146 | "the",
147 | ],
148 | "post": [
149 | "episode",
150 | "of",
151 | ],
152 | },
153 | ```
154 | This will allow the user to say something like `play the latest episode of Friends`, `play latest of Friends`, `play latest Friends`, or `play the latest Friends`
155 | and the options for latest and media type are set, then our command in each case becomes `play Friends`.
156 |
157 |
158 | ### Ordinal Numbers
159 |
160 | The ordinals section is for converting ordinal numbers (`first, second, third...`) into their corrisponding integers (`1, 2, 3...`). I'm not entirely sure how other languages handle ordinals, but this is the only section where you would edit the keys for translation and leave the integers alone. This section also includes "pre" and "post" as above, do not change their keys.
161 |
162 | ```
163 | # Ordinal Numbers to Integers
164 | "ordinals": {
165 | "first": "1",
166 | "second": "2",
167 | "third": "3",
168 | "fourth": "4",
169 | "fifth": "5",
170 | "sixth": "6",
171 | "seventh": "7",
172 | "eighth": "8",
173 | "ninth": "9",
174 | "tenth": "10",
175 | "pre": [
176 | "the",
177 | ],
178 | "post": [],
179 | },
180 | ```
181 |
182 | Ordinal numbers between 1 and 10 (first and tenth) are often represented as words by Google Assistant, but anything past that is returned as an integer followed by "st", "nd", "rd" or "th" (`31st, 42nd, 23rd, 11th`). The way we would handle those in english would be using the "pre" key like in the "episode" example below.
183 |
184 | ```
185 | "episode": {
186 | "keywords": [
187 | "episode",
188 | ],
189 | "pre": [
190 | 'st',
191 | 'nd',
192 | 'rd',
193 | 'th',
194 | ],
195 | "post": [
196 | "number",
197 | "of",
198 | ],
199 | },
200 | ```
201 |
202 | ### Seperator
203 |
204 | This is the word that seperates the media from cast device. In English it is "on". It operates like the keywords above with a post and pre. The "music_separator" works the same way, but for phrases like `"Play album by artist"`.:
205 |
206 | ```
207 | "separator": {
208 | # Only use one keyword for this one.
209 | "keywords": [
210 | "on",
211 | ],
212 | "pre": [],
213 | "post": [
214 | "the",
215 | ],
216 | },
217 | ```
218 |
219 | ### Additional Info
220 |
221 | There is a commented out template at the end of the file that you may copy and paste from.
222 |
223 | Please, also consider translating `custom_components/plex_assistant/translations/en.json` to a new file with the language code replaced in the file name. Example: `custom_components/plex_assistant/translations/nl.json`. This is for the setup and options on HA's integration page.
224 |
--------------------------------------------------------------------------------
/custom_components/plex_assistant/process_speech.py:
--------------------------------------------------------------------------------
1 | import re
2 |
3 | from .helpers import fuzzy
4 |
5 |
6 | class ProcessSpeech:
7 | def __init__(self, pa, localize, command, default_cast):
8 | self.pa = pa
9 | self.command = command
10 | self.localize = localize
11 | self.device = default_cast
12 | self.tv_keys = localize["shows"] + localize["season"]["keywords"] + localize["episode"]["keywords"]
13 | self.music_keys = localize["music"] + localize["artists"] + localize["albums"] + localize["tracks"]
14 | self.random = False
15 | self.control = None
16 | self.library = None
17 | self.media = None
18 | self.process_command()
19 |
20 | @property
21 | def results(self):
22 | options = [
23 | "media",
24 | "device",
25 | "season",
26 | "episode",
27 | "latest",
28 | "unwatched",
29 | "random",
30 | "ondeck",
31 | "control",
32 | "library",
33 | ]
34 | return {option: getattr(self, option, None) for option in options}
35 |
36 | def process_command(self):
37 | controls = self.localize["controls"]
38 | pre_command = self.command
39 | for control in controls:
40 | ctrl = [controls[control]] if isinstance(controls[control], str) else controls[control]
41 | for c in ctrl:
42 | if self.command.startswith(c):
43 | control_check = self.command.replace(c, "").strip()
44 | if control_check == "":
45 | self.control = control
46 | return
47 | device = fuzzy(control_check, self.pa.device_names)
48 | self.find_replace("separator")
49 | if device[0] in ["watched", "deck", "on watched", "on deck"]:
50 | continue
51 | if device[1] > 60 and self.command.replace(device[0].lower(), "").strip() == c:
52 | self.device = device[0]
53 | self.control = control
54 | return
55 | self.command = pre_command
56 |
57 | self.library = self.get_library()
58 | self.find_replace("play_start")
59 |
60 | for item in ["random", "latest", "unwatched", "ondeck"]:
61 | setattr(self, item, self.find_replace(item))
62 |
63 | for item in ["season", "episode"]:
64 | if self.find_replace(item, False):
65 | self.library = "show"
66 | setattr(self, item, self.get_season_episode_num(self.localize[item]))
67 | self.find_replace(item)
68 |
69 | for item in ["artist", "album", "track"]:
70 | if self.find_replace(f"music_{item}"):
71 | self.library = item
72 |
73 | self.get_media_and_device()
74 |
75 | def get_library(self):
76 | cmd = self.command
77 | for device in self.pa.device_names:
78 | if device.lower() in cmd:
79 | cmd = cmd.replace(device.lower(), "")
80 |
81 | for item in ["shows", "movies", "artists", "albums", "tracks", "playlists"]:
82 | if any(word in cmd for word in self.localize[item]):
83 | return item[:-1]
84 |
85 | if any(word in cmd for word in self.music_keys):
86 | return "track"
87 | if any(word in cmd for word in self.tv_keys):
88 | return "episode"
89 |
90 | def is_device(self, media_list, separator):
91 | split = self.command.split(separator)
92 | full_score = fuzzy(self.command, media_list)[1]
93 | split_score = fuzzy(self.command.replace(split[-1], "")[0], media_list)[1]
94 | cast_score = fuzzy(split[-1], self.pa.device_names)[1]
95 | return full_score < split_score or full_score < cast_score
96 |
97 | def clear_generic(self):
98 | self.find_replace("movies")
99 | self.find_replace("playlists")
100 | for key in self.music_keys + self.tv_keys:
101 | self.command = self.command.replace(key, "")
102 |
103 | def get_media_and_device(self):
104 | for separator in self.localize["separator"]["keywords"]:
105 | if separator in self.command:
106 | self.find_replace("separator", True, separator)
107 |
108 | if self.command.strip().startswith(separator + " "):
109 | self.device = self.command.replace(separator, "").strip()
110 | return
111 |
112 | separator = f" {separator} "
113 | if separator in self.command:
114 | for item in ["show", "movie", "artist", "album", "track", "playlist", "all"]:
115 | if item == "all" or self.library == item:
116 | self.device = self.is_device(self.pa.media[f"{item}_titles"], separator)
117 |
118 | if self.device:
119 | split = self.command.split(separator)
120 | self.command = self.command.replace(separator + split[-1], "")
121 | self.device = split[-1]
122 |
123 | self.clear_generic()
124 |
125 | if self.find_replace("music_separator", False) and getattr(self, "library", None) in [
126 | "artist",
127 | "album",
128 | "track",
129 | None,
130 | ]:
131 | self.media = self.media_by_artist() or self.command
132 |
133 | if not getattr(self, "media", None):
134 | self.clear_generic()
135 | self.media = self.command
136 |
137 | def media_by_artist(self):
138 | for separator in self.localize["music_separator"]["keywords"]:
139 | if separator in self.command:
140 | self.find_replace("music_separator", True, separator)
141 | split = self.command.split(f" {separator} ")
142 | artist = fuzzy(split[-1], self.pa.media["artist_titles"])
143 | if artist[1] > 60:
144 | albums = self.pa.server.search(artist[0], "album")
145 | album_titles = [x.title for x in albums]
146 | tracks = self.pa.server.search(artist[0], "track")
147 | track_titles = [x.title for x in tracks]
148 | if not self.library:
149 | artist_item = fuzzy(split[0], album_titles + track_titles)
150 | if artist_item[1] > 60:
151 | return next((x for x in albums + tracks if artist_item[0] in getattr(x, "title", "")), None)
152 | elif self.library == "album":
153 | artist_item = fuzzy(split[0], album_titles)
154 | if artist_item[1] > 60:
155 | return next((x for x in albums if artist_item[0] in getattr(x, "title", "")), None)
156 | elif self.library == "track":
157 | artist_item = fuzzy(split[0], track_titles)
158 | if artist_item[1] > 60:
159 | return next((x for x in tracks if artist_item[0] in getattr(x, "title", "")), None)
160 | return self.command
161 |
162 | def find_replace(self, item, replace=True, replacement=""):
163 | item = self.localize[item]
164 | if isinstance(item, str):
165 | item = {"keywords": [item]}
166 | elif isinstance(item, list):
167 | item = {"keywords": item}
168 |
169 | if all(keyword not in self.command for keyword in item["keywords"]):
170 | return False
171 |
172 | if replace:
173 | if replacement:
174 | replacement = f" {replacement} "
175 | for keyword in item["keywords"]:
176 | self.command = f" {self.command} "
177 | for pre in item.get("pre", []):
178 | self.command = self.command.replace(f"{pre} {keyword}", replacement)
179 | for post in item.get("post", []):
180 | self.command = self.command.replace(f"{keyword} {post}", replacement)
181 | if keyword in self.command:
182 | self.command = self.command.replace(f" {keyword} ", replacement)
183 | self.command = self.command.strip()
184 | self.command = " ".join(self.command.split())
185 | return True
186 |
187 | def convert_ordinals(self, item):
188 | match = ""
189 | matched = ""
190 | ordinals = self.localize["ordinals"]
191 | for word in item["keywords"]:
192 | for ordinal in ordinals.keys():
193 | if ordinal not in ("pre", "post") and ordinal in self.command:
194 | match_before = re.search(fr"({ordinal})\s*({word})", self.command)
195 | match_after = re.search(fr"({word})\s*({ordinal})", self.command)
196 | if match_before:
197 | match = match_before
198 | matched = match.group(1)
199 | if match_after:
200 | match = match_after
201 | matched = match.group(2)
202 | if match:
203 | replacement = match.group(0).replace(matched, ordinals[matched])
204 | self.command = self.command.replace(match.group(0), replacement)
205 | for pre in ordinals["pre"]:
206 | if f"{pre} {match.group(0)}" in self.command:
207 | self.command = self.command.replace(f"{pre} {match.group(0)}", replacement)
208 | for post in ordinals["post"]:
209 | if f"{match.group(0)} {post}" in self.command:
210 | self.command = self.command.replace(f"{match.group(0)} {post}", replacement)
211 | return self.command.strip()
212 |
213 | def get_season_episode_num(self, item):
214 | self.command = self.convert_ordinals(item)
215 | phrase = ""
216 | number = None
217 | for keyword in item["keywords"]:
218 | if keyword in self.command:
219 | phrase = keyword
220 | for pre in item["pre"]:
221 | if pre in self.command:
222 | regex = fr"(\d+\s+)({pre}\s+)({phrase}\s+)"
223 | if re.search(regex, self.command):
224 | self.command = re.sub(regex, fr"{phrase} \1 ", self.command)
225 | else:
226 | self.command = re.sub(
227 | fr"({pre}\s+)({phrase}\s+)(\d+\s+)",
228 | fr"{phrase} \3",
229 | self.command,
230 | )
231 | self.command = re.sub(
232 | fr"({phrase}\s+)(\d+\s+)({pre}\s+)",
233 | fr"{phrase} \2",
234 | self.command,
235 | )
236 | for post in item["post"]:
237 | if post in self.command:
238 | regex = fr"({phrase}\s+)({post}\s+)(\d+\s+)"
239 | if re.search(regex, self.command):
240 | self.command = re.sub(regex, fr"{phrase} \3", self.command)
241 | else:
242 | self.command = re.sub(
243 | fr"(\d+\s+)({phrase}\s+)({post}\s+)",
244 | fr"{phrase} \1",
245 | self.command,
246 | )
247 | self.command = re.sub(
248 | fr"({phrase}\s+)(\d+\s+)({post}\s+)",
249 | fr" {phrase} \2",
250 | self.command,
251 | )
252 | match = re.search(fr"(\d+)\s*({phrase}|^)|({phrase}|^)\s*(\d+)", self.command)
253 | if match:
254 | number = match.group(1) or match.group(4)
255 | self.command = self.command.replace(match.group(0), "").strip()
256 | return number
257 |
--------------------------------------------------------------------------------
/custom_components/plex_assistant/helpers.py:
--------------------------------------------------------------------------------
1 | import re
2 | import time
3 | import uuid
4 | import pychromecast
5 |
6 | from rapidfuzz import fuzz, process
7 | from gtts import gTTS
8 | from json import JSONDecodeError, loads
9 | from homeassistant.components.plex.services import get_plex_server
10 | from homeassistant.exceptions import HomeAssistantError, ServiceNotFound
11 | from homeassistant.core import Context
12 | from pychromecast.controllers.plex import PlexController
13 |
14 | from .const import DOMAIN, _LOGGER
15 |
16 |
17 | def fuzzy(media, lib, scorer=fuzz.QRatio):
18 | if isinstance(lib, list) and len(lib) > 0:
19 | return process.extractOne(media, lib, scorer=scorer) or ["", 0]
20 | return ["", 0]
21 |
22 |
23 | def process_config_item(options, option_type):
24 | option = options.get(option_type)
25 | if not option:
26 | return {}
27 | try:
28 | option = loads("{" + option + "}")
29 | for i in option.keys():
30 | _LOGGER.debug(f"{option_type} {i}: {option[i]}")
31 | except (TypeError, AttributeError, KeyError, JSONDecodeError):
32 | _LOGGER.warning(f"There is a formatting error in the {option_type.replace('_', ' ')} config.")
33 | option = {}
34 | return option
35 |
36 |
37 | async def get_server(hass, config, server_name):
38 | try:
39 | await hass.helpers.discovery.async_discover(None, None, "plex", config)
40 | return get_plex_server(hass, server_name)._plex_server
41 | except HomeAssistantError as error:
42 | server_name_str = ", the server_name is correct," if server_name else ""
43 | _LOGGER.warning(
44 | f"Plex Assistant: {error.args[0]}. Ensure that you've setup the HA "
45 | f"Plex integration{server_name_str} and the server is reachable. "
46 | )
47 |
48 |
49 | def get_devices(hass, pa):
50 | for entity in list(hass.data["media_player"].entities):
51 | info = str(entity.device_info.get("identifiers", "")) if entity.device_info else ""
52 | dev_type = [x for x in ["cast", "sonos", "plex", ""] if x in info][0]
53 | if not dev_type:
54 | continue
55 | try:
56 | name = hass.states.get(entity.entity_id).attributes.get("friendly_name")
57 | except AttributeError:
58 | continue
59 | pa.devices[name] = {"entity_id": entity.entity_id, "device_type": dev_type}
60 |
61 |
62 | def run_start_script(hass, pa, command, start_script, device, default_device):
63 | if device[0] in start_script.keys():
64 | start = hass.data["script"].get_entity(start_script[device[0]])
65 | start.script.run(context=Context())
66 | get_devices(hass, pa)
67 | return fuzzy(command["device"] or default_device, list(pa.devices.keys()))
68 | return device
69 |
70 |
71 | async def listeners(hass):
72 | def ifttt_webhook_callback(event):
73 | if event.data["service"] == "plex_assistant.command":
74 | _LOGGER.debug("IFTTT Call: %s", event.data["command"])
75 | hass.services.call(DOMAIN, "command", {"command": event.data["command"]})
76 |
77 | listener = hass.bus.async_listen("ifttt_webhook_received", ifttt_webhook_callback)
78 | try:
79 | await hass.services.async_call("conversation", "process", {"text": "tell plex to initialize_plex_intent"})
80 | except ServiceNotFound:
81 | pass
82 | return listener
83 |
84 |
85 | def media_service(hass, entity_id, call, payload=None):
86 | args = {"entity_id": entity_id}
87 | if call == "play_media":
88 | args = {**args, **{"media_content_type": "video", "media_content_id": payload}}
89 | elif call == "media_seek":
90 | args = {**args, **{"seek_position": payload}}
91 | hass.services.call("media_player", call, args)
92 |
93 |
94 | def jump(hass, device, amount):
95 | if device["device_type"] == "plex":
96 | media_service(hass, device["entity_id"], "media_pause")
97 | time.sleep(0.5)
98 |
99 | offset = hass.states.get(device["entity_id"]).attributes.get("media_position", 0) + amount
100 | media_service(hass, device["entity_id"], "media_seek", offset)
101 |
102 | if device["device_type"] == "plex":
103 | media_service(hass, device["entity_id"], "media_play")
104 |
105 |
106 | def cast_next_prev(hass, zeroconf, plex_c, device, direction):
107 | entity = hass.data["media_player"].get_entity(device["entity_id"])
108 | cast, browser = pychromecast.get_listed_chromecasts(
109 | uuids=[uuid.UUID(entity._cast_info.uuid)], zeroconf_instance=zeroconf
110 | )
111 | pychromecast.discovery.stop_discovery(browser)
112 | cast[0].register_handler(plex_c)
113 | cast[0].wait()
114 | if direction == "next":
115 | plex_c.next()
116 | else:
117 | plex_c.previous()
118 |
119 |
120 | def remote_control(hass, zeroconf, control, device, jump_amount):
121 | plex_c = PlexController()
122 | if control == "jump_forward":
123 | jump(hass, device, jump_amount[0])
124 | elif control == "jump_back":
125 | jump(hass, device, -jump_amount[1])
126 | elif control == "next_track" and device["device_type"] == "cast":
127 | cast_next_prev(hass, zeroconf, plex_c, device, "next")
128 | elif control == "previous_track" and device["device_type"] == "cast":
129 | cast_next_prev(hass, zeroconf, plex_c, device, "previous")
130 | else:
131 | media_service(hass, device["entity_id"], f"media_{control}")
132 |
133 |
134 | def seek_to_offset(hass, offset, entity):
135 | if offset < 1:
136 | return
137 | timeout = 0
138 | while not hass.states.is_state(entity, "playing") and timeout < 100:
139 | time.sleep(0.10)
140 | timeout += 1
141 |
142 | timeout = 0
143 | if hass.states.is_state(entity, "playing"):
144 | media_service(hass, entity, "media_pause")
145 | while not hass.states.is_state(entity, "paused") and timeout < 100:
146 | time.sleep(0.10)
147 | timeout += 1
148 |
149 | if hass.states.is_state(entity, "paused"):
150 | if hass.states.get(entity).attributes.get("media_position", 0) < 9:
151 | media_service(hass, entity, "media_seek", offset)
152 | media_service(hass, entity, "media_play")
153 |
154 |
155 | def no_device_error(localize, device=None):
156 | device = f': "{device.title()}".' if device else "."
157 | _LOGGER.warning(f"{localize['cast_device'].capitalize()} {localize['not_found']}{device}")
158 |
159 |
160 | def media_error(command, localize):
161 | error = "".join(
162 | f"{localize[keyword]['keywords'][0]} " for keyword in ["latest", "unwatched", "ondeck"] if command[keyword]
163 | )
164 | if command["media"]:
165 | media = command["media"]
166 | media = media if isinstance(media, str) else getattr(media, "title", str(media))
167 | error += f"{media.capitalize()} "
168 | elif command["library"]:
169 | error += f"{localize[command['library']+'s'][0]} "
170 | for keyword in ["season", "episode"]:
171 | if command[keyword]:
172 | error += f"{localize[keyword]['keywords'][0]} {command[keyword]} "
173 | error += f"{localize['not_found']}."
174 | return error.capitalize()
175 |
176 |
177 | def play_tts_error(hass, tts_dir, device, error, lang):
178 | tts = gTTS(error, lang=lang)
179 | tts.save(tts_dir + "error.mp3")
180 | hass.services.call(
181 | "media_player",
182 | "play_media",
183 | {
184 | "entity_id": device,
185 | "media_content_type": "audio/mp3",
186 | "media_content_id": "/local/plex_assist_tts/error.mp3",
187 | },
188 | )
189 |
190 |
191 | def filter_media(pa, command, media, library):
192 | offset = 0
193 |
194 | if library == "playlist":
195 | media = pa.server.playlist(media) if media else pa.server.playlists()
196 | elif media or library:
197 | media = pa.library.search(title=media or None, libtype=library or None)
198 |
199 | if isinstance(media, list) and len(media) == 1:
200 | media = media[0]
201 |
202 | if command["episode"]:
203 | media = media.episode(season=int(command["season"] or 1), episode=int(command["episode"]))
204 | elif command["season"]:
205 | media = media.season(season=int(command["season"]))
206 |
207 | if command["ondeck"]:
208 | title, libtype = [command["media"], command["library"]]
209 | if getattr(media, "onDeck", None):
210 | media = media.onDeck()
211 | elif title or libtype:
212 | search_result = pa.library.search(title=title or None, libtype=libtype or None, limit=1)[0]
213 | if getattr(search_result, "onDeck", None):
214 | media = search_result.onDeck()
215 | else:
216 | media = pa.library.sectionByID(search_result.librarySectionID).onDeck()
217 | else:
218 | media = pa.library.sectionByID(pa.tv_id).onDeck() + pa.library.sectionByID(pa.movie_id).onDeck()
219 | media.sort(key=lambda x: getattr(x, "addedAt", None), reverse=False)
220 |
221 | if command["unwatched"]:
222 | if isinstance(media, list) or (not media and not library):
223 | media = media[:200] if isinstance(media, list) else pa.library.recentlyAdded()
224 | media = [x for x in media if getattr(x, "viewCount", 0) == 0]
225 | elif getattr(media, "unwatched", None):
226 | media = media.unwatched()[:200]
227 |
228 | if command["latest"] and not command["unwatched"]:
229 | if library and not media and pa.section_id[library]:
230 | media = pa.library.sectionByID(pa.section_id[library]).recentlyAdded()[:200]
231 | elif not media:
232 | media = pa.library.sectionByID(pa.tv_id).recentlyAdded()
233 | media += pa.library.sectionByID(pa.mov_id).recentlyAdded()
234 | media.sort(key=lambda x: getattr(x, "addedAt", None), reverse=True)
235 | media = media[:200]
236 | elif command["latest"]:
237 | if getattr(media, "type", None) in ["show", "season"]:
238 | media = media.episodes()[-1]
239 | elif isinstance(media, list):
240 | media = media[:200]
241 | media.sort(key=lambda x: getattr(x, "addedAt", None), reverse=True)
242 |
243 | if not command["random"] and media:
244 | pos = getattr(media[0], "viewOffset", 0) if isinstance(media, list) else getattr(media, "viewOffset", 0)
245 | offset = (pos / 1000) - 5 if pos > 15 else 0
246 |
247 | if getattr(media, "TYPE", None) == "show":
248 | unwatched = media.unwatched()[:30]
249 | media = unwatched if unwatched and not command["random"] else media.episodes()[:30]
250 | elif getattr(media, "TYPE", None) == "episode":
251 | episodes = media.show().episodes()
252 | episodes = episodes[episodes.index(media) : episodes.index(media) + 30]
253 | media = pa.server.createPlayQueue(episodes, shuffle=int(command["random"]))
254 | elif getattr(media, "TYPE", None) in ["artist", "album"]:
255 | tracks = media.tracks()
256 | media = pa.server.createPlayQueue(tracks, shuffle=int(command["random"]))
257 | elif getattr(media, "TYPE", None) == "track":
258 | tracks = media.album().tracks()
259 | tracks = tracks[tracks.index(media) :]
260 | media = pa.server.createPlayQueue(tracks, shuffle=int(command["random"]))
261 |
262 | if getattr(media, "TYPE", None) != "playqueue" and media:
263 | media = pa.server.createPlayQueue(media, shuffle=int(command["random"]))
264 |
265 | return [media, 0 if media and media.items[0].listType == "audio" else offset]
266 |
267 |
268 | def roman_numeral_test(media, lib):
269 | regex = re.compile(r"\b(\d|(10))\b")
270 | replacements = {
271 | "1": "I",
272 | "2": "II",
273 | "3": "III",
274 | "4": "IV",
275 | "5": "V",
276 | "6": "VI",
277 | "7": "VII",
278 | "8": "VIII",
279 | "9": "IX",
280 | "10": "X",
281 | }
282 |
283 | if len(re.findall(regex, media)) > 0:
284 | replaced = re.sub(regex, lambda m: replacements[m.group(1)], media)
285 | return fuzzy(replaced, lib, fuzz.WRatio)
286 | return ["", 0]
287 |
288 |
289 | def find_media(pa, command):
290 | result = ""
291 | lib = ""
292 | if getattr(command["media"], "type", None) in ["artist", "album", "track"]:
293 | return [command["media"], command["media"].type]
294 | if command["library"]:
295 | lib_titles = pa.media[f"{command['library']}_titles"]
296 | if command["media"]:
297 | result = fuzzy(command["media"], lib_titles, fuzz.WRatio)
298 | roman_test = roman_numeral_test(command["media"], lib_titles)
299 | result = result[0] if result[1] > roman_test[1] else roman_test[0]
300 | elif command["media"]:
301 | item = {}
302 | score = {}
303 | for category in ["show", "movie", "artist", "album", "track", "playlist"]:
304 | lib_titles = pa.media[f"{category}_titles"]
305 | standard = fuzzy(command["media"], lib_titles, fuzz.WRatio) if lib_titles else ["", 0]
306 | roman = roman_numeral_test(command["media"], lib_titles) if lib_titles else ["", 0]
307 |
308 | winner = standard if standard[1] > roman[1] else roman
309 | item[category] = winner[0]
310 | score[category] = winner[1]
311 |
312 | winning_category = max(score, key=score.get)
313 | result = item[winning_category]
314 | lib = winning_category
315 |
316 | return [result, lib or command["library"]]
317 |
--------------------------------------------------------------------------------
/OLD_README.md:
--------------------------------------------------------------------------------
1 | # ❱ Plex Assistant
2 |
3 | [](https://github.com/custom-components/hacs) [](https://www.buymeacoffee.com/FgwNR2l)
4 |
5 | [Installation](#installation) | [Configuration](#configuration) | [Cast Devices](#cast-devices) | [Commands](#commands)
6 | [Google Assistant Triggers](#google-assistant-triggers) | [HA Conversation Setup](#home-assistant-conversation-setup)