├── beers.png ├── lyrics.png ├── setup.png ├── screenshot.png ├── shortcuts.png ├── media_browser.png ├── debug_as_error.png ├── polr-ytube-media-card.png ├── media_player_with_buttons.png ├── hacs.json ├── .github ├── ISSUE_TEMPLATE │ ├── custom.md │ ├── feature_request.md │ └── bug_report.md └── workflows │ └── hassfest.yaml ├── .flake8 ├── package ├── default.yaml ├── full_control.yaml └── markdown.yaml ├── custom_components └── ytube_music_player │ ├── manifest.json │ ├── __init__.py │ ├── sensor.py │ ├── services.yaml │ ├── select.py │ ├── config_flow.py │ ├── const.py │ ├── translations │ └── en.json │ └── browse_media.py ├── git2ha.sh ├── .gitignore ├── Multiple_accounts.md ├── BROWSER_AUTH_GUIDE.md ├── QUICK_START_BROWSER_AUTH.md └── README.md /beers.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/KoljaWindeler/ytube_music_player/HEAD/beers.png -------------------------------------------------------------------------------- /lyrics.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/KoljaWindeler/ytube_music_player/HEAD/lyrics.png -------------------------------------------------------------------------------- /setup.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/KoljaWindeler/ytube_music_player/HEAD/setup.png -------------------------------------------------------------------------------- /screenshot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/KoljaWindeler/ytube_music_player/HEAD/screenshot.png -------------------------------------------------------------------------------- /shortcuts.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/KoljaWindeler/ytube_music_player/HEAD/shortcuts.png -------------------------------------------------------------------------------- /media_browser.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/KoljaWindeler/ytube_music_player/HEAD/media_browser.png -------------------------------------------------------------------------------- /debug_as_error.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/KoljaWindeler/ytube_music_player/HEAD/debug_as_error.png -------------------------------------------------------------------------------- /polr-ytube-media-card.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/KoljaWindeler/ytube_music_player/HEAD/polr-ytube-media-card.png -------------------------------------------------------------------------------- /media_player_with_buttons.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/KoljaWindeler/ytube_music_player/HEAD/media_player_with_buttons.png -------------------------------------------------------------------------------- /hacs.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "ytube_music_player", 3 | "content_in_root": false, 4 | "render_readme": true, 5 | "iot_class": "Cloud Polling" 6 | } 7 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/custom.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Custom issue template 3 | about: Describe this issue template's purpose here. 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | 11 | -------------------------------------------------------------------------------- /.flake8: -------------------------------------------------------------------------------- 1 | [flake8] 2 | #E117 over-indented?? 3 | #E722 bare except 4 | 5 | #W191 indentation contains tabs 6 | #F403 'from .const import *' used; unable to detect undefined names 7 | #F405 may be undefined, or defined from star imports: .const 8 | #E501 line too long 9 | ignore = W191,E501,E117,E303,F405,E722,F403 10 | -------------------------------------------------------------------------------- /.github/workflows/hassfest.yaml: -------------------------------------------------------------------------------- 1 | name: Validate with hassfest 2 | 3 | on: 4 | push: 5 | pull_request: 6 | schedule: 7 | - cron: "0 0 * * *" 8 | 9 | jobs: 10 | validate: 11 | runs-on: "ubuntu-latest" 12 | steps: 13 | - uses: "actions/checkout@v2" 14 | - uses: home-assistant/actions/hassfest@master 15 | -------------------------------------------------------------------------------- /package/default.yaml: -------------------------------------------------------------------------------- 1 | 2 | input_select: 3 | ytube_music_player_source: 4 | name: Source 5 | icon: mdi:music-box-multiple 6 | options: # don't change 7 | - "Playlist Radio" 8 | - "Playlist" 9 | 10 | ytube_music_player_speakers: 11 | name: Speakers 12 | icon: mdi:speaker 13 | options: # don't change 14 | - "loading" 15 | 16 | ytube_music_player_playlist: 17 | name: Playlist 18 | icon: mdi:playlist-music 19 | options: # don't change 20 | - "loading" 21 | -------------------------------------------------------------------------------- /custom_components/ytube_music_player/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "domain": "ytube_music_player", 3 | "name": "YouTube Music Player", 4 | "codeowners": [ 5 | "@KoljaWindeler", 6 | "@mang1985" 7 | ], 8 | "config_flow": true, 9 | "dependencies": [ 10 | "persistent_notification" 11 | ], 12 | "documentation": "https://github.com/KoljaWindeler/ytube_music_player", 13 | "iot_class": "cloud_polling", 14 | "issue_tracker": "https://github.com/KoljaWindeler/ytube_music_player/issues", 15 | "requirements": [ 16 | "ytmusicapi==1.9.1", 17 | "pytubefix==10.2.1", 18 | "integrationhelper==0.2.2" 19 | ], 20 | "version": "20251015.01" 21 | } 22 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this project 4 | title: '' 5 | labels: enhancement 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Is your feature request related to a problem? Please describe.** 11 | A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] 12 | 13 | **Describe the solution you'd like** 14 | A clear and concise description of what you want to happen. 15 | 16 | **Describe alternatives you've considered** 17 | A clear and concise description of any alternative solutions or features you've considered. 18 | 19 | **Additional context** 20 | Add any other context or screenshots about the feature request here. 21 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | title: '' 5 | labels: bug 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Enable DEBUG Output** 11 | Please see: https://github.com/KoljaWindeler/ytube_music_player/#debug-information 12 | 13 | **Describe the bug** 14 | A clear and concise description of what the bug is, ideally a homeassistant log with debug enabled 15 | 16 | **Version** 17 | please provide the version you are using (see HACS) 18 | 19 | **To Reproduce** 20 | Steps to reproduce the behavior: 21 | 1. Go to '...' 22 | 2. Click on '....' 23 | 3. Scroll down to '....' 24 | 4. See error 25 | 26 | **Additional context** 27 | Add any other context about the problem here. 28 | 29 | Thanks 30 | -------------------------------------------------------------------------------- /git2ha.sh: -------------------------------------------------------------------------------- 1 | cp custom_components/ytube_music_player/browse_media.py ../homeassistant/custom_components/ytube_music_player/ 2 | cp custom_components/ytube_music_player/config_flow.py ../homeassistant/custom_components/ytube_music_player/ 3 | cp custom_components/ytube_music_player/media_player.py ../homeassistant/custom_components/ytube_music_player/ 4 | cp custom_components/ytube_music_player/sensor.py ../homeassistant/custom_components/ytube_music_player/ 5 | cp custom_components/ytube_music_player/const.py ../homeassistant/custom_components/ytube_music_player/ 6 | cp custom_components/ytube_music_player/manifest.json ../homeassistant/custom_components/ytube_music_player/ 7 | cp custom_components/ytube_music_player/translations/* ../homeassistant/custom_components/ytube_music_player/translations/ 8 | -------------------------------------------------------------------------------- /package/full_control.yaml: -------------------------------------------------------------------------------- 1 | 2 | input_select: 3 | ytube_music_player_source: 4 | name: Source 5 | icon: mdi:music-box-multiple 6 | options: # don't change 7 | - "Playlist Radio" 8 | - "Playlist" 9 | 10 | ytube_music_player_speakers: 11 | name: Speakers 12 | icon: mdi:speaker 13 | options: # don't change 14 | - "loading" 15 | 16 | ytube_music_player_playlist: 17 | name: Playlist 18 | icon: mdi:playlist-music 19 | options: # don't change 20 | - "loading" 21 | 22 | ytube_music_player_playmode: 23 | name: Playmode 24 | icon: mdi:playlist-music 25 | options: ## Should be empty 26 | - "Shuffle" 27 | - "Random" 28 | - "Shuffle Random" 29 | - "Direct" 30 | 31 | input_boolean: 32 | ytube_music_player_playcontinuous: 33 | initial: true 34 | name: "Countinuous playback" 35 | -------------------------------------------------------------------------------- /custom_components/ytube_music_player/__init__.py: -------------------------------------------------------------------------------- 1 | """Provide the initial setup.""" 2 | import logging 3 | from .const import * 4 | 5 | _LOGGER = logging.getLogger(__name__) 6 | 7 | 8 | async def async_setup(hass, config): 9 | """Provide Setup of platform.""" 10 | return True 11 | 12 | 13 | async def async_setup_entry(hass, config_entry): 14 | """Set up this integration using UI/YAML.""" 15 | hass.data.setdefault(DOMAIN, {}) 16 | hass.data[DOMAIN][config_entry.entry_id] = {} 17 | hass.config_entries.async_update_entry(config_entry, data=ensure_config(config_entry.data)) 18 | 19 | if not config_entry.update_listeners: 20 | config_entry.add_update_listener(async_update_options) 21 | 22 | # Add entities to HA 23 | await hass.config_entries.async_forward_entry_setups(config_entry, PLATFORMS) 24 | return True 25 | 26 | 27 | 28 | async def async_remove_entry(hass, config_entry): 29 | """Handle removal of an entry.""" 30 | for platform in PLATFORMS: 31 | try: 32 | await hass.config_entries.async_forward_entry_unload(config_entry, platform) 33 | _LOGGER.info( 34 | "Successfully removed entities from the integration" 35 | ) 36 | except ValueError: 37 | pass 38 | 39 | 40 | async def async_update_options(hass, config_entry): 41 | _LOGGER.debug("Config updated,reload the entities.") 42 | for platform in PLATFORMS: 43 | await hass.config_entries.async_forward_entry_unload(config_entry, platform) 44 | await hass.config_entries.async_forward_entry_setups(config_entry, PLATFORMS) 45 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | wheels/ 23 | pip-wheel-metadata/ 24 | share/python-wheels/ 25 | *.egg-info/ 26 | .installed.cfg 27 | *.egg 28 | MANIFEST 29 | 30 | # PyInstaller 31 | # Usually these files are written by a python script from a template 32 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 33 | *.manifest 34 | *.spec 35 | 36 | # Installer logs 37 | pip-log.txt 38 | pip-delete-this-directory.txt 39 | 40 | # Unit test / coverage reports 41 | htmlcov/ 42 | .tox/ 43 | .nox/ 44 | .coverage 45 | .coverage.* 46 | .cache 47 | nosetests.xml 48 | coverage.xml 49 | *.cover 50 | *.py,cover 51 | .hypothesis/ 52 | .pytest_cache/ 53 | 54 | # Translations 55 | *.mo 56 | *.pot 57 | 58 | # Django stuff: 59 | *.log 60 | local_settings.py 61 | db.sqlite3 62 | db.sqlite3-journal 63 | 64 | # Flask stuff: 65 | instance/ 66 | .webassets-cache 67 | 68 | # Scrapy stuff: 69 | .scrapy 70 | 71 | # Sphinx documentation 72 | docs/_build/ 73 | 74 | # PyBuilder 75 | target/ 76 | 77 | # Jupyter Notebook 78 | .ipynb_checkpoints 79 | 80 | # IPython 81 | profile_default/ 82 | ipython_config.py 83 | 84 | # pyenv 85 | .python-version 86 | 87 | # pipenv 88 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 89 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 90 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 91 | # install all needed dependencies. 92 | #Pipfile.lock 93 | 94 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow 95 | __pypackages__/ 96 | 97 | # Celery stuff 98 | celerybeat-schedule 99 | celerybeat.pid 100 | 101 | # SageMath parsed files 102 | *.sage.py 103 | 104 | # Environments 105 | .env 106 | .venv 107 | env/ 108 | venv/ 109 | ENV/ 110 | env.bak/ 111 | venv.bak/ 112 | 113 | # Spyder project settings 114 | .spyderproject 115 | .spyproject 116 | 117 | # Rope project settings 118 | .ropeproject 119 | 120 | # mkdocs documentation 121 | /site 122 | 123 | # mypy 124 | .mypy_cache/ 125 | .dmypy.json 126 | dmypy.json 127 | 128 | # Pyre type checker 129 | .pyre/ 130 | -------------------------------------------------------------------------------- /custom_components/ytube_music_player/sensor.py: -------------------------------------------------------------------------------- 1 | """Platform for sensor integration.""" 2 | import logging 3 | from homeassistant.helpers.entity import Entity 4 | from homeassistant.exceptions import NoEntitySpecifiedError 5 | from . import DOMAIN 6 | from .const import * 7 | 8 | 9 | _LOGGER = logging.getLogger(__name__) 10 | 11 | 12 | async def async_setup_entry(hass, config, async_add_entities): 13 | # Run setup via Storage 14 | _LOGGER.debug("init ytube sensor") 15 | if(config.data.get(CONF_INIT_EXTRA_SENSOR, DEFAULT_INIT_EXTRA_SENSOR)): 16 | async_add_entities([yTubeMusicSensor(hass, config)], update_before_add=True) 17 | 18 | 19 | class yTubeMusicSensor(Entity): 20 | # Extra Sensor for the YouTube Music player integration 21 | 22 | def __init__(self,hass, config): 23 | # Initialize the sensor. 24 | self.hass = hass 25 | self._state = STATE_OFF 26 | self._device_id = config.entry_id 27 | self._device_name = config.data.get(CONF_NAME) 28 | self._attr_unique_id = config.entry_id + "_extra" # should be different from the media_player entity 29 | self._attr_has_entity_name = True 30 | self._attr_name = "Extra" 31 | self._attr_icon = 'mdi:information-outline' 32 | self.hass.data[DOMAIN][self._device_id]['extra_sensor'] = self 33 | self._attr = {'tracks', 'search', 'lyrics', 'playlists', 'total_tracks'} 34 | self._attributes = {} 35 | for attr in self._attr: 36 | self._attributes[attr] = "" 37 | 38 | _LOGGER.debug("init ytube sensor done") 39 | 40 | @property 41 | def device_info(self): 42 | return { 43 | 'identifiers': {(DOMAIN, self._device_id)}, 44 | 'name': self._device_name, 45 | 'manufacturer': "Google Inc.", 46 | 'model': DOMAIN 47 | } 48 | 49 | @property 50 | def name(self): 51 | # Return the name of the sensor. 52 | return self._attr_name 53 | 54 | @property 55 | def state(self): 56 | # Return the state of the sensor. 57 | return self._state 58 | 59 | @property 60 | def should_poll(self): 61 | # No polling needed. 62 | return False 63 | 64 | async def async_update(self): 65 | # update sensor 66 | self._ready = True 67 | _LOGGER.debug("updating ytube sensor") 68 | 69 | # update all attributes from the data var 70 | for attr in self._attr: 71 | if attr in self.hass.data[DOMAIN][self._device_id]: 72 | self._attributes[attr] = self.hass.data[DOMAIN][self._device_id][attr] 73 | 74 | try: 75 | self.async_schedule_update_ha_state() 76 | except NoEntitySpecifiedError: 77 | pass # we ignore this due to a harmless startup race condition 78 | 79 | @property 80 | def extra_state_attributes(self): 81 | # Return the device state attributes. 82 | return self._attributes -------------------------------------------------------------------------------- /custom_components/ytube_music_player/services.yaml: -------------------------------------------------------------------------------- 1 | call_method: 2 | fields: 3 | entity_id: 4 | example: "media_player.ytube_music_player" 5 | required: true 6 | selector: 7 | entity: 8 | domain: media_player 9 | command: 10 | example: "rate_track" 11 | required: true 12 | selector: 13 | text: 14 | parameters: 15 | example: "thumb_up" 16 | required: true 17 | selector: 18 | text: 19 | 20 | search: 21 | fields: 22 | entity_id: 23 | example: "media_player.ytube_music_player" 24 | required: true 25 | selector: 26 | entity: 27 | domain: media_player 28 | query: 29 | example: "2pm tetris" 30 | required: true 31 | selector: 32 | text: 33 | filter: 34 | required: false 35 | selector: 36 | text: 37 | limit: 38 | required: false 39 | example: "20" 40 | default: 20 41 | selector: 42 | number: 43 | min: 1 44 | max: 1000 45 | 46 | add_to_playlist: 47 | fields: 48 | entity_id: 49 | example: "media_player.ytube_music_player" 50 | required: true 51 | selector: 52 | entity: 53 | domain: media_player 54 | song_id: 55 | required: false 56 | example: "" 57 | selector: 58 | text: 59 | playlist_id: 60 | required: false 61 | example: "" 62 | selector: 63 | text: 64 | 65 | remove_from_playlist: 66 | fields: 67 | entity_id: 68 | example: "media_player.ytube_music_player" 69 | required: true 70 | selector: 71 | entity: 72 | domain: media_player 73 | song_id: 74 | example: "" 75 | playlist_id: 76 | example: "" 77 | 78 | rate_track: 79 | fields: 80 | entity_id: 81 | example: "media_player.ytube_music_player" 82 | required: true 83 | selector: 84 | entity: 85 | domain: media_player 86 | rating: 87 | example: "thumb_up" 88 | required: true 89 | selector: 90 | select: 91 | options: 92 | - "thumb_up" 93 | - "thumb_down" 94 | - "thumb_middle" 95 | - "thumb_toggle_up_middle" 96 | song_id: 97 | example: "" 98 | 99 | limit_count: 100 | fields: 101 | entity_id: 102 | example: "media_player.ytube_music_player" 103 | required: true 104 | selector: 105 | entity: 106 | domain: media_player 107 | limit: 108 | example: "20" 109 | required: true 110 | selector: 111 | number: 112 | min: 1 113 | max: 1000 114 | 115 | start_radio: 116 | fields: 117 | entity_id: 118 | example: "media_player.ytube_music_player" 119 | required: true 120 | selector: 121 | entity: 122 | domain: media_player 123 | interrupt: 124 | required: true 125 | example: "true" 126 | selector: 127 | boolean: 128 | -------------------------------------------------------------------------------- /Multiple_accounts.md: -------------------------------------------------------------------------------- 1 | It should be possible to use multipe accounts with the following steps: 2 | 1. Open Settings -> Integration -> Add new "YouTube Music Player" -> Paste cookie data of the first user 3 | 2. The integration will generate a ytube_header.json file in the .storage folder of your configuration, rename this file to something like ytube_header_kolja.json 4 | 3. Repeat Step 1 (you can delete the component before, or just have two of them) 5 | 4. Repeate Step 2 6 | 5. Define the media_player and all input selects twice (source / playlist / speaker / playmode) and directly link them directly as shown below. 7 | _Suggestion: create two files in the package folder_ 8 | 9 | ## Player1.yaml 10 | ```yaml 11 | media_player: 12 | ############## Koljas Player ############## 13 | - platform: ytube_music_player 14 | header_path: '/config/.storage/ytube_header_kolja.json' 15 | speakers: 16 | - keller_2 17 | select_source: ytube_music_player_source_kolja 18 | select_playlist: ytube_music_player_playlist_kolja 19 | select_speakers: ytube_music_player_speakers_kolja 20 | select_playmode: ytube_music_player_playmode_kolja 21 | 22 | input_select: 23 | ############################ 24 | ### Koljas select fields ### 25 | ############################ 26 | ytube_music_player_source_kolja: 27 | name: Source 28 | icon: mdi:music-box-multiple 29 | options: 30 | - "Playlist Radio" 31 | - "Playlist" 32 | 33 | ytube_music_player_speakers_kolja: 34 | name: Speakers 35 | icon: mdi:speaker 36 | options: ## Should be empty 37 | - " " 38 | 39 | ytube_music_player_playlist_kolja: 40 | name: Playlist 41 | icon: mdi:playlist-music 42 | options: ## Should be empty 43 | - " " 44 | 45 | ytube_music_player_playmode_kolja: 46 | name: Playmode 47 | icon: mdi:playlist-music 48 | options: ## Should be empty 49 | - "Shuffle" 50 | - "Random" 51 | - "Shuffle Random" 52 | - "Direct" 53 | 54 | 55 | 56 | ``` 57 | 58 | ## Player2.yaml 59 | ```yaml 60 | media_player: 61 | ############## Caros Player ############## 62 | - platform: ytube_music_player 63 | header_path: '/config/.storage/ytube_header_caro.json' 64 | speakers: 65 | - keller 66 | select_source: ytube_music_player_source_caro 67 | select_playlist: ytube_music_player_playlist_caro 68 | select_speakers: ytube_music_player_speakers_caro 69 | select_playmode: ytube_music_player_playmode_caro 70 | 71 | input_select: 72 | ########################### 73 | ### Caros select fields ### 74 | ########################### 75 | 76 | ytube_music_player_source_caro: 77 | name: Source 78 | icon: mdi:music-box-multiple 79 | options: 80 | - "Playlist Radio" 81 | - "Playlist" 82 | 83 | ytube_music_player_speakers_caro: 84 | name: Speakers 85 | icon: mdi:speaker 86 | options: ## Should be empty 87 | - " " 88 | 89 | ytube_music_player_playlist_caro: 90 | name: Playlist 91 | icon: mdi:playlist-music 92 | options: ## Should be empty 93 | - " " 94 | 95 | ytube_music_player_playmode_caro: 96 | name: Playmode 97 | icon: mdi:playlist-music 98 | options: ## Should be empty 99 | - "Shuffle" 100 | - "Random" 101 | - "Shuffle Random" 102 | - "Direct" 103 | ``` 104 | -------------------------------------------------------------------------------- /BROWSER_AUTH_GUIDE.md: -------------------------------------------------------------------------------- 1 | # Browser Authentication Guide 2 | 3 | ## ⚠️ IMPORTANT: OAuth is Currently Broken 4 | 5 | As of November 2024, OAuth authentication for YouTube Music is experiencing widespread issues due to server-side changes by Google/YouTube. Many users report HTTP 400 "Bad Request" errors even with valid OAuth credentials. This is a known issue tracked in ytmusicapi repository (issues #676, #682). 6 | 7 | **The recommended solution is to use browser-based authentication instead.** 8 | 9 | ## Why Browser Authentication? 10 | 11 | - **Works reliably** - Not affected by OAuth server-side issues 12 | - **Simple to set up** - Just extract headers from your browser 13 | - **Long-lasting** - Browser cookies remain valid for months when created in a private/incognito session 14 | 15 | ## How to Set Up Browser Authentication 16 | 17 | ### Step 1: Extract Browser Headers 18 | 19 | Follow the official ytmusicapi guide: 20 | https://ytmusicapi.readthedocs.io/en/stable/setup/browser.html 21 | 22 | Quick summary: 23 | 1. Open YouTube Music in a **private/incognito** browser window 24 | 2. Log in to your account 25 | 3. Open Developer Tools (F12) 26 | 4. Go to Network tab 27 | 5. Refresh the page and look for a request to `music.youtube.com` 28 | 6. Copy the request headers (specifically the Cookie header) 29 | 30 | ### Step 2: Create Header File 31 | 32 | The ytmusicapi documentation provides a tool to help you create the header file from your browser cookies. Run: 33 | 34 | ```bash 35 | ytmusicapi browser 36 | ``` 37 | 38 | This will guide you through pasting your headers and save them to `oauth.json` (or you can specify a custom path). 39 | 40 | ### Step 3: Configure Home Assistant Integration 41 | 42 | 1. **Delete the existing integration** if you have one with OAuth errors 43 | - Go to Settings → Devices & Services 44 | - Find "YouTube Music Player" 45 | - Click the three dots and select "Delete" 46 | 47 | 2. **Add the integration again** 48 | - Go to Settings → Devices & Services → "+ Add Integration" 49 | - Search for "YouTube Music Player" 50 | 51 | 3. **Skip OAuth setup** 52 | - On the first screen, **UNCHECK** "Renew OAuth credentials" 53 | - Continue to the finish screen 54 | 55 | 4. **Specify your header file path** 56 | - Enter the path to the header file you created in Step 2 57 | - Default location: `/config/.storage/ytube_music_player_auth_header_.json` 58 | - Or use a custom path if you saved it elsewhere 59 | 60 | 5. **Complete setup** 61 | - Select your output devices 62 | - Configure any advanced options 63 | - Save 64 | 65 | ## Troubleshooting 66 | 67 | **Q: Can I still try OAuth?** 68 | A: Yes, but it's likely to fail with HTTP 400 errors. The integration will warn you about this. If you want to try anyway, keep "Renew OAuth credentials" checked and follow the OAuth setup flow. 69 | 70 | **Q: How often do I need to update browser headers?** 71 | A: When created in a private/incognito session, browser cookies can last for months. You'll know they've expired when the integration stops working and shows authentication errors. 72 | 73 | **Q: The integration still shows OAuth-related errors** 74 | A: Make sure you: 75 | 1. Completely removed the old integration (not just disabled it) 76 | 2. Deleted or renamed the old OAuth token file 77 | 3. Created a fresh header file using browser authentication 78 | 4. Unchecked "Renew OAuth credentials" during setup 79 | 80 | ## Additional Resources 81 | 82 | - ytmusicapi Browser Authentication Guide: https://ytmusicapi.readthedocs.io/en/stable/setup/browser.html 83 | - ytmusicapi OAuth Issue #676: https://github.com/sigma67/ytmusicapi/issues/676 84 | - ytmusicapi OAuth Issue #682: https://github.com/sigma67/ytmusicapi/discussions/682 85 | - Home Assistant Community: https://community.home-assistant.io/ 86 | 87 | ## Future Updates 88 | 89 | This guide will be updated when/if OAuth authentication is fixed by YouTube Music. Monitor the ytmusicapi GitHub repository for updates on the OAuth issue. 90 | -------------------------------------------------------------------------------- /custom_components/ytube_music_player/select.py: -------------------------------------------------------------------------------- 1 | """Platform for sensor integration.""" 2 | import logging 3 | from homeassistant.components.select import SelectEntity 4 | from homeassistant.exceptions import NoEntitySpecifiedError 5 | from . import DOMAIN 6 | from .const import * 7 | 8 | _LOGGER = logging.getLogger(__name__) 9 | 10 | async def async_setup_entry(hass, config, async_add_entities): 11 | _LOGGER.debug("Init the dropdown(s)") 12 | init_dropdowns = config.data.get(CONF_INIT_DROPDOWNS, DEFAULT_INIT_DROPDOWNS) 13 | select_entities = { 14 | "playlists": yTubeMusicPlaylistSelect(hass, config), 15 | "speakers": yTubeMusicSpeakerSelect(hass, config), 16 | "playmode": yTubeMusicPlayModeSelect(hass, config), 17 | "radiomode": yTubeMusicSourceSelect(hass, config), 18 | "repeatmode": yTubeMusicRepeatSelect(hass, config) 19 | } 20 | entities = [] 21 | for dropdown,entity in select_entities.items(): 22 | if dropdown in init_dropdowns: 23 | entities.append(entity) 24 | async_add_entities(entities, update_before_add=True) 25 | 26 | class yTubeMusicSelectEntity(SelectEntity): 27 | def __init__(self, hass, config): 28 | self.hass = hass 29 | self._device_id = config.entry_id 30 | self._device_name = config.data.get(CONF_NAME) 31 | self._attr_has_entity_name = True 32 | 33 | def select_option(self, option): 34 | """Change the selected option.""" 35 | self._attr_current_option = option 36 | self.schedule_update_ha_state() 37 | @property 38 | def device_info(self): 39 | return { 40 | 'identifiers': {(DOMAIN, self._device_id)}, 41 | 'name': self._device_name, 42 | 'manufacturer': "Google Inc.", 43 | 'model': DOMAIN 44 | } 45 | 46 | @property 47 | def should_poll(self): 48 | return False 49 | 50 | 51 | class yTubeMusicPlaylistSelect(yTubeMusicSelectEntity): 52 | def __init__(self, hass, config): 53 | super().__init__(hass, config) 54 | self._attr_unique_id = self._device_id + "_playlist" 55 | self._attr_name = "Playlist" 56 | self._attr_icon = 'mdi:playlist-music' 57 | self.hass.data[DOMAIN][self._device_id]['select_playlists'] = self 58 | self._attr_options = ["loading"] 59 | self._attr_current_option = None 60 | 61 | async def async_update(self): 62 | # update select 63 | self._ready = True 64 | try: 65 | self._attr_options = list(self.hass.data[DOMAIN][self._device_id]['playlists'].keys()) 66 | except: 67 | pass 68 | try: 69 | self.async_schedule_update_ha_state() 70 | except NoEntitySpecifiedError: 71 | pass # we ignore this due to a harmless startup race condition 72 | 73 | 74 | class yTubeMusicSpeakerSelect(yTubeMusicSelectEntity): 75 | def __init__(self, hass, config): 76 | super().__init__(hass, config) 77 | self._attr_unique_id = self._device_id + "_speaker" 78 | self._attr_name = "Speaker" 79 | self._attr_icon = 'mdi:speaker' 80 | self.hass.data[DOMAIN][self._device_id]['select_speakers'] = self 81 | self._attr_options = ["loading"] 82 | self._attr_current_option = None 83 | 84 | async def async_update(self, options=[]): 85 | # update select 86 | self._ready = True 87 | try: 88 | self._attr_options = options 89 | except: 90 | pass 91 | try: 92 | self.async_schedule_update_ha_state() 93 | except NoEntitySpecifiedError: 94 | pass # we ignore this due to a harmless startup race condition 95 | 96 | 97 | class yTubeMusicPlayModeSelect(yTubeMusicSelectEntity): 98 | def __init__(self, hass, config): 99 | super().__init__(hass, config) 100 | self._attr_unique_id = self._device_id + "_playmode" 101 | self._attr_name = "Play Mode" 102 | self._attr_icon = 'mdi:shuffle' 103 | self.hass.data[DOMAIN][self._device_id]['select_playmode'] = self 104 | self._attr_options = ["Shuffle","Random","Shuffle Random","Direct"] 105 | self._attr_current_option = "Shuffle Random" 106 | 107 | 108 | class yTubeMusicSourceSelect(yTubeMusicSelectEntity): 109 | def __init__(self, hass, config): 110 | super().__init__(hass, config) 111 | self._attr_unique_id = self._device_id + "_radiomode" 112 | self._attr_name = "Radio Mode" 113 | self._attr_icon = 'mdi:music-box-multiple' 114 | self.hass.data[DOMAIN][self._device_id]['select_radiomode'] = self 115 | self._attr_options = ["Playlist","Playlist Radio"] # "Playlist" means not radio mode 116 | self._attr_current_option = "Playlist" 117 | 118 | 119 | class yTubeMusicRepeatSelect(yTubeMusicSelectEntity): 120 | def __init__(self, hass, config): 121 | super().__init__(hass, config) 122 | self._attr_unique_id = self._device_id + "_repeat" 123 | self._attr_name = "Repeat Mode" 124 | self._attr_icon = 'mdi:repeat' 125 | self.hass.data[DOMAIN][self._device_id]['select_repeatmode'] = self 126 | self._attr_options = ["all", "one", "off"] # one for future 127 | self._attr_current_option = "all" -------------------------------------------------------------------------------- /package/markdown.yaml: -------------------------------------------------------------------------------- 1 | title: dev 2 | views: 3 | - badges: null 4 | cards: 5 | - type: vertical-stack 6 | cards: 7 | - type: 'custom:mini-media-player' 8 | entity: media_player.ytube_music_player 9 | artwork: cover 10 | hide: 11 | shuffle: false 12 | icon_state: false 13 | shortcuts: 14 | columns: 6 15 | buttons: 16 | - name: Keller 17 | type: source 18 | id: keller_2 19 | - name: jkw_cast 20 | type: source 21 | id: jkw_cast2 22 | - name: Laptop 23 | type: source 24 | id: bm_8e5f874f_8dfcb60f 25 | - name: My Likes 26 | type: channel 27 | id: RDTMAK5uy_kset8D_____SD4TNjEVvrKRTmG7a56sY 28 | - name: Lala 29 | type: playlist 30 | id: PLZvjm51R8SGu_______-A17Kp3jZfg6pg 31 | - type: entities 32 | entities: 33 | - entity: select.ytube_music_player_playlist 34 | - entity: select.ytube_music_player_radio_mode 35 | - entity: select.ytube_music_player_speaker 36 | - entity: select.ytube_music_player_play_mode 37 | title: Kolja 38 | - type: 'custom:mini-media-player' 39 | entity: media_player.keller_2 40 | artwork: cover 41 | - type: 'custom:mini-media-player' 42 | entity: media_player.jkw_cast2 43 | - type: 'custom:mini-media-player' 44 | entity: media_player.kuche 45 | - type: vertical-stack 46 | cards: 47 | - type: markdown 48 | content: >- 49 | {% if is_state('media_player.ytube_music_player','playing')%} 50 | **Playback started via:** (Arguments for play_media) 51 | 52 | *_media_type*: 53 | {{state_attr("media_player.ytube_music_player","_media_type")}} 54 | 55 | *_media_id*: {{state_attr("media_player.ytube_music_player", 56 | "_media_id")}} 57 | 58 | 59 | **Current Track Infos:** 60 | 61 | **Artist:** 62 | {{state_attr("media_player.ytube_music_player","media_artist")}} 63 | 64 | **Track:** 65 | {{state_attr("media_player.ytube_music_player","media_title")}} 66 | 67 | **videoId:** {{state_attr("media_player.ytube_music_player", 68 | "videoId")}} 69 | 70 | **Rating:** 71 | {{state_attr("media_player.ytube_music_player","likeStatus")}} 72 | 73 | **Playing on:** 74 | {{state_attr("media_player.ytube_music_player","_player_id")}} 75 | 76 | {% else %}Player off{% endif %} 77 | - type: markdown 78 | content: >- 79 | {% if is_state('media_player.ytube_music_player','playing') %}{% 80 | for i in range(0,state_attr("sensor.ytube_music_player_extra", 81 | "total_tracks")) %} 82 | {% if i == state_attr("media_player.ytube_music_player", 83 | "current_track") %}**{% endif %}\[{{i+1}}] {{ 84 | state_attr("sensor.ytube_music_player_extra", "tracks")[i] }}{% if 85 | i == state_attr("media_player.ytube_music_player", 86 | "current_track") %}**{% endif %} 87 | 88 | {%- endfor %}{% else %}Player off{% endif %} 89 | 90 | - type: horizontal-stack 91 | cards: 92 | - type: 'custom:button-card' 93 | state: 94 | - operator: template 95 | value: > 96 | [[[ return 97 | states['media_player.ytube_music_player'].attributes.likeStatus 98 | == "LIKE"]]] 99 | icon: 'mdi:thumb-up' 100 | - operator: template 101 | value: > 102 | [[[ return 103 | states['media_player.ytube_music_player'].attributes.likeStatus 104 | == "INDIFFERENT"]]] 105 | icon: 'mdi:thumb-up-outline' 106 | - operator: default 107 | icon: 'mdi:power-off' 108 | value: false 109 | tap_action: 110 | action: call-service 111 | service: ytube_music_player.call_method 112 | service_data: 113 | entity_id: media_player.ytube_music_player 114 | command: rate_track 115 | parameters: thumb_toggle_up_middle 116 | - type: markdown 117 | content: >- 118 | {% if is_state('media_player.ytube_music_player','playing') %} 119 | {% for i in range(0,state_attr("sensor.ytube_music_player_extra","total_tracks")) %} 120 | {% if i == state_attr("media_player.ytube_music_player","current_track") %}**{% endif %}\[{{i+1}}]{{state_attr("sensor.ytube_music_player_extra", "tracks")[i] }}{% if i== state_attr("media_player.ytube_music_player", "current_track")%}**{% endif %} 121 | {%- endfor %}{% else %}Player off{% endif %} 122 | - type: markdown 123 | content: >- 124 | {% if is_state('media_player.ytube_music_player','playing') %}{{ 125 | state_attr("sensor.ytube_music_player_extra", "lyrics") }}{% else 126 | %}Player off{% endif %} 127 | path: default_view 128 | title: Home 129 | -------------------------------------------------------------------------------- /QUICK_START_BROWSER_AUTH.md: -------------------------------------------------------------------------------- 1 | # Quick Start: Browser Authentication Setup 2 | 3 | ## What You Need to Do Right Now 4 | 5 | While Home Assistant is restarting, follow these steps to create your browser authentication file. 6 | 7 | ## Step 1: Open YouTube Music in Incognito/Private Window 8 | 9 | **Important:** Use an incognito/private window so cookies last longer! 10 | 11 | 1. Open your browser's incognito/private mode: 12 | - Chrome/Edge: `Ctrl+Shift+N` (Windows) or `Cmd+Shift+N` (Mac) 13 | - Firefox: `Ctrl+Shift+P` (Windows) or `Cmd+Shift+P` (Mac) 14 | - Safari: `Cmd+Shift+N` 15 | 16 | 2. Go to https://music.youtube.com 17 | 18 | 3. Log in to your YouTube Music account 19 | 20 | ## Step 2: Extract Browser Headers 21 | 22 | ### Method 1: Using Browser Developer Tools (Manual) 23 | 24 | 1. **Open Developer Tools**: Press `F12` 25 | 26 | 2. **Go to Network tab** 27 | 28 | 3. **Filter for "browse"**: Type `browse` in the filter box at the top 29 | 30 | 4. **Refresh the page**: Press `F5` or click the refresh button 31 | 32 | 5. **Click on a "browse" request**: Look for a request to `music.youtube.com` in the list 33 | 34 | 6. **Copy Request Headers**: 35 | - Find the "Request Headers" section (usually on the right side) 36 | - Look for these specific headers and copy their values: 37 | - `Cookie:` (most important!) 38 | - `User-Agent:` 39 | - `Accept-Language:` 40 | - `X-Goog-AuthUser:` 41 | 42 | ### Method 2: Using ytmusicapi Command (Easier) 43 | 44 | If you have Python installed on your local machine: 45 | 46 | ```bash 47 | # Install ytmusicapi 48 | pip install ytmusicapi 49 | 50 | # Run the browser auth setup 51 | ytmusicapi browser 52 | ``` 53 | 54 | This will guide you through pasting headers and automatically create the JSON file. 55 | 56 | ## Step 3: Create the Header File 57 | 58 | ### JSON Format 59 | 60 | Create a file with this structure: 61 | 62 | ```json 63 | { 64 | "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36...", 65 | "Accept": "*/*", 66 | "Accept-Language": "en-US,en;q=0.5", 67 | "Accept-Encoding": "gzip, deflate, br", 68 | "Content-Type": "application/json", 69 | "X-Goog-AuthUser": "0", 70 | "X-Goog-Visitor-Id": "...", 71 | "Authorization": "...", 72 | "Cookie": "__Secure-1PSID=...; __Secure-1PAPISID=...; __Secure-3PSID=...; __Secure-3PAPISID=..." 73 | } 74 | ``` 75 | 76 | **CRITICAL:** The `Cookie:` header MUST include these values: 77 | - `__Secure-1PSID` 78 | - `__Secure-1PAPISID` 79 | - `__Secure-3PSID` 80 | - `__Secure-3PAPISID` 81 | 82 | ### Where to Save the File 83 | 84 | **Option A:** Default location (recommended) 85 | ``` 86 | /config/.storage/header_ytube_music_player.json 87 | ``` 88 | 89 | **Option B:** Custom location 90 | Save anywhere you want, but remember the path for the integration setup. 91 | 92 | ## Step 4: Copy File to Home Assistant 93 | 94 | ### Method 1: Using File Editor Add-on 95 | 96 | 1. Install "File editor" add-on if not already installed 97 | 2. Navigate to `.storage` folder 98 | 3. Create/upload your `header_ytube_music_player.json` file 99 | 100 | ### Method 2: Using Samba/SMB Share 101 | 102 | 1. Connect to your Home Assistant via network share 103 | 2. Navigate to `config/.storage/` 104 | 3. Copy your JSON file there 105 | 106 | ### Method 3: Using SSH/Terminal 107 | 108 | ```bash 109 | # Copy from your local machine to Home Assistant 110 | scp header_ytube_music_player.json root@homeassistant.local:/config/.storage/ 111 | ``` 112 | 113 | ## Step 5: Configure the Integration 114 | 115 | Once Home Assistant has restarted: 116 | 117 | 1. **Go to**: Settings → Devices & Services → "+ Add Integration" 118 | 119 | 2. **Search for**: "YouTube Music Player" 120 | 121 | 3. **First screen**: 122 | - Enter entity name: `ytube_music_player` (or whatever you want) 123 | - **UNCHECK** "Use OAuth authentication" (it's broken anyway) 124 | - Click Submit 125 | 126 | 4. **Final screen**: 127 | - Select your output devices (speakers/media players) 128 | - **File path for the header file**: Enter the path where you saved it 129 | - If you used default location: `/config/.storage/header_ytube_music_player.json` 130 | - Otherwise: enter your custom path 131 | - Language: `en` (or your preference) 132 | - Click Submit 133 | 134 | ## Step 6: Test It! 135 | 136 | 1. Go to Media Browser 137 | 2. Navigate to YouTube Music Player 138 | 3. Browse your playlists 139 | 4. Play a song! 140 | 141 | ## Troubleshooting 142 | 143 | ### "Unknown error occurred" 144 | 145 | **Cause:** The header file doesn't exist at the path you specified or is malformed. 146 | 147 | **Fix:** 148 | - Double-check the file path is correct 149 | - Verify the JSON is valid (use https://jsonlint.com) 150 | - Make sure the file has the Cookie header with all required fields 151 | 152 | ### "HTTP 401: Unauthorized" 153 | 154 | **Cause:** Your cookies have expired. 155 | 156 | **Fix:** 157 | - Repeat steps 1-4 to extract fresh headers 158 | - Replace the old header file with the new one 159 | - Restart Home Assistant 160 | 161 | ### "HTTP 403: Forbidden" 162 | 163 | **Cause:** Missing PO tokens (but this should be fixed now!) 164 | 165 | **Fix:** 166 | - Make sure you have the latest code with PO token support 167 | - The integration should automatically generate PO tokens 168 | 169 | ### Playback doesn't work but browsing does 170 | 171 | **Cause:** PO tokens not being generated (rare). 172 | 173 | **Fix:** 174 | - Check Home Assistant logs for pytubefix errors 175 | - Ensure nodejs-wheel-binaries is installed 176 | - Restart Home Assistant 177 | 178 | ## How Long Do Cookies Last? 179 | 180 | - **Created in incognito/private mode:** Several months 181 | - **Created in normal mode:** Could expire sooner 182 | - **You'll know they expired when:** Integration stops working with 401 errors 183 | 184 | ## Need More Help? 185 | 186 | - Full guide: See `BROWSER_AUTH_GUIDE.md` in this repository 187 | - ytmusicapi docs: https://ytmusicapi.readthedocs.io/en/stable/setup/browser.html 188 | - Home Assistant Community: https://community.home-assistant.io/ 189 | 190 | --- 191 | 192 | **Pro Tip:** Bookmark the page in your browser showing the Developer Tools with the request headers. If your cookies expire, you can quickly re-open it and grab new ones! 193 | -------------------------------------------------------------------------------- /custom_components/ytube_music_player/config_flow.py: -------------------------------------------------------------------------------- 1 | """Provide the config flow.""" 2 | from homeassistant.core import callback 3 | from homeassistant import config_entries 4 | import homeassistant.helpers.config_validation as cv 5 | from homeassistant.helpers.selector import selector 6 | import voluptuous as vol 7 | import logging 8 | from .const import * 9 | import os 10 | import os.path 11 | from homeassistant.helpers.storage import STORAGE_DIR 12 | import ytmusicapi 13 | from ytmusicapi.helpers import SUPPORTED_LANGUAGES 14 | from ytmusicapi.auth.oauth import OAuthCredentials, RefreshingToken 15 | import requests 16 | 17 | from collections import OrderedDict 18 | 19 | _LOGGER = logging.getLogger(__name__) 20 | 21 | 22 | @config_entries.HANDLERS.register(DOMAIN) 23 | class yTubeMusicFlowHandler(config_entries.ConfigFlow): 24 | """Provide the initial setup.""" 25 | 26 | CONNECTION_CLASS = config_entries.CONN_CLASS_LOCAL_POLL 27 | VERSION = 1 28 | 29 | def __init__(self): 30 | """Provide the init function of the config flow.""" 31 | # Called once the flow is started by the user 32 | self._errors = {} 33 | 34 | # entry point from config start 35 | async def async_step_user(self, user_input=None): # pylint: disable=unused-argument 36 | return await async_common_step_user(self,user_input) 37 | 38 | 39 | async def async_step_oauth(self, user_input=None): # pylint: disable=unused-argument 40 | return await async_common_step_oauth(self, user_input) 41 | 42 | # we get here after the user click submit on the oauth screem 43 | # lets check if oauth worked 44 | async def async_step_oauth2(self, user_input=None): # pylint: disable=unused-argument 45 | return await async_common_step_oauth2(self, user_input) 46 | 47 | # we get here after the user click submit on the oauth screem 48 | # lets check if oauth worked 49 | async def async_step_oauth3(self, user_input=None): # pylint: disable=unused-argument 50 | return await async_common_step_oauth3(self, user_input) # pylint: disable=unused-argument 51 | 52 | # will be called by sending the form, until configuration is done 53 | async def async_step_finish(self,user_input=None): 54 | return await async_common_step_finish(self, user_input) 55 | 56 | 57 | async def async_step_adv_finish(self,user_input=None): 58 | return await async_common_step_adv_finish(self, user_input) 59 | 60 | 61 | # TODO .. what is this good for? 62 | async def async_step_import(self, user_input): # pylint: disable=unused-argument 63 | """Import a config entry. 64 | 65 | Special type of import, we're not actually going to store any data. 66 | Instead, we're going to rely on the values that are in config file. 67 | """ 68 | if self._async_current_entries(): 69 | return self.async_abort(reason="single_instance_allowed") 70 | 71 | return self.async_create_entry(title="configuration.yaml", data={}) 72 | 73 | @staticmethod 74 | @callback 75 | def async_get_options_flow(config_entry): 76 | """Call back to start the change flow.""" 77 | return OptionsFlowHandler(config_entry) 78 | 79 | 80 | class OptionsFlowHandler(config_entries.OptionsFlow): 81 | """Change an entity via GUI.""" 82 | 83 | def __init__(self, config_entry): 84 | """Set initial parameter to grab them later on.""" 85 | # store old entry for later 86 | self.data = dict(config_entry.options or config_entry.data) 87 | self.data[CONF_HEADER_PATH+"_old"] = self.data[CONF_HEADER_PATH] 88 | self.data[CONF_RENEW_OAUTH] = False 89 | 90 | 91 | # will be called by sending the form, until configuration is done 92 | async def async_step_init(self, user_input=None): # pylint: disable=unused-argument 93 | """Call this as first page.""" 94 | user_input = self.data 95 | return await async_common_step_user(self,user_input, option_flow = True) 96 | 97 | async def async_step_oauth(self, user_input=None): # pylint: disable=unused-argument 98 | return await async_common_step_oauth(self, user_input, option_flow = True) 99 | 100 | # we get here after the user click submit on the oauth screem 101 | # lets check if oauth worked 102 | async def async_step_oauth2(self, user_input=None): # pylint: disable=unused-argument 103 | return await async_common_step_oauth2(self, user_input, option_flow = True) 104 | 105 | # we get here after the user click submit on the oauth screem 106 | # lets check if oauth worked 107 | async def async_step_oauth3(self, user_input=None): # pylint: disable=unused-argument 108 | return await async_common_step_oauth3(self, user_input, option_flow = True) # pylint: disable=unused-argument 109 | 110 | # will be called by sending the form, until configuration is done 111 | async def async_step_finish(self,user_input=None): 112 | return await async_common_step_finish(self, user_input, option_flow = True) 113 | 114 | 115 | async def async_step_adv_finish(self,user_input=None): 116 | return await async_common_step_adv_finish(self, user_input, option_flow = True) 117 | 118 | 119 | async def async_common_step_user(self, user_input=None, option_flow = False): 120 | self._errors = {} 121 | #_LOGGER.error("step user was just called") 122 | """Call this as first page.""" 123 | if(user_input == None): 124 | user_input = dict() 125 | user_input[CONF_NAME] = DOMAIN 126 | self.data = user_input 127 | return self.async_show_form(step_id="oauth", data_schema=vol.Schema(await async_create_form(self.hass,user_input,0, option_flow)), errors=self._errors) 128 | 129 | 130 | async def async_common_step_oauth(self, user_input=None, option_flow = False): # pylint: disable=unused-argument 131 | # we should have received the entity ID 132 | # now we show the form to enter the oauth user credentials 133 | self._errors = {} 134 | #_LOGGER.error("step oauth was just called") 135 | if user_input is not None: 136 | self.data.update(user_input) 137 | if CONF_NAME in user_input: 138 | self.data[CONF_NAME] = user_input[CONF_NAME].replace(DOMAIN_MP+".","") # make sure to erase "media_player.bla" -> bla 139 | 140 | # skip the complete oauth cycle if unchecked (default) 141 | if CONF_RENEW_OAUTH in user_input: 142 | if not(user_input[CONF_RENEW_OAUTH]): 143 | # Set CONF_HEADER_PATH before jumping to finish screen (normally set in oauth2 step) 144 | self.data[CONF_HEADER_PATH] = os.path.join(self.hass.config.path(STORAGE_DIR),DEFAULT_HEADER_FILENAME+self.data[CONF_NAME].replace(' ','_')+'.json') 145 | return self.async_show_form(step_id="finish", data_schema=vol.Schema(await async_create_form(self.hass,self.data,3, option_flow)), errors=self._errors) 146 | 147 | return self.async_show_form(step_id="oauth2", data_schema=vol.Schema(await async_create_form(self.hass,user_input,1, option_flow)), errors=self._errors) 148 | 149 | 150 | async def async_common_step_oauth2(self, user_input=None, option_flow = False): # pylint: disable=unused-argument 151 | self._errors = {} 152 | #_LOGGER.error("step oauth2 was just called") 153 | if user_input is not None: 154 | self.data.update(user_input) 155 | # OAUTH 156 | self.data[CONF_HEADER_PATH] = os.path.join(self.hass.config.path(STORAGE_DIR),DEFAULT_HEADER_FILENAME+self.data[CONF_NAME].replace(' ','_')+'.json') 157 | try: 158 | self.oauth = await self.hass.async_add_executor_job(lambda: OAuthCredentials(self.data[CONF_CLIENT_ID], self.data[CONF_CLIENT_SECRET], None, None)) 159 | self.code = await self.hass.async_add_executor_job(self.oauth.get_code) 160 | self.data[CONF_CODE] = self.code 161 | except: 162 | self._errors["base"] = ERROR_OAUTH 163 | return self.async_show_form(step_id="oauth2", data_schema=vol.Schema(await async_create_form(self.hass,self.data,1, option_flow)), errors=self._errors) 164 | 165 | # OAUTH 166 | return self.async_show_form(step_id="oauth3", data_schema=vol.Schema(await async_create_form(self.hass,self.data,2, option_flow)), errors=self._errors) 167 | 168 | async def async_common_step_oauth3(self, user_input=None, option_flow = False): # pylint: disable=unused-argument 169 | self._errors = {} 170 | #_LOGGER.error("step oauth3 was just called") 171 | self.data.update(user_input) 172 | 173 | store_token = True 174 | if CONF_RENEW_OAUTH in self.data: 175 | if not(self.data[CONF_RENEW_OAUTH]): 176 | store_token = False 177 | 178 | if store_token: 179 | try: 180 | self.token = await self.hass.async_add_executor_job(lambda: self.oauth.token_from_code(self.code["device_code"])) 181 | self.refresh_token = RefreshingToken(credentials=self.oauth, **self.token) 182 | self.refresh_token.update(self.refresh_token.as_dict()) 183 | except: 184 | self._errors["base"] = ERROR_AUTH_USER 185 | user_input = self.data 186 | return self.async_show_form(step_id="oauth3", data_schema=vol.Schema(await async_create_form(self.hass,self.data,2, option_flow)), errors=self._errors) 187 | # OAUTH 188 | return self.async_show_form(step_id="finish", data_schema=vol.Schema(await async_create_form(self.hass,self.data,3, option_flow)), errors=self._errors) 189 | 190 | 191 | async def async_common_step_finish(self,user_input=None, option_flow = False): 192 | self._errors = {} 193 | #_LOGGER.error("step finish was just called") 194 | self.data.update(user_input) 195 | store_token = True 196 | if CONF_RENEW_OAUTH in self.data: 197 | if not(self.data[CONF_RENEW_OAUTH]): 198 | store_token = False 199 | 200 | if store_token: 201 | await self.hass.async_add_executor_job(lambda: self.refresh_token.store_token(self.data[CONF_HEADER_PATH])) 202 | elif (CONF_HEADER_PATH+"_old" in self.data) and (self.data[CONF_HEADER_PATH] != self.data[CONF_HEADER_PATH+"_old"]): 203 | #_LOGGER.error("moving cookie to "+self.data[CONF_HEADER_PATH]) 204 | if os.path.exists(self.data[CONF_HEADER_PATH+"_old"]): 205 | os.rename(self.data[CONF_HEADER_PATH+"_old"],self.data[CONF_HEADER_PATH]) 206 | 207 | 208 | if(self.data[CONF_ADVANCE_CONFIG]): 209 | return self.async_show_form(step_id="adv_finish", data_schema=vol.Schema(await async_create_form(self.hass,self.data,4, option_flow)), errors=self._errors) 210 | elif option_flow: 211 | return self.async_create_entry(data = self.data) 212 | else: 213 | return self.async_create_entry(title="yTubeMusic "+self.data[CONF_NAME].replace(DOMAIN,''), data=self.data) 214 | 215 | 216 | async def async_common_step_adv_finish(self,user_input=None, option_flow = False): 217 | self._errors = {} 218 | #_LOGGER.error("step adv finish was just called") 219 | self.data.update(user_input) 220 | if option_flow: 221 | return self.async_create_entry(data = self.data) 222 | else: 223 | return self.async_create_entry(title="yTubeMusic "+self.data[CONF_NAME].replace(DOMAIN,''), data=self.data) 224 | 225 | 226 | async def async_create_form(hass, user_input, page=1, option_flow = False): 227 | """Create form for UI setup.""" 228 | user_input = ensure_config(user_input) 229 | data_schema = OrderedDict() 230 | languages = list(SUPPORTED_LANGUAGES) 231 | 232 | if(page == 0): 233 | data_schema[vol.Required(CONF_NAME, default=user_input[CONF_NAME])] = str # name of the component without domain 234 | # Always show the OAuth checkbox (not just for option_flow) to allow skipping OAuth during initial setup 235 | data_schema[vol.Required(CONF_RENEW_OAUTH, default=user_input[CONF_RENEW_OAUTH])] = vol.Coerce(bool) # show page 2 236 | elif(page == 1): 237 | data_schema[vol.Required(CONF_CLIENT_ID, default=user_input[CONF_CLIENT_ID])] = str # configuration of the cookie 238 | data_schema[vol.Required(CONF_CLIENT_SECRET, default=user_input[CONF_CLIENT_SECRET])] = str # configuration of the cookie 239 | elif(page == 2): 240 | data_schema[vol.Required(CONF_CODE+"TT", default="https://www.google.com/device?user_code="+user_input[CONF_CODE]["user_code"])] = str # name of the component without domain 241 | elif(page == 3): 242 | # Generate a list of excluded entities. 243 | # This method is more reliable because it won't become invalid 244 | # if users modify entity IDs, and it supports multiple instances. 245 | _exclude_entities = [] 246 | if (_ytm := hass.data.get(DOMAIN)) is not None: 247 | for _ytm_player in _ytm.values(): 248 | if DOMAIN_MP in _ytm_player: 249 | _exclude_entities.append(_ytm_player[DOMAIN_MP].entity_id) 250 | 251 | data_schema[vol.Required(CONF_RECEIVERS,default=user_input[CONF_RECEIVERS])] = selector({ 252 | "entity": { 253 | "multiple": "true", 254 | "filter": [{"domain": DOMAIN_MP}], 255 | "exclude_entities": _exclude_entities 256 | } 257 | }) 258 | data_schema[vol.Required(CONF_API_LANGUAGE, default=user_input[CONF_API_LANGUAGE])] = selector({ 259 | "select": { 260 | "options": languages, 261 | "mode": "dropdown", 262 | "sort": True 263 | } 264 | }) 265 | data_schema[vol.Required(CONF_HEADER_PATH, default=user_input[CONF_HEADER_PATH])] = str # file path of the header 266 | data_schema[vol.Required(CONF_ADVANCE_CONFIG, default=user_input[CONF_ADVANCE_CONFIG])] = vol.Coerce(bool) # show page 2 267 | 268 | elif(page == 4): 269 | data_schema[vol.Optional(CONF_SHUFFLE, default=user_input[CONF_SHUFFLE])] = vol.Coerce(bool) # default shuffle, TRUE/FALSE 270 | data_schema[vol.Optional(CONF_SHUFFLE_MODE, default=user_input[CONF_SHUFFLE_MODE])] = selector({ # choose default shuffle mode 271 | "select": { 272 | "options": ALL_SHUFFLE_MODES, 273 | "mode": "dropdown" 274 | } 275 | }) 276 | data_schema[vol.Optional(CONF_LIKE_IN_NAME, default=user_input[CONF_LIKE_IN_NAME])] = vol.Coerce(bool) # default like_in_name, TRUE/FALSE 277 | data_schema[vol.Optional(CONF_DEBUG_AS_ERROR, default=user_input[CONF_DEBUG_AS_ERROR])] = vol.Coerce(bool) # debug_as_error, TRUE/FALSE 278 | data_schema[vol.Optional(CONF_LEGACY_RADIO, default=user_input[CONF_LEGACY_RADIO])] = vol.Coerce(bool) # default radio generation typ 279 | data_schema[vol.Optional(CONF_SORT_BROWSER, default=user_input[CONF_SORT_BROWSER])] = vol.Coerce(bool) # sort browser results 280 | data_schema[vol.Optional(CONF_INIT_EXTRA_SENSOR, default=user_input[CONF_INIT_EXTRA_SENSOR])] = vol.Coerce(bool) # default radio generation typ 281 | data_schema[vol.Optional(CONF_INIT_DROPDOWNS,default=user_input[CONF_INIT_DROPDOWNS])] = selector({ # choose dropdown(s) 282 | "select": { 283 | "options": ALL_DROPDOWNS, 284 | "multiple": "true" 285 | } 286 | }) 287 | # add for the old inputs. 288 | for _old_conf_input in OLD_INPUTS.values(): 289 | if user_input.get(_old_conf_input) is not None: 290 | data_schema[vol.Optional(_old_conf_input, default=user_input[_old_conf_input])] = str 291 | 292 | data_schema[vol.Optional(CONF_TRACK_LIMIT, default=user_input[CONF_TRACK_LIMIT])] = vol.Coerce(int) 293 | data_schema[vol.Optional(CONF_MAX_DATARATE, default=user_input[CONF_MAX_DATARATE])] = vol.Coerce(int) 294 | data_schema[vol.Optional(CONF_BRAND_ID, default=user_input[CONF_BRAND_ID])] = str # brand id 295 | 296 | data_schema[vol.Optional(CONF_PROXY_PATH, default=user_input[CONF_PROXY_PATH])] = str # select of input_boolean -> continuous on/off 297 | data_schema[vol.Optional(CONF_PROXY_URL, default=user_input[CONF_PROXY_URL])] = str # select of input_boolean -> continuous on/off 298 | 299 | return data_schema 300 | 301 | -------------------------------------------------------------------------------- /custom_components/ytube_music_player/const.py: -------------------------------------------------------------------------------- 1 | from homeassistant.components.sensor import PLATFORM_SCHEMA, ENTITY_ID_FORMAT 2 | import homeassistant.helpers.config_validation as cv 3 | from homeassistant.components.media_player import MediaPlayerState, MediaPlayerEntityFeature 4 | from homeassistant.components.media_player.const import MediaClass, MediaType, RepeatMode 5 | import voluptuous as vol 6 | import logging 7 | import datetime 8 | import traceback 9 | import asyncio 10 | import json 11 | import os 12 | from collections import OrderedDict 13 | from ytmusicapi import YTMusic 14 | from ytmusicapi.auth.oauth.exceptions import BadOAuthClient 15 | 16 | 17 | from homeassistant.const import ( 18 | EVENT_HOMEASSISTANT_START, 19 | ATTR_ENTITY_ID, 20 | CONF_DEVICE_ID, 21 | CONF_NAME, 22 | CONF_USERNAME, 23 | CONF_PASSWORD, 24 | STATE_PLAYING, 25 | STATE_PAUSED, 26 | STATE_ON, 27 | STATE_OFF, 28 | STATE_IDLE, 29 | ATTR_COMMAND, 30 | ) 31 | 32 | from homeassistant.components.media_player import ( 33 | MediaPlayerEntity, 34 | PLATFORM_SCHEMA, 35 | SERVICE_TURN_ON, 36 | SERVICE_TURN_OFF, 37 | SERVICE_PLAY_MEDIA, 38 | SERVICE_MEDIA_PAUSE, 39 | SERVICE_VOLUME_UP, 40 | SERVICE_VOLUME_DOWN, 41 | SERVICE_VOLUME_SET, 42 | ATTR_MEDIA_VOLUME_LEVEL, 43 | ATTR_MEDIA_CONTENT_ID, 44 | ATTR_MEDIA_CONTENT_TYPE, 45 | DOMAIN as DOMAIN_MP, 46 | ) 47 | 48 | # add for old settings 49 | from homeassistant.components.input_boolean import ( 50 | SERVICE_TURN_OFF as IB_OFF, 51 | SERVICE_TURN_ON as IB_ON, 52 | DOMAIN as DOMAIN_IB, 53 | ) 54 | 55 | import homeassistant.components.select as select 56 | import homeassistant.components.input_select as input_select # add for old settings 57 | import homeassistant.components.input_boolean as input_boolean # add for old settings 58 | 59 | # Should be equal to the name of your component. 60 | PLATFORMS = {"sensor", "select", "media_player" } 61 | DOMAIN = "ytube_music_player" 62 | 63 | SUPPORT_YTUBEMUSIC_PLAYER = ( 64 | MediaPlayerEntityFeature.TURN_ON 65 | | MediaPlayerEntityFeature.TURN_OFF 66 | | MediaPlayerEntityFeature.PLAY 67 | | MediaPlayerEntityFeature.PLAY_MEDIA 68 | | MediaPlayerEntityFeature.PAUSE 69 | | MediaPlayerEntityFeature.STOP 70 | | MediaPlayerEntityFeature.VOLUME_SET 71 | | MediaPlayerEntityFeature.VOLUME_STEP 72 | | MediaPlayerEntityFeature.VOLUME_MUTE 73 | | MediaPlayerEntityFeature.PREVIOUS_TRACK 74 | | MediaPlayerEntityFeature.NEXT_TRACK 75 | | MediaPlayerEntityFeature.SHUFFLE_SET 76 | | MediaPlayerEntityFeature.REPEAT_SET 77 | | MediaPlayerEntityFeature.BROWSE_MEDIA 78 | | MediaPlayerEntityFeature.SELECT_SOURCE 79 | | MediaPlayerEntityFeature.SEEK 80 | ) 81 | 82 | SERVICE_SEARCH = "search" 83 | SERVICE_ADD_TO_PLAYLIST = "add_to_playlist" 84 | SERVICE_REMOVE_FROM_PLAYLIST = "remove_from_playlist" 85 | SERVICE_LIMIT_COUNT = "limit_count" 86 | SERVICE_RADIO = "start_radio" 87 | ATTR_PARAMETERS = "parameters" 88 | ATTR_QUERY = "query" 89 | ATTR_FILTER = "filter" 90 | ATTR_LIMIT = "limit" 91 | ATTR_SONG_ID = "song_id" 92 | ATTR_PLAYLIST_ID = "playlist_id" 93 | ATTR_RATING = "rating" 94 | ATTR_INTERRUPT = "interrupt" 95 | SERVICE_CALL_METHOD = "call_method" 96 | SERVICE_CALL_RATE_TRACK = "rate_track" 97 | SERVICE_CALL_THUMB_UP = "thumb_up" 98 | SERVICE_CALL_THUMB_DOWN = "thumb_down" 99 | SERVICE_CALL_THUMB_MIDDLE = "thumb_middle" 100 | SERVICE_CALL_TOGGLE_THUMB_UP_MIDDLE = "thumb_toggle_up_middle" 101 | SERVICE_CALL_INTERRUPT_START = "interrupt_start" 102 | SERVICE_CALL_INTERRUPT_RESUME = "interrupt_resume" 103 | SERVICE_CALL_RELOAD_DROPDOWNS = "reload_dropdowns" 104 | SERVICE_CALL_OFF_IS_IDLE = "off_is_idle" 105 | SERVICE_CALL_PAUSED_IS_IDLE = "paused_is_idle" 106 | SERVICE_CALL_IGNORE_PAUSED_ON_MEDIA_CHANGE = "ignore_paused_on_media_change" 107 | SERVICE_CALL_DO_NOT_IGNORE_PAUSED_ON_MEDIA_CHANGE = "do_not_ignore_paused_on_media_change" 108 | SERVICE_CALL_IDLE_IS_IDLE = "idle_is_idle" 109 | SERIVCE_CALL_DEBUG_AS_ERROR = "debug_as_error" 110 | SERVICE_CALL_LIKE_IN_NAME = "like_in_name" 111 | SERVICE_CALL_GOTO_TRACK = "goto_track" 112 | SERVICE_CALL_MOVE_TRACK = "move_track_within_queue" 113 | SERVICE_CALL_APPEND_TRACK = "append_track_to_queue" 114 | 115 | CONF_RECEIVERS = 'speakers' # list of speakers (media_players) 116 | CONF_HEADER_PATH = 'header_path' 117 | CONF_API_LANGUAGE = 'api_language' 118 | CONF_SHUFFLE = 'shuffle' 119 | CONF_SHUFFLE_MODE = 'shuffle_mode' 120 | CONF_COOKIE = 'cookie' 121 | CONF_CLIENT_ID = 'client_id' 122 | CONF_CLIENT_SECRET = 'secret' 123 | CONF_CODE = 'code' 124 | CONF_BRAND_ID = 'brand_id' 125 | CONF_ADVANCE_CONFIG = 'advance_config' 126 | CONF_RENEW_OAUTH = 'renew_oauth' 127 | CONF_LIKE_IN_NAME = 'like_in_name' 128 | CONF_DEBUG_AS_ERROR = 'debug_as_error' 129 | CONF_LEGACY_RADIO = 'legacy_radio' 130 | CONF_SORT_BROWSER = 'sort_browser' 131 | CONF_INIT_EXTRA_SENSOR = 'extra_sensor' 132 | CONF_INIT_DROPDOWNS = 'dropdowns' 133 | ALL_DROPDOWNS = ["playlists","speakers","playmode","radiomode","repeatmode"] 134 | DEFAULT_INIT_DROPDOWNS = ["playlists","speakers","playmode"] 135 | CONF_MAX_DATARATE = 'max_datarate' 136 | 137 | CONF_TRACK_LIMIT = 'track_limit' 138 | CONF_PROXY_URL = 'proxy_url' 139 | CONF_PROXY_PATH = 'proxy_path' 140 | 141 | # add for old settings 142 | CONF_SELECT_SOURCE = 'select_source' 143 | CONF_SELECT_PLAYLIST = 'select_playlist' 144 | CONF_SELECT_SPEAKERS = 'select_speakers' 145 | CONF_SELECT_PLAYMODE = 'select_playmode' 146 | CONF_SELECT_PLAYCONTINUOUS = 'select_playcontinuous' 147 | OLD_INPUTS = { 148 | "playlists": CONF_SELECT_PLAYLIST, 149 | "speakers": CONF_SELECT_SPEAKERS, 150 | "playmode": CONF_SELECT_PLAYMODE, 151 | "radiomode": CONF_SELECT_SOURCE, 152 | "repeatmode": CONF_SELECT_PLAYCONTINUOUS 153 | } 154 | DEFAULT_SELECT_PLAYCONTINUOUS = "" 155 | DEFAULT_SELECT_SOURCE = "" 156 | DEFAULT_SELECT_PLAYLIST = "" 157 | DEFAULT_SELECT_PLAYMODE = "" 158 | DEFAULT_SELECT_SPEAKERS = "" 159 | 160 | DEFAULT_HEADER_FILENAME = 'header_' 161 | DEFAULT_API_LANGUAGE = 'en' 162 | DEFAULT_LIKE_IN_NAME = False 163 | DEFAULT_DEBUG_AS_ERROR = False 164 | DEFAULT_INIT_EXTRA_SENSOR = False 165 | PROXY_FILENAME = "ytube_proxy.mp4" 166 | 167 | DEFAULT_TRACK_LIMIT = 25 168 | DEFAULT_MAX_DATARATE = 129000 169 | DEFAULT_LEGACY_RADIO = True 170 | DEFAULT_SORT_BROWSER = True 171 | 172 | ERROR_COOKIE = 'ERROR_COOKIE' 173 | ERROR_AUTH_USER = 'ERROR_AUTH_USER' 174 | ERROR_GENERIC = 'ERROR_GENERIC' 175 | ERROR_OAUTH = 'ERROR_OAUTH' 176 | ERROR_CONTENTS = 'ERROR_CONTENTS' 177 | ERROR_FORMAT = 'ERROR_FORMAT' 178 | ERROR_NONE = 'ERROR_NONE' 179 | ERROR_FORBIDDEN = 'ERROR_FORBIDDEN' 180 | 181 | PLAYMODE_SHUFFLE = "Shuffle" 182 | PLAYMODE_RANDOM = "Random" 183 | PLAYMODE_SHUFFLE_RANDOM = "Shuffle Random" 184 | PLAYMODE_DIRECT = "Direct" 185 | 186 | ALL_SHUFFLE_MODES = [PLAYMODE_SHUFFLE, PLAYMODE_RANDOM, PLAYMODE_SHUFFLE_RANDOM, PLAYMODE_DIRECT] 187 | DEFAULT_SHUFFLE_MODE = PLAYMODE_SHUFFLE_RANDOM 188 | DEFAULT_SHUFFLE = True 189 | 190 | SEARCH_ID = "search_id" 191 | SEARCH_TYPE = "search_type" 192 | LIB_PLAYLIST = 'library_playlists' 193 | LIB_PLAYLIST_TITLE = "Library Playlists" 194 | 195 | HOME_TITLE = "Home" 196 | HOME_CAT = "home" 197 | HOME_CAT_2 = "home2" 198 | 199 | LIB_ALBUM = 'library_albums' 200 | LIB_ALBUM_TITLE = "Library Albums" 201 | 202 | LIB_TRACKS = 'library_tracks' 203 | LIB_TRACKS_TITLE = "Library Songs" 204 | ALL_LIB_TRACKS = 'all_library_tracks' 205 | ALL_LIB_TRACKS_TITLE = 'All library tracks' 206 | 207 | HISTORY = 'history' 208 | HISTORY_TITLE = "Last played songs" 209 | 210 | USER_TRACKS = 'user_tracks' 211 | USER_TRACKS_TITLE = "Uploaded songs" 212 | 213 | USER_ALBUMS = 'user_albums' 214 | USER_ALBUMS_TITLE = "Uploaded Albums" 215 | USER_ALBUM = 'user_album' 216 | 217 | USER_ARTISTS = 'user_artists' 218 | USER_ARTISTS_TITLE = "Uploaded Artists" 219 | 220 | USER_ARTISTS_2 = 'user_artists2' 221 | USER_ARTISTS_2_TITLE = "Uploaded Artists -> Album" 222 | 223 | USER_ARTIST = 'user_artist' 224 | USER_ARTIST_TITLE = "Uploaded Artist" 225 | 226 | USER_ARTIST_2 = 'user_artist2' 227 | USER_ARTIST_2_TITLE = "Uploaded Album" 228 | 229 | SEARCH = 'search' 230 | SEARCH_TITLE = "Search results" 231 | 232 | ALBUM_OF_TRACK = 'album_of_track' 233 | ALBUM_OF_TRACK_TITLE = 'Album of current Track' 234 | 235 | PLAYER_TITLE = "Playback device" 236 | 237 | MOOD_OVERVIEW = 'mood_overview' 238 | MOOD_PLAYLISTS = 'mood_playlists' 239 | MOOD_TITLE = 'Moods & Genres' 240 | 241 | CUR_PLAYLIST = 'cur_playlists' 242 | CUR_PLAYLIST_TITLE = "Current Playlists" 243 | CUR_PLAYLIST_COMMAND = "PLAYLIST_GOTO_TRACK" 244 | 245 | CHANNEL = 'channel' 246 | CHANNEL_VID = 'vid_channel' 247 | CHANNEL_VID_NO_INTERRUPT = 'vid_no_interrupt_channel' 248 | STATE_OFF_1X = 'OFF_1X' 249 | BROWSER_LIMIT = 500 250 | 251 | 252 | PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend = vol.Schema({ 253 | DOMAIN: vol.Schema({ 254 | vol.Optional(CONF_RECEIVERS): cv.string, 255 | vol.Optional(CONF_HEADER_PATH, default=DEFAULT_HEADER_FILENAME): cv.string, 256 | }) 257 | }, extra=vol.ALLOW_EXTRA) 258 | 259 | # Shortcut for the logger 260 | _LOGGER = logging.getLogger(__name__) 261 | 262 | 263 | 264 | async def async_try_login(hass, path, brand_id=None, language='en',oauth=None): 265 | ret = {} 266 | api = None 267 | msg = "" 268 | 269 | # Check if the auth file is an OAuth token file 270 | is_oauth_file = False 271 | if os.path.exists(path): 272 | try: 273 | with open(path, 'r') as f: 274 | auth_data = json.load(f) 275 | # OAuth token files have specific keys like 'token_type', 'access_token', 'refresh_token' 276 | if isinstance(auth_data, dict) and ('token_type' in auth_data or 'refresh_token' in auth_data): 277 | is_oauth_file = True 278 | _LOGGER.debug("- Detected OAuth token file") 279 | except: 280 | # Not a JSON file, probably browser headers 281 | pass 282 | 283 | # If it's an OAuth file but no credentials provided, fail early with helpful message 284 | if is_oauth_file and not oauth: 285 | msg = "OAuth token file detected but no OAuth credentials provided. NOTE: OAuth authentication is currently experiencing widespread issues due to YouTube Music server-side changes (November 2024). Many users report HTTP 400 errors even with valid OAuth credentials. RECOMMENDED SOLUTION: Use browser-based authentication instead. See https://ytmusicapi.readthedocs.io/en/stable/setup/browser.html for instructions on extracting browser headers." 286 | _LOGGER.error(msg) 287 | ret["base"] = ERROR_AUTH_USER 288 | return [ret, msg, api] 289 | 290 | #### try to init object ##### 291 | try: 292 | if(oauth): 293 | api = await hass.async_add_executor_job(lambda: YTMusic(auth=path,oauth_credentials=oauth,user=brand_id,language=language)) 294 | else: 295 | api = await hass.async_add_executor_job(YTMusic,path,brand_id,None,None,language) 296 | except BadOAuthClient as err: 297 | _LOGGER.debug("- BadOAuthClient exception") 298 | msg = "OAuth authentication failed. NOTE: OAuth authentication is currently experiencing widespread issues due to YouTube Music server-side changes (November 2024 - Issue #676, #682). Many users report this error even with valid credentials. RECOMMENDED SOLUTION: Use browser-based authentication instead. See https://ytmusicapi.readthedocs.io/en/stable/setup/browser.html for instructions. If you want to continue with OAuth: (1) Ensure client_id/client_secret match your token, (2) Enable YouTube Data API in Google Cloud Console." 299 | _LOGGER.error(msg) 300 | _LOGGER.error("Details: " + str(err)) 301 | ret["base"] = ERROR_AUTH_USER 302 | except KeyError as err: 303 | _LOGGER.debug("- Key exception") 304 | if(str(err)=="'contents'"): 305 | msg = "Format of cookie is OK, found '__Secure-3PAPISID' and '__Secure-3PSID' but can't retrieve any data with this settings, maybe you didn't copy all data?" 306 | _LOGGER.error(msg) 307 | ret["base"] = ERROR_CONTENTS 308 | elif(str(err)=="'Cookie'"): 309 | msg = "Format of cookie is NOT OK, Field 'Cookie' not found!" 310 | _LOGGER.error(msg) 311 | ret["base"] = ERROR_COOKIE 312 | elif(str(err)=="'__Secure-3PAPISID'" or str(err)=="'__Secure-3PSID'"): 313 | msg = "Format of cookie is NOT OK, likely missing '__Secure-3PAPISID' or '__Secure-3PSID'" 314 | _LOGGER.error(msg) 315 | ret["base"] = ERROR_FORMAT 316 | else: 317 | msg = "Some unknown error occured during the cookies usage, key is: "+str(err) 318 | _LOGGER.error(msg) 319 | _LOGGER.error("please see below") 320 | _LOGGER.error(traceback.format_exc()) 321 | ret["base"] = ERROR_GENERIC 322 | except: 323 | _LOGGER.debug("- Generic exception") 324 | msg = "Format of cookie is NOT OK, missing e.g. AuthUser or Cookie" 325 | _LOGGER.error(msg) 326 | ret["base"] = ERROR_FORMAT 327 | 328 | #### try to grab library data ##### 329 | if(api == None and ret == {}): 330 | msg = "Format of cookie seams OK, but the returned sub API object is None" 331 | _LOGGER.error(msg) 332 | ret["base"] = ERROR_NONE 333 | elif(not(api == None) and ret == {}): 334 | try: 335 | await hass.async_add_executor_job(api.get_library_songs) 336 | except KeyError as err: 337 | if(str(err)=="'contents'"): 338 | msg = "Format of cookie is OK, found '__Secure-3PAPISID' and '__Secure-3PSID' but can't retrieve any data with this settings, maybe you didn't copy all data? Or did you log-out?" 339 | _LOGGER.error(msg) 340 | ret["base"] = ERROR_CONTENTS 341 | except Exception as e: 342 | error_str = str(e) 343 | if hasattr(e, 'args'): 344 | if(len(e.args)>0): 345 | if(isinstance(e.args[0],str)): 346 | if(e.args[0].startswith("Server returned HTTP 403: Forbidden")): 347 | msg = "The entered information has the correct format, but returned an error 403 (access forbidden). You don't have access with this data (anymore?). Please update the cookie" 348 | _LOGGER.error(msg) 349 | ret["base"] = ERROR_FORBIDDEN 350 | elif "HTTP 400" in e.args[0] and "Bad Request" in e.args[0]: 351 | # HTTP 400 often indicates OAuth token issues 352 | if is_oauth_file: 353 | msg = "HTTP 400 Bad Request error. This is a KNOWN ISSUE with OAuth authentication due to YouTube Music server-side changes (November 2024 - ytmusicapi issues #676, #682). OAuth is currently broken for most users even with valid credentials. RECOMMENDED SOLUTION: Switch to browser-based authentication. See https://ytmusicapi.readthedocs.io/en/stable/setup/browser.html for instructions. To use browser auth: (1) Delete this integration, (2) Extract browser headers using the guide above, (3) Manually create the header file, (4) Reconfigure integration with 'Renew OAuth credentials' unchecked." 354 | else: 355 | msg = "The entered information has the correct format, but returned an error 400 (bad request). Please update the cookie or reconfigure with OAuth authentication." 356 | _LOGGER.error(msg) 357 | ret["base"] = ERROR_AUTH_USER 358 | else: 359 | msg = "Running get_library_songs resulted in an exception, no idea why.. honestly" 360 | _LOGGER.error(msg) 361 | _LOGGER.error("Please see below") 362 | _LOGGER.error(traceback.format_exc()) 363 | ret["base"] = ERROR_GENERIC 364 | return [ret, msg, api] 365 | 366 | def ensure_config(user_input): 367 | """Make sure that needed Parameter exist and are filled with default if not.""" 368 | out = {} 369 | out[CONF_NAME] = DOMAIN 370 | out[CONF_API_LANGUAGE] = DEFAULT_API_LANGUAGE 371 | out[CONF_RECEIVERS] = '' 372 | out[CONF_SHUFFLE] = DEFAULT_SHUFFLE 373 | out[CONF_SHUFFLE_MODE] = DEFAULT_SHUFFLE_MODE 374 | out[CONF_PROXY_PATH] = "" 375 | out[CONF_PROXY_URL] = "" 376 | out[CONF_BRAND_ID] = "" 377 | out[CONF_COOKIE] = "" 378 | out[CONF_CLIENT_ID] = "" 379 | out[CONF_CLIENT_SECRET] = "" 380 | out[CONF_ADVANCE_CONFIG] = False 381 | out[CONF_RENEW_OAUTH] = False 382 | out[CONF_LIKE_IN_NAME] = DEFAULT_LIKE_IN_NAME 383 | out[CONF_DEBUG_AS_ERROR] = DEFAULT_DEBUG_AS_ERROR 384 | out[CONF_TRACK_LIMIT] = DEFAULT_TRACK_LIMIT 385 | out[CONF_LEGACY_RADIO] = DEFAULT_LEGACY_RADIO 386 | out[CONF_SORT_BROWSER] = DEFAULT_SORT_BROWSER 387 | out[CONF_INIT_EXTRA_SENSOR] = DEFAULT_INIT_EXTRA_SENSOR 388 | out[CONF_INIT_DROPDOWNS] = DEFAULT_INIT_DROPDOWNS 389 | out[CONF_MAX_DATARATE] = DEFAULT_MAX_DATARATE 390 | 391 | if user_input is not None: 392 | # for the old shuffle_mode setting. 393 | out.update(user_input) 394 | if isinstance(_shuffle_mode := out[CONF_SHUFFLE_MODE], int): 395 | if _shuffle_mode >= 1: 396 | out[CONF_SHUFFLE_MODE] = ALL_SHUFFLE_MODES[_shuffle_mode - 1] 397 | else: 398 | out[CONF_SHUFFLE_MODE] = PLAYMODE_DIRECT 399 | _LOGGER.debug(f"shuffle_mode: {_shuffle_mode} is a deprecated value and has been replaced with '{out[CONF_SHUFFLE_MODE]}'.") 400 | 401 | # If old input(s) exists,uncheck the new corresponding select(s). 402 | # If the old input is set to a blank space character, then permanently delete this field. 403 | for dropdown in ALL_DROPDOWNS: 404 | if (_old_conf_input := out.get(OLD_INPUTS[dropdown])) is not None: 405 | if _old_conf_input.replace(" ","") == "": 406 | del out[OLD_INPUTS[dropdown]] 407 | else: 408 | if dropdown in out[CONF_INIT_DROPDOWNS]: 409 | out[CONF_INIT_DROPDOWNS].remove(dropdown) 410 | _LOGGER.debug(f"old {dropdown} input_select: {_old_conf_input} exists,uncheck the corresponding new select.") 411 | return out 412 | 413 | 414 | def find_thumbnail(item): 415 | item_thumbnail = "" 416 | try: 417 | thumbnail_list = "" 418 | if 'thumbnails' in item: 419 | if 'thumbnail' in item['thumbnails']: 420 | thumbnail_list = item['thumbnails']['thumbnail'] 421 | else: 422 | thumbnail_list = item['thumbnails'] 423 | elif 'thumbnail' in item: 424 | thumbnail_list = item['thumbnail'] 425 | 426 | if isinstance(thumbnail_list, list): 427 | if 'url' in thumbnail_list[-1]: 428 | item_thumbnail = thumbnail_list[-1]['url'] 429 | except: 430 | pass 431 | return item_thumbnail 432 | -------------------------------------------------------------------------------- /custom_components/ytube_music_player/translations/en.json: -------------------------------------------------------------------------------- 1 | { 2 | "title": "yTubeMediaPlayer", 3 | "config": { 4 | "step": { 5 | "oauth": { 6 | "description": "⚠️ WARNING: OAuth is currently BROKEN due to YouTube Music changes (Nov 2024). UNCHECK the box below to use browser authentication instead (recommended). See BROWSER_AUTH_GUIDE.md for setup instructions.", 7 | "data": { 8 | "name": "Name for the entity (without 'media_player' prefix)", 9 | "renew_oauth": "Use OAuth authentication (NOT RECOMMENDED - currently broken, use browser auth instead)" 10 | } 11 | }, 12 | "oauth2": { 13 | "description": "⚠️ WARNING: OAuth authentication is currently experiencing issues due to YouTube Music server-side changes (November 2024). Many users report HTTP 400 errors even with valid credentials. If you encounter errors, please uncheck 'Renew OAuth credentials' on the previous screen and manually create a browser-based authentication file instead. See https://ytmusicapi.readthedocs.io/en/stable/setup/browser.html for instructions. If you still want to try OAuth, enter your credentials below:", 14 | "data": { 15 | "client_id": "Enter your google client ID", 16 | "secret": "Enter your google client secret" 17 | } 18 | }, 19 | "oauth3": { 20 | "description": "Great, your OAuth works. After reading the next steps, open the URL below. Next Steps: You have to confirm the same code twice, then connect it to you google account, (expand the page) and allow accesss. Only after this is done go to the next step here.", 21 | "data": {} 22 | }, 23 | "finish": { 24 | "description": "Please enter the path and the cookie data. Further information available at https://github.com/KoljaWindeler/ytube_music_player. As of November 2024, YouTube Music requires a Client Id and Secret for the YouTube Data API to connect to the API. Please open https://console.cloud.google.com/apis/credentials, click create credentials -> OAuth Client ID and pick TVs and Limited Input devices.", 25 | "data": { 26 | "speakers": "Select the output devices, the first one will be the default device", 27 | "api_language": "The language parameter of the ytmusicapi, which determines the language of the returned results", 28 | "advance_config": "Show advance configuration", 29 | "header_path": "File path for the header file" 30 | } 31 | }, 32 | "adv_finish": { 33 | "description": "You can configure some behaviors of the player here, such as limiting data usage and setting the maximum number of tracks loaded per session.", 34 | "data": { 35 | "brand_id": "Enter a brand id if you are using a brand account", 36 | "proxy_path": "Local path for proxy mode, leave blank if you don't need it", 37 | "proxy_url": "Base URL for proxy mode, leave blank if you don't need it", 38 | "like_in_name": "Show like status in the name", 39 | "debug_as_error": "Show all debug output as ERROR in the log", 40 | "shuffle": "Turn on shuffle on startup", 41 | "shuffle_mode": "Playmode", 42 | "track_limit": "Limit of simultaneously loaded tracks", 43 | "max_datarate": "Limit the maximum bit rate", 44 | "legacy_radio": "Create radio as watchlist of random playlist track", 45 | "sort_browser": "Sort results in the media browser", 46 | "extra_sensor": "Create sensor that provide extra information", 47 | "dropdowns": "Create the dropdown(s) you want to use", 48 | "select_speakers": "Entity id of input_select for speaker selection(Deprecated. Leaving a space can permanently delete this field)", 49 | "select_playmode": "Entity id of input_select for playmode selection(Deprecated. Leaving a space can permanently delete this field)", 50 | "select_source": "Entity id of input_select for playlist/radio selection(Deprecated. Leaving a space can permanently delete this field)", 51 | "select_playlist": "Entity id of input_select for playlist selection(Deprecated. Leaving a space can permanently delete this field)", 52 | "select_playcontinuous": "Entity id of input_boolean for play continuous selection(Deprecated. Leaving a space can permanently delete this field)" 53 | } 54 | } 55 | }, 56 | "error": { 57 | "ERROR_GENERIC": "Something with your cookie wasn't right. Format and fields are ok but the login failed", 58 | "ERROR_OAUTH":"Your OAuth user/secret combination was rejected, please check and try again", 59 | "ERROR_AUTH_USER": "It look like you haven't registered the code below, please follow the process before going to the next dialog", 60 | "ERROR_COOKIE": "Can't find the 'Cookie' field, please check your input", 61 | "ERROR_CONTENTS": "Format of cookie is OK, found '__Secure-3PAPISID' and '__Secure-3PSID' but can't retrieve any data with this settings, maybe you didn't copy all data?", 62 | "ERROR_FORMAT": "Format of cookie is NOT OK, likely missing '__Secure-3PAPISID' or '__Secure-3PSID'", 63 | "ERROR_NONE": "Format of cookie seams OK, but the returned sub API object is None", 64 | "ERROR_FORBIDDEN": "YouTube returned a 403 error, meaning that you login data are not longer valid. Please update the cookie" 65 | 66 | } 67 | }, 68 | "options": { 69 | "step": { 70 | "oauth": { 71 | "description": "⚠️ WARNING: OAuth is currently BROKEN due to YouTube Music changes (Nov 2024). UNCHECK the box below to use browser authentication instead (recommended). See BROWSER_AUTH_GUIDE.md for setup instructions.", 72 | "data": { 73 | "name": "Name for the entity (without 'media_player' prefix)", 74 | "renew_oauth": "Use OAuth authentication (NOT RECOMMENDED - currently broken, use browser auth instead)" 75 | } 76 | }, 77 | "oauth2": { 78 | "description": "⚠️ WARNING: OAuth authentication is currently experiencing issues due to YouTube Music server-side changes (November 2024). Many users report HTTP 400 errors even with valid credentials. If you encounter errors, please uncheck 'Renew OAuth credentials' on the previous screen and manually create a browser-based authentication file instead. See https://ytmusicapi.readthedocs.io/en/stable/setup/browser.html for instructions. If you still want to try OAuth, enter your credentials below:", 79 | "data": { 80 | "client_id": "Enter your google client ID", 81 | "secret": "Enter your google client secret" 82 | } 83 | }, 84 | "oauth3": { 85 | "description": "Great, your OAuth works. After reading the next steps, open the URL below. Next Steps: You have to confirm the same code twice, then connect it to you google account, (expand the page) and allow accesss. Only after this is done go to the next step here.", 86 | "data": {} 87 | }, 88 | "finish": { 89 | "description": "Please enter the path and the cookie data. Further information available at https://github.com/KoljaWindeler/ytube_music_player. As of November 2024, YouTube Music requires a Client Id and Secret for the YouTube Data API to connect to the API. Please open https://console.cloud.google.com/apis/credentials, click create credentials -> OAuth Client ID and pick TVs and Limited Input devices.", 90 | "data": { 91 | "speakers": "Select the output devices, the first one will be the default device", 92 | "api_language": "The language parameter of the ytmusicapi, which determines the language of the returned results", 93 | "advance_config": "Show advance configuration", 94 | "header_path": "File path for the header file" 95 | } 96 | }, 97 | "adv_finish": { 98 | "description": "You can configure some behaviors of the player here, such as limiting data usage and setting the maximum number of tracks loaded per session.", 99 | "data": { 100 | "brand_id": "Enter a brand id if you are using a brand account", 101 | "proxy_path": "Local path for proxy mode, leave blank if you don't need it", 102 | "proxy_url": "Base URL for proxy mode, leave blank if you don't need it", 103 | "like_in_name": "Show like status in the name", 104 | "debug_as_error": "Show all debug output as ERROR in the log", 105 | "shuffle": "Turn on shuffle on startup", 106 | "shuffle_mode": "Playmode", 107 | "track_limit": "Limit of simultaneously loaded tracks", 108 | "max_datarate": "Limit the maximum bit rate", 109 | "legacy_radio": "Create radio as watchlist of random playlist track", 110 | "sort_browser": "Sort results in the media browser", 111 | "extra_sensor": "Create sensor that provide extra information", 112 | "dropdowns": "Create the dropdown(s) you want to use", 113 | "select_speakers": "Entity id of input_select for speaker selection(Deprecated. Leaving a space can permanently delete this field)", 114 | "select_playmode": "Entity id of input_select for playmode selection(Deprecated. Leaving a space can permanently delete this field)", 115 | "select_source": "Entity id of input_select for playlist/radio selection(Deprecated. Leaving a space can permanently delete this field)", 116 | "select_playlist": "Entity id of input_select for playlist selection(Deprecated. Leaving a space can permanently delete this field)", 117 | "select_playcontinuous": "Entity id of input_boolean for play continuous selection(Deprecated. Leaving a space can permanently delete this field)" 118 | } 119 | } 120 | }, 121 | "error": { 122 | "ERROR_GENERIC": "Something with your cookie wasn't right. Format and fields are ok but the login failed", 123 | "ERROR_OAUTH":"Your OAuth user/secret combination was rejected, please check and try again", 124 | "ERROR_AUTH_USER": "It look like you haven't registered the code below, please follow the process before going to the next dialog", 125 | "ERROR_COOKIE": "Can't find the 'Cookie' field, please check your input", 126 | "ERROR_CONTENTS": "Format of cookie is OK, found '__Secure-3PAPISID' and '__Secure-3PSID' but can't retrieve any data with this settings, maybe you didn't copy all data?", 127 | "ERROR_FORMAT": "Format of cookie is NOT OK, likely missing '__Secure-3PAPISID' or '__Secure-3PSID'", 128 | "ERROR_NONE": "Format of cookie seams OK, but the returned sub API object is None", 129 | "ERROR_FORBIDDEN": "YouTube returned a 403 error, meaning that you login data are not longer valid. Please update the cookie" 130 | 131 | } 132 | }, 133 | "services": { 134 | "add_to_playlist": { 135 | "name": "Add song to playlist", 136 | "description": "Adds a song to a playlist", 137 | "fields": { 138 | "entity_id": { 139 | "name": "Entity ID", 140 | "description": "Entity ID of the ytube media player" 141 | }, 142 | "song_id": { 143 | "name": "Song ID", 144 | "description": "The id of the song, optional. By default the current song id is used. Only provide an argument to override this behavior." 145 | }, 146 | "playlist_id": { 147 | "name": "Playlist ID", 148 | "description": "The id of the playlist, optional. By default the current playlist is used. Only provide an argument to override this behavior." 149 | } 150 | } 151 | }, 152 | "remove_from_playlist": { 153 | "name": "Remove song from playlist", 154 | "description": "Removes a song from a playlist", 155 | "fields": { 156 | "entity_id": { 157 | "name": "Entity ID", 158 | "description": "Entity ID of the ytube media player" 159 | }, 160 | "song_id": { 161 | "name": "Song ID", 162 | "description": "The id of the song, optional. By default the current song id is used. Only provide an argument to override this behavior." 163 | }, 164 | "playlist_id": { 165 | "name": "Playlist ID", 166 | "description": "The id of the playlist, optional. By default the current playlist is used. Only provide an argument to override this behavior." 167 | } 168 | } 169 | }, 170 | "call_method": { 171 | "name": "Call a submethod of ytubemusic player", 172 | "description": "Call a custom command.", 173 | "fields": { 174 | "entity_id": { 175 | "name": "Entity ID", 176 | "description": "Name(s) of the yTube music player entity where to run the custom command.", 177 | "example": "media_player.ytube_music_player" 178 | }, 179 | "command": { 180 | "name": "Command", 181 | "description": "Command to pass to LyTube music player.", 182 | "example": "rate_track" 183 | }, 184 | "parameters": { 185 | "name": "Parameter", 186 | "description": "Array of additional parameters, optional and depends on command.", 187 | "example": "thumb_up" 188 | } 189 | } 190 | }, 191 | "search": { 192 | "description": "Search for music / album / etc on Ytube YouTube Music Player", 193 | "name": "Search", 194 | "fields":{ 195 | "entity_id": { 196 | "name": "Entity ID", 197 | "description": "Name(s) of the yTube music player entity where to run the custom command.", 198 | "example": "media_player.ytube_music_player" 199 | }, 200 | "query": { 201 | "name": "Query", 202 | "description": "The search query", 203 | "example": "2pm tetris" 204 | }, 205 | "filter": { 206 | "name": "Filter", 207 | "description": "filter for query, values can be 'albums', 'playlists','artists' or 'songs'. Leave this out to get all types." 208 | }, 209 | "limit": { 210 | "name": "Limit", 211 | "description": "Limits the amount of resuls", 212 | "example": "20" 213 | } 214 | } 215 | }, 216 | "rate_track": { 217 | "name": "Rate a track", 218 | "description": "Rates a song", 219 | "fields": { 220 | "entity_id": { 221 | "name": "Entity ID", 222 | "description": "Name(s) of the yTube music player entity where to run the custom command.", 223 | "example": "media_player.ytube_music_player" 224 | }, 225 | "rating": { 226 | "name":"Rating", 227 | "description": "The rating of the song, can be 'thumb_up' / 'thumb_down' / 'thumb_middle' / 'thumb_toggle_up_middle'.", 228 | "example": "thumb_up" 229 | }, 230 | "song_id": { 231 | "name": "Song ID", 232 | "description": "The id of the song, optional. By default the current song id is used. Only provide an argument to override this behavior.", 233 | "example": "" 234 | } 235 | } 236 | }, 237 | "limit_count": { 238 | "name": "Limit song count", 239 | "description": "Limits the count of loaded tracks", 240 | "fields": { 241 | "entity_id": { 242 | "name": "Entity ID", 243 | "description": "Name(s) of the yTube music player entity where to run the custom command.", 244 | "example": "media_player.ytube_music_player" 245 | }, 246 | "limit": { 247 | "name": "Limit", 248 | "description": "The amount of tracks, loaded per call", 249 | "example": "20" 250 | } 251 | } 252 | }, 253 | "start_radio": { 254 | "name": "Radio", 255 | "description": "Creates a radio of the current track", 256 | "fields": { 257 | "entity_id": { 258 | "name": "Entity ID", 259 | "description": "Name(s) of the yTube music player entity where to run the command.", 260 | "example": "media_player.ytube_music_player" 261 | }, 262 | "interrupt": { 263 | "name": "Interrupt", 264 | "description": "interrupt the current playback or not", 265 | "example": "true" 266 | } 267 | } 268 | } 269 | } 270 | } 271 | -------------------------------------------------------------------------------- /custom_components/ytube_music_player/browse_media.py: -------------------------------------------------------------------------------- 1 | """Support for media browsing.""" 2 | import logging 3 | from homeassistant.components.media_player import BrowseError, BrowseMedia 4 | from ytmusicapi import ytmusic 5 | from .const import * 6 | import random 7 | 8 | 9 | 10 | PLAYABLE_MEDIA_TYPES = [ 11 | MediaType.ALBUM, 12 | USER_ALBUM, 13 | USER_ARTIST, 14 | MediaType.TRACK, 15 | MediaType.PLAYLIST, 16 | LIB_TRACKS, 17 | HISTORY, 18 | USER_TRACKS, 19 | ALBUM_OF_TRACK 20 | ] 21 | 22 | CONTAINER_TYPES_SPECIFIC_MEDIA_CLASS = { 23 | MediaType.ALBUM: MediaClass.ALBUM, 24 | LIB_ALBUM: MediaClass.ALBUM, 25 | MediaType.ARTIST: MediaClass.ARTIST, 26 | MediaType.PLAYLIST: MediaClass.PLAYLIST, 27 | LIB_PLAYLIST: MediaClass.PLAYLIST, 28 | HISTORY: MediaClass.PLAYLIST, 29 | USER_TRACKS: MediaClass.PLAYLIST, 30 | MediaType.SEASON: MediaClass.SEASON, 31 | MediaType.TVSHOW: MediaClass.TV_SHOW, 32 | } 33 | 34 | CHILD_TYPE_MEDIA_CLASS = { 35 | MediaType.SEASON: MediaClass.SEASON, 36 | MediaType.ALBUM: MediaClass.ALBUM, 37 | MediaType.ARTIST: MediaClass.ARTIST, 38 | MediaType.MOVIE: MediaClass.MOVIE, 39 | MediaType.PLAYLIST: MediaClass.PLAYLIST, 40 | MediaType.TRACK: MediaClass.TRACK, 41 | MediaType.TVSHOW: MediaClass.TV_SHOW, 42 | MediaType.CHANNEL: MediaClass.CHANNEL, 43 | MediaType.EPISODE: MediaClass.EPISODE, 44 | } 45 | 46 | _LOGGER = logging.getLogger(__name__) 47 | 48 | 49 | class UnknownMediaType(BrowseError): 50 | """Unknown media type.""" 51 | 52 | 53 | async def build_item_response(ytmusicplayer, payload): 54 | """Create response payload for the provided media query.""" 55 | search_id = payload[SEARCH_ID] 56 | search_type = payload[SEARCH_TYPE] 57 | media_library = ytmusicplayer._api 58 | hass = ytmusicplayer.hass 59 | children = [] 60 | header_thumbnail = None 61 | header_title = None 62 | media = None 63 | sort_list = ytmusicplayer._sortBrowser 64 | p1 = datetime.datetime.now() 65 | 66 | # the only static source is the ytmusicplayer, to store content over multiple calls 67 | # is this the smartest way to figure out if the var exists? probably not, but the only i found :) 68 | try: 69 | lastHomeMedia = ytmusicplayer.lastHomeMedia 70 | except: 71 | lastHomeMedia = "" 72 | 73 | _LOGGER.debug("- build_item_response for: " + search_type) 74 | 75 | if search_type == LIB_PLAYLIST: # playlist OVERVIEW -> lists playlists 76 | media = await hass.async_add_executor_job(media_library.get_library_playlists, BROWSER_LIMIT) 77 | header_title = LIB_PLAYLIST_TITLE # single playlist 78 | 79 | for item in media: 80 | children.append(BrowseMedia( 81 | title = f"{item['title']}", # noqa: E251 82 | media_class = MediaClass.PLAYLIST, # noqa: E251 83 | media_content_type = MediaType.PLAYLIST, # noqa: E251 84 | media_content_id = f"{item['playlistId']}", # noqa: E251 85 | can_play = True, # noqa: E251 86 | can_expand = True, # noqa: E251 87 | thumbnail = find_thumbnail(item) # noqa: E251 88 | )) 89 | 90 | elif search_type == HOME_CAT: 91 | sort_list = False 92 | header_title = HOME_TITLE 93 | media = await hass.async_add_executor_job(media_library.get_home, 20) 94 | ytmusicplayer.lastHomeMedia = media # store for next round, to keep the same respnse, two seperate calls lead to different data 95 | 96 | for item in media: 97 | #_LOGGER.debug(item) 98 | #_LOGGER.debug("") 99 | if(("contents" in item) and ("title" in item)): 100 | if(item["contents"][0] != None): 101 | thumbnail = find_thumbnail(item["contents"][int(random.random()*len(item["contents"]))]) 102 | children.append(BrowseMedia( 103 | title = f"{item['title']}", # noqa: E251 104 | media_class = MediaClass.PLAYLIST, # noqa: E251 105 | media_content_type = HOME_CAT_2, # noqa: E251 106 | media_content_id = f"{item['title']}", # noqa: E251 107 | can_play = False, # noqa: E251 108 | can_expand = True, # noqa: E251 109 | thumbnail = thumbnail # noqa: E251 110 | )) 111 | 112 | elif search_type == HOME_CAT_2: 113 | sort_list = False 114 | # try to run with the same session as HOME_CAT had in the first call 115 | media = lastHomeMedia 116 | header_title = search_id 117 | # backup if this fails (e.g. direct URL call that jumped the HOME_CAT) 118 | if(media == ""): 119 | media = await hass.async_add_executor_job(media_library.get_home, 20) 120 | for item in media: 121 | if(item['title'] == search_id): 122 | for content in item['contents']: 123 | if(content != None): 124 | play_id = "" 125 | item_title = "" 126 | #_LOGGER.debug(content) 127 | #_LOGGER.debug("") 128 | if('videoId' in content): 129 | item_title = content["title"] 130 | if("artists" in content): 131 | for artist in content["artists"]: 132 | if(artist["id"] != None): 133 | item_title += " - "+artist["name"] 134 | break 135 | play_id = f"{content['videoId']}" 136 | play_type = MediaClass.TRACK 137 | playable = True 138 | browsable = False 139 | #_LOGGER.debug("1") 140 | elif('browseId' in content): 141 | # ok, this can be an Artist or and Album 142 | # album start with MPRE 143 | if(content['browseId'].startswith('MPREb_')): 144 | item_title = content["title"] 145 | if("year" in content): # WTF YT, why is the artist stored in 'year'? 146 | item_title += " - "+content["year"] 147 | play_id = f"{content['browseId']}" 148 | play_type = MediaClass.ALBUM 149 | playable = True 150 | browsable = True 151 | #_LOGGER.debug("2") 152 | #else: 153 | #_LOGGER.debug("2.2") 154 | elif('playlistId' in content): 155 | # RDAMPL - playlist / album radio 156 | # RDAMVL - track radio 157 | item_title = content["title"] 158 | play_id = f"{content['playlistId']}" 159 | play_type = MediaClass.PLAYLIST 160 | playable = True 161 | browsable = True 162 | #_LOGGER.debug("3") 163 | else: 164 | _LOGGER.debug("didn't get this item:") 165 | _LOGGER.debug(content) 166 | _LOGGER.debug("") 167 | 168 | if(play_id!=""): 169 | children.append(BrowseMedia( 170 | title = item_title, # noqa: E251 171 | media_class = MediaClass.PLAYLIST, # noqa: E251 172 | media_content_type = play_type, # noqa: E251 173 | media_content_id = play_id, # noqa: E251 174 | can_play = playable, # noqa: E251 175 | can_expand = browsable, # noqa: E251 176 | thumbnail = find_thumbnail(content) # noqa: E251 177 | )) 178 | break 179 | 180 | elif search_type == MediaType.PLAYLIST: # single playlist -> lists tracks 181 | media = await hass.async_add_executor_job(media_library.get_playlist, search_id, BROWSER_LIMIT) 182 | header_title = media['title'] 183 | header_thumbnail = find_thumbnail(media) 184 | 185 | for item in media['tracks']: 186 | item_title = f"{item['title']}" 187 | if("artists" in item): 188 | artist = "" 189 | if(isinstance(item["artists"], str)): 190 | artist = item["artists"] 191 | elif(isinstance(item["artists"], list)): 192 | artist = item["artists"][0]["name"] 193 | if(artist): 194 | item_title = artist + " - " + item_title 195 | 196 | children.append(BrowseMedia( 197 | title = item_title, # noqa: E251 198 | media_class = MediaClass.TRACK, # noqa: E251 199 | media_content_type = MediaType.TRACK, # noqa: E251 200 | media_content_id = f"{item['videoId']}", # noqa: E251 201 | can_play = True, # noqa: E251 202 | can_expand = False, # noqa: E251 203 | thumbnail = find_thumbnail(item) # noqa: E251 204 | )) 205 | 206 | elif search_type == LIB_ALBUM: # LIB! album OVERVIEW, not uploaded -> lists albums 207 | media = await hass.async_add_executor_job(media_library.get_library_albums, BROWSER_LIMIT) 208 | header_title = LIB_ALBUM_TITLE 209 | 210 | for item in media: 211 | item_title = item['title'] 212 | if("artists" in item): 213 | artist = "" 214 | if(isinstance(item["artists"], str)): 215 | artist = item["artists"] 216 | elif(isinstance(item["artists"], list)): 217 | artist = item["artists"][0]["name"] 218 | if(artist): 219 | item_title = item['title'] + " - " + artist 220 | 221 | children.append(BrowseMedia( 222 | title = item_title, # noqa: E251 223 | media_class = MediaClass.ALBUM, # noqa: E251 224 | media_content_type = MediaType.ALBUM, # noqa: E251 225 | media_content_id = f"{item['browseId']}", # noqa: E251 226 | can_play = True, # noqa: E251 227 | can_expand = True, # noqa: E251 228 | thumbnail = find_thumbnail(item) # noqa: E251 229 | )) 230 | 231 | elif search_type == MediaType.ALBUM: # single album (NOT uploaded) -> lists tracks 232 | res = await hass.async_add_executor_job(media_library.get_album, search_id) 233 | media = res['tracks'] 234 | header_title = res['title'] 235 | header_thumbnail = find_thumbnail(res) 236 | 237 | for item in media: 238 | children.append(BrowseMedia( 239 | title = f"{item['title']}", # noqa: E251 240 | media_class = MediaClass.TRACK, # noqa: E251 241 | media_content_type = MediaType.TRACK, # noqa: E251 242 | media_content_id = f"{item['videoId']}", # noqa: E251 243 | can_play = True, # noqa: E251 244 | can_expand = False, # noqa: E251 245 | thumbnail = find_thumbnail(res) # noqa: E251 246 | )) 247 | 248 | elif search_type == LIB_TRACKS: # liked songs (direct list, NOT uploaded) -> lists tracks 249 | media = await hass.async_add_executor_job(lambda: media_library.get_library_songs(limit=BROWSER_LIMIT)) 250 | header_title = LIB_TRACKS_TITLE 251 | 252 | for item in media: 253 | item_title = f"{item['title']}" 254 | if("artists" in item): 255 | artist = "" 256 | if(isinstance(item["artists"], str)): 257 | artist = item["artists"] 258 | elif(isinstance(item["artists"], list)): 259 | artist = item["artists"][0]["name"] 260 | if(artist): 261 | item_title = artist + " - " + item_title 262 | 263 | children.append(BrowseMedia( 264 | title = item_title, # noqa: E251 265 | media_class = MediaClass.TRACK, # noqa: E251 266 | media_content_type = MediaType.TRACK, # noqa: E251 267 | media_content_id = f"{item['videoId']}", # noqa: E251 268 | can_play = True, # noqa: E251 269 | can_expand = False, # noqa: E251 270 | thumbnail = find_thumbnail(item) # noqa: E251 271 | )) 272 | 273 | elif search_type == HISTORY: # history songs (direct list) -> lists tracks 274 | media = await hass.async_add_executor_job(media_library.get_history) 275 | search_id = HISTORY 276 | header_title = HISTORY_TITLE 277 | 278 | for item in media: 279 | item_title = f"{item['title']}" 280 | if("artists" in item): 281 | artist = "" 282 | if(isinstance(item["artists"], str)): 283 | artist = item["artists"] 284 | elif(isinstance(item["artists"], list)): 285 | artist = item["artists"][0]["name"] 286 | if(artist): 287 | item_title = artist + " - " + item_title 288 | 289 | children.append(BrowseMedia( 290 | title = item_title, # noqa: E251 291 | media_class = MediaClass.TRACK, # noqa: E251 292 | media_content_type = MediaType.TRACK, # noqa: E251 293 | media_content_id = f"{item['videoId']}", # noqa: E251 294 | can_play = True, # noqa: E251 295 | can_expand = False, # noqa: E251 296 | thumbnail = find_thumbnail(item) # noqa: E251 297 | )) 298 | 299 | elif search_type == USER_TRACKS: # list all uploaded songs -> lists tracks 300 | media = await hass.async_add_executor_job(media_library.get_library_upload_songs, BROWSER_LIMIT) 301 | search_id = USER_TRACKS 302 | header_title = USER_TRACKS_TITLE 303 | 304 | for item in media: 305 | item_title = f"{item['title']}" 306 | if("artist" in item): 307 | artist = "" 308 | if(isinstance(item["artist"], str)): 309 | artist = item["artist"] 310 | elif(isinstance(item["artist"], list)): 311 | artist = item["artist"][0]["name"] 312 | if(artist): 313 | item_title = artist + " - " + item_title 314 | 315 | children.append(BrowseMedia( 316 | title = item_title, # noqa: E251 317 | media_class = MediaClass.TRACK, # noqa: E251 318 | media_content_type = MediaType.TRACK, # noqa: E251 319 | media_content_id = f"{item['videoId']}", # noqa: E251 320 | can_play = True, # noqa: E251 321 | can_expand = False, # noqa: E251 322 | thumbnail = find_thumbnail(item) # noqa: E251 323 | )) 324 | 325 | elif search_type == USER_ALBUMS: # uploaded album overview!! -> lists user albums 326 | media = await hass.async_add_executor_job(media_library.get_library_upload_albums, BROWSER_LIMIT) 327 | header_title = USER_ALBUMS_TITLE 328 | 329 | for item in media: 330 | children.append(BrowseMedia( 331 | title = f"{item['title']}", # noqa: E251 332 | media_class = MediaClass.ALBUM, # noqa: E251 333 | media_content_type = USER_ALBUM, # noqa: E251 334 | media_content_id = f"{item['browseId']}", # noqa: E251 335 | can_play = True, # noqa: E251 336 | can_expand = True, # noqa: E251 337 | thumbnail = find_thumbnail(item) # noqa: E251 338 | )) 339 | 340 | elif search_type == USER_ALBUM: # single uploaded album -> lists tracks 341 | res = await hass.async_add_executor_job(media_library.get_library_upload_album, search_id) 342 | media = res['tracks'] 343 | header_title = res['title'] 344 | 345 | for item in media: 346 | children.append(BrowseMedia( 347 | title = f"{item['title']}", # noqa: E251 348 | media_class = MediaClass.TRACK, # noqa: E251 349 | media_content_type = MediaType.TRACK, # noqa: E251 350 | media_content_id = f"{item['videoId']}", # noqa: E251 351 | can_play = True, # noqa: E251 352 | can_expand = False, # noqa: E251 353 | thumbnail = find_thumbnail(item) # noqa: E251 354 | )) 355 | 356 | elif search_type == USER_ARTISTS: # with S 357 | media = await hass.async_add_executor_job(media_library.get_library_upload_artists, BROWSER_LIMIT) 358 | header_title = USER_ARTISTS_TITLE 359 | 360 | for item in media: 361 | children.append(BrowseMedia( 362 | title = f"{item['artist']}", # noqa: E251 363 | media_class = MediaClass.ARTIST, # noqa: E251 364 | media_content_type = USER_ARTIST, # noqa: E251 365 | media_content_id = f"{item['browseId']}", # noqa: E251 366 | can_play = False, # noqa: E251 367 | can_expand = True, # noqa: E251 368 | thumbnail = find_thumbnail(item) # noqa: E251 369 | )) 370 | 371 | elif search_type == USER_ARTISTS_2: # list all artists now, but follow up will be the albums of that artist 372 | media = await hass.async_add_executor_job(media_library.get_library_upload_artists, BROWSER_LIMIT) 373 | header_title = USER_ARTISTS_2_TITLE 374 | 375 | for item in media: 376 | children.append(BrowseMedia( 377 | title = f"{item['artist']}", # noqa: E251 378 | media_class = MediaClass.ARTIST, # noqa: E251 379 | media_content_type = USER_ARTIST_2, # noqa: E251 380 | media_content_id = f"{item['browseId']}", # noqa: E251 381 | can_play = False, # noqa: E251 382 | can_expand = True, # noqa: E251 383 | thumbnail = find_thumbnail(item) # noqa: E251 384 | )) 385 | 386 | elif search_type == USER_ARTIST: # without S 387 | media = await hass.async_add_executor_job(media_library.get_library_upload_artist, search_id, BROWSER_LIMIT) 388 | header_title = USER_ARTIST_TITLE 389 | if(isinstance(media, list)): 390 | if('artist' in media[0]): 391 | if(isinstance(media[0]['artist'], list)): 392 | if('name' in media[0]['artist'][0]): 393 | header_title = media[0]['artist'][0]['name'] 394 | elif('artists' in media[0]): 395 | if(isinstance(media[0]['artists'], list)): 396 | if('name' in media[0]['artists'][0]): 397 | header_title = media[0]['artists'][0]['name'] 398 | 399 | for item in media: 400 | children.append(BrowseMedia( 401 | title = f"{item['title']}", # noqa: E251 402 | media_class = MediaClass.TRACK, # noqa: E251 403 | media_content_type = MediaType.TRACK, # noqa: E251 404 | media_content_id = f"{item['videoId']}", # noqa: E251 405 | can_play = True, # noqa: E251 406 | can_expand = False, # noqa: E251 407 | thumbnail = find_thumbnail(item) # noqa: E251 408 | )) 409 | 410 | elif search_type == USER_ARTIST_2: # list each album of an uploaded artists only once .. next will be uploaded album view 'USER_ALBUM' 411 | media_all = await hass.async_add_executor_job(media_library.get_library_upload_artist, search_id, BROWSER_LIMIT) 412 | header_title = USER_ARTIST_2_TITLE 413 | media = list() 414 | for item in media_all: 415 | if('album' in item): 416 | if('name' in item['album']): 417 | if(all(item['album']['name'] != a['title'] for a in media)): 418 | media.append({ 419 | 'type': 'user_album', 420 | 'browseId': item['album']['id'], 421 | 'title': item['album']['name'], 422 | 'thumbnails': item['thumbnails'] 423 | }) 424 | if('artist' in media_all[0]): 425 | if(isinstance(media_all[0]['artist'], list)): 426 | if('name' in media_all[0]['artist'][0]): 427 | title = "Uploaded albums of " + media_all[0]['artist'][0]['name'] 428 | 429 | 430 | for item in media: 431 | children.append(BrowseMedia( 432 | title = f"{item['title']}", # noqa: E251 433 | media_class = MediaClass.ALBUM, # noqa: E251 434 | media_content_type = USER_ALBUM, # noqa: E251 435 | media_content_id = f"{item['browseId']}", # noqa: E251 436 | can_play = True, # noqa: E251 437 | can_expand = True, # noqa: E251 438 | thumbnail = find_thumbnail(item) # noqa: E251 439 | )) 440 | 441 | 442 | elif search_type == SEARCH: 443 | header_title = SEARCH_TITLE 444 | if ytmusicplayer._search is not None: 445 | media_all = await hass.async_add_executor_job(lambda: media_library.search(query=ytmusicplayer._search.get('query', ""), filter=ytmusicplayer._search.get('filter', None), limit=int(ytmusicplayer._search.get('limit', 20)))) 446 | 447 | if(ytmusicplayer._search.get('filter', None) is not None): 448 | helper = {} 449 | else: 450 | helper = {'song': "Track: ", 'playlist': "Playlist: ", 'album': "Album: ", 'artist': "Artist: "} 451 | 452 | for a in media_all: 453 | if(a['category'] in ["Top result", "Podcast"]): 454 | continue 455 | 456 | if(a['resultType'] == 'song'): 457 | artists = "" 458 | if("artist" in a): 459 | artists = a["artist"] 460 | if("artists" in a): 461 | artists = ', '.join(artist["name"] for artist in a["artists"] if "name" in artist) 462 | children.append(BrowseMedia( 463 | title = helper.get(a['resultType'], "") + artists + " - " + a['title'], # noqa: E251 464 | media_class = MediaClass.TRACK, # noqa: E251 465 | media_content_type = MediaType.TRACK, # noqa: E251 466 | media_content_id = a['videoId'], # noqa: E251 467 | can_play = True, # noqa: E251 468 | can_expand = False, # noqa: E251 469 | thumbnail = find_thumbnail(a) # noqa: E251 470 | )) 471 | elif(a['resultType'] == 'playlist'): 472 | children.append(BrowseMedia( 473 | title = helper.get(a['resultType'], "") + a['title'], # noqa: E251 474 | media_class = MediaClass.PLAYLIST, # noqa: E251 475 | media_content_type = MediaType.PLAYLIST, # noqa: E251 476 | media_content_id = f"{a['browseId']}", # noqa: E251 477 | can_play = True, # noqa: E251 478 | can_expand = True, # noqa: E251 479 | thumbnail = find_thumbnail(a) # noqa: E251 480 | )) 481 | elif(a['resultType'] == 'album'): 482 | children.append(BrowseMedia( 483 | title = helper.get(a['resultType'], "") + a['title'], # noqa: E251 484 | media_class = MediaClass.ALBUM, # noqa: E251 485 | media_content_type = MediaType.ALBUM, # noqa: E251 486 | media_content_id = f"{a['browseId']}", # noqa: E251 487 | can_play = True, # noqa: E251 488 | can_expand = True, # noqa: E251 489 | thumbnail = find_thumbnail(a) # noqa: E251 490 | )) 491 | elif(a['resultType'] == 'artist'): 492 | _LOGGER.debug("a: %s", a) 493 | if not('artist' in a): 494 | a['artist'] = a['artists'][0]['name'] # Fix Top result 495 | a['browseId'] = a['artists'][0]['id'] # Fix Top result 496 | children.append(BrowseMedia( 497 | title = helper.get(a['resultType'], "") + a['artist'], # noqa: E251 498 | media_class = MediaClass.ARTIST, # noqa: E251 499 | media_content_type = MediaType.ARTIST, # noqa: E251 500 | media_content_id = f"{a['browseId']}", # noqa: E251 501 | can_play = False, # noqa: E251 502 | can_expand = True, # noqa: E251 503 | thumbnail = find_thumbnail(a) # noqa: E251 504 | )) 505 | else: # video / artists / uploads are currently ignored 506 | continue 507 | 508 | # _LOGGER.debug("search entry end") 509 | elif search_type == MediaType.ARTIST: 510 | media_all = await hass.async_add_executor_job(media_library.get_artist, search_id) 511 | helper = {'song': "Track: ", 'playlist': "Playlist: ", 'album': "Album: ", 'artist': "Artist"} 512 | 513 | if('singles' in media_all): 514 | for a in media_all['singles']['results']: 515 | children.append(BrowseMedia( 516 | title = helper.get('song', "") + a['title'], # noqa: E251 517 | media_class = MediaClass.ALBUM, # noqa: E251 518 | media_content_type = MediaType.ALBUM, # noqa: E251 519 | media_content_id = a['browseId'], # noqa: E251 520 | can_play = True, # noqa: E251 521 | can_expand = False, # noqa: E251 522 | thumbnail = find_thumbnail(a) # noqa: E251 523 | )) 524 | if('albums' in media_all): 525 | for a in media_all['albums']['results']: 526 | children.append(BrowseMedia( 527 | title = helper.get('album', "") + a['title'], # noqa: E251 528 | media_class = MediaClass.ALBUM, # noqa: E251 529 | media_content_type = MediaType.ALBUM, # noqa: E251 530 | media_content_id = f"{a['browseId']}", # noqa: E251 531 | can_play = True, # noqa: E251 532 | can_expand = True, # noqa: E251 533 | thumbnail = find_thumbnail(a) # noqa: E251 534 | )) 535 | 536 | elif search_type == MOOD_OVERVIEW: 537 | media_all = await hass.async_add_executor_job(lambda: media_library.get_mood_categories()) 538 | header_title = MOOD_TITLE 539 | for cap in media_all: 540 | for e in media_all[cap]: 541 | children.append(BrowseMedia( 542 | title = f"{cap} - {e['title']}", # noqa: E251 543 | media_class = MediaClass.PLAYLIST, # noqa: E251 544 | media_content_type = MOOD_PLAYLISTS, # noqa: E251 545 | media_content_id = e['params'], # noqa: E251 546 | can_play = False, # noqa: E251 547 | can_expand = True, # noqa: E251 548 | thumbnail = "", # noqa: E251 549 | )) 550 | 551 | elif search_type == MOOD_PLAYLISTS: 552 | media = await hass.async_add_executor_job(lambda: media_library.get_mood_playlists(search_id)) 553 | header_title = MOOD_TITLE 554 | for item in media: 555 | children.append(BrowseMedia( 556 | title = f"{item['title']}", # noqa: E251 557 | media_class = MediaClass.PLAYLIST, # noqa: E251 558 | media_content_type = MediaType.PLAYLIST, # noqa: E251 559 | media_content_id = f"{item['playlistId']}", # noqa: E251 560 | can_play = True, # noqa: E251 561 | can_expand = True, # noqa: E251 562 | thumbnail = find_thumbnail(item) # noqa: E251 563 | )) 564 | 565 | elif search_type == CONF_RECEIVERS: 566 | header_title = PLAYER_TITLE 567 | for e, f in ytmusicplayer._friendly_speakersList.items(): 568 | children.append(BrowseMedia( 569 | title = f, # noqa: E251 570 | media_class = MediaClass.TV_SHOW, # noqa: E251 571 | media_content_type = CONF_RECEIVERS, # noqa: E251 572 | media_content_id = e, # noqa: E251 573 | can_play = True, # noqa: E251 574 | can_expand = False, # noqa: E251 575 | thumbnail = "", # noqa: E251 576 | )) 577 | elif search_type == CUR_PLAYLIST: 578 | header_title = CUR_PLAYLIST_TITLE 579 | sort_list = False 580 | i = 1 581 | for item in ytmusicplayer._tracks: 582 | item_title = item["title"] 583 | if("artists" in item): 584 | artist = "" 585 | if(isinstance(item["artists"], str)): 586 | artist = item["artists"] 587 | elif(isinstance(item["artists"], list)): 588 | artist = item["artists"][0]["name"] 589 | if(artist): 590 | item_title = artist + " - " + item_title 591 | 592 | children.append(BrowseMedia( 593 | title = item_title, # noqa: E251 594 | media_class = MediaClass.TRACK, # noqa: E251 595 | media_content_type = CUR_PLAYLIST_COMMAND, # noqa: E251 596 | media_content_id = i, # noqa: E251 597 | can_play = True, # noqa: E251 598 | can_expand = False, # noqa: E251 599 | thumbnail = find_thumbnail(item) # noqa: E251 600 | )) 601 | i += 1 602 | 603 | elif search_type == ALBUM_OF_TRACK: 604 | try: 605 | res = await hass.async_add_executor_job(lambda: media_library.get_album(ytmusicplayer._track_album_id)) 606 | sort_list = False 607 | media = res['tracks'] 608 | header_title = res['title'] 609 | 610 | for item in media: 611 | children.append(BrowseMedia( 612 | title = f"{item['title']}", # noqa: E251 613 | media_class = MediaClass.TRACK, # noqa: E251 614 | media_content_type = MediaType.TRACK, # noqa: E251 615 | media_content_id = f"{item['videoId']}", # noqa: E251 616 | can_play = True, # noqa: E251 617 | can_expand = False, # noqa: E251 618 | thumbnail = find_thumbnail(item) # noqa: E251 619 | )) 620 | except: 621 | pass 622 | 623 | 624 | # ########################################### END ############### 625 | if sort_list: 626 | children.sort(key=lambda x: x.title, reverse=False) 627 | response = BrowseMedia( 628 | media_class = CONTAINER_TYPES_SPECIFIC_MEDIA_CLASS.get(search_type, MediaClass.DIRECTORY), 629 | media_content_id = search_id, 630 | media_content_type = search_type, 631 | title = header_title, 632 | can_play = search_type in PLAYABLE_MEDIA_TYPES and search_id, 633 | can_expand = True, 634 | children = children, 635 | thumbnail = header_thumbnail, 636 | ) 637 | 638 | if search_type == "library_music": 639 | response.children_media_class = MediaClass.MUSIC 640 | elif len(children) > 0: 641 | response.calculate_children_class() 642 | t = (datetime.datetime.now() - p1).total_seconds() 643 | _LOGGER.debug("- Calc / grab time: " + str(t) + " sec") 644 | return response 645 | 646 | 647 | 648 | def library_payload(ytmusicplayer): 649 | # Create response payload to describe contents of a specific library. 650 | # Used by async_browse_media. 651 | library_info = BrowseMedia(media_class=MediaClass.DIRECTORY, media_content_id="library", media_content_type="library", title="Media Library", can_play=False, can_expand=True, children=[]) 652 | 653 | library_info.children.append(BrowseMedia(title=HOME_TITLE, media_class=MediaClass.PLAYLIST, media_content_type=HOME_CAT, media_content_id="", can_play=False, can_expand=True, thumbnail="")) # noqa: E241 654 | library_info.children.append(BrowseMedia(title=LIB_PLAYLIST_TITLE, media_class=MediaClass.PLAYLIST, media_content_type=LIB_PLAYLIST, media_content_id="", can_play=False, can_expand=True, thumbnail="")) # noqa: E241 655 | library_info.children.append(BrowseMedia(title=LIB_ALBUM_TITLE, media_class=MediaClass.ALBUM, media_content_type=LIB_ALBUM, media_content_id="", can_play=False, can_expand=True, thumbnail="")) # noqa: E241 656 | library_info.children.append(BrowseMedia(title=LIB_TRACKS_TITLE, media_class=MediaClass.TRACK, media_content_type=LIB_TRACKS, media_content_id="", can_play=False, can_expand=True, thumbnail="")) # noqa: E241 657 | library_info.children.append(BrowseMedia(title=HISTORY_TITLE, media_class=MediaClass.TRACK, media_content_type=HISTORY, media_content_id="", can_play=False, can_expand=True, thumbnail="")) # noqa: E241 658 | library_info.children.append(BrowseMedia(title=USER_TRACKS_TITLE, media_class=MediaClass.TRACK, media_content_type=USER_TRACKS, media_content_id="", can_play=False, can_expand=True, thumbnail="")) # noqa: E241 659 | library_info.children.append(BrowseMedia(title=USER_ALBUMS_TITLE, media_class=MediaClass.ALBUM, media_content_type=USER_ALBUMS, media_content_id="", can_play=False, can_expand=True, thumbnail="")) # noqa: E241 660 | library_info.children.append(BrowseMedia(title=USER_ARTISTS_TITLE, media_class=MediaClass.ARTIST, media_content_type=USER_ARTISTS, media_content_id="", can_play=False, can_expand=True, thumbnail="")) # noqa: E241 661 | library_info.children.append(BrowseMedia(title=USER_ARTISTS_2_TITLE, media_class=MediaClass.ARTIST, media_content_type=USER_ARTISTS_2, media_content_id="", can_play=False, can_expand=True, thumbnail="")) # noqa: E241 662 | library_info.children.append(BrowseMedia(title=MOOD_TITLE, media_class=MediaClass.PLAYLIST, media_content_type=MOOD_OVERVIEW, media_content_id="", can_play=False, can_expand=True, thumbnail="")) # noqa: E241 663 | library_info.children.append(BrowseMedia(title=PLAYER_TITLE, media_class=MediaClass.TV_SHOW, media_content_type=CONF_RECEIVERS, media_content_id="", can_play=False, can_expand=True, thumbnail="")) # noqa: E241 664 | library_info.children.append(BrowseMedia(title=CUR_PLAYLIST_TITLE, media_class=MediaClass.PLAYLIST, media_content_type=CUR_PLAYLIST, media_content_id="", can_play=False, can_expand=True, thumbnail="")) # noqa: E241 665 | 666 | # add search button if possible 667 | if(ytmusicplayer._search.get("query", "") != ""): 668 | library_info.children.append( 669 | BrowseMedia(title="Results for \"" + str(ytmusicplayer._search.get("query", "No search")) + "\"", media_class=MediaClass.DIRECTORY, media_content_type=SEARCH, media_content_id="", can_play=False, can_expand=True, thumbnail="") 670 | ) 671 | 672 | # add "go to album of track" if possible 673 | if(ytmusicplayer._track_album_id not in ["", None] and ytmusicplayer._track_name not in ["", None]): 674 | library_info.children.append( 675 | BrowseMedia(title="Album of \"" + str(ytmusicplayer._track_name) + "\"", media_class=MediaClass.ALBUM, media_content_type=ALBUM_OF_TRACK, media_content_id="1", can_play=True, can_expand=True, thumbnail=ytmusicplayer._track_album_cover) 676 | ) 677 | 678 | # add "radio of track" if possible 679 | if(ytmusicplayer._attributes['videoId'] != ""): 680 | library_info.children.append( 681 | BrowseMedia(title="Radio of \"" + str(ytmusicplayer._track_name) + "\"", media_class=MediaClass.PLAYLIST, media_content_type=CHANNEL_VID, media_content_id=ytmusicplayer._attributes['videoId'], can_play=True, can_expand=False, thumbnail="") 682 | ) 683 | 684 | return library_info 685 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # yTube Music Player 2 | [![hacs_badge](https://img.shields.io/badge/HACS-Default-orange.svg?style=for-the-badge)](https://github.com/hacs/integration) 3 | 4 | ![mini-media-player](./media_player_with_buttons.png) 5 | 6 | The purpose of this integration is to provide easy access to media (Tracks/Playlists/Albums) of your YouTube Music premium subscription. The integration will let you login to your account and present you a [GUI](media_browser.png) similar to the YouTube website, where you can select your playlist/album/.. but in HomeAssistant, actually in the "Media Browser" of HomeAssistant. 7 | 8 | Once you've selected what you want to listen (e.g. a Playlist) it will grab the YouTube ID (everything has unique IDs at YouTube) decode it to a streaming URL and forward that URL to a HomeAssistant media_player of your choice, the "remote_player". This works great in combination with Google Chromecasts but other media_player might work as well, as long as they can stream from a URL (the Alexa integration can't do this to my knowledge). 9 | 10 | Media_player in Homeassistant are not designed to play multiple tracks, so the playback on the remote_player will usually just stop, once it reaches the end of the track. We as user often don't want that. We want to listen to a complete album or a playlist with multiple tracks. Hence this integration will buffer the playlist and supervise the status of the remote_player. Once it detects that the playback stopped it will start the next track of the playback ... so this integration is kind of a DJ, always making sure that you're entertained. :) 11 | 12 | This integration will show up in homeassistant as media_player + (optional) sensor. The media_player itself is required to offer the media_browser dialog, the sensor will provide extra information like the buffered playlist. 13 | 14 | 15 | ## Features 16 | - Browse through all you library tracks / artists / playlists showing names and covers of the media 17 | - Either plays straight from the playlist or creates a radio based on the playlist 18 | - Forwards the streaming data to any generic mediaplayer 19 | - Keeps auto_playing as long as it is turned on 20 | - On the fly change of the remove_player (playlist will stay the same, and position in track will be submitted to next player) 21 | 22 | # Support 23 | If you like what I've done and you want to help: buy me a coffee/beer. Thanks! 24 | 25 | [![Beer](beers.png)](https://www.buymeacoffee.com/KoljaWindeler) 26 | 27 | # Overview / Step - by - step guide 28 | 29 | 1. Initial setup: [Videotutorial](https://www.youtube.com/watch?v=_UQv7fc3h5s) 30 | - (required) install the component via HACS [see Installation](#installation-via-hacs) 31 | - (required) configure the component via config flow, see details for the [see Setup](#setup) 32 | 33 | you can now use it via the media browser and the default mediaplayer card, but please read on ... 34 | 35 | 2. Mini-media-player [Videotutorial](https://www.youtube.com/watch?v=YccSsBr3Tag) 36 | - (highly recommended) install the mini-media-player and use [see shortcut buttons](#shortcuts) 37 | 3. (optional for mpd / sonos) install the automation to fix [see auto-advancing](#mpd-fix) 38 | 4. (optional) install [automations](#Automations) 39 | 40 | if you find a bug, or want some extra informations 41 | 42 | 6. (debug) enable [debug info](#debug-information) 43 | 44 | ## Installation via HACS 45 | 46 | Please install this custom component via [HACS](https://hacs.xyz/docs/installation/prerequisites). 47 | 48 | Once you've installed HACS follow this [Guide](https://codingcyclist.medium.com/how-to-install-any-custom-component-from-github-in-less-than-5-minutes-ad84e6dc56ff) and install the yTube_music_player from the default HACS repository. 49 | 50 | # Setup 51 | Go to Settings -> Devices -> "Add integration" -> "YouTube Music Player" 52 | If the integration didn't show up in the list please REFRESH the page 53 | 54 | For the installation you need an Oauth token from google. Here is how you create it: 55 | 56 | Open the provided link during the config flow. if this is the first time you use a google developer account you won't have project. So click "create project" in the upper right corner 57 | Image 58 | 59 | call it what ever you want .. it is just visible for you 60 | Image 61 | 62 | once created you need to cofigure the consent screen, again in the upper right corner 63 | Image 64 | 65 | click get-started and follow the process 66 | Image 67 | 68 | again .. names won't matter 69 | Image 70 | 71 | likely you will also be able to have an external project 72 | Image 73 | 74 | done 75 | Image 76 | 77 | now you can create the oauth data 78 | Image 79 | 80 | click create client 81 | Image 82 | 83 | select TV 84 | Image 85 | 86 | again .. names don't matter .. note down the data from the next screenshot .. those are the data that you need for homeassistant in a second but keep following the process here or you will end up with an error 87 | Image 88 | 89 | go to audiance and add a testuser -> add user 90 | Image 91 | 92 | add your googleaccount 93 | Image 94 | 95 | make sure that it is listed ... 96 | Image 97 | 98 | now you can continue with the oauth data in the next step in homeassistant 99 | 100 | ## Installation went fine, what now? 101 | At this point you should have a new entity called `media_player.ytube_music_player` (or similar if you've changed the name). Open the media_browser, make sure this new media_player is selected (lower right corner). You'll see an overview of differnt types like playlists / albums etc. Go, open a section and click play on one of those items. 102 | At this point you should hear some music from the remote_player. 103 | 104 | [@pathofleastresisor](https://github.com/pathofleastresistor) build a great custom card for the ytube_music_player that you can find [here](https://github.com/pathofleastresistor/polr-ytube-media-card). 105 | I really recommend to use this card to ease the navigation within YouTube. 106 | 107 | ![polr-ytube-media-card](./polr-ytube-media-card.png) 108 | 109 | 110 | Ok, the media_browser and polr-ytube-media-card are nice, but what if you want a little more? Like automations, or call it via Node-Red or Appdaemon .. I mean, we're in HomeAssistant, right? 111 | Well you don't have to use the media_browser. You can start a playback directly. All you need to know is the 'type' of the media (playlist / album / track / ..) and the 'id'. 112 | 113 | The easiest way to get those information is to start the playback once with the media_browser and then (while it is still playing) checkout the media_player state. Go to 'development tools' -> 'states' and find `media_player.ytube_music_player`. It will display some attributes. Note `_media_id` (e.g. 'PL1ua59sKbGkcgVVsiMuPxlq5vaIJn4Ise') and `_media_type` (e.g. 'playlist') 114 | Once you have those information, stop the playback. Go to the service tab ('development tools' -> 'services') and find the service called 'Media Player: Play media'. Click on 'fill example data' and you should see something like this: 115 | ``` 116 | service: media_player.play_media 117 | target: 118 | entity_id: media_player.ytube_music_player 119 | data: 120 | media_content_id: https://home-assistant.io/images/cast/splash.png 121 | media_content_type: music 122 | ``` 123 | Replace the 'https://home-assistant.io/images/cast/splash.png' with your 'id' from `_media_id` above and music with the `_media_type` and hit 'call service'. 124 | Now the same music should start playing. Neat right? From here you can go on and create your automations. (Also see 'Automations' section below). 125 | 126 | ## Shortcuts 127 | The screenshot below shows the mini-media-player from kalkih (https://github.com/kalkih/mini-media-player) 128 | 129 | ![mini-media-player](shortcuts.png) 130 | 131 | This mediaplayer offers shortcuts, which can be used to directly start the playback of a album or playback. It can even be used to change the remote_player with a single click. 132 | 133 | ``` 134 | - type: 'custom:mini-media-player' 135 | entity: media_player.ytube_music_player 136 | artwork: cover 137 | hide: 138 | shuffle: false 139 | icon_state: false 140 | shortcuts: 141 | columns: 3 142 | buttons: 143 | - name: Badezimmer 144 | type: source 145 | id: badezimmer 146 | - name: Keller 147 | type: source 148 | id: keller 149 | - name: Laptop 150 | type: source 151 | id: bm_8e5f874f_8dfcb60f 152 | - name: My Likes 153 | type: channel 154 | id: PLZvjm51R8SGuxxxxxxx-A17Kp3jZfg6pg 155 | - name: Lala 156 | type: playlist 157 | id: PLZvjm51R8SGuxxxxxxx-A17Kp3jZfg6pg 158 | ``` 159 | 160 | ## Services 161 | There are multiple services available the most important once are `media_player.select_source` and `media_player.play_media`. 162 | `media_player.select_source` will change the 'remote_player', just pass the entity_id of the new remote speaker to it. 163 | 164 | mini-media-player shortcut type | service call | details 165 | -- | -- | -- 166 | `source` | **media_player.select_source** *source=id and entity_id=[this]* | selects the media_player that plays the music. id can be an entity_id like `media_player.speaker123` or just the name `speaker123` 167 | 168 | To start a playback use the `media_player.play_media` service. 169 | 170 | type for mini-media-player or `media_content_type` for the service call | details 171 | -- | -- 172 | `playlist` | plays a playlist from YouTube. *You can get the playlist Id from the Youtube Music website. Open a playlist from the library and copy the id from the link e.g. https://music.youtube.com/playlist?list=PL6H6TfFpYvpersxxxxxxxxxaPueTqieF*. You can also use `media_content_id: all_library_tracks` to start a playlist with all tracks from your library. 173 | `channel` | Starts a radio based on a playlist. So the id has to be a **playlist_id** 174 | `vid_channel` | Starts a radio based on a videoId. So the id has to be a **video_id** 175 | `album` | plays an album. *You can get the album Id from the Youtube Music website. Open an album from the library https://music.youtube.com/library/albums and copy the Id from the links* 176 | `track` | will play only one dedicated track 177 | `history` | will play a playlist from your recent listen music **on the website or the app** *the music that you play with this component will not show up in the list* 178 | `user_tracks` | this type will play the **uploaded** tracks of a user 179 | `user_album` | **uploaded** album of a user 180 | `user_artist` | play all **uploaded** tracks of an artists 181 | 182 | All calls to *media_player.play_media* need three arguments: media_content_id is the equivalent of the shortcut id, media_content_type represents the type (e.g. album) and the entity_id is always media_player.ytube_music_player 183 | 184 | You can also select the music you want to listen to via the media_browser and look up the media_content_type and media_content_id in the attributs of the player. 185 | 186 | In addition the following special commands are also available: 187 | Service | parameter | details 188 | -- | -- | -- 189 | `ytube_music_player.rate_track` | `entity_id`: media_player.ytube_media_player, `rating`: thumb_up / thumb_down / thumb_middle / thumb_toggle_up_middle, rate_track, `song_id`: ID of the track | Rates the currently playing song (or if provided: the song_id). The current rating is available as 'likeStatus' attribute of the player entity_id. middle means that the rating will be 'indifferent' so basically removes your previous rating 190 | `ytube_music_player.limit_count` | `entity_id`: media_player.ytube_media_player, `limit`: number of tracks | Limits the amount of tracks that is loaded into the playlist, e.g. to stop playing after 3 tracks. Setting -1 as value disables the feature again. 191 | `ytube_music_player.call_method` | `entity_id`: media_player.ytube_media_player, `command`: reload_dropdowns | Reloads the dropdown list of all media_players and also the playlists. Might be nice to reload those lists without reboot HA 192 | `ytube_music_player.call_method` | `entity_id`: media_player.ytube_media_player, `command`: interrupt_start | Special animal 1/2: This will stop the current track, but note the position in the track. It will also store the track number in the playlist and the playlist. Finally it will UNTRACK the media_player. As result you can e.g. play another sound on that player, like a door bell or a warning 193 | `ytube_music_player.start_radio` | `entity_id`: media_player.ytube_media_player | Starts a radio, based on the current playing track 194 | `ytube_music_player.call_method` | `entity_id`: media_player.ytube_media_player, `command`: interrupt_resume | Special animal 2/2: This is the 2nd part and will resume the playback 195 | `ytube_music_player.add_to_playlist` | `entity_id`: media_player.ytube_media_player, `song_id`: define the ID of the song you want to add. Skip this parameter if you want to add the song that is currently playing, `playlist_id`: define the ID of the playlist, skip this if you've started a channel based on your own playlist and want to add the song to that playlist | Adds a song to a playlist 196 | `ytube_music_player.remove_from_playlist` | `entity_id`: media_player.ytube_media_player, `song_id`: define the ID of the song you want to remove. Skip this parameter if you want to remove the song that is currently playing, `playlist_id`: define the ID of the playlist, skip this if you want to remove the song from the current playlist (requires that you've started playing via playlist) | Removes a song from a playlist 197 | `ytube_music_player.call_method` | `entity_id`: media_player.ytube_media_player, `command`: off_is_idle | Some media_player integrations, like MPD or OwnTone server, switch to off state at the end of a song instead of switching to idle state. Calling this method will make YTube music player detect an off state also as "idle" state. Consequence ofcourse is that manual turning off of the remote player will also trigger next song. 198 | `ytube_music_player.call_method` | `entity_id`: media_player.ytube_media_player, `command`: paused_is_idle | Some media_player integrations, like Sonos, will switch to paused at the end of a song instead of switching to idle state. Calling this method will make YTube music player detect a paused state also as "idle" state. Consequence ofcourse is that manual pausing of the remote player will also trigger next song. 199 | `ytube_music_player.call_method` | `entity_id`: media_player.ytube_media_player, `command`: idle_is_idle | If the idle detection method was changed by calling the `off_is_idle` or `paused_id_idle` method, this service call will reset the idle detection back to the default behavior where the next song is only started when the remote media_player switches to the actual idle state. 200 | `ytube_music_player.call_method` | `entity_id`: media_player.ytube_media_player, `command`: ignore_paused_on_media_change | Some media_player integrations, like OwnTone server, temporarily switch to paused when next/prev track is manually selected or when a new position in the current track is selected (seek). By default YTube music player will always try to sync its state with the remote player, hence in this case pause playback on next/prev track or seek actions. Calling this method will make YTube music player ignore next playing -> paused state change of the remote player whenever a manual next/prev track or seek action is performed. 201 | `ytube_music_player.call_method` | `entity_id`: media_player.ytube_media_player, `command`: do_not_ignore_paused_on_media_change | This call will reset a previous `ignore_paused_on_media_change` call. 202 | 203 | ## Dropdowns, Buttons and Marksdowns 204 | The player can controlled with shortcut from the mini-media-player, with direct calls to the offered services or simply by turing the player on. 205 | However certain extra informations are required to controll what will be played and where to support the "one-click-turn-on" mode. 206 | These are presented in the form of drop-down fields, as shown in the screenshot below. 207 | Go to the 'options' dialog (configflow) and choose the dropdowns you want to use. 208 | 209 | The player attributes contain addition informations, like the playlist and if available the lyrics of the track 210 | ![lyrics](lyrics.png) 211 | The yaml setup is available at [package/markdown.yaml](https://github.com/KoljaWindeler/ytube_music_player/blob/main/package/markdown.yaml) 212 | 213 | ## Automations 214 | Play my **favorite** playlist in **random** mode on my **kitchen** speaker (kuche) 215 | ```yaml 216 | alias: ytube morning routine 217 | sequence: 218 | - service: media_player.select_source 219 | data: 220 | source: kuche 221 | entity_id: media_player.ytube_music_player 222 | - service: media_player.shuffle_set 223 | data: 224 | shuffle: true 225 | entity_id: media_player.ytube_music_player 226 | - service: media_player.play_media 227 | data: 228 | entity_id: media_player.ytube_music_player 229 | media_content_id: PL6H6TfFpYvpersEdHECeWkocaPueTqieF 230 | media_content_type: playlist 231 | mode: single 232 | ``` 233 | Interrupt current playback, play a "DingDong" and resume playback 234 | ```yaml 235 | alias: dingdong 236 | sequence: 237 | - service: ytube_music_player.call_method 238 | entity_id: media_player.ytube_music_player 239 | data: 240 | command: interrupt_start 241 | - variables: 242 | vol: '{{ state_attr("media_player.keller_2", "volume_level") }}' 243 | - service: media_player.volume_set 244 | entity_id: media_player.keller_2 245 | data: 246 | volume_level: 1 247 | - service: media_player.play_media 248 | entity_id: media_player.keller_2 249 | data: 250 | media_content_id: 'http://192.168.2.84:8123/local/dingdong.mp3' 251 | media_content_type: music 252 | - delay: '00:00:02' 253 | - service: media_player.volume_set 254 | entity_id: media_player.keller_2 255 | data: 256 | volume_level: 0 257 | - service: ytube_music_player.call_method 258 | entity_id: media_player.ytube_music_player 259 | data: 260 | command: interrupt_resume 261 | - service: media_player.volume_set 262 | entity_id: media_player.keller_2 263 | data: 264 | volume_level: '{{vol}}' 265 | mode: single 266 | 267 | ``` 268 | 269 | Play a radio on the current track: 270 | ```yaml 271 | alias: RadioOnSong 272 | sequence: 273 | - service: media_player.play_media 274 | data: 275 | media_content_id: > 276 | {{state_attr("media_player.ytube_music_player","_media_id") }} 277 | media_content_type: vid_channel 278 | entity_id: media_player.ytube_music_player 279 | ``` 280 | 281 | ## Proxy 282 | 283 | Some media_players have issues playing the forwarded streams from YTube music player. E.g. Playback on Sonos speakers returns a "mime-type unknown" error. Also many DLNA devices are quite picky about the stream and may fail to play it for several reasons (e.g. URL too long, invalid format). A workaround is to use a proxy that will in turn stream a compatible file to the problem device. 284 | 285 | ### Method 1: Built-in proxy 286 | YTube music player provides a simple built-in proxy functionality where it will download the current track and place it in a local folder (e.g. `/config/www`) and forward a predefined url pointing to that downloaded track to the target media_player. 287 | 288 | **Update 01/2022**: Playback using the proxy is currently flawed. YouTube Music reduced the download speed to something close to real-time. The solution below relied on a fast download and thus won't work any more (will take ~3min to buffer the song before it can be played). 289 | 290 | This feature can be activated by providing two settings: 291 | 1) `proxy_path` | This is the local folder, that the component is using to STORE the file. 292 | The easiest way it to provide your www folder. Be aware: If you're using a docker image (or HassOS) that the component looks from INSIDE the image. 293 | So for most users the path will be `/config/www` 294 | 2) `proxy_url` | The path will be send to your Sonos speaker. So typically this should be something like `http://192.168.1.xxx:8123/local`. Please note that https will only work if you have a valid ssl-certificat, otherwise the Sonos will not connect. 295 | 296 | You can also use a dedicated server, if you don't want to use homeassistant as http server, or you have some special SSL setup. 297 | If you're running docker anyway you could try `docker run --restart=always --name nginx-ytube-proxy -p 8080:80 -v /config/www:/usr/share/nginx/html:ro -d nginx` 298 | This will spin up server on port 8080 that serves `/config/www` so your `proxy_url` would have to be `http://192.168.1.xxx:8080`. 299 | 300 | You can use this also with other speakers, but it will in general add some lack as the component has to download the track before it will start the playback. So if you don't need it: don't use it. If you have further question or if this is working for you please provide some feedback at https://github.com/KoljaWindeler/ytube_music_player/issues/38 as I can't test this on my own easily. Thanks! 301 | 302 | ### Method 2: OwnTone server as proxy 303 | [OwnTone server](https://owntone.github.io/owntone-server/) through the [OwnTone HA integration](https://www.home-assistant.io/integrations/forked_daapd) is able to play the stream provided by YTube music player and can output the stream to either OwnTone server configured targets (by default it should detect DLNA and AirPlay devices on the network) or serve the stream as a file `http://SERVER_ADDRESS:3689/stream.mp3` in real-time. 304 | 305 | * Set up OwnTone server 306 | 307 | You can set up an independent OwnTone server using the instructions [here](https://owntone.github.io/owntone-server/installation/) or you can use the [OwnTone server HA addon](https://github.com/a-marcel/hassio-addon-owntone) by @a-marcel. **Note: Currently only the beta version from this repo seems to work with recent versions of HA.** 308 | 309 | * Install the HA [OwnTone integration](https://www.home-assistant.io/integrations/forked_daapd) 310 | 311 | After installation, it should automatically detect your OwnTone server. See the OwnTone integration page for more information. 312 | 313 | * After a restart of HA you should now be able to select the OwnTone server as target speaker 314 | 315 | However the OwnTone server/integration has a few odities of its own: it will turn to state 'off' instead of 'idle' at the end of a track and it will shortly switch to state 'paused' when next/previous track is selected or when performing a seek in the current track. YTube music player can handle this, but requires some extra service calls to know about this behavior: 316 | 317 | * Send commands `off_is_idle` and `ignore_paused_on_media_change` to the YTube music player entity. 318 | 319 | As mentioned above, OwnTone should auto-detect DLNA and Airplay devices automatically, if they are in the same subnet as the OwnTone server. If that is the case, you can set the current output of OwnTone server using the OwnTone integration. 320 | 321 | For other devices like e.g. Sonos speakers, or if you have problems with the auto-detection of OwnTone, you can use HA media_player integrations (Sonos, DLNA, ...) to play the URL `http://SERVER_ADDRESS:3689/stream.mp3` which should contain the YTube music stream when YTube music player is streaming to the OwnTone media_player. 322 | 323 | Example automation to set up YTube music player when OwnTone server is set as target speaker: 324 | ```yaml 325 | alias: Set YT Music player settings according to selected speakers 326 | mode: single 327 | trigger: 328 | - platform: state 329 | entity_id: 330 | - select.ytube_music_player_speaker 331 | from: null 332 | to: null 333 | action: 334 | - choose: 335 | - conditions: 336 | - condition: state 337 | entity_id: select.ytube_music_player_speaker 338 | state: OwnTone server 339 | sequence: 340 | - alias: Detect OFF state also as idle 341 | service: ytube_music_player.call_method 342 | data: 343 | entity_id: media_player.ytube_music_player 344 | command: off_is_idle 345 | - alias: Ignore PLAYING to PAUSED transition after media change 346 | service: ytube_music_player.call_method 347 | data: 348 | entity_id: media_player.ytube_music_player 349 | command: ignore_paused_on_media_change 350 | default: 351 | - alias: Only detect IDLE state as idle 352 | service: ytube_music_player.call_method 353 | data: 354 | entity_id: media_player.ytube_music_player 355 | command: idle_is_idle 356 | - alias: Do NOT ignore PLAYING to PAUSED transitions 357 | service: ytube_music_player.call_method 358 | data: 359 | entity_id: media_player.ytube_music_player 360 | command: do_not_ignore_paused_on_media_change 361 | ``` 362 | 363 | Example automation to play the OwnTone server stream on Sonos speakers: 364 | ```yaml 365 | alias: Play OwnTone stream on Sonos 366 | mode: single 367 | trigger: 368 | - platform: state 369 | entity_id: 370 | - media_player.owntone_server 371 | to: playing 372 | action: 373 | - service: media_player.play_media 374 | data: 375 | media_content_type: music 376 | media_content_id: http://[server IP]:3689/stream.mp3 377 | target: 378 | entity_id: media_player.sonos 379 | ``` 380 | 381 | ## Auto Advance 382 | When playing a playlist / album / radio the natural expectation is to play the next track once the last has finished. Ytube_music_player can't offload this task to the remote_player (the one that actually plays the music) as most players don't support playlists. 383 | 384 | Thus Ytube_music_player has to track the status the remote_player and detect the 'end of track' to start the next track from the list. 385 | 386 | Most player I've tested (Chromecast / Google Home / Browser Mod) will transistion from `playing` to `idle`. 387 | As a result the code of Ytube_music_player will play the next track whenever this state transition happens. 388 | 389 | Sadly not all player follow this logic. E.g. MPD based media_player will transition from `playing` to `off` at the end of a tack, some sonos speaker will switch to `paused`. I've added special commands to Ytube_music_player to overcome those issues. This will change the way ytube_music_player will react on state changes. E.g. if the `off_is_idle` command was sent, ytube_music_player will advance to the next track whenever the remote_player will transition from `playing` to `off`. This will enable auto-next-track. 390 | 391 | ### Off is Idle / Paused is idle / Idle is idle 392 | 393 | Some media_players like MPD or OwnTone server will transition to `off` instead of `idle` at the end of each track as mentioned above. Other media_players like Sonos may transition to `paused` instead of `idle`. You can set Ytube_music_player to handle this by performing a service call with command `off_is_idle` or `paused_is_idle`. 394 | 395 | *The drawback is obviously that you can't switch off the playback on the remote_player anymore (meaning the `off` button of `media_player.mpd`) because ytube_music_player will understand this as the end of the track. You can of course still shutdown the playback by turning off ytube_music_player.* 396 | 397 | To reset this behavior and only start a next song when an actual transition to `idle` is detected, send the command `idle_is_idle`. 398 | 399 | If you will only use e.g. MPD as target player, you can do this during startup of homeassistant using this automation from **@lightzhuk** in your configuration: 400 | ```yaml 401 | - alias: mpd_fix 402 | initial_state: true 403 | trigger: 404 | - platform: homeassistant 405 | event: start 406 | action: 407 | - delay: 00:00:12 408 | - service: ytube_music_player.call_method 409 | entity_id: media_player.ytube_music_player 410 | data: 411 | command: off_is_idle 412 | ``` 413 | 414 | Or you can add an automation triggered on the `select.ytube_music_player_speaker` entity to set this behavior depending on the selected target: 415 | ```yaml 416 | alias: Set YT Music player auto advance detection according to selected speakers 417 | mode: single 418 | trigger: 419 | - platform: state 420 | entity_id: 421 | - select.ytube_music_player_speaker 422 | from: null 423 | to: null 424 | action: 425 | - choose: 426 | - conditions: 427 | - condition: or 428 | conditions: 429 | - condition: state 430 | entity_id: select.ytube_music_player_speaker 431 | state: Mpd 432 | - condition: state 433 | entity_id: select.ytube_music_player_speaker 434 | state: OwnTone server 435 | sequence: 436 | - service: ytube_music_player.call_method 437 | data: 438 | entity_id: media_player.ytube_music_player 439 | command: off_is_idle 440 | - conditions: 441 | - condition: state 442 | entity_id: select.ytube_music_player_speaker 443 | state: Sonos 444 | sequence: 445 | - service: ytube_music_player.call_method 446 | data: 447 | entity_id: media_player.ytube_music_player 448 | command: paused_is_idle 449 | default: 450 | - service: ytube_music_player.call_method 451 | data: 452 | entity_id: media_player.ytube_music_player 453 | command: idle_is_idle 454 | ``` 455 | 456 | ## Debug Information 457 | I've added extensive debugging information to the component. So if you hit an error, please see if you can get as many details as possible for the issue by enabling the debug-log-level for the component. This will produce quite a lot extra information in the log (configuration -> logs). 458 | 459 | There are two ways to enable the debug output (as of 20210303): 460 | 461 | ### 1. Reroute debug output to error via service 462 | - Open Developer tools 463 | - open service tab 464 | - call service below 465 | 466 | ```yaml 467 | service: ytube_music_player.call_method 468 | data: 469 | entity_id: media_player.ytube_music_player <-- replace this with your player 470 | command: debug_as_error 471 | parameters: [0] 472 | ``` 473 | 474 | This will instantly post all messages as errors until you reboot homeassistant: 475 | ![Debug as error](debug_as_error.png) 476 | 477 | ### 2. Let Homeassistant show debug information 478 | - edit the `configuration.yaml` and add the logger section 479 | - Please keep in mind that a restart of Homeassistant is needed to apply this change. 480 | 481 | ```yaml 482 | logger: 483 | default: info 484 | logs: 485 | custom_components.ytube_music_player: debug 486 | ``` 487 | 488 | ## Multiple accounts 489 | Not yet tested, but should work in general. Please create two entities via the Config_flow and use **different** paths for the header file 490 | 491 | ## FAQ 492 | - **[Q] Where are the input_select fields?** 493 | [A] After Version 20240420.02,You can find the options in advanced settings to choose the select entities you want to use,they will be automatically added to Home Assistant. 494 | Existing users can continue using the original input_select(s) which are created by the yaml.If you want to use the new select(s), follow these steps: 495 | 1.) Setting the IDs of the original inputs to a blank space character will permanently delete this field. 496 | 2.) Use checkboxes in the advance configuration to choose the new select entities you want to use. 497 | 3.) If everything is working correctly(after adjusting your dashboard and automations), delete the YAML file used to create the old input entities. 498 | 499 | 500 | - **[Q] Where can I find the ID for e.g. a playlist?** 501 | [A] simply start the playlist / album / track via the media_browser. Once the music is playing open the `developer tools` -> `states` and search for your `media_player.ytube_music_player`. Note the `_media_type` and the `_media_id` and use them for your service calls / shortcuts 502 | 503 | 504 | - **[Q] I get 'malformed used input' what should I do?** 505 | [A] I can't really explain what happens here, but simply remove the integration (on the integration page, no need to remove it via HACS) and set it up once more. 506 | 507 | 508 | - **[Q] What is legacy radio?** 509 | [A] YouTube Music offers differnt ways to play a radio. The 'legacy' version would choose a random track from that playlist and create a radio based on that single track. The 'non legacy' version will be based on the complete playlist. At least for me the 'legacy' way offers more variaty, the 'non legacy' is mostly the same list. 510 | 511 | 512 | - **[Q] What is Shuffle vs Random vs Shuffle Random** 513 | [A] Once shuffle is enabled you can choose the method: 514 | 1.) **Shuffle** will shuffle the playlist on generation and the play straight 1,2,3,..., this is the default 515 | 2.) **Random** will NOT shuffle the playlist on generation but pick the tracks randomly, repeats can happen 516 | 3.) **Shuffle Random** will shuffle the playlist on generation and pick the next random, repeats can happen 517 | You can change the mode when you add the select.ytube_music_player_play_mode 518 | 519 | 520 | - **[Q] Can I search for items** 521 | [A] yes, please have a look at this little clip https://youtu.be/6pQJa0tvVMQ 522 | basically call the service `ytube_music_player.search` and open the media_browser after that. There should be a new item that contains the results 523 | 524 | 525 | - **[Q] Why is my playlist limited to 25 entries** 526 | [A] This is the default number that this integration will load. You can change this number via the configuration menu: 527 | "configuration" -> "integration" -> "ytube_music_player" -> "configure" -> "Show advance configuration" -> "Limit of simultaniously loaded tracks". 528 | Raising that number will increase loading time slightly. Please also make sure that didn't define a lower "limit_count" (see service section) 529 | 530 | ## Credits 531 | 532 | This is based on the gmusic mediaplayer of tprelog (https://github.com/tprelog/HomeAssistant-gmusic_player), ytmusicapi (https://github.com/sigma67/ytmusicapi) and pytube (https://github.com/nficano/pytube). This project is not supported nor endorsed by Google. Its aim is not the abuse of the service but the one to improve the access to it. The maintainers are not responsible for misuse. 533 | --------------------------------------------------------------------------------