@ohmykreee/dsh-remote-control 0.0.0-stage → 1.0.3
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.
- package/AGENTS.md +556 -0
- package/INSTALLATION.md +310 -0
- package/LICENSE +21 -0
- package/README.md +195 -2
- package/client.js +3 -0
- package/components.json +20 -0
- package/cordis.patch.yml +3 -0
- package/docs/README.en.md +196 -0
- package/docs/screenshots/en/overview.png +0 -0
- package/docs/screenshots/en/question.png +0 -0
- package/docs/screenshots/zh/overview.png +0 -0
- package/docs/screenshots/zh/question.png +0 -0
- package/icon.svg +24 -0
- package/index.js +2 -0
- package/lib/account.js +1 -0
- package/lib/app-mapping.js +1 -0
- package/lib/i18n.js +1 -0
- package/lib/media-enrich.js +1 -0
- package/lib/notifications.js +1 -0
- package/lib/performance.js +1 -0
- package/lib/platform/index.js +1 -0
- package/lib/probe-adapter.js +1 -0
- package/lib/probe-daemon.js +1 -0
- package/lib/probe.js +1 -0
- package/lib/qr.js +1 -0
- package/lib/runtime.js +1 -0
- package/lib/service.js +1 -0
- package/lib/sessions.js +1 -0
- package/lib/settings.js +1 -0
- package/lib/types.js +1 -0
- package/lib/util.js +1 -0
- package/lib/weather.js +1 -0
- package/lib/ws.js +1 -0
- package/locale/en.json +6 -0
- package/locale/zh.json +6 -0
- package/package.json +118 -4
- package/scripts/browser-shot.mjs +217 -0
- package/scripts/build-dsh.mjs +228 -0
- package/scripts/build-rust.mjs +319 -0
- package/scripts/build-web.mjs +17 -0
- package/scripts/check-npm-package.mjs +322 -0
- package/scripts/mock-preview.py +574 -0
- package/scripts/screenshot-compose.py +89 -0
- package/scripts/screenshot-device.sh +75 -0
- package/src/dsh/client/api.ts +68 -0
- package/src/dsh/client/i18n.ts +573 -0
- package/src/dsh/client/icons.ts +56 -0
- package/src/dsh/client/index.ts +95 -0
- package/src/dsh/client/logic.ts +310 -0
- package/src/dsh/client/panel.ts +483 -0
- package/src/dsh/client/sections/apps.ts +40 -0
- package/src/dsh/client/sections/balance.ts +38 -0
- package/src/dsh/client/sections/connection.ts +97 -0
- package/src/dsh/client/sections/notifications.ts +43 -0
- package/src/dsh/client/sections/pomodoro.ts +184 -0
- package/src/dsh/client/sections/preferences.ts +138 -0
- package/src/dsh/client/sections/system.ts +120 -0
- package/src/dsh/client/sections/weather.ts +276 -0
- package/src/dsh/client/styles.ts +1119 -0
- package/src/dsh/client/types.ts +366 -0
- package/src/dsh/client/ui.ts +142 -0
- package/src/dsh/index.ts +108 -0
- package/src/dsh/lib/account.ts +520 -0
- package/src/dsh/lib/app-mapping.ts +366 -0
- package/src/dsh/lib/i18n.ts +82 -0
- package/src/dsh/lib/media-enrich.ts +202 -0
- package/src/dsh/lib/notifications.ts +322 -0
- package/src/dsh/lib/performance.ts +279 -0
- package/src/dsh/lib/platform/index.ts +43 -0
- package/src/dsh/lib/probe-adapter.ts +275 -0
- package/src/dsh/lib/probe-daemon.ts +359 -0
- package/src/dsh/lib/probe.ts +395 -0
- package/src/dsh/lib/qr.ts +42 -0
- package/src/dsh/lib/runtime.ts +126 -0
- package/src/dsh/lib/service.ts +1645 -0
- package/src/dsh/lib/sessions.ts +907 -0
- package/src/dsh/lib/settings.ts +468 -0
- package/src/dsh/lib/types.ts +231 -0
- package/src/dsh/lib/util.ts +115 -0
- package/src/dsh/lib/weather.ts +532 -0
- package/src/dsh/lib/ws.ts +328 -0
- package/src/dsh/types/qrcode-generator.d.ts +16 -0
- package/src/rust/Cargo.lock +1431 -0
- package/src/rust/Cargo.toml +68 -0
- package/src/rust/src/cli.rs +233 -0
- package/src/rust/src/daemon.rs +140 -0
- package/src/rust/src/location/linux.rs +118 -0
- package/src/rust/src/location/macos.rs +26 -0
- package/src/rust/src/location/mod.rs +170 -0
- package/src/rust/src/location/windows.rs +205 -0
- package/src/rust/src/main.rs +42 -0
- package/src/rust/src/media/catalog.rs +454 -0
- package/src/rust/src/media/linux.rs +350 -0
- package/src/rust/src/media/macos/accessibility.rs +59 -0
- package/src/rust/src/media/macos.rs +366 -0
- package/src/rust/src/media/mod.rs +298 -0
- package/src/rust/src/media/vendor/apple_music.rs +90 -0
- package/src/rust/src/media/vendor/common.rs +186 -0
- package/src/rust/src/media/vendor/kugou.rs +52 -0
- package/src/rust/src/media/vendor/kuwo.rs +49 -0
- package/src/rust/src/media/vendor/mod.rs +75 -0
- package/src/rust/src/media/vendor/netease.rs +1715 -0
- package/src/rust/src/media/vendor/qishui.rs +36 -0
- package/src/rust/src/media/vendor/qqmusic.rs +33 -0
- package/src/rust/src/media/vendor/spotify.rs +46 -0
- package/src/rust/src/media/vendor/wesing.rs +39 -0
- package/src/rust/src/media/vendor/youtube_music.rs +49 -0
- package/src/rust/src/media/windows/accessibility.rs +85 -0
- package/src/rust/src/media/windows/audio.rs +116 -0
- package/src/rust/src/media/windows/private.rs +291 -0
- package/src/rust/src/media/windows.rs +775 -0
- package/src/rust/src/notifications/linux.rs +276 -0
- package/src/rust/src/notifications/macos.rs +382 -0
- package/src/rust/src/notifications/mod.rs +244 -0
- package/src/rust/src/notifications/windows.rs +565 -0
- package/src/rust/src/perf/common.rs +109 -0
- package/src/rust/src/perf/linux.rs +282 -0
- package/src/rust/src/perf/macos.rs +28 -0
- package/src/rust/src/perf/mod.rs +157 -0
- package/src/rust/src/perf/windows.rs +981 -0
- package/src/rust/src/util.rs +360 -0
- package/src/rust/src/volume/linux.rs +195 -0
- package/src/rust/src/volume/macos.rs +54 -0
- package/src/rust/src/volume/mod.rs +116 -0
- package/src/rust/src/volume/windows.rs +75 -0
- package/src/rust/tests/cli.rs +79 -0
- package/src/rust/tests/daemon.rs +107 -0
- package/src/svelte/App.svelte +385 -0
- package/src/svelte/index.html +18 -0
- package/src/svelte/lib/billing.svelte.ts +228 -0
- package/src/svelte/lib/components/BalanceCard.svelte +173 -0
- package/src/svelte/lib/components/ClockCard.svelte +183 -0
- package/src/svelte/lib/components/CountdownCard.svelte +261 -0
- package/src/svelte/lib/components/CpuMemoryCard.svelte +238 -0
- package/src/svelte/lib/components/EmptyState.svelte +34 -0
- package/src/svelte/lib/components/GpuCard.svelte +245 -0
- package/src/svelte/lib/components/HomePage.svelte +34 -0
- package/src/svelte/lib/components/MusicControls.svelte +256 -0
- package/src/svelte/lib/components/MusicCover.svelte +88 -0
- package/src/svelte/lib/components/MusicPage.svelte +116 -0
- package/src/svelte/lib/components/NotificationsCard.svelte +109 -0
- package/src/svelte/lib/components/Panel.svelte +22 -0
- package/src/svelte/lib/components/PerformancePage.svelte +55 -0
- package/src/svelte/lib/components/PomodoroCard.svelte +250 -0
- package/src/svelte/lib/components/QuestionSheet.svelte +354 -0
- package/src/svelte/lib/components/RadialGauge.svelte +99 -0
- package/src/svelte/lib/components/Rail.svelte +217 -0
- package/src/svelte/lib/components/SectionHeader.svelte +40 -0
- package/src/svelte/lib/components/SeekBar.svelte +421 -0
- package/src/svelte/lib/components/SessionList.svelte +364 -0
- package/src/svelte/lib/components/SessionStats.svelte +98 -0
- package/src/svelte/lib/components/SessionsPage.svelte +61 -0
- package/src/svelte/lib/components/SpendCard.svelte +105 -0
- package/src/svelte/lib/components/ThermalCard.svelte +249 -0
- package/src/svelte/lib/components/Toast.svelte +32 -0
- package/src/svelte/lib/components/WeatherIcon.svelte +40 -0
- package/src/svelte/lib/components/WeatherPanel.svelte +420 -0
- package/src/svelte/lib/components/ui/alert/alert-action.svelte +20 -0
- package/src/svelte/lib/components/ui/alert/alert-description.svelte +23 -0
- package/src/svelte/lib/components/ui/alert/alert-title.svelte +23 -0
- package/src/svelte/lib/components/ui/alert/alert.svelte +43 -0
- package/src/svelte/lib/components/ui/alert/index.ts +17 -0
- package/src/svelte/lib/components/ui/avatar/avatar-badge.svelte +26 -0
- package/src/svelte/lib/components/ui/avatar/avatar-fallback.svelte +20 -0
- package/src/svelte/lib/components/ui/avatar/avatar-group-count.svelte +23 -0
- package/src/svelte/lib/components/ui/avatar/avatar-group.svelte +23 -0
- package/src/svelte/lib/components/ui/avatar/avatar-image.svelte +17 -0
- package/src/svelte/lib/components/ui/avatar/avatar.svelte +26 -0
- package/src/svelte/lib/components/ui/avatar/index.ts +22 -0
- package/src/svelte/lib/components/ui/badge/badge.svelte +49 -0
- package/src/svelte/lib/components/ui/badge/index.ts +2 -0
- package/src/svelte/lib/components/ui/button/button.svelte +82 -0
- package/src/svelte/lib/components/ui/button/index.ts +17 -0
- package/src/svelte/lib/components/ui/card/card-action.svelte +23 -0
- package/src/svelte/lib/components/ui/card/card-content.svelte +20 -0
- package/src/svelte/lib/components/ui/card/card-description.svelte +20 -0
- package/src/svelte/lib/components/ui/card/card-footer.svelte +20 -0
- package/src/svelte/lib/components/ui/card/card-header.svelte +23 -0
- package/src/svelte/lib/components/ui/card/card-title.svelte +20 -0
- package/src/svelte/lib/components/ui/card/card.svelte +22 -0
- package/src/svelte/lib/components/ui/card/index.ts +25 -0
- package/src/svelte/lib/components/ui/dialog/dialog-close.svelte +11 -0
- package/src/svelte/lib/components/ui/dialog/dialog-content.svelte +48 -0
- package/src/svelte/lib/components/ui/dialog/dialog-description.svelte +17 -0
- package/src/svelte/lib/components/ui/dialog/dialog-footer.svelte +32 -0
- package/src/svelte/lib/components/ui/dialog/dialog-header.svelte +20 -0
- package/src/svelte/lib/components/ui/dialog/dialog-overlay.svelte +17 -0
- package/src/svelte/lib/components/ui/dialog/dialog-portal.svelte +7 -0
- package/src/svelte/lib/components/ui/dialog/dialog-title.svelte +17 -0
- package/src/svelte/lib/components/ui/dialog/dialog-trigger.svelte +11 -0
- package/src/svelte/lib/components/ui/dialog/dialog.svelte +7 -0
- package/src/svelte/lib/components/ui/dialog/index.ts +34 -0
- package/src/svelte/lib/components/ui/empty/empty-content.svelte +23 -0
- package/src/svelte/lib/components/ui/empty/empty-description.svelte +23 -0
- package/src/svelte/lib/components/ui/empty/empty-header.svelte +20 -0
- package/src/svelte/lib/components/ui/empty/empty-media.svelte +41 -0
- package/src/svelte/lib/components/ui/empty/empty-title.svelte +20 -0
- package/src/svelte/lib/components/ui/empty/empty.svelte +23 -0
- package/src/svelte/lib/components/ui/empty/index.ts +22 -0
- package/src/svelte/lib/components/ui/input/index.ts +7 -0
- package/src/svelte/lib/components/ui/input/input.svelte +48 -0
- package/src/svelte/lib/components/ui/progress/index.ts +7 -0
- package/src/svelte/lib/components/ui/progress/progress.svelte +27 -0
- package/src/svelte/lib/components/ui/scroll-area/index.ts +10 -0
- package/src/svelte/lib/components/ui/scroll-area/scroll-area-scrollbar.svelte +30 -0
- package/src/svelte/lib/components/ui/scroll-area/scroll-area.svelte +43 -0
- package/src/svelte/lib/components/ui/separator/index.ts +7 -0
- package/src/svelte/lib/components/ui/separator/separator.svelte +21 -0
- package/src/svelte/lib/components/ui/sheet/index.ts +34 -0
- package/src/svelte/lib/components/ui/sheet/sheet-close.svelte +7 -0
- package/src/svelte/lib/components/ui/sheet/sheet-content.svelte +55 -0
- package/src/svelte/lib/components/ui/sheet/sheet-description.svelte +17 -0
- package/src/svelte/lib/components/ui/sheet/sheet-footer.svelte +20 -0
- package/src/svelte/lib/components/ui/sheet/sheet-header.svelte +20 -0
- package/src/svelte/lib/components/ui/sheet/sheet-overlay.svelte +20 -0
- package/src/svelte/lib/components/ui/sheet/sheet-portal.svelte +7 -0
- package/src/svelte/lib/components/ui/sheet/sheet-title.svelte +17 -0
- package/src/svelte/lib/components/ui/sheet/sheet-trigger.svelte +7 -0
- package/src/svelte/lib/components/ui/sheet/sheet.svelte +7 -0
- package/src/svelte/lib/components/ui/skeleton/index.ts +7 -0
- package/src/svelte/lib/components/ui/skeleton/skeleton.svelte +17 -0
- package/src/svelte/lib/components/ui/slider/index.ts +7 -0
- package/src/svelte/lib/components/ui/slider/slider.svelte +52 -0
- package/src/svelte/lib/components/ui/tooltip/index.ts +19 -0
- package/src/svelte/lib/components/ui/tooltip/tooltip-content.svelte +51 -0
- package/src/svelte/lib/components/ui/tooltip/tooltip-portal.svelte +7 -0
- package/src/svelte/lib/components/ui/tooltip/tooltip-provider.svelte +7 -0
- package/src/svelte/lib/components/ui/tooltip/tooltip-trigger.svelte +7 -0
- package/src/svelte/lib/components/ui/tooltip/tooltip.svelte +7 -0
- package/src/svelte/lib/countdown.svelte.ts +252 -0
- package/src/svelte/lib/format.ts +199 -0
- package/src/svelte/lib/i18n.svelte.ts +583 -0
- package/src/svelte/lib/media.ts +32 -0
- package/src/svelte/lib/pages.ts +18 -0
- package/src/svelte/lib/pomodoro.svelte.ts +223 -0
- package/src/svelte/lib/session-tree.ts +80 -0
- package/src/svelte/lib/state.svelte.ts +359 -0
- package/src/svelte/lib/timer-alarm.svelte.ts +319 -0
- package/src/svelte/lib/types.ts +306 -0
- package/src/svelte/lib/utils.ts +8 -0
- package/src/svelte/lib/weather.ts +84 -0
- package/src/svelte/lib/ws.ts +104 -0
- package/src/svelte/main.ts +13 -0
- package/src/svelte/styles/global.css +315 -0
- package/src/svelte/vite-env.d.ts +12 -0
- package/static/fonts/LICENSE.txt +287 -0
- package/static/icon.svg +24 -0
- package/static/manifest.webmanifest +18 -0
- package/svelte.config.js +5 -0
- package/tsconfig.dsh-client.json +18 -0
- package/tsconfig.dsh.json +21 -0
- package/tsconfig.json +20 -0
- package/vite.config.js +31 -0
- package/web/assets/index-DLdSfnJ3.css +1 -0
- package/web/assets/index-YNE8X0Uy.js +6 -0
- package/web/assets/inter-cyrillic-ext-wght-normal-BOeWTOD4.woff2 +0 -0
- package/web/assets/inter-cyrillic-wght-normal-DqGufNeO.woff2 +0 -0
- package/web/assets/inter-greek-ext-wght-normal-DlzME5K_.woff2 +0 -0
- package/web/assets/inter-greek-wght-normal-CkhJZR-_.woff2 +0 -0
- package/web/assets/inter-latin-ext-wght-normal-DO1Apj_S.woff2 +0 -0
- package/web/assets/inter-latin-wght-normal-Dx4kXJAl.woff2 +0 -0
- package/web/assets/inter-vietnamese-wght-normal-CBcvBZtf.woff2 +0 -0
- package/web/assets/jetbrains-mono-cyrillic-ext-wght-normal-EocZY2iu.woff2 +0 -0
- package/web/assets/jetbrains-mono-cyrillic-wght-normal-D73BlboJ.woff2 +0 -0
- package/web/assets/jetbrains-mono-greek-wght-normal-Bw9x6K1M.woff2 +0 -0
- package/web/assets/jetbrains-mono-latin-ext-wght-normal-DBQx-q_a.woff2 +0 -0
- package/web/assets/jetbrains-mono-latin-wght-normal-B9CIFXIH.woff2 +0 -0
- package/web/assets/jetbrains-mono-vietnamese-wght-normal-Bt-aOZkq.woff2 +0 -0
- package/web/assets/noto-sans-sc-100-wght-normal-DluiXYzi.woff2 +0 -0
- package/web/assets/noto-sans-sc-101-wght-normal-CPkZHnw0.woff2 +0 -0
- package/web/assets/noto-sans-sc-102-wght-normal-gzIulUH6.woff2 +0 -0
- package/web/assets/noto-sans-sc-103-wght-normal-CJfSBxFo.woff2 +0 -0
- package/web/assets/noto-sans-sc-104-wght-normal-B3QMglgE.woff2 +0 -0
- package/web/assets/noto-sans-sc-105-wght-normal-xXh_OMfk.woff2 +0 -0
- package/web/assets/noto-sans-sc-106-wght-normal-B9n3jMjP.woff2 +0 -0
- package/web/assets/noto-sans-sc-107-wght-normal-D7fvVprN.woff2 +0 -0
- package/web/assets/noto-sans-sc-108-wght-normal-J0_LhxVo.woff2 +0 -0
- package/web/assets/noto-sans-sc-109-wght-normal-Dsp-De_0.woff2 +0 -0
- package/web/assets/noto-sans-sc-110-wght-normal-DE_6NUrb.woff2 +0 -0
- package/web/assets/noto-sans-sc-111-wght-normal-C4S-TlTn.woff2 +0 -0
- package/web/assets/noto-sans-sc-112-wght-normal-BcFuP3w6.woff2 +0 -0
- package/web/assets/noto-sans-sc-113-wght-normal-Dk932kmj.woff2 +0 -0
- package/web/assets/noto-sans-sc-114-wght-normal-CO_5Xo-i.woff2 +0 -0
- package/web/assets/noto-sans-sc-115-wght-normal-BWy07H14.woff2 +0 -0
- package/web/assets/noto-sans-sc-116-wght-normal-B4HVAfHo.woff2 +0 -0
- package/web/assets/noto-sans-sc-117-wght-normal-siZzFqgK.woff2 +0 -0
- package/web/assets/noto-sans-sc-118-wght-normal-xhBB7f6V.woff2 +0 -0
- package/web/assets/noto-sans-sc-119-wght-normal-Ck_g2FGz.woff2 +0 -0
- package/web/assets/noto-sans-sc-21-wght-normal-D0x9-yBt.woff2 +0 -0
- package/web/assets/noto-sans-sc-22-wght-normal-BBUVTShg.woff2 +0 -0
- package/web/assets/noto-sans-sc-23-wght-normal-Du1oYWkE.woff2 +0 -0
- package/web/assets/noto-sans-sc-24-wght-normal-CmWjJ0gt.woff2 +0 -0
- package/web/assets/noto-sans-sc-25-wght-normal-BFHiqilg.woff2 +0 -0
- package/web/assets/noto-sans-sc-26-wght-normal-C0sF2Z2f.woff2 +0 -0
- package/web/assets/noto-sans-sc-27-wght-normal-COFdrquJ.woff2 +0 -0
- package/web/assets/noto-sans-sc-28-wght-normal-DMgdNu1o.woff2 +0 -0
- package/web/assets/noto-sans-sc-29-wght-normal-Cmq8em30.woff2 +0 -0
- package/web/assets/noto-sans-sc-30-wght-normal-CZWqZ3yU.woff2 +0 -0
- package/web/assets/noto-sans-sc-31-wght-normal-Wfi_lutW.woff2 +0 -0
- package/web/assets/noto-sans-sc-32-wght-normal-BRJQ4PY5.woff2 +0 -0
- package/web/assets/noto-sans-sc-33-wght-normal-CUVQXNAJ.woff2 +0 -0
- package/web/assets/noto-sans-sc-34-wght-normal-Bspba0wC.woff2 +0 -0
- package/web/assets/noto-sans-sc-35-wght-normal-K8fX_YPG.woff2 +0 -0
- package/web/assets/noto-sans-sc-36-wght-normal-CkITyjoT.woff2 +0 -0
- package/web/assets/noto-sans-sc-37-wght-normal-WCZ2zfEe.woff2 +0 -0
- package/web/assets/noto-sans-sc-38-wght-normal-DeM7CUce.woff2 +0 -0
- package/web/assets/noto-sans-sc-39-wght-normal-BMZNLWQU.woff2 +0 -0
- package/web/assets/noto-sans-sc-4-wght-normal-AR5bdDjz.woff2 +0 -0
- package/web/assets/noto-sans-sc-40-wght-normal-BQ5xpS5j.woff2 +0 -0
- package/web/assets/noto-sans-sc-41-wght-normal-Hthm7FFi.woff2 +0 -0
- package/web/assets/noto-sans-sc-42-wght-normal-eaS2gKds.woff2 +0 -0
- package/web/assets/noto-sans-sc-43-wght-normal-o-T53XXV.woff2 +0 -0
- package/web/assets/noto-sans-sc-44-wght-normal-BJH5J4Ez.woff2 +0 -0
- package/web/assets/noto-sans-sc-45-wght-normal-BKuxSCOJ.woff2 +0 -0
- package/web/assets/noto-sans-sc-46-wght-normal-BR_5Mu6e.woff2 +0 -0
- package/web/assets/noto-sans-sc-47-wght-normal-CyY7gKNX.woff2 +0 -0
- package/web/assets/noto-sans-sc-48-wght-normal-BNMfTR6k.woff2 +0 -0
- package/web/assets/noto-sans-sc-49-wght-normal-DX-0y7wg.woff2 +0 -0
- package/web/assets/noto-sans-sc-5-wght-normal-CfWugllB.woff2 +0 -0
- package/web/assets/noto-sans-sc-50-wght-normal-DAOGW-Dr.woff2 +0 -0
- package/web/assets/noto-sans-sc-51-wght-normal-BlFLOjcP.woff2 +0 -0
- package/web/assets/noto-sans-sc-52-wght-normal-OZjrybnw.woff2 +0 -0
- package/web/assets/noto-sans-sc-53-wght-normal-CwyWNT6x.woff2 +0 -0
- package/web/assets/noto-sans-sc-54-wght-normal-DM0nRe18.woff2 +0 -0
- package/web/assets/noto-sans-sc-55-wght-normal-DBK2wUOR.woff2 +0 -0
- package/web/assets/noto-sans-sc-56-wght-normal-C-VKK1LS.woff2 +0 -0
- package/web/assets/noto-sans-sc-57-wght-normal-Cdbfh5kq.woff2 +0 -0
- package/web/assets/noto-sans-sc-58-wght-normal-CflMKARZ.woff2 +0 -0
- package/web/assets/noto-sans-sc-59-wght-normal-BS4Kwzxx.woff2 +0 -0
- package/web/assets/noto-sans-sc-6-wght-normal-ChnZIQfQ.woff2 +0 -0
- package/web/assets/noto-sans-sc-60-wght-normal-B7QOYsXJ.woff2 +0 -0
- package/web/assets/noto-sans-sc-61-wght-normal-C8JjQS7w.woff2 +0 -0
- package/web/assets/noto-sans-sc-62-wght-normal-bS4wq1Gg.woff2 +0 -0
- package/web/assets/noto-sans-sc-63-wght-normal-BJr8P-OB.woff2 +0 -0
- package/web/assets/noto-sans-sc-64-wght-normal-DVB11t7j.woff2 +0 -0
- package/web/assets/noto-sans-sc-65-wght-normal-DMPCXBvK.woff2 +0 -0
- package/web/assets/noto-sans-sc-66-wght-normal-BobaG10V.woff2 +0 -0
- package/web/assets/noto-sans-sc-67-wght-normal-Dzk7WhCR.woff2 +0 -0
- package/web/assets/noto-sans-sc-68-wght-normal-DGbCD9xu.woff2 +0 -0
- package/web/assets/noto-sans-sc-69-wght-normal-Ctrv1EqA.woff2 +0 -0
- package/web/assets/noto-sans-sc-70-wght-normal-RgTO941D.woff2 +0 -0
- package/web/assets/noto-sans-sc-71-wght-normal-D9sG-CWv.woff2 +0 -0
- package/web/assets/noto-sans-sc-72-wght-normal-C4oWy3ul.woff2 +0 -0
- package/web/assets/noto-sans-sc-73-wght-normal-B1ZoOTgM.woff2 +0 -0
- package/web/assets/noto-sans-sc-74-wght-normal-F7dH_rZJ.woff2 +0 -0
- package/web/assets/noto-sans-sc-75-wght-normal-Cp2wp4si.woff2 +0 -0
- package/web/assets/noto-sans-sc-76-wght-normal-B494D7xS.woff2 +0 -0
- package/web/assets/noto-sans-sc-77-wght-normal-B3MVY9Ax.woff2 +0 -0
- package/web/assets/noto-sans-sc-78-wght-normal-5jX7iNMC.woff2 +0 -0
- package/web/assets/noto-sans-sc-79-wght-normal-BIoZy8fr.woff2 +0 -0
- package/web/assets/noto-sans-sc-80-wght-normal-Bje1Fxy1.woff2 +0 -0
- package/web/assets/noto-sans-sc-81-wght-normal-B2NN7Qi3.woff2 +0 -0
- package/web/assets/noto-sans-sc-82-wght-normal-CUggNZi4.woff2 +0 -0
- package/web/assets/noto-sans-sc-83-wght-normal-99rD8YO-.woff2 +0 -0
- package/web/assets/noto-sans-sc-84-wght-normal-zMBmUocm.woff2 +0 -0
- package/web/assets/noto-sans-sc-85-wght-normal-CjI3qdxG.woff2 +0 -0
- package/web/assets/noto-sans-sc-86-wght-normal-BHY8TVEH.woff2 +0 -0
- package/web/assets/noto-sans-sc-87-wght-normal-DioZSHnU.woff2 +0 -0
- package/web/assets/noto-sans-sc-88-wght-normal-BXYxwf2t.woff2 +0 -0
- package/web/assets/noto-sans-sc-89-wght-normal-ClMFrFaL.woff2 +0 -0
- package/web/assets/noto-sans-sc-90-wght-normal-BnOlPreP.woff2 +0 -0
- package/web/assets/noto-sans-sc-91-wght-normal-DTr9RQcS.woff2 +0 -0
- package/web/assets/noto-sans-sc-97-wght-normal-9u6Aiav-.woff2 +0 -0
- package/web/assets/noto-sans-sc-98-wght-normal-BR-H2C0V.woff2 +0 -0
- package/web/assets/noto-sans-sc-99-wght-normal-BI6XPoxp.woff2 +0 -0
- package/web/assets/noto-sans-sc-cyrillic-wght-normal-BGmsQlw-.woff2 +0 -0
- package/web/assets/noto-sans-sc-latin-ext-wght-normal-DuJTpTg_.woff2 +0 -0
- package/web/assets/noto-sans-sc-latin-wght-normal-DDW8mKAR.woff2 +0 -0
- package/web/assets/noto-sans-sc-vietnamese-wght-normal-Bi3ATJ76.woff2 +0 -0
- package/web/fonts/LICENSE.txt +287 -0
- package/web/icon.svg +24 -0
- package/web/index.html +19 -0
- 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(¤t_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
|
+
}
|