@ohmykreee/dsh-remote-control 0.0.0-stage → 1.0.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (373) hide show
  1. package/AGENTS.md +556 -0
  2. package/INSTALLATION.md +310 -0
  3. package/LICENSE +21 -0
  4. package/README.md +195 -2
  5. package/client.js +3 -0
  6. package/components.json +20 -0
  7. package/cordis.patch.yml +3 -0
  8. package/docs/README.en.md +196 -0
  9. package/docs/screenshots/en/overview.png +0 -0
  10. package/docs/screenshots/en/question.png +0 -0
  11. package/docs/screenshots/zh/overview.png +0 -0
  12. package/docs/screenshots/zh/question.png +0 -0
  13. package/icon.svg +24 -0
  14. package/index.js +2 -0
  15. package/lib/account.js +1 -0
  16. package/lib/app-mapping.js +1 -0
  17. package/lib/i18n.js +1 -0
  18. package/lib/media-enrich.js +1 -0
  19. package/lib/notifications.js +1 -0
  20. package/lib/performance.js +1 -0
  21. package/lib/platform/index.js +1 -0
  22. package/lib/probe-adapter.js +1 -0
  23. package/lib/probe-daemon.js +1 -0
  24. package/lib/probe.js +1 -0
  25. package/lib/qr.js +1 -0
  26. package/lib/runtime.js +1 -0
  27. package/lib/service.js +1 -0
  28. package/lib/sessions.js +1 -0
  29. package/lib/settings.js +1 -0
  30. package/lib/types.js +1 -0
  31. package/lib/util.js +1 -0
  32. package/lib/weather.js +1 -0
  33. package/lib/ws.js +1 -0
  34. package/locale/en.json +6 -0
  35. package/locale/zh.json +6 -0
  36. package/package.json +118 -4
  37. package/scripts/browser-shot.mjs +217 -0
  38. package/scripts/build-dsh.mjs +228 -0
  39. package/scripts/build-rust.mjs +319 -0
  40. package/scripts/build-web.mjs +17 -0
  41. package/scripts/check-npm-package.mjs +322 -0
  42. package/scripts/mock-preview.py +574 -0
  43. package/scripts/screenshot-compose.py +89 -0
  44. package/scripts/screenshot-device.sh +75 -0
  45. package/src/dsh/client/api.ts +68 -0
  46. package/src/dsh/client/i18n.ts +573 -0
  47. package/src/dsh/client/icons.ts +56 -0
  48. package/src/dsh/client/index.ts +95 -0
  49. package/src/dsh/client/logic.ts +310 -0
  50. package/src/dsh/client/panel.ts +483 -0
  51. package/src/dsh/client/sections/apps.ts +40 -0
  52. package/src/dsh/client/sections/balance.ts +38 -0
  53. package/src/dsh/client/sections/connection.ts +97 -0
  54. package/src/dsh/client/sections/notifications.ts +43 -0
  55. package/src/dsh/client/sections/pomodoro.ts +184 -0
  56. package/src/dsh/client/sections/preferences.ts +138 -0
  57. package/src/dsh/client/sections/system.ts +120 -0
  58. package/src/dsh/client/sections/weather.ts +276 -0
  59. package/src/dsh/client/styles.ts +1119 -0
  60. package/src/dsh/client/types.ts +366 -0
  61. package/src/dsh/client/ui.ts +142 -0
  62. package/src/dsh/index.ts +108 -0
  63. package/src/dsh/lib/account.ts +520 -0
  64. package/src/dsh/lib/app-mapping.ts +366 -0
  65. package/src/dsh/lib/i18n.ts +82 -0
  66. package/src/dsh/lib/media-enrich.ts +202 -0
  67. package/src/dsh/lib/notifications.ts +322 -0
  68. package/src/dsh/lib/performance.ts +279 -0
  69. package/src/dsh/lib/platform/index.ts +43 -0
  70. package/src/dsh/lib/probe-adapter.ts +275 -0
  71. package/src/dsh/lib/probe-daemon.ts +359 -0
  72. package/src/dsh/lib/probe.ts +395 -0
  73. package/src/dsh/lib/qr.ts +42 -0
  74. package/src/dsh/lib/runtime.ts +126 -0
  75. package/src/dsh/lib/service.ts +1645 -0
  76. package/src/dsh/lib/sessions.ts +907 -0
  77. package/src/dsh/lib/settings.ts +468 -0
  78. package/src/dsh/lib/types.ts +231 -0
  79. package/src/dsh/lib/util.ts +115 -0
  80. package/src/dsh/lib/weather.ts +532 -0
  81. package/src/dsh/lib/ws.ts +328 -0
  82. package/src/dsh/types/qrcode-generator.d.ts +16 -0
  83. package/src/rust/Cargo.lock +1431 -0
  84. package/src/rust/Cargo.toml +68 -0
  85. package/src/rust/src/cli.rs +233 -0
  86. package/src/rust/src/daemon.rs +140 -0
  87. package/src/rust/src/location/linux.rs +118 -0
  88. package/src/rust/src/location/macos.rs +26 -0
  89. package/src/rust/src/location/mod.rs +170 -0
  90. package/src/rust/src/location/windows.rs +205 -0
  91. package/src/rust/src/main.rs +42 -0
  92. package/src/rust/src/media/catalog.rs +454 -0
  93. package/src/rust/src/media/linux.rs +350 -0
  94. package/src/rust/src/media/macos/accessibility.rs +59 -0
  95. package/src/rust/src/media/macos.rs +366 -0
  96. package/src/rust/src/media/mod.rs +298 -0
  97. package/src/rust/src/media/vendor/apple_music.rs +90 -0
  98. package/src/rust/src/media/vendor/common.rs +186 -0
  99. package/src/rust/src/media/vendor/kugou.rs +52 -0
  100. package/src/rust/src/media/vendor/kuwo.rs +49 -0
  101. package/src/rust/src/media/vendor/mod.rs +75 -0
  102. package/src/rust/src/media/vendor/netease.rs +1715 -0
  103. package/src/rust/src/media/vendor/qishui.rs +36 -0
  104. package/src/rust/src/media/vendor/qqmusic.rs +33 -0
  105. package/src/rust/src/media/vendor/spotify.rs +46 -0
  106. package/src/rust/src/media/vendor/wesing.rs +39 -0
  107. package/src/rust/src/media/vendor/youtube_music.rs +49 -0
  108. package/src/rust/src/media/windows/accessibility.rs +85 -0
  109. package/src/rust/src/media/windows/audio.rs +116 -0
  110. package/src/rust/src/media/windows/private.rs +291 -0
  111. package/src/rust/src/media/windows.rs +775 -0
  112. package/src/rust/src/notifications/linux.rs +276 -0
  113. package/src/rust/src/notifications/macos.rs +382 -0
  114. package/src/rust/src/notifications/mod.rs +244 -0
  115. package/src/rust/src/notifications/windows.rs +565 -0
  116. package/src/rust/src/perf/common.rs +109 -0
  117. package/src/rust/src/perf/linux.rs +282 -0
  118. package/src/rust/src/perf/macos.rs +28 -0
  119. package/src/rust/src/perf/mod.rs +157 -0
  120. package/src/rust/src/perf/windows.rs +981 -0
  121. package/src/rust/src/util.rs +360 -0
  122. package/src/rust/src/volume/linux.rs +195 -0
  123. package/src/rust/src/volume/macos.rs +54 -0
  124. package/src/rust/src/volume/mod.rs +116 -0
  125. package/src/rust/src/volume/windows.rs +75 -0
  126. package/src/rust/tests/cli.rs +79 -0
  127. package/src/rust/tests/daemon.rs +107 -0
  128. package/src/svelte/App.svelte +385 -0
  129. package/src/svelte/index.html +18 -0
  130. package/src/svelte/lib/billing.svelte.ts +228 -0
  131. package/src/svelte/lib/components/BalanceCard.svelte +173 -0
  132. package/src/svelte/lib/components/ClockCard.svelte +183 -0
  133. package/src/svelte/lib/components/CountdownCard.svelte +261 -0
  134. package/src/svelte/lib/components/CpuMemoryCard.svelte +238 -0
  135. package/src/svelte/lib/components/EmptyState.svelte +34 -0
  136. package/src/svelte/lib/components/GpuCard.svelte +245 -0
  137. package/src/svelte/lib/components/HomePage.svelte +34 -0
  138. package/src/svelte/lib/components/MusicControls.svelte +256 -0
  139. package/src/svelte/lib/components/MusicCover.svelte +88 -0
  140. package/src/svelte/lib/components/MusicPage.svelte +116 -0
  141. package/src/svelte/lib/components/NotificationsCard.svelte +109 -0
  142. package/src/svelte/lib/components/Panel.svelte +22 -0
  143. package/src/svelte/lib/components/PerformancePage.svelte +55 -0
  144. package/src/svelte/lib/components/PomodoroCard.svelte +250 -0
  145. package/src/svelte/lib/components/QuestionSheet.svelte +354 -0
  146. package/src/svelte/lib/components/RadialGauge.svelte +99 -0
  147. package/src/svelte/lib/components/Rail.svelte +217 -0
  148. package/src/svelte/lib/components/SectionHeader.svelte +40 -0
  149. package/src/svelte/lib/components/SeekBar.svelte +421 -0
  150. package/src/svelte/lib/components/SessionList.svelte +364 -0
  151. package/src/svelte/lib/components/SessionStats.svelte +98 -0
  152. package/src/svelte/lib/components/SessionsPage.svelte +61 -0
  153. package/src/svelte/lib/components/SpendCard.svelte +105 -0
  154. package/src/svelte/lib/components/ThermalCard.svelte +249 -0
  155. package/src/svelte/lib/components/Toast.svelte +32 -0
  156. package/src/svelte/lib/components/WeatherIcon.svelte +40 -0
  157. package/src/svelte/lib/components/WeatherPanel.svelte +420 -0
  158. package/src/svelte/lib/components/ui/alert/alert-action.svelte +20 -0
  159. package/src/svelte/lib/components/ui/alert/alert-description.svelte +23 -0
  160. package/src/svelte/lib/components/ui/alert/alert-title.svelte +23 -0
  161. package/src/svelte/lib/components/ui/alert/alert.svelte +43 -0
  162. package/src/svelte/lib/components/ui/alert/index.ts +17 -0
  163. package/src/svelte/lib/components/ui/avatar/avatar-badge.svelte +26 -0
  164. package/src/svelte/lib/components/ui/avatar/avatar-fallback.svelte +20 -0
  165. package/src/svelte/lib/components/ui/avatar/avatar-group-count.svelte +23 -0
  166. package/src/svelte/lib/components/ui/avatar/avatar-group.svelte +23 -0
  167. package/src/svelte/lib/components/ui/avatar/avatar-image.svelte +17 -0
  168. package/src/svelte/lib/components/ui/avatar/avatar.svelte +26 -0
  169. package/src/svelte/lib/components/ui/avatar/index.ts +22 -0
  170. package/src/svelte/lib/components/ui/badge/badge.svelte +49 -0
  171. package/src/svelte/lib/components/ui/badge/index.ts +2 -0
  172. package/src/svelte/lib/components/ui/button/button.svelte +82 -0
  173. package/src/svelte/lib/components/ui/button/index.ts +17 -0
  174. package/src/svelte/lib/components/ui/card/card-action.svelte +23 -0
  175. package/src/svelte/lib/components/ui/card/card-content.svelte +20 -0
  176. package/src/svelte/lib/components/ui/card/card-description.svelte +20 -0
  177. package/src/svelte/lib/components/ui/card/card-footer.svelte +20 -0
  178. package/src/svelte/lib/components/ui/card/card-header.svelte +23 -0
  179. package/src/svelte/lib/components/ui/card/card-title.svelte +20 -0
  180. package/src/svelte/lib/components/ui/card/card.svelte +22 -0
  181. package/src/svelte/lib/components/ui/card/index.ts +25 -0
  182. package/src/svelte/lib/components/ui/dialog/dialog-close.svelte +11 -0
  183. package/src/svelte/lib/components/ui/dialog/dialog-content.svelte +48 -0
  184. package/src/svelte/lib/components/ui/dialog/dialog-description.svelte +17 -0
  185. package/src/svelte/lib/components/ui/dialog/dialog-footer.svelte +32 -0
  186. package/src/svelte/lib/components/ui/dialog/dialog-header.svelte +20 -0
  187. package/src/svelte/lib/components/ui/dialog/dialog-overlay.svelte +17 -0
  188. package/src/svelte/lib/components/ui/dialog/dialog-portal.svelte +7 -0
  189. package/src/svelte/lib/components/ui/dialog/dialog-title.svelte +17 -0
  190. package/src/svelte/lib/components/ui/dialog/dialog-trigger.svelte +11 -0
  191. package/src/svelte/lib/components/ui/dialog/dialog.svelte +7 -0
  192. package/src/svelte/lib/components/ui/dialog/index.ts +34 -0
  193. package/src/svelte/lib/components/ui/empty/empty-content.svelte +23 -0
  194. package/src/svelte/lib/components/ui/empty/empty-description.svelte +23 -0
  195. package/src/svelte/lib/components/ui/empty/empty-header.svelte +20 -0
  196. package/src/svelte/lib/components/ui/empty/empty-media.svelte +41 -0
  197. package/src/svelte/lib/components/ui/empty/empty-title.svelte +20 -0
  198. package/src/svelte/lib/components/ui/empty/empty.svelte +23 -0
  199. package/src/svelte/lib/components/ui/empty/index.ts +22 -0
  200. package/src/svelte/lib/components/ui/input/index.ts +7 -0
  201. package/src/svelte/lib/components/ui/input/input.svelte +48 -0
  202. package/src/svelte/lib/components/ui/progress/index.ts +7 -0
  203. package/src/svelte/lib/components/ui/progress/progress.svelte +27 -0
  204. package/src/svelte/lib/components/ui/scroll-area/index.ts +10 -0
  205. package/src/svelte/lib/components/ui/scroll-area/scroll-area-scrollbar.svelte +30 -0
  206. package/src/svelte/lib/components/ui/scroll-area/scroll-area.svelte +43 -0
  207. package/src/svelte/lib/components/ui/separator/index.ts +7 -0
  208. package/src/svelte/lib/components/ui/separator/separator.svelte +21 -0
  209. package/src/svelte/lib/components/ui/sheet/index.ts +34 -0
  210. package/src/svelte/lib/components/ui/sheet/sheet-close.svelte +7 -0
  211. package/src/svelte/lib/components/ui/sheet/sheet-content.svelte +55 -0
  212. package/src/svelte/lib/components/ui/sheet/sheet-description.svelte +17 -0
  213. package/src/svelte/lib/components/ui/sheet/sheet-footer.svelte +20 -0
  214. package/src/svelte/lib/components/ui/sheet/sheet-header.svelte +20 -0
  215. package/src/svelte/lib/components/ui/sheet/sheet-overlay.svelte +20 -0
  216. package/src/svelte/lib/components/ui/sheet/sheet-portal.svelte +7 -0
  217. package/src/svelte/lib/components/ui/sheet/sheet-title.svelte +17 -0
  218. package/src/svelte/lib/components/ui/sheet/sheet-trigger.svelte +7 -0
  219. package/src/svelte/lib/components/ui/sheet/sheet.svelte +7 -0
  220. package/src/svelte/lib/components/ui/skeleton/index.ts +7 -0
  221. package/src/svelte/lib/components/ui/skeleton/skeleton.svelte +17 -0
  222. package/src/svelte/lib/components/ui/slider/index.ts +7 -0
  223. package/src/svelte/lib/components/ui/slider/slider.svelte +52 -0
  224. package/src/svelte/lib/components/ui/tooltip/index.ts +19 -0
  225. package/src/svelte/lib/components/ui/tooltip/tooltip-content.svelte +51 -0
  226. package/src/svelte/lib/components/ui/tooltip/tooltip-portal.svelte +7 -0
  227. package/src/svelte/lib/components/ui/tooltip/tooltip-provider.svelte +7 -0
  228. package/src/svelte/lib/components/ui/tooltip/tooltip-trigger.svelte +7 -0
  229. package/src/svelte/lib/components/ui/tooltip/tooltip.svelte +7 -0
  230. package/src/svelte/lib/countdown.svelte.ts +252 -0
  231. package/src/svelte/lib/format.ts +199 -0
  232. package/src/svelte/lib/i18n.svelte.ts +583 -0
  233. package/src/svelte/lib/media.ts +32 -0
  234. package/src/svelte/lib/pages.ts +18 -0
  235. package/src/svelte/lib/pomodoro.svelte.ts +223 -0
  236. package/src/svelte/lib/session-tree.ts +80 -0
  237. package/src/svelte/lib/state.svelte.ts +359 -0
  238. package/src/svelte/lib/timer-alarm.svelte.ts +319 -0
  239. package/src/svelte/lib/types.ts +306 -0
  240. package/src/svelte/lib/utils.ts +8 -0
  241. package/src/svelte/lib/weather.ts +84 -0
  242. package/src/svelte/lib/ws.ts +104 -0
  243. package/src/svelte/main.ts +13 -0
  244. package/src/svelte/styles/global.css +315 -0
  245. package/src/svelte/vite-env.d.ts +12 -0
  246. package/static/fonts/LICENSE.txt +287 -0
  247. package/static/icon.svg +24 -0
  248. package/static/manifest.webmanifest +18 -0
  249. package/svelte.config.js +5 -0
  250. package/tsconfig.dsh-client.json +18 -0
  251. package/tsconfig.dsh.json +21 -0
  252. package/tsconfig.json +20 -0
  253. package/vite.config.js +31 -0
  254. package/web/assets/index-DLdSfnJ3.css +1 -0
  255. package/web/assets/index-YNE8X0Uy.js +6 -0
  256. package/web/assets/inter-cyrillic-ext-wght-normal-BOeWTOD4.woff2 +0 -0
  257. package/web/assets/inter-cyrillic-wght-normal-DqGufNeO.woff2 +0 -0
  258. package/web/assets/inter-greek-ext-wght-normal-DlzME5K_.woff2 +0 -0
  259. package/web/assets/inter-greek-wght-normal-CkhJZR-_.woff2 +0 -0
  260. package/web/assets/inter-latin-ext-wght-normal-DO1Apj_S.woff2 +0 -0
  261. package/web/assets/inter-latin-wght-normal-Dx4kXJAl.woff2 +0 -0
  262. package/web/assets/inter-vietnamese-wght-normal-CBcvBZtf.woff2 +0 -0
  263. package/web/assets/jetbrains-mono-cyrillic-ext-wght-normal-EocZY2iu.woff2 +0 -0
  264. package/web/assets/jetbrains-mono-cyrillic-wght-normal-D73BlboJ.woff2 +0 -0
  265. package/web/assets/jetbrains-mono-greek-wght-normal-Bw9x6K1M.woff2 +0 -0
  266. package/web/assets/jetbrains-mono-latin-ext-wght-normal-DBQx-q_a.woff2 +0 -0
  267. package/web/assets/jetbrains-mono-latin-wght-normal-B9CIFXIH.woff2 +0 -0
  268. package/web/assets/jetbrains-mono-vietnamese-wght-normal-Bt-aOZkq.woff2 +0 -0
  269. package/web/assets/noto-sans-sc-100-wght-normal-DluiXYzi.woff2 +0 -0
  270. package/web/assets/noto-sans-sc-101-wght-normal-CPkZHnw0.woff2 +0 -0
  271. package/web/assets/noto-sans-sc-102-wght-normal-gzIulUH6.woff2 +0 -0
  272. package/web/assets/noto-sans-sc-103-wght-normal-CJfSBxFo.woff2 +0 -0
  273. package/web/assets/noto-sans-sc-104-wght-normal-B3QMglgE.woff2 +0 -0
  274. package/web/assets/noto-sans-sc-105-wght-normal-xXh_OMfk.woff2 +0 -0
  275. package/web/assets/noto-sans-sc-106-wght-normal-B9n3jMjP.woff2 +0 -0
  276. package/web/assets/noto-sans-sc-107-wght-normal-D7fvVprN.woff2 +0 -0
  277. package/web/assets/noto-sans-sc-108-wght-normal-J0_LhxVo.woff2 +0 -0
  278. package/web/assets/noto-sans-sc-109-wght-normal-Dsp-De_0.woff2 +0 -0
  279. package/web/assets/noto-sans-sc-110-wght-normal-DE_6NUrb.woff2 +0 -0
  280. package/web/assets/noto-sans-sc-111-wght-normal-C4S-TlTn.woff2 +0 -0
  281. package/web/assets/noto-sans-sc-112-wght-normal-BcFuP3w6.woff2 +0 -0
  282. package/web/assets/noto-sans-sc-113-wght-normal-Dk932kmj.woff2 +0 -0
  283. package/web/assets/noto-sans-sc-114-wght-normal-CO_5Xo-i.woff2 +0 -0
  284. package/web/assets/noto-sans-sc-115-wght-normal-BWy07H14.woff2 +0 -0
  285. package/web/assets/noto-sans-sc-116-wght-normal-B4HVAfHo.woff2 +0 -0
  286. package/web/assets/noto-sans-sc-117-wght-normal-siZzFqgK.woff2 +0 -0
  287. package/web/assets/noto-sans-sc-118-wght-normal-xhBB7f6V.woff2 +0 -0
  288. package/web/assets/noto-sans-sc-119-wght-normal-Ck_g2FGz.woff2 +0 -0
  289. package/web/assets/noto-sans-sc-21-wght-normal-D0x9-yBt.woff2 +0 -0
  290. package/web/assets/noto-sans-sc-22-wght-normal-BBUVTShg.woff2 +0 -0
  291. package/web/assets/noto-sans-sc-23-wght-normal-Du1oYWkE.woff2 +0 -0
  292. package/web/assets/noto-sans-sc-24-wght-normal-CmWjJ0gt.woff2 +0 -0
  293. package/web/assets/noto-sans-sc-25-wght-normal-BFHiqilg.woff2 +0 -0
  294. package/web/assets/noto-sans-sc-26-wght-normal-C0sF2Z2f.woff2 +0 -0
  295. package/web/assets/noto-sans-sc-27-wght-normal-COFdrquJ.woff2 +0 -0
  296. package/web/assets/noto-sans-sc-28-wght-normal-DMgdNu1o.woff2 +0 -0
  297. package/web/assets/noto-sans-sc-29-wght-normal-Cmq8em30.woff2 +0 -0
  298. package/web/assets/noto-sans-sc-30-wght-normal-CZWqZ3yU.woff2 +0 -0
  299. package/web/assets/noto-sans-sc-31-wght-normal-Wfi_lutW.woff2 +0 -0
  300. package/web/assets/noto-sans-sc-32-wght-normal-BRJQ4PY5.woff2 +0 -0
  301. package/web/assets/noto-sans-sc-33-wght-normal-CUVQXNAJ.woff2 +0 -0
  302. package/web/assets/noto-sans-sc-34-wght-normal-Bspba0wC.woff2 +0 -0
  303. package/web/assets/noto-sans-sc-35-wght-normal-K8fX_YPG.woff2 +0 -0
  304. package/web/assets/noto-sans-sc-36-wght-normal-CkITyjoT.woff2 +0 -0
  305. package/web/assets/noto-sans-sc-37-wght-normal-WCZ2zfEe.woff2 +0 -0
  306. package/web/assets/noto-sans-sc-38-wght-normal-DeM7CUce.woff2 +0 -0
  307. package/web/assets/noto-sans-sc-39-wght-normal-BMZNLWQU.woff2 +0 -0
  308. package/web/assets/noto-sans-sc-4-wght-normal-AR5bdDjz.woff2 +0 -0
  309. package/web/assets/noto-sans-sc-40-wght-normal-BQ5xpS5j.woff2 +0 -0
  310. package/web/assets/noto-sans-sc-41-wght-normal-Hthm7FFi.woff2 +0 -0
  311. package/web/assets/noto-sans-sc-42-wght-normal-eaS2gKds.woff2 +0 -0
  312. package/web/assets/noto-sans-sc-43-wght-normal-o-T53XXV.woff2 +0 -0
  313. package/web/assets/noto-sans-sc-44-wght-normal-BJH5J4Ez.woff2 +0 -0
  314. package/web/assets/noto-sans-sc-45-wght-normal-BKuxSCOJ.woff2 +0 -0
  315. package/web/assets/noto-sans-sc-46-wght-normal-BR_5Mu6e.woff2 +0 -0
  316. package/web/assets/noto-sans-sc-47-wght-normal-CyY7gKNX.woff2 +0 -0
  317. package/web/assets/noto-sans-sc-48-wght-normal-BNMfTR6k.woff2 +0 -0
  318. package/web/assets/noto-sans-sc-49-wght-normal-DX-0y7wg.woff2 +0 -0
  319. package/web/assets/noto-sans-sc-5-wght-normal-CfWugllB.woff2 +0 -0
  320. package/web/assets/noto-sans-sc-50-wght-normal-DAOGW-Dr.woff2 +0 -0
  321. package/web/assets/noto-sans-sc-51-wght-normal-BlFLOjcP.woff2 +0 -0
  322. package/web/assets/noto-sans-sc-52-wght-normal-OZjrybnw.woff2 +0 -0
  323. package/web/assets/noto-sans-sc-53-wght-normal-CwyWNT6x.woff2 +0 -0
  324. package/web/assets/noto-sans-sc-54-wght-normal-DM0nRe18.woff2 +0 -0
  325. package/web/assets/noto-sans-sc-55-wght-normal-DBK2wUOR.woff2 +0 -0
  326. package/web/assets/noto-sans-sc-56-wght-normal-C-VKK1LS.woff2 +0 -0
  327. package/web/assets/noto-sans-sc-57-wght-normal-Cdbfh5kq.woff2 +0 -0
  328. package/web/assets/noto-sans-sc-58-wght-normal-CflMKARZ.woff2 +0 -0
  329. package/web/assets/noto-sans-sc-59-wght-normal-BS4Kwzxx.woff2 +0 -0
  330. package/web/assets/noto-sans-sc-6-wght-normal-ChnZIQfQ.woff2 +0 -0
  331. package/web/assets/noto-sans-sc-60-wght-normal-B7QOYsXJ.woff2 +0 -0
  332. package/web/assets/noto-sans-sc-61-wght-normal-C8JjQS7w.woff2 +0 -0
  333. package/web/assets/noto-sans-sc-62-wght-normal-bS4wq1Gg.woff2 +0 -0
  334. package/web/assets/noto-sans-sc-63-wght-normal-BJr8P-OB.woff2 +0 -0
  335. package/web/assets/noto-sans-sc-64-wght-normal-DVB11t7j.woff2 +0 -0
  336. package/web/assets/noto-sans-sc-65-wght-normal-DMPCXBvK.woff2 +0 -0
  337. package/web/assets/noto-sans-sc-66-wght-normal-BobaG10V.woff2 +0 -0
  338. package/web/assets/noto-sans-sc-67-wght-normal-Dzk7WhCR.woff2 +0 -0
  339. package/web/assets/noto-sans-sc-68-wght-normal-DGbCD9xu.woff2 +0 -0
  340. package/web/assets/noto-sans-sc-69-wght-normal-Ctrv1EqA.woff2 +0 -0
  341. package/web/assets/noto-sans-sc-70-wght-normal-RgTO941D.woff2 +0 -0
  342. package/web/assets/noto-sans-sc-71-wght-normal-D9sG-CWv.woff2 +0 -0
  343. package/web/assets/noto-sans-sc-72-wght-normal-C4oWy3ul.woff2 +0 -0
  344. package/web/assets/noto-sans-sc-73-wght-normal-B1ZoOTgM.woff2 +0 -0
  345. package/web/assets/noto-sans-sc-74-wght-normal-F7dH_rZJ.woff2 +0 -0
  346. package/web/assets/noto-sans-sc-75-wght-normal-Cp2wp4si.woff2 +0 -0
  347. package/web/assets/noto-sans-sc-76-wght-normal-B494D7xS.woff2 +0 -0
  348. package/web/assets/noto-sans-sc-77-wght-normal-B3MVY9Ax.woff2 +0 -0
  349. package/web/assets/noto-sans-sc-78-wght-normal-5jX7iNMC.woff2 +0 -0
  350. package/web/assets/noto-sans-sc-79-wght-normal-BIoZy8fr.woff2 +0 -0
  351. package/web/assets/noto-sans-sc-80-wght-normal-Bje1Fxy1.woff2 +0 -0
  352. package/web/assets/noto-sans-sc-81-wght-normal-B2NN7Qi3.woff2 +0 -0
  353. package/web/assets/noto-sans-sc-82-wght-normal-CUggNZi4.woff2 +0 -0
  354. package/web/assets/noto-sans-sc-83-wght-normal-99rD8YO-.woff2 +0 -0
  355. package/web/assets/noto-sans-sc-84-wght-normal-zMBmUocm.woff2 +0 -0
  356. package/web/assets/noto-sans-sc-85-wght-normal-CjI3qdxG.woff2 +0 -0
  357. package/web/assets/noto-sans-sc-86-wght-normal-BHY8TVEH.woff2 +0 -0
  358. package/web/assets/noto-sans-sc-87-wght-normal-DioZSHnU.woff2 +0 -0
  359. package/web/assets/noto-sans-sc-88-wght-normal-BXYxwf2t.woff2 +0 -0
  360. package/web/assets/noto-sans-sc-89-wght-normal-ClMFrFaL.woff2 +0 -0
  361. package/web/assets/noto-sans-sc-90-wght-normal-BnOlPreP.woff2 +0 -0
  362. package/web/assets/noto-sans-sc-91-wght-normal-DTr9RQcS.woff2 +0 -0
  363. package/web/assets/noto-sans-sc-97-wght-normal-9u6Aiav-.woff2 +0 -0
  364. package/web/assets/noto-sans-sc-98-wght-normal-BR-H2C0V.woff2 +0 -0
  365. package/web/assets/noto-sans-sc-99-wght-normal-BI6XPoxp.woff2 +0 -0
  366. package/web/assets/noto-sans-sc-cyrillic-wght-normal-BGmsQlw-.woff2 +0 -0
  367. package/web/assets/noto-sans-sc-latin-ext-wght-normal-DuJTpTg_.woff2 +0 -0
  368. package/web/assets/noto-sans-sc-latin-wght-normal-DDW8mKAR.woff2 +0 -0
  369. package/web/assets/noto-sans-sc-vietnamese-wght-normal-Bi3ATJ76.woff2 +0 -0
  370. package/web/fonts/LICENSE.txt +287 -0
  371. package/web/icon.svg +24 -0
  372. package/web/index.html +19 -0
  373. package/web/manifest.webmanifest +18 -0
@@ -0,0 +1,1715 @@
1
+ //! NetEase Cloud Music vendor adapter: local metadata cache and CDP bridge.
2
+ //!
3
+ //! The desktop client keeps its library in `Library/webdb.dat` (a SQLite
4
+ //! database) and its image cache in `Statics/*.dat`. This module opens those
5
+ //! files read-only to enrich the private window-title path with a track id,
6
+ //! duration and cover art. It never writes to the client, never calls a network
7
+ //! API and never extracts cookies.
8
+ //!
9
+ //! When the client was launched with `/remote-debugging-port=<port>` (or
10
+ //! `--remote-debugging-port=<port>`, default port `2015`), the CDP bridge also
11
+ //! supplies:
12
+ //!
13
+ //! - with a usable SMTC/MPRIS/Now Playing session: exact playback progress,
14
+ //! duration and liked state, while the system session keeps its transport and
15
+ //! seek policy;
16
+ //! - without a system session: the full media snapshot (title, artist, album,
17
+ //! cover URL, playback state and progress) plus play/pause and next/previous,
18
+ //! but no seek grant;
19
+ //! - the like button state and click, which the client store does not expose.
20
+ //!
21
+ //! Without CDP, liked state stays `null` and progress falls back to the
22
+ //! platform's window-title / UI Automation text. The local database is never
23
+ //! used for liked state.
24
+
25
+ use std::io::{Read, Write};
26
+ use std::net::{SocketAddr, TcpStream, ToSocketAddrs};
27
+ use std::path::{Path, PathBuf};
28
+ use std::time::{Duration, SystemTime};
29
+
30
+ use base64::Engine as _;
31
+ use rusqlite::{Connection, OpenFlags};
32
+ use serde_json::{json, Value};
33
+
34
+ use super::common;
35
+ use super::Platform;
36
+ use crate::media::catalog::ParsedTitle;
37
+ use crate::media::Media;
38
+ use crate::util;
39
+
40
+ const MAX_COVER_BYTES: u64 = 4 * 1024 * 1024;
41
+ const HISTORY_LIMIT: usize = 120;
42
+
43
+ /// A NetEase track matched from the local client database.
44
+ #[derive(Debug, Clone, Default)]
45
+ pub struct LocalTrack {
46
+ pub track_id: Option<String>,
47
+ pub duration_seconds: Option<f64>,
48
+ pub cover: Option<String>,
49
+ }
50
+
51
+ #[derive(Debug, Clone)]
52
+ struct TrackData {
53
+ id: Option<String>,
54
+ name: String,
55
+ artists: Vec<String>,
56
+ duration_ms: Option<f64>,
57
+ source_history: bool,
58
+ }
59
+
60
+ /// True when the SMTC/MPRIS app id belongs to the NetEase desktop client.
61
+ pub fn is_netease(app_id: &str) -> bool {
62
+ let app_id = app_id.to_ascii_lowercase();
63
+ app_id.contains("cloudmusic") || app_id.contains("netease") || app_id.contains("163music")
64
+ }
65
+
66
+ /// Parse the NetEase desktop window title.
67
+ ///
68
+ /// NetEase's SMTC session provides metadata but not playback progress, so the
69
+ /// title fallback is only used when SMTC is missing/incomplete. Titles look like
70
+ /// `歌名 - 歌手 - 网易云音乐`.
71
+ pub fn parse_title(raw: &str) -> Option<ParsedTitle> {
72
+ const SUFFIXES: &[&str] = &[
73
+ " - 网易云音乐",
74
+ " - NetEase Cloud Music",
75
+ " - 网易云",
76
+ " - CloudMusic",
77
+ ];
78
+ const PRODUCT_NAMES: &[&str] = &["网易云音乐", "网易云", "netease cloud music", "cloudmusic"];
79
+ let text = common::strip_suffixes(common::normalize_spaces(raw), SUFFIXES);
80
+ if common::is_generic_title(&text, PRODUCT_NAMES) {
81
+ return None;
82
+ }
83
+ let mut parsed = common::split_title_artist(&text, false);
84
+ parsed.artist = common::normalize_artist(&parsed.artist, &[("/", " / ")]);
85
+ if parsed.title.is_empty() {
86
+ return None;
87
+ }
88
+ Some(parsed)
89
+ }
90
+
91
+ /// Enrich a playing media item with local NetEase cover/duration data.
92
+ ///
93
+ /// Liked state is deliberately not read here: it is only available through the
94
+ /// CDP bridge when the client is launched with remote debugging.
95
+ pub fn enrich(media: &mut Media) {
96
+ if !is_netease(&media.app_id) {
97
+ return;
98
+ }
99
+ // Seek policy is decided by the platform adapter. Windows NetEase SMTC is
100
+ // metadata-only; Linux MPRIS / macOS Now Playing may keep a valid seek
101
+ // grant. This module only enriches metadata and must not grant seek itself.
102
+ let Some(local) = read_local(&media.title, &media.artist) else {
103
+ return;
104
+ };
105
+ if media.track_id.is_none() {
106
+ media.track_id = local.track_id;
107
+ }
108
+ // The cover must come from the active provider: SMTC while a system
109
+ // session exists, or the CDP page in CDP-only mode. The local Statics cache
110
+ // is only a fallback for the private window-title path.
111
+ let provider_cover =
112
+ media.provider_detail.contains("smtc") || media.provider_detail.contains("cdp");
113
+ if !provider_cover && media.thumbnail.is_none() {
114
+ media.thumbnail = local.cover;
115
+ if media.thumbnail.is_some() {
116
+ media.thumbnail_type = Some("image/*".to_string());
117
+ }
118
+ }
119
+ if media.end_time <= media.start_time {
120
+ if let Some(duration) = local.duration_seconds.filter(|value| *value > 0.0) {
121
+ media.end_time = duration;
122
+ media.max_seek_time = duration;
123
+ }
124
+ }
125
+ }
126
+
127
+ pub fn read_local(title: &str, artist: &str) -> Option<LocalTrack> {
128
+ let track = with_database(|connection| find_track(connection, title, artist))?;
129
+ let track = track?;
130
+ Some(LocalTrack {
131
+ track_id: track.id,
132
+ duration_seconds: track.duration_ms.map(|value| value / 1000.0),
133
+ cover: latest_cover(),
134
+ })
135
+ }
136
+
137
+ /// Run `f` against a read-only copy of the NetEase database.
138
+ ///
139
+ /// Opening the live file first mirrors the old Node adapter: SQLite readers can
140
+ /// share a WAL database. If the client holds an exclusive lock, fall back to a
141
+ /// temporary copy of the database and its sidecars.
142
+ fn with_database<T>(f: impl FnOnce(&Connection) -> T) -> Option<T> {
143
+ for root in netease_roots() {
144
+ let source = root.join("Library").join("webdb.dat");
145
+ if !source.is_file() {
146
+ continue;
147
+ }
148
+ if let Ok(connection) =
149
+ Connection::open_with_flags(&source, OpenFlags::SQLITE_OPEN_READ_ONLY)
150
+ {
151
+ let _ = connection.busy_timeout(std::time::Duration::from_millis(500));
152
+ return Some(f(&connection));
153
+ }
154
+ let temp = util::TempDir::create("dsh-probe-netease").ok()?;
155
+ let target = temp.path().join("webdb.dat");
156
+ util::copy_sqlite_database(&source, &target).ok()?;
157
+ if let Ok(connection) =
158
+ Connection::open_with_flags(&target, OpenFlags::SQLITE_OPEN_READ_ONLY)
159
+ {
160
+ return Some(f(&connection));
161
+ }
162
+ }
163
+ None
164
+ }
165
+
166
+ /// Candidate NetEase Cloud Music data roots, most specific first.
167
+ pub fn netease_roots() -> Vec<PathBuf> {
168
+ let mut roots = Vec::new();
169
+ if cfg!(windows) {
170
+ let local = std::env::var_os("LOCALAPPDATA")
171
+ .map(PathBuf::from)
172
+ .or_else(|| util::home_dir().map(|home| home.join("AppData").join("Local")));
173
+ if let Some(local) = local {
174
+ for name in ["NetEase", "Netease"] {
175
+ roots.push(local.join(name).join("CloudMusic"));
176
+ }
177
+ }
178
+ } else if cfg!(target_os = "macos") {
179
+ if let Some(home) = util::home_dir() {
180
+ roots.push(
181
+ home.join("Library")
182
+ .join("Application Support")
183
+ .join("Netease")
184
+ .join("CloudMusic"),
185
+ );
186
+ roots.push(
187
+ home.join("Library")
188
+ .join("Application Support")
189
+ .join("NetEase")
190
+ .join("CloudMusic"),
191
+ );
192
+ roots.push(
193
+ home.join("Library")
194
+ .join("Containers")
195
+ .join("com.netease.163music")
196
+ .join("Data")
197
+ .join("Documents"),
198
+ );
199
+ }
200
+ } else {
201
+ if let Some(home) = util::home_dir() {
202
+ roots.push(home.join(".config").join("netease-cloud-music"));
203
+ roots.push(
204
+ home.join(".local")
205
+ .join("share")
206
+ .join("netease-cloud-music"),
207
+ );
208
+ roots.push(home.join(".netease-cloud-music"));
209
+ roots.push(home.join(".config").join("NetEase").join("CloudMusic"));
210
+ }
211
+ }
212
+ roots.retain(|root| root.is_dir());
213
+ roots
214
+ }
215
+
216
+ /// Normalize a title/artist for comparison (case, spaces, punctuation).
217
+ pub fn normalize(value: &str) -> String {
218
+ value
219
+ .to_lowercase()
220
+ .chars()
221
+ .filter(|character| !character.is_whitespace() && !"()()[]【】'\"`".contains(*character))
222
+ .collect()
223
+ }
224
+
225
+ fn column_names(connection: &Connection, table: &str) -> Vec<String> {
226
+ let sql = format!("pragma table_info(\"{}\")", table.replace('"', "\"\""));
227
+ let Ok(mut statement) = connection.prepare(&sql) else {
228
+ return Vec::new();
229
+ };
230
+ let Ok(rows) = statement.query_map([], |row| row.get::<_, String>(1)) else {
231
+ return Vec::new();
232
+ };
233
+ rows.flatten().collect()
234
+ }
235
+
236
+ fn json_column(columns: &[String]) -> Option<String> {
237
+ columns
238
+ .iter()
239
+ .find(|name| name.eq_ignore_ascii_case("jsonStr"))
240
+ .cloned()
241
+ }
242
+
243
+ fn value_to_string(value: &Value) -> Option<String> {
244
+ match value {
245
+ Value::String(text) => Some(text.clone()),
246
+ Value::Number(number) => Some(number.to_string()),
247
+ _ => None,
248
+ }
249
+ }
250
+
251
+ fn parse_track(json: &str, source_history: bool) -> Option<TrackData> {
252
+ let value: Value = serde_json::from_str(json).ok()?;
253
+ let name = value.get("name")?.as_str()?.trim().to_string();
254
+ if name.is_empty() {
255
+ return None;
256
+ }
257
+ let id = value.get("id").and_then(value_to_string);
258
+ let artists = value
259
+ .get("artists")
260
+ .and_then(Value::as_array)
261
+ .map(|items| {
262
+ items
263
+ .iter()
264
+ .filter_map(|item| item.get("name").and_then(Value::as_str))
265
+ .map(str::to_string)
266
+ .collect()
267
+ })
268
+ .unwrap_or_default();
269
+ let duration_ms = value.get("duration").and_then(Value::as_f64);
270
+ Some(TrackData {
271
+ id,
272
+ name,
273
+ artists,
274
+ duration_ms,
275
+ source_history,
276
+ })
277
+ }
278
+
279
+ fn find_track(connection: &Connection, title: &str, artist: &str) -> Option<TrackData> {
280
+ let wanted_title = normalize(title);
281
+ if wanted_title.is_empty() {
282
+ return None;
283
+ }
284
+ let wanted_artist = normalize(artist);
285
+ let mut candidates: Vec<(i32, TrackData)> = Vec::new();
286
+
287
+ for table in ["historyTracks", "dbTrack"] {
288
+ let columns = column_names(connection, table);
289
+ let Some(json_column) = json_column(&columns) else {
290
+ continue;
291
+ };
292
+ let order = if table == "historyTracks" {
293
+ " order by playtime desc"
294
+ } else {
295
+ ""
296
+ };
297
+ let sql = format!(
298
+ "select \"{}\" from \"{}\"{} limit {HISTORY_LIMIT}",
299
+ json_column.replace('"', "\"\""),
300
+ table,
301
+ order
302
+ );
303
+ let Ok(mut statement) = connection.prepare(&sql) else {
304
+ continue;
305
+ };
306
+ let Ok(rows) = statement.query_map([], |row| row.get::<_, String>(0)) else {
307
+ continue;
308
+ };
309
+ for json in rows.flatten() {
310
+ let Some(track) = parse_track(&json, table == "historyTracks") else {
311
+ continue;
312
+ };
313
+ let name = normalize(&track.name);
314
+ let mut score: i32 = 0;
315
+ if name == wanted_title {
316
+ score += 10;
317
+ } else if name.contains(&wanted_title) || wanted_title.contains(&name) {
318
+ score += 5;
319
+ }
320
+ if !wanted_artist.is_empty() {
321
+ let artist_match = track.artists.iter().any(|candidate| {
322
+ let candidate = normalize(candidate);
323
+ candidate == wanted_artist
324
+ || candidate.contains(&wanted_artist)
325
+ || wanted_artist.contains(&candidate)
326
+ });
327
+ if artist_match {
328
+ score += 6;
329
+ }
330
+ }
331
+ if track.source_history {
332
+ score += 1;
333
+ }
334
+ if score >= 5 {
335
+ candidates.push((score, track));
336
+ }
337
+ }
338
+ }
339
+
340
+ candidates.sort_by(|left, right| right.0.cmp(&left.0));
341
+ candidates.into_iter().next().map(|(_, track)| track)
342
+ }
343
+
344
+ /// Read the newest image from the client `Statics` cache.
345
+ fn latest_cover() -> Option<String> {
346
+ for root in netease_roots() {
347
+ let directory = root.join("Statics");
348
+ if let Some(cover) = cover_from_directory(&directory) {
349
+ return Some(cover);
350
+ }
351
+ }
352
+ None
353
+ }
354
+
355
+ fn cover_from_directory(directory: &Path) -> Option<String> {
356
+ let mut files: Vec<(SystemTime, PathBuf, u64)> = std::fs::read_dir(directory)
357
+ .ok()?
358
+ .flatten()
359
+ .filter_map(|entry| {
360
+ let path = entry.path();
361
+ let is_dat = path
362
+ .extension()
363
+ .map(|extension| extension.eq_ignore_ascii_case("dat"))
364
+ .unwrap_or(false);
365
+ if !is_dat {
366
+ return None;
367
+ }
368
+ let metadata = entry.metadata().ok()?;
369
+ if !metadata.is_file() {
370
+ return None;
371
+ }
372
+ let size = metadata.len();
373
+ if !(500..MAX_COVER_BYTES).contains(&size) {
374
+ return None;
375
+ }
376
+ let modified = metadata.modified().ok()?;
377
+ Some((modified, path, size))
378
+ })
379
+ .collect();
380
+ files.sort_by(|left, right| right.0.cmp(&left.0));
381
+ for (_, path, _) in files.into_iter().take(8) {
382
+ let Ok(bytes) = std::fs::read(&path) else {
383
+ continue;
384
+ };
385
+ if let Some(data) = util::image_data_url(&bytes) {
386
+ return Some(data);
387
+ }
388
+ }
389
+ None
390
+ }
391
+
392
+ /// Read the track metadata for a test database (exposed for unit tests).
393
+ #[cfg(test)]
394
+ fn read_local_from(connection: &Connection, title: &str, artist: &str) -> Option<LocalTrack> {
395
+ let track = find_track(connection, title, artist)?;
396
+ Some(LocalTrack {
397
+ track_id: track.id.clone(),
398
+ duration_seconds: track.duration_ms.map(|value| value / 1000.0),
399
+ cover: None,
400
+ })
401
+ }
402
+
403
+ /// Simple key/value view used by the tests.
404
+ #[cfg(test)]
405
+ fn json_track(name: &str, artist: &str, id: &str, duration: f64) -> String {
406
+ serde_json::json!({
407
+ "id": id,
408
+ "name": name,
409
+ "duration": duration,
410
+ "artists": [{ "id": "1", "name": artist }],
411
+ })
412
+ .to_string()
413
+ }
414
+
415
+ /// Live state read from the NetEase page through the Chromium DevTools
416
+ /// Protocol. Only available when the client was started with
417
+ /// `/remote-debugging-port=<port>` (or `--remote-debugging-port=<port>`).
418
+ #[derive(Debug, Clone, Copy, Default)]
419
+ pub struct CdpState {
420
+ pub position: Option<f64>,
421
+ pub duration: Option<f64>,
422
+ pub liked: Option<bool>,
423
+ pub can_like: bool,
424
+ pub can_set_liked: bool,
425
+ }
426
+
427
+ /// Full media snapshot used only when NetEase has no SMTC session at all.
428
+ /// The page is then the only source, so it also supplies metadata, cover art
429
+ /// and transport availability.
430
+ #[derive(Debug, Clone, Default)]
431
+ struct CdpTrack {
432
+ title: String,
433
+ artist: String,
434
+ album: String,
435
+ cover_url: Option<String>,
436
+ position: Option<f64>,
437
+ duration: Option<f64>,
438
+ playing: Option<bool>,
439
+ liked: Option<bool>,
440
+ can_like: bool,
441
+ can_set_liked: bool,
442
+ can_play_pause: bool,
443
+ can_next: bool,
444
+ can_previous: bool,
445
+ }
446
+
447
+ const CDP_DEFAULT_PORT: u16 = 2015;
448
+ const CDP_CONNECT_TIMEOUT: Duration = Duration::from_millis(250);
449
+ const CDP_IO_TIMEOUT: Duration = Duration::from_millis(1500);
450
+
451
+ /// Store/audio progress are preferred when the client's webpack runtime is
452
+ /// reachable; the visible player bar is a layout-independent fallback. The
453
+ /// like action uses the client's Ctrl+L shortcut, with a visible-button click
454
+ /// only if CDP key dispatch itself fails.
455
+ const CDP_STATE_TEMPLATE: &str = r#"
456
+ (async () => {
457
+ __STORE_HELPER__
458
+ const dom = domMedia();
459
+ const position = (await readProgress()) ?? dom.position ?? null;
460
+ const effectiveDuration = duration || dom.duration || null;
461
+ const button = pickLikeButton(false);
462
+ return {
463
+ position,
464
+ duration: effectiveDuration,
465
+ liked: readLiked(button),
466
+ canLike: !!button,
467
+ canSetLiked: !!button && button.getAttribute('aria-disabled') !== 'true'
468
+ };
469
+ })()
470
+ "#;
471
+
472
+ #[allow(dead_code)] // called by the platform adapters; kept cross-platform.
473
+ const CDP_CLICK_LIKE_EXPRESSION: &str = r#"
474
+ (() => {
475
+ const all = Array.from(document.querySelectorAll(
476
+ 'button[data-testid="tid_redstar_btn"]'
477
+ ));
478
+ const visible = all.filter((button) => {
479
+ const rect = button.getBoundingClientRect();
480
+ const style = window.getComputedStyle(button);
481
+ return rect.width > 0 && rect.height > 0
482
+ && style.display !== 'none' && style.visibility !== 'hidden';
483
+ });
484
+ const pool = visible.length ? visible : all;
485
+ const button = pool.find(
486
+ (candidate) => candidate.getAttribute('aria-disabled') !== 'true'
487
+ ) || null;
488
+ if (!button) return { ok: false };
489
+ button.click();
490
+ return { ok: true };
491
+ })()
492
+ "#;
493
+
494
+ /// Attempt to resolve the live store through the NetEase webpack runtime. The
495
+ /// module id changes between client builds, so inspect the loaded module cache
496
+ /// instead of hard-coding one id. This is opportunistic: after a page reload
497
+ /// the require hook may not be installable, so every template also has a DOM
498
+ /// player-bar fallback.
499
+ const CDP_STORE_HELPER: &str = r#"
500
+ const req = (() => {
501
+ if (window.__dsh_cm_require__) return window.__dsh_cm_require__;
502
+ try {
503
+ webpackJsonp.push([[987660], { 987660: function(module, exports, require) {
504
+ window.__dsh_cm_require__ = require;
505
+ } }]);
506
+ } catch (error) {}
507
+ return window.__dsh_cm_require__;
508
+ })();
509
+ const findStoreApi = () => {
510
+ if (!req) return null;
511
+ const cache = req.c || {};
512
+ for (const id of Object.keys(cache)) {
513
+ let exports;
514
+ try { exports = cache[id] && cache[id].exports; } catch (error) { continue; }
515
+ if (!exports) continue;
516
+ const api = typeof exports.getStore === 'function'
517
+ ? exports
518
+ : (exports.a && typeof exports.a.getStore === 'function' ? exports.a : null);
519
+ if (api) return api;
520
+ }
521
+ return null;
522
+ };
523
+ const knownStoreApi = (id) => {
524
+ try {
525
+ const module = req(id);
526
+ return module && typeof module.getStore === 'function'
527
+ ? module
528
+ : (module && module.a && typeof module.a.getStore === 'function' ? module.a : null);
529
+ } catch (error) { return null; }
530
+ };
531
+ let storeApi = window.__dsh_cm_store_api__;
532
+ if (!storeApi) {
533
+ storeApi = knownStoreApi(7) || knownStoreApi(12) || findStoreApi();
534
+ if (storeApi) window.__dsh_cm_store_api__ = storeApi;
535
+ }
536
+ let store = null;
537
+ try { store = storeApi ? storeApi.getStore() : null; } catch (error) { store = null; }
538
+ const playing = (store && store.playing) || {};
539
+ const cur = playing.curPlaying || {};
540
+ const track = cur.track || {};
541
+ const storeArtists = Array.isArray(playing.resourceArtists) ? playing.resourceArtists : [];
542
+ const artistList = (Array.isArray(track.artists) ? track.artists : storeArtists)
543
+ .map((item) => typeof item === 'string' ? item : (item && item.name) || '')
544
+ .filter(Boolean);
545
+ const storeTitle = String(track.name || playing.resourceName || '').trim();
546
+ const storeArtist = artistList.join(' / ');
547
+ const storeAlbum = String((track.album && track.album.name) || '');
548
+ const coverUrl = String(
549
+ (track.album && (track.album.picUrl || track.album.cover))
550
+ || playing.resourceCoverUrl
551
+ || ''
552
+ ).trim().replace(/^http:/, 'https:');
553
+ const rawDuration = Number(track.duration || 0);
554
+ const storeDuration = Number(playing.resourceDuration || 0);
555
+ const duration = rawDuration > 1000 ? rawDuration / 1000 : (storeDuration || rawDuration || 0);
556
+ const storePlaying = Number(playing.playingState);
557
+
558
+ const findAudio = () => {
559
+ if (!req) return null;
560
+ const cache = req.c || {};
561
+ for (const id of Object.keys(cache)) {
562
+ let exports;
563
+ try { exports = cache[id] && cache[id].exports; } catch (error) { continue; }
564
+ if (!exports) continue;
565
+ const api = exports.a || exports;
566
+ if (api && api.audioPlayerPlayProgress$
567
+ && typeof api.audioPlayerPlayProgress$.subscribe === 'function') {
568
+ return api;
569
+ }
570
+ }
571
+ return null;
572
+ };
573
+ let audio = window.__dsh_cm_audio__;
574
+ if (!audio || !audio.audioPlayerPlayProgress$) {
575
+ const audioApi = (id) => {
576
+ try {
577
+ const module = req(id);
578
+ const api = module && (module.a || module);
579
+ return api && api.audioPlayerPlayProgress$
580
+ && typeof api.audioPlayerPlayProgress$.subscribe === 'function'
581
+ ? api : null;
582
+ } catch (error) { return null; }
583
+ };
584
+ audio = audioApi(1195) || audioApi(12) || findAudio();
585
+ if (audio) window.__dsh_cm_audio__ = audio;
586
+ }
587
+ const progressFrom = (value) => {
588
+ let key = '';
589
+ let seconds = null;
590
+ if (Array.isArray(value) && value.length >= 2) {
591
+ key = String(value[0] || '');
592
+ seconds = Number(value[1]);
593
+ } else if (value && typeof value === 'object') {
594
+ key = String(value.key || value.trackId || '');
595
+ seconds = Number(value.position ?? value.currentTime ?? value.seconds);
596
+ } else if (typeof value === 'number') {
597
+ seconds = value;
598
+ }
599
+ return key && Number.isFinite(seconds) ? { key, position: seconds } : null;
600
+ };
601
+ if (!window.__dsh_cm_progress_sub__ && audio
602
+ && typeof audio.audioPlayerPlayProgress$.subscribe === 'function') {
603
+ try {
604
+ window.__dsh_cm_progress_sub__ = audio.audioPlayerPlayProgress$.subscribe((value) => {
605
+ const next = progressFrom(value);
606
+ if (next) window.__dsh_cm_progress__ = next;
607
+ });
608
+ } catch (error) {}
609
+ }
610
+ const currentProgress = () => {
611
+ const cached = window.__dsh_cm_progress__;
612
+ if (!cached || !track.id) return null;
613
+ return String(cached.key).startsWith(String(track.id)) ? cached.position : null;
614
+ };
615
+ const readProgress = () => {
616
+ const current = currentProgress();
617
+ // NetEase's internal IAudioPlayer state uses 2 = playing, 1 = paused.
618
+ if (!audio || storePlaying !== 2) return Promise.resolve(current);
619
+ return new Promise((resolve) => {
620
+ let done = false;
621
+ let subscription = null;
622
+ const finish = (value) => {
623
+ if (done) return;
624
+ done = true;
625
+ try { subscription && subscription.unsubscribe(); } catch (error) {}
626
+ const next = progressFrom(value);
627
+ if (next) window.__dsh_cm_progress__ = next;
628
+ resolve(next && track.id && next.key.startsWith(String(track.id))
629
+ ? next.position
630
+ : current);
631
+ };
632
+ try {
633
+ subscription = audio.audioPlayerPlayProgress$.subscribe(finish);
634
+ } catch (error) {
635
+ finish(null);
636
+ return;
637
+ }
638
+ setTimeout(() => finish(null), 500);
639
+ });
640
+ };
641
+
642
+ // DOM is used only for the like control, which is not exposed by the store.
643
+ // Pick a visible button so mini, expanded and comment-area layouts all work.
644
+ const allLikeButtons = () => Array.from(
645
+ document.querySelectorAll('button[data-testid="tid_redstar_btn"]')
646
+ );
647
+ const visibleLikeButtons = () => allLikeButtons().filter((button) => {
648
+ const rect = button.getBoundingClientRect();
649
+ const style = window.getComputedStyle(button);
650
+ return rect.width > 0 && rect.height > 0
651
+ && style.display !== 'none' && style.visibility !== 'hidden';
652
+ });
653
+ const pickLikeButton = (requireEnabled) => {
654
+ const all = allLikeButtons();
655
+ const visible = visibleLikeButtons();
656
+ const pool = visible.length ? visible : all;
657
+ const enabled = pool.filter(
658
+ (button) => button.getAttribute('aria-disabled') !== 'true'
659
+ );
660
+ return requireEnabled ? (enabled[0] || null) : (enabled[0] || pool[0] || null);
661
+ };
662
+ const readLiked = (button) => button
663
+ ? (button.querySelector('[aria-label="likenumber_red"]') ? true
664
+ : (button.querySelector('[aria-label="like_number"]') ? false : null))
665
+ : null;
666
+
667
+ // The webpack store is not always reachable (the runtime changed between
668
+ // NetEase builds). The visible player bar still carries the same metadata,
669
+ // progress and cover, so use it as a fallback instead of returning null.
670
+ const domMedia = () => {
671
+ const footer = document.querySelector('footer');
672
+ const root = footer
673
+ || document.querySelector('[data-testid="tid_playbar_controls"]')?.closest('footer')
674
+ || document;
675
+ const visibleRange = Array.from(root.querySelectorAll('input[type="range"]'))
676
+ .find((candidate) => Number(candidate.max) > 5)
677
+ || Array.from(document.querySelectorAll('input[type="range"]'))
678
+ .find((candidate) => Number(candidate.max) > 5);
679
+ const range = root.querySelector('.slider-default input[type="range"]')
680
+ || visibleRange;
681
+ const textOf = (node) => node ? (node.innerText || '').replace(/\s+/g, ' ').trim() : '';
682
+ const firstText = (nodes) => {
683
+ for (const node of nodes) {
684
+ const text = textOf(node);
685
+ if (text) return text;
686
+ }
687
+ return '';
688
+ };
689
+ // In the vinyl / comment layouts the footer collapses to 0px and its
690
+ // minibar title is empty; the same track is rendered outside the footer.
691
+ const title = firstText([
692
+ root.querySelector('[data-testid="tid_minibar_title"] .main-title'),
693
+ document.querySelector('[data-testid="tid_minibar_title"] .main-title'),
694
+ ...document.querySelectorAll('.main-title'),
695
+ ...document.querySelectorAll('[class*="TrackInfo"] .title')
696
+ ]).replace(/\s+VIP\s*$/i, '').trim();
697
+ const artist = firstText([
698
+ root.querySelector('[data-testid="tid_minibar_title"] .author'),
699
+ document.querySelector('[data-testid="tid_minibar_title"] .author'),
700
+ ...document.querySelectorAll('.author'),
701
+ ...document.querySelectorAll('[class*="TrackInfo"] .artist')
702
+ ]);
703
+ const playButton = root.querySelector('button[data-testid="tid_playbar_play_btn"]')
704
+ || document.querySelector('button[data-testid="tid_playbar_play_btn"]');
705
+ const playIcon = playButton && playButton.querySelector('[role="img"]');
706
+ const playLabel = playIcon ? playIcon.getAttribute('aria-label') : null;
707
+ const knownCovers = [
708
+ root.querySelector('[data-testid="tid_btn_pc_minibar_vinyl"] img'),
709
+ document.querySelector('[data-testid="tid_btn_pc_minibar_vinyl"] img'),
710
+ document.querySelector('[data-testid="tid_vinylcover_cover"] img'),
711
+ document.querySelector('[data-testid="tid_vinylcover_container"] img'),
712
+ document.querySelector('[class*="TrackInfo"] img')
713
+ ];
714
+ const fallbackCovers = Array.from(document.images).filter((image) => {
715
+ const rect = image.getBoundingClientRect();
716
+ const source = image.currentSrc || image.src || '';
717
+ return rect.width >= 40 && rect.height >= 40 && /music\.126\.net/.test(source);
718
+ });
719
+ const image = knownCovers.find((candidate) => {
720
+ if (!candidate) return false;
721
+ const rect = candidate.getBoundingClientRect();
722
+ return rect.width >= 1 && rect.height >= 1;
723
+ }) || fallbackCovers.find((candidate) => {
724
+ const source = candidate.currentSrc || candidate.src || '';
725
+ const parent = candidate.closest('[data-testid="tid_useravatar_click"], [data-testid="tid_comment_card"]');
726
+ return !parent && /music\.126\.net/.test(source);
727
+ }) || null;
728
+ let cover = '';
729
+ if (image) {
730
+ cover = String(image.currentSrc || image.src || '')
731
+ .replace(/^orpheus:\/\/cache\/\?/, '')
732
+ .replace(/^http:\/\//, 'https://')
733
+ .replace(/thumbnail=\d+y\d+/, 'thumbnail=300y300');
734
+ if (!/^https?:\/\//.test(cover)) cover = '';
735
+ }
736
+ return {
737
+ title,
738
+ artist,
739
+ cover,
740
+ position: range ? Number(range.value) : null,
741
+ duration: range ? Number(range.max) : null,
742
+ playing: playLabel === 'pause' ? true : (playLabel === 'play' ? false : null)
743
+ };
744
+ };
745
+ "#;
746
+
747
+ /// Full CDP snapshot for the no-SMTC case. Store values win when available;
748
+ /// the player-bar DOM supplies the same fields after a page reload where the
749
+ /// webpack require cache is no longer reachable.
750
+ #[allow(dead_code)] // called by `cdp_media` on every platform.
751
+ const CDP_MEDIA_TEMPLATE: &str = r#"
752
+ (async () => {
753
+ __STORE_HELPER__
754
+ const dom = domMedia();
755
+ const position = (await readProgress()) ?? dom.position ?? null;
756
+ const effectiveTitle = storeTitle || dom.title || '';
757
+ const effectiveArtist = storeArtist || dom.artist || '';
758
+ const effectiveDuration = duration || dom.duration || null;
759
+ const effectiveCover = coverUrl || dom.cover || null;
760
+ const effectivePlaying = storePlaying === 2 ? true
761
+ : storePlaying === 1 ? false : (dom.playing ?? null);
762
+ const likeButton = pickLikeButton(false);
763
+ return {
764
+ title: effectiveTitle,
765
+ artist: effectiveArtist,
766
+ album: storeAlbum,
767
+ coverUrl: effectiveCover,
768
+ position,
769
+ duration: effectiveDuration,
770
+ playing: effectivePlaying,
771
+ liked: readLiked(likeButton),
772
+ canLike: !!likeButton,
773
+ canSetLiked: !!likeButton && likeButton.getAttribute('aria-disabled') !== 'true',
774
+ canPlayPause: !!effectiveTitle,
775
+ canNext: !!effectiveTitle,
776
+ canPrevious: !!effectiveTitle
777
+ };
778
+ })()
779
+ "#;
780
+
781
+ fn cdp_ports() -> Vec<u16> {
782
+ if let Ok(value) = std::env::var("DSH_RC_NETEASE_CDP_PORT") {
783
+ if let Ok(port) = value.trim().parse::<u16>() {
784
+ if port > 0 {
785
+ return vec![port];
786
+ }
787
+ }
788
+ }
789
+ // NetEase follows the Chromium switch; 2015 is the value documented by
790
+ // the cloudmusic-patch project, 9222 is the common alternative.
791
+ vec![CDP_DEFAULT_PORT, 9222]
792
+ }
793
+
794
+ fn http_get(port: u16, path: &str) -> Result<String, String> {
795
+ let address = SocketAddr::from(([127, 0, 0, 1], port));
796
+ let mut stream = TcpStream::connect_timeout(&address, CDP_CONNECT_TIMEOUT)
797
+ .map_err(|error| format!("CDP connect 127.0.0.1:{port}: {error}"))?;
798
+ let _ = stream.set_read_timeout(Some(CDP_IO_TIMEOUT));
799
+ let _ = stream.set_write_timeout(Some(CDP_IO_TIMEOUT));
800
+ let request =
801
+ format!("GET {path} HTTP/1.1\r\nHost: 127.0.0.1:{port}\r\nConnection: close\r\n\r\n");
802
+ stream
803
+ .write_all(request.as_bytes())
804
+ .map_err(|error| format!("CDP HTTP write: {error}"))?;
805
+ let headers = read_http_headers(&mut stream)?;
806
+ let content_length = headers.lines().find_map(|line| {
807
+ let (name, value) = line.split_once(':')?;
808
+ if name.eq_ignore_ascii_case("content-length") {
809
+ value.trim().parse::<usize>().ok()
810
+ } else {
811
+ None
812
+ }
813
+ });
814
+ let mut body = Vec::new();
815
+ if let Some(length) = content_length {
816
+ body.resize(length, 0);
817
+ stream
818
+ .read_exact(&mut body)
819
+ .map_err(|error| format!("CDP HTTP body: {error}"))?;
820
+ } else {
821
+ stream
822
+ .read_to_end(&mut body)
823
+ .map_err(|error| format!("CDP HTTP read: {error}"))?;
824
+ }
825
+ Ok(String::from_utf8_lossy(&body).into_owned())
826
+ }
827
+
828
+ fn cdp_websocket_url(port: u16) -> Result<String, String> {
829
+ let body = http_get(port, "/json")?;
830
+ let targets: Value =
831
+ serde_json::from_str(&body).map_err(|error| format!("CDP target JSON: {error}"))?;
832
+ let list = targets
833
+ .as_array()
834
+ .ok_or_else(|| "CDP target list is not an array".to_string())?;
835
+ for target in list {
836
+ let kind = target.get("type").and_then(Value::as_str).unwrap_or("");
837
+ if kind != "page" {
838
+ continue;
839
+ }
840
+ let url = target.get("url").and_then(Value::as_str).unwrap_or("");
841
+ let title = target.get("title").and_then(Value::as_str).unwrap_or("");
842
+ let ws = target
843
+ .get("webSocketDebuggerUrl")
844
+ .and_then(Value::as_str)
845
+ .unwrap_or("");
846
+ if ws.is_empty() {
847
+ continue;
848
+ }
849
+ let is_netease = url.contains("orpheus")
850
+ || url.contains("music.163.com")
851
+ || title.contains("网易云")
852
+ || title.contains("NetEase");
853
+ if is_netease {
854
+ return Ok(ws.to_string());
855
+ }
856
+ }
857
+ Err("CDP has no NetEase page target".to_string())
858
+ }
859
+
860
+ struct CdpConnection {
861
+ stream: TcpStream,
862
+ }
863
+
864
+ impl CdpConnection {
865
+ fn connect(ws_url: &str) -> Result<Self, String> {
866
+ let rest = ws_url
867
+ .strip_prefix("ws://")
868
+ .ok_or_else(|| format!("unsupported CDP URL: {ws_url}"))?;
869
+ let (host_port, path) = rest.split_once('/').unwrap_or((rest, ""));
870
+ let path = format!("/{path}");
871
+ let address = host_port
872
+ .to_socket_addrs()
873
+ .map_err(|error| format!("CDP resolve {host_port}: {error}"))?
874
+ .next()
875
+ .ok_or_else(|| format!("CDP address not found: {host_port}"))?;
876
+ let mut stream = TcpStream::connect_timeout(&address, CDP_CONNECT_TIMEOUT)
877
+ .map_err(|error| format!("CDP websocket connect: {error}"))?;
878
+ let _ = stream.set_read_timeout(Some(CDP_IO_TIMEOUT));
879
+ let _ = stream.set_write_timeout(Some(CDP_IO_TIMEOUT));
880
+ let key = websocket_key();
881
+ let request = format!(
882
+ "GET {path} HTTP/1.1\r\nHost: {host_port}\r\nUpgrade: websocket\r\nConnection: Upgrade\r\nSec-WebSocket-Key: {key}\r\nSec-WebSocket-Version: 13\r\n\r\n"
883
+ );
884
+ stream
885
+ .write_all(request.as_bytes())
886
+ .map_err(|error| format!("CDP handshake write: {error}"))?;
887
+ let headers = read_http_headers(&mut stream)?;
888
+ if !headers.starts_with("HTTP/1.1 101") {
889
+ return Err(format!(
890
+ "CDP websocket rejected: {}",
891
+ headers.lines().next().unwrap_or("")
892
+ ));
893
+ }
894
+ Ok(Self { stream })
895
+ }
896
+
897
+ fn command(&mut self, method: &str, params: Value) -> Result<Value, String> {
898
+ let payload = json!({
899
+ "id": 1,
900
+ "method": method,
901
+ "params": params,
902
+ });
903
+ write_ws_text(&mut self.stream, payload.to_string().as_bytes())?;
904
+ for _ in 0..24 {
905
+ let (opcode, fin, data) = read_ws_frame(&mut self.stream)?;
906
+ if opcode == 0x8 {
907
+ return Err("CDP websocket closed".to_string());
908
+ }
909
+ if opcode == 0x9 {
910
+ write_ws_frame(&mut self.stream, 0xA, false, &data)?;
911
+ continue;
912
+ }
913
+ if opcode != 0x1 || !fin {
914
+ continue;
915
+ }
916
+ let message: Value = match serde_json::from_slice(&data) {
917
+ Ok(value) => value,
918
+ Err(_) => continue,
919
+ };
920
+ if message.get("id").and_then(Value::as_i64) != Some(1) {
921
+ continue;
922
+ }
923
+ if let Some(exception) = message.get("exceptionDetails") {
924
+ return Err(format!("CDP evaluate failed: {exception}"));
925
+ }
926
+ return Ok(message.get("result").cloned().unwrap_or(Value::Null));
927
+ }
928
+ Err("CDP evaluate timed out".to_string())
929
+ }
930
+
931
+ fn evaluate(&mut self, expression: &str) -> Result<Value, String> {
932
+ let result = self.command(
933
+ "Runtime.evaluate",
934
+ json!({
935
+ "expression": expression,
936
+ "returnByValue": true,
937
+ "awaitPromise": true,
938
+ }),
939
+ )?;
940
+ Ok(result
941
+ .get("result")
942
+ .and_then(|result| result.get("value"))
943
+ .cloned()
944
+ .unwrap_or(Value::Null))
945
+ }
946
+
947
+ /// Send Ctrl+L (NetEase's built-in like shortcut) to the renderer.
948
+ fn press_like_shortcut(&mut self) -> Result<(), String> {
949
+ self.command(
950
+ "Input.dispatchKeyEvent",
951
+ json!({
952
+ "type": "rawKeyDown",
953
+ "modifiers": 2,
954
+ "key": "l",
955
+ "code": "KeyL",
956
+ "windowsVirtualKeyCode": 76,
957
+ "nativeVirtualKeyCode": 76,
958
+ "text": "\u{000c}",
959
+ "unmodifiedText": "l",
960
+ }),
961
+ )?;
962
+ self.command(
963
+ "Input.dispatchKeyEvent",
964
+ json!({
965
+ "type": "keyUp",
966
+ "modifiers": 2,
967
+ "key": "l",
968
+ "code": "KeyL",
969
+ "windowsVirtualKeyCode": 76,
970
+ "nativeVirtualKeyCode": 76,
971
+ }),
972
+ )?;
973
+ Ok(())
974
+ }
975
+ }
976
+
977
+ fn websocket_key() -> String {
978
+ let now = SystemTime::now()
979
+ .duration_since(std::time::UNIX_EPOCH)
980
+ .map(|duration| duration.as_nanos())
981
+ .unwrap_or_default();
982
+ let seed = format!("{now}-{}-{}", std::process::id(), now.rotate_left(17));
983
+ let mut bytes = [0u8; 16];
984
+ for (index, byte) in seed.as_bytes().iter().take(16).enumerate() {
985
+ bytes[index] = *byte;
986
+ }
987
+ base64::engine::general_purpose::STANDARD.encode(bytes)
988
+ }
989
+
990
+ fn read_http_headers(stream: &mut TcpStream) -> Result<String, String> {
991
+ let mut bytes = Vec::new();
992
+ let mut buffer = [0u8; 1];
993
+ while bytes.len() < 16 * 1024 {
994
+ stream
995
+ .read_exact(&mut buffer)
996
+ .map_err(|error| format!("CDP handshake read: {error}"))?;
997
+ bytes.push(buffer[0]);
998
+ if bytes.ends_with(b"\r\n\r\n") {
999
+ return Ok(String::from_utf8_lossy(&bytes).into_owned());
1000
+ }
1001
+ }
1002
+ Err("CDP handshake headers are too large".to_string())
1003
+ }
1004
+
1005
+ fn write_ws_text(stream: &mut TcpStream, payload: &[u8]) -> Result<(), String> {
1006
+ write_ws_frame(stream, 0x1, true, payload)
1007
+ }
1008
+
1009
+ fn write_ws_frame(
1010
+ stream: &mut TcpStream,
1011
+ opcode: u8,
1012
+ fin: bool,
1013
+ payload: &[u8],
1014
+ ) -> Result<(), String> {
1015
+ let mut frame = Vec::with_capacity(payload.len() + 14);
1016
+ frame.push(if fin { 0x80 | opcode } else { opcode });
1017
+ let mask_bit = 0x80;
1018
+ match payload.len() {
1019
+ length if length < 126 => frame.push(mask_bit | length as u8),
1020
+ length if length <= u16::MAX as usize => {
1021
+ frame.push(mask_bit | 126);
1022
+ frame.extend_from_slice(&(length as u16).to_be_bytes());
1023
+ }
1024
+ length => {
1025
+ frame.push(mask_bit | 127);
1026
+ frame.extend_from_slice(&(length as u64).to_be_bytes());
1027
+ }
1028
+ }
1029
+ let now = SystemTime::now()
1030
+ .duration_since(std::time::UNIX_EPOCH)
1031
+ .map(|duration| duration.as_nanos() as u32)
1032
+ .unwrap_or(0);
1033
+ let mask = now.to_be_bytes();
1034
+ frame.extend_from_slice(&mask);
1035
+ frame.extend(
1036
+ payload
1037
+ .iter()
1038
+ .enumerate()
1039
+ .map(|(index, byte)| byte ^ mask[index % 4]),
1040
+ );
1041
+ stream
1042
+ .write_all(&frame)
1043
+ .map_err(|error| format!("CDP websocket write: {error}"))
1044
+ }
1045
+
1046
+ fn read_ws_frame(stream: &mut TcpStream) -> Result<(u8, bool, Vec<u8>), String> {
1047
+ let mut header = [0u8; 2];
1048
+ stream
1049
+ .read_exact(&mut header)
1050
+ .map_err(|error| format!("CDP websocket read: {error}"))?;
1051
+ let fin = header[0] & 0x80 != 0;
1052
+ let opcode = header[0] & 0x0F;
1053
+ let masked = header[1] & 0x80 != 0;
1054
+ let mut length = (header[1] & 0x7F) as u64;
1055
+ if length == 126 {
1056
+ let mut extended = [0u8; 2];
1057
+ stream
1058
+ .read_exact(&mut extended)
1059
+ .map_err(|error| format!("CDP websocket read: {error}"))?;
1060
+ length = u16::from_be_bytes(extended) as u64;
1061
+ } else if length == 127 {
1062
+ let mut extended = [0u8; 8];
1063
+ stream
1064
+ .read_exact(&mut extended)
1065
+ .map_err(|error| format!("CDP websocket read: {error}"))?;
1066
+ length = u64::from_be_bytes(extended);
1067
+ }
1068
+ if length > 8 * 1024 * 1024 {
1069
+ return Err("CDP websocket frame is too large".to_string());
1070
+ }
1071
+ let mut mask = [0u8; 4];
1072
+ if masked {
1073
+ stream
1074
+ .read_exact(&mut mask)
1075
+ .map_err(|error| format!("CDP websocket read: {error}"))?;
1076
+ }
1077
+ let mut payload = vec![0u8; length as usize];
1078
+ stream
1079
+ .read_exact(&mut payload)
1080
+ .map_err(|error| format!("CDP websocket read: {error}"))?;
1081
+ if masked {
1082
+ for (index, byte) in payload.iter_mut().enumerate() {
1083
+ *byte ^= mask[index % 4];
1084
+ }
1085
+ }
1086
+ Ok((opcode, fin, payload))
1087
+ }
1088
+
1089
+ /// Store-backed page identity, used to recognize a NetEase SMTC session that
1090
+ /// exposes an auto-generated AUMID instead of `cloudmusic.exe`.
1091
+ #[allow(dead_code)] // called by the Windows adapter; kept cross-platform.
1092
+ const CDP_IDENTITY_TEMPLATE: &str = r#"
1093
+ (() => {
1094
+ __STORE_HELPER__
1095
+ const dom = domMedia();
1096
+ return {
1097
+ title: storeTitle || dom.title || '',
1098
+ artist: storeArtist || dom.artist || ''
1099
+ };
1100
+ })()
1101
+ "#;
1102
+
1103
+ fn cdp_identity_expression() -> String {
1104
+ CDP_IDENTITY_TEMPLATE.replace("__STORE_HELPER__", CDP_STORE_HELPER)
1105
+ }
1106
+
1107
+ fn cdp_media_expression() -> String {
1108
+ CDP_MEDIA_TEMPLATE.replace("__STORE_HELPER__", CDP_STORE_HELPER)
1109
+ }
1110
+
1111
+ fn cdp_state_expression() -> String {
1112
+ CDP_STATE_TEMPLATE.replace("__STORE_HELPER__", CDP_STORE_HELPER)
1113
+ }
1114
+
1115
+ /// The current NetEase page title/artist, used to map an auto-generated SMTC
1116
+ /// AUMID back to the NetEase profile.
1117
+ #[allow(dead_code)] // used by the Windows adapter; kept cross-platform.
1118
+ pub fn cdp_identity() -> Option<(String, String)> {
1119
+ let value = cdp_evaluate(&cdp_identity_expression()).ok()?;
1120
+ let object = value.as_object()?;
1121
+ let title = object
1122
+ .get("title")
1123
+ .and_then(Value::as_str)
1124
+ .unwrap_or("")
1125
+ .trim()
1126
+ .to_string();
1127
+ let artist = object
1128
+ .get("artist")
1129
+ .and_then(Value::as_str)
1130
+ .unwrap_or("")
1131
+ .trim()
1132
+ .to_string();
1133
+ if title.is_empty() && artist.is_empty() {
1134
+ None
1135
+ } else {
1136
+ Some((title, artist))
1137
+ }
1138
+ }
1139
+
1140
+ fn parse_cdp_track(value: &Value) -> Option<CdpTrack> {
1141
+ let object = value.as_object()?;
1142
+ let string = |key: &str| {
1143
+ object
1144
+ .get(key)
1145
+ .and_then(Value::as_str)
1146
+ .unwrap_or("")
1147
+ .trim()
1148
+ .to_string()
1149
+ };
1150
+ let number = |key: &str| {
1151
+ object
1152
+ .get(key)
1153
+ .and_then(Value::as_f64)
1154
+ .filter(|value| value.is_finite())
1155
+ };
1156
+ let flag = |key: &str| object.get(key).and_then(Value::as_bool).unwrap_or(false);
1157
+ let title = string("title");
1158
+ let artist = string("artist");
1159
+ if title.is_empty() && artist.is_empty() {
1160
+ return None;
1161
+ }
1162
+ Some(CdpTrack {
1163
+ title,
1164
+ artist,
1165
+ album: string("album"),
1166
+ cover_url: object
1167
+ .get("coverUrl")
1168
+ .and_then(Value::as_str)
1169
+ .filter(|value| value.starts_with("http://") || value.starts_with("https://"))
1170
+ .map(str::to_string),
1171
+ position: number("position").filter(|value| *value >= 0.0),
1172
+ duration: number("duration").filter(|value| *value > 0.0),
1173
+ playing: object.get("playing").and_then(Value::as_bool),
1174
+ liked: object.get("liked").and_then(Value::as_bool),
1175
+ can_like: flag("canLike"),
1176
+ can_set_liked: flag("canSetLiked"),
1177
+ can_play_pause: flag("canPlayPause"),
1178
+ can_next: flag("canNext"),
1179
+ can_previous: flag("canPrevious"),
1180
+ })
1181
+ }
1182
+
1183
+ /// Read the complete NetEase page state. Used only when SMTC is unavailable;
1184
+ /// with SMTC present the page contributes progress/liked while metadata, cover
1185
+ /// and transport stay on the system session.
1186
+ #[allow(dead_code)] // called by `merge` on every platform; kept cross-platform.
1187
+ pub fn cdp_media() -> Option<Media> {
1188
+ let value = cdp_evaluate(&cdp_media_expression()).ok()?;
1189
+ let track = parse_cdp_track(&value)?;
1190
+ let duration = track.duration.unwrap_or(0.0);
1191
+ let mut media = Media::new("cloudmusic.exe");
1192
+ media.provider = "private".to_string();
1193
+ media.provider_detail = "netease-cdp".to_string();
1194
+ media.title = track.title;
1195
+ media.artist = track.artist;
1196
+ media.album = track.album;
1197
+ media.playback_status = match track.playing {
1198
+ Some(true) => "Playing".to_string(),
1199
+ _ => "Paused".to_string(),
1200
+ };
1201
+ media.position = track.position.unwrap_or(0.0).max(0.0);
1202
+ media.start_time = 0.0;
1203
+ media.end_time = duration;
1204
+ media.min_seek_time = 0.0;
1205
+ media.max_seek_time = duration;
1206
+ media.last_updated_at = util::now_ms();
1207
+ media.can_play_pause = track.can_play_pause;
1208
+ media.can_next = track.can_next;
1209
+ media.can_previous = track.can_previous;
1210
+ // CDP-only has no SMTC timeline. The page slider is still used for direct
1211
+ // transport control, but only an explicitly seekable SMTC session may
1212
+ // report a seekable timeline to the client.
1213
+ media.can_seek = false;
1214
+ media.thumbnail_url = track.cover_url;
1215
+ media.liked = track.liked;
1216
+ media.can_like = track.can_like;
1217
+ media.can_set_liked = track.can_set_liked;
1218
+ Some(media)
1219
+ }
1220
+
1221
+ /// Transport control through the NetEase page when there is no SMTC session.
1222
+ #[allow(dead_code)] // called by the platform control paths; kept cross-platform.
1223
+ pub fn cdp_control(action: &str, position: f64) -> Result<(), String> {
1224
+ let position = if position.is_finite() {
1225
+ position.max(0.0)
1226
+ } else {
1227
+ 0.0
1228
+ };
1229
+ let expression = match action {
1230
+ "playpause" => "(() => { const button = document.querySelector('button[data-testid=\"tid_playbar_play_btn\"]'); if (!button) return false; button.click(); return true; })()".to_string(),
1231
+ "next" => "(() => { const button = document.querySelector('button[data-testid=\"tid_playbar_next_btn\"]'); if (!button) return false; button.click(); return true; })()".to_string(),
1232
+ "previous" => "(() => { const button = document.querySelector('button[data-testid=\"tid_playbar_prev_btn\"]'); if (!button) return false; button.click(); return true; })()".to_string(),
1233
+ "seek" => format!(
1234
+ r#"(() => {{
1235
+ const range = document.querySelector('footer .slider-default input[type="range"]')
1236
+ || document.querySelector('footer input[type="range"]');
1237
+ if (!range) return false;
1238
+ const max = Number(range.max);
1239
+ if (!(max > 0)) return false;
1240
+ const value = Math.max(0, Math.min(max, {position}));
1241
+ const setter = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value').set;
1242
+ setter.call(range, String(value));
1243
+ range.dispatchEvent(new Event('input', {{bubbles: true}}));
1244
+ range.dispatchEvent(new Event('change', {{bubbles: true}}));
1245
+ return true;
1246
+ }})()"#
1247
+ ),
1248
+ other => return Err(format!("unsupported CDP media action: {other}")),
1249
+ };
1250
+ let value = cdp_evaluate(&expression)?;
1251
+ if value.as_bool() == Some(true) {
1252
+ Ok(())
1253
+ } else {
1254
+ Err(format!("NetEase CDP could not perform {action}"))
1255
+ }
1256
+ }
1257
+
1258
+ fn parse_cdp_state(value: &Value) -> Option<CdpState> {
1259
+ let object = value.as_object()?;
1260
+ let number = |key: &str| {
1261
+ object
1262
+ .get(key)
1263
+ .and_then(Value::as_f64)
1264
+ .filter(|value| value.is_finite())
1265
+ };
1266
+ let flag = |key: &str| object.get(key).and_then(Value::as_bool).unwrap_or(false);
1267
+ let state = CdpState {
1268
+ position: number("position").filter(|value| *value >= 0.0),
1269
+ duration: number("duration").filter(|value| *value > 0.0),
1270
+ liked: object.get("liked").and_then(Value::as_bool),
1271
+ can_like: flag("canLike"),
1272
+ can_set_liked: flag("canSetLiked"),
1273
+ };
1274
+ if state.position.is_none() && state.duration.is_none() && state.liked.is_none() {
1275
+ return None;
1276
+ }
1277
+ Some(state)
1278
+ }
1279
+
1280
+ /// Read progress/liked state from the NetEase CDP page, if the client exposes
1281
+ /// a debugging port. `None` means "CDP unavailable or no play bar", and the
1282
+ /// platform adapter must use the now-playing-service fallback.
1283
+ #[allow(dead_code)] // called by the platform adapters; kept cross-platform.
1284
+ pub fn cdp_state() -> Option<CdpState> {
1285
+ cdp_state_result().ok()
1286
+ }
1287
+
1288
+ fn cdp_target() -> Result<(u16, String), String> {
1289
+ let mut errors = Vec::new();
1290
+ for port in cdp_ports() {
1291
+ match cdp_websocket_url(port) {
1292
+ Ok(url) => return Ok((port, url)),
1293
+ Err(error) => errors.push(format!("{port}: {error}")),
1294
+ }
1295
+ }
1296
+ Err(format!(
1297
+ "NetEase CDP is unavailable ({})",
1298
+ errors.join("; ")
1299
+ ))
1300
+ }
1301
+
1302
+ fn cdp_evaluate(expression: &str) -> Result<Value, String> {
1303
+ let (_port, websocket_url) = cdp_target()?;
1304
+ let mut connection = CdpConnection::connect(&websocket_url)
1305
+ .map_err(|error| format!("CDP websocket failed: {error}"))?;
1306
+ connection
1307
+ .evaluate(expression)
1308
+ .map_err(|error| format!("CDP evaluate failed: {error}"))
1309
+ }
1310
+
1311
+ /// Same as [`cdp_state`] but preserves the failure reason for diagnostics.
1312
+ pub fn cdp_state_result() -> Result<CdpState, String> {
1313
+ let value = cdp_evaluate(&cdp_state_expression())?;
1314
+ parse_cdp_state(&value)
1315
+ .ok_or_else(|| format!("CDP page returned no play-bar state: {}", value.to_string()))
1316
+ }
1317
+
1318
+ /// Diagnostic payload for `media cdp`.
1319
+ pub fn cdp_diagnostic() -> Value {
1320
+ match cdp_state_result() {
1321
+ Ok(state) => {
1322
+ let full = cdp_media().map(|media| {
1323
+ json!({
1324
+ "title": media.title,
1325
+ "artist": media.artist,
1326
+ "album": media.album,
1327
+ "hasCover": media.thumbnail.is_some() || media.thumbnail_url.is_some(),
1328
+ "thumbnailType": media.thumbnail_type,
1329
+ "position": media.position,
1330
+ "duration": media.end_time,
1331
+ "playbackStatus": media.playback_status,
1332
+ "canPlayPause": media.can_play_pause,
1333
+ "canNext": media.can_next,
1334
+ "canPrevious": media.can_previous,
1335
+ "canSeek": media.can_seek,
1336
+ "liked": media.liked,
1337
+ "canLike": media.can_like,
1338
+ "canSetLiked": media.can_set_liked,
1339
+ })
1340
+ });
1341
+ json!({
1342
+ "ok": true,
1343
+ "state": {
1344
+ "position": state.position,
1345
+ "duration": state.duration,
1346
+ "liked": state.liked,
1347
+ "canLike": state.can_like,
1348
+ "canSetLiked": state.can_set_liked,
1349
+ },
1350
+ "full": full,
1351
+ "ports": cdp_ports(),
1352
+ })
1353
+ }
1354
+ Err(error) => json!({
1355
+ "ok": false,
1356
+ "error": error,
1357
+ "ports": cdp_ports(),
1358
+ }),
1359
+ }
1360
+ }
1361
+
1362
+ /// Vendor-first NetEase entry point: combine the system session (metadata and
1363
+ /// transport status) with the CDP page (exact progress and liked state), and
1364
+ /// fall back to UIA/title only when needed.
1365
+ #[allow(dead_code)] // called by `vendor::resolve` from every platform adapter.
1366
+ pub fn merge(
1367
+ platform: Platform,
1368
+ system: Option<Media>,
1369
+ private: Option<Media>,
1370
+ fallback: impl FnOnce() -> Option<(f64, f64)>,
1371
+ ) -> Option<Media> {
1372
+ let system_has_metadata = system
1373
+ .as_ref()
1374
+ .map(|media| !media.title.trim().is_empty() || !media.artist.trim().is_empty())
1375
+ .unwrap_or(false);
1376
+
1377
+ // No usable system session: the page is the complete source when CDP is
1378
+ // available (metadata, cover, progress, liked state and transport).
1379
+ if !system_has_metadata {
1380
+ if let Some(media) = cdp_media() {
1381
+ return Some(media);
1382
+ }
1383
+ }
1384
+
1385
+ let mut fallback = Some(fallback);
1386
+ let mut media = if system_has_metadata {
1387
+ system?
1388
+ } else {
1389
+ // Neither system metadata nor CDP: keep the original private/UIA path.
1390
+ // If that also yields nothing the caller receives `None` (empty media).
1391
+ private?
1392
+ };
1393
+ resolve(
1394
+ platform,
1395
+ &mut media,
1396
+ fallback.take().expect("fallback is consumed once"),
1397
+ );
1398
+ Some(media)
1399
+ }
1400
+
1401
+ /// Whether the system branch has a usable, seek-capable timeline. Windows uses
1402
+ /// the catalog policy; MPRIS/Now Playing are accepted when they expose a real
1403
+ /// timeline and `CanSeek`.
1404
+ fn system_valid(_platform: Platform, media: &Media) -> bool {
1405
+ // Vendor modules may call the selected platform's common system API
1406
+ // directly; windows.rs/linux.rs/macos.rs supply the same validity rule.
1407
+ super::super::platform::system_valid(media)
1408
+ }
1409
+
1410
+ /// Resolve NetEase's progress/liked state. This is the single entry point each
1411
+ /// platform adapter calls after it identifies the app as NetEase:
1412
+ ///
1413
+ /// 1. CDP when the client was launched with a remote-debugging port,
1414
+ /// 2. otherwise the caller-provided accessibility fallback (`MM:SS | MM:SS`),
1415
+ /// 3. otherwise leave the system metadata alone.
1416
+ ///
1417
+ /// With a valid MPRIS/Now Playing session, transport seeking stays on the
1418
+ /// system API; with Windows SMTC (MetadataOnly) it is disabled. Liked state is
1419
+ /// never read from the local database; it is CDP-only.
1420
+ #[allow(dead_code)] // called by vendor::resolve on every platform.
1421
+ pub fn resolve(
1422
+ platform: Platform,
1423
+ media: &mut Media,
1424
+ fallback: impl FnOnce() -> Option<(f64, f64)>,
1425
+ ) {
1426
+ let seekable_system = system_valid(platform, media);
1427
+ if apply_cdp(media, seekable_system) {
1428
+ return;
1429
+ }
1430
+ if let Some((position, total)) = fallback() {
1431
+ if total > 0.0 {
1432
+ media.position = position;
1433
+ media.start_time = 0.0;
1434
+ media.end_time = total;
1435
+ media.min_seek_time = 0.0;
1436
+ media.max_seek_time = total;
1437
+ media.provider_detail = if media.provider_detail.starts_with("smtc") {
1438
+ "smtc+uia".to_string()
1439
+ } else {
1440
+ format!("{}+uia", media.provider_detail)
1441
+ };
1442
+ }
1443
+ }
1444
+ if !seekable_system {
1445
+ media.can_seek = false;
1446
+ }
1447
+ media.last_updated_at = util::now_ms();
1448
+ }
1449
+
1450
+ #[allow(dead_code)] // called by `resolve` on every platform.
1451
+ fn apply_cdp(media: &mut Media, seekable_system: bool) -> bool {
1452
+ let Some(state) = cdp_state() else {
1453
+ return false;
1454
+ };
1455
+ if let Some(position) = state.position {
1456
+ media.position = position;
1457
+ }
1458
+ if let Some(duration) = state.duration {
1459
+ media.start_time = 0.0;
1460
+ media.end_time = duration;
1461
+ media.min_seek_time = 0.0;
1462
+ media.max_seek_time = duration;
1463
+ }
1464
+ media.liked = state.liked;
1465
+ media.can_like = state.can_like;
1466
+ media.can_set_liked = state.can_set_liked;
1467
+ // Windows SMTC has no progress timeline and must not grant seek. MPRIS/Now
1468
+ // Playing can keep their seek grant while CDP supplies exact progress.
1469
+ if !seekable_system {
1470
+ media.can_seek = false;
1471
+ }
1472
+ media.provider_detail = if media.provider_detail.starts_with("smtc") {
1473
+ "smtc+cdp".to_string()
1474
+ } else {
1475
+ format!("{}+cdp", media.provider_detail)
1476
+ };
1477
+ media.last_updated_at = util::now_ms();
1478
+ true
1479
+ }
1480
+
1481
+ /// Toggle/set the red-heart state through CDP. The client's Ctrl+L shortcut is
1482
+ /// sent first (layout-independent); a visible button click is only a fallback
1483
+ /// when key dispatch itself fails. Returns the settled state.
1484
+ #[allow(dead_code)] // called by every platform media control path.
1485
+ pub fn cdp_set_liked(liked: Option<bool>) -> Result<CdpState, String> {
1486
+ let (_port, websocket_url) = cdp_target()?;
1487
+ let mut connection = CdpConnection::connect(&websocket_url)?;
1488
+ let current_value = connection.evaluate(&cdp_state_expression())?;
1489
+ let current = parse_cdp_state(&current_value)
1490
+ .ok_or_else(|| "NetEase CDP page did not expose a play bar".to_string())?;
1491
+ let target = match liked {
1492
+ Some(target) => {
1493
+ if current.liked.is_none() {
1494
+ return Err("NetEase liked state is unknown; cannot set it safely".to_string());
1495
+ }
1496
+ target
1497
+ }
1498
+ None => current
1499
+ .liked
1500
+ .map(|value| !value)
1501
+ .ok_or_else(|| "NetEase liked state is unknown".to_string())?,
1502
+ };
1503
+ if current.liked == Some(target) {
1504
+ return Ok(current);
1505
+ }
1506
+ if !current.can_set_liked {
1507
+ return Err("NetEase like button is disabled".to_string());
1508
+ }
1509
+ // Prefer the client's own Ctrl+L shortcut. It targets the app's like
1510
+ // handler instead of relying on a particular DOM button/layout.
1511
+ if let Err(shortcut_error) = connection.press_like_shortcut() {
1512
+ let click_result = connection.evaluate(CDP_CLICK_LIKE_EXPRESSION)?;
1513
+ if click_result.get("ok").and_then(Value::as_bool) != Some(true) {
1514
+ return Err(format!(
1515
+ "NetEase CDP could not send Ctrl+L ({shortcut_error}) or find the like button"
1516
+ ));
1517
+ }
1518
+ }
1519
+ let mut last = current;
1520
+ for _ in 0..20 {
1521
+ std::thread::sleep(Duration::from_millis(200));
1522
+ if let Ok(value) = connection.evaluate(&cdp_state_expression()) {
1523
+ if let Some(state) = parse_cdp_state(&value) {
1524
+ last = state;
1525
+ if state.liked == Some(target) {
1526
+ return Ok(state);
1527
+ }
1528
+ }
1529
+ }
1530
+ }
1531
+ Err(format!(
1532
+ "NetEase CDP did not apply liked={target} (last={:?})",
1533
+ last.liked
1534
+ ))
1535
+ }
1536
+
1537
+ #[cfg(test)]
1538
+ mod tests {
1539
+ use super::*;
1540
+
1541
+ #[test]
1542
+ #[ignore = "reads the live NetEase CDP page"]
1543
+ fn cdp_identity_returns_title_and_artist() {
1544
+ let (title, artist) = cdp_identity().expect("NetEase must be running with CDP");
1545
+ assert!(!title.trim().is_empty());
1546
+ assert!(!artist.trim().is_empty());
1547
+ }
1548
+
1549
+ #[test]
1550
+ #[ignore = "reads the live NetEase CDP page"]
1551
+ fn cdp_only_merge_returns_full_page_media() {
1552
+ let media = merge(Platform::Windows, None, None, || None)
1553
+ .expect("NetEase must be running with CDP");
1554
+ assert!(!media.title.trim().is_empty());
1555
+ assert_eq!(media.provider, "private");
1556
+ assert_eq!(media.provider_detail, "netease-cdp");
1557
+ assert!(media.thumbnail.is_some());
1558
+ assert!(media.can_play_pause);
1559
+ assert!(!media.can_seek);
1560
+ assert!(media.end_time > 0.0);
1561
+ }
1562
+
1563
+ #[test]
1564
+ #[ignore = "controls the live NetEase CDP page"]
1565
+ fn cdp_only_control_seek_is_safe() {
1566
+ let media = merge(Platform::Windows, None, None, || None)
1567
+ .expect("NetEase must be running with CDP");
1568
+ cdp_control("seek", media.position).expect("CDP seek must apply");
1569
+ }
1570
+
1571
+ #[test]
1572
+ fn parses_cdp_progress_and_liked_state() {
1573
+ let value = json!({
1574
+ "position": 21.012,
1575
+ "duration": 103.0,
1576
+ "liked": true,
1577
+ "canLike": true,
1578
+ "canSetLiked": true
1579
+ });
1580
+ let state = parse_cdp_state(&value).unwrap();
1581
+ assert!((state.position.unwrap() - 21.012).abs() < 0.001);
1582
+ assert!((state.duration.unwrap() - 103.0).abs() < 0.001);
1583
+ assert_eq!(state.liked, Some(true));
1584
+ assert!(state.can_like);
1585
+ assert!(state.can_set_liked);
1586
+ assert!(parse_cdp_state(&json!({ "liked": null })).is_none());
1587
+ }
1588
+
1589
+ fn fixture() -> Connection {
1590
+ let connection = Connection::open_in_memory().unwrap();
1591
+ connection
1592
+ .execute_batch(
1593
+ "create table historyTracks (playtime integer, id text, jsonStr text);
1594
+ create table dbTrack (id text, jsonStr text);
1595
+ create table historyPlaylists (playtime integer, id text, jsonStr text);
1596
+ create table playlistTrackIds (id text, jsonStr text);",
1597
+ )
1598
+ .unwrap();
1599
+ connection
1600
+ }
1601
+
1602
+ #[test]
1603
+ fn normalizes_titles_for_matching() {
1604
+ assert_eq!(normalize(" Hello (Live) "), "hellolive");
1605
+ assert_eq!(normalize("A B\tC"), "abc");
1606
+ }
1607
+
1608
+ #[test]
1609
+ fn parses_netease_window_title() {
1610
+ let parsed = parse_title("晴天 - 周杰伦 - 网易云音乐").unwrap();
1611
+ assert_eq!(parsed.title, "晴天");
1612
+ assert_eq!(parsed.artist, "周杰伦");
1613
+ assert!(parse_title("网易云音乐").is_none());
1614
+ }
1615
+
1616
+ #[test]
1617
+ fn netease_enrichment_never_grants_seek() {
1618
+ let mut media = Media::new("cloudmusic.exe");
1619
+ media.can_seek = false;
1620
+ enrich(&mut media);
1621
+ assert!(!media.can_seek);
1622
+ }
1623
+
1624
+ #[test]
1625
+ fn matches_history_track_and_duration() {
1626
+ let connection = fixture();
1627
+ connection
1628
+ .execute(
1629
+ "insert into historyTracks (playtime, id, jsonStr) values (?, ?, ?)",
1630
+ rusqlite::params![
1631
+ 10,
1632
+ "7",
1633
+ json_track("Tablear", "kuro", "2660424367", 200506.0)
1634
+ ],
1635
+ )
1636
+ .unwrap();
1637
+ let local = read_local_from(&connection, "Tablear", "kuro").unwrap();
1638
+ assert_eq!(local.track_id.as_deref(), Some("2660424367"));
1639
+ assert!((local.duration_seconds.unwrap() - 200.506).abs() < 0.001);
1640
+ }
1641
+
1642
+ #[test]
1643
+ fn unmatched_track_is_none() {
1644
+ let connection = fixture();
1645
+ assert!(find_track(&connection, "missing", "nobody").is_none());
1646
+ }
1647
+
1648
+ #[test]
1649
+ fn identifies_netease_app_ids() {
1650
+ assert!(is_netease("cloudmusic.exe"));
1651
+ assert!(is_netease("com.netease.163music"));
1652
+ assert!(!is_netease("MSEdge"));
1653
+ }
1654
+
1655
+ #[test]
1656
+ fn reads_cover_from_statics_cache() {
1657
+ let directory = util::TempDir::create("dsh-probe-netease-test").unwrap();
1658
+ // Smallest useful PNG: a 1x1 transparent image.
1659
+ let png: [u8; 66] = [
1660
+ 0x89, b'P', b'N', b'G', 0x0D, 0x0A, 0x1A, 0x0A, 0, 0, 0, 0x0D, b'I', b'H', b'D', b'R',
1661
+ 0, 0, 0, 1, 0, 0, 0, 1, 8, 6, 0, 0, 0, 0x1F, 0x15, 0xC4, 0x89, 0, 0, 0, 0x0A, b'I',
1662
+ b'D', b'A', b'T', 0x78, 0x9C, 0x63, 0, 0, 0, 2, 0, 1, 0xE2, 0x21, 0xBC, 0x33, 0, 0, 0,
1663
+ 0, b'I', b'E', b'N', b'D', 0xAE, 0x42, 0x60, 0x82,
1664
+ ];
1665
+ let path = directory.path().join("cover.dat");
1666
+ let mut bytes = png.to_vec();
1667
+ bytes.resize(600, 0);
1668
+ std::fs::write(&path, bytes).unwrap();
1669
+ let cover = cover_from_directory(directory.path()).unwrap();
1670
+ assert!(cover.starts_with("data:image/png;base64,"));
1671
+ }
1672
+
1673
+ #[test]
1674
+ fn ignores_non_image_cache_files() {
1675
+ let directory = util::TempDir::create("dsh-probe-netease-test").unwrap();
1676
+ std::fs::write(directory.path().join("index.dat"), vec![0u8; 4096]).unwrap();
1677
+ assert!(cover_from_directory(directory.path()).is_none());
1678
+ }
1679
+
1680
+ #[test]
1681
+ #[ignore = "reads the local NetEase desktop client database"]
1682
+ fn reads_live_netease_database() {
1683
+ let latest = with_database(|connection| {
1684
+ let columns = column_names(connection, "historyTracks");
1685
+ let json_column = json_column(&columns)?;
1686
+ let sql = format!(
1687
+ "select \"{}\" from historyTracks order by playtime desc limit 1",
1688
+ json_column.replace('"', "\"\"")
1689
+ );
1690
+ let json: String = connection.query_row(&sql, [], |row| row.get(0)).ok()?;
1691
+ let track = parse_track(&json, true)?;
1692
+ Some((
1693
+ track.name,
1694
+ track.artists.first().cloned().unwrap_or_default(),
1695
+ ))
1696
+ })
1697
+ .flatten();
1698
+ let Some((title, artist)) = latest else {
1699
+ eprintln!("no NetEase history track found; skipping live assertions");
1700
+ return;
1701
+ };
1702
+ let local = read_local(&title, &artist).expect("live track should match");
1703
+ assert!(local.track_id.is_some(), "live track id");
1704
+ assert!(
1705
+ local.cover.is_some(),
1706
+ "live cover should be found in Statics"
1707
+ );
1708
+ let mut media = Media::new("cloudmusic.exe");
1709
+ media.title = title;
1710
+ media.artist = artist;
1711
+ media.end_time = 0.0;
1712
+ enrich(&mut media);
1713
+ assert!(media.track_id.is_some());
1714
+ }
1715
+ }