├── hacs.json ├── .github ├── workflows │ └── validate.yml └── FUNDING.yml ├── custom_components └── mopidy │ ├── const.py │ ├── manifest.json │ ├── translations │ ├── en.json │ ├── fr.json │ └── nl.json │ ├── __init__.py │ ├── services.yaml │ ├── config_flow.py │ ├── media_player.py │ └── speaker.py ├── mopidy-CHANGELOG.md ├── README.md └── LICENSE /hacs.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Mopidy Media Player component", 3 | "country": [ 4 | "BE" 5 | ], 6 | "render_readme": true, 7 | "zip_release": false, 8 | "hide_default_branch": true, 9 | "homeassistant": "2021.3" 10 | } 11 | -------------------------------------------------------------------------------- /.github/workflows/validate.yml: -------------------------------------------------------------------------------- 1 | name: Validate Integration 2 | 3 | on: 4 | push: 5 | pull_request: 6 | schedule: 7 | - cron: "0 0 * * *" 8 | workflow_dispatch: 9 | 10 | jobs: 11 | validate-hacs: 12 | runs-on: "ubuntu-latest" 13 | steps: 14 | - uses: "actions/checkout@v3" 15 | - name: HACS validation 16 | uses: "hacs/action@main" 17 | with: 18 | category: "integration" -------------------------------------------------------------------------------- /custom_components/mopidy/const.py: -------------------------------------------------------------------------------- 1 | """Constants for the Mopidy integration.""" 2 | 3 | DOMAIN = "mopidy" 4 | ICON = "mdi:speaker-wireless" 5 | DEFAULT_NAME = "Mopidy" 6 | DEFAULT_PORT = 6680 7 | SERVICE_SET_CONSUME_MODE = "set_consume_mode" 8 | SERVICE_SNAPSHOT = "snapshot" 9 | SERVICE_RESTORE = "restore" 10 | SERVICE_SEARCH = "search" 11 | SERVICE_GET_SEARCH_RESULT = "get_search_result" 12 | CACHE_TITLES = {} 13 | CACHE_ART = {} 14 | YOUTUBE_URLS = [ 15 | "youtube.com", 16 | "youtu.be" 17 | ] 18 | -------------------------------------------------------------------------------- /custom_components/mopidy/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "config_flow": true, 3 | "codeowners": [ 4 | "@bushvin" 5 | ], 6 | "domain": "mopidy", 7 | "documentation": "https://github.com/bushvin/hass-integrations#mopidy", 8 | "issue_tracker": "https://github.com/bushvin/hass-integrations/issues", 9 | "name": "Mopidy", 10 | "requirements": [ 11 | "mopidyapi>=1.1.0", 12 | "spotifyaio==0.9.0" 13 | ], 14 | "version": "2.4.1", 15 | "zeroconf": [ 16 | { 17 | "type": "_mopidy-http._tcp.local." 18 | } 19 | ] 20 | } 21 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | # These are supported funding model platforms 2 | 3 | github: # Replace with up to 4 GitHub Sponsors-enabled usernames e.g., [user1, user2] 4 | patreon: # Replace with a single Patreon username 5 | open_collective: # Replace with a single Open Collective username 6 | ko_fi: bushvin 7 | tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel 8 | community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry 9 | liberapay: # Replace with a single Liberapay username 10 | issuehunt: # Replace with a single IssueHunt username 11 | otechie: # Replace with a single Otechie username 12 | lfx_crowdfunding: # Replace with a single LFX Crowdfunding project-name e.g., cloud-foundry 13 | custom: # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2'] 14 | -------------------------------------------------------------------------------- /custom_components/mopidy/translations/en.json: -------------------------------------------------------------------------------- 1 | { 2 | "config": { 3 | "abort": { 4 | "cannot_connect": "Cannot connect to discovered Mopidy Server", 5 | "not_mopidy": "Not a Mopidy Server" 6 | }, 7 | "step": { 8 | "discovery_confirm": { 9 | "description": "Do you want to add Mopidy Server {name} (`{host}:{port}`) to Home Assistant?", 10 | "title": "Discovered Mopidy Server" 11 | }, 12 | "user": { 13 | "title": "Add Mopidy Server", 14 | "description": "Set up your Mopidy Server host. Make sure to specify the correct FQDN or IP Addres. Do not use 'localhost', '127.0.0.1', or '::1'.", 15 | "data": { 16 | "host": "Hostname", 17 | "name": "Name", 18 | "port": "Port" 19 | } 20 | } 21 | }, 22 | "error": { 23 | "cannot_connect": "Cannot Connect to Mopidy host", 24 | "unknown": "Unknown Error" 25 | } 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /custom_components/mopidy/translations/fr.json: -------------------------------------------------------------------------------- 1 | { 2 | "config": { 3 | "abort": { 4 | "cannot_connect": "Impossible de se connecter au Serveur Mopidy d\u00e9couvert", 5 | "not_mopidy": "Ceci n'est pas un Serveur Mopidy" 6 | }, 7 | "step": { 8 | "discovery_confirm": { 9 | "description": "Voulez-vous ajouter le Serveur Mopidy {name} (`{host}:{port}`) \u00e0 Home Assistant?", 10 | "title": "Servuer Mopidy d\u00e9couvert" 11 | }, 12 | "user": { 13 | "title": "Ajouter Serveur Mopidy", 14 | "description": "Configurez votre Serveur Mopidy. Specifiez un FQDN ou adresse IP correcte. N'utilisez pas 'localhost', '127.0.0.1', ou '::1'.", 15 | "data": { 16 | "host": "H\u00f4te", 17 | "name": "Nom", 18 | "port": "Port" 19 | } 20 | } 21 | }, 22 | "error": { 23 | "cannot_connect": "\u00c9chec de connexion vers l'H\u00f4te Mopidy", 24 | "unknown": "Erreur inconnu" 25 | } 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /custom_components/mopidy/translations/nl.json: -------------------------------------------------------------------------------- 1 | { 2 | "config": { 3 | "abort": { 4 | "cannot_connect": "Kan geen verbinding maken met ontdekte Mopidy Server", 5 | "not_mopidy": "Apparaat is geen Mopidy Server" 6 | }, 7 | "step": { 8 | "discovery_confirm": { 9 | "description": "Wilt u Mopidy Server {name} (`{host}:{port}`) toevoegen aan Home Assistant?", 10 | "title": "Mopidy Server Ontdekt" 11 | }, 12 | "user": { 13 | "title": "Mopidy Server toevoegen", 14 | "description": "Configureer uw Mopidy Server apparaat. Zorg ervoor het correcte FQDN of IP adres in te geven. Gebruik de volgende adressen niet: 'localhost', '127.0.0.1', of '::1'", 15 | "data": { 16 | "host": "Hostname", 17 | "name": "Naam", 18 | "port": "Poort" 19 | } 20 | } 21 | }, 22 | "error": { 23 | "cannot_connect": "Kan geen verbinding maken met Mopidy host", 24 | "unknown": "Ongekende fout" 25 | } 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /custom_components/mopidy/__init__.py: -------------------------------------------------------------------------------- 1 | """The mopidy component.""" 2 | import logging 3 | 4 | from mopidyapi import MopidyAPI 5 | from requests.exceptions import ConnectionError as reConnectionError 6 | 7 | from homeassistant.components.media_player import DOMAIN as MEDIA_PLAYER_DOMAIN 8 | from homeassistant.config_entries import ConfigEntry 9 | from homeassistant.const import CONF_HOST, CONF_PORT 10 | from homeassistant.core import HomeAssistant 11 | from homeassistant.exceptions import ConfigEntryNotReady 12 | 13 | from .const import DOMAIN 14 | 15 | _LOGGER = logging.getLogger(__name__) 16 | 17 | 18 | async def async_setup(hass: HomeAssistant, config: dict): 19 | """Set up the mopidy component.""" 20 | return True 21 | 22 | 23 | def _test_connection(host, port): 24 | client = MopidyAPI( 25 | host=host, 26 | port=port, 27 | use_websocket=False, 28 | logger=logging.getLogger(__name__ + ".client"), 29 | ) 30 | client.rpc_call("core.get_version") 31 | return True 32 | 33 | 34 | async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry): 35 | """Set up the mopidy from a config entry.""" 36 | try: 37 | await hass.async_add_executor_job( 38 | _test_connection, entry.data[CONF_HOST], entry.data[CONF_PORT] 39 | ) 40 | 41 | except reConnectionError as error: 42 | raise ConfigEntryNotReady from error 43 | 44 | hass.data.setdefault(DOMAIN, {}) 45 | 46 | await hass.config_entries.async_forward_entry_setups(entry, [MEDIA_PLAYER_DOMAIN]) 47 | return True 48 | -------------------------------------------------------------------------------- /custom_components/mopidy/services.yaml: -------------------------------------------------------------------------------- 1 | restore: 2 | name: Restore 3 | description: Restore a snapshot of the Mopidy Server. 4 | target: 5 | entity: 6 | integration: mopidy 7 | domain: media_player 8 | 9 | snapshot: 10 | name: Snapshot 11 | description: Take a snapshot of the Mopidy Server 12 | target: 13 | entity: 14 | integration: mopidy 15 | domain: media_player 16 | 17 | search: 18 | name: Search 19 | description: 20 | Search the mopidy library for audio, and add it to the current queue. 21 | The service does not start playing or clear the queue in any way, so this needs to be 22 | handled by separate service calls (like `clear_playlist()` and `media_play()`) 23 | target: 24 | entity: 25 | integration: mopidy 26 | domain: media_player 27 | fields: 28 | exact: 29 | name: Match exactly 30 | description: Should the search be an exact match 31 | example: "false" 32 | default: false 33 | selector: 34 | boolean: 35 | keyword: 36 | name: Search keywords 37 | description: The keywords to search for. Will search all track fields. 38 | example: Everlong 39 | selector: 40 | text: 41 | keyword_album: 42 | name: Search album title 43 | description: The keywords to search for in album titles. 44 | example: From Mars to Sirius 45 | selector: 46 | text: 47 | keyword_artist: 48 | name: Search artist 49 | description: The keywords to search for in artists. 50 | example: Queens of the Stoneage 51 | selector: 52 | text: 53 | keyword_genre: 54 | name: Search genre 55 | description: The keywords to search for in genres. 56 | example: rock 57 | selector: 58 | text: 59 | keyword_track_name: 60 | name: Search track name 61 | description: The keywords to search for in track names. 62 | example: Lazarus 63 | selector: 64 | text: 65 | source: 66 | name: Limit search to source 67 | description: 68 | URI sources to search. 69 | `local`, `spotify` and `tunein` are the only supported options. Make sure to have these extensions enabled on 70 | your Mopidy Server! Separate multiple sources with a comma (,). 71 | example: "local,spotify" 72 | selector: 73 | text: 74 | 75 | get_search_result: 76 | name: Get search result 77 | description: 78 | Search the mopidy library for audio and returns any URIs found. 79 | target: 80 | entity: 81 | integration: mopidy 82 | domain: media_player 83 | fields: 84 | exact: 85 | name: Match exactly 86 | description: Should the search be an exact match 87 | example: "false" 88 | default: false 89 | selector: 90 | boolean: 91 | keyword: 92 | name: Search keywords 93 | description: The keywords to search for. Will search all track fields. 94 | example: Everlong 95 | selector: 96 | text: 97 | keyword_album: 98 | name: Search album title 99 | description: The keywords to search for in album titles. 100 | example: From Mars to Sirius 101 | selector: 102 | text: 103 | keyword_artist: 104 | name: Search artist 105 | description: The keywords to search for in artists. 106 | example: Queens of the Stoneage 107 | selector: 108 | text: 109 | keyword_genre: 110 | name: Search genre 111 | description: The keywords to search for in genres. 112 | example: rock 113 | selector: 114 | text: 115 | keyword_track_name: 116 | name: Search track name 117 | description: The keywords to search for in track names. 118 | example: Lazarus 119 | selector: 120 | text: 121 | source: 122 | name: Limit search to source 123 | description: 124 | URI sources to search. 125 | `local`, `spotify` and `tunein` are the only supported options. Make sure to have these extensions enabled on 126 | your Mopidy Server! Separate multiple sources with a comma (,). 127 | example: "local,spotify" 128 | selector: 129 | text: 130 | 131 | set_consume_mode: 132 | name: 'Set the mopidy consume mode' 133 | description: 134 | Set/Unset the consume mode in mopidy. Setting this will remove tracks from the tracklist 135 | when they have been played 136 | target: 137 | entity: 138 | integration: mopidy 139 | domain: media_player 140 | fields: 141 | consume_mode: 142 | name: Set or unset the consume mode 143 | description: Set the Consume mode. 144 | example: "false" 145 | required: true 146 | default: false 147 | selector: 148 | boolean: 149 | -------------------------------------------------------------------------------- /custom_components/mopidy/config_flow.py: -------------------------------------------------------------------------------- 1 | """Config flow for Mopidy.""" 2 | import logging 3 | import re 4 | import socket 5 | from typing import Optional 6 | 7 | from mopidyapi import MopidyAPI 8 | from requests.exceptions import ConnectionError as reConnectionError 9 | import voluptuous as vol 10 | 11 | from homeassistant import config_entries 12 | from homeassistant.const import CONF_HOST, CONF_ID, CONF_NAME, CONF_PORT, CONF_TYPE 13 | from homeassistant.core import callback 14 | import homeassistant.helpers.config_validation as cv 15 | from homeassistant.helpers.typing import DiscoveryInfoType 16 | 17 | from .const import DEFAULT_PORT, DOMAIN # pylint: disable=unused-import 18 | 19 | _LOGGER = logging.getLogger(__name__) 20 | 21 | 22 | def _validate_input(host, port): 23 | """Validate the user input.""" 24 | client = MopidyAPI( 25 | host=host, 26 | port=port, 27 | use_websocket=False, 28 | logger=logging.getLogger(__name__ + ".client"), 29 | ) 30 | client.rpc_call("core.get_version") 31 | return True 32 | 33 | 34 | class MopidyConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): 35 | """Handle config flow for Mopidy Servers.""" 36 | 37 | VERSION = 1 38 | CONNECTION_CLASS = config_entries.CONN_CLASS_LOCAL_POLL 39 | 40 | def __init__(self): 41 | """Initialize flow.""" 42 | self._host: Optional[str] = None 43 | self._port: Optional[int] = None 44 | self._name: Optional[str] = None 45 | self._uuid: Optional[str] = None 46 | 47 | @callback 48 | def _async_get_entry(self): 49 | return self.async_create_entry( 50 | title=self._name, 51 | data={ 52 | CONF_NAME: self._name, 53 | CONF_HOST: self._host, 54 | CONF_PORT: self._port, 55 | CONF_ID: self._uuid, 56 | }, 57 | ) 58 | 59 | async def _set_uid_and_abort(self): 60 | await self.async_set_unique_id(self._uuid) 61 | self._abort_if_unique_id_configured( 62 | updates={ 63 | CONF_HOST: self._host, 64 | CONF_PORT: self._port, 65 | CONF_NAME: self._name, 66 | } 67 | ) 68 | 69 | async def async_step_user(self, user_input=None): 70 | """Handle the initial step.""" 71 | errors = {} 72 | if user_input is not None: 73 | self._host = user_input[CONF_HOST] 74 | self._port = user_input[CONF_PORT] 75 | self._name = user_input[CONF_NAME] 76 | self._uuid = re.sub(r"[._-]+", "_", self._host) + "_" + str(self._port) 77 | 78 | try: 79 | await self.hass.async_add_executor_job( 80 | _validate_input, self._host, self._port 81 | ) 82 | except reConnectionError: 83 | _LOGGER.error("Can't connect to %s:%d", self._host, self._port) 84 | errors["base"] = "cannot_connect" 85 | except: # noqa: E722 # pylint: disable=bare-except 86 | _LOGGER.exception( 87 | "Unexpected exception connecting to %s:%d", self._host, self._port 88 | ) 89 | errors["base"] = "unknown" 90 | 91 | if not errors: 92 | await self._set_uid_and_abort() 93 | return self._async_get_entry() 94 | 95 | return self.async_show_form( 96 | step_id="user", 97 | data_schema=vol.Schema( 98 | { 99 | vol.Required(CONF_NAME): cv.string, 100 | vol.Required(CONF_HOST): cv.string, 101 | vol.Required(CONF_PORT, default=DEFAULT_PORT): cv.positive_int, 102 | } 103 | ), 104 | errors=errors, 105 | ) 106 | 107 | async def async_step_zeroconf(self, discovery_info: DiscoveryInfoType): 108 | """Handle zeroconf discovery.""" 109 | # Get mDNS address. 110 | mdns_address = discovery_info.hostname[:-1] 111 | 112 | # Try to resolve mDNS address (no Docker HASS scenario) 113 | try: 114 | socket.gethostbyname(mdns_address) 115 | # If success, use the mDNS address as host. 116 | host = mdns_address 117 | 118 | # Otherwise: 119 | except socket.gaierror: 120 | 121 | # Try to reverse solve the IP to a DNS name (Docker HASS with reachable local DNS scenario) 122 | try: 123 | ip_address = discovery_info.host 124 | host = socket.gethostbyaddr(ip_address)[0] 125 | 126 | # Fallback on IP in last resort (Docker HASS without local DNS scenario) 127 | except socket.gaierror: 128 | host = ip_address 129 | 130 | # Set host. 131 | self._host = host 132 | # Set port. 133 | self._port = int(discovery_info.port) 134 | # Set name. 135 | self._name = ( 136 | getattr(discovery_info, CONF_NAME)[: (len(getattr(discovery_info, CONF_TYPE)) +1) * -1] 137 | + "@" 138 | + str(self._port) 139 | ) 140 | # Set UUID. 141 | node_name = mdns_address.rsplit(".")[0] 142 | self._uuid = node_name + "_" + str(self._port) 143 | 144 | await self._set_uid_and_abort() 145 | 146 | return await self.async_step_discovery_confirm() 147 | 148 | async def async_step_discovery_confirm(self, user_input=None): 149 | """Handle user-confirmation of discovered node.""" 150 | if user_input is not None: 151 | try: 152 | await self.hass.async_add_executor_job( 153 | _validate_input, self._host, self._port 154 | ) 155 | 156 | return self._async_get_entry() 157 | except reConnectionError: 158 | return self.async_abort(reason="cannot_connect") 159 | 160 | return self.async_show_form( 161 | step_id="discovery_confirm", 162 | description_placeholders={ 163 | "name": self._name, 164 | "host": self._host, 165 | "port": self._port, 166 | }, 167 | ) 168 | -------------------------------------------------------------------------------- /mopidy-CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Change log 2 | 3 | All notable changes to this project will be documented in this file. 4 | 5 | The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), 6 | and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). 7 | 8 | ## [2.4.1] - 2024-11-12 9 | 10 | ### Fixed 11 | 12 | - fix deprecation of `hass.config_entries.async_forward_entry_setup` in favour of `hass.config_entries.async_forward_entry_setups` 13 | 14 | ## [2.4.0] - 2024-10-04 15 | 16 | ## Added 17 | 18 | - `get_search_result` service to look for tracks and return the result 19 | 20 | ## [2.3.5] - 2024-05-29 21 | 22 | ### Fixed 23 | 24 | - fix issue with async method called from a non async method and HA complaining about it 25 | 26 | ## [2.3.4] - 2024-01-26 27 | 28 | ### Fixed 29 | 30 | - glitch causing the card to go grey when buttons are pushed 31 | 32 | ## [2.3.3] - 2024-01-19 33 | 34 | ### Fixed 35 | 36 | - state info should be read, not written 37 | 38 | ## [2.3.2] - 2024-01-13 39 | 40 | ### Fixed 41 | 42 | - replace unoverrideable `_attr_*` properties 43 | 44 | ## [2.3.1] - 2024-01-12 45 | 46 | ### Fixed 47 | 48 | - wrong value for `is_stream` 49 | 50 | ## [2.3.0] - 2024-01-11 51 | 52 | ### Changed 53 | 54 | - don't complain when there is no `image_url` and a stream is playing. 55 | 56 | ## [2.2.2] - 2024-01-06 57 | 58 | Happy New Year! 59 | 60 | ### Changed 61 | 62 | - bumped `mopidyapi` version requirement to 1.1.0 63 | 64 | ### Fixed 65 | 66 | - default `media_player` properties changed to `cached_property` types 67 | 68 | ## [2.2.1] - 2023-11-13 69 | 70 | ### Fixed 71 | 72 | - expanding the url will add a timestamp based on the day instead of epoch, causing it to reload daily instead of every time the image is refreshed (which is every 10 seconds) 73 | - correct snapshotting variables/methods 74 | - alphabetize `extra_state_attributes` variables 75 | - retrieve the correct current track information 76 | - fix queue variables for `media_play` 77 | 78 | ## [2.2.0] - 2023-11-11 79 | 80 | ### Changed 81 | 82 | - Improved support queue information 83 | - Improved support for tracks playing in playlists 84 | - Overall improvement of the event code 85 | 86 | ## [2.1.3] - 2023-11-08 87 | 88 | ### Fixed 89 | 90 | - Fix error on startup when using yaml configuration (by [Daniele Ricci](https://github.com/daniele-athome)) 91 | 92 | ## [2.1.2] - 2023-11-07 93 | 94 | ### Fixed 95 | 96 | - make features a fixed set 97 | 98 | ## [2.1.1] - 2023-11-06 99 | 100 | ### Fixed 101 | 102 | - detection of youtube urls and conversion to extension compatible uris did not work 103 | 104 | ## [2.1.0] - 2023-11-06 105 | 106 | ### Changed 107 | 108 | - update `media_player` information on websocket event 109 | 110 | ## [2.0.4] - 2023-11-06 111 | 112 | ### Fixed 113 | 114 | - fix `media_player.play_media` service `enqueue.add` behaviour 115 | 116 | ## [2.0.3] - 2023-11-05 117 | 118 | ### Fixed 119 | 120 | - fix `media_player.play_media` service `enqueue.play` behaviour 121 | 122 | ## [2.0.2] - 2023-11-02 123 | 124 | ### Fixed 125 | 126 | - wrong varname for youtube (#40) 127 | 128 | ## [2.0.1] - 2023-10-31 129 | 130 | ### Changed 131 | 132 | - Better handling of youtube URLs based on available extensions 133 | - Complete the media URL with hostname and timestamps if not available 134 | 135 | ## [2.0.0] - 2023-10-31 136 | 137 | This version incorporates a refactor of the integration to include numerous new 138 | Home Assistant `media_player` features. I did not keep track of all features updated, but these incorporate the major ones 139 | 140 | ### Added 141 | 142 | - Support for `media_player.play_media` `enqueue` feature 143 | - `mopid.set_consume_mode` service 144 | - `consume_mode` entity attribute for the current consume_mode 145 | - `mopidy_extension` entity attribute for currently used extension 146 | - `queue_position` entity attribute for the index of the currently playing track in the queue 147 | - `queu_size` entity attribute for the number of tracks in the currently playing queue 148 | - `snapshot_taken_at` entity attribute to show when the snapshot was taken (if any) 149 | 150 | ### Fixed 151 | 152 | - Wrong volume level on snapshot restore 153 | 154 | ### Removed 155 | 156 | - Support for ON/OFF, as these refer to a physical ON/OFF switch. 157 | 158 | ## [1.4.8] - 2023-05-31 159 | 160 | ### Changed 161 | 162 | - Modified the way playlists are handled in the play queue 163 | 164 | ### Fixed 165 | 166 | - FIX Issue #26: Tidal playlists not expanding correctly 167 | 168 | ## [1.4.7] - 2022-09-24 169 | 170 | ### Fixed 171 | 172 | - BUGFIX: playing mopidy-local "directory" resources (eg `artists/albums`) failed as the resource is not considered 173 | a media source according to URI\_SCHEME\_REGEX 174 | - typo in the README.md 175 | 176 | ### Added 177 | 178 | - support for mopidyapi>=1.0.0, no need to stay in the stoneage 179 | 180 | ## [1.4.6] - 2022-03-06 181 | 182 | ### Fixed 183 | 184 | - playing from local media (thanks, [koying](https://github.com/koying)) 185 | 186 | ## [1.4.5] - 2022-03-05 187 | 188 | ### Added 189 | 190 | - Support for media browsing and playing from other components in HA (thanks, [koying](https://github.com/koying)) 191 | 192 | ## [1.4.4] - 2022-02-19 193 | 194 | ### Fixed 195 | 196 | - change of code for 2022.6 warning introduced issue where an int was added to a string. 197 | 198 | ## [1.4.3] - 2022-01-07 199 | 200 | ### Fixed 201 | 202 | - git version tag added before last PR 203 | 204 | ## [1.4.2] - 2022-01-03 205 | 206 | - mopidy play instruction is slow on streming media. now waiting for status to change into `playing` asynchronously 207 | - update code to comply with 2022.6 deprecation (thanks, [VDRainer](https://github.com/VDRainer)) 208 | 209 | ## [1.4.1] - 2021-05-23 210 | 211 | ### Changed 212 | 213 | - bugfix: snapshot and restore player state (thanks [AdmiralStipe](https://community.home-assistant.io/u/AdmiralStipe)) 214 | - better messages when device detected through zeroconf is not a mopidy server 215 | - formatting (pylint, pep8, pydocstyle) 216 | - fix zeroconf issues on docker (thanks, [@guix77](https://github.com/guix77)) 217 | - set name to zeroconf name and port 218 | 219 | ## [1.4.0] - 2021-04-05 220 | 221 | ### Changed 222 | 223 | - fixed issue with logging on detected non-mopidy zeroconf http devices 224 | - added service `search` 225 | - change service targetting 226 | - sort the sourcelist 227 | - modifications to pass tests to add to core 228 | 229 | ## [1.3.2] - 2021-03-14 230 | 231 | ### Changed 232 | 233 | - refactored media library routines 234 | - provide home assistant logger to MopidyAPI 235 | 236 | ## [1.3.1] - 2021-03-13 237 | 238 | ### Changed 239 | 240 | - fixed issue with snapshot/restore track index 241 | 242 | ## [1.3.0] - 2021-03-12 243 | 244 | ### Added 245 | 246 | - snapshot service 247 | - restore service 248 | - dutch translation 249 | - french translation 250 | 251 | ### Changed 252 | 253 | - fixed typo in english translation 254 | 255 | ## [1.2.0] - 2021-03-08 256 | 257 | ### Added 258 | 259 | - Support for zeroconf discovery 260 | 261 | ## [1.1.4] - 2021-03-06 262 | 263 | ### Changed 264 | 265 | - Handle connection errors in a better way 266 | 267 | ## [1.1.3] - 2021-03-06 268 | 269 | ### Changed 270 | 271 | - uids based on hostname and port number instead of hostname only, thenks @Burningstone91 272 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Home Assitant integrations 2 | 3 | Additional integrations for [Home Assistant](https://www.home-assistant.io/) 4 | 5 | ![badge_mastodon] 6 | 7 | ## Mopidy 8 | 9 | ![badge_version] ![badge_issues] ![badge_hacs_pipeline] 10 | 11 | This is a platform integration for [Mopidy Music Servers](https://mopidy.com/) 12 | 13 | ### Installation 14 | 15 | Please look at the [Mopidy installation & configuration instructions](https://docs.mopidy.com/en/latest/installation/) to set up a Mopidy Server. 16 | 17 | #### HACS 18 | 19 | 1. Install [HACS](https://hacs.xyz) 20 | 1. Go to any of the sections (integrations, frontend, automation). 21 | 1. Click on the 3 dots in the top right corner. 22 | 1. Select "Custom repositories" 23 | 1. Add the URL to the repository. 24 | 1. Select the correct category. 25 | 1. Click the "ADD" button. 26 | 1. Go to Home Assistant settings -> Integrations and add Mopidy 27 | 1. Restart HA 28 | 29 | #### Manual 30 | 31 | 1. Clone this repository 32 | 2. Copy `custom_components/mopidy` to your Home Assistant instance on `/custom_components/` 33 | 34 | ### Setup 35 | 36 | #### zeroconf 37 | 38 | Your Mopidy Servers can be detected and added to Home Assistant through zeroconf. 39 | 40 | #### GUI 41 | 42 | 1. Go to the *Integrations* page and click **+ ADD INTEGRATION** 43 | 1. Select *Mopidy* in the list of integrations 44 | 1. Fill out the requested information. Make sure to enter your correct FQDN or IP address. Using `localhost`, `127.0.0.1`, `::1` or any other loopback address will disable Mopidy-Local artwork. 45 | 1. Click Submit. 46 | 47 | Repeat the above steps to add more Mopidy Server instances. 48 | 49 | #### Manual Configuration 50 | 51 | 1. add a media player to your home assistant configuration (`/configuration.yaml`): 52 | 53 | ```yaml 54 | media_player: 55 | - name: 56 | host: 57 | port: 58 | platform: mopidy 59 | ``` 60 | 61 | 2. Restart your Home assistant to make changes take effect. 62 | 63 | ### Configuration 64 | 65 | ```yaml 66 | - name: # The name of your Mopidy server. 67 | host: # The FQDN or IP address of your Mopidy Server, do not use ::1, localhost or 127.0.0.1 68 | port: # The port number of the Mopidy Server, default: 6680 69 | platform: mopidy # specify mopidy platform 70 | ``` 71 | 72 | ### Services 73 | 74 | #### Service mopidy.get_search_result 75 | 76 | *This service was originally developed by [Daniele Ricci](https://github.com/daniele-athome)* 77 | 78 | Search media based on keywords and return them for use in a script or automation. 79 | 80 | **Note:** One of the keyword fields **must** be used: `keyword`, `keyword_album`, `keyword_artist`, `keyword_genre` or `keyword_track_name` 81 | 82 | |Service data attribute|Optional|Description|Example| 83 | |-|-|-|-| 84 | |`entity_id`|no|String or list of `entity_id`s to search and return the result to.| | 85 | |`exact`|yes|String. Should the search be an exact match|false| 86 | |`keyword`|yes|String. The keywords to search for. Will search all track fields.|Everlong| 87 | |`keyword_album`|yes|String. The keywords to search for in album titles.|From Mars to Sirius| 88 | |`keyword_artist`|yes|String. The keywords to search for in artists.|Queens of the Stoneage| 89 | |`keyword_genre`|yes|String. The keywords to search for in genres.|rock| 90 | |`keyword_track_name`|yes|String. The keywords to search for in track names.|Lazarus| 91 | |`source`|yes|String. URI sources to search. `local`, `spotify` and `tunein` are the only supported options. Make sure to have these extensions enabled on your Mopidy Server! Separate multiple sources with a comma (,).|local,spotify| 92 | 93 | ##### Example 94 | 95 | The service is to be used as a normal service returning some data into a variable. The result is actually a dictionary 96 | with keys corresponding to the media player entities used as targets in the service call. Every item has in turn a 97 | `result` attribute containing the list of actual media IDs matching the search parameters. 98 | 99 | ```yaml 100 | script: 101 | search_and_play_music: 102 | fields: 103 | [...] 104 | sequence: 105 | - action: mopidy.get_search_result 106 | data: 107 | keyword_artist: "Some music artist" 108 | keyword_track_name: "Some song title" 109 | source: local 110 | target: 111 | entity_id: "media_player.music" 112 | response_variable: music_tracks # result will be returned into this variable 113 | - if: "{{ music_tracks['media_player.music'].result|length > 0 }}" 114 | then: 115 | - action: media_player.play_media 116 | data: 117 | media_content_id: "{{ music_tracks['media_player.music'].result[0] }}" 118 | media_content_type: music 119 | target: 120 | entity_id: "media_player.music" 121 | ``` 122 | 123 | #### Service media_player.play_media 124 | 125 | The `media_content_id` needs to be formatted according to the Mopidy URI scheme. These can be easily found using the *Developer tools*. 126 | 127 | When using the `play_media` service, the Mopidy Media Player platform will attempt to discover your URL when not properly formatted. 128 | Currently supported for: 129 | 130 | - Youtube 131 | 132 | #### Service mopidy.restore 133 | 134 | Restore a previously taken snapshot of one or more Mopidy Servers 135 | 136 | The playing queue is snapshotted 137 | 138 | |Service data attribute|Optional|Description| 139 | |-|-|-| 140 | |`entity_id`|no|String or list of `entity_id`s that should have their snapshot restored.| 141 | 142 | #### Service mopidy.search 143 | 144 | Search media based on keywords and add them to the queue. This service does not replace the queue, nor does it start playing the queue. This can be achieved through the use of [media\_player.clear\_playlist](https://www.home-assistant.io/integrations/media_player/) and [media\_player.media\_play](https://www.home-assistant.io/integrations/media_player/) 145 | 146 | **Note:** One of the keyword fields **must** be used: `keyword`, `keyword_album`, `keyword_artist`, `keyword_genre` or `keyword_track_name` 147 | 148 | |Service data attribute|Optional|Description|Example| 149 | |-|-|-|-| 150 | |`entity_id`|no|String or list of `entity_id`s to search and return the result to.| | 151 | |`exact`|yes|String. Should the search be an exact match|false| 152 | |`keyword`|yes|String. The keywords to search for. Will search all track fields.|Everlong| 153 | |`keyword_album`|yes|String. The keywords to search for in album titles.|From Mars to Sirius| 154 | |`keyword_artist`|yes|String. The keywords to search for in artists.|Queens of the Stoneage| 155 | |`keyword_genre`|yes|String. The keywords to search for in genres.|rock| 156 | |`keyword_track_name`|yes|String. The keywords to search for in track names.|Lazarus| 157 | |`source`|yes|String. URI sources to search. `local`, `spotify` and `tunein` are the only supported options. Make sure to have these extensions enabled on your Mopidy Server! Separate multiple sources with a comma (,).|local,spotify| 158 | 159 | #### Service mopidy.set_consume_mode 160 | 161 | Set the mopidy consume mode for the specified entity 162 | 163 | |Service data attribute|Optional|Description| 164 | |-|-|-| 165 | |`entity_id`|no|String or list of `entity_id`s to set the consume mode of.| 166 | |`consume_mode`|no|`True` to enable consume mode, `False` to disable | 167 | 168 | #### Service mopidy.snapshot 169 | 170 | Take a snapshot of what is currently playing on one or more Mopidy Servers. This service, and the following one, are useful if you want to play a doorbell or notification sound and resume playback afterwards. 171 | 172 | **Warning:** *This service is controlled by the platform, this is not a built-in function of Mopidy Server! Restarting Home Assistant will cause the snapshot to be lost.* 173 | 174 | |Service data attribute|Optional|Description| 175 | |-|-|-| 176 | |`entity_id`|no|String or list of `entity_id`s ito take a snapshot of.| 177 | 178 | ### Notes 179 | 180 | Due to the nature of the way Mopidy provides thumbnails of the media, 181 | proxying them through Home Assistant is very resource intensive, 182 | causing delays. Therefore, I have decided to not proxy the art when 183 | using the Media Library for the time being. 184 | 185 | ## Testers 186 | 187 | - [Jan Gutowski](https://github.com/Switch123456789) 188 | 189 | [badge_version]: https://img.shields.io/github/v/tag/bushvin/hass-integrations?label=Version&style=flat-square&color=2577a1 190 | [badge_issues]: https://img.shields.io/github/issues/bushvin/hass-integrations?style=flat-square 191 | [badge_mastodon]: https://img.shields.io/mastodon/follow/1084764?domain=https%3A%2F%2Fmastodon.social&logo=mastodon&logoColor=white&style=flat-square&label=%40bushvin%40mastodon.social 192 | [badge_hacs_pipeline]: https://img.shields.io/github/actions/workflow/status/bushvin/hass-integrations/validate.yml?label=HACS%20build%20validation&style=flat-square 193 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | 2 | Apache License 3 | Version 2.0, January 2004 4 | http://www.apache.org/licenses/ 5 | 6 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 7 | 8 | 1. Definitions. 9 | 10 | "License" shall mean the terms and conditions for use, reproduction, 11 | and distribution as defined by Sections 1 through 9 of this document. 12 | 13 | "Licensor" shall mean the copyright owner or entity authorized by 14 | the copyright owner that is granting the License. 15 | 16 | "Legal Entity" shall mean the union of the acting entity and all 17 | other entities that control, are controlled by, or are under common 18 | control with that entity. For the purposes of this definition, 19 | "control" means (i) the power, direct or indirect, to cause the 20 | direction or management of such entity, whether by contract or 21 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 22 | outstanding shares, or (iii) beneficial ownership of such entity. 23 | 24 | "You" (or "Your") shall mean an individual or Legal Entity 25 | exercising permissions granted by this License. 26 | 27 | "Source" form shall mean the preferred form for making modifications, 28 | including but not limited to software source code, documentation 29 | source, and configuration files. 30 | 31 | "Object" form shall mean any form resulting from mechanical 32 | transformation or translation of a Source form, including but 33 | not limited to compiled object code, generated documentation, 34 | and conversions to other media types. 35 | 36 | "Work" shall mean the work of authorship, whether in Source or 37 | Object form, made available under the License, as indicated by a 38 | copyright notice that is included in or attached to the work 39 | (an example is provided in the Appendix below). 40 | 41 | "Derivative Works" shall mean any work, whether in Source or Object 42 | form, that is based on (or derived from) the Work and for which the 43 | editorial revisions, annotations, elaborations, or other modifications 44 | represent, as a whole, an original work of authorship. For the purposes 45 | of this License, Derivative Works shall not include works that remain 46 | separable from, or merely link (or bind by name) to the interfaces of, 47 | the Work and Derivative Works thereof. 48 | 49 | "Contribution" shall mean any work of authorship, including 50 | the original version of the Work and any modifications or additions 51 | to that Work or Derivative Works thereof, that is intentionally 52 | submitted to Licensor for inclusion in the Work by the copyright owner 53 | or by an individual or Legal Entity authorized to submit on behalf of 54 | the copyright owner. For the purposes of this definition, "submitted" 55 | means any form of electronic, verbal, or written communication sent 56 | to the Licensor or its representatives, including but not limited to 57 | communication on electronic mailing lists, source code control systems, 58 | and issue tracking systems that are managed by, or on behalf of, the 59 | Licensor for the purpose of discussing and improving the Work, but 60 | excluding communication that is conspicuously marked or otherwise 61 | designated in writing by the copyright owner as "Not a Contribution." 62 | 63 | "Contributor" shall mean Licensor and any individual or Legal Entity 64 | on behalf of whom a Contribution has been received by Licensor and 65 | subsequently incorporated within the Work. 66 | 67 | 2. Grant of Copyright License. Subject to the terms and conditions of 68 | this License, each Contributor hereby grants to You a perpetual, 69 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 70 | copyright license to reproduce, prepare Derivative Works of, 71 | publicly display, publicly perform, sublicense, and distribute the 72 | Work and such Derivative Works in Source or Object form. 73 | 74 | 3. Grant of Patent License. Subject to the terms and conditions of 75 | this License, each Contributor hereby grants to You a perpetual, 76 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 77 | (except as stated in this section) patent license to make, have made, 78 | use, offer to sell, sell, import, and otherwise transfer the Work, 79 | where such license applies only to those patent claims licensable 80 | by such Contributor that are necessarily infringed by their 81 | Contribution(s) alone or by combination of their Contribution(s) 82 | with the Work to which such Contribution(s) was submitted. If You 83 | institute patent litigation against any entity (including a 84 | cross-claim or counterclaim in a lawsuit) alleging that the Work 85 | or a Contribution incorporated within the Work constitutes direct 86 | or contributory patent infringement, then any patent licenses 87 | granted to You under this License for that Work shall terminate 88 | as of the date such litigation is filed. 89 | 90 | 4. Redistribution. You may reproduce and distribute copies of the 91 | Work or Derivative Works thereof in any medium, with or without 92 | modifications, and in Source or Object form, provided that You 93 | meet the following conditions: 94 | 95 | (a) You must give any other recipients of the Work or 96 | Derivative Works a copy of this License; and 97 | 98 | (b) You must cause any modified files to carry prominent notices 99 | stating that You changed the files; and 100 | 101 | (c) You must retain, in the Source form of any Derivative Works 102 | that You distribute, all copyright, patent, trademark, and 103 | attribution notices from the Source form of the Work, 104 | excluding those notices that do not pertain to any part of 105 | the Derivative Works; and 106 | 107 | (d) If the Work includes a "NOTICE" text file as part of its 108 | distribution, then any Derivative Works that You distribute must 109 | include a readable copy of the attribution notices contained 110 | within such NOTICE file, excluding those notices that do not 111 | pertain to any part of the Derivative Works, in at least one 112 | of the following places: within a NOTICE text file distributed 113 | as part of the Derivative Works; within the Source form or 114 | documentation, if provided along with the Derivative Works; or, 115 | within a display generated by the Derivative Works, if and 116 | wherever such third-party notices normally appear. The contents 117 | of the NOTICE file are for informational purposes only and 118 | do not modify the License. You may add Your own attribution 119 | notices within Derivative Works that You distribute, alongside 120 | or as an addendum to the NOTICE text from the Work, provided 121 | that such additional attribution notices cannot be construed 122 | as modifying the License. 123 | 124 | You may add Your own copyright statement to Your modifications and 125 | may provide additional or different license terms and conditions 126 | for use, reproduction, or distribution of Your modifications, or 127 | for any such Derivative Works as a whole, provided Your use, 128 | reproduction, and distribution of the Work otherwise complies with 129 | the conditions stated in this License. 130 | 131 | 5. Submission of Contributions. Unless You explicitly state otherwise, 132 | any Contribution intentionally submitted for inclusion in the Work 133 | by You to the Licensor shall be under the terms and conditions of 134 | this License, without any additional terms or conditions. 135 | Notwithstanding the above, nothing herein shall supersede or modify 136 | the terms of any separate license agreement you may have executed 137 | with Licensor regarding such Contributions. 138 | 139 | 6. Trademarks. This License does not grant permission to use the trade 140 | names, trademarks, service marks, or product names of the Licensor, 141 | except as required for reasonable and customary use in describing the 142 | origin of the Work and reproducing the content of the NOTICE file. 143 | 144 | 7. Disclaimer of Warranty. Unless required by applicable law or 145 | agreed to in writing, Licensor provides the Work (and each 146 | Contributor provides its Contributions) on an "AS IS" BASIS, 147 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 148 | implied, including, without limitation, any warranties or conditions 149 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 150 | PARTICULAR PURPOSE. You are solely responsible for determining the 151 | appropriateness of using or redistributing the Work and assume any 152 | risks associated with Your exercise of permissions under this License. 153 | 154 | 8. Limitation of Liability. In no event and under no legal theory, 155 | whether in tort (including negligence), contract, or otherwise, 156 | unless required by applicable law (such as deliberate and grossly 157 | negligent acts) or agreed to in writing, shall any Contributor be 158 | liable to You for damages, including any direct, indirect, special, 159 | incidental, or consequential damages of any character arising as a 160 | result of this License or out of the use or inability to use the 161 | Work (including but not limited to damages for loss of goodwill, 162 | work stoppage, computer failure or malfunction, or any and all 163 | other commercial damages or losses), even if such Contributor 164 | has been advised of the possibility of such damages. 165 | 166 | 9. Accepting Warranty or Additional Liability. While redistributing 167 | the Work or Derivative Works thereof, You may choose to offer, 168 | and charge a fee for, acceptance of support, warranty, indemnity, 169 | or other liability obligations and/or rights consistent with this 170 | License. However, in accepting such obligations, You may act only 171 | on Your own behalf and on Your sole responsibility, not on behalf 172 | of any other Contributor, and only if You agree to indemnify, 173 | defend, and hold each Contributor harmless for any liability 174 | incurred by, or claims asserted against, such Contributor by reason 175 | of your accepting any such warranty or additional liability. 176 | 177 | END OF TERMS AND CONDITIONS 178 | 179 | APPENDIX: How to apply the Apache License to your work. 180 | 181 | To apply the Apache License to your work, attach the following 182 | boilerplate notice, with the fields enclosed by brackets "[]" 183 | replaced with your own identifying information. (Don't include 184 | the brackets!) The text should be enclosed in the appropriate 185 | comment syntax for the file format. We also recommend that a 186 | file or class name and description of purpose be included on the 187 | same "printed page" as the copyright notice for easier 188 | identification within third-party archives. 189 | 190 | Copyright [yyyy] [name of copyright owner] 191 | 192 | Licensed under the Apache License, Version 2.0 (the "License"); 193 | you may not use this file except in compliance with the License. 194 | You may obtain a copy of the License at 195 | 196 | http://www.apache.org/licenses/LICENSE-2.0 197 | 198 | Unless required by applicable law or agreed to in writing, software 199 | distributed under the License is distributed on an "AS IS" BASIS, 200 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 201 | See the License for the specific language governing permissions and 202 | limitations under the License. 203 | -------------------------------------------------------------------------------- /custom_components/mopidy/media_player.py: -------------------------------------------------------------------------------- 1 | """Support to interact with a MopidyMusic Server.""" 2 | import asyncio 3 | import logging 4 | from functools import partial 5 | import re 6 | import time 7 | import urllib.parse as urlparse 8 | from typing import Any 9 | import datetime as dt 10 | 11 | import urllib.parse as urlparse 12 | from urllib.parse import parse_qs 13 | 14 | from mopidyapi import MopidyAPI 15 | from requests.exceptions import ConnectionError as reConnectionError 16 | import voluptuous as vol 17 | 18 | from homeassistant.components import media_source, spotify 19 | 20 | from homeassistant.components.media_player import ( 21 | PLATFORM_SCHEMA, 22 | BrowseMedia, 23 | MediaClass, 24 | MediaPlayerDeviceClass, 25 | MediaPlayerEntity, 26 | MediaPlayerEntityFeature, 27 | MediaPlayerState, 28 | MediaType, 29 | RepeatMode, 30 | async_process_play_media_url, 31 | ) 32 | 33 | from homeassistant.components.media_player.errors import BrowseError 34 | from homeassistant.config_entries import ConfigEntry 35 | from homeassistant.const import ( 36 | CONF_HOST, 37 | CONF_ID, 38 | CONF_NAME, 39 | CONF_PORT, 40 | STATE_UNAVAILABLE, 41 | STATE_UNKNOWN, 42 | ) 43 | from homeassistant.core import HomeAssistant, SupportsResponse 44 | from homeassistant.helpers import config_validation as cv, entity_platform 45 | from homeassistant.helpers.entity_platform import AddEntitiesCallback 46 | import homeassistant.util.dt as dt_util 47 | 48 | from .const import ( 49 | CACHE_ART, 50 | CACHE_TITLES, 51 | DEFAULT_NAME, 52 | DEFAULT_PORT, 53 | DOMAIN, 54 | ICON, 55 | SERVICE_RESTORE, 56 | SERVICE_SEARCH, 57 | SERVICE_GET_SEARCH_RESULT, 58 | SERVICE_SNAPSHOT, 59 | SERVICE_SET_CONSUME_MODE, 60 | YOUTUBE_URLS, 61 | ) 62 | 63 | from .speaker import ( 64 | MopidyLibrary, 65 | MopidySpeaker, 66 | ) 67 | 68 | PLAYABLE_MEDIA_TYPES = [ 69 | MediaType.ALBUM, 70 | MediaType.ARTIST, 71 | MediaType.EPISODE, 72 | MediaType.TRACK, 73 | ] 74 | 75 | EXPANDABLE_MEDIA_TYPES = [ 76 | MediaClass.ALBUM, 77 | MediaClass.ARTIST, 78 | MediaClass.COMPOSER, 79 | MediaClass.DIRECTORY, 80 | MediaClass.GENRE, 81 | MediaClass.MUSIC, 82 | MediaClass.PLAYLIST, 83 | MediaClass.PODCAST, 84 | ] 85 | 86 | _LOGGER = logging.getLogger(__name__) 87 | 88 | PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( 89 | { 90 | vol.Required(CONF_HOST): cv.string, 91 | vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, 92 | vol.Optional(CONF_PORT, default=DEFAULT_PORT): cv.port, 93 | } 94 | ) 95 | 96 | SEARCH_SCHEMA = { 97 | vol.Optional("exact"): cv.boolean, 98 | vol.Optional("keyword"): cv.string, 99 | vol.Optional("keyword_album"): cv.string, 100 | vol.Optional("keyword_artist"): cv.string, 101 | vol.Optional("keyword_genre"): cv.string, 102 | vol.Optional("keyword_track_name"): cv.string, 103 | vol.Optional("source"): cv.string, 104 | } 105 | 106 | 107 | def media_source_filter(item: BrowseMedia): 108 | """Filter media sources.""" 109 | return item.media_content_type.startswith("audio/") 110 | 111 | 112 | class MissingMediaInformation(BrowseError): 113 | """Missing media required information.""" 114 | 115 | class MissingMopidyExtension(BrowseError): 116 | """Missing Mopidy Extension.""" 117 | 118 | async def async_setup_entry( 119 | hass: HomeAssistant, 120 | config_entry: ConfigEntry, 121 | async_add_entities: AddEntitiesCallback, 122 | ): 123 | """Set up the Mopidy platform.""" 124 | device_uuid = config_entry.data[CONF_ID] 125 | device_name = config_entry.data[CONF_NAME] 126 | hostname = config_entry.data[CONF_HOST] 127 | port = config_entry.data[CONF_PORT] 128 | 129 | speaker = MopidySpeaker(hass, hostname, port) 130 | entity = MopidyMediaPlayerEntity(speaker, device_name, device_uuid) 131 | async_add_entities([entity]) 132 | 133 | platform = entity_platform.async_get_current_platform() 134 | 135 | platform.async_register_entity_service(SERVICE_RESTORE, {}, "service_restore") 136 | platform.async_register_entity_service( 137 | SERVICE_SEARCH, 138 | SEARCH_SCHEMA, 139 | "service_search", 140 | ) 141 | platform.async_register_entity_service( 142 | SERVICE_GET_SEARCH_RESULT, 143 | SEARCH_SCHEMA, 144 | "service_get_search_result", 145 | supports_response=SupportsResponse.ONLY, 146 | ) 147 | platform.async_register_entity_service(SERVICE_SNAPSHOT, {}, "service_snapshot") 148 | platform.async_register_entity_service( 149 | SERVICE_SET_CONSUME_MODE, 150 | {vol.Required("consume_mode", default=False): cv.boolean}, 151 | "service_set_consume_mode", 152 | ) 153 | 154 | # NOTE: Is this still needed? 155 | async def async_setup_platform( 156 | hass: HomeAssistant, 157 | config: ConfigEntry, 158 | async_add_entities: AddEntitiesCallback, 159 | discover_info=None 160 | ): 161 | """Set up the Mopidy platform.""" 162 | device_name = config.get(CONF_NAME) 163 | hostname = config.get(CONF_HOST) 164 | port = config.get(CONF_PORT) 165 | 166 | speaker = MopidySpeaker(hass, hostname, port) 167 | entity = MopidyMediaPlayerEntity(speaker, device_name) 168 | async_add_entities([entity], True) 169 | 170 | 171 | class MopidyMediaPlayerEntity(MediaPlayerEntity): 172 | """Representation of the Mopidy server.""" 173 | 174 | _attr_name = None 175 | _attr_media_content_type = MediaType.MUSIC 176 | _attr_device_class = MediaPlayerDeviceClass.SPEAKER 177 | 178 | _attr_consume_mode: bool | None = None 179 | speaker: MopidySpeaker | None = None 180 | 181 | def __init__(self, speaker, device_name, device_uuid=None) -> None: 182 | """Initialize the Mopidy device.""" 183 | 184 | self.speaker = speaker 185 | self.speaker.entity = self 186 | self.device_name = device_name 187 | 188 | if device_uuid is None: 189 | self.device_uuid = re.sub(r"[._-]+", "_", self.speaker.hostname) + "_" + str(self.speaker.port) 190 | else: 191 | self.device_uuid = device_uuid 192 | 193 | def is_youtube_media_type(self, media_id): 194 | """Check if the provided is a youtube resource""" 195 | url = urlparse.urlparse(media_id) 196 | if len([ x for x in YOUTUBE_URLS if url.netloc.lower().endswith(x.lower()) ]) > 0: 197 | return True 198 | else: 199 | return False 200 | 201 | def resolve_youtube_media_type(self, media_type): 202 | """Return the media type for youtube videos""" 203 | return MediaType.MUSIC 204 | 205 | def youtube_uri_from_media_id(self, media_id): 206 | """Parse the youtube media_id and return a usable resource""" 207 | url = urlparse.urlparse(media_id) 208 | if "youtube" in self.speaker.supported_uri_schemes: 209 | query_parsed = parse_qs(url.query) 210 | media_id = f"youtube:video:{query_parsed['v'][0]}" 211 | 212 | elif "yt" in self.speaker.supported_uri_schemes: 213 | media_id = f"yt:{media_id}" 214 | 215 | else: 216 | raise MissingMopidyExtension("No Mopidy Extensions found for Youtube. If this incorrect, please open an issue.") 217 | 218 | return media_id 219 | 220 | async def async_play_media( 221 | self, media_type: MediaType | str, media_id: str, **kwargs: Any 222 | ) -> None: 223 | """Play provided media_id""" 224 | 225 | if self.is_youtube_media_type(media_id): 226 | media_type = self.resolve_youtube_media_type(media_type) 227 | media_id = self.youtube_uri_from_media_id(media_id) 228 | 229 | if media_source.is_media_source_id(media_id): 230 | 231 | media_type = MediaType.MUSIC 232 | media = await media_source.async_resolve_media( 233 | self.hass, media_id, self.entity_id 234 | ) 235 | media_id = async_process_play_media_url(self.hass, media.url) 236 | 237 | if spotify.is_spotify_media_type(media_type): 238 | media_type = spotify.resolve_spotify_media_type(media_type) 239 | media_id = spotify.spotify_uri_from_media_browser_url(media_id) 240 | 241 | 242 | await self.hass.async_add_executor_job( 243 | partial(self.speaker.play_media , media_type, media_id, **kwargs) 244 | ) 245 | 246 | def force_update_ha_state(self): 247 | self.schedule_update_ha_state(force_refresh=True) 248 | 249 | def clear_playlist(self) -> None: 250 | """Clear players playlist.""" 251 | self.speaker.clear_queue() 252 | 253 | def media_next_track(self) -> None: 254 | """Send next track command.""" 255 | self.speaker.media_next_track() 256 | 257 | def media_pause(self) -> None: 258 | """Send pause command.""" 259 | self.speaker.media_pause() 260 | 261 | def media_play(self) -> None: 262 | """Send play command.""" 263 | self.speaker.media_play() 264 | 265 | def media_previous_track(self) -> None: 266 | """Send previous track command.""" 267 | self.speaker.media_previous_track() 268 | 269 | def media_seek(self, position) -> None: 270 | """Send seek command.""" 271 | self.speaker.media_seek(int(position * 1000)) 272 | 273 | def media_stop(self) -> None: 274 | """Send stop command.""" 275 | self.speaker.media_stop() 276 | 277 | def mute_volume(self, mute) -> None: 278 | """Mute the volume.""" 279 | self.speaker.set_mute(mute) 280 | 281 | def select_source(self, source) -> None: 282 | """Select input source.""" 283 | self.speaker.select_source(source) 284 | 285 | def service_restore(self) -> None: 286 | """Restore Mopidy Server snapshot.""" 287 | self.speaker.restore_snapshot() 288 | 289 | def service_search(self, **kwargs) -> None: 290 | """Search the Mopidy Server media library.""" 291 | self.speaker.queue_tracks( 292 | self._search(**kwargs) 293 | ) 294 | 295 | def service_get_search_result(self, **kwargs) -> dict: 296 | return {'result': self._search(**kwargs)} 297 | 298 | def _search(self, **kwargs) -> dict: 299 | query = {} 300 | if isinstance(kwargs.get("keyword"), str): 301 | query["any"] = [kwargs["keyword"].strip()] 302 | 303 | if isinstance(kwargs.get("keyword_album"), str): 304 | query["album"] = [kwargs["keyword_album"].strip()] 305 | 306 | if isinstance(kwargs.get("keyword_artist"), str): 307 | query["artist"] = [kwargs["keyword_artist"].strip()] 308 | 309 | if isinstance(kwargs.get("keyword_genre"), str): 310 | query["genre"] = [kwargs["keyword_genre"].strip()] 311 | 312 | if isinstance(kwargs.get("keyword_track_name"), str): 313 | query["track_name"] = [kwargs["keyword_track_name"].strip()] 314 | 315 | if len(query.keys()) == 0: 316 | return {'result': {}} 317 | 318 | sources = [] 319 | if isinstance(kwargs.get("source"), str): 320 | sources = kwargs["source"].split(",") 321 | 322 | return self.library.search_tracks(sources, query, kwargs.get("exact", False)) 323 | 324 | def service_set_consume_mode(self, **kwargs) -> None: 325 | """Set/Unset Consume mode""" 326 | self.speaker.set_consume_mode(kwargs.get("consume_mode", False)) 327 | 328 | def service_snapshot(self) -> None: 329 | """Make a snapshot of Mopidy Server.""" 330 | self.speaker.take_snapshot() 331 | 332 | def set_repeat(self, repeat) -> None: 333 | """Set repeat mode.""" 334 | self.speaker.set_repeat_mode(repeat) 335 | 336 | def set_shuffle(self, shuffle) -> None: 337 | """Enable/disable shuffle mode.""" 338 | self.speaker.set_shuffle(shuffle) 339 | 340 | def set_volume_level(self, volume) -> None: 341 | """Set volume level, range 0..1.""" 342 | self.speaker.set_volume(int(volume * 100)) 343 | 344 | def volume_down(self) -> None: 345 | """Turn volume down for media player.""" 346 | self.speaker.volume_down() 347 | 348 | def volume_up(self) -> None: 349 | """Turn volume up for media player.""" 350 | self.speaker.volume_up() 351 | 352 | @property 353 | def available(self) -> bool: 354 | """Return True if entity is available.""" 355 | return self.speaker.is_available 356 | 357 | @property 358 | def device_info(self) -> dict[str, Any]: 359 | """Return device information about this entity.""" 360 | return { 361 | "identifiers": {(DOMAIN, self.device_name)}, 362 | "manufacturer": "Mopidy", 363 | "model": f"Mopidy server {self.speaker.software_version}", 364 | "name": self.device_name, 365 | "sw_version": self.speaker.software_version, 366 | } 367 | 368 | @property 369 | def extra_state_attributes(self) -> dict[str, Any]: 370 | """Return entity specific state attributes""" 371 | attributes: dict[str, Any] = {} 372 | 373 | if self.speaker.consume_mode is not None: 374 | attributes["consume_mode"] = self.speaker.consume_mode 375 | 376 | if self.speaker.queue.current_track_extension is not None: 377 | attributes["mopidy_extension"] = self.speaker.queue.current_track_extension 378 | 379 | if self.speaker.queue.position is not None: 380 | attributes["queue_position"] = self.speaker.queue.position 381 | 382 | if self.speaker.queue.size is not None: 383 | attributes["queue_size"] = self.speaker.queue.size 384 | 385 | if self.speaker.snapshot_taken_at is not None: 386 | attributes["snapshot_taken_at"] = self.speaker.snapshot_taken_at 387 | 388 | return attributes 389 | 390 | @property 391 | def icon(self) -> str: 392 | """Return the icon.""" 393 | return ICON 394 | 395 | @property 396 | def library(self) -> MopidyLibrary: 397 | """Return the library object from the speaker""" 398 | return self.speaker.library 399 | 400 | # @property 401 | # def media(self) -> MopidyMedia: 402 | # """Return the media object from the speaker""" 403 | # return self.speaker.media 404 | 405 | @property 406 | def name(self) -> str: 407 | """Return the name of the entity.""" 408 | return self.device_name 409 | 410 | @property 411 | def state(self) -> MediaPlayerState | None: 412 | """State of the player.""" 413 | if self.speaker is None: 414 | return None 415 | else: 416 | return self.speaker.state 417 | 418 | @property 419 | def volume_level(self) -> float | None: 420 | """Volume level of the media player (0..1).""" 421 | if self.speaker is None: 422 | return None 423 | elif self.speaker.volume_level is None: 424 | return None 425 | else: 426 | return float(self.speaker.volume_level/100) 427 | 428 | @property 429 | def is_volume_muted(self) -> bool | None: 430 | """Boolean if volume is currently muted.""" 431 | if self.speaker is None: 432 | return None 433 | else: 434 | return self.speaker.is_muted 435 | 436 | @property 437 | def media_content_id(self) -> str | None: 438 | """Content ID of current playing media.""" 439 | return self.speaker.queue.current_track_uri 440 | 441 | @property 442 | def media_content_type(self) -> MediaType | str | None: 443 | """Content type of current playing media.""" 444 | return self._attr_media_content_type 445 | 446 | @property 447 | def media_duration(self) -> int | None: 448 | """Duration of current playing media in seconds.""" 449 | return self.speaker.queue.current_track_duration 450 | 451 | @property 452 | def media_position(self) -> int | None: 453 | """Position of current playing media in seconds.""" 454 | return self.speaker.queue.current_track_position 455 | 456 | @property 457 | def media_position_updated_at(self) -> dt.datetime | None: 458 | """When was the position of the current playing media valid. 459 | 460 | Returns value from homeassistant.util.dt.utcnow(). 461 | """ 462 | return self.speaker.queue.current_track_position_updated_at 463 | 464 | @property 465 | def media_image_url(self) -> str | None: 466 | """Image url of current playing media.""" 467 | return self.speaker.queue.current_track_image_url 468 | 469 | @property 470 | def media_image_remotely_accessible(self) -> bool: 471 | """If the image url is remotely accessible.""" 472 | return self.speaker.queue.current_track_image_remotely_accessible 473 | 474 | @property 475 | def media_title(self) -> str | None: 476 | """Title of current playing media.""" 477 | return self.speaker.queue.current_track_title 478 | 479 | @property 480 | def media_artist(self) -> str | None: 481 | """Artist of current playing media, music track only.""" 482 | return self.speaker.queue.current_track_artist 483 | 484 | @property 485 | def media_album_name(self) -> str | None: 486 | """Album name of current playing media, music track only.""" 487 | return self.speaker.queue.current_track_album_name 488 | 489 | @property 490 | def media_album_artist(self) -> str | None: 491 | """Album artist of current playing media, music track only.""" 492 | return self.speaker.queue.current_track_album_artist 493 | 494 | @property 495 | def media_track(self) -> int | None: 496 | """Track number of current playing media, music track only.""" 497 | return self.speaker.queue.current_track_number 498 | 499 | @property 500 | def media_playlist(self) -> str | None: 501 | """Title of Playlist currently playing.""" 502 | return self.speaker.queue.current_track_playlist_name 503 | 504 | @property 505 | def source(self) -> str | None: 506 | """Name of the current input source.""" 507 | # FIXME: DO we need to look at this? 508 | return self._attr_source 509 | 510 | @property 511 | def source_list(self) -> list[str] | None: 512 | """List of available input sources.""" 513 | if self.speaker is None: 514 | return None 515 | else: 516 | return self.speaker.source_list 517 | 518 | @property 519 | def shuffle(self) -> bool | None: 520 | """Boolean if shuffle is enabled.""" 521 | if self.speaker is None: 522 | return None 523 | else: 524 | return self.speaker.is_shuffled 525 | 526 | @property 527 | def repeat(self) -> RepeatMode | str | None: 528 | """Return current repeat mode.""" 529 | if self.speaker is None: 530 | return None 531 | else: 532 | return self.speaker.repeat 533 | 534 | @property 535 | def supported_features(self) -> MediaPlayerEntityFeature: 536 | """Flag media player features that are supported.""" 537 | if self.speaker is None: 538 | return None 539 | else: 540 | return self.speaker.features 541 | 542 | @property 543 | def unique_id(self) -> str: 544 | """Return the unique id for the entity.""" 545 | return self.device_uuid 546 | 547 | def update(self) -> None: 548 | """Get the latest data and update the state.""" 549 | 550 | self.speaker.update() 551 | 552 | if self.state is None: 553 | _LOGGER.error(f"{self.entity_id} is unavailable") 554 | return 555 | 556 | async def async_browse_media( 557 | self, 558 | media_content_type=None, 559 | media_content_id=None, 560 | ) -> None: 561 | 562 | if media_content_id is None: 563 | return await self.root_payload() 564 | 565 | if media_source.is_media_source_id(media_content_id): 566 | return await media_source.async_browse_media( 567 | self.hass, media_content_id, content_filter=media_source_filter 568 | ) 569 | 570 | if spotify.is_spotify_media_type(media_content_type): 571 | return await spotify.async_browse_media( 572 | self.hass, media_content_type, media_content_id, can_play_artist=False 573 | ) 574 | 575 | return await self.hass.async_add_executor_job( 576 | self._media_library_payload, 577 | { 578 | "media_content_type": media_content_type, 579 | "media_content_id": media_content_id, 580 | }, 581 | ) 582 | 583 | async def root_payload(self) -> dict[str, Any]: 584 | """Return root payload for Mopidy.""" 585 | children = [ 586 | BrowseMedia( 587 | title="Mopidy", 588 | media_class=MediaClass.APP, 589 | media_content_id="library", 590 | media_content_type="library", 591 | can_play=False, 592 | can_expand=True, 593 | thumbnail="https://brands.home-assistant.io/_/mopidy/logo.png", 594 | ) 595 | ] 596 | 597 | # If we have spotify both in mopidy and HA, show the HA component 598 | lib = await self.hass.async_add_executor_job(self.library.browse, None) 599 | for item in lib: 600 | if getattr(item, "uri") == "spotify:directory" and "spotify" in self.hass.config.components: 601 | result = await spotify.async_browse_media(self.hass, None, None) 602 | children.extend(result.children) 603 | break 604 | 605 | try: 606 | item = await media_source.async_browse_media( 607 | self.hass, None, content_filter=media_source_filter 608 | ) 609 | # If domain is None, it's overview of available sources 610 | if item.domain is None: 611 | children.extend(item.children) 612 | else: 613 | children.append(item) 614 | except media_source.BrowseError: 615 | pass 616 | 617 | if len(children) == 1: 618 | return await self.async_browse_media( 619 | children[0].media_content_type, 620 | children[0].media_content_id, 621 | ) 622 | 623 | return BrowseMedia( 624 | title="Mopidy", 625 | media_class=MediaClass.DIRECTORY, 626 | media_content_id="", 627 | media_content_type="root", 628 | can_play=False, 629 | can_expand=True, 630 | children=children, 631 | ) 632 | 633 | def _media_library_payload(self, payload): 634 | """Create response payload to describe contents of a specific library.""" 635 | _image_uris = [] 636 | 637 | if ( 638 | payload.get("media_content_type") is None 639 | or payload.get("media_content_id") is None 640 | ): 641 | _LOGGER.error("Missing type or uri for media item payload: %s", payload) 642 | raise MissingMediaInformation 643 | 644 | library_info, mopidy_info = get_media_info(payload) 645 | if mopidy_info["art_uri"] != "library": 646 | if mopidy_info["art_uri"] not in CACHE_ART: 647 | _image_uris.append(mopidy_info["art_uri"]) 648 | 649 | library_children = {} 650 | for path in self.library.browse(mopidy_info["browsepath"]): 651 | library_children[getattr(path, "uri")] = dict( 652 | zip( 653 | ("library_info", "mopidy_info"), 654 | get_media_info( 655 | { 656 | "media_content_type": getattr(path, "type", "directory"), 657 | "media_content_id": getattr(path, "uri"), 658 | "name": getattr(path, "name", "unknown"), 659 | } 660 | ), 661 | ) 662 | ) 663 | if ( 664 | library_children[getattr(path, "uri")]["mopidy_info"] is not None 665 | and library_children[getattr(path, "uri")]["mopidy_info"]["art_uri"] 666 | not in CACHE_ART 667 | ): 668 | _image_uris.append( 669 | library_children[getattr(path, "uri")]["mopidy_info"]["art_uri"] 670 | ) 671 | 672 | if mopidy_info["source"] == "spotify": 673 | # Spotify thumbnail lookup is throttled 674 | pagesize = 10 675 | else: 676 | pagesize = 1000 677 | uri_sets = [ 678 | _image_uris[r * pagesize : (r + 1) * pagesize] 679 | for r in range((len(_image_uris) + pagesize - 1) // pagesize) 680 | ] 681 | 682 | for uri_set in uri_sets: 683 | if len(uri_set) == 0: 684 | continue 685 | i = self.library.get_images(uri_set) 686 | for img_uri in i: 687 | if len(i[img_uri]) > 0: 688 | CACHE_ART[img_uri] = self.speaker.queue.expand_url(mopidy_info["source"], i[img_uri][0].uri) 689 | else: 690 | CACHE_ART[img_uri] = None 691 | 692 | if ( 693 | mopidy_info["art_uri"] in CACHE_ART 694 | and CACHE_ART[mopidy_info["art_uri"]] is not None 695 | ): 696 | library_info["thumbnail"] = CACHE_ART[mopidy_info["art_uri"]] 697 | 698 | for i in library_children: 699 | if ( 700 | library_children[i]["mopidy_info"] is not None 701 | and library_children[i]["mopidy_info"]["art_uri"] in CACHE_ART 702 | and CACHE_ART[library_children[i]["mopidy_info"]["art_uri"]] is not None 703 | ): 704 | library_children[i]["library_info"]["thumbnail"] = CACHE_ART[ 705 | library_children[i]["mopidy_info"]["art_uri"] 706 | ] 707 | 708 | library_info["children"] = [ 709 | BrowseMedia(**library_children[c]["library_info"]) 710 | for c in library_children 711 | if library_children[c]["library_info"] is not None 712 | ] 713 | return BrowseMedia(**library_info) 714 | 715 | 716 | def get_media_info(info): 717 | """Build Library object.""" 718 | disabled_uris = ["local:directory?type=track"] 719 | if info["media_content_id"] in CACHE_TITLES: 720 | info["name"] = CACHE_TITLES[info["media_content_id"]] 721 | 722 | library_info = { 723 | "children": [], 724 | "media_class": info["media_content_type"], 725 | "media_content_id": info["media_content_id"], 726 | "media_content_type": info["media_content_type"], 727 | "title": info.get("name", "Unknown"), 728 | "can_play": info.get("media_content_type", MediaClass.DIRECTORY) 729 | in PLAYABLE_MEDIA_TYPES, 730 | "can_expand": info.get("media_content_type", MediaClass.DIRECTORY) 731 | in EXPANDABLE_MEDIA_TYPES, 732 | } 733 | mopidy_info = { 734 | "browsepath": info.get("media_content_id"), 735 | "art_uri": info.get("media_content_id"), 736 | "source": info.get("media_content_id").partition(":")[0], 737 | } 738 | 739 | source = info.get("media_content_id").partition(":")[0] 740 | uri = info.get("media_content_id").partition(":")[2] 741 | 742 | if info["media_content_id"] in disabled_uris: 743 | return None, None 744 | 745 | if info["media_content_id"] == "library": 746 | library_info.update( 747 | { 748 | "title": "Media Library", 749 | "can_expand": True, 750 | } 751 | ) 752 | mopidy_info["browsepath"] = None 753 | 754 | if source == "local": 755 | media_info = {} 756 | for uri_info in uri.partition("?")[2].split("&"): 757 | if uri_info != "": 758 | media_info[uri_info.partition("=")[0]] = uri_info.partition("=")[2] 759 | if media_info.get("type") == "album": 760 | library_info["media_class"] = MediaClass.ALBUM 761 | elif media_info.get("type") == "artist": 762 | library_info["media_class"] = MediaClass.ARTIST 763 | elif media_info.get("type") == "genre": 764 | library_info["media_class"] = MediaClass.GENRE 765 | elif media_info.get("type") == "track": 766 | library_info["media_class"] = MediaClass.TRACK 767 | 768 | if media_info.get("album") is not None: 769 | mopidy_info["art_uri"] = media_info["album"] 770 | library_info["can_play"] = True 771 | library_info["media_class"] = MediaClass.ALBUM 772 | elif media_info.get("genre") is not None: 773 | library_info["media_class"] = MediaClass.GENRE 774 | 775 | if ( 776 | media_info.get("role") is not None 777 | and media_info["role"] == "composer" 778 | or media_info.get("composer") is not None 779 | ): 780 | library_info["media_class"] = MediaClass.COMPOSER 781 | 782 | elif source == "spotify": 783 | if ( 784 | "spotify:top:albums" in info["media_content_id"] 785 | or "spotify:your:albums" in info["media_content_id"] 786 | ): 787 | library_info["media_class"] = MediaClass.ALBUM 788 | elif "spotify:top:artists" in info["media_content_id"]: 789 | library_info["media_class"] = MediaClass.ARTIST 790 | elif ( 791 | "spotify:top:tracks" in info["media_content_id"] 792 | or "spotify:your:tracks" in info["media_content_id"] 793 | ): 794 | library_info["media_class"] = MediaClass.TRACK 795 | elif "spotify:playlists" in info["media_content_id"]: 796 | library_info["media_class"] = MediaClass.PLAYLIST 797 | 798 | elif "podcast+" in source: 799 | library_info["media_class"] = MediaClass.PODCAST 800 | 801 | elif source == "tunein": 802 | media_info = library_info["media_content_id"].split(":") 803 | library_info["media_class"] = MediaClass.DIRECTORY 804 | 805 | CACHE_TITLES[info["media_content_id"]] = library_info["title"] 806 | return library_info, mopidy_info 807 | -------------------------------------------------------------------------------- /custom_components/mopidy/speaker.py: -------------------------------------------------------------------------------- 1 | """Base classes for common mopidy speaker tasks..""" 2 | import logging 3 | import datetime 4 | import time 5 | import urllib.parse as urlparse 6 | from urllib.parse import urlencode 7 | from mopidyapi import MopidyAPI 8 | 9 | from homeassistant.components import media_source, spotify 10 | from homeassistant.core import HomeAssistant, callback 11 | from homeassistant.components.media_player import ( 12 | ATTR_MEDIA_ENQUEUE, 13 | async_process_play_media_url, 14 | MediaClass, 15 | MediaPlayerEnqueue, 16 | MediaPlayerEntityFeature, 17 | MediaPlayerState, 18 | MediaType, 19 | RepeatMode, 20 | ) 21 | from homeassistant.components.media_player.errors import BrowseError 22 | from homeassistant.helpers.dispatcher import async_dispatcher_send 23 | import homeassistant.util.dt as dt_util 24 | from requests.exceptions import ConnectionError as reConnectionError 25 | 26 | from .const import ( 27 | DEFAULT_PORT, 28 | ) 29 | 30 | _LOGGER = logging.getLogger(__name__) 31 | 32 | class MissingMediaInformation(BrowseError): 33 | """Missing media required information.""" 34 | 35 | class MopidyLibrary: 36 | """Representation of the current Mopidy library.""" 37 | 38 | api: MopidyAPI | None = None 39 | _attr_supported_uri_schemes: list | None = None 40 | 41 | def browse(self, uri=None): 42 | """Wrapper for the MopidyAPI.library.browse method""" 43 | # NOTE: when uri is None, the root will be returned 44 | return self.api.library.browse(uri) 45 | 46 | def get_images(self, uris=None): 47 | """Wrapper for the MopidyAPI.library.get_images method""" 48 | if uris is None: 49 | # TODO: return error 50 | return 51 | 52 | return self.api.library.get_images(uris) 53 | 54 | def get_playlist(self, uri=None): 55 | """Get the playlist tracks""" 56 | return self.api.playlists.lookup(uri) 57 | 58 | def get_playlist_track_uris(self, uri=None): 59 | """Get uris of playlist tracks""" 60 | if uri.partition(":")[0] == "m3u": 61 | return [x.uri for x in self.get_playlist(uri).tracks] 62 | 63 | return [x.uri for x in self.browse(uri)] 64 | 65 | def search(self, sources=None, query=None, exact=False): 66 | """Search the library for something""" 67 | if sources is None: 68 | sources = [] 69 | 70 | uris = [] 71 | for el in sources: 72 | if el.partition(":")[1] == "": 73 | el = "%s:" % el 74 | if el.partition(":")[0] in self.supported_uri_schemes: 75 | uris.append(el) 76 | 77 | if len(uris) == 0: 78 | uris = None 79 | 80 | res = self.api.library.search( 81 | query=query, 82 | uris=uris, 83 | exact=exact, 84 | ) 85 | return res 86 | 87 | def search_tracks(self, sources=None, query=None, exact=False): 88 | """Search the library for matching tracks""" 89 | uris = [] 90 | for res in self.search(sources, query, exact): 91 | for track in getattr(res, "tracks", []): 92 | uris.append(track.uri) 93 | 94 | return uris 95 | 96 | @property 97 | def playlists(self): 98 | """Return playlists known to mopidy""" 99 | if not hasattr(self.api, "playlists"): 100 | return [] 101 | return self.api.playlists.as_list() 102 | 103 | @property 104 | def supported_uri_schemes(self): 105 | """Return the supported schemes (extensions)""" 106 | if self._attr_supported_uri_schemes is None: 107 | self._attr_supported_uri_schemes = self.api.rpc_call("core.get_uri_schemes") 108 | 109 | return self._attr_supported_uri_schemes 110 | 111 | class MopidyQueue: 112 | """Representation of Mopidy Queue""" 113 | 114 | hass: HomeAssistant | None = None 115 | api: MopidyAPI | None = None 116 | queue: dict | None = None 117 | local_url_base: str | None = None 118 | 119 | _current_track_tlid: int | None = None 120 | _current_track_album_artist: str | None = None 121 | _current_track_album_name: str | None = None 122 | _current_track_artist: str | None = None 123 | _current_track_duration: int | None = None 124 | _current_track_extension: str | None = None 125 | _current_track_image_url: str | None = None 126 | _current_track_image_remotely_accessible: bool | None = None 127 | _current_track_playlist_name: str | None = None 128 | _current_track_position: int | None = None 129 | _current_track_position_updated_at: datetime.datetime | None = None 130 | _current_track_title: str | None = None 131 | _current_track_is_stream: bool | None = None 132 | _current_track_number: str | None = None 133 | _current_track_uri: str | None = None 134 | _attr_queue_position: int | None = None 135 | _attr_queue_size: int | None = None 136 | 137 | def __init__(self): 138 | """Initialize queue""" 139 | self.queue = {} 140 | self.clear_current_track() 141 | 142 | def __get_current_track_position(self): 143 | """Get the position of the current track""" 144 | try: 145 | current_media_position = self.api.playback.get_time_position() 146 | except reConnectionError as error: 147 | _LOGGER.error( 148 | "Cannot get current position" 149 | ) 150 | _LOGGER.debug(str(error)) 151 | 152 | self.set_current_track_position(int(current_media_position / 1000)) 153 | 154 | def __get_current_track_stream_info(self): 155 | """Get the current track stream info""" 156 | try: 157 | current_stream_title = self.api.playback.get_stream_title() 158 | except reConnectionError as error: 159 | _LOGGER.error( 160 | "Cannot get current stream title" 161 | ) 162 | _LOGGER.debug(str(error)) 163 | return 164 | 165 | if self._current_track_tlid is not None and self.queue[self._current_track_tlid] is not None: 166 | if current_stream_title is not None: 167 | self.set_stream_title(current_stream_title) 168 | else: 169 | self._current_track_is_stream = False 170 | 171 | def __get_track_image(self, uri=None): 172 | if uri is None: 173 | return 174 | 175 | try: 176 | current_image = self.api.library.get_images([uri]) 177 | except reConnectionError as error: 178 | _LOGGER.error( 179 | "Cannot get image for media" 180 | ) 181 | _LOGGER.debug(str(error)) 182 | 183 | if ( 184 | current_image is not None 185 | and uri in current_image 186 | and len(current_image[uri]) > 0 187 | and hasattr(current_image[uri][0], "uri") 188 | ): 189 | image_url = self.expand_url( 190 | self.current_track_extension, current_image[uri][0].uri 191 | ) 192 | elif (self._current_track_is_stream): 193 | image_url = None 194 | else: 195 | _LOGGER.warning("No image_url found for %s", uri) 196 | image_url = None 197 | 198 | return image_url 199 | 200 | def __set_track_info(self, tlid, track_info): 201 | """Update track information using tlid""" 202 | if not isinstance(tlid, int): 203 | _LOGGER.error("__set_track_info: tlid is invalid: %s", str(tlid)) 204 | return None 205 | 206 | if tlid not in self.queue: 207 | self.queue[tlid] = { "tlid": tlid } 208 | 209 | self.queue[tlid].update(track_info) 210 | 211 | return self.queue[tlid] 212 | 213 | def clear_current_track(self): 214 | self._attr_current_track = None 215 | self._current_track_tlid = None 216 | self._current_track_album_artist = None 217 | self._current_track_album_name = None 218 | self._current_track_artist = None 219 | self._current_track_duration = None 220 | self._current_track_extension = None 221 | self._current_track_image_url = None 222 | self._current_track_image_remotely_accessible = None 223 | self._current_track_playlist_name = None 224 | self._current_track_position = None 225 | self._current_track_position_updated_at = dt_util.utcnow() 226 | self._current_track_title = None 227 | self._current_track_is_stream = None 228 | self._current_track_number = None 229 | self._current_track_uri = None 230 | 231 | def expand_url(self, extension, url): 232 | """Expand the URL with the mopidy base url and possibly a timestamp""" 233 | parsed_url = urlparse.urlparse(url) 234 | if parsed_url.netloc == "": 235 | url = f"{self.local_url_base}{url}" 236 | 237 | # Force the browser to reload the image once per day 238 | query = dict(urlparse.parse_qsl(parsed_url.query)) 239 | if query.get("mopt") is None: 240 | url_parts = list(urlparse.urlparse(url)) 241 | query["mopt"] = datetime.datetime.now().strftime("%Y%m%d") 242 | url_parts[4] = urlencode(query) 243 | url = urlparse.urlunparse(url_parts) 244 | 245 | return url 246 | 247 | def parse_track_info(self, track, tlid=None, current=False): 248 | """Parse the track info""" 249 | track_info = { "tlid": tlid } 250 | if hasattr(track, "uri"): 251 | track_info["uri"] = track.uri 252 | track_info["source"] = track.uri.partition(":")[0] 253 | 254 | if hasattr(track, "track_no"): 255 | track_info["number"] = int(track.track_no) 256 | 257 | if hasattr(track, "length"): 258 | track_info["duration"] = int(track.length / 1000) 259 | 260 | if hasattr(track, "album") and hasattr(track.album, "name"): 261 | track_info["album_name"] = track.album.name 262 | 263 | if hasattr(track, "artists"): 264 | track_info["album_artist"] = ", ".join([x.name for x in track.artists]) 265 | 266 | if hasattr(track, "name"): 267 | track_info["title"] = track.name 268 | 269 | if hasattr(track, "artists"): 270 | track_info["artist"] = ", ".join([x.name for x in track.artists]) 271 | 272 | self.__set_track_info(tlid, track_info) 273 | if current: 274 | self._current_track_tlid = tlid 275 | self._current_track_uri = self.queue[tlid].get("uri") 276 | self._current_track_album_artist = self.queue[tlid].get("album_artist") 277 | self._current_track_album_name = self.queue[tlid].get("album_name") 278 | self._current_track_artist = self.queue[tlid].get("artist") 279 | self._current_track_duration = self.queue[tlid].get("duration") 280 | self._current_track_extension = self.queue[tlid].get("source") 281 | self._current_track_playlist_name = self.queue[tlid].get("playlist_name") 282 | self._current_track_title = self.queue[tlid].get("title") 283 | self._current_track_is_stream = self.queue[tlid].get("is_stream") 284 | self._current_track_number = self.queue[tlid].get("number") 285 | 286 | return track_info 287 | 288 | def set_current_track_position(self, value): 289 | """Set the media position""" 290 | self._current_track_position = value 291 | self._current_track_position_updated_at = dt_util.utcnow() 292 | 293 | def set_local_url_base(self, value): 294 | """Assign a url base""" 295 | self.local_url_base = value 296 | 297 | def set_stream_title(self, stream_title): 298 | self._current_track_title = stream_title 299 | self._current_track_is_stream = True 300 | if self._current_track_tlid is not None: 301 | self.__set_track_info( 302 | self._current_track_tlid, 303 | { 304 | "title": stream_title, 305 | "is_stream": True, 306 | } 307 | ) 308 | 309 | def update(self): 310 | self.update_queue_information() 311 | self.update_tracks() 312 | self.update_current_track() 313 | 314 | def update_current_track(self, updater=None): 315 | try: 316 | current_track = self.api.playback.get_current_tl_track() 317 | self._attr_current_track = current_track 318 | except reConnectionError as error: 319 | _LOGGER.error( 320 | "Cannot get current track information" 321 | ) 322 | _LOGGER.debug(str(error)) 323 | return 324 | 325 | if hasattr(current_track, "track") and hasattr(current_track, "tlid"): 326 | track_info = self.parse_track_info( 327 | track=current_track.track, 328 | tlid=current_track.tlid, 329 | current=True 330 | ) 331 | self.update_current_image_url() 332 | 333 | self.__get_current_track_position() 334 | self.__get_current_track_stream_info() 335 | 336 | if updater is not None: 337 | updater() 338 | 339 | def update_current_image_url(self, uri=None, updater=None): 340 | """Update the current track image url""" 341 | if uri is None: 342 | uri = self._current_track_uri 343 | 344 | self._current_track_image_url = self.__get_track_image(uri) 345 | self._current_track_image_remotely_accessible = False 346 | 347 | if updater is not None: 348 | updater() 349 | 350 | def update_tracks(self): 351 | res = [] 352 | try: 353 | res = self.api.tracklist.get_tl_tracks() 354 | except reConnectionError as error: 355 | _LOGGER.error( 356 | "An error ocurred getting the queue tracks for %s.", 357 | self.hostname 358 | ) 359 | _LOGGER.debug(str(error)) 360 | 361 | tlid_queue = [ x.tlid for x in res ] 362 | purge_queue = [] 363 | for tlid in self.queue: 364 | if tlid not in tlid_queue: 365 | purge_queue.append(tlid) 366 | 367 | index = 0 368 | for el in res: 369 | self.__set_track_info( 370 | el.tlid, 371 | { 372 | "uri": el.track.uri, 373 | "index": index, 374 | }) 375 | index = index +1 376 | 377 | for tlid in purge_queue: 378 | del self.queue[tlid] 379 | 380 | def update_queued_tracks(self, media_id, media_type, **kwargs): 381 | """Update the queue with new information""" 382 | self.update_tracks() 383 | if media_type == "playlist": 384 | if "tracks" not in kwargs: 385 | return 386 | res = self.api.playlists.lookup(media_id) 387 | for tl_track in kwargs["tracks"]: 388 | track_info = { 389 | "tlid": tl_track.tlid, 390 | "playlist_name": res.name, 391 | "playlist_uri": res.uri, 392 | } 393 | self.__set_track_info(tl_track.tlid, track_info) 394 | 395 | def update_queue_information(self, updater=None): 396 | """Get the Mopidy Instance queue information""" 397 | try: 398 | self._attr_queue_position = self.api.tracklist.index() 399 | except reConnectionError as error: 400 | self._attr_is_available = False 401 | _LOGGER.error( 402 | "An error ocurred getting the queue index for %s.", 403 | self.hostname 404 | ) 405 | _LOGGER.debug(str(error)) 406 | 407 | try: 408 | self._attr_queue_size = self.api.tracklist.get_length() 409 | except reConnectionError as error: 410 | self._attr_is_available = False 411 | _LOGGER.error( 412 | "An error ocurred getting the queue track list size for %s.", 413 | self.hostname 414 | ) 415 | _LOGGER.debug(str(error)) 416 | 417 | if updater is not None: 418 | updater() 419 | 420 | @property 421 | def current_track_album_artist(self): 422 | """Return the album artist information about the current track""" 423 | return self._current_track_album_artist 424 | 425 | @property 426 | def current_track_album_name(self): 427 | """Return the album name of the current track""" 428 | return self._current_track_album_name 429 | 430 | @property 431 | def current_track_artist(self): 432 | """Return the artist of the current track""" 433 | return self._current_track_artist 434 | 435 | @property 436 | def current_track_duration(self): 437 | """Return the duration of the current track""" 438 | return self._current_track_duration 439 | 440 | @property 441 | def current_track_extension(self): 442 | """Return the mopidy extension of the current track""" 443 | return self._current_track_extension 444 | 445 | @property 446 | def current_track_image_url(self): 447 | """Return the cover art image url of the current track""" 448 | return self._current_track_image_url 449 | 450 | @property 451 | def current_track_image_remotely_accessible(self): 452 | """Return whether the image url is remotely accessible of the current track""" 453 | # FIXME: Check how this works 454 | return self._current_track_image_remotely_accessible 455 | 456 | @property 457 | def current_track_playlist_name(self): 458 | """Return the playlist name of the current track""" 459 | return self._current_track_playlist_name 460 | 461 | @property 462 | def current_track_position(self): 463 | """Return position of the current track in the queue""" 464 | return self._current_track_position 465 | 466 | @property 467 | def current_track_position_updated_at(self): 468 | """Return the update time of the position of the current track in the queue""" 469 | return self._current_track_position_updated_at 470 | 471 | @property 472 | def current_track_title(self): 473 | """Return the title of the current track""" 474 | return self._current_track_title 475 | 476 | @property 477 | def current_track_number(self): 478 | """Return the number of the current track in the album""" 479 | return self._current_track_number 480 | 481 | @property 482 | def current_track_uri(self): 483 | """Return the mopidy uri of the current track""" 484 | return self._current_track_uri 485 | 486 | @property 487 | def uri_list(self): 488 | """Return a list of uris of the current queue""" 489 | return [ self.queue[x]["uri"] for x in self.queue ] 490 | 491 | @property 492 | def size(self): 493 | """Return the size of the current queue""" 494 | return self._attr_queue_size 495 | 496 | @property 497 | def position(self): 498 | """Return the index of the currently playing track in the tracklist""" 499 | return self._attr_queue_position 500 | 501 | class MopidySpeaker: 502 | """Representation of Mopidy Speaker""" 503 | 504 | hass: HomeAssistant | None = None 505 | hostname: str | None = None 506 | port: int | None = None 507 | api: MopidyAPI | None = None 508 | snapshot: dict | None = None 509 | queue: MopidyQueue | None = None 510 | 511 | _attr_is_available: bool | None = None 512 | _attr_software_version: str | None = None 513 | _attr_supported_uri_schemes: list | None = None 514 | _attr_consume_mode: bool | None = None 515 | _attr_source_list: list | None = None 516 | _attr_volume_level: int | None = None 517 | _attr_is_volume_muted: bool | None = None 518 | _attr_state: MediaPlayerState | None = None 519 | _attr_repeat: RepeatMode | str | None = None 520 | _attr_shuffle: bool | None = None 521 | _attr_snapshot_at: datetime.datetime | None = None 522 | 523 | _attr_supported_features = ( 524 | MediaPlayerEntityFeature.BROWSE_MEDIA 525 | | MediaPlayerEntityFeature.CLEAR_PLAYLIST 526 | | MediaPlayerEntityFeature.MEDIA_ENQUEUE 527 | | MediaPlayerEntityFeature.NEXT_TRACK 528 | | MediaPlayerEntityFeature.PAUSE 529 | | MediaPlayerEntityFeature.PLAY 530 | | MediaPlayerEntityFeature.PLAY_MEDIA 531 | | MediaPlayerEntityFeature.PREVIOUS_TRACK 532 | | MediaPlayerEntityFeature.REPEAT_SET 533 | | MediaPlayerEntityFeature.SEEK 534 | | MediaPlayerEntityFeature.SHUFFLE_SET 535 | | MediaPlayerEntityFeature.STOP 536 | | MediaPlayerEntityFeature.SELECT_SOURCE 537 | | MediaPlayerEntityFeature.VOLUME_MUTE 538 | | MediaPlayerEntityFeature.VOLUME_SET 539 | ) 540 | 541 | _first_failure = True 542 | 543 | def __init__(self, 544 | hass: HomeAssistant, 545 | hostname: str, 546 | port: int = None, 547 | ) -> None: 548 | self.hass = hass 549 | self.hostname = hostname 550 | if port is None: 551 | self.port = DEFAULT_PORT 552 | else: 553 | self.port = port 554 | 555 | self._attr_is_available = False 556 | self.queue = MopidyQueue() 557 | self.queue.set_local_url_base(f"http://{hostname}:{port}") 558 | self.library = MopidyLibrary() 559 | 560 | self.__connect() 561 | self.entity = None 562 | self.queue.api = self.api 563 | self.library.api = self.api 564 | self._attr_snapshot_at = None 565 | 566 | def __clear(self): 567 | """Reset all Values""" 568 | self._attr_software_version = None 569 | self._attr_supported_uri_schemes = None 570 | self._attr_consume_mode = None 571 | self._attr_source_list = None 572 | self._attr_volume_level = None 573 | self._attr_is_volume_muted = None 574 | self._attr_state = None 575 | self._attr_repeat = None 576 | self._attr_shuffle = None 577 | self._attr_is_available = False 578 | 579 | def __connect(self): 580 | """(Re)Connect to the Mopidy Server""" 581 | self.api = MopidyAPI( 582 | host = self.hostname, 583 | port = self.port, 584 | use_websocket = True, 585 | logger = logging.getLogger(__name__ + ".api"), 586 | ) 587 | 588 | # NOTE: the callbacks can be found at 589 | # https://docs.mopidy.com/en/latest/api/core/#mopidy.core.CoreListener 590 | # not using playlist_changed, playlist_deleted, playlists_loaded, track_playback_ended 591 | # as they are updated on update 592 | self.api.add_callback('options_changed', self.__ws_options_changed) 593 | self.api.add_callback('mute_changed', self.__ws_mute_changed) 594 | self.api.add_callback('playback_state_changed', self.__ws_playback_state_changed) 595 | self.api.add_callback('seeked', self.__ws_seeked) 596 | self.api.add_callback('stream_title_changed', self.__ws_stream_title_changed) 597 | self.api.add_callback('track_playback_paused', self.__ws_track_playback_paused) 598 | self.api.add_callback('track_playback_resumed', self.__ws_track_playback_resumed) 599 | self.api.add_callback('track_playback_started', self.__ws_track_playback_started) 600 | self.api.add_callback('tracklist_changed', self.__ws_tracklist_changed) 601 | self.api.add_callback('volume_changed', self.__ws_volume_changed) 602 | 603 | def __eval_state(self, PlaybackState): 604 | """Return the Mopidy PlaybackState as a valid media_player state""" 605 | if PlaybackState is None: 606 | return None 607 | elif PlaybackState == "playing": 608 | return MediaPlayerState.PLAYING 609 | elif PlaybackState == "paused": 610 | return MediaPlayerState.PAUSED 611 | elif PlaybackState == "stopped": 612 | return MediaPlayerState.IDLE 613 | else: 614 | return None 615 | 616 | def __get_consume_mode(self): 617 | """Get the Mopidy Instance consume mode""" 618 | try: 619 | self._attr_consume_mode = self.api.tracklist.get_consume() 620 | self._first_failure = True 621 | except reConnectionError as error: 622 | self._attr_is_available = False 623 | if self._first_failure: 624 | self._first_failure = False 625 | _LOGGER.error( 626 | "An error ocurred getting consume mode for %s.", 627 | self.hostname 628 | ) 629 | else: 630 | _LOGGER.debug( 631 | "An error ocurred getting consume mode for %s.", 632 | self.hostname 633 | ) 634 | _LOGGER.debug(str(error)) 635 | 636 | def __get_repeat_mode(self): 637 | """Get the Mopidy Instance repeat mode""" 638 | try: 639 | repeat = self.api.tracklist.get_repeat() 640 | except reConnectionError as error: 641 | self._attr_is_available = False 642 | _LOGGER.error( 643 | "An error ocurred getting the repeat mode for %s.", 644 | self.hostname 645 | ) 646 | _LOGGER.debug(str(error)) 647 | 648 | try: 649 | single = self.api.tracklist.get_single() 650 | except reConnectionError as error: 651 | self._attr_is_available = False 652 | _LOGGER.error( 653 | "An error ocurred getting single repeat mode for %s.", 654 | self.hostname 655 | ) 656 | _LOGGER.debug(str(error)) 657 | 658 | if repeat and single: 659 | self._attr_repeat = RepeatMode.ONE 660 | elif repeat and not single: 661 | self._attr_repeat = RepeatMode.ALL 662 | else: 663 | self._attr_repeat = RepeatMode.OFF 664 | 665 | def __get_shuffle_mode(self): 666 | """Get the Mopidy Instance shuffle mode""" 667 | try: 668 | self._attr_shuffle = self.api.tracklist.get_random() 669 | except reConnectionError as error: 670 | self._attr_is_available = False 671 | _LOGGER.error( 672 | "An error ocurred getting the shuffle mode for %s.", 673 | self.hostname 674 | ) 675 | _LOGGER.debug(str(error)) 676 | 677 | def __get_software_version(self): 678 | """Get the Mopidy Instance Software Version""" 679 | try: 680 | self._attr_software_version = self.api.rpc_call("core.get_version") 681 | self._attr_is_available = True 682 | except reConnectionError as error: 683 | self._attr_is_available = False 684 | _LOGGER.error( 685 | "An error ocurred connecting to %s of port %s.", 686 | self.hostname, 687 | self.port 688 | ) 689 | _LOGGER.debug(str(error)) 690 | 691 | def __get_supported_uri_schemes(self): 692 | """Get the Mopidy Instance supported extensions/schemes""" 693 | try: 694 | self._attr_supported_uri_schemes = self.api.rpc_call("core.get_uri_schemes") 695 | except reConnectionError as error: 696 | self._attr_is_available = False 697 | _LOGGER.error( 698 | "An error ocurred getting uri schemes for %s.", 699 | self.hostname 700 | ) 701 | _LOGGER.debug(str(error)) 702 | 703 | def __get_source_list(self): 704 | """Get the Mopidy Instance sources available""" 705 | self._attr_source_list = [x.name for x in self.library.playlists] 706 | 707 | def __get_state(self): 708 | """Get the Mopidy Instance state""" 709 | try: 710 | self._attr_state = self.__eval_state( 711 | self.api.playback.get_state() 712 | ) 713 | except reConnectionError as error: 714 | self._attr_is_available = False 715 | _LOGGER.error( 716 | "An error ocurred getting the state for %s.", 717 | self.hostname 718 | ) 719 | _LOGGER.debug(str(error)) 720 | 721 | def __get_volume(self): 722 | """Get the Mopidy Instance volume information""" 723 | try: 724 | self._attr_volume_level = self.api.mixer.get_volume() 725 | except reConnectionError as error: 726 | self._attr_is_available = False 727 | _LOGGER.error( 728 | "An error ocurred getting the volume level for %s.", 729 | self.hostname 730 | ) 731 | _LOGGER.debug(str(error)) 732 | try: 733 | self._attr_is_volume_muted = self.api.mixer.get_mute() 734 | except reConnectionError as error: 735 | self._attr_is_available = False 736 | _LOGGER.error( 737 | "An error ocurred getting the mute mode for %s.", 738 | self.hostname 739 | ) 740 | _LOGGER.debug(str(error)) 741 | 742 | def clear_queue(self): 743 | """Clear the playing queue""" 744 | try: 745 | self.api.tracklist.clear() 746 | except reConnectionError as error: 747 | self._attr_is_available = False 748 | _LOGGER.error( 749 | "An error ocurred clearing the queue for %s.", 750 | self.hostname 751 | ) 752 | _LOGGER.debug(str(error)) 753 | 754 | def media_next_track(self): 755 | """Play next track""" 756 | try: 757 | self.api.playback.next() 758 | except reConnectionError as error: 759 | self._attr_is_available = False 760 | _LOGGER.error( 761 | "An error ocurred skipping to the next track for %s.", 762 | self.hostname 763 | ) 764 | _LOGGER.debug(str(error)) 765 | 766 | def media_pause(self): 767 | """Pause the current queue""" 768 | try: 769 | self.api.playback.pause() 770 | except reConnectionError as error: 771 | self._attr_is_available = False 772 | _LOGGER.error( 773 | "An error ocurred pausing for for %s.", 774 | self.hostname 775 | ) 776 | _LOGGER.debug(str(error)) 777 | 778 | def media_play(self, index=None): 779 | """Play the current media""" 780 | if index is None: 781 | self.api.playback.play() 782 | else: 783 | try: 784 | current_tracks = self.api.tracklist.get_tl_tracks() 785 | self.api.playback.play( 786 | tlid=current_tracks[int(index)].tlid 787 | ) 788 | 789 | except Exception as error: 790 | _LOGGER.error("The specified index %s could not be resolved", index) 791 | _LOGGER.debug(str(error)) 792 | 793 | def media_previous_track(self): 794 | """Play previous track""" 795 | self.api.playback.previous() 796 | 797 | def media_seek(self, value): 798 | """Play from a specific point in time""" 799 | self.api.playback.seek(value) 800 | 801 | def media_stop(self): 802 | """Play the current media""" 803 | self.api.playback.stop() 804 | 805 | def play_media(self, media_type, media_id, **kwargs): 806 | """Play the provided media""" 807 | 808 | enqueue = kwargs.get(ATTR_MEDIA_ENQUEUE, MediaPlayerEnqueue.REPLACE) 809 | 810 | media_uris = [media_id] 811 | if media_type == MediaClass.PLAYLIST: 812 | media_uris = self.library.get_playlist_track_uris(media_id) 813 | 814 | if media_type == MediaClass.DIRECTORY: 815 | media_uris = [ x.uri for x in self.library.browse(media_id)] 816 | 817 | if enqueue == MediaPlayerEnqueue.ADD: 818 | # Add media uris to end of the queue 819 | queued = self.queue_tracks(media_uris) 820 | if self.state != MediaPlayerState.PLAYING: 821 | self.media_play() 822 | 823 | elif enqueue == MediaPlayerEnqueue.NEXT: 824 | # Add media uris to queue after current playing track 825 | index = self.queue.position 826 | queued = self.queue_tracks(media_uris, at_position=index+1) 827 | if self.state != MediaPlayerState.PLAYING: 828 | self.media_play() 829 | 830 | elif enqueue == MediaPlayerEnqueue.PLAY: 831 | # Insert media uris before current playing track into queue and play first of new uris 832 | index = self.queue.position 833 | if index is None and self.queue.size is not None: 834 | # no index, probably in stopped state; 835 | # use the last element as index (if known); 836 | # if all else fail, will play from the beginning 837 | index = self.queue.size 838 | queued = self.queue_tracks(media_uris, at_position=index) 839 | self.media_play(index) 840 | 841 | elif enqueue == MediaPlayerEnqueue.REPLACE: 842 | # clear queue and replace with media uris 843 | self.media_stop() 844 | self.clear_queue() 845 | queued = self.queue_tracks(media_uris) 846 | self.media_play() 847 | 848 | else: 849 | _LOGGER.error("No media for %s (%s) could be found.", media_id, media_type) 850 | raise MissingMediaInformation 851 | 852 | self.queue.update_queued_tracks(media_id, media_type, tracks=queued) 853 | 854 | def queue_tracks(self, uris, at_position=None): 855 | """Queue tracks""" 856 | ret = [] 857 | if len(uris) > 0: 858 | ret = self.api.tracklist.add(uris=uris, at_position=at_position) 859 | self.queue.update_tracks() 860 | return ret 861 | 862 | def restore_snapshot(self): 863 | """Restore a snapshot""" 864 | if self.snapshot is None: 865 | # TODO: Raise an error 866 | return 867 | self.media_stop() 868 | self.clear_queue() 869 | self.queue_tracks(self.snapshot.get("queue_list",[])) 870 | self.set_volume(self.snapshot.get("volume")) 871 | self.set_mute(self.snapshot.get("muted")) 872 | if self.snapshot.get("state", MediaPlayerState.IDLE) in [MediaPlayerState.PLAYING, MediaPlayerState.PAUSED]: 873 | current_tracks = self.api.tracklist.get_tl_tracks() 874 | self.api.playback.play( 875 | tlid=current_tracks[self.snapshot.get("queue_index")].tlid 876 | ) 877 | 878 | count = 0 879 | while True: 880 | state = self.api.playback.get_state() 881 | if state in [MediaPlayerState.PLAYING, MediaPlayerState.PAUSED]: 882 | break 883 | if count >= 120: 884 | _LOGGER.error("media player is not playing after 60 seconds. Restoring the snapshot failed") 885 | self.snapshot = None 886 | return 887 | count = count +1 888 | time.sleep(.5) 889 | 890 | if self.snapshot.get("mediaposition",0) > 0: 891 | self.media_seek(self.snapshot["mediaposition"]) 892 | 893 | if self.snapshot["state"] == MediaPlayerState.PAUSED: 894 | self.media_pause() 895 | 896 | self.snapshot = None 897 | self._attr_snapshot_at = None 898 | 899 | def select_source(self, value): 900 | """play the selected source""" 901 | for source in self.library.playlists: 902 | if value == source.name: 903 | self.play_media(MediaType.PLAYLIST, source.uri) 904 | return 905 | raise ValueError(f"Could not find source '{value}'") 906 | 907 | def set_consume_mode(self, value): 908 | """Set the Consume Mode""" 909 | if not isinstance(value, bool): 910 | return False 911 | 912 | if value != self._attr_consume_mode: 913 | self._attr_consume_mode = value 914 | self.entity.force_update_ha_state() 915 | self.api.tracklist.set_consume(value) 916 | 917 | def set_mute(self, value): 918 | """Mute/unmute the speaker""" 919 | self.api.mixer.set_mute(value) 920 | 921 | def set_repeat_mode(self, value): 922 | """Set repeat mode""" 923 | if value == RepeatMode.ALL: 924 | self.api.tracklist.set_repeat(True) 925 | self.api.tracklist.set_single(False) 926 | 927 | elif value == RepeatMode.ONE: 928 | self.api.tracklist.set_repeat(True) 929 | self.api.tracklist.set_single(True) 930 | 931 | else: 932 | self.api.tracklist.set_repeat(False) 933 | self.api.tracklist.set_single(False) 934 | 935 | def set_shuffle(self, value): 936 | """Set Shuffle state""" 937 | self.api.tracklist.set_random(value) 938 | 939 | def set_volume(self, value): 940 | """Set the speaker volume""" 941 | if value is None: 942 | return 943 | if value >= 100: 944 | self.api.mixer.set_volume(100) 945 | self._attr_volume_level = 100 946 | elif value <= 0: 947 | self.api.mixer.set_volume(0) 948 | self._attr_volume_level = 0 949 | else: 950 | self.api.mixer.set_volume(value) 951 | self._attr_volume_level = value 952 | 953 | def take_snapshot(self): 954 | """Take a snapshot""" 955 | self.update() 956 | self._attr_snapshot_at = dt_util.utcnow() 957 | self.snapshot = { 958 | "mediaposition": self.queue.current_track_position, 959 | "muted": self.is_muted, 960 | "repeat_mode": self.repeat, 961 | "shuffled": self.is_shuffled, 962 | "state": self.state, 963 | "queue_list": self.queue.uri_list, 964 | "queue_index": self.queue.position, 965 | "volume": self.volume_level, 966 | } 967 | 968 | def update(self): 969 | """Update the data known by the Speaker Object""" 970 | self.__get_software_version() 971 | 972 | if not self._attr_is_available: 973 | self.__clear() 974 | self.queue.clear_current_track() 975 | return 976 | 977 | if not self.api.wsclient.wsthread.is_alive(): 978 | _LOGGER.warning("The websocket connection was interrupted, re-create connection") 979 | del self.api 980 | self.__connect() 981 | 982 | self.__get_supported_uri_schemes() 983 | self.__get_consume_mode() 984 | self.__get_source_list() 985 | self.__get_volume() 986 | self.__get_shuffle_mode() 987 | self.__get_state() 988 | self.__get_repeat_mode() 989 | 990 | self.queue.update() 991 | 992 | def volume_down(self): 993 | """Turn down the volume""" 994 | if self.volume_level is not None: 995 | self.set_volume(self.volume_level - 5) 996 | 997 | def volume_up(self): 998 | """Turn up the volume""" 999 | if self.volume_level is not None: 1000 | self.set_volume(self.volume_level + 5) 1001 | 1002 | @callback 1003 | def __ws_mute_changed(self, state_info): 1004 | """Mute state has changed""" 1005 | self._attr_is_volume_muted = state_info.mute 1006 | self.entity.force_update_ha_state() 1007 | 1008 | @callback 1009 | def __ws_options_changed(self, options_info): 1010 | """speaker options have changed""" 1011 | self.hass.async_add_executor_job( 1012 | self.__get_consume_mode 1013 | ) 1014 | self.hass.async_add_executor_job( 1015 | self.__get_repeat_mode 1016 | ) 1017 | self.hass.async_add_executor_job( 1018 | self.__get_shuffle_mode 1019 | ) 1020 | self.entity.force_update_ha_state() 1021 | 1022 | @callback 1023 | def __ws_playback_state_changed(self, state_info): 1024 | """playback has changed""" 1025 | self._attr_state = self.__eval_state(state_info.new_state) 1026 | if state_info.new_state == "stopped": 1027 | self.queue.clear_current_track() 1028 | self.entity.force_update_ha_state() 1029 | 1030 | if state_info.new_state == "playing": 1031 | self.hass.async_add_executor_job( 1032 | self.queue.update_current_track, self.entity.force_update_ha_state 1033 | ) 1034 | 1035 | @callback 1036 | def __ws_seeked(self, seek_info): 1037 | """Track time position has changed""" 1038 | self.queue.set_current_track_position(int(seek_info.time_position / 1000)) 1039 | self.entity.force_update_ha_state() 1040 | 1041 | @callback 1042 | def __ws_stream_title_changed(self, stream_info): 1043 | """Stream title changed""" 1044 | self.queue.set_stream_title(stream_info.title) 1045 | self.entity.force_update_ha_state() 1046 | 1047 | self.hass.async_add_executor_job( 1048 | self.queue.update_current_track, self.entity.force_update_ha_state 1049 | ) 1050 | 1051 | @callback 1052 | def __ws_track_playback_paused(self, playback_state): 1053 | """Playback of track was paused""" 1054 | self._attr_state = self.__eval_state("paused") 1055 | self.entity.force_update_ha_state() 1056 | 1057 | @callback 1058 | def __ws_track_playback_resumed(self, playback_state): 1059 | """Playback of paused track was resumed""" 1060 | self._attr_state = self.__eval_state("playing") 1061 | 1062 | self.queue.parse_track_info( 1063 | track = playback_state.tl_track.track, 1064 | tlid = playback_state.tl_track.tlid, 1065 | current = True 1066 | ) 1067 | self.queue.set_current_track_position(int(playback_state.time_position/1000)) 1068 | self.entity.force_update_ha_state() 1069 | 1070 | @callback 1071 | def __ws_track_playback_started(self, playback_state): 1072 | """Playback of track started""" 1073 | self.queue.parse_track_info( 1074 | track = playback_state.tl_track.track, 1075 | tlid = playback_state.tl_track.tlid, 1076 | current = True 1077 | ) 1078 | self.entity.force_update_ha_state() 1079 | self.hass.async_add_executor_job( 1080 | self.queue.update_current_image_url, playback_state.tl_track.track.uri, self.entity.force_update_ha_state 1081 | ) 1082 | 1083 | self.hass.async_add_executor_job( 1084 | self.queue.update_current_track, self.entity.force_update_ha_state 1085 | ) 1086 | 1087 | @callback 1088 | def __ws_tracklist_changed(self, tracklist_info): 1089 | """The queue has changed""" 1090 | self.hass.async_add_executor_job( 1091 | self.queue.update_queue_information, self.entity.force_update_ha_state 1092 | ) 1093 | 1094 | @callback 1095 | def __ws_volume_changed(self, volume_info): 1096 | """The volume was changed""" 1097 | self._attr_volume_level = volume_info.volume 1098 | self.entity.force_update_ha_state() 1099 | 1100 | @property 1101 | def consume_mode(self): 1102 | """Return the consume mode of the the device""" 1103 | return self._attr_consume_mode 1104 | 1105 | @property 1106 | def features(self): 1107 | """Return the features of the Speaker""" 1108 | return self._attr_supported_features 1109 | 1110 | @property 1111 | def is_available(self): 1112 | """Return whether the device is available""" 1113 | return self._attr_is_available 1114 | 1115 | @property 1116 | def is_muted(self): 1117 | """Return whether the speaker is muted""" 1118 | return self._attr_is_volume_muted 1119 | 1120 | @property 1121 | def is_shuffled(self): 1122 | """Return whether the queue is shuffled""" 1123 | return self._attr_shuffle 1124 | 1125 | @property 1126 | def repeat(self): 1127 | """Return repeat mode""" 1128 | return self._attr_repeat 1129 | 1130 | @property 1131 | def snapshot_taken_at(self): 1132 | """Return the time the snapshot is taken at""" 1133 | return self._attr_snapshot_at 1134 | 1135 | @property 1136 | def software_version(self): 1137 | """Return the software version of the Mopidy Device""" 1138 | return self._attr_software_version 1139 | 1140 | @property 1141 | def source_list(self): 1142 | """Return the Source list of the Modpidy speaker""" 1143 | return self._attr_source_list 1144 | 1145 | @property 1146 | def state(self): 1147 | return self._attr_state 1148 | 1149 | @property 1150 | def supported_uri_schemes(self): 1151 | """Return a list of supported URI schemes""" 1152 | return self._attr_supported_uri_schemes 1153 | 1154 | @property 1155 | def volume_level(self): 1156 | """Return the volume level""" 1157 | return self._attr_volume_level 1158 | --------------------------------------------------------------------------------