├── .gitignore ├── LICENSE ├── README.md ├── custom_components └── ha_cloud_music │ ├── __init__.py │ ├── browse_media.py │ ├── cloud_music.py │ ├── config_flow.py │ ├── const.py │ ├── http.py │ ├── http_api.py │ ├── manifest.json │ ├── manifest.py │ ├── media_player.py │ ├── models │ └── music_info.py │ ├── translations │ └── en.json │ └── utils.py └── hacs.json /.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 | 131 | <<<<<<< HEAD 132 | .vscode/ 133 | ======= 134 | .vscode/ 135 | >>>>>>> dev 136 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 Monkey • D • Code 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # 云音乐 2 | 3 | 在Home Assistant里使用的网易云音乐插件 4 | 5 | [![hacs_badge](https://img.shields.io/badge/Home-Assistant-%23049cdb)](https://www.home-assistant.io/) 6 | [![hacs_badge](https://img.shields.io/badge/HACS-Custom-41BDF5.svg)](https://github.com/hacs/integration) 7 | ![visit](https://visitor-badge.laobi.icu/badge?page_id=shaonianzhentan.ha_cloud_music&left_text=visit) 8 | 9 | --- 10 | ## 历史旧版本项目,请点击链接访问安装 11 | https://github.com/shaonianzhentan/cloud_music 12 | 13 | --- 14 | 15 | ## 安装 16 | 17 | 安装完成重启HA,刷新一下页面,在集成里搜索`云音乐` 18 | 19 | [![Add Integration](https://my.home-assistant.io/badges/config_flow_start.svg)](https://my.home-assistant.io/redirect/config_flow_start?domain=ha_cloud_music) 20 | 21 | > 接口说明 22 | 23 | 接口服务是开源免费的,但需要自己进行部署,然后持续进行更新升级,如果遇到接口相关的问题,请去`NeteaseCloudMusicApi`项目中查找问题 24 | 25 | https://github.com/Binaryify/NeteaseCloudMusicApi 26 | 27 | 不想动手不想操心,也可以付费使用由我部署维护的接口服务(每年30) 28 | 29 | **注意:关联媒体播放器调整为在集成选项中选择** 30 | 31 | ## 使用 - [插件图片预览](https://github.com/shaonianzhentan/image/blob/main/ha_cloud_music/README.md) 32 | 33 | > **指定ID播放** 34 | 35 | - 播放网易云音乐歌单 `cloudmusic://163/playlist?id=25724904` 36 | - 播放网易云音乐电台 `cloudmusic://163/radio/playlist?id=1008` 37 | - 播放网易云音乐歌手 `cloudmusic://163/artist/playlist?id=2116` 38 | - 播放喜马拉雅专辑 `cloudmusic://xmly/playlist?id=258244` 39 | 40 | > **搜索播放** 41 | 42 | - [x] 音乐搜索播放 `cloudmusic://play/song?kv=关键词` 43 | - [x] 歌手搜索播放 `cloudmusic://play/singer?kv=关键词` 44 | - [x] 歌单搜索播放 `cloudmusic://play/list?kv=关键词` 45 | - [x] 电台搜索播放 `cloudmusic://play/radio?kv=关键词` 46 | - [x] 喜马拉雅搜索播放 `cloudmusic://play/xmly?kv=关键词` 47 | - [ ] FM搜索播放 `cloudmusic://play/fm?kv=关键词` 48 | - [x] (不推荐)第三方音乐搜索播放 `cloudmusic://search/play?kv=关键词` 49 | 50 | > **登录后播放** 51 | - [x] 每日推荐 `cloudmusic://163/my/daily` 52 | - [x] 我喜欢的音乐 `cloudmusic://163/my/ilike` 53 | 54 | ## 关联项目 55 | 56 | - https://github.com/shaonianzhentan/cloud_music_mpd 57 | - https://github.com/shaonianzhentan/ha_windows 58 | 59 | ## 如果这个项目对你有帮助,请我喝杯咖啡奶茶吧😘 60 | |支付宝|微信| 61 | |---|---| 62 | 支付宝 | 微信支付 63 | 64 | #### 关注我的微信订阅号,了解更多HomeAssistant相关知识 65 | HomeAssistant家庭助理 -------------------------------------------------------------------------------- /custom_components/ha_cloud_music/__init__.py: -------------------------------------------------------------------------------- 1 | from homeassistant.config_entries import ConfigEntry 2 | from homeassistant.core import HomeAssistant 3 | import homeassistant.helpers.config_validation as cv 4 | from homeassistant.const import CONF_URL 5 | 6 | import asyncio 7 | from .const import PLATFORMS 8 | from .manifest import manifest 9 | from .http import HttpView 10 | from .cloud_music import CloudMusic 11 | 12 | DOMAIN = manifest.domain 13 | 14 | CONFIG_SCHEMA = cv.deprecated(DOMAIN) 15 | 16 | async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: 17 | 18 | data = entry.data 19 | api_url = data.get(CONF_URL) 20 | vip_url = entry.options.get(CONF_URL, '') 21 | hass.data['cloud_music'] = CloudMusic(hass, api_url, vip_url) 22 | 23 | hass.http.register_view(HttpView) 24 | await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) 25 | entry.async_on_unload(entry.add_update_listener(update_listener)) 26 | return True 27 | 28 | async def update_listener(hass, entry): 29 | await async_unload_entry(hass, entry) 30 | await asyncio.sleep(1) 31 | await async_setup_entry(hass, entry) 32 | 33 | async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: 34 | return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) -------------------------------------------------------------------------------- /custom_components/ha_cloud_music/browse_media.py: -------------------------------------------------------------------------------- 1 | """Support for media browsing.""" 2 | from enum import Enum 3 | import logging, os, random, time 4 | from urllib.parse import urlparse, parse_qs, parse_qsl, quote 5 | from homeassistant.helpers.json import save_json 6 | from custom_components.ha_cloud_music.http_api import http_get 7 | from .utils import parse_query 8 | 9 | from homeassistant.components import media_source 10 | from homeassistant.components.media_player import ( 11 | BrowseError, BrowseMedia, 12 | async_process_play_media_url 13 | ) 14 | from homeassistant.components.media_player import MediaType 15 | from homeassistant.components.media_player import MediaClass 16 | 17 | PLAYABLE_MEDIA_TYPES = [ 18 | MediaType.ALBUM, 19 | MediaType.ARTIST, 20 | MediaType.TRACK, 21 | ] 22 | 23 | CONTAINER_TYPES_SPECIFIC_MEDIA_CLASS = { 24 | MediaType.ALBUM: MediaClass.ALBUM, 25 | MediaType.ARTIST: MediaClass.ARTIST, 26 | MediaType.PLAYLIST: MediaClass.PLAYLIST, 27 | MediaType.SEASON: MediaClass.SEASON, 28 | MediaType.TVSHOW: MediaClass.TV_SHOW, 29 | } 30 | 31 | CHILD_TYPE_MEDIA_CLASS = { 32 | MediaType.SEASON: MediaClass.SEASON, 33 | MediaType.ALBUM: MediaClass.ALBUM, 34 | MediaType.MUSIC: MediaClass.MUSIC, 35 | MediaType.ARTIST: MediaClass.ARTIST, 36 | MediaType.MOVIE: MediaClass.MOVIE, 37 | MediaType.PLAYLIST: MediaClass.PLAYLIST, 38 | MediaType.TRACK: MediaClass.TRACK, 39 | MediaType.TVSHOW: MediaClass.TV_SHOW, 40 | MediaType.CHANNEL: MediaClass.CHANNEL, 41 | MediaType.EPISODE: MediaClass.EPISODE, 42 | } 43 | 44 | _LOGGER = logging.getLogger(__name__) 45 | 46 | protocol = 'cloudmusic://' 47 | cloudmusic_protocol = 'cloudmusic://163/' 48 | xmly_protocol = 'cloudmusic://xmly/' 49 | fm_protocol = 'cloudmusic://fm/' 50 | qq_protocol = 'cloudmusic://qq/' 51 | ting_protocol = 'cloudmusic://ting/' 52 | search_protocol = 'cloudmusic://search/' 53 | play_protocol = 'cloudmusic://play/' 54 | 55 | # 云音乐路由表 56 | class CloudMusicRouter(): 57 | 58 | media_source = 'media-source://' 59 | local_playlist = f'{protocol}local/playlist' 60 | 61 | toplist = f'{cloudmusic_protocol}toplist' 62 | playlist = f'{cloudmusic_protocol}playlist' 63 | radio_playlist = f'{cloudmusic_protocol}radio/playlist' 64 | artist_playlist = f'{cloudmusic_protocol}artist/playlist' 65 | 66 | my_login = f'{cloudmusic_protocol}my/login' 67 | my_daily = f'{cloudmusic_protocol}my/daily' 68 | my_ilike = f'{cloudmusic_protocol}my/ilike' 69 | my_recommend_resource = f'{cloudmusic_protocol}my/recommend_resource' 70 | my_cloud = f'{cloudmusic_protocol}my/cloud' 71 | my_created = f'{cloudmusic_protocol}my/created' 72 | my_radio = f'{cloudmusic_protocol}my/radio' 73 | my_artist = f'{cloudmusic_protocol}my/artist' 74 | 75 | # 乐听头条 76 | ting_homepage = f'{ting_protocol}homepage' 77 | ting_playlist = f'{ting_protocol}playlist' 78 | 79 | # 喜马拉雅 80 | xmly_playlist = f'{xmly_protocol}playlist' 81 | 82 | # FM 83 | fm_channel = f'{fm_protocol}channel' 84 | fm_playlist = f'{fm_protocol}playlist' 85 | 86 | # 搜索名称 87 | search_name = f'{search_protocol}name' 88 | search_play = f'{search_protocol}play' 89 | 90 | # 播放 91 | play_song = f'{play_protocol}song' 92 | play_singer = f'{play_protocol}singer' 93 | play_list = f'{play_protocol}list' 94 | play_radio = f'{play_protocol}radio' 95 | play_xmly = f'{play_protocol}xmly' 96 | play_fm = f'{play_protocol}fm' 97 | 98 | 99 | 100 | async def async_browse_media(media_player, media_content_type, media_content_id): 101 | hass = media_player.hass 102 | cloud_music = hass.data['cloud_music'] 103 | 104 | # 媒体库 105 | if media_content_id is not None and media_content_id.startswith(CloudMusicRouter.media_source): 106 | if media_content_id.startswith(CloudMusicRouter.media_source + '?title='): 107 | media_content_id = None 108 | return await media_source.async_browse_media( 109 | hass, 110 | media_content_id, 111 | content_filter=lambda item: item.media_content_type.startswith("audio/"), 112 | ) 113 | 114 | # 主界面 115 | if media_content_id in [None, protocol]: 116 | children = [ 117 | { 118 | 'title': '播放列表', 119 | 'path': CloudMusicRouter.local_playlist, 120 | 'type': MediaType.PLAYLIST 121 | }, 122 | { 123 | 'title': '媒体库', 124 | 'path': CloudMusicRouter.media_source, 125 | 'type': MediaType.PLAYLIST, 126 | 'thumbnail': 'https://brands.home-assistant.io/_/media_source/icon.png' 127 | }, 128 | { 129 | 'title': '榜单', 130 | 'path': CloudMusicRouter.toplist, 131 | 'type': MediaType.ALBUM, 132 | 'thumbnail': 'http://p2.music.126.net/pcYHpMkdC69VVvWiynNklA==/109951166952713766.jpg' 133 | } 134 | ] 135 | # 当前登录用户 136 | if cloud_music.userinfo.get('uid') is not None: 137 | children.extend([ 138 | { 139 | 'title': '每日推荐歌曲', 140 | 'path': CloudMusicRouter.my_daily, 141 | 'type': MediaType.MUSIC 142 | },{ 143 | 'title': '每日推荐歌单', 144 | 'path': CloudMusicRouter.my_recommend_resource, 145 | 'type': MediaType.ALBUM 146 | },{ 147 | 'title': '我的云盘', 148 | 'path': CloudMusicRouter.my_cloud, 149 | 'type': MediaType.ALBUM, 150 | 'thumbnail': 'http://p3.music.126.net/ik8RFcDiRNSV2wvmTnrcbA==/3435973851857038.jpg' 151 | },{ 152 | 'title': '我的歌单', 153 | 'path': CloudMusicRouter.my_created, 154 | 'type': MediaType.ALBUM, 155 | 'thumbnail': 'https://p2.music.126.net/tGHU62DTszbFQ37W9qPHcg==/2002210674180197.jpg' 156 | },{ 157 | 'title': '我的电台', 158 | 'path': CloudMusicRouter.my_radio, 159 | 'type': MediaType.SEASON 160 | },{ 161 | 'title': '我的歌手', 162 | 'path': CloudMusicRouter.my_artist, 163 | 'type': MediaType.ARTIST, 164 | #'thumbnail': 'http://p1.music.126.net/9M-U5gX1gccbuBXZ6JnTUg==/109951165264087991.jpg' 165 | } 166 | ]) 167 | 168 | 169 | 170 | # 扩展资源 171 | children.extend([ 172 | { 173 | 'title': '新闻快讯', 174 | 'path': CloudMusicRouter.ting_homepage, 175 | 'type': MediaType.ALBUM, 176 | 'thumbnail': 'https://p1.music.126.net/ilcqG4jS0GJgAlLs9BCz0g==/109951166709733089.jpg' 177 | },{ 178 | 'title': 'FM电台', 179 | 'path': CloudMusicRouter.fm_channel, 180 | 'type': MediaType.CHANNEL 181 | },{ 182 | 'title': '二维码登录', 183 | 'path': CloudMusicRouter.my_login + '?action=menu', 184 | 'type': MediaType.CHANNEL, 185 | 'thumbnail': 'https://p1.music.126.net/kMuXXbwHbduHpLYDmHXrlA==/109951168152833223.jpg' 186 | } 187 | ]) 188 | 189 | library_info = BrowseMedia( 190 | media_class=MediaClass.DIRECTORY, 191 | media_content_id=protocol, 192 | media_content_type=MediaType.CHANNEL, 193 | title="云音乐", 194 | can_play=False, 195 | can_expand=True, 196 | children=[], 197 | ) 198 | for item in children: 199 | title = item['title'] 200 | media_content_type = item['type'] 201 | media_content_id = item['path'] 202 | if '?' not in media_content_id: 203 | media_content_id = media_content_id + f'?title={quote(title)}' 204 | thumbnail = item.get('thumbnail') 205 | if thumbnail is not None and 'music.126.net' in thumbnail: 206 | thumbnail = cloud_music.netease_image_url(thumbnail) 207 | library_info.children.append( 208 | BrowseMedia( 209 | title=title, 210 | media_class=CHILD_TYPE_MEDIA_CLASS[media_content_type], 211 | media_content_type=media_content_type, 212 | media_content_id=media_content_id, 213 | can_play=False, 214 | can_expand=True, 215 | thumbnail=thumbnail 216 | ) 217 | ) 218 | return library_info 219 | 220 | # 判断是否云音乐协议 221 | if media_content_id.startswith(protocol) == False: 222 | return None 223 | 224 | # 协议转换 225 | url = urlparse(media_content_id) 226 | query = parse_query(url.query) 227 | 228 | title = query.get('title') 229 | id = query.get('id') 230 | 231 | if media_content_id.startswith(CloudMusicRouter.local_playlist): 232 | # 本地播放列表 233 | library_info = BrowseMedia( 234 | media_class=MediaClass.DIRECTORY, 235 | media_content_id=media_content_id, 236 | media_content_type=MediaType.PLAYLIST, 237 | title=title, 238 | can_play=False, 239 | can_expand=False, 240 | children=[], 241 | ) 242 | 243 | playlist = [] if hasattr(media_player, 'playlist') == False else media_player.playlist 244 | for index, item in enumerate(playlist): 245 | title = item.song 246 | if not item.singer: 247 | title = f'{title} - {item.singer}' 248 | library_info.children.append( 249 | BrowseMedia( 250 | title=title, 251 | media_class=MediaClass.MUSIC, 252 | media_content_type=MediaType.PLAYLIST, 253 | media_content_id=f"{media_content_id}&index={index}", 254 | can_play=True, 255 | can_expand=False, 256 | thumbnail=item.thumbnail 257 | ) 258 | ) 259 | return library_info 260 | if media_content_id.startswith(CloudMusicRouter.my_login): 261 | action = query.get('action') 262 | if action == 'menu': 263 | # 显示菜单 264 | qr = cloud_music.login_qrcode 265 | now = int(time.time()) 266 | # 超过5分钟重新获取验证码 267 | if qr['time'] is None or now - qr['time'] > 300: 268 | res = await cloud_music.netease_cloud_music('/login/qr/key') 269 | if res['code'] == 200: 270 | codekey = res['data']['unikey'] 271 | res = await cloud_music.netease_cloud_music(f'/login/qr/create?key={codekey}') 272 | qr['key'] = codekey 273 | qr['url'] = res['data']['qrurl'] 274 | qr['time'] = now 275 | 276 | return BrowseMedia( 277 | media_class=MediaClass.DIRECTORY, 278 | media_content_id=media_content_id, 279 | media_content_type=MediaClass.TRACK, 280 | title='APP扫码授权后,点击二维码登录', 281 | can_play=False, 282 | can_expand=True, 283 | children=[ 284 | BrowseMedia( 285 | title='点击检查登录', 286 | media_class=MediaClass.DIRECTORY, 287 | media_content_type=MediaType.MUSIC, 288 | media_content_id=CloudMusicRouter.my_login + '?action=login&id=' + qr['key'], 289 | can_play=False, 290 | can_expand=True, 291 | thumbnail=f'https://cdn.dotmaui.com/qrc/?t={qr["url"]}' 292 | ) 293 | ], 294 | ) 295 | elif action == 'login': 296 | # 用户登录 297 | res = await cloud_music.netease_cloud_music(f'/login/qr/check?key={id}&t={int(time.time())}') 298 | message = res['message'] 299 | if res['code'] == 803: 300 | title = f'{message},刷新页面开始使用吧' 301 | await cloud_music.qrcode_login(res['cookie']) 302 | else: 303 | title = f'{message},点击返回重试' 304 | 305 | return BrowseMedia( 306 | media_class=MediaClass.DIRECTORY, 307 | media_content_id=media_content_id, 308 | media_content_type=MediaType.PLAYLIST, 309 | title=title, 310 | can_play=False, 311 | can_expand=False, 312 | children=[], 313 | ) 314 | if media_content_id.startswith(CloudMusicRouter.my_daily): 315 | # 每日推荐 316 | library_info = BrowseMedia( 317 | media_class=MediaClass.DIRECTORY, 318 | media_content_id=media_content_id, 319 | media_content_type=MediaType.PLAYLIST, 320 | title=title, 321 | can_play=True, 322 | can_expand=False, 323 | children=[], 324 | ) 325 | playlist = await cloud_music.async_get_dailySongs() 326 | for index, music_info in enumerate(playlist): 327 | library_info.children.append( 328 | BrowseMedia( 329 | title=music_info.song, 330 | media_class=MediaClass.MUSIC, 331 | media_content_type=MediaType.PLAYLIST, 332 | media_content_id=f"{media_content_id}&index={index}", 333 | can_play=True, 334 | can_expand=False, 335 | thumbnail=music_info.thumbnail 336 | ) 337 | ) 338 | return library_info 339 | if media_content_id.startswith(CloudMusicRouter.my_cloud): 340 | # 我的云盘 341 | library_info = BrowseMedia( 342 | media_class=MediaClass.DIRECTORY, 343 | media_content_id=media_content_id, 344 | media_content_type=MediaType.PLAYLIST, 345 | title=title, 346 | can_play=True, 347 | can_expand=False, 348 | children=[], 349 | ) 350 | playlist = await cloud_music.async_get_cloud() 351 | for index, music_info in enumerate(playlist): 352 | library_info.children.append( 353 | BrowseMedia( 354 | title=music_info.song, 355 | media_class=MediaClass.MUSIC, 356 | media_content_type=MediaType.PLAYLIST, 357 | media_content_id=f"{media_content_id}&index={index}", 358 | can_play=True, 359 | can_expand=False, 360 | thumbnail=music_info.thumbnail 361 | ) 362 | ) 363 | return library_info 364 | if media_content_id.startswith(CloudMusicRouter.my_created): 365 | # 我创建的歌单 366 | library_info = BrowseMedia( 367 | media_class=MediaClass.DIRECTORY, 368 | media_content_id=media_content_id, 369 | media_content_type=MediaType.PLAYLIST, 370 | title=title, 371 | can_play=False, 372 | can_expand=False, 373 | children=[], 374 | ) 375 | uid = cloud_music.userinfo.get('uid') 376 | res = await cloud_music.netease_cloud_music(f'/user/playlist?uid={uid}') 377 | for item in res['playlist']: 378 | library_info.children.append( 379 | BrowseMedia( 380 | title=item.get('name'), 381 | media_class=MediaClass.DIRECTORY, 382 | media_content_type=MediaType.MUSIC, 383 | media_content_id=f"{CloudMusicRouter.playlist}?title={quote(item['name'])}&id={item['id']}", 384 | can_play=False, 385 | can_expand=True, 386 | thumbnail=cloud_music.netease_image_url(item['coverImgUrl']) 387 | ) 388 | ) 389 | return library_info 390 | if media_content_id.startswith(CloudMusicRouter.my_radio): 391 | # 收藏的电台 392 | library_info = BrowseMedia( 393 | media_class=MediaClass.DIRECTORY, 394 | media_content_id=media_content_id, 395 | media_content_type=MediaType.PLAYLIST, 396 | title=title, 397 | can_play=False, 398 | can_expand=False, 399 | children=[], 400 | ) 401 | res = await cloud_music.netease_cloud_music('/dj/sublist') 402 | for item in res['djRadios']: 403 | library_info.children.append( 404 | BrowseMedia( 405 | title=item.get('name'), 406 | media_class=MediaClass.DIRECTORY, 407 | media_content_type=MediaType.PLAYLIST, 408 | media_content_id=f"{CloudMusicRouter.radio_playlist}?title={quote(item['name'])}&id={item['id']}", 409 | can_play=False, 410 | can_expand=True, 411 | thumbnail=cloud_music.netease_image_url(item['picUrl']) 412 | ) 413 | ) 414 | return library_info 415 | if media_content_id.startswith(CloudMusicRouter.radio_playlist): 416 | # 电台音乐列表 417 | library_info = BrowseMedia( 418 | media_class=MediaClass.DIRECTORY, 419 | media_content_id=media_content_id, 420 | media_content_type=MediaType.PLAYLIST, 421 | title=title, 422 | can_play=True, 423 | can_expand=False, 424 | children=[], 425 | ) 426 | playlist = await cloud_music.async_get_djradio(id) 427 | for index, music_info in enumerate(playlist): 428 | library_info.children.append( 429 | BrowseMedia( 430 | title=music_info.song, 431 | media_class=MediaClass.MUSIC, 432 | media_content_type=MediaType.PLAYLIST, 433 | media_content_id=f"{media_content_id}&index={index}", 434 | can_play=True, 435 | can_expand=False, 436 | thumbnail=music_info.thumbnail 437 | ) 438 | ) 439 | return library_info 440 | if media_content_id.startswith(CloudMusicRouter.my_artist): 441 | # 收藏的歌手 442 | library_info = BrowseMedia( 443 | media_class=MediaClass.DIRECTORY, 444 | media_content_id=media_content_id, 445 | media_content_type=MediaType.PLAYLIST, 446 | title=title, 447 | can_play=False, 448 | can_expand=False, 449 | children=[], 450 | ) 451 | res = await cloud_music.netease_cloud_music('/artist/sublist') 452 | for item in res['data']: 453 | library_info.children.append( 454 | BrowseMedia( 455 | title=item['name'], 456 | media_class=MediaClass.ARTIST, 457 | media_content_type=MediaType.PLAYLIST, 458 | media_content_id=f"{cloudmusic_protocol}my/artist/playlist?title={quote(item['name'])}&id={item['id']}", 459 | can_play=False, 460 | can_expand=True, 461 | thumbnail=cloud_music.netease_image_url(item['picUrl']) 462 | ) 463 | ) 464 | return library_info 465 | if media_content_id.startswith(CloudMusicRouter.artist_playlist): 466 | # 歌手音乐列表 467 | library_info = BrowseMedia( 468 | media_class=MediaClass.DIRECTORY, 469 | media_content_id=media_content_id, 470 | media_content_type=MediaType.PLAYLIST, 471 | title=title, 472 | can_play=True, 473 | can_expand=False, 474 | children=[], 475 | ) 476 | playlist = await cloud_music.async_get_artists(id) 477 | for index, music_info in enumerate(playlist): 478 | library_info.children.append( 479 | BrowseMedia( 480 | title=music_info.song, 481 | media_class=MediaClass.MUSIC, 482 | media_content_type=MediaType.PLAYLIST, 483 | media_content_id=f"{media_content_id}&index={index}", 484 | can_play=True, 485 | can_expand=False, 486 | thumbnail=music_info.thumbnail 487 | ) 488 | ) 489 | return library_info 490 | if media_content_id.startswith(CloudMusicRouter.my_recommend_resource): 491 | # 每日推荐歌单 492 | library_info = BrowseMedia( 493 | media_class=MediaClass.DIRECTORY, 494 | media_content_id=media_content_id, 495 | media_content_type=MediaClass.TRACK, 496 | title=title, 497 | can_play=False, 498 | can_expand=True, 499 | children=[], 500 | ) 501 | res = await cloud_music.netease_cloud_music('/recommend/resource') 502 | for item in res['recommend']: 503 | library_info.children.append( 504 | BrowseMedia( 505 | title=item['name'], 506 | media_class=MediaClass.PLAYLIST, 507 | media_content_type=MediaType.PLAYLIST, 508 | media_content_id=f"{CloudMusicRouter.playlist}?title={quote(item['name'])}&id={item['id']}", 509 | can_play=False, 510 | can_expand=True, 511 | thumbnail=cloud_music.netease_image_url(item['picUrl']) 512 | ) 513 | ) 514 | return library_info 515 | if media_content_id.startswith(CloudMusicRouter.toplist): 516 | # 排行榜 517 | library_info = BrowseMedia( 518 | media_class=MediaClass.DIRECTORY, 519 | media_content_id=media_content_id, 520 | media_content_type=MediaClass.TRACK, 521 | title=title, 522 | can_play=False, 523 | can_expand=True, 524 | children=[], 525 | ) 526 | res = await cloud_music.netease_cloud_music('/toplist') 527 | for item in res['list']: 528 | library_info.children.append( 529 | BrowseMedia( 530 | title=item['name'], 531 | media_class=MediaClass.PLAYLIST, 532 | media_content_type=MediaType.PLAYLIST, 533 | media_content_id=f"{CloudMusicRouter.playlist}?title={quote(item['name'])}&id={item['id']}", 534 | can_play=False, 535 | can_expand=True, 536 | thumbnail=cloud_music.netease_image_url(item['coverImgUrl']) 537 | ) 538 | ) 539 | return library_info 540 | if media_content_id.startswith(CloudMusicRouter.playlist): 541 | # 歌单列表 542 | library_info = BrowseMedia( 543 | media_class=MediaClass.PLAYLIST, 544 | media_content_id=media_content_id, 545 | media_content_type=MediaType.PLAYLIST, 546 | title=title, 547 | can_play=True, 548 | can_expand=False, 549 | children=[], 550 | ) 551 | playlist = await cloud_music.async_get_playlist(id) 552 | for index, music_info in enumerate(playlist): 553 | library_info.children.append( 554 | BrowseMedia( 555 | title=f'{music_info.song} - {music_info.singer}', 556 | media_class=MediaClass.MUSIC, 557 | media_content_type=MediaType.PLAYLIST, 558 | media_content_id=f"{media_content_id}&index={index}", 559 | can_play=True, 560 | can_expand=False, 561 | thumbnail=music_info.thumbnail 562 | ) 563 | ) 564 | return library_info 565 | 566 | #================= 乐听头条 567 | if media_content_id.startswith(CloudMusicRouter.ting_homepage): 568 | children = [ 569 | { 570 | 'id': 'f3f5a6d2-5557-4555-be8e-1da281f97c22', 571 | 'title': '热点' 572 | }, 573 | { 574 | 'id': 'd8e89746-1e66-47ad-8998-1a41ada3beee', 575 | 'title': '社会' 576 | }, 577 | { 578 | 'id': '4905d954-5a85-494a-bd8c-7bc3e1563299', 579 | 'title': '国际' 580 | }, 581 | { 582 | 'id': 'fc583bff-e803-44b6-873a-50743ce7a1e9', 583 | 'title': '国内' 584 | }, 585 | { 586 | 'id': 'c7467c00-463d-4c93-b999-7bbfc86ec2d4', 587 | 'title': '体育' 588 | }, 589 | { 590 | 'id': '75564ed6-7b68-4922-b65b-859ea552422c', 591 | 'title': '娱乐' 592 | }, 593 | { 594 | 'id': 'c6bc8af2-e1cc-4877-ac26-bac1e15e0aa9', 595 | 'title': '财经' 596 | }, 597 | { 598 | 'id': 'f5cff467-2d78-4656-9b72-8e064c373874', 599 | 'title': '科技' 600 | }, 601 | { 602 | 'id': 'ba89c581-7b16-4d25-a7ce-847a04bc9d91', 603 | 'title': '军事' 604 | }, 605 | { 606 | 'id': '40f31d9d-8af8-4b28-a773-2e8837924e2e', 607 | 'title': '生活' 608 | }, 609 | { 610 | 'id': '0dee077c-4956-41d3-878f-f2ab264dc379', 611 | 'title': '教育' 612 | }, 613 | { 614 | 'id': '5c930af2-5c8a-4a12-9561-82c5e1c41e48', 615 | 'title': '汽车' 616 | }, 617 | { 618 | 'id': 'f463180f-7a49-415e-b884-c6832ba876f0', 619 | 'title': '人文' 620 | }, 621 | { 622 | 'id': '8cae0497-4878-4de9-b3fe-30518e2b6a9f', 623 | 'title': '旅游' 624 | } 625 | ] 626 | library_info = BrowseMedia( 627 | media_class=MediaClass.DIRECTORY, 628 | media_content_id=media_content_id, 629 | media_content_type=MediaType.CHANNEL, 630 | title=title, 631 | can_play=False, 632 | can_expand=False, 633 | children=[], 634 | ) 635 | for item in children: 636 | title = item['title'] 637 | library_info.children.append( 638 | BrowseMedia( 639 | title=title, 640 | media_class=CHILD_TYPE_MEDIA_CLASS[MediaType.EPISODE], 641 | media_content_type=MediaType.EPISODE, 642 | media_content_id=f'{CloudMusicRouter.ting_playlist}?title={quote(title)}&id=' + item['id'], 643 | can_play=True, 644 | can_expand=False 645 | ) 646 | ) 647 | return library_info 648 | 649 | #================= FM 650 | if media_content_id.startswith(CloudMusicRouter.fm_channel): 651 | 652 | library_info = BrowseMedia( 653 | media_class=MediaClass.DIRECTORY, 654 | media_content_id=media_content_id, 655 | media_content_type=MediaType.CHANNEL, 656 | title=title, 657 | can_play=False, 658 | can_expand=False, 659 | children=[], 660 | ) 661 | 662 | result = await http_get('https://rapi.qingting.fm/categories?type=channel') 663 | data = result['Data'] 664 | for item in data: 665 | title = item['title'] 666 | library_info.children.append( 667 | BrowseMedia( 668 | title=title, 669 | media_class=CHILD_TYPE_MEDIA_CLASS[MediaType.CHANNEL], 670 | media_content_type=MediaType.CHANNEL, 671 | media_content_id=f'{CloudMusicRouter.fm_playlist}?title={quote(title)}&id={item["id"]}', 672 | can_play=False, 673 | can_expand=True 674 | ) 675 | ) 676 | return library_info 677 | 678 | if media_content_id.startswith(CloudMusicRouter.fm_playlist): 679 | 680 | library_info = BrowseMedia( 681 | media_class=MediaClass.DIRECTORY, 682 | media_content_id=media_content_id, 683 | media_content_type=MediaType.PLAYLIST, 684 | title=title, 685 | can_play=True, 686 | can_expand=False, 687 | children=[], 688 | ) 689 | playlist = await cloud_music.async_fm_playlist(id) 690 | for index, music_info in enumerate(playlist): 691 | library_info.children.append( 692 | BrowseMedia( 693 | title=f'{music_info.song} - {music_info.singer}', 694 | media_class=MediaClass.MUSIC, 695 | media_content_type=MediaType.PLAYLIST, 696 | media_content_id=f"{media_content_id}&index={index}", 697 | can_play=True, 698 | can_expand=False, 699 | thumbnail=music_info.thumbnail 700 | ) 701 | ) 702 | return library_info 703 | 704 | #================= 喜马拉雅 705 | 706 | 707 | 708 | 709 | ''' ================== 播放音乐 ================== ''' 710 | async def async_play_media(media_player, cloud_music, media_content_id): 711 | hass = media_player.hass 712 | # 媒体库 713 | if media_source.is_media_source_id(media_content_id): 714 | play_item = await media_source.async_resolve_media( 715 | hass, media_content_id, media_player.entity_id 716 | ) 717 | return async_process_play_media_url(hass, play_item.url) 718 | 719 | # 判断是否云音乐协议 720 | if media_content_id.startswith(protocol) == False: 721 | return 722 | 723 | # 协议转换 724 | url = urlparse(media_content_id) 725 | query = parse_query(url.query) 726 | 727 | playlist = None 728 | # 通用索引 729 | playindex = int(query.get('index', 0)) 730 | # 通用ID 731 | id = query.get('id') 732 | # 通用搜索关键词 733 | keywords = query.get('kv') 734 | 735 | if media_content_id.startswith(CloudMusicRouter.local_playlist): 736 | media_player.playindex = playindex 737 | return 'index' 738 | 739 | if media_content_id.startswith(CloudMusicRouter.playlist): 740 | playlist = await cloud_music.async_get_playlist(id) 741 | elif media_content_id.startswith(CloudMusicRouter.my_daily): 742 | playlist = await cloud_music.async_get_dailySongs() 743 | elif media_content_id.startswith(CloudMusicRouter.my_ilike): 744 | playlist = await cloud_music.async_get_ilinkSongs() 745 | elif media_content_id.startswith(CloudMusicRouter.my_cloud): 746 | playlist = await cloud_music.async_get_cloud() 747 | elif media_content_id.startswith(CloudMusicRouter.artist_playlist): 748 | playlist = await cloud_music.async_get_artists(id) 749 | elif media_content_id.startswith(CloudMusicRouter.radio_playlist): 750 | playlist = await cloud_music.async_get_djradio(id) 751 | elif media_content_id.startswith(CloudMusicRouter.ting_playlist): 752 | playlist = await cloud_music.async_ting_playlist(id) 753 | elif media_content_id.startswith(CloudMusicRouter.xmly_playlist): 754 | page = query.get('page', 1) 755 | size = query.get('size', 50) 756 | asc = query.get('asc', 1) 757 | playlist = await cloud_music.async_xmly_playlist(id, page, size, asc) 758 | elif media_content_id.startswith(CloudMusicRouter.fm_playlist): 759 | page = query.get('page', 1) 760 | size = query.get('size', 200) 761 | playlist = await cloud_music.async_fm_playlist(id, page, size) 762 | elif media_content_id.startswith(CloudMusicRouter.search_name): 763 | playlist = await cloud_music.async_search_song(keywords) 764 | elif media_content_id.startswith(CloudMusicRouter.search_play): 765 | ''' 外部接口搜索 ''' 766 | result = await cloud_music.async_music_source(keywords) 767 | if result is not None: 768 | playlist = [ result ] 769 | elif media_content_id.startswith(CloudMusicRouter.play_song): 770 | playlist = await cloud_music.async_play_song(keywords) 771 | elif media_content_id.startswith(CloudMusicRouter.play_list): 772 | playlist = await cloud_music.async_play_playlist(keywords) 773 | elif media_content_id.startswith(CloudMusicRouter.play_radio): 774 | playlist = await cloud_music.async_play_radio(keywords) 775 | elif media_content_id.startswith(CloudMusicRouter.play_singer): 776 | playlist = await cloud_music.async_play_singer(keywords) 777 | elif media_content_id.startswith(CloudMusicRouter.play_xmly): 778 | playlist = await cloud_music.async_play_xmly(keywords) 779 | 780 | if playlist is not None: 781 | media_player.playindex = playindex 782 | media_player.playlist = playlist 783 | return 'playlist' 784 | 785 | 786 | # 上一曲 787 | async def async_media_previous_track(media_player, shuffle=False): 788 | if hasattr(media_player, 'playlist') == False: 789 | return 790 | 791 | playlist = media_player.playlist 792 | count = len(playlist) 793 | # 随机 794 | if shuffle: 795 | playindex = random.randint(0, count - 1) 796 | else: 797 | if count <= 1: 798 | return 799 | playindex = media_player.playindex - 1 800 | if playindex < 0: 801 | playindex = count - 1 802 | media_player.playindex = playindex 803 | await media_player.async_play_media(MediaType.MUSIC, playlist[playindex].url) 804 | 805 | # 下一曲 806 | async def async_media_next_track(media_player, shuffle=False): 807 | if hasattr(media_player, 'playlist') == False: 808 | return 809 | 810 | playindex = media_player.playindex + 1 811 | playlist = media_player.playlist 812 | count = len(playlist) 813 | # 随机 814 | if shuffle: 815 | playindex = random.randint(0, count - 1) 816 | else: 817 | if playindex >= len(playlist): 818 | playindex = 0 819 | media_player.playindex = playindex 820 | await media_player.async_play_media(MediaType.MUSIC, playlist[playindex].url) -------------------------------------------------------------------------------- /custom_components/ha_cloud_music/cloud_music.py: -------------------------------------------------------------------------------- 1 | import uuid, time, logging, os, hashlib, aiohttp, requests, base64 2 | from urllib.parse import quote 3 | from homeassistant.helpers.network import get_url 4 | from .http_api import http_get, http_cookie 5 | from .models.music_info import MusicInfo, MusicSource 6 | from homeassistant.helpers.storage import STORAGE_DIR 7 | from homeassistant.util.json import load_json 8 | from homeassistant.helpers.json import save_json 9 | from http.cookies import SimpleCookie 10 | 11 | from .browse_media import ( 12 | async_browse_media, 13 | async_play_media, 14 | async_media_previous_track, 15 | async_media_next_track 16 | ) 17 | 18 | def md5(data): 19 | return hashlib.md5(data.encode('utf-8')).hexdigest() 20 | 21 | _LOGGER = logging.getLogger(__name__) 22 | 23 | class CloudMusic(): 24 | 25 | def __init__(self, hass, url, vip_url) -> None: 26 | self.hass = hass 27 | self.api_url = url.strip('/') 28 | self.vip_url = vip_url.strip('/') 29 | 30 | # 媒体资源 31 | self.async_browse_media = async_browse_media 32 | self.async_play_media = async_play_media 33 | self.async_media_previous_track = async_media_previous_track 34 | self.async_media_next_track = async_media_next_track 35 | 36 | self.userinfo = {} 37 | # 读取用户信息 38 | self.userinfo_filepath = self.get_storage_dir('cloud_music.userinfo') 39 | if os.path.exists(self.userinfo_filepath): 40 | self.userinfo = load_json(self.userinfo_filepath) 41 | # 登录二维码 42 | self.login_qrcode = { 43 | 'key': None, 44 | 'time': None, 45 | 'url': None 46 | } 47 | 48 | def get_storage_dir(self, file_name): 49 | return os.path.abspath(f'{STORAGE_DIR}/{file_name}') 50 | 51 | def netease_image_url(self, url, size=200): 52 | return f'{url}?param={size}y{size}' 53 | 54 | # 登录 55 | async def login(self, username, password): 56 | login_url = f'{self.api_url}/login' 57 | if username.count('@') > 0: 58 | login_url = login_url + '?email=' 59 | else: 60 | login_url = login_url + '/cellphone?phone=' 61 | 62 | data = await http_cookie(login_url + f'{quote(username)}&md5_password={md5(password)}') 63 | _LOGGER.debug(data) 64 | res_data = data.get('data', {}) 65 | # 登录成功 66 | if res_data.get('code') == 200: 67 | # 写入cookie 68 | uid = res_data['account']['id'] 69 | cookie = data.get('cookie') 70 | self.userinfo = { 71 | 'uid': uid, 72 | 'cookie': cookie 73 | } 74 | save_json(self.userinfo_filepath, self.userinfo) 75 | return res_data 76 | 77 | # 二维码登录 78 | async def qrcode_login(self, cookie_str): 79 | ''' 80 | s = SimpleCookie(cookie_str) 81 | cookie = {v.key:v.value for k,v in s.items()} 82 | ''' 83 | arr = cookie_str.split(';') 84 | cookie = {} 85 | for item in arr: 86 | x = item.strip() 87 | if x == '' or x.startswith('Max-Age=') or x.startswith('Expires=') \ 88 | or x.startswith('Path=') or x.startswith('HTTPOnly'): 89 | continue 90 | kv = x.split('=') 91 | if kv[1] != '': 92 | cookie[kv[0]] = kv[1] 93 | 94 | # 设置cookie 95 | self.userinfo['cookie'] = cookie 96 | res = await self.netease_cloud_music('/user/account') 97 | self.userinfo['uid'] = res['account']['id'] 98 | save_json(self.userinfo_filepath, self.userinfo) 99 | 100 | # 退出 101 | def logout(self): 102 | self.userinfo = {} 103 | self.login_qrcode = { 104 | 'key': None, 105 | 'time': None, 106 | 'url': None 107 | } 108 | self.notification('用户凭据失效,请重新登录。如果多次失败,请联系插件作者') 109 | 110 | def notification(self, message, notification_id='ha_cloud_music'): 111 | self.hass.create_task(self.hass.services.async_call('persistent_notification', 'create', { 112 | 'title': '云音乐', 113 | 'message': message, 114 | 'notification_id': notification_id 115 | })) 116 | 117 | # 获取播放链接 118 | def get_play_url(self, id, song, singer, source): 119 | base_url = get_url(self.hass, prefer_external=True) 120 | if singer is None: 121 | singer = '' 122 | encoded_data = base64.b64encode(f'id={id}&song={quote(song)}&singer={quote(singer)}&source={source}'.encode('utf-8')) 123 | url_encoded_data = quote(encoded_data.decode('utf-8'), safe='-_') 124 | return f'{base_url}/cloud_music/url?data={url_encoded_data}' 125 | 126 | # 网易云音乐接口 127 | async def netease_cloud_music(self, url): 128 | res = await http_get(self.api_url + url, self.userinfo.get('cookie', {})) 129 | code = res.get('code') 130 | if code != 200 and code != 801: 131 | msg = res.get('msg') 132 | if msg is not None: 133 | self.notification(msg) 134 | elif code == 302: 135 | if self.userinfo.get('uid') is not None: 136 | self.notification(f'请求数据失败,账号出现异常\n\ncode: {code} \nurl: {url} \n\n这种情况一般是接口问题,和插件没有关系') 137 | return res 138 | 139 | # 获取音乐链接 140 | async def song_url(self, id): 141 | res = await self.netease_cloud_music(f'/song/url/v1?id={id}&level=standard') 142 | data = res['data'][0] 143 | url = data['url'] 144 | # 0:免费 145 | # 1:收费 146 | fee = 0 if data['freeTrialInfo'] is None else 1 147 | return url, fee 148 | 149 | # 获取云盘音乐链接 150 | async def cloud_song_url(self, id): 151 | if self.userinfo.get('uid') is not None: 152 | res = await self.netease_cloud_music(f'/user/cloud') 153 | filter_list = list(filter(lambda x:x['simpleSong']['id'] == id, res['data'])) 154 | if len(filter_list) > 0: 155 | songId = filter_list[0]['songId'] 156 | url, fee = await self.song_url(songId) 157 | return url 158 | 159 | # 获取歌单列表 160 | async def async_get_playlist(self, playlist_id): 161 | res = await self.netease_cloud_music(f'/playlist/track/all?id={playlist_id}&limit=1000') 162 | 163 | def format_playlist(item): 164 | id = item['id'] 165 | song = item['name'] 166 | singer = item['ar'][0].get('name', '') 167 | album = item['al']['name'] 168 | duration = item['dt'] 169 | url = self.get_play_url(id, song, singer, MusicSource.PLAYLIST.value) 170 | picUrl = item['al'].get('picUrl', 'https://p2.music.126.net/fL9ORyu0e777lppGU3D89A==/109951167206009876.jpg') 171 | music_info = MusicInfo(id, song, singer, album, duration, url, picUrl, MusicSource.PLAYLIST.value) 172 | return music_info 173 | 174 | return list(map(format_playlist, res['songs'])) 175 | 176 | # 获取电台列表 177 | async def async_get_djradio(self, rid): 178 | res = await self.netease_cloud_music(f'/dj/program?rid={rid}&limit=200') 179 | 180 | def format_playlist(item): 181 | mainSong = item['mainSong'] 182 | id = mainSong['id'] 183 | song = mainSong['name'] 184 | singer = mainSong['artists'][0]['name'] 185 | album = item['dj']['brand'] 186 | duration = mainSong['duration'] 187 | url = self.get_play_url(id, song, singer, MusicSource.DJRADIO.value) 188 | picUrl = item['coverUrl'] 189 | music_info = MusicInfo(id, song, singer, album, duration, url, picUrl, MusicSource.DJRADIO.value) 190 | return music_info 191 | 192 | return list(map(format_playlist, res['programs'])) 193 | 194 | # 获取歌手列表 195 | async def async_get_artists(self, aid): 196 | res = await self.netease_cloud_music(f'/artists?id={aid}') 197 | 198 | def format_playlist(item): 199 | id = item['id'] 200 | song = item['name'] 201 | singer = item['ar'][0]['name'] 202 | album = item['al']['name'] 203 | duration = item['dt'] 204 | url = self.get_play_url(id, song, singer, MusicSource.ARTISTS.value) 205 | picUrl = res['artist']['picUrl'] 206 | music_info = MusicInfo(id, song, singer, album, duration, url, picUrl, MusicSource.ARTISTS.value) 207 | return music_info 208 | 209 | return list(map(format_playlist, res['hotSongs'])) 210 | 211 | # 获取云盘音乐 212 | async def async_get_cloud(self): 213 | res = await self.netease_cloud_music('/user/cloud') 214 | def format_playlist(item): 215 | id = item['songId'] 216 | song = '' 217 | singer = '' 218 | duration = '' 219 | album = '' 220 | picUrl = 'http://p3.music.126.net/ik8RFcDiRNSV2wvmTnrcbA==/3435973851857038.jpg' 221 | 222 | simpleSong = item.get('simpleSong') 223 | if simpleSong is not None: 224 | song = simpleSong.get("name") 225 | duration = simpleSong.get("dt") 226 | al = simpleSong.get('al') 227 | if al is not None: 228 | picUrl = al.get('picUrl') 229 | album = al.get('name') 230 | ar = simpleSong.get('ar') 231 | if ar is not None and len(ar) > 0: 232 | singer = ar[0].get('name', '') 233 | 234 | if singer is None: 235 | singer = '' 236 | 237 | url = self.get_play_url(id, song, singer, MusicSource.CLOUD.value) 238 | music_info = MusicInfo(id, song, singer, album, duration, url, picUrl, MusicSource.CLOUD.value) 239 | return music_info 240 | 241 | return list(map(format_playlist, res['data'])) 242 | 243 | # 获取每日推荐歌曲 244 | async def async_get_dailySongs(self): 245 | res = await self.netease_cloud_music('/recommend/songs') 246 | def format_playlist(item): 247 | id = item['id'] 248 | song = item['name'] 249 | singer = item['ar'][0]['name'] 250 | album = item['al']['name'] 251 | duration = item['dt'] 252 | url = self.get_play_url(id, song, singer, MusicSource.PLAYLIST.value) 253 | picUrl = item['al'].get('picUrl', 'https://p2.music.126.net/fL9ORyu0e777lppGU3D89A==/109951167206009876.jpg') 254 | music_info = MusicInfo(id, song, singer, album, duration, url, picUrl, MusicSource.PLAYLIST.value) 255 | return music_info 256 | 257 | return list(map(format_playlist, res['data']['dailySongs'])) 258 | 259 | # 获取我喜欢的音乐 260 | async def async_get_ilinkSongs(self): 261 | uid = self.userinfo.get('uid') 262 | if uid is not None: 263 | res = await self.netease_cloud_music(f'/user/playlist?uid={uid}') 264 | return await self.async_get_playlist(res['playlist'][0]['id']) 265 | 266 | # 乐听头条 267 | async def async_ting_playlist(self, catalog_id): 268 | 269 | now = int(time.time()) 270 | if hasattr(self, 'letingtoutiao') == False: 271 | uid = uuid.uuid4().hex 272 | self.letingtoutiao = { 273 | 'time': now, 274 | 'headers': {"uid": uid, "logid": uid, "token": ''} 275 | } 276 | 277 | headers = self.letingtoutiao['headers'] 278 | async with aiohttp.ClientSession() as session: 279 | # 获取token 280 | if headers['token'] == '' or now > self.letingtoutiao['time']: 281 | async with session.get('https://app.leting.io/app/auth?uid=' + 282 | uid + '&appid=a435325b8662a4098f615a7d067fe7b8&ts=1628297581496&sign=4149682cf40c2bf2efcec8155c48b627&v=v9&channel=huawei', 283 | headers=headers) as res: 284 | r = await res.json() 285 | token = r['data']['token'] 286 | headers['token'] = token 287 | # 保存时间(10分钟重新获取token) 288 | self.letingtoutiao['time'] = now + 60 * 10 289 | self.letingtoutiao['headers']['token'] = token 290 | 291 | # 获取播放列表 292 | async with session.get('https://app.leting.io/app/url/channel?catalog_id=' + 293 | catalog_id + '&size=100&distinct=1&v=v8&channel=xiaomi', headers=headers) as res: 294 | r = await res.json() 295 | 296 | def format_playlist(item): 297 | id = item['sid'] 298 | song = item['title'] 299 | singer = item['source'] 300 | album = item['catalog_name'] 301 | duration = item['duration'] 302 | url = item['audio'] 303 | picUrl = item['source_icon'] 304 | music_info = MusicInfo(id, song, singer, album, duration, url, picUrl, MusicSource.URL.value) 305 | return music_info 306 | 307 | return list(map(format_playlist, r['data']['data'])) 308 | 309 | # 喜马拉雅 310 | async def async_xmly_playlist(self, id, page=1, size=50, asc=1): 311 | if page < 1: 312 | page = 1 313 | isAsc = 'true' if asc != 1 else 'false' 314 | url = f'https://mobile.ximalaya.com/mobile/v1/album/track?albumId={id}&isAsc={isAsc}&pageId={page}&pageSize={size}' 315 | result = await http_get(url) 316 | if result['ret'] == 0: 317 | _list = result['data']['list'] 318 | _totalCount = result['data']['totalCount'] 319 | if len(_list) > 0: 320 | # 获取专辑名称 321 | trackId = _list[0]['trackId'] 322 | url = f'http://mobile.ximalaya.com/v1/track/baseInfo?trackId={trackId}' 323 | album_result = await http_get(url) 324 | # 格式化列表 325 | def format_playlist(item): 326 | id = item['trackId'] 327 | song = item['title'] 328 | singer = item['nickname'] 329 | album = album_result['albumTitle'] 330 | duration = item['duration'] 331 | url = item['playUrl64'] 332 | picUrl = item['coverLarge'] 333 | music_info = MusicInfo(id, song, singer, album, duration, url, picUrl, MusicSource.XIMALAYA.value) 334 | return music_info 335 | 336 | return list(map(format_playlist, _list)) 337 | 338 | # FM 339 | async def async_fm_playlist(self, id, page=1, size=100): 340 | result = await http_get(f'https://rapi.qingting.fm/categories/{id}/channels?with_total=true&page={page}&pagesize={size}') 341 | data = result['Data'] 342 | # 格式化列表 343 | def format_playlist(item): 344 | id = item['content_id'] 345 | song = item['title'] 346 | album = item['categories'][0]['title'] 347 | singer = album 348 | duration = item['audience_count'] 349 | url = f'http://lhttp.qingting.fm/live/{id}/64k.mp3' 350 | picUrl = item['cover'] 351 | 352 | nowplaying = item.get('nowplaying') 353 | if nowplaying is not None: 354 | singer = nowplaying.get('title', song) 355 | 356 | music_info = MusicInfo(id, song, singer, album, duration, url, picUrl, MusicSource.URL.value) 357 | return music_info 358 | 359 | return list(map(format_playlist, data['items'])) 360 | 361 | # 搜索音乐播放 362 | async def async_play_song(self, name): 363 | 364 | if '周杰伦' in name: 365 | result = await self.async_music_source(name) 366 | if result is not None: 367 | return [ result ] 368 | 369 | res = await self.netease_cloud_music(f'/cloudsearch?limit=1&keywords={name}') 370 | if res['code'] == 200: 371 | 372 | songs = res['result']['songs'] 373 | if len(songs) > 0: 374 | item = songs[0] 375 | 376 | al = item['al'] 377 | ar = item['ar'][0] 378 | 379 | id = item['id'] 380 | song = item['name'] 381 | album = al.get('name') 382 | singer = ar.get('name') 383 | picUrl = self.netease_image_url(al.get('picUrl')) 384 | duration = item.get('dt') 385 | 386 | url = self.get_play_url(id, song, singer, MusicSource.PLAYLIST.value) 387 | 388 | music_info = MusicInfo(id, song, singer, album, duration, url, picUrl, MusicSource.URL.value) 389 | return [ music_info ] 390 | 391 | # 歌手 392 | async def async_play_singer(self, keywords): 393 | if keywords == '周杰伦': 394 | return await self.async_get_playlist(422947217) 395 | 396 | res = await self.netease_cloud_music(f'/search?limit=1&keywords={keywords}&type=100') 397 | if res['code'] == 200: 398 | playlists = res['result']['artists'] 399 | return await self.async_get_artists(playlists[0]['id']) 400 | 401 | # 歌单 402 | async def async_play_playlist(self, keywords): 403 | res = await self.netease_cloud_music(f'/search?limit=1&keywords={keywords}&type=1000') 404 | if res['code'] == 200: 405 | playlists = res['result']['playlists'] 406 | return await self.async_get_playlist(playlists[0]['id']) 407 | 408 | # 电台 409 | async def async_play_radio(self, keywords): 410 | res = await self.netease_cloud_music(f'/search?limit=1&keywords={keywords}&type=1009') 411 | if res['code'] == 200: 412 | playlists = res['result']['djRadios'] 413 | return await self.async_get_djradio(playlists[0]['id']) 414 | 415 | # 喜马拉雅专辑 416 | async def async_play_xmly(self, keywords): 417 | _list = await self.async_search_xmly(keywords) 418 | if len(_list) > 0: 419 | return await self.async_xmly_playlist(_list[0]['id'], 1, 100) 420 | 421 | # 音乐搜索 422 | async def async_search_song(self, name): 423 | ha_music_source = self.hass.data.get('ha_music_source') 424 | if ha_music_source is not None: 425 | music_list = await ha_music_source.async_search_all(name) 426 | # 格式化列表 427 | def format_playlist(item): 428 | id = item['id'] 429 | song = item['song'] 430 | album = item['album'] 431 | singer = item['singer'] 432 | duration = 0 433 | url = item['url'] 434 | picUrl = self.netease_image_url('http://p1.music.126.net/6nuYK0CVBFE3aslWtsmCkQ==/109951165472872790.jpg') 435 | 436 | music_info = MusicInfo(id, song, singer, album, duration, url, picUrl, MusicSource.URL.value) 437 | return music_info 438 | 439 | return list(map(format_playlist, music_list)) 440 | 441 | # 电台 442 | async def async_search_djradio(self, name): 443 | _list = [] 444 | res = await self.netease_cloud_music(f'/search?keywords={name}&type=1009') 445 | if res['code'] == 200: 446 | _list = list(map(lambda item: { 447 | "id": item['id'], 448 | "name": item['name'], 449 | "cover": item['picUrl'], 450 | "intro": item['dj']['signature'], 451 | "creator": item['dj']['nickname'], 452 | "source": MusicSource.DJRADIO.value 453 | }, res['result']['djRadios'])) 454 | return _list 455 | 456 | # 喜马拉雅 457 | async def async_search_xmly(self, name): 458 | _list = [] 459 | url = f'https://m.ximalaya.com/m-revision/page/search?kw={name}&core=all&page=1&rows=5' 460 | res = await http_get(url) 461 | if res['ret'] == 0: 462 | result = res['data']['albumViews'] 463 | if result['total'] > 0: 464 | _list = list(map(lambda item: { 465 | "id": item['albumInfo']['id'], 466 | "name": item['albumInfo']['title'], 467 | "cover": item['albumInfo'].get('cover_path', 'https://imagev2.xmcdn.com/group79/M02/77/6C/wKgPEF6masWTCICAAAA7qPQDtNY545.jpg!strip=1&quality=7&magick=webp&op_type=5&upload_type=cover&name=web_large&device_type=ios'), 468 | "intro": item['albumInfo']['intro'], 469 | "creator": item['albumInfo']['nickname'], 470 | "source": MusicSource.XIMALAYA.value 471 | }, result['albums'])) 472 | return _list 473 | 474 | # 歌单 475 | async def async_search_playlist(self, name): 476 | _list = [] 477 | res = await self.netease_cloud_music(f'/search?keywords={name}&type=1000') 478 | if res['code'] == 200: 479 | _list = list(map(lambda item: { 480 | "id": item['id'], 481 | "name": item['name'], 482 | "cover": item['coverImgUrl'], 483 | "intro": item['description'], 484 | "creator": item['creator']['nickname'], 485 | "source": MusicSource.PLAYLIST.value 486 | }, res['result']['playlists'])) 487 | return _list 488 | 489 | async def async_music_source(self, song, singer=''): 490 | keyword = f'{singer} {song}'.strip() 491 | _LOGGER.debug(keyword) 492 | try: 493 | res = await http_get(f'{self.vip_url}?k={keyword}') 494 | album = res.get('album', '') 495 | songId = res['id'] 496 | song = res['song'] 497 | singer = res['singer'] 498 | audio_url = res['url'] 499 | pic = res['cover'] 500 | return MusicInfo(songId, song, singer, album, 0, audio_url, pic, MusicSource.URL.value) 501 | except Exception as ex: 502 | print(ex) -------------------------------------------------------------------------------- /custom_components/ha_cloud_music/config_flow.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from typing import Any 4 | import voluptuous as vol 5 | 6 | from homeassistant.config_entries import ConfigFlow, OptionsFlow, ConfigEntry 7 | from homeassistant.data_entry_flow import FlowResult 8 | from homeassistant.const import CONF_URL, CONF_USERNAME, CONF_PASSWORD 9 | from homeassistant.helpers.storage import STORAGE_DIR 10 | from urllib.parse import quote 11 | from homeassistant.core import callback 12 | from homeassistant.helpers.selector import selector 13 | 14 | from .manifest import manifest 15 | from .http_api import fetch_data 16 | 17 | DOMAIN = manifest.domain 18 | 19 | class SimpleConfigFlow(ConfigFlow, domain=DOMAIN): 20 | 21 | VERSION = 3 22 | 23 | async def async_step_user( 24 | self, user_input: dict[str, Any] | None = None 25 | ) -> FlowResult: 26 | """Handle the initial step.""" 27 | if self._async_current_entries(): 28 | return self.async_abort(reason="single_instance_allowed") 29 | errors = {} 30 | if user_input is not None: 31 | url = user_input.get(CONF_URL).strip('/') 32 | # 检查接口是否可用 33 | try: 34 | res = await fetch_data(f'{url}/login/status') 35 | if res['data']['code'] == 200: 36 | user_input[CONF_URL] = url 37 | return self.async_create_entry(title=DOMAIN, data=user_input) 38 | except Exception as ex: 39 | errors = { 'base': 'api_failed' } 40 | else: 41 | user_input = {} 42 | 43 | DATA_SCHEMA = vol.Schema({ 44 | vol.Required(CONF_URL, default=user_input.get(CONF_URL)): str 45 | }) 46 | return self.async_show_form(step_id="user", data_schema=DATA_SCHEMA, errors=errors) 47 | 48 | @staticmethod 49 | @callback 50 | def async_get_options_flow(entry: ConfigEntry): 51 | return OptionsFlowHandler(entry) 52 | 53 | class OptionsFlowHandler(OptionsFlow): 54 | def __init__(self, config_entry: ConfigEntry): 55 | self.config_entry = config_entry 56 | 57 | async def async_step_init(self, user_input=None): 58 | return await self.async_step_user(user_input) 59 | 60 | async def async_step_user(self, user_input=None): 61 | options = self.config_entry.options 62 | errors = {} 63 | if user_input is not None: 64 | return self.async_create_entry(title='', data=user_input) 65 | 66 | media_states = self.hass.states.async_all('media_player') 67 | media_entities = [] 68 | 69 | for state in media_states: 70 | friendly_name = state.attributes.get('friendly_name') 71 | platform = state.attributes.get('platform') 72 | entity_id = state.entity_id 73 | value = f'{friendly_name}({entity_id})' 74 | 75 | if platform != 'cloud_music' and state.state != 'unavailable': 76 | media_entities.append({ 'label': value, 'value': entity_id }) 77 | 78 | DATA_SCHEMA = vol.Schema({ 79 | vol.Optional('media_player', default=options.get('media_player')): selector({ 80 | "select": { 81 | "options": media_entities, 82 | "multiple": True 83 | } 84 | }), 85 | vol.Optional(CONF_URL, default=options.get(CONF_URL)): str 86 | }) 87 | return self.async_show_form(step_id="user", data_schema=DATA_SCHEMA, errors=errors) -------------------------------------------------------------------------------- /custom_components/ha_cloud_music/const.py: -------------------------------------------------------------------------------- 1 | PLATFORMS = ["media_player"] -------------------------------------------------------------------------------- /custom_components/ha_cloud_music/http.py: -------------------------------------------------------------------------------- 1 | import base64 2 | import requests 3 | from urllib.parse import parse_qsl, quote 4 | from homeassistant.components.http import HomeAssistantView 5 | from aiohttp import web 6 | from .models.music_info import MusicSource 7 | from .manifest import manifest 8 | 9 | DOMAIN = manifest.domain 10 | 11 | class HttpView(HomeAssistantView): 12 | 13 | url = "/cloud_music/url" 14 | name = f"cloud_music:url" 15 | requires_auth = False 16 | 17 | play_key = None 18 | play_url = None 19 | 20 | async def get(self, request): 21 | 22 | hass = request.app["hass"] 23 | cloud_music = hass.data['cloud_music'] 24 | 25 | query = {} 26 | data = request.query.get('data') 27 | if data is not None: 28 | decoded_data = base64.b64decode(data).decode('utf-8') 29 | qsl = parse_qsl(decoded_data) 30 | for q in qsl: 31 | query[q[0]] = q[1] 32 | 33 | id = query.get('id') 34 | source = query.get('source') 35 | song = query.get('song') 36 | singer = query.get('singer') 37 | 38 | not_found_tips = quote(f'当前没有找到编号是{id},歌名为{song},作者是{singer}的播放链接') 39 | play_url = f'http://fanyi.baidu.com/gettts?lan=zh&text={not_found_tips}&spd=5&source=web' 40 | 41 | # 缓存KEY 42 | play_key = f'{id}{song}{singer}{source}' 43 | if self.play_key == play_key: 44 | return web.HTTPFound(self.play_url) 45 | 46 | source = int(source) 47 | if source == MusicSource.PLAYLIST.value \ 48 | or source == MusicSource.ARTISTS.value \ 49 | or source == MusicSource.DJRADIO.value \ 50 | or source == MusicSource.CLOUD.value: 51 | # 获取播放链接 52 | url, fee = await cloud_music.song_url(id) 53 | if url is not None: 54 | # 收费音乐 55 | if fee == 1: 56 | url = await hass.async_add_executor_job(self.getVipMusic, id) 57 | if url is None or url == '': 58 | result = await cloud_music.async_music_source(song, singer) 59 | if result is not None: 60 | url = result.url 61 | 62 | play_url = url 63 | else: 64 | # 从云盘里获取 65 | url = await cloud_music.cloud_song_url(id) 66 | if url is not None: 67 | play_url = url 68 | else: 69 | result = await cloud_music.async_music_source(song, singer) 70 | if result is not None: 71 | play_url = result.url 72 | 73 | self.play_key = play_key 74 | self.play_url = play_url 75 | # 重定向到可播放链接 76 | return web.HTTPFound(play_url) 77 | 78 | # VIP音乐资源 79 | def getVipMusic(self, id): 80 | try: 81 | res = requests.post('https://music.dogged.cn/api.php', data={ 82 | 'types': 'url', 83 | 'id': id, 84 | 'source': 'netease' 85 | }) 86 | data = res.json() 87 | return data.get('url') 88 | except Exception as ex: 89 | pass 90 | -------------------------------------------------------------------------------- /custom_components/ha_cloud_music/http_api.py: -------------------------------------------------------------------------------- 1 | import json, aiohttp 2 | from urllib.parse import urlparse 3 | 4 | # 全局请求头 5 | HEADERS = { 6 | 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/105.0.0.0 Safari/537.36 Edg/105.0.1343.50' 7 | } 8 | 9 | # 获取cookie 10 | async def http_cookie(url): 11 | COOKIES = {'os': 'osx'} 12 | jar = aiohttp.CookieJar(unsafe=True) 13 | location = urlparse(url) 14 | location_orgin = f'{location.scheme}://{location.netloc}' 15 | async with aiohttp.ClientSession(headers=HEADERS, cookies=COOKIES, cookie_jar=jar) as session: 16 | async with session.get(url) as resp: 17 | cookies = session.cookie_jar.filter_cookies(location_orgin) 18 | for key, cookie in cookies.items(): 19 | COOKIES[key] = cookie.value 20 | result = await resp.json() 21 | return { 22 | 'cookie': COOKIES, 23 | 'data': result 24 | } 25 | 26 | async def http_get(url, COOKIES={}): 27 | headers = {'Referer': url, **HEADERS} 28 | jar = aiohttp.CookieJar(unsafe=True) 29 | async with aiohttp.ClientSession(headers=headers, cookies=COOKIES, cookie_jar=jar) as session: 30 | async with session.get(url) as resp: 31 | # 喜马拉雅返回的是文本内容 32 | if 'https://mobile.ximalaya.com/mobile/' in url: 33 | result = json.loads(await resp.text()) 34 | else: 35 | result = await resp.json() 36 | return result 37 | 38 | async def http_code(url): 39 | async with aiohttp.ClientSession() as session: 40 | async with session.get(url) as response: 41 | return response.status 42 | 43 | async def fetch_data(url): 44 | timeout = aiohttp.ClientTimeout(total=5) 45 | async with aiohttp.ClientSession(timeout=timeout) as session: 46 | async with session.get(url) as response: 47 | return await response.json() -------------------------------------------------------------------------------- /custom_components/ha_cloud_music/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "domain": "ha_cloud_music", 3 | "name": "\u4E91\u97F3\u4E50", 4 | "version": "2025.9.24", 5 | "config_flow": true, 6 | "documentation": "https://github.com/shaonianzhentan/ha_cloud_music", 7 | "requirements": [ 8 | "beautifulsoup4>=4.11.1", 9 | "lxml>=4.9.1" 10 | ], 11 | "codeowners": [ 12 | "@shaonianzhentan" 13 | ], 14 | "iot_class": "cloud_polling" 15 | } -------------------------------------------------------------------------------- /custom_components/ha_cloud_music/manifest.py: -------------------------------------------------------------------------------- 1 | import os 2 | from homeassistant.util.json import load_json 3 | 4 | def custom_components_path(file_path): 5 | return os.path.abspath('./custom_components/' + file_path) 6 | 7 | class Manifest(): 8 | 9 | def __init__(self, domain): 10 | self.domain = domain 11 | self.manifest_path = custom_components_path(f'{domain}/manifest.json') 12 | self.update() 13 | 14 | @property 15 | def remote_url(self): 16 | return 'https://gitee.com/shaonianzhentan/ha_cloud_music/raw/dev/custom_components/ha_cloud_music/manifest.json' 17 | 18 | def update(self): 19 | data = load_json(self.manifest_path, {}) 20 | self.domain = data.get('domain') 21 | self.name = data.get('name') 22 | self.version = data.get('version') 23 | self.documentation = data.get('documentation') 24 | 25 | manifest = Manifest('ha_cloud_music') -------------------------------------------------------------------------------- /custom_components/ha_cloud_music/media_player.py: -------------------------------------------------------------------------------- 1 | import logging, time, datetime 2 | 3 | from homeassistant.core import HomeAssistant 4 | from homeassistant.helpers.entity_platform import AddEntitiesCallback 5 | from homeassistant.config_entries import ConfigEntry 6 | from homeassistant.helpers.event import async_track_time_interval 7 | from homeassistant.components.media_player import MediaPlayerEntity, MediaPlayerDeviceClass, MediaPlayerEntityFeature 8 | from homeassistant.const import ( 9 | CONF_TOKEN, 10 | CONF_URL, 11 | CONF_NAME, 12 | STATE_OFF, 13 | STATE_ON, 14 | STATE_PLAYING, 15 | STATE_PAUSED, 16 | STATE_IDLE, 17 | STATE_UNAVAILABLE 18 | ) 19 | 20 | from .manifest import manifest 21 | DOMAIN = manifest.domain 22 | 23 | _LOGGER = logging.getLogger(__name__) 24 | 25 | SUPPORT_FEATURES = MediaPlayerEntityFeature.VOLUME_STEP | MediaPlayerEntityFeature.VOLUME_MUTE | MediaPlayerEntityFeature.VOLUME_SET | \ 26 | MediaPlayerEntityFeature.PLAY_MEDIA | MediaPlayerEntityFeature.PLAY | MediaPlayerEntityFeature.PAUSE | MediaPlayerEntityFeature.PREVIOUS_TRACK | MediaPlayerEntityFeature.NEXT_TRACK | \ 27 | MediaPlayerEntityFeature.BROWSE_MEDIA | MediaPlayerEntityFeature.SEEK | MediaPlayerEntityFeature.CLEAR_PLAYLIST | MediaPlayerEntityFeature.SHUFFLE_SET | MediaPlayerEntityFeature.REPEAT_SET 28 | 29 | # 定时器时间 30 | TIME_BETWEEN_UPDATES = datetime.timedelta(seconds=2) 31 | UNSUB_INTERVAL = None 32 | 33 | async def async_setup_entry( 34 | hass: HomeAssistant, 35 | entry: ConfigEntry, 36 | async_add_entities: AddEntitiesCallback, 37 | ) -> None: 38 | 39 | entities = [] 40 | for source_media_player in entry.options.get('media_player', []): 41 | entities.append(CloudMusicMediaPlayer(hass, source_media_player)) 42 | 43 | def media_player_interval(now): 44 | for mp in entities: 45 | mp.interval(now) 46 | 47 | # 开启定时器 48 | global UNSUB_INTERVAL 49 | if UNSUB_INTERVAL is not None: 50 | UNSUB_INTERVAL() 51 | UNSUB_INTERVAL = async_track_time_interval(hass, media_player_interval, TIME_BETWEEN_UPDATES) 52 | 53 | async_add_entities(entities, True) 54 | 55 | class CloudMusicMediaPlayer(MediaPlayerEntity): 56 | 57 | def __init__(self, hass, source_media_player): 58 | self.hass = hass 59 | self._attributes = { 60 | 'platform': 'cloud_music' 61 | } 62 | # fixed attribute 63 | self._attr_media_image_remotely_accessible = True 64 | self._attr_device_class = MediaPlayerDeviceClass.TV.value 65 | self._attr_supported_features = SUPPORT_FEATURES 66 | 67 | # default attribute 68 | self.source_media_player = source_media_player 69 | self._attr_name = f'{manifest.name} {source_media_player.split(".")[1]}' 70 | self._attr_unique_id = f'{manifest.domain}{source_media_player}' 71 | self._attr_state = STATE_ON 72 | self._attr_volume_level = 1 73 | self._attr_repeat = 'all' 74 | self._attr_shuffle = False 75 | 76 | self.cloud_music = hass.data['cloud_music'] 77 | self.before_state = None 78 | self.current_state = None 79 | 80 | def interval(self, now): 81 | # 暂停时不更新 82 | if self._attr_state == STATE_PAUSED: 83 | return 84 | 85 | media_player = self.media_player 86 | if media_player is not None: 87 | attrs = media_player.attributes 88 | self._attr_media_position = attrs.get('media_position', 0) 89 | self._attr_media_duration = attrs.get('media_duration', 0) 90 | self._attr_media_position_updated_at = datetime.datetime.now() 91 | # 判断是否下一曲 92 | if self.before_state is not None: 93 | # 判断音乐总时长 94 | if self.before_state['media_duration'] > 0 and self.before_state['media_duration'] - self.before_state['media_duration'] <= 5: 95 | # 判断源音乐播放器状态 96 | if self.before_state['state'] == STATE_PLAYING and self.current_state == STATE_IDLE: 97 | self.hass.create_task(self.async_media_next_track()) 98 | self.before_state = None 99 | return 100 | 101 | # 源播放器空闲 & 当前正在播放 102 | if self.before_state['media_duration'] == 0 and self.before_state['media_position'] == 0 and self.current_state == STATE_IDLE \ 103 | and self._attr_media_duration == 0 and self._attr_media_position == 0 and self._attr_state == STATE_PLAYING: 104 | self.hass.create_task(self.async_media_next_track()) 105 | self.before_state = None 106 | return 107 | 108 | self.before_state = { 109 | 'media_position': int(self._attr_media_position), 110 | 'media_duration': int(self._attr_media_duration), 111 | 'state': self.current_state 112 | } 113 | self.current_state = media_player.state 114 | 115 | if hasattr(self, 'playlist'): 116 | music_info = self.playlist[self.playindex] 117 | self._attr_app_name = music_info.singer 118 | self._attr_media_image_url = music_info.thumbnail 119 | self._attr_media_album_name = music_info.album 120 | self._attr_media_title = music_info.song 121 | self._attr_media_artist = music_info.singer 122 | 123 | @property 124 | def media_player(self): 125 | if self.entity_id is not None and self.source_media_player is not None: 126 | return self.hass.states.get(self.source_media_player) 127 | 128 | @property 129 | def device_info(self): 130 | return { 131 | 'identifiers': { 132 | (DOMAIN, manifest.documentation) 133 | }, 134 | 'name': self.name, 135 | 'manufacturer': 'shaonianzhentan', 136 | 'model': 'CloudMusic', 137 | 'sw_version': manifest.version 138 | } 139 | 140 | @property 141 | def extra_state_attributes(self): 142 | return self._attributes 143 | 144 | async def async_browse_media(self, media_content_type=None, media_content_id=None): 145 | return await self.cloud_music.async_browse_media(self, media_content_type, media_content_id) 146 | 147 | async def async_volume_up(self): 148 | await self.async_call('volume_up') 149 | 150 | async def async_volume_down(self): 151 | await self.async_call('volume_down') 152 | 153 | async def async_mute_volume(self, mute): 154 | self._attr_is_volume_muted = mute 155 | await self.async_call('mute_volume', { 'is_volume_muted': mute }) 156 | 157 | async def async_set_volume_level(self, volume: float): 158 | self._attr_volume_level = volume 159 | await self.async_call('volume_set', { 'volume_level': volume }) 160 | 161 | async def async_play_media(self, media_type, media_id, **kwargs): 162 | 163 | self._attr_state = STATE_PAUSED 164 | 165 | media_content_id = media_id 166 | result = await self.cloud_music.async_play_media(self, self.cloud_music, media_id) 167 | if result is not None: 168 | if result == 'index': 169 | # 播放当前列表指定项 170 | media_content_id = self.playlist[self.playindex].url 171 | elif result.startswith('http'): 172 | # HTTP播放链接 173 | media_content_id = result 174 | else: 175 | # 添加播放列表到播放器 176 | media_content_id = self.playlist[self.playindex].url 177 | 178 | self._attr_media_content_id = media_content_id 179 | await self.async_call('play_media', { 180 | 'media_content_id': media_content_id, 181 | 'media_content_type': 'music' 182 | }) 183 | self._attr_state = STATE_PLAYING 184 | 185 | self.before_state = None 186 | 187 | async def async_media_play(self): 188 | self._attr_state = STATE_PLAYING 189 | await self.async_call('media_play') 190 | 191 | async def async_media_pause(self): 192 | self._attr_state = STATE_PAUSED 193 | await self.async_call('media_pause') 194 | 195 | async def async_set_repeat(self, repeat): 196 | self._attr_repeat = repeat 197 | 198 | async def async_set_shuffle(self, shuffle): 199 | self._attr_shuffle = shuffle 200 | 201 | async def async_media_next_track(self): 202 | self._attr_state = STATE_PAUSED 203 | await self.cloud_music.async_media_next_track(self, self._attr_shuffle) 204 | 205 | async def async_media_previous_track(self): 206 | self._attr_state = STATE_PAUSED 207 | await self.cloud_music.async_media_previous_track(self, self._attr_shuffle) 208 | 209 | async def async_media_seek(self, position): 210 | await self.async_call('media_seek', { 'seek_position': position }) 211 | 212 | async def async_media_stop(self): 213 | await self.async_call('media_stop') 214 | 215 | # 更新属性 216 | async def async_update(self): 217 | pass 218 | 219 | # 调用服务 220 | async def async_call(self, service, service_data={}): 221 | media_player = self.media_player 222 | if media_player is not None: 223 | service_data.update({ 'entity_id': media_player.entity_id }) 224 | await self.hass.services.async_call('media_player', service, service_data) -------------------------------------------------------------------------------- /custom_components/ha_cloud_music/models/music_info.py: -------------------------------------------------------------------------------- 1 | import enum 2 | 3 | class MusicSource(enum.Enum): 4 | 5 | URL = 1 6 | XIMALAYA = 2 7 | PLAYLIST = 3 8 | DJRADIO = 4 9 | ARTISTS = 5 10 | CLOUD = 6 11 | 12 | class MusicInfo: 13 | 14 | def __init__(self, id, song, singer, album, duration, url, picUrl, source) -> None: 15 | self._id = id 16 | self._song = song 17 | self._singer = singer 18 | self._duration = duration 19 | self._album = album 20 | self._url = url 21 | self._picUrl = picUrl 22 | self._source = source 23 | 24 | @property 25 | def id(self): 26 | return self._id 27 | 28 | @property 29 | def song(self): 30 | return self._song 31 | 32 | @property 33 | def singer(self): 34 | return self._singer 35 | 36 | @property 37 | def duration(self): 38 | return self._duration 39 | 40 | @property 41 | def album(self): 42 | return self._album 43 | 44 | @property 45 | def url(self): 46 | return self._url 47 | 48 | @property 49 | def picUrl(self): 50 | return self._picUrl 51 | 52 | @property 53 | def thumbnail(self): 54 | return self._picUrl + '?param=200y200' 55 | 56 | @property 57 | def source(self) -> MusicSource: 58 | return self._source 59 | 60 | def to_dict(self): 61 | return { 62 | 'id': self.id, 63 | 'song': self.song, 64 | 'singer': self.singer, 65 | 'album': self.album, 66 | 'duration': self.duration, 67 | 'url': self.url, 68 | 'picUrl': self.picUrl, 69 | 'source': self.source 70 | } -------------------------------------------------------------------------------- /custom_components/ha_cloud_music/translations/en.json: -------------------------------------------------------------------------------- 1 | { 2 | "title": "云音乐", 3 | "config": { 4 | "abort": { 5 | "single_instance_allowed": "仅允许单个配置" 6 | }, 7 | "step": { 8 | "user": { 9 | "title": "接口配置", 10 | "description": "为防止你的账号密码泄露,建议自行部署API接口服务 \n免费部署文档:https://neteasecloudmusicapi.vercel.app \n实在是搞不来,也可以付费使用由我部署维护持续更新的接口服务😊", 11 | "data": { 12 | "url": "网易云音乐API" 13 | } 14 | } 15 | }, 16 | "error": { 17 | "login_failed": "登录失败", 18 | "api_failed": "接口地址不正确" 19 | } 20 | }, 21 | "options": { 22 | "step": { 23 | "user": { 24 | "title": "配置", 25 | "description": "关联的媒体播放器必须支持自定义音乐资源,可通过TTS插件自行测试是否可用", 26 | "data": { 27 | "media_player": "关联媒体播放器", 28 | "url": "第三方音乐接口" 29 | } 30 | } 31 | }, 32 | "error": { 33 | "login_failed": "登录失败" 34 | } 35 | } 36 | } -------------------------------------------------------------------------------- /custom_components/ha_cloud_music/utils.py: -------------------------------------------------------------------------------- 1 | from urllib.parse import parse_qsl, quote 2 | 3 | def parse_query(url_query): 4 | query = parse_qsl(url_query) 5 | data = {} 6 | for item in query: 7 | data[item[0]] = item[1] 8 | return data -------------------------------------------------------------------------------- /hacs.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "云音乐", 3 | "country": "CN", 4 | "render_readme": true, 5 | "domains": [ 6 | "media_player" 7 | ] 8 | } --------------------------------------------------------------------------------