├── .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 | [](https://www.home-assistant.io/)
6 | [](https://github.com/hacs/integration)
7 | 
8 |
9 | ---
10 | ## 历史旧版本项目,请点击链接访问安装
11 | https://github.com/shaonianzhentan/cloud_music
12 |
13 | ---
14 |
15 | ## 安装
16 |
17 | 安装完成重启HA,刷新一下页面,在集成里搜索`云音乐`
18 |
19 | [](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 |
--------------------------------------------------------------------------------
/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 | }
--------------------------------------------------------------------------------