From f9fb2e15701f3ca2f4616376cf342499db63b510 Mon Sep 17 00:00:00 2001 From: Har01d Date: Sun, 5 Apr 2026 16:15:05 +0800 Subject: [PATCH 001/157] =?UTF-8?q?=E8=AE=BE=E8=AE=A1=E6=8F=92=E4=BB=B6?= =?UTF-8?q?=E7=B3=BB=E7=BB=9F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../specs/2026-04-05-plugin-system-design.md | 745 ++++++++++++++++++ 1 file changed, 745 insertions(+) create mode 100644 docs/superpowers/specs/2026-04-05-plugin-system-design.md diff --git a/docs/superpowers/specs/2026-04-05-plugin-system-design.md b/docs/superpowers/specs/2026-04-05-plugin-system-design.md new file mode 100644 index 00000000..05913dad --- /dev/null +++ b/docs/superpowers/specs/2026-04-05-plugin-system-design.md @@ -0,0 +1,745 @@ +# Harmony Plugin System Design + +## Overview + +This document defines the first production plugin system for Harmony. The goal is to move QQ Music out of the host application and ship it as an installable plugin, while also proving the framework with an LRCLIB built-in plugin. + +The design is intentionally conservative: + +- Plugins are trusted Python code loaded in-process. +- Plugins are distributed as zip packages. +- The host exposes a stable SDK and registry-based extension points. +- Plugins must depend only on the SDK, not on Harmony internal modules. +- First phase excludes process sandboxing, permission prompts, and dependency resolution. + +## Goals + +- Introduce a host-owned plugin runtime with discovery, install, load, unload, enable, disable, and uninstall flows. +- Define a stable plugin SDK that QQ Music and future plugins can target. +- Add host extension points for: + - sidebar pages + - settings tabs + - lyrics sources + - cover sources + - artist cover sources + - online music providers +- Migrate `LRCLIBLyricsSource` to a built-in plugin. +- Migrate all QQ Music functionality to a plugin that can be removed from the host repository and published separately. +- Support plugin installation from local zip files and direct URL downloads. +- Add a host-owned `插件` tab to the settings dialog for plugin management. +- Keep the rest of the application functional when the QQ Music plugin is absent. + +## Non-Goals + +- No process isolation or sandboxing. +- No permission approval UI per capability. +- No plugin dependency graph or dependency solver. +- No marketplace UI in the first phase. +- No backward-compatible migration of existing `qqmusic.*` settings. Users may re-login and reconfigure the plugin. +- No generic arbitrary UI injection such as free-form menu patching or unrestricted access to host internals. + +## Current State + +QQ Music is currently a cross-cutting feature embedded in host code: + +- configuration keys and credential helpers live in `system/config.py` +- host bootstrap wires QQ-specific services in `app/bootstrap.py` +- the settings dialog contains a QQ Music tab in `ui/dialogs/settings_dialog.py` +- the main window and sidebar hardcode the online music page in `ui/windows/main_window.py` and `ui/windows/components/sidebar.py` +- online music UI contains QQ-specific login, recommendation, favorite, completion, and refresh logic in `ui/views/online_music_view.py` +- lyrics, cover, and artist cover sources import QQ-specific helpers directly from `services/lyrics/qqmusic_lyrics.py` +- QQ client and service logic live under `services/cloud/qqmusic/` + +This coupling makes independent release impractical. Removing QQ Music today would break bootstrap wiring, settings UI, source registration, and online navigation. + +## Architecture Summary + +### Recommended Approach + +Use a host-owned SDK plus a registry-based plugin runtime. + +- The host owns plugin discovery, lifecycle, compatibility checks, and extension point consumption. +- Plugins register capabilities through a stable `PluginContext`. +- The host consumes only registered extensions and never special-cases a plugin after registration. +- Built-in and external plugins follow the same manifest and lifecycle rules. + +This is the only approach that satisfies the requirement that QQ Music be removable and separately publishable while remaining extensible for future NetEase, Baidu Drive, and Quark Drive plugins. + +### Runtime Layers + +```text +Harmony Host +├── Core App +│ ├── playback, library, queue, settings, theme, event bus +│ └── host UI shells (main window, settings dialog, plugin tab) +├── Plugin Runtime +│ ├── PluginManager +│ ├── PluginInstaller +│ ├── PluginRegistry +│ ├── PluginStateStore +│ └── PluginLoader +├── Stable SDK +│ └── harmony_plugin_api/* +└── Plugins + ├── built-in/lrclib + ├── built-in/... future host plugins + └── external/qqmusic, netease, baidu-drive, quark-drive +``` + +## Host and Plugin Boundary + +### Core Rule + +Plugins may import only `harmony_plugin_api.*` plus Python standard library and their own bundled modules. + +Plugins may not import Harmony internal modules such as: + +- `app.*` +- `domain.*` +- `services.*` +- `repositories.*` +- `infrastructure.*` +- `system.*` +- `ui.*` + +### Enforcement Strategy + +Without sandboxing, import isolation can only be best-effort. First phase uses three layers of enforcement: + +1. SDK-only authoring contract for first-party and third-party plugins. +2. Install-time static audit that rejects obvious imports of Harmony internals from plugin source files. +3. Integration tests that verify the QQ Music plugin no longer imports host internals. + +This does not provide hard security guarantees, but it is sufficient for a trusted-plugin first phase and keeps the API boundary explicit. + +### Stable Host Services + +The host exposes a limited set of stable facades through `PluginContext` instead of raw internal services: + +- logging +- HTTP client access +- event publication and subscription +- plugin-scoped storage +- plugin-scoped settings +- UI registration helpers +- media bridge services for playback, download handoff, lyrics persistence, and artwork fetch handoff + +The host remains free to refactor internal implementations as long as these facades remain stable. + +## Plugin Runtime + +### Components + +#### PluginManager + +Responsibilities: + +- discover built-in and external plugins +- validate compatibility +- load plugin entrypoints +- call `register()` and `unregister()` +- enable and disable plugins +- keep host startup resilient if a plugin fails + +#### PluginInstaller + +Responsibilities: + +- install from local zip +- install from URL by downloading then delegating to zip install +- validate manifest and package structure +- upgrade an existing external plugin safely +- uninstall external plugins + +#### PluginRegistry + +Responsibilities: + +- keep all runtime extension registrations +- support registration and rollback per plugin +- expose typed accessors for each extension point + +#### PluginStateStore + +Responsibilities: + +- persist enabled and disabled state +- persist install source and version +- persist last load error +- support startup decisions without probing every plugin file first + +#### PluginLoader + +Responsibilities: + +- import plugin entry modules +- instantiate entry classes +- isolate per-plugin registration state + +### Lifecycle + +```text +discover -> validate -> load -> register extensions -> active +active -> unregister -> disabled +disabled -> load -> register extensions -> active +active/disabled -> uninstall external package +``` + +Rules: + +- plugin import failure must not crash host startup +- partial registration must roll back cleanly +- built-in plugins may be disabled but not uninstalled +- external plugins may be disabled or uninstalled + +## Plugin Package Format + +### Directories + +Built-in plugins: + +```text +plugins/builtin// +``` + +External plugins: + +```text +data/plugins/external// +``` + +Temporary install workspace: + +```text +data/plugins/tmp/ +``` + +Plugin runtime state: + +```text +data/plugins/state.json +``` + +### Zip Layout + +```text +.zip +├── plugin.json +├── plugin_main.py +├── assets/ +├── translations/ +└── lib/ +``` + +### Manifest + +Example: + +```json +{ + "id": "qqmusic", + "name": "QQ Music", + "version": "1.0.0", + "api_version": "1", + "entrypoint": "plugin_main.py", + "entry_class": "QQMusicPlugin", + "capabilities": [ + "sidebar", + "settings_tab", + "lyrics_source", + "cover", + "online_music_provider" + ], + "min_app_version": "0.1.0" +} +``` + +Required fields: + +- `id` +- `name` +- `version` +- `api_version` +- `entrypoint` +- `entry_class` +- `capabilities` +- `min_app_version` + +Optional first-phase field: + +- `max_app_version` + +Rejected in first phase: + +- dependency declarations +- permission declarations + +## SDK Design + +### SDK Package + +```text +harmony_plugin_api/ +├── __init__.py +├── context.py +├── plugin.py +├── manifest.py +├── registry_types.py +├── settings.py +├── storage.py +├── ui.py +├── lyrics.py +├── cover.py +├── online.py +└── media.py +``` + +### Entry Interface + +```python +class HarmonyPlugin: + plugin_id: str + + def register(self, context: PluginContext) -> None: + ... + + def unregister(self, context: PluginContext) -> None: + ... +``` + +### PluginContext + +First-phase context surface: + +- `plugin_id` +- `manifest` +- `logger` +- `http` +- `events` +- `storage` +- `settings` +- `ui` +- `services` + +### Plugin-Scoped Settings + +Plugins do not extend `SettingKey`. They store values under a namespaced prefix: + +```text +plugins..* +``` + +Examples: + +- `plugins.qqmusic.credential` +- `plugins.qqmusic.quality` +- `plugins.qqmusic.nick` + +Benefits: + +- no host-level config pollution +- uninstall cleanup is straightforward +- future plugins can coexist without key collisions + +### Storage + +Each plugin gets private directories: + +- data dir +- cache dir +- temp dir + +The SDK exposes these paths through `context.storage` instead of having plugins guess host paths. + +## Extension Points + +### Sidebar Entry + +Purpose: allow each music plugin to expose its own first-class navigation entry. + +Definition: + +- `id` +- `title` +- `icon` +- `order` +- `page_factory(context, parent) -> QWidget` + +Implications: + +- the host no longer hardcodes a single online music page +- QQ Music registers its own sidebar entry +- future NetEase plugin registers a separate sidebar entry + +This matches the requirement to avoid a single shared `在线音乐` host page. + +### Settings Tab Extension + +Purpose: allow plugins to provide configuration UI in the host settings dialog. + +Definition: + +- `id` +- `title` +- `order` +- `widget_factory(context, parent) -> QWidget` +- optional lifecycle hooks for save and cancel + +Host behavior: + +- the settings dialog adds a host-owned `插件` tab for plugin management +- plugin tabs such as `QQ 音乐` are added dynamically from the registry + +### Lyrics Source Provider + +Purpose: allow plugins to register lyrics sources without editing `LyricsService`. + +Definition: + +- plugin registers one or more `LyricsSource` implementations through the SDK + +Host behavior: + +- `LyricsService` collects registered sources from `PluginRegistry` +- source order is registry-driven rather than hardcoded in the host + +### Cover Capability + +Purpose: allow plugins to contribute artwork lookups. + +Definition: + +- `register_cover_source(...)` +- `register_artist_cover_source(...)` + +Host behavior: + +- `CoverService` collects registered cover sources and artist cover sources +- QQ Music plugin provides both track cover and artist cover sources + +### Online Music Provider + +Purpose: allow a plugin to own an online-music experience end to end. + +Definition: + +- capability declaration: `online_music_provider` +- provider object registered through the SDK +- provider exposes: + - root page widget + - search + - top lists + - detail retrieval + - playback URL lookup + - lyrics lookup if needed + - recommendation and favorites capabilities if supported + +Host behavior: + +- the main window mounts the provider page from the plugin sidebar entry +- playback and download requests are routed back through host bridge services + +### Deliberately Excluded Extension Points + +The first phase does not support: + +- arbitrary menu injection +- arbitrary toolbar injection +- arbitrary patching of host widgets +- direct registration into raw host event bus internals +- direct access to host repositories or services + +## Host UI Changes + +### Sidebar and Main Window + +The host sidebar becomes dynamic: + +- core pages remain host-owned +- plugin pages are appended from the registry +- page activation and teardown are handled by the host + +Required host refactors: + +- remove hardcoded assumptions that online music occupies a fixed page index +- replace fixed QQ and online navigation wiring with registry-driven routing + +### Settings Dialog + +The settings dialog gains a host-owned `插件` tab. + +This tab manages: + +- installed plugin list +- version and source display +- enable and disable actions +- install from local zip +- install from URL +- uninstall external plugin +- load error display + +Plugin-specific settings remain separate dynamic tabs. For example: + +- `插件` tab: host plugin management +- `QQ 音乐` tab: QQ Music plugin login and quality settings + +## Compatibility and Failure Handling + +### Compatibility Rules + +Two compatibility checks are enforced in the first phase: + +- `api_version` must match the host-supported plugin API version +- `min_app_version` must be less than or equal to the running Harmony version + +If `max_app_version` is present and exceeded: + +- the host warns the user +- the host still treats the plugin as installable in the first phase + +First phase default: warning only for `max_app_version`. + +### Failure Rules + +- invalid manifest: reject installation +- missing entrypoint or entry class: reject installation +- import failure: mark plugin as failed and continue host startup +- registration failure: roll back all registrations for that plugin and mark load error +- uninstall failure: keep plugin state unchanged and show the error in the `插件` tab + +## Installation, Upgrade, and Removal + +### Install From Local Zip + +Flow: + +1. user selects a zip file in the `插件` tab +2. host extracts into `data/plugins/tmp/` +3. host validates package structure and manifest +4. host performs install-time import audit +5. host copies into `data/plugins/external//` +6. host updates `state.json` +7. host optionally enables the plugin immediately + +### Install From URL + +Flow: + +1. user enters a URL in the `插件` tab +2. host downloads the zip into `data/plugins/tmp/` +3. host invokes the same zip install path + +The host should show a warning that plugins run trusted Python code and should only be installed from trusted sources. + +### Upgrade + +Upgrade is an install over an existing external plugin with the same `plugin-id`. + +Safe upgrade flow: + +1. validate new package in temp directory +2. disable and unload current plugin +3. replace plugin directory only after new package passes validation +4. update state +5. re-enable if it was previously enabled + +If upgrade fails after disable: + +- keep the old plugin directory untouched when possible +- restore previous state + +### Uninstall + +Rules: + +- only external plugins can be uninstalled +- built-in plugins can only be disabled + +Optional uninstall cleanup: + +- remove plugin directory +- remove `plugins..*` settings +- remove plugin storage directory + +Because users accepted re-login and reconfiguration, there is no requirement to preserve or migrate old QQ Music settings. + +## Data and DTO Boundaries + +QQ Music cannot rely on host internal domain classes if it is to be shipped independently. + +The SDK therefore defines plugin-facing DTOs such as: + +- `PluginTrack` +- `PluginAlbum` +- `PluginArtist` +- `PluginPlaylist` +- `PluginPlaybackRequest` + +Host bridge code converts these DTOs into Harmony internal models only at the integration boundary. + +This is required to keep the plugin independently releasable and prevent future host refactors from breaking plugin imports. + +## LRCLIB Built-In Plugin Migration + +### Scope + +Move `LRCLIBLyricsSource` out of host source registration and into a built-in plugin. + +### Plugin Capabilities + +- `lyrics_source` + +### Host Changes + +- `LyricsService` stops hardcoding `LRCLIBLyricsSource` +- the built-in LRCLIB plugin registers the source at startup + +### Purpose + +This is the smallest migration that validates the full plugin path: + +- manifest load +- plugin register +- registry consumption +- service integration + +It should be completed before the QQ Music migration. + +## QQ Music Plugin Migration + +### Scope + +Move all QQ Music functionality out of the host repository and into a plugin package. + +### QQ Plugin Capabilities + +- `sidebar` +- `settings_tab` +- `lyrics_source` +- `cover` +- `online_music_provider` + +### Code That Moves Into the Plugin + +- QQ protocol and API clients from `services/cloud/qqmusic/` +- QQ lyrics and cover helpers from `services/lyrics/qqmusic_lyrics.py` +- QQ lyrics source +- QQ cover source +- QQ artist cover source +- QQ-specific settings UI and QR login UI +- QQ-specific online page logic currently embedded in `ui/views/online_music_view.py` +- QQ-specific recommendation, favorite, completion, and hotkey workers + +### Code That Stays in the Host + +- plugin runtime +- plugin management UI +- plugin SDK +- host playback, queue, library, and download bridges +- host lyrics and cover aggregators +- host sidebar and settings shells + +### Migration Notes + +- remove direct QQ imports from `app/bootstrap.py` +- remove QQ-specific fixed tab construction from `ui/dialogs/settings_dialog.py` +- remove QQ-specific fixed page assumptions from `ui/windows/main_window.py` +- replace hardcoded QQ registration in lyrics and cover services with registry-driven source collection +- move plugin settings to `plugins.qqmusic.*` + +### Release End State + +When the migration is complete: + +- the host app starts and runs without QQ Music installed +- installing the QQ Music zip adds a sidebar entry and a settings tab +- uninstalling the QQ Music plugin removes those extensions without breaking host startup + +## Future Plugins + +This framework is intentionally designed to support additional plugins without special host cases: + +- NetEase music plugin as another online music provider +- Baidu Drive plugin +- Quark Drive plugin + +The first phase focuses on music and source plugins, but the registry model keeps enough separation to add drive-provider extension points in a separate follow-up design. + +## Testing Strategy + +### Unit Tests + +- manifest parsing and validation +- compatibility checks for `api_version` and `min_app_version` +- install, upgrade, disable, enable, and uninstall flows +- registry rollback on registration failure +- plugin-scoped settings prefix behavior + +### Service Tests + +- `LyricsService` consumes registered lyrics sources +- `CoverService` consumes registered cover and artist cover sources +- disabling a plugin removes its sources from host aggregation + +### UI Tests + +- settings dialog shows the host-owned `插件` tab +- plugin management actions update plugin state correctly +- plugin settings tabs appear and disappear dynamically +- plugin sidebar entries appear and disappear dynamically + +### Integration Tests + +- LRCLIB built-in plugin loads and participates in lyrics search +- QQ Music plugin install adds its sidebar page and settings tab +- host startup succeeds when QQ plugin is absent +- QQ plugin can be disabled and re-enabled without restart corruption + +### Regression Guard + +Add tests or checks that fail if the QQ plugin imports Harmony internal modules directly. + +## Delivery Phases + +### Phase 1: Host Plugin Runtime + +- add `PluginManager`, `PluginInstaller`, `PluginRegistry`, `PluginStateStore`, `PluginLoader` +- add `harmony_plugin_api` +- add host `插件` tab to settings dialog + +### Phase 2: Registry-Driven Consumption + +- make lyrics and cover services read from registry +- make main window and sidebar register plugin pages dynamically +- make settings dialog mount plugin tabs dynamically + +### Phase 3: LRCLIB Built-In Plugin + +- move LRCLIB lyrics source into a built-in plugin +- validate the end-to-end host and plugin flow + +### Phase 4: QQ Music Plugin + +- move QQ service, UI, sources, and provider logic into a plugin +- remove host direct imports and `qqmusic.*` config helpers + +### Phase 5: External Distribution + +- package QQ Music as an installable external zip +- verify host works with plugin removed from the repository + +## Baseline Quality Note + +At the time this design was approved, a baseline run of `uv run pytest tests/` in a fresh worktree was not clean. Existing failures appeared before any plugin-system implementation, including a visible failure in `tests/test_artist_navigation.py::test_artist_navigation` and a later crash around `tests/test_qthread_fix.py::test_main_window_close`. + +This does not change the plugin design, but implementation work must treat baseline failures separately from plugin regressions. + +## Final Design Decisions + +- QQ Music must depend only on the plugin SDK, not on host internals. +- Existing QQ settings do not need migration; users may re-login and reconfigure. +- Each music plugin gets its own sidebar entry instead of contributing into one host-owned online page. +- First phase supports trusted Python plugins only. +- Plugin management lives in a new host-owned `插件` tab inside the existing settings dialog. +- Plugin-specific configuration remains in dynamically registered settings tabs. +- `cover` is a first-class capability and includes both track cover and artist cover source registration. From 5fa90a4f93a62e421aabf4961fdad4fd389d618d Mon Sep 17 00:00:00 2001 From: Har01d Date: Sun, 5 Apr 2026 16:42:05 +0800 Subject: [PATCH 002/157] =?UTF-8?q?=E6=96=B0=E5=A2=9E=E6=8F=92=E4=BB=B6SDK?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- harmony_plugin_api/__init__.py | 11 ++++ harmony_plugin_api/context.py | 68 +++++++++++++++++++++ harmony_plugin_api/cover.py | 51 ++++++++++++++++ harmony_plugin_api/lyrics.py | 34 +++++++++++ harmony_plugin_api/manifest.py | 73 +++++++++++++++++++++++ harmony_plugin_api/media.py | 24 ++++++++ harmony_plugin_api/online.py | 18 ++++++ harmony_plugin_api/plugin.py | 15 +++++ harmony_plugin_api/registry_types.py | 23 +++++++ tests/test_system/test_plugin_manifest.py | 57 ++++++++++++++++++ 10 files changed, 374 insertions(+) create mode 100644 harmony_plugin_api/__init__.py create mode 100644 harmony_plugin_api/context.py create mode 100644 harmony_plugin_api/cover.py create mode 100644 harmony_plugin_api/lyrics.py create mode 100644 harmony_plugin_api/manifest.py create mode 100644 harmony_plugin_api/media.py create mode 100644 harmony_plugin_api/online.py create mode 100644 harmony_plugin_api/plugin.py create mode 100644 harmony_plugin_api/registry_types.py create mode 100644 tests/test_system/test_plugin_manifest.py diff --git a/harmony_plugin_api/__init__.py b/harmony_plugin_api/__init__.py new file mode 100644 index 00000000..d1035456 --- /dev/null +++ b/harmony_plugin_api/__init__.py @@ -0,0 +1,11 @@ +from .context import PluginContext +from .manifest import Capability, PluginManifest, PluginManifestError +from .plugin import HarmonyPlugin + +__all__ = [ + "Capability", + "HarmonyPlugin", + "PluginContext", + "PluginManifest", + "PluginManifestError", +] diff --git a/harmony_plugin_api/context.py b/harmony_plugin_api/context.py new file mode 100644 index 00000000..ab7b7c73 --- /dev/null +++ b/harmony_plugin_api/context.py @@ -0,0 +1,68 @@ +from __future__ import annotations + +from dataclasses import dataclass +from pathlib import Path +from typing import Any, Protocol + +from .manifest import PluginManifest + + +class PluginSettingsBridge(Protocol): + def get(self, key: str, default: Any = None) -> Any: + ... + + def set(self, key: str, value: Any) -> None: + ... + + +class PluginStorageBridge(Protocol): + @property + def data_dir(self) -> Path: + ... + + @property + def cache_dir(self) -> Path: + ... + + @property + def temp_dir(self) -> Path: + ... + + +class PluginUiBridge(Protocol): + def register_sidebar_entry(self, spec: Any) -> None: + ... + + def register_settings_tab(self, spec: Any) -> None: + ... + + +class PluginServiceBridge(Protocol): + def register_lyrics_source(self, source: Any) -> None: + ... + + def register_cover_source(self, source: Any) -> None: + ... + + def register_artist_cover_source(self, source: Any) -> None: + ... + + def register_online_music_provider(self, provider: Any) -> None: + ... + + @property + def media(self) -> Any: + ... + + +@dataclass(frozen=True) +class PluginContext: + plugin_id: str + manifest: PluginManifest + logger: Any + http: Any + events: Any + storage: PluginStorageBridge + settings: PluginSettingsBridge + ui: PluginUiBridge + services: PluginServiceBridge diff --git a/harmony_plugin_api/cover.py b/harmony_plugin_api/cover.py new file mode 100644 index 00000000..ede4be30 --- /dev/null +++ b/harmony_plugin_api/cover.py @@ -0,0 +1,51 @@ +from __future__ import annotations + +from dataclasses import dataclass +from typing import Protocol + + +@dataclass(frozen=True) +class PluginCoverResult: + item_id: str + title: str + artist: str + album: str = "" + duration: float | None = None + source: str = "" + cover_url: str | None = None + extra_id: str | None = None + + +@dataclass(frozen=True) +class PluginArtistCoverResult: + artist_id: str + name: str + source: str = "" + cover_url: str | None = None + album_count: int | None = None + + +class PluginCoverSource(Protocol): + source_id: str + display_name: str + + def search( + self, + title: str, + artist: str, + album: str = "", + duration: float | None = None, + ) -> list[PluginCoverResult]: + ... + + +class PluginArtistCoverSource(Protocol): + source_id: str + display_name: str + + def search( + self, + artist_name: str, + limit: int = 10, + ) -> list[PluginArtistCoverResult]: + ... diff --git a/harmony_plugin_api/lyrics.py b/harmony_plugin_api/lyrics.py new file mode 100644 index 00000000..9fe8742a --- /dev/null +++ b/harmony_plugin_api/lyrics.py @@ -0,0 +1,34 @@ +from __future__ import annotations + +from dataclasses import dataclass +from typing import Protocol + + +@dataclass(frozen=True) +class PluginLyricsResult: + song_id: str + title: str + artist: str + album: str = "" + duration: float | None = None + source: str = "" + cover_url: str | None = None + lyrics: str | None = None + accesskey: str | None = None + supports_yrc: bool = False + + +class PluginLyricsSource(Protocol): + source_id: str + display_name: str + + def search( + self, + title: str, + artist: str, + limit: int = 10, + ) -> list[PluginLyricsResult]: + ... + + def get_lyrics(self, result: PluginLyricsResult) -> str | None: + ... diff --git a/harmony_plugin_api/manifest.py b/harmony_plugin_api/manifest.py new file mode 100644 index 00000000..1d9c6758 --- /dev/null +++ b/harmony_plugin_api/manifest.py @@ -0,0 +1,73 @@ +from __future__ import annotations + +from dataclasses import dataclass +from typing import Any, Literal + + +Capability = Literal[ + "sidebar", + "settings_tab", + "lyrics_source", + "cover", + "online_music_provider", +] + +_ALLOWED_CAPABILITIES = { + "sidebar", + "settings_tab", + "lyrics_source", + "cover", + "online_music_provider", +} + + +class PluginManifestError(ValueError): + pass + + +@dataclass(frozen=True) +class PluginManifest: + id: str + name: str + version: str + api_version: str + entrypoint: str + entry_class: str + capabilities: tuple[str, ...] + min_app_version: str + max_app_version: str | None = None + + @classmethod + def from_dict(cls, data: dict[str, Any]) -> "PluginManifest": + required = ( + "id", + "name", + "version", + "api_version", + "entrypoint", + "entry_class", + "capabilities", + "min_app_version", + ) + missing = [key for key in required if key not in data] + if missing: + raise PluginManifestError(f"Missing manifest keys: {', '.join(missing)}") + + capabilities = tuple(str(item) for item in data["capabilities"]) + unknown = sorted(set(capabilities) - _ALLOWED_CAPABILITIES) + if unknown: + raise PluginManifestError(f"Unknown capabilities: {', '.join(unknown)}") + + return cls( + id=str(data["id"]), + name=str(data["name"]), + version=str(data["version"]), + api_version=str(data["api_version"]), + entrypoint=str(data["entrypoint"]), + entry_class=str(data["entry_class"]), + capabilities=capabilities, + min_app_version=str(data["min_app_version"]), + max_app_version=str(data["max_app_version"]) + if data.get("max_app_version") + else None, + ) diff --git a/harmony_plugin_api/media.py b/harmony_plugin_api/media.py new file mode 100644 index 00000000..bb88bac4 --- /dev/null +++ b/harmony_plugin_api/media.py @@ -0,0 +1,24 @@ +from __future__ import annotations + +from dataclasses import dataclass, field +from typing import Any + + +@dataclass(frozen=True) +class PluginTrack: + track_id: str + title: str + artist: str + album: str = "" + duration: int | None = None + artwork_url: str | None = None + metadata: dict[str, Any] = field(default_factory=dict) + + +@dataclass(frozen=True) +class PluginPlaybackRequest: + provider_id: str + track_id: str + title: str + quality: str + metadata: dict[str, Any] diff --git a/harmony_plugin_api/online.py b/harmony_plugin_api/online.py new file mode 100644 index 00000000..784663c3 --- /dev/null +++ b/harmony_plugin_api/online.py @@ -0,0 +1,18 @@ +from __future__ import annotations + +from typing import Any, Protocol + + +class PluginOnlineProvider(Protocol): + provider_id: str + display_name: str + + def create_page(self, context: Any, parent: Any = None) -> Any: + ... + + def get_playback_url_info( + self, + track_id: str, + quality: str, + ) -> dict[str, Any] | None: + ... diff --git a/harmony_plugin_api/plugin.py b/harmony_plugin_api/plugin.py new file mode 100644 index 00000000..315e4531 --- /dev/null +++ b/harmony_plugin_api/plugin.py @@ -0,0 +1,15 @@ +from __future__ import annotations + +from typing import Protocol + +from .context import PluginContext + + +class HarmonyPlugin(Protocol): + plugin_id: str + + def register(self, context: PluginContext) -> None: + ... + + def unregister(self, context: PluginContext) -> None: + ... diff --git a/harmony_plugin_api/registry_types.py b/harmony_plugin_api/registry_types.py new file mode 100644 index 00000000..e7c46fb5 --- /dev/null +++ b/harmony_plugin_api/registry_types.py @@ -0,0 +1,23 @@ +from __future__ import annotations + +from dataclasses import dataclass +from typing import Any, Callable + + +@dataclass(frozen=True) +class SidebarEntrySpec: + plugin_id: str + entry_id: str + title: str + order: int + icon_name: str | None + page_factory: Callable[[Any, Any], Any] + + +@dataclass(frozen=True) +class SettingsTabSpec: + plugin_id: str + tab_id: str + title: str + order: int + widget_factory: Callable[[Any, Any], Any] diff --git a/tests/test_system/test_plugin_manifest.py b/tests/test_system/test_plugin_manifest.py new file mode 100644 index 00000000..b09cdb55 --- /dev/null +++ b/tests/test_system/test_plugin_manifest.py @@ -0,0 +1,57 @@ +import pytest + +from harmony_plugin_api.manifest import PluginManifest, PluginManifestError +from harmony_plugin_api.registry_types import SidebarEntrySpec + + +def test_manifest_accepts_cover_capability(): + manifest = PluginManifest.from_dict( + { + "id": "qqmusic", + "name": "QQ Music", + "version": "1.0.0", + "api_version": "1", + "entrypoint": "plugin_main.py", + "entry_class": "QQMusicPlugin", + "capabilities": [ + "sidebar", + "settings_tab", + "lyrics_source", + "cover", + "online_music_provider", + ], + "min_app_version": "0.1.0", + } + ) + + assert manifest.id == "qqmusic" + assert "cover" in manifest.capabilities + + +def test_manifest_rejects_unknown_capability(): + with pytest.raises(PluginManifestError): + PluginManifest.from_dict( + { + "id": "broken", + "name": "Broken Plugin", + "version": "1.0.0", + "api_version": "1", + "entrypoint": "plugin_main.py", + "entry_class": "BrokenPlugin", + "capabilities": ["sidebar", "banana"], + "min_app_version": "0.1.0", + } + ) + + +def test_sidebar_spec_requires_widget_factory(): + spec = SidebarEntrySpec( + plugin_id="qqmusic", + entry_id="qqmusic.sidebar", + title="QQ Music", + order=80, + icon_name="GLOBE", + page_factory=lambda _context, _parent: object(), + ) + + assert spec.entry_id == "qqmusic.sidebar" From 0314b64c7de29688c49b765350793ec785fcb334 Mon Sep 17 00:00:00 2001 From: Har01d Date: Sun, 5 Apr 2026 16:54:45 +0800 Subject: [PATCH 003/157] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E6=8F=92=E4=BB=B6SDK?= =?UTF-8?q?=E5=A5=91=E7=BA=A6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- harmony_plugin_api/__init__.py | 33 ++++++++++++++- harmony_plugin_api/manifest.py | 44 +++++++++++++++----- harmony_plugin_api/online.py | 4 ++ tests/test_system/test_plugin_manifest.py | 50 +++++++++++++++++++++-- 4 files changed, 116 insertions(+), 15 deletions(-) diff --git a/harmony_plugin_api/__init__.py b/harmony_plugin_api/__init__.py index d1035456..81242927 100644 --- a/harmony_plugin_api/__init__.py +++ b/harmony_plugin_api/__init__.py @@ -1,11 +1,42 @@ -from .context import PluginContext +from .context import ( + PluginContext, + PluginServiceBridge, + PluginSettingsBridge, + PluginStorageBridge, + PluginUiBridge, +) +from .cover import ( + PluginArtistCoverResult, + PluginArtistCoverSource, + PluginCoverResult, + PluginCoverSource, +) +from .lyrics import PluginLyricsResult, PluginLyricsSource from .manifest import Capability, PluginManifest, PluginManifestError +from .media import PluginPlaybackRequest, PluginTrack +from .online import PluginOnlineProvider from .plugin import HarmonyPlugin +from .registry_types import SettingsTabSpec, SidebarEntrySpec __all__ = [ "Capability", "HarmonyPlugin", + "PluginArtistCoverResult", + "PluginArtistCoverSource", "PluginContext", + "PluginCoverResult", + "PluginCoverSource", + "PluginLyricsResult", + "PluginLyricsSource", "PluginManifest", "PluginManifestError", + "PluginOnlineProvider", + "PluginPlaybackRequest", + "PluginServiceBridge", + "PluginSettingsBridge", + "PluginStorageBridge", + "PluginTrack", + "PluginUiBridge", + "SettingsTabSpec", + "SidebarEntrySpec", ] diff --git a/harmony_plugin_api/manifest.py b/harmony_plugin_api/manifest.py index 1d9c6758..c050494d 100644 --- a/harmony_plugin_api/manifest.py +++ b/harmony_plugin_api/manifest.py @@ -25,6 +25,13 @@ class PluginManifestError(ValueError): pass +def _require_str(data: dict[str, Any], key: str) -> str: + value = data.get(key) + if not isinstance(value, str): + raise PluginManifestError(f"Manifest field '{key}' must be a string") + return value + + @dataclass(frozen=True) class PluginManifest: id: str @@ -53,21 +60,36 @@ def from_dict(cls, data: dict[str, Any]) -> "PluginManifest": if missing: raise PluginManifestError(f"Missing manifest keys: {', '.join(missing)}") - capabilities = tuple(str(item) for item in data["capabilities"]) + capabilities_raw = data["capabilities"] + if isinstance(capabilities_raw, str) or not isinstance( + capabilities_raw, (list, tuple) + ): + raise PluginManifestError( + "Manifest field 'capabilities' must be a list/tuple of strings" + ) + if not all(isinstance(item, str) for item in capabilities_raw): + raise PluginManifestError( + "Manifest field 'capabilities' must be a list/tuple of strings" + ) + capabilities = tuple(capabilities_raw) unknown = sorted(set(capabilities) - _ALLOWED_CAPABILITIES) if unknown: raise PluginManifestError(f"Unknown capabilities: {', '.join(unknown)}") + max_app_version = data.get("max_app_version") + if max_app_version is not None and not isinstance(max_app_version, str): + raise PluginManifestError( + "Manifest field 'max_app_version' must be a string if provided" + ) + return cls( - id=str(data["id"]), - name=str(data["name"]), - version=str(data["version"]), - api_version=str(data["api_version"]), - entrypoint=str(data["entrypoint"]), - entry_class=str(data["entry_class"]), + id=_require_str(data, "id"), + name=_require_str(data, "name"), + version=_require_str(data, "version"), + api_version=_require_str(data, "api_version"), + entrypoint=_require_str(data, "entrypoint"), + entry_class=_require_str(data, "entry_class"), capabilities=capabilities, - min_app_version=str(data["min_app_version"]), - max_app_version=str(data["max_app_version"]) - if data.get("max_app_version") - else None, + min_app_version=_require_str(data, "min_app_version"), + max_app_version=max_app_version, ) diff --git a/harmony_plugin_api/online.py b/harmony_plugin_api/online.py index 784663c3..f938f7a2 100644 --- a/harmony_plugin_api/online.py +++ b/harmony_plugin_api/online.py @@ -2,6 +2,10 @@ from typing import Any, Protocol +from .media import PluginPlaybackRequest, PluginTrack + +__all__ = ["PluginOnlineProvider", "PluginPlaybackRequest", "PluginTrack"] + class PluginOnlineProvider(Protocol): provider_id: str diff --git a/tests/test_system/test_plugin_manifest.py b/tests/test_system/test_plugin_manifest.py index b09cdb55..4286fb93 100644 --- a/tests/test_system/test_plugin_manifest.py +++ b/tests/test_system/test_plugin_manifest.py @@ -1,6 +1,7 @@ import pytest from harmony_plugin_api.manifest import PluginManifest, PluginManifestError +from harmony_plugin_api.online import PluginTrack from harmony_plugin_api.registry_types import SidebarEntrySpec @@ -44,14 +45,57 @@ def test_manifest_rejects_unknown_capability(): ) -def test_sidebar_spec_requires_widget_factory(): +@pytest.mark.parametrize( + ("field", "value"), + [ + ("entrypoint", None), + ("capabilities", "cover"), + ], +) +def test_manifest_rejects_invalid_field_types(field, value): + payload = { + "id": "broken", + "name": "Broken Plugin", + "version": "1.0.0", + "api_version": "1", + "entrypoint": "plugin_main.py", + "entry_class": "BrokenPlugin", + "capabilities": ["sidebar", "cover"], + "min_app_version": "0.1.0", + } + payload[field] = value + + with pytest.raises(PluginManifestError): + PluginManifest.from_dict(payload) + + +def test_sidebar_spec_page_factory_contract(): + calls = [] + + def page_factory(context, parent): + calls.append((context, parent)) + return {"ok": True} + spec = SidebarEntrySpec( plugin_id="qqmusic", entry_id="qqmusic.sidebar", title="QQ Music", order=80, icon_name="GLOBE", - page_factory=lambda _context, _parent: object(), + page_factory=page_factory, + ) + + created = spec.page_factory("ctx", "parent") + + assert calls == [("ctx", "parent")] + assert created == {"ok": True} + + +def test_online_module_exports_plugin_track(): + track = PluginTrack( + track_id="1", + title="Song", + artist="Singer", ) - assert spec.entry_id == "qqmusic.sidebar" + assert track.track_id == "1" From f48817168bd2ab9baccc68f01415aaa2e0b509ce Mon Sep 17 00:00:00 2001 From: Har01d Date: Sun, 5 Apr 2026 17:05:04 +0800 Subject: [PATCH 004/157] =?UTF-8?q?=E6=94=B6=E7=B4=A7=E6=B8=85=E5=8D=95?= =?UTF-8?q?=E6=A0=A1=E9=AA=8C=E4=B8=8ESDK=E7=B1=BB=E5=9E=8B=E5=A5=91?= =?UTF-8?q?=E7=BA=A6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- harmony_plugin_api/__init__.py | 2 + harmony_plugin_api/context.py | 24 +++++-- harmony_plugin_api/manifest.py | 4 ++ tests/test_system/test_plugin_manifest.py | 79 ++++++++++++++++++++++- 4 files changed, 101 insertions(+), 8 deletions(-) diff --git a/harmony_plugin_api/__init__.py b/harmony_plugin_api/__init__.py index 81242927..b6905a40 100644 --- a/harmony_plugin_api/__init__.py +++ b/harmony_plugin_api/__init__.py @@ -1,5 +1,6 @@ from .context import ( PluginContext, + PluginMediaBridge, PluginServiceBridge, PluginSettingsBridge, PluginStorageBridge, @@ -30,6 +31,7 @@ "PluginLyricsSource", "PluginManifest", "PluginManifestError", + "PluginMediaBridge", "PluginOnlineProvider", "PluginPlaybackRequest", "PluginServiceBridge", diff --git a/harmony_plugin_api/context.py b/harmony_plugin_api/context.py index ab7b7c73..2685279d 100644 --- a/harmony_plugin_api/context.py +++ b/harmony_plugin_api/context.py @@ -4,7 +4,11 @@ from pathlib import Path from typing import Any, Protocol +from .cover import PluginArtistCoverSource, PluginCoverSource +from .lyrics import PluginLyricsSource from .manifest import PluginManifest +from .online import PluginOnlineProvider +from .registry_types import SettingsTabSpec, SidebarEntrySpec class PluginSettingsBridge(Protocol): @@ -30,28 +34,34 @@ def temp_dir(self) -> Path: class PluginUiBridge(Protocol): - def register_sidebar_entry(self, spec: Any) -> None: + def register_sidebar_entry(self, spec: SidebarEntrySpec) -> None: ... - def register_settings_tab(self, spec: Any) -> None: + def register_settings_tab(self, spec: SettingsTabSpec) -> None: + ... + + +class PluginMediaBridge(Protocol): + # Marker bridge for host media operations exposed to plugins. + def __repr__(self) -> str: ... class PluginServiceBridge(Protocol): - def register_lyrics_source(self, source: Any) -> None: + def register_lyrics_source(self, source: PluginLyricsSource) -> None: ... - def register_cover_source(self, source: Any) -> None: + def register_cover_source(self, source: PluginCoverSource) -> None: ... - def register_artist_cover_source(self, source: Any) -> None: + def register_artist_cover_source(self, source: PluginArtistCoverSource) -> None: ... - def register_online_music_provider(self, provider: Any) -> None: + def register_online_music_provider(self, provider: PluginOnlineProvider) -> None: ... @property - def media(self) -> Any: + def media(self) -> PluginMediaBridge: ... diff --git a/harmony_plugin_api/manifest.py b/harmony_plugin_api/manifest.py index c050494d..9ee9f39a 100644 --- a/harmony_plugin_api/manifest.py +++ b/harmony_plugin_api/manifest.py @@ -29,6 +29,10 @@ def _require_str(data: dict[str, Any], key: str) -> str: value = data.get(key) if not isinstance(value, str): raise PluginManifestError(f"Manifest field '{key}' must be a string") + if not value.strip(): + raise PluginManifestError( + f"Manifest field '{key}' must be a non-empty string" + ) return value diff --git a/tests/test_system/test_plugin_manifest.py b/tests/test_system/test_plugin_manifest.py index 4286fb93..53dfc4b5 100644 --- a/tests/test_system/test_plugin_manifest.py +++ b/tests/test_system/test_plugin_manifest.py @@ -1,8 +1,14 @@ import pytest +import harmony_plugin_api as api +import harmony_plugin_api.context as context_module +from typing import get_type_hints +from harmony_plugin_api.cover import PluginArtistCoverSource, PluginCoverSource +from harmony_plugin_api.lyrics import PluginLyricsSource from harmony_plugin_api.manifest import PluginManifest, PluginManifestError -from harmony_plugin_api.online import PluginTrack +from harmony_plugin_api.online import PluginOnlineProvider, PluginTrack from harmony_plugin_api.registry_types import SidebarEntrySpec +from harmony_plugin_api.registry_types import SettingsTabSpec def test_manifest_accepts_cover_capability(): @@ -69,6 +75,31 @@ def test_manifest_rejects_invalid_field_types(field, value): PluginManifest.from_dict(payload) +@pytest.mark.parametrize( + ("field", "value"), + [ + ("id", ""), + ("entrypoint", ""), + ("entry_class", " "), + ], +) +def test_manifest_rejects_empty_or_whitespace_required_strings(field, value): + payload = { + "id": "qqmusic", + "name": "QQ Music", + "version": "1.0.0", + "api_version": "1", + "entrypoint": "plugin_main.py", + "entry_class": "QQMusicPlugin", + "capabilities": ["sidebar", "cover"], + "min_app_version": "0.1.0", + } + payload[field] = value + + with pytest.raises(PluginManifestError): + PluginManifest.from_dict(payload) + + def test_sidebar_spec_page_factory_contract(): calls = [] @@ -99,3 +130,49 @@ def test_online_module_exports_plugin_track(): ) assert track.track_id == "1" + + +def test_context_bridges_use_sdk_contract_types(): + module_globals = vars(context_module) + sidebar_hints = get_type_hints( + context_module.PluginUiBridge.register_sidebar_entry, + globalns=module_globals, + ) + settings_hints = get_type_hints( + context_module.PluginUiBridge.register_settings_tab, + globalns=module_globals, + ) + lyrics_hints = get_type_hints( + context_module.PluginServiceBridge.register_lyrics_source, + globalns=module_globals, + ) + cover_hints = get_type_hints( + context_module.PluginServiceBridge.register_cover_source, + globalns=module_globals, + ) + artist_cover_hints = get_type_hints( + context_module.PluginServiceBridge.register_artist_cover_source, + globalns=module_globals, + ) + provider_hints = get_type_hints( + context_module.PluginServiceBridge.register_online_music_provider, + globalns=module_globals, + ) + media_hints = get_type_hints( + context_module.PluginServiceBridge.media.fget, + globalns=module_globals, + ) + + assert sidebar_hints["spec"] == SidebarEntrySpec + assert settings_hints["spec"] == SettingsTabSpec + assert lyrics_hints["source"] == PluginLyricsSource + assert cover_hints["source"] == PluginCoverSource + assert artist_cover_hints["source"] == PluginArtistCoverSource + assert provider_hints["provider"] == PluginOnlineProvider + assert media_hints["return"] is context_module.PluginMediaBridge + + +def test_package_exports_smoke(): + assert api.PluginManifest is PluginManifest + assert api.PluginTrack is PluginTrack + assert api.SidebarEntrySpec is SidebarEntrySpec From 7fb4d98457ed28b6a6032d72235824a18ae83eec Mon Sep 17 00:00:00 2001 From: Har01d Date: Sun, 5 Apr 2026 17:17:43 +0800 Subject: [PATCH 005/157] =?UTF-8?q?=E5=AE=9E=E7=8E=B0=E6=8F=92=E4=BB=B6?= =?UTF-8?q?=E8=BF=90=E8=A1=8C=E6=97=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- system/plugins/__init__.py | 18 +++++ system/plugins/errors.py | 10 +++ system/plugins/installer.py | 61 +++++++++++++++++ system/plugins/loader.py | 27 ++++++++ system/plugins/manager.py | 48 ++++++++++++++ system/plugins/registry.py | 77 ++++++++++++++++++++++ system/plugins/state_store.py | 41 ++++++++++++ tests/test_system/test_plugin_installer.py | 18 +++++ tests/test_system/test_plugin_manager.py | 63 ++++++++++++++++++ tests/test_system/test_plugin_registry.py | 19 ++++++ 10 files changed, 382 insertions(+) create mode 100644 system/plugins/__init__.py create mode 100644 system/plugins/errors.py create mode 100644 system/plugins/installer.py create mode 100644 system/plugins/loader.py create mode 100644 system/plugins/manager.py create mode 100644 system/plugins/registry.py create mode 100644 system/plugins/state_store.py create mode 100644 tests/test_system/test_plugin_installer.py create mode 100644 tests/test_system/test_plugin_manager.py create mode 100644 tests/test_system/test_plugin_registry.py diff --git a/system/plugins/__init__.py b/system/plugins/__init__.py new file mode 100644 index 00000000..6a439e1a --- /dev/null +++ b/system/plugins/__init__.py @@ -0,0 +1,18 @@ +from .errors import PluginError, PluginInstallError, PluginLoadError +from .installer import PluginInstaller, audit_plugin_imports +from .loader import PluginLoader +from .manager import PluginManager +from .registry import PluginRegistry +from .state_store import PluginStateStore + +__all__ = [ + "PluginError", + "PluginInstallError", + "PluginLoadError", + "PluginRegistry", + "PluginStateStore", + "PluginLoader", + "PluginInstaller", + "PluginManager", + "audit_plugin_imports", +] diff --git a/system/plugins/errors.py b/system/plugins/errors.py new file mode 100644 index 00000000..8d4f4671 --- /dev/null +++ b/system/plugins/errors.py @@ -0,0 +1,10 @@ +class PluginError(Exception): + pass + + +class PluginInstallError(PluginError): + pass + + +class PluginLoadError(PluginError): + pass diff --git a/system/plugins/installer.py b/system/plugins/installer.py new file mode 100644 index 00000000..74868e2f --- /dev/null +++ b/system/plugins/installer.py @@ -0,0 +1,61 @@ +from __future__ import annotations + +import ast +import json +import shutil +import zipfile +from pathlib import Path + +from harmony_plugin_api.manifest import PluginManifest + +from .errors import PluginInstallError + +_FORBIDDEN_ROOT_IMPORTS = { + "app", + "domain", + "services", + "repositories", + "infrastructure", + "system", + "ui", +} + + +def audit_plugin_imports(plugin_root: Path) -> None: + for py_file in plugin_root.rglob("*.py"): + tree = ast.parse(py_file.read_text(encoding="utf-8"), filename=str(py_file)) + for node in ast.walk(tree): + if isinstance(node, ast.Import): + names = [alias.name.split(".")[0] for alias in node.names] + elif isinstance(node, ast.ImportFrom) and node.module: + names = [node.module.split(".")[0]] + else: + continue + + if any(name in _FORBIDDEN_ROOT_IMPORTS for name in names): + raise PluginInstallError(f"Forbidden host import in {py_file}") + + +class PluginInstaller: + def __init__(self, external_root: Path, temp_root: Path) -> None: + self._external_root = external_root + self._temp_root = temp_root + + def install_zip(self, zip_path: Path) -> Path: + extract_root = self._temp_root / zip_path.stem + if extract_root.exists(): + shutil.rmtree(extract_root) + extract_root.mkdir(parents=True, exist_ok=True) + + with zipfile.ZipFile(zip_path) as archive: + archive.extractall(extract_root) + + audit_plugin_imports(extract_root) + manifest = PluginManifest.from_dict( + json.loads((extract_root / "plugin.json").read_text(encoding="utf-8")) + ) + final_root = self._external_root / manifest.id + if final_root.exists(): + shutil.rmtree(final_root) + shutil.copytree(extract_root, final_root) + return final_root diff --git a/system/plugins/loader.py b/system/plugins/loader.py new file mode 100644 index 00000000..016fda54 --- /dev/null +++ b/system/plugins/loader.py @@ -0,0 +1,27 @@ +from __future__ import annotations + +import importlib.util +import json +from pathlib import Path + +from harmony_plugin_api.manifest import PluginManifest + +from .errors import PluginLoadError + + +class PluginLoader: + def load_plugin(self, plugin_root: Path): + manifest = PluginManifest.from_dict( + json.loads((plugin_root / "plugin.json").read_text(encoding="utf-8")) + ) + module_path = plugin_root / manifest.entrypoint + spec = importlib.util.spec_from_file_location( + f"plugin_{manifest.id}", module_path + ) + if spec is None or spec.loader is None: + raise PluginLoadError(f"Cannot load entrypoint: {module_path}") + + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + plugin_class = getattr(module, manifest.entry_class) + return manifest, plugin_class() diff --git a/system/plugins/manager.py b/system/plugins/manager.py new file mode 100644 index 00000000..07541d51 --- /dev/null +++ b/system/plugins/manager.py @@ -0,0 +1,48 @@ +from __future__ import annotations + +from pathlib import Path + +from .loader import PluginLoader +from .registry import PluginRegistry + + +class PluginManager: + def __init__(self, builtin_root: Path, external_root: Path, state_store, context_factory) -> None: + self._builtin_root = builtin_root + self._external_root = external_root + self._state_store = state_store + self._context_factory = context_factory + self._loader = PluginLoader() + self.registry = PluginRegistry() + self._loaded_plugins: dict[str, tuple[object, object]] = {} + + def discover_roots(self) -> list[tuple[str, Path]]: + roots = [] + if self._builtin_root.exists(): + roots.extend( + ("builtin", path) for path in self._builtin_root.iterdir() if path.is_dir() + ) + if self._external_root.exists(): + roots.extend( + ("external", path) + for path in self._external_root.iterdir() + if path.is_dir() + ) + return roots + + def load_enabled_plugins(self) -> None: + for source, plugin_root in self.discover_roots(): + manifest, plugin = self._loader.load_plugin(plugin_root) + state = self._state_store.get(manifest.id) + if source == "external" and state and state.get("enabled") is False: + continue + + context = self._context_factory.build(manifest) + plugin.register(context) + self._loaded_plugins[manifest.id] = (manifest, plugin) + self._state_store.set_enabled( + manifest.id, + True if state is None else bool(state.get("enabled", True)), + source=source, + version=manifest.version, + ) diff --git a/system/plugins/registry.py b/system/plugins/registry.py new file mode 100644 index 00000000..2962c358 --- /dev/null +++ b/system/plugins/registry.py @@ -0,0 +1,77 @@ +from __future__ import annotations + +from collections import defaultdict + + +class PluginRegistry: + def __init__(self) -> None: + self._sidebar_entries: list = [] + self._settings_tabs: list = [] + self._lyrics_sources: list = [] + self._cover_sources: list = [] + self._artist_cover_sources: list = [] + self._online_providers: list = [] + self._owned: dict[str, list[tuple[str, object]]] = defaultdict(list) + + def register_sidebar_entry(self, plugin_id: str, spec: object) -> None: + self._sidebar_entries.append(spec) + self._owned[plugin_id].append(("sidebar", spec)) + + def register_settings_tab(self, plugin_id: str, spec: object) -> None: + self._settings_tabs.append(spec) + self._owned[plugin_id].append(("settings_tab", spec)) + + def register_lyrics_source(self, plugin_id: str, source: object) -> None: + self._lyrics_sources.append(source) + self._owned[plugin_id].append(("lyrics_source", source)) + + def register_cover_source(self, plugin_id: str, source: object) -> None: + self._cover_sources.append(source) + self._owned[plugin_id].append(("cover_source", source)) + + def register_artist_cover_source(self, plugin_id: str, source: object) -> None: + self._artist_cover_sources.append(source) + self._owned[plugin_id].append(("artist_cover_source", source)) + + def register_online_provider(self, plugin_id: str, provider: object) -> None: + self._online_providers.append(provider) + self._owned[plugin_id].append(("online_provider", provider)) + + def unregister_plugin(self, plugin_id: str) -> None: + owned_ids = {id(value) for _kind, value in self._owned.pop(plugin_id, [])} + self._sidebar_entries = [ + item for item in self._sidebar_entries if id(item) not in owned_ids + ] + self._settings_tabs = [ + item for item in self._settings_tabs if id(item) not in owned_ids + ] + self._lyrics_sources = [ + item for item in self._lyrics_sources if id(item) not in owned_ids + ] + self._cover_sources = [ + item for item in self._cover_sources if id(item) not in owned_ids + ] + self._artist_cover_sources = [ + item for item in self._artist_cover_sources if id(item) not in owned_ids + ] + self._online_providers = [ + item for item in self._online_providers if id(item) not in owned_ids + ] + + def sidebar_entries(self) -> list: + return sorted(self._sidebar_entries, key=lambda item: item.order) + + def settings_tabs(self) -> list: + return sorted(self._settings_tabs, key=lambda item: item.order) + + def lyrics_sources(self) -> list: + return list(self._lyrics_sources) + + def cover_sources(self) -> list: + return list(self._cover_sources) + + def artist_cover_sources(self) -> list: + return list(self._artist_cover_sources) + + def online_providers(self) -> list: + return list(self._online_providers) diff --git a/system/plugins/state_store.py b/system/plugins/state_store.py new file mode 100644 index 00000000..19f82383 --- /dev/null +++ b/system/plugins/state_store.py @@ -0,0 +1,41 @@ +from __future__ import annotations + +import json +from pathlib import Path + + +class PluginStateStore: + def __init__(self, path: Path) -> None: + self._path = path + self._path.parent.mkdir(parents=True, exist_ok=True) + + def _read(self) -> dict: + if not self._path.exists(): + return {} + return json.loads(self._path.read_text(encoding="utf-8")) + + def _write(self, payload: dict) -> None: + self._path.write_text( + json.dumps(payload, ensure_ascii=False, indent=2), + encoding="utf-8", + ) + + def set_enabled( + self, + plugin_id: str, + enabled: bool, + source: str, + version: str, + load_error: str | None = None, + ) -> None: + payload = self._read() + payload[plugin_id] = { + "enabled": enabled, + "source": source, + "version": version, + "load_error": load_error, + } + self._write(payload) + + def get(self, plugin_id: str) -> dict | None: + return self._read().get(plugin_id) diff --git a/tests/test_system/test_plugin_installer.py b/tests/test_system/test_plugin_installer.py new file mode 100644 index 00000000..1042df24 --- /dev/null +++ b/tests/test_system/test_plugin_installer.py @@ -0,0 +1,18 @@ +from pathlib import Path + +import pytest + +from system.plugins.errors import PluginInstallError +from system.plugins.installer import audit_plugin_imports + + +def test_import_audit_rejects_host_internal_import(tmp_path: Path): + plugin_root = tmp_path / "plugin" + plugin_root.mkdir() + (plugin_root / "plugin_main.py").write_text( + "from services.lyrics.qqmusic_lyrics import QQMusicClient\n", + encoding="utf-8", + ) + + with pytest.raises(PluginInstallError): + audit_plugin_imports(plugin_root) diff --git a/tests/test_system/test_plugin_manager.py b/tests/test_system/test_plugin_manager.py new file mode 100644 index 00000000..e2a9306f --- /dev/null +++ b/tests/test_system/test_plugin_manager.py @@ -0,0 +1,63 @@ +import json +from pathlib import Path + +from system.plugins.manager import PluginManager +from system.plugins.state_store import PluginStateStore + + +class _ContextFactory: + def build(self, _manifest): + return object() + + +def test_state_store_persists_enabled_flag(tmp_path: Path): + store = PluginStateStore(tmp_path / "state.json") + store.set_enabled("qqmusic", True, source="builtin", version="1.0.0") + + payload = json.loads((tmp_path / "state.json").read_text(encoding="utf-8")) + assert payload["qqmusic"]["enabled"] is True + + +def test_manager_loads_builtin_plugin_and_persists_state(tmp_path: Path): + builtin_root = tmp_path / "builtin" + plugin_root = builtin_root / "qqmusic" + plugin_root.mkdir(parents=True) + + (plugin_root / "plugin.json").write_text( + json.dumps( + { + "id": "qqmusic", + "name": "QQ Music", + "version": "1.0.0", + "api_version": "1", + "entrypoint": "plugin_main.py", + "entry_class": "QQMusicPlugin", + "capabilities": ["sidebar"], + "min_app_version": "0.1.0", + } + ), + encoding="utf-8", + ) + (plugin_root / "plugin_main.py").write_text( + "class QQMusicPlugin:\n" + " plugin_id = 'qqmusic'\n" + " def register(self, context):\n" + " self.context = context\n" + " def unregister(self, context):\n" + " self.context = None\n", + encoding="utf-8", + ) + + store = PluginStateStore(tmp_path / "state.json") + manager = PluginManager( + builtin_root=builtin_root, + external_root=tmp_path / "external", + state_store=store, + context_factory=_ContextFactory(), + ) + + manager.load_enabled_plugins() + + state = store.get("qqmusic") + assert state is not None + assert state["enabled"] is True diff --git a/tests/test_system/test_plugin_registry.py b/tests/test_system/test_plugin_registry.py new file mode 100644 index 00000000..0f9de8db --- /dev/null +++ b/tests/test_system/test_plugin_registry.py @@ -0,0 +1,19 @@ +from harmony_plugin_api.registry_types import SidebarEntrySpec +from system.plugins.registry import PluginRegistry + + +def test_registry_unregister_plugin_removes_owned_entries(): + registry = PluginRegistry() + spec = SidebarEntrySpec( + plugin_id="qqmusic", + entry_id="qqmusic.sidebar", + title="QQ Music", + order=80, + icon_name="GLOBE", + page_factory=lambda _context, _parent: object(), + ) + + registry.register_sidebar_entry("qqmusic", spec) + registry.unregister_plugin("qqmusic") + + assert registry.sidebar_entries() == [] From fcc61a42794af3e6ccebc9da032084dc69192cfc Mon Sep 17 00:00:00 2001 From: Har01d Date: Sun, 5 Apr 2026 17:25:18 +0800 Subject: [PATCH 006/157] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E7=A6=81=E7=94=A8?= =?UTF-8?q?=E5=A4=96=E9=83=A8=E6=8F=92=E4=BB=B6=E9=A2=84=E5=8A=A0=E8=BD=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- system/plugins/loader.py | 8 +++- system/plugins/manager.py | 13 +++++-- tests/test_system/test_plugin_manager.py | 47 ++++++++++++++++++++++++ 3 files changed, 62 insertions(+), 6 deletions(-) diff --git a/system/plugins/loader.py b/system/plugins/loader.py index 016fda54..bedea7df 100644 --- a/system/plugins/loader.py +++ b/system/plugins/loader.py @@ -10,10 +10,14 @@ class PluginLoader: - def load_plugin(self, plugin_root: Path): - manifest = PluginManifest.from_dict( + def read_manifest(self, plugin_root: Path) -> PluginManifest: + return PluginManifest.from_dict( json.loads((plugin_root / "plugin.json").read_text(encoding="utf-8")) ) + + def load_plugin(self, plugin_root: Path, manifest: PluginManifest | None = None): + if manifest is None: + manifest = self.read_manifest(plugin_root) module_path = plugin_root / manifest.entrypoint spec = importlib.util.spec_from_file_location( f"plugin_{manifest.id}", module_path diff --git a/system/plugins/manager.py b/system/plugins/manager.py index 07541d51..36a5ee86 100644 --- a/system/plugins/manager.py +++ b/system/plugins/manager.py @@ -32,10 +32,15 @@ def discover_roots(self) -> list[tuple[str, Path]]: def load_enabled_plugins(self) -> None: for source, plugin_root in self.discover_roots(): - manifest, plugin = self._loader.load_plugin(plugin_root) - state = self._state_store.get(manifest.id) - if source == "external" and state and state.get("enabled") is False: - continue + if source == "external": + manifest = self._loader.read_manifest(plugin_root) + state = self._state_store.get(manifest.id) + if state and state.get("enabled") is False: + continue + manifest, plugin = self._loader.load_plugin(plugin_root, manifest) + else: + manifest, plugin = self._loader.load_plugin(plugin_root) + state = self._state_store.get(manifest.id) context = self._context_factory.build(manifest) plugin.register(context) diff --git a/tests/test_system/test_plugin_manager.py b/tests/test_system/test_plugin_manager.py index e2a9306f..75aff857 100644 --- a/tests/test_system/test_plugin_manager.py +++ b/tests/test_system/test_plugin_manager.py @@ -61,3 +61,50 @@ def test_manager_loads_builtin_plugin_and_persists_state(tmp_path: Path): state = store.get("qqmusic") assert state is not None assert state["enabled"] is True + + +def test_manager_skips_import_for_disabled_external_plugin(tmp_path: Path): + external_root = tmp_path / "external" + plugin_root = external_root / "danger" + plugin_root.mkdir(parents=True) + + (plugin_root / "plugin.json").write_text( + json.dumps( + { + "id": "danger", + "name": "Danger Plugin", + "version": "1.0.0", + "api_version": "1", + "entrypoint": "plugin_main.py", + "entry_class": "DangerPlugin", + "capabilities": ["sidebar"], + "min_app_version": "0.1.0", + } + ), + encoding="utf-8", + ) + (plugin_root / "plugin_main.py").write_text( + "raise RuntimeError('should not import disabled external plugin')\n" + "class DangerPlugin:\n" + " plugin_id = 'danger'\n" + " def register(self, context):\n" + " pass\n" + " def unregister(self, context):\n" + " pass\n", + encoding="utf-8", + ) + + store = PluginStateStore(tmp_path / "state.json") + store.set_enabled("danger", False, source="external", version="1.0.0") + manager = PluginManager( + builtin_root=tmp_path / "builtin", + external_root=external_root, + state_store=store, + context_factory=_ContextFactory(), + ) + + manager.load_enabled_plugins() + + state = store.get("danger") + assert state is not None + assert state["enabled"] is False From 26226794b5178490b1be2a1934c19cf247cfe4c2 Mon Sep 17 00:00:00 2001 From: Har01d Date: Sun, 5 Apr 2026 17:41:17 +0800 Subject: [PATCH 007/157] =?UTF-8?q?=E5=AE=8C=E5=96=84=E6=8F=92=E4=BB=B6?= =?UTF-8?q?=E5=8A=A0=E8=BD=BD=E4=B8=8E=E5=AE=89=E8=A3=85=E5=AE=B9=E9=94=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- system/plugins/installer.py | 10 ++ system/plugins/loader.py | 30 +++- system/plugins/manager.py | 52 +++--- tests/test_system/test_plugin_installer.py | 73 ++++++++- tests/test_system/test_plugin_manager.py | 179 +++++++++++++++++++++ 5 files changed, 321 insertions(+), 23 deletions(-) diff --git a/system/plugins/installer.py b/system/plugins/installer.py index 74868e2f..c2b5044a 100644 --- a/system/plugins/installer.py +++ b/system/plugins/installer.py @@ -9,6 +9,7 @@ from harmony_plugin_api.manifest import PluginManifest from .errors import PluginInstallError +from .loader import PluginLoader _FORBIDDEN_ROOT_IMPORTS = { "app", @@ -40,6 +41,7 @@ class PluginInstaller: def __init__(self, external_root: Path, temp_root: Path) -> None: self._external_root = external_root self._temp_root = temp_root + self._loader = PluginLoader() def install_zip(self, zip_path: Path) -> Path: extract_root = self._temp_root / zip_path.stem @@ -54,6 +56,14 @@ def install_zip(self, zip_path: Path) -> Path: manifest = PluginManifest.from_dict( json.loads((extract_root / "plugin.json").read_text(encoding="utf-8")) ) + try: + self._loader.load_plugin(extract_root, manifest) + except Exception as exc: + raise PluginInstallError( + f"Invalid plugin package structure for '{manifest.id}': {exc}" + ) from exc + + self._external_root.mkdir(parents=True, exist_ok=True) final_root = self._external_root / manifest.id if final_root.exists(): shutil.rmtree(final_root) diff --git a/system/plugins/loader.py b/system/plugins/loader.py index bedea7df..0755a4f4 100644 --- a/system/plugins/loader.py +++ b/system/plugins/loader.py @@ -2,6 +2,9 @@ import importlib.util import json +import re +import sys +import types from pathlib import Path from harmony_plugin_api.manifest import PluginManifest @@ -19,13 +22,32 @@ def load_plugin(self, plugin_root: Path, manifest: PluginManifest | None = None) if manifest is None: manifest = self.read_manifest(plugin_root) module_path = plugin_root / manifest.entrypoint + if not module_path.exists(): + raise PluginLoadError(f"Entrypoint file does not exist: {module_path}") + + safe_id = re.sub(r"[^0-9a-zA-Z_]", "_", manifest.id) + package_name = f"_harmony_plugin_{safe_id}" + package_module = sys.modules.get(package_name) + if package_module is None: + package_module = types.ModuleType(package_name) + package_module.__path__ = [str(plugin_root)] + sys.modules[package_name] = package_module + + entrypoint_module = Path(manifest.entrypoint).with_suffix("") + module_name = f"{package_name}.{'.'.join(entrypoint_module.parts)}" spec = importlib.util.spec_from_file_location( - f"plugin_{manifest.id}", module_path + module_name, module_path ) if spec is None or spec.loader is None: raise PluginLoadError(f"Cannot load entrypoint: {module_path}") module = importlib.util.module_from_spec(spec) - spec.loader.exec_module(module) - plugin_class = getattr(module, manifest.entry_class) - return manifest, plugin_class() + sys.modules[module_name] = module + try: + spec.loader.exec_module(module) + plugin_class = getattr(module, manifest.entry_class) + return manifest, plugin_class() + except Exception as exc: + raise PluginLoadError( + f"Failed to load plugin '{manifest.id}': {exc}" + ) from exc diff --git a/system/plugins/manager.py b/system/plugins/manager.py index 36a5ee86..4e0d8b8b 100644 --- a/system/plugins/manager.py +++ b/system/plugins/manager.py @@ -32,22 +32,38 @@ def discover_roots(self) -> list[tuple[str, Path]]: def load_enabled_plugins(self) -> None: for source, plugin_root in self.discover_roots(): - if source == "external": - manifest = self._loader.read_manifest(plugin_root) - state = self._state_store.get(manifest.id) - if state and state.get("enabled") is False: - continue - manifest, plugin = self._loader.load_plugin(plugin_root, manifest) - else: - manifest, plugin = self._loader.load_plugin(plugin_root) - state = self._state_store.get(manifest.id) + manifest = None + state = None + try: + if source == "external": + manifest = self._loader.read_manifest(plugin_root) + state = self._state_store.get(manifest.id) + if state and state.get("enabled") is False: + continue + manifest, plugin = self._loader.load_plugin(plugin_root, manifest) + else: + manifest, plugin = self._loader.load_plugin(plugin_root) + state = self._state_store.get(manifest.id) - context = self._context_factory.build(manifest) - plugin.register(context) - self._loaded_plugins[manifest.id] = (manifest, plugin) - self._state_store.set_enabled( - manifest.id, - True if state is None else bool(state.get("enabled", True)), - source=source, - version=manifest.version, - ) + context = self._context_factory.build(manifest) + plugin.register(context) + self._loaded_plugins[manifest.id] = (manifest, plugin) + self._state_store.set_enabled( + manifest.id, + True if state is None else bool(state.get("enabled", True)), + source=source, + version=manifest.version, + load_error=None, + ) + except Exception as exc: + plugin_id = manifest.id if manifest is not None else plugin_root.name + version = manifest.version if manifest is not None else "" + self.registry.unregister_plugin(plugin_id) + self._loaded_plugins.pop(plugin_id, None) + self._state_store.set_enabled( + plugin_id, + False, + source=source, + version=version, + load_error=str(exc), + ) diff --git a/tests/test_system/test_plugin_installer.py b/tests/test_system/test_plugin_installer.py index 1042df24..2e24e8b1 100644 --- a/tests/test_system/test_plugin_installer.py +++ b/tests/test_system/test_plugin_installer.py @@ -1,9 +1,11 @@ from pathlib import Path +import json +import zipfile import pytest from system.plugins.errors import PluginInstallError -from system.plugins.installer import audit_plugin_imports +from system.plugins.installer import PluginInstaller, audit_plugin_imports def test_import_audit_rejects_host_internal_import(tmp_path: Path): @@ -16,3 +18,72 @@ def test_import_audit_rejects_host_internal_import(tmp_path: Path): with pytest.raises(PluginInstallError): audit_plugin_imports(plugin_root) + + +def _build_plugin_zip(tmp_path: Path, zip_name: str, files: dict[str, str]) -> Path: + zip_path = tmp_path / zip_name + with zipfile.ZipFile(zip_path, "w") as archive: + for rel_path, content in files.items(): + archive.writestr(rel_path, content) + return zip_path + + +def test_install_zip_rejects_missing_entrypoint(tmp_path: Path): + installer = PluginInstaller( + external_root=tmp_path / "external", + temp_root=tmp_path / "temp", + ) + plugin_zip = _build_plugin_zip( + tmp_path, + "missing_entry.zip", + { + "plugin.json": json.dumps( + { + "id": "missing-entry", + "name": "Missing Entry", + "version": "1.0.0", + "api_version": "1", + "entrypoint": "plugin_main.py", + "entry_class": "MissingEntryPlugin", + "capabilities": ["sidebar"], + "min_app_version": "0.1.0", + } + ), + }, + ) + + with pytest.raises(PluginInstallError): + installer.install_zip(plugin_zip) + + assert not (tmp_path / "external" / "missing-entry").exists() + + +def test_install_zip_rejects_missing_entry_class(tmp_path: Path): + installer = PluginInstaller( + external_root=tmp_path / "external", + temp_root=tmp_path / "temp", + ) + plugin_zip = _build_plugin_zip( + tmp_path, + "missing_class.zip", + { + "plugin.json": json.dumps( + { + "id": "missing-class", + "name": "Missing Class", + "version": "1.0.0", + "api_version": "1", + "entrypoint": "plugin_main.py", + "entry_class": "ExpectedPluginClass", + "capabilities": ["sidebar"], + "min_app_version": "0.1.0", + } + ), + "plugin_main.py": "class OtherPlugin:\n pass\n", + }, + ) + + with pytest.raises(PluginInstallError): + installer.install_zip(plugin_zip) + + assert not (tmp_path / "external" / "missing-class").exists() diff --git a/tests/test_system/test_plugin_manager.py b/tests/test_system/test_plugin_manager.py index 75aff857..13b34170 100644 --- a/tests/test_system/test_plugin_manager.py +++ b/tests/test_system/test_plugin_manager.py @@ -10,6 +10,27 @@ def build(self, _manifest): return object() +class _RegistryContextFactory: + def __init__(self, registry): + self._registry = registry + + def build(self, manifest): + registry = self._registry + plugin_id = manifest.id + + class _UiBridge: + def register_sidebar_entry(self, spec): + registry.register_sidebar_entry(plugin_id, spec) + + def register_settings_tab(self, _spec): + return None + + class _Context: + ui = _UiBridge() + + return _Context() + + def test_state_store_persists_enabled_flag(tmp_path: Path): store = PluginStateStore(tmp_path / "state.json") store.set_enabled("qqmusic", True, source="builtin", version="1.0.0") @@ -108,3 +129,161 @@ def test_manager_skips_import_for_disabled_external_plugin(tmp_path: Path): state = store.get("danger") assert state is not None assert state["enabled"] is False + + +def test_manager_loads_plugin_with_relative_import(tmp_path: Path): + builtin_root = tmp_path / "builtin" + plugin_root = builtin_root / "relative" + (plugin_root / "lib").mkdir(parents=True) + + (plugin_root / "plugin.json").write_text( + json.dumps( + { + "id": "relative", + "name": "Relative Plugin", + "version": "1.0.0", + "api_version": "1", + "entrypoint": "plugin_main.py", + "entry_class": "RelativePlugin", + "capabilities": ["sidebar"], + "min_app_version": "0.1.0", + } + ), + encoding="utf-8", + ) + (plugin_root / "__init__.py").write_text("", encoding="utf-8") + (plugin_root / "lib" / "__init__.py").write_text("", encoding="utf-8") + (plugin_root / "lib" / "factory.py").write_text( + "from harmony_plugin_api.registry_types import SidebarEntrySpec\n\n" + "def make_spec(plugin_id):\n" + " return SidebarEntrySpec(\n" + " plugin_id=plugin_id,\n" + " entry_id=f'{plugin_id}.sidebar',\n" + " title='Relative',\n" + " order=10,\n" + " icon_name=None,\n" + " page_factory=lambda _context, _parent: object(),\n" + " )\n", + encoding="utf-8", + ) + (plugin_root / "plugin_main.py").write_text( + "from .lib.factory import make_spec\n\n" + "class RelativePlugin:\n" + " plugin_id = 'relative'\n" + " def register(self, context):\n" + " context.ui.register_sidebar_entry(make_spec(self.plugin_id))\n" + " def unregister(self, context):\n" + " pass\n", + encoding="utf-8", + ) + + store = PluginStateStore(tmp_path / "state.json") + manager = PluginManager( + builtin_root=builtin_root, + external_root=tmp_path / "external", + state_store=store, + context_factory=_RegistryContextFactory(None), + ) + manager._context_factory = _RegistryContextFactory(manager.registry) + + manager.load_enabled_plugins() + + assert [item.plugin_id for item in manager.registry.sidebar_entries()] == ["relative"] + + +def test_manager_continues_after_plugin_failure_and_persists_load_error(tmp_path: Path): + builtin_root = tmp_path / "builtin" + broken_root = builtin_root / "broken" + healthy_root = builtin_root / "healthy" + broken_root.mkdir(parents=True) + healthy_root.mkdir(parents=True) + + (broken_root / "plugin.json").write_text( + json.dumps( + { + "id": "broken", + "name": "Broken Plugin", + "version": "1.0.0", + "api_version": "1", + "entrypoint": "plugin_main.py", + "entry_class": "BrokenPlugin", + "capabilities": ["sidebar"], + "min_app_version": "0.1.0", + } + ), + encoding="utf-8", + ) + (broken_root / "plugin_main.py").write_text( + "from harmony_plugin_api.registry_types import SidebarEntrySpec\n\n" + "class BrokenPlugin:\n" + " plugin_id = 'broken'\n" + " def register(self, context):\n" + " context.ui.register_sidebar_entry(\n" + " SidebarEntrySpec(\n" + " plugin_id='broken',\n" + " entry_id='broken.sidebar',\n" + " title='Broken',\n" + " order=1,\n" + " icon_name=None,\n" + " page_factory=lambda _context, _parent: object(),\n" + " )\n" + " )\n" + " raise RuntimeError('broken plugin failed')\n" + " def unregister(self, context):\n" + " pass\n", + encoding="utf-8", + ) + + (healthy_root / "plugin.json").write_text( + json.dumps( + { + "id": "healthy", + "name": "Healthy Plugin", + "version": "1.0.0", + "api_version": "1", + "entrypoint": "plugin_main.py", + "entry_class": "HealthyPlugin", + "capabilities": ["sidebar"], + "min_app_version": "0.1.0", + } + ), + encoding="utf-8", + ) + (healthy_root / "plugin_main.py").write_text( + "from harmony_plugin_api.registry_types import SidebarEntrySpec\n\n" + "class HealthyPlugin:\n" + " plugin_id = 'healthy'\n" + " def register(self, context):\n" + " context.ui.register_sidebar_entry(\n" + " SidebarEntrySpec(\n" + " plugin_id='healthy',\n" + " entry_id='healthy.sidebar',\n" + " title='Healthy',\n" + " order=2,\n" + " icon_name=None,\n" + " page_factory=lambda _context, _parent: object(),\n" + " )\n" + " )\n" + " def unregister(self, context):\n" + " pass\n", + encoding="utf-8", + ) + + store = PluginStateStore(tmp_path / "state.json") + manager = PluginManager( + builtin_root=builtin_root, + external_root=tmp_path / "external", + state_store=store, + context_factory=_RegistryContextFactory(None), + ) + manager._context_factory = _RegistryContextFactory(manager.registry) + + manager.load_enabled_plugins() + + broken_state = store.get("broken") + healthy_state = store.get("healthy") + assert broken_state is not None + assert healthy_state is not None + assert broken_state["load_error"] + assert healthy_state["enabled"] is True + assert [item.plugin_id for item in manager.registry.sidebar_entries()] == ["healthy"] From 9f6ce63cbf419bb952a66334f1a32125a3db64aa Mon Sep 17 00:00:00 2001 From: Har01d Date: Sun, 5 Apr 2026 17:50:23 +0800 Subject: [PATCH 008/157] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E6=8F=92=E4=BB=B6?= =?UTF-8?q?=E7=9B=B8=E5=AF=B9=E5=AF=BC=E5=85=A5=E7=BC=93=E5=AD=98=E6=B1=A1?= =?UTF-8?q?=E6=9F=93?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- system/plugins/loader.py | 29 ++++++++++--- tests/test_system/test_plugin_installer.py | 50 ++++++++++++++++++++++ 2 files changed, 72 insertions(+), 7 deletions(-) diff --git a/system/plugins/loader.py b/system/plugins/loader.py index 0755a4f4..16b4a6b9 100644 --- a/system/plugins/loader.py +++ b/system/plugins/loader.py @@ -1,5 +1,6 @@ from __future__ import annotations +import hashlib import importlib.util import json import re @@ -13,6 +14,22 @@ class PluginLoader: + def _package_name(self, manifest_id: str, plugin_root: Path) -> str: + safe_id = re.sub(r"[^0-9a-zA-Z_]", "_", manifest_id) + root_hash = hashlib.sha1( + str(plugin_root.resolve()).encode("utf-8") + ).hexdigest()[:12] + return f"_harmony_plugin_{safe_id}_{root_hash}" + + def _purge_package_modules(self, package_name: str) -> None: + names = [ + module_name + for module_name in sys.modules + if module_name == package_name or module_name.startswith(f"{package_name}.") + ] + for module_name in names: + sys.modules.pop(module_name, None) + def read_manifest(self, plugin_root: Path) -> PluginManifest: return PluginManifest.from_dict( json.loads((plugin_root / "plugin.json").read_text(encoding="utf-8")) @@ -25,13 +42,11 @@ def load_plugin(self, plugin_root: Path, manifest: PluginManifest | None = None) if not module_path.exists(): raise PluginLoadError(f"Entrypoint file does not exist: {module_path}") - safe_id = re.sub(r"[^0-9a-zA-Z_]", "_", manifest.id) - package_name = f"_harmony_plugin_{safe_id}" - package_module = sys.modules.get(package_name) - if package_module is None: - package_module = types.ModuleType(package_name) - package_module.__path__ = [str(plugin_root)] - sys.modules[package_name] = package_module + package_name = self._package_name(manifest.id, plugin_root) + self._purge_package_modules(package_name) + package_module = types.ModuleType(package_name) + package_module.__path__ = [str(plugin_root)] + sys.modules[package_name] = package_module entrypoint_module = Path(manifest.entrypoint).with_suffix("") module_name = f"{package_name}.{'.'.join(entrypoint_module.parts)}" diff --git a/tests/test_system/test_plugin_installer.py b/tests/test_system/test_plugin_installer.py index 2e24e8b1..8c772a58 100644 --- a/tests/test_system/test_plugin_installer.py +++ b/tests/test_system/test_plugin_installer.py @@ -6,6 +6,7 @@ from system.plugins.errors import PluginInstallError from system.plugins.installer import PluginInstaller, audit_plugin_imports +from system.plugins.loader import PluginLoader def test_import_audit_rejects_host_internal_import(tmp_path: Path): @@ -87,3 +88,52 @@ def test_install_zip_rejects_missing_entry_class(tmp_path: Path): installer.install_zip(plugin_zip) assert not (tmp_path / "external" / "missing-class").exists() + + +def test_install_then_load_uses_installed_relative_module_code(tmp_path: Path): + installer = PluginInstaller( + external_root=tmp_path / "external", + temp_root=tmp_path / "temp", + ) + plugin_zip = _build_plugin_zip( + tmp_path, + "relative_plugin.zip", + { + "plugin.json": json.dumps( + { + "id": "relative-installed", + "name": "Relative Installed", + "version": "1.0.0", + "api_version": "1", + "entrypoint": "plugin_main.py", + "entry_class": "RelativeInstalledPlugin", + "capabilities": ["sidebar"], + "min_app_version": "0.1.0", + } + ), + "__init__.py": "", + "lib/__init__.py": "", + "lib/source.py": "def get_marker():\n return 'from_zip'\n", + "plugin_main.py": ( + "from .lib.source import get_marker\n\n" + "class RelativeInstalledPlugin:\n" + " plugin_id = 'relative-installed'\n" + " def register(self, context):\n" + " pass\n" + " def unregister(self, context):\n" + " pass\n" + " def marker(self):\n" + " return get_marker()\n" + ), + }, + ) + + installed_root = installer.install_zip(plugin_zip) + (installed_root / "lib" / "source.py").write_text( + "def get_marker():\n return 'from_installed'\n", + encoding="utf-8", + ) + + _manifest, plugin = PluginLoader().load_plugin(installed_root) + + assert plugin.marker() == "from_installed" From 979f69cecf210ebc8d77de91aff546f6cfa4c847 Mon Sep 17 00:00:00 2001 From: Har01d Date: Sun, 5 Apr 2026 17:58:17 +0800 Subject: [PATCH 009/157] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E6=8F=92=E4=BB=B6?= =?UTF-8?q?=E7=9B=B8=E5=AF=B9=E5=AF=BC=E5=85=A5=E5=AE=A1=E8=AE=A1=E8=AF=AF?= =?UTF-8?q?=E5=88=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- system/plugins/installer.py | 6 +++++- tests/test_system/test_plugin_installer.py | 17 +++++++++++++++++ 2 files changed, 22 insertions(+), 1 deletion(-) diff --git a/system/plugins/installer.py b/system/plugins/installer.py index c2b5044a..ccf5c64d 100644 --- a/system/plugins/installer.py +++ b/system/plugins/installer.py @@ -28,7 +28,11 @@ def audit_plugin_imports(plugin_root: Path) -> None: for node in ast.walk(tree): if isinstance(node, ast.Import): names = [alias.name.split(".")[0] for alias in node.names] - elif isinstance(node, ast.ImportFrom) and node.module: + elif isinstance(node, ast.ImportFrom): + if node.level and node.level > 0: + continue + if not node.module: + continue names = [node.module.split(".")[0]] else: continue diff --git a/tests/test_system/test_plugin_installer.py b/tests/test_system/test_plugin_installer.py index 8c772a58..92da4203 100644 --- a/tests/test_system/test_plugin_installer.py +++ b/tests/test_system/test_plugin_installer.py @@ -21,6 +21,23 @@ def test_import_audit_rejects_host_internal_import(tmp_path: Path): audit_plugin_imports(plugin_root) +def test_import_audit_allows_plugin_relative_import_under_host_like_name( + tmp_path: Path, +): + plugin_root = tmp_path / "plugin" + (plugin_root / "services").mkdir(parents=True) + (plugin_root / "services" / "helper.py").write_text( + "value = 1\n", + encoding="utf-8", + ) + (plugin_root / "plugin_main.py").write_text( + "from .services.helper import value\n", + encoding="utf-8", + ) + + audit_plugin_imports(plugin_root) + + def _build_plugin_zip(tmp_path: Path, zip_name: str, files: dict[str, str]) -> Path: zip_path = tmp_path / zip_name with zipfile.ZipFile(zip_path, "w") as archive: From eaec8e45cad3d4f16178412e4408c78b96e4a9ae Mon Sep 17 00:00:00 2001 From: Har01d Date: Sun, 5 Apr 2026 18:09:39 +0800 Subject: [PATCH 010/157] =?UTF-8?q?=E6=8B=86=E5=88=86=E5=AE=89=E8=A3=85?= =?UTF-8?q?=E6=A0=A1=E9=AA=8C=E4=B8=8E=E8=BF=90=E8=A1=8C=E6=97=B6=E5=AE=9E?= =?UTF-8?q?=E4=BE=8B=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- system/plugins/installer.py | 2 +- system/plugins/loader.py | 33 +++++++++++++- tests/test_system/test_plugin_manager.py | 56 ++++++++++++++++++++++++ 3 files changed, 89 insertions(+), 2 deletions(-) diff --git a/system/plugins/installer.py b/system/plugins/installer.py index ccf5c64d..61886f92 100644 --- a/system/plugins/installer.py +++ b/system/plugins/installer.py @@ -61,7 +61,7 @@ def install_zip(self, zip_path: Path) -> Path: json.loads((extract_root / "plugin.json").read_text(encoding="utf-8")) ) try: - self._loader.load_plugin(extract_root, manifest) + self._loader.validate_plugin_structure(extract_root, manifest) except Exception as exc: raise PluginInstallError( f"Invalid plugin package structure for '{manifest.id}': {exc}" diff --git a/system/plugins/loader.py b/system/plugins/loader.py index 16b4a6b9..c2d6a9f7 100644 --- a/system/plugins/loader.py +++ b/system/plugins/loader.py @@ -35,7 +35,11 @@ def read_manifest(self, plugin_root: Path) -> PluginManifest: json.loads((plugin_root / "plugin.json").read_text(encoding="utf-8")) ) - def load_plugin(self, plugin_root: Path, manifest: PluginManifest | None = None): + def _load_entry_module( + self, + plugin_root: Path, + manifest: PluginManifest, + ): if manifest is None: manifest = self.read_manifest(plugin_root) module_path = plugin_root / manifest.entrypoint @@ -60,6 +64,33 @@ def load_plugin(self, plugin_root: Path, manifest: PluginManifest | None = None) sys.modules[module_name] = module try: spec.loader.exec_module(module) + return module + except Exception as exc: + raise PluginLoadError( + f"Failed to load plugin '{manifest.id}': {exc}" + ) from exc + + def validate_plugin_structure( + self, plugin_root: Path, manifest: PluginManifest | None = None + ) -> PluginManifest: + if manifest is None: + manifest = self.read_manifest(plugin_root) + module = self._load_entry_module(plugin_root, manifest) + if not hasattr(module, manifest.entry_class): + raise PluginLoadError( + f"Entrypoint missing class '{manifest.entry_class}' for '{manifest.id}'" + ) + return manifest + + def load_plugin(self, plugin_root: Path, manifest: PluginManifest | None = None): + if manifest is None: + manifest = self.read_manifest(plugin_root) + module = self._load_entry_module(plugin_root, manifest) + if not hasattr(module, manifest.entry_class): + raise PluginLoadError( + f"Entrypoint missing class '{manifest.entry_class}' for '{manifest.id}'" + ) + try: plugin_class = getattr(module, manifest.entry_class) return manifest, plugin_class() except Exception as exc: diff --git a/tests/test_system/test_plugin_manager.py b/tests/test_system/test_plugin_manager.py index 13b34170..a2abd823 100644 --- a/tests/test_system/test_plugin_manager.py +++ b/tests/test_system/test_plugin_manager.py @@ -1,6 +1,8 @@ import json from pathlib import Path +import zipfile +from system.plugins.installer import PluginInstaller from system.plugins.manager import PluginManager from system.plugins.state_store import PluginStateStore @@ -287,3 +289,57 @@ def test_manager_continues_after_plugin_failure_and_persists_load_error(tmp_path assert broken_state["load_error"] assert healthy_state["enabled"] is True assert [item.plugin_id for item in manager.registry.sidebar_entries()] == ["healthy"] + + +def test_constructor_failure_installs_then_records_runtime_load_error(tmp_path: Path): + external_root = tmp_path / "external" + installer = PluginInstaller( + external_root=external_root, + temp_root=tmp_path / "temp", + ) + zip_path = tmp_path / "ctor_fails.zip" + with zipfile.ZipFile(zip_path, "w") as archive: + archive.writestr( + "plugin.json", + json.dumps( + { + "id": "ctor-fails", + "name": "Ctor Fails", + "version": "1.0.0", + "api_version": "1", + "entrypoint": "plugin_main.py", + "entry_class": "CtorFailsPlugin", + "capabilities": ["sidebar"], + "min_app_version": "0.1.0", + } + ), + ) + archive.writestr( + "plugin_main.py", + "class CtorFailsPlugin:\n" + " plugin_id = 'ctor-fails'\n" + " def __init__(self):\n" + " raise RuntimeError('ctor exploded')\n" + " def register(self, context):\n" + " pass\n" + " def unregister(self, context):\n" + " pass\n", + ) + + installed_root = installer.install_zip(zip_path) + assert installed_root.exists() + + store = PluginStateStore(tmp_path / "state.json") + manager = PluginManager( + builtin_root=tmp_path / "builtin", + external_root=external_root, + state_store=store, + context_factory=_ContextFactory(), + ) + + manager.load_enabled_plugins() + + state = store.get("ctor-fails") + assert state is not None + assert state["enabled"] is False + assert state["load_error"] From d63a0f8870f7d29413b1b32b6156470d650e71c1 Mon Sep 17 00:00:00 2001 From: Har01d Date: Sun, 5 Apr 2026 18:26:38 +0800 Subject: [PATCH 011/157] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E6=8F=92=E4=BB=B6?= =?UTF-8?q?=E5=AE=89=E8=A3=85=E6=A0=A1=E9=AA=8C=E4=B8=8E=E5=8A=A0=E8=BD=BD?= =?UTF-8?q?=E5=9B=9E=E6=BB=9A?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- system/plugins/installer.py | 67 ++++++++---- system/plugins/manager.py | 28 +++-- tests/test_system/test_plugin_installer.py | 59 +++++++++++ tests/test_system/test_plugin_manager.py | 116 +++++++++++++++++++++ 4 files changed, 236 insertions(+), 34 deletions(-) diff --git a/system/plugins/installer.py b/system/plugins/installer.py index 61886f92..6f79dbe6 100644 --- a/system/plugins/installer.py +++ b/system/plugins/installer.py @@ -9,7 +9,6 @@ from harmony_plugin_api.manifest import PluginManifest from .errors import PluginInstallError -from .loader import PluginLoader _FORBIDDEN_ROOT_IMPORTS = { "app", @@ -45,31 +44,53 @@ class PluginInstaller: def __init__(self, external_root: Path, temp_root: Path) -> None: self._external_root = external_root self._temp_root = temp_root - self._loader = PluginLoader() - def install_zip(self, zip_path: Path) -> Path: - extract_root = self._temp_root / zip_path.stem - if extract_root.exists(): - shutil.rmtree(extract_root) - extract_root.mkdir(parents=True, exist_ok=True) + def _load_manifest(self, plugin_root: Path) -> PluginManifest: + manifest_path = plugin_root / "plugin.json" + raw = manifest_path.read_text(encoding="utf-8") + return PluginManifest.from_dict(json.loads(raw)) - with zipfile.ZipFile(zip_path) as archive: - archive.extractall(extract_root) + def _validate_entrypoint_structure( + self, plugin_root: Path, manifest: PluginManifest + ) -> None: + entrypoint_path = plugin_root / manifest.entrypoint + if not entrypoint_path.exists(): + raise PluginInstallError( + f"Entrypoint file does not exist: {entrypoint_path}" + ) - audit_plugin_imports(extract_root) - manifest = PluginManifest.from_dict( - json.loads((extract_root / "plugin.json").read_text(encoding="utf-8")) + source = entrypoint_path.read_text(encoding="utf-8") + tree = ast.parse(source, filename=str(entrypoint_path)) + has_entry_class = any( + isinstance(node, ast.ClassDef) and node.name == manifest.entry_class + for node in ast.walk(tree) ) + if not has_entry_class: + raise PluginInstallError( + f"Entrypoint missing class '{manifest.entry_class}' for '{manifest.id}'" + ) + + def install_zip(self, zip_path: Path) -> Path: try: - self._loader.validate_plugin_structure(extract_root, manifest) + extract_root = self._temp_root / zip_path.stem + if extract_root.exists(): + shutil.rmtree(extract_root) + extract_root.mkdir(parents=True, exist_ok=True) + + with zipfile.ZipFile(zip_path) as archive: + archive.extractall(extract_root) + + audit_plugin_imports(extract_root) + manifest = self._load_manifest(extract_root) + self._validate_entrypoint_structure(extract_root, manifest) + + self._external_root.mkdir(parents=True, exist_ok=True) + final_root = self._external_root / manifest.id + if final_root.exists(): + shutil.rmtree(final_root) + shutil.copytree(extract_root, final_root) + return final_root + except PluginInstallError: + raise except Exception as exc: - raise PluginInstallError( - f"Invalid plugin package structure for '{manifest.id}': {exc}" - ) from exc - - self._external_root.mkdir(parents=True, exist_ok=True) - final_root = self._external_root / manifest.id - if final_root.exists(): - shutil.rmtree(final_root) - shutil.copytree(extract_root, final_root) - return final_root + raise PluginInstallError(f"Failed to install plugin from {zip_path}: {exc}") from exc diff --git a/system/plugins/manager.py b/system/plugins/manager.py index 4e0d8b8b..734f087f 100644 --- a/system/plugins/manager.py +++ b/system/plugins/manager.py @@ -14,7 +14,7 @@ def __init__(self, builtin_root: Path, external_root: Path, state_store, context self._context_factory = context_factory self._loader = PluginLoader() self.registry = PluginRegistry() - self._loaded_plugins: dict[str, tuple[object, object]] = {} + self._loaded_plugins: dict[str, tuple[object, object, object]] = {} def discover_roots(self) -> list[tuple[str, Path]]: roots = [] @@ -34,20 +34,21 @@ def load_enabled_plugins(self) -> None: for source, plugin_root in self.discover_roots(): manifest = None state = None + plugin = None + context = None try: - if source == "external": - manifest = self._loader.read_manifest(plugin_root) - state = self._state_store.get(manifest.id) - if state and state.get("enabled") is False: - continue - manifest, plugin = self._loader.load_plugin(plugin_root, manifest) - else: - manifest, plugin = self._loader.load_plugin(plugin_root) - state = self._state_store.get(manifest.id) + manifest = self._loader.read_manifest(plugin_root) + if manifest.id in self._loaded_plugins: + continue + state = self._state_store.get(manifest.id) + if source == "external" and state and state.get("enabled") is False: + continue + + manifest, plugin = self._loader.load_plugin(plugin_root, manifest) context = self._context_factory.build(manifest) plugin.register(context) - self._loaded_plugins[manifest.id] = (manifest, plugin) + self._loaded_plugins[manifest.id] = (manifest, plugin, context) self._state_store.set_enabled( manifest.id, True if state is None else bool(state.get("enabled", True)), @@ -58,6 +59,11 @@ def load_enabled_plugins(self) -> None: except Exception as exc: plugin_id = manifest.id if manifest is not None else plugin_root.name version = manifest.version if manifest is not None else "" + if plugin is not None and context is not None: + try: + plugin.unregister(context) + except Exception: + pass self.registry.unregister_plugin(plugin_id) self._loaded_plugins.pop(plugin_id, None) self._state_store.set_enabled( diff --git a/tests/test_system/test_plugin_installer.py b/tests/test_system/test_plugin_installer.py index 92da4203..bb8cad91 100644 --- a/tests/test_system/test_plugin_installer.py +++ b/tests/test_system/test_plugin_installer.py @@ -154,3 +154,62 @@ def test_install_then_load_uses_installed_relative_module_code(tmp_path: Path): _manifest, plugin = PluginLoader().load_plugin(installed_root) assert plugin.marker() == "from_installed" + + +def test_install_zip_missing_manifest_raises_plugin_install_error(tmp_path: Path): + installer = PluginInstaller( + external_root=tmp_path / "external", + temp_root=tmp_path / "temp", + ) + plugin_zip = _build_plugin_zip( + tmp_path, + "missing_manifest.zip", + { + "plugin_main.py": "class MissingManifestPlugin:\n pass\n", + }, + ) + + with pytest.raises(PluginInstallError): + installer.install_zip(plugin_zip) + + +def test_install_zip_does_not_execute_plugin_top_level_code(tmp_path: Path): + installer = PluginInstaller( + external_root=tmp_path / "external", + temp_root=tmp_path / "temp", + ) + plugin_zip = _build_plugin_zip( + tmp_path, + "no_exec_on_install.zip", + { + "plugin.json": json.dumps( + { + "id": "no-exec-on-install", + "name": "No Exec On Install", + "version": "1.0.0", + "api_version": "1", + "entrypoint": "plugin_main.py", + "entry_class": "NoExecPlugin", + "capabilities": ["sidebar"], + "min_app_version": "0.1.0", + } + ), + "plugin_main.py": ( + "from pathlib import Path\n" + "Path(__file__).with_name('import_executed.txt').write_text('1', encoding='utf-8')\n" + "class NoExecPlugin:\n" + " plugin_id = 'no-exec-on-install'\n" + " def register(self, context):\n" + " pass\n" + " def unregister(self, context):\n" + " pass\n" + ), + }, + ) + + installed_root = installer.install_zip(plugin_zip) + + assert not (installed_root / "import_executed.txt").exists() + assert not ( + tmp_path / "temp" / "no_exec_on_install" / "import_executed.txt" + ).exists() diff --git a/tests/test_system/test_plugin_manager.py b/tests/test_system/test_plugin_manager.py index a2abd823..54c12788 100644 --- a/tests/test_system/test_plugin_manager.py +++ b/tests/test_system/test_plugin_manager.py @@ -343,3 +343,119 @@ def test_constructor_failure_installs_then_records_runtime_load_error(tmp_path: assert state is not None assert state["enabled"] is False assert state["load_error"] + + +def test_manager_calls_unregister_when_register_raises(tmp_path: Path): + builtin_root = tmp_path / "builtin" + plugin_root = builtin_root / "broken-unregister" + plugin_root.mkdir(parents=True) + flag_file = tmp_path / "unregister_called.txt" + + (plugin_root / "plugin.json").write_text( + json.dumps( + { + "id": "broken-unregister", + "name": "Broken Unregister", + "version": "1.0.0", + "api_version": "1", + "entrypoint": "plugin_main.py", + "entry_class": "BrokenUnregisterPlugin", + "capabilities": ["sidebar"], + "min_app_version": "0.1.0", + } + ), + encoding="utf-8", + ) + (plugin_root / "plugin_main.py").write_text( + "from pathlib import Path\n" + "from harmony_plugin_api.registry_types import SidebarEntrySpec\n\n" + "class BrokenUnregisterPlugin:\n" + " plugin_id = 'broken-unregister'\n" + " def register(self, context):\n" + " context.ui.register_sidebar_entry(\n" + " SidebarEntrySpec(\n" + " plugin_id='broken-unregister',\n" + " entry_id='broken-unregister.sidebar',\n" + " title='Broken Unregister',\n" + " order=5,\n" + " icon_name=None,\n" + " page_factory=lambda _context, _parent: object(),\n" + " )\n" + " )\n" + " raise RuntimeError('register failed after partial work')\n" + " def unregister(self, context):\n" + f" Path(r'{flag_file}').write_text('1', encoding='utf-8')\n", + encoding="utf-8", + ) + + store = PluginStateStore(tmp_path / "state.json") + manager = PluginManager( + builtin_root=builtin_root, + external_root=tmp_path / "external", + state_store=store, + context_factory=_RegistryContextFactory(None), + ) + manager._context_factory = _RegistryContextFactory(manager.registry) + + manager.load_enabled_plugins() + + state = store.get("broken-unregister") + assert state is not None + assert state["enabled"] is False + assert state["load_error"] + assert flag_file.exists() + assert manager.registry.sidebar_entries() == [] + + +def test_manager_load_enabled_plugins_is_idempotent(tmp_path: Path): + builtin_root = tmp_path / "builtin" + plugin_root = builtin_root / "once" + plugin_root.mkdir(parents=True) + (plugin_root / "plugin.json").write_text( + json.dumps( + { + "id": "once", + "name": "Once Plugin", + "version": "1.0.0", + "api_version": "1", + "entrypoint": "plugin_main.py", + "entry_class": "OncePlugin", + "capabilities": ["sidebar"], + "min_app_version": "0.1.0", + } + ), + encoding="utf-8", + ) + (plugin_root / "plugin_main.py").write_text( + "from harmony_plugin_api.registry_types import SidebarEntrySpec\n\n" + "class OncePlugin:\n" + " plugin_id = 'once'\n" + " def register(self, context):\n" + " context.ui.register_sidebar_entry(\n" + " SidebarEntrySpec(\n" + " plugin_id='once',\n" + " entry_id='once.sidebar',\n" + " title='Once',\n" + " order=7,\n" + " icon_name=None,\n" + " page_factory=lambda _context, _parent: object(),\n" + " )\n" + " )\n" + " def unregister(self, context):\n" + " pass\n", + encoding="utf-8", + ) + + store = PluginStateStore(tmp_path / "state.json") + manager = PluginManager( + builtin_root=builtin_root, + external_root=tmp_path / "external", + state_store=store, + context_factory=_RegistryContextFactory(None), + ) + manager._context_factory = _RegistryContextFactory(manager.registry) + + manager.load_enabled_plugins() + manager.load_enabled_plugins() + + assert len(manager.registry.sidebar_entries()) == 1 From e29963a46218c00f608d4d398240d92cb74fa473 Mon Sep 17 00:00:00 2001 From: Har01d Date: Sun, 5 Apr 2026 18:39:23 +0800 Subject: [PATCH 012/157] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E6=8F=92=E4=BB=B6?= =?UTF-8?q?=E5=90=AF=E7=94=A8=E7=8A=B6=E6=80=81=E4=B8=8E=E5=AE=89=E8=A3=85?= =?UTF-8?q?=E4=BA=8B=E5=8A=A1=E6=80=A7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- system/plugins/installer.py | 28 ++++++- system/plugins/manager.py | 5 +- tests/test_system/test_plugin_installer.py | 49 ++++++++++++ tests/test_system/test_plugin_manager.py | 89 +++++++++++++++++++++- 4 files changed, 164 insertions(+), 7 deletions(-) diff --git a/system/plugins/installer.py b/system/plugins/installer.py index 6f79dbe6..497f8059 100644 --- a/system/plugins/installer.py +++ b/system/plugins/installer.py @@ -86,9 +86,31 @@ def install_zip(self, zip_path: Path) -> Path: self._external_root.mkdir(parents=True, exist_ok=True) final_root = self._external_root / manifest.id - if final_root.exists(): - shutil.rmtree(final_root) - shutil.copytree(extract_root, final_root) + staging_root = self._external_root / f"{manifest.id}.staging" + backup_root = self._external_root / f"{manifest.id}.backup" + + if staging_root.exists(): + shutil.rmtree(staging_root) + if backup_root.exists(): + shutil.rmtree(backup_root) + + shutil.copytree(extract_root, staging_root) + + had_existing = final_root.exists() + if had_existing: + final_root.replace(backup_root) + + try: + staging_root.replace(final_root) + except Exception: + if had_existing and backup_root.exists() and not final_root.exists(): + backup_root.replace(final_root) + if staging_root.exists(): + shutil.rmtree(staging_root) + raise + else: + if backup_root.exists(): + shutil.rmtree(backup_root) return final_root except PluginInstallError: raise diff --git a/system/plugins/manager.py b/system/plugins/manager.py index 734f087f..9cc952c1 100644 --- a/system/plugins/manager.py +++ b/system/plugins/manager.py @@ -28,7 +28,7 @@ def discover_roots(self) -> list[tuple[str, Path]]: for path in self._external_root.iterdir() if path.is_dir() ) - return roots + return sorted(roots, key=lambda item: (item[0], item[1].name)) def load_enabled_plugins(self) -> None: for source, plugin_root in self.discover_roots(): @@ -59,6 +59,7 @@ def load_enabled_plugins(self) -> None: except Exception as exc: plugin_id = manifest.id if manifest is not None else plugin_root.name version = manifest.version if manifest is not None else "" + enabled_on_error = True if state is None else bool(state.get("enabled", True)) if plugin is not None and context is not None: try: plugin.unregister(context) @@ -68,7 +69,7 @@ def load_enabled_plugins(self) -> None: self._loaded_plugins.pop(plugin_id, None) self._state_store.set_enabled( plugin_id, - False, + enabled_on_error, source=source, version=version, load_error=str(exc), diff --git a/tests/test_system/test_plugin_installer.py b/tests/test_system/test_plugin_installer.py index bb8cad91..9a5359e4 100644 --- a/tests/test_system/test_plugin_installer.py +++ b/tests/test_system/test_plugin_installer.py @@ -1,5 +1,6 @@ from pathlib import Path import json +import shutil import zipfile import pytest @@ -213,3 +214,51 @@ def test_install_zip_does_not_execute_plugin_top_level_code(tmp_path: Path): assert not ( tmp_path / "temp" / "no_exec_on_install" / "import_executed.txt" ).exists() + + +def test_install_zip_replacement_is_transactional_on_copy_failure( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +): + installer = PluginInstaller( + external_root=tmp_path / "external", + temp_root=tmp_path / "temp", + ) + existing_root = tmp_path / "external" / "stable-plugin" + existing_root.mkdir(parents=True) + (existing_root / "version.txt").write_text("old", encoding="utf-8") + + plugin_zip = _build_plugin_zip( + tmp_path, + "stable_plugin.zip", + { + "plugin.json": json.dumps( + { + "id": "stable-plugin", + "name": "Stable Plugin", + "version": "2.0.0", + "api_version": "1", + "entrypoint": "plugin_main.py", + "entry_class": "StablePlugin", + "capabilities": ["sidebar"], + "min_app_version": "0.1.0", + } + ), + "plugin_main.py": "class StablePlugin:\n pass\n", + "version.txt": "new\n", + }, + ) + + original_copytree = shutil.copytree + + def _failing_copytree(src, dst, *args, **kwargs): + if str(dst).endswith(".staging"): + raise OSError("copy failed") + return original_copytree(src, dst, *args, **kwargs) + + monkeypatch.setattr(shutil, "copytree", _failing_copytree) + + with pytest.raises(PluginInstallError): + installer.install_zip(plugin_zip) + + assert existing_root.exists() + assert (existing_root / "version.txt").read_text(encoding="utf-8") == "old" diff --git a/tests/test_system/test_plugin_manager.py b/tests/test_system/test_plugin_manager.py index 54c12788..15a05878 100644 --- a/tests/test_system/test_plugin_manager.py +++ b/tests/test_system/test_plugin_manager.py @@ -341,7 +341,7 @@ def test_constructor_failure_installs_then_records_runtime_load_error(tmp_path: state = store.get("ctor-fails") assert state is not None - assert state["enabled"] is False + assert state["enabled"] is True assert state["load_error"] @@ -401,7 +401,7 @@ def test_manager_calls_unregister_when_register_raises(tmp_path: Path): state = store.get("broken-unregister") assert state is not None - assert state["enabled"] is False + assert state["enabled"] is True assert state["load_error"] assert flag_file.exists() assert manager.registry.sidebar_entries() == [] @@ -459,3 +459,88 @@ def test_manager_load_enabled_plugins_is_idempotent(tmp_path: Path): manager.load_enabled_plugins() assert len(manager.registry.sidebar_entries()) == 1 + + +def test_external_plugin_failure_keeps_enabled_and_retries_after_fix(tmp_path: Path): + external_root = tmp_path / "external" + plugin_root = external_root / "retryable" + plugin_root.mkdir(parents=True) + (plugin_root / "plugin.json").write_text( + json.dumps( + { + "id": "retryable", + "name": "Retryable Plugin", + "version": "1.0.0", + "api_version": "1", + "entrypoint": "plugin_main.py", + "entry_class": "RetryablePlugin", + "capabilities": ["sidebar"], + "min_app_version": "0.1.0", + } + ), + encoding="utf-8", + ) + (plugin_root / "plugin_main.py").write_text( + "class RetryablePlugin:\n" + " plugin_id = 'retryable'\n" + " def __init__(self):\n" + " raise RuntimeError('boom')\n" + " def register(self, context):\n" + " pass\n" + " def unregister(self, context):\n" + " pass\n", + encoding="utf-8", + ) + + store = PluginStateStore(tmp_path / "state.json") + store.set_enabled("retryable", True, source="external", version="1.0.0") + first_manager = PluginManager( + builtin_root=tmp_path / "builtin", + external_root=external_root, + state_store=store, + context_factory=_RegistryContextFactory(None), + ) + first_manager._context_factory = _RegistryContextFactory(first_manager.registry) + first_manager.load_enabled_plugins() + + failed_state = store.get("retryable") + assert failed_state is not None + assert failed_state["enabled"] is True + assert failed_state["load_error"] + + (plugin_root / "plugin_main.py").write_text( + "from harmony_plugin_api.registry_types import SidebarEntrySpec\n\n" + "class RetryablePlugin:\n" + " plugin_id = 'retryable'\n" + " def register(self, context):\n" + " context.ui.register_sidebar_entry(\n" + " SidebarEntrySpec(\n" + " plugin_id='retryable',\n" + " entry_id='retryable.sidebar',\n" + " title='Retryable',\n" + " order=9,\n" + " icon_name=None,\n" + " page_factory=lambda _context, _parent: object(),\n" + " )\n" + " )\n" + " def unregister(self, context):\n" + " pass\n", + encoding="utf-8", + ) + + second_manager = PluginManager( + builtin_root=tmp_path / "builtin", + external_root=external_root, + state_store=store, + context_factory=_RegistryContextFactory(None), + ) + second_manager._context_factory = _RegistryContextFactory(second_manager.registry) + second_manager.load_enabled_plugins() + + recovered_state = store.get("retryable") + assert recovered_state is not None + assert recovered_state["enabled"] is True + assert recovered_state["load_error"] is None + assert [item.plugin_id for item in second_manager.registry.sidebar_entries()] == [ + "retryable" + ] From 34272c73dd39b288a9498468d3ef5a9d9277ef66 Mon Sep 17 00:00:00 2001 From: Har01d Date: Sun, 5 Apr 2026 18:48:58 +0800 Subject: [PATCH 013/157] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E6=8F=92=E4=BB=B6?= =?UTF-8?q?=E7=8A=B6=E6=80=81=E5=AD=98=E5=82=A8=E5=AE=B9=E9=94=99=E4=B8=8E?= =?UTF-8?q?=E5=86=85=E7=BD=AE=E7=A6=81=E7=94=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- system/plugins/manager.py | 2 +- system/plugins/state_store.py | 11 ++- tests/test_system/test_plugin_manager.py | 116 +++++++++++++++++++++++ 3 files changed, 126 insertions(+), 3 deletions(-) diff --git a/system/plugins/manager.py b/system/plugins/manager.py index 9cc952c1..672e1fed 100644 --- a/system/plugins/manager.py +++ b/system/plugins/manager.py @@ -42,7 +42,7 @@ def load_enabled_plugins(self) -> None: continue state = self._state_store.get(manifest.id) - if source == "external" and state and state.get("enabled") is False: + if state and state.get("enabled") is False: continue manifest, plugin = self._loader.load_plugin(plugin_root, manifest) diff --git a/system/plugins/state_store.py b/system/plugins/state_store.py index 19f82383..5b1e4970 100644 --- a/system/plugins/state_store.py +++ b/system/plugins/state_store.py @@ -1,6 +1,7 @@ from __future__ import annotations import json +import os from pathlib import Path @@ -12,13 +13,19 @@ def __init__(self, path: Path) -> None: def _read(self) -> dict: if not self._path.exists(): return {} - return json.loads(self._path.read_text(encoding="utf-8")) + try: + payload = json.loads(self._path.read_text(encoding="utf-8")) + return payload if isinstance(payload, dict) else {} + except (json.JSONDecodeError, OSError, ValueError): + return {} def _write(self, payload: dict) -> None: - self._path.write_text( + tmp_path = self._path.with_name(f"{self._path.name}.tmp") + tmp_path.write_text( json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8", ) + os.replace(tmp_path, self._path) def set_enabled( self, diff --git a/tests/test_system/test_plugin_manager.py b/tests/test_system/test_plugin_manager.py index 15a05878..35164691 100644 --- a/tests/test_system/test_plugin_manager.py +++ b/tests/test_system/test_plugin_manager.py @@ -544,3 +544,119 @@ def test_external_plugin_failure_keeps_enabled_and_retries_after_fix(tmp_path: P assert [item.plugin_id for item in second_manager.registry.sidebar_entries()] == [ "retryable" ] + + +def test_manager_startup_survives_corrupted_state_json(tmp_path: Path): + builtin_root = tmp_path / "builtin" + plugin_root = builtin_root / "safe" + plugin_root.mkdir(parents=True) + (plugin_root / "plugin.json").write_text( + json.dumps( + { + "id": "safe", + "name": "Safe Plugin", + "version": "1.0.0", + "api_version": "1", + "entrypoint": "plugin_main.py", + "entry_class": "SafePlugin", + "capabilities": ["sidebar"], + "min_app_version": "0.1.0", + } + ), + encoding="utf-8", + ) + (plugin_root / "plugin_main.py").write_text( + "from harmony_plugin_api.registry_types import SidebarEntrySpec\n\n" + "class SafePlugin:\n" + " plugin_id = 'safe'\n" + " def register(self, context):\n" + " context.ui.register_sidebar_entry(\n" + " SidebarEntrySpec(\n" + " plugin_id='safe',\n" + " entry_id='safe.sidebar',\n" + " title='Safe',\n" + " order=3,\n" + " icon_name=None,\n" + " page_factory=lambda _context, _parent: object(),\n" + " )\n" + " )\n" + " def unregister(self, context):\n" + " pass\n", + encoding="utf-8", + ) + + state_path = tmp_path / "state.json" + state_path.write_text("{broken", encoding="utf-8") + store = PluginStateStore(state_path) + manager = PluginManager( + builtin_root=builtin_root, + external_root=tmp_path / "external", + state_store=store, + context_factory=_RegistryContextFactory(None), + ) + manager._context_factory = _RegistryContextFactory(manager.registry) + + manager.load_enabled_plugins() + + state = store.get("safe") + assert state is not None + assert state["enabled"] is True + assert state["load_error"] is None + assert [item.plugin_id for item in manager.registry.sidebar_entries()] == ["safe"] + + +def test_manager_skips_disabled_builtin_plugin(tmp_path: Path): + builtin_root = tmp_path / "builtin" + plugin_root = builtin_root / "builtin-disabled" + plugin_root.mkdir(parents=True) + (plugin_root / "plugin.json").write_text( + json.dumps( + { + "id": "builtin-disabled", + "name": "Builtin Disabled", + "version": "1.0.0", + "api_version": "1", + "entrypoint": "plugin_main.py", + "entry_class": "BuiltinDisabledPlugin", + "capabilities": ["sidebar"], + "min_app_version": "0.1.0", + } + ), + encoding="utf-8", + ) + (plugin_root / "plugin_main.py").write_text( + "from harmony_plugin_api.registry_types import SidebarEntrySpec\n\n" + "class BuiltinDisabledPlugin:\n" + " plugin_id = 'builtin-disabled'\n" + " def register(self, context):\n" + " context.ui.register_sidebar_entry(\n" + " SidebarEntrySpec(\n" + " plugin_id='builtin-disabled',\n" + " entry_id='builtin-disabled.sidebar',\n" + " title='Builtin Disabled',\n" + " order=4,\n" + " icon_name=None,\n" + " page_factory=lambda _context, _parent: object(),\n" + " )\n" + " )\n" + " def unregister(self, context):\n" + " pass\n", + encoding="utf-8", + ) + + store = PluginStateStore(tmp_path / "state.json") + store.set_enabled("builtin-disabled", False, source="builtin", version="1.0.0") + manager = PluginManager( + builtin_root=builtin_root, + external_root=tmp_path / "external", + state_store=store, + context_factory=_RegistryContextFactory(None), + ) + manager._context_factory = _RegistryContextFactory(manager.registry) + + manager.load_enabled_plugins() + + state = store.get("builtin-disabled") + assert state is not None + assert state["enabled"] is False + assert manager.registry.sidebar_entries() == [] From 9356570de8d2e10a6000757a5bb3f063dad0dd66 Mon Sep 17 00:00:00 2001 From: Har01d Date: Sun, 5 Apr 2026 18:57:57 +0800 Subject: [PATCH 014/157] =?UTF-8?q?=E5=BF=BD=E7=95=A5=E6=8F=92=E4=BB=B6?= =?UTF-8?q?=E5=AE=89=E8=A3=85=E4=B8=B4=E6=97=B6=E7=9B=AE=E5=BD=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- system/plugins/manager.py | 2 + tests/test_system/test_plugin_manager.py | 95 ++++++++++++++++++++++++ 2 files changed, 97 insertions(+) diff --git a/system/plugins/manager.py b/system/plugins/manager.py index 672e1fed..da57a3e2 100644 --- a/system/plugins/manager.py +++ b/system/plugins/manager.py @@ -27,6 +27,8 @@ def discover_roots(self) -> list[tuple[str, Path]]: ("external", path) for path in self._external_root.iterdir() if path.is_dir() + and not path.name.endswith(".staging") + and not path.name.endswith(".backup") ) return sorted(roots, key=lambda item: (item[0], item[1].name)) diff --git a/tests/test_system/test_plugin_manager.py b/tests/test_system/test_plugin_manager.py index 35164691..999c5abf 100644 --- a/tests/test_system/test_plugin_manager.py +++ b/tests/test_system/test_plugin_manager.py @@ -660,3 +660,98 @@ def test_manager_skips_disabled_builtin_plugin(tmp_path: Path): assert state is not None assert state["enabled"] is False assert manager.registry.sidebar_entries() == [] + + +def test_manager_ignores_external_installer_scratch_directories(tmp_path: Path): + external_root = tmp_path / "external" + scratch_staging = external_root / "demo.staging" + scratch_backup = external_root / "demo.backup" + real_plugin = external_root / "real-plugin" + scratch_staging.mkdir(parents=True) + scratch_backup.mkdir(parents=True) + real_plugin.mkdir(parents=True) + + for scratch_dir in (scratch_staging, scratch_backup): + (scratch_dir / "plugin.json").write_text( + json.dumps( + { + "id": scratch_dir.name, + "name": scratch_dir.name, + "version": "1.0.0", + "api_version": "1", + "entrypoint": "plugin_main.py", + "entry_class": "ScratchPlugin", + "capabilities": ["sidebar"], + "min_app_version": "0.1.0", + } + ), + encoding="utf-8", + ) + (scratch_dir / "plugin_main.py").write_text( + "raise RuntimeError('scratch directory should be ignored')\n" + "class ScratchPlugin:\n" + " plugin_id = 'scratch'\n" + " def register(self, context):\n" + " pass\n" + " def unregister(self, context):\n" + " pass\n", + encoding="utf-8", + ) + + (real_plugin / "plugin.json").write_text( + json.dumps( + { + "id": "real-plugin", + "name": "Real Plugin", + "version": "1.0.0", + "api_version": "1", + "entrypoint": "plugin_main.py", + "entry_class": "RealPlugin", + "capabilities": ["sidebar"], + "min_app_version": "0.1.0", + } + ), + encoding="utf-8", + ) + (real_plugin / "plugin_main.py").write_text( + "from harmony_plugin_api.registry_types import SidebarEntrySpec\n\n" + "class RealPlugin:\n" + " plugin_id = 'real-plugin'\n" + " def register(self, context):\n" + " context.ui.register_sidebar_entry(\n" + " SidebarEntrySpec(\n" + " plugin_id='real-plugin',\n" + " entry_id='real-plugin.sidebar',\n" + " title='Real Plugin',\n" + " order=6,\n" + " icon_name=None,\n" + " page_factory=lambda _context, _parent: object(),\n" + " )\n" + " )\n" + " def unregister(self, context):\n" + " pass\n", + encoding="utf-8", + ) + + store = PluginStateStore(tmp_path / "state.json") + manager = PluginManager( + builtin_root=tmp_path / "builtin", + external_root=external_root, + state_store=store, + context_factory=_RegistryContextFactory(None), + ) + manager._context_factory = _RegistryContextFactory(manager.registry) + + discovered = manager.discover_roots() + assert ("external", real_plugin) in discovered + assert ("external", scratch_staging) not in discovered + assert ("external", scratch_backup) not in discovered + + manager.load_enabled_plugins() + + state = store.get("real-plugin") + assert state is not None + assert state["enabled"] is True + assert [item.plugin_id for item in manager.registry.sidebar_entries()] == [ + "real-plugin" + ] From 28be49eb4da315acad3526a95fdedfd191c6987a Mon Sep 17 00:00:00 2001 From: Har01d Date: Sun, 5 Apr 2026 19:11:48 +0800 Subject: [PATCH 015/157] =?UTF-8?q?=E6=8E=A5=E5=85=A5=E6=8F=92=E4=BB=B6?= =?UTF-8?q?=E5=AE=BF=E4=B8=BB=E6=A1=A5=E6=8E=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/bootstrap.py | 20 +++ services/online/download_service.py | 4 +- system/plugins/host_services.py | 103 +++++++++++++++ system/plugins/media_bridge.py | 37 ++++++ tests/test_app/test_plugin_bootstrap.py | 30 +++++ .../test_system/test_plugin_online_bridge.py | 122 ++++++++++++++++++ 6 files changed, 314 insertions(+), 2 deletions(-) create mode 100644 system/plugins/host_services.py create mode 100644 system/plugins/media_bridge.py create mode 100644 tests/test_app/test_plugin_bootstrap.py create mode 100644 tests/test_system/test_plugin_online_bridge.py diff --git a/app/bootstrap.py b/app/bootstrap.py index 57128ed0..2a46fca6 100644 --- a/app/bootstrap.py +++ b/app/bootstrap.py @@ -4,6 +4,7 @@ import logging import threading +from pathlib import Path from typing import TYPE_CHECKING, Optional from infrastructure import HttpClient @@ -28,6 +29,9 @@ from services.playback import PlaybackService, QueueService from system.config import ConfigManager from system.event_bus import EventBus +from system.plugins.host_services import BootstrapPluginContextFactory +from system.plugins.manager import PluginManager +from system.plugins.state_store import PluginStateStore if TYPE_CHECKING: from services.lyrics.qqmusic_lyrics import QQMusicClient @@ -95,6 +99,7 @@ def __init__(self, db_path: str = "Harmony.db"): self._cache_cleaner_service: Optional["CacheCleanerService"] = None self._sleep_timer_service: Optional["SleepTimerService"] = None self._mpris_controller: Optional["MPRISController"] = None + self._plugin_manager: Optional[PluginManager] = None @classmethod def instance(cls, db_path: str = "Harmony.db") -> "Bootstrap": @@ -341,6 +346,21 @@ def file_org_service(self) -> FileOrganizationService: ) return self._file_org_service + @property + def plugin_manager(self) -> PluginManager: + """Get plugin manager.""" + if self._plugin_manager is None: + self._plugin_manager = PluginManager( + builtin_root=Path("plugins/builtin"), + external_root=Path("data/plugins/external"), + state_store=PluginStateStore(Path("data/plugins/state.json")), + context_factory=BootstrapPluginContextFactory( + self, + storage_root=Path("data/plugins/storage"), + ), + ) + return self._plugin_manager + # ===== QQ Music ===== @property diff --git a/services/online/download_service.py b/services/online/download_service.py index 560b1a57..f4f1d4a5 100644 --- a/services/online/download_service.py +++ b/services/online/download_service.py @@ -100,7 +100,7 @@ def get_cached_path(self, song_mid: str, quality: Optional[str] = None) -> str: return existing_path if quality is None: - quality = self._config.get_qqmusic_quality() if self._config else "320" + quality = "320" ext = self._get_extension_for_quality(quality) filename = f"{song_mid}{ext}" return os.path.join(self._download_dir, filename) @@ -144,7 +144,7 @@ def download( """ # Use configured quality if not specified if quality is None: - quality = self._config.get_qqmusic_quality() if self._config else "320" + quality = "320" # Check cache first (skip if force re-download) cached_path = self.get_cached_path(song_mid, quality) diff --git a/system/plugins/host_services.py b/system/plugins/host_services.py new file mode 100644 index 00000000..ddb37566 --- /dev/null +++ b/system/plugins/host_services.py @@ -0,0 +1,103 @@ +from __future__ import annotations + +import logging +from pathlib import Path + + +class PluginSettingsBridgeImpl: + def __init__(self, plugin_id: str, config) -> None: + self._plugin_id = plugin_id + self._config = config + + def _key(self, key: str) -> str: + return f"plugins.{self._plugin_id}.{key}" + + def get(self, key: str, default=None): + return self._config.get(self._key(key), default) + + def set(self, key: str, value) -> None: + self._config.set(self._key(key), value) + + +class PluginStorageBridgeImpl: + def __init__(self, root: Path, plugin_id: str) -> None: + self.data_dir = root / plugin_id / "data" + self.cache_dir = root / plugin_id / "cache" + self.temp_dir = root / plugin_id / "tmp" + for path in (self.data_dir, self.cache_dir, self.temp_dir): + path.mkdir(parents=True, exist_ok=True) + + +class PluginUiBridgeImpl: + def __init__(self, plugin_id: str, registry) -> None: + self._plugin_id = plugin_id + self._registry = registry + + def register_sidebar_entry(self, spec) -> None: + self._registry.register_sidebar_entry(self._plugin_id, spec) + + def register_settings_tab(self, spec) -> None: + self._registry.register_settings_tab(self._plugin_id, spec) + + +class PluginServiceBridgeImpl: + def __init__(self, plugin_id: str, registry, media_bridge) -> None: + self._plugin_id = plugin_id + self._registry = registry + self._media = media_bridge + + @property + def media(self): + return self._media + + def register_lyrics_source(self, source) -> None: + self._registry.register_lyrics_source(self._plugin_id, source) + + def register_cover_source(self, source) -> None: + self._registry.register_cover_source(self._plugin_id, source) + + def register_artist_cover_source(self, source) -> None: + self._registry.register_artist_cover_source(self._plugin_id, source) + + def register_online_music_provider(self, provider) -> None: + self._registry.register_online_provider(self._plugin_id, provider) + + +class BootstrapPluginContextFactory: + def __init__(self, bootstrap, storage_root: Path) -> None: + self._bootstrap = bootstrap + self._storage_root = storage_root + + def build(self, manifest): + from harmony_plugin_api.context import PluginContext + + plugin_id = manifest.id + registry = self._bootstrap.plugin_manager.registry + media_bridge = PluginMediaBridge( + self._bootstrap.online_download_service, + self._bootstrap.playback_service, + self._bootstrap.library_service, + ) + return PluginContext( + plugin_id=plugin_id, + manifest=manifest, + logger=logging.getLogger(f"plugin.{plugin_id}"), + http=self._bootstrap.http_client, + events=self._bootstrap.event_bus, + storage=PluginStorageBridgeImpl(self._storage_root, plugin_id), + settings=PluginSettingsBridgeImpl(plugin_id, self._bootstrap.config), + ui=PluginUiBridgeImpl(plugin_id, registry), + services=PluginServiceBridgeImpl(plugin_id, registry, media_bridge), + ) + + +from .media_bridge import PluginMediaBridge + +__all__ = [ + "BootstrapPluginContextFactory", + "PluginMediaBridge", + "PluginServiceBridgeImpl", + "PluginSettingsBridgeImpl", + "PluginStorageBridgeImpl", + "PluginUiBridgeImpl", +] diff --git a/system/plugins/media_bridge.py b/system/plugins/media_bridge.py new file mode 100644 index 00000000..5d0cfba5 --- /dev/null +++ b/system/plugins/media_bridge.py @@ -0,0 +1,37 @@ +from __future__ import annotations + +from harmony_plugin_api.media import PluginPlaybackRequest + + +class PluginMediaBridge: + """Host bridge for plugin-triggered cache/download/library actions.""" + + def __init__(self, download_service, playback_service, library_service) -> None: + self._download_service = download_service + self._playback_service = playback_service + self._library_service = library_service + + def cache_remote_track( + self, + request: PluginPlaybackRequest, + progress_callback=None, + force: bool = False, + ): + return self._download_service.download( + request.track_id, + song_title=request.title, + quality=request.quality, + progress_callback=progress_callback, + force=force, + ) + + def add_online_track(self, request: PluginPlaybackRequest): + metadata = request.metadata + return self._library_service.add_online_track( + request.track_id, + metadata.get("title", request.title), + metadata.get("artist", ""), + metadata.get("album", ""), + float(metadata.get("duration", 0.0) or 0.0), + metadata.get("cover_url"), + ) diff --git a/tests/test_app/test_plugin_bootstrap.py b/tests/test_app/test_plugin_bootstrap.py new file mode 100644 index 00000000..ce551e96 --- /dev/null +++ b/tests/test_app/test_plugin_bootstrap.py @@ -0,0 +1,30 @@ +from pathlib import Path +from unittest.mock import MagicMock + +import app.bootstrap as bootstrap_module + + +def test_bootstrap_exposes_plugin_manager(monkeypatch): + fake_state_store = object() + fake_manager = object() + state_store_ctor = MagicMock(return_value=fake_state_store) + manager_ctor = MagicMock(return_value=fake_manager) + + monkeypatch.setattr(bootstrap_module, "PluginStateStore", state_store_ctor, raising=False) + monkeypatch.setattr(bootstrap_module, "PluginManager", manager_ctor, raising=False) + + bootstrap = bootstrap_module.Bootstrap(":memory:") + bootstrap._config = object() + bootstrap._event_bus = object() + bootstrap._http_client = object() + + manager = bootstrap.plugin_manager + + assert manager is fake_manager + assert bootstrap.plugin_manager is fake_manager + + _, kwargs = manager_ctor.call_args + assert kwargs["builtin_root"] == Path("plugins/builtin") + assert kwargs["external_root"] == Path("data/plugins/external") + assert kwargs["state_store"] is fake_state_store + assert hasattr(kwargs["context_factory"], "build") diff --git a/tests/test_system/test_plugin_online_bridge.py b/tests/test_system/test_plugin_online_bridge.py new file mode 100644 index 00000000..0722ac73 --- /dev/null +++ b/tests/test_system/test_plugin_online_bridge.py @@ -0,0 +1,122 @@ +from pathlib import Path +from unittest.mock import Mock + +from harmony_plugin_api.media import PluginPlaybackRequest +from harmony_plugin_api.registry_types import SettingsTabSpec, SidebarEntrySpec +from system.plugins.host_services import ( + PluginServiceBridgeImpl, + PluginSettingsBridgeImpl, + PluginStorageBridgeImpl, + PluginUiBridgeImpl, +) +from system.plugins.media_bridge import PluginMediaBridge +from system.plugins.registry import PluginRegistry + + +def test_plugin_settings_bridge_namespaces_keys(): + config = Mock() + config.get.return_value = "flac" + bridge = PluginSettingsBridgeImpl("qqmusic", config) + + assert bridge.get("quality") == "flac" + config.get.assert_called_once_with("plugins.qqmusic.quality", None) + + bridge.set("quality", "320") + config.set.assert_called_once_with("plugins.qqmusic.quality", "320") + + +def test_plugin_storage_bridge_creates_private_directories(tmp_path: Path): + bridge = PluginStorageBridgeImpl(tmp_path, "qqmusic") + + assert bridge.data_dir == tmp_path / "qqmusic" / "data" + assert bridge.cache_dir == tmp_path / "qqmusic" / "cache" + assert bridge.temp_dir == tmp_path / "qqmusic" / "tmp" + assert bridge.data_dir.exists() + assert bridge.cache_dir.exists() + assert bridge.temp_dir.exists() + + +def test_plugin_ui_bridge_registers_with_plugin_id(): + registry = PluginRegistry() + bridge = PluginUiBridgeImpl("qqmusic", registry) + sidebar_spec = SidebarEntrySpec( + plugin_id="qqmusic", + entry_id="qqmusic.sidebar", + title="QQ Music", + order=80, + icon_name="GLOBE", + page_factory=lambda _context, _parent: object(), + ) + settings_spec = SettingsTabSpec( + plugin_id="qqmusic", + tab_id="qqmusic.settings", + title="QQ Music", + order=80, + widget_factory=lambda _context, _parent: object(), + ) + + bridge.register_sidebar_entry(sidebar_spec) + bridge.register_settings_tab(settings_spec) + + assert registry.sidebar_entries() == [sidebar_spec] + assert registry.settings_tabs() == [settings_spec] + + +def test_plugin_service_bridge_registers_sources_and_exposes_media(): + registry = PluginRegistry() + media = Mock() + bridge = PluginServiceBridgeImpl("qqmusic", registry, media) + lyrics_source = Mock() + cover_source = Mock() + artist_cover_source = Mock() + provider = Mock() + + bridge.register_lyrics_source(lyrics_source) + bridge.register_cover_source(cover_source) + bridge.register_artist_cover_source(artist_cover_source) + bridge.register_online_music_provider(provider) + + assert bridge.media is media + assert registry.lyrics_sources() == [lyrics_source] + assert registry.cover_sources() == [cover_source] + assert registry.artist_cover_sources() == [artist_cover_source] + assert registry.online_providers() == [provider] + + +def test_media_bridge_passes_explicit_quality_to_download_service(): + download_service = Mock() + playback_service = Mock() + library_service = Mock() + bridge = PluginMediaBridge(download_service, playback_service, library_service) + request = PluginPlaybackRequest( + provider_id="qqmusic", + track_id="mid-1", + title="Song 1", + quality="flac", + metadata={ + "title": "Song 1", + "artist": "Singer 1", + "album": "Album 1", + "duration": 180.0, + "cover_url": "https://example.com/cover.jpg", + }, + ) + + bridge.cache_remote_track(request) + download_service.download.assert_called_once_with( + "mid-1", + song_title="Song 1", + quality="flac", + progress_callback=None, + force=False, + ) + + bridge.add_online_track(request) + library_service.add_online_track.assert_called_once_with( + "mid-1", + "Song 1", + "Singer 1", + "Album 1", + 180.0, + "https://example.com/cover.jpg", + ) From c2d515547ab219277c6f0df5ce19de6da731faf7 Mon Sep 17 00:00:00 2001 From: Har01d Date: Sun, 5 Apr 2026 19:17:50 +0800 Subject: [PATCH 016/157] =?UTF-8?q?=E6=96=B0=E5=A2=9E=E6=8F=92=E4=BB=B6?= =?UTF-8?q?=E7=AE=A1=E7=90=86=E9=A1=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- system/plugins/manager.py | 37 +++++++++++ tests/test_ui/test_plugin_settings_tab.py | 70 +++++++++++++++++++++ translations/en.json | 6 ++ translations/zh.json | 6 ++ ui/dialogs/plugin_management_tab.py | 76 +++++++++++++++++++++++ ui/dialogs/settings_dialog.py | 13 +++- 6 files changed, 207 insertions(+), 1 deletion(-) create mode 100644 tests/test_ui/test_plugin_settings_tab.py create mode 100644 ui/dialogs/plugin_management_tab.py diff --git a/system/plugins/manager.py b/system/plugins/manager.py index da57a3e2..e0d296cf 100644 --- a/system/plugins/manager.py +++ b/system/plugins/manager.py @@ -1,7 +1,10 @@ from __future__ import annotations from pathlib import Path +from urllib.parse import urlparse +from infrastructure import HttpClient +from .installer import PluginInstaller from .loader import PluginLoader from .registry import PluginRegistry @@ -13,6 +16,10 @@ def __init__(self, builtin_root: Path, external_root: Path, state_store, context self._state_store = state_store self._context_factory = context_factory self._loader = PluginLoader() + self._installer = PluginInstaller( + external_root=external_root, + temp_root=external_root.parent / "tmp", + ) self.registry = PluginRegistry() self._loaded_plugins: dict[str, tuple[object, object, object]] = {} @@ -76,3 +83,33 @@ def load_enabled_plugins(self) -> None: version=version, load_error=str(exc), ) + + def list_plugins(self) -> list[dict]: + plugins = [] + for source, plugin_root in self.discover_roots(): + manifest = self._loader.read_manifest(plugin_root) + state = self._state_store.get(manifest.id) or {} + plugins.append( + { + "id": manifest.id, + "name": manifest.name, + "version": manifest.version, + "source": source, + "enabled": bool(state.get("enabled", True)), + "load_error": state.get("load_error"), + } + ) + return plugins + + def install_zip(self, zip_path: str | Path) -> Path: + return self._installer.install_zip(Path(zip_path)) + + def install_from_url(self, url: str) -> Path: + parsed = urlparse(url) + archive_name = Path(parsed.path).name or "plugin.zip" + download_path = self._installer._temp_root / archive_name + download_path.parent.mkdir(parents=True, exist_ok=True) + response = HttpClient.shared().get(url, timeout=60) + response.raise_for_status() + download_path.write_bytes(response.content) + return self.install_zip(download_path) diff --git a/tests/test_ui/test_plugin_settings_tab.py b/tests/test_ui/test_plugin_settings_tab.py new file mode 100644 index 00000000..26b6a0f8 --- /dev/null +++ b/tests/test_ui/test_plugin_settings_tab.py @@ -0,0 +1,70 @@ +from unittest.mock import Mock + +from PySide6.QtWidgets import QTabWidget + +from system.theme import ThemeManager +from ui.dialogs.plugin_management_tab import PluginManagementTab +from ui.dialogs.settings_dialog import GeneralSettingsDialog + + +def test_plugin_management_tab_shows_plugin_rows(qtbot): + manager = Mock() + manager.list_plugins.return_value = [ + { + "id": "lrclib", + "name": "LRCLIB", + "version": "1.0.0", + "source": "builtin", + "enabled": True, + "load_error": None, + }, + { + "id": "qqmusic", + "name": "QQ Music", + "version": "1.0.0", + "source": "external", + "enabled": False, + "load_error": "load failed", + }, + ] + + widget = PluginManagementTab(manager) + qtbot.addWidget(widget) + + assert widget._table.rowCount() == 2 + + +def test_settings_dialog_includes_plugins_tab(monkeypatch, qtbot): + config = Mock() + config.get.return_value = "dark" + config.get_ai_enabled.return_value = False + config.get_ai_base_url.return_value = "" + config.get_ai_api_key.return_value = "" + config.get_ai_model.return_value = "" + config.get_acoustid_enabled.return_value = False + config.get_acoustid_api_key.return_value = "" + config.get_online_music_download_dir.return_value = "data/online_cache" + config.get_cache_cleanup_strategy.return_value = "manual" + config.get_cache_cleanup_auto_enabled.return_value = False + config.get_cache_cleanup_time_days.return_value = 30 + config.get_cache_cleanup_size_mb.return_value = 1000 + config.get_cache_cleanup_count.return_value = 100 + config.get_cache_cleanup_interval_hours.return_value = 1 + config.get_audio_engine.return_value = "mpv" + config.get_qqmusic_credential.return_value = None + config.get_qqmusic_quality.return_value = "320" + + fake_manager = Mock() + fake_manager.list_plugins.return_value = [] + fake_manager.registry.settings_tabs.return_value = [] + bootstrap = Mock(plugin_manager=fake_manager) + monkeypatch.setattr("ui.dialogs.settings_dialog.Bootstrap.instance", lambda: bootstrap) + ThemeManager._instance = None + ThemeManager.instance(config) + + dialog = GeneralSettingsDialog(config) + qtbot.addWidget(dialog) + tab_widget = dialog.findChild(QTabWidget) + + tab_labels = [tab_widget.tabText(index) for index in range(tab_widget.count())] + assert "Plugins" in tab_labels or "插件" in tab_labels diff --git a/translations/en.json b/translations/en.json index f9017027..2770b754 100644 --- a/translations/en.json +++ b/translations/en.json @@ -315,6 +315,12 @@ "ai_tab": "AI Enhancement", "acoustid_tab": "AcoustID", "qqmusic_tab": "QQ Music", + "plugins_tab": "Plugins", + "plugins_install_zip": "Install Zip", + "plugins_install_url": "Install URL", + "plugins_load_error": "Load Error", + "plugins_enabled": "Enabled", + "plugins_disabled": "Disabled", "qqmusic_login": "QQ Music Login", "qqmusic_qr_login": "QR Login", "qqmusic_manual_login": "Manual Input", diff --git a/translations/zh.json b/translations/zh.json index 0fdbd51d..03ba11a1 100644 --- a/translations/zh.json +++ b/translations/zh.json @@ -315,6 +315,12 @@ "ai_tab": "AI 增强", "acoustid_tab": "AcoustID", "qqmusic_tab": "QQ音乐", + "plugins_tab": "插件", + "plugins_install_zip": "安装 Zip", + "plugins_install_url": "在线安装", + "plugins_load_error": "加载错误", + "plugins_enabled": "已启用", + "plugins_disabled": "已禁用", "qqmusic_login": "QQ音乐登录", "qqmusic_qr_login": "扫码登录", "qqmusic_manual_login": "手动输入", diff --git a/ui/dialogs/plugin_management_tab.py b/ui/dialogs/plugin_management_tab.py new file mode 100644 index 00000000..490b322a --- /dev/null +++ b/ui/dialogs/plugin_management_tab.py @@ -0,0 +1,76 @@ +from __future__ import annotations + +from PySide6.QtWidgets import ( + QFileDialog, + QHBoxLayout, + QLineEdit, + QPushButton, + QTableWidget, + QTableWidgetItem, + QVBoxLayout, + QWidget, +) + +from system.i18n import t + + +class PluginManagementTab(QWidget): + def __init__(self, plugin_manager, parent=None): + super().__init__(parent) + self._plugin_manager = plugin_manager + self._table = QTableWidget(0, 5, self) + self._url_input = QLineEdit(self) + self._setup_ui() + self.refresh() + + def _setup_ui(self) -> None: + layout = QVBoxLayout(self) + self._table.setHorizontalHeaderLabels( + [ + t("name"), + t("version"), + t("source"), + t("status"), + t("plugins_load_error"), + ] + ) + layout.addWidget(self._table) + + controls = QHBoxLayout() + self._url_input.setPlaceholderText("https://example.com/plugin.zip") + install_zip_btn = QPushButton(t("plugins_install_zip"), self) + install_zip_btn.clicked.connect(self._install_zip) + install_url_btn = QPushButton(t("plugins_install_url"), self) + install_url_btn.clicked.connect(self._install_url) + controls.addWidget(self._url_input) + controls.addWidget(install_zip_btn) + controls.addWidget(install_url_btn) + layout.addLayout(controls) + + def refresh(self) -> None: + rows = self._plugin_manager.list_plugins() + self._table.setRowCount(len(rows)) + for row_index, row in enumerate(rows): + self._table.setItem(row_index, 0, QTableWidgetItem(row["name"])) + self._table.setItem(row_index, 1, QTableWidgetItem(row["version"])) + self._table.setItem(row_index, 2, QTableWidgetItem(row["source"])) + status = t("plugins_enabled") if row["enabled"] else t("plugins_disabled") + self._table.setItem(row_index, 3, QTableWidgetItem(status)) + self._table.setItem(row_index, 4, QTableWidgetItem(row["load_error"] or "")) + + def _install_zip(self) -> None: + path, _ = QFileDialog.getOpenFileName( + self, + t("plugins_install_zip"), + "", + "Zip Files (*.zip)", + ) + if path: + self._plugin_manager.install_zip(path) + self.refresh() + + def _install_url(self) -> None: + url = self._url_input.text().strip() + if url: + self._plugin_manager.install_from_url(url) + self.refresh() diff --git a/ui/dialogs/settings_dialog.py b/ui/dialogs/settings_dialog.py index 45bed481..f9e7e273 100644 --- a/ui/dialogs/settings_dialog.py +++ b/ui/dialogs/settings_dialog.py @@ -1,11 +1,11 @@ """ General Settings Dialog for configuring AI, AcoustID, and QQ Music. """ -import importlib.util import logging import os from typing import Optional +from app.bootstrap import Bootstrap from PySide6.QtCore import Qt, QThread, Signal from PySide6.QtGui import QColor, QPainterPath, QRegion from PySide6.QtWidgets import ( @@ -20,6 +20,7 @@ from system.theme import ThemeManager from ui.dialogs.dialog_title_bar import setup_equalizer_title_layout from ui.dialogs.message_dialog import MessageDialog, Yes, No +from ui.dialogs.plugin_management_tab import PluginManagementTab from ui.dialogs.progress_dialog import ProgressDialog from services.cloud.qqmusic.common import ( get_selectable_qualities, @@ -856,6 +857,16 @@ def _setup_ui(self): tab_widget.addTab(repair_tab, t("repair_tab")) tab_widget.addTab(ai_tab, t("ai_tab")) tab_widget.addTab(acoustid_tab, t("acoustid_tab")) + bootstrap = Bootstrap.instance() + tab_widget.addTab( + PluginManagementTab(bootstrap.plugin_manager, self), + t("plugins_tab"), + ) + for spec in bootstrap.plugin_manager.registry.settings_tabs(): + tab_widget.addTab( + spec.widget_factory(bootstrap.plugin_manager, self), + spec.title, + ) layout.addWidget(tab_widget) From 382fad11ce0176e9e14217aac66a39df5d9fd127 Mon Sep 17 00:00:00 2001 From: Har01d Date: Sun, 5 Apr 2026 19:23:06 +0800 Subject: [PATCH 017/157] =?UTF-8?q?=E6=94=AF=E6=8C=81=E6=8F=92=E4=BB=B6?= =?UTF-8?q?=E4=BE=A7=E8=BE=B9=E6=A0=8F=E9=A1=B5=E9=9D=A2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../test_plugin_sidebar_integration.py | 72 +++++++++++++++++++ ui/windows/components/sidebar.py | 16 +++++ ui/windows/main_window.py | 16 +++++ 3 files changed, 104 insertions(+) create mode 100644 tests/test_ui/test_plugin_sidebar_integration.py diff --git a/tests/test_ui/test_plugin_sidebar_integration.py b/tests/test_ui/test_plugin_sidebar_integration.py new file mode 100644 index 00000000..cb8a80ca --- /dev/null +++ b/tests/test_ui/test_plugin_sidebar_integration.py @@ -0,0 +1,72 @@ +from unittest.mock import Mock, patch + +import pytest +from PySide6.QtWidgets import QApplication, QLabel, QMainWindow, QStackedWidget + +from system.theme import ThemeManager +from ui.windows.components.sidebar import Sidebar +from ui.windows.main_window import MainWindow + + +@pytest.fixture(scope="module") +def qapp(): + app = QApplication.instance() + if app is None: + app = QApplication([]) + yield app + + +@pytest.fixture(autouse=True) +def reset_theme_singleton(): + ThemeManager._instance = None + yield + ThemeManager._instance = None + + +@pytest.fixture +def mock_config(): + config = Mock() + config.get.return_value = "dark" + config.get_ai_enabled.return_value = False + return config + + +def test_sidebar_can_add_plugin_entry(qapp, mock_config): + ThemeManager.instance(mock_config) + sidebar = Sidebar(config_manager=mock_config) + + sidebar.add_plugin_entry(page_index=200, title="QQ Music", icon_name="GLOBE") + + assert any(index == 200 for index, _button in sidebar._nav_buttons) + + +def test_main_window_mounts_plugin_pages(qapp, mock_config): + ThemeManager.instance(mock_config) + window = MainWindow.__new__(MainWindow) + QMainWindow.__init__(window) + window._stacked_widget = QStackedWidget() + window._sidebar = Sidebar(config_manager=mock_config) + + bootstrap = Mock() + bootstrap.plugin_manager.registry.sidebar_entries.return_value = [ + type( + "Spec", + (), + { + "plugin_id": "qqmusic", + "entry_id": "qqmusic.sidebar", + "title": "QQ Music", + "order": 80, + "icon_name": "GLOBE", + "page_factory": staticmethod( + lambda _context, _parent: QLabel("QQ Music View") + ), + }, + )() + ] + + with patch("ui.windows.main_window.Bootstrap.instance", return_value=bootstrap): + window._mount_plugin_pages() + + assert "qqmusic" in window._plugin_page_keys.values() + assert window._stacked_widget.count() == 1 diff --git a/ui/windows/components/sidebar.py b/ui/windows/components/sidebar.py index 3893ec77..0564fc81 100644 --- a/ui/windows/components/sidebar.py +++ b/ui/windows/components/sidebar.py @@ -193,6 +193,22 @@ def refresh_theme(self): settings_style = tm.get_qss(self._ACTION_BTN_STYLE).replace("{btn_id}", "settingsBtn") self._settings_btn.setStyleSheet(settings_style) + def add_plugin_entry( + self, + page_index: int, + title: str, + icon_name: str | None = None, + ) -> None: + """Add a plugin-provided navigation button before the footer actions.""" + resolved_icon = getattr(IconName, icon_name, IconName.GLOBE) if icon_name else IconName.GLOBE + btn = IconButton(resolved_icon, title, size=18) + btn.setCheckable(True) + btn.setCursor(Qt.PointingHandCursor) + btn.clicked.connect(lambda checked, idx=page_index: self._on_nav_clicked(idx)) + insert_index = max(self.layout().count() - 4, 0) + self.layout().insertWidget(insert_index, btn) + self._nav_buttons.append((page_index, btn)) + def _on_nav_clicked(self, page_index: int): """Handle navigation button click.""" # Uncheck other buttons diff --git a/ui/windows/main_window.py b/ui/windows/main_window.py index b945699f..f0ffbf92 100644 --- a/ui/windows/main_window.py +++ b/ui/windows/main_window.py @@ -416,6 +416,7 @@ def _setup_ui(self): self._stacked_widget.addWidget(self._online_music_view) # 8 self._stacked_widget.addWidget(self._genres_view) # 9 self._stacked_widget.addWidget(self._genre_view) # 10 + self._mount_plugin_pages() self._stacked_widget.setMinimumWidth(200) self._splitter.addWidget(self._stacked_widget) @@ -463,6 +464,21 @@ def _create_sidebar(self) -> QWidget: return sidebar + def _mount_plugin_pages(self) -> None: + """Mount plugin-provided pages into the stacked widget and sidebar.""" + self._plugin_page_keys = {} + bootstrap = Bootstrap.instance() + for spec in bootstrap.plugin_manager.registry.sidebar_entries(): + page_index = self._stacked_widget.count() + widget = spec.page_factory(bootstrap.plugin_manager, self) + self._stacked_widget.addWidget(widget) + self._sidebar.add_plugin_entry( + page_index=page_index, + title=spec.title, + icon_name=spec.icon_name, + ) + self._plugin_page_keys[page_index] = spec.plugin_id + def _on_sidebar_page_requested(self, page_index: int): """Handle sidebar page request.""" self._nav_stack.clear() From ca9cf1ddc7f7be915f5e7301e6d851de067142d6 Mon Sep 17 00:00:00 2001 From: Har01d Date: Sun, 5 Apr 2026 19:28:50 +0800 Subject: [PATCH 018/157] =?UTF-8?q?=E8=BF=81=E7=A7=BBLRCLIB=E6=8F=92?= =?UTF-8?q?=E4=BB=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- plugins/__init__.py | 1 + plugins/builtin/__init__.py | 1 + plugins/builtin/lrclib/__init__.py | 1 + plugins/builtin/lrclib/lib/__init__.py | 1 + plugins/builtin/lrclib/lib/lrclib_source.py | 41 ++++++++++ plugins/builtin/lrclib/plugin.json | 10 +++ plugins/builtin/lrclib/plugin_main.py | 13 ++++ services/lyrics/lyrics_service.py | 78 ++++++++++++------- services/metadata/cover_service.py | 48 ++++++++---- services/sources/__init__.py | 2 - tests/test_plugins/test_lrclib_plugin.py | 12 +++ .../test_plugin_cover_registry.py | 33 ++++++++ .../test_plugin_lyrics_registry.py | 37 +++++++++ 13 files changed, 231 insertions(+), 47 deletions(-) create mode 100644 plugins/__init__.py create mode 100644 plugins/builtin/__init__.py create mode 100644 plugins/builtin/lrclib/__init__.py create mode 100644 plugins/builtin/lrclib/lib/__init__.py create mode 100644 plugins/builtin/lrclib/lib/lrclib_source.py create mode 100644 plugins/builtin/lrclib/plugin.json create mode 100644 plugins/builtin/lrclib/plugin_main.py create mode 100644 tests/test_plugins/test_lrclib_plugin.py create mode 100644 tests/test_services/test_plugin_cover_registry.py create mode 100644 tests/test_services/test_plugin_lyrics_registry.py diff --git a/plugins/__init__.py b/plugins/__init__.py new file mode 100644 index 00000000..04e7fa86 --- /dev/null +++ b/plugins/__init__.py @@ -0,0 +1 @@ +"""Built-in and external plugin packages for tests and local development.""" diff --git a/plugins/builtin/__init__.py b/plugins/builtin/__init__.py new file mode 100644 index 00000000..358dcd83 --- /dev/null +++ b/plugins/builtin/__init__.py @@ -0,0 +1 @@ +"""Built-in plugins shipped with the host.""" diff --git a/plugins/builtin/lrclib/__init__.py b/plugins/builtin/lrclib/__init__.py new file mode 100644 index 00000000..4042cfc9 --- /dev/null +++ b/plugins/builtin/lrclib/__init__.py @@ -0,0 +1 @@ +"""LRCLIB built-in plugin.""" diff --git a/plugins/builtin/lrclib/lib/__init__.py b/plugins/builtin/lrclib/lib/__init__.py new file mode 100644 index 00000000..115b7e58 --- /dev/null +++ b/plugins/builtin/lrclib/lib/__init__.py @@ -0,0 +1 @@ +"""LRCLIB plugin internals.""" diff --git a/plugins/builtin/lrclib/lib/lrclib_source.py b/plugins/builtin/lrclib/lib/lrclib_source.py new file mode 100644 index 00000000..d2a760b7 --- /dev/null +++ b/plugins/builtin/lrclib/lib/lrclib_source.py @@ -0,0 +1,41 @@ +from __future__ import annotations + +from harmony_plugin_api.lyrics import PluginLyricsResult + + +class LRCLIBPluginSource: + source_id = "lrclib" + display_name = "LRCLIB" + + def __init__(self, http_client) -> None: + self._http_client = http_client + + def search( + self, + title: str, + artist: str, + limit: int = 10, + ) -> list[PluginLyricsResult]: + response = self._http_client.get( + "https://lrclib.net/api/search", + params={"track_name": title, "artist_name": artist}, + headers={"User-Agent": "Mozilla/5.0"}, + timeout=3, + ) + payload = response.json() if response.status_code == 200 else [] + return [ + PluginLyricsResult( + song_id=str(item.get("id", "")), + title=item.get("trackName", ""), + artist=item.get("artistName", ""), + album=item.get("albumName", ""), + duration=item.get("duration"), + source="lrclib", + lyrics=item.get("syncedLyrics") or item.get("plainLyrics"), + ) + for item in payload[:limit] + if item.get("syncedLyrics") or item.get("plainLyrics") + ] + + def get_lyrics(self, result: PluginLyricsResult) -> str | None: + return result.lyrics diff --git a/plugins/builtin/lrclib/plugin.json b/plugins/builtin/lrclib/plugin.json new file mode 100644 index 00000000..63331293 --- /dev/null +++ b/plugins/builtin/lrclib/plugin.json @@ -0,0 +1,10 @@ +{ + "id": "lrclib", + "name": "LRCLIB", + "version": "1.0.0", + "api_version": "1", + "entrypoint": "plugin_main.py", + "entry_class": "LRCLIBPlugin", + "capabilities": ["lyrics_source"], + "min_app_version": "0.1.0" +} diff --git a/plugins/builtin/lrclib/plugin_main.py b/plugins/builtin/lrclib/plugin_main.py new file mode 100644 index 00000000..5ddfb822 --- /dev/null +++ b/plugins/builtin/lrclib/plugin_main.py @@ -0,0 +1,13 @@ +from __future__ import annotations + +from .lib.lrclib_source import LRCLIBPluginSource + + +class LRCLIBPlugin: + plugin_id = "lrclib" + + def register(self, context) -> None: + context.services.register_lyrics_source(LRCLIBPluginSource(context.http)) + + def unregister(self, context) -> None: + return None diff --git a/services/lyrics/lyrics_service.py b/services/lyrics/lyrics_service.py index b93431fa..8aa84d01 100644 --- a/services/lyrics/lyrics_service.py +++ b/services/lyrics/lyrics_service.py @@ -6,6 +6,7 @@ from pathlib import Path from typing import TYPE_CHECKING, List, Optional +from harmony_plugin_api.lyrics import PluginLyricsResult from services._singleflight import SingleFlight from utils.lrc_parser import LyricLine from utils.match_scorer import MatchScorer, TrackInfo @@ -55,22 +56,47 @@ class LyricsService: ENABLE_ONLINE = True # Changed to True for better UX @classmethod - def _get_sources(cls) -> List["LyricsSource"]: - """Get lyrics sources.""" + def _get_builtin_sources(cls) -> List["LyricsSource"]: + """Get built-in host lyrics sources.""" from services.sources import ( NetEaseLyricsSource, QQMusicLyricsSource, KugouLyricsSource, - LRCLIBLyricsSource, ) http_client = _get_http_client() return [ - LRCLIBLyricsSource(http_client), NetEaseLyricsSource(http_client), KugouLyricsSource(http_client), QQMusicLyricsSource(), ] + @classmethod + def _get_sources(cls) -> List["LyricsSource"]: + """Get host and plugin-provided lyrics sources.""" + from app.bootstrap import Bootstrap + + plugin_sources = Bootstrap.instance().plugin_manager.registry.lyrics_sources() + return cls._get_builtin_sources() + plugin_sources + + @staticmethod + def _get_source_name(source) -> str: + return getattr(source, "name", getattr(source, "display_name", source.__class__.__name__)) + + @staticmethod + def _result_to_dict(result) -> dict: + return { + "id": getattr(result, "id", getattr(result, "song_id", "")), + "title": getattr(result, "title", ""), + "artist": getattr(result, "artist", ""), + "album": getattr(result, "album", ""), + "duration": getattr(result, "duration", None), + "cover_url": getattr(result, "cover_url", None), + "source": getattr(result, "source", ""), + "lyrics": getattr(result, "lyrics", None), + "accesskey": getattr(result, "accesskey", None), + "supports_yrc": getattr(result, "supports_yrc", False), + } + @classmethod def _convert_to_simplified_chinese(cls, text: str) -> str: """ @@ -114,7 +140,7 @@ def search_songs(cls, title: str, artist: str, limit: int = 10, # Parallel search from multiple sources with progressive updates with ThreadPoolExecutor(max_workers=len(sources)) as executor: futures = { - executor.submit(source.search, title, artist, limit): source.name + executor.submit(source.search, title, artist, limit): cls._get_source_name(source) for source in sources } @@ -123,19 +149,7 @@ def search_songs(cls, title: str, artist: str, limit: int = 10, source_name = futures[future] try: search_results = future.result(timeout=6) - # Convert LyricsSearchResult to dict for compatibility - results.extend({ - 'id': r.id, - 'title': r.title, - 'artist': r.artist, - 'album': r.album, - 'duration': r.duration, - 'cover_url': r.cover_url, - 'source': r.source, - 'lyrics': r.lyrics, - 'accesskey': r.accesskey, - 'supports_yrc': r.supports_yrc, - } for r in search_results) + results.extend(cls._result_to_dict(r) for r in search_results) logger.debug(f"[LyricsService] {source_name}: found {len(search_results)} results") # Call progress callback if provided @@ -164,21 +178,29 @@ def download_lyrics_by_id(cls, song_id: str, source: str, accesskey: str = None) """ # Find the appropriate source and download lyrics sources = cls._get_sources() - source_map = {s.name.lower(): s for s in sources} + source_map = {cls._get_source_name(s).lower(): s for s in sources} lyrics_source = source_map.get(source.lower()) if not lyrics_source: return "" - # Create a result object for get_lyrics - from services.sources.base import LyricsSearchResult - result = LyricsSearchResult( - id=song_id, - title="", - artist="", - source=source, - accesskey=accesskey, - ) + if hasattr(lyrics_source, "display_name"): + result = PluginLyricsResult( + song_id=song_id, + title="", + artist="", + source=source, + ) + else: + from services.sources.base import LyricsSearchResult + + result = LyricsSearchResult( + id=song_id, + title="", + artist="", + source=source, + accesskey=accesskey, + ) lyrics = lyrics_source.get_lyrics(result) if lyrics: diff --git a/services/metadata/cover_service.py b/services/metadata/cover_service.py index 916ef5c0..6cb41b20 100644 --- a/services/metadata/cover_service.py +++ b/services/metadata/cover_service.py @@ -43,25 +43,31 @@ def __init__( self.http_client = http_client self._sources = sources + def _get_builtin_sources(self) -> List["CoverSource"]: + """Get built-in host cover sources.""" + from services.sources import ( + NetEaseCoverSource, + QQMusicCoverSource, + ITunesCoverSource, + LastFmCoverSource, + ) + return [ + NetEaseCoverSource(self.http_client), + QQMusicCoverSource(), + ITunesCoverSource(self.http_client), + LastFmCoverSource(self.http_client), + ] + def _get_sources(self) -> List["CoverSource"]: - """Get cover sources, creating default ones if needed.""" - if self._sources is None: - from services.sources import ( - NetEaseCoverSource, - QQMusicCoverSource, - ITunesCoverSource, - LastFmCoverSource, - ) - self._sources = [ - NetEaseCoverSource(self.http_client), - QQMusicCoverSource(), - ITunesCoverSource(self.http_client), - LastFmCoverSource(self.http_client), - ] - return [s for s in self._sources if s.is_available()] + """Get cover sources, including plugin-provided sources.""" + from app.bootstrap import Bootstrap - def _get_artist_sources(self) -> List["ArtistCoverSource"]: - """Get artist cover sources.""" + sources = list(self._sources) if self._sources is not None else self._get_builtin_sources() + sources.extend(Bootstrap.instance().plugin_manager.registry.cover_sources()) + return [s for s in sources if getattr(s, "is_available", lambda: True)()] + + def _get_builtin_artist_sources(self) -> List["ArtistCoverSource"]: + """Get built-in host artist cover sources.""" from services.sources import ( NetEaseArtistCoverSource, QQMusicArtistCoverSource, @@ -73,6 +79,14 @@ def _get_artist_sources(self) -> List["ArtistCoverSource"]: ITunesArtistCoverSource(self.http_client), ] + def _get_artist_sources(self) -> List["ArtistCoverSource"]: + """Get artist cover sources, including plugin-provided sources.""" + from app.bootstrap import Bootstrap + + sources = self._get_builtin_artist_sources() + sources.extend(Bootstrap.instance().plugin_manager.registry.artist_cover_sources()) + return sources + def get_cover(self, track_path: str, title: str, artist: str, album: str = "", duration: float = None, skip_online: bool = False) -> Optional[str]: """ diff --git a/services/sources/__init__.py b/services/sources/__init__.py index 0d2fc11b..d7db8a8c 100644 --- a/services/sources/__init__.py +++ b/services/sources/__init__.py @@ -18,7 +18,6 @@ NetEaseLyricsSource, QQMusicLyricsSource, KugouLyricsSource, - LRCLIBLyricsSource, ) from .artist_cover_sources import ( NetEaseArtistCoverSource, @@ -43,7 +42,6 @@ "NetEaseLyricsSource", "QQMusicLyricsSource", "KugouLyricsSource", - "LRCLIBLyricsSource", # Artist cover sources "NetEaseArtistCoverSource", "QQMusicArtistCoverSource", diff --git a/tests/test_plugins/test_lrclib_plugin.py b/tests/test_plugins/test_lrclib_plugin.py new file mode 100644 index 00000000..3bfb3ef9 --- /dev/null +++ b/tests/test_plugins/test_lrclib_plugin.py @@ -0,0 +1,12 @@ +from unittest.mock import Mock + +from plugins.builtin.lrclib.plugin_main import LRCLIBPlugin + + +def test_lrclib_plugin_registers_lyrics_source(): + context = Mock() + plugin = LRCLIBPlugin() + + plugin.register(context) + + context.services.register_lyrics_source.assert_called_once() diff --git a/tests/test_services/test_plugin_cover_registry.py b/tests/test_services/test_plugin_cover_registry.py new file mode 100644 index 00000000..266de5ad --- /dev/null +++ b/tests/test_services/test_plugin_cover_registry.py @@ -0,0 +1,33 @@ +from types import SimpleNamespace + +from services.metadata.cover_service import CoverService + + +def test_cover_service_merges_plugin_cover_sources(monkeypatch): + fake_cover = SimpleNamespace(source_id="qqmusic-cover") + fake_artist_cover = SimpleNamespace(source_id="qqmusic-artist-cover") + fake_registry = SimpleNamespace( + cover_sources=lambda: [fake_cover], + artist_cover_sources=lambda: [fake_artist_cover], + ) + fake_manager = SimpleNamespace(registry=fake_registry) + + monkeypatch.setattr( + "app.bootstrap.Bootstrap.instance", + lambda: SimpleNamespace(plugin_manager=fake_manager), + ) + monkeypatch.setattr( + CoverService, + "_get_builtin_sources", + lambda self: [], + ) + monkeypatch.setattr( + CoverService, + "_get_builtin_artist_sources", + lambda self: [], + ) + + service = CoverService(http_client=SimpleNamespace(), sources=None) + + assert service._get_sources() == [fake_cover] + assert service._get_artist_sources() == [fake_artist_cover] diff --git a/tests/test_services/test_plugin_lyrics_registry.py b/tests/test_services/test_plugin_lyrics_registry.py new file mode 100644 index 00000000..64d9459a --- /dev/null +++ b/tests/test_services/test_plugin_lyrics_registry.py @@ -0,0 +1,37 @@ +from types import SimpleNamespace + +from harmony_plugin_api.lyrics import PluginLyricsResult +from services.lyrics.lyrics_service import LyricsService + + +def test_lyrics_service_merges_plugin_sources(monkeypatch): + fake_plugin_source = SimpleNamespace( + display_name="LRCLIB", + search=lambda *_args, **_kwargs: [ + PluginLyricsResult( + song_id="song-1", + title="Song 1", + artist="Singer 1", + source="lrclib", + lyrics="[00:01.00]line", + ) + ], + get_lyrics=lambda result: result.lyrics, + ) + fake_manager = SimpleNamespace( + registry=SimpleNamespace(lyrics_sources=lambda: [fake_plugin_source]) + ) + + monkeypatch.setattr( + LyricsService, + "_get_builtin_sources", + classmethod(lambda cls: []), + ) + monkeypatch.setattr( + "app.bootstrap.Bootstrap.instance", + lambda: SimpleNamespace(plugin_manager=fake_manager), + ) + + results = LyricsService.search_songs("Song 1", "Singer 1") + + assert any(item["source"] == "lrclib" for item in results) From e45c5bd8c295ff11002060fc1f5642836befe3a9 Mon Sep 17 00:00:00 2001 From: Har01d Date: Sun, 5 Apr 2026 19:30:53 +0800 Subject: [PATCH 019/157] =?UTF-8?q?=E5=88=9B=E5=BB=BAQQ=E9=9F=B3=E4=B9=90?= =?UTF-8?q?=E6=8F=92=E4=BB=B6=E5=8C=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- plugins/builtin/qqmusic/__init__.py | 1 + plugins/builtin/qqmusic/lib/__init__.py | 1 + .../qqmusic/lib/artist_cover_source.py | 12 ++++++ plugins/builtin/qqmusic/lib/cover_source.py | 12 ++++++ plugins/builtin/qqmusic/lib/lyrics_source.py | 17 ++++++++ plugins/builtin/qqmusic/lib/provider.py | 17 ++++++++ plugins/builtin/qqmusic/lib/settings_tab.py | 11 +++++ plugins/builtin/qqmusic/plugin.json | 10 +++++ plugins/builtin/qqmusic/plugin_main.py | 43 +++++++++++++++++++ tests/test_plugins/test_qqmusic_plugin.py | 17 ++++++++ 10 files changed, 141 insertions(+) create mode 100644 plugins/builtin/qqmusic/__init__.py create mode 100644 plugins/builtin/qqmusic/lib/__init__.py create mode 100644 plugins/builtin/qqmusic/lib/artist_cover_source.py create mode 100644 plugins/builtin/qqmusic/lib/cover_source.py create mode 100644 plugins/builtin/qqmusic/lib/lyrics_source.py create mode 100644 plugins/builtin/qqmusic/lib/provider.py create mode 100644 plugins/builtin/qqmusic/lib/settings_tab.py create mode 100644 plugins/builtin/qqmusic/plugin.json create mode 100644 plugins/builtin/qqmusic/plugin_main.py create mode 100644 tests/test_plugins/test_qqmusic_plugin.py diff --git a/plugins/builtin/qqmusic/__init__.py b/plugins/builtin/qqmusic/__init__.py new file mode 100644 index 00000000..ed0a3567 --- /dev/null +++ b/plugins/builtin/qqmusic/__init__.py @@ -0,0 +1 @@ +"""QQ Music built-in plugin.""" diff --git a/plugins/builtin/qqmusic/lib/__init__.py b/plugins/builtin/qqmusic/lib/__init__.py new file mode 100644 index 00000000..a7bd796b --- /dev/null +++ b/plugins/builtin/qqmusic/lib/__init__.py @@ -0,0 +1 @@ +"""QQ Music plugin internals.""" diff --git a/plugins/builtin/qqmusic/lib/artist_cover_source.py b/plugins/builtin/qqmusic/lib/artist_cover_source.py new file mode 100644 index 00000000..42dac948 --- /dev/null +++ b/plugins/builtin/qqmusic/lib/artist_cover_source.py @@ -0,0 +1,12 @@ +from __future__ import annotations + + +class QQMusicArtistCoverPluginSource: + source_id = "qqmusic-artist-cover" + display_name = "QQMusic Artist" + + def __init__(self, context): + self._context = context + + def search(self, artist_name: str, limit: int = 10) -> list: + return [] diff --git a/plugins/builtin/qqmusic/lib/cover_source.py b/plugins/builtin/qqmusic/lib/cover_source.py new file mode 100644 index 00000000..fd7bcbd8 --- /dev/null +++ b/plugins/builtin/qqmusic/lib/cover_source.py @@ -0,0 +1,12 @@ +from __future__ import annotations + + +class QQMusicCoverPluginSource: + source_id = "qqmusic-cover" + display_name = "QQMusic" + + def __init__(self, context): + self._context = context + + def search(self, title: str, artist: str, album: str = "", duration: float | None = None) -> list: + return [] diff --git a/plugins/builtin/qqmusic/lib/lyrics_source.py b/plugins/builtin/qqmusic/lib/lyrics_source.py new file mode 100644 index 00000000..f39571f6 --- /dev/null +++ b/plugins/builtin/qqmusic/lib/lyrics_source.py @@ -0,0 +1,17 @@ +from __future__ import annotations + +from harmony_plugin_api.lyrics import PluginLyricsResult + + +class QQMusicLyricsPluginSource: + source_id = "qqmusic" + display_name = "QQMusic" + + def __init__(self, context): + self._context = context + + def search(self, title: str, artist: str, limit: int = 10) -> list[PluginLyricsResult]: + return [] + + def get_lyrics(self, result: PluginLyricsResult) -> str | None: + return None diff --git a/plugins/builtin/qqmusic/lib/provider.py b/plugins/builtin/qqmusic/lib/provider.py new file mode 100644 index 00000000..e23808d6 --- /dev/null +++ b/plugins/builtin/qqmusic/lib/provider.py @@ -0,0 +1,17 @@ +from __future__ import annotations + +from PySide6.QtWidgets import QLabel + + +class QQMusicOnlineProvider: + provider_id = "qqmusic" + display_name = "QQ 音乐" + + def __init__(self, context): + self._context = context + + def create_page(self, context, parent=None): + return QLabel("QQ Music", parent) + + def get_playback_url_info(self, track_id: str, quality: str): + return None diff --git a/plugins/builtin/qqmusic/lib/settings_tab.py b/plugins/builtin/qqmusic/lib/settings_tab.py new file mode 100644 index 00000000..8d4bc0d4 --- /dev/null +++ b/plugins/builtin/qqmusic/lib/settings_tab.py @@ -0,0 +1,11 @@ +from __future__ import annotations + +from PySide6.QtWidgets import QLabel, QVBoxLayout, QWidget + + +class QQMusicSettingsTab(QWidget): + def __init__(self, context, parent=None): + super().__init__(parent) + layout = QVBoxLayout(self) + layout.addWidget(QLabel("QQ Music Settings", self)) + self._context = context diff --git a/plugins/builtin/qqmusic/plugin.json b/plugins/builtin/qqmusic/plugin.json new file mode 100644 index 00000000..c8c4a3bf --- /dev/null +++ b/plugins/builtin/qqmusic/plugin.json @@ -0,0 +1,10 @@ +{ + "id": "qqmusic", + "name": "QQ Music", + "version": "1.0.0", + "api_version": "1", + "entrypoint": "plugin_main.py", + "entry_class": "QQMusicPlugin", + "capabilities": ["sidebar", "settings_tab", "lyrics_source", "cover", "online_music_provider"], + "min_app_version": "0.1.0" +} diff --git a/plugins/builtin/qqmusic/plugin_main.py b/plugins/builtin/qqmusic/plugin_main.py new file mode 100644 index 00000000..bf4b8ff7 --- /dev/null +++ b/plugins/builtin/qqmusic/plugin_main.py @@ -0,0 +1,43 @@ +from __future__ import annotations + +from harmony_plugin_api.registry_types import SettingsTabSpec, SidebarEntrySpec + +from .lib.artist_cover_source import QQMusicArtistCoverPluginSource +from .lib.cover_source import QQMusicCoverPluginSource +from .lib.lyrics_source import QQMusicLyricsPluginSource +from .lib.provider import QQMusicOnlineProvider +from .lib.settings_tab import QQMusicSettingsTab + + +class QQMusicPlugin: + plugin_id = "qqmusic" + + def register(self, context) -> None: + context.ui.register_sidebar_entry( + SidebarEntrySpec( + plugin_id="qqmusic", + entry_id="qqmusic.sidebar", + title="QQ 音乐", + order=80, + icon_name="GLOBE", + page_factory=lambda _context, parent: QQMusicOnlineProvider(context).create_page(context, parent), + ) + ) + context.ui.register_settings_tab( + SettingsTabSpec( + plugin_id="qqmusic", + tab_id="qqmusic.settings", + title="QQ 音乐", + order=80, + widget_factory=lambda _context, parent: QQMusicSettingsTab(context, parent), + ) + ) + context.services.register_lyrics_source(QQMusicLyricsPluginSource(context)) + context.services.register_cover_source(QQMusicCoverPluginSource(context)) + context.services.register_artist_cover_source( + QQMusicArtistCoverPluginSource(context) + ) + context.services.register_online_music_provider(QQMusicOnlineProvider(context)) + + def unregister(self, context) -> None: + return None diff --git a/tests/test_plugins/test_qqmusic_plugin.py b/tests/test_plugins/test_qqmusic_plugin.py new file mode 100644 index 00000000..c04955cf --- /dev/null +++ b/tests/test_plugins/test_qqmusic_plugin.py @@ -0,0 +1,17 @@ +from unittest.mock import Mock + +from plugins.builtin.qqmusic.plugin_main import QQMusicPlugin + + +def test_qqmusic_plugin_registers_expected_capabilities(): + context = Mock() + plugin = QQMusicPlugin() + + plugin.register(context) + + assert context.ui.register_sidebar_entry.call_count == 1 + assert context.ui.register_settings_tab.call_count == 1 + assert context.services.register_lyrics_source.call_count == 1 + assert context.services.register_cover_source.call_count == 1 + assert context.services.register_artist_cover_source.call_count == 1 + assert context.services.register_online_music_provider.call_count == 1 From 57d8511a936374e408e83278e4b5e1bfb80a104a Mon Sep 17 00:00:00 2001 From: Har01d Date: Sun, 5 Apr 2026 19:52:23 +0800 Subject: [PATCH 020/157] =?UTF-8?q?=E8=BF=81=E7=A7=BBQQ=E9=9F=B3=E4=B9=90?= =?UTF-8?q?=E5=AE=BF=E4=B8=BB=E6=8E=A5=E7=BA=BF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/bootstrap.py | 6 +- plugins/builtin/qqmusic/lib/client.py | 18 ++ plugins/builtin/qqmusic/lib/login_dialog.py | 10 + plugins/builtin/qqmusic/lib/provider.py | 18 +- plugins/builtin/qqmusic/lib/root_view.py | 35 +++ tests/test_app/test_plugin_bootstrap.py | 42 ++- tests/test_system/test_plugin_import_guard.py | 14 + tests/test_ui/test_plugin_settings_tab.py | 37 +++ ui/dialogs/settings_dialog.py | 241 +----------------- 9 files changed, 178 insertions(+), 243 deletions(-) create mode 100644 plugins/builtin/qqmusic/lib/client.py create mode 100644 plugins/builtin/qqmusic/lib/login_dialog.py create mode 100644 plugins/builtin/qqmusic/lib/root_view.py create mode 100644 tests/test_system/test_plugin_import_guard.py diff --git a/app/bootstrap.py b/app/bootstrap.py index 2a46fca6..12b35883 100644 --- a/app/bootstrap.py +++ b/app/bootstrap.py @@ -100,6 +100,7 @@ def __init__(self, db_path: str = "Harmony.db"): self._sleep_timer_service: Optional["SleepTimerService"] = None self._mpris_controller: Optional["MPRISController"] = None self._plugin_manager: Optional[PluginManager] = None + self._plugins_loaded = False @classmethod def instance(cls, db_path: str = "Harmony.db") -> "Bootstrap": @@ -359,6 +360,9 @@ def plugin_manager(self) -> PluginManager: storage_root=Path("data/plugins/storage"), ), ) + if not self._plugins_loaded: + self._plugin_manager.load_enabled_plugins() + self._plugins_loaded = True return self._plugin_manager # ===== QQ Music ===== @@ -429,7 +433,7 @@ def online_download_service(self) -> "OnlineDownloadService": self._online_download_service = OnlineDownloadService( config_manager=self.config, qqmusic_service=None, - online_music_service=self.online_music_service + online_music_service=None ) return self._online_download_service diff --git a/plugins/builtin/qqmusic/lib/client.py b/plugins/builtin/qqmusic/lib/client.py new file mode 100644 index 00000000..85d0d006 --- /dev/null +++ b/plugins/builtin/qqmusic/lib/client.py @@ -0,0 +1,18 @@ +from __future__ import annotations + + +class QQMusicPluginClient: + def __init__(self, context): + self._context = context + self._credential = context.settings.get("credential", None) + + def get_quality(self) -> str: + return str(self._context.settings.get("quality", "320")) + + def set_credential(self, credential: dict) -> None: + self._credential = credential + self._context.settings.set("credential", credential) + + def clear_credential(self) -> None: + self._credential = None + self._context.settings.set("credential", None) diff --git a/plugins/builtin/qqmusic/lib/login_dialog.py b/plugins/builtin/qqmusic/lib/login_dialog.py new file mode 100644 index 00000000..3b7c2eb9 --- /dev/null +++ b/plugins/builtin/qqmusic/lib/login_dialog.py @@ -0,0 +1,10 @@ +from __future__ import annotations + +from PySide6.QtWidgets import QDialog, QVBoxLayout, QLabel + + +class QQMusicLoginDialog(QDialog): + def __init__(self, parent=None): + super().__init__(parent) + layout = QVBoxLayout(self) + layout.addWidget(QLabel("QQ Music Login", self)) diff --git a/plugins/builtin/qqmusic/lib/provider.py b/plugins/builtin/qqmusic/lib/provider.py index e23808d6..b3e58eda 100644 --- a/plugins/builtin/qqmusic/lib/provider.py +++ b/plugins/builtin/qqmusic/lib/provider.py @@ -1,6 +1,9 @@ from __future__ import annotations -from PySide6.QtWidgets import QLabel +from harmony_plugin_api.media import PluginTrack + +from .client import QQMusicPluginClient +from .root_view import QQMusicRootView class QQMusicOnlineProvider: @@ -9,9 +12,18 @@ class QQMusicOnlineProvider: def __init__(self, context): self._context = context + self._client = QQMusicPluginClient(context) def create_page(self, context, parent=None): - return QLabel("QQ Music", parent) + return QQMusicRootView(context, self, parent) + + def get_demo_track(self) -> PluginTrack: + return PluginTrack( + track_id="demo-mid", + title="Demo Song", + artist="Demo Artist", + album="Demo Album", + ) def get_playback_url_info(self, track_id: str, quality: str): - return None + return {"url": "https://example.com/demo.mp3", "quality": quality, "extension": ".mp3"} diff --git a/plugins/builtin/qqmusic/lib/root_view.py b/plugins/builtin/qqmusic/lib/root_view.py new file mode 100644 index 00000000..b0143ed0 --- /dev/null +++ b/plugins/builtin/qqmusic/lib/root_view.py @@ -0,0 +1,35 @@ +from __future__ import annotations + +from PySide6.QtWidgets import QLabel, QPushButton, QVBoxLayout, QWidget + +from harmony_plugin_api.media import PluginPlaybackRequest, PluginTrack + + +class QQMusicRootView(QWidget): + def __init__(self, context, provider, parent=None): + super().__init__(parent) + self._context = context + self._provider = provider + self._status = QLabel("QQ Music", self) + self._play_btn = QPushButton("Play first track", self) + self._play_btn.clicked.connect(self._play_demo_track) + layout = QVBoxLayout(self) + layout.addWidget(self._status) + layout.addWidget(self._play_btn) + + def _play_demo_track(self): + track = self._provider.get_demo_track() + request = PluginPlaybackRequest( + provider_id="qqmusic", + track_id=track.track_id, + title=track.title, + quality=self._context.settings.get("quality", "320"), + metadata={ + "title": track.title, + "artist": track.artist, + "album": track.album, + }, + ) + local_path = self._context.services.media.cache_remote_track(request) + self._context.services.media.add_online_track(request) + self._status.setText(local_path or "download failed") diff --git a/tests/test_app/test_plugin_bootstrap.py b/tests/test_app/test_plugin_bootstrap.py index ce551e96..f50fbeca 100644 --- a/tests/test_app/test_plugin_bootstrap.py +++ b/tests/test_app/test_plugin_bootstrap.py @@ -2,11 +2,12 @@ from unittest.mock import MagicMock import app.bootstrap as bootstrap_module +import services.online as online_module def test_bootstrap_exposes_plugin_manager(monkeypatch): fake_state_store = object() - fake_manager = object() + fake_manager = MagicMock() state_store_ctor = MagicMock(return_value=fake_state_store) manager_ctor = MagicMock(return_value=fake_manager) @@ -28,3 +29,42 @@ def test_bootstrap_exposes_plugin_manager(monkeypatch): assert kwargs["external_root"] == Path("data/plugins/external") assert kwargs["state_store"] is fake_state_store assert hasattr(kwargs["context_factory"], "build") + fake_manager.load_enabled_plugins.assert_called_once() + + +def test_bootstrap_only_loads_plugins_once(monkeypatch): + fake_state_store = object() + fake_manager = MagicMock() + state_store_ctor = MagicMock(return_value=fake_state_store) + manager_ctor = MagicMock(return_value=fake_manager) + + monkeypatch.setattr(bootstrap_module, "PluginStateStore", state_store_ctor, raising=False) + monkeypatch.setattr(bootstrap_module, "PluginManager", manager_ctor, raising=False) + + bootstrap = bootstrap_module.Bootstrap(":memory:") + bootstrap._config = object() + bootstrap._event_bus = object() + bootstrap._http_client = object() + + _ = bootstrap.plugin_manager + _ = bootstrap.plugin_manager + + fake_manager.load_enabled_plugins.assert_called_once() + + +def test_online_download_service_is_created_without_host_online_music_service(monkeypatch): + fake_download_service = object() + download_ctor = MagicMock(return_value=fake_download_service) + + monkeypatch.setattr(online_module, "OnlineDownloadService", download_ctor) + + bootstrap = bootstrap_module.Bootstrap(":memory:") + bootstrap._config = object() + + service = bootstrap.online_download_service + + assert service is fake_download_service + _, kwargs = download_ctor.call_args + assert kwargs["config_manager"] is bootstrap._config + assert kwargs["qqmusic_service"] is None + assert kwargs["online_music_service"] is None diff --git a/tests/test_system/test_plugin_import_guard.py b/tests/test_system/test_plugin_import_guard.py new file mode 100644 index 00000000..29d2d1fd --- /dev/null +++ b/tests/test_system/test_plugin_import_guard.py @@ -0,0 +1,14 @@ +from pathlib import Path + +from system.plugins.installer import audit_plugin_imports + + +def test_plugin_import_audit_allows_sdk_only_imports(tmp_path: Path): + plugin_root = tmp_path / "qqmusic" + plugin_root.mkdir() + (plugin_root / "plugin_main.py").write_text( + "from harmony_plugin_api.plugin import HarmonyPlugin\n", + encoding="utf-8", + ) + + audit_plugin_imports(plugin_root) diff --git a/tests/test_ui/test_plugin_settings_tab.py b/tests/test_ui/test_plugin_settings_tab.py index 26b6a0f8..25fd5d2d 100644 --- a/tests/test_ui/test_plugin_settings_tab.py +++ b/tests/test_ui/test_plugin_settings_tab.py @@ -68,3 +68,40 @@ def test_settings_dialog_includes_plugins_tab(monkeypatch, qtbot): tab_labels = [tab_widget.tabText(index) for index in range(tab_widget.count())] assert "Plugins" in tab_labels or "插件" in tab_labels + + +def test_settings_dialog_omits_qqmusic_tab_without_plugin(monkeypatch, qtbot): + config = Mock() + config.get.return_value = "dark" + config.get_ai_enabled.return_value = False + config.get_ai_base_url.return_value = "" + config.get_ai_api_key.return_value = "" + config.get_ai_model.return_value = "" + config.get_acoustid_enabled.return_value = False + config.get_acoustid_api_key.return_value = "" + config.get_online_music_download_dir.return_value = "data/online_cache" + config.get_cache_cleanup_strategy.return_value = "manual" + config.get_cache_cleanup_auto_enabled.return_value = False + config.get_cache_cleanup_time_days.return_value = 30 + config.get_cache_cleanup_size_mb.return_value = 1000 + config.get_cache_cleanup_count.return_value = 100 + config.get_cache_cleanup_interval_hours.return_value = 1 + config.get_audio_engine.return_value = "mpv" + config.get_qqmusic_credential.return_value = None + config.get_qqmusic_quality.return_value = "320" + + fake_manager = Mock() + fake_manager.list_plugins.return_value = [] + fake_manager.registry.settings_tabs.return_value = [] + bootstrap = Mock(plugin_manager=fake_manager) + monkeypatch.setattr("ui.dialogs.settings_dialog.Bootstrap.instance", lambda: bootstrap) + ThemeManager._instance = None + ThemeManager.instance(config) + + dialog = GeneralSettingsDialog(config) + qtbot.addWidget(dialog) + tab_widget = dialog.findChild(QTabWidget) + + tab_labels = [tab_widget.tabText(index) for index in range(tab_widget.count())] + assert "QQ音乐" not in tab_labels + assert "QQ Music" not in tab_labels diff --git a/ui/dialogs/settings_dialog.py b/ui/dialogs/settings_dialog.py index f9e7e273..6c519833 100644 --- a/ui/dialogs/settings_dialog.py +++ b/ui/dialogs/settings_dialog.py @@ -1,12 +1,10 @@ -""" -General Settings Dialog for configuring AI, AcoustID, and QQ Music. -""" +"""General Settings Dialog for configuring host and plugin settings.""" import logging import os from typing import Optional from app.bootstrap import Bootstrap -from PySide6.QtCore import Qt, QThread, Signal +from PySide6.QtCore import Qt from PySide6.QtGui import QColor, QPainterPath, QRegion from PySide6.QtWidgets import ( QDialog, QVBoxLayout, QHBoxLayout, QLabel, QLineEdit, @@ -22,11 +20,6 @@ from ui.dialogs.message_dialog import MessageDialog, Yes, No from ui.dialogs.plugin_management_tab import PluginManagementTab from ui.dialogs.progress_dialog import ProgressDialog -from services.cloud.qqmusic.common import ( - get_selectable_qualities, - get_quality_label_key, - normalize_quality, -) # Configure logging logger = logging.getLogger(__name__) @@ -42,30 +35,8 @@ def _get_audio_engine_options() -> list[tuple[str, str]]: return options -class VerifyLoginThread(QThread): - """Background thread for verifying QQ Music login status.""" - - verified = Signal(bool, str, int) # valid, nick, uin - - def __init__(self, credential: dict, parent=None): - super().__init__(parent) - self._credential = credential - - def run(self): - """Verify login status.""" - try: - from services.cloud.qqmusic import QQMusicClient - - client = QQMusicClient(self._credential) - result = client.verify_login() - self.verified.emit(result['valid'], result['nick'], result['uin']) - except Exception as e: - logger.error(f"Verify login error: {e}") - self.verified.emit(False, '', 0) - - class GeneralSettingsDialog(QDialog): - """Dialog for configuring AI, AcoustID, and QQ Music settings.""" + """Dialog for configuring host and plugin settings.""" _STYLE_TEMPLATE = """ QWidget#settingsContainer { @@ -166,7 +137,6 @@ def __init__(self, config_manager, parent=None): """ super().__init__(parent) self._config = config_manager - self._verify_thread: Optional[VerifyLoginThread] = None self._batch_worker = None self._drag_pos = None @@ -323,82 +293,6 @@ def _setup_ui(self): acoustid_layout.addStretch() - # QQ Music Settings Tab - qqmusic_tab = QWidget() - qqmusic_layout = QVBoxLayout(qqmusic_tab) - qqmusic_layout.setSpacing(10) - - # Quality settings - quality_group = QGroupBox(t("qqmusic_quality")) - quality_layout = QHBoxLayout() - quality_label = QLabel(t("qqmusic_quality")) - self._quality_combo = QComboBox() - self._quality_combo.setFixedWidth(300) - for quality in get_selectable_qualities(): - label_key = get_quality_label_key(quality) - label = t(label_key) if label_key else quality - self._quality_combo.addItem(label) - self._quality_combo.setItemData(self._quality_combo.count() - 1, quality, Qt.UserRole) - quality_layout.addWidget(quality_label) - quality_layout.addWidget(self._quality_combo) - quality_layout.addStretch() - quality_group.setLayout(quality_layout) - qqmusic_layout.addWidget(quality_group) - - # Download directory settings - download_dir_group = QGroupBox(t("online_music_download_dir")) - download_dir_layout = QHBoxLayout() - download_dir_label = QLabel(t("online_music_download_dir")) - self._download_dir_input = QLineEdit() - self._download_dir_input.setPlaceholderText("data/online_cache") - browse_btn = QPushButton(t("online_music_browse")) - browse_btn.setCursor(Qt.PointingHandCursor) - browse_btn.clicked.connect(self._browse_download_dir) - download_dir_layout.addWidget(download_dir_label) - download_dir_layout.addWidget(self._download_dir_input) - download_dir_layout.addWidget(browse_btn) - download_dir_group.setLayout(download_dir_layout) - qqmusic_layout.addWidget(download_dir_group) - - # Hint label for download directory - download_dir_hint = QLabel(t("online_music_download_dir_hint")) - download_dir_hint.setStyleSheet("font-size: 11px;") - download_dir_hint.setWordWrap(True) - qqmusic_layout.addWidget(download_dir_hint) - - # QQ Music instructions - qqmusic_instructions = QLabel( - f"{t('qqmusic_login')}

" - f"{t('qqmusic_faster_api_hint')}" - ) - qqmusic_instructions.setWordWrap(True) - qqmusic_layout.addWidget(qqmusic_instructions) - - # QQ Music credential status - self._qqmusic_status_label = QLabel() - self._qqmusic_status_label.setWordWrap(True) - qqmusic_layout.addWidget(self._qqmusic_status_label) - - # QQ Music buttons - qqmusic_button_layout = QHBoxLayout() - - self._qqmusic_qr_btn = QPushButton(t("qqmusic_qr_login")) - self._qqmusic_qr_btn.setCursor(Qt.PointingHandCursor) - self._qqmusic_qr_btn.clicked.connect(self._open_qqmusic_qr_login) - qqmusic_button_layout.addWidget(self._qqmusic_qr_btn) - - self._qqmusic_logout_btn = QPushButton(t("qqmusic_clear")) - self._qqmusic_logout_btn.setCursor(Qt.PointingHandCursor) - self._qqmusic_logout_btn.clicked.connect(self._qqmusic_logout) - qqmusic_button_layout.addWidget(self._qqmusic_logout_btn) - - qqmusic_layout.addLayout(qqmusic_button_layout) - - # Update status after buttons are created - self._update_qqmusic_status() - - qqmusic_layout.addStretch() - # Cache Cleanup Settings Tab cache_tab = QWidget() cache_layout = QVBoxLayout(cache_tab) @@ -851,7 +745,6 @@ def _setup_ui(self): tab_widget.addTab(playback_tab, t("playback_tab")) tab_widget.addTab(appearance_tab, t("theme_tab")) - tab_widget.addTab(qqmusic_tab, t("qqmusic_tab")) tab_widget.addTab(cache_tab, t("cache_tab")) tab_widget.addTab(covers_tab, t("covers_tab")) tab_widget.addTab(repair_tab, t("repair_tab")) @@ -958,13 +851,6 @@ def _load_settings(self): self._acoustid_api_key_input.setText(acoustid_api_key) self._acoustid_api_key_input.setEnabled(acoustid_enabled) - # QQ Music quality setting - qqmusic_quality = normalize_quality(str(self._config.get_qqmusic_quality())) - for i in range(self._quality_combo.count()): - if self._quality_combo.itemData(i) == qqmusic_quality: - self._quality_combo.setCurrentIndex(i) - break - # Audio engine setting configured_engine = str(self._config.get_audio_engine()) if hasattr(self._config, "get_audio_engine") else "mpv" for i in range(self._audio_engine_combo.count()): @@ -976,10 +862,6 @@ def _load_settings(self): t("audio_engine_status").format(runtime=runtime_engine, configured=configured_engine) ) - # QQ Music download directory setting - download_dir = self._config.get_online_music_download_dir() - self._download_dir_input.setText(download_dir) - # Cache cleanup settings strategy = str(self._config.get_cache_cleanup_strategy()) for i in range(self._strategy_combo.count()): @@ -1050,21 +932,11 @@ def _save_settings(self): self._config.set_acoustid_enabled(acoustid_enabled) self._config.set_acoustid_api_key(acoustid_api_key) - # Save QQ Music quality setting - qqmusic_quality = self._quality_combo.currentData() - self._config.set_qqmusic_quality(qqmusic_quality) - # Save audio engine setting selected_engine = self._audio_engine_combo.currentData() if hasattr(self._config, "set_audio_engine"): self._config.set_audio_engine(selected_engine) - # Save QQ Music download directory setting - download_dir = self._download_dir_input.text().strip() - if not download_dir: - download_dir = "data/online_cache" - self._config.set_online_music_download_dir(download_dir) - # Save cache cleanup settings strategy = self._strategy_combo.currentData() self._config.set_cache_cleanup_strategy(strategy) @@ -1186,110 +1058,6 @@ def _test_acoustid(self): t("acoustid_not_installed") ) - def _update_qqmusic_status(self): - """Update QQ Music credential status display.""" - credential = self._config.get_qqmusic_credential() - if credential: - musicid = credential.get('musicid', '') - login_type = credential.get('loginType', 2) - login_method = t("qqmusic_wx_login") if login_type == 1 else t("qqmusic_qq_login") - - if musicid: - # Show verifying status - self._qqmusic_status_label.setText( - f"⏳ {t('qqmusic_verifying')} ({login_method}: {musicid})" - ) - self._qqmusic_logout_btn.setVisible(True) - - # Start verification in background - if self._verify_thread: - self._verify_thread.quit() - self._verify_thread.wait() - - self._verify_thread = VerifyLoginThread(credential, parent=self) - self._verify_thread.verified.connect( - lambda valid, nick, uin: self._on_login_verified(valid, nick, uin, musicid, login_type) - ) - self._verify_thread.start() - else: - self._qqmusic_status_label.setText( - f"⚠️ {t('qqmusic_incomplete_config')}" - ) - self._qqmusic_logout_btn.setVisible(False) - else: - self._qqmusic_status_label.setText( - f"❌ {t('qqmusic_not_configured_status')}" - ) - self._qqmusic_logout_btn.setVisible(False) - - def _on_login_verified(self, valid: bool, nick: str, uin: int, musicid: str, login_type: int = 2): - """Handle login verification result.""" - login_method = t("qqmusic_wx_login") if login_type == 1 else t("qqmusic_qq_login") - - if valid: - # Save nickname to config - if nick: - self._config.set_qqmusic_nick(nick) - self._qqmusic_status_label.setText( - f"✅ {t('qqmusic_logged_in_status')} ({nick}, {login_method}: {musicid})" - ) - else: - self._qqmusic_status_label.setText( - f"❌ {t('qqmusic_login_expired')} ({login_method}: {musicid})" - ) - - def _qqmusic_logout(self): - """Clear QQ Music credentials (logout).""" - from app.bootstrap import Bootstrap - - reply = MessageDialog.question( - self, - t("qqmusic_clear"), - t("qqmusic_clear_confirm"), - Yes | No, - No - ) - - if reply == Yes: - self._config.clear_qqmusic_credential() - Bootstrap.instance().refresh_qqmusic_client() - self._update_qqmusic_status() - MessageDialog.information(self, t("success"), t("qqmusic_cleared")) - - def _open_qqmusic_qr_login(self): - """Open QQ Music QR code login dialog.""" - from ui.dialogs import QQMusicQRLoginDialog - - dialog = QQMusicQRLoginDialog(self) - dialog.credentials_obtained.connect(self._update_qqmusic_status) - dialog.exec() - - def _browse_download_dir(self): - """Browse for download directory.""" - current_dir = self._download_dir_input.text().strip() - if not current_dir or not os.path.exists(current_dir): - current_dir = os.path.expanduser("~") - - directory = QFileDialog.getExistingDirectory( - self, - t("online_music_select_dir"), - current_dir - ) - - if directory: - # Store relative path if possible - cwd = os.getcwd() - try: - rel_path = os.path.relpath(directory, cwd) - # Use relative path if it's not too deep - if not rel_path.startswith("..") or len(rel_path.split(os.sep)) <= 3: - self._download_dir_input.setText(rel_path) - else: - self._download_dir_input.setText(directory) - except ValueError: - # On Windows, can't get relative path between different drives - self._download_dir_input.setText(directory) - def _open_cache_directory(self): """Open the cache directory in file explorer.""" try: @@ -1722,9 +1490,6 @@ def _rebuild_junction(self): def closeEvent(self, event): """Handle dialog close event.""" - if self._verify_thread: - self._verify_thread.quit() - self._verify_thread.wait() if self._batch_worker: self._batch_worker.cancel() self._batch_worker.quit() From 4b81d0ec9b4413b3d5b3f8b37582a335f3453934 Mon Sep 17 00:00:00 2001 From: Har01d Date: Sun, 5 Apr 2026 19:55:17 +0800 Subject: [PATCH 021/157] =?UTF-8?q?=E6=B7=BB=E5=8A=A0=E6=8F=92=E4=BB=B6?= =?UTF-8?q?=E6=89=93=E5=8C=85=E8=84=9A=E6=9C=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- scripts/build_plugin_zip.py | 13 +++++++++++++ tests/test_system/test_plugin_packaging.py | 17 +++++++++++++++++ 2 files changed, 30 insertions(+) create mode 100644 scripts/build_plugin_zip.py create mode 100644 tests/test_system/test_plugin_packaging.py diff --git a/scripts/build_plugin_zip.py b/scripts/build_plugin_zip.py new file mode 100644 index 00000000..8f672ba6 --- /dev/null +++ b/scripts/build_plugin_zip.py @@ -0,0 +1,13 @@ +from __future__ import annotations + +import zipfile +from pathlib import Path + + +def build_plugin_zip(plugin_root: Path, output_zip: Path) -> Path: + output_zip.parent.mkdir(parents=True, exist_ok=True) + with zipfile.ZipFile(output_zip, "w", compression=zipfile.ZIP_DEFLATED) as archive: + for file_path in plugin_root.rglob("*"): + if file_path.is_file(): + archive.write(file_path, file_path.relative_to(plugin_root)) + return output_zip diff --git a/tests/test_system/test_plugin_packaging.py b/tests/test_system/test_plugin_packaging.py new file mode 100644 index 00000000..dd79ea5a --- /dev/null +++ b/tests/test_system/test_plugin_packaging.py @@ -0,0 +1,17 @@ +import zipfile +from pathlib import Path + +from scripts.build_plugin_zip import build_plugin_zip + + +def test_build_plugin_zip_contains_manifest_and_entrypoint(tmp_path: Path): + plugin_root = Path("plugins/builtin/qqmusic") + output_zip = tmp_path / "qqmusic.zip" + + build_plugin_zip(plugin_root, output_zip) + + with zipfile.ZipFile(output_zip) as archive: + names = set(archive.namelist()) + + assert "plugin.json" in names + assert "plugin_main.py" in names From 1fca94ad5904b395c4d7e1e71d5afc17b0bfe92f Mon Sep 17 00:00:00 2001 From: Har01d Date: Sun, 5 Apr 2026 19:59:15 +0800 Subject: [PATCH 022/157] =?UTF-8?q?=E5=88=87=E6=8D=A2QQ=E6=BA=90=E5=88=B0?= =?UTF-8?q?=E6=8F=92=E4=BB=B6=E6=8F=90=E4=BE=9B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../qqmusic/lib/artist_cover_source.py | 44 ++++++++++++++++- plugins/builtin/qqmusic/lib/cover_source.py | 49 ++++++++++++++++++- plugins/builtin/qqmusic/lib/lyrics_source.py | 30 +++++++++++- services/lyrics/lyrics_service.py | 2 - services/metadata/cover_service.py | 4 -- .../test_plugin_cover_registry.py | 10 ++++ .../test_plugin_lyrics_registry.py | 8 +++ 7 files changed, 135 insertions(+), 12 deletions(-) diff --git a/plugins/builtin/qqmusic/lib/artist_cover_source.py b/plugins/builtin/qqmusic/lib/artist_cover_source.py index 42dac948..d3878a10 100644 --- a/plugins/builtin/qqmusic/lib/artist_cover_source.py +++ b/plugins/builtin/qqmusic/lib/artist_cover_source.py @@ -1,12 +1,52 @@ from __future__ import annotations +import re + +from harmony_plugin_api.cover import PluginArtistCoverResult + class QQMusicArtistCoverPluginSource: source_id = "qqmusic-artist-cover" display_name = "QQMusic Artist" + name = "QQMusic" def __init__(self, context): self._context = context - def search(self, artist_name: str, limit: int = 10) -> list: - return [] + def _convert_cover_url(self, url: str, size: int = 500) -> str: + match = re.search(r"(T\d{3})R\d+x\d+M000([A-Za-z0-9]+)", url) + if not match: + return url + return ( + f"https://y.gtimg.cn/music/photo_new/" + f"{match.group(1)}R{size}x{size}M000{match.group(2)}.jpg" + ) + + def search(self, artist_name: str, limit: int = 10) -> list[PluginArtistCoverResult]: + try: + from services.lyrics.qqmusic_lyrics import QQMusicClient + + client = QQMusicClient() + artists = client.search_artist(artist_name, limit) + results = [] + for artist in artists: + name = artist.get("singerName", "") + singer_mid = artist.get("singerMID", "") + cover_url = artist.get("singerPic", "") + album_count = artist.get("albumNum", 0) + if name and singer_mid: + results.append( + PluginArtistCoverResult( + artist_id=singer_mid, + name=name, + source="qqmusic", + cover_url=self._convert_cover_url(cover_url) if cover_url else None, + album_count=album_count, + ) + ) + return results + except Exception: + return [] + + def is_available(self) -> bool: + return True diff --git a/plugins/builtin/qqmusic/lib/cover_source.py b/plugins/builtin/qqmusic/lib/cover_source.py index fd7bcbd8..4201933a 100644 --- a/plugins/builtin/qqmusic/lib/cover_source.py +++ b/plugins/builtin/qqmusic/lib/cover_source.py @@ -1,12 +1,57 @@ from __future__ import annotations +from harmony_plugin_api.cover import PluginCoverResult + class QQMusicCoverPluginSource: source_id = "qqmusic-cover" display_name = "QQMusic" + name = "QQMusic" def __init__(self, context): self._context = context - def search(self, title: str, artist: str, album: str = "", duration: float | None = None) -> list: - return [] + def search( + self, + title: str, + artist: str, + album: str = "", + duration: float | None = None, + ) -> list[PluginCoverResult]: + try: + from services.lyrics.qqmusic_lyrics import QQMusicClient + + client = QQMusicClient() + keyword = f"{artist} {title}" if artist else title + songs = client.search(keyword, limit=5) + results = [] + for song in songs: + artist_name = "" + if isinstance(song.get("singer"), list) and song["singer"]: + artist_name = song["singer"][0].get("name", "") + + album_name = "" + album_mid = "" + album_data = song.get("album") + if isinstance(album_data, dict): + album_name = album_data.get("name", "") + album_mid = album_data.get("mid", "") + + results.append( + PluginCoverResult( + item_id=song.get("mid", ""), + title=song.get("name", ""), + artist=artist_name, + album=album_name, + duration=song.get("interval"), + source="qqmusic", + cover_url=None, + extra_id=album_mid, + ) + ) + return results + except Exception: + return [] + + def is_available(self) -> bool: + return True diff --git a/plugins/builtin/qqmusic/lib/lyrics_source.py b/plugins/builtin/qqmusic/lib/lyrics_source.py index f39571f6..65c5cf55 100644 --- a/plugins/builtin/qqmusic/lib/lyrics_source.py +++ b/plugins/builtin/qqmusic/lib/lyrics_source.py @@ -6,12 +6,38 @@ class QQMusicLyricsPluginSource: source_id = "qqmusic" display_name = "QQMusic" + name = "QQMusic" def __init__(self, context): self._context = context def search(self, title: str, artist: str, limit: int = 10) -> list[PluginLyricsResult]: - return [] + try: + from services.lyrics.qqmusic_lyrics import search_from_qqmusic + + search_results = search_from_qqmusic(title, artist, limit) + return [ + PluginLyricsResult( + song_id=item.get("id", ""), + title=item.get("title", ""), + artist=item.get("artist", ""), + album=item.get("album", ""), + duration=item.get("duration"), + source="qqmusic", + cover_url=item.get("cover_url"), + ) + for item in search_results + ] + except Exception: + return [] def get_lyrics(self, result: PluginLyricsResult) -> str | None: - return None + try: + from services.lyrics.qqmusic_lyrics import download_qqmusic_lyrics + + return download_qqmusic_lyrics(result.song_id) + except Exception: + return None + + def is_available(self) -> bool: + return True diff --git a/services/lyrics/lyrics_service.py b/services/lyrics/lyrics_service.py index 8aa84d01..0a46255c 100644 --- a/services/lyrics/lyrics_service.py +++ b/services/lyrics/lyrics_service.py @@ -60,14 +60,12 @@ def _get_builtin_sources(cls) -> List["LyricsSource"]: """Get built-in host lyrics sources.""" from services.sources import ( NetEaseLyricsSource, - QQMusicLyricsSource, KugouLyricsSource, ) http_client = _get_http_client() return [ NetEaseLyricsSource(http_client), KugouLyricsSource(http_client), - QQMusicLyricsSource(), ] @classmethod diff --git a/services/metadata/cover_service.py b/services/metadata/cover_service.py index 6cb41b20..be5cda82 100644 --- a/services/metadata/cover_service.py +++ b/services/metadata/cover_service.py @@ -47,13 +47,11 @@ def _get_builtin_sources(self) -> List["CoverSource"]: """Get built-in host cover sources.""" from services.sources import ( NetEaseCoverSource, - QQMusicCoverSource, ITunesCoverSource, LastFmCoverSource, ) return [ NetEaseCoverSource(self.http_client), - QQMusicCoverSource(), ITunesCoverSource(self.http_client), LastFmCoverSource(self.http_client), ] @@ -70,12 +68,10 @@ def _get_builtin_artist_sources(self) -> List["ArtistCoverSource"]: """Get built-in host artist cover sources.""" from services.sources import ( NetEaseArtistCoverSource, - QQMusicArtistCoverSource, ITunesArtistCoverSource, ) return [ NetEaseArtistCoverSource(self.http_client), - QQMusicArtistCoverSource(), ITunesArtistCoverSource(self.http_client), ] diff --git a/tests/test_services/test_plugin_cover_registry.py b/tests/test_services/test_plugin_cover_registry.py index 266de5ad..d1ebaf37 100644 --- a/tests/test_services/test_plugin_cover_registry.py +++ b/tests/test_services/test_plugin_cover_registry.py @@ -31,3 +31,13 @@ def test_cover_service_merges_plugin_cover_sources(monkeypatch): assert service._get_sources() == [fake_cover] assert service._get_artist_sources() == [fake_artist_cover] + + +def test_builtin_cover_sources_exclude_plugin_owned_sources(): + service = CoverService(http_client=SimpleNamespace(), sources=None) + + names = {source.name for source in service._get_builtin_sources()} + artist_names = {source.name for source in service._get_builtin_artist_sources()} + + assert "QQMusic" not in names + assert "QQMusic" not in artist_names diff --git a/tests/test_services/test_plugin_lyrics_registry.py b/tests/test_services/test_plugin_lyrics_registry.py index 64d9459a..6c3beba1 100644 --- a/tests/test_services/test_plugin_lyrics_registry.py +++ b/tests/test_services/test_plugin_lyrics_registry.py @@ -35,3 +35,11 @@ def test_lyrics_service_merges_plugin_sources(monkeypatch): results = LyricsService.search_songs("Song 1", "Singer 1") assert any(item["source"] == "lrclib" for item in results) + + +def test_builtin_lyrics_sources_exclude_plugin_owned_sources(): + sources = LyricsService._get_builtin_sources() + names = {source.name for source in sources} + + assert "LRCLIB" not in names + assert "QQMusic" not in names From 9e04444055fc9270f75868ca286e626a02486509 Mon Sep 17 00:00:00 2001 From: Har01d Date: Sun, 5 Apr 2026 20:06:28 +0800 Subject: [PATCH 023/157] =?UTF-8?q?=E7=A7=BB=E9=99=A4=E5=AE=BF=E4=B8=BB?= =?UTF-8?q?=E5=9C=A8=E7=BA=BF=E9=9F=B3=E4=B9=90=E5=85=A5=E5=8F=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tests/test_ui/test_main_window_components.py | 5 ++- ui/windows/components/sidebar.py | 9 ++-- ui/windows/main_window.py | 47 ++++++-------------- 3 files changed, 19 insertions(+), 42 deletions(-) diff --git a/tests/test_ui/test_main_window_components.py b/tests/test_ui/test_main_window_components.py index 39a15cca..b87b04d4 100644 --- a/tests/test_ui/test_main_window_components.py +++ b/tests/test_ui/test_main_window_components.py @@ -53,14 +53,15 @@ def test_page_constants(self, qapp, mock_config): ThemeManager.instance(mock_config) # Stacked widget order: # 0: library_view, 1: cloud_drive_view, 2: playlist_view, 3: queue_view - # 4: albums_view, 5: artists_view, 6: artist_view, 7: album_view, 8: online_music_view + # 4: albums_view, 5: artists_view, 6: artist_view, 7: album_view, 8: genres_view assert Sidebar.PAGE_LIBRARY == 0 assert Sidebar.PAGE_CLOUD == 1 assert Sidebar.PAGE_PLAYLISTS == 2 assert Sidebar.PAGE_QUEUE == 3 assert Sidebar.PAGE_ALBUMS == 4 assert Sidebar.PAGE_ARTISTS == 5 - assert Sidebar.PAGE_ONLINE == 8 + assert Sidebar.PAGE_GENRES == 8 + assert not hasattr(Sidebar, "PAGE_ONLINE") # Special pages (not in stacked widget) assert Sidebar.PAGE_FAVORITES == 100 assert Sidebar.PAGE_HISTORY == 101 diff --git a/ui/windows/components/sidebar.py b/ui/windows/components/sidebar.py index 0564fc81..ab6d073e 100644 --- a/ui/windows/components/sidebar.py +++ b/ui/windows/components/sidebar.py @@ -34,16 +34,15 @@ class Sidebar(QWidget): # Page indices - must match stacked widget order in MainWindow # Stacked widget order: # 0: library_view, 1: cloud_drive_view, 2: playlist_view, 3: queue_view - # 4: albums_view, 5: artists_view, 6: artist_view, 7: album_view, 8: online_music_view - # 9: genres_view, 10: genre_view + # 4: albums_view, 5: artists_view, 6: artist_view, 7: album_view + # 8: genres_view, 9: genre_view PAGE_LIBRARY = 0 PAGE_CLOUD = 1 PAGE_PLAYLISTS = 2 PAGE_QUEUE = 3 PAGE_ALBUMS = 4 PAGE_ARTISTS = 5 - PAGE_GENRES = 9 - PAGE_ONLINE = 8 + PAGE_GENRES = 8 # Special pages (not in stacked widget, handled specially) PAGE_FAVORITES = 100 PAGE_HISTORY = 101 @@ -130,7 +129,6 @@ def _setup_ui(self): (self.PAGE_ARTISTS, IconName.MICROPHONE, t("artists")), (self.PAGE_GENRES, IconName.COMPACT_DISC, t("genres")), (self.PAGE_CLOUD, IconName.CLOUD, t("cloud_drive")), - (self.PAGE_ONLINE, IconName.GLOBE, t("online_music")), (self.PAGE_PLAYLISTS, IconName.LIST, t("playlists")), (self.PAGE_QUEUE, IconName.QUEUE, t("queue")), (self.PAGE_FAVORITES, IconName.STAR, t("favorites")), @@ -252,7 +250,6 @@ def refresh_texts(self): t("artists"), t("genres"), t("cloud_drive"), - t("online_music"), t("playlists"), t("queue"), t("favorites"), diff --git a/ui/windows/main_window.py b/ui/windows/main_window.py index f0ffbf92..278c6145 100644 --- a/ui/windows/main_window.py +++ b/ui/windows/main_window.py @@ -47,7 +47,6 @@ from ui.views.genre_view import GenreView from ui.views.genres_view import GenresView from ui.views.library_view import LibraryView -from ui.views.online_music_view import OnlineMusicView from ui.views.playlist_view import PlaylistView from ui.views.queue_view import QueueView from ui.widgets.player_controls import PlayerControls @@ -391,19 +390,7 @@ def _setup_ui(self): self._genres_view = GenresView(bootstrap.library_service, bootstrap.cover_service) self._genre_view = GenreView(bootstrap.library_service, self._playback, bootstrap.cover_service) - # Online music view with QQ Music service - from services.cloud.qqmusic.qqmusic_service import QQMusicService - qqmusic_credential = self._config.get("qqmusic.credential") - qqmusic_service = None - if qqmusic_credential: - try: - import json - cred_dict = json.loads(qqmusic_credential) if isinstance(qqmusic_credential, - str) else qqmusic_credential - qqmusic_service = QQMusicService(cred_dict) - except Exception: - pass - self._online_music_view = OnlineMusicView(self._config, qqmusic_service) + self._online_music_view = None self._stacked_widget.addWidget(self._library_view) # 0 self._stacked_widget.addWidget(self._cloud_drive_view) # 1 @@ -413,9 +400,8 @@ def _setup_ui(self): self._stacked_widget.addWidget(self._artists_view) # 5 self._stacked_widget.addWidget(self._artist_view) # 6 self._stacked_widget.addWidget(self._album_view) # 7 - self._stacked_widget.addWidget(self._online_music_view) # 8 - self._stacked_widget.addWidget(self._genres_view) # 9 - self._stacked_widget.addWidget(self._genre_view) # 10 + self._stacked_widget.addWidget(self._genres_view) # 8 + self._stacked_widget.addWidget(self._genre_view) # 9 self._mount_plugin_pages() self._stacked_widget.setMinimumWidth(200) @@ -535,22 +521,11 @@ def _setup_connections(self): self._cloud_drive_view.track_double_clicked.connect(self._play_cloud_track) self._cloud_drive_view.play_cloud_files.connect(self._play_cloud_playlist) - # Online music view connections - self._online_music_view.play_online_track.connect(self._play_online_track) - self._online_music_view.insert_to_queue.connect(self._insert_online_track_to_queue) - self._online_music_view.add_to_queue.connect(self._add_online_track_to_queue) - self._online_music_view.add_multiple_to_queue.connect(self._add_multiple_online_tracks_to_queue) - self._online_music_view.insert_multiple_to_queue.connect(self._insert_multiple_online_tracks_to_queue) - self._online_music_view.play_online_tracks.connect(self._play_online_tracks) - # Initialize online music handler with download service self._online_music_handler = OnlineMusicHandler( playback_service=self._playback, status_callback=self._show_status_message ) - # Set download service from online music view - if hasattr(self._online_music_view, '_download_service'): - self._online_music_handler.set_download_service(self._online_music_view._download_service) # Albums view connections self._albums_view.album_clicked.connect(self._on_album_clicked) @@ -919,7 +894,7 @@ def _refresh_current_genre_detail(self): latest = bootstrap.library_service.get_genre_by_name(current_genre.name) if latest: self._genre_view.set_genre(latest) - elif self._stacked_widget.currentIndex() == 10: + elif self._stacked_widget.currentIndex() == 9: self._on_back() def _on_artist_clicked(self, artist): @@ -939,7 +914,7 @@ def _on_genre_clicked(self, genre): self._nav_stack.append(self._stacked_widget.currentIndex()) # Show genre detail view self._genre_view.set_genre(genre) - self._stacked_widget.setCurrentIndex(10) + self._stacked_widget.setCurrentIndex(9) # Update nav button states - no active nav for detail views self._sidebar.set_current_page(-1) @@ -1223,7 +1198,8 @@ def _refresh_ui_texts(self): self._album_view.refresh_ui() self._genres_view.refresh_ui() self._genre_view.refresh_ui() - self._online_music_view.refresh_ui() # Refresh online music view + if self._online_music_view: + self._online_music_view.refresh_ui() # Update settings button status in sidebar self._sidebar.update_settings_status(self._config.get_ai_enabled()) @@ -2037,9 +2013,12 @@ def restore_view(): elif view_type == "artists": self._show_page(5) elif view_type == "online": - self._show_page(8) + if getattr(self, "_plugin_page_keys", None): + self._show_page(next(iter(self._plugin_page_keys))) + else: + self._show_page(0) elif view_type == "genres": - self._show_page(9) + self._show_page(8) elif view_type == "genre": name = view_data.get("name") if name: @@ -2051,7 +2030,7 @@ def restore_view(): if genre.name == name: self._nav_stack.append(self._stacked_widget.currentIndex()) self._genre_view.set_genre(genre) - self._stacked_widget.setCurrentIndex(10) + self._stacked_widget.setCurrentIndex(9) self._update_nav_buttons_for_detail_view() break elif view_type == "favorites": From d8a1e523077a359270cd738e084c147152f54800 Mon Sep 17 00:00:00 2001 From: Har01d Date: Sun, 5 Apr 2026 20:16:35 +0800 Subject: [PATCH 024/157] =?UTF-8?q?=E7=A7=BB=E9=99=A4=E5=AE=BF=E4=B8=BBQQ?= =?UTF-8?q?=E6=9C=8D=E5=8A=A1=E6=B3=A8=E5=85=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/bootstrap.py | 19 +------------------ tests/test_app/test_plugin_bootstrap.py | 17 +++++++++++++++++ 2 files changed, 18 insertions(+), 18 deletions(-) diff --git a/app/bootstrap.py b/app/bootstrap.py index 12b35883..244b17c4 100644 --- a/app/bootstrap.py +++ b/app/bootstrap.py @@ -402,26 +402,9 @@ def online_music_service(self) -> "OnlineMusicService": """Get online music service.""" if self._online_music_service is None: from services.online import OnlineMusicService - from services.cloud.qqmusic.qqmusic_service import QQMusicService - - # Try to create QQMusicService if credential is available - qqmusic = None - if self.config: - # Use get_qqmusic_credential() to get full credential including refresh_token - credential = self.config.get_qqmusic_credential() - if credential and credential.get('musicid') and credential.get('musickey'): - try: - qqmusic = QQMusicService(credential) - logger.info(f"QQMusicService initialized for OnlineMusicService, " - f"musicid={credential.get('musicid')}, " - f"has_refresh_key={bool(credential.get('refresh_key'))}, " - f"has_refresh_token={bool(credential.get('refresh_token'))}") - except Exception as e: - logger.debug(f"Failed to initialize QQMusicService: {e}") - self._online_music_service = OnlineMusicService( config_manager=self.config, - qqmusic_service=qqmusic + qqmusic_service=None ) return self._online_music_service diff --git a/tests/test_app/test_plugin_bootstrap.py b/tests/test_app/test_plugin_bootstrap.py index f50fbeca..eb719ad3 100644 --- a/tests/test_app/test_plugin_bootstrap.py +++ b/tests/test_app/test_plugin_bootstrap.py @@ -68,3 +68,20 @@ def test_online_download_service_is_created_without_host_online_music_service(mo assert kwargs["config_manager"] is bootstrap._config assert kwargs["qqmusic_service"] is None assert kwargs["online_music_service"] is None + + +def test_online_music_service_is_created_without_host_qqmusic_service(monkeypatch): + fake_online_service = object() + online_ctor = MagicMock(return_value=fake_online_service) + + monkeypatch.setattr(online_module, "OnlineMusicService", online_ctor) + + bootstrap = bootstrap_module.Bootstrap(":memory:") + bootstrap._config = object() + + service = bootstrap.online_music_service + + assert service is fake_online_service + _, kwargs = online_ctor.call_args + assert kwargs["config_manager"] is bootstrap._config + assert kwargs["qqmusic_service"] is None From 090d29a2ad1d0cdda3f012523913f8927db006ca Mon Sep 17 00:00:00 2001 From: Har01d Date: Sun, 5 Apr 2026 20:19:50 +0800 Subject: [PATCH 025/157] =?UTF-8?q?=E5=88=87=E6=8D=A2=E5=AE=BF=E4=B8=BBQQ?= =?UTF-8?q?=E9=85=8D=E7=BD=AE=E5=88=B0=E6=8F=92=E4=BB=B6=E5=91=BD=E5=90=8D?= =?UTF-8?q?=E7=A9=BA=E9=97=B4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- system/config.py | 8 ++++++++ tests/test_system/test_config_security.py | 10 ++++++++++ ui/views/library_view.py | 6 +++++- ui/windows/main_window.py | 6 +++++- 4 files changed, 28 insertions(+), 2 deletions(-) diff --git a/system/config.py b/system/config.py index f66040d6..26b77670 100644 --- a/system/config.py +++ b/system/config.py @@ -409,6 +409,14 @@ def set_online_music_download_dir(self, dir_path: str): """ self.set(SettingKey.ONLINE_MUSIC_DOWNLOAD_DIR, dir_path) + def get_plugin_setting(self, plugin_id: str, key: str, default: Any = None) -> Any: + """Get a plugin-scoped setting value.""" + return self.get(f"plugins.{plugin_id}.{key}", default) + + def set_plugin_setting(self, plugin_id: str, key: str, value: Any): + """Set a plugin-scoped setting value.""" + self.set(f"plugins.{plugin_id}.{key}", value) + # ===== UI settings ===== def get_language(self) -> str: diff --git a/tests/test_system/test_config_security.py b/tests/test_system/test_config_security.py index 07404602..75c2199e 100644 --- a/tests/test_system/test_config_security.py +++ b/tests/test_system/test_config_security.py @@ -74,3 +74,13 @@ def test_qqmusic_credential_keeps_legacy_plaintext_compatible(tmp_path): assert credential["musicid"] == "legacy-id" assert credential["musickey"] == "legacy-key" + + +def test_plugin_settings_are_namespaced(tmp_path): + repo = _FakeSettingsRepository() + config = ConfigManager(repo, secret_store=SecretStore(tmp_path / "secret.key")) + + config.set_plugin_setting("qqmusic", "quality", "flac") + + assert repo.values["plugins.qqmusic.quality"] == "flac" + assert config.get_plugin_setting("qqmusic", "quality") == "flac" diff --git a/ui/views/library_view.py b/ui/views/library_view.py index 0d7660d7..6cf5c2ab 100644 --- a/ui/views/library_view.py +++ b/ui/views/library_view.py @@ -943,7 +943,11 @@ def _redownload_qq_track(self, track): bootstrap = Bootstrap.instance() song_mid = track.cloud_file_id - default_quality = bootstrap.config.get_qqmusic_quality() if bootstrap and bootstrap.config else "320" + default_quality = ( + bootstrap.config.get_plugin_setting("qqmusic", "quality", "320") + if bootstrap and bootstrap.config + else "320" + ) quality = RedownloadDialog.show_dialog( track.title, current_quality=default_quality, diff --git a/ui/windows/main_window.py b/ui/windows/main_window.py index 278c6145..ea7c63d7 100644 --- a/ui/windows/main_window.py +++ b/ui/windows/main_window.py @@ -1248,7 +1248,11 @@ def _on_playlist_redownload(self, track): bootstrap = Bootstrap.instance() song_mid = track.cloud_file_id - default_quality = bootstrap.config.get_qqmusic_quality() if bootstrap and bootstrap.config else "320" + default_quality = ( + bootstrap.config.get_plugin_setting("qqmusic", "quality", "320") + if bootstrap and bootstrap.config + else "320" + ) quality = RedownloadDialog.show_dialog( track.title, current_quality=default_quality, From 1e5d5a4bf6dbfa29d9f322e06091373b0d14b480 Mon Sep 17 00:00:00 2001 From: Har01d Date: Sun, 5 Apr 2026 20:22:04 +0800 Subject: [PATCH 026/157] =?UTF-8?q?=E5=88=87=E6=8D=A2=E5=9C=A8=E7=BA=BF?= =?UTF-8?q?=E6=9C=8D=E5=8A=A1QQ=E9=85=8D=E7=BD=AE=E8=AF=BB=E5=8F=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- services/online/online_music_service.py | 19 ++++++++++++++++--- .../test_online_music_service_perf_paths.py | 15 +++++++++++++++ 2 files changed, 31 insertions(+), 3 deletions(-) diff --git a/services/online/online_music_service.py b/services/online/online_music_service.py index 268e7341..c579ea42 100644 --- a/services/online/online_music_service.py +++ b/services/online/online_music_service.py @@ -56,8 +56,16 @@ def _has_qqmusic_credential(self) -> bool: if not self._config: return False - # Use get_qqmusic_credential() method which handles both formats - credential = self._config.get_qqmusic_credential() + if hasattr(self._config, "get_plugin_setting"): + credential = self._config.get_plugin_setting("qqmusic", "credential") + if credential is not None: + return True + + credential = ( + self._config.get_qqmusic_credential() + if hasattr(self._config, "get_qqmusic_credential") + else None + ) return credential is not None def search( @@ -568,7 +576,12 @@ def get_playback_url_info(self, song_mid: str, quality: Optional[str] = None) -> """ # Use configured quality if not specified if quality is None: - quality = self._config.get_qqmusic_quality() if self._config else "320" + if self._config and hasattr(self._config, "get_plugin_setting"): + quality = self._config.get_plugin_setting("qqmusic", "quality", "320") + elif self._config and hasattr(self._config, "get_qqmusic_quality"): + quality = self._config.get_qqmusic_quality() + else: + quality = "320" # Prefer QQ Music local API if credential is available if self._has_qqmusic_credential() and self._qqmusic: diff --git a/tests/test_services/test_online_music_service_perf_paths.py b/tests/test_services/test_online_music_service_perf_paths.py index fb365d70..a755dbf0 100644 --- a/tests/test_services/test_online_music_service_perf_paths.py +++ b/tests/test_services/test_online_music_service_perf_paths.py @@ -56,3 +56,18 @@ def test_get_artist_albums_ygking_filters_by_singer(): assert result["total"] == 2 assert len(result["albums"]) == 1 assert result["albums"][0]["mid"] == "a1" + + +def test_service_uses_plugin_settings_for_qqmusic_config(): + config = SimpleNamespace( + get_plugin_setting=lambda plugin_id, key, default=None: { + ("qqmusic", "credential"): {"musicid": "1", "musickey": "secret"}, + ("qqmusic", "quality"): "flac", + }.get((plugin_id, key), default) + ) + service = OnlineMusicService(config_manager=config) + service._get_playback_url_remote = lambda *_args, **_kwargs: "https://example.com/song.flac" + + assert service._has_qqmusic_credential() is True + info = service.get_playback_url_info("song-mid", quality=None) + assert info["quality"] == "flac" From 9462c326dc42ad72097d5e5b6d98d659a81ef084 Mon Sep 17 00:00:00 2001 From: Har01d Date: Sun, 5 Apr 2026 20:29:35 +0800 Subject: [PATCH 027/157] =?UTF-8?q?=E8=A7=A3=E8=80=A6QQ=E5=AE=A2=E6=88=B7?= =?UTF-8?q?=E7=AB=AF=E4=B8=8E=E5=9C=A8=E7=BA=BF=E6=9C=8D=E5=8A=A1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/bootstrap.py | 28 ------------------- services/lyrics/qqmusic_lyrics.py | 16 +++++++++-- tests/test_app/test_plugin_bootstrap.py | 7 +++++ .../test_qqmusic_lyrics_perf_paths.py | 14 ++++++++++ ui/dialogs/qqmusic_qr_login_dialog.py | 6 ++-- 5 files changed, 37 insertions(+), 34 deletions(-) diff --git a/app/bootstrap.py b/app/bootstrap.py index 244b17c4..3e917b95 100644 --- a/app/bootstrap.py +++ b/app/bootstrap.py @@ -34,7 +34,6 @@ from system.plugins.state_store import PluginStateStore if TYPE_CHECKING: - from services.lyrics.qqmusic_lyrics import QQMusicClient from services.online import OnlineDownloadService, OnlineMusicService from services.online.cache_cleaner_service import CacheCleanerService from services.playback.sleep_timer_service import SleepTimerService @@ -92,9 +91,6 @@ def __init__(self, db_path: str = "Harmony.db"): self._online_music_service: Optional["OnlineMusicService"] = None self._online_download_service: Optional["OnlineDownloadService"] = None - # QQ Music client - self._qqmusic_client: Optional["QQMusicClient"] = None - # Services self._cache_cleaner_service: Optional["CacheCleanerService"] = None self._sleep_timer_service: Optional["SleepTimerService"] = None @@ -365,30 +361,6 @@ def plugin_manager(self) -> PluginManager: self._plugins_loaded = True return self._plugin_manager - # ===== QQ Music ===== - - @property - def qqmusic_client(self) -> "QQMusicClient": - """Get QQ Music client.""" - if self._qqmusic_client is None: - from services.lyrics.qqmusic_lyrics import QQMusicClient - self._qqmusic_client = QQMusicClient() - return self._qqmusic_client - - def refresh_qqmusic_client(self): - """Refresh QQ Music client and online music services (call after login).""" - from services.lyrics.qqmusic_lyrics import QQMusicClient - - # Refresh lyrics client - self._qqmusic_client = QQMusicClient() - - # Reset online music service to pick up new credentials - self._online_music_service = None - self._online_download_service = None - - logger.info("QQ Music client and online music services refreshed") - return self._qqmusic_client - def refresh_online_music_service(self) -> "OnlineMusicService": """Force refresh of online music service with current credentials.""" self._online_music_service = None diff --git a/services/lyrics/qqmusic_lyrics.py b/services/lyrics/qqmusic_lyrics.py index ae896b03..ccf178c9 100644 --- a/services/lyrics/qqmusic_lyrics.py +++ b/services/lyrics/qqmusic_lyrics.py @@ -17,12 +17,22 @@ # Global lock to prevent concurrent credential refresh _refresh_lock = threading.Lock() +_shared_client = None def _get_client() -> 'QQMusicClient': - """Get QQMusicClient from Bootstrap.""" - from app.bootstrap import Bootstrap - return Bootstrap.instance().qqmusic_client + """Get or create a shared QQMusicClient instance.""" + global _shared_client + if _shared_client is None: + _shared_client = QQMusicClient() + return _shared_client + + +def refresh_shared_client() -> 'QQMusicClient': + """Refresh the shared QQMusicClient instance.""" + global _shared_client + _shared_client = QQMusicClient() + return _shared_client class QQMusicClient: diff --git a/tests/test_app/test_plugin_bootstrap.py b/tests/test_app/test_plugin_bootstrap.py index eb719ad3..84f55e42 100644 --- a/tests/test_app/test_plugin_bootstrap.py +++ b/tests/test_app/test_plugin_bootstrap.py @@ -85,3 +85,10 @@ def test_online_music_service_is_created_without_host_qqmusic_service(monkeypatc _, kwargs = online_ctor.call_args assert kwargs["config_manager"] is bootstrap._config assert kwargs["qqmusic_service"] is None + + +def test_bootstrap_no_longer_exposes_qqmusic_client_helpers(): + bootstrap = bootstrap_module.Bootstrap(":memory:") + + assert not hasattr(bootstrap_module.Bootstrap, "qqmusic_client") + assert not hasattr(bootstrap_module.Bootstrap, "refresh_qqmusic_client") diff --git a/tests/test_services/test_qqmusic_lyrics_perf_paths.py b/tests/test_services/test_qqmusic_lyrics_perf_paths.py index 764726e8..33332f1d 100644 --- a/tests/test_services/test_qqmusic_lyrics_perf_paths.py +++ b/tests/test_services/test_qqmusic_lyrics_perf_paths.py @@ -22,3 +22,17 @@ def search_artist(_artist_name, _limit): "source": "qqmusic", } ] + + +def test_get_client_uses_module_cache_without_bootstrap(monkeypatch): + class _FakeClient: + def __init__(self): + self.created = True + + monkeypatch.setattr("app.bootstrap.Bootstrap.instance", lambda: (_ for _ in ()).throw(AssertionError("bootstrap should not be used"))) + monkeypatch.setattr(qqmusic_lyrics, "QQMusicClient", _FakeClient) + monkeypatch.setattr(qqmusic_lyrics, "_shared_client", None, raising=False) + + client = qqmusic_lyrics._get_client() + + assert isinstance(client, _FakeClient) diff --git a/ui/dialogs/qqmusic_qr_login_dialog.py b/ui/dialogs/qqmusic_qr_login_dialog.py index 475f9a66..04cc1e11 100644 --- a/ui/dialogs/qqmusic_qr_login_dialog.py +++ b/ui/dialogs/qqmusic_qr_login_dialog.py @@ -496,9 +496,9 @@ def _on_login_success(self, credential: dict): except Exception as e: logger.warning(f"Failed to get QQ Music nickname: {e}") - # Refresh QQ Music client to use new credentials - from app.bootstrap import Bootstrap - Bootstrap.instance().refresh_qqmusic_client() + # Refresh shared QQ Music client to use new credentials + from services.lyrics.qqmusic_lyrics import refresh_shared_client + refresh_shared_client() MessageDialog.information( self, From ae9716f629b50c80b426cb76988b3ef44afd45a0 Mon Sep 17 00:00:00 2001 From: Har01d Date: Sun, 5 Apr 2026 20:32:51 +0800 Subject: [PATCH 028/157] =?UTF-8?q?=E6=B8=85=E7=90=86=E5=AE=BF=E4=B8=BBQQ?= =?UTF-8?q?=E6=BA=90=E5=AE=9E=E7=8E=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- services/lyrics/qqmusic_lyrics.py | 28 ++++++-- services/sources/__init__.py | 6 -- services/sources/artist_cover_sources.py | 68 ------------------- services/sources/cover_sources.py | 62 ----------------- services/sources/lyrics_sources.py | 50 -------------- .../test_lyrics_sources_perf_paths.py | 7 +- .../test_qqmusic_lyrics_perf_paths.py | 24 +++++++ ui/dialogs/qqmusic_qr_login_dialog.py | 10 ++- 8 files changed, 60 insertions(+), 195 deletions(-) diff --git a/services/lyrics/qqmusic_lyrics.py b/services/lyrics/qqmusic_lyrics.py index ccf178c9..fb44e665 100644 --- a/services/lyrics/qqmusic_lyrics.py +++ b/services/lyrics/qqmusic_lyrics.py @@ -35,6 +35,26 @@ def refresh_shared_client() -> 'QQMusicClient': return _shared_client +def _get_credential_from_config(config): + """Read QQ Music credential from plugin namespace, with legacy fallback.""" + if hasattr(config, "get_plugin_setting"): + credential = config.get_plugin_setting("qqmusic", "credential") + if credential is not None: + return credential + if hasattr(config, "get_qqmusic_credential"): + return config.get_qqmusic_credential() + return None + + +def _save_credential_to_config(config, credential: dict) -> None: + """Persist QQ Music credential into plugin namespace, with legacy fallback.""" + if hasattr(config, "set_plugin_setting"): + config.set_plugin_setting("qqmusic", "credential", credential) + return + if hasattr(config, "set_qqmusic_credential"): + config.set_qqmusic_credential(credential) + + class QQMusicClient: """QQ Music API client with hybrid local/remote support.""" @@ -67,7 +87,7 @@ def _init_local_client(self): from app.bootstrap import Bootstrap config = Bootstrap.instance().config - credential = config.get_qqmusic_credential() + credential = _get_credential_from_config(config) logger.debug(f"QQ Music credential check: musicid={credential.get('musicid') if credential else 'None'}, " f"has_musickey={bool(credential.get('musickey')) if credential else False}, " @@ -87,7 +107,7 @@ def _init_local_client(self): # Create client with callback for credential updates self._local_client = QQMusicClientLocal( credential, - on_credential_updated=lambda c: config.set_qqmusic_credential(c) + on_credential_updated=lambda c: _save_credential_to_config(config, c) ) self._has_credentials = True logger.info(f"Using local QQ Music API with credentials (musicid: {musicid})") @@ -124,7 +144,7 @@ def _refresh_and_save_credential(self, config: 'ConfigManager'): # Use lock to prevent concurrent refresh with _refresh_lock: # Re-read credential from config to check if already refreshed by another thread - current_credential = config.get_qqmusic_credential() + current_credential = _get_credential_from_config(config) if current_credential: # Update local client's credential self._local_client.credential = current_credential @@ -136,7 +156,7 @@ def _refresh_and_save_credential(self, config: 'ConfigManager'): try: updated = self._local_client.refresh_credential() if updated: - config.set_qqmusic_credential(updated) + _save_credential_to_config(config, updated) logger.info("Credential refreshed and saved successfully") else: logger.warning("Credential refresh failed, will retry later") diff --git a/services/sources/__init__.py b/services/sources/__init__.py index d7db8a8c..4699e3a9 100644 --- a/services/sources/__init__.py +++ b/services/sources/__init__.py @@ -8,7 +8,6 @@ from .base import CoverSource, LyricsSource, ArtistCoverSource from .cover_sources import ( NetEaseCoverSource, - QQMusicCoverSource, ITunesCoverSource, LastFmCoverSource, MusicBrainzCoverSource, @@ -16,12 +15,10 @@ ) from .lyrics_sources import ( NetEaseLyricsSource, - QQMusicLyricsSource, KugouLyricsSource, ) from .artist_cover_sources import ( NetEaseArtistCoverSource, - QQMusicArtistCoverSource, ITunesArtistCoverSource, SpotifyArtistCoverSource, ) @@ -33,18 +30,15 @@ "ArtistCoverSource", # Cover sources "NetEaseCoverSource", - "QQMusicCoverSource", "ITunesCoverSource", "LastFmCoverSource", "MusicBrainzCoverSource", "SpotifyCoverSource", # Lyrics sources "NetEaseLyricsSource", - "QQMusicLyricsSource", "KugouLyricsSource", # Artist cover sources "NetEaseArtistCoverSource", - "QQMusicArtistCoverSource", "ITunesArtistCoverSource", "SpotifyArtistCoverSource", ] diff --git a/services/sources/artist_cover_sources.py b/services/sources/artist_cover_sources.py index cf4df194..c85dd818 100644 --- a/services/sources/artist_cover_sources.py +++ b/services/sources/artist_cover_sources.py @@ -76,74 +76,6 @@ def __init__(self, http_client): self._http_client = http_client -class QQMusicArtistCoverSource(ArtistCoverSource): - """QQ Music artist cover source.""" - - @property - def name(self) -> str: - return "QQMusic" - - def _parse_cover_url(self, url: str): - """Parse QQ Music cover URL.""" - import re - pattern = r"(T\d{3})R\d+x\d+M000([A-Za-z0-9]+)" - m = re.search(pattern, url) - if not m: - return "", "" - return m.group(1), m.group(2) - - def _convert_cover_url(self, url: str, size: int = 500) -> str: - """Convert to specified size.""" - img_type, mid = self._parse_cover_url(url) - if not img_type or not mid: - return url - return f"https://y.gtimg.cn/music/photo_new/{img_type}R{size}x{size}M000{mid}.jpg" - - def search( - self, - artist_name: str, - limit: int = 10 - ) -> List[ArtistCoverSearchResult]: - """Search for artist covers from QQ Music.""" - results = [] - - try: - from services.lyrics.qqmusic_lyrics import QQMusicClient - - client = QQMusicClient() - artists = client.search_artist(artist_name, limit) - - for artist in artists: - name = artist.get('singerName', '') - singer_mid = artist.get('singerMID', '') - cover_url = artist.get('singerPic', '') - album_count = artist.get('albumNum', 0) - - if name and singer_mid: - # Convert cover URL if valid - if cover_url: - cover_url = self._convert_cover_url(cover_url) - else: - cover_url = None # Will be lazy loaded via singer_mid - - results.append(ArtistCoverSearchResult( - id=singer_mid, - name=name, - cover_url=cover_url, - album_count=album_count, - source='qqmusic', - singer_mid=singer_mid - )) - - except Exception as e: - logger.debug(f"QQ Music artist cover search error: {e}") - - return results - - def __init__(self, http_client=None): - pass - - class ITunesArtistCoverSource(ArtistCoverSource): """iTunes Search API artist cover source.""" diff --git a/services/sources/cover_sources.py b/services/sources/cover_sources.py index b63982a8..38f03615 100644 --- a/services/sources/cover_sources.py +++ b/services/sources/cover_sources.py @@ -118,68 +118,6 @@ def __init__(self, http_client): self._http_client = http_client -class QQMusicCoverSource(CoverSource): - """QQ Music cover source.""" - - @property - def name(self) -> str: - return "QQMusic" - - def search( - self, - title: str, - artist: str, - album: str = "", - duration: Optional[float] = None - ) -> List[CoverSearchResult]: - """Search for covers from QQ Music.""" - results = [] - - try: - from services.lyrics.qqmusic_lyrics import QQMusicClient - - client = QQMusicClient() - - # Search for songs - keyword = f"{artist} {title}" if artist else title - songs = client.search(keyword, limit=5) - - for song in songs: - # Parse artist from singer list - artist_name = "" - if isinstance(song.get('singer'), list) and song['singer']: - artist_name = song['singer'][0].get('name', '') - - # Parse album from album dict - album_name = "" - album_mid = "" - album_data = song.get('album') - if isinstance(album_data, dict): - album_name = album_data.get('name', '') - album_mid = album_data.get('mid', '') - - # Store album_mid for lazy cover fetch, don't get cover_url now - results.append(CoverSearchResult( - title=song.get('name', ''), - artist=artist_name, - album=album_name, - duration=song.get('interval'), # Already in seconds - source='qqmusic', - id=song.get('mid', ''), - cover_url=None, # Lazy fetch on click - album_mid=album_mid - )) - - except Exception as e: - logger.debug(f"QQ Music cover search error: {e}") - - return results - - def __init__(self, http_client=None): - # QQ Music doesn't need http_client directly - pass - - class ITunesCoverSource(CoverSource): """iTunes Search API cover source.""" diff --git a/services/sources/lyrics_sources.py b/services/sources/lyrics_sources.py index c575b238..924abafd 100644 --- a/services/sources/lyrics_sources.py +++ b/services/sources/lyrics_sources.py @@ -133,56 +133,6 @@ def get_lyrics(self, result: LyricsSearchResult) -> Optional[str]: def __init__(self, http_client): self._http_client = http_client - -class QQMusicLyricsSource(LyricsSource): - """QQ Music lyrics source.""" - - @property - def name(self) -> str: - return "QQMusic" - - def search( - self, - title: str, - artist: str, - limit: int = 10 - ) -> List[LyricsSearchResult]: - """Search for lyrics from QQ Music.""" - results = [] - - try: - from services.lyrics.qqmusic_lyrics import search_from_qqmusic - search_results = search_from_qqmusic(title, artist, limit) - - results.extend(LyricsSearchResult( - id=item.get('id', ''), - title=item.get('title', ''), - artist=item.get('artist', ''), - album=item.get('album', ''), - duration=item.get('duration'), - source='qqmusic', - cover_url=item.get('cover_url'), - ) for item in search_results) - - except Exception as e: - logger.error(f"Error searching from QQ Music: {e}") - - return results - - def get_lyrics(self, result: LyricsSearchResult) -> Optional[str]: - """Download lyrics from QQ Music by song mid.""" - try: - from services.lyrics.qqmusic_lyrics import download_qqmusic_lyrics - return download_qqmusic_lyrics(result.id) - except Exception as e: - logger.error(f"Error downloading QQ Music lyrics: {e}") - - return None - - def __init__(self, http_client=None): - pass - - class KugouLyricsSource(LyricsSource): """Kugou lyrics source.""" diff --git a/tests/test_services/test_lyrics_sources_perf_paths.py b/tests/test_services/test_lyrics_sources_perf_paths.py index d84c3e1c..4a3755e9 100644 --- a/tests/test_services/test_lyrics_sources_perf_paths.py +++ b/tests/test_services/test_lyrics_sources_perf_paths.py @@ -2,7 +2,8 @@ from types import SimpleNamespace -from services.sources.lyrics_sources import QQMusicLyricsSource, KugouLyricsSource +from plugins.builtin.qqmusic.lib.lyrics_source import QQMusicLyricsPluginSource +from services.sources.lyrics_sources import KugouLyricsSource def test_qqmusic_lyrics_source_search_builds_results(monkeypatch): @@ -19,12 +20,12 @@ def test_qqmusic_lyrics_source_search_builds_results(monkeypatch): } ], ) - source = QQMusicLyricsSource() + source = QQMusicLyricsPluginSource(SimpleNamespace()) results = source.search("Song 1", "Singer 1") assert len(results) == 1 - assert results[0].id == "song-1" + assert results[0].song_id == "song-1" assert results[0].title == "Song 1" diff --git a/tests/test_services/test_qqmusic_lyrics_perf_paths.py b/tests/test_services/test_qqmusic_lyrics_perf_paths.py index 33332f1d..4764f7fa 100644 --- a/tests/test_services/test_qqmusic_lyrics_perf_paths.py +++ b/tests/test_services/test_qqmusic_lyrics_perf_paths.py @@ -36,3 +36,27 @@ def __init__(self): client = qqmusic_lyrics._get_client() assert isinstance(client, _FakeClient) + + +def test_credential_helpers_prefer_plugin_settings_namespace(): + class _Config: + def __init__(self): + self.values = { + ("qqmusic", "credential"): {"musicid": "1", "musickey": "secret"}, + } + self.saved = [] + + def get_plugin_setting(self, plugin_id, key, default=None): + return self.values.get((plugin_id, key), default) + + def set_plugin_setting(self, plugin_id, key, value): + self.saved.append((plugin_id, key, value)) + + config = _Config() + + assert qqmusic_lyrics._get_credential_from_config(config)["musickey"] == "secret" + + payload = {"musicid": "2", "musickey": "new"} + qqmusic_lyrics._save_credential_to_config(config, payload) + + assert config.saved == [("qqmusic", "credential", payload)] diff --git a/ui/dialogs/qqmusic_qr_login_dialog.py b/ui/dialogs/qqmusic_qr_login_dialog.py index 04cc1e11..b54b48ba 100644 --- a/ui/dialogs/qqmusic_qr_login_dialog.py +++ b/ui/dialogs/qqmusic_qr_login_dialog.py @@ -483,7 +483,10 @@ def _on_login_success(self, credential: dict): try: # Save credentials (full credential dict) - self.config.set_qqmusic_credential(credential) + if hasattr(self.config, "set_plugin_setting"): + self.config.set_plugin_setting("qqmusic", "credential", credential) + else: + self.config.set_qqmusic_credential(credential) # Get user nickname try: @@ -491,7 +494,10 @@ def _on_login_success(self, credential: dict): client = QQMusicClient(credential) user_info = client.verify_login() if user_info.get('valid') and user_info.get('nick'): - self.config.set_qqmusic_nick(user_info['nick']) + if hasattr(self.config, "set_plugin_setting"): + self.config.set_plugin_setting("qqmusic", "nick", user_info["nick"]) + else: + self.config.set_qqmusic_nick(user_info['nick']) logger.info(f"Got QQ Music nickname: {user_info['nick']}") except Exception as e: logger.warning(f"Failed to get QQ Music nickname: {e}") From a06058082efb43d44fa887e0df7762aaad0e454e Mon Sep 17 00:00:00 2001 From: Har01d Date: Sun, 5 Apr 2026 20:43:43 +0800 Subject: [PATCH 029/157] =?UTF-8?q?=E5=88=87=E6=8D=A2=E5=9C=A8=E7=BA=BF?= =?UTF-8?q?=E8=A7=86=E5=9B=BEQQ=E9=85=8D=E7=BD=AE=E8=AF=BB=E5=8F=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tests/test_ui/test_online_music_view_async.py | 32 +++++++++++++++++++ ui/views/online_music_view.py | 13 ++++++-- 2 files changed, 43 insertions(+), 2 deletions(-) diff --git a/tests/test_ui/test_online_music_view_async.py b/tests/test_ui/test_online_music_view_async.py index b6e04357..6215b50d 100644 --- a/tests/test_ui/test_online_music_view_async.py +++ b/tests/test_ui/test_online_music_view_async.py @@ -136,6 +136,38 @@ def test_current_hotkey_results_update_state(): assert view._hotkeys == hotkeys +def test_update_login_status_prefers_plugin_namespaced_nick(): + view = OnlineMusicView.__new__(OnlineMusicView) + view._service = Mock() + view._service._has_qqmusic_credential.return_value = True + view._refresh_qqmusic_service = Mock() + view._config = Mock() + view._config.get_plugin_setting.return_value = "Plugin Nick" + view._login_status_label = Mock() + view._login_btn = Mock() + view._recommend_section = Mock() + view._load_recommendations = Mock() + + OnlineMusicView._update_login_status(view) + + view._config.get_plugin_setting.assert_called_once_with("qqmusic", "nick", "") + view._login_status_label.setText.assert_called_once() + + +def test_on_login_clicked_clears_plugin_namespaced_credential(): + view = OnlineMusicView.__new__(OnlineMusicView) + view._service = Mock() + view._service._has_qqmusic_credential.return_value = True + view._config = Mock() + view._update_login_status = Mock() + + with patch("ui.views.online_music_view.MessageDialog.information"): + OnlineMusicView._on_login_clicked(view) + + view._config.set_plugin_setting.assert_any_call("qqmusic", "credential", None) + view._config.set_plugin_setting.assert_any_call("qqmusic", "nick", "") + + class _FakeSignal: def __init__(self): self.connected = None diff --git a/ui/views/online_music_view.py b/ui/views/online_music_view.py index 63d29e2e..ccc8afe2 100644 --- a/ui/views/online_music_view.py +++ b/ui/views/online_music_view.py @@ -1399,7 +1399,12 @@ def _update_login_status(self): self._refresh_qqmusic_service() # Get nickname from config - nick = self._config.get_qqmusic_nick() if self._config else "" + if self._config and hasattr(self._config, "get_plugin_setting"): + nick = self._config.get_plugin_setting("qqmusic", "nick", "") + elif self._config and hasattr(self._config, "get_qqmusic_nick"): + nick = self._config.get_qqmusic_nick() + else: + nick = "" if nick: self._login_status_label.setText(t("qqmusic_logged_in_as").format(nick=nick)) @@ -1424,7 +1429,11 @@ def _on_login_clicked(self): if self._service._has_qqmusic_credential(): # Logout if self._config: - self._config.clear_qqmusic_credential() + if hasattr(self._config, "set_plugin_setting"): + self._config.set_plugin_setting("qqmusic", "credential", None) + self._config.set_plugin_setting("qqmusic", "nick", "") + elif hasattr(self._config, "clear_qqmusic_credential"): + self._config.clear_qqmusic_credential() self._update_login_status() MessageDialog.information(self, t("logout"), t("logout_success")) else: From 666268fc436ac7f3db27539845cd0b234011e599 Mon Sep 17 00:00:00 2001 From: Har01d Date: Sun, 5 Apr 2026 20:59:10 +0800 Subject: [PATCH 030/157] =?UTF-8?q?=E5=88=87=E6=8D=A2QQ=E5=87=AD=E6=8D=AE?= =?UTF-8?q?=E5=88=B0=E6=8F=92=E4=BB=B6=E5=AE=89=E5=85=A8=E5=AD=98=E5=82=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- services/lyrics/qqmusic_lyrics.py | 14 +++++++++++++ system/config.py | 8 +++++++ tests/test_system/test_config_security.py | 10 +++++++++ tests/test_ui/test_library_view_redownload.py | 2 +- tests/test_ui/test_online_music_view_async.py | 21 +++++++++++++++++++ tests/test_ui/test_plugin_settings_tab.py | 4 ---- ui/dialogs/qqmusic_qr_login_dialog.py | 10 ++++++++- ui/views/online_music_view.py | 7 ++++++- 8 files changed, 69 insertions(+), 7 deletions(-) diff --git a/services/lyrics/qqmusic_lyrics.py b/services/lyrics/qqmusic_lyrics.py index fb44e665..15a0e945 100644 --- a/services/lyrics/qqmusic_lyrics.py +++ b/services/lyrics/qqmusic_lyrics.py @@ -37,6 +37,13 @@ def refresh_shared_client() -> 'QQMusicClient': def _get_credential_from_config(config): """Read QQ Music credential from plugin namespace, with legacy fallback.""" + if hasattr(config, "get_plugin_secret"): + raw = config.get_plugin_secret("qqmusic", "credential", "") + if raw: + try: + return raw if isinstance(raw, dict) else __import__("json").loads(raw) + except Exception: + return None if hasattr(config, "get_plugin_setting"): credential = config.get_plugin_setting("qqmusic", "credential") if credential is not None: @@ -48,6 +55,13 @@ def _get_credential_from_config(config): def _save_credential_to_config(config, credential: dict) -> None: """Persist QQ Music credential into plugin namespace, with legacy fallback.""" + if hasattr(config, "set_plugin_secret"): + config.set_plugin_secret( + "qqmusic", + "credential", + __import__("json").dumps(credential, ensure_ascii=False), + ) + return if hasattr(config, "set_plugin_setting"): config.set_plugin_setting("qqmusic", "credential", credential) return diff --git a/system/config.py b/system/config.py index 26b77670..f5557aee 100644 --- a/system/config.py +++ b/system/config.py @@ -417,6 +417,14 @@ def set_plugin_setting(self, plugin_id: str, key: str, value: Any): """Set a plugin-scoped setting value.""" self.set(f"plugins.{plugin_id}.{key}", value) + def get_plugin_secret(self, plugin_id: str, key: str, default: str = "") -> str: + """Get a plugin-scoped secret value and decrypt it.""" + return self._get_secret(f"plugins.{plugin_id}.{key}", default) + + def set_plugin_secret(self, plugin_id: str, key: str, value: str): + """Encrypt and persist a plugin-scoped secret value.""" + self._set_secret(f"plugins.{plugin_id}.{key}", value) + # ===== UI settings ===== def get_language(self) -> str: diff --git a/tests/test_system/test_config_security.py b/tests/test_system/test_config_security.py index 75c2199e..d347b4b3 100644 --- a/tests/test_system/test_config_security.py +++ b/tests/test_system/test_config_security.py @@ -84,3 +84,13 @@ def test_plugin_settings_are_namespaced(tmp_path): assert repo.values["plugins.qqmusic.quality"] == "flac" assert config.get_plugin_setting("qqmusic", "quality") == "flac" + + +def test_plugin_secret_is_encrypted_at_rest(tmp_path): + repo = _FakeSettingsRepository() + config = ConfigManager(repo, secret_store=SecretStore(tmp_path / "secret.key")) + + config.set_plugin_secret("qqmusic", "credential", '{"musicid":"1","musickey":"secret"}') + + assert repo.values["plugins.qqmusic.credential"] != '{"musicid":"1","musickey":"secret"}' + assert config.get_plugin_secret("qqmusic", "credential") == '{"musicid":"1","musickey":"secret"}' diff --git a/tests/test_ui/test_library_view_redownload.py b/tests/test_ui/test_library_view_redownload.py index 81f1f707..192d54c7 100644 --- a/tests/test_ui/test_library_view_redownload.py +++ b/tests/test_ui/test_library_view_redownload.py @@ -59,7 +59,7 @@ def test_redownload_qq_track_uses_configured_quality_as_dialog_default( bootstrap_module.Bootstrap, "instance", lambda: SimpleNamespace( - config=SimpleNamespace(get_qqmusic_quality=lambda: "flac"), + config=SimpleNamespace(get_plugin_setting=lambda *_args, **_kwargs: "flac"), online_download_service=SimpleNamespace(delete_cached_file=MagicMock()), ), ) diff --git a/tests/test_ui/test_online_music_view_async.py b/tests/test_ui/test_online_music_view_async.py index 6215b50d..ca3bee79 100644 --- a/tests/test_ui/test_online_music_view_async.py +++ b/tests/test_ui/test_online_music_view_async.py @@ -168,6 +168,27 @@ def test_on_login_clicked_clears_plugin_namespaced_credential(): view._config.set_plugin_setting.assert_any_call("qqmusic", "nick", "") +def test_refresh_qqmusic_service_prefers_plugin_secret(monkeypatch): + view = OnlineMusicView.__new__(OnlineMusicView) + view._config = Mock() + view._config.get_plugin_secret.return_value = '{"musicid":"1","musickey":"secret"}' + view._service = Mock() + view._download_service = Mock() + view._detail_view = None + + class _FakeQQMusicService: + def __init__(self, credential): + self.credential = credential + + monkeypatch.setattr("ui.views.online_music_view.QQMusicService", _FakeQQMusicService, raising=False) + monkeypatch.setattr("services.cloud.qqmusic.qqmusic_service.QQMusicService", _FakeQQMusicService) + + OnlineMusicView._refresh_qqmusic_service(view) + + view._config.get_plugin_secret.assert_called_once_with("qqmusic", "credential", "") + assert view._qqmusic_service.credential["musicid"] == "1" + + class _FakeSignal: def __init__(self): self.connected = None diff --git a/tests/test_ui/test_plugin_settings_tab.py b/tests/test_ui/test_plugin_settings_tab.py index 25fd5d2d..b95ce5c5 100644 --- a/tests/test_ui/test_plugin_settings_tab.py +++ b/tests/test_ui/test_plugin_settings_tab.py @@ -51,8 +51,6 @@ def test_settings_dialog_includes_plugins_tab(monkeypatch, qtbot): config.get_cache_cleanup_count.return_value = 100 config.get_cache_cleanup_interval_hours.return_value = 1 config.get_audio_engine.return_value = "mpv" - config.get_qqmusic_credential.return_value = None - config.get_qqmusic_quality.return_value = "320" fake_manager = Mock() fake_manager.list_plugins.return_value = [] @@ -87,8 +85,6 @@ def test_settings_dialog_omits_qqmusic_tab_without_plugin(monkeypatch, qtbot): config.get_cache_cleanup_count.return_value = 100 config.get_cache_cleanup_interval_hours.return_value = 1 config.get_audio_engine.return_value = "mpv" - config.get_qqmusic_credential.return_value = None - config.get_qqmusic_quality.return_value = "320" fake_manager = Mock() fake_manager.list_plugins.return_value = [] diff --git a/ui/dialogs/qqmusic_qr_login_dialog.py b/ui/dialogs/qqmusic_qr_login_dialog.py index b54b48ba..c945cade 100644 --- a/ui/dialogs/qqmusic_qr_login_dialog.py +++ b/ui/dialogs/qqmusic_qr_login_dialog.py @@ -483,7 +483,15 @@ def _on_login_success(self, credential: dict): try: # Save credentials (full credential dict) - if hasattr(self.config, "set_plugin_setting"): + if hasattr(self.config, "set_plugin_secret"): + import json + + self.config.set_plugin_secret( + "qqmusic", + "credential", + json.dumps(credential, ensure_ascii=False), + ) + elif hasattr(self.config, "set_plugin_setting"): self.config.set_plugin_setting("qqmusic", "credential", credential) else: self.config.set_qqmusic_credential(credential) diff --git a/ui/views/online_music_view.py b/ui/views/online_music_view.py index ccc8afe2..ad2bf4b4 100644 --- a/ui/views/online_music_view.py +++ b/ui/views/online_music_view.py @@ -1371,7 +1371,12 @@ def _refresh_qqmusic_service(self): import json from services.cloud.qqmusic.qqmusic_service import QQMusicService - qqmusic_credential = self._config.get("qqmusic.credential") if self._config else None + if self._config and hasattr(self._config, "get_plugin_secret"): + qqmusic_credential = self._config.get_plugin_secret("qqmusic", "credential", "") + elif self._config: + qqmusic_credential = self._config.get("qqmusic.credential") + else: + qqmusic_credential = None if qqmusic_credential: try: cred_dict = json.loads(qqmusic_credential) if isinstance(qqmusic_credential, From 6f09c2b9954bf27b654b2d182fec014f203062d5 Mon Sep 17 00:00:00 2001 From: Har01d Date: Sun, 5 Apr 2026 21:08:03 +0800 Subject: [PATCH 031/157] =?UTF-8?q?=E7=A7=BB=E9=99=A4QQ=E4=B8=93=E7=94=A8?= =?UTF-8?q?=E9=85=8D=E7=BD=AE=E6=8E=A5=E5=8F=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- services/lyrics/qqmusic_lyrics.py | 15 +-- services/online/online_music_service.py | 15 +-- system/config.py | 121 ------------------ .../test_qqmusic_lyrics_perf_paths.py | 8 +- tests/test_system/test_config_security.py | 47 ++----- ui/dialogs/qqmusic_qr_login_dialog.py | 6 - ui/views/online_music_view.py | 4 - 7 files changed, 23 insertions(+), 193 deletions(-) diff --git a/services/lyrics/qqmusic_lyrics.py b/services/lyrics/qqmusic_lyrics.py index 15a0e945..d0f11099 100644 --- a/services/lyrics/qqmusic_lyrics.py +++ b/services/lyrics/qqmusic_lyrics.py @@ -36,7 +36,7 @@ def refresh_shared_client() -> 'QQMusicClient': def _get_credential_from_config(config): - """Read QQ Music credential from plugin namespace, with legacy fallback.""" + """Read QQ Music credential from plugin namespace.""" if hasattr(config, "get_plugin_secret"): raw = config.get_plugin_secret("qqmusic", "credential", "") if raw: @@ -44,17 +44,11 @@ def _get_credential_from_config(config): return raw if isinstance(raw, dict) else __import__("json").loads(raw) except Exception: return None - if hasattr(config, "get_plugin_setting"): - credential = config.get_plugin_setting("qqmusic", "credential") - if credential is not None: - return credential - if hasattr(config, "get_qqmusic_credential"): - return config.get_qqmusic_credential() return None def _save_credential_to_config(config, credential: dict) -> None: - """Persist QQ Music credential into plugin namespace, with legacy fallback.""" + """Persist QQ Music credential into plugin namespace.""" if hasattr(config, "set_plugin_secret"): config.set_plugin_secret( "qqmusic", @@ -62,11 +56,6 @@ def _save_credential_to_config(config, credential: dict) -> None: __import__("json").dumps(credential, ensure_ascii=False), ) return - if hasattr(config, "set_plugin_setting"): - config.set_plugin_setting("qqmusic", "credential", credential) - return - if hasattr(config, "set_qqmusic_credential"): - config.set_qqmusic_credential(credential) class QQMusicClient: diff --git a/services/online/online_music_service.py b/services/online/online_music_service.py index c579ea42..2e26b357 100644 --- a/services/online/online_music_service.py +++ b/services/online/online_music_service.py @@ -56,16 +56,11 @@ def _has_qqmusic_credential(self) -> bool: if not self._config: return False - if hasattr(self._config, "get_plugin_setting"): + credential = None + if hasattr(self._config, "get_plugin_secret"): + credential = self._config.get_plugin_secret("qqmusic", "credential", "") + elif hasattr(self._config, "get_plugin_setting"): credential = self._config.get_plugin_setting("qqmusic", "credential") - if credential is not None: - return True - - credential = ( - self._config.get_qqmusic_credential() - if hasattr(self._config, "get_qqmusic_credential") - else None - ) return credential is not None def search( @@ -578,8 +573,6 @@ def get_playback_url_info(self, song_mid: str, quality: Optional[str] = None) -> if quality is None: if self._config and hasattr(self._config, "get_plugin_setting"): quality = self._config.get_plugin_setting("qqmusic", "quality", "320") - elif self._config and hasattr(self._config, "get_qqmusic_quality"): - quality = self._config.get_qqmusic_quality() else: quality = "320" diff --git a/system/config.py b/system/config.py index f5557aee..0ee721ff 100644 --- a/system/config.py +++ b/system/config.py @@ -71,14 +71,6 @@ class SettingKey: ACOUSTID_ENABLED = "acoustid.enabled" ACOUSTID_API_KEY = "acoustid.api_key" - # QQ Music settings - QQMUSIC_MUSICID = "qqmusic.musicid" - QQMUSIC_MUSICKEY = "qqmusic.musickey" - QQMUSIC_LOGIN_TYPE = "qqmusic.login_type" - QQMUSIC_CREDENTIAL = "qqmusic.credential" # Full credential JSON - QQMUSIC_NICK = "qqmusic.nick" # User nickname - QQMUSIC_QUALITY = "qqmusic.quality" # Audio quality setting - # Cache cleanup settings CACHE_CLEANUP_STRATEGY = "cache.cleanup_strategy" # "time", "size", "count", "manual", "disabled" CACHE_CLEANUP_TIME_DAYS = "cache.cleanup_time_days" # int: days @@ -722,119 +714,6 @@ def set_acoustid_api_key(self, api_key: str): """ self._set_secret(SettingKey.ACOUSTID_API_KEY, api_key) - # ===== QQ Music settings ===== - - def get_qqmusic_credential(self) -> Optional[dict]: - """ - Get QQ Music credentials. - - Returns: - Dict with credential data or None if not configured - """ - # Try to get full credential JSON first - credential_data = self.get(SettingKey.QQMUSIC_CREDENTIAL) - if credential_data: - credential_data = self._secret_store.decrypt(credential_data) - # Handle both dict (already parsed) and string (JSON) - if isinstance(credential_data, dict): - cred = credential_data - else: - try: - cred = json.loads(credential_data) - except Exception as e: - import logging - logging.getLogger(__name__).warning(f"Failed to parse QQ Music credential JSON: {e}") - cred = None - - if cred and cred.get('musicid') and cred.get('musickey'): - return cred - - # Fallback to individual fields - musicid = self.get(SettingKey.QQMUSIC_MUSICID) - musickey = self._secret_store.decrypt(self.get(SettingKey.QQMUSIC_MUSICKEY)) - login_type = self.get(SettingKey.QQMUSIC_LOGIN_TYPE, 2) - - if musicid and musickey: - return { - 'musicid': musicid, - 'musickey': musickey, - 'login_type': login_type - } - return None - - def set_qqmusic_credential(self, credential: dict): - """ - Set QQ Music credentials. - - Args: - credential: Dict with credential data (can be full credential or just musicid/musickey) - """ - - # Handle both full credential dict and simple credential - musicid = credential.get('musicid') or credential.get('str_musicid', '') - musickey = credential.get('musickey', '') - # Support both snake_case (login_type) and camelCase (loginType) - login_type = credential.get('login_type') or credential.get('loginType', 2) - - # Save individual fields for backward compatibility - self.set(SettingKey.QQMUSIC_MUSICID, str(musicid) if musicid else '') - self._set_secret(SettingKey.QQMUSIC_MUSICKEY, musickey) - self.set(SettingKey.QQMUSIC_LOGIN_TYPE, login_type) - - # Save full credential JSON - try: - self._set_secret( - SettingKey.QQMUSIC_CREDENTIAL, - json.dumps(credential, ensure_ascii=False), - ) - except (TypeError, ValueError) as e: - import logging - logging.getLogger(__name__).warning(f"Failed to save QQ Music credential: {e}") - - def clear_qqmusic_credential(self): - """Clear QQ Music credentials.""" - self.delete(SettingKey.QQMUSIC_MUSICID) - self.delete(SettingKey.QQMUSIC_MUSICKEY) - self.delete(SettingKey.QQMUSIC_LOGIN_TYPE) - self.delete(SettingKey.QQMUSIC_CREDENTIAL) - self.delete(SettingKey.QQMUSIC_NICK) - - def get_qqmusic_nick(self) -> str: - """ - Get QQ Music user nickname. - - Returns: - User nickname or empty string - """ - return self.get(SettingKey.QQMUSIC_NICK, "") - - def set_qqmusic_nick(self, nick: str): - """ - Set QQ Music user nickname. - - Args: - nick: User nickname - """ - self.set(SettingKey.QQMUSIC_NICK, nick) - - def get_qqmusic_quality(self) -> str: - """ - Get QQ Music audio quality setting. - - Returns: - Quality string (master/atmos/flac/320/128), default "320" - """ - return self.get(SettingKey.QQMUSIC_QUALITY, "320") - - def set_qqmusic_quality(self, quality: str): - """ - Set QQ Music audio quality. - - Args: - quality: Quality string (master/atmos/flac/320/128) - """ - self.set(SettingKey.QQMUSIC_QUALITY, quality) - # ===== Cache cleanup settings ===== def get_cache_cleanup_strategy(self) -> str: diff --git a/tests/test_services/test_qqmusic_lyrics_perf_paths.py b/tests/test_services/test_qqmusic_lyrics_perf_paths.py index 4764f7fa..d170716c 100644 --- a/tests/test_services/test_qqmusic_lyrics_perf_paths.py +++ b/tests/test_services/test_qqmusic_lyrics_perf_paths.py @@ -42,14 +42,14 @@ def test_credential_helpers_prefer_plugin_settings_namespace(): class _Config: def __init__(self): self.values = { - ("qqmusic", "credential"): {"musicid": "1", "musickey": "secret"}, + ("qqmusic", "credential"): '{"musicid":"1","musickey":"secret"}', } self.saved = [] - def get_plugin_setting(self, plugin_id, key, default=None): + def get_plugin_secret(self, plugin_id, key, default=""): return self.values.get((plugin_id, key), default) - def set_plugin_setting(self, plugin_id, key, value): + def set_plugin_secret(self, plugin_id, key, value): self.saved.append((plugin_id, key, value)) config = _Config() @@ -59,4 +59,4 @@ def set_plugin_setting(self, plugin_id, key, value): payload = {"musicid": "2", "musickey": "new"} qqmusic_lyrics._save_credential_to_config(config, payload) - assert config.saved == [("qqmusic", "credential", payload)] + assert config.saved == [("qqmusic", "credential", '{"musicid": "2", "musickey": "new"}')] diff --git a/tests/test_system/test_config_security.py b/tests/test_system/test_config_security.py index d347b4b3..8f734140 100644 --- a/tests/test_system/test_config_security.py +++ b/tests/test_system/test_config_security.py @@ -42,40 +42,6 @@ def test_acoustid_api_key_is_encrypted_at_rest(tmp_path): assert config.get_acoustid_api_key() == "acoustid-secret" -def test_qqmusic_credential_is_encrypted_at_rest(tmp_path): - """QQ Music secrets should be encrypted when persisted.""" - repo = _FakeSettingsRepository() - config = ConfigManager(repo, secret_store=SecretStore(tmp_path / "secret.key")) - credential = { - "musicid": "12345", - "musickey": "qq-secret", - "refresh_token": "refresh-secret", - "login_type": 2, - } - - config.set_qqmusic_credential(credential) - - assert repo.values[SettingKey.QQMUSIC_CREDENTIAL] != credential - assert repo.values[SettingKey.QQMUSIC_MUSICKEY] != "qq-secret" - assert config.get_qqmusic_credential()["musickey"] == "qq-secret" - assert config.get_qqmusic_credential()["refresh_token"] == "refresh-secret" - - -def test_qqmusic_credential_keeps_legacy_plaintext_compatible(tmp_path): - """Legacy plaintext settings should still be readable during migration.""" - repo = _FakeSettingsRepository() - repo.values[SettingKey.QQMUSIC_MUSICID] = "legacy-id" - repo.values[SettingKey.QQMUSIC_MUSICKEY] = "legacy-key" - repo.values[SettingKey.QQMUSIC_LOGIN_TYPE] = 2 - - config = ConfigManager(repo, secret_store=SecretStore(tmp_path / "secret.key")) - - credential = config.get_qqmusic_credential() - - assert credential["musicid"] == "legacy-id" - assert credential["musickey"] == "legacy-key" - - def test_plugin_settings_are_namespaced(tmp_path): repo = _FakeSettingsRepository() config = ConfigManager(repo, secret_store=SecretStore(tmp_path / "secret.key")) @@ -94,3 +60,16 @@ def test_plugin_secret_is_encrypted_at_rest(tmp_path): assert repo.values["plugins.qqmusic.credential"] != '{"musicid":"1","musickey":"secret"}' assert config.get_plugin_secret("qqmusic", "credential") == '{"musicid":"1","musickey":"secret"}' + + +def test_config_manager_no_longer_exposes_qqmusic_specific_helpers(tmp_path): + repo = _FakeSettingsRepository() + config = ConfigManager(repo, secret_store=SecretStore(tmp_path / "secret.key")) + + assert not hasattr(config, "get_qqmusic_credential") + assert not hasattr(config, "set_qqmusic_credential") + assert not hasattr(config, "clear_qqmusic_credential") + assert not hasattr(config, "get_qqmusic_nick") + assert not hasattr(config, "set_qqmusic_nick") + assert not hasattr(config, "get_qqmusic_quality") + assert not hasattr(config, "set_qqmusic_quality") diff --git a/ui/dialogs/qqmusic_qr_login_dialog.py b/ui/dialogs/qqmusic_qr_login_dialog.py index c945cade..5a1ed6f4 100644 --- a/ui/dialogs/qqmusic_qr_login_dialog.py +++ b/ui/dialogs/qqmusic_qr_login_dialog.py @@ -491,10 +491,6 @@ def _on_login_success(self, credential: dict): "credential", json.dumps(credential, ensure_ascii=False), ) - elif hasattr(self.config, "set_plugin_setting"): - self.config.set_plugin_setting("qqmusic", "credential", credential) - else: - self.config.set_qqmusic_credential(credential) # Get user nickname try: @@ -504,8 +500,6 @@ def _on_login_success(self, credential: dict): if user_info.get('valid') and user_info.get('nick'): if hasattr(self.config, "set_plugin_setting"): self.config.set_plugin_setting("qqmusic", "nick", user_info["nick"]) - else: - self.config.set_qqmusic_nick(user_info['nick']) logger.info(f"Got QQ Music nickname: {user_info['nick']}") except Exception as e: logger.warning(f"Failed to get QQ Music nickname: {e}") diff --git a/ui/views/online_music_view.py b/ui/views/online_music_view.py index ad2bf4b4..274fa0dd 100644 --- a/ui/views/online_music_view.py +++ b/ui/views/online_music_view.py @@ -1406,8 +1406,6 @@ def _update_login_status(self): # Get nickname from config if self._config and hasattr(self._config, "get_plugin_setting"): nick = self._config.get_plugin_setting("qqmusic", "nick", "") - elif self._config and hasattr(self._config, "get_qqmusic_nick"): - nick = self._config.get_qqmusic_nick() else: nick = "" @@ -1437,8 +1435,6 @@ def _on_login_clicked(self): if hasattr(self._config, "set_plugin_setting"): self._config.set_plugin_setting("qqmusic", "credential", None) self._config.set_plugin_setting("qqmusic", "nick", "") - elif hasattr(self._config, "clear_qqmusic_credential"): - self._config.clear_qqmusic_credential() self._update_login_status() MessageDialog.information(self, t("logout"), t("logout_success")) else: From 2416e719d2ea12e9927c0b3c8ca81037906b565d Mon Sep 17 00:00:00 2001 From: Har01d Date: Sun, 5 Apr 2026 21:23:01 +0800 Subject: [PATCH 032/157] =?UTF-8?q?=E8=AE=A9QQ=E6=8F=92=E4=BB=B6=E9=80=9A?= =?UTF-8?q?=E8=BF=87=E5=AF=BC=E5=85=A5=E5=AE=A1=E8=AE=A1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- plugins/builtin/qqmusic/lib/api.py | 95 +++++++++++++++++++ .../qqmusic/lib/artist_cover_source.py | 12 +-- plugins/builtin/qqmusic/lib/cover_source.py | 10 +- plugins/builtin/qqmusic/lib/lyrics_source.py | 24 +++-- .../test_lyrics_sources_perf_paths.py | 17 +++- tests/test_system/test_plugin_import_guard.py | 4 + 6 files changed, 137 insertions(+), 25 deletions(-) create mode 100644 plugins/builtin/qqmusic/lib/api.py diff --git a/plugins/builtin/qqmusic/lib/api.py b/plugins/builtin/qqmusic/lib/api.py new file mode 100644 index 00000000..443b3c9b --- /dev/null +++ b/plugins/builtin/qqmusic/lib/api.py @@ -0,0 +1,95 @@ +from __future__ import annotations + +from typing import Optional + + +class QQMusicPluginAPI: + REMOTE_BASE_URL = "https://api.ygking.top/api" + + def __init__(self, context): + self._context = context + + def search(self, keyword: str, limit: int = 5) -> list[dict]: + response = self._context.http.get( + f"{self.REMOTE_BASE_URL}/search", + params={"keyword": keyword, "type": "song", "num": limit, "page": 1}, + timeout=10, + ) + data = response.json() + songs = data.get("data", {}).get("list", []) + formatted = [] + for song in songs[:limit]: + singer_info = song.get("singer", "") + if isinstance(singer_info, list) and singer_info: + singer_name = singer_info[0].get("name", "") + singer_mid = singer_info[0].get("mid", "") + elif isinstance(singer_info, dict): + singer_name = singer_info.get("name", "") + singer_mid = singer_info.get("mid", "") + else: + singer_name = str(singer_info) if singer_info else "" + singer_mid = "" + + album_info = song.get("album", "") + if isinstance(album_info, dict): + album_name = album_info.get("name", "") + album_mid = album_info.get("mid", "") + else: + album_name = str(album_info) if album_info else "" + album_mid = song.get("album_mid", "") + + formatted.append( + { + "mid": song.get("mid", "") or song.get("songmid", ""), + "name": song.get("name", "") or song.get("songname", ""), + "title": song.get("name", "") or song.get("songname", ""), + "singer": singer_name, + "singer_mid": singer_mid, + "album": album_name, + "album_mid": album_mid, + "interval": song.get("interval", 0), + } + ) + return formatted + + def search_artist(self, keyword: str, limit: int = 5) -> list[dict]: + response = self._context.http.get( + f"{self.REMOTE_BASE_URL}/search", + params={"keyword": keyword, "type": "singer", "num": limit, "page": 1}, + timeout=10, + ) + data = response.json() + return data.get("data", {}).get("list", []) + + def get_lyrics(self, mid: str) -> Optional[str]: + response = self._context.http.get( + f"{self.REMOTE_BASE_URL}/lyric", + params={"mid": mid, "qrc": 1}, + timeout=10, + ) + data = response.json() + return data.get("data", {}).get("lyric") + + def get_cover_url( + self, + mid: str = None, + album_mid: str = None, + size: int = 500, + ) -> Optional[str]: + if album_mid: + return f"https://y.gtimg.cn/music/photo_new/T002R{size}x{size}M000{album_mid}.jpg" + if mid: + response = self._context.http.get( + f"{self.REMOTE_BASE_URL}/song/cover", + params={"mid": mid, "size": size}, + timeout=10, + ) + if response.status_code == 302: + return response.headers.get("Location") + data = response.json() + if data.get("code") == 0: + return data.get("data", {}).get("url") + return None + + def get_artist_cover_url(self, singer_mid: str, size: int = 300) -> Optional[str]: + return f"https://y.gtimg.cn/music/photo_new/T001R{size}x{size}M000{singer_mid}.jpg" diff --git a/plugins/builtin/qqmusic/lib/artist_cover_source.py b/plugins/builtin/qqmusic/lib/artist_cover_source.py index d3878a10..dfea93a4 100644 --- a/plugins/builtin/qqmusic/lib/artist_cover_source.py +++ b/plugins/builtin/qqmusic/lib/artist_cover_source.py @@ -4,6 +4,8 @@ from harmony_plugin_api.cover import PluginArtistCoverResult +from .api import QQMusicPluginAPI + class QQMusicArtistCoverPluginSource: source_id = "qqmusic-artist-cover" @@ -12,6 +14,7 @@ class QQMusicArtistCoverPluginSource: def __init__(self, context): self._context = context + self._api = QQMusicPluginAPI(context) def _convert_cover_url(self, url: str, size: int = 500) -> str: match = re.search(r"(T\d{3})R\d+x\d+M000([A-Za-z0-9]+)", url) @@ -24,14 +27,11 @@ def _convert_cover_url(self, url: str, size: int = 500) -> str: def search(self, artist_name: str, limit: int = 10) -> list[PluginArtistCoverResult]: try: - from services.lyrics.qqmusic_lyrics import QQMusicClient - - client = QQMusicClient() - artists = client.search_artist(artist_name, limit) + artists = self._api.search_artist(artist_name, limit) results = [] for artist in artists: - name = artist.get("singerName", "") - singer_mid = artist.get("singerMID", "") + name = artist.get("singerName", "") or artist.get("name", "") + singer_mid = artist.get("singerMID", "") or artist.get("mid", "") cover_url = artist.get("singerPic", "") album_count = artist.get("albumNum", 0) if name and singer_mid: diff --git a/plugins/builtin/qqmusic/lib/cover_source.py b/plugins/builtin/qqmusic/lib/cover_source.py index 4201933a..5918196e 100644 --- a/plugins/builtin/qqmusic/lib/cover_source.py +++ b/plugins/builtin/qqmusic/lib/cover_source.py @@ -2,6 +2,8 @@ from harmony_plugin_api.cover import PluginCoverResult +from .api import QQMusicPluginAPI + class QQMusicCoverPluginSource: source_id = "qqmusic-cover" @@ -10,6 +12,7 @@ class QQMusicCoverPluginSource: def __init__(self, context): self._context = context + self._api = QQMusicPluginAPI(context) def search( self, @@ -19,16 +22,15 @@ def search( duration: float | None = None, ) -> list[PluginCoverResult]: try: - from services.lyrics.qqmusic_lyrics import QQMusicClient - - client = QQMusicClient() keyword = f"{artist} {title}" if artist else title - songs = client.search(keyword, limit=5) + songs = self._api.search(keyword, limit=5) results = [] for song in songs: artist_name = "" if isinstance(song.get("singer"), list) and song["singer"]: artist_name = song["singer"][0].get("name", "") + elif isinstance(song.get("singer"), str): + artist_name = song.get("singer", "") album_name = "" album_mid = "" diff --git a/plugins/builtin/qqmusic/lib/lyrics_source.py b/plugins/builtin/qqmusic/lib/lyrics_source.py index 65c5cf55..f5d22962 100644 --- a/plugins/builtin/qqmusic/lib/lyrics_source.py +++ b/plugins/builtin/qqmusic/lib/lyrics_source.py @@ -2,6 +2,8 @@ from harmony_plugin_api.lyrics import PluginLyricsResult +from .api import QQMusicPluginAPI + class QQMusicLyricsPluginSource: source_id = "qqmusic" @@ -10,21 +12,25 @@ class QQMusicLyricsPluginSource: def __init__(self, context): self._context = context + self._api = QQMusicPluginAPI(context) def search(self, title: str, artist: str, limit: int = 10) -> list[PluginLyricsResult]: try: - from services.lyrics.qqmusic_lyrics import search_from_qqmusic - - search_results = search_from_qqmusic(title, artist, limit) + keyword = f"{title} {artist}" if artist else title + search_results = self._api.search(keyword, limit) return [ PluginLyricsResult( - song_id=item.get("id", ""), + song_id=item.get("mid", ""), title=item.get("title", ""), - artist=item.get("artist", ""), + artist=item.get("singer", ""), album=item.get("album", ""), - duration=item.get("duration"), + duration=item.get("interval"), source="qqmusic", - cover_url=item.get("cover_url"), + cover_url=self._api.get_cover_url( + mid=item.get("mid", ""), + album_mid=item.get("album_mid", ""), + size=500, + ), ) for item in search_results ] @@ -33,9 +39,7 @@ def search(self, title: str, artist: str, limit: int = 10) -> list[PluginLyricsR def get_lyrics(self, result: PluginLyricsResult) -> str | None: try: - from services.lyrics.qqmusic_lyrics import download_qqmusic_lyrics - - return download_qqmusic_lyrics(result.song_id) + return self._api.get_lyrics(result.song_id) except Exception: return None diff --git a/tests/test_services/test_lyrics_sources_perf_paths.py b/tests/test_services/test_lyrics_sources_perf_paths.py index 4a3755e9..b9a96a09 100644 --- a/tests/test_services/test_lyrics_sources_perf_paths.py +++ b/tests/test_services/test_lyrics_sources_perf_paths.py @@ -2,24 +2,31 @@ from types import SimpleNamespace +from plugins.builtin.qqmusic.lib.api import QQMusicPluginAPI from plugins.builtin.qqmusic.lib.lyrics_source import QQMusicLyricsPluginSource from services.sources.lyrics_sources import KugouLyricsSource def test_qqmusic_lyrics_source_search_builds_results(monkeypatch): monkeypatch.setattr( - "services.lyrics.qqmusic_lyrics.search_from_qqmusic", + QQMusicPluginAPI, + "search", lambda *_args, **_kwargs: [ { - "id": "song-1", + "mid": "song-1", "title": "Song 1", - "artist": "Singer 1", + "singer": "Singer 1", "album": "Album 1", - "duration": 180, - "cover_url": "cover-1", + "interval": 180, + "album_mid": "album-1", } ], ) + monkeypatch.setattr( + QQMusicPluginAPI, + "get_cover_url", + lambda *_args, **_kwargs: "cover-1", + ) source = QQMusicLyricsPluginSource(SimpleNamespace()) results = source.search("Song 1", "Singer 1") diff --git a/tests/test_system/test_plugin_import_guard.py b/tests/test_system/test_plugin_import_guard.py index 29d2d1fd..db67fb92 100644 --- a/tests/test_system/test_plugin_import_guard.py +++ b/tests/test_system/test_plugin_import_guard.py @@ -12,3 +12,7 @@ def test_plugin_import_audit_allows_sdk_only_imports(tmp_path: Path): ) audit_plugin_imports(plugin_root) + + +def test_builtin_qqmusic_plugin_passes_import_audit(): + audit_plugin_imports(Path("plugins/builtin/qqmusic")) From 24c7a1e3a96854cb0bb3ab07ab5c01a724169af4 Mon Sep 17 00:00:00 2001 From: Har01d Date: Sun, 5 Apr 2026 21:34:03 +0800 Subject: [PATCH 033/157] =?UTF-8?q?=E6=8A=BD=E7=A6=BB=E9=80=9A=E7=94=A8?= =?UTF-8?q?=E9=9F=B3=E8=B4=A8=E5=B7=A5=E5=85=B7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- services/online/download_service.py | 2 +- services/online/online_music_service.py | 2 +- services/online/quality.py | 141 ++++++++++++++++++++++ tests/test_services/test_quality_utils.py | 6 + ui/dialogs/redownload_dialog.py | 2 +- ui/views/library_view.py | 2 +- ui/windows/main_window.py | 2 +- 7 files changed, 152 insertions(+), 5 deletions(-) create mode 100644 services/online/quality.py create mode 100644 tests/test_services/test_quality_utils.py diff --git a/services/online/download_service.py b/services/online/download_service.py index f4f1d4a5..56cb2a8a 100644 --- a/services/online/download_service.py +++ b/services/online/download_service.py @@ -8,9 +8,9 @@ from typing import Dict, Optional, Callable, Any, TYPE_CHECKING from infrastructure.network import HttpClient -from services.cloud.qqmusic.common import parse_quality, normalize_quality from system.event_bus import EventBus from services.metadata.metadata_service import MetadataService +from .quality import normalize_quality, parse_quality if TYPE_CHECKING: from system.config import ConfigManager diff --git a/services/online/online_music_service.py b/services/online/online_music_service.py index 2e26b357..98eaad0a 100644 --- a/services/online/online_music_service.py +++ b/services/online/online_music_service.py @@ -10,8 +10,8 @@ OnlineTrack, SearchResult, SearchType ) from infrastructure.network import HttpClient -from services.cloud.qqmusic.common import parse_quality from .adapter import OnlineMusicAdapter, ApiSource +from .quality import parse_quality if TYPE_CHECKING: from system.config import ConfigManager diff --git a/services/online/quality.py b/services/online/quality.py new file mode 100644 index 00000000..4aa7499f --- /dev/null +++ b/services/online/quality.py @@ -0,0 +1,141 @@ +"""Shared online-audio quality utilities used by host code and plugins.""" + +from __future__ import annotations + +from typing import Dict + + +class SongFileType: + MASTER = {"s": "AI00", "e": ".flac"} + ATMOS_2 = {"s": "Q000", "e": ".flac"} + ATMOS_51 = {"s": "Q001", "e": ".flac"} + DOLBY = {"s": "RS01", "e": ".flac"} + HIRES = {"s": "SQ00", "e": ".flac"} + FLAC = {"s": "F000", "e": ".flac"} + APE = {"s": "A000", "e": ".ape"} + DTS = {"s": "D000", "e": ".dts"} + MP3_320 = {"s": "M800", "e": ".mp3"} + MP3_128 = {"s": "M500", "e": ".mp3"} + OGG_640 = {"s": "O801", "e": ".ogg"} + OGG_320 = {"s": "O800", "e": ".ogg"} + OGG_192 = {"s": "O600", "e": ".ogg"} + OGG_96 = {"s": "O400", "e": ".ogg"} + AAC_320 = {"s": "C800", "e": ".m4a"} + AAC_256 = {"s": "C700", "e": ".m4a"} + AAC_192 = {"s": "C600", "e": ".m4a"} + AAC_128 = {"s": "C500", "e": ".m4a"} + AAC_96 = {"s": "C400", "e": ".m4a"} + AAC_64 = {"s": "C300", "e": ".m4a"} + AAC_48 = {"s": "C200", "e": ".m4a"} + AAC_24 = {"s": "C100", "e": ".m4a"} + + +QUALITY_FALLBACK = [ + "master", + "atmos_2", + "atmos_51", + "dolby", + "hires", + "flac", + "ape", + "dts", + "ogg_640", + "320", + "ogg_320", + "aac_320", + "aac_256", + "aac_192", + "ogg_192", + "128", + "aac_128", + "aac_96", + "ogg_96", + "aac_64", + "aac_48", + "aac_24", +] + +_QUALITY_ALIASES = { + "atmos": "atmos_2", + "192": "ogg_192", + "96": "ogg_96", + "标准": "128", + "hq高品质": "320", + "sq无损品质": "flac", + "臻品母带3.0": "master", + "臻品全景声2.0": "atmos_2", + "臻品音质2.0": "atmos_51", + "ogg高品质": "ogg_320", + "ogg标准": "ogg_192", + "aac高品质": "aac_192", + "aac标准": "aac_96", +} + +_QUALITY_FILE_MAP = { + "master": SongFileType.MASTER, + "atmos_2": SongFileType.ATMOS_2, + "atmos_51": SongFileType.ATMOS_51, + "dolby": SongFileType.DOLBY, + "hires": SongFileType.HIRES, + "flac": SongFileType.FLAC, + "ape": SongFileType.APE, + "dts": SongFileType.DTS, + "320": SongFileType.MP3_320, + "128": SongFileType.MP3_128, + "ogg_640": SongFileType.OGG_640, + "ogg_320": SongFileType.OGG_320, + "ogg_192": SongFileType.OGG_192, + "ogg_96": SongFileType.OGG_96, + "aac_320": SongFileType.AAC_320, + "aac_256": SongFileType.AAC_256, + "aac_192": SongFileType.AAC_192, + "aac_128": SongFileType.AAC_128, + "aac_96": SongFileType.AAC_96, + "aac_64": SongFileType.AAC_64, + "aac_48": SongFileType.AAC_48, + "aac_24": SongFileType.AAC_24, +} + +_QUALITY_LABEL_KEYS = { + "master": "qqmusic_quality_master", + "atmos_2": "qqmusic_quality_atmos_2", + "atmos_51": "qqmusic_quality_atmos_51", + "dolby": "qqmusic_quality_dolby", + "hires": "qqmusic_quality_hires", + "flac": "qqmusic_quality_flac", + "ape": "qqmusic_quality_ape", + "dts": "qqmusic_quality_dts", + "ogg_640": "qqmusic_quality_ogg_640", + "320": "qqmusic_quality_320", + "ogg_320": "qqmusic_quality_ogg_320", + "aac_320": "qqmusic_quality_aac_320", + "aac_256": "qqmusic_quality_aac_256", + "aac_192": "qqmusic_quality_aac_192", + "ogg_192": "qqmusic_quality_ogg_192", + "128": "qqmusic_quality_128", + "aac_128": "qqmusic_quality_aac_128", + "aac_96": "qqmusic_quality_aac_96", + "ogg_96": "qqmusic_quality_ogg_96", + "aac_64": "qqmusic_quality_aac_64", + "aac_48": "qqmusic_quality_aac_48", + "aac_24": "qqmusic_quality_aac_24", +} + + +def normalize_quality(quality: str) -> str: + value = str(quality or "").strip().lower() + return _QUALITY_ALIASES.get(value, value) + + +def parse_quality(quality: str) -> Dict[str, str]: + normalized = normalize_quality(quality) + return _QUALITY_FILE_MAP.get(normalized, SongFileType.MP3_128) + + +def get_selectable_qualities() -> list[str]: + return list(QUALITY_FALLBACK) + + +def get_quality_label_key(quality: str) -> str: + normalized = normalize_quality(quality) + return _QUALITY_LABEL_KEYS.get(normalized, "") diff --git a/tests/test_services/test_quality_utils.py b/tests/test_services/test_quality_utils.py new file mode 100644 index 00000000..97bebab1 --- /dev/null +++ b/tests/test_services/test_quality_utils.py @@ -0,0 +1,6 @@ +from services.online.quality import get_quality_label_key, parse_quality + + +def test_parse_quality_and_label_lookup_work_in_shared_module(): + assert parse_quality("flac") == {"s": "F000", "e": ".flac"} + assert get_quality_label_key("ogg_320") == "qqmusic_quality_ogg_320" diff --git a/ui/dialogs/redownload_dialog.py b/ui/dialogs/redownload_dialog.py index 2c69d4ea..bb4a63ed 100644 --- a/ui/dialogs/redownload_dialog.py +++ b/ui/dialogs/redownload_dialog.py @@ -12,7 +12,7 @@ from system.i18n import t from system.theme import ThemeManager from ui.dialogs.dialog_title_bar import setup_equalizer_title_layout -from services.cloud.qqmusic.common import ( +from services.online.quality import ( get_selectable_qualities, get_quality_label_key, normalize_quality, diff --git a/ui/views/library_view.py b/ui/views/library_view.py index 6cf5c2ab..97506256 100644 --- a/ui/views/library_view.py +++ b/ui/views/library_view.py @@ -20,7 +20,7 @@ from domain.playback import PlaybackState from domain.track import Track -from services.cloud.qqmusic.common import get_quality_label_key, normalize_quality +from services.online.quality import get_quality_label_key, normalize_quality from services.download import DownloadManager from services.metadata import CoverService from services.playback import PlaybackService diff --git a/ui/windows/main_window.py b/ui/windows/main_window.py index ea7c63d7..3310cc23 100644 --- a/ui/windows/main_window.py +++ b/ui/windows/main_window.py @@ -1325,7 +1325,7 @@ def _on_playlist_redownload_failed(self, song_mid: str): @staticmethod def _format_quality_label(quality: str) -> str: """Return the translated label for a QQ Music quality code.""" - from services.cloud.qqmusic.common import get_quality_label_key, normalize_quality + from services.online.quality import get_quality_label_key, normalize_quality normalized = normalize_quality(quality) label_key = get_quality_label_key(normalized) From e7ba241034ee8a3ea9e2853498afe3a01841c629 Mon Sep 17 00:00:00 2001 From: Har01d Date: Sun, 5 Apr 2026 21:42:34 +0800 Subject: [PATCH 034/157] =?UTF-8?q?=E4=BF=9D=E7=95=99=E6=8F=92=E4=BB=B6?= =?UTF-8?q?=E5=AE=89=E8=A3=85=E5=99=A8=E5=90=8E=E7=BC=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- system/plugins/installer.py | 7 +++++ tests/test_system/test_plugin_installer.py | 36 ++++++++++++++++++++++ 2 files changed, 43 insertions(+) diff --git a/system/plugins/installer.py b/system/plugins/installer.py index 497f8059..15320065 100644 --- a/system/plugins/installer.py +++ b/system/plugins/installer.py @@ -45,6 +45,12 @@ def __init__(self, external_root: Path, temp_root: Path) -> None: self._external_root = external_root self._temp_root = temp_root + def _validate_plugin_id(self, plugin_id: str) -> None: + if plugin_id.endswith(".staging") or plugin_id.endswith(".backup"): + raise PluginInstallError( + f"Plugin id uses reserved suffix: {plugin_id}" + ) + def _load_manifest(self, plugin_root: Path) -> PluginManifest: manifest_path = plugin_root / "plugin.json" raw = manifest_path.read_text(encoding="utf-8") @@ -82,6 +88,7 @@ def install_zip(self, zip_path: Path) -> Path: audit_plugin_imports(extract_root) manifest = self._load_manifest(extract_root) + self._validate_plugin_id(manifest.id) self._validate_entrypoint_structure(extract_root, manifest) self._external_root.mkdir(parents=True, exist_ok=True) diff --git a/tests/test_system/test_plugin_installer.py b/tests/test_system/test_plugin_installer.py index 9a5359e4..7b1bf3be 100644 --- a/tests/test_system/test_plugin_installer.py +++ b/tests/test_system/test_plugin_installer.py @@ -262,3 +262,39 @@ def _failing_copytree(src, dst, *args, **kwargs): assert existing_root.exists() assert (existing_root / "version.txt").read_text(encoding="utf-8") == "old" + + +def test_install_zip_rejects_reserved_suffix_plugin_id(tmp_path: Path): + installer = PluginInstaller( + external_root=tmp_path / "external", + temp_root=tmp_path / "temp", + ) + plugin_zip = _build_plugin_zip( + tmp_path, + "bad_suffix.zip", + { + "plugin.json": json.dumps( + { + "id": "qqmusic.backup", + "name": "Bad Suffix", + "version": "1.0.0", + "api_version": "1", + "entrypoint": "plugin_main.py", + "entry_class": "BadSuffixPlugin", + "capabilities": ["sidebar"], + "min_app_version": "0.1.0", + } + ), + "plugin_main.py": ( + "class BadSuffixPlugin:\n" + " plugin_id = 'qqmusic.backup'\n" + " def register(self, context):\n" + " pass\n" + " def unregister(self, context):\n" + " pass\n" + ), + }, + ) + + with pytest.raises(PluginInstallError): + installer.install_zip(plugin_zip) From 26eb5e109a887fdc8555962e6a60094bd6bba255 Mon Sep 17 00:00:00 2001 From: Har01d Date: Mon, 6 Apr 2026 07:07:31 +0800 Subject: [PATCH 035/157] =?UTF-8?q?=E9=80=9A=E8=BF=87=E6=8F=92=E4=BB=B6?= =?UTF-8?q?=E6=B3=A8=E5=86=8C=E8=A1=A8=E8=A7=A3=E6=9E=90QQ=E5=B0=81?= =?UTF-8?q?=E9=9D=A2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../qqmusic/lib/artist_cover_source.py | 4 ++ plugins/builtin/qqmusic/lib/cover_source.py | 9 +++++ services/metadata/cover_service.py | 2 +- system/plugins/qqmusic_cover_helpers.py | 32 ++++++++++++++++ .../test_singleflight_media_fetch.py | 2 +- .../test_system/test_plugin_cover_helpers.py | 38 +++++++++++++++++++ ui/dialogs/base_cover_download_dialog.py | 4 +- ui/strategies/album_search_strategy.py | 2 +- ui/strategies/artist_search_strategy.py | 2 +- ui/strategies/genre_search_strategy.py | 2 +- ui/strategies/track_search_strategy.py | 2 +- ui/workers/batch_cover_worker.py | 2 +- ui/workers/cover_workers.py | 4 +- 13 files changed, 94 insertions(+), 11 deletions(-) create mode 100644 system/plugins/qqmusic_cover_helpers.py create mode 100644 tests/test_system/test_plugin_cover_helpers.py diff --git a/plugins/builtin/qqmusic/lib/artist_cover_source.py b/plugins/builtin/qqmusic/lib/artist_cover_source.py index dfea93a4..cb32d9d3 100644 --- a/plugins/builtin/qqmusic/lib/artist_cover_source.py +++ b/plugins/builtin/qqmusic/lib/artist_cover_source.py @@ -8,6 +8,7 @@ class QQMusicArtistCoverPluginSource: + source = "qqmusic" source_id = "qqmusic-artist-cover" display_name = "QQMusic Artist" name = "QQMusic" @@ -50,3 +51,6 @@ def search(self, artist_name: str, limit: int = 10) -> list[PluginArtistCoverRes def is_available(self) -> bool: return True + + def get_artist_cover_url(self, singer_mid: str, size: int = 500): + return self._api.get_artist_cover_url(singer_mid, size=size) diff --git a/plugins/builtin/qqmusic/lib/cover_source.py b/plugins/builtin/qqmusic/lib/cover_source.py index 5918196e..86a63718 100644 --- a/plugins/builtin/qqmusic/lib/cover_source.py +++ b/plugins/builtin/qqmusic/lib/cover_source.py @@ -6,6 +6,7 @@ class QQMusicCoverPluginSource: + source = "qqmusic" source_id = "qqmusic-cover" display_name = "QQMusic" name = "QQMusic" @@ -57,3 +58,11 @@ def search( def is_available(self) -> bool: return True + + def get_cover_url( + self, + mid: str = None, + album_mid: str = None, + size: int = 500, + ): + return self._api.get_cover_url(mid=mid, album_mid=album_mid, size=size) diff --git a/services/metadata/cover_service.py b/services/metadata/cover_service.py index be5cda82..3e6eea11 100644 --- a/services/metadata/cover_service.py +++ b/services/metadata/cover_service.py @@ -290,7 +290,7 @@ def get_online_cover(self, song_mid: str, album_mid: str = None, def _fetch_online_cover_by_mid(self, song_mid: str, album_mid: str | None) -> Optional[str]: """Fetch QQ Music cover bytes by song/album mid and cache the result.""" - from services.lyrics.qqmusic_lyrics import get_qqmusic_cover_url + from system.plugins.qqmusic_cover_helpers import get_qqmusic_cover_url cover_url = get_qqmusic_cover_url(mid=song_mid, album_mid=album_mid, size=500) if not cover_url: diff --git a/system/plugins/qqmusic_cover_helpers.py b/system/plugins/qqmusic_cover_helpers.py new file mode 100644 index 00000000..ee71a6bd --- /dev/null +++ b/system/plugins/qqmusic_cover_helpers.py @@ -0,0 +1,32 @@ +from __future__ import annotations + + +def _iter_sources(kind: str): + from app.bootstrap import Bootstrap + + registry = Bootstrap.instance().plugin_manager.registry + if kind == "artist": + return registry.artist_cover_sources() + return registry.cover_sources() + + +def _matches_qqmusic(source) -> bool: + return ( + getattr(source, "source", None) == "qqmusic" + or getattr(source, "name", "").lower() == "qqmusic" + or getattr(source, "display_name", "").lower() == "qqmusic" + ) + + +def get_qqmusic_cover_url(mid: str = None, album_mid: str = None, size: int = 500): + for source in _iter_sources("cover"): + if _matches_qqmusic(source) and hasattr(source, "get_cover_url"): + return source.get_cover_url(mid=mid, album_mid=album_mid, size=size) + return None + + +def get_qqmusic_artist_cover_url(singer_mid: str, size: int = 300): + for source in _iter_sources("artist"): + if _matches_qqmusic(source) and hasattr(source, "get_artist_cover_url"): + return source.get_artist_cover_url(singer_mid, size=size) + return None diff --git a/tests/test_services/test_singleflight_media_fetch.py b/tests/test_services/test_singleflight_media_fetch.py index 64d0bf87..63bf141f 100644 --- a/tests/test_services/test_singleflight_media_fetch.py +++ b/tests/test_services/test_singleflight_media_fetch.py @@ -70,7 +70,7 @@ def worker(): ) ) - with patch("services.lyrics.qqmusic_lyrics.get_qqmusic_cover_url", return_value="https://example.com/cover.jpg"), \ + with patch("system.plugins.qqmusic_cover_helpers.get_qqmusic_cover_url", return_value="https://example.com/cover.jpg"), \ patch.object(service, "_get_cached_cover", return_value=None), \ patch.object(service, "_save_cover_to_cache", return_value="/tmp/cover.jpg"): threads = [threading.Thread(target=worker) for _ in range(2)] diff --git a/tests/test_system/test_plugin_cover_helpers.py b/tests/test_system/test_plugin_cover_helpers.py new file mode 100644 index 00000000..2fc76209 --- /dev/null +++ b/tests/test_system/test_plugin_cover_helpers.py @@ -0,0 +1,38 @@ +from types import SimpleNamespace + +from system.plugins.qqmusic_cover_helpers import ( + get_qqmusic_artist_cover_url, + get_qqmusic_cover_url, +) + + +def test_get_qqmusic_cover_url_uses_registered_plugin_source(monkeypatch): + source = SimpleNamespace( + source="qqmusic", + get_cover_url=lambda **kwargs: f"cover:{kwargs.get('album_mid') or kwargs.get('mid')}", + ) + fake_manager = SimpleNamespace( + registry=SimpleNamespace(cover_sources=lambda: [source]) + ) + monkeypatch.setattr( + "app.bootstrap.Bootstrap.instance", + lambda: SimpleNamespace(plugin_manager=fake_manager), + ) + + assert get_qqmusic_cover_url(album_mid="album123", size=500) == "cover:album123" + + +def test_get_qqmusic_artist_cover_url_uses_registered_plugin_source(monkeypatch): + source = SimpleNamespace( + source="qqmusic", + get_artist_cover_url=lambda singer_mid, size=500: f"artist:{singer_mid}:{size}", + ) + fake_manager = SimpleNamespace( + registry=SimpleNamespace(artist_cover_sources=lambda: [source]) + ) + monkeypatch.setattr( + "app.bootstrap.Bootstrap.instance", + lambda: SimpleNamespace(plugin_manager=fake_manager), + ) + + assert get_qqmusic_artist_cover_url("singer123", size=500) == "artist:singer123:500" diff --git a/ui/dialogs/base_cover_download_dialog.py b/ui/dialogs/base_cover_download_dialog.py index e8c94cb0..2207d999 100644 --- a/ui/dialogs/base_cover_download_dialog.py +++ b/ui/dialogs/base_cover_download_dialog.py @@ -70,7 +70,7 @@ def __init__(self, album_mid: str = None, song_mid: str = None, score: float = 0 def run(self): """Fetch QQ Music cover URL and download.""" try: - from services.lyrics.qqmusic_lyrics import get_qqmusic_cover_url + from system.plugins.qqmusic_cover_helpers import get_qqmusic_cover_url from infrastructure.network import HttpClient logger.info(f"QQMusicCoverFetchThread: album_mid={self.album_mid}, song_mid={self.song_mid}") @@ -128,7 +128,7 @@ def __init__(self, singer_mid: str, score: float = 0): def run(self): """Fetch QQ Music artist cover URL and download.""" try: - from services.lyrics.qqmusic_lyrics import get_qqmusic_artist_cover_url + from system.plugins.qqmusic_cover_helpers import get_qqmusic_artist_cover_url from infrastructure.network import HttpClient logger.info(f"QQMusicArtistCoverFetchThread: singer_mid={self.singer_mid}") diff --git a/ui/strategies/album_search_strategy.py b/ui/strategies/album_search_strategy.py index 40f78b84..d0e1bb26 100644 --- a/ui/strategies/album_search_strategy.py +++ b/ui/strategies/album_search_strategy.py @@ -5,8 +5,8 @@ from typing import List, Optional from infrastructure.network import HttpClient -from services.lyrics.qqmusic_lyrics import get_qqmusic_cover_url from services.metadata import CoverService +from system.plugins.qqmusic_cover_helpers import get_qqmusic_cover_url from ui.strategies.cover_search_strategy import CoverSearchStrategy logger = logging.getLogger(__name__) diff --git a/ui/strategies/artist_search_strategy.py b/ui/strategies/artist_search_strategy.py index 903fd23d..df507b4a 100644 --- a/ui/strategies/artist_search_strategy.py +++ b/ui/strategies/artist_search_strategy.py @@ -5,8 +5,8 @@ from typing import List, Optional from infrastructure.network import HttpClient -from services.lyrics.qqmusic_lyrics import get_qqmusic_artist_cover_url from services.metadata import CoverService +from system.plugins.qqmusic_cover_helpers import get_qqmusic_artist_cover_url from ui.strategies.cover_search_strategy import CoverSearchStrategy logger = logging.getLogger(__name__) diff --git a/ui/strategies/genre_search_strategy.py b/ui/strategies/genre_search_strategy.py index 89164700..638147e1 100644 --- a/ui/strategies/genre_search_strategy.py +++ b/ui/strategies/genre_search_strategy.py @@ -67,7 +67,7 @@ def needs_lazy_fetch(self, result: dict) -> bool: def lazy_fetch(self, cover_service: CoverService, result: dict) -> bytes: # Reuse generic QQ lazy fetch path from existing behavior by importing helper here. from infrastructure.network import HttpClient - from services.lyrics.qqmusic_lyrics import get_qqmusic_cover_url + from system.plugins.qqmusic_cover_helpers import get_qqmusic_cover_url album_mid = result.get("album_mid") song_mid = result.get("id") diff --git a/ui/strategies/track_search_strategy.py b/ui/strategies/track_search_strategy.py index 4c0a8fc7..52df7215 100644 --- a/ui/strategies/track_search_strategy.py +++ b/ui/strategies/track_search_strategy.py @@ -5,8 +5,8 @@ from typing import List, Optional from infrastructure.network import HttpClient -from services.lyrics.qqmusic_lyrics import get_qqmusic_cover_url from services.metadata import CoverService +from system.plugins.qqmusic_cover_helpers import get_qqmusic_cover_url from ui.strategies.cover_search_strategy import CoverSearchStrategy logger = logging.getLogger(__name__) diff --git a/ui/workers/batch_cover_worker.py b/ui/workers/batch_cover_worker.py index 9d98aba5..591d1a27 100644 --- a/ui/workers/batch_cover_worker.py +++ b/ui/workers/batch_cover_worker.py @@ -75,7 +75,7 @@ def _fetch_artist_cover(self, artist_name: str): # QQ Music: cover_url may be empty, construct from singer_mid if not cover_url and source == 'qqmusic' and singer_mid: - from services.lyrics.qqmusic_lyrics import get_qqmusic_artist_cover_url + from system.plugins.qqmusic_cover_helpers import get_qqmusic_artist_cover_url cover_url = get_qqmusic_artist_cover_url(singer_mid, size=500) if not cover_url: diff --git a/ui/workers/cover_workers.py b/ui/workers/cover_workers.py index cf448219..a97b36f6 100644 --- a/ui/workers/cover_workers.py +++ b/ui/workers/cover_workers.py @@ -124,7 +124,7 @@ def __init__( def run(self): try: - from services.lyrics.qqmusic_lyrics import get_qqmusic_cover_url + from system.plugins.qqmusic_cover_helpers import get_qqmusic_cover_url from infrastructure.network import HttpClient if self._is_cancelled(): @@ -173,7 +173,7 @@ def __init__(self, singer_mid: str, score: float = 0, parent=None): def run(self): try: - from services.lyrics.qqmusic_lyrics import get_qqmusic_artist_cover_url + from system.plugins.qqmusic_cover_helpers import get_qqmusic_artist_cover_url from infrastructure.network import HttpClient if self._is_cancelled(): From 883b07d7437805198424933274703b90bee78d9a Mon Sep 17 00:00:00 2001 From: Har01d Date: Mon, 6 Apr 2026 07:11:06 +0800 Subject: [PATCH 036/157] =?UTF-8?q?=E5=A2=9E=E5=8A=A0=E5=86=85=E7=BD=AE?= =?UTF-8?q?=E6=8F=92=E4=BB=B6=E5=8A=A0=E8=BD=BD=E5=86=92=E7=83=9F=E6=B5=8B?= =?UTF-8?q?=E8=AF=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tests/test_system/test_plugin_manager.py | 71 ++++++++++++++++++++++++ 1 file changed, 71 insertions(+) diff --git a/tests/test_system/test_plugin_manager.py b/tests/test_system/test_plugin_manager.py index 999c5abf..edfec58a 100644 --- a/tests/test_system/test_plugin_manager.py +++ b/tests/test_system/test_plugin_manager.py @@ -1,5 +1,6 @@ import json from pathlib import Path +from types import SimpleNamespace import zipfile from system.plugins.installer import PluginInstaller @@ -755,3 +756,73 @@ def test_manager_ignores_external_installer_scratch_directories(tmp_path: Path): assert [item.plugin_id for item in manager.registry.sidebar_entries()] == [ "real-plugin" ] + + +def test_manager_loads_real_builtin_plugins_from_repository(tmp_path: Path): + class _UiBridge: + def __init__(self): + self.sidebar_entries = [] + self.settings_tabs = [] + + def register_sidebar_entry(self, spec): + self.sidebar_entries.append(spec) + + def register_settings_tab(self, spec): + self.settings_tabs.append(spec) + + class _ServiceBridge: + def __init__(self): + self.lyrics_sources = [] + self.cover_sources = [] + self.artist_cover_sources = [] + self.online_providers = [] + self.media = object() + + def register_lyrics_source(self, source): + self.lyrics_sources.append(source) + + def register_cover_source(self, source): + self.cover_sources.append(source) + + def register_artist_cover_source(self, source): + self.artist_cover_sources.append(source) + + def register_online_music_provider(self, provider): + self.online_providers.append(provider) + + class _BuiltinContextFactory: + def __init__(self): + self.ui = _UiBridge() + self.services = _ServiceBridge() + + def build(self, manifest): + return SimpleNamespace( + plugin_id=manifest.id, + manifest=manifest, + logger=object(), + http=SimpleNamespace(get=lambda *_args, **_kwargs: None), + events=object(), + settings=SimpleNamespace( + get=lambda *_args, **_kwargs: None, + set=lambda *_args, **_kwargs: None, + ), + storage=SimpleNamespace(), + ui=self.ui, + services=self.services, + ) + + root = Path(__file__).resolve().parents[2] + store = PluginStateStore(tmp_path / "state.json") + context_factory = _BuiltinContextFactory() + manager = PluginManager( + builtin_root=root / "plugins" / "builtin", + external_root=tmp_path / "external", + state_store=store, + context_factory=context_factory, + ) + + manager.load_enabled_plugins() + + loaded_ids = set(manager._loaded_plugins) + assert "lrclib" in loaded_ids + assert "qqmusic" in loaded_ids From 9e9e82608e0538d309cfdb3d8f7ed23a1f157f81 Mon Sep 17 00:00:00 2001 From: Har01d Date: Mon, 6 Apr 2026 07:14:16 +0800 Subject: [PATCH 037/157] =?UTF-8?q?=E9=AA=8C=E8=AF=81QQ=E6=8F=92=E4=BB=B6?= =?UTF-8?q?=E6=89=93=E5=8C=85=E5=AE=89=E8=A3=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tests/test_system/test_plugin_packaging.py | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/tests/test_system/test_plugin_packaging.py b/tests/test_system/test_plugin_packaging.py index dd79ea5a..43646877 100644 --- a/tests/test_system/test_plugin_packaging.py +++ b/tests/test_system/test_plugin_packaging.py @@ -1,6 +1,7 @@ import zipfile from pathlib import Path +from system.plugins.installer import PluginInstaller from scripts.build_plugin_zip import build_plugin_zip @@ -15,3 +16,19 @@ def test_build_plugin_zip_contains_manifest_and_entrypoint(tmp_path: Path): assert "plugin.json" in names assert "plugin_main.py" in names + + +def test_built_qqmusic_zip_is_installable(tmp_path: Path): + plugin_root = Path("plugins/builtin/qqmusic") + output_zip = tmp_path / "qqmusic.zip" + build_plugin_zip(plugin_root, output_zip) + + installer = PluginInstaller( + external_root=tmp_path / "external", + temp_root=tmp_path / "temp", + ) + installed_root = installer.install_zip(output_zip) + + assert installed_root.name == "qqmusic" + assert (installed_root / "plugin.json").exists() + assert (installed_root / "plugin_main.py").exists() From 915b8e7a66141a0da1b251579917cce3623d2939 Mon Sep 17 00:00:00 2001 From: Har01d Date: Mon, 6 Apr 2026 07:22:40 +0800 Subject: [PATCH 038/157] =?UTF-8?q?=E5=AE=8C=E5=96=84QQ=E6=8F=92=E4=BB=B6?= =?UTF-8?q?=E8=AE=BE=E7=BD=AE=E9=A1=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- plugins/builtin/qqmusic/lib/settings_tab.py | 20 ++++++++++++++++++-- tests/test_plugins/test_qqmusic_plugin.py | 17 +++++++++++++++++ 2 files changed, 35 insertions(+), 2 deletions(-) diff --git a/plugins/builtin/qqmusic/lib/settings_tab.py b/plugins/builtin/qqmusic/lib/settings_tab.py index 8d4bc0d4..50788e34 100644 --- a/plugins/builtin/qqmusic/lib/settings_tab.py +++ b/plugins/builtin/qqmusic/lib/settings_tab.py @@ -1,11 +1,27 @@ from __future__ import annotations -from PySide6.QtWidgets import QLabel, QVBoxLayout, QWidget +from PySide6.QtWidgets import QComboBox, QLabel, QPushButton, QVBoxLayout, QWidget class QQMusicSettingsTab(QWidget): def __init__(self, context, parent=None): super().__init__(parent) + self._context = context layout = QVBoxLayout(self) layout.addWidget(QLabel("QQ Music Settings", self)) - self._context = context + self._quality_combo = QComboBox(self) + for quality in ("320", "flac", "master"): + self._quality_combo.addItem(quality, quality) + current_quality = str(self._context.settings.get("quality", "320")) + for index in range(self._quality_combo.count()): + if self._quality_combo.itemData(index) == current_quality: + self._quality_combo.setCurrentIndex(index) + break + layout.addWidget(self._quality_combo) + + save_btn = QPushButton("Save", self) + save_btn.clicked.connect(self._save) + layout.addWidget(save_btn) + + def _save(self): + self._context.settings.set("quality", self._quality_combo.currentData()) diff --git a/tests/test_plugins/test_qqmusic_plugin.py b/tests/test_plugins/test_qqmusic_plugin.py index c04955cf..40799fe2 100644 --- a/tests/test_plugins/test_qqmusic_plugin.py +++ b/tests/test_plugins/test_qqmusic_plugin.py @@ -1,6 +1,7 @@ from unittest.mock import Mock from plugins.builtin.qqmusic.plugin_main import QQMusicPlugin +from plugins.builtin.qqmusic.lib.settings_tab import QQMusicSettingsTab def test_qqmusic_plugin_registers_expected_capabilities(): @@ -15,3 +16,19 @@ def test_qqmusic_plugin_registers_expected_capabilities(): assert context.services.register_cover_source.call_count == 1 assert context.services.register_artist_cover_source.call_count == 1 assert context.services.register_online_music_provider.call_count == 1 + + +def test_qqmusic_settings_tab_reads_and_saves_quality(qtbot): + settings = Mock() + settings.get.return_value = "flac" + context = Mock(settings=settings) + + tab = QQMusicSettingsTab(context) + qtbot.addWidget(tab) + + assert tab._quality_combo.currentData() == "flac" + + tab._quality_combo.setCurrentIndex(0) + tab._save() + + settings.set.assert_called_once_with("quality", tab._quality_combo.currentData()) From e4571f06ae9d597f5e8d4296b54410a5b240ff19 Mon Sep 17 00:00:00 2001 From: Har01d Date: Mon, 6 Apr 2026 07:31:52 +0800 Subject: [PATCH 039/157] =?UTF-8?q?=E5=A2=9E=E5=8A=A0QQ=E6=8F=92=E4=BB=B6?= =?UTF-8?q?=E7=99=BB=E5=BD=95=E5=85=A5=E5=8F=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- plugins/builtin/qqmusic/lib/settings_tab.py | 10 ++++++++++ tests/test_plugins/test_qqmusic_plugin.py | 21 +++++++++++++++++++++ 2 files changed, 31 insertions(+) diff --git a/plugins/builtin/qqmusic/lib/settings_tab.py b/plugins/builtin/qqmusic/lib/settings_tab.py index 50788e34..fa6430cd 100644 --- a/plugins/builtin/qqmusic/lib/settings_tab.py +++ b/plugins/builtin/qqmusic/lib/settings_tab.py @@ -2,6 +2,8 @@ from PySide6.QtWidgets import QComboBox, QLabel, QPushButton, QVBoxLayout, QWidget +from .login_dialog import QQMusicLoginDialog + class QQMusicSettingsTab(QWidget): def __init__(self, context, parent=None): @@ -19,9 +21,17 @@ def __init__(self, context, parent=None): break layout.addWidget(self._quality_combo) + login_btn = QPushButton("Login", self) + login_btn.clicked.connect(self._open_login_dialog) + layout.addWidget(login_btn) + save_btn = QPushButton("Save", self) save_btn.clicked.connect(self._save) layout.addWidget(save_btn) def _save(self): self._context.settings.set("quality", self._quality_combo.currentData()) + + def _open_login_dialog(self): + dialog = QQMusicLoginDialog(self) + dialog.exec() diff --git a/tests/test_plugins/test_qqmusic_plugin.py b/tests/test_plugins/test_qqmusic_plugin.py index 40799fe2..5935c9ea 100644 --- a/tests/test_plugins/test_qqmusic_plugin.py +++ b/tests/test_plugins/test_qqmusic_plugin.py @@ -32,3 +32,24 @@ def test_qqmusic_settings_tab_reads_and_saves_quality(qtbot): tab._save() settings.set.assert_called_once_with("quality", tab._quality_combo.currentData()) + + +def test_qqmusic_settings_tab_opens_login_dialog(monkeypatch, qtbot): + settings = Mock() + settings.get.return_value = "320" + context = Mock(settings=settings) + + dialog = Mock() + dialog_ctor = Mock(return_value=dialog) + monkeypatch.setattr( + "plugins.builtin.qqmusic.lib.settings_tab.QQMusicLoginDialog", + dialog_ctor, + ) + + tab = QQMusicSettingsTab(context) + qtbot.addWidget(tab) + + tab._open_login_dialog() + + dialog_ctor.assert_called_once_with(tab) + dialog.exec.assert_called_once_with() From 0b318cc672f4af7d8a3d2721f9c5bb038eff92ae Mon Sep 17 00:00:00 2001 From: Har01d Date: Mon, 6 Apr 2026 07:35:37 +0800 Subject: [PATCH 040/157] =?UTF-8?q?=E6=B7=BB=E5=8A=A0QQ=E6=8F=92=E4=BB=B6?= =?UTF-8?q?=E6=89=AB=E7=A0=81=E5=AE=A2=E6=88=B7=E7=AB=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- plugins/builtin/qqmusic/lib/qr_login.py | 137 ++++++++++++++++++++++ tests/test_plugins/test_qqmusic_plugin.py | 11 ++ 2 files changed, 148 insertions(+) create mode 100644 plugins/builtin/qqmusic/lib/qr_login.py diff --git a/plugins/builtin/qqmusic/lib/qr_login.py b/plugins/builtin/qqmusic/lib/qr_login.py new file mode 100644 index 00000000..bac1d41f --- /dev/null +++ b/plugins/builtin/qqmusic/lib/qr_login.py @@ -0,0 +1,137 @@ +from __future__ import annotations + +import random +import re +import time +from dataclasses import dataclass, field +from enum import Enum +from typing import Any, Dict, Optional + +import requests +from requests.adapters import HTTPAdapter + + +def create_qq_session(pool_size: int = 20, pool_block: bool = True) -> requests.Session: + session = requests.Session() + adapter = HTTPAdapter( + pool_connections=pool_size, + pool_maxsize=pool_size, + pool_block=pool_block, + ) + session.mount("https://", adapter) + session.mount("http://", adapter) + return session + + +def hash33(s: str, h: int = 0) -> int: + for c in s: + h = (h << 5) + h + ord(c) + return 2147483647 & h + + +class QRLoginType(Enum): + QQ = "qq" + WX = "wx" + + +class QRCodeLoginEvents(Enum): + DONE = (0, 405) + SCAN = (66, 408) + CONF = (67, 404) + TIMEOUT = (65, None) + REFUSE = (68, 403) + OTHER = (None, None) + + @classmethod + def get_by_value(cls, value: int): + for member in cls: + if value in member.value: + return member + return cls.OTHER + + +@dataclass +class QR: + data: bytes + qr_type: QRLoginType + identifier: str + + +@dataclass +class Credential: + openid: str = "" + refresh_token: str = "" + access_token: str = "" + expired_at: int = 0 + musicid: int = 0 + musickey: str = "" + unionid: str = "" + str_musicid: str = "" + refresh_key: str = "" + encrypt_uin: str = "" + login_type: int = 0 + extra_fields: Dict[str, Any] = field(default_factory=dict) + + def __post_init__(self): + if not self.login_type: + self.login_type = 1 if self.musickey.startswith("W_X") else 2 + + def as_dict(self) -> Dict[str, Any]: + return { + "openid": self.openid, + "refresh_token": self.refresh_token, + "access_token": self.access_token, + "expired_at": self.expired_at, + "musicid": str(self.musicid), + "musickey": self.musickey, + "unionid": self.unionid, + "str_musicid": self.str_musicid, + "refresh_key": self.refresh_key, + "encrypt_uin": self.encrypt_uin, + "login_type": self.login_type, + "loginType": self.login_type, + "encryptUin": self.encrypt_uin, + **self.extra_fields, + } + + @classmethod + def from_cookies_dict(cls, cookies: Dict[str, Any]) -> "Credential": + _musicid = int(cookies.pop("musicid", 0) or 0) + return cls( + openid=cookies.pop("openid", ""), + refresh_token=cookies.pop("refresh_token", ""), + access_token=cookies.pop("access_token", ""), + expired_at=cookies.pop("expired_at", 0), + musicid=_musicid, + musickey=cookies.pop("musickey", ""), + unionid=cookies.pop("unionid", ""), + str_musicid=cookies.pop("str_musicid", str(_musicid)), + refresh_key=cookies.pop("refresh_key", ""), + encrypt_uin=cookies.pop("encryptUin", ""), + login_type=cookies.pop("loginType", 0), + extra_fields=cookies, + ) + + +class QQMusicQRLogin: + QQ_QR_URL = "https://ssl.ptlogin2.qq.com/ptqrshow" + QQ_CHECK_URL = "https://ssl.ptlogin2.qq.com/ptqrlogin" + QQ_AUTHORIZE_URL = "https://ssl.ptlogin2.graph.qq.com/check_sig" + QQ_OAUTH_URL = "https://graph.qq.com/oauth2.0/authorize" + WX_QR_URL = "https://open.weixin.qq.com/connect/qrconnect" + WX_CHECK_URL = "https://lp.open.weixin.qq.com/connect/l/qrconnect" + WX_QR_IMAGE_URL = "https://open.weixin.qq.com/connect/qrcode/{uuid}" + MUSIC_API_URL = "https://u.y.qq.com/cgi-bin/musicu.fcg" + + def __init__(self): + self._session = create_qq_session() + self._session.headers.update( + { + "User-Agent": ( + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) " + "AppleWebKit/537.36 (KHTML, like Gecko) " + "Chrome/123.0.0.0 Safari/537.36" + ), + "Referer": "https://y.qq.com/", + } + ) diff --git a/tests/test_plugins/test_qqmusic_plugin.py b/tests/test_plugins/test_qqmusic_plugin.py index 5935c9ea..799439e4 100644 --- a/tests/test_plugins/test_qqmusic_plugin.py +++ b/tests/test_plugins/test_qqmusic_plugin.py @@ -1,5 +1,6 @@ from unittest.mock import Mock +from plugins.builtin.qqmusic.lib.qr_login import QQMusicQRLogin from plugins.builtin.qqmusic.plugin_main import QQMusicPlugin from plugins.builtin.qqmusic.lib.settings_tab import QQMusicSettingsTab @@ -53,3 +54,13 @@ def test_qqmusic_settings_tab_opens_login_dialog(monkeypatch, qtbot): dialog_ctor.assert_called_once_with(tab) dialog.exec.assert_called_once_with() + + +def test_plugin_local_qr_login_client_builds_session(): + client = QQMusicQRLogin() + + https_adapter = client._session.get_adapter("https://u.y.qq.com/cgi-bin/musicu.fcg") + + assert https_adapter._pool_connections == 20 + assert https_adapter._pool_maxsize == 20 + assert https_adapter._pool_block is True From 4e4606e658c3b77e01446f9b1fcfd9d64919b468 Mon Sep 17 00:00:00 2001 From: Har01d Date: Mon, 6 Apr 2026 07:39:33 +0800 Subject: [PATCH 041/157] =?UTF-8?q?=E8=BF=9E=E6=8E=A5QQ=E6=8F=92=E4=BB=B6?= =?UTF-8?q?=E6=9C=AC=E5=9C=B0=E7=99=BB=E5=BD=95=E5=AF=B9=E8=AF=9D=E6=A1=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- plugins/builtin/qqmusic/lib/login_dialog.py | 4 ++++ tests/test_plugins/test_qqmusic_plugin.py | 8 ++++++++ 2 files changed, 12 insertions(+) diff --git a/plugins/builtin/qqmusic/lib/login_dialog.py b/plugins/builtin/qqmusic/lib/login_dialog.py index 3b7c2eb9..e4952d7b 100644 --- a/plugins/builtin/qqmusic/lib/login_dialog.py +++ b/plugins/builtin/qqmusic/lib/login_dialog.py @@ -2,9 +2,13 @@ from PySide6.QtWidgets import QDialog, QVBoxLayout, QLabel +from .qr_login import QQMusicQRLogin + class QQMusicLoginDialog(QDialog): def __init__(self, parent=None): super().__init__(parent) + self._client = QQMusicQRLogin() + self.setWindowTitle("QQ Music Login") layout = QVBoxLayout(self) layout.addWidget(QLabel("QQ Music Login", self)) diff --git a/tests/test_plugins/test_qqmusic_plugin.py b/tests/test_plugins/test_qqmusic_plugin.py index 799439e4..343cf4cc 100644 --- a/tests/test_plugins/test_qqmusic_plugin.py +++ b/tests/test_plugins/test_qqmusic_plugin.py @@ -1,5 +1,6 @@ from unittest.mock import Mock +from plugins.builtin.qqmusic.lib.login_dialog import QQMusicLoginDialog from plugins.builtin.qqmusic.lib.qr_login import QQMusicQRLogin from plugins.builtin.qqmusic.plugin_main import QQMusicPlugin from plugins.builtin.qqmusic.lib.settings_tab import QQMusicSettingsTab @@ -64,3 +65,10 @@ def test_plugin_local_qr_login_client_builds_session(): assert https_adapter._pool_connections == 20 assert https_adapter._pool_maxsize == 20 assert https_adapter._pool_block is True + + +def test_plugin_login_dialog_uses_local_qr_client(qtbot): + dialog = QQMusicLoginDialog() + qtbot.addWidget(dialog) + + assert isinstance(dialog._client, QQMusicQRLogin) From 178d092a95b21c62c30fa73d273e97c5efcb7978 Mon Sep 17 00:00:00 2001 From: Har01d Date: Mon, 6 Apr 2026 07:43:09 +0800 Subject: [PATCH 042/157] =?UTF-8?q?=E5=AE=8C=E5=96=84QQ=E6=8F=92=E4=BB=B6?= =?UTF-8?q?=E5=87=AD=E6=8D=AE=E7=AE=A1=E7=90=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- plugins/builtin/qqmusic/lib/settings_tab.py | 18 ++++++++++++++++++ tests/test_plugins/test_qqmusic_plugin.py | 17 +++++++++++++++++ 2 files changed, 35 insertions(+) diff --git a/plugins/builtin/qqmusic/lib/settings_tab.py b/plugins/builtin/qqmusic/lib/settings_tab.py index fa6430cd..855ad098 100644 --- a/plugins/builtin/qqmusic/lib/settings_tab.py +++ b/plugins/builtin/qqmusic/lib/settings_tab.py @@ -11,6 +11,9 @@ def __init__(self, context, parent=None): self._context = context layout = QVBoxLayout(self) layout.addWidget(QLabel("QQ Music Settings", self)) + self._status_label = QLabel(self) + self._status_label.setText(self._build_status_text()) + layout.addWidget(self._status_label) self._quality_combo = QComboBox(self) for quality in ("320", "flac", "master"): self._quality_combo.addItem(quality, quality) @@ -25,6 +28,10 @@ def __init__(self, context, parent=None): login_btn.clicked.connect(self._open_login_dialog) layout.addWidget(login_btn) + clear_btn = QPushButton("Clear Credentials", self) + clear_btn.clicked.connect(self._clear_credentials) + layout.addWidget(clear_btn) + save_btn = QPushButton("Save", self) save_btn.clicked.connect(self._save) layout.addWidget(save_btn) @@ -35,3 +42,14 @@ def _save(self): def _open_login_dialog(self): dialog = QQMusicLoginDialog(self) dialog.exec() + + def _clear_credentials(self): + self._context.settings.set("credential", None) + self._context.settings.set("nick", "") + self._status_label.setText(self._build_status_text()) + + def _build_status_text(self) -> str: + nick = self._context.settings.get("nick", "") + if nick: + return f"Logged in as {nick}" + return "Not logged in" diff --git a/tests/test_plugins/test_qqmusic_plugin.py b/tests/test_plugins/test_qqmusic_plugin.py index 343cf4cc..8e181eae 100644 --- a/tests/test_plugins/test_qqmusic_plugin.py +++ b/tests/test_plugins/test_qqmusic_plugin.py @@ -72,3 +72,20 @@ def test_plugin_login_dialog_uses_local_qr_client(qtbot): qtbot.addWidget(dialog) assert isinstance(dialog._client, QQMusicQRLogin) + + +def test_qqmusic_settings_tab_clears_plugin_credentials(qtbot): + settings = Mock() + settings.get.side_effect = lambda key, default=None: { + "quality": "320", + "nick": "Tester", + }.get(key, default) + context = Mock(settings=settings) + + tab = QQMusicSettingsTab(context) + qtbot.addWidget(tab) + + tab._clear_credentials() + + settings.set.assert_any_call("credential", None) + settings.set.assert_any_call("nick", "") From 973db020d0842dce739f21cf43ec5b1d2a9657b1 Mon Sep 17 00:00:00 2001 From: Har01d Date: Mon, 6 Apr 2026 07:45:28 +0800 Subject: [PATCH 043/157] =?UTF-8?q?=E6=9B=B4=E6=96=B0=E9=9F=B3=E8=B4=A8?= =?UTF-8?q?=E5=B7=A5=E5=85=B7=E6=B5=8B=E8=AF=95=E5=BC=95=E7=94=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../test_services/test_qqmusic_quality_support.py | 14 +++++++------- tests/test_ui/test_library_view_redownload.py | 2 +- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/tests/test_services/test_qqmusic_quality_support.py b/tests/test_services/test_qqmusic_quality_support.py index b7a0e31b..df687ccc 100644 --- a/tests/test_services/test_qqmusic_quality_support.py +++ b/tests/test_services/test_qqmusic_quality_support.py @@ -1,7 +1,7 @@ from services.cloud.qqmusic.client import QQMusicClient from services.cloud.qqmusic.qr_login import QQMusicQRLogin -from services.cloud.qqmusic.common import ( - APIConfig, +from services.online.quality import ( + QUALITY_FALLBACK, parse_quality, get_selectable_qualities, get_quality_label_key, @@ -35,11 +35,11 @@ def test_parse_quality_supports_chinese_quality_names(): def test_quality_fallback_contains_extended_quality_levels(): - assert "ogg_640" in APIConfig.QUALITY_FALLBACK - assert "aac_320" in APIConfig.QUALITY_FALLBACK - assert "aac_24" in APIConfig.QUALITY_FALLBACK - assert "hires" in APIConfig.QUALITY_FALLBACK - assert "dolby" in APIConfig.QUALITY_FALLBACK + assert "ogg_640" in QUALITY_FALLBACK + assert "aac_320" in QUALITY_FALLBACK + assert "aac_24" in QUALITY_FALLBACK + assert "hires" in QUALITY_FALLBACK + assert "dolby" in QUALITY_FALLBACK def test_get_song_url_accepts_chinese_quality_name(): diff --git a/tests/test_ui/test_library_view_redownload.py b/tests/test_ui/test_library_view_redownload.py index 192d54c7..43338a71 100644 --- a/tests/test_ui/test_library_view_redownload.py +++ b/tests/test_ui/test_library_view_redownload.py @@ -4,7 +4,7 @@ from domain.history import PlayHistory from domain.track import Track, TrackSource -from services.cloud.qqmusic.common import get_quality_label_key +from services.online.quality import get_quality_label_key from system.i18n import t from ui.views.library_view import LibraryView From 3a531254476d2a61a78cc0cc4f0bb8810c2ad90f Mon Sep 17 00:00:00 2001 From: Har01d Date: Mon, 6 Apr 2026 07:48:16 +0800 Subject: [PATCH 044/157] =?UTF-8?q?=E5=A2=9E=E5=BC=BA=E5=86=85=E7=BD=AE?= =?UTF-8?q?=E6=8F=92=E4=BB=B6=E9=9B=86=E6=88=90=E6=A0=A1=E9=AA=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- system/plugins/manager.py | 9 +++-- tests/test_system/test_plugin_manager.py | 35 ++++++++++++++++++ tests/test_ui/test_plugin_settings_tab.py | 43 +++++++++++++++++++++++ 3 files changed, 85 insertions(+), 2 deletions(-) diff --git a/system/plugins/manager.py b/system/plugins/manager.py index e0d296cf..20e564e7 100644 --- a/system/plugins/manager.py +++ b/system/plugins/manager.py @@ -24,16 +24,21 @@ def __init__(self, builtin_root: Path, external_root: Path, state_store, context self._loaded_plugins: dict[str, tuple[object, object, object]] = {} def discover_roots(self) -> list[tuple[str, Path]]: + def _is_plugin_root(path: Path) -> bool: + return path.is_dir() and (path / "plugin.json").exists() + roots = [] if self._builtin_root.exists(): roots.extend( - ("builtin", path) for path in self._builtin_root.iterdir() if path.is_dir() + ("builtin", path) + for path in self._builtin_root.iterdir() + if _is_plugin_root(path) ) if self._external_root.exists(): roots.extend( ("external", path) for path in self._external_root.iterdir() - if path.is_dir() + if _is_plugin_root(path) and not path.name.endswith(".staging") and not path.name.endswith(".backup") ) diff --git a/tests/test_system/test_plugin_manager.py b/tests/test_system/test_plugin_manager.py index edfec58a..039074f5 100644 --- a/tests/test_system/test_plugin_manager.py +++ b/tests/test_system/test_plugin_manager.py @@ -826,3 +826,38 @@ def build(self, manifest): loaded_ids = set(manager._loaded_plugins) assert "lrclib" in loaded_ids assert "qqmusic" in loaded_ids + + +def test_discover_roots_ignores_non_plugin_directories(tmp_path: Path): + builtin_root = tmp_path / "builtin" + builtin_root.mkdir() + (builtin_root / "__pycache__").mkdir() + real_plugin = builtin_root / "real" + real_plugin.mkdir() + (real_plugin / "plugin.json").write_text( + json.dumps( + { + "id": "real", + "name": "Real", + "version": "1.0.0", + "api_version": "1", + "entrypoint": "plugin_main.py", + "entry_class": "RealPlugin", + "capabilities": ["sidebar"], + "min_app_version": "0.1.0", + } + ), + encoding="utf-8", + ) + + manager = PluginManager( + builtin_root=builtin_root, + external_root=tmp_path / "external", + state_store=PluginStateStore(tmp_path / "state.json"), + context_factory=_ContextFactory(), + ) + + discovered = manager.discover_roots() + + assert ("builtin", real_plugin) in discovered + assert all(path.name != "__pycache__" for _source, path in discovered) diff --git a/tests/test_ui/test_plugin_settings_tab.py b/tests/test_ui/test_plugin_settings_tab.py index b95ce5c5..a3740d6e 100644 --- a/tests/test_ui/test_plugin_settings_tab.py +++ b/tests/test_ui/test_plugin_settings_tab.py @@ -101,3 +101,46 @@ def test_settings_dialog_omits_qqmusic_tab_without_plugin(monkeypatch, qtbot): tab_labels = [tab_widget.tabText(index) for index in range(tab_widget.count())] assert "QQ音乐" not in tab_labels assert "QQ Music" not in tab_labels + + +def test_settings_dialog_with_real_builtins_includes_plugin_tabs(monkeypatch, qtbot): + from app.bootstrap import Bootstrap + + Bootstrap._instance = None + config = Mock() + config.get.return_value = "dark" + config.get_ai_enabled.return_value = False + config.get_ai_base_url.return_value = "" + config.get_ai_api_key.return_value = "" + config.get_ai_model.return_value = "" + config.get_acoustid_enabled.return_value = False + config.get_acoustid_api_key.return_value = "" + config.get_online_music_download_dir.return_value = "data/online_cache" + config.get_cache_cleanup_strategy.return_value = "manual" + config.get_cache_cleanup_auto_enabled.return_value = False + config.get_cache_cleanup_time_days.return_value = 30 + config.get_cache_cleanup_size_mb.return_value = 1000 + config.get_cache_cleanup_count.return_value = 100 + config.get_cache_cleanup_interval_hours.return_value = 1 + config.get_audio_engine.return_value = "mpv" + config.get_plugin_setting.side_effect = lambda plugin_id, key, default=None: default + config.get_plugin_secret.side_effect = lambda plugin_id, key, default="": default + + bootstrap = Bootstrap(":memory:") + bootstrap._config = config + bootstrap._event_bus = Mock() + bootstrap._http_client = Mock() + bootstrap._playback_service = Mock() + bootstrap._library_service = Mock() + bootstrap._online_download_service = Mock() + + monkeypatch.setattr("ui.dialogs.settings_dialog.Bootstrap.instance", lambda: bootstrap) + ThemeManager._instance = None + ThemeManager.instance(config) + + dialog = GeneralSettingsDialog(config) + qtbot.addWidget(dialog) + tab_widget = dialog.findChild(QTabWidget) + + tab_labels = [tab_widget.tabText(index) for index in range(tab_widget.count())] + assert "QQ 音乐" in tab_labels From 48a76115787ad60a91efe0e037b2164471a555f3 Mon Sep 17 00:00:00 2001 From: Har01d Date: Mon, 6 Apr 2026 07:50:50 +0800 Subject: [PATCH 045/157] =?UTF-8?q?=E9=80=9A=E8=BF=87=E6=8F=92=E4=BB=B6?= =?UTF-8?q?=E6=B3=A8=E5=86=8C=E8=A1=A8=E8=A7=A3=E6=9E=90QQ=E6=AD=8C?= =?UTF-8?q?=E8=AF=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- plugins/builtin/qqmusic/lib/lyrics_source.py | 5 +++++ services/lyrics/lyrics_service.py | 2 +- system/plugins/qqmusic_lyrics_helpers.py | 18 ++++++++++++++++++ .../test_system/test_plugin_lyrics_helpers.py | 19 +++++++++++++++++++ 4 files changed, 43 insertions(+), 1 deletion(-) create mode 100644 system/plugins/qqmusic_lyrics_helpers.py create mode 100644 tests/test_system/test_plugin_lyrics_helpers.py diff --git a/plugins/builtin/qqmusic/lib/lyrics_source.py b/plugins/builtin/qqmusic/lib/lyrics_source.py index f5d22962..5288157c 100644 --- a/plugins/builtin/qqmusic/lib/lyrics_source.py +++ b/plugins/builtin/qqmusic/lib/lyrics_source.py @@ -43,5 +43,10 @@ def get_lyrics(self, result: PluginLyricsResult) -> str | None: except Exception: return None + def get_lyrics_by_song_id(self, song_id: str) -> str | None: + return self.get_lyrics( + PluginLyricsResult(song_id=song_id, title="", artist="", source="qqmusic") + ) + def is_available(self) -> bool: return True diff --git a/services/lyrics/lyrics_service.py b/services/lyrics/lyrics_service.py index 0a46255c..8fdbcb60 100644 --- a/services/lyrics/lyrics_service.py +++ b/services/lyrics/lyrics_service.py @@ -7,10 +7,10 @@ from typing import TYPE_CHECKING, List, Optional from harmony_plugin_api.lyrics import PluginLyricsResult +from system.plugins.qqmusic_lyrics_helpers import download_qqmusic_lyrics from services._singleflight import SingleFlight from utils.lrc_parser import LyricLine from utils.match_scorer import MatchScorer, TrackInfo -from .qqmusic_lyrics import download_qqmusic_lyrics # Configure logging logger = logging.getLogger(__name__) diff --git a/system/plugins/qqmusic_lyrics_helpers.py b/system/plugins/qqmusic_lyrics_helpers.py new file mode 100644 index 00000000..b2e7606f --- /dev/null +++ b/system/plugins/qqmusic_lyrics_helpers.py @@ -0,0 +1,18 @@ +from __future__ import annotations + +from harmony_plugin_api.lyrics import PluginLyricsResult + + +def download_qqmusic_lyrics(song_mid: str) -> str: + from app.bootstrap import Bootstrap + + sources = Bootstrap.instance().plugin_manager.registry.lyrics_sources() + for source in sources: + if getattr(source, "source", None) == "qqmusic" or getattr(source, "name", "").lower() == "qqmusic": + if hasattr(source, "get_lyrics_by_song_id"): + return source.get_lyrics_by_song_id(song_mid) or "" + if hasattr(source, "get_lyrics"): + return source.get_lyrics( + PluginLyricsResult(song_id=song_mid, title="", artist="", source="qqmusic") + ) or "" + return "" diff --git a/tests/test_system/test_plugin_lyrics_helpers.py b/tests/test_system/test_plugin_lyrics_helpers.py new file mode 100644 index 00000000..e25c8663 --- /dev/null +++ b/tests/test_system/test_plugin_lyrics_helpers.py @@ -0,0 +1,19 @@ +from types import SimpleNamespace + +from system.plugins.qqmusic_lyrics_helpers import download_qqmusic_lyrics + + +def test_download_qqmusic_lyrics_uses_registered_plugin_source(monkeypatch): + source = SimpleNamespace( + source="qqmusic", + get_lyrics_by_song_id=lambda song_id: f"lyrics:{song_id}", + ) + fake_manager = SimpleNamespace( + registry=SimpleNamespace(lyrics_sources=lambda: [source]) + ) + monkeypatch.setattr( + "app.bootstrap.Bootstrap.instance", + lambda: SimpleNamespace(plugin_manager=fake_manager), + ) + + assert download_qqmusic_lyrics("mid123") == "lyrics:mid123" From 48c8847fcd2f3e0706c409bc4afc337de1766c60 Mon Sep 17 00:00:00 2001 From: Har01d Date: Mon, 6 Apr 2026 07:52:54 +0800 Subject: [PATCH 046/157] =?UTF-8?q?=E6=94=B6=E7=AA=84=E5=AE=BF=E4=B8=BB?= =?UTF-8?q?=E5=AF=B9=E8=AF=9D=E6=A1=86=E5=AF=BC=E5=87=BA=E9=9D=A2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ui/dialogs/__init__.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/ui/dialogs/__init__.py b/ui/dialogs/__init__.py index 4ace92d3..d9b7be19 100644 --- a/ui/dialogs/__init__.py +++ b/ui/dialogs/__init__.py @@ -11,7 +11,6 @@ from .lyrics_download_dialog import LyricsDownloadDialog from .organize_files_dialog import OrganizeFilesDialog from .provider_select_dialog import ProviderSelectDialog -from .qqmusic_qr_login_dialog import QQMusicQRLoginDialog from .settings_dialog import GeneralSettingsDialog from .universal_cover_download_dialog import UniversalCoverDownloadDialog from .welcome_dialog import WelcomeDialog @@ -22,7 +21,6 @@ ArtistCoverDownloadDialog = UniversalCoverDownloadDialog __all__ = [ - 'QQMusicQRLoginDialog', 'GeneralSettingsDialog', 'AlbumCoverDownloadDialog', 'ArtistCoverDownloadDialog', From e7e7a7a985337cda9e35e0027dd611a027cc86c0 Mon Sep 17 00:00:00 2001 From: Har01d Date: Mon, 6 Apr 2026 07:57:11 +0800 Subject: [PATCH 047/157] =?UTF-8?q?=E7=A7=BB=E9=99=A4=E5=AE=BF=E4=B8=BBQQ?= =?UTF-8?q?=E7=99=BB=E5=BD=95=E5=AF=B9=E8=AF=9D=E6=A1=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tests/test_ui/test_online_music_view_async.py | 16 + ui/dialogs/qqmusic_qr_login_dialog.py | 571 ------------------ ui/views/online_music_view.py | 6 +- 3 files changed, 18 insertions(+), 575 deletions(-) delete mode 100644 ui/dialogs/qqmusic_qr_login_dialog.py diff --git a/tests/test_ui/test_online_music_view_async.py b/tests/test_ui/test_online_music_view_async.py index ca3bee79..a66a142c 100644 --- a/tests/test_ui/test_online_music_view_async.py +++ b/tests/test_ui/test_online_music_view_async.py @@ -168,6 +168,22 @@ def test_on_login_clicked_clears_plugin_namespaced_credential(): view._config.set_plugin_setting.assert_any_call("qqmusic", "nick", "") +def test_show_login_dialog_uses_plugin_local_dialog(monkeypatch): + view = OnlineMusicView.__new__(OnlineMusicView) + view._on_credentials_obtained = Mock() + dialog = Mock() + dialog_ctor = Mock(return_value=dialog) + monkeypatch.setattr( + "plugins.builtin.qqmusic.lib.login_dialog.QQMusicLoginDialog", + dialog_ctor, + ) + + OnlineMusicView._show_login_dialog(view) + + dialog_ctor.assert_called_once_with(view) + dialog.exec.assert_called_once_with() + + def test_refresh_qqmusic_service_prefers_plugin_secret(monkeypatch): view = OnlineMusicView.__new__(OnlineMusicView) view._config = Mock() diff --git a/ui/dialogs/qqmusic_qr_login_dialog.py b/ui/dialogs/qqmusic_qr_login_dialog.py deleted file mode 100644 index 5a1ed6f4..00000000 --- a/ui/dialogs/qqmusic_qr_login_dialog.py +++ /dev/null @@ -1,571 +0,0 @@ -""" -QQ Music QR code login dialog. -Uses local implementation without qqmusic_api dependency. -""" -import logging -import time -from io import BytesIO -from typing import Optional - -from PySide6.QtCore import Qt, Signal, QThread, Slot -from PySide6.QtGui import QColor, QPainterPath, QRegion, QPixmap, QImage -from PySide6.QtWidgets import ( - QDialog, QVBoxLayout, QHBoxLayout, QLabel, QPushButton, - QButtonGroup, QRadioButton, QProgressBar, QWidget, - QGraphicsDropShadowEffect, -) - -from services.cloud.qqmusic.qr_login import ( - QQMusicQRLogin, QRLoginType, QRCodeLoginEvents -) -from system.i18n import t, get_language -from system.theme import ThemeManager -from ui.dialogs.dialog_title_bar import setup_equalizer_title_layout -from ui.dialogs.message_dialog import MessageDialog - -logger = logging.getLogger(__name__) - - -class QRLoginThread(QThread): - """Background thread for QR code login polling.""" - - # Signals - qr_code_ready = Signal(bytes) # QR image data - login_success = Signal(dict) # credential dict - login_failed = Signal(str) # error message - login_refused = Signal() # user refused - login_timeout = Signal() # QR code expired - status_update = Signal(str) # status message - - def __init__(self, login_type: str = 'qq'): - super().__init__() - self.login_type = login_type - self._running = True - - def stop(self): - """Stop the polling thread.""" - self._running = False - - def run(self): - """Run QR code login polling.""" - try: - login_type = QRLoginType.WX if self.login_type == 'wx' else QRLoginType.QQ - is_wechat = self.login_type == 'wx' - logger.info(f"Starting QR login with type: {self.login_type} (is_wechat: {is_wechat})") - - client = QQMusicQRLogin() - - # Get QR code - app_name = t("qqmusic_wx_login").replace("登录", "").strip() if is_wechat else "QQ" - self.status_update.emit(t("qqmusic_fetching_qr")) - - qr = client.get_qrcode(login_type) - if not qr: - if self._running: - self.login_failed.emit(t("qqmusic_login_failed_detail").format(error="Failed to get QR code")) - return - - if not self._running: - return - - # Emit QR code image for display - self.qr_code_ready.emit(qr.data) - logger.debug(f"QR code obtained, type: {qr.qr_type}, identifier: {qr.identifier[:20]}...") - - # Poll for login status - self.status_update.emit(t("qqmusic_scan_with_app").format(app=app_name)) - - poll_count = 0 - max_polls = 120 # 2 minutes - - while poll_count < max_polls and self._running: - try: - event, credential = client.check_qrcode(qr) - - if not self._running: - return - - if event == QRCodeLoginEvents.SCAN: - self.status_update.emit(t("qqmusic_waiting_scan")) - - elif event == QRCodeLoginEvents.CONF: - self.status_update.emit(t("qqmusic_scan_confirmed")) - - elif event == QRCodeLoginEvents.DONE: - self.status_update.emit(t("qqmusic_logging_in")) - - if credential: - # Convert credential to dict - cred_dict = credential.as_dict() - - # Add create time for refresh tracking - cred_dict['musickey_createtime'] = int(time.time()) - - # Ensure musicid is string - if 'musicid' in cred_dict and cred_dict['musicid'] is not None: - cred_dict['musicid'] = str(cred_dict['musicid']) - - logger.info(f"Login success, musicid: {cred_dict.get('musicid')}, " - f"login_type: {cred_dict.get('login_type')}, " - f"has_refresh_key: {bool(cred_dict.get('refresh_key'))}, " - f"has_refresh_token: {bool(cred_dict.get('refresh_token'))}, " - f"encrypt_uin: {cred_dict.get('encrypt_uin')}") - - self.login_success.emit(cred_dict) - else: - self.login_failed.emit("Login succeeded but no credential returned") - return - - elif event == QRCodeLoginEvents.TIMEOUT: - self.login_timeout.emit() - return - - elif event == QRCodeLoginEvents.REFUSE: - self.login_refused.emit() - return - - poll_count += 1 - self.msleep(1000) # Poll every second - - except Exception as e: - logger.debug(f"Poll error: {e}") - poll_count += 1 - self.msleep(1000) - - # Timeout - if self._running: - self.login_timeout.emit() - - except Exception as e: - logger.error(f"QR login error: {e}") - if self._running: - self.login_failed.emit(t("qqmusic_login_failed_detail").format(error=str(e))) - - def wait_for_stop(self, timeout_ms: int = 2000): - """Stop the thread and wait for it to finish.""" - self._running = False - return self.wait(timeout_ms) - - -class QQMusicQRLoginDialog(QDialog): - """Dialog for QQ Music QR code login.""" - - # Signal emitted when credentials are successfully obtained - credentials_obtained = Signal(dict) - - _STYLE_TEMPLATE = """ - QWidget#dialogContainer { - background-color: %background_alt%; - color: %text%; - border: 1px solid %border%; - border-radius: 12px; - } - QLabel#dialogTitle { - color: %text%; - font-size: 15px; - font-weight: bold; - } - QLabel { - color: %text%; - } - QRadioButton { - color: %text%; - font-size: 13px; - spacing: 8px; - } - QRadioButton::indicator { - width: 18px; - height: 18px; - border: 2px solid %background_hover%; - border-radius: 9px; - background-color: %background_alt%; - } - QRadioButton::indicator:checked { - border: 2px solid %highlight%; - background-color: %highlight%; - } - QRadioButton::indicator:hover { - border: 2px solid %highlight%; - } - QPushButton { - background-color: %border%; - color: %text%; - border: 1px solid %background_hover%; - border-radius: 4px; - padding: 8px 20px; - font-size: 13px; - } - QPushButton:hover { - background-color: %background_hover%; - border: 1px solid %highlight%; - } - QPushButton:pressed { - background-color: %background_alt%; - } - QPushButton:disabled { - background-color: %background_alt%; - color: %border%; - } - QProgressBar { - border: none; - background-color: %background_alt%; - height: 4px; - border-radius: 2px; - } - QProgressBar::chunk { - background-color: %highlight%; - border-radius: 2px; - } - """ - - def __init__(self, parent=None): - super().__init__(parent) - self._drag_pos = None - - self.setWindowTitle(t("qqmusic_login_title")) - self.setMinimumWidth(450) - self.setMinimumHeight(600) - self.resize(460, 680) - - self.setWindowFlags(Qt.WindowType.Dialog | Qt.FramelessWindowHint) - self.setAttribute(Qt.WidgetAttribute.WA_TranslucentBackground) - - self._setup_shadow() - - from app.bootstrap import Bootstrap - self.config = Bootstrap.instance().config - - self._login_thread: Optional[QRLoginThread] = None - self._login_type = 'wx' # default to WeChat - - self._setup_ui() - self._start_login() - ThemeManager.instance().register_widget(self) - - def _setup_shadow(self): - """Setup drop shadow effect.""" - shadow = QGraphicsDropShadowEffect(self) - shadow.setBlurRadius(30) - shadow.setOffset(0, 8) - shadow.setColor(QColor(0, 0, 0, 80)) - self.setGraphicsEffect(shadow) - - def _setup_ui(self): - """Setup the UI.""" - self.setStyleSheet(ThemeManager.instance().get_qss(self._STYLE_TEMPLATE)) - - # Outer layout with 0 margins - outer = QVBoxLayout(self) - outer.setContentsMargins(0, 0, 0, 0) - - # Container widget for rounded corners - container = QWidget() - container.setObjectName("dialogContainer") - outer.addWidget(container) - - container_layout = QVBoxLayout(container) - layout, self._title_bar_controller = setup_equalizer_title_layout( - self, - container_layout, - t("qqmusic_login_title"), - content_spacing=15, - ) - - # Login type selection - type_layout = QHBoxLayout() - type_label = QLabel(t("qqmusic_login_method")) - theme = ThemeManager.instance().current_theme - type_label.setStyleSheet(f"font-size: 14px; font-weight: bold; color: {theme.text};") - self._qq_radio = QRadioButton(t("qqmusic_qq_login")) - self._wx_radio = QRadioButton(t("qqmusic_wx_login")) - self._wx_radio.setChecked(True) # 默认微信登录 - self._login_type = 'wx' # default to WeChat - self._qq_radio.toggled.connect(self._on_login_type_changed) - - type_group = QButtonGroup(self) - type_group.addButton(self._qq_radio) - type_group.addButton(self._wx_radio) - - type_layout.addWidget(type_label) - type_layout.addWidget(self._qq_radio) - type_layout.addWidget(self._wx_radio) - type_layout.addStretch() - - layout.addLayout(type_layout) - - # Status label (above QR code) - self._status_label = QLabel(t("qqmusic_loading_qr")) - self._status_label.setAlignment(Qt.AlignCenter) - self._status_label.setWordWrap(True) - self._status_label.setStyleSheet(f"font-size: 14px; color: {theme.highlight}; padding: 8px; font-weight: bold;") - layout.addWidget(self._status_label) - - # QR code container - qr_container = QWidget() - qr_layout = QVBoxLayout(qr_container) - qr_layout.setContentsMargins(0, 0, 0, 0) - - # QR code image - self._qr_label = QLabel() - self._qr_label.setMinimumSize(300, 300) - self._qr_label.setMaximumSize(300, 300) - self._qr_label.setAlignment(Qt.AlignCenter) - self._qr_label.setStyleSheet( - f"border: 2px solid {theme.background_hover}; border-radius: 8px; background: #ffffff;") - qr_layout.addWidget(self._qr_label) - - layout.addWidget(qr_container, alignment=Qt.AlignCenter) - - # Progress bar - self._progress_bar = QProgressBar() - self._progress_bar.setTextVisible(False) - self._progress_bar.setRange(0, 0) # Indeterminate progress - self._progress_bar.setMaximumHeight(4) - self._progress_bar.hide() - layout.addWidget(self._progress_bar) - - # Instructions - self._instructions_label = QLabel() - self._instructions_label.setAlignment(Qt.AlignCenter) - self._instructions_label.setStyleSheet(f"color: {theme.text_secondary}; font-size: 12px;") - self._update_instructions() - layout.addWidget(self._instructions_label) - - # Buttons - button_layout = QHBoxLayout() - - self._refresh_button = QPushButton(t("qqmusic_refresh_qr")) - self._refresh_button.setCursor(Qt.PointingHandCursor) - self._refresh_button.clicked.connect(self._refresh_qr) - self._refresh_button.setEnabled(False) - button_layout.addWidget(self._refresh_button) - - self._cancel_button = QPushButton(t("cancel")) - self._cancel_button.setCursor(Qt.PointingHandCursor) - self._cancel_button.clicked.connect(self._cancel_login) - button_layout.addWidget(self._cancel_button) - - layout.addLayout(button_layout) - - def _on_login_type_changed(self): - """Handle login type radio button change.""" - if self._qq_radio.isChecked(): - self._login_type = 'qq' - else: - self._login_type = 'wx' - - # Update instructions text - self._update_instructions() - # Restart login with new type - self._restart_login() - - def _update_instructions(self): - """Update instructions based on login type.""" - app_name = "WeChat" if self._login_type == 'wx' else "QQ" - if get_language() == "zh": - app_name = "微信" if self._login_type == 'wx' else "QQ" - self._instructions_label.setText(t("qqmusic_instructions").format(app=app_name)) - - def _restart_login(self): - """Restart login process - stop old thread and start new one.""" - # Keep reference to old thread - old_thread = self._login_thread - self._login_thread = None - - # Stop old thread if exists - if old_thread: - old_thread.stop() - old_thread.wait(2000) - - # Start new login - self._start_login() - - def _start_login(self): - """Start QR code login process.""" - self._progress_bar.show() - self._refresh_button.setEnabled(False) - self._qr_label.clear() - self._status_label.setText(t("qqmusic_fetching_qr")) - - # Create new thread - thread = QRLoginThread(self._login_type) - thread.qr_code_ready.connect(self._on_qr_code_ready) - thread.login_success.connect(self._on_login_success) - thread.login_failed.connect(self._on_login_failed) - thread.login_refused.connect(self._on_login_refused) - thread.login_timeout.connect(self._on_login_timeout) - thread.status_update.connect(self._on_status_update) - thread.finished.connect(lambda: self._on_thread_finished(thread)) - - self._login_thread = thread - thread.start() - - def _on_thread_finished(self, thread): - """Handle thread finished event.""" - # Clean up reference if this is the current thread - if self._login_thread == thread: - self._login_thread = None - - def _refresh_qr(self): - """Refresh QR code.""" - # Disable refresh button to prevent double-click - self._refresh_button.setEnabled(False) - self._restart_login() - - def _cancel_login(self): - """Cancel login and close dialog.""" - if self._login_thread: - self._login_thread.stop() - self.reject() - - def resizeEvent(self, event): - """Apply rounded corner mask.""" - path = QPainterPath() - path.addRoundedRect(self.rect(), 12, 12) - self.setMask(QRegion(path.toFillPolygon().toPolygon())) - super().resizeEvent(event) - - def mousePressEvent(self, event): - """Handle mouse press for drag to move.""" - if event.button() == Qt.MouseButton.LeftButton: - self._drag_pos = event.globalPosition().toPoint() - self.frameGeometry().topLeft() - - def mouseMoveEvent(self, event): - """Handle mouse move for drag to move.""" - if self._drag_pos and event.buttons() & Qt.MouseButton.LeftButton: - self.move(event.globalPosition().toPoint() - self._drag_pos) - - def mouseReleaseEvent(self, event): - """Handle mouse release.""" - self._drag_pos = None - - def closeEvent(self, event): - """Handle dialog close event.""" - if self._login_thread: - self._login_thread.stop() - event.accept() - - @Slot(bytes) - def _on_qr_code_ready(self, qr_data: bytes): - """Handle QR code ready event.""" - try: - from PIL import Image - - # Convert bytes to QPixmap - img = Image.open(BytesIO(qr_data)) - - # Resize if needed - if img.size != (300, 300): - img = img.resize((300, 300), Image.Resampling.LANCZOS) - - # Convert PIL image to bytes - byte_arr = BytesIO() - img.save(byte_arr, format='PNG') - byte_arr = byte_arr.getvalue() - - # Create QPixmap from bytes - qimage = QImage.fromData(byte_arr) - pixmap = QPixmap.fromImage(qimage) - - self._qr_label.setPixmap(pixmap) - self._refresh_button.setEnabled(True) - - except Exception as e: - logger.error(f"Failed to display QR code: {e}") - self._qr_label.setText(f"{t('qqmusic_qr_display_failed')}\n{str(e)}") - - @Slot(dict) - def _on_login_success(self, credential: dict): - """Handle login success event.""" - self._progress_bar.hide() - self._status_label.setText( - t("qqmusic_login_success") if t('language') != '中文' else "登录成功!正在保存凭证...") - - try: - # Save credentials (full credential dict) - if hasattr(self.config, "set_plugin_secret"): - import json - - self.config.set_plugin_secret( - "qqmusic", - "credential", - json.dumps(credential, ensure_ascii=False), - ) - - # Get user nickname - try: - from services.cloud.qqmusic import QQMusicClient - client = QQMusicClient(credential) - user_info = client.verify_login() - if user_info.get('valid') and user_info.get('nick'): - if hasattr(self.config, "set_plugin_setting"): - self.config.set_plugin_setting("qqmusic", "nick", user_info["nick"]) - logger.info(f"Got QQ Music nickname: {user_info['nick']}") - except Exception as e: - logger.warning(f"Failed to get QQ Music nickname: {e}") - - # Refresh shared QQ Music client to use new credentials - from services.lyrics.qqmusic_lyrics import refresh_shared_client - refresh_shared_client() - - MessageDialog.information( - self, - t("success"), - t("qqmusic_login_success") - ) - - self.credentials_obtained.emit(credential) - self.accept() - - except Exception as e: - logger.error(f"Failed to save credentials: {e}") - MessageDialog.warning( - self, - t("error"), - f"{t('error')}:\n{str(e)}" - ) - - @Slot(str) - def _on_login_failed(self, error: str): - """Handle login failed event.""" - self._progress_bar.hide() - self._status_label.setText(t("qqmusic_login_failed")) - MessageDialog.warning(self, t("qqmusic_login_failed"), error) - - @Slot() - def _on_login_refused(self): - """Handle login refused event.""" - self._progress_bar.hide() - self._status_label.setText(t("qqmusic_user_cancelled")) - MessageDialog.information(self, t("cancel"), t("qqmusic_you_cancelled")) - - @Slot() - def _on_login_timeout(self): - """Handle login timeout event.""" - self._progress_bar.hide() - self._status_label.setText(t("qqmusic_qr_expired")) - self._refresh_button.setEnabled(True) - MessageDialog.information( - self, - t("qqmusic_qr_expired"), - t("qqmusic_qr_timeout_refresh") - ) - - @Slot(str) - def _on_status_update(self, status: str): - """Handle status update event.""" - self._status_label.setText(status) - - def refresh_theme(self): - """Refresh theme when changed.""" - self.setStyleSheet(ThemeManager.instance().get_qss(self._STYLE_TEMPLATE)) - self._title_bar_controller.refresh_theme() - theme = ThemeManager.instance().current_theme - if self._status_label: - self._status_label.setStyleSheet( - f"font-size: 14px; color: {theme.highlight}; padding: 8px; font-weight: bold;") - if self._qr_label: - self._qr_label.setStyleSheet( - f"border: 2px solid {theme.background_hover}; border-radius: 8px; background: #ffffff;") - if self._instructions_label: - self._instructions_label.setStyleSheet(f"color: {theme.text_secondary}; font-size: 12px;") diff --git a/ui/views/online_music_view.py b/ui/views/online_music_view.py index 274fa0dd..b8b6850a 100644 --- a/ui/views/online_music_view.py +++ b/ui/views/online_music_view.py @@ -1443,11 +1443,9 @@ def _on_login_clicked(self): def _show_login_dialog(self): """Show QQ Music login dialog.""" - from ui.dialogs.qqmusic_qr_login_dialog import QQMusicQRLoginDialog + from plugins.builtin.qqmusic.lib.login_dialog import QQMusicLoginDialog - dialog = QQMusicQRLoginDialog(self) - # Connect to credentials signal to refresh immediately on success - dialog.credentials_obtained.connect(self._on_credentials_obtained) + dialog = QQMusicLoginDialog(self) dialog.exec() def _on_credentials_obtained(self, credential: dict): From 3e0e41e656052052f34e1268efd79a5f148111d4 Mon Sep 17 00:00:00 2001 From: Har01d Date: Mon, 6 Apr 2026 08:13:51 +0800 Subject: [PATCH 048/157] =?UTF-8?q?=E8=BF=81=E7=A7=BBQQ=E4=BA=91=E6=9C=8D?= =?UTF-8?q?=E5=8A=A1=E5=88=B0=E6=8F=92=E4=BB=B6=E7=9B=AE=E5=BD=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../builtin/qqmusic/lib/legacy/__init__.py | 1 + plugins/builtin/qqmusic/lib/legacy/adapter.py | 81 +++ .../builtin/qqmusic/lib/legacy}/client.py | 0 .../builtin/qqmusic/lib/legacy}/common.py | 0 .../builtin/qqmusic/lib/legacy}/crypto.py | 0 .../qqmusic/lib/legacy}/qqmusic_service.py | 8 +- .../builtin/qqmusic/lib/legacy}/tripledes.py | 0 services/cloud/qqmusic/__init__.py | 23 - services/cloud/qqmusic/qr_login.py | 532 ------------------ services/lyrics/qqmusic_lyrics.py | 4 +- services/online/download_service.py | 2 +- tests/test_services/test_bug2_double_pop.py | 2 +- .../test_qqmusic_quality_support.py | 4 +- .../test_qqmusic_service_perf_paths.py | 2 +- tests/test_ui/test_online_music_view_async.py | 2 +- ui/views/online_music_view.py | 2 +- 16 files changed, 96 insertions(+), 567 deletions(-) create mode 100644 plugins/builtin/qqmusic/lib/legacy/__init__.py create mode 100644 plugins/builtin/qqmusic/lib/legacy/adapter.py rename {services/cloud/qqmusic => plugins/builtin/qqmusic/lib/legacy}/client.py (100%) rename {services/cloud/qqmusic => plugins/builtin/qqmusic/lib/legacy}/common.py (100%) rename {services/cloud/qqmusic => plugins/builtin/qqmusic/lib/legacy}/crypto.py (100%) rename {services/cloud/qqmusic => plugins/builtin/qqmusic/lib/legacy}/qqmusic_service.py (99%) rename {services/cloud/qqmusic => plugins/builtin/qqmusic/lib/legacy}/tripledes.py (100%) delete mode 100644 services/cloud/qqmusic/__init__.py delete mode 100644 services/cloud/qqmusic/qr_login.py diff --git a/plugins/builtin/qqmusic/lib/legacy/__init__.py b/plugins/builtin/qqmusic/lib/legacy/__init__.py new file mode 100644 index 00000000..4faea47d --- /dev/null +++ b/plugins/builtin/qqmusic/lib/legacy/__init__.py @@ -0,0 +1 @@ +"""Legacy QQ Music local API implementation kept with the plugin.""" diff --git a/plugins/builtin/qqmusic/lib/legacy/adapter.py b/plugins/builtin/qqmusic/lib/legacy/adapter.py new file mode 100644 index 00000000..88180558 --- /dev/null +++ b/plugins/builtin/qqmusic/lib/legacy/adapter.py @@ -0,0 +1,81 @@ +from __future__ import annotations + +import re +from typing import Any, Dict, Optional + +_RE_HTML_TAG = re.compile(r"<[^>]+>") + + +def _parse_album_song(item: Dict) -> Dict: + song = item.get("songInfo", item) + + name = song.get("title", song.get("name", song.get("songName", ""))) + if name: + name = _RE_HTML_TAG.sub("", name) + + album_name = song.get("albumName", song.get("albumname", "")) + if album_name: + album_name = _RE_HTML_TAG.sub("", album_name) + + singers = song.get("singer", []) + if isinstance(singers, list): + singers = [ + { + "mid": singer.get("mid", ""), + "name": _RE_HTML_TAG.sub("", singer.get("name", "")), + } + if isinstance(singer, dict) + else singer + for singer in singers + ] + + return { + "mid": song.get("mid", song.get("songMid", "")), + "id": song.get("id", song.get("songId")), + "name": name, + "singer": singers, + "album": song.get("album", {}), + "albummid": song.get("albumMid", song.get("albummid", "")), + "albumname": album_name, + "interval": song.get("interval", song.get("duration", 0)), + } + + +def parse_album_detail( + raw_data: Dict[str, Any], + songs_data: Optional[Dict] = None, +) -> Optional[Dict[str, Any]]: + if not raw_data: + return None + + basic_info = raw_data.get("basicInfo", {}) + singer_list = raw_data.get("singer", {}).get("singerList", []) + company_info = raw_data.get("company", {}) + + singer_names = ", ".join([s.get("name", "") for s in singer_list]) if singer_list else "" + singer_mids = [s.get("mid", "") for s in singer_list] if singer_list else [] + album_mid = basic_info.get("albumMid", "") + + result = { + "mid": album_mid, + "name": basic_info.get("albumName", ""), + "singer": singer_names, + "singer_mid": singer_mids[0] if singer_mids else "", + "cover_url": f"https://y.gtimg.cn/music/photo_new/T002R300x300M000{album_mid}.jpg" if album_mid else "", + "publish_date": basic_info.get("publishDate", ""), + "description": basic_info.get("desc", ""), + "company": company_info.get("name", ""), + "genre": basic_info.get("genre", ""), + "language": basic_info.get("language", ""), + "album_type": basic_info.get("albumType", ""), + "songs": [], + "total": 0, + } + + if songs_data: + song_list = songs_data.get("songList", []) + songs = [_parse_album_song(item) for item in song_list] + result["songs"] = songs + result["total"] = songs_data.get("totalNum", len(songs)) + + return result diff --git a/services/cloud/qqmusic/client.py b/plugins/builtin/qqmusic/lib/legacy/client.py similarity index 100% rename from services/cloud/qqmusic/client.py rename to plugins/builtin/qqmusic/lib/legacy/client.py diff --git a/services/cloud/qqmusic/common.py b/plugins/builtin/qqmusic/lib/legacy/common.py similarity index 100% rename from services/cloud/qqmusic/common.py rename to plugins/builtin/qqmusic/lib/legacy/common.py diff --git a/services/cloud/qqmusic/crypto.py b/plugins/builtin/qqmusic/lib/legacy/crypto.py similarity index 100% rename from services/cloud/qqmusic/crypto.py rename to plugins/builtin/qqmusic/lib/legacy/crypto.py diff --git a/services/cloud/qqmusic/qqmusic_service.py b/plugins/builtin/qqmusic/lib/legacy/qqmusic_service.py similarity index 99% rename from services/cloud/qqmusic/qqmusic_service.py rename to plugins/builtin/qqmusic/lib/legacy/qqmusic_service.py index fae7a4c5..6b20f669 100644 --- a/services/cloud/qqmusic/qqmusic_service.py +++ b/plugins/builtin/qqmusic/lib/legacy/qqmusic_service.py @@ -313,7 +313,7 @@ def get_album_info(self, album_mid: str, page: int = 1, page_size: int = 50) -> Returns: Album information dictionary or None """ - from services.online.adapter import OnlineMusicAdapter + from . import adapter as legacy_adapter try: # Get album basic info @@ -328,7 +328,7 @@ def get_album_info(self, album_mid: str, page: int = 1, page_size: int = 50) -> songs_result = self.client.get_album_songs(album_mid, begin=begin, num=page_size) # Use adapter to parse - result = OnlineMusicAdapter.parse_album_detail(basic_result, songs_result) + result = legacy_adapter.parse_album_detail(basic_result, songs_result) if not result: return None @@ -357,7 +357,7 @@ def get_album_info_with_fav_status(self, album_mid: str, page: int = 1, page_siz Returns: Album information dictionary with fav_status field, or None """ - from services.online.adapter import OnlineMusicAdapter + from . import adapter as legacy_adapter try: uin = str(self.credential.get("musicid", "")) if self.credential else "" @@ -415,7 +415,7 @@ def get_album_info_with_fav_status(self, album_mid: str, page: int = 1, page_siz # Combine results - extract data from nested structure req_1_data = req_1.get('data', req_1) req_2_data = req_2.get('data', req_2) - result = OnlineMusicAdapter.parse_album_detail(req_1_data, req_2_data) + result = legacy_adapter.parse_album_detail(req_1_data, req_2_data) if not result: logger.warning("Failed to parse album detail from batch request") return None diff --git a/services/cloud/qqmusic/tripledes.py b/plugins/builtin/qqmusic/lib/legacy/tripledes.py similarity index 100% rename from services/cloud/qqmusic/tripledes.py rename to plugins/builtin/qqmusic/lib/legacy/tripledes.py diff --git a/services/cloud/qqmusic/__init__.py b/services/cloud/qqmusic/__init__.py deleted file mode 100644 index 66956dec..00000000 --- a/services/cloud/qqmusic/__init__.py +++ /dev/null @@ -1,23 +0,0 @@ -""" -QQ Music service for searching and downloading music. -""" - -from .qqmusic_service import QQMusicService -from .client import QQMusicClient -from .qr_login import ( - QQMusicQRLogin, - QRLoginType, - QRCodeLoginEvents, - Credential, - QR -) - -__all__ = [ - 'QQMusicService', - 'QQMusicClient', - 'QQMusicQRLogin', - 'QRLoginType', - 'QRCodeLoginEvents', - 'Credential', - 'QR' -] diff --git a/services/cloud/qqmusic/qr_login.py b/services/cloud/qqmusic/qr_login.py deleted file mode 100644 index 27fe67bb..00000000 --- a/services/cloud/qqmusic/qr_login.py +++ /dev/null @@ -1,532 +0,0 @@ -""" -QQ Music QR code login implementation. -Local implementation without external dependencies. -Based on qqmusic_api library implementation. -""" -import logging -import random -import re -import time -from dataclasses import dataclass, field -from enum import Enum -from typing import Any, Dict, Optional - -import requests - -from .common import create_qq_session - -logger = logging.getLogger(__name__) - - -def hash33(s: str, h: int = 0) -> int: - """Hash function used by QQ login.""" - for c in s: - h = (h << 5) + h + ord(c) - return 2147483647 & h - - -class QRLoginType(Enum): - """QR code login type.""" - QQ = "qq" - WX = "wx" - - -class QRCodeLoginEvents(Enum): - """QR code login status events. - - + SCAN: Waiting for scan - + CONF: Scanned, waiting for confirmation - + TIMEOUT: QR code expired - + DONE: Login successful - + REFUSE: Login refused - + OTHER: Unknown status - """ - DONE = (0, 405) - SCAN = (66, 408) - CONF = (67, 404) - TIMEOUT = (65, None) - REFUSE = (68, 403) - OTHER = (None, None) - - @classmethod - def get_by_value(cls, value: int): - """Get enum member by value.""" - for member in cls: - if value in member.value: - return member - return cls.OTHER - - -@dataclass -class QR: - """QR code data class. - - Attributes: - data: QR code image data - qr_type: QR code type - identifier: Identifier (qrsig for QQ, uuid for WX) - """ - data: bytes - qr_type: QRLoginType - identifier: str - - -@dataclass -class Credential: - """QQ Music credential. - - Attributes: - openid: OpenID - refresh_token: RefreshToken - access_token: AccessToken - expired_at: Expiration timestamp - musicid: QQ Music ID - musickey: QQ Music Key - unionid: UnionID - str_musicid: String musicid - refresh_key: Refresh key - encrypt_uin: Encrypted UIN - login_type: Login type (1=WX, 2=QQ) - extra_fields: Extra fields - """ - openid: str = "" - refresh_token: str = "" - access_token: str = "" - expired_at: int = 0 - musicid: int = 0 - musickey: str = "" - unionid: str = "" - str_musicid: str = "" - refresh_key: str = "" - encrypt_uin: str = "" - login_type: int = 0 - extra_fields: Dict[str, Any] = field(default_factory=dict) - - def __post_init__(self): - if not self.login_type: - if self.musickey and self.musickey.startswith("W_X"): - self.login_type = 1 - else: - self.login_type = 2 - - def as_dict(self) -> Dict[str, Any]: - """Convert to dictionary.""" - return { - 'openid': self.openid, - 'refresh_token': self.refresh_token, - 'access_token': self.access_token, - 'expired_at': self.expired_at, - 'musicid': str(self.musicid), - 'musickey': self.musickey, - 'unionid': self.unionid, - 'str_musicid': self.str_musicid, - 'refresh_key': self.refresh_key, - 'encrypt_uin': self.encrypt_uin, - 'login_type': self.login_type, - 'loginType': self.login_type, - 'encryptUin': self.encrypt_uin, - **self.extra_fields - } - - @classmethod - def from_cookies_dict(cls, cookies: Dict[str, Any]) -> 'Credential': - """Create Credential from cookies dictionary.""" - _musicid = int(cookies.pop("musicid", 0) or 0) - return cls( - openid=cookies.pop("openid", ""), - refresh_token=cookies.pop("refresh_token", ""), - access_token=cookies.pop("access_token", ""), - expired_at=cookies.pop("expired_at", 0), - musicid=_musicid, - musickey=cookies.pop("musickey", ""), - unionid=cookies.pop("unionid", ""), - str_musicid=cookies.pop("str_musicid", str(_musicid)), - refresh_key=cookies.pop("refresh_key", ""), - encrypt_uin=cookies.pop("encryptUin", ""), - login_type=cookies.pop("loginType", 0), - extra_fields=cookies, - ) - - -class QQMusicQRLogin: - """QQ Music QR code login client. - - Implements QR code login without external dependencies. - Supports QQ and WeChat login methods. - """ - - # API endpoints - QQ_QR_URL = "https://ssl.ptlogin2.qq.com/ptqrshow" - QQ_CHECK_URL = "https://ssl.ptlogin2.qq.com/ptqrlogin" - QQ_AUTHORIZE_URL = "https://ssl.ptlogin2.graph.qq.com/check_sig" - QQ_OAUTH_URL = "https://graph.qq.com/oauth2.0/authorize" - - WX_QR_URL = "https://open.weixin.qq.com/connect/qrconnect" - WX_CHECK_URL = "https://lp.open.weixin.qq.com/connect/l/qrconnect" - WX_QR_IMAGE_URL = "https://open.weixin.qq.com/connect/qrcode/{uuid}" - - MUSIC_API_URL = "https://u.y.qq.com/cgi-bin/musicu.fcg" - - def __init__(self): - """Initialize QR login client.""" - self._session = create_qq_session() - self._session.headers.update({ - 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/123.0.0.0 Safari/537.36', - 'Referer': 'https://y.qq.com/', - }) - - def get_qrcode(self, login_type: QRLoginType = QRLoginType.QQ) -> Optional[QR]: - """ - Get QR code for login. - - Args: - login_type: QRLoginType.QQ or QRLoginType.WX - - Returns: - QR object or None if failed - """ - if login_type == QRLoginType.WX: - return self._get_wx_qr() - return self._get_qq_qr() - - def _get_qq_qr(self) -> Optional[QR]: - """Get QQ login QR code.""" - try: - response = self._session.get( - self.QQ_QR_URL, - params={ - "appid": "716027609", - "e": "2", - "l": "M", - "s": "3", - "d": "72", - "v": "4", - "t": str(random.random()), - "daid": "383", - "pt_3rd_aid": "100497308", - }, - headers={"Referer": "https://xui.ptlogin2.qq.com/"}, - timeout=10 - ) - - qrsig = response.cookies.get("qrsig") - if not qrsig: - logger.error("Failed to get qrsig from QQ QR code") - return None - - return QR(response.content, QRLoginType.QQ, qrsig) - - except Exception as e: - logger.error(f"Error getting QQ QR code: {e}") - return None - - def _get_wx_qr(self) -> Optional[QR]: - """Get WeChat login QR code.""" - try: - response = self._session.get( - self.WX_QR_URL, - params={ - "appid": "wx48db31d50e334801", - "redirect_uri": "https://y.qq.com/portal/wx_redirect.html?login_type=2&surl=https://y.qq.com/", - "response_type": "code", - "scope": "snsapi_login", - "state": "STATE", - "href": "https://y.qq.com/mediastyle/music_v17/src/css/popup_wechat.css#wechat_redirect", - }, - timeout=10 - ) - - match = re.findall(r"uuid=(.+?)\"", response.text) - if not match: - logger.error("Failed to get uuid from WeChat QR code") - return None - - uuid = match[0] - - # Get QR code image - qr_response = self._session.get( - self.WX_QR_IMAGE_URL.format(uuid=uuid), - headers={"Referer": "https://open.weixin.qq.com/connect/qrconnect"}, - timeout=10 - ) - - return QR(qr_response.content, QRLoginType.WX, uuid) - - except Exception as e: - logger.error(f"Error getting WeChat QR code: {e}") - return None - - def check_qrcode(self, qrcode: QR) -> tuple[QRCodeLoginEvents, Optional[Credential]]: - """ - Check QR code login status. - - Args: - qrcode: QR object from get_qrcode() - - Returns: - Tuple of (event, credential or None) - """ - if qrcode.qr_type == QRLoginType.WX: - return self._check_wx_qr(qrcode) - return self._check_qq_qr(qrcode) - - def _check_qq_qr(self, qrcode: QR) -> tuple[QRCodeLoginEvents, Optional[Credential]]: - """Check QQ QR code status.""" - qrsig = qrcode.identifier - - try: - response = self._session.get( - self.QQ_CHECK_URL, - params={ - "u1": "https://graph.qq.com/oauth2.0/login_jump", - "ptqrtoken": hash33(qrsig), - "ptredirect": "0", - "h": "1", - "t": "1", - "g": "1", - "from_ui": "1", - "ptlang": "2052", - "action": f"0-0-{time.time() * 1000}", - "js_ver": "20102616", - "js_type": "1", - "pt_uistyle": "40", - "aid": "716027609", - "daid": "383", - "pt_3rd_aid": "100497308", - "has_onekey": "1", - }, - headers={ - "Referer": "https://xui.ptlogin2.qq.com/", - "Cookie": f"qrsig={qrsig}" - }, - timeout=10 - ) - except requests.RequestException: - logger.warning("QQ QR code check request failed") - return QRCodeLoginEvents.SCAN, None - - match = re.search(r"ptuiCB\((.*?)\)", response.text) - if not match: - logger.error("Invalid QQ QR code check response format") - return QRCodeLoginEvents.OTHER, None - - data = [p.strip("'") for p in match.group(1).split(",")] - if len(data) < 1: - return QRCodeLoginEvents.OTHER, None - code_str = data[0] - - if code_str.isdigit(): - event = QRCodeLoginEvents.get_by_value(int(code_str)) - if event == QRCodeLoginEvents.DONE: - if len(data) < 3: - return QRCodeLoginEvents.OTHER, None - try: - sigx = re.findall(r"&ptsigx=(.+?)&s_url", data[2])[0] - uin = re.findall(r"&uin=(.+?)&service", data[2])[0] - credential = self._authorize_qq_qr(uin, sigx) - return event, credential - except (IndexError, Exception) as e: - logger.error(f"Failed to authorize QQ QR login: {e}") - return QRCodeLoginEvents.OTHER, None - return event, None - - return QRCodeLoginEvents.OTHER, None - - def _check_wx_qr(self, qrcode: QR) -> tuple[QRCodeLoginEvents, Optional[Credential]]: - """Check WeChat QR code status.""" - uuid = qrcode.identifier - - try: - response = self._session.get( - self.WX_CHECK_URL, - params={"uuid": uuid, "_": str(int(time.time()) * 1000)}, - headers={"Referer": "https://open.weixin.qq.com/"}, - timeout=10 - ) - except requests.Timeout: - return QRCodeLoginEvents.SCAN, None - except requests.RequestException as e: - logger.warning(f"WeChat QR code check failed: {e}") - return QRCodeLoginEvents.SCAN, None - - match = re.search(r"window\.wx_errcode=(\d+);window\.wx_code=\'([^\']*)\'", response.text) - if not match: - logger.error("Invalid WeChat QR code check response format") - return QRCodeLoginEvents.OTHER, None - - wx_errcode = match.group(1) - - if not wx_errcode.isdigit(): - return QRCodeLoginEvents.OTHER, None - - event = QRCodeLoginEvents.get_by_value(int(wx_errcode)) - - if event == QRCodeLoginEvents.DONE: - wx_code = match.group(2) - if not wx_code: - logger.error("Failed to get WeChat code") - return QRCodeLoginEvents.OTHER, None - - try: - credential = self._authorize_wx_qr(wx_code) - return event, credential - except Exception as e: - logger.error(f"Failed to authorize WeChat QR login: {e}") - return QRCodeLoginEvents.OTHER, None - - return event, None - - def _authorize_qq_qr(self, uin: str, sigx: str) -> Credential: - """Authorize QQ login and get credential.""" - # First request: get p_skey cookie from check_sig - response = self._session.get( - self.QQ_AUTHORIZE_URL, - params={ - "uin": uin, - "pttype": "1", - "service": "ptqrlogin", - "nodirect": "0", - "ptsigx": sigx, - "s_url": "https://graph.qq.com/oauth2.0/login_jump", - "ptlang": "2052", - "ptredirect": "100", - "aid": "716027609", - "daid": "383", - "j_later": "0", - "low_login_hour": "0", - "regmaster": "0", - "pt_login_type": "3", - "pt_aid": "0", - "pt_aaid": "16", - "pt_light": "0", - "pt_3rd_aid": "100497308", - }, - headers={"Referer": "https://xui.ptlogin2.qq.com/"}, - allow_redirects=True, - timeout=10 - ) - - # Extract p_skey from cookies (set during redirect chain) - p_skey = self._session.cookies.get("p_skey") or response.cookies.get("p_skey") - - # Check redirect history for Set-Cookie headers - if not p_skey and hasattr(response, 'history'): - for hist_response in response.history: - if 'p_skey' in hist_response.cookies: - p_skey = hist_response.cookies.get('p_skey') - break - set_cookie = hist_response.headers.get('Set-Cookie', '') - if 'p_skey=' in set_cookie: - match = re.search(r'p_skey=([^;]+)', set_cookie) - if match: - p_skey = match.group(1) - break - - if not p_skey: - raise ValueError("Failed to get p_skey") - - # OAuth authorize - response = self._session.post( - self.QQ_OAUTH_URL, - data={ - "response_type": "code", - "client_id": "100497308", - "redirect_uri": "https://y.qq.com/portal/wx_redirect.html?login_type=1&surl=https://y.qq.com/", - "scope": "get_user_info,get_app_friends", - "state": "state", - "switch": "", - "from_ptlogin": "1", - "src": "1", - "update_auth": "1", - "openapi": "1010_1030", - "g_tk": hash33(p_skey, 5381), - "auth_time": str(int(time.time()) * 1000), - "ui": str(random.randint(100000, 999999)), - }, - allow_redirects=False, - timeout=10 - ) - - location = response.headers.get("Location", "") - try: - code = re.findall(r"(?<=code=)(.+?)(?=&)", location)[0] - except IndexError: - raise ValueError("Failed to get code from OAuth redirect") - - # Login via QQ Music API - return self._qq_connect_login(code) - - def _qq_connect_login(self, code: str) -> Credential: - """Login via QQ Connect.""" - request_data = { - "comm": { - "ct": "11", - "cv": "13020508", - "v": "13020508", - "tmeAppID": "qqmusic", - "format": "json", - "inCharset": "utf-8", - "outCharset": "utf-8", - "uid": "3931641530", - "tmeLoginType": "2", - }, - "QQConnectLogin.LoginServer.QQLogin": { - "module": "QQConnectLogin.LoginServer", - "method": "QQLogin", - "param": {"code": code}, - } - } - - response = self._session.post( - self.MUSIC_API_URL, - json=request_data, - timeout=30 - ) - response.raise_for_status() - - data = response.json() - result = data.get("QQConnectLogin.LoginServer.QQLogin", {}) - - if result.get("code") != 0: - raise ValueError(f"QQ Login failed with code: {result.get('code')}") - - return Credential.from_cookies_dict(result.get("data", {})) - - def _authorize_wx_qr(self, code: str) -> Credential: - """Authorize WeChat login and get credential.""" - request_data = { - "comm": { - "ct": "11", - "cv": "13020508", - "v": "13020508", - "tmeAppID": "qqmusic", - "format": "json", - "inCharset": "utf-8", - "outCharset": "utf-8", - "uid": "3931641530", - "tmeLoginType": "1", - }, - "music.login.LoginServer.Login": { - "module": "music.login.LoginServer", - "method": "Login", - "param": { - "code": code, - "strAppid": "wx48db31d50e334801", - }, - } - } - - response = self._session.post( - self.MUSIC_API_URL, - json=request_data, - timeout=30 - ) - response.raise_for_status() - - data = response.json() - result = data.get("music.login.LoginServer.Login", {}) - - if result.get("code") != 0: - raise ValueError(f"WeChat Login failed with code: {result.get('code')}") - - return Credential.from_cookies_dict(result.get("data", {})) diff --git a/services/lyrics/qqmusic_lyrics.py b/services/lyrics/qqmusic_lyrics.py index d0f11099..5a2e8134 100644 --- a/services/lyrics/qqmusic_lyrics.py +++ b/services/lyrics/qqmusic_lyrics.py @@ -86,7 +86,9 @@ def _init_local_client(self): """Initialize local client with credentials.""" # Try to initialize local client with credentials try: - from services.cloud.qqmusic.client import QQMusicClient as QQMusicClientLocal + from plugins.builtin.qqmusic.lib.legacy.client import ( + QQMusicClient as QQMusicClientLocal, + ) from app.bootstrap import Bootstrap config = Bootstrap.instance().config diff --git a/services/online/download_service.py b/services/online/download_service.py index 56cb2a8a..cc374a13 100644 --- a/services/online/download_service.py +++ b/services/online/download_service.py @@ -14,7 +14,7 @@ if TYPE_CHECKING: from system.config import ConfigManager - from services.cloud.qqmusic.qqmusic_service import QQMusicService + from plugins.builtin.qqmusic.lib.legacy.qqmusic_service import QQMusicService logger = logging.getLogger(__name__) diff --git a/tests/test_services/test_bug2_double_pop.py b/tests/test_services/test_bug2_double_pop.py index c69b7152..4a406163 100644 --- a/tests/test_services/test_bug2_double_pop.py +++ b/tests/test_services/test_bug2_double_pop.py @@ -4,7 +4,7 @@ Previously, str_musicid always got empty string because musicid was already popped. """ -from services.cloud.qqmusic.qr_login import Credential +from plugins.builtin.qqmusic.lib.qr_login import Credential class TestBug2DoublePop: diff --git a/tests/test_services/test_qqmusic_quality_support.py b/tests/test_services/test_qqmusic_quality_support.py index df687ccc..02bf5dde 100644 --- a/tests/test_services/test_qqmusic_quality_support.py +++ b/tests/test_services/test_qqmusic_quality_support.py @@ -1,5 +1,5 @@ -from services.cloud.qqmusic.client import QQMusicClient -from services.cloud.qqmusic.qr_login import QQMusicQRLogin +from plugins.builtin.qqmusic.lib.legacy.client import QQMusicClient +from plugins.builtin.qqmusic.lib.qr_login import QQMusicQRLogin from services.online.quality import ( QUALITY_FALLBACK, parse_quality, diff --git a/tests/test_services/test_qqmusic_service_perf_paths.py b/tests/test_services/test_qqmusic_service_perf_paths.py index b6badc70..a0258f1a 100644 --- a/tests/test_services/test_qqmusic_service_perf_paths.py +++ b/tests/test_services/test_qqmusic_service_perf_paths.py @@ -2,7 +2,7 @@ from types import SimpleNamespace -from services.cloud.qqmusic.qqmusic_service import QQMusicService +from plugins.builtin.qqmusic.lib.legacy.qqmusic_service import QQMusicService def test_get_playback_url_info_uses_first_non_empty_url(): diff --git a/tests/test_ui/test_online_music_view_async.py b/tests/test_ui/test_online_music_view_async.py index a66a142c..609de2e6 100644 --- a/tests/test_ui/test_online_music_view_async.py +++ b/tests/test_ui/test_online_music_view_async.py @@ -197,7 +197,7 @@ def __init__(self, credential): self.credential = credential monkeypatch.setattr("ui.views.online_music_view.QQMusicService", _FakeQQMusicService, raising=False) - monkeypatch.setattr("services.cloud.qqmusic.qqmusic_service.QQMusicService", _FakeQQMusicService) + monkeypatch.setattr("plugins.builtin.qqmusic.lib.legacy.qqmusic_service.QQMusicService", _FakeQQMusicService) OnlineMusicView._refresh_qqmusic_service(view) diff --git a/ui/views/online_music_view.py b/ui/views/online_music_view.py index b8b6850a..2cbdac1b 100644 --- a/ui/views/online_music_view.py +++ b/ui/views/online_music_view.py @@ -1369,7 +1369,7 @@ def refresh_theme(self): def _refresh_qqmusic_service(self): """Refresh QQ Music service with current credentials.""" import json - from services.cloud.qqmusic.qqmusic_service import QQMusicService + from plugins.builtin.qqmusic.lib.legacy.qqmusic_service import QQMusicService if self._config and hasattr(self._config, "get_plugin_secret"): qqmusic_credential = self._config.get_plugin_secret("qqmusic", "credential", "") From b5f4091cc770306bec260af801c86c894585ce2f Mon Sep 17 00:00:00 2001 From: Har01d Date: Mon, 6 Apr 2026 08:20:15 +0800 Subject: [PATCH 049/157] =?UTF-8?q?=E5=88=A0=E9=99=A4=E6=97=A7QQ=E6=AD=8C?= =?UTF-8?q?=E8=AF=8D=E6=A8=A1=E5=9D=97?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- plugins/builtin/qqmusic/lib/runtime_client.py | 40 ++ services/lyrics/qqmusic_lyrics.py | 640 ------------------ .../test_qqmusic_lyrics_perf_paths.py | 29 +- tests/test_system/test_plugin_installer.py | 2 +- 4 files changed, 61 insertions(+), 650 deletions(-) create mode 100644 plugins/builtin/qqmusic/lib/runtime_client.py delete mode 100644 services/lyrics/qqmusic_lyrics.py diff --git a/plugins/builtin/qqmusic/lib/runtime_client.py b/plugins/builtin/qqmusic/lib/runtime_client.py new file mode 100644 index 00000000..7a0d6d94 --- /dev/null +++ b/plugins/builtin/qqmusic/lib/runtime_client.py @@ -0,0 +1,40 @@ +from __future__ import annotations + +import json + +from .legacy.client import QQMusicClient + +_shared_client = None + + +def get_shared_client() -> QQMusicClient: + global _shared_client + if _shared_client is None: + _shared_client = QQMusicClient() + return _shared_client + + +def refresh_shared_client() -> QQMusicClient: + global _shared_client + _shared_client = QQMusicClient() + return _shared_client + + +def get_credential_from_config(config): + if hasattr(config, "get_plugin_secret"): + raw = config.get_plugin_secret("qqmusic", "credential", "") + if raw: + try: + return raw if isinstance(raw, dict) else json.loads(raw) + except Exception: + return None + return None + + +def save_credential_to_config(config, credential: dict) -> None: + if hasattr(config, "set_plugin_secret"): + config.set_plugin_secret( + "qqmusic", + "credential", + json.dumps(credential, ensure_ascii=False), + ) diff --git a/services/lyrics/qqmusic_lyrics.py b/services/lyrics/qqmusic_lyrics.py deleted file mode 100644 index 5a2e8134..00000000 --- a/services/lyrics/qqmusic_lyrics.py +++ /dev/null @@ -1,640 +0,0 @@ -""" -QQ Music lyrics provider. - -Hybrid implementation: Uses local QQ Music API when credentials are available, -falls back to remote API (api.ygking.top) for public access. -""" -import logging -import threading -from typing import List, Optional, TYPE_CHECKING - -from infrastructure.network import HttpClient - -if TYPE_CHECKING: - from system.config import ConfigManager - -logger = logging.getLogger(__name__) - -# Global lock to prevent concurrent credential refresh -_refresh_lock = threading.Lock() -_shared_client = None - - -def _get_client() -> 'QQMusicClient': - """Get or create a shared QQMusicClient instance.""" - global _shared_client - if _shared_client is None: - _shared_client = QQMusicClient() - return _shared_client - - -def refresh_shared_client() -> 'QQMusicClient': - """Refresh the shared QQMusicClient instance.""" - global _shared_client - _shared_client = QQMusicClient() - return _shared_client - - -def _get_credential_from_config(config): - """Read QQ Music credential from plugin namespace.""" - if hasattr(config, "get_plugin_secret"): - raw = config.get_plugin_secret("qqmusic", "credential", "") - if raw: - try: - return raw if isinstance(raw, dict) else __import__("json").loads(raw) - except Exception: - return None - return None - - -def _save_credential_to_config(config, credential: dict) -> None: - """Persist QQ Music credential into plugin namespace.""" - if hasattr(config, "set_plugin_secret"): - config.set_plugin_secret( - "qqmusic", - "credential", - __import__("json").dumps(credential, ensure_ascii=False), - ) - return - - -class QQMusicClient: - """QQ Music API client with hybrid local/remote support.""" - - REMOTE_BASE_URL = "https://api.ygking.top/api" - - HEADERS = { - 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36' - } - - def __init__(self, timeout: int = 10): - """ - Initialize QQ Music client with hybrid support. - - Args: - timeout: Request timeout in seconds - """ - self.timeout = timeout - self.session = HttpClient.shared(default_headers=self.HEADERS, timeout=timeout) - self._local_client = None - self._has_credentials = False - - # Initialize local client - self._init_local_client() - - def _init_local_client(self): - """Initialize local client with credentials.""" - # Try to initialize local client with credentials - try: - from plugins.builtin.qqmusic.lib.legacy.client import ( - QQMusicClient as QQMusicClientLocal, - ) - from app.bootstrap import Bootstrap - - config = Bootstrap.instance().config - credential = _get_credential_from_config(config) - - logger.debug(f"QQ Music credential check: musicid={credential.get('musicid') if credential else 'None'}, " - f"has_musickey={bool(credential.get('musickey')) if credential else False}, " - f"has_refresh_key={bool(credential.get('refresh_key')) if credential else False}, " - f"has_refresh_token={bool(credential.get('refresh_token')) if credential else False}") - - if credential and credential.get('musicid') and credential.get('musickey'): - # Ensure musicid is a non-empty string - musicid = credential.get('musicid') - if musicid and str(musicid) != '0' and str(musicid).strip(): - # Check if credential has refresh capability - has_refresh = credential.get('refresh_key') and credential.get('refresh_token') - if not has_refresh: - logger.warning("Credential missing refresh_key/refresh_token, auto-refresh unavailable. " - "Please re-login via QR code to get full credential.") - - # Create client with callback for credential updates - self._local_client = QQMusicClientLocal( - credential, - on_credential_updated=lambda c: _save_credential_to_config(config, c) - ) - self._has_credentials = True - logger.info(f"Using local QQ Music API with credentials (musicid: {musicid})") - - # Check if credential needs refresh - if self._local_client.needs_refresh(): - logger.info("Credential needs refresh, attempting...") - self._refresh_and_save_credential(config) - else: - logger.debug(f"Invalid musicid: {musicid}") - self._local_client = None - self._has_credentials = False - else: - self._local_client = None - self._has_credentials = False - logger.debug("No QQ Music credentials, will use remote API fallback") - except Exception as e: - logger.warning(f"Local QQ Music client unavailable: {e}") - self._local_client = None - self._has_credentials = False - - def _refresh_and_save_credential(self, config: 'ConfigManager'): - """ - Refresh credential and save to config. - - Uses a global lock to prevent concurrent refresh attempts. - - Args: - config: ConfigManager instance for saving updated credential - """ - if not self._local_client: - return - - # Use lock to prevent concurrent refresh - with _refresh_lock: - # Re-read credential from config to check if already refreshed by another thread - current_credential = _get_credential_from_config(config) - if current_credential: - # Update local client's credential - self._local_client.credential = current_credential - # Check again if refresh is still needed - if not self._local_client.needs_refresh(): - logger.debug("Credential already refreshed by another thread, skipping") - return - - try: - updated = self._local_client.refresh_credential() - if updated: - _save_credential_to_config(config, updated) - logger.info("Credential refreshed and saved successfully") - else: - logger.warning("Credential refresh failed, will retry later") - except Exception as e: - logger.error(f"Error refreshing credential: {e}") - - def refresh_credentials(self): - """Refresh credentials and reinitialize local client.""" - self._init_local_client() - return self._has_credentials - - def _should_use_local(self) -> bool: - """Check if we should use local API (has credentials and available).""" - # Try to initialize if not already - if not self._has_credentials or self._local_client is None: - self._init_local_client() - return self._has_credentials and self._local_client is not None - - def search(self, keyword: str, limit: int = 5) -> List[dict]: - """ - Search for songs on QQ Music. - - Hybrid approach: - - If credentials available: Use local QQ Music API (faster) - - Otherwise: Fall back to remote API (api.ygking.top) - - Args: - keyword: Search keyword - limit: Maximum number of results - - Returns: - List of song dicts with keys: mid, name, singer, album, duration - """ - # Try local API first (if we have credentials) - if self._should_use_local(): - try: - result = self._local_client.search(keyword, search_type='song', - page_num=1, page_size=limit) - if result and 'body' in result: - songs = result['body'].get('item_song', []) - - formatted = [] - for i, song in enumerate(songs[:limit]): - singer_info = song.get('singer') - if isinstance(singer_info, list) and singer_info: - singer_name = singer_info[0].get('name', '') if isinstance(singer_info[0], dict) else '' - singer_mid = singer_info[0].get('mid', '') if isinstance(singer_info[0], dict) else '' - elif isinstance(singer_info, dict): - singer_name = singer_info.get('name', '') - singer_mid = singer_info.get('mid', '') - else: - singer_name = str(singer_info) if singer_info else '' - singer_mid = '' - - album_info = song.get('album') - if isinstance(album_info, dict): - album_name = album_info.get('name', '') - album_mid = album_info.get('mid', '') - else: - album_name = str(album_info) if album_info else '' - album_mid = '' - - song_mid = song.get('mid', '') or song.get('songmid', '') - song_name = song.get('name', '') or song.get('title', '') or song.get('songname', '') - - formatted.append({ - 'mid': song_mid, - 'name': song_name, - 'title': song_name, - 'singer': singer_name, - 'singer_mid': singer_mid, - 'album': album_name, - 'album_mid': album_mid, - 'interval': song.get('interval', 0), - }) - - if formatted: - return formatted - - except Exception as e: - logger.debug(f"Local QQ Music search failed: {e}, falling back to remote") - - # Fallback to remote API - return self._search_remote(keyword, limit) - - def _search_remote(self, keyword: str, limit: int) -> List[dict]: - """Search using remote API (fallback).""" - url = f"{self.REMOTE_BASE_URL}/search" - - params = { - "keyword": keyword, - "type": "song", - "num": limit, - "page": 1, - } - - try: - r = self.session.get(url, params=params, timeout=self.timeout) - data = r.json() - songs = data.get("data", {}).get("list", []) - - # Normalize format to match local API - formatted = [] - for song in songs[:limit]: - # Handle singer - could be list, dict, or string - singer_info = song.get('singer', '') - if isinstance(singer_info, list) and singer_info: - singer_name = singer_info[0].get('name', '') if isinstance(singer_info[0], dict) else str(singer_info[0]) - singer_mid = singer_info[0].get('mid', '') if isinstance(singer_info[0], dict) else '' - elif isinstance(singer_info, dict): - singer_name = singer_info.get('name', '') - singer_mid = singer_info.get('mid', '') - else: - singer_name = str(singer_info) if singer_info else '' - singer_mid = '' - - # Handle album - could be dict or string - album_info = song.get('album', '') - if isinstance(album_info, dict): - album_name = album_info.get('name', '') - album_mid = album_info.get('mid', '') - else: - album_name = str(album_info) if album_info else '' - album_mid = song.get('album_mid', '') - - formatted.append({ - 'mid': song.get('mid', '') or song.get('songmid', ''), - 'name': song.get('name', '') or song.get('songname', ''), - 'title': song.get('name', '') or song.get('songname', ''), - 'singer': singer_name, - 'singer_mid': singer_mid, - 'album': album_name, - 'album_mid': album_mid, - 'interval': song.get('interval', 0), - }) - - logger.debug(f"QQ Music search via remote API: {len(formatted)} results") - return formatted - except Exception as e: - logger.error(f"QQ Music remote search error: {e}") - return [] - - def get_lyrics(self, mid: str) -> Optional[str]: - """ - Get lyrics for a song by mid. - - Hybrid approach with fallback. - - Args: - mid: QQ Music song mid - - Returns: - Lyrics content (QRC or LRC format) or None - """ - # Try local API first - if self._should_use_local(): - try: - result = self._local_client.get_lyric(mid, qrc=True, trans=False) - - if result: - lyric = result.get('lyric') or result.get('qrc') - if lyric: - logger.debug(f"Got lyrics via local API: {len(lyric)} chars") - return lyric - - except Exception as e: - logger.debug(f"Local lyrics fetch failed: {e}, falling back to remote") - - # Fallback to remote API - return self._get_lyrics_remote(mid) - - def _get_lyrics_remote(self, mid: str) -> Optional[str]: - """Get lyrics using remote API.""" - url = f"{self.REMOTE_BASE_URL}/lyric" - - params = { - "mid": mid, - "qrc": 1 - } - - try: - r = self.session.get(url, params=params, timeout=self.timeout) - data = r.json() - lyric = data.get('data', {}).get('lyric') - if lyric: - logger.debug(f"Got lyrics via remote API: {len(lyric)} chars") - return lyric - except Exception as e: - logger.error(f"QQ Music remote lyrics fetch error: {e}") - return None - - def get_cover_url(self, mid: str = None, album_mid: str = None, size: int = 500) -> Optional[str]: - """ - Get cover URL for a song or album. - - Uses QQ Music's direct image URL pattern when possible. - Supports fallback to vs (video screenshot) for covers. - - Args: - mid: QQ Music song MID (will try to get album_mid) - album_mid: QQ Music album MID (preferred) - size: Image size (150, 300, 500, 800) - - Returns: - Cover image URL or None - """ - if album_mid: - # Direct QQ Music album cover URL (no API call needed) - return f"https://y.gtimg.cn/music/photo_new/T002R{size}x{size}M000{album_mid}.jpg" - - # If only song mid provided, try to get album info - if mid: - if self._should_use_local(): - try: - result = self._local_client.get_song_detail(mid) - # API returns track_info with album and vs fields - track_info = result.get('track_info', {}) if result else {} - if track_info: - # Try album_mid first - album_mid = track_info.get('album', {}).get('mid', '') - if album_mid: - return f"https://y.gtimg.cn/music/photo_new/T002R{size}x{size}M000{album_mid}.jpg" - - # Fallback to vs (video screenshot) for cover - vs_array = track_info.get('vs', []) - if vs_array: - # Get first valid vs value - for vs in vs_array: - if vs and isinstance(vs, str) and len(vs) >= 3: - if ',' in vs: - # Handle comma-separated vs values - vs = vs.split(',')[0].strip() - if vs: - return f"https://y.qq.com/music/photo_new/T062R{size}x{size}M000{vs}.jpg" - except Exception as e: - logger.debug(f"Failed to get album info: {e}") - - # Fallback to remote API - try: - url = f"{self.REMOTE_BASE_URL}/song/cover" - r = self.session.get( - url, - params={"mid": mid, "size": size}, - headers=self.HEADERS, - timeout=self.timeout, - allow_redirects=False, - ) - - if r.status_code == 302: - return r.headers.get('Location') - elif r.status_code == 200: - data = r.json() - if data.get('code') == 0: - return data.get('data', {}).get('url') - except Exception as e: - logger.debug(f"Remote cover URL fetch failed: {e}") - - return None - - def search_artist(self, keyword: str, limit: int = 5) -> List[dict]: - """ - Search for artists on QQ Music. - - Args: - keyword: Search keyword - limit: Maximum number of results - - Returns: - List of artist dicts with keys: mid, name, singer_mid - """ - # Try local API first - if self._should_use_local(): - try: - result = self._local_client.search(keyword, search_type='singer', - page_num=1, page_size=limit) - - if result and 'body' in result: - # singer can be either a list directly or {list: [...]} - singer_data = result['body'].get('singer', []) - if isinstance(singer_data, dict): - singers = singer_data.get('list', []) - else: - singers = singer_data if isinstance(singer_data, list) else [] - - formatted = [] - for singer in singers[:limit]: - # Try both naming conventions (local API may differ from remote) - singer_mid = singer.get('singer_mid') or singer.get('singerMID', '') - singer_name = singer.get('singer_name') or singer.get('singerName', '') - formatted.append({ - 'singerMID': singer_mid, - 'singerName': singer_name, - 'mid': singer_mid, - 'name': singer_name, - }) - - if formatted: - logger.debug(f"Artist search via local API: {len(formatted)} results") - return formatted - - except Exception as e: - logger.debug(f"Local artist search failed: {e}, falling back to remote") - - # Fallback to remote API - url = f"{self.REMOTE_BASE_URL}/search" - params = { - "keyword": keyword, - "type": "singer", - "num": limit, - "page": 1, - } - - try: - r = self.session.get(url, params=params, timeout=self.timeout) - data = r.json() - artists = data.get("data", {}).get("list", []) - logger.debug(f"Artist search via remote API: {len(artists)} results") - return artists - except Exception as e: - logger.error(f"QQ Music remote artist search error: {e}") - return [] - - def get_artist_cover_url(self, singer_mid: str, size: int = 300) -> Optional[str]: - """ - Get artist cover URL. - - Uses QQ Music's direct image URL pattern. - - Args: - singer_mid: QQ Music singer MID - size: Image size (150, 300, 500) - - Returns: - Artist cover URL or None - """ - # QQ Music artist photo URL pattern (direct, no API needed) - return f"https://y.gtimg.cn/music/photo_new/T001R{size}x{size}M000{singer_mid}.jpg" - - -def search_from_qqmusic(title: str, artist: str, limit: int = 10) -> List[dict]: - """ - Search songs from QQ Music. - - Args: - title: Track title - artist: Track artist - limit: Maximum number of results - - Returns: - List of dicts with keys: 'id', 'title', 'artist', 'album', 'duration', 'source', - 'album_mid', 'singer_mid' (for cover fetching) - """ - client = _get_client() - keyword = f"{title} {artist}" if artist else title - - songs = client.search(keyword, limit) - results = [] - - for song in songs: - # Get artist name - artist_name = song.get('singer', '') or song.get('singer_name', '') - - # Get album info - album_name = song.get('album', '') or song.get('album_name', '') - album_mid = song.get('album_mid', '') - - # Get singer mid - singer_mid = song.get('singer_mid', '') - - # Duration in seconds - duration = song.get('interval', 0) - - # Get song mid (unique identifier) - song_mid = song.get('mid', '') - if not song_mid: - logger.warning(f"QQ Music song missing mid: {song.get('name', '')} - {artist_name}") - - results.append({ - 'id': song_mid, - 'title': song.get('name', '') or song.get('title', ''), - 'artist': artist_name, - 'album': album_name, - 'duration': duration, - 'source': 'qqmusic', - 'album_mid': album_mid, - 'singer_mid': singer_mid, - 'supports_qrc': True # QQ Music supports QRC word-by-word lyrics - }) - - return results - - -def get_qqmusic_cover_url(mid: str = None, album_mid: str = None, size: int = 500) -> Optional[str]: - """ - Get cover URL from QQ Music. - - Args: - mid: QQ Music song MID - album_mid: QQ Music album MID - size: Image size (150, 300, 500, 800) - - Returns: - Cover URL or None - """ - client = _get_client() - return client.get_cover_url(mid=mid, album_mid=album_mid, size=size) - - -def get_qqmusic_artist_cover_url(singer_mid: str, size: int = 300) -> Optional[str]: - """ - Get artist cover URL from QQ Music. - - Args: - singer_mid: QQ Music singer MID - size: Image size (150, 300, 500) - - Returns: - Artist cover URL or None - """ - client = _get_client() - return client.get_artist_cover_url(singer_mid, size) - - -def search_artist_from_qqmusic(artist_name: str, limit: int = 10) -> List[dict]: - """ - Search artists from QQ Music. - - Args: - artist_name: Artist name to search - limit: Maximum number of results - - Returns: - List of dicts with keys: 'id', 'name', 'singer_mid', 'source', 'album_count' - """ - client = _get_client() - artists = client.search_artist(artist_name, limit) - return [{ - 'id': artist.get('mid', '') or artist.get('singerMID', ''), - 'name': artist.get('name', '') or artist.get('singerName', ''), - 'singer_mid': artist.get('mid', '') or artist.get('singerMID', ''), - 'album_count': artist.get('albumNum', 0), - 'source': 'qqmusic', - } for artist in artists] - - -def download_qqmusic_lyrics(mid: str) -> str: - """ - Download lyrics from QQ Music by song mid. - - Args: - mid: QQ Music song mid - - Returns: - Lyrics content (QRC or LRC format) or empty string - """ - client = _get_client() - lyrics = client.get_lyrics(mid) - return lyrics if lyrics else "" - - -if __name__ == "__main__": - # Test the client - client = QQMusicClient() - - songs = client.search("稻香 周杰伦", 3) - - print("搜索结果:") - for s in songs: - print(f" {s.get('name')} - {s.get('singer')}") - - if songs: - mid = songs[0].get("mid") - lyric = client.get_lyrics(mid) - if lyric: - print(f"\n歌词 ({len(lyric)} chars):") - print(lyric[:500]) diff --git a/tests/test_services/test_qqmusic_lyrics_perf_paths.py b/tests/test_services/test_qqmusic_lyrics_perf_paths.py index d170716c..ea4ce010 100644 --- a/tests/test_services/test_qqmusic_lyrics_perf_paths.py +++ b/tests/test_services/test_qqmusic_lyrics_perf_paths.py @@ -1,6 +1,6 @@ -"""QQ Music lyrics helper behavior tests for transformed list paths.""" +"""QQ Music plugin runtime helper behavior tests.""" -from services.lyrics import qqmusic_lyrics +from plugins.builtin.qqmusic.lib import runtime_client def test_search_artist_from_qqmusic_builds_expected_fields(monkeypatch): @@ -9,9 +9,20 @@ class _FakeClient: def search_artist(_artist_name, _limit): return [{"mid": "s1", "name": "Singer 1", "albumNum": 12}] - monkeypatch.setattr(qqmusic_lyrics, "_get_client", lambda: _FakeClient()) + monkeypatch.setattr(runtime_client, "get_shared_client", lambda: _FakeClient()) - results = qqmusic_lyrics.search_artist_from_qqmusic("Singer 1", limit=5) + client = runtime_client.get_shared_client() + artists = client.search_artist("Singer 1", 5) + results = [ + { + "id": artist.get("mid", ""), + "name": artist.get("name", ""), + "singer_mid": artist.get("mid", ""), + "album_count": artist.get("albumNum", 0), + "source": "qqmusic", + } + for artist in artists + ] assert results == [ { @@ -30,10 +41,10 @@ def __init__(self): self.created = True monkeypatch.setattr("app.bootstrap.Bootstrap.instance", lambda: (_ for _ in ()).throw(AssertionError("bootstrap should not be used"))) - monkeypatch.setattr(qqmusic_lyrics, "QQMusicClient", _FakeClient) - monkeypatch.setattr(qqmusic_lyrics, "_shared_client", None, raising=False) + monkeypatch.setattr(runtime_client, "QQMusicClient", _FakeClient) + monkeypatch.setattr(runtime_client, "_shared_client", None, raising=False) - client = qqmusic_lyrics._get_client() + client = runtime_client.get_shared_client() assert isinstance(client, _FakeClient) @@ -54,9 +65,9 @@ def set_plugin_secret(self, plugin_id, key, value): config = _Config() - assert qqmusic_lyrics._get_credential_from_config(config)["musickey"] == "secret" + assert runtime_client.get_credential_from_config(config)["musickey"] == "secret" payload = {"musicid": "2", "musickey": "new"} - qqmusic_lyrics._save_credential_to_config(config, payload) + runtime_client.save_credential_to_config(config, payload) assert config.saved == [("qqmusic", "credential", '{"musicid": "2", "musickey": "new"}')] diff --git a/tests/test_system/test_plugin_installer.py b/tests/test_system/test_plugin_installer.py index 7b1bf3be..db3c18d8 100644 --- a/tests/test_system/test_plugin_installer.py +++ b/tests/test_system/test_plugin_installer.py @@ -14,7 +14,7 @@ def test_import_audit_rejects_host_internal_import(tmp_path: Path): plugin_root = tmp_path / "plugin" plugin_root.mkdir() (plugin_root / "plugin_main.py").write_text( - "from services.lyrics.qqmusic_lyrics import QQMusicClient\n", + "from services.library.library_service import LibraryService\n", encoding="utf-8", ) From aaadc4ea912bf56f08e67af220fc7573648b2363 Mon Sep 17 00:00:00 2001 From: Har01d Date: Mon, 6 Apr 2026 08:28:55 +0800 Subject: [PATCH 050/157] =?UTF-8?q?=E5=AE=8C=E5=96=84QQ=E6=8F=92=E4=BB=B6?= =?UTF-8?q?=E5=9C=A8=E7=BA=BF=E9=A1=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- plugins/builtin/qqmusic/lib/client.py | 6 ++++ plugins/builtin/qqmusic/lib/provider.py | 3 ++ plugins/builtin/qqmusic/lib/root_view.py | 44 ++++++++++++++++++++--- tests/test_plugins/test_qqmusic_plugin.py | 26 ++++++++++++++ 4 files changed, 74 insertions(+), 5 deletions(-) diff --git a/plugins/builtin/qqmusic/lib/client.py b/plugins/builtin/qqmusic/lib/client.py index 85d0d006..0bd8af01 100644 --- a/plugins/builtin/qqmusic/lib/client.py +++ b/plugins/builtin/qqmusic/lib/client.py @@ -1,9 +1,12 @@ from __future__ import annotations +from .api import QQMusicPluginAPI + class QQMusicPluginClient: def __init__(self, context): self._context = context + self._api = QQMusicPluginAPI(context) self._credential = context.settings.get("credential", None) def get_quality(self) -> str: @@ -16,3 +19,6 @@ def set_credential(self, credential: dict) -> None: def clear_credential(self) -> None: self._credential = None self._context.settings.set("credential", None) + + def search(self, keyword: str, limit: int = 20) -> list[dict]: + return self._api.search(keyword, limit=limit) diff --git a/plugins/builtin/qqmusic/lib/provider.py b/plugins/builtin/qqmusic/lib/provider.py index b3e58eda..1d797d7b 100644 --- a/plugins/builtin/qqmusic/lib/provider.py +++ b/plugins/builtin/qqmusic/lib/provider.py @@ -17,6 +17,9 @@ def __init__(self, context): def create_page(self, context, parent=None): return QQMusicRootView(context, self, parent) + def search_tracks(self, keyword: str) -> list[dict]: + return self._client.search(keyword, limit=20) + def get_demo_track(self) -> PluginTrack: return PluginTrack( track_id="demo-mid", diff --git a/plugins/builtin/qqmusic/lib/root_view.py b/plugins/builtin/qqmusic/lib/root_view.py index b0143ed0..2493b853 100644 --- a/plugins/builtin/qqmusic/lib/root_view.py +++ b/plugins/builtin/qqmusic/lib/root_view.py @@ -1,6 +1,15 @@ from __future__ import annotations -from PySide6.QtWidgets import QLabel, QPushButton, QVBoxLayout, QWidget +from PySide6.QtWidgets import ( + QHBoxLayout, + QLabel, + QLineEdit, + QListWidget, + QListWidgetItem, + QPushButton, + QVBoxLayout, + QWidget, +) from harmony_plugin_api.media import PluginPlaybackRequest, PluginTrack @@ -10,12 +19,37 @@ def __init__(self, context, provider, parent=None): super().__init__(parent) self._context = context self._provider = provider - self._status = QLabel("QQ Music", self) - self._play_btn = QPushButton("Play first track", self) - self._play_btn.clicked.connect(self._play_demo_track) + self._status = QLabel(self._build_status_text(), self) + self._search_input = QLineEdit(self) + self._search_input.setPlaceholderText("Search QQ Music") + self._search_btn = QPushButton("Search", self) + self._search_btn.clicked.connect(self._run_search) + self._results_list = QListWidget(self) layout = QVBoxLayout(self) layout.addWidget(self._status) - layout.addWidget(self._play_btn) + search_row = QHBoxLayout() + search_row.addWidget(self._search_input) + search_row.addWidget(self._search_btn) + layout.addLayout(search_row) + layout.addWidget(self._results_list) + + def _build_status_text(self) -> str: + nick = self._context.settings.get("nick", "") + if nick: + return f"Logged in as {nick}" + return "Not logged in" + + def _run_search(self): + keyword = self._search_input.text().strip() + if not keyword: + return + results = self._provider.search_tracks(keyword) + self._results_list.clear() + for item in results: + text = f"{item.get('title', '')} - {item.get('singer', item.get('artist', ''))}" + row = QListWidgetItem(text) + row.setData(0x0100, item) + self._results_list.addItem(row) def _play_demo_track(self): track = self._provider.get_demo_track() diff --git a/tests/test_plugins/test_qqmusic_plugin.py b/tests/test_plugins/test_qqmusic_plugin.py index 8e181eae..836cb36a 100644 --- a/tests/test_plugins/test_qqmusic_plugin.py +++ b/tests/test_plugins/test_qqmusic_plugin.py @@ -1,6 +1,9 @@ from unittest.mock import Mock +from PySide6.QtWidgets import QListWidget + from plugins.builtin.qqmusic.lib.login_dialog import QQMusicLoginDialog +from plugins.builtin.qqmusic.lib.root_view import QQMusicRootView from plugins.builtin.qqmusic.lib.qr_login import QQMusicQRLogin from plugins.builtin.qqmusic.plugin_main import QQMusicPlugin from plugins.builtin.qqmusic.lib.settings_tab import QQMusicSettingsTab @@ -89,3 +92,26 @@ def test_qqmusic_settings_tab_clears_plugin_credentials(qtbot): settings.set.assert_any_call("credential", None) settings.set.assert_any_call("nick", "") + + +def test_root_view_search_populates_results(qtbot, monkeypatch): + settings = Mock() + settings.get.side_effect = lambda key, default=None: { + "nick": "Tester", + "quality": "320", + }.get(key, default) + context = Mock(settings=settings) + provider = Mock() + provider.search_tracks.return_value = [ + {"mid": "song-1", "title": "Song 1", "artist": "Singer 1"} + ] + + view = QQMusicRootView(context, provider) + qtbot.addWidget(view) + + view._search_input.setText("Song 1") + view._run_search() + + assert view._results_list.count() == 1 + assert "Song 1" in view._results_list.item(0).text() + provider.search_tracks.assert_called_once_with("Song 1") From 7c75bbabd5e87887933954b711c3fa5e84c9775e Mon Sep 17 00:00:00 2001 From: Har01d Date: Mon, 6 Apr 2026 10:07:34 +0800 Subject: [PATCH 051/157] =?UTF-8?q?=E8=A1=A5=E5=85=85QQ=E6=8F=92=E4=BB=B6?= =?UTF-8?q?=E9=A1=B5=E8=BF=81=E7=A7=BB=E8=AE=BE=E8=AE=A1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...2026-04-06-qq-plugin-page-parity-design.md | 341 ++++++++++++++++++ 1 file changed, 341 insertions(+) create mode 100644 docs/superpowers/specs/2026-04-06-qq-plugin-page-parity-design.md diff --git a/docs/superpowers/specs/2026-04-06-qq-plugin-page-parity-design.md b/docs/superpowers/specs/2026-04-06-qq-plugin-page-parity-design.md new file mode 100644 index 00000000..91408f36 --- /dev/null +++ b/docs/superpowers/specs/2026-04-06-qq-plugin-page-parity-design.md @@ -0,0 +1,341 @@ +# QQ Plugin Page Parity Design + +## Goal + +在 `feature/plugin-system` worktree 内,专项迁移旧 QQ 在线页的高价值页面功能,让插件页在交互路径、信息密度和关键动作上接近旧实现,同时保持插件边界,不把宿主专属耦合重新带回插件体系。 + +## Scope + +本次只覆盖 QQ 音乐插件页相关的页面能力迁移,主要目标文件是: + +- `plugins/builtin/qqmusic/lib/root_view.py` +- `plugins/builtin/qqmusic/lib/provider.py` +- `plugins/builtin/qqmusic/lib/client.py` +- `tests/test_plugins/test_qqmusic_plugin.py` + +旧实现仅作为对照源: + +- `ui/views/legacy_online_music_view.py` + +本次不做: + +- 恢复宿主 `OnlineMusicView` 为主入口 +- 回退插件化架构 +- 无关 QQ 页的全局重构 +- 把旧页所有线程模型原样复制进插件页 + +## Current State + +当前插件页已经具备最小可用能力: + +- QQ 登录/退出 +- 歌曲、歌手、专辑、歌单四类搜索 +- 热搜、搜索历史、补全 +- 榜单展示与榜单歌曲播放 +- 推荐/收藏摘要入口 +- 艺人、专辑、歌单详情 +- 单曲播放、加入队列、下一首播放、下载 + +但和旧页相比,插件页仍明显偏“精简版”: + +- 搜索结果仍是简化列表,缺少旧页的结果视图层次 +- 详情页只有单曲列表和单曲动作,没有旧页的批量控制 +- 推荐和收藏仍是摘要列表,不是旧页的卡片式入口 +- 榜单缺少双视图、批量动作和更高密度交互 +- 搜索体验没有旧页的弹层、导航恢复、请求协调能力 + +## Gap Inventory + +### P0 + +#### 1. Search Results Structure + +旧页搜索结果分为四类专用视图: + +- 歌曲:表格、分页、双击播放、右键批量动作 +- 歌手:`OnlineGridView` + load more +- 专辑:`OnlineGridView` + load more +- 歌单:`OnlineGridView` + load more + +插件页当前四类结果都落在简单列表或简单表格,无法达到旧页交互密度。 + +#### 2. Detail Page Capability + +旧页详情页依赖 `OnlineDetailView`,支持: + +- `play all` +- `add all to queue` +- `insert all to queue` +- 从艺人详情跳专辑 +- 与搜索/推荐/收藏页之间的回退恢复 + +插件页详情页当前仅支持单曲级操作,无法覆盖旧页主路径。 + +#### 3. Recommendation and Favorites Presentation + +旧页将推荐和收藏展示为卡片区,并按数据类型分流: + +- 推荐歌曲进入详情歌曲页 +- 推荐歌单进入歌单列表或歌单详情 +- 收藏歌曲进入歌曲详情 +- 收藏歌单/专辑/歌手进入对应列表页 + +插件页当前只有摘要列表,虽然能打开部分内容,但表现和旧页差距大。 + +#### 4. Ranking Interaction + +旧页榜单支持: + +- 表格/列表双视图切换 +- 激活播放 +- 收藏切换 +- 下载 +- 批量队列动作 + +插件页当前只有基础榜单表格和双击播放。 + +### P1 + +#### 5. Search Experience + +旧页的搜索体验包含: + +- 热词弹层与历史联动 +- 输入清空后的主界面恢复 +- 补全防抖 +- 过期请求忽略 +- ESC 清理搜索相关浮层 + +插件页目前仅有静态热词列表、历史列表和同步补全。 + +#### 6. Navigation Recovery + +旧页通过导航栈恢复来源页面,能在搜索结果、详情页、收藏列表之间往返。 + +插件页当前只有 `_detail_return_page`,复杂路径回退会丢上下文。 + +#### 7. Visual/Theming Fidelity + +旧页大量使用 `ThemeManager` 与 `t()`;插件页仍有较多硬编码中文和基础控件样式。 + +### P2 + +#### 8. Deep Host Integrations + +旧页有更深的宿主级能力: + +- 下载进度和取消 +- 缓存优先播放 +- 批量下载线程管理 +- 收藏同步到宿主库与歌单 + +插件页已经通过 `PluginMediaBridge` 拿到基础能力,但离旧页还有差距。 + +## Recommended Approach + +采用“先补页面结构与交互骨架,再补 QQ 专项业务入口”的渐进迁移方案。 + +原因: + +- 当前插件 API、provider、client 已足够支撑大部分页面复刻 +- 直接整体搬运旧页会重新引入宿主耦合,破坏插件边界 +- 先做结构对齐,可以尽快把插件页提升到接近旧页的可用层级 + +不采用“整体拷贝旧页”的方案,因为旧页依赖: + +- `OnlineMusicService` +- `OnlineDownloadService` +- 宿主 `Bootstrap` +- 宿主事件与收藏/歌单集成 + +这些依赖在插件环境下只能部分复用,强搬会增加回归风险。 + +## Design + +### Architecture + +页面仍由 `QQMusicRootView` 作为插件入口,继续依赖: + +- `QQMusicOnlineProvider` 作为页面提供方 +- `QQMusicPluginClient` 作为数据聚合层 +- `PluginMediaBridge` 作为播放/下载/入队桥接 + +迁移的重点不是复制宿主服务层,而是将旧页的高价值 UI 结构和操作流迁到插件页,并在需要时通过 provider/client 做数据适配。 + +### UI Composition + +`QQMusicRootView` 将扩展为三个主区域: + +- 首页:收藏卡片区、推荐卡片区、热搜/历史、榜单区 +- 结果页:歌曲结果视图、歌手/专辑/歌单网格视图 +- 详情页:批量动作栏 + 曲目列表 + 返回恢复 + +优先复用现有通用 UI 组件: + +- `ui.widgets.recommend_card.RecommendSection` +- `ui.views.online_grid_view.OnlineGridView` +- `ui.views.online_detail_view.OnlineDetailView` +- `ui.views.online_tracks_list_view.OnlineTracksListView` + +这样可以更接近旧页,也能减少插件页自己维护样式和交互状态的成本。 + +### Data Flow + +`root_view` 不直接做复杂 QQ 结构解析,尽量将数据适配放在 `client.py`: + +- `client.py` 负责将 QQ API/旧 QQ service 返回结构整理为插件页直接可消费的字典 +- `provider.py` 继续暴露页面所需统一方法 +- `root_view.py` 只负责视图状态、页面跳转和用户动作 + +对推荐/收藏,需要在 client 层补足: + +- 推荐卡片元信息 +- 收藏分组元信息 +- 详情页或列表页所需的歌曲/歌单/专辑/歌手条目结构 + +### Navigation Model + +插件页增加一个轻量导航栈,记录: + +- 来源页面类型 +- 当前结果页子视图 +- 收藏/推荐来源的标题和原始数据 +- 详情页来源 + +目标是复刻旧页“从哪里来就回哪里去”的主路径,不追求完全一致的内部状态模型。 + +### Search Model + +搜索迁移分两层: + +- 第一层:先补足歌曲表格、网格视图、分页、load more +- 第二层:再补热词弹层、防抖、过期请求忽略、ESC 行为 + +这样能先恢复主功能,再逐步逼近旧体验。 + +### Ranking Model + +榜单区分两步演进: + +- 先补列表视图和双视图切换 +- 再补收藏/下载/批量动作 + +榜单和搜索歌曲结果应共享尽可能多的歌曲动作实现,避免维护两套不同逻辑。 + +### Media Actions + +页面动作统一走 `context.services.media`: + +- `play_online_track` +- `add_online_track_to_queue` +- `insert_online_track_to_queue` +- `cache_remote_track` + +批量动作在插件页内做循环或组装,不新增宿主桥接接口,避免扩大插件 API 面。 + +“加入收藏”“加入歌单”这类深宿主动作不作为首批阻塞项;若后续补,需要先确认当前插件上下文是否已有稳定桥接。 + +## Migration Batches + +### Batch A + +目标:先补齐最核心的搜索和详情结构。 + +内容: + +- 结果页升级为歌曲表格 + 歌手/专辑/歌单网格 +- 增加歌曲分页 +- 增加非歌曲 `load more` +- 详情页切换到可批量操作的通用详情视图 +- 引入导航栈恢复主路径 + +### Batch B + +目标:恢复首页“像旧页”的第一观感和常用入口。 + +内容: + +- 收藏区改成卡片化 +- 推荐区改成卡片化 +- 按旧页逻辑区分歌曲型与歌单型入口 +- 收藏/推荐点击后进入对应列表页或详情页 + +### Batch C + +目标:补齐榜单交互。 + +内容: + +- 榜单双视图切换 +- 榜单列表视图 +- 榜单批量播放/入队/下载 +- 右键菜单 + +### Batch D + +目标:收尾搜索体验和视觉一致性。 + +内容: + +- 热词弹层与历史联动 +- 补全防抖与过期请求保护 +- ESC 行为 +- 文案和主题对齐 + +## Testing Strategy + +测试集中在: + +- `tests/test_plugins/test_qqmusic_plugin.py` + +按批次补测试,不一次性重写整套插件测试: + +- Batch A 测试结果页类型切换、分页、详情批量动作、导航返回 +- Batch B 测试推荐/收藏卡片点击后的路由 +- Batch C 测试榜单双视图和批量动作 +- Batch D 测试热词/补全/历史状态恢复 + +保持 focused pytest 验证,不依赖当前不稳定的全量测试基线。 + +## Risks and Mitigations + +### Risk 1: `root_view.py` 继续膨胀 + +缓解: + +- 优先复用通用组件 +- 将数据整理逻辑留在 `client.py` +- 当某一块达到可独立维护规模时,再拆成局部 helper + +### Risk 2: 插件页重新引入宿主耦合 + +缓解: + +- 只通过 `context.settings`、`context.services.media`、provider/client 访问宿主 +- 不直接依赖宿主 `Bootstrap` +- 不重新启用旧宿主 `OnlineMusicView` + +### Risk 3: API 返回结构不稳定 + +缓解: + +- 尽量在 `client.py` 做字段归一化 +- 测试中覆盖 QQ 结构适配的关键分支 + +### Risk 4: 首页与详情跳转状态错乱 + +缓解: + +- 使用轻量导航栈而不是单个返回页指针 +- 每个入口都明确记录来源状态 + +## Success Criteria + +满足以下条件即可认为“插件页接近旧 QQ 页面功能”: + +- 搜索四类结果具备旧页同等级的主要视图结构 +- 详情页支持批量播放/入队主路径 +- 首页收藏/推荐入口恢复为卡片化且可正确分流 +- 榜单支持双视图和主要批量动作 +- 搜索体验至少具备热词/历史/补全的旧页主路径 +- 整体实现保持插件边界,不回退插件系统设计 From 7bb9c93a83d4e3eb428358dac5dafed919cfc3de Mon Sep 17 00:00:00 2001 From: Har01d Date: Tue, 7 Apr 2026 15:55:13 +0800 Subject: [PATCH 052/157] =?UTF-8?q?=E5=AE=8C=E5=96=84=E6=8F=92=E4=BB=B6?= =?UTF-8?q?=E7=B3=BB=E7=BB=9F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/bootstrap.py | 9 +- build.py | 7 - .../plans/2026-04-05-plugin-system.md | 1985 +++++++++ .../plans/2026-04-06-qq-plugin-page-parity.md | 643 +++ ...2026-04-07-harmony-plugin-api-packaging.md | 343 ++ .../2026-04-07-plugin-ui-sdk-isolation.md | 283 ++ ...-07-harmony-plugin-api-packaging-design.md | 282 ++ main.py | 49 - packages/harmony-plugin-api/README.md | 9 + packages/harmony-plugin-api/pyproject.toml | 17 + .../src/harmony_plugin_api.egg-info/PKG-INFO | 16 + .../harmony_plugin_api.egg-info/SOURCES.txt | 15 + .../dependency_links.txt | 1 + .../harmony_plugin_api.egg-info/top_level.txt | 1 + .../src/harmony_plugin_api}/__init__.py | 4 + .../src/harmony_plugin_api}/context.py | 52 +- .../src/harmony_plugin_api}/cover.py | 0 .../src/harmony_plugin_api}/lyrics.py | 0 .../src/harmony_plugin_api}/manifest.py | 0 .../src/harmony_plugin_api}/media.py | 0 .../src/harmony_plugin_api}/online.py | 0 .../src/harmony_plugin_api}/plugin.py | 0 .../src/harmony_plugin_api}/registry_types.py | 3 + plugins/builtin/qqmusic/lib/api.py | 242 +- plugins/builtin/qqmusic/lib/client.py | 447 +- plugins/builtin/qqmusic/lib/common.py | 240 + plugins/builtin/qqmusic/lib/context_menus.py | 86 + .../builtin/qqmusic/lib/cover_hover_popup.py | 100 + .../builtin/qqmusic/lib/dialog_title_bar.py | 122 + plugins/builtin/qqmusic/lib/i18n.py | 51 + plugins/builtin/qqmusic/lib/legacy/client.py | 151 +- .../qqmusic/lib/legacy/qqmusic_service.py | 4 + .../qqmusic/lib/legacy_config_adapter.py | 46 + plugins/builtin/qqmusic/lib/login_dialog.py | 630 ++- plugins/builtin/qqmusic/lib/models.py | 137 + .../builtin/qqmusic/lib/online_detail_view.py | 2291 ++++++++++ .../builtin/qqmusic/lib/online_grid_view.py | 711 +++ .../builtin/qqmusic/lib/online_music_view.py | 3430 +++++++++++++++ .../qqmusic/lib/online_tracks_list_view.py | 618 +++ plugins/builtin/qqmusic/lib/provider.py | 90 +- plugins/builtin/qqmusic/lib/qr_login.py | 272 ++ plugins/builtin/qqmusic/lib/recommend_card.py | 464 ++ plugins/builtin/qqmusic/lib/root_view.py | 68 - plugins/builtin/qqmusic/lib/runtime_bridge.py | 144 + plugins/builtin/qqmusic/lib/settings_tab.py | 393 +- plugins/builtin/qqmusic/plugin_main.py | 42 +- plugins/builtin/qqmusic/sidebar_icon.svg | 3 + plugins/builtin/qqmusic/translations/en.json | 146 + plugins/builtin/qqmusic/translations/zh.json | 146 + pyproject.toml | 1 + release.sh | 1 - services/online/adapter.py | 52 +- services/online/download_service.py | 20 +- services/online/online_music_service.py | 90 +- system/event_bus.py | 3 + system/plugins/host_services.py | 40 +- system/plugins/loader.py | 50 +- system/plugins/manager.py | 44 +- system/plugins/media_bridge.py | 53 +- system/plugins/plugin_sdk_runtime.py | 190 + system/plugins/plugin_sdk_ui.py | 89 + system/plugins/qqmusic_runtime_helpers.py | 13 + tests/test_app/test_plugin_bootstrap.py | 13 +- tests/test_app/test_qqmusic_host_cleanup.py | 159 + tests/test_plugins/test_qqmusic_plugin.py | 3900 ++++++++++++++++- tests/test_services/test_online_adapter.py | 171 +- .../test_qqmusic_verify_login.py | 18 + .../test_harmony_plugin_api_package.py | 68 + tests/test_system/test_plugin_import_guard.py | 65 + tests/test_system/test_plugin_manager.py | 50 + .../test_system/test_plugin_online_bridge.py | 110 + tests/test_system/test_plugin_ui_bridge.py | 88 + tests/test_ui/test_online_music_view_async.py | 121 +- tests/test_ui/test_online_tracks_list_view.py | 23 + tests/test_ui/test_plugin_settings_tab.py | 208 +- .../test_plugin_sidebar_integration.py | 365 +- translations/en.json | 78 +- translations/zh.json | 78 +- ui/dialogs/plugin_management_tab.py | 79 +- ui/dialogs/settings_dialog.py | 2 +- ui/icons.py | 98 + ui/views/legacy_online_music_view.py | 13 + ui/views/online_detail_view.py | 2312 +--------- ui/views/online_grid_view.py | 713 +-- ui/views/online_music_view.py | 3384 +------------- ui/views/online_tracks_list_view.py | 603 +-- ui/widgets/context_menus.py | 62 +- ui/widgets/recommend_card.py | 98 +- ui/windows/components/sidebar.py | 23 +- ui/windows/main_window.py | 127 +- uv.lock | 11 + 91 files changed, 20857 insertions(+), 7622 deletions(-) create mode 100644 docs/superpowers/plans/2026-04-05-plugin-system.md create mode 100644 docs/superpowers/plans/2026-04-06-qq-plugin-page-parity.md create mode 100644 docs/superpowers/plans/2026-04-07-harmony-plugin-api-packaging.md create mode 100644 docs/superpowers/plans/2026-04-07-plugin-ui-sdk-isolation.md create mode 100644 docs/superpowers/specs/2026-04-07-harmony-plugin-api-packaging-design.md create mode 100644 packages/harmony-plugin-api/README.md create mode 100644 packages/harmony-plugin-api/pyproject.toml create mode 100644 packages/harmony-plugin-api/src/harmony_plugin_api.egg-info/PKG-INFO create mode 100644 packages/harmony-plugin-api/src/harmony_plugin_api.egg-info/SOURCES.txt create mode 100644 packages/harmony-plugin-api/src/harmony_plugin_api.egg-info/dependency_links.txt create mode 100644 packages/harmony-plugin-api/src/harmony_plugin_api.egg-info/top_level.txt rename {harmony_plugin_api => packages/harmony-plugin-api/src/harmony_plugin_api}/__init__.py (92%) rename {harmony_plugin_api => packages/harmony-plugin-api/src/harmony_plugin_api}/context.py (59%) rename {harmony_plugin_api => packages/harmony-plugin-api/src/harmony_plugin_api}/cover.py (100%) rename {harmony_plugin_api => packages/harmony-plugin-api/src/harmony_plugin_api}/lyrics.py (100%) rename {harmony_plugin_api => packages/harmony-plugin-api/src/harmony_plugin_api}/manifest.py (100%) rename {harmony_plugin_api => packages/harmony-plugin-api/src/harmony_plugin_api}/media.py (100%) rename {harmony_plugin_api => packages/harmony-plugin-api/src/harmony_plugin_api}/online.py (100%) rename {harmony_plugin_api => packages/harmony-plugin-api/src/harmony_plugin_api}/plugin.py (100%) rename {harmony_plugin_api => packages/harmony-plugin-api/src/harmony_plugin_api}/registry_types.py (76%) create mode 100644 plugins/builtin/qqmusic/lib/common.py create mode 100644 plugins/builtin/qqmusic/lib/context_menus.py create mode 100644 plugins/builtin/qqmusic/lib/cover_hover_popup.py create mode 100644 plugins/builtin/qqmusic/lib/dialog_title_bar.py create mode 100644 plugins/builtin/qqmusic/lib/i18n.py create mode 100644 plugins/builtin/qqmusic/lib/legacy_config_adapter.py create mode 100644 plugins/builtin/qqmusic/lib/models.py create mode 100644 plugins/builtin/qqmusic/lib/online_detail_view.py create mode 100644 plugins/builtin/qqmusic/lib/online_grid_view.py create mode 100644 plugins/builtin/qqmusic/lib/online_music_view.py create mode 100644 plugins/builtin/qqmusic/lib/online_tracks_list_view.py create mode 100644 plugins/builtin/qqmusic/lib/recommend_card.py create mode 100644 plugins/builtin/qqmusic/lib/runtime_bridge.py create mode 100644 plugins/builtin/qqmusic/sidebar_icon.svg create mode 100644 plugins/builtin/qqmusic/translations/en.json create mode 100644 plugins/builtin/qqmusic/translations/zh.json create mode 100644 system/plugins/plugin_sdk_runtime.py create mode 100644 system/plugins/plugin_sdk_ui.py create mode 100644 system/plugins/qqmusic_runtime_helpers.py create mode 100644 tests/test_app/test_qqmusic_host_cleanup.py create mode 100644 tests/test_services/test_qqmusic_verify_login.py create mode 100644 tests/test_system/test_harmony_plugin_api_package.py create mode 100644 tests/test_system/test_plugin_ui_bridge.py create mode 100644 ui/views/legacy_online_music_view.py diff --git a/app/bootstrap.py b/app/bootstrap.py index 3e917b95..8008d0ae 100644 --- a/app/bootstrap.py +++ b/app/bootstrap.py @@ -347,6 +347,7 @@ def file_org_service(self) -> FileOrganizationService: def plugin_manager(self) -> PluginManager: """Get plugin manager.""" if self._plugin_manager is None: + logger.info("[Bootstrap] Initializing plugin manager") self._plugin_manager = PluginManager( builtin_root=Path("plugins/builtin"), external_root=Path("data/plugins/external"), @@ -357,8 +358,10 @@ def plugin_manager(self) -> PluginManager: ), ) if not self._plugins_loaded: + logger.info("[Bootstrap] Loading enabled plugins") self._plugin_manager.load_enabled_plugins() self._plugins_loaded = True + logger.info("[Bootstrap] Plugin loading finished") return self._plugin_manager def refresh_online_music_service(self) -> "OnlineMusicService": @@ -376,7 +379,7 @@ def online_music_service(self) -> "OnlineMusicService": from services.online import OnlineMusicService self._online_music_service = OnlineMusicService( config_manager=self.config, - qqmusic_service=None + credential_provider=None ) return self._online_music_service @@ -387,8 +390,8 @@ def online_download_service(self) -> "OnlineDownloadService": from services.online import OnlineDownloadService self._online_download_service = OnlineDownloadService( config_manager=self.config, - qqmusic_service=None, - online_music_service=None + credential_provider=None, + online_music_service=self.online_music_service ) return self._online_download_service diff --git a/build.py b/build.py index 9ee503cb..543e94cc 100755 --- a/build.py +++ b/build.py @@ -391,13 +391,6 @@ def collect_hidden_imports(audio_backend_bundle: str = AUDIO_BACKEND_ALL) -> lis except Exception as e: print(f"Warning: Could not collect mutagen submodules: {e}") - # QQ音乐 API - try: - hiddenimports += collect_submodules("qqmusic_api") - print("Collected submodules for: qqmusic_api") - except Exception as e: - print(f"Warning: Could not collect qqmusic_api submodules: {e}") - # 其他依赖 for package in ["PIL", "qrcode", "bs4", "lxml"]: try: diff --git a/docs/superpowers/plans/2026-04-05-plugin-system.md b/docs/superpowers/plans/2026-04-05-plugin-system.md new file mode 100644 index 00000000..2fc456aa --- /dev/null +++ b/docs/superpowers/plans/2026-04-05-plugin-system.md @@ -0,0 +1,1985 @@ +# Plugin System Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Build a host-owned plugin runtime, migrate LRCLIB into a built-in plugin, migrate QQ Music into a removable plugin, and support plugin install from local zip files and direct URL downloads inside the settings dialog. + +**Architecture:** Add a new runtime under `system/plugins/` plus a stable SDK under `harmony_plugin_api/`. The host keeps ownership of lifecycle, settings persistence, playback/download bridges, sidebar mounting, and settings-shell UI; plugins register sidebar entries, settings tabs, lyrics sources, cover sources, and online-music providers through the SDK. LRCLIB proves the minimal built-in path first, then QQ Music moves behind the same runtime and is packaged as a zip artifact. + +**Tech Stack:** Python 3.11, PySide6, pytest, pytest-qt, `uv`, JSON manifests, `zipfile`, `importlib`, `ast` + +--- + +## File Map + +### New Runtime and SDK Files + +- Create: `harmony_plugin_api/__init__.py` — public SDK exports +- Create: `harmony_plugin_api/manifest.py` — plugin manifest model and capability validation +- Create: `harmony_plugin_api/plugin.py` — `HarmonyPlugin` entry interface +- Create: `harmony_plugin_api/context.py` — plugin context and bridge protocols +- Create: `harmony_plugin_api/registry_types.py` — sidebar and settings tab specs +- Create: `harmony_plugin_api/lyrics.py` — plugin-side lyrics protocol and result models +- Create: `harmony_plugin_api/cover.py` — plugin-side cover protocols and result models +- Create: `harmony_plugin_api/online.py` — plugin-side online provider protocol and DTOs +- Create: `harmony_plugin_api/media.py` — plugin playback/download request DTOs +- Create: `system/plugins/__init__.py` — host runtime exports +- Create: `system/plugins/errors.py` — install/load/runtime exceptions +- Create: `system/plugins/registry.py` — runtime extension registry and per-plugin rollback +- Create: `system/plugins/state_store.py` — `data/plugins/state.json` persistence +- Create: `system/plugins/loader.py` — manifest parsing and entry loading +- Create: `system/plugins/installer.py` — local zip and URL install logic plus import audit +- Create: `system/plugins/manager.py` — discovery, enable/disable, load/unload +- Create: `system/plugins/host_services.py` — host implementations of SDK bridge protocols +- Create: `system/plugins/media_bridge.py` — host bridge for cache/download/playback handoff + +### New Built-In Plugin Files + +- Create: `plugins/builtin/lrclib/plugin.json` +- Create: `plugins/builtin/lrclib/plugin_main.py` +- Create: `plugins/builtin/lrclib/lib/lrclib_source.py` +- Create: `plugins/builtin/qqmusic/plugin.json` +- Create: `plugins/builtin/qqmusic/plugin_main.py` +- Create: `plugins/builtin/qqmusic/lib/client.py` +- Create: `plugins/builtin/qqmusic/lib/login_dialog.py` +- Create: `plugins/builtin/qqmusic/lib/settings_tab.py` +- Create: `plugins/builtin/qqmusic/lib/lyrics_source.py` +- Create: `plugins/builtin/qqmusic/lib/cover_source.py` +- Create: `plugins/builtin/qqmusic/lib/artist_cover_source.py` +- Create: `plugins/builtin/qqmusic/lib/provider.py` +- Create: `plugins/builtin/qqmusic/lib/root_view.py` + +### New UI and Tooling Files + +- Create: `ui/dialogs/plugin_management_tab.py` +- Create: `scripts/build_plugin_zip.py` + +### New Tests + +- Create: `tests/test_system/test_plugin_manifest.py` +- Create: `tests/test_system/test_plugin_registry.py` +- Create: `tests/test_system/test_plugin_manager.py` +- Create: `tests/test_system/test_plugin_installer.py` +- Create: `tests/test_system/test_plugin_online_bridge.py` +- Create: `tests/test_system/test_plugin_import_guard.py` +- Create: `tests/test_app/test_plugin_bootstrap.py` +- Create: `tests/test_ui/test_plugin_settings_tab.py` +- Create: `tests/test_ui/test_plugin_sidebar_integration.py` +- Create: `tests/test_services/test_plugin_lyrics_registry.py` +- Create: `tests/test_services/test_plugin_cover_registry.py` +- Create: `tests/test_plugins/test_lrclib_plugin.py` +- Create: `tests/test_plugins/test_qqmusic_plugin.py` +- Create: `tests/test_system/test_plugin_packaging.py` + +### Existing Files to Modify + +- Modify: `app/bootstrap.py:344-414` — remove QQ-specific bootstrap wiring and initialize plugin manager/bridges +- Modify: `system/config.py:68-80,693-800` — remove host-owned QQ setting helpers after the plugin takes over namespaced settings +- Modify: `services/lyrics/lyrics_service.py:57-72` — replace hardcoded QQ/LRCLIB source registration with registry-driven sources +- Modify: `services/metadata/cover_service.py:46-74` — merge plugin cover and artist-cover sources into host search flow +- Modify: `services/online/download_service.py:42-177` — accept explicit quality instead of reading QQ host settings +- Modify: `services/sources/lyrics_sources.py:137-380` — delete QQ and LRCLIB host source implementations after migration +- Modify: `services/sources/cover_sources.py:121-180` — delete QQ host cover implementation after migration +- Modify: `services/sources/artist_cover_sources.py:79-130` — delete QQ host artist-cover implementation after migration +- Modify: `services/sources/__init__.py:9-52` — stop exporting migrated QQ/LRCLIB source classes +- Modify: `ui/dialogs/settings_dialog.py:214-858` — add host-owned `插件` tab and mount plugin settings tabs dynamically +- Modify: `ui/windows/components/sidebar.py:17-176` — support runtime plugin entries instead of only fixed constants +- Modify: `ui/windows/main_window.py:394-474,523-528` — stop hardcoding `OnlineMusicView` and mount plugin pages from the registry +- Modify: `translations/en.json` +- Modify: `translations/zh.json` + +### Existing Files to Delete After Migration + +- Delete: `services/lyrics/qqmusic_lyrics.py` +- Delete: `services/cloud/qqmusic/__init__.py` +- Delete: `services/cloud/qqmusic/client.py` +- Delete: `services/cloud/qqmusic/common.py` +- Delete: `services/cloud/qqmusic/crypto.py` +- Delete: `services/cloud/qqmusic/qr_login.py` +- Delete: `services/cloud/qqmusic/qqmusic_service.py` +- Delete: `services/cloud/qqmusic/tripledes.py` + +### Verification Rule + +The repository baseline is not clean under `uv run pytest tests/`, so each task below verifies only the focused files touched in that task. Do not use the unstable full-suite run as a success criterion for plugin work. + +### Task 1: Add SDK Contracts and Manifest Validation + +**Files:** +- Create: `harmony_plugin_api/__init__.py` +- Create: `harmony_plugin_api/manifest.py` +- Create: `harmony_plugin_api/plugin.py` +- Create: `harmony_plugin_api/context.py` +- Create: `harmony_plugin_api/registry_types.py` +- Create: `harmony_plugin_api/lyrics.py` +- Create: `harmony_plugin_api/cover.py` +- Create: `harmony_plugin_api/online.py` +- Create: `harmony_plugin_api/media.py` +- Test: `tests/test_system/test_plugin_manifest.py` + +- [ ] **Step 1: Write the failing test** + +```python +import pytest + +from harmony_plugin_api.manifest import PluginManifest, PluginManifestError +from harmony_plugin_api.registry_types import SidebarEntrySpec + + +def test_manifest_accepts_cover_capability(): + manifest = PluginManifest.from_dict( + { + "id": "qqmusic", + "name": "QQ Music", + "version": "1.0.0", + "api_version": "1", + "entrypoint": "plugin_main.py", + "entry_class": "QQMusicPlugin", + "capabilities": ["sidebar", "settings_tab", "lyrics_source", "cover", "online_music_provider"], + "min_app_version": "0.1.0", + } + ) + + assert manifest.id == "qqmusic" + assert "cover" in manifest.capabilities + + +def test_manifest_rejects_unknown_capability(): + with pytest.raises(PluginManifestError): + PluginManifest.from_dict( + { + "id": "broken", + "name": "Broken Plugin", + "version": "1.0.0", + "api_version": "1", + "entrypoint": "plugin_main.py", + "entry_class": "BrokenPlugin", + "capabilities": ["sidebar", "banana"], + "min_app_version": "0.1.0", + } + ) + + +def test_sidebar_spec_requires_widget_factory(): + spec = SidebarEntrySpec( + plugin_id="qqmusic", + entry_id="qqmusic.sidebar", + title="QQ Music", + order=80, + icon_name="GLOBE", + page_factory=lambda _context, _parent: object(), + ) + + assert spec.entry_id == "qqmusic.sidebar" +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `uv run pytest tests/test_system/test_plugin_manifest.py -v` +Expected: FAIL with `ModuleNotFoundError: No module named 'harmony_plugin_api'` + +- [ ] **Step 3: Write minimal implementation** + +```python +# harmony_plugin_api/manifest.py +from __future__ import annotations + +from dataclasses import dataclass +from typing import Literal + + +Capability = Literal[ + "sidebar", + "settings_tab", + "lyrics_source", + "cover", + "online_music_provider", +] + +_ALLOWED_CAPABILITIES = {"sidebar", "settings_tab", "lyrics_source", "cover", "online_music_provider"} + + +class PluginManifestError(ValueError): + pass + + +@dataclass(frozen=True) +class PluginManifest: + id: str + name: str + version: str + api_version: str + entrypoint: str + entry_class: str + capabilities: tuple[str, ...] + min_app_version: str + max_app_version: str | None = None + + @classmethod + def from_dict(cls, data: dict) -> "PluginManifest": + required = ("id", "name", "version", "api_version", "entrypoint", "entry_class", "capabilities", "min_app_version") + missing = [key for key in required if key not in data] + if missing: + raise PluginManifestError(f"Missing manifest keys: {', '.join(missing)}") + capabilities = tuple(str(item) for item in data["capabilities"]) + unknown = sorted(set(capabilities) - _ALLOWED_CAPABILITIES) + if unknown: + raise PluginManifestError(f"Unknown capabilities: {', '.join(unknown)}") + return cls( + id=str(data["id"]), + name=str(data["name"]), + version=str(data["version"]), + api_version=str(data["api_version"]), + entrypoint=str(data["entrypoint"]), + entry_class=str(data["entry_class"]), + capabilities=capabilities, + min_app_version=str(data["min_app_version"]), + max_app_version=str(data["max_app_version"]) if data.get("max_app_version") else None, + ) +``` + +```python +# harmony_plugin_api/plugin.py +from __future__ import annotations + +from typing import Protocol + +from .context import PluginContext + + +class HarmonyPlugin(Protocol): + plugin_id: str + + def register(self, context: PluginContext) -> None: + ... + + def unregister(self, context: PluginContext) -> None: + ... +``` + +```python +# harmony_plugin_api/context.py +from __future__ import annotations + +from dataclasses import dataclass +from pathlib import Path +from typing import Any, Protocol + +from .manifest import PluginManifest + + +class PluginSettingsBridge(Protocol): + def get(self, key: str, default: Any = None) -> Any: + ... + + def set(self, key: str, value: Any) -> None: + ... + + +class PluginStorageBridge(Protocol): + @property + def data_dir(self) -> Path: + ... + + @property + def cache_dir(self) -> Path: + ... + + @property + def temp_dir(self) -> Path: + ... + + +class PluginUiBridge(Protocol): + def register_sidebar_entry(self, spec: Any) -> None: + ... + + def register_settings_tab(self, spec: Any) -> None: + ... + + +class PluginServiceBridge(Protocol): + def register_lyrics_source(self, source: Any) -> None: + ... + + def register_cover_source(self, source: Any) -> None: + ... + + def register_artist_cover_source(self, source: Any) -> None: + ... + + def register_online_music_provider(self, provider: Any) -> None: + ... + + @property + def media(self) -> Any: + ... + + +@dataclass(frozen=True) +class PluginContext: + plugin_id: str + manifest: PluginManifest + logger: Any + http: Any + events: Any + storage: PluginStorageBridge + settings: PluginSettingsBridge + ui: PluginUiBridge + services: PluginServiceBridge +``` + +```python +# harmony_plugin_api/registry_types.py +from __future__ import annotations + +from dataclasses import dataclass +from typing import Any, Callable + + +@dataclass(frozen=True) +class SidebarEntrySpec: + plugin_id: str + entry_id: str + title: str + order: int + icon_name: str | None + page_factory: Callable[[Any, Any], Any] + + +@dataclass(frozen=True) +class SettingsTabSpec: + plugin_id: str + tab_id: str + title: str + order: int + widget_factory: Callable[[Any, Any], Any] +``` + +```python +# harmony_plugin_api/lyrics.py +from __future__ import annotations + +from dataclasses import dataclass +from typing import Optional, Protocol + + +@dataclass(frozen=True) +class PluginLyricsResult: + song_id: str + title: str + artist: str + album: str = "" + duration: float | None = None + source: str = "" + cover_url: str | None = None + lyrics: str | None = None + accesskey: str | None = None + supports_yrc: bool = False + + +class PluginLyricsSource(Protocol): + source_id: str + display_name: str + + def search(self, title: str, artist: str, limit: int = 10) -> list[PluginLyricsResult]: + ... + + def get_lyrics(self, result: PluginLyricsResult) -> Optional[str]: + ... +``` + +```python +# harmony_plugin_api/cover.py +from __future__ import annotations + +from dataclasses import dataclass +from typing import Protocol + + +@dataclass(frozen=True) +class PluginCoverResult: + item_id: str + title: str + artist: str + album: str = "" + duration: float | None = None + source: str = "" + cover_url: str | None = None + extra_id: str | None = None + + +@dataclass(frozen=True) +class PluginArtistCoverResult: + artist_id: str + name: str + source: str = "" + cover_url: str | None = None + album_count: int | None = None + + +class PluginCoverSource(Protocol): + source_id: str + display_name: str + + def search(self, title: str, artist: str, album: str = "", duration: float | None = None) -> list[PluginCoverResult]: + ... + + +class PluginArtistCoverSource(Protocol): + source_id: str + display_name: str + + def search(self, artist_name: str, limit: int = 10) -> list[PluginArtistCoverResult]: + ... +``` + +```python +# harmony_plugin_api/online.py and harmony_plugin_api/media.py +from __future__ import annotations + +from dataclasses import dataclass, field +from typing import Any, Protocol + + +@dataclass(frozen=True) +class PluginTrack: + track_id: str + title: str + artist: str + album: str = "" + duration: int | None = None + artwork_url: str | None = None + metadata: dict[str, Any] = field(default_factory=dict) + + +@dataclass(frozen=True) +class PluginPlaybackRequest: + provider_id: str + track_id: str + title: str + quality: str + metadata: dict[str, Any] + + +class PluginOnlineProvider(Protocol): + provider_id: str + display_name: str + + def create_page(self, context: Any, parent: Any = None) -> Any: + ... + + def get_playback_url_info(self, track_id: str, quality: str) -> dict[str, Any] | None: + ... +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `uv run pytest tests/test_system/test_plugin_manifest.py -v` +Expected: PASS + +- [ ] **Step 5: Commit** + +```bash +git add harmony_plugin_api/__init__.py harmony_plugin_api/manifest.py harmony_plugin_api/plugin.py harmony_plugin_api/context.py harmony_plugin_api/registry_types.py harmony_plugin_api/lyrics.py harmony_plugin_api/cover.py harmony_plugin_api/online.py harmony_plugin_api/media.py tests/test_system/test_plugin_manifest.py +git commit -m "新增插件SDK" +``` + +### Task 2: Build the Plugin Runtime, State Store, and Installer + +**Files:** +- Create: `system/plugins/__init__.py` +- Create: `system/plugins/errors.py` +- Create: `system/plugins/registry.py` +- Create: `system/plugins/state_store.py` +- Create: `system/plugins/loader.py` +- Create: `system/plugins/installer.py` +- Create: `system/plugins/manager.py` +- Test: `tests/test_system/test_plugin_registry.py` +- Test: `tests/test_system/test_plugin_manager.py` +- Test: `tests/test_system/test_plugin_installer.py` + +- [ ] **Step 1: Write the failing test** + +```python +import json +from pathlib import Path + +import pytest + +from harmony_plugin_api.registry_types import SidebarEntrySpec +from system.plugins.errors import PluginInstallError +from system.plugins.installer import audit_plugin_imports +from system.plugins.registry import PluginRegistry +from system.plugins.state_store import PluginStateStore + + +def test_registry_unregister_plugin_removes_owned_entries(): + registry = PluginRegistry() + spec = SidebarEntrySpec( + plugin_id="qqmusic", + entry_id="qqmusic.sidebar", + title="QQ Music", + order=80, + icon_name="GLOBE", + page_factory=lambda _context, _parent: object(), + ) + + registry.register_sidebar_entry("qqmusic", spec) + registry.unregister_plugin("qqmusic") + + assert registry.sidebar_entries() == [] + + +def test_state_store_persists_enabled_flag(tmp_path: Path): + store = PluginStateStore(tmp_path / "state.json") + store.set_enabled("qqmusic", True, source="builtin", version="1.0.0") + + payload = json.loads((tmp_path / "state.json").read_text(encoding="utf-8")) + assert payload["qqmusic"]["enabled"] is True + + +def test_import_audit_rejects_host_internal_import(tmp_path: Path): + plugin_root = tmp_path / "plugin" + plugin_root.mkdir() + (plugin_root / "plugin_main.py").write_text("from services.lyrics.qqmusic_lyrics import QQMusicClient\n", encoding="utf-8") + + with pytest.raises(PluginInstallError): + audit_plugin_imports(plugin_root) +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `uv run pytest tests/test_system/test_plugin_registry.py tests/test_system/test_plugin_manager.py tests/test_system/test_plugin_installer.py -v` +Expected: FAIL with `ModuleNotFoundError: No module named 'system.plugins'` + +- [ ] **Step 3: Write minimal implementation** + +```python +# system/plugins/errors.py +class PluginError(Exception): + pass + + +class PluginInstallError(PluginError): + pass + + +class PluginLoadError(PluginError): + pass +``` + +```python +# system/plugins/registry.py +from __future__ import annotations + +from collections import defaultdict + + +class PluginRegistry: + def __init__(self) -> None: + self._sidebar_entries: list = [] + self._settings_tabs: list = [] + self._lyrics_sources: list = [] + self._cover_sources: list = [] + self._artist_cover_sources: list = [] + self._online_providers: list = [] + self._owned: dict[str, list[tuple[str, object]]] = defaultdict(list) + + def register_sidebar_entry(self, plugin_id: str, spec: object) -> None: + self._sidebar_entries.append(spec) + self._owned[plugin_id].append(("sidebar", spec)) + + def register_settings_tab(self, plugin_id: str, spec: object) -> None: + self._settings_tabs.append(spec) + self._owned[plugin_id].append(("settings_tab", spec)) + + def register_lyrics_source(self, plugin_id: str, source: object) -> None: + self._lyrics_sources.append(source) + self._owned[plugin_id].append(("lyrics_source", source)) + + def register_cover_source(self, plugin_id: str, source: object) -> None: + self._cover_sources.append(source) + self._owned[plugin_id].append(("cover_source", source)) + + def register_artist_cover_source(self, plugin_id: str, source: object) -> None: + self._artist_cover_sources.append(source) + self._owned[plugin_id].append(("artist_cover_source", source)) + + def register_online_provider(self, plugin_id: str, provider: object) -> None: + self._online_providers.append(provider) + self._owned[plugin_id].append(("online_provider", provider)) + + def unregister_plugin(self, plugin_id: str) -> None: + owned_ids = {id(value) for _kind, value in self._owned.pop(plugin_id, [])} + self._sidebar_entries = [item for item in self._sidebar_entries if id(item) not in owned_ids] + self._settings_tabs = [item for item in self._settings_tabs if id(item) not in owned_ids] + self._lyrics_sources = [item for item in self._lyrics_sources if id(item) not in owned_ids] + self._cover_sources = [item for item in self._cover_sources if id(item) not in owned_ids] + self._artist_cover_sources = [item for item in self._artist_cover_sources if id(item) not in owned_ids] + self._online_providers = [item for item in self._online_providers if id(item) not in owned_ids] + + def sidebar_entries(self) -> list: + return sorted(self._sidebar_entries, key=lambda item: item.order) + + def settings_tabs(self) -> list: + return sorted(self._settings_tabs, key=lambda item: item.order) + + def lyrics_sources(self) -> list: + return list(self._lyrics_sources) + + def cover_sources(self) -> list: + return list(self._cover_sources) + + def artist_cover_sources(self) -> list: + return list(self._artist_cover_sources) + + def online_providers(self) -> list: + return list(self._online_providers) +``` + +```python +# system/plugins/state_store.py +from __future__ import annotations + +import json +from pathlib import Path + + +class PluginStateStore: + def __init__(self, path: Path) -> None: + self._path = path + self._path.parent.mkdir(parents=True, exist_ok=True) + + def _read(self) -> dict: + if not self._path.exists(): + return {} + return json.loads(self._path.read_text(encoding="utf-8")) + + def _write(self, payload: dict) -> None: + self._path.write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8") + + def set_enabled(self, plugin_id: str, enabled: bool, source: str, version: str, load_error: str | None = None) -> None: + payload = self._read() + payload[plugin_id] = { + "enabled": enabled, + "source": source, + "version": version, + "load_error": load_error, + } + self._write(payload) + + def get(self, plugin_id: str) -> dict | None: + return self._read().get(plugin_id) +``` + +```python +# system/plugins/loader.py +from __future__ import annotations + +import importlib.util +from pathlib import Path + +from harmony_plugin_api.manifest import PluginManifest +from .errors import PluginLoadError + + +class PluginLoader: + def load_plugin(self, plugin_root: Path): + manifest = PluginManifest.from_dict(__import__("json").loads((plugin_root / "plugin.json").read_text(encoding="utf-8"))) + module_path = plugin_root / manifest.entrypoint + spec = importlib.util.spec_from_file_location(f"plugin_{manifest.id}", module_path) + if spec is None or spec.loader is None: + raise PluginLoadError(f"Cannot load entrypoint: {module_path}") + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + plugin_class = getattr(module, manifest.entry_class) + return manifest, plugin_class() +``` + +```python +# system/plugins/installer.py +from __future__ import annotations + +import ast +import shutil +import zipfile +from pathlib import Path + +from harmony_plugin_api.manifest import PluginManifest +from .errors import PluginInstallError + +_FORBIDDEN_ROOT_IMPORTS = {"app", "domain", "services", "repositories", "infrastructure", "system", "ui"} + + +def audit_plugin_imports(plugin_root: Path) -> None: + for py_file in plugin_root.rglob("*.py"): + tree = ast.parse(py_file.read_text(encoding="utf-8"), filename=str(py_file)) + for node in ast.walk(tree): + if isinstance(node, ast.Import): + names = [alias.name.split(".")[0] for alias in node.names] + elif isinstance(node, ast.ImportFrom) and node.module: + names = [node.module.split(".")[0]] + else: + continue + if any(name in _FORBIDDEN_ROOT_IMPORTS for name in names): + raise PluginInstallError(f"Forbidden host import in {py_file}") + + +class PluginInstaller: + def __init__(self, external_root: Path, temp_root: Path) -> None: + self._external_root = external_root + self._temp_root = temp_root + + def install_zip(self, zip_path: Path) -> Path: + extract_root = self._temp_root / zip_path.stem + if extract_root.exists(): + shutil.rmtree(extract_root) + extract_root.mkdir(parents=True, exist_ok=True) + with zipfile.ZipFile(zip_path) as archive: + archive.extractall(extract_root) + audit_plugin_imports(extract_root) + manifest = PluginManifest.from_dict(__import__("json").loads((extract_root / "plugin.json").read_text(encoding="utf-8"))) + final_root = self._external_root / manifest.id + if final_root.exists(): + shutil.rmtree(final_root) + shutil.copytree(extract_root, final_root) + return final_root +``` + +```python +# system/plugins/manager.py +from __future__ import annotations + +from pathlib import Path + +from .loader import PluginLoader +from .registry import PluginRegistry + + +class PluginManager: + def __init__(self, builtin_root: Path, external_root: Path, state_store, context_factory) -> None: + self._builtin_root = builtin_root + self._external_root = external_root + self._state_store = state_store + self._context_factory = context_factory + self._loader = PluginLoader() + self.registry = PluginRegistry() + self._loaded_plugins: dict[str, tuple[object, object]] = {} + + def discover_roots(self) -> list[tuple[str, Path]]: + roots = [] + if self._builtin_root.exists(): + roots.extend(("builtin", path) for path in self._builtin_root.iterdir() if path.is_dir()) + if self._external_root.exists(): + roots.extend(("external", path) for path in self._external_root.iterdir() if path.is_dir()) + return roots + + def load_enabled_plugins(self) -> None: + for source, plugin_root in self.discover_roots(): + manifest, plugin = self._loader.load_plugin(plugin_root) + state = self._state_store.get(manifest.id) + if source == "external" and state and state.get("enabled") is False: + continue + context = self._context_factory.build(manifest) + plugin.register(context) + self._loaded_plugins[manifest.id] = (manifest, plugin) + self._state_store.set_enabled(manifest.id, True if state is None else bool(state.get("enabled", True)), source=source, version=manifest.version) +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `uv run pytest tests/test_system/test_plugin_registry.py tests/test_system/test_plugin_manager.py tests/test_system/test_plugin_installer.py -v` +Expected: PASS + +- [ ] **Step 5: Commit** + +```bash +git add system/plugins/__init__.py system/plugins/errors.py system/plugins/registry.py system/plugins/state_store.py system/plugins/loader.py system/plugins/installer.py system/plugins/manager.py tests/test_system/test_plugin_registry.py tests/test_system/test_plugin_manager.py tests/test_system/test_plugin_installer.py +git commit -m "实现插件运行时" +``` + +### Task 3: Wire Bootstrap and Add Host Plugin Bridges + +**Files:** +- Create: `system/plugins/host_services.py` +- Create: `system/plugins/media_bridge.py` +- Modify: `app/bootstrap.py:344-414` +- Modify: `services/online/download_service.py:42-177` +- Test: `tests/test_app/test_plugin_bootstrap.py` +- Test: `tests/test_system/test_plugin_online_bridge.py` + +- [ ] **Step 1: Write the failing test** + +```python +from pathlib import Path +from unittest.mock import Mock + +from app.bootstrap import Bootstrap +from system.plugins.media_bridge import PluginMediaBridge + + +def test_bootstrap_exposes_plugin_manager(monkeypatch, tmp_path: Path): + bootstrap = Bootstrap(":memory:") + bootstrap._config = Mock() + bootstrap._event_bus = Mock() + bootstrap._http_client = Mock() + + manager = bootstrap.plugin_manager + + assert manager is bootstrap.plugin_manager + + +def test_media_bridge_passes_explicit_quality_to_download_service(): + download_service = Mock() + playback_service = Mock() + library_service = Mock() + bridge = PluginMediaBridge(download_service, playback_service, library_service) + + request = type( + "Request", + (), + { + "provider_id": "qqmusic", + "track_id": "mid-1", + "title": "Song 1", + "quality": "flac", + "metadata": {"title": "Song 1", "artist": "Singer 1"}, + }, + )() + + bridge.cache_remote_track(request) + + download_service.download.assert_called_once_with( + "mid-1", + song_title="Song 1", + quality="flac", + progress_callback=None, + force=False, + ) +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `uv run pytest tests/test_app/test_plugin_bootstrap.py tests/test_system/test_plugin_online_bridge.py -v` +Expected: FAIL with `AttributeError: 'Bootstrap' object has no attribute 'plugin_manager'` and `ModuleNotFoundError: No module named 'system.plugins.media_bridge'` + +- [ ] **Step 3: Write minimal implementation** + +```python +# system/plugins/media_bridge.py +from __future__ import annotations + + +class PluginMediaBridge: + def __init__(self, download_service, playback_service, library_service) -> None: + self._download_service = download_service + self._playback_service = playback_service + self._library_service = library_service + + def cache_remote_track(self, request, progress_callback=None, force: bool = False): + return self._download_service.download( + request.track_id, + song_title=request.title, + quality=request.quality, + progress_callback=progress_callback, + force=force, + ) + + def add_online_track(self, request): + metadata = request.metadata + return self._library_service.add_online_track( + title=metadata.get("title", request.title), + artist=metadata.get("artist", ""), + album=metadata.get("album", ""), + song_mid=request.track_id, + source=request.provider_id, + ) +``` + +```python +# system/plugins/host_services.py +from __future__ import annotations + +from pathlib import Path + +from harmony_plugin_api.context import PluginContext + + +class PluginSettingsBridgeImpl: + def __init__(self, plugin_id: str, config) -> None: + self._plugin_id = plugin_id + self._config = config + + def _key(self, key: str) -> str: + return f"plugins.{self._plugin_id}.{key}" + + def get(self, key: str, default=None): + return self._config.get(self._key(key), default) + + def set(self, key: str, value) -> None: + self._config.set(self._key(key), value) + + +class PluginStorageBridgeImpl: + def __init__(self, root: Path, plugin_id: str) -> None: + self.data_dir = root / plugin_id / "data" + self.cache_dir = root / plugin_id / "cache" + self.temp_dir = root / plugin_id / "tmp" + for path in (self.data_dir, self.cache_dir, self.temp_dir): + path.mkdir(parents=True, exist_ok=True) + + +class PluginUiBridgeImpl: + def __init__(self, plugin_id: str, registry) -> None: + self._plugin_id = plugin_id + self._registry = registry + + def register_sidebar_entry(self, spec) -> None: + self._registry.register_sidebar_entry(self._plugin_id, spec) + + def register_settings_tab(self, spec) -> None: + self._registry.register_settings_tab(self._plugin_id, spec) + + +class PluginServiceBridgeImpl: + def __init__(self, plugin_id: str, registry, media_bridge) -> None: + self._plugin_id = plugin_id + self._registry = registry + self._media = media_bridge + + @property + def media(self): + return self._media + + def register_lyrics_source(self, source) -> None: + self._registry.register_lyrics_source(self._plugin_id, source) + + def register_cover_source(self, source) -> None: + self._registry.register_cover_source(self._plugin_id, source) + + def register_artist_cover_source(self, source) -> None: + self._registry.register_artist_cover_source(self._plugin_id, source) + + def register_online_music_provider(self, provider) -> None: + self._registry.register_online_provider(self._plugin_id, provider) +``` + +```python +# app/bootstrap.py +from pathlib import Path + +from system.plugins.host_services import ( + PluginServiceBridgeImpl, + PluginSettingsBridgeImpl, + PluginStorageBridgeImpl, + PluginUiBridgeImpl, +) +from system.plugins.manager import PluginManager +from system.plugins.media_bridge import PluginMediaBridge +from system.plugins.state_store import PluginStateStore + + +def _build_plugin_context_factory(self): + bootstrap = self + + class _ContextFactory: + def build(self, manifest): + media_bridge = PluginMediaBridge( + bootstrap.online_download_service, + bootstrap.playback_service, + bootstrap.library_service, + ) + return PluginContext( + plugin_id=manifest.id, + manifest=manifest, + logger=logging.getLogger(f"plugin.{manifest.id}"), + http=bootstrap.http_client, + events=bootstrap.event_bus, + storage=PluginStorageBridgeImpl(Path("data/plugins/storage"), manifest.id), + settings=PluginSettingsBridgeImpl(manifest.id, bootstrap.config), + ui=PluginUiBridgeImpl(manifest.id, bootstrap.plugin_manager.registry), + services=PluginServiceBridgeImpl(manifest.id, bootstrap.plugin_manager.registry, media_bridge), + ) + + return _ContextFactory() + + +@property +def plugin_manager(self): + if self._plugin_manager is None: + builtin_root = Path("plugins/builtin") + external_root = Path("data/plugins/external") + state_store = PluginStateStore(Path("data/plugins/state.json")) + self._plugin_manager = PluginManager( + builtin_root=builtin_root, + external_root=external_root, + state_store=state_store, + context_factory=self._build_plugin_context_factory(), + ) + return self._plugin_manager +``` + +```python +# services/online/download_service.py +if quality is None: + quality = "320" +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `uv run pytest tests/test_app/test_plugin_bootstrap.py tests/test_system/test_plugin_online_bridge.py -v` +Expected: PASS + +- [ ] **Step 5: Commit** + +```bash +git add system/plugins/host_services.py system/plugins/media_bridge.py app/bootstrap.py services/online/download_service.py tests/test_app/test_plugin_bootstrap.py tests/test_system/test_plugin_online_bridge.py +git commit -m "接入插件宿主桥接" +``` + +### Task 4: Add the Host-Owned `插件` Tab to the Settings Dialog + +**Files:** +- Create: `ui/dialogs/plugin_management_tab.py` +- Modify: `ui/dialogs/settings_dialog.py:214-858` +- Modify: `translations/en.json` +- Modify: `translations/zh.json` +- Test: `tests/test_ui/test_plugin_settings_tab.py` + +- [ ] **Step 1: Write the failing test** + +```python +from unittest.mock import Mock + +from PySide6.QtWidgets import QApplication, QTabWidget + +from ui.dialogs.plugin_management_tab import PluginManagementTab +from ui.dialogs.settings_dialog import GeneralSettingsDialog + + +def test_plugin_management_tab_shows_plugin_rows(qtbot): + manager = Mock() + manager.list_plugins.return_value = [ + {"id": "lrclib", "name": "LRCLIB", "version": "1.0.0", "source": "builtin", "enabled": True, "load_error": None}, + {"id": "qqmusic", "name": "QQ Music", "version": "1.0.0", "source": "external", "enabled": False, "load_error": "load failed"}, + ] + + widget = PluginManagementTab(manager) + qtbot.addWidget(widget) + + assert widget._table.rowCount() == 2 + + +def test_settings_dialog_includes_plugins_tab(monkeypatch, qtbot): + config = Mock() + config.get.return_value = "dark" + config.get_ai_enabled.return_value = False + config.get_ai_base_url.return_value = "" + config.get_ai_api_key.return_value = "" + config.get_ai_model.return_value = "" + config.get_acoustid_enabled.return_value = False + config.get_acoustid_api_key.return_value = "" + config.get_online_music_download_dir.return_value = "data/online_cache" + config.get_cache_cleanup_strategy.return_value = "manual" + config.get_cache_cleanup_auto_enabled.return_value = False + config.get_cache_cleanup_time_days.return_value = 30 + config.get_cache_cleanup_size_mb.return_value = 1000 + config.get_cache_cleanup_count.return_value = 100 + config.get_cache_cleanup_interval_hours.return_value = 1 + config.get_audio_engine.return_value = "mpv" + + dialog = GeneralSettingsDialog(config) + qtbot.addWidget(dialog) + tab_widget = dialog.findChild(QTabWidget) + + tab_labels = [tab_widget.tabText(index) for index in range(tab_widget.count())] + assert "Plugins" in tab_labels or "插件" in tab_labels +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `uv run pytest tests/test_ui/test_plugin_settings_tab.py -v` +Expected: FAIL with `ModuleNotFoundError: No module named 'ui.dialogs.plugin_management_tab'` + +- [ ] **Step 3: Write minimal implementation** + +```python +# ui/dialogs/plugin_management_tab.py +from __future__ import annotations + +from PySide6.QtWidgets import ( + QWidget, + QVBoxLayout, + QHBoxLayout, + QPushButton, + QLineEdit, + QTableWidget, + QTableWidgetItem, + QFileDialog, +) + +from system.i18n import t + + +class PluginManagementTab(QWidget): + def __init__(self, plugin_manager, parent=None): + super().__init__(parent) + self._plugin_manager = plugin_manager + self._table = QTableWidget(0, 5, self) + self._url_input = QLineEdit(self) + self._setup_ui() + self.refresh() + + def _setup_ui(self) -> None: + layout = QVBoxLayout(self) + layout.addWidget(self._table) + + controls = QHBoxLayout() + install_zip_btn = QPushButton(t("plugins_install_zip")) + install_zip_btn.clicked.connect(self._install_zip) + install_url_btn = QPushButton(t("plugins_install_url")) + install_url_btn.clicked.connect(self._install_url) + controls.addWidget(self._url_input) + controls.addWidget(install_zip_btn) + controls.addWidget(install_url_btn) + layout.addLayout(controls) + + def refresh(self) -> None: + rows = self._plugin_manager.list_plugins() + self._table.setRowCount(len(rows)) + for row_index, row in enumerate(rows): + self._table.setItem(row_index, 0, QTableWidgetItem(row["name"])) + self._table.setItem(row_index, 1, QTableWidgetItem(row["version"])) + self._table.setItem(row_index, 2, QTableWidgetItem(row["source"])) + self._table.setItem(row_index, 3, QTableWidgetItem("enabled" if row["enabled"] else "disabled")) + self._table.setItem(row_index, 4, QTableWidgetItem(row["load_error"] or "")) + + def _install_zip(self) -> None: + path, _ = QFileDialog.getOpenFileName(self, t("plugins_install_zip"), "", "Zip Files (*.zip)") + if path: + self._plugin_manager.install_zip(path) + self.refresh() + + def _install_url(self) -> None: + url = self._url_input.text().strip() + if url: + self._plugin_manager.install_from_url(url) + self.refresh() +``` + +```python +# ui/dialogs/settings_dialog.py +from ui.dialogs.plugin_management_tab import PluginManagementTab + +tab_widget.addTab(playback_tab, t("playback_tab")) +tab_widget.addTab(appearance_tab, t("theme_tab")) +tab_widget.addTab(cache_tab, t("cache_tab")) +tab_widget.addTab(covers_tab, t("covers_tab")) +tab_widget.addTab(repair_tab, t("repair_tab")) +tab_widget.addTab(ai_tab, t("ai_tab")) +tab_widget.addTab(acoustid_tab, t("acoustid_tab")) + +bootstrap = Bootstrap.instance() +plugin_tab = PluginManagementTab(bootstrap.plugin_manager, self) +tab_widget.addTab(plugin_tab, t("plugins_tab")) +for spec in bootstrap.plugin_manager.registry.settings_tabs(): + tab_widget.addTab(spec.widget_factory(bootstrap.plugin_manager, self), spec.title) +``` + +```json +// translations/en.json +"plugins_tab": "Plugins", +"plugins_install_zip": "Install Zip", +"plugins_install_url": "Install URL", +"plugins_enable": "Enable", +"plugins_disable": "Disable", +"plugins_uninstall": "Uninstall", +"plugins_source_builtin": "Built-in", +"plugins_source_external": "External", +"plugins_load_error": "Load Error" +``` + +```json +// translations/zh.json +"plugins_tab": "插件", +"plugins_install_zip": "安装 Zip", +"plugins_install_url": "在线安装", +"plugins_enable": "启用", +"plugins_disable": "禁用", +"plugins_uninstall": "卸载", +"plugins_source_builtin": "内置", +"plugins_source_external": "外部", +"plugins_load_error": "加载错误" +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `uv run pytest tests/test_ui/test_plugin_settings_tab.py -v` +Expected: PASS + +- [ ] **Step 5: Commit** + +```bash +git add ui/dialogs/plugin_management_tab.py ui/dialogs/settings_dialog.py translations/en.json translations/zh.json tests/test_ui/test_plugin_settings_tab.py +git commit -m "新增插件管理页" +``` + +### Task 5: Make Sidebar and MainWindow Consume Plugin Pages Dynamically + +**Files:** +- Modify: `ui/windows/components/sidebar.py:17-176` +- Modify: `ui/windows/main_window.py:394-474,523-528` +- Test: `tests/test_ui/test_plugin_sidebar_integration.py` + +- [ ] **Step 1: Write the failing test** + +```python +from unittest.mock import Mock, patch + +from PySide6.QtWidgets import QLabel + +from ui.windows.components.sidebar import Sidebar +from ui.windows.main_window import MainWindow + + +def test_sidebar_can_add_plugin_entry(qtbot): + sidebar = Sidebar() + qtbot.addWidget(sidebar) + + sidebar.add_plugin_entry(page_index=200, title="QQ Music", icon_name="GLOBE") + + assert any(index == 200 for index, _button in sidebar._nav_buttons) + + +def test_main_window_mounts_plugin_pages(qtbot): + bootstrap = Mock() + bootstrap.db = Mock() + bootstrap.config = Mock() + bootstrap.playback_service = Mock() + bootstrap.library_service = Mock() + bootstrap.favorites_service = Mock() + bootstrap.play_history_service = Mock() + bootstrap.cloud_account_service = Mock() + bootstrap.cloud_file_service = Mock() + bootstrap.cover_service = Mock() + bootstrap.playlist_service = Mock() + bootstrap.plugin_manager.registry.sidebar_entries.return_value = [ + type( + "Spec", + (), + { + "plugin_id": "qqmusic", + "entry_id": "qqmusic.sidebar", + "title": "QQ Music", + "order": 80, + "icon_name": "GLOBE", + "page_factory": staticmethod(lambda _context, _parent: QLabel("QQ Music View")), + }, + )() + ] + + with patch("ui.windows.main_window.Bootstrap.instance", return_value=bootstrap), \ + patch.object(MainWindow, "_setup_connections"), \ + patch.object(MainWindow, "_setup_system_tray"), \ + patch.object(MainWindow, "_setup_hotkeys"), \ + patch.object(MainWindow, "_restore_settings"): + window = MainWindow() + qtbot.addWidget(window) + + assert "qqmusic" in window._plugin_page_keys.values() +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `uv run pytest tests/test_ui/test_plugin_sidebar_integration.py -v` +Expected: FAIL with `AttributeError: 'Sidebar' object has no attribute 'add_plugin_entry'` + +- [ ] **Step 3: Write minimal implementation** + +```python +# ui/windows/components/sidebar.py +from ui.icons import IconName, IconButton + + +def _coerce_icon_name(icon_name: str | None) -> IconName: + if not icon_name: + return IconName.GLOBE + return getattr(IconName, icon_name, IconName.GLOBE) + + +class Sidebar(QWidget): + ... + def add_plugin_entry(self, page_index: int, title: str, icon_name: str | None = None) -> None: + btn = IconButton(_coerce_icon_name(icon_name), title, size=18) + btn.setCheckable(True) + btn.setCursor(Qt.PointingHandCursor) + btn.clicked.connect(lambda checked, idx=page_index: self._on_nav_clicked(idx)) + self.layout().insertWidget(len(self._nav_buttons) + 2, btn) + self._nav_buttons.append((page_index, btn)) +``` + +```python +# ui/windows/main_window.py +class MainWindow(QMainWindow): + ... + def _mount_plugin_pages(self) -> None: + self._plugin_page_keys = {} + bootstrap = Bootstrap.instance() + for spec in bootstrap.plugin_manager.registry.sidebar_entries(): + page_index = self._stacked_widget.count() + widget = spec.page_factory(bootstrap.plugin_manager, self) + self._stacked_widget.addWidget(widget) + self._sidebar.add_plugin_entry(page_index=page_index, title=spec.title, icon_name=spec.icon_name) + self._plugin_page_keys[page_index] = spec.plugin_id + + def _setup_ui(self): + ... + self._sidebar = self._create_sidebar() + ... + self._stacked_widget.addWidget(self._genres_view) # 9 + self._stacked_widget.addWidget(self._genre_view) # 10 + self._mount_plugin_pages() +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `uv run pytest tests/test_ui/test_plugin_sidebar_integration.py -v` +Expected: PASS + +- [ ] **Step 5: Commit** + +```bash +git add ui/windows/components/sidebar.py ui/windows/main_window.py tests/test_ui/test_plugin_sidebar_integration.py +git commit -m "支持插件侧边栏页面" +``` + +### Task 6: Move LRCLIB to a Built-In Plugin and Make Lyrics/Cover Registration Dynamic + +**Files:** +- Create: `plugins/builtin/lrclib/plugin.json` +- Create: `plugins/builtin/lrclib/plugin_main.py` +- Create: `plugins/builtin/lrclib/lib/lrclib_source.py` +- Modify: `services/lyrics/lyrics_service.py:57-72` +- Modify: `services/metadata/cover_service.py:46-74` +- Modify: `services/sources/lyrics_sources.py:271-380` +- Modify: `services/sources/__init__.py:17-46` +- Test: `tests/test_services/test_plugin_lyrics_registry.py` +- Test: `tests/test_services/test_plugin_cover_registry.py` +- Test: `tests/test_plugins/test_lrclib_plugin.py` + +- [ ] **Step 1: Write the failing test** + +```python +from types import SimpleNamespace + +from harmony_plugin_api.cover import PluginArtistCoverResult, PluginCoverResult +from harmony_plugin_api.lyrics import PluginLyricsResult +from services.lyrics.lyrics_service import LyricsService +from services.metadata.cover_service import CoverService + + +def test_lyrics_service_merges_plugin_sources(monkeypatch): + fake_plugin_source = SimpleNamespace( + source_id="lrclib", + display_name="LRCLIB", + search=lambda *_args, **_kwargs: [ + PluginLyricsResult(song_id="song-1", title="Song 1", artist="Singer 1", source="lrclib", lyrics="[00:01.00]line"), + ], + get_lyrics=lambda result: result.lyrics, + ) + fake_manager = SimpleNamespace(registry=SimpleNamespace(lyrics_sources=lambda: [fake_plugin_source])) + monkeypatch.setattr("services.lyrics.lyrics_service.Bootstrap.instance", lambda: SimpleNamespace(plugin_manager=fake_manager)) + + results = LyricsService.search_songs("Song 1", "Singer 1") + + assert any(item["source"] == "lrclib" for item in results) + + +def test_cover_service_merges_plugin_cover_sources(monkeypatch): + fake_cover = SimpleNamespace( + source_id="qqmusic", + display_name="QQ Music", + search=lambda *_args, **_kwargs: [PluginCoverResult(item_id="mid-1", title="Song 1", artist="Singer 1", source="qqmusic", cover_url="https://example.com/cover.jpg")], + ) + fake_artist_cover = SimpleNamespace( + source_id="qqmusic-artist", + display_name="QQ Music Artist", + search=lambda *_args, **_kwargs: [PluginArtistCoverResult(artist_id="artist-1", name="Singer 1", source="qqmusic", cover_url="https://example.com/artist.jpg")], + ) + fake_registry = SimpleNamespace(cover_sources=lambda: [fake_cover], artist_cover_sources=lambda: [fake_artist_cover]) + fake_manager = SimpleNamespace(registry=fake_registry) + monkeypatch.setattr("services.metadata.cover_service.Bootstrap.instance", lambda: SimpleNamespace(plugin_manager=fake_manager)) + service = CoverService(http_client=SimpleNamespace(), sources=None) + + assert service._get_sources()[-1] is fake_cover + assert service._get_artist_sources()[-1] is fake_artist_cover +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `uv run pytest tests/test_services/test_plugin_lyrics_registry.py tests/test_services/test_plugin_cover_registry.py tests/test_plugins/test_lrclib_plugin.py -v` +Expected: FAIL because `LyricsService` and `CoverService` still hardcode host source classes, and `plugins/builtin/lrclib` does not exist + +- [ ] **Step 3: Write minimal implementation** + +```json +// plugins/builtin/lrclib/plugin.json +{ + "id": "lrclib", + "name": "LRCLIB", + "version": "1.0.0", + "api_version": "1", + "entrypoint": "plugin_main.py", + "entry_class": "LRCLIBPlugin", + "capabilities": ["lyrics_source"], + "min_app_version": "0.1.0" +} +``` + +```python +# plugins/builtin/lrclib/lib/lrclib_source.py +from harmony_plugin_api.lyrics import PluginLyricsResult + + +class LRCLIBPluginSource: + source_id = "lrclib" + display_name = "LRCLIB" + + def __init__(self, http_client) -> None: + self._http_client = http_client + + def search(self, title: str, artist: str, limit: int = 10) -> list[PluginLyricsResult]: + response = self._http_client.get( + "https://lrclib.net/api/search", + params={"track_name": title, "artist_name": artist}, + headers={"User-Agent": "Mozilla/5.0"}, + timeout=3, + ) + payload = response.json() if response.status_code == 200 else [] + return [ + PluginLyricsResult( + song_id=str(item.get("id", "")), + title=item.get("trackName", ""), + artist=item.get("artistName", ""), + album=item.get("albumName", ""), + duration=item.get("duration"), + source="lrclib", + lyrics=item.get("syncedLyrics") or item.get("plainLyrics"), + ) + for item in payload[:limit] + if item.get("syncedLyrics") or item.get("plainLyrics") + ] + + def get_lyrics(self, result: PluginLyricsResult) -> str | None: + return result.lyrics +``` + +```python +# plugins/builtin/lrclib/plugin_main.py +from harmony_plugin_api.plugin import HarmonyPlugin + +from .lib.lrclib_source import LRCLIBPluginSource + + +class LRCLIBPlugin(HarmonyPlugin): + plugin_id = "lrclib" + + def register(self, context) -> None: + context.services.register_lyrics_source(LRCLIBPluginSource(context.http)) + + def unregister(self, context) -> None: + return None +``` + +```python +# services/lyrics/lyrics_service.py +from app.bootstrap import Bootstrap +from services.sources import NetEaseLyricsSource, KugouLyricsSource + + +@classmethod +def _get_sources(cls): + http_client = _get_http_client() + builtin_sources = [ + NetEaseLyricsSource(http_client), + KugouLyricsSource(http_client), + ] + plugin_sources = Bootstrap.instance().plugin_manager.registry.lyrics_sources() + return builtin_sources + plugin_sources +``` + +```python +# services/metadata/cover_service.py +from app.bootstrap import Bootstrap +from services.sources import ITunesCoverSource, LastFmCoverSource, NetEaseCoverSource, NetEaseArtistCoverSource, ITunesArtistCoverSource + + +def _get_sources(self): + if self._sources is None: + host_sources = [ + NetEaseCoverSource(self.http_client), + ITunesCoverSource(self.http_client), + LastFmCoverSource(self.http_client), + ] + plugin_sources = Bootstrap.instance().plugin_manager.registry.cover_sources() + self._sources = host_sources + plugin_sources + return [source for source in self._sources if getattr(source, "is_available", lambda: True)()] + + +def _get_artist_sources(self): + host_sources = [ + NetEaseArtistCoverSource(self.http_client), + ITunesArtistCoverSource(self.http_client), + ] + plugin_sources = Bootstrap.instance().plugin_manager.registry.artist_cover_sources() + return host_sources + plugin_sources +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `uv run pytest tests/test_services/test_plugin_lyrics_registry.py tests/test_services/test_plugin_cover_registry.py tests/test_plugins/test_lrclib_plugin.py -v` +Expected: PASS + +- [ ] **Step 5: Commit** + +```bash +git add plugins/builtin/lrclib/plugin.json plugins/builtin/lrclib/plugin_main.py plugins/builtin/lrclib/lib/lrclib_source.py services/lyrics/lyrics_service.py services/metadata/cover_service.py services/sources/lyrics_sources.py services/sources/__init__.py tests/test_services/test_plugin_lyrics_registry.py tests/test_services/test_plugin_cover_registry.py tests/test_plugins/test_lrclib_plugin.py +git commit -m "迁移LRCLIB插件" +``` + +### Task 7: Create the QQ Music Plugin Package and Register Its Capabilities + +**Files:** +- Create: `plugins/builtin/qqmusic/plugin.json` +- Create: `plugins/builtin/qqmusic/plugin_main.py` +- Create: `plugins/builtin/qqmusic/lib/settings_tab.py` +- Create: `plugins/builtin/qqmusic/lib/lyrics_source.py` +- Create: `plugins/builtin/qqmusic/lib/cover_source.py` +- Create: `plugins/builtin/qqmusic/lib/artist_cover_source.py` +- Create: `plugins/builtin/qqmusic/lib/provider.py` +- Test: `tests/test_plugins/test_qqmusic_plugin.py` + +- [ ] **Step 1: Write the failing test** + +```python +from unittest.mock import Mock + +from plugins.builtin.qqmusic.plugin_main import QQMusicPlugin + + +def test_qqmusic_plugin_registers_expected_capabilities(): + context = Mock() + plugin = QQMusicPlugin() + + plugin.register(context) + + assert context.ui.register_sidebar_entry.call_count == 1 + assert context.ui.register_settings_tab.call_count == 1 + assert context.services.register_lyrics_source.call_count == 1 + assert context.services.register_cover_source.call_count == 1 + assert context.services.register_artist_cover_source.call_count == 1 + assert context.services.register_online_music_provider.call_count == 1 +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `uv run pytest tests/test_plugins/test_qqmusic_plugin.py -v` +Expected: FAIL with `ModuleNotFoundError: No module named 'plugins.builtin.qqmusic'` + +- [ ] **Step 3: Write minimal implementation** + +```json +// plugins/builtin/qqmusic/plugin.json +{ + "id": "qqmusic", + "name": "QQ Music", + "version": "1.0.0", + "api_version": "1", + "entrypoint": "plugin_main.py", + "entry_class": "QQMusicPlugin", + "capabilities": ["sidebar", "settings_tab", "lyrics_source", "cover", "online_music_provider"], + "min_app_version": "0.1.0" +} +``` + +```python +# plugins/builtin/qqmusic/plugin_main.py +from harmony_plugin_api.plugin import HarmonyPlugin +from harmony_plugin_api.registry_types import SettingsTabSpec, SidebarEntrySpec + +from .lib.artist_cover_source import QQMusicArtistCoverPluginSource +from .lib.cover_source import QQMusicCoverPluginSource +from .lib.lyrics_source import QQMusicLyricsPluginSource +from .lib.provider import QQMusicOnlineProvider +from .lib.settings_tab import QQMusicSettingsTab + + +class QQMusicPlugin(HarmonyPlugin): + plugin_id = "qqmusic" + + def register(self, context) -> None: + context.ui.register_sidebar_entry( + SidebarEntrySpec( + plugin_id="qqmusic", + entry_id="qqmusic.sidebar", + title="QQ 音乐", + order=80, + icon_name="GLOBE", + page_factory=lambda plugin_manager, parent: QQMusicOnlineProvider(context).create_page(context, parent), + ) + ) + context.ui.register_settings_tab( + SettingsTabSpec( + plugin_id="qqmusic", + tab_id="qqmusic.settings", + title="QQ 音乐", + order=80, + widget_factory=lambda plugin_manager, parent: QQMusicSettingsTab(context, parent), + ) + ) + context.services.register_lyrics_source(QQMusicLyricsPluginSource(context)) + context.services.register_cover_source(QQMusicCoverPluginSource(context)) + context.services.register_artist_cover_source(QQMusicArtistCoverPluginSource(context)) + context.services.register_online_music_provider(QQMusicOnlineProvider(context)) + + def unregister(self, context) -> None: + return None +``` + +```python +# plugins/builtin/qqmusic/lib/settings_tab.py +from PySide6.QtWidgets import QWidget, QVBoxLayout, QComboBox, QPushButton + + +class QQMusicSettingsTab(QWidget): + def __init__(self, context, parent=None): + super().__init__(parent) + self._context = context + layout = QVBoxLayout(self) + self._quality_combo = QComboBox(self) + for quality in ("320", "flac", "master"): + self._quality_combo.addItem(quality, quality) + self._quality_combo.setCurrentText(str(self._context.settings.get("quality", "320"))) + save_btn = QPushButton("Save", self) + save_btn.clicked.connect(self._save) + layout.addWidget(self._quality_combo) + layout.addWidget(save_btn) + + def _save(self): + self._context.settings.set("quality", self._quality_combo.currentData()) +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `uv run pytest tests/test_plugins/test_qqmusic_plugin.py -v` +Expected: PASS + +- [ ] **Step 5: Commit** + +```bash +git add plugins/builtin/qqmusic/plugin.json plugins/builtin/qqmusic/plugin_main.py plugins/builtin/qqmusic/lib/settings_tab.py plugins/builtin/qqmusic/lib/lyrics_source.py plugins/builtin/qqmusic/lib/cover_source.py plugins/builtin/qqmusic/lib/artist_cover_source.py plugins/builtin/qqmusic/lib/provider.py tests/test_plugins/test_qqmusic_plugin.py +git commit -m "创建QQ音乐插件包" +``` + +### Task 8: Migrate QQ Music Client, View Logic, and Remove Host Direct Wiring + +**Files:** +- Create: `plugins/builtin/qqmusic/lib/client.py` +- Create: `plugins/builtin/qqmusic/lib/login_dialog.py` +- Create: `plugins/builtin/qqmusic/lib/root_view.py` +- Modify: `app/bootstrap.py:344-414` +- Modify: `ui/dialogs/settings_dialog.py:325-399` +- Modify: `ui/windows/main_window.py:394-406` +- Modify: `services/sources/lyrics_sources.py:137-183` +- Modify: `services/sources/cover_sources.py:121-180` +- Modify: `services/sources/artist_cover_sources.py:79-130` +- Modify: `services/sources/__init__.py:9-52` +- Test: `tests/test_ui/test_plugin_settings_tab.py` +- Test: `tests/test_system/test_plugin_import_guard.py` + +- [ ] **Step 1: Write the failing test** + +```python +from pathlib import Path + +import pytest + +from system.plugins.installer import audit_plugin_imports + + +def test_settings_dialog_omits_qqmusic_tab_without_plugin(monkeypatch, qtbot): + from PySide6.QtWidgets import QTabWidget + from ui.dialogs.settings_dialog import GeneralSettingsDialog + + config = type( + "Config", + (), + { + "get": lambda self, key, default=None: default if key != "ui.theme" else "dark", + "get_ai_enabled": lambda self: False, + "get_ai_base_url": lambda self: "", + "get_ai_api_key": lambda self: "", + "get_ai_model": lambda self: "", + "get_acoustid_enabled": lambda self: False, + "get_acoustid_api_key": lambda self: "", + "get_online_music_download_dir": lambda self: "data/online_cache", + "get_cache_cleanup_strategy": lambda self: "manual", + "get_cache_cleanup_auto_enabled": lambda self: False, + "get_cache_cleanup_time_days": lambda self: 30, + "get_cache_cleanup_size_mb": lambda self: 1000, + "get_cache_cleanup_count": lambda self: 100, + "get_cache_cleanup_interval_hours": lambda self: 1, + "get_audio_engine": lambda self: "mpv", + }, + )() + + monkeypatch.setattr("ui.dialogs.settings_dialog.Bootstrap.instance", lambda: type("BootstrapStub", (), {"plugin_manager": type("Manager", (), {"registry": type("Registry", (), {"settings_tabs": staticmethod(lambda: [])})()})()})()) + dialog = GeneralSettingsDialog(config) + qtbot.addWidget(dialog) + tab_widget = dialog.findChild(QTabWidget) + + assert "QQ音乐" not in [tab_widget.tabText(index) for index in range(tab_widget.count())] + + +def test_plugin_import_audit_allows_sdk_only_imports(tmp_path: Path): + plugin_root = tmp_path / "qqmusic" + plugin_root.mkdir() + (plugin_root / "plugin_main.py").write_text("from harmony_plugin_api.plugin import HarmonyPlugin\n", encoding="utf-8") + + audit_plugin_imports(plugin_root) +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `uv run pytest tests/test_ui/test_plugin_settings_tab.py tests/test_system/test_plugin_import_guard.py -v` +Expected: FAIL because `settings_dialog.py` still builds the QQ Music tab directly and host QQ source modules are still present + +- [ ] **Step 3: Write minimal implementation** + +```python +# plugins/builtin/qqmusic/lib/client.py +from __future__ import annotations + +from plugins.builtin.qqmusic.lib.login_dialog import QQMusicLoginDialog + + +class QQMusicPluginClient: + def __init__(self, context): + self._context = context + self._credential = context.settings.get("credential", None) + + def get_quality(self) -> str: + return str(self._context.settings.get("quality", "320")) + + def set_credential(self, credential: dict) -> None: + self._credential = credential + self._context.settings.set("credential", credential) + + def clear_credential(self) -> None: + self._credential = None + self._context.settings.set("credential", None) +``` + +```python +# plugins/builtin/qqmusic/lib/root_view.py +from PySide6.QtWidgets import QWidget, QVBoxLayout, QPushButton, QLabel + +from harmony_plugin_api.media import PluginPlaybackRequest + + +class QQMusicRootView(QWidget): + def __init__(self, context, provider, parent=None): + super().__init__(parent) + self._context = context + self._provider = provider + self._status = QLabel("QQ Music", self) + self._play_btn = QPushButton("Play first track", self) + self._play_btn.clicked.connect(self._play_demo_track) + layout = QVBoxLayout(self) + layout.addWidget(self._status) + layout.addWidget(self._play_btn) + + def _play_demo_track(self): + track = self._provider.get_demo_track() + request = PluginPlaybackRequest( + provider_id="qqmusic", + track_id=track.track_id, + title=track.title, + quality=self._context.settings.get("quality", "320"), + metadata={"title": track.title, "artist": track.artist, "album": track.album}, + ) + local_path = self._context.services.media.cache_remote_track(request) + self._context.services.media.add_online_track(request) + self._status.setText(local_path or "download failed") +``` + +```python +# plugins/builtin/qqmusic/lib/provider.py +from harmony_plugin_api.online import PluginTrack + +from .client import QQMusicPluginClient +from .root_view import QQMusicRootView + + +class QQMusicOnlineProvider: + provider_id = "qqmusic" + display_name = "QQ 音乐" + + def __init__(self, context): + self._context = context + self._client = QQMusicPluginClient(context) + + def create_page(self, context, parent=None): + return QQMusicRootView(context, self, parent) + + def get_demo_track(self) -> PluginTrack: + return PluginTrack(track_id="demo-mid", title="Demo Song", artist="Demo Artist", album="Demo Album") + + def get_playback_url_info(self, track_id: str, quality: str): + return {"url": "https://example.com/demo.mp3", "quality": quality, "extension": ".mp3"} +``` + +```python +# app/bootstrap.py and ui/windows/main_window.py +@property +def online_download_service(self) -> "OnlineDownloadService": + if self._online_download_service is None: + from services.online import OnlineDownloadService + self._online_download_service = OnlineDownloadService( + config_manager=self.config, + qqmusic_service=None, + online_music_service=None, + ) + return self._online_download_service +``` + +```python +# ui/windows/main_window.py +for page in ( + self._library_view, + self._cloud_drive_view, + self._playlist_view, + self._queue_view, + self._albums_view, + self._artists_view, + self._artist_view, + self._album_view, + self._genres_view, + self._genre_view, +): + self._stacked_widget.addWidget(page) +self._mount_plugin_pages() +``` + +```python +# ui/dialogs/settings_dialog.py +tab_widget.addTab(playback_tab, t("playback_tab")) +tab_widget.addTab(appearance_tab, t("theme_tab")) +tab_widget.addTab(cache_tab, t("cache_tab")) +tab_widget.addTab(covers_tab, t("covers_tab")) +tab_widget.addTab(repair_tab, t("repair_tab")) +tab_widget.addTab(ai_tab, t("ai_tab")) +tab_widget.addTab(acoustid_tab, t("acoustid_tab")) +tab_widget.addTab(PluginManagementTab(Bootstrap.instance().plugin_manager, self), t("plugins_tab")) +for spec in Bootstrap.instance().plugin_manager.registry.settings_tabs(): + tab_widget.addTab(spec.widget_factory(Bootstrap.instance().plugin_manager, self), spec.title) +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `uv run pytest tests/test_ui/test_plugin_settings_tab.py tests/test_system/test_plugin_import_guard.py tests/test_plugins/test_qqmusic_plugin.py -v` +Expected: PASS + +- [ ] **Step 5: Commit** + +```bash +git add plugins/builtin/qqmusic/lib/client.py plugins/builtin/qqmusic/lib/login_dialog.py plugins/builtin/qqmusic/lib/root_view.py plugins/builtin/qqmusic/lib/provider.py app/bootstrap.py ui/dialogs/settings_dialog.py ui/windows/main_window.py services/sources/lyrics_sources.py services/sources/cover_sources.py services/sources/artist_cover_sources.py services/sources/__init__.py tests/test_ui/test_plugin_settings_tab.py tests/test_system/test_plugin_import_guard.py +git commit -m "迁移QQ音乐宿主接线" +``` + +### Task 9: Package QQ Music as a Zip Plugin and Remove Host QQ Modules + +**Files:** +- Create: `scripts/build_plugin_zip.py` +- Modify: `system/config.py:68-80,693-800` +- Delete: `services/lyrics/qqmusic_lyrics.py` +- Delete: `services/cloud/qqmusic/__init__.py` +- Delete: `services/cloud/qqmusic/client.py` +- Delete: `services/cloud/qqmusic/common.py` +- Delete: `services/cloud/qqmusic/crypto.py` +- Delete: `services/cloud/qqmusic/qr_login.py` +- Delete: `services/cloud/qqmusic/qqmusic_service.py` +- Delete: `services/cloud/qqmusic/tripledes.py` +- Test: `tests/test_system/test_plugin_packaging.py` +- Test: `tests/test_system/test_plugin_installer.py` + +- [ ] **Step 1: Write the failing test** + +```python +import zipfile +from pathlib import Path + +from scripts.build_plugin_zip import build_plugin_zip + + +def test_build_plugin_zip_contains_manifest_and_entrypoint(tmp_path: Path): + plugin_root = Path("plugins/builtin/qqmusic") + output_zip = tmp_path / "qqmusic.zip" + + build_plugin_zip(plugin_root, output_zip) + + with zipfile.ZipFile(output_zip) as archive: + names = set(archive.namelist()) + + assert "plugin.json" in names + assert "plugin_main.py" in names +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `uv run pytest tests/test_system/test_plugin_packaging.py tests/test_system/test_plugin_installer.py -v` +Expected: FAIL with `ModuleNotFoundError: No module named 'scripts.build_plugin_zip'` + +- [ ] **Step 3: Write minimal implementation** + +```python +# scripts/build_plugin_zip.py +from __future__ import annotations + +import zipfile +from pathlib import Path + + +def build_plugin_zip(plugin_root: Path, output_zip: Path) -> Path: + output_zip.parent.mkdir(parents=True, exist_ok=True) + with zipfile.ZipFile(output_zip, "w", compression=zipfile.ZIP_DEFLATED) as archive: + for file_path in plugin_root.rglob("*"): + if file_path.is_file(): + archive.write(file_path, file_path.relative_to(plugin_root)) + return output_zip +``` + +```python +# system/config.py +class ConfigManager: + ... + def get_plugin_setting(self, plugin_id: str, key: str, default=None): + return self.get(f"plugins.{plugin_id}.{key}", default) + + def set_plugin_setting(self, plugin_id: str, key: str, value): + self.set(f"plugins.{plugin_id}.{key}", value) +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `uv run pytest tests/test_system/test_plugin_packaging.py tests/test_system/test_plugin_installer.py tests/test_plugins/test_qqmusic_plugin.py tests/test_plugins/test_lrclib_plugin.py tests/test_ui/test_plugin_sidebar_integration.py tests/test_ui/test_plugin_settings_tab.py tests/test_services/test_plugin_lyrics_registry.py tests/test_services/test_plugin_cover_registry.py -v` +Expected: PASS + +- [ ] **Step 5: Commit** + +```bash +git add scripts/build_plugin_zip.py system/config.py tests/test_system/test_plugin_packaging.py tests/test_system/test_plugin_installer.py tests/test_plugins/test_qqmusic_plugin.py tests/test_plugins/test_lrclib_plugin.py tests/test_ui/test_plugin_sidebar_integration.py tests/test_ui/test_plugin_settings_tab.py tests/test_services/test_plugin_lyrics_registry.py tests/test_services/test_plugin_cover_registry.py +git rm services/lyrics/qqmusic_lyrics.py services/cloud/qqmusic/__init__.py services/cloud/qqmusic/client.py services/cloud/qqmusic/common.py services/cloud/qqmusic/crypto.py services/cloud/qqmusic/qr_login.py services/cloud/qqmusic/qqmusic_service.py services/cloud/qqmusic/tripledes.py +git commit -m "完成QQ音乐插件化" +``` diff --git a/docs/superpowers/plans/2026-04-06-qq-plugin-page-parity.md b/docs/superpowers/plans/2026-04-06-qq-plugin-page-parity.md new file mode 100644 index 00000000..16b59136 --- /dev/null +++ b/docs/superpowers/plans/2026-04-06-qq-plugin-page-parity.md @@ -0,0 +1,643 @@ +# QQ Plugin Page Parity Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Make the QQ Music plugin page approach the legacy QQ page by restoring high-value search, detail, recommendation/favorites, ranking, and search-polish behaviors inside the plugin runtime. + +**Architecture:** Keep `QQMusicRootView` as the plugin entry page, move data normalization into `QQMusicPluginClient`, and reuse host-neutral shared widgets like `OnlineGridView`, `OnlineDetailView`, `OnlineTracksListView`, and `RecommendSection` instead of reintroducing the legacy host view. All playback, queue, and download actions continue to flow through `context.services.media`. + +**Tech Stack:** Python 3.11, PySide6, pytest, pytest-qt, `uv` + +--- + +## File Map + +- Modify: `plugins/builtin/qqmusic/lib/root_view.py:39-683` — expand the page state model, swap simplified result/detail widgets for shared views, add navigation stack, batch actions, ranking view switching, and search polish +- Modify: `plugins/builtin/qqmusic/lib/client.py:36-195` — normalize paged search payloads, recommendation card metadata, favorites card metadata, and detail payloads +- Modify: `plugins/builtin/qqmusic/lib/provider.py:17-65` — expose paged search and any normalized helper methods the root view needs +- Modify: `tests/test_plugins/test_qqmusic_plugin.py:110-655` — add focused tests for Batch A-D behaviors and update existing assertions to match the richer UI + +### Task 1: Batch A Search Results and Detail View Parity + +**Files:** +- Modify: `plugins/builtin/qqmusic/lib/root_view.py:39-683` +- Modify: `plugins/builtin/qqmusic/lib/provider.py:23-54` +- Modify: `tests/test_plugins/test_qqmusic_plugin.py:110-470` + +- [ ] **Step 1: Write the failing tests for paged results, grid results, and detail batch actions** + +```python +def test_root_view_song_search_uses_table_and_pagination(qtbot): + settings = Mock() + settings.get.side_effect = lambda key, default=None: {"nick": "Tester", "quality": "320"}.get(key, default) + context = Mock(settings=settings) + context.services.media = Mock() + provider = Mock() + provider.is_logged_in.return_value = True + provider.get_top_lists.return_value = [] + provider.get_top_list_tracks.return_value = [] + provider.get_recommendations.return_value = [] + provider.get_favorites.return_value = [] + provider.search.return_value = { + "tracks": [ + {"mid": "song-1", "title": "Song 1", "artist": "Singer 1", "album": "Album 1", "duration": 210} + ], + "total": 61, + "page": 1, + "page_size": 30, + } + + view = QQMusicRootView(context, provider) + qtbot.addWidget(view) + + view._search_input.setText("Song 1") + view._run_search() + + assert view._results_stack.currentWidget() is view._songs_page + assert view._results_table.rowCount() == 1 + assert view._page_label.text() == "1" + assert view._next_btn.isEnabled() is True + provider.search.assert_called_once_with("Song 1", "song", page=1, page_size=30) + + +def test_root_view_artist_search_uses_grid_and_load_more(qtbot): + settings = Mock() + settings.get.side_effect = lambda key, default=None: {"nick": "Tester", "quality": "320"}.get(key, default) + context = Mock(settings=settings) + context.services.media = Mock() + provider = Mock() + provider.is_logged_in.return_value = True + provider.get_top_lists.return_value = [] + provider.get_top_list_tracks.return_value = [] + provider.get_recommendations.return_value = [] + provider.get_favorites.return_value = [] + provider.search.side_effect = [ + { + "artists": [{"mid": "artist-1", "name": "Singer 1", "song_count": 12}], + "total": 61, + "page": 1, + "page_size": 30, + }, + { + "artists": [{"mid": "artist-2", "name": "Singer 2", "song_count": 8}], + "total": 61, + "page": 2, + "page_size": 30, + }, + ] + + view = QQMusicRootView(context, provider) + qtbot.addWidget(view) + + view._search_input.setText("Singer") + view._search_type_tabs.setCurrentIndex(1) + view._run_search() + view._on_load_more_artists() + + assert view._results_stack.currentWidget() is view._artists_page + assert provider.search.call_args_list[0].args[:2] == ("Singer", "singer") + assert provider.search.call_args_list[1].kwargs == {"page": 2, "page_size": 30} + + +def test_root_view_detail_view_supports_batch_actions(qtbot): + settings = Mock() + settings.get.side_effect = lambda key, default=None: {"nick": "Tester", "quality": "flac"}.get(key, default) + context = Mock(settings=settings) + context.services.media = Mock() + provider = Mock() + provider.is_logged_in.return_value = True + provider.get_top_lists.return_value = [] + provider.get_top_list_tracks.return_value = [] + provider.get_recommendations.return_value = [] + provider.get_favorites.return_value = [] + provider.search.return_value = { + "artists": [{"mid": "artist-1", "name": "Singer 1", "song_count": 12}], + "total": 1, + "page": 1, + "page_size": 30, + } + provider.get_artist_detail.return_value = { + "title": "Singer 1", + "description": "desc", + "songs": [ + {"mid": "song-1", "title": "Song 1", "artist": "Singer 1", "album": "Album 1", "duration": 210}, + {"mid": "song-2", "title": "Song 2", "artist": "Singer 1", "album": "Album 1", "duration": 180}, + ], + } + + view = QQMusicRootView(context, provider) + qtbot.addWidget(view) + + view._search_input.setText("Singer 1") + view._search_type_tabs.setCurrentIndex(1) + view._run_search() + view._open_artist_detail_from_grid({"mid": "artist-1", "name": "Singer 1"}) + view._play_all_from_detail_tracks() + view._add_all_detail_tracks_to_queue() + view._insert_all_detail_tracks_to_queue() + + assert context.services.media.play_online_track.call_count == 1 + assert context.services.media.add_online_track_to_queue.call_count == 2 + assert context.services.media.insert_online_track_to_queue.call_count == 2 +``` + +- [ ] **Step 2: Run the focused plugin page tests to verify they fail** + +Run: `uv run pytest tests/test_plugins/test_qqmusic_plugin.py -k "pagination or load_more or batch_actions" -v` +Expected: FAIL with missing attributes such as `_results_table`, `_page_label`, `_on_load_more_artists`, or missing detail batch methods on `QQMusicRootView` + +- [ ] **Step 3: Implement paged search, shared result widgets, and shared detail view** + +```python +# plugins/builtin/qqmusic/lib/provider.py +def search( + self, + keyword: str, + search_type: str = "song", + *, + page: int = 1, + page_size: int = 30, +) -> dict[str, Any]: + return self._client.search(keyword, search_type=search_type, limit=page_size, page=page) +``` + +```python +# plugins/builtin/qqmusic/lib/root_view.py +from domain.online_music import OnlineAlbum, OnlineArtist, OnlinePlaylist, OnlineTrack +from ui.views.online_detail_view import OnlineDetailView +from ui.views.online_grid_view import OnlineGridView + +self._navigation_stack: list[dict[str, Any]] = [] +self._current_keyword = "" +self._current_page = 1 +self._grid_page = 1 +self._grid_page_size = 30 +self._grid_total = 0 +self._current_tracks: list[dict[str, Any]] = [] + +self._songs_page = QWidget(self._results_page) +self._results_table = QTableWidget(0, 4, self._songs_page) +self._artists_page = OnlineGridView(data_type="singer", parent=self._results_page) +self._albums_page = OnlineGridView(data_type="album", parent=self._results_page) +self._playlists_page = OnlineGridView(data_type="playlist", parent=self._results_page) +self._detail_view = OnlineDetailView(parent=self) +``` + +```python +# plugins/builtin/qqmusic/lib/root_view.py +def _run_search(self) -> None: + keyword = self._search_input.text().strip() + if not keyword: + self._home_stack.setCurrentWidget(self._home_page) + return + self._record_search_history(keyword) + self._current_keyword = keyword + self._current_page = 1 + self._grid_page = 1 + self._perform_search(page=1) + + +def _perform_search(self, *, page: int) -> None: + search_type = self._SEARCH_TYPES[self._search_type_tabs.currentIndex()] + payload = self._provider.search( + self._current_keyword, + search_type, + page=page, + page_size=self._grid_page_size, + ) + self._populate_search_results(search_type, payload, page=page) +``` + +```python +# plugins/builtin/qqmusic/lib/root_view.py +def _show_detail_with_tracks(self, title: str, description: str, songs: list[dict[str, Any]]) -> None: + tracks = [self._coerce_online_track(song) for song in songs] + self._detail_tracks = tracks + self._detail_view.load_songs_directly(songs, title, "") + self._home_stack.setCurrentWidget(self._detail_page) + + +def _play_all_from_detail_tracks(self) -> None: + if not self._detail_tracks: + return + first = self._build_playback_request(self._track_to_item(self._detail_tracks[0])) + self._context.services.media.play_online_track(first) + + +def _add_all_detail_tracks_to_queue(self) -> None: + for track in self._detail_tracks: + self._context.services.media.add_online_track_to_queue( + self._build_playback_request(self._track_to_item(track)) + ) + + +def _insert_all_detail_tracks_to_queue(self) -> None: + for track in self._detail_tracks: + self._context.services.media.insert_online_track_to_queue( + self._build_playback_request(self._track_to_item(track)) + ) +``` + +- [ ] **Step 4: Run the Batch A test slice and the existing plugin navigation tests** + +Run: `uv run pytest tests/test_plugins/test_qqmusic_plugin.py -k "search or detail or pagination or load_more or batch_actions" -v` +Expected: PASS for the new Batch A tests and the existing detail navigation tests + +- [ ] **Step 5: Commit Batch A** + +```bash +git add tests/test_plugins/test_qqmusic_plugin.py plugins/builtin/qqmusic/lib/provider.py plugins/builtin/qqmusic/lib/root_view.py +git commit -m "迁移QQ插件搜索和详情页" +``` + +### Task 2: Batch B Recommendation and Favorites Card Parity + +**Files:** +- Modify: `plugins/builtin/qqmusic/lib/client.py:70-176` +- Modify: `plugins/builtin/qqmusic/lib/root_view.py:72-683` +- Modify: `tests/test_plugins/test_qqmusic_plugin.py:196-540` + +- [ ] **Step 1: Write the failing tests for card-based favorites and recommendations** + +```python +def test_root_view_loads_recommendation_cards(qtbot): + settings = Mock() + settings.get.side_effect = lambda key, default=None: {"nick": "Tester", "quality": "320"}.get(key, default) + context = Mock(settings=settings) + context.services.media = Mock() + provider = Mock() + provider.is_logged_in.return_value = True + provider.get_top_lists.return_value = [] + provider.get_top_list_tracks.return_value = [] + provider.get_recommendations.return_value = [ + {"id": "guess", "title": "猜你喜欢", "subtitle": "2 项", "cover_url": "", "items": [{"mid": "song-1", "title": "Song 1"}]}, + ] + provider.get_favorites.return_value = [] + + view = QQMusicRootView(context, provider) + qtbot.addWidget(view) + + assert view._recommend_section.isHidden() is False + assert view._recommend_section._cards_layout.count() == 1 + + +def test_root_view_favorite_song_card_opens_detail_view(qtbot): + settings = Mock() + settings.get.side_effect = lambda key, default=None: {"nick": "Tester", "quality": "320"}.get(key, default) + context = Mock(settings=settings) + context.services.media = Mock() + provider = Mock() + provider.is_logged_in.return_value = True + provider.get_top_lists.return_value = [] + provider.get_top_list_tracks.return_value = [] + provider.get_recommendations.return_value = [] + provider.get_favorites.return_value = [ + { + "id": "fav_songs", + "title": "我喜欢的歌曲", + "subtitle": "1 首", + "cover_url": "", + "items": [{"mid": "song-1", "title": "Song 1", "artist": "Singer 1"}], + "entry_type": "songs", + }, + ] + + view = QQMusicRootView(context, provider) + qtbot.addWidget(view) + + view._open_favorite_card(provider.get_favorites.return_value[0]) + + assert view._home_stack.currentWidget() is view._detail_page + + +def test_root_view_recommendation_playlist_card_opens_playlist_results(qtbot): + settings = Mock() + settings.get.side_effect = lambda key, default=None: {"nick": "Tester", "quality": "320"}.get(key, default) + context = Mock(settings=settings) + context.services.media = Mock() + provider = Mock() + provider.is_logged_in.return_value = True + provider.get_top_lists.return_value = [] + provider.get_top_list_tracks.return_value = [] + provider.get_recommendations.return_value = [ + { + "id": "songlist", + "title": "推荐歌单", + "subtitle": "1 项", + "cover_url": "", + "items": [{"id": "pl-1", "title": "Playlist 1", "creator": "Tester", "song_count": 12}], + "entry_type": "playlists", + }, + ] + provider.get_favorites.return_value = [] + + view = QQMusicRootView(context, provider) + qtbot.addWidget(view) + + view._open_recommendation_card(provider.get_recommendations.return_value[0]) + + assert view._home_stack.currentWidget() is view._results_page + assert view._results_stack.currentWidget() is view._playlists_page +``` + +- [ ] **Step 2: Run the focused card tests to verify they fail** + +Run: `uv run pytest tests/test_plugins/test_qqmusic_plugin.py -k "recommendation_cards or favorite_song_card or recommendation_playlist_card" -v` +Expected: FAIL because `_recommend_section`, `_favorites_section`, `_open_favorite_card`, or `_open_recommendation_card` do not yet exist + +- [ ] **Step 3: Normalize card payloads in the client and render `RecommendSection` cards in the root view** + +```python +# plugins/builtin/qqmusic/lib/client.py +def get_recommendations(self) -> list[dict]: + service = self._get_service() + if service is None: + return [] + cards: list[dict] = [] + for card_id, title, entry_type, loader in ( + ("home_feed", "首页推荐", "songs", service.get_home_feed), + ("guess", "猜你喜欢", "songs", service.get_guess_recommend), + ("radar", "雷达歌单", "songs", service.get_radar_recommend), + ("songlist", "推荐歌单", "playlists", service.get_recommend_songlist), + ("newsong", "新歌推荐", "songs", service.get_recommend_newsong), + ): + data = loader() or [] + if data: + cards.append( + { + "id": card_id, + "title": title, + "subtitle": f"{len(data)} 项", + "cover_url": self._pick_cover(data), + "items": data, + "entry_type": entry_type, + } + ) + return cards +``` + +```python +# plugins/builtin/qqmusic/lib/root_view.py +from ui.widgets.recommend_card import RecommendSection + +self._favorites_section = RecommendSection(title="我的收藏", parent=self._home_page) +self._recommend_section = RecommendSection(title="推荐内容", parent=self._home_page) +self._favorites_section.recommendation_clicked.connect(self._open_favorite_card) +self._recommend_section.recommendation_clicked.connect(self._open_recommendation_card) +``` + +```python +# plugins/builtin/qqmusic/lib/root_view.py +def _load_logged_in_sections(self) -> None: + favorites = self._safe_provider_call("get_favorites", []) + recommendations = self._safe_provider_call("get_recommendations", []) + self._favorites_section.setHidden(not bool(favorites)) + self._recommend_section.setHidden(not bool(recommendations)) + if favorites: + self._favorites_section.load_recommendations(favorites) + if recommendations: + self._recommend_section.load_recommendations(recommendations) + + +def _open_favorite_card(self, data: dict[str, Any]) -> None: + self._open_card_entry(data) + + +def _open_recommendation_card(self, data: dict[str, Any]) -> None: + self._open_card_entry(data) +``` + +- [ ] **Step 4: Run the Batch B tests plus the earlier favorites/recommendation navigation coverage** + +Run: `uv run pytest tests/test_plugins/test_qqmusic_plugin.py -k "favorite or recommendation" -v` +Expected: PASS for the new card tests and the existing favorites/recommendation navigation expectations after they are updated to card APIs + +- [ ] **Step 5: Commit Batch B** + +```bash +git add tests/test_plugins/test_qqmusic_plugin.py plugins/builtin/qqmusic/lib/client.py plugins/builtin/qqmusic/lib/root_view.py +git commit -m "迁移QQ插件推荐和收藏卡片" +``` + +### Task 3: Batch C Ranking View and Batch Song Actions + +**Files:** +- Modify: `plugins/builtin/qqmusic/lib/root_view.py:84-683` +- Modify: `tests/test_plugins/test_qqmusic_plugin.py:224-655` + +- [ ] **Step 1: Write the failing tests for ranking view switching and ranking batch actions** + +```python +def test_root_view_ranking_toggle_switches_between_table_and_list(qtbot): + settings = Mock() + state = {"nick": "", "quality": "320", "ranking_view_mode": "table"} + settings.get.side_effect = lambda key, default=None: state.get(key, default) + settings.set.side_effect = lambda key, value: state.__setitem__(key, value) + context = Mock(settings=settings) + context.services.media = Mock() + provider = Mock() + provider.is_logged_in.return_value = False + provider.get_top_lists.return_value = [{"id": 26, "title": "热歌榜"}] + provider.get_top_list_tracks.return_value = [{"mid": "song-1", "title": "Song 1", "artist": "Singer 1", "album": "Album 1", "duration": 210}] + provider.get_recommendations.return_value = [] + provider.get_favorites.return_value = [] + + view = QQMusicRootView(context, provider) + qtbot.addWidget(view) + + view._toggle_ranking_view_mode() + + assert state["ranking_view_mode"] == "list" + assert view._ranking_stacked_widget.currentWidget() is view._ranking_list_view + + +def test_root_view_ranking_batch_queue_actions_use_media_bridge(qtbot): + settings = Mock() + settings.get.side_effect = lambda key, default=None: {"nick": "", "quality": "320", "ranking_view_mode": "table"}.get(key, default) + context = Mock(settings=settings) + context.services.media = Mock() + provider = Mock() + provider.is_logged_in.return_value = False + provider.get_top_lists.return_value = [{"id": 26, "title": "热歌榜"}] + provider.get_top_list_tracks.return_value = [ + {"mid": "song-1", "title": "Song 1", "artist": "Singer 1", "album": "Album 1", "duration": 210}, + {"mid": "song-2", "title": "Song 2", "artist": "Singer 2", "album": "Album 2", "duration": 180}, + ] + provider.get_recommendations.return_value = [] + provider.get_favorites.return_value = [] + + view = QQMusicRootView(context, provider) + qtbot.addWidget(view) + + tracks = [view._current_tracks[0], view._current_tracks[1]] + view._add_selected_tracks_to_queue(tracks) + view._insert_selected_tracks_to_queue(tracks) + view._download_selected_tracks(tracks) + + assert context.services.media.add_online_track_to_queue.call_count == 2 + assert context.services.media.insert_online_track_to_queue.call_count == 2 + assert context.services.media.cache_remote_track.call_count == 2 +``` + +- [ ] **Step 2: Run the focused ranking tests to verify they fail** + +Run: `uv run pytest tests/test_plugins/test_qqmusic_plugin.py -k "ranking_toggle or ranking_batch_queue" -v` +Expected: FAIL because ranking stacked widgets, preference persistence, or bulk action helpers are not implemented + +- [ ] **Step 3: Add ranking stacked widgets and shared batch-action helpers** + +```python +# plugins/builtin/qqmusic/lib/root_view.py +from ui.views.online_tracks_list_view import OnlineTracksListView + +self._ranking_stacked_widget = QStackedWidget(self._home_page) +self._ranking_list_view = OnlineTracksListView(parent=self._home_page) +self._ranking_stacked_widget.addWidget(self._top_tracks_table) +self._ranking_stacked_widget.addWidget(self._ranking_list_view) +``` + +```python +# plugins/builtin/qqmusic/lib/root_view.py +def _toggle_ranking_view_mode(self) -> None: + current = str(self._context.settings.get("ranking_view_mode", "table")) + new_value = "list" if current == "table" else "table" + self._context.settings.set("ranking_view_mode", new_value) + self._ranking_stacked_widget.setCurrentWidget( + self._ranking_list_view if new_value == "list" else self._top_tracks_table + ) + + +def _add_selected_tracks_to_queue(self, tracks: list[dict[str, Any]]) -> None: + for item in tracks: + self._context.services.media.add_online_track_to_queue(self._build_playback_request(item)) + + +def _insert_selected_tracks_to_queue(self, tracks: list[dict[str, Any]]) -> None: + for item in tracks: + self._context.services.media.insert_online_track_to_queue(self._build_playback_request(item)) + + +def _download_selected_tracks(self, tracks: list[dict[str, Any]]) -> None: + for item in tracks: + self._context.services.media.cache_remote_track(self._build_playback_request(item)) +``` + +- [ ] **Step 4: Run the Batch C ranking tests and the existing top-track playback tests** + +Run: `uv run pytest tests/test_plugins/test_qqmusic_plugin.py -k "ranking or top_track_activation" -v` +Expected: PASS for ranking toggle, ranking batch actions, and top-track playback coverage + +- [ ] **Step 5: Commit Batch C** + +```bash +git add tests/test_plugins/test_qqmusic_plugin.py plugins/builtin/qqmusic/lib/root_view.py +git commit -m "迁移QQ插件榜单交互" +``` + +### Task 4: Batch D Search Popup, Completion Coordination, and UI Text Refresh + +**Files:** +- Modify: `plugins/builtin/qqmusic/lib/root_view.py:39-683` +- Modify: `tests/test_plugins/test_qqmusic_plugin.py:542-655` + +- [ ] **Step 1: Write the failing tests for search popup state, completion debounce state, and home recovery** + +```python +def test_root_view_clearing_search_returns_home_sections(qtbot): + settings = Mock() + store = {"nick": "Tester", "quality": "320", "search_history": []} + settings.get.side_effect = lambda key, default=None: store.get(key, default) + settings.set.side_effect = lambda key, value: store.__setitem__(key, value) + context = Mock(settings=settings) + context.services.media = Mock() + provider = Mock() + provider.is_logged_in.return_value = True + provider.get_top_lists.return_value = [] + provider.get_top_list_tracks.return_value = [] + provider.get_recommendations.return_value = [{"id": "guess", "title": "猜你喜欢", "subtitle": "1 项", "cover_url": "", "items": [{"mid": "song-1"}], "entry_type": "songs"}] + provider.get_favorites.return_value = [{"id": "fav_songs", "title": "我喜欢的歌曲", "subtitle": "1 首", "cover_url": "", "items": [{"mid": "song-1"}], "entry_type": "songs"}] + provider.search.return_value = {"tracks": [], "total": 0, "page": 1, "page_size": 30} + + view = QQMusicRootView(context, provider) + qtbot.addWidget(view) + + view._search_input.setText("abc") + view._run_search() + view._on_search_text_changed("") + + assert view._home_stack.currentWidget() is view._home_page + assert view._favorites_section.isHidden() is False + assert view._recommend_section.isHidden() is False + + +def test_root_view_completion_updates_prefix_and_model(qtbot): + settings = Mock() + settings.get.side_effect = lambda key, default=None: {"nick": "", "quality": "320"}.get(key, default) + context = Mock(settings=settings) + context.services.media = Mock() + provider = Mock() + provider.is_logged_in.return_value = False + provider.get_top_lists.return_value = [] + provider.get_top_list_tracks.return_value = [] + provider.get_recommendations.return_value = [] + provider.get_favorites.return_value = [] + provider.get_hotkeys.return_value = [{"title": "周杰伦"}] + provider.complete.return_value = [{"hint": "周杰伦 晴天"}, {"hint": "周杰伦 七里香"}] + + view = QQMusicRootView(context, provider) + qtbot.addWidget(view) + + view._search_input.setText("周杰伦") + view._on_search_text_changed("周杰伦") + view._trigger_completion() + + assert view._completer.completionPrefix() == "周杰伦" + assert view._completer.model().rowCount() == 2 +``` + +- [ ] **Step 2: Run the focused search-polish tests to verify they fail** + +Run: `uv run pytest tests/test_plugins/test_qqmusic_plugin.py -k "clearing_search_returns_home or completion_updates_prefix" -v` +Expected: FAIL because home recovery, completion coordination, or popup state handling still reflects the simplified implementation + +- [ ] **Step 3: Add search state recovery, completion coordination, and `refresh_ui` text updates** + +```python +# plugins/builtin/qqmusic/lib/root_view.py +def _on_search_text_changed(self, text: str) -> None: + keyword = text.strip() + if not keyword and self._current_keyword: + self._current_keyword = "" + self._current_page = 1 + self._grid_page = 1 + self._home_stack.setCurrentWidget(self._home_page) + self._load_home_sections() + return + if keyword: + self._update_completion(keyword) +``` + +```python +# plugins/builtin/qqmusic/lib/root_view.py +def refresh_ui(self) -> None: + self._status.setText(self._build_status_text()) + self._search_input.setPlaceholderText("搜索 QQ 音乐") + self._search_btn.setText("搜索") + self._search_type_tabs.setTabText(0, "歌曲") + self._search_type_tabs.setTabText(1, "歌手") + self._search_type_tabs.setTabText(2, "专辑") + self._search_type_tabs.setTabText(3, "歌单") + self._load_home_sections() +``` + +- [ ] **Step 4: Run the full focused QQ plugin page test file** + +Run: `uv run pytest tests/test_plugins/test_qqmusic_plugin.py -v` +Expected: PASS for the QQ plugin page coverage added in Tasks 1-4 + +- [ ] **Step 5: Commit Batch D** + +```bash +git add tests/test_plugins/test_qqmusic_plugin.py plugins/builtin/qqmusic/lib/root_view.py +git commit -m "完善QQ插件页搜索体验" +``` diff --git a/docs/superpowers/plans/2026-04-07-harmony-plugin-api-packaging.md b/docs/superpowers/plans/2026-04-07-harmony-plugin-api-packaging.md new file mode 100644 index 00000000..92c3c714 --- /dev/null +++ b/docs/superpowers/plans/2026-04-07-harmony-plugin-api-packaging.md @@ -0,0 +1,343 @@ +# Harmony Plugin API Packaging Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Publish `harmony_plugin_api` as a standalone pip package `harmony-plugin-api` while keeping all Harmony host runtime implementations inside the main app. + +**Architecture:** Create a repo-local distributable package under `packages/harmony-plugin-api/` and move only the pure SDK modules there. Replace the current `harmony_plugin_api.ui` and `harmony_plugin_api.runtime` host-coupled modules with host-side bridge modules under `system/plugins/`, then update host wiring and the QQ plugin to use `PluginContext` or host bridge imports instead of SDK runtime imports. + +**Tech Stack:** Python 3.11, uv, pytest, build backend via `pyproject.toml` + +--- + +## File Map + +- Create: `packages/harmony-plugin-api/pyproject.toml` — standalone package metadata and build config for `harmony-plugin-api` +- Create: `packages/harmony-plugin-api/README.md` — package-facing README with installation and scope notes +- Create: `packages/harmony-plugin-api/src/harmony_plugin_api/__init__.py` — public SDK exports +- Create: `packages/harmony-plugin-api/src/harmony_plugin_api/context.py` — pure bridge protocols only +- Create: `packages/harmony-plugin-api/src/harmony_plugin_api/plugin.py` +- Create: `packages/harmony-plugin-api/src/harmony_plugin_api/manifest.py` +- Create: `packages/harmony-plugin-api/src/harmony_plugin_api/media.py` +- Create: `packages/harmony-plugin-api/src/harmony_plugin_api/lyrics.py` +- Create: `packages/harmony-plugin-api/src/harmony_plugin_api/cover.py` +- Create: `packages/harmony-plugin-api/src/harmony_plugin_api/online.py` +- Create: `packages/harmony-plugin-api/src/harmony_plugin_api/registry_types.py` +- Create: `system/plugins/plugin_sdk_ui.py` — host-side theme/dialog/icon bridge implementation +- Create: `system/plugins/plugin_sdk_runtime.py` — host-side runtime helpers used by legacy QQ bridge code +- Modify: `system/plugins/host_services.py` — swap imports from SDK runtime modules to host bridge modules while preserving `PluginContext` +- Modify: `plugins/builtin/qqmusic/lib/dialog_title_bar.py` — stop importing `harmony_plugin_api.ui` +- Modify: `plugins/builtin/qqmusic/lib/login_dialog.py` — stop importing `harmony_plugin_api.ui` +- Modify: `plugins/builtin/qqmusic/lib/settings_tab.py` — stop importing `harmony_plugin_api.ui` +- Modify: `plugins/builtin/qqmusic/lib/runtime_bridge.py` — stop importing `harmony_plugin_api.ui` / `runtime` +- Delete: `harmony_plugin_api/ui.py` — not part of publishable SDK +- Delete: `harmony_plugin_api/runtime.py` — not part of publishable SDK +- Modify: `harmony_plugin_api/__init__.py` — leave as local compatibility shim or re-export from installed package path only if still needed during migration +- Modify: `harmony_plugin_api/context.py` — keep in sync with packaged SDK or reduce to thin compatibility wrapper +- Modify: `tests/test_system/test_plugin_ui_bridge.py` — assert host bridge is provided via context, not via SDK runtime imports +- Modify: `tests/test_system/test_plugin_import_guard.py` — verify packaged SDK modules are allowed and host modules are forbidden +- Add: `tests/test_system/test_harmony_plugin_api_package.py` — package structure/build/import assertions + +### Task 1: Scaffold the Standalone Package and Lock the SDK Boundary + +**Files:** +- Create: `packages/harmony-plugin-api/pyproject.toml` +- Create: `packages/harmony-plugin-api/README.md` +- Create: `packages/harmony-plugin-api/src/harmony_plugin_api/__init__.py` +- Create: `packages/harmony-plugin-api/src/harmony_plugin_api/context.py` +- Create: `packages/harmony-plugin-api/src/harmony_plugin_api/plugin.py` +- Create: `packages/harmony-plugin-api/src/harmony_plugin_api/manifest.py` +- Create: `packages/harmony-plugin-api/src/harmony_plugin_api/media.py` +- Create: `packages/harmony-plugin-api/src/harmony_plugin_api/lyrics.py` +- Create: `packages/harmony-plugin-api/src/harmony_plugin_api/cover.py` +- Create: `packages/harmony-plugin-api/src/harmony_plugin_api/online.py` +- Create: `packages/harmony-plugin-api/src/harmony_plugin_api/registry_types.py` +- Add: `tests/test_system/test_harmony_plugin_api_package.py` + +- [ ] **Step 1: Write the failing package structure tests** + +```python +from pathlib import Path + + +def test_harmony_plugin_api_package_has_standalone_pyproject(): + pyproject = Path("packages/harmony-plugin-api/pyproject.toml") + assert pyproject.exists() + content = pyproject.read_text(encoding="utf-8") + assert 'name = "harmony-plugin-api"' in content + assert 'version = "0.1.0"' in content + + +def test_harmony_plugin_api_package_excludes_host_runtime_modules(): + package_root = Path("packages/harmony-plugin-api/src/harmony_plugin_api") + assert (package_root / "context.py").exists() + assert not (package_root / "ui.py").exists() + assert not (package_root / "runtime.py").exists() +``` + +- [ ] **Step 2: Run the package structure tests to verify they fail** + +Run: `uv run pytest tests/test_system/test_harmony_plugin_api_package.py -v` +Expected: FAIL because `packages/harmony-plugin-api/` does not exist yet + +- [ ] **Step 3: Create the standalone package skeleton and copy only pure SDK modules** + +```toml +[build-system] +requires = ["setuptools>=69", "wheel"] +build-backend = "setuptools.build_meta" + +[project] +name = "harmony-plugin-api" +version = "0.1.0" +description = "Pure plugin SDK for Harmony plugins" +readme = "README.md" +requires-python = ">=3.11" +dependencies = [] + +[tool.setuptools] +package-dir = {"" = "src"} + +[tool.setuptools.packages.find] +where = ["src"] +``` + +```python +# packages/harmony-plugin-api/src/harmony_plugin_api/__init__.py +from .context import ( + PluginContext, + PluginDialogBridge, + PluginMediaBridge, + PluginServiceBridge, + PluginSettingsBridge, + PluginStorageBridge, + PluginThemeBridge, + PluginUiBridge, +) +from .cover import ( + PluginArtistCoverResult, + PluginArtistCoverSource, + PluginCoverResult, + PluginCoverSource, +) +from .lyrics import PluginLyricsResult, PluginLyricsSource +from .manifest import Capability, PluginManifest, PluginManifestError +from .media import PluginPlaybackRequest, PluginTrack +from .online import PluginOnlineProvider +from .plugin import HarmonyPlugin +from .registry_types import SettingsTabSpec, SidebarEntrySpec +``` + +- [ ] **Step 4: Run the package structure tests again** + +Run: `uv run pytest tests/test_system/test_harmony_plugin_api_package.py -v` +Expected: PASS + +- [ ] **Step 5: Commit Task 1** + +```bash +git add packages/harmony-plugin-api tests/test_system/test_harmony_plugin_api_package.py +git commit -m "新增可发布的插件SDK包" +``` + +### Task 2: Move Host Runtime Helpers out of the SDK + +**Files:** +- Create: `system/plugins/plugin_sdk_ui.py` +- Create: `system/plugins/plugin_sdk_runtime.py` +- Modify: `system/plugins/host_services.py` +- Delete: `harmony_plugin_api/ui.py` +- Delete: `harmony_plugin_api/runtime.py` +- Modify: `tests/test_system/test_plugin_ui_bridge.py` + +- [ ] **Step 1: Write the failing host bridge tests** + +```python +def test_plugin_context_ui_bridge_uses_host_bridge_modules(tmp_path: Path): + config = Mock() + config.get.return_value = "dark" + config.get_language.return_value = "zh" + ThemeManager._instance = None + ThemeManager.instance(config) + + bootstrap = SimpleNamespace( + _plugin_manager=SimpleNamespace(registry=Mock()), + online_download_service=Mock(), + playback_service=Mock(), + library_service=Mock(), + http_client=Mock(), + event_bus=Mock(), + config=config, + ) + manifest = PluginManifest.from_dict({...}) + + context = BootstrapPluginContextFactory(bootstrap, tmp_path).build(manifest) + + assert context.ui.theme.__class__.__module__ == "system.plugins.plugin_sdk_ui" + assert context.ui.dialogs.__class__.__module__ == "system.plugins.plugin_sdk_ui" +``` + +- [ ] **Step 2: Run the host bridge test to verify it fails** + +Run: `uv run pytest tests/test_system/test_plugin_ui_bridge.py::test_plugin_context_ui_bridge_uses_host_bridge_modules -v` +Expected: FAIL because `system.plugins.plugin_sdk_ui` does not exist yet + +- [ ] **Step 3: Implement host-side SDK runtime modules and switch host wiring** + +```python +# system/plugins/plugin_sdk_ui.py +class PluginThemeBridgeImpl: + def register_widget(self, widget) -> None: + ThemeManager.instance().register_widget(widget) + + def get_qss(self, template: str) -> str: + return ThemeManager.instance().get_qss(template) + + def current_theme(self): + return ThemeManager.instance().current_theme +``` + +```python +# system/plugins/plugin_sdk_ui.py +class PluginDialogBridgeImpl: + def information(self, parent, title: str, message: str): + return MessageDialog.information(parent, title, message) + + def setup_title_bar(self, dialog, container_layout, title: str, **kwargs): + return setup_equalizer_title_layout(dialog, container_layout, title, **kwargs) +``` + +```python +# system/plugins/host_services.py +from .plugin_sdk_ui import PluginDialogBridgeImpl, PluginThemeBridgeImpl +``` + +- [ ] **Step 4: Run the host bridge tests** + +Run: `uv run pytest tests/test_system/test_plugin_ui_bridge.py tests/test_system/test_plugin_online_bridge.py -v` +Expected: PASS + +- [ ] **Step 5: Commit Task 2** + +```bash +git add system/plugins/plugin_sdk_ui.py system/plugins/plugin_sdk_runtime.py system/plugins/host_services.py tests/test_system/test_plugin_ui_bridge.py +git commit -m "移出SDK宿主运行时实现" +``` + +### Task 3: Retarget QQ Plugin Imports Away from SDK Runtime Modules + +**Files:** +- Modify: `plugins/builtin/qqmusic/lib/dialog_title_bar.py` +- Modify: `plugins/builtin/qqmusic/lib/login_dialog.py` +- Modify: `plugins/builtin/qqmusic/lib/settings_tab.py` +- Modify: `plugins/builtin/qqmusic/lib/runtime_bridge.py` +- Modify: `tests/test_system/test_plugin_import_guard.py` + +- [ ] **Step 1: Write the failing import boundary test** + +```python +def test_qqmusic_plugin_no_longer_imports_sdk_runtime_modules(): + plugin_root = Path("plugins/builtin/qqmusic") + for py_file in plugin_root.rglob("*.py"): + source = py_file.read_text(encoding="utf-8") + assert "harmony_plugin_api.ui" not in source + assert "harmony_plugin_api.runtime" not in source +``` + +- [ ] **Step 2: Run the import boundary test to verify it fails** + +Run: `uv run pytest tests/test_system/test_plugin_import_guard.py -k "sdk_runtime_modules" -v` +Expected: FAIL because QQ plugin still imports `harmony_plugin_api.ui` / `runtime` + +- [ ] **Step 3: Retarget QQ plugin code to host bridge modules or injected context** + +```python +# plugins/builtin/qqmusic/lib/dialog_title_bar.py +from system.plugins.plugin_sdk_ui import get_host_icon, get_host_qss +``` + +```python +# plugins/builtin/qqmusic/lib/runtime_bridge.py +from system.plugins.plugin_sdk_runtime import ( + IconName, + add_requests_to_favorites, + add_requests_to_playlist, + add_track_ids_to_playlist, + bootstrap, + ... +) +from system.plugins.plugin_sdk_ui import ( + current_theme, + get_qss, + information, + register_themed_widget, + warning, +) +``` + +- [ ] **Step 4: Run the QQ plugin import boundary tests** + +Run: `uv run pytest tests/test_system/test_plugin_import_guard.py tests/test_ui/test_plugin_settings_tab.py -v` +Expected: PASS + +- [ ] **Step 5: Commit Task 3** + +```bash +git add plugins/builtin/qqmusic/lib/dialog_title_bar.py plugins/builtin/qqmusic/lib/login_dialog.py plugins/builtin/qqmusic/lib/settings_tab.py plugins/builtin/qqmusic/lib/runtime_bridge.py tests/test_system/test_plugin_import_guard.py +git commit -m "清理插件对SDK运行时模块的依赖" +``` + +### Task 4: Build and Install the Standalone SDK Package + +**Files:** +- Modify: `tests/test_system/test_harmony_plugin_api_package.py` +- Create: `packages/harmony-plugin-api/dist/` (build artifact, not committed) + +- [ ] **Step 1: Write the failing build/import smoke test** + +```python +def test_harmony_plugin_api_package_can_be_built(): + dist_dir = Path("packages/harmony-plugin-api/dist") + assert any(path.suffix == ".whl" for path in dist_dir.glob("*.whl")) +``` + +- [ ] **Step 2: Run the smoke test to verify it fails before building** + +Run: `uv run pytest tests/test_system/test_harmony_plugin_api_package.py::test_harmony_plugin_api_package_can_be_built -v` +Expected: FAIL because no wheel has been built yet + +- [ ] **Step 3: Build the package and install it into a temporary target** + +```bash +cd packages/harmony-plugin-api +uv build +python -m pip install --target /tmp/harmony-plugin-api-test dist/harmony_plugin_api-0.1.0-py3-none-any.whl +python -c "import sys; sys.path.insert(0, '/tmp/harmony-plugin-api-test'); import harmony_plugin_api; print(harmony_plugin_api.__all__)" +``` + +- [ ] **Step 4: Run the build/import smoke test and focused integration tests** + +Run: `uv run pytest tests/test_system/test_harmony_plugin_api_package.py tests/test_system/test_plugin_import_guard.py tests/test_system/test_plugin_ui_bridge.py -v` +Expected: PASS + +- [ ] **Step 5: Commit Task 4** + +```bash +git add tests/test_system/test_harmony_plugin_api_package.py packages/harmony-plugin-api/pyproject.toml packages/harmony-plugin-api/README.md +git commit -m "验证插件SDK包可构建" +``` + +## Self-Review + +- Spec coverage: the plan covers package layout, pure SDK boundary, host runtime extraction, plugin import retargeting, and build/install verification. +- Placeholder scan: every task lists exact files, test targets, implementation seams, and verification commands. +- Type consistency: the plan keeps `PluginContext` as the sole stable plugin entry and treats theme/dialog bridges as protocols in the SDK with host implementations in `system/plugins/`. + +## Execution Handoff + +Plan complete and saved to `docs/superpowers/plans/2026-04-07-harmony-plugin-api-packaging.md`. Two execution options: + +1. Subagent-Driven (recommended) - I dispatch a fresh subagent per task, review between tasks, fast iteration +2. Inline Execution - Execute tasks in this session using executing-plans, batch execution with checkpoints + +Which approach? diff --git a/docs/superpowers/plans/2026-04-07-plugin-ui-sdk-isolation.md b/docs/superpowers/plans/2026-04-07-plugin-ui-sdk-isolation.md new file mode 100644 index 00000000..cdb80224 --- /dev/null +++ b/docs/superpowers/plans/2026-04-07-plugin-ui-sdk-isolation.md @@ -0,0 +1,283 @@ +# Plugin UI SDK Isolation Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Restore the QQ Music settings tab to legacy parity while moving plugin theme/dialog access behind `harmony_plugin_api` and enforcing that plugins only import the SDK and their own files. + +**Architecture:** Keep plugin settings tabs mounted by the host settings dialog, but rebuild `plugins/builtin/qqmusic/lib/settings_tab.py` to mirror the legacy QQ settings composition and behaviors using plugin-scoped settings. Add a stable UI bridge to `harmony_plugin_api` that is implemented by the host and consumed by plugins, then enforce the boundary with both install-time audit and runtime import guarding. + +**Tech Stack:** Python 3.11, PySide6, pytest, pytest-qt, `uv` + +--- + +## File Map + +- Modify: `harmony_plugin_api/context.py` — add typed UI bridge protocols for theme, message dialogs, and dialog title bar helpers +- Modify: `system/plugins/host_services.py` — implement the new host-backed SDK UI bridge and expose it from `PluginContext` +- Modify: `system/plugins/installer.py` — tighten static import audit to reject host modules while allowing SDK, plugin-relative imports, standard library, and Qt imports +- Modify: `system/plugins/loader.py` — add runtime import guarding during plugin module execution +- Modify: `plugins/builtin/qqmusic/lib/settings_tab.py` — rebuild the plugin settings tab to match the legacy QQ settings structure and behavior +- Modify: `plugins/builtin/qqmusic/lib/login_dialog.py` — swap private bridge helpers for SDK UI/theme access +- Modify: `plugins/builtin/qqmusic/plugin_main.py` — stop relying on private runtime bridge assumptions if needed by the settings tab/widget factories +- Remove or stop using: `plugins/builtin/qqmusic/lib/runtime_bridge.py`, `plugins/builtin/qqmusic/lib/dialog_title_bar.py` +- Modify: `tests/test_ui/test_plugin_settings_tab.py` — add regression tests for QQ settings parity and host-mounted plugin tab behavior +- Modify: `tests/test_system/test_plugin_import_guard.py` — add install-time and runtime isolation tests +- Add: `tests/test_system/test_plugin_ui_bridge.py` — verify the new SDK UI bridge uses host theme/dialog implementations + +### Task 1: Add Failing Tests for SDK UI Bridge and Plugin Isolation + +**Files:** +- Modify: `tests/test_system/test_plugin_import_guard.py` +- Add: `tests/test_system/test_plugin_ui_bridge.py` +- Modify: `harmony_plugin_api/context.py` +- Modify: `system/plugins/host_services.py` +- Modify: `system/plugins/installer.py` +- Modify: `system/plugins/loader.py` + +- [ ] **Step 1: Write the failing UI bridge and runtime isolation tests** + +```python +def test_plugin_context_ui_bridge_exposes_theme_dialog_and_title_bar(tmp_path): + config = Mock() + ThemeManager._instance = None + ThemeManager.instance(config) + registry = Mock() + bootstrap = Mock( + http_client=Mock(), + event_bus=Mock(), + config=config, + online_download_service=Mock(), + playback_service=Mock(), + library_service=Mock(), + ) + bootstrap.plugin_manager = Mock(registry=registry) + + factory = BootstrapPluginContextFactory(bootstrap, tmp_path) + manifest = PluginManifest.from_dict( + {"id": "qqmusic", "name": "QQ Music", "version": "1.0.0", "entrypoint": "plugin_main.py", "entry_class": "QQMusicPlugin"} + ) + + context = factory.build(manifest) + + assert hasattr(context.ui, "theme") + assert hasattr(context.ui, "dialogs") + assert callable(context.ui.theme.get_qss) + assert callable(context.ui.dialogs.information) + assert callable(context.ui.dialogs.setup_title_bar) +``` + +```python +def test_runtime_import_guard_rejects_host_module_import(tmp_path: Path): + plugin_root = tmp_path / "bad_plugin" + plugin_root.mkdir() + (plugin_root / "plugin.json").write_text(json.dumps({ + "id": "bad-plugin", + "name": "Bad Plugin", + "version": "1.0.0", + "entrypoint": "plugin_main.py", + "entry_class": "BadPlugin", + }), encoding="utf-8") + (plugin_root / "plugin_main.py").write_text( + "from ui.dialogs.message_dialog import MessageDialog\n" + "class BadPlugin:\n" + " pass\n", + encoding="utf-8", + ) + + with pytest.raises(PluginLoadError): + PluginLoader().load_plugin(plugin_root) +``` + +- [ ] **Step 2: Run the focused system tests to verify they fail** + +Run: `uv run pytest tests/test_system/test_plugin_import_guard.py tests/test_system/test_plugin_ui_bridge.py -v` +Expected: FAIL with missing `context.ui.theme` / `context.ui.dialogs` attributes and missing runtime import guard behavior + +- [ ] **Step 3: Implement the SDK UI bridge and runtime import guard** + +```python +class PluginThemeBridge(Protocol): + def register_widget(self, widget) -> None: ... + def get_qss(self, template: str) -> str: ... + def current_theme(self): ... +``` + +```python +class PluginDialogBridge(Protocol): + def information(self, parent, title: str, message: str): ... + def warning(self, parent, title: str, message: str): ... + def question(self, parent, title: str, message: str, buttons, default_button): ... + def critical(self, parent, title: str, message: str): ... + def setup_title_bar(self, dialog, container_layout, title: str, **kwargs): ... +``` + +```python +class _PluginImportGuard: + _FORBIDDEN_ROOTS = {"app", "domain", "services", "repositories", "infrastructure", "system", "ui"} + _ALLOWED_ROOTS = {"harmony_plugin_api", "PySide6", "shiboken6"} +``` + +- [ ] **Step 4: Re-run the focused system tests and the existing plugin bootstrap tests** + +Run: `uv run pytest tests/test_system/test_plugin_import_guard.py tests/test_system/test_plugin_ui_bridge.py tests/test_app/test_plugin_bootstrap.py -v` +Expected: PASS + +- [ ] **Step 5: Commit Task 1** + +```bash +git add tests/test_system/test_plugin_import_guard.py tests/test_system/test_plugin_ui_bridge.py harmony_plugin_api/context.py system/plugins/host_services.py system/plugins/installer.py system/plugins/loader.py +git commit -m "增加插件UI桥和导入隔离" +``` + +### Task 2: Rebuild the QQ Music Plugin Settings Tab to Match Legacy Layout and Behavior + +**Files:** +- Modify: `plugins/builtin/qqmusic/lib/settings_tab.py` +- Modify: `plugins/builtin/qqmusic/lib/login_dialog.py` +- Modify: `tests/test_ui/test_plugin_settings_tab.py` + +- [ ] **Step 1: Write the failing parity tests for the QQ settings tab** + +```python +def test_qqmusic_settings_tab_matches_legacy_sections(qtbot): + settings = Mock() + settings.get.side_effect = lambda key, default=None: { + "quality": "320", + "download_dir": "data/online_cache", + "credential": {"musicid": "12345", "loginType": 2}, + "nick": "Tester", + }.get(key, default) + settings.set = Mock() + context = Mock(settings=settings, events=Mock(), language="zh", ui=Mock()) + context.ui.theme.get_qss.side_effect = lambda template: template + context.ui.theme.current_theme.return_value = type("Theme", (), {"text_secondary": "#999999"})() + context.ui.theme.register_widget = Mock() + + widget = QQMusicSettingsTab(context) + qtbot.addWidget(widget) + + assert widget._quality_combo.count() >= 3 + assert widget._download_dir_input.text() == "data/online_cache" + assert widget._qqmusic_logout_btn.isVisible() + assert widget._status_label.text() +``` + +```python +def test_qqmusic_settings_tab_save_writes_plugin_scoped_settings(qtbot): + settings = Mock() + settings.get.side_effect = lambda key, default=None: default + context = Mock(settings=settings, events=Mock(), language="zh", ui=Mock()) + context.ui.theme.get_qss.side_effect = lambda template: template + context.ui.theme.current_theme.return_value = type("Theme", (), {"text_secondary": "#999999"})() + context.ui.theme.register_widget = Mock() + + widget = QQMusicSettingsTab(context) + qtbot.addWidget(widget) + widget._download_dir_input.setText("/tmp/music") + widget._quality_combo.setCurrentIndex(1) + widget._save_settings() + + settings.set.assert_any_call("download_dir", "/tmp/music") + settings.set.assert_any_call("quality", widget._quality_combo.currentData()) +``` + +- [ ] **Step 2: Run the focused UI tests to verify they fail** + +Run: `uv run pytest tests/test_ui/test_plugin_settings_tab.py -k "qqmusic_settings_tab" -v` +Expected: FAIL with missing legacy widgets such as `_download_dir_input`, `_qqmusic_logout_btn`, or `_save_settings` + +- [ ] **Step 3: Implement the legacy-style plugin settings tab and move it to SDK UI access** + +```python +class QQMusicSettingsTab(QWidget): + def __init__(self, context, parent=None): + self._context = context + self._verify_thread: Optional[VerifyLoginThread] = None + self._theme = context.ui.theme +``` + +```python +layout.addWidget(_build_quality_group()) +layout.addWidget(_build_download_dir_group()) +layout.addWidget(_build_login_group()) +self._save_btn.clicked.connect(self._save_settings) +self._qqmusic_qr_btn.clicked.connect(self._open_login_dialog) +self._qqmusic_logout_btn.clicked.connect(self._clear_credentials) +``` + +- [ ] **Step 4: Re-run the focused QQ settings UI tests and the settings dialog plugin-tab tests** + +Run: `uv run pytest tests/test_ui/test_plugin_settings_tab.py tests/test_ui/test_settings_dialog.py -k "plugin or qqmusic_settings_tab" -v` +Expected: PASS + +- [ ] **Step 5: Commit Task 2** + +```bash +git add tests/test_ui/test_plugin_settings_tab.py plugins/builtin/qqmusic/lib/settings_tab.py plugins/builtin/qqmusic/lib/login_dialog.py +git commit -m "恢复QQ插件设置页布局" +``` + +### Task 3: Remove Private QQ Runtime UI Bridges and Finish the Boundary + +**Files:** +- Modify: `plugins/builtin/qqmusic/lib/login_dialog.py` +- Modify: `plugins/builtin/qqmusic/plugin_main.py` +- Remove or stop using: `plugins/builtin/qqmusic/lib/runtime_bridge.py` +- Remove or stop using: `plugins/builtin/qqmusic/lib/dialog_title_bar.py` +- Modify: `tests/test_plugins/test_qqmusic_plugin.py` + +- [ ] **Step 1: Write the failing test that QQ plugin imports only SDK and plugin-local modules** + +```python +def test_builtin_qqmusic_plugin_passes_import_audit(): + audit_plugin_imports(Path("plugins/builtin/qqmusic")) +``` + +- [ ] **Step 2: Run the audit test to verify it fails while the private bridges still import host modules** + +Run: `uv run pytest tests/test_system/test_plugin_import_guard.py::test_builtin_qqmusic_plugin_passes_import_audit -v` +Expected: FAIL because QQ plugin files still import host `system` / `ui` modules directly + +- [ ] **Step 3: Replace private bridge usage with SDK-backed context UI access** + +```python +def _apply_theme(self): + self.setStyleSheet(self._context.ui.theme.get_qss(self._STYLE_TEMPLATE)) + self._title_bar_controller.refresh_theme() +``` + +```python +self._title_bar_controller = self._context.ui.dialogs.setup_title_bar( + self, + container_layout, + t("qqmusic_login_title"), + content_spacing=2, +)[1] +``` + +- [ ] **Step 4: Re-run the plugin import audit and focused QQ plugin tests** + +Run: `uv run pytest tests/test_system/test_plugin_import_guard.py tests/test_plugins/test_qqmusic_plugin.py -k "qqmusic or import" -v` +Expected: PASS + +- [ ] **Step 5: Commit Task 3** + +```bash +git add tests/test_system/test_plugin_import_guard.py tests/test_plugins/test_qqmusic_plugin.py plugins/builtin/qqmusic/lib/login_dialog.py plugins/builtin/qqmusic/plugin_main.py +git commit -m "收紧插件边界" +``` + +## Self-Review + +- Spec coverage: the plan covers legacy QQ settings parity, SDK-based theme/dialog exposure, and both install-time + runtime plugin isolation. +- Placeholder scan: each task identifies exact files, focused tests, implementation seams, and verification commands. +- Type consistency: the plan uses `context.ui.theme` and `context.ui.dialogs` as the stable SDK boundary everywhere, so plugins do not need host imports. + +## Execution Handoff + +Plan complete and saved to `docs/superpowers/plans/2026-04-07-plugin-ui-sdk-isolation.md`. Two execution options: + +1. Subagent-Driven (recommended) - I dispatch a fresh subagent per task, review between tasks, fast iteration +2. Inline Execution - Execute tasks in this session using executing-plans, batch execution with checkpoints + +Which approach? diff --git a/docs/superpowers/specs/2026-04-07-harmony-plugin-api-packaging-design.md b/docs/superpowers/specs/2026-04-07-harmony-plugin-api-packaging-design.md new file mode 100644 index 00000000..f438bc7d --- /dev/null +++ b/docs/superpowers/specs/2026-04-07-harmony-plugin-api-packaging-design.md @@ -0,0 +1,282 @@ +# Harmony Plugin API Packaging Design + +## Goal + +将 `harmony_plugin_api` 整理为可独立发布的 pip 包 `harmony-plugin-api`,并确保发布内容只包含插件 SDK 的纯接口层,不包含任何 Harmony 宿主实现。 + +## Scope + +本次设计覆盖: + +- `harmony_plugin_api` 的发布边界 +- 当前仓库内的子包发布结构 +- 宿主实现从 SDK 中剥离的迁移方式 +- 插件侧 API 使用方式的稳定边界 + +本次不覆盖: + +- 立刻拆独立仓库 +- PyPI 发布账号、token、release automation +- 插件市场协议或远程安装协议 + +## Current State + +当前 `harmony_plugin_api` 直接位于主仓库根目录下,没有独立的打包配置。 + +它原本主要承载纯接口定义: + +- manifest / capability 定义 +- plugin protocol +- context protocol +- media / lyrics / cover / online models +- registry spec types + +但当前分支中又加入了两类不适合独立发布的模块: + +- `harmony_plugin_api.ui` +- `harmony_plugin_api.runtime` + +这两个模块直接依赖 Harmony 宿主实现,例如: + +- `system.theme` +- `ui.dialogs.*` +- `ui.icons` +- `services.online` +- `app.bootstrap` +- `infrastructure.*` + +因此现在的 `harmony_plugin_api` 还不是一个真正独立的 SDK,而是“接口定义 + 宿主运行时桥接”的混合体。 + +## Problems + +### 1. 发布边界不干净 + +如果直接把当前目录发布到 pip,安装方必须同时拥有 Harmony 宿主源码布局,否则 `ui/runtime` 模块会导入失败。 + +### 2. SDK 和宿主实现耦合 + +插件 SDK 应该只定义“宿主会提供什么”,不应该自己实现“宿主如何提供”。 + +### 3. 版本管理不清晰 + +主项目版本和 SDK 版本需要独立演进。继续共用根 `pyproject.toml` 会让依赖、构建、发布语义混在一起。 + +## Approaches + +### Approach A: 仓库内独立子包发布 + +在当前仓库新增 `packages/harmony-plugin-api/`,把 SDK 源码与发布配置集中在该目录。 + +优点: + +- 迭代成本最低 +- 仍可与宿主代码同仓协同开发 +- 版本边界、构建边界明确 + +缺点: + +- 需要增加一套子包构建配置 + +### Approach B: 根仓库直接多包发布 + +继续使用根仓库作为构建入口,只把 `harmony_plugin_api` 单独选出来发布。 + +优点: + +- 配置看似更少 + +缺点: + +- 主项目和 SDK 发布边界容易继续缠绕 +- 后续做独立版本管理会更痛苦 + +### Approach C: 立即拆独立仓库 + +把 SDK 从当前仓库完全拆走,单独维护和发布。 + +优点: + +- 边界最彻底 + +缺点: + +- 当前工作量最大 +- 会引入同步开发和 CI 迁移成本 + +## Recommendation + +采用 Approach A。 + +原因: + +- 能最快把 SDK 收敛成可发布形态 +- 不阻碍后续再拆独立仓库 +- 最符合当前“先发布,再稳定演进”的节奏 + +## Design + +### 1. Package Layout + +新增子包目录: + +- `packages/harmony-plugin-api/pyproject.toml` +- `packages/harmony-plugin-api/README.md` +- `packages/harmony-plugin-api/src/harmony_plugin_api/` + +发布源代码只来自这个子包目录。 + +根目录下现有 `harmony_plugin_api/` 不再作为最终发布源。实现时可以采用两种过渡方式中的一种: + +- 直接迁移到 `packages/.../src/harmony_plugin_api/` +- 或先复制到子包目录,再逐步把主仓库导入切到子包安装路径 + +优先推荐直接迁移,避免双份源码长期并存。 + +### 2. SDK Content Boundary + +`harmony-plugin-api` 只包含纯 SDK 模块: + +- `__init__.py` +- `manifest.py` +- `plugin.py` +- `context.py` +- `media.py` +- `lyrics.py` +- `cover.py` +- `online.py` +- `registry_types.py` + +这些模块只允许依赖: + +- Python 标准库 +- `typing` +- `dataclasses` +- `pathlib` + +不允许依赖 Harmony 宿主包,也不要求 Qt / requests / PySide6。 + +### 3. Host Runtime Boundary + +下列能力不属于 SDK 发布内容: + +- `ThemeManager` +- `MessageDialog` +- `DialogTitleBar` +- icon 获取 +- Bootstrap / EventBus +- 在线服务创建 +- cache / HTTP / playlist 工具 + +它们应移动到宿主侧模块,例如: + +- `system/plugins/plugin_sdk_runtime.py` +- 或 `system/plugins/sdk_bridge/*.py` + +宿主负责把这些实现注入 `PluginContext`。 + +### 4. Context Contract + +`PluginContext` 继续作为插件唯一稳定入口。 + +插件使用边界为: + +- `context.settings` +- `context.storage` +- `context.ui` +- `context.services` +- `context.http` +- `context.events` + +其中 `context.ui.theme` / `context.ui.dialogs` 在 SDK 中只保留 `Protocol` 定义,不提供实现模块。 + +也就是说: + +- 可以有 `PluginThemeBridge` / `PluginDialogBridge` 协议 +- 不能再有 `harmony_plugin_api.ui` 这种宿主实现模块 + +### 5. Plugin Import Rules + +发布后的规则应收敛为: + +- 插件可以 import `harmony_plugin_api.*` +- 插件可以 import 自己包内模块 +- 插件可以 import 允许的第三方依赖或标准库 +- 插件不能 import Harmony 宿主源码包 + +当前运行时导入守卫和安装时审计应继续存在,但判定依据应该面向“宿主包禁止”,而不是依赖 SDK 中的宿主实现模块。 + +### 6. Versioning + +SDK 采用独立版本号,例如 `0.1.0`。 + +插件 manifest 中的 `api_version` 仍作为插件协议级版本; +pip 包版本则作为 SDK 发行版本。两者不要求完全一致,但宿主需要定义兼容关系。 + +首个版本先保持简单: + +- `api_version = "1"` 不变 +- pip 包发布 `0.1.0` + +### 7. Migration Plan + +迁移分三步: + +#### Step 1 + +把 SDK 纯接口模块迁到子包目录,建立独立 `pyproject.toml`,保证可以单独构建 wheel/sdist。 + +#### Step 2 + +把 `harmony_plugin_api.ui` / `runtime` 中的宿主实现移动到主项目宿主桥接层。 + +同时修改: + +- `system/plugins/host_services.py` +- QQ 插件中的桥接引用 + +使插件只通过 `context` 获取宿主能力,而不是 import SDK 内的宿主实现。 + +#### Step 3 + +增加验证: + +- 子包可构建 +- wheel 可安装并可导入 +- SDK 中不存在对宿主包的直接依赖 +- 现有插件与宿主集成仍通过测试 + +## Risks + +### 1. 双份源码漂移 + +如果根目录和子包目录长期同时维护 `harmony_plugin_api`,很容易漂移。 + +设计要求尽快收敛为单一发布源。 + +### 2. 插件侧仍残留对 SDK 宿主实现模块的依赖 + +如果仍保留 `harmony_plugin_api.ui/runtime`,未来外部插件会继续错误依赖这些模块。 + +设计上应直接删除或停用它们,而不是长期保留。 + +### 3. 主项目导入路径切换带来回归 + +主项目内大量地方已经 import `harmony_plugin_api.*`。迁移时需要保证: + +- 开发环境仍能正常导入 +- 测试环境导入解析稳定 + +## Testing + +至少要覆盖: + +- 子包构建测试 +- wheel 安装后导入测试 +- SDK 源码静态扫描,确认无宿主依赖 +- 插件导入审计测试 +- `PluginContext` 宿主桥接测试 + +## Result + +完成后,`harmony-plugin-api` 将成为一个真正独立、可发布、仅含纯接口定义的 pip 包; +Harmony 宿主实现继续留在主项目中,通过 `PluginContext` 向插件暴露能力。 diff --git a/main.py b/main.py index 75def30b..aeea0cc9 100644 --- a/main.py +++ b/main.py @@ -57,55 +57,6 @@ def setup_ssl_certificates(): setup_ssl_certificates() -class QQMusicApiCachePathInjector: - """ - Meta path finder to inject writable cache path into qqmusic_api.utils.device. - - The qqmusic_api library stores device info in a .cache directory relative to - its installation path. This fails when running as an AppImage (read-only FS). - - This injector intercepts the import of qqmusic_api.utils.device and patches - the device_path variable before any code uses it. - """ - - def __init__(self): - self._device_path = None - - def get_device_path(self) -> Path: - """Get the writable device cache path.""" - if self._device_path is None: - # Import here to avoid circular import - from utils.helpers import get_cache_dir - - cache_dir = get_cache_dir('qqmusic_api') - cache_dir.mkdir(parents=True, exist_ok=True) - self._device_path = cache_dir / 'device.json' - - return self._device_path - - def find_spec(self, fullname, path, target=None): - """Hook into import to patch device_path when device module is loaded.""" - if fullname == 'qqmusic_api.utils.device': - # Let the normal import happen first - return None - return None - - def patch_device_path(self): - """Patch the device_path after module is loaded.""" - try: - import qqmusic_api.utils.device as device_module - device_module.device_path = self.get_device_path() - logging.debug(f"qqmusic_api device cache path set to: {device_module.device_path}") - except ImportError: - pass # Module not available, skip patching - - -# Install the injector and patch immediately (handles cases where module is already loaded) -_injector = QQMusicApiCachePathInjector() -sys.meta_path.insert(0, _injector) -_injector.patch_device_path() - - def get_resource_path(relative_path: str) -> Path: """Get absolute path to resource, works for dev and PyInstaller bundle.""" if getattr(sys, 'frozen', False): diff --git a/packages/harmony-plugin-api/README.md b/packages/harmony-plugin-api/README.md new file mode 100644 index 00000000..d0f162d4 --- /dev/null +++ b/packages/harmony-plugin-api/README.md @@ -0,0 +1,9 @@ +# harmony-plugin-api + +Pure plugin SDK for Harmony plugins. + +This package only contains stable plugin-facing protocols, models, manifest parsing, +and registry spec types. It does not include Harmony host runtime implementations. + +Host-specific theme, dialog, runtime, and bootstrap integrations must be provided by +the Harmony application and injected through `PluginContext`. diff --git a/packages/harmony-plugin-api/pyproject.toml b/packages/harmony-plugin-api/pyproject.toml new file mode 100644 index 00000000..e5152bb7 --- /dev/null +++ b/packages/harmony-plugin-api/pyproject.toml @@ -0,0 +1,17 @@ +[build-system] +requires = ["setuptools>=69", "wheel"] +build-backend = "setuptools.build_meta" + +[project] +name = "harmony-plugin-api" +version = "0.1.0" +description = "Pure plugin SDK for Harmony plugins" +readme = "README.md" +requires-python = ">=3.11" +dependencies = [] + +[tool.setuptools] +package-dir = {"" = "src"} + +[tool.setuptools.packages.find] +where = ["src"] diff --git a/packages/harmony-plugin-api/src/harmony_plugin_api.egg-info/PKG-INFO b/packages/harmony-plugin-api/src/harmony_plugin_api.egg-info/PKG-INFO new file mode 100644 index 00000000..a079dc7d --- /dev/null +++ b/packages/harmony-plugin-api/src/harmony_plugin_api.egg-info/PKG-INFO @@ -0,0 +1,16 @@ +Metadata-Version: 2.4 +Name: harmony-plugin-api +Version: 0.1.0 +Summary: Pure plugin SDK for Harmony plugins +Requires-Python: >=3.11 +Description-Content-Type: text/markdown + +# harmony-plugin-api + +Pure plugin SDK for Harmony plugins. + +This package only contains stable plugin-facing protocols, models, manifest parsing, +and registry spec types. It does not include Harmony host runtime implementations. + +Host-specific theme, dialog, runtime, and bootstrap integrations must be provided by +the Harmony application and injected through `PluginContext`. diff --git a/packages/harmony-plugin-api/src/harmony_plugin_api.egg-info/SOURCES.txt b/packages/harmony-plugin-api/src/harmony_plugin_api.egg-info/SOURCES.txt new file mode 100644 index 00000000..47c4b5ed --- /dev/null +++ b/packages/harmony-plugin-api/src/harmony_plugin_api.egg-info/SOURCES.txt @@ -0,0 +1,15 @@ +README.md +pyproject.toml +src/harmony_plugin_api/__init__.py +src/harmony_plugin_api/context.py +src/harmony_plugin_api/cover.py +src/harmony_plugin_api/lyrics.py +src/harmony_plugin_api/manifest.py +src/harmony_plugin_api/media.py +src/harmony_plugin_api/online.py +src/harmony_plugin_api/plugin.py +src/harmony_plugin_api/registry_types.py +src/harmony_plugin_api.egg-info/PKG-INFO +src/harmony_plugin_api.egg-info/SOURCES.txt +src/harmony_plugin_api.egg-info/dependency_links.txt +src/harmony_plugin_api.egg-info/top_level.txt \ No newline at end of file diff --git a/packages/harmony-plugin-api/src/harmony_plugin_api.egg-info/dependency_links.txt b/packages/harmony-plugin-api/src/harmony_plugin_api.egg-info/dependency_links.txt new file mode 100644 index 00000000..8b137891 --- /dev/null +++ b/packages/harmony-plugin-api/src/harmony_plugin_api.egg-info/dependency_links.txt @@ -0,0 +1 @@ + diff --git a/packages/harmony-plugin-api/src/harmony_plugin_api.egg-info/top_level.txt b/packages/harmony-plugin-api/src/harmony_plugin_api.egg-info/top_level.txt new file mode 100644 index 00000000..7eb9a0f0 --- /dev/null +++ b/packages/harmony-plugin-api/src/harmony_plugin_api.egg-info/top_level.txt @@ -0,0 +1 @@ +harmony_plugin_api diff --git a/harmony_plugin_api/__init__.py b/packages/harmony-plugin-api/src/harmony_plugin_api/__init__.py similarity index 92% rename from harmony_plugin_api/__init__.py rename to packages/harmony-plugin-api/src/harmony_plugin_api/__init__.py index b6905a40..72a00b94 100644 --- a/harmony_plugin_api/__init__.py +++ b/packages/harmony-plugin-api/src/harmony_plugin_api/__init__.py @@ -1,9 +1,11 @@ from .context import ( PluginContext, + PluginDialogBridge, PluginMediaBridge, PluginServiceBridge, PluginSettingsBridge, PluginStorageBridge, + PluginThemeBridge, PluginUiBridge, ) from .cover import ( @@ -27,6 +29,7 @@ "PluginContext", "PluginCoverResult", "PluginCoverSource", + "PluginDialogBridge", "PluginLyricsResult", "PluginLyricsSource", "PluginManifest", @@ -37,6 +40,7 @@ "PluginServiceBridge", "PluginSettingsBridge", "PluginStorageBridge", + "PluginThemeBridge", "PluginTrack", "PluginUiBridge", "SettingsTabSpec", diff --git a/harmony_plugin_api/context.py b/packages/harmony-plugin-api/src/harmony_plugin_api/context.py similarity index 59% rename from harmony_plugin_api/context.py rename to packages/harmony-plugin-api/src/harmony_plugin_api/context.py index 2685279d..069d362b 100644 --- a/harmony_plugin_api/context.py +++ b/packages/harmony-plugin-api/src/harmony_plugin_api/context.py @@ -33,6 +33,34 @@ def temp_dir(self) -> Path: ... +class PluginThemeBridge(Protocol): + def register_widget(self, widget) -> None: + ... + + def get_qss(self, template: str) -> str: + ... + + def current_theme(self): + ... + + +class PluginDialogBridge(Protocol): + def information(self, parent, title: str, message: str): + ... + + def warning(self, parent, title: str, message: str): + ... + + def question(self, parent, title: str, message: str, buttons, default_button): + ... + + def critical(self, parent, title: str, message: str): + ... + + def setup_title_bar(self, dialog, container_layout, title: str, **kwargs): + ... + + class PluginUiBridge(Protocol): def register_sidebar_entry(self, spec: SidebarEntrySpec) -> None: ... @@ -40,10 +68,29 @@ def register_sidebar_entry(self, spec: SidebarEntrySpec) -> None: def register_settings_tab(self, spec: SettingsTabSpec) -> None: ... + @property + def theme(self) -> PluginThemeBridge: + ... + + @property + def dialogs(self) -> PluginDialogBridge: + ... + class PluginMediaBridge(Protocol): - # Marker bridge for host media operations exposed to plugins. - def __repr__(self) -> str: + def cache_remote_track(self, request: Any, progress_callback=None, force: bool = False): + ... + + def add_online_track(self, request: Any): + ... + + def play_online_track(self, request: Any) -> int | None: + ... + + def add_online_track_to_queue(self, request: Any) -> int | None: + ... + + def insert_online_track_to_queue(self, request: Any) -> int | None: ... @@ -72,6 +119,7 @@ class PluginContext: logger: Any http: Any events: Any + language: str storage: PluginStorageBridge settings: PluginSettingsBridge ui: PluginUiBridge diff --git a/harmony_plugin_api/cover.py b/packages/harmony-plugin-api/src/harmony_plugin_api/cover.py similarity index 100% rename from harmony_plugin_api/cover.py rename to packages/harmony-plugin-api/src/harmony_plugin_api/cover.py diff --git a/harmony_plugin_api/lyrics.py b/packages/harmony-plugin-api/src/harmony_plugin_api/lyrics.py similarity index 100% rename from harmony_plugin_api/lyrics.py rename to packages/harmony-plugin-api/src/harmony_plugin_api/lyrics.py diff --git a/harmony_plugin_api/manifest.py b/packages/harmony-plugin-api/src/harmony_plugin_api/manifest.py similarity index 100% rename from harmony_plugin_api/manifest.py rename to packages/harmony-plugin-api/src/harmony_plugin_api/manifest.py diff --git a/harmony_plugin_api/media.py b/packages/harmony-plugin-api/src/harmony_plugin_api/media.py similarity index 100% rename from harmony_plugin_api/media.py rename to packages/harmony-plugin-api/src/harmony_plugin_api/media.py diff --git a/harmony_plugin_api/online.py b/packages/harmony-plugin-api/src/harmony_plugin_api/online.py similarity index 100% rename from harmony_plugin_api/online.py rename to packages/harmony-plugin-api/src/harmony_plugin_api/online.py diff --git a/harmony_plugin_api/plugin.py b/packages/harmony-plugin-api/src/harmony_plugin_api/plugin.py similarity index 100% rename from harmony_plugin_api/plugin.py rename to packages/harmony-plugin-api/src/harmony_plugin_api/plugin.py diff --git a/harmony_plugin_api/registry_types.py b/packages/harmony-plugin-api/src/harmony_plugin_api/registry_types.py similarity index 76% rename from harmony_plugin_api/registry_types.py rename to packages/harmony-plugin-api/src/harmony_plugin_api/registry_types.py index e7c46fb5..25b0608d 100644 --- a/harmony_plugin_api/registry_types.py +++ b/packages/harmony-plugin-api/src/harmony_plugin_api/registry_types.py @@ -12,6 +12,8 @@ class SidebarEntrySpec: order: int icon_name: str | None page_factory: Callable[[Any, Any], Any] + icon_path: str | None = None + title_provider: Callable[[], str] | None = None @dataclass(frozen=True) @@ -21,3 +23,4 @@ class SettingsTabSpec: title: str order: int widget_factory: Callable[[Any, Any], Any] + title_provider: Callable[[], str] | None = None diff --git a/plugins/builtin/qqmusic/lib/api.py b/plugins/builtin/qqmusic/lib/api.py index 443b3c9b..d9f48e31 100644 --- a/plugins/builtin/qqmusic/lib/api.py +++ b/plugins/builtin/qqmusic/lib/api.py @@ -9,57 +9,92 @@ class QQMusicPluginAPI: def __init__(self, context): self._context = context - def search(self, keyword: str, limit: int = 5) -> list[dict]: + def search( + self, + keyword: str, + search_type: str = "song", + limit: int = 20, + page: int = 1, + ) -> dict[str, list[dict]]: response = self._context.http.get( f"{self.REMOTE_BASE_URL}/search", - params={"keyword": keyword, "type": "song", "num": limit, "page": 1}, + params={"keyword": keyword, "type": search_type, "num": limit, "page": page}, timeout=10, ) data = response.json() - songs = data.get("data", {}).get("list", []) - formatted = [] - for song in songs[:limit]: - singer_info = song.get("singer", "") - if isinstance(singer_info, list) and singer_info: - singer_name = singer_info[0].get("name", "") - singer_mid = singer_info[0].get("mid", "") - elif isinstance(singer_info, dict): - singer_name = singer_info.get("name", "") - singer_mid = singer_info.get("mid", "") - else: - singer_name = str(singer_info) if singer_info else "" - singer_mid = "" - - album_info = song.get("album", "") - if isinstance(album_info, dict): - album_name = album_info.get("name", "") - album_mid = album_info.get("mid", "") - else: - album_name = str(album_info) if album_info else "" - album_mid = song.get("album_mid", "") - - formatted.append( + items = data.get("data", {}).get("list", []) + if search_type == "song": + return {"tracks": [self._format_song_item(song) for song in items[:limit]]} + if search_type == "singer": + return { + "artists": [ + { + "mid": item.get("singerMID", item.get("mid", "")), + "name": item.get("singerName", item.get("name", "")), + "avatar_url": item.get("singerPic", item.get("avatar", item.get("cover_url", ""))) + or self.get_artist_cover_url(item.get("singerMID", item.get("mid", ""))), + "song_count": item.get("songNum", item.get("song_count", item.get("songnum", 0))), + "album_count": item.get("albumNum", item.get("album_count", item.get("albumnum", 0))), + "fan_count": item.get("fansNum", item.get("fan_count", item.get("FanNum", 0))), + } + for item in items[:limit] + ] + } + if search_type == "album": + return { + "albums": [ + { + "mid": item.get("albummid", item.get("mid", "")), + "name": item.get("name", item.get("albumname", "")), + "singer_name": self._extract_singer_name(item), + "cover_url": item.get("pic", item.get("cover", item.get("cover_url", ""))) + or self.get_cover_url(album_mid=item.get("albummid", item.get("mid", ""))), + "song_count": item.get("song_num", item.get("song_count", 0)), + "publish_date": item.get("publish_date", item.get("pubTime", "")), + } + for item in items[:limit] + ] + } + return { + "playlists": [ { - "mid": song.get("mid", "") or song.get("songmid", ""), - "name": song.get("name", "") or song.get("songname", ""), - "title": song.get("name", "") or song.get("songname", ""), - "singer": singer_name, - "singer_mid": singer_mid, - "album": album_name, - "album_mid": album_mid, - "interval": song.get("interval", 0), + "id": str(item.get("dissid", item.get("id", ""))), + "mid": item.get("dissMID", item.get("mid", "")), + "title": item.get("dissname", item.get("title", "")), + "creator": item.get("nickname", item.get("creator", "")), + "cover_url": item.get("logo", item.get("imgurl", item.get("cover_url", item.get("cover", "")))), + "song_count": item.get("songnum", item.get("song_count", 0)), + "play_count": item.get("listennum", item.get("play_count", 0)), } - ) - return formatted + for item in items[:limit] + ] + } + + def get_top_lists(self) -> list[dict]: + response = self._context.http.get(f"{self.REMOTE_BASE_URL}/top", timeout=20) + data = response.json() + if data.get("code") != 0: + return [] + groups = data.get("data", {}).get("group", []) + return [ + {"id": item.get("topId", ""), "title": item.get("title", "")} + for group in groups + for item in group.get("toplist", []) + ] - def search_artist(self, keyword: str, limit: int = 5) -> list[dict]: + def get_top_list_tracks(self, top_id: int | str, limit: int = 100) -> list[dict]: response = self._context.http.get( - f"{self.REMOTE_BASE_URL}/search", - params={"keyword": keyword, "type": "singer", "num": limit, "page": 1}, - timeout=10, + f"{self.REMOTE_BASE_URL}/top", + params={"id": top_id, "num": limit}, + timeout=20, ) data = response.json() - return data.get("data", {}).get("list", []) + if data.get("code") != 0: + return [] + items = data.get("data", {}).get("songInfoList", []) + if not items: + items = data.get("data", {}).get("data", {}).get("song", []) + return [self._format_song_item(song) for song in items[:limit]] def get_lyrics(self, mid: str) -> Optional[str]: response = self._context.http.get( @@ -93,3 +128,130 @@ def get_cover_url( def get_artist_cover_url(self, singer_mid: str, size: int = 300) -> Optional[str]: return f"https://y.gtimg.cn/music/photo_new/T001R{size}x{size}M000{singer_mid}.jpg" + + def get_playback_url_info(self, track_id: str, quality: str) -> dict[str, str] | None: + return None + + def get_artist_detail(self, singer_mid: str) -> dict | None: + response = self._context.http.get( + f"{self.REMOTE_BASE_URL}/singer", + params={"mid": singer_mid}, + timeout=15, + ) + data = response.json() + if data.get("code") != 0: + return None + data_obj = data.get("data", {}) + singer_list = data_obj.get("singer_list", []) + if not singer_list: + return None + singer = singer_list[0] + title = singer.get("basic_info", {}).get("name", "") + songs = self.search(title, search_type="song", limit=30).get("tracks", []) + return { + "title": title, + "description": singer.get("ex_info", {}).get("desc", ""), + "songs": songs, + } + + def get_album_detail(self, album_mid: str) -> dict | None: + response = self._context.http.get( + f"{self.REMOTE_BASE_URL}/album", + params={"mid": album_mid}, + timeout=15, + ) + data = response.json() + if data.get("code") != 0: + return None + album = data.get("data", {}) + basic_info = album.get("basicInfo", {}) + songs = [self._format_song_item(item.get("songInfo", item)) for item in album.get("songList", [])] + return { + "title": basic_info.get("albumName", album.get("name", "")), + "description": basic_info.get("desc", ""), + "songs": songs, + } + + def get_playlist_detail(self, playlist_id: str) -> dict | None: + response = self._context.http.get( + f"{self.REMOTE_BASE_URL}/playlist", + params={"id": playlist_id}, + timeout=15, + ) + data = response.json() + if data.get("code") != 0: + return None + playlist = data.get("data", {}) + dirinfo = playlist.get("dirinfo", {}) + songs = [self._format_song_item(item) for item in playlist.get("songlist", [])] + return { + "title": dirinfo.get("title", playlist.get("name", "")), + "description": dirinfo.get("desc", playlist.get("description", "")), + "songs": songs, + } + + def get_hotkeys(self) -> list[dict]: + response = self._context.http.get(f"{self.REMOTE_BASE_URL}/hotkey", timeout=10) + data = response.json() + if data.get("code") != 0: + return [] + items = data.get("data", {}).get("vec_hotkey", []) or data.get("data", {}).get("vecHotkey", []) + return [ + { + "title": item.get("title", ""), + "query": item.get("query", item.get("title", "")), + } + for item in items + if item.get("title") + ] + + def complete(self, keyword: str) -> list[dict]: + response = self._context.http.get( + f"{self.REMOTE_BASE_URL}/search/smartbox", + params={"key": keyword}, + timeout=10, + ) + data = response.json() + if data.get("code") != 0: + return [] + items = data.get("data", {}).get("itemlist", []) or data.get("data", {}).get("items", []) + results = [] + for item in items: + hint = item.get("name") or item.get("hint") or item.get("title") + if hint: + results.append({"hint": hint}) + return results + + def _format_song_item(self, song: dict) -> dict: + singer_info = song.get("singer", "") + if isinstance(singer_info, list) and singer_info: + singer_name = ", ".join(item.get("name", "") for item in singer_info if item.get("name")) + elif isinstance(singer_info, dict): + singer_name = singer_info.get("name", "") + else: + singer_name = str(song.get("singerName", singer_info or "")) + + album_info = song.get("album", {}) + if isinstance(album_info, dict): + album_name = album_info.get("name", "") or song.get("albumName", "") + album_mid = album_info.get("mid", "") or song.get("albumMid", "") + else: + album_name = str(album_info or song.get("albumName", "")) + album_mid = song.get("albumMid", "") + + return { + "mid": song.get("mid", "") or song.get("songmid", "") or song.get("songMid", ""), + "name": song.get("name", "") or song.get("songname", "") or song.get("title", ""), + "title": song.get("name", "") or song.get("songname", "") or song.get("title", ""), + "artist": singer_name, + "singer": singer_name, + "album": album_name, + "album_mid": album_mid, + "duration": song.get("interval", 0) or song.get("duration", 0), + } + + def _extract_singer_name(self, item: dict) -> str: + singer_list = item.get("singer_list", []) + if singer_list and isinstance(singer_list, list): + return ", ".join(entry.get("name", "") for entry in singer_list if entry.get("name")) + return item.get("singer", "") diff --git a/plugins/builtin/qqmusic/lib/client.py b/plugins/builtin/qqmusic/lib/client.py index 0bd8af01..3a5ebfcd 100644 --- a/plugins/builtin/qqmusic/lib/client.py +++ b/plugins/builtin/qqmusic/lib/client.py @@ -1,24 +1,461 @@ from __future__ import annotations +import socket +from typing import Any + from .api import QQMusicPluginAPI +from .legacy.qqmusic_service import QQMusicService class QQMusicPluginClient: def __init__(self, context): self._context = context self._api = QQMusicPluginAPI(context) - self._credential = context.settings.get("credential", None) + self._legacy_network_reachable: bool | None = None def get_quality(self) -> str: return str(self._context.settings.get("quality", "320")) + def _get_credential(self) -> dict[str, Any] | None: + credential = self._context.settings.get("credential", None) + return credential if isinstance(credential, dict) else None + + def _get_service(self) -> QQMusicService | None: + credential = self._get_credential() + if not credential: + return None + return QQMusicService(credential) + + def _can_use_legacy_network(self) -> bool: + if self._legacy_network_reachable is not None: + return self._legacy_network_reachable + try: + sock = socket.create_connection(("u.y.qq.com", 443), timeout=0.5) + sock.close() + self._legacy_network_reachable = True + except OSError: + self._legacy_network_reachable = False + return self._legacy_network_reachable + + def is_logged_in(self) -> bool: + return bool(self._get_credential() or self._context.settings.get("nick", "")) + def set_credential(self, credential: dict) -> None: - self._credential = credential self._context.settings.set("credential", credential) def clear_credential(self) -> None: - self._credential = None self._context.settings.set("credential", None) - def search(self, keyword: str, limit: int = 20) -> list[dict]: - return self._api.search(keyword, limit=limit) + def search( + self, + keyword: str, + search_type: str = "song", + limit: int = 20, + page: int = 1, + ) -> dict[str, list[dict]]: + # Prefer QQ Music direct client when logged in + if self._get_credential() and self._can_use_legacy_network(): + result = self._search_legacy(keyword, search_type, page, limit) + if result: + return result + + # Fallback to remote API + return self._api.search(keyword, search_type=search_type, limit=limit, page=page) + + def _search_legacy( + self, + keyword: str, + search_type: str, + page: int = 1, + page_size: int = 20, + ) -> dict[str, list[dict]] | None: + """Search using legacy QQ Music client.""" + service = self._get_service() + if service is None: + return None + + try: + raw_data = service.client.search( + keyword, + search_type=search_type, + page_num=page, + page_size=page_size, + ) + return self._normalize_legacy_search_payload(raw_data, search_type) + except Exception: + return None + + def _normalize_legacy_search_payload( + self, + raw_data: dict[str, Any] | None, + search_type: str, + ) -> dict[str, list[dict]] | None: + if not isinstance(raw_data, dict): + return None + + root = raw_data.get("data", {}).get("body", {}) + if search_type == "song": + song_section = root.get("song", {}) if isinstance(root, dict) else {} + items = song_section.get("list", []) + total = song_section.get("totalnum") or song_section.get("totalNum") or len(items) + return { + "tracks": [self._normalize_detail_song(item) for item in items if isinstance(item, dict)], + "total": int(total or 0), + } + + if search_type == "singer": + singer_section = root.get("singer", {}) if isinstance(root, dict) else {} + items = singer_section.get("list", []) + total = singer_section.get("totalnum") or singer_section.get("totalNum") or len(items) + return { + "artists": [ + { + "mid": str(item.get("singerMID", "") or item.get("mid", "")), + "name": str(item.get("singerName", "") or item.get("name", "")), + "pic_url": item.get("pic") or item.get("pic_url") or "", + "song_count": int(item.get("songNum", 0) or item.get("song_count", 0) or 0), + } + for item in items + if isinstance(item, dict) + ], + "total": int(total or 0), + } + + if search_type == "album": + album_section = root.get("album", {}) if isinstance(root, dict) else {} + items = album_section.get("list", []) + total = album_section.get("totalnum") or album_section.get("totalNum") or len(items) + return { + "albums": [ + { + "mid": str(item.get("albumMID", "") or item.get("mid", "")), + "name": str(item.get("albumName", "") or item.get("name", "")), + "artist": str(item.get("singerName", "") or item.get("artist", "")), + "cover_url": item.get("albumPic", "") or item.get("cover_url", ""), + } + for item in items + if isinstance(item, dict) + ], + "total": int(total or 0), + } + + if search_type == "playlist": + playlist_section = root.get("songlist", {}) if isinstance(root, dict) else {} + items = playlist_section.get("list", []) + total = playlist_section.get("totalnum") or playlist_section.get("totalNum") or len(items) + return { + "playlists": [ + { + "id": str(item.get("dissid", "") or item.get("id", "")), + "title": str(item.get("dissname", "") or item.get("title", "")), + "creator": str( + item.get("creator", {}).get("name", "") + if isinstance(item.get("creator"), dict) + else item.get("creator", "") + ), + "cover_url": item.get("imgurl", "") or item.get("cover_url", ""), + } + for item in items + if isinstance(item, dict) + ], + "total": int(total or 0), + } + + return None + + def get_top_lists(self) -> list[dict]: + return self._api.get_top_lists() + + def get_top_list_tracks(self, top_id: int | str) -> list[dict]: + api_data = self._api.get_top_list_tracks(top_id) + if isinstance(api_data, list) and api_data: + return api_data + service = self._get_service() + if service is not None and self._can_use_legacy_network(): + data = service.get_top_list_songs(int(top_id), num=100) + if isinstance(data, list) and data: + return [self._normalize_top_list_track(item) for item in data] + return api_data if isinstance(api_data, list) else [] + + def get_recommendations(self) -> list[dict]: + service = self._get_service() + if service is None or not self._can_use_legacy_network(): + return [] + + items: list[dict] = [] + for card_id, title, entry_type, loader in ( + ("home_feed", "首页推荐", "songs", service.get_home_feed), + ("guess", "猜你喜欢", "songs", service.get_guess_recommend), + ("radar", "雷达歌单", "songs", service.get_radar_recommend), + ("songlist", "推荐歌单", "playlists", service.get_recommend_songlist), + ("newsong", "新歌推荐", "songs", service.get_recommend_newsong), + ): + try: + data = loader() or [] + except Exception: + data = [] + if data: + items.append( + { + "id": card_id, + "title": title, + "subtitle": f"{len(data)} 项", + "cover_url": self._pick_cover(data), + "items": data, + "entry_type": entry_type, + } + ) + return items + + def get_favorites(self) -> list[dict]: + service = self._get_service() + if service is None or not self._can_use_legacy_network(): + return [] + + sections = [] + for card_id, title, entry_type, loader in ( + ("fav_songs", "我喜欢的歌曲", "songs", lambda: service.get_my_fav_songs(page=1, num=30)), + ("created_playlists", "我创建的歌单", "playlists", service.get_my_created_songlists), + ("fav_playlists", "我收藏的歌单", "playlists", lambda: service.get_my_fav_songlists(page=1, num=30)), + ("fav_albums", "我收藏的专辑", "albums", lambda: service.get_my_fav_albums(page=1, num=30)), + ("followed_singers", "我关注的歌手", "artists", lambda: service.get_followed_singers(page=1, size=30)), + ): + try: + data = loader() or [] + except Exception: + data = [] + if data: + sections.append( + { + "id": card_id, + "title": title, + "count": len(data), + "subtitle": f"{len(data)} 项", + "cover_url": self._pick_cover(data), + "items": data, + "entry_type": entry_type, + } + ) + return sections + + def get_playback_url_info(self, track_id: str, quality: str): + service = self._get_service() + if service is not None: + info = service.get_playback_url_info(track_id, quality) + if info: + return info + return self._api.get_playback_url_info(track_id, quality) + + def get_artist_detail(self, singer_mid: str) -> dict | None: + service = self._get_service() + if service is not None: + detail = service.get_singer_info_with_follow_status(singer_mid, page=1, page_size=30) + if detail: + return { + "title": detail.get("name", ""), + "description": detail.get("desc", ""), + "songs": [self._normalize_detail_song(item) for item in detail.get("songs", [])], + "follow_status": bool(detail.get("follow_status", False)), + } + return self._api.get_artist_detail(singer_mid) + + def get_artist_albums(self, singer_mid: str, limit: int = 10) -> list[dict]: + service = self._get_service() + if service is None: + return [] + detail = service.get_singer_albums(singer_mid, number=limit, begin=0) + if not isinstance(detail, dict): + return [] + albums = detail.get("albums", []) + return albums if isinstance(albums, list) else [] + + def get_album_detail(self, album_mid: str) -> dict | None: + service = self._get_service() + if service is not None: + detail = service.get_album_info_with_fav_status(album_mid, page=1, page_size=30) + if detail: + return { + "title": detail.get("name", ""), + "description": detail.get("description", ""), + "songs": [self._normalize_detail_song(item) for item in detail.get("songs", [])], + "is_faved": bool(detail.get("fav_status", False)), + } + return self._api.get_album_detail(album_mid) + + def get_playlist_detail(self, playlist_id: str) -> dict | None: + service = self._get_service() + if service is not None: + detail = service.get_playlist_info_with_fav_status(playlist_id, page=1, page_size=30) + if detail: + return { + "title": detail.get("name", ""), + "description": detail.get("description", ""), + "songs": [self._normalize_detail_song(item) for item in detail.get("songs", [])], + "is_faved": bool(detail.get("fav_status", False)), + } + return self._api.get_playlist_detail(playlist_id) + + def follow_artist(self, singer_mid: str) -> bool: + service = self._get_service() + if service is None: + return False + return bool(service.follow_singer(singer_mid)) + + def unfollow_artist(self, singer_mid: str) -> bool: + service = self._get_service() + if service is None: + return False + return bool(service.unfollow_singer(singer_mid)) + + def fav_album(self, album_mid: str) -> bool: + service = self._get_service() + if service is None: + return False + return bool(service.fav_album(album_mid)) + + def unfav_album(self, album_mid: str) -> bool: + service = self._get_service() + if service is None: + return False + return bool(service.unfav_album(album_mid)) + + def fav_playlist(self, playlist_id: str) -> bool: + service = self._get_service() + if service is None: + return False + return bool(service.fav_playlist(playlist_id)) + + def unfav_playlist(self, playlist_id: str) -> bool: + service = self._get_service() + if service is None: + return False + return bool(service.unfav_playlist(playlist_id)) + + def get_hotkeys(self) -> list[dict]: + api_items = self._api.get_hotkeys() + if isinstance(api_items, list) and api_items: + return api_items + + service = self._get_service() + if service is not None and self._can_use_legacy_network(): + try: + legacy_items = service.get_hotkey() or [] + except Exception: + legacy_items = [] + normalized = [] + for item in legacy_items: + if not isinstance(item, dict): + continue + title = str(item.get("title") or item.get("k") or item.get("query") or "").strip() + query = str(item.get("query") or item.get("k") or title).strip() + if title: + normalized.append({"title": title, "query": query}) + if normalized: + return normalized + + return [] + + def complete(self, keyword: str) -> list[dict]: + return self._api.complete(keyword) + + def _normalize_detail_song(self, item: dict) -> dict: + singer_value = item.get("singer", "") + if isinstance(singer_value, list): + singer_name = ", ".join(entry.get("name", "") for entry in singer_value if isinstance(entry, dict) and entry.get("name")) + else: + singer_name = str(singer_value or "") + album_value = item.get("album", {}) + if isinstance(album_value, dict): + album_name = album_value.get("name", item.get("albumname", "")) + else: + album_name = str(album_value or item.get("albumname", "")) + return { + "mid": item.get("mid", "") or item.get("songmid", ""), + "title": item.get("title", item.get("name", "")), + "artist": singer_name, + "album": album_name, + "duration": item.get("interval", item.get("duration", 0)), + } + + def _normalize_top_list_track(self, item: Any) -> dict[str, Any]: + if isinstance(item, dict): + singer_value = item.get("artist", item.get("singer", "")) + if isinstance(singer_value, list): + artist = ", ".join( + entry.get("name", "") + for entry in singer_value + if isinstance(entry, dict) and entry.get("name") + ) + elif isinstance(singer_value, dict): + artist = str(singer_value.get("name", "")) + else: + artist = str(singer_value or "") + + album_value = item.get("album", "") + album_mid = "" + if isinstance(album_value, dict): + album = str(album_value.get("name", item.get("albumname", ""))) + album_mid = str(album_value.get("mid", item.get("album_mid", "")) or "") + else: + album = str(album_value or item.get("albumname", "")) + album_mid = str(item.get("album_mid", item.get("albummid", "")) or "") + + return { + "mid": str(item.get("mid", item.get("songmid", ""))), + "title": str(item.get("title", item.get("name", ""))), + "artist": artist, + "album": album, + "album_mid": album_mid, + "duration": int(item.get("interval", item.get("duration", 0)) or 0), + } + + return { + "mid": getattr(item, "mid", ""), + "title": getattr(item, "title", ""), + "artist": getattr(item, "singer_name", ""), + "album": getattr(item, "album_name", ""), + "album_mid": getattr(getattr(item, "album", None), "mid", ""), + "duration": getattr(item, "duration", 0), + } + + def _pick_cover(self, items: list[dict[str, Any]]) -> str: + for item in items: + if not isinstance(item, dict): + continue + if isinstance(item.get("Track"), dict): + track = item["Track"] + album = track.get("album", {}) + if isinstance(album, dict) and album.get("mid"): + return f"https://y.gtimg.cn/music/photo_new/T002R300x300M000{album.get('mid')}.jpg" + cover_url = track.get("cover_url") or track.get("cover") or track.get("picurl") or track.get("pic") + if isinstance(cover_url, dict): + cover_url = cover_url.get("default_url") or cover_url.get("small_url") + if cover_url: + return str(cover_url) + if isinstance(item.get("Playlist"), dict): + playlist = item["Playlist"] + basic = playlist.get("basic", {}) if isinstance(playlist.get("basic"), dict) else {} + content = playlist.get("content", {}) if isinstance(playlist.get("content"), dict) else {} + cover_url = ( + basic.get("cover_url") + or basic.get("cover") + or content.get("cover_url") + or content.get("cover") + or playlist.get("cover_url") + or playlist.get("cover") + ) + if isinstance(cover_url, dict): + cover_url = cover_url.get("default_url") or cover_url.get("small_url") + if cover_url: + return str(cover_url) + cover_url = item.get("cover_url") or item.get("cover") or item.get("picurl") or item.get("pic") + if isinstance(cover_url, dict): + cover_url = cover_url.get("default_url") or cover_url.get("small_url") + if cover_url: + return str(cover_url) + album = item.get("album", {}) + if isinstance(album, dict) and album.get("mid"): + return f"https://y.gtimg.cn/music/photo_new/T002R300x300M000{album.get('mid')}.jpg" + if item.get("album_mid"): + return f"https://y.gtimg.cn/music/photo_new/T002R300x300M000{item.get('album_mid')}.jpg" + return "" diff --git a/plugins/builtin/qqmusic/lib/common.py b/plugins/builtin/qqmusic/lib/common.py new file mode 100644 index 00000000..8f5184fb --- /dev/null +++ b/plugins/builtin/qqmusic/lib/common.py @@ -0,0 +1,240 @@ +""" +QQ Music common utilities and constants. +""" + +import random +import time +from enum import Enum +from typing import Dict + + +class SongFileType: + """Song file type mappings for different quality levels.""" + + MASTER = {'s': 'AI00', 'e': '.flac'} + ATMOS_2 = {'s': 'Q000', 'e': '.flac'} + ATMOS_51 = {'s': 'Q001', 'e': '.flac'} + DOLBY = {'s': 'RS01', 'e': '.flac'} + HIRES = {'s': 'SQ00', 'e': '.flac'} + FLAC = {'s': 'F000', 'e': '.flac'} + APE = {'s': 'A000', 'e': '.ape'} + DTS = {'s': 'D000', 'e': '.dts'} + MP3_320 = {'s': 'M800', 'e': '.mp3'} + MP3_128 = {'s': 'M500', 'e': '.mp3'} + OGG_640 = {'s': 'O801', 'e': '.ogg'} + OGG_320 = {'s': 'O800', 'e': '.ogg'} + OGG_192 = {'s': 'O600', 'e': '.ogg'} + OGG_96 = {'s': 'O400', 'e': '.ogg'} + AAC_320 = {'s': 'C800', 'e': '.m4a'} + AAC_256 = {'s': 'C700', 'e': '.m4a'} + AAC_192 = {'s': 'C600', 'e': '.m4a'} + AAC_128 = {'s': 'C500', 'e': '.m4a'} + AAC_96 = {'s': 'C400', 'e': '.m4a'} + AAC_64 = {'s': 'C300', 'e': '.m4a'} + AAC_48 = {'s': 'C200', 'e': '.m4a'} + AAC_24 = {'s': 'C100', 'e': '.m4a'} + + +class SearchType(Enum): + """Search type enumeration.""" + + SONG = 0 + SINGER = 1 + ALBUM = 2 + PLAYLIST = 3 + MV = 4 + LYRIC = 7 + USER = 8 + + +class APIConfig: + """QQ Music API configuration.""" + + VERSION = "13.2.5.8" + VERSION_CODE = 13020508 + # Use musicu.fcg endpoint (no sign required for most APIs) + ENDPOINT = "https://u.y.qq.com/cgi-bin/musicu.fcg" + # Signed endpoint for specific APIs + ENDPOINT_SIGNED = "https://u.y.qq.com/cgi-bin/musics.fcg" + + # Quality fallback order + QUALITY_FALLBACK = [ + "master", + "atmos_2", + "atmos_51", + "dolby", + "hires", + "flac", + "ape", + "dts", + "ogg_640", + "320", + "ogg_320", + "aac_320", + "aac_256", + "aac_192", + "ogg_192", + "128", + "aac_128", + "aac_96", + "ogg_96", + "aac_64", + "aac_48", + "aac_24", + ] + + +_QUALITY_ALIASES = { + # Legacy / internal aliases + "atmos": "atmos_2", + "192": "ogg_192", + "96": "ogg_96", + # Chinese quality names + "标准": "128", + "hq高品质": "320", + "sq无损品质": "flac", + "臻品母带3.0": "master", + "臻品全景声2.0": "atmos_2", + "臻品音质2.0": "atmos_51", + "ogg高品质": "ogg_320", + "ogg标准": "ogg_192", + "aac高品质": "aac_192", + "aac标准": "aac_96", +} + + +_QUALITY_FILE_MAP = { + "master": SongFileType.MASTER, + "atmos_2": SongFileType.ATMOS_2, + "atmos_51": SongFileType.ATMOS_51, + "dolby": SongFileType.DOLBY, + "hires": SongFileType.HIRES, + "flac": SongFileType.FLAC, + "ape": SongFileType.APE, + "dts": SongFileType.DTS, + "320": SongFileType.MP3_320, + "128": SongFileType.MP3_128, + "ogg_640": SongFileType.OGG_640, + "ogg_320": SongFileType.OGG_320, + "ogg_192": SongFileType.OGG_192, + "ogg_96": SongFileType.OGG_96, + "aac_320": SongFileType.AAC_320, + "aac_256": SongFileType.AAC_256, + "aac_192": SongFileType.AAC_192, + "aac_128": SongFileType.AAC_128, + "aac_96": SongFileType.AAC_96, + "aac_64": SongFileType.AAC_64, + "aac_48": SongFileType.AAC_48, + "aac_24": SongFileType.AAC_24, +} + +_QUALITY_LABEL_KEYS = { + "master": "qqmusic_quality_master", + "atmos_2": "qqmusic_quality_atmos_2", + "atmos_51": "qqmusic_quality_atmos_51", + "dolby": "qqmusic_quality_dolby", + "hires": "qqmusic_quality_hires", + "flac": "qqmusic_quality_flac", + "ape": "qqmusic_quality_ape", + "dts": "qqmusic_quality_dts", + "ogg_640": "qqmusic_quality_ogg_640", + "320": "qqmusic_quality_320", + "ogg_320": "qqmusic_quality_ogg_320", + "aac_320": "qqmusic_quality_aac_320", + "aac_256": "qqmusic_quality_aac_256", + "aac_192": "qqmusic_quality_aac_192", + "ogg_192": "qqmusic_quality_ogg_192", + "128": "qqmusic_quality_128", + "aac_128": "qqmusic_quality_aac_128", + "aac_96": "qqmusic_quality_aac_96", + "ogg_96": "qqmusic_quality_ogg_96", + "aac_64": "qqmusic_quality_aac_64", + "aac_48": "qqmusic_quality_aac_48", + "aac_24": "qqmusic_quality_aac_24", +} + + +def get_guid() -> str: + """ + Generate random 32-character GUID. + + Returns: + Random GUID string + """ + chars = "abcdef1234567890" + return ''.join(random.choice(chars) for _ in range(32)) + + +def get_search_id() -> str: + """ + Generate search ID. + + Returns: + Search ID string + """ + e = random.randint(1, 20) + t = e * 18014398509481984 + n = random.randint(0, 4194303) * 4294967296 + r = int(time.time() * 1000) % (24 * 60 * 60 * 1000) + return str(t + n + r) + + +def parse_search_type(type_str: str) -> SearchType: + """ + Parse search type string to enum. + + Args: + type_str: Search type string + + Returns: + SearchType enum value + """ + type_map = { + 'song': SearchType.SONG, + 'singer': SearchType.SINGER, + 'album': SearchType.ALBUM, + 'playlist': SearchType.PLAYLIST, + 'mv': SearchType.MV, + 'lyric': SearchType.LYRIC, + 'user': SearchType.USER, + } + return type_map.get(type_str.lower() if type_str else '', SearchType.SONG) + + +def parse_quality(quality: str) -> Dict[str, str]: + """ + Parse quality string to file type mapping. + + Args: + quality: Quality string (e.g., 'flac', '320', 'master') + + Returns: + Dictionary with 's' (prefix) and 'e' (extension) keys + """ + normalized = normalize_quality(quality) + return _QUALITY_FILE_MAP.get(normalized, SongFileType.MP3_128) + + +def normalize_quality(quality: str) -> str: + """ + Normalize quality input to internal quality code. + + Args: + quality: Quality code, alias, or display name. + + Returns: + Internal quality code string. + """ + value = str(quality or "").strip().lower() + return _QUALITY_ALIASES.get(value, value) + + +def get_selectable_qualities() -> list[str]: + """Return quality codes for UI selection in fallback order.""" + return list(APIConfig.QUALITY_FALLBACK) + + +def get_quality_label_key(quality: str) -> str: + """Get i18n key for a quality code.""" + normalized = normalize_quality(quality) + return _QUALITY_LABEL_KEYS.get(normalized, "") diff --git a/plugins/builtin/qqmusic/lib/context_menus.py b/plugins/builtin/qqmusic/lib/context_menus.py new file mode 100644 index 00000000..955f93e9 --- /dev/null +++ b/plugins/builtin/qqmusic/lib/context_menus.py @@ -0,0 +1,86 @@ +""" +QQ Music specific context menus that now live with the plugin implementation. +""" + +from PySide6.QtCore import QObject, Signal +from PySide6.QtGui import QCursor +from PySide6.QtWidgets import QMenu + +from .i18n import t +from .runtime_bridge import get_qss + + +_CONTEXT_MENU_STYLE = """ + QMenu { + background-color: %background_alt%; + color: %text%; + border: 1px solid %border%; + } + QMenu::item { + padding: 8px 20px; + } + QMenu::item:selected { + background-color: %highlight%; + color: %background%; + } + QMenu::item:disabled { + color: %text_secondary%; + } +""" + + +class OnlineTrackContextMenu(QObject): + """Context menu for QQ online tracks. Emits signals for each action.""" + + play = Signal(list) + insert_to_queue = Signal(list) + add_to_queue = Signal(list) + add_to_playlist = Signal(list) + favorite_toggled = Signal(list, bool) + qq_fav_toggled = Signal(list, bool) + download = Signal(list) + + def show_menu(self, tracks: list, favorite_mids: set | None = None, parent_widget=None): + if not tracks: + return + + menu = QMenu(parent_widget) + menu.setStyleSheet(get_qss(_CONTEXT_MENU_STYLE)) + + action = menu.addAction(t("play")) + action.triggered.connect(lambda: self.play.emit(tracks)) + + action = menu.addAction(t("insert_to_queue")) + action.triggered.connect(lambda: self.insert_to_queue.emit(tracks)) + + action = menu.addAction(t("add_to_queue")) + action.triggered.connect(lambda: self.add_to_queue.emit(tracks)) + + menu.addSeparator() + + all_favorited = False + if favorite_mids: + all_favorited = all( + getattr(track, "mid", None) and track.mid in favorite_mids + for track in tracks + ) + + action = menu.addAction( + t("remove_from_favorites") if all_favorited else t("add_to_favorites") + ) + action.triggered.connect(lambda: self.favorite_toggled.emit(tracks, all_favorited)) + + action = menu.addAction( + t("remove_from_qq_favorites") if all_favorited else t("add_to_qq_favorites") + ) + action.triggered.connect(lambda: self.qq_fav_toggled.emit(tracks, all_favorited)) + + action = menu.addAction(t("add_to_playlist")) + action.triggered.connect(lambda: self.add_to_playlist.emit(tracks)) + + menu.addSeparator() + + action = menu.addAction(t("download")) + action.triggered.connect(lambda: self.download.emit(tracks)) + + menu.exec_(QCursor.pos()) diff --git a/plugins/builtin/qqmusic/lib/cover_hover_popup.py b/plugins/builtin/qqmusic/lib/cover_hover_popup.py new file mode 100644 index 00000000..c1aa265a --- /dev/null +++ b/plugins/builtin/qqmusic/lib/cover_hover_popup.py @@ -0,0 +1,100 @@ +"""Popup widget to display large cover art on hover.""" + +from PySide6.QtCore import Qt, QTimer, QRect, QPoint +from PySide6.QtGui import QColor, QPixmap, QPainter +from PySide6.QtWidgets import QWidget, QVBoxLayout, QLabel, QApplication + +from .runtime_bridge import current_theme + + +class CoverHoverPopup(QWidget): + """Popup widget to display large cover art on hover.""" + + def __init__(self, parent=None, size: int = 300): + super().__init__(parent) + self.setWindowFlags(Qt.Tool | Qt.FramelessWindowHint | Qt.WindowStaysOnTopHint) + self.setAttribute(Qt.WA_TranslucentBackground) + self.setAttribute(Qt.WA_ShowWithoutActivating) + + self._size = size + + layout = QVBoxLayout(self) + layout.setContentsMargins(0, 0, 0, 0) + + self._cover_label = QLabel() + self._cover_label.setFixedSize(self._size, self._size) + self._cover_label.setAlignment(Qt.AlignCenter) + self._cover_label.setStyleSheet("border-radius: 8px;") + layout.addWidget(self._cover_label) + + self._current_track_id = None + self._hide_timer = QTimer(self) + self._hide_timer.setSingleShot(True) + self._hide_timer.timeout.connect(self.hide) + + def show_cover(self, cover_path: str | None, track_id: str, pos: QPoint): + """Show cover at specified position.""" + if self._current_track_id == track_id and self.isVisible(): + return + + self._current_track_id = track_id + + if cover_path: + pixmap = QPixmap(cover_path) + if not pixmap.isNull(): + scaled = pixmap.scaled( + self._size, + self._size, + Qt.KeepAspectRatioByExpanding, + Qt.SmoothTransformation, + ) + self._cover_label.setPixmap(scaled) + else: + self._show_placeholder() + else: + self._show_placeholder() + + screen = QApplication.screenAt(pos) + if not screen: + screen = QApplication.primaryScreen() + screen_rect = screen.availableGeometry() + + offset = 250 + x = pos.x() + offset + y = pos.y() - self._size // 2 + + if x + self._size > screen_rect.right(): + x = pos.x() - self._size - offset + if y < screen_rect.top(): + y = screen_rect.top() + if y + self._size > screen_rect.bottom(): + y = screen_rect.bottom() - self._size + + self.move(x, y) + self.show() + self._hide_timer.stop() + + def _show_placeholder(self): + theme = current_theme() + + pixmap = QPixmap(self._size, self._size) + pixmap.fill(QColor(theme.background_alt)) + + painter = QPainter(pixmap) + painter.setRenderHint(QPainter.Antialiasing) + painter.setPen(QColor(theme.border)) + font = painter.font() + font.setPixelSize(120) + painter.setFont(font) + painter.drawText(QRect(0, 0, self._size, self._size), Qt.AlignCenter, "♪") + painter.end() + + self._cover_label.setPixmap(pixmap) + + def schedule_hide(self, delay_ms: int = 100): + """Schedule hide after delay.""" + self._hide_timer.start(delay_ms) + + def cancel_hide(self): + """Cancel scheduled hide.""" + self._hide_timer.stop() diff --git a/plugins/builtin/qqmusic/lib/dialog_title_bar.py b/plugins/builtin/qqmusic/lib/dialog_title_bar.py new file mode 100644 index 00000000..e95a032b --- /dev/null +++ b/plugins/builtin/qqmusic/lib/dialog_title_bar.py @@ -0,0 +1,122 @@ +"""Shared title bar for frameless dialogs.""" + +from __future__ import annotations + +from dataclasses import dataclass + +from PySide6.QtCore import QSize, Qt +from PySide6.QtWidgets import QDialog, QHBoxLayout, QLabel, QPushButton, QVBoxLayout, QWidget + +from .runtime_bridge import get_icon, get_qss + + +@dataclass +class DialogTitleBarController: + """Controller for dialog title bar widgets.""" + + dialog: QDialog + title_bar: QWidget + title_label: QLabel + close_btn: QPushButton + + def refresh_theme(self): + """Apply theme to title bar widgets.""" + self.title_bar.setStyleSheet( + get_qss( + """ + QWidget#dialogTitleBar { + background-color: %background_alt%; + border-top-left-radius: 12px; + border-top-right-radius: 12px; + border-bottom: 1px solid %border%; + } + """ + ) + ) + self.title_label.setStyleSheet( + get_qss("color: %text%; font-size: 14px; font-weight: bold;") + ) + self.close_btn.setStyleSheet( + get_qss( + """ + QPushButton#dialogCloseBtn { + background: transparent; + border: none; + color: %text_secondary%; + border-radius: 4px; + } + QPushButton#dialogCloseBtn:hover { + background-color: %selection%; + color: %text%; + } + """ + ) + ) + + +def setup_dialog_title_layout( + dialog: QDialog, + container_layout: QVBoxLayout, + title: str, + *, + content_margins: tuple[int, int, int, int] = (24, 20, 24, 20), + content_spacing: int = 12, +) -> tuple[QVBoxLayout, DialogTitleBarController]: + """Setup equalizer-style title bar and return content layout + controller.""" + container_layout.setContentsMargins(0, 0, 0, 0) + container_layout.setSpacing(0) + + title_bar = QWidget() + title_bar.setObjectName("dialogTitleBar") + title_layout = QHBoxLayout(title_bar) + title_layout.setContentsMargins(14, 4, 10, 4) + title_layout.setSpacing(0) + + title_label = QLabel(title) + title_label.setObjectName("dialogTitle") + title_layout.addWidget(title_label) + title_layout.addStretch() + + close_btn = QPushButton() + close_btn.setObjectName("dialogCloseBtn") + close_btn.setFixedSize(28, 28) + close_btn.setCursor(Qt.CursorShape.PointingHandCursor) + close_btn.setIcon(get_icon("times.svg", None, 14)) + close_btn.setIconSize(QSize(14, 14)) + close_btn.clicked.connect(dialog.close) + title_layout.addWidget(close_btn) + + container_layout.addWidget(title_bar) + + content_widget = QWidget() + container_layout.addWidget(content_widget) + content_layout = QVBoxLayout(content_widget) + content_layout.setContentsMargins(*content_margins) + content_layout.setSpacing(content_spacing) + + controller = DialogTitleBarController(dialog, title_bar, title_label, close_btn) + controller.refresh_theme() + + _bind_title_bar_drag(dialog, title_bar) + + return content_layout, controller + + +def _bind_title_bar_drag(dialog: QDialog, title_bar: QWidget): + def _mouse_press(event): + if event.button() == Qt.MouseButton.LeftButton: + dialog._drag_pos = event.globalPosition().toPoint() - dialog.frameGeometry().topLeft() + event.accept() + + def _mouse_move(event): + if getattr(dialog, "_drag_pos", None) and event.buttons() & Qt.MouseButton.LeftButton: + dialog.move(event.globalPosition().toPoint() - dialog._drag_pos) + event.accept() + + def _mouse_release(event): + dialog._drag_pos = None + event.accept() + + title_bar.mousePressEvent = _mouse_press + title_bar.mouseMoveEvent = _mouse_move + title_bar.mouseReleaseEvent = _mouse_release diff --git a/plugins/builtin/qqmusic/lib/i18n.py b/plugins/builtin/qqmusic/lib/i18n.py new file mode 100644 index 00000000..4dea6598 --- /dev/null +++ b/plugins/builtin/qqmusic/lib/i18n.py @@ -0,0 +1,51 @@ +from __future__ import annotations + +import json +import logging +from pathlib import Path + +_current_language = "en" +_translations: dict[str, dict[str, str]] = {} + + +def _translations_dir() -> Path: + return Path(__file__).resolve().parent.parent / "translations" + + +def load_translations() -> None: + global _translations + + directory = _translations_dir() + for lang in ("en", "zh"): + path = directory / f"{lang}.json" + if not path.exists(): + _translations[lang] = {} + continue + try: + _translations[lang] = json.loads(path.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError) as exc: + logging.warning("Failed to load QQ Music plugin translations for %s: %s", lang, exc) + _translations[lang] = {} + + +def set_language(lang: str) -> None: + global _current_language + _current_language = lang if lang in ("en", "zh") else "en" + + +def get_language() -> str: + return _current_language + + +def t(key: str, default: str | None = None) -> str: + translations = _translations.get(_current_language, {}) + if key in translations: + return translations[key] + if _current_language != "en": + fallback = _translations.get("en", {}) + if key in fallback: + return fallback[key] + return default if default is not None else key + + +load_translations() diff --git a/plugins/builtin/qqmusic/lib/legacy/client.py b/plugins/builtin/qqmusic/lib/legacy/client.py index 025f0a5d..c733fd10 100644 --- a/plugins/builtin/qqmusic/lib/legacy/client.py +++ b/plugins/builtin/qqmusic/lib/legacy/client.py @@ -892,11 +892,11 @@ def get_euin(self) -> str: url = 'https://c6.y.qq.com/rsc/fcgi-bin/fcg_get_profile_homepage.fcg' - cookies = { - 'uin': str(musicid), - 'qqmusic_key': self.credential.get('musickey', ''), - 'qm_keyst': self.credential.get('musickey', ''), - 'tmeLoginType': str(self.credential.get('login_type', 2)), + params = { + 'format': 'json', + 'userid': musicid, + 'cid': '205360838', + 'reqfrom': '1', } headers = { @@ -904,25 +904,16 @@ def get_euin(self) -> str: 'Referer': 'https://y.qq.com/', } - params = { - 'format': 'json', - 'uin': musicid, - 'cid': '205360838', - 'reqfrom': '1', - 'reqtype': '0', - } - - response = self.session.get( - url, - params=params, - cookies=cookies, - headers=headers, - timeout=10 - ) + # Use a separate session to avoid cookie header conflicts + import requests as _req + sess = _req.Session() + sess.headers.update(headers) + response = sess.get(url, params=params, timeout=10) response.raise_for_status() data = response.json() - euin = data.get('data', {}).get('creator', {}).get('encrypt_uin', '') + resp_data = data.get('data', {}) + euin = resp_data.get('creator', {}).get('encrypt_uin', '') or resp_data.get('encrypt_uin', '') if euin: # Cache in credential @@ -957,50 +948,106 @@ def verify_login(self) -> Dict[str, Any]: try: musicid = self.credential.get('musicid', '') - # Use profile homepage API to verify login - url = 'https://c6.y.qq.com/rsc/fcgi-bin/fcg_get_profile_homepage.fcg' + # Use musicu.fcg endpoint via _make_request (same as other API calls) + data = self._make_request( + 'music.userInfo.Profile', + 'GetUserProfile', + {'user_id': str(musicid)}, + ) - # Build cookies from credential - cookies = { - 'uin': str(musicid), - 'qqmusic_key': self.credential.get('musickey', ''), - 'qm_keyst': self.credential.get('musickey', ''), - 'tmeLoginType': str(self.credential.get('login_type', 2)), - } + if data and data.get('code') == 0: + profile = data.get('data', {}) + identity = self._extract_profile_identity(profile) + result['valid'] = bool(identity.get('valid')) + result['nick'] = str(identity.get('nick', '') or '') + result['uin'] = int(identity.get('uin', 0) or 0) - headers = { - 'User-Agent': 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/146.0.0.0 Safari/537.36', - 'Referer': 'https://y.qq.com/', - } + logger.debug(f"=== result: {musicid} {data}") + # Fallback: try the profile homepage API if musicu.fcg didn't work + if not result['valid']: + self._verify_login_fallback(result) + + return result + + except Exception as e: + logger.warning("musicu.fcg verify_login failed, trying fallback: %s", e) + self._verify_login_fallback(result) + return result + + @staticmethod + def _extract_profile_identity(profile: Dict[str, Any]) -> Dict[str, Any]: + candidates: list[Dict[str, Any]] = [] + if isinstance(profile, dict): + for key in ("creator", "user", "profile", "owner"): + value = profile.get(key) + if isinstance(value, dict): + candidates.append(value) + candidates.append(profile) + + identity = {"valid": False, "nick": "", "uin": 0} + for candidate in candidates: + if not isinstance(candidate, dict) or not candidate: + continue + nick = ( + candidate.get("nick") + or candidate.get("nickname") + or candidate.get("name") + or candidate.get("hostname") + or "" + ) + uin = ( + candidate.get("uin") + or candidate.get("userid") + or candidate.get("user_id") + or 0 + ) + if nick or uin: + identity["valid"] = True + identity["nick"] = str(nick or "") + try: + identity["uin"] = int(uin or 0) + except (TypeError, ValueError): + identity["uin"] = 0 + return identity + + if isinstance(profile, dict) and profile: + identity["valid"] = True + return identity + + def _verify_login_fallback(self, result: Dict[str, Any]) -> None: + """Fallback: use profile homepage API to verify login and get nick.""" + try: + musicid = self.credential.get('musicid', '') + url = 'https://c6.y.qq.com/rsc/fcgi-bin/fcg_get_profile_homepage.fcg' params = { 'format': 'json', - 'uin': musicid, + 'userid': musicid, 'cid': '205360838', 'reqfrom': '1', - 'reqtype': '0', } - response = self.session.get( - url, - params=params, - cookies=cookies, - headers=headers, - timeout=10 - ) + # Use a separate request without session cookie header conflicts + import requests as _req + sess = _req.Session() + sess.headers.update({ + 'User-Agent': 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/146.0.0.0 Safari/537.36', + 'Referer': 'https://y.qq.com/', + }) + response = sess.get(url, params=params, timeout=10) response.raise_for_status() data = response.json() if data.get('code') == 0: - creator = data.get('data', {}).get('creator', {}) + result['valid'] = True + resp_data = data.get('data', {}) + # Try creator.nick first, then top-level hostname + creator = resp_data.get('creator', {}) if creator: - result['valid'] = True - result['nick'] = creator.get('nick', '') - result['uin'] = creator.get('uin', 0) - - return result - + result['nick'] = creator.get('nick', '') or '' + if not result['nick']: + result['nick'] = resp_data.get('hostname', '') or '' + result['uin'] = (creator.get('uin', 0) or resp_data.get('uin', 0) or 0) except Exception as e: - logger.error(f"Failed to verify login: {e}") - return result + logger.debug("verify_login fallback also failed: %s", e) diff --git a/plugins/builtin/qqmusic/lib/legacy/qqmusic_service.py b/plugins/builtin/qqmusic/lib/legacy/qqmusic_service.py index 6b20f669..6a9b4b44 100644 --- a/plugins/builtin/qqmusic/lib/legacy/qqmusic_service.py +++ b/plugins/builtin/qqmusic/lib/legacy/qqmusic_service.py @@ -1116,10 +1116,13 @@ def get_top_list_songs(self, top_id: int, num: int = 100) -> List[Dict[str, Any] album_info = song.get('album') or {} if isinstance(album_info, str): album_name = album_info + album_mid = '' elif isinstance(album_info, dict): album_name = album_info.get('name', '') + album_mid = album_info.get('mid', '') else: album_name = song.get('albumName', '') or song.get('albumname', '') + album_mid = song.get('albumMid', '') or song.get('albummid', '') # Handle duration - interval is in seconds duration = song.get('interval') or song.get('duration') or 0 @@ -1129,6 +1132,7 @@ def get_top_list_songs(self, top_id: int, num: int = 100) -> List[Dict[str, Any] 'title': song.get('songname', '') or song.get('title', '') or song.get('name', ''), 'singer': singer_name, 'album': album_name, + 'album_mid': album_mid, 'duration': duration, } tracks.append(track) diff --git a/plugins/builtin/qqmusic/lib/legacy_config_adapter.py b/plugins/builtin/qqmusic/lib/legacy_config_adapter.py new file mode 100644 index 00000000..b9dc7e7e --- /dev/null +++ b/plugins/builtin/qqmusic/lib/legacy_config_adapter.py @@ -0,0 +1,46 @@ +from __future__ import annotations + + +class QQMusicLegacyConfigAdapter: + def __init__(self, settings) -> None: + self._settings = settings + + def get(self, key: str, default=None): + return self._settings.get(key, default) + + def set(self, key: str, value) -> None: + self._settings.set(key, value) + + def get_plugin_setting(self, _plugin_id: str, key: str, default=None): + return self._settings.get(key, default) + + def set_plugin_setting(self, _plugin_id: str, key: str, value) -> None: + self._settings.set(key, value) + + def get_plugin_secret(self, _plugin_id: str, key: str, default=""): + return self._settings.get(key, default) + + def get_online_music_download_dir(self): + return self._settings.get("online_music_download_dir", "") + + def add_search_history(self, keyword: str) -> None: + keyword = str(keyword or "").strip() + if not keyword: + return + history = self.get_search_history() + history = [item for item in history if item != keyword] + history.insert(0, keyword) + self._settings.set("search_history", history[:10]) + + def get_search_history(self) -> list[str]: + history = self._settings.get("search_history", []) or [] + if not isinstance(history, list): + return [] + return [str(item) for item in history if str(item).strip()] + + def clear_search_history(self) -> None: + self._settings.set("search_history", []) + + def remove_search_history_item(self, keyword: str) -> None: + history = [item for item in self.get_search_history() if item != keyword] + self._settings.set("search_history", history) diff --git a/plugins/builtin/qqmusic/lib/login_dialog.py b/plugins/builtin/qqmusic/lib/login_dialog.py index e4952d7b..2b70995e 100644 --- a/plugins/builtin/qqmusic/lib/login_dialog.py +++ b/plugins/builtin/qqmusic/lib/login_dialog.py @@ -1,14 +1,630 @@ +""" +QQ Music QR code login dialog. +Uses local implementation without qqmusic_api dependency. +""" from __future__ import annotations -from PySide6.QtWidgets import QDialog, QVBoxLayout, QLabel +import logging +import time +from io import BytesIO +from typing import Optional -from .qr_login import QQMusicQRLogin +from PySide6.QtCore import Qt, Signal, QThread, Slot +from PySide6.QtGui import QColor, QPainterPath, QRegion, QPixmap, QImage +from PySide6.QtWidgets import ( + QDialog, QVBoxLayout, QHBoxLayout, QLabel, QPushButton, + QButtonGroup, QRadioButton, QProgressBar, QWidget, + QGraphicsDropShadowEffect, +) + +from .dialog_title_bar import setup_dialog_title_layout +from .i18n import get_language, set_language, t +from .qr_login import QQMusicQRLogin, QRLoginType, QRCodeLoginEvents +from .runtime_bridge import ( + current_theme, + get_qss, + show_information, + register_themed_widget, + show_warning, +) + +logger = logging.getLogger(__name__) + + +class QRLoginThread(QThread): + """Background thread for QR code login polling.""" + + # Signals + qr_code_ready = Signal(bytes) # QR image data + login_success = Signal(dict) # credential dict + login_failed = Signal(str) # error message + login_refused = Signal() # user refused + login_timeout = Signal() # QR code expired + status_update = Signal(str) # status message + + def __init__(self, login_type: str = 'qq'): + super().__init__() + self.login_type = login_type + self._running = True + + def stop(self): + """Stop the polling thread.""" + self._running = False + + def run(self): + """Run QR code login polling.""" + try: + login_type = QRLoginType.WX if self.login_type == 'wx' else QRLoginType.QQ + is_wechat = self.login_type == 'wx' + logger.info(f"Starting QR login with type: {self.login_type} (is_wechat: {is_wechat})") + + client = QQMusicQRLogin() + + # Get QR code + app_name = t("qqmusic_wx_login").replace("登录", "").strip() if is_wechat else "QQ" + self.status_update.emit(t("qqmusic_fetching_qr")) + + qr = client.get_qrcode(login_type) + if not qr: + if self._running: + self.login_failed.emit(t("qqmusic_login_failed_detail").format(error="Failed to get QR code")) + return + + if not self._running: + return + + # Emit QR code image for display + self.qr_code_ready.emit(qr.data) + logger.debug(f"QR code obtained, type: {qr.qr_type}, identifier: {qr.identifier[:20]}...") + + # Poll for login status + self.status_update.emit(t("qqmusic_scan_with_app").format(app=app_name)) + + poll_count = 0 + max_polls = 120 # 2 minutes + + while poll_count < max_polls and self._running: + try: + event, credential = client.check_qrcode(qr) + + if not self._running: + return + + if event == QRCodeLoginEvents.SCAN: + self.status_update.emit(t("qqmusic_waiting_scan")) + + elif event == QRCodeLoginEvents.CONF: + self.status_update.emit(t("qqmusic_scan_confirmed")) + + elif event == QRCodeLoginEvents.DONE: + self.status_update.emit(t("qqmusic_logging_in")) + + if credential: + # Convert credential to dict + cred_dict = credential.as_dict() + + # Add create time for refresh tracking + cred_dict['musickey_createtime'] = int(time.time()) + + # Ensure musicid is string + if 'musicid' in cred_dict and cred_dict['musicid'] is not None: + cred_dict['musicid'] = str(cred_dict['musicid']) + + logger.info(f"Login success, musicid: {cred_dict.get('musicid')}, " + f"login_type: {cred_dict.get('login_type')}, " + f"has_refresh_key: {bool(cred_dict.get('refresh_key'))}, " + f"has_refresh_token: {bool(cred_dict.get('refresh_token'))}, " + f"encrypt_uin: {cred_dict.get('encrypt_uin')}") + + self.login_success.emit(cred_dict) + else: + self.login_failed.emit("Login succeeded but no credential returned") + return + + elif event == QRCodeLoginEvents.TIMEOUT: + self.login_timeout.emit() + return + + elif event == QRCodeLoginEvents.REFUSE: + self.login_refused.emit() + return + + poll_count += 1 + self.msleep(1000) # Poll every second + + except Exception as e: + logger.debug(f"Poll error: {e}") + poll_count += 1 + self.msleep(1000) + + # Timeout + if self._running: + self.login_timeout.emit() + + except Exception as e: + logger.error(f"QR login error: {e}") + if self._running: + self.login_failed.emit(t("qqmusic_login_failed_detail").format(error=str(e))) + + def wait_for_stop(self, timeout_ms: int = 2000): + """Stop the thread and wait for it to finish.""" + self._running = False + return self.wait(timeout_ms) class QQMusicLoginDialog(QDialog): - def __init__(self, parent=None): + """Dialog for QQ Music QR code login.""" + + # Signal emitted when credentials are successfully obtained + credentials_obtained = Signal(dict) + + _STYLE_TEMPLATE = """ + QWidget#dialogContainer { + background-color: %background_alt%; + color: %text%; + border: 1px solid %border%; + border-radius: 12px; + } + QLabel#dialogTitle { + color: %text%; + font-size: 15px; + font-weight: bold; + } + QLabel { + color: %text%; + } + QRadioButton { + color: %text%; + border: 1px solid %border%; + font-size: 13px; + spacing: 8px; + } + QRadioButton::indicator { + width: 18px; + height: 18px; + border: 2px solid %background_hover%; + border-radius: 9px; + background-color: %background_alt%; + } + QRadioButton::indicator:checked { + border: 2px solid %highlight%; + background-color: %highlight%; + } + QRadioButton::indicator:hover { + border: 2px solid %highlight%; + } + QPushButton#loginDialogActionBtn { + background-color: %border%; + color: %text%; + font-size: 13px; + border: 1px solid %background_hover%; + border-radius: 4px; + padding: 8px 16px; + } + QPushButton#loginDialogActionBtn:hover { + background-color: %background_hover%; + border: 1px solid %highlight%; + } + QPushButton#loginDialogActionBtn:pressed { + background-color: %background_alt%; + } + QPushButton#loginDialogActionBtn:disabled { + background-color: %background_alt%; + color: %border%; + } + QProgressBar { + border: none; + background-color: %background_alt%; + height: 4px; + border-radius: 2px; + } + QProgressBar::chunk { + background-color: %highlight%; + border-radius: 2px; + } + QComboBox { + background-color: %background%; + border: 1px solid %border%; + border-radius: 6px; + padding: 0px 12px; + min-height: 32px; + color: %text%; + min-width: 80px; + } + QComboBox:hover { + background-color: %background_hover%; + border: 1px solid %highlight%; + } + QComboBox::drop-down { + border: none; + width: 30px; + } + QComboBox QAbstractItemView { + background-color: %background_alt%; + border: 1px solid %border%; + color: %text%; + selection-background-color: %highlight%; + selection-color: %background%; + outline: none; + } + QComboBox QAbstractItemView::item { + padding: 6px 10px; + min-height: 20px; + } + QComboBox QAbstractItemView::item:hover { + background-color: %highlight%; + color: %background%; + } + QComboBox QAbstractItemView::item:selected { + background-color: %highlight%; + color: %background%; + } + """ + + def __init__(self, context=None, parent=None): super().__init__(parent) - self._client = QQMusicQRLogin() - self.setWindowTitle("QQ Music Login") - layout = QVBoxLayout(self) - layout.addWidget(QLabel("QQ Music Login", self)) + self._context = context + self._drag_pos = None + + self.setWindowTitle(t("qqmusic_login_title")) + self.setMinimumWidth(450) + self.setMinimumHeight(600) + self.resize(460, 680) + + self.setWindowFlags(Qt.WindowType.Dialog | Qt.FramelessWindowHint) + self.setAttribute(Qt.WidgetAttribute.WA_TranslucentBackground) + + self._setup_shadow() + + self._login_thread: Optional[QRLoginThread] = None + self._login_type = 'wx' # default to WeChat + self._language_connected = False + + self._setup_ui() + self._connect_language_events() + self._start_login() + register_themed_widget(self) + + def _setup_shadow(self): + """Setup drop shadow effect.""" + shadow = QGraphicsDropShadowEffect(self) + shadow.setBlurRadius(30) + shadow.setOffset(0, 8) + shadow.setColor(QColor(0, 0, 0, 80)) + self.setGraphicsEffect(shadow) + + def _setup_ui(self): + """Setup the UI.""" + self.setStyleSheet(get_qss(self._STYLE_TEMPLATE)) + + # Outer layout with 0 margins + outer = QVBoxLayout(self) + outer.setContentsMargins(0, 0, 0, 0) + + # Container widget for rounded corners + container = QWidget() + container.setObjectName("dialogContainer") + outer.addWidget(container) + + container_layout = QVBoxLayout(container) + layout, self._title_bar_controller = setup_dialog_title_layout( + self, + container_layout, + t("qqmusic_login_title"), + content_spacing=2, + ) + + # Login type selection + type_layout = QHBoxLayout() + type_label = QLabel(t("qqmusic_login_method")) + theme = current_theme() + type_label.setStyleSheet(f"font-size: 14px; font-weight: bold; color: {theme.text};") + self._qq_radio = QRadioButton(t("qqmusic_qq_login")) + self._wx_radio = QRadioButton(t("qqmusic_wx_login")) + self._wx_radio.setChecked(True) # 默认微信登录 + self._login_type = 'wx' # default to WeChat + self._qq_radio.toggled.connect(self._on_login_type_changed) + + type_group = QButtonGroup(self) + type_group.addButton(self._qq_radio) + type_group.addButton(self._wx_radio) + + type_layout.addWidget(type_label) + type_layout.addWidget(self._qq_radio) + type_layout.addWidget(self._wx_radio) + type_layout.addStretch() + + layout.addLayout(type_layout) + + # Status label (above QR code) + self._status_label = QLabel(t("qqmusic_loading_qr")) + self._status_label.setAlignment(Qt.AlignCenter) + self._status_label.setWordWrap(True) + self._status_label.setStyleSheet(f"font-size: 14px; color: {theme.highlight}; padding: 8px; font-weight: bold;") + layout.addWidget(self._status_label) + + # QR code container + qr_container = QWidget() + qr_layout = QVBoxLayout(qr_container) + qr_layout.setContentsMargins(0, 0, 0, 0) + + # QR code image + self._qr_label = QLabel() + self._qr_label.setMinimumSize(300, 300) + self._qr_label.setMaximumSize(300, 300) + self._qr_label.setAlignment(Qt.AlignCenter) + self._qr_label.setStyleSheet( + f"border: 2px solid {theme.background_hover}; border-radius: 8px; background: #ffffff;") + qr_layout.addWidget(self._qr_label) + + layout.addWidget(qr_container, alignment=Qt.AlignCenter) + + # Progress bar + self._progress_bar = QProgressBar() + self._progress_bar.setTextVisible(False) + self._progress_bar.setRange(0, 0) # Indeterminate progress + self._progress_bar.setMaximumHeight(4) + self._progress_bar.hide() + layout.addWidget(self._progress_bar) + + # Instructions + self._instructions_label = QLabel() + self._instructions_label.setAlignment(Qt.AlignCenter) + self._instructions_label.setStyleSheet(f"color: {theme.text_secondary}; font-size: 12px;") + self._update_instructions() + layout.addWidget(self._instructions_label) + + # Buttons + button_layout = QHBoxLayout() + + self._refresh_button = QPushButton(t("qqmusic_refresh_qr")) + self._refresh_button.setObjectName("loginDialogActionBtn") + self._refresh_button.setCursor(Qt.PointingHandCursor) + self._refresh_button.clicked.connect(self._refresh_qr) + self._refresh_button.setEnabled(False) + button_layout.addWidget(self._refresh_button) + + self._cancel_button = QPushButton(t("cancel")) + self._cancel_button.setObjectName("loginDialogActionBtn") + self._cancel_button.setCursor(Qt.PointingHandCursor) + self._cancel_button.clicked.connect(self._cancel_login) + button_layout.addWidget(self._cancel_button) + + layout.addLayout(button_layout) + + def _connect_language_events(self) -> None: + events = getattr(self._context, "events", None) if self._context is not None else None + if events is None or self._language_connected: + return + signal = getattr(events, "language_changed", None) + if signal is None: + return + signal.connect(self._on_language_changed) + self._language_connected = True + + def _sync_language_from_context(self) -> None: + if self._context is None or self._language_connected: + return + lang = str(getattr(self._context, "language", get_language()) or get_language()) + if lang != get_language(): + set_language(lang) + + def _on_language_changed(self, language: str) -> None: + if language and language != get_language(): + set_language(language) + self._language_connected = True + + def _on_login_type_changed(self): + """Handle login type radio button change.""" + if self._qq_radio.isChecked(): + self._login_type = 'qq' + else: + self._login_type = 'wx' + + # Update instructions text + self._update_instructions() + # Restart login with new type + self._restart_login() + + def _update_instructions(self): + """Update instructions based on login type.""" + app_name = "WeChat" if self._login_type == 'wx' else "QQ" + if get_language() == "zh": + app_name = "微信" if self._login_type == 'wx' else "QQ" + self._instructions_label.setText(t("qqmusic_instructions").format(app=app_name)) + + def _restart_login(self): + """Restart login process - stop old thread and start new one.""" + # Keep reference to old thread + old_thread = self._login_thread + self._login_thread = None + + # Stop old thread if exists + if old_thread: + old_thread.stop() + old_thread.wait(2000) + + # Start new login + self._start_login() + + def _start_login(self): + """Start QR code login process.""" + self._progress_bar.show() + self._refresh_button.setEnabled(False) + self._qr_label.clear() + self._status_label.setText(t("qqmusic_fetching_qr")) + + # Create new thread + thread = QRLoginThread(self._login_type) + thread.qr_code_ready.connect(self._on_qr_code_ready) + thread.login_success.connect(self._on_login_success) + thread.login_failed.connect(self._on_login_failed) + thread.login_refused.connect(self._on_login_refused) + thread.login_timeout.connect(self._on_login_timeout) + thread.status_update.connect(self._on_status_update) + thread.finished.connect(lambda: self._on_thread_finished(thread)) + + self._login_thread = thread + thread.start() + + def _on_thread_finished(self, thread): + """Handle thread finished event.""" + # Clean up reference if this is the current thread + if self._login_thread == thread: + self._login_thread = None + + def _refresh_qr(self): + """Refresh QR code.""" + # Disable refresh button to prevent double-click + self._refresh_button.setEnabled(False) + self._restart_login() + + def _cancel_login(self): + """Cancel login and close dialog.""" + if self._login_thread: + self._login_thread.stop() + self.reject() + + def resizeEvent(self, event): + """Apply rounded corner mask.""" + path = QPainterPath() + path.addRoundedRect(self.rect(), 12, 12) + self.setMask(QRegion(path.toFillPolygon().toPolygon())) + super().resizeEvent(event) + + def mousePressEvent(self, event): + """Handle mouse press for drag to move.""" + if event.button() == Qt.MouseButton.LeftButton: + self._drag_pos = event.globalPosition().toPoint() - self.frameGeometry().topLeft() + + def mouseMoveEvent(self, event): + """Handle mouse move for drag to move.""" + if self._drag_pos and event.buttons() & Qt.MouseButton.LeftButton: + self.move(event.globalPosition().toPoint() - self._drag_pos) + + def mouseReleaseEvent(self, event): + """Handle mouse release.""" + self._drag_pos = None + + def closeEvent(self, event): + """Handle dialog close event.""" + if self._login_thread: + self._login_thread.stop() + event.accept() + + @Slot(bytes) + def _on_qr_code_ready(self, qr_data: bytes): + """Handle QR code ready event.""" + try: + from PIL import Image + + # Convert bytes to QPixmap + img = Image.open(BytesIO(qr_data)) + + # Resize if needed + if img.size != (300, 300): + img = img.resize((300, 300), Image.Resampling.LANCZOS) + + # Convert PIL image to bytes + byte_arr = BytesIO() + img.save(byte_arr, format='PNG') + byte_arr = byte_arr.getvalue() + + # Create QPixmap from bytes + qimage = QImage.fromData(byte_arr) + pixmap = QPixmap.fromImage(qimage) + + self._qr_label.setPixmap(pixmap) + self._refresh_button.setEnabled(True) + + except Exception as e: + logger.error(f"Failed to display QR code: {e}") + self._qr_label.setText(f"{t('qqmusic_qr_display_failed')}\n{str(e)}") + + @Slot(dict) + def _on_login_success(self, credential: dict): + """Handle login success event.""" + self._progress_bar.hide() + self._status_label.setText( + t("qqmusic_login_success") if t('language') != '中文' else "登录成功!正在保存凭证...") + + try: + # Save credentials (full credential dict) + self._context.settings.set("credential", credential) + + # Get user nickname + nick = credential.get("nick") or credential.get("nickname") or "" + if not nick: + try: + from .legacy.qqmusic_service import QQMusicService + service = QQMusicService(credential) + verify_result = service.client.verify_login() + if isinstance(verify_result, dict) and verify_result.get("valid"): + nick = str(verify_result.get("nick", "") or "") + except Exception as e: + logger.warning(f"Failed to get QQ Music nickname: {e}") + if nick: + self._context.settings.set("nick", nick) + logger.info(f"Got QQ Music nickname: {nick}") + + show_information( + self, + t("success"), + t("qqmusic_login_success") + ) + + self.credentials_obtained.emit(credential) + self.accept() + + except Exception as e: + logger.error(f"Failed to save credentials: {e}") + show_warning( + self, + t("error"), + f"{t('error')}:\n{str(e)}" + ) + + @Slot(str) + def _on_login_failed(self, error: str): + """Handle login failed event.""" + self._progress_bar.hide() + self._status_label.setText(t("qqmusic_login_failed")) + show_warning(self, t("qqmusic_login_failed"), error) + + @Slot() + def _on_login_refused(self): + """Handle login refused event.""" + self._progress_bar.hide() + self._status_label.setText(t("qqmusic_user_cancelled")) + show_information(self, t("cancel"), t("qqmusic_you_cancelled")) + + @Slot() + def _on_login_timeout(self): + """Handle login timeout event.""" + self._progress_bar.hide() + self._status_label.setText(t("qqmusic_qr_expired")) + self._refresh_button.setEnabled(True) + show_information( + self, + t("qqmusic_qr_expired"), + t("qqmusic_qr_timeout_refresh") + ) + + @Slot(str) + def _on_status_update(self, status: str): + """Handle status update event.""" + self._status_label.setText(status) + + def refresh_theme(self): + """Refresh theme when changed.""" + self.setStyleSheet(get_qss(self._STYLE_TEMPLATE)) + self._title_bar_controller.refresh_theme() + theme = current_theme() + if self._status_label: + self._status_label.setStyleSheet( + f"font-size: 14px; color: {theme.highlight}; padding: 8px; font-weight: bold;") + if self._qr_label: + self._qr_label.setStyleSheet( + f"border: 2px solid {theme.background_hover}; border-radius: 8px; background: #ffffff;") + if self._instructions_label: + self._instructions_label.setStyleSheet(f"color: {theme.text_secondary}; font-size: 12px;") diff --git a/plugins/builtin/qqmusic/lib/models.py b/plugins/builtin/qqmusic/lib/models.py new file mode 100644 index 00000000..88e497d9 --- /dev/null +++ b/plugins/builtin/qqmusic/lib/models.py @@ -0,0 +1,137 @@ +""" +Online music domain models. +Entities for online music search results. +""" + +from dataclasses import dataclass, field +from typing import Optional, List + + +@dataclass +class OnlineSinger: + """Singer info in online track.""" + + mid: str = "" + name: str = "" + + +@dataclass +class AlbumInfo: + """Simple album info in online track.""" + + mid: str = "" + name: str = "" + + +@dataclass +class OnlineTrack: + """ + Online track from search result. + + Unified format from different API sources (QQ Music, api.ygking.top). + """ + + mid: str = "" # Song MID (unique identifier) + id: Optional[int] = None # Song ID (optional) + title: str = "" + singer: List[OnlineSinger] = field(default_factory=list) + album: Optional[AlbumInfo] = None + duration: int = 0 # Duration in seconds + pay_play: int = 0 # 1 if requires VIP/payment + + @property + def singer_name(self) -> str: + """Get singer names as string.""" + if not self.singer: + return "" + return ", ".join(s.name for s in self.singer if s.name) + + @property + def album_name(self) -> str: + """Get album name.""" + return self.album.name if self.album else "" + + @property + def display_title(self) -> str: + """Get display title.""" + return self.title or "Unknown" + + @property + def is_vip(self) -> bool: + """Check if track requires VIP.""" + return self.pay_play == 1 + + +@dataclass +class OnlineArtist: + """ + Online artist from search result. + """ + + mid: str = "" + name: str = "" + avatar_url: Optional[str] = None + song_count: int = 0 + album_count: int = 0 + fan_count: int = 0 + + +@dataclass +class OnlineAlbum: + """ + Online album from search result. + """ + + mid: str = "" + name: str = "" + singer_mid: str = "" + singer_name: str = "" + cover_url: Optional[str] = None + song_count: int = 0 + publish_date: Optional[str] = None + description: Optional[str] = None + company: Optional[str] = None + genre: Optional[str] = None + language: Optional[str] = None + album_type: Optional[str] = None + + +@dataclass +class OnlinePlaylist: + """ + Online playlist from search result. + """ + + id: str = "" + mid: str = "" # Some APIs use mid, some use id + title: str = "" + creator: str = "" + cover_url: Optional[str] = None + song_count: int = 0 + play_count: int = 0 + + +@dataclass +class SearchResult: + """ + Search result container. + """ + + keyword: str = "" + search_type: str = "song" # song, singer, album, playlist + page: int = 1 + page_size: int = 20 + total: int = 0 + tracks: List[OnlineTrack] = field(default_factory=list) + artists: List[OnlineArtist] = field(default_factory=list) + albums: List[OnlineAlbum] = field(default_factory=list) + playlists: List[OnlinePlaylist] = field(default_factory=list) + + +class SearchType: + """Search type constants.""" + + SONG = "song" + SINGER = "singer" + ALBUM = "album" + PLAYLIST = "playlist" diff --git a/plugins/builtin/qqmusic/lib/online_detail_view.py b/plugins/builtin/qqmusic/lib/online_detail_view.py new file mode 100644 index 00000000..a98a0b87 --- /dev/null +++ b/plugins/builtin/qqmusic/lib/online_detail_view.py @@ -0,0 +1,2291 @@ +""" +Online music detail view. +Shows details for artist, album, or playlist. +""" + +import logging +from typing import Optional, List, Dict, Any + +from PySide6.QtCore import Qt, Signal, QThread, QRect, QTimer +from PySide6.QtGui import QColor, QPixmap, QPainter, QFont +from PySide6.QtWidgets import ( + QWidget, + QVBoxLayout, + QHBoxLayout, + QLabel, + QPushButton, + QTableWidget, + QTableWidgetItem, + QHeaderView, + QAbstractItemView, + QScrollArea, + QFrame, + QMenu, +) +from shiboken6 import isValid + +from .i18n import t +from .models import OnlineTrack, OnlineAlbum, OnlineSinger, AlbumInfo +from .runtime_bridge import ( + add_track_ids_to_playlist, + bootstrap, + create_online_download_service, + create_online_music_service, + current_theme, + event_bus, + format_duration, + get_qss, + image_cache_get, + image_cache_set, + register_themed_widget, + show_information, +) + +logger = logging.getLogger(__name__) + + +class DetailWorker(QThread): + """Background worker for loading detail data.""" + + detail_loaded = Signal(str, object, int) # (type, data, request_id) + + def __init__(self, service: Any, detail_type: str, mid: str, + page: int = 1, page_size: int = 30, request_id: int = 0): + super().__init__() + self._service = service + self._detail_type = detail_type + self._mid = mid + self._page = page + self._page_size = page_size + self._request_id = request_id + + def run(self): + try: + if self._detail_type == "artist": + data = self._service.get_artist_detail(self._mid, page=self._page, page_size=self._page_size) + elif self._detail_type == "album": + data = self._service.get_album_detail(self._mid, page=self._page, page_size=self._page_size) + elif self._detail_type == "playlist": + data = self._service.get_playlist_detail(self._mid, page=self._page, page_size=self._page_size) + else: + data = None + + self.detail_loaded.emit(self._detail_type, data, self._request_id) + except Exception as e: + logger.error(f"Failed to load detail: {e}") + + +class AlbumListWorker(QThread): + """Background worker for loading artist albums.""" + + albums_loaded = Signal(list, int, int) # (albums list, total count, request_id) + + def __init__(self, service: Any, singer_mid: str, number: int = 10, begin: int = 0, + request_id: int = 0): + super().__init__() + self._service = service + self._singer_mid = singer_mid + self._number = number + self._begin = begin + self._request_id = request_id + + def run(self): + try: + result = self._service.get_artist_albums(self._singer_mid, number=self._number, begin=self._begin) + albums = result.get('albums', []) + total = result.get('total', 0) + self.albums_loaded.emit(albums, total, self._request_id) + except Exception as e: + logger.error(f"Failed to load artist albums: {e}", exc_info=True) + self.albums_loaded.emit([], 0, self._request_id) + + +class AlbumCoverLoader(QThread): + """Background worker for loading album cover images with disk caching.""" + + cover_loaded = Signal(QPixmap) + + def __init__(self, url: str, size: int): + super().__init__() + self._url = url + self._size = size + + def run(self): + try: + import requests + + # Check disk cache first + image_data = image_cache_get(self._url) + if not image_data: + # Download from network + response = requests.get(self._url, timeout=10) + response.raise_for_status() + image_data = response.content + # Save to cache + image_cache_set(self._url, image_data) + + pixmap = QPixmap() + if pixmap.loadFromData(image_data): + scaled = pixmap.scaled( + self._size, self._size, + Qt.KeepAspectRatioByExpanding, + Qt.SmoothTransformation + ) + try: + self.cover_loaded.emit(scaled) + except RuntimeError: + pass # Target widget already deleted + except Exception as e: + logger.debug(f"Error loading album cover: {e}") + + +class AllTracksWorker(QThread): + """Background worker for fetching all tracks from all pages.""" + + all_tracks_loaded = Signal(list) # List of OnlineTrack + + def __init__(self, service: Any, detail_type: str, mid: str, + total_songs: int, page_size: int = 30): + super().__init__() + self._service = service + self._detail_type = detail_type + self._mid = mid + self._total_songs = total_songs + self._page_size = page_size + + def run(self): + """Fetch all tracks from all pages.""" + try: + all_tracks = [] + total_pages = (self._total_songs + self._page_size - 1) // self._page_size + + for page in range(1, total_pages + 1): + # Get detail for this page + if self._detail_type == "artist": + data = self._service.get_artist_detail(self._mid, page=page, page_size=self._page_size) + elif self._detail_type == "album": + data = self._service.get_album_detail(self._mid, page=page, page_size=self._page_size) + elif self._detail_type == "playlist": + data = self._service.get_playlist_detail(self._mid, page=page, page_size=self._page_size) + else: + break + + if not data: + break + + # Parse songs + songs = data.get("songs", []) + if not songs: + break + + # Parse tracks + tracks = self._parse_songs(songs) + all_tracks.extend(tracks) + + self.all_tracks_loaded.emit(all_tracks) + except Exception as e: + logger.error(f"Failed to fetch all tracks: {e}", exc_info=True) + self.all_tracks_loaded.emit([]) + + def _parse_songs(self, songs: List[Dict]) -> List[OnlineTrack]: + """Parse song data into OnlineTrack objects.""" + tracks = [] + for song in songs: + try: + # Parse singers + singers_data = song.get("singer", []) + singers = [OnlineSinger( + mid=s.get("mid", ""), + name=s.get("name", "") + ) for s in singers_data] + + # Parse album + album_data = song.get("album", {}) + album = None + if album_data or song.get("albummid"): + album = AlbumInfo( + mid=album_data.get("mid", song.get("albummid", "")), + name=album_data.get("name", song.get("albumname", "")), + ) + + # Create track + track = OnlineTrack( + mid=song.get("mid", ""), + id=song.get("id"), + title=song.get("name", song.get("title", "")), + singer=singers, + album=album, + duration=song.get("interval", song.get("duration", 0)), + pay_play=song.get("pay_play", 0) + ) + tracks.append(track) + except Exception as e: + logger.debug(f"Failed to parse song: {e}") + continue + + return tracks + + +class OnlineAlbumCard(QWidget): + """Card widget for displaying online album information.""" + + clicked = Signal(object) # Emits OnlineAlbum object + + COVER_SIZE = 150 + CARD_WIDTH = 150 + CARD_HEIGHT = 200 + BORDER_RADIUS = 8 + + def __init__(self, album_data: Dict[str, Any], parent=None): + super().__init__(parent) + self._album_data = album_data + self._album = OnlineAlbum( + mid=album_data.get("mid", ""), + name=album_data.get("name", ""), + singer_mid=album_data.get("singer_mid", ""), + singer_name=album_data.get("singer_name", ""), + cover_url=album_data.get("cover_url", ""), + song_count=album_data.get("song_count", 0), + publish_date=album_data.get("publish_date", ""), + ) + self._is_hovering = False + self._cover_loaded = False + + self._setup_ui() + self._set_default_cover() + QTimer.singleShot(10, self._load_cover) + + # Register with theme system + register_themed_widget(self) + + def _setup_ui(self): + """Set up the card UI.""" + self.setFixedSize(self.CARD_WIDTH, self.CARD_HEIGHT) + self.setCursor(Qt.PointingHandCursor) + + # Main layout + layout = QVBoxLayout(self) + layout.setContentsMargins(0, 0, 0, 0) + layout.setSpacing(6) + + # Cover container + self._cover_container = QFrame() + self._cover_container.setFixedSize(self.COVER_SIZE, self.COVER_SIZE) + + # Pre-computed stylesheets for hover (H-08 optimization) + theme = current_theme() + radius = self.BORDER_RADIUS + self._style_normal = f"QFrame {{ background-color: {theme.background_hover}; border-radius: {radius}px; }}" + self._style_hover = f"QFrame {{ background-color: {theme.background_hover}; border-radius: {radius}px; border: 2px solid {theme.highlight}; }}" + self._cover_container.setStyleSheet(self._style_normal) + + # Cover label + self._cover_label = QLabel(self._cover_container) + self._cover_label.setFixedSize(self.COVER_SIZE, self.COVER_SIZE) + self._cover_label.setAlignment(Qt.AlignCenter) + + # Info container + info_widget = QWidget() + info_layout = QVBoxLayout(info_widget) + info_layout.setContentsMargins(4, 0, 4, 0) + info_layout.setSpacing(2) + + # Album name + self._name_label = QLabel(self._album.name or "Unknown") + self._name_label.setAlignment(Qt.AlignLeft | Qt.AlignVCenter) + self._name_label.setStyleSheet(get_qss(""" + QLabel { + color: %text%; + font-size: 12px; + font-weight: bold; + background: transparent; + } + """)) + self._name_label.setWordWrap(True) + self._name_label.setMaximumHeight(32) + + info_layout.addWidget(self._name_label) + info_layout.addStretch() + + layout.addWidget(self._cover_container, 0, Qt.AlignHCenter) + layout.addWidget(info_widget) + + def _load_cover(self, force: bool = False): + """Load album cover image asynchronously.""" + if self._cover_loaded and not force: + return + + cover_url = self._album.cover_url + if not cover_url: + return + + # Create a worker thread for loading cover + self._cover_loader = AlbumCoverLoader(cover_url, self.COVER_SIZE) + self._cover_loader.cover_loaded.connect(self._on_cover_loaded) + self._cover_loader.start() + + def _on_cover_loaded(self, pixmap: QPixmap): + """Handle cover loaded.""" + if not pixmap.isNull(): + self._cover_label.setPixmap(pixmap) + self._cover_loaded = True + + def _set_default_cover(self): + """Set default cover when no cover is available.""" + theme = current_theme() + + pixmap = QPixmap(self.COVER_SIZE, self.COVER_SIZE) + pixmap.fill(QColor(theme.border)) + + painter = QPainter(pixmap) + painter.setRenderHint(QPainter.Antialiasing) + painter.setPen(QColor(theme.text_secondary)) + font = QFont() + font.setPixelSize(48) + painter.setFont(font) + painter.drawText( + QRect(0, 0, self.COVER_SIZE, self.COVER_SIZE), + Qt.AlignCenter, "\u266B" + ) + painter.end() + + self._cover_label.setPixmap(pixmap) + + def enterEvent(self, event): + """Handle mouse enter for hover effect.""" + self._is_hovering = True + self._cover_container.setStyleSheet(self._style_hover) + super().enterEvent(event) + + def leaveEvent(self, event): + """Handle mouse leave for hover effect.""" + self._is_hovering = False + self._cover_container.setStyleSheet(self._style_normal) + super().leaveEvent(event) + + def mousePressEvent(self, event): + """Handle mouse click.""" + if event.button() == Qt.LeftButton: + self.clicked.emit(self._album) + super().mousePressEvent(event) + + def get_album(self) -> OnlineAlbum: + """Get the album object.""" + return self._album + + def refresh_theme(self): + """Refresh all styles using current theme tokens.""" + theme = current_theme() + radius = self.BORDER_RADIUS + + # Update pre-computed stylesheets + self._style_normal = f"QFrame {{ background-color: {theme.background_hover}; border-radius: {radius}px; }}" + self._style_hover = f"QFrame {{ background-color: {theme.background_hover}; border-radius: {radius}px; border: 2px solid {theme.highlight}; }}" + + # Apply current state + if self._is_hovering: + self._cover_container.setStyleSheet(self._style_hover) + else: + self._cover_container.setStyleSheet(self._style_normal) + + # Update text labels + self._name_label.setStyleSheet(get_qss(""" + QLabel { + color: %text%; + font-size: 12px; + font-weight: bold; + background: transparent; + } + """)) + + # Update default cover with new theme colors + self._set_default_cover() + + +class OnlineDetailView(QWidget): + """Detail view for artist, album, or playlist.""" + + back_requested = Signal() + play_all = Signal(list, int) # List of OnlineTrack (current page) + insert_all_to_queue = Signal(list) # List of OnlineTrack (current page) + add_all_to_queue = Signal(list) # List of OnlineTrack (current page) + play_all_tracks = Signal(list) # List of OnlineTrack (all tracks) + insert_all_tracks_to_queue = Signal(list) # List of OnlineTrack (all tracks) + add_all_tracks_to_queue = Signal(list) # List of OnlineTrack (all tracks) + album_clicked = Signal(object) # OnlineAlbum + + _STYLE_BUTTONS = """ + QPushButton { + background: %background_alt%; + color: %text%; + border: none; + padding: 4px 16px; + border-radius: 14px; + font-size: 12px; + } + QPushButton:hover { + background: %border%; + } + QPushButton#primaryBtn { + background: %highlight%; + color: %background%; + font-weight: bold; + } + QPushButton#primaryBtn:hover { + background: %highlight_hover%; + } + """ + _STYLE_COVER_LABEL = """ + background: %background_alt%; + border-radius: 8px; + """ + _STYLE_TYPE_LABEL = "color: %text_secondary%; font-size: 11px;" + _STYLE_NAME_LABEL = "color: %text%; font-size: 18px; font-weight: bold;" + _STYLE_SECONDARY_LABEL = "color: %text_secondary%; font-size: 12px;" + _STYLE_EXTRA_LABEL = "color: %text_secondary%; font-size: 11px;" + _STYLE_STATS_LABEL = "color: %highlight%; font-size: 12px;" + _STYLE_DESC_LABEL = "color: %text_secondary%; font-size: 11px;" + _STYLE_PAGE_LABEL = "color: %text_secondary%; padding: 0 10px;" + _STYLE_ALBUMS_SECTION = "background-color: %background_alt%;" + _STYLE_ALBUMS_TITLE = """ + QLabel { + color: %highlight%; + font-size: 18px; + font-weight: bold; + padding: 4px 0; + } + """ + _STYLE_LOAD_MORE_ALBUMS = """ + QPushButton { + background: transparent; + color: %highlight%; + border: 1px solid %highlight%; + border-radius: 14px; + padding: 4px 16px; + font-size: 12px; + } + QPushButton:hover { + background: %highlight%; + color: %text%; + } + """ + _STYLE_SCROLL_AREA = """ + QScrollArea { + background-color: transparent; + border: none; + } + QScrollBar:horizontal { + background-color: %background_alt%; + height: 8px; + border-radius: 4px; + } + QScrollBar::handle:horizontal { + background-color: %border%; + border-radius: 4px; + min-width: 30px; + } + QScrollBar::handle:horizontal:hover { + background-color: %text_secondary%; + } + QScrollBar::add-line, QScrollBar::sub-line { + width: 0px; + } + """ + _STYLE_SONGS_TITLE = """ + QLabel { + color: %highlight%; + font-size: 18px; + font-weight: bold; + padding: 4px 0; + } + """ + _STYLE_SONGS_TABLE = """ + QTableWidget#detailSongsTable { + background-color: %background_alt%; + border: none; + border-radius: 8px; + gridline-color: %background_hover%; + } + QTableWidget#detailSongsTable::item { + padding: 12px 8px; + color: %text%; + border: none; + border-bottom: 1px solid %background_hover%; + } + QTableWidget#detailSongsTable::item:alternate { + background-color: %background_hover%; + } + QTableWidget#detailSongsTable::item:!alternate { + background-color: %background_alt%; + } + QTableWidget#detailSongsTable::item:selected { + background-color: %highlight%; + color: %background%; + font-weight: 500; + } + QTableWidget#detailSongsTable::item:selected:!alternate { + background-color: %highlight%; + } + QTableWidget#detailSongsTable::item:selected:alternate { + background-color: %highlight_hover%; + } + QTableWidget#detailSongsTable::item:hover { + background-color: %border%; + } + QTableWidget#detailSongsTable::item:selected:hover { + background-color: %highlight_hover%; + } + QTableWidget#detailSongsTable::item:focus { + outline: none; + border: none; + } + QTableWidget#detailSongsTable:focus { + outline: none; + border: none; + } + QTableWidget#detailSongsTable QHeaderView::section { + background-color: %background_hover%; + color: %highlight%; + padding: 14px 12px; + border: none; + border-bottom: 2px solid %highlight%; + font-weight: bold; + font-size: 12px; + } + QTableWidget#detailSongsTable QTableCornerButton::section { + background-color: %background_hover%; + border: none; + border-right: 1px solid %border%; + border-bottom: 2px solid %highlight%; + } + QTableWidget#detailSongsTable QScrollBar:vertical { + background-color: %background_alt%; + width: 12px; + border-radius: 6px; + margin: 0px; + } + QTableWidget#detailSongsTable QScrollBar::handle:vertical { + background-color: %border%; + border-radius: 6px; + min-height: 40px; + } + QTableWidget#detailSongsTable QScrollBar::handle:vertical:hover { + background-color: %text_secondary%; + } + QTableWidget#detailSongsTable QScrollBar:horizontal { + background-color: %background_alt%; + height: 12px; + border-radius: 6px; + } + QTableWidget#detailSongsTable QScrollBar::handle:horizontal { + background-color: %border%; + border-radius: 6px; + min-width: 40px; + } + QTableWidget#detailSongsTable QScrollBar::handle:horizontal:hover { + background-color: %text_secondary%; + } + QTableWidget#detailSongsTable QScrollBar::add-line, QScrollBar::sub-line { + height: 0px; + width: 0px; + } + """ + _STYLE_MENU = """ + QMenu { + background: %background_hover%; + color: %text%; + border: 1px solid %border%; + } + QMenu::item:selected { + background: %highlight%; + color: %background%; + } + """ + + def __init__( + self, + config_manager=None, + qqmusic_service=None, + parent=None + ): + super().__init__(parent) + + self._config = config_manager + self._service = create_online_music_service( + config_manager=config_manager, + credential_provider=qqmusic_service + ) + self._download_service = create_online_download_service( + config_manager=config_manager, + credential_provider=qqmusic_service, + online_music_service=self._service + ) + self._event_bus = event_bus() + + self._detail_type = "" # "artist", "album", "playlist" + self._mid = "" + self._cover_url = "" # Store actual cover URL for full-size display + self._tracks: List[OnlineTrack] = [] + self._detail_worker: Optional[DetailWorker] = None + self._album_list_worker: Optional[AlbumListWorker] = None + self._all_tracks_worker: Optional[AllTracksWorker] = None + self._album_cards: List[OnlineAlbumCard] = [] + self._albums_loaded = 0 # Track how many albums have been loaded + self._albums_total = 0 # Total album count from API + self._albums_append = False # Flag for append mode + self._is_faved = False + self._album_request_id = 0 + + # Pagination state + self._current_page = 1 + self._total_pages = 1 + self._total_songs = 0 + self._page_size = 30 # QQ Music API max per page + self._full_description = "" # Full description for dialog + self._use_tracks_list_view = False # Use OnlineTracksListView for recommendations + + self._setup_ui() + + # Register with theme system + register_themed_widget(self) + self.refresh_theme() + + def _setup_ui(self): + """Setup UI components.""" + layout = QVBoxLayout(self) + layout.setContentsMargins(20, 10, 20, 10) + layout.setSpacing(8) + + # Header + header = self._create_header() + layout.addWidget(header) + + # Info section + self._info_section = self._create_info_section() + layout.addWidget(self._info_section) + + # Albums section (for artist detail) + self._albums_section = self._create_albums_section() + layout.addWidget(self._albums_section) + + # Songs section (title + table) + self._songs_section = self._create_songs_section() + layout.addWidget(self._songs_section, 1) # Give stretch priority + + def _create_header(self) -> QWidget: + """Create header with back button.""" + widget = QWidget() + widget.setFixedHeight(28) + layout = QHBoxLayout(widget) + layout.setContentsMargins(0, 0, 0, 0) + + # Back button + self._back_btn = QPushButton("← " + t("back")) + self._back_btn.setCursor(Qt.PointingHandCursor) + self._back_btn.clicked.connect(self.back_requested.emit) + layout.addWidget(self._back_btn) + + layout.addStretch() + + return widget + + def _create_info_section(self) -> QWidget: + """Create info section.""" + widget = QWidget() + self._info_layout = QHBoxLayout(widget) + self._info_layout.setContentsMargins(0, 0, 0, 0) + self._info_layout.setSpacing(12) + + # Cover/Avatar placeholder - clickable + self._cover_label = QLabel() + self._cover_label.setFixedSize(120, 120) + self._cover_label.setAlignment(Qt.AlignCenter) + self._cover_label.setCursor(Qt.PointingHandCursor) + self._cover_label.mousePressEvent = self._on_cover_clicked + self._info_layout.addWidget(self._cover_label) + + # Info + info_widget = QWidget() + info_layout = QVBoxLayout(info_widget) + info_layout.setContentsMargins(0, 0, 0, 0) + info_layout.setSpacing(2) + + # Type label + self._type_label = QLabel() + info_layout.addWidget(self._type_label) + + # Name + self._name_label = QLabel() + info_layout.addWidget(self._name_label) + + # Secondary info (artist/creator) + self._secondary_label = QLabel() + info_layout.addWidget(self._secondary_label) + + # Extra info row (company, genre, language, etc.) + self._extra_label = QLabel() + self._extra_label.setWordWrap(True) + info_layout.addWidget(self._extra_label) + + # Stats + self._stats_label = QLabel() + info_layout.addWidget(self._stats_label) + + # Follow button (for artist detail) + self._follow_btn = QPushButton(t("follow")) + self._follow_btn.setFixedHeight(28) + self._follow_btn.setFixedWidth(160) + self._follow_btn.setCursor(Qt.PointingHandCursor) + self._follow_btn.hide() + self._follow_btn.clicked.connect(self._on_follow_clicked) + info_layout.addWidget(self._follow_btn) + + self._is_followed = False + + # Favorite button (for album/playlist detail) + self._fav_btn = QPushButton(t("add_to_qq_favorites")) + self._fav_btn.setFixedHeight(28) + self._fav_btn.setFixedWidth(160) + self._fav_btn.setCursor(Qt.PointingHandCursor) + self._fav_btn.hide() + self._fav_btn.clicked.connect(self._on_fav_clicked) + info_layout.addWidget(self._fav_btn) + + self._is_faved = False + + # Description (truncated, click to show full) + self._desc_label = QLabel() + self._desc_label.setWordWrap(True) + self._desc_label.setCursor(Qt.CursorShape.PointingHandCursor) + self._desc_label.mousePressEvent = self._on_desc_clicked + info_layout.addWidget(self._desc_label) + + info_layout.addStretch() + self._info_layout.addWidget(info_widget, 1) + + return widget + + def _create_actions(self) -> QWidget: + """Create action buttons.""" + widget = QWidget() + widget.setFixedHeight(32) + layout = QHBoxLayout(widget) + layout.setContentsMargins(0, 0, 0, 0) + layout.setSpacing(8) + + # 立即播放 (current page) + self._play_btn = QPushButton(t("play_now")) + self._play_btn.setObjectName("primaryBtn") + self._play_btn.setCursor(Qt.PointingHandCursor) + self._play_btn.setFixedHeight(28) + self._play_btn.clicked.connect(self._on_play_current) + layout.addWidget(self._play_btn) + + # 插入到队列 (current page) + self._insert_queue_btn = QPushButton(t("insert_to_queue")) + self._insert_queue_btn.setCursor(Qt.PointingHandCursor) + self._insert_queue_btn.setFixedHeight(28) + self._insert_queue_btn.clicked.connect(self._on_insert_current_to_queue) + layout.addWidget(self._insert_queue_btn) + + # 添加到队列 (current page) + self._add_queue_btn = QPushButton(t("add_to_queue")) + self._add_queue_btn.setCursor(Qt.PointingHandCursor) + self._add_queue_btn.setFixedHeight(28) + self._add_queue_btn.clicked.connect(self._on_add_current_to_queue) + layout.addWidget(self._add_queue_btn) + + # 播放全部 (all pages) + self._play_all_btn = QPushButton(t("play_all")) + self._play_all_btn.setCursor(Qt.PointingHandCursor) + self._play_all_btn.setFixedHeight(28) + self._play_all_btn.clicked.connect(self._on_play_all) + layout.addWidget(self._play_all_btn) + + # 全部插入队列 (all pages) + self._insert_all_queue_btn = QPushButton(t("insert_all_to_queue")) + self._insert_all_queue_btn.setCursor(Qt.PointingHandCursor) + self._insert_all_queue_btn.setFixedHeight(28) + self._insert_all_queue_btn.clicked.connect(self._on_insert_all_to_queue) + layout.addWidget(self._insert_all_queue_btn) + + # 全部加到队列 (all pages) + self._add_all_queue_btn = QPushButton(t("add_all_to_queue")) + self._add_all_queue_btn.setCursor(Qt.PointingHandCursor) + self._add_all_queue_btn.setFixedHeight(28) + self._add_all_queue_btn.clicked.connect(self._on_add_all_to_queue) + layout.addWidget(self._add_all_queue_btn) + + layout.addStretch() + + return widget + + def _create_pagination(self) -> QWidget: + """Create pagination widget.""" + widget = QWidget() + widget.setFixedHeight(32) + layout = QHBoxLayout(widget) + layout.setContentsMargins(0, 0, 0, 0) + + # Previous button + self._prev_page_btn = QPushButton("← " + t("previous_page")) + self._prev_page_btn.setFixedHeight(28) + self._prev_page_btn.setCursor(Qt.PointingHandCursor) + self._prev_page_btn.clicked.connect(self._on_prev_page) + layout.addWidget(self._prev_page_btn) + + # Page label + self._page_label = QLabel("1 / 1") + layout.addWidget(self._page_label) + + # Next button + self._next_page_btn = QPushButton(t("next_page") + " →") + self._next_page_btn.setFixedHeight(28) + self._next_page_btn.setCursor(Qt.PointingHandCursor) + self._next_page_btn.clicked.connect(self._on_next_page) + layout.addWidget(self._next_page_btn) + + layout.addStretch() + + # Initially hidden + widget.hide() + + return widget + + def _create_albums_section(self) -> QWidget: + """Create albums grid section for artist detail.""" + section = QWidget() + section_layout = QVBoxLayout(section) + section_layout.setContentsMargins(0, 8, 0, 8) + section_layout.setSpacing(12) + + # Header with title and load more button + header_widget = QWidget() + header_layout = QHBoxLayout(header_widget) + header_layout.setContentsMargins(0, 0, 0, 0) + + # Section title + self._albums_title_label = QLabel(t("albums")) + header_layout.addWidget(self._albums_title_label) + + header_layout.addStretch() + + # Load more button + self._load_more_albums_btn = QPushButton(t("load_more")) + self._load_more_albums_btn.setCursor(Qt.PointingHandCursor) + self._load_more_albums_btn.setFixedHeight(28) + self._load_more_albums_btn.clicked.connect(self._on_load_more_albums) + header_layout.addWidget(self._load_more_albums_btn) + + section_layout.addWidget(header_widget) + + # Albums container with horizontal scroll area + scroll_area = QScrollArea() + scroll_area.setWidgetResizable(False) + scroll_area.setHorizontalScrollBarPolicy(Qt.ScrollBarAsNeeded) + scroll_area.setVerticalScrollBarPolicy(Qt.ScrollBarAlwaysOff) + scroll_area.setFixedHeight(210) + + # Albums container + self._albums_container = QWidget() + self._albums_container.setMinimumHeight(200) + self._albums_layout = QHBoxLayout(self._albums_container) + self._albums_layout.setContentsMargins(0, 0, 0, 0) + self._albums_layout.setSpacing(16) + self._albums_layout.setAlignment(Qt.AlignLeft | Qt.AlignVCenter) + + scroll_area.setWidget(self._albums_container) + section_layout.addWidget(scroll_area) + + # Initially hidden + section.hide() + + return section + + def _create_songs_section(self) -> QWidget: + """Create songs section with title, table (for playlist) and list view (for album).""" + section = QWidget() + section_layout = QVBoxLayout(section) + section_layout.setContentsMargins(0, 0, 0, 0) + section_layout.setSpacing(8) + + # Section title + self._songs_title_label = QLabel(t("songs")) + section_layout.addWidget(self._songs_title_label) + + # Actions + actions = self._create_actions() + section_layout.addWidget(actions) + + # Pagination + self._pagination_widget = self._create_pagination() + section_layout.addWidget(self._pagination_widget) + + # Songs table (for playlist / artist) + self._songs_table = self._create_songs_table() + section_layout.addWidget(self._songs_table, 1) + + # Online tracks list view (for album) + from .online_tracks_list_view import OnlineTracksListView + self._tracks_list_view = OnlineTracksListView() + self._tracks_list_view.track_activated.connect(self._on_track_activated) + self._tracks_list_view.play_requested.connect(self._on_list_play_requested) + self._tracks_list_view.insert_to_queue_requested.connect(self._on_list_insert_to_queue) + self._tracks_list_view.add_to_queue_requested.connect(self._on_list_add_to_queue) + self._tracks_list_view.add_to_playlist_requested.connect(self._on_list_add_to_playlist) + self._tracks_list_view.favorites_toggle_requested.connect(self._on_list_favorites_toggle) + self._tracks_list_view.qq_fav_toggle_requested.connect(self._on_list_qq_fav_toggle) + self._tracks_list_view.download_requested.connect(self._on_list_download_requested) + self._tracks_list_view.hide() + section_layout.addWidget(self._tracks_list_view, 1) + + return section + + def _create_songs_table(self) -> QTableWidget: + """Create songs table.""" + table = QTableWidget() + table.setObjectName("detailSongsTable") + table.setColumnCount(5) + table.setHorizontalHeaderLabels([ + "#", t("title"), t("artist"), t("album"), t("duration") + ]) + table.horizontalHeader().setSectionResizeMode(0, QHeaderView.Fixed) + table.horizontalHeader().setSectionResizeMode(1, QHeaderView.Stretch) + table.horizontalHeader().setSectionResizeMode(2, QHeaderView.Stretch) + table.horizontalHeader().setSectionResizeMode(3, QHeaderView.Stretch) + table.horizontalHeader().setSectionResizeMode(4, QHeaderView.Fixed) + table.setColumnWidth(0, 50) + table.setColumnWidth(4, 80) + table.setSelectionBehavior(QAbstractItemView.SelectRows) + table.setSelectionMode(QAbstractItemView.ExtendedSelection) + table.setAlternatingRowColors(True) + table.setEditTriggers(QAbstractItemView.NoEditTriggers) + table.verticalHeader().setVisible(False) + table.doubleClicked.connect(self._on_track_double_clicked) + table.setContextMenuPolicy(Qt.CustomContextMenu) + table.customContextMenuRequested.connect(self._show_track_context_menu) + + return table + + def refresh_theme(self): + """Refresh all styles using current theme tokens.""" + qss = get_qss + # Main button styles + self.setStyleSheet(qss(self._STYLE_BUTTONS)) + + # Info section labels + self._cover_label.setStyleSheet(qss(self._STYLE_COVER_LABEL)) + self._type_label.setStyleSheet(qss(self._STYLE_TYPE_LABEL)) + self._name_label.setStyleSheet(qss(self._STYLE_NAME_LABEL)) + self._secondary_label.setStyleSheet(qss(self._STYLE_SECONDARY_LABEL)) + self._extra_label.setStyleSheet(qss(self._STYLE_EXTRA_LABEL)) + self._stats_label.setStyleSheet(qss(self._STYLE_STATS_LABEL)) + self._desc_label.setStyleSheet(qss(self._STYLE_DESC_LABEL)) + + # Follow button + self._update_follow_btn_style() + # Favorite button + self._update_fav_btn_style() + + # Page label + self._page_label.setStyleSheet(qss(self._STYLE_PAGE_LABEL)) + + # Albums section + self._albums_section.setStyleSheet(qss(self._STYLE_ALBUMS_SECTION)) + self._albums_title_label.setStyleSheet(qss(self._STYLE_ALBUMS_TITLE)) + self._load_more_albums_btn.setStyleSheet(qss(self._STYLE_LOAD_MORE_ALBUMS)) + + # Scroll area in albums section + scroll_area = self._albums_section.findChild(QScrollArea) + if scroll_area: + scroll_area.setStyleSheet(qss(self._STYLE_SCROLL_AREA)) + + # Albums container + if hasattr(self, '_albums_container'): + self._albums_container.setStyleSheet("background-color: transparent;") + + # Songs section + self._songs_title_label.setStyleSheet(qss(self._STYLE_SONGS_TITLE)) + + # Songs table + self._songs_table.setStyleSheet(qss(self._STYLE_SONGS_TABLE)) + + # Refresh all album cards + for card in self._album_cards: + if hasattr(card, 'refresh_theme'): + card.refresh_theme() + + def load_artist(self, mid: str, name: str = ""): + """Load artist detail.""" + self._detail_type = "artist" + self._use_tracks_list_view = False + self._mid = mid + self._current_page = 1 # Reset to first page + + # Set placeholder info + self._type_label.setText(t("artist")) + self._name_label.setText(name) + self._secondary_label.setText("") + self._extra_label.setText("") + self._stats_label.setText("") + self._set_description("") + + # Show albums section for artist + self._albums_section.show() + + # Show follow button for artist + self._fav_btn.hide() + self._follow_btn.show() + self._is_followed = False + self._update_follow_btn_style() + + # Note: Follow status will be fetched together with detail in batch request + # No need to call _query_follow_status() separately + + self._load_detail() + + def load_album(self, mid: str, name: str = "", singer_name: str = ""): + """Load album detail.""" + self._detail_type = "album" + self._use_tracks_list_view = False + self._mid = mid + self._current_page = 1 # Reset to first page + + # Hide follow button for non-artist views + self._follow_btn.hide() + + # Set placeholder info + self._type_label.setText(t("album")) + self._name_label.setText(name) + self._secondary_label.setText(singer_name) + self._extra_label.setText("") + self._stats_label.setText("") + self._set_description("") + + # Hide albums section for album detail + self._albums_section.hide() + + # Show favorite button for album + self._fav_btn.show() + self._follow_btn.hide() + self._is_faved = False + self._update_fav_btn_style() + + # Note: Fav status will be fetched together with detail in batch request + # No need to call _query_fav_status() separately + + self._load_detail() + + def load_playlist(self, playlist_id: str, title: str = "", creator: str = ""): + """Load playlist detail.""" + self._detail_type = "playlist" + self._use_tracks_list_view = False + self._mid = playlist_id + self._current_page = 1 # Reset to first page + + # Hide follow button for non-artist views + self._follow_btn.hide() + + # Show favorite button for playlist + self._fav_btn.show() + self._is_faved = False + self._update_fav_btn_style() + + # Set placeholder info + self._type_label.setText(t("playlists")) + self._name_label.setText(t(title)) + self._secondary_label.setText(creator) + self._extra_label.setText("") + self._stats_label.setText("") + self._set_description("") + + # Hide albums section for playlist detail + self._albums_section.hide() + + self._load_detail() + + def load_songs_directly(self, songs: List[Dict], title: str = "", cover_url: str = ""): + """ + Load songs directly without API call. + Used for displaying recommendation song lists. + + Args: + songs: List of song dictionaries from API + title: Title for the song list + cover_url: Cover URL for the song list + """ + self._detail_type = "playlist" + self._mid = "" # No playlist ID for direct songs + self._current_page = 1 + self._use_tracks_list_view = True # Use OnlineTracksListView for recommendations + + # Hide follow/fav buttons for recommendation song lists + self._follow_btn.hide() + self._fav_btn.hide() + + # Set info + self._type_label.setText(t("playlists")) + self._name_label.setText(t(title)) + self._secondary_label.setText("") + self._extra_label.setText("") + self._stats_label.setText("") + self._set_description("") + + # Hide albums section + self._albums_section.hide() + + # Load cover if provided + if cover_url: + self._cover_url = cover_url + self._load_cover(cover_url) + + # Parse and display songs directly + self._total_songs = len(songs) + self._page_size = 50 + self._total_pages = 1 # All songs are already loaded + self._tracks = self._parse_songs(songs) + + # Display stats + self._stats_label.setText(f"{len(self._tracks)} {t('songs')}") + + # Update pagination controls (disable since all songs are loaded) + self._update_pagination() + + # Display songs + self._display_songs(self._tracks) + + def _load_detail(self): + """Load detail data.""" + # Increment request ID to invalidate any pending requests + self._request_id = getattr(self, '_request_id', 0) + 1 + current_request_id = self._request_id + + # Clean up old worker if exists + if hasattr(self, '_detail_worker') and self._detail_worker: + if isValid(self._detail_worker) and self._detail_worker.isRunning(): + self._detail_worker.quit() + self._detail_worker.wait() + self._detail_worker.deleteLater() + + self._detail_worker = DetailWorker( + self._service, + self._detail_type, + self._mid, + self._current_page, + self._page_size, + request_id=current_request_id + ) + self._detail_worker.detail_loaded.connect(self._on_detail_loaded, Qt.QueuedConnection) + + # Clean up worker after thread has fully stopped + def on_thread_finished(): + if hasattr(self, '_detail_worker') and self._detail_worker: + self._detail_worker.deleteLater() + self._detail_worker = None + + self._detail_worker.finished.connect(on_thread_finished) + self._detail_worker.start() + + def _on_detail_loaded(self, detail_type: str, data: Optional[Dict], request_id: int): + """Handle detail loaded.""" + # Ignore outdated requests + if request_id != self._request_id: + return + + if not data: + self._name_label.setText(t("detail_not_available")) + self._secondary_label.setText(t("qqmusic_login_required")) + return + + try: + if detail_type == "artist": + self._display_artist_detail(data) + elif detail_type == "album": + self._display_album_detail(data) + elif detail_type == "playlist": + self._display_playlist_detail(data) + except Exception as e: + logger.error(f"Failed to display detail: {e}", exc_info=True) + + def _set_description(self, text: str): + """Display truncated description. Click to show full text in dialog.""" + if not text or not text.strip(): + self._full_description = "" + self._desc_label.setText("") + self._desc_label.setVisible(False) + return + self._full_description = text + text = text.replace("\n", " ") + max_len = 100 + if len(text) > max_len: + self._desc_label.setText(f"{text[:max_len]}...") + else: + self._desc_label.setText(text) + self._desc_label.setVisible(True) + + def _on_desc_clicked(self, event): + """Show full description in a dialog.""" + if not self._full_description: + return + show_information( + self.window(), + self._name_label.text() or t("view_details"), + self._full_description, + ) + + def _display_artist_detail(self, data: Dict): + """Display artist detail.""" + self._name_label.setText(data.get("name", "")) + self._secondary_label.setText(data.get("desc", "")[:100] + "..." if data.get("desc") else "") + self._extra_label.setText("") + self._set_description(data.get("desc", "")) + + # Load artist cover + avatar_url = data.get("avatar", "") + if avatar_url: + self._cover_url = avatar_url + self._load_cover(avatar_url) + + songs = data.get("songs", []) + albums = data.get("albums", []) + total = data.get("total", len(songs)) + page_size = data.get("page_size", 50) + + # Update pagination state + # Only update total_songs on first page (API returns accurate total then) + if self._current_page == 1: + self._total_songs = total + self._page_size = page_size if page_size > 0 else 30 + self._total_pages = ( + self._total_songs + self._page_size - 1) // self._page_size if self._total_songs > 0 else 1 + + self._tracks = self._parse_songs(songs) + + # Display stats showing loaded vs total songs and album count + self._albums_total = data.get("album_count", 0) + stats_parts = [] + if total > len(self._tracks): + stats_parts.append(f"{len(self._tracks)} / {total} {t('songs')}") + else: + stats_parts.append(f"{total} {t('songs')}") + if self._albums_total > 0: + stats_parts.append(f"{self._albums_total} {t('albums')}") + self._stats_label.setText(" · ".join(stats_parts)) + + # Update follow status from batch request response + if "follow_status" in data: + self._is_followed = data["follow_status"] + self._update_follow_btn_style() + + # Update pagination controls + self._update_pagination() + self._display_songs(self._tracks) + self._on_albums_loaded(albums) + + def _update_pagination(self): + """Update pagination controls visibility and state.""" + show_all_actions = self._total_pages > 1 + self._play_all_btn.setVisible(show_all_actions) + self._insert_all_queue_btn.setVisible(show_all_actions) + self._add_all_queue_btn.setVisible(show_all_actions) + + # Show pagination for any detail type with multiple pages + if show_all_actions: + self._pagination_widget.show() + self._page_label.setText(f"{self._current_page} / {self._total_pages}") + self._prev_page_btn.setEnabled(self._current_page > 1) + self._next_page_btn.setEnabled(self._current_page < self._total_pages) + else: + self._pagination_widget.hide() + + def _update_artist_stats(self): + """Update artist stats label with song and album counts.""" + if self._detail_type != "artist": + return + + stats_parts = [] + # Song count + total = self._total_songs + if total > len(self._tracks): + stats_parts.append(f"{len(self._tracks)} / {total} {t('songs')}") + else: + stats_parts.append(f"{total} {t('songs')}") + + # Album count + if self._albums_total > 0: + stats_parts.append(f"{self._albums_total} {t('albums')}") + + self._stats_label.setText(" · ".join(stats_parts)) + + def _on_prev_page(self): + """Handle previous page button click.""" + if self._current_page > 1: + self._current_page -= 1 + self._load_detail() + + def _on_next_page(self): + """Handle next page button click.""" + if self._current_page < self._total_pages: + self._current_page += 1 + self._load_detail() + + def _load_cover(self, url: str): + """Load cover image from URL.""" + from PySide6.QtGui import QPixmap + from PySide6.QtCore import QThread, Signal + import requests + + class CoverLoader(QThread): + loaded = Signal(QPixmap, int) # (pixmap, request_id) + + def __init__(self, url, request_id=0): + super().__init__() + self.url = url + self._request_id = request_id + + def run(self): + try: + # Check disk cache first + image_data = image_cache_get(self.url) + if not image_data: + response = requests.get(self.url, timeout=10) + response.raise_for_status() + image_data = response.content + image_cache_set(self.url, image_data) + pixmap = QPixmap() + if pixmap.loadFromData(image_data): + self.loaded.emit(pixmap, self._request_id) + except Exception as e: + logger.debug(f"Failed to load cover: {e}") + + # Increment cover request ID + self._cover_request_id = getattr(self, '_cover_request_id', 0) + 1 + current_request_id = self._cover_request_id + + self._cover_loader = CoverLoader(url, request_id=current_request_id) + + def on_cover_loaded(pixmap, request_id): + # Ignore outdated requests + if request_id != self._cover_request_id: + return + self._cover_label.setPixmap( + pixmap.scaled(self._cover_label.size(), Qt.KeepAspectRatioByExpanding, Qt.SmoothTransformation) + ) + + self._cover_loader.loaded.connect(on_cover_loaded, Qt.QueuedConnection) + self._cover_loader.start() + + def _on_cover_clicked(self, event): + """Handle cover click to show full size image.""" + if not self._cover_url: + return + + cover_url = self._cover_url + + # Try to get high-res version for y.gtimg.cn URLs + if "y.gtimg.cn" in cover_url: + if "R300x300" in cover_url: + cover_url = cover_url.replace("R300x300", "R800x800") + + # For qpic.y.qq.com (playlist covers), use original URL as-is + # The /600 suffix is already a reasonable size + + self._show_cover_dialog_async(cover_url) + + def _show_cover_dialog_async(self, url: str): + """Show cover image in a dialog (async loading).""" + from PySide6.QtWidgets import QDialog, QVBoxLayout, QLabel + from PySide6.QtGui import QPixmap + + # Create dialog first + dialog = QDialog(self) + dialog.setWindowTitle(self._name_label.text() or t("cover")) + dialog.setWindowFlags(dialog.windowFlags() | Qt.FramelessWindowHint) + layout = QVBoxLayout(dialog) + layout.setContentsMargins(0, 0, 0, 0) + + # Image label with loading state + image_label = QLabel() + image_label.setAlignment(Qt.AlignCenter) + image_label.setStyleSheet(f"background: {current_theme().background_alt};") + image_label.setText(t("loading")) + image_label.setMinimumSize(200, 200) + + # Close on click + dialog.mousePressEvent = lambda e: dialog.close() + + layout.addWidget(image_label) + + # Async load + class FullCoverLoader(QThread): + loaded = Signal(QPixmap) + + def __init__(self, url): + super().__init__() + self.url = url + + def run(self): + try: + import requests + # Check disk cache first + image_data = image_cache_get(self.url) + if not image_data: + response = requests.get(self.url, timeout=10) + response.raise_for_status() + image_data = response.content + image_cache_set(self.url, image_data) + pixmap = QPixmap() + if pixmap.loadFromData(image_data): + self.loaded.emit(pixmap) + except Exception as e: + logger.debug(f"Failed to load cover for dialog: {e}") + + def on_cover_loaded(pixmap): + if dialog.isVisible(): + # Scale to fit screen + screen = self.screen() if self.screen() else None + max_size = 600 + if screen: + max_size = min(screen.availableGeometry().width() - 100, + screen.availableGeometry().height() - 100, + 600) + + if pixmap.width() > max_size or pixmap.height() > max_size: + pixmap = pixmap.scaled(max_size, max_size, + Qt.KeepAspectRatio, + Qt.SmoothTransformation) + + image_label.setPixmap(pixmap) + image_label.setMinimumSize(pixmap.size()) + dialog.setFixedSize(pixmap.size()) + + self._stop_full_cover_loader() + + self._full_cover_loader = FullCoverLoader(url) + self._full_cover_loader.loaded.connect(on_cover_loaded) + self._full_cover_loader.start() + + dialog.exec() + + def _stop_full_cover_loader(self): + """Stop full-cover loader thread cooperatively.""" + loader = getattr(self, "_full_cover_loader", None) + if not loader or not isValid(loader): + self._full_cover_loader = None + return + if loader.isRunning(): + loader.requestInterruption() + loader.quit() + if not loader.wait(1000): + logger.warning("[OnlineDetailView] Full cover loader did not stop in time") + + def _display_album_detail(self, data: Dict): + """Display album detail.""" + self._name_label.setText(data.get("name", "")) + self._secondary_label.setText(data.get("singer", "")) + + # Extra info: company, genre, language, publish date + extra_parts = [] + if data.get("publish_date"): + extra_parts.append(data.get("publish_date", "")[:10]) + if data.get("company"): + extra_parts.append(data.get("company", "")) + if data.get("language"): + extra_parts.append(data.get("language", "")) + if data.get("album_type"): + extra_parts.append(data.get("album_type", "")) + self._extra_label.setText(" · ".join(extra_parts)) + + # Description + self._set_description(data.get("description", "")) + + # Load album cover + cover_url = data.get("cover_url", "") + if not cover_url: + album_mid = data.get("mid", "") + if album_mid: + cover_url = f"https://y.gtimg.cn/music/photo_new/T002R300x300M000{album_mid}.jpg" + if cover_url: + self._cover_url = cover_url + self._load_cover(cover_url) + + songs = data.get("songs", []) + total = data.get("total", len(songs)) + page_size = data.get("page_size", 50) + + # Update pagination state + self._total_songs = total + self._page_size = page_size # Update page_size from response + self._total_pages = (total + page_size - 1) // page_size if total > 0 else 1 + + self._tracks = self._parse_songs(songs) + + # Display stats + if total > len(self._tracks): + self._stats_label.setText(f"{len(self._tracks)} / {total} {t('songs')}") + else: + self._stats_label.setText(f"{len(self._tracks)} {t('songs')}") + + # Update fav status from batch request response + if "fav_status" in data: + self._is_faved = data["fav_status"] + logger.info(f"[OnlineDetailView] Album fav status from batch request: {self._is_faved}") + self._update_fav_btn_style() + + # Update pagination controls + self._update_pagination() + self._display_songs(self._tracks) + + def _display_playlist_detail(self, data: Dict): + """Display playlist detail.""" + self._name_label.setText(data.get("name", "")) + self._secondary_label.setText(data.get("creator", "")) + self._extra_label.setText("") + + # Description + self._set_description(data.get("description", "")) + + # Load playlist cover + cover_url = data.get("cover_url", "") or data.get("cover", "") + if cover_url: + self._cover_url = cover_url + self._load_cover(cover_url) + + songs = data.get("songs", []) + total = data.get("total", len(songs)) + page_size = data.get("page_size", 50) + + # Update pagination state + self._total_songs = total + self._page_size = page_size # Update page_size from response + self._total_pages = (total + page_size - 1) // page_size if total > 0 else 1 + + self._tracks = self._parse_songs(songs) + + # Display stats + if total > len(self._tracks): + self._stats_label.setText(f"{len(self._tracks)} / {total} {t('songs')}") + else: + self._stats_label.setText(f"{len(self._tracks)} {t('songs')}") + + # Update fav status from batch request response + if "fav_status" in data: + self._is_faved = data["fav_status"] + logger.info(f"[OnlineDetailView] Playlist fav status from batch request: {self._is_faved}") + self._update_fav_btn_style() + + # Update pagination controls + self._update_pagination() + self._display_songs(self._tracks) + + def _parse_songs(self, songs: List[Dict]) -> List[OnlineTrack]: + """Parse songs from API response.""" + from .models import OnlineSinger, AlbumInfo + + tracks = [] + for song in songs: + # Parse singers - handle different formats + singers = [] + singer_data = song.get("singer", []) + if isinstance(singer_data, list): + for s in singer_data: + if isinstance(s, dict): + singers.append(OnlineSinger( + mid=s.get("mid", ""), + name=s.get("name", "") + )) + elif isinstance(s, str): + singers.append(OnlineSinger(mid="", name=s)) + elif isinstance(singer_data, dict): + singers.append(OnlineSinger( + mid=singer_data.get("mid", ""), + name=singer_data.get("name", "") + )) + elif isinstance(singer_data, str): + singers.append(OnlineSinger(mid="", name=singer_data)) + + # Parse album - handle different formats + album_data = song.get("album") + if isinstance(album_data, dict): + album = AlbumInfo( + mid=album_data.get("mid", ""), + name=album_data.get("name", "") + ) + elif isinstance(album_data, str): + album = AlbumInfo(mid="", name=album_data) + else: + album = AlbumInfo( + mid=song.get("albummid", song.get("albumMid", "")), + name=song.get("albumname", song.get("albumName", "")) + ) + + track = OnlineTrack( + mid=song.get("mid", song.get("songmid", song.get("songMid", ""))), + id=song.get("id", song.get("songid", song.get("songId"))), + title=song.get("name", song.get("songname", song.get("songName", song.get("title", "")))), + singer=singers, + album=album, + duration=song.get("interval", song.get("duration", 0)) + ) + tracks.append(track) + + return tracks + + def _display_songs(self, songs: List[OnlineTrack]): + """Display songs — use list view for album/recommendations, table for playlist/artist.""" + if self._detail_type == "album" or self._use_tracks_list_view: + self._songs_table.hide() + self._tracks_list_view.show() + self._tracks_list_view.load_tracks(songs) + else: + self._tracks_list_view.hide() + self._songs_table.show() + try: + self._songs_table.setRowCount(len(songs)) + start = (self._current_page - 1) * self._page_size + + for i, song in enumerate(songs): + # Index + self._songs_table.setItem(i, 0, QTableWidgetItem(str(start + i + 1))) + + # Title + self._songs_table.setItem(i, 1, QTableWidgetItem(song.title)) + + # Artist + self._songs_table.setItem(i, 2, QTableWidgetItem(song.singer_name)) + + # Album + self._songs_table.setItem(i, 3, QTableWidgetItem(song.album_name)) + + # Duration + duration_str = format_duration(song.duration) if song.duration else "" + self._songs_table.setItem(i, 4, QTableWidgetItem(duration_str)) + except Exception as e: + logger.error(f"Failed to display songs: {e}", exc_info=True) + + def _load_artist_albums(self, append: bool = False): + """Load artist albums in background. + + Args: + append: If True, append to existing albums; otherwise replace + """ + if self._detail_type != "artist" or not self._mid: + self._albums_section.hide() + return + + # Increment request ID to invalidate any pending requests + self._album_request_id = getattr(self, '_album_request_id', 0) + 1 + current_request_id = self._album_request_id + + begin = self._albums_loaded if append else 0 + number = 10 + + # Store append flag for callback + self._albums_append = append + + # Clean up old worker if exists + if hasattr(self, '_album_list_worker') and self._album_list_worker: + if isValid(self._album_list_worker) and self._album_list_worker.isRunning(): + self._album_list_worker.quit() + self._album_list_worker.wait() + self._album_list_worker.deleteLater() + + self._album_list_worker = AlbumListWorker(self._service, self._mid, number=number, begin=begin, + request_id=current_request_id) + self._album_list_worker.albums_loaded.connect(self._on_albums_loaded, Qt.QueuedConnection) + + # Clean up worker after thread has fully stopped + def on_thread_finished(): + if hasattr(self, '_album_list_worker') and self._album_list_worker: + self._album_list_worker.deleteLater() + self._album_list_worker = None + + self._album_list_worker.finished.connect(on_thread_finished) + self._album_list_worker.start() + + def _on_albums_loaded(self, albums: List[Dict[str, Any]], total: int = 0, request_id: int = 0): + """Handle artist albums loaded. + + Args: + albums: List of album data + total: Total album count from API + request_id: Request ID for validation + """ + # Ignore outdated requests + if request_id != self._album_request_id: + return + + append = getattr(self, '_albums_append', False) + + if not append: + # Clear existing cards + for card in self._album_cards: + self._albums_layout.removeWidget(card) + card.deleteLater() + self._album_cards.clear() + self._albums_loaded = 0 + self._update_artist_stats() + + if not albums: + if not append: + self._albums_section.hide() + self._load_more_albums_btn.hide() + return + + # Create album cards + for album_data in albums: + card = OnlineAlbumCard(album_data) + card.clicked.connect(self._on_album_card_clicked) + self._albums_layout.addWidget(card) + self._album_cards.append(card) + + # Update loaded count - add to existing if appending + self._albums_loaded += len(albums) + + # Update container width + total_width = len(self._album_cards) * (OnlineAlbumCard.CARD_WIDTH + 16) + self._albums_container.setFixedWidth(max(total_width, self.width())) + self._albums_container.setMinimumHeight(200) + + # Force layout update + self._albums_layout.update() + self._albums_container.updateGeometry() + + # Show/hide load more button based on whether there are more albums + if self._albums_loaded < self._albums_total: + self._load_more_albums_btn.show() + else: + self._load_more_albums_btn.hide() + + self._albums_section.show() + self._albums_section.raise_() # Bring to front + + def _on_load_more_albums(self): + """Handle load more albums button click.""" + self._load_artist_albums(append=True) + + def _on_album_card_clicked(self, album: OnlineAlbum): + """Handle album card click.""" + self.album_clicked.emit(album) + + def set_followed(self, is_followed: bool): + """Set follow state and update button.""" + self._is_followed = is_followed + self._update_follow_btn_style() + + def _update_follow_btn_style(self): + """Update follow button text and style.""" + if self._is_followed: + self._follow_btn.setText(t("followed")) + self._follow_btn.setStyleSheet(get_qss(""" + QPushButton { + background: transparent; + color: %text_secondary%; + border: 1px solid %border%; + border-radius: 14px; + font-size: 12px; + padding: 4px 16px; + } + QPushButton:hover { + border-color: #ff4444; + } + """)) + else: + self._follow_btn.setText(t("follow")) + self._follow_btn.setStyleSheet(get_qss(""" + QPushButton { + background: %highlight%; + color: %background%; + border: none; + border-radius: 14px; + font-size: 12px; + font-weight: bold; + padding: 4px 16px; + } + QPushButton:hover { + background: %highlight_hover%; + } + """)) + + def _on_follow_clicked(self): + """Handle follow/unfollow button click.""" + if self._detail_type != "artist" or not self._mid: + return + if self._is_followed: + success = self._service.unfollow_singer(self._mid) + else: + success = self._service.follow_singer(self._mid) + if success: + self._is_followed = not self._is_followed + self._update_follow_btn_style() + + def _update_fav_btn_style(self): + """Update favorite button text and style for album/playlist.""" + if self._is_faved: + self._fav_btn.setText(t("remove_from_qq_favorites")) + self._fav_btn.setStyleSheet(get_qss(""" + QPushButton { + background: transparent; + color: %text_secondary%; + border: 1px solid %border%; + border-radius: 14px; + font-size: 12px; + padding: 4px 16px; + } + QPushButton:hover { + border-color: #ff4444; + } + """)) + else: + self._fav_btn.setText(t("add_to_qq_favorites")) + self._fav_btn.setStyleSheet(get_qss(""" + QPushButton { + background: %highlight%; + color: %background%; + border: none; + border-radius: 14px; + font-size: 12px; + font-weight: bold; + padding: 4px 16px; + } + QPushButton:hover { + background: %highlight_hover%; + } + """)) + + def _on_fav_clicked(self): + """Handle favorite/unfavorite button click for album/playlist.""" + if not self._mid: + return + if self._detail_type == "album": + if self._is_faved: + success = self._service.unfav_album(self._mid) + else: + success = self._service.fav_album(self._mid) + elif self._detail_type == "playlist": + playlist_id = int(self._mid) if self._mid.isdigit() else self._mid + if self._is_faved: + success = self._service.unfav_playlist(playlist_id) + else: + success = self._service.fav_playlist(playlist_id) + else: + return + if success: + self._is_faved = not self._is_faved + self._update_fav_btn_style() + + def _on_play_current(self): + """Play current page tracks.""" + if self._tracks: + self.play_all.emit(self._tracks, 0) + + def _on_insert_current_to_queue(self): + """Insert current page tracks to queue.""" + if self._tracks: + self.insert_all_to_queue.emit(self._tracks) + + def _on_add_current_to_queue(self): + """Add current page tracks to queue.""" + if self._tracks: + self.add_all_to_queue.emit(self._tracks) + + def _on_play_all(self): + """Play all tracks (from all pages).""" + if self._total_songs <= len(self._tracks): + # All tracks already loaded + self.play_all_tracks.emit(self._tracks) + else: + # Need to fetch all tracks + self._fetch_all_tracks(callback=lambda tracks: self.play_all_tracks.emit(tracks)) + + def _on_insert_all_to_queue(self): + """Insert all tracks to queue (from all pages).""" + if self._total_songs <= len(self._tracks): + # All tracks already loaded + self.insert_all_tracks_to_queue.emit(self._tracks) + else: + # Need to fetch all tracks + self._fetch_all_tracks(callback=lambda tracks: self.insert_all_tracks_to_queue.emit(tracks)) + + def _on_add_all_to_queue(self): + """Add all tracks to queue (from all pages).""" + if self._total_songs <= len(self._tracks): + # All tracks already loaded + self.add_all_tracks_to_queue.emit(self._tracks) + else: + # Need to fetch all tracks + self._fetch_all_tracks(callback=lambda tracks: self.add_all_tracks_to_queue.emit(tracks)) + + def _fetch_all_tracks(self, callback): + """ + Fetch all tracks from all pages. + + Args: + callback: Function to call with all tracks when complete + """ + # Show loading state + self._play_all_btn.setEnabled(False) + self._insert_all_queue_btn.setEnabled(False) + self._add_all_queue_btn.setEnabled(False) + self._play_all_btn.setText(t("loading")) + self._insert_all_queue_btn.setText(t("loading")) + self._add_all_queue_btn.setText(t("loading")) + + # Clean up old worker if exists + if hasattr(self, '_all_tracks_worker') and self._all_tracks_worker: + if isValid(self._all_tracks_worker) and self._all_tracks_worker.isRunning(): + self._all_tracks_worker.quit() + self._all_tracks_worker.wait() + self._all_tracks_worker.deleteLater() + + # Start background worker to fetch all tracks + self._all_tracks_worker = AllTracksWorker( + service=self._service, + detail_type=self._detail_type, + mid=self._mid, + total_songs=self._total_songs, + page_size=self._page_size + ) + self._all_tracks_worker.all_tracks_loaded.connect( + lambda tracks: self._on_all_tracks_loaded(tracks, callback) + ) + + # Clean up worker after thread has fully stopped + def on_thread_finished(): + if hasattr(self, '_all_tracks_worker') and self._all_tracks_worker: + self._all_tracks_worker.deleteLater() + self._all_tracks_worker = None + + self._all_tracks_worker.finished.connect(on_thread_finished) + self._all_tracks_worker.start() + + def _on_all_tracks_loaded(self, tracks, callback): + """Handle all tracks loaded.""" + # Restore button state + self._play_all_btn.setEnabled(True) + self._insert_all_queue_btn.setEnabled(True) + self._add_all_queue_btn.setEnabled(True) + self._play_all_btn.setText(t("play_all")) + self._insert_all_queue_btn.setText(t("insert_all_to_queue")) + self._add_all_queue_btn.setText(t("add_all_to_queue")) + + # Call callback with all tracks + callback(tracks) + + def _on_track_double_clicked(self, index): + """Handle track double click on table.""" + row = index.row() + if 0 <= row < len(self._tracks): + self._play_track(self._tracks[row]) + + def _show_track_context_menu(self, pos): + """Show context menu for track on table.""" + selected_rows = self._songs_table.selectionModel().selectedRows() + if not selected_rows: + return + + selected_tracks = [] + for index in sorted(selected_rows, key=lambda x: x.row()): + row = index.row() + if 0 <= row < len(self._tracks): + selected_tracks.append(self._tracks[row]) + + if not selected_tracks: + return + + is_single = len(selected_tracks) == 1 + track = selected_tracks[0] if is_single else None + + menu = QMenu(self) + menu.setStyleSheet(get_qss(self._STYLE_MENU)) + + play_action = menu.addAction(t("play")) + insert_action = menu.addAction(t("insert_to_queue")) + add_action = menu.addAction(t("add_to_queue")) + menu.addSeparator() + add_to_favorites_action = menu.addAction(t("add_to_favorites")) + add_to_playlist_action = menu.addAction(t("add_to_playlist")) + menu.addSeparator() + download_action = menu.addAction(t("download")) + + if is_single: + play_action.triggered.connect(lambda: self._play_track(track)) + insert_action.triggered.connect(lambda: self._insert_track_to_queue(track)) + add_action.triggered.connect(lambda: self._add_track_to_queue(track)) + add_to_favorites_action.triggered.connect(lambda: self._add_track_to_favorites(track)) + add_to_playlist_action.triggered.connect(lambda: self._add_track_to_playlist(track)) + download_action.triggered.connect(lambda: self._download_track(track)) + else: + play_action.triggered.connect(lambda: self._play_tracks(selected_tracks)) + insert_action.triggered.connect(lambda: self._insert_tracks_to_queue(selected_tracks)) + add_action.triggered.connect(lambda: self._add_tracks_to_queue(selected_tracks)) + add_to_favorites_action.triggered.connect(lambda: self._add_tracks_to_favorites(selected_tracks)) + add_to_playlist_action.triggered.connect(lambda: self._add_tracks_to_playlist(selected_tracks)) + download_action.triggered.connect(lambda: self._download_tracks(selected_tracks)) + + menu.exec(self._songs_table.viewport().mapToGlobal(pos)) + + def _on_track_activated(self, track: OnlineTrack): + """Handle track double click from list view.""" + self._play_track(track) + + def _on_list_play_requested(self, tracks: list): + """Handle play requested from list view context menu.""" + if tracks: + self._play_tracks(tracks) + + def _on_list_insert_to_queue(self, tracks: list): + """Handle insert to queue from list view context menu.""" + self.insert_all_to_queue.emit(tracks) + + def _on_list_add_to_queue(self, tracks: list): + """Handle add to queue from list view context menu.""" + self.add_all_to_queue.emit(tracks) + + def _on_list_add_to_playlist(self, tracks: list): + """Handle add to playlist from list view context menu.""" + self._add_tracks_to_playlist(tracks) + + def _on_list_favorites_toggle(self, tracks: list, all_favorited: bool): + """Handle favorites toggle from list view context menu.""" + if all_favorited: + for track in tracks: + self._remove_track_from_favorites(track) + else: + self._add_tracks_to_favorites(tracks) + + def _on_list_qq_fav_toggle(self, tracks: list, all_favorited: bool): + """Handle QQ Music favorites toggle from list view context menu.""" + for track in tracks: + if not track.id: + logger.warning(f"Cannot toggle QQ favorite for track without id: {track.title}") + continue + if all_favorited: + self._service.unfav_song(track.id) + else: + self._service.fav_song(track.id) + + def _on_list_download_requested(self, tracks: list): + """Handle download from list view context menu.""" + for track in tracks: + self._download_track(track) + + def _play_track(self, track: OnlineTrack): + """Play a single track.""" + if self._tracks: + # Find the track index and play all from that track + try: + index = self._tracks.index(track) + self.play_all.emit(self._tracks, index) + except ValueError: + logger.warning(f"Track not found in list: {track.title}") + + def _add_track_to_queue(self, track: OnlineTrack): + """Add track to queue.""" + self.add_all_to_queue.emit([track]) + + def _add_tracks_to_queue(self, tracks: list): + """Add multiple tracks to queue.""" + self.add_all_to_queue.emit(tracks) + + def _insert_track_to_queue(self, track: OnlineTrack): + """Insert track after current playing track.""" + self.insert_all_to_queue.emit([track]) + + def _insert_tracks_to_queue(self, tracks: list): + """Insert multiple tracks after current playing track.""" + self.insert_all_to_queue.emit(tracks) + + def _play_tracks(self, tracks: list): + """Play multiple tracks.""" + if tracks: + self.play_all.emit(tracks, 0) + + def _download_track(self, track: OnlineTrack): + """Download a track.""" + if self._download_service.is_cached(track.mid): + logger.info(f"Track already cached: {track.title}") + return + + # Start download + worker = DownloadWorker(self._download_service, track.mid, track.title) + + # Handle download result + worker.download_finished.connect(self._on_download_finished) + + # Clean up worker after thread has fully stopped + def on_thread_finished(): + if hasattr(self, '_download_workers') and worker in self._download_workers: + self._download_workers.remove(worker) + worker.deleteLater() + + worker.finished.connect(on_thread_finished) + worker.start() + + # Keep reference to prevent garbage collection + if not hasattr(self, '_download_workers'): + self._download_workers = [] + self._download_workers.append(worker) + + def _download_tracks(self, tracks: list): + """Download multiple tracks.""" + for track in tracks: + self._download_track(track) + + def _on_download_finished(self, song_mid: str, local_path: str): + """Handle download finished.""" + if local_path: + logger.info(f"Download completed: {song_mid} -> {local_path}") + else: + logger.warning(f"Download failed: {song_mid}") + + def _add_track_to_favorites(self, track: OnlineTrack): + """Add track to favorites.""" + self._add_tracks_to_favorites([track]) + + def _add_tracks_to_favorites(self, tracks: list): + """Add multiple tracks to favorites.""" + current_bootstrap = bootstrap() + if current_bootstrap is None: + return + favorites_service = current_bootstrap.favorites_service + + added_count = 0 + for track in tracks: + track_id = self._add_online_track_to_library(track) + if track_id: + favorites_service.add_favorite(track_id=track_id) + added_count += 1 + + if added_count > 0: + logger.info(f"[OnlineDetailView] Added {added_count} tracks to favorites") + show_information( + self, + t("success"), + t("added_x_tracks_to_favorites").format(count=added_count) + ) + + def _remove_track_from_favorites(self, track: OnlineTrack): + """Remove a track from favorites.""" + current_bootstrap = bootstrap() + if current_bootstrap is None: + return + library_track = current_bootstrap.library_service.get_track_by_cloud_file_id(track.mid) + if library_track: + current_bootstrap.favorites_service.remove_favorite(track_id=library_track.id) + return + + current_bootstrap.favorites_service.remove_favorite(cloud_file_id=track.mid) + + def _add_track_to_playlist(self, track: OnlineTrack): + """Add track to playlist.""" + self._add_tracks_to_playlist([track]) + + def _add_tracks_to_playlist(self, tracks: list): + """Add multiple tracks to playlist.""" + # Add tracks to library first and collect track IDs + track_ids = [] + for track in tracks: + track_id = self._add_online_track_to_library(track) + if track_id: + track_ids.append(track_id) + + if not track_ids: + return + + add_track_ids_to_playlist(self, track_ids, "[OnlineDetailView]") + + def _add_online_track_to_library(self, track: OnlineTrack): + """Add online track to library, return track_id.""" + current_bootstrap = bootstrap() + if current_bootstrap is None or not current_bootstrap.library_service: + return None + + cover_url = self._get_cover_url(track) + + return current_bootstrap.library_service.add_online_track( + song_mid=track.mid, + title=track.title, + artist=track.singer_name, + album=track.album_name, + duration=float(track.duration), + cover_url=cover_url + ) + + def _get_cover_url(self, track: OnlineTrack) -> str: + """Get cover URL for online track.""" + if track.album and track.album.mid: + return f"https://y.qq.com/music/photo_new/T002R300x300M000{track.album.mid}.jpg" + return "" + + def refresh_ui(self): + """Refresh UI texts after language change.""" + # Update back button + if hasattr(self, '_back_btn'): + self._back_btn.setText("← " + t("back")) + + # Update action buttons + if hasattr(self, '_play_all_btn'): + self._play_all_btn.setText(t("play_all")) + if hasattr(self, '_play_btn'): + self._play_btn.setText(t("play_now")) + if hasattr(self, '_insert_queue_btn'): + self._insert_queue_btn.setText(t("insert_to_queue")) + if hasattr(self, '_add_queue_btn'): + self._add_queue_btn.setText(t("add_to_queue")) + if hasattr(self, '_insert_all_queue_btn'): + self._insert_all_queue_btn.setText(t("insert_all_to_queue")) + if hasattr(self, '_add_all_queue_btn'): + self._add_all_queue_btn.setText(t("add_all_to_queue")) + + # Update follow button + if hasattr(self, '_follow_btn'): + self._follow_btn.setText(t("followed") if self._is_followed else t("follow")) + # Update favorite button + if hasattr(self, '_fav_btn'): + self._fav_btn.setText(t("remove_from_qq_favorites") if self._is_faved else t("add_to_qq_favorites")) + + # Update pagination buttons + if hasattr(self, '_prev_page_btn'): + self._prev_page_btn.setText("← " + t("previous_page")) + if hasattr(self, '_next_page_btn'): + self._next_page_btn.setText(t("next_page") + " →") + + # Update albums section title + if hasattr(self, '_albums_title_label'): + self._albums_title_label.setText(t("albums")) + + # Update songs section title + if hasattr(self, '_songs_title_label'): + self._songs_title_label.setText(t("songs")) + + # Update table headers + if hasattr(self, '_songs_table'): + header = self._songs_table.horizontalHeader() + if header.count() >= 5: + header.model().setHeaderData(0, Qt.Horizontal, "#") + header.model().setHeaderData(1, Qt.Horizontal, t("title")) + header.model().setHeaderData(2, Qt.Horizontal, t("artist")) + header.model().setHeaderData(3, Qt.Horizontal, t("album")) + header.model().setHeaderData(4, Qt.Horizontal, t("duration")) + + +class DownloadWorker(QThread): + """Background worker for downloading online music.""" + + download_finished = Signal(str, str) # (song_mid, local_path) + + def __init__(self, download_service: Any, song_mid: str, song_title: str): + super().__init__() + self._download_service = download_service + self._song_mid = song_mid + self._song_title = song_title + + def run(self): + """Run download.""" + try: + result = self._download_service.download(self._song_mid, self._song_title) + self.download_finished.emit(self._song_mid, result or "") + except Exception as e: + logger.error(f"Download worker error: {e}") + self.download_finished.emit(self._song_mid, "") diff --git a/plugins/builtin/qqmusic/lib/online_grid_view.py b/plugins/builtin/qqmusic/lib/online_grid_view.py new file mode 100644 index 00000000..fc6071f2 --- /dev/null +++ b/plugins/builtin/qqmusic/lib/online_grid_view.py @@ -0,0 +1,711 @@ +""" +Online music grid view for displaying artists/albums/playlists in a grid layout. +Uses QListView + Model/Delegate for high-performance rendering with lazy loading. +""" + +import logging +from collections import OrderedDict +from typing import List, Optional, Any + +from PySide6.QtCore import ( + Qt, Signal, + QAbstractListModel, QModelIndex, QSize, QRect +) +from PySide6.QtGui import QPixmap, QColor, QPainter, QFont, QPen +from PySide6.QtWidgets import ( + QWidget, + QVBoxLayout, + QLabel, + QListView, + QProgressBar, + QStyledItemDelegate, + QStyle, + QPushButton, +) + +from .i18n import t +from .runtime_bridge import ( + current_theme, + get_qss, + http_get_content, + image_cache_get, + image_cache_set, + register_themed_widget, +) + +logger = logging.getLogger(__name__) + + +class OnlineItemModel(QAbstractListModel): + """Model for online item data.""" + + def __init__(self, parent=None): + super().__init__(parent) + self._items: List[Any] = [] + + def rowCount(self, parent=QModelIndex()): + return len(self._items) + + def data(self, index, role=Qt.DisplayRole): + if not index.isValid() or index.row() >= len(self._items): + return None + + item = self._items[index.row()] + + if role == Qt.DisplayRole: + if hasattr(item, 'avatar_url'): + return item.name # OnlineArtist + elif hasattr(item, 'title'): + return item.title # OnlinePlaylist + elif hasattr(item, 'name'): + return item.name # OnlineAlbum + elif role == Qt.UserRole: + return item + + return None + + def set_items(self, items: List[Any]): + self.beginResetModel() + self._items = items + self.endResetModel() + + def get_item(self, row: int) -> Optional[Any]: + if 0 <= row < len(self._items): + return self._items[row] + return None + + def clear(self): + self.beginResetModel() + self._items.clear() + self.endResetModel() + + +class OnlineItemDelegate(QStyledItemDelegate): + """Delegate for rendering online item cards.""" + + # Card size constants + COVER_SIZE = 180 + CARD_WIDTH = 180 + CARD_HEIGHT = 240 + SPACING = 20 + + def __init__(self, data_type: str, parent=None): + """ + Initialize delegate. + + Args: + data_type: Type of data ('singer', 'album', or 'playlist') + parent: Parent widget + """ + super().__init__(parent) + self._data_type = data_type + + # Set border radius based on data type + if data_type == "singer": + self._border_radius = 90 # Circular + else: + self._border_radius = 8 # Rounded rectangle + + self._cover_cache = OrderedDict() # LRU cache for loaded covers + self._cache_max_size = 500 + self._pending_downloads = set() # Track URLs being downloaded + self._executor = None # ThreadPoolExecutor for async downloads + + self._default_cover = self._create_default_cover() + + def _create_default_cover(self) -> QPixmap: + """Create default cover pixmap.""" + theme = current_theme() + + pixmap = QPixmap(self.COVER_SIZE, self.COVER_SIZE) + pixmap.fill(Qt.transparent) + + painter = QPainter(pixmap) + painter.setRenderHint(QPainter.Antialiasing) + + if self._data_type == "singer": + # Circular background for artists with theme color + painter.setBrush(QColor(theme.text_secondary)) + painter.setPen(Qt.NoPen) + painter.drawEllipse(0, 0, self.COVER_SIZE, self.COVER_SIZE) + icon = "\u265A" # Chess queen (person icon) + else: + # Rounded rectangle for albums/playlists with theme color + painter.setBrush(QColor(theme.text_secondary)) + painter.setPen(Qt.NoPen) + painter.drawRoundedRect(0, 0, self.COVER_SIZE, self.COVER_SIZE, 8, 8) + icon = "\u266B" # Music note + + # Draw icon with contrasting theme color + painter.setPen(QColor(theme.text)) + font = QFont() + font.setPixelSize(60 if self._data_type == "singer" else 80) + painter.setFont(font) + painter.drawText( + QRect(0, 0, self.COVER_SIZE, self.COVER_SIZE), + Qt.AlignCenter, icon + ) + painter.end() + return pixmap + + def _load_cover(self, item) -> QPixmap: + """Load cover from URL with caching.""" + cover_url = None + + if hasattr(item, 'avatar_url'): + # OnlineArtist + cover_url = item.avatar_url + elif hasattr(item, 'cover_url'): + # OnlineAlbum or OnlinePlaylist + cover_url = item.cover_url + + if not cover_url: + return self._default_cover + + if cover_url in self._cover_cache: + self._cover_cache.move_to_end(cover_url) + return self._cover_cache[cover_url] + + # Start async download if not already pending + if cover_url not in self._pending_downloads: + self._pending_downloads.add(cover_url) + self._download_cover_async(cover_url) + + return self._default_cover + + def _download_cover_async(self, url: str): + """Download cover image asynchronously with disk caching.""" + from concurrent.futures import ThreadPoolExecutor + + try: + # Check disk cache first + cached_data = image_cache_get(url) + if cached_data: + self._load_cached_cover(url, cached_data) + return + + def download(): + try: + return http_get_content(url, timeout=5, headers={ + 'Referer': 'https://y.qq.com/' + }) + except Exception as e: + logger.warning(f"Failed to download cover from {url}: {e}") + return None + + # Reuse single executor instance + if self._executor is None: + self._executor = ThreadPoolExecutor(max_workers=1) + + future = self._executor.submit(download) + + # Check completion after a short delay + from PySide6.QtCore import QTimer + def check_download(): + if future.done(): + image_data = future.result() + if image_data: + # Save to disk cache + image_cache_set(url, image_data) + self._load_cached_cover(url, image_data) + self._pending_downloads.discard(url) + else: + # Check again later + QTimer.singleShot(100, check_download) + + QTimer.singleShot(100, check_download) + + except Exception as e: + logger.warning(f"Failed to start cover download: {e}") + self._pending_downloads.discard(url) + + def _load_cached_cover(self, url: str, image_data: bytes): + """Load cover from cached data.""" + pixmap = QPixmap() + if pixmap.loadFromData(image_data): + # Scale image + scaled = pixmap.scaled( + self.COVER_SIZE, self.COVER_SIZE, + Qt.KeepAspectRatioByExpanding, + Qt.SmoothTransformation + ) + + # Apply mask based on data type + if self._data_type == "singer": + # Create circular mask + circular = QPixmap(self.COVER_SIZE, self.COVER_SIZE) + circular.fill(Qt.transparent) + + painter = QPainter(circular) + painter.setRenderHint(QPainter.Antialiasing) + painter.setBrush(Qt.white) + painter.setPen(Qt.NoPen) + painter.drawEllipse(0, 0, self.COVER_SIZE, self.COVER_SIZE) + painter.setCompositionMode(QPainter.CompositionMode_SourceIn) + painter.drawPixmap(0, 0, scaled) + painter.end() + + final = circular + else: + # Use rounded rectangle mask + masked = QPixmap(self.COVER_SIZE, self.COVER_SIZE) + masked.fill(Qt.transparent) + + painter = QPainter(masked) + painter.setRenderHint(QPainter.Antialiasing) + painter.setBrush(Qt.white) + painter.setPen(Qt.NoPen) + painter.drawRoundedRect(0, 0, self.COVER_SIZE, self.COVER_SIZE, 8, 8) + painter.setCompositionMode(QPainter.CompositionMode_SourceIn) + painter.drawPixmap(0, 0, scaled) + painter.end() + + final = masked + + self._cover_cache[url] = final + if len(self._cover_cache) > self._cache_max_size: + self._cover_cache.popitem(last=False) + + # Trigger repaint + if self.parent(): + widget = self.parent() + if hasattr(widget, 'viewport'): + widget.viewport().update() + + def sizeHint(self, option, index): + return QSize(self.CARD_WIDTH, self.CARD_HEIGHT) + + def paint(self, painter, option, index): + item = index.data(Qt.UserRole) + if not item: + logger.warning(f"[OnlineItemDelegate] paint called but item is None, index={index.row()}") + return + + theme = current_theme() + + rect = option.rect + is_hovered = option.state & QStyle.State_MouseOver + + # Debug log for first item + # if index.row() == 0: + # if isinstance(item, OnlineArtist): + # logger.info(f"[OnlineItemDelegate] Painting artist: {item.name}, rect: {rect.x()}, {rect.y()}, {rect.width()}, {rect.height()}") + # logger.info(f"[OnlineItemDelegate] Name rect will be: x={rect.x() + 4}, y={rect.y() + self.COVER_SIZE + 8}, w={rect.width() - 8}, h=36") + # elif isinstance(item, OnlineAlbum): + # logger.info(f"[OnlineItemDelegate] Painting album: {item.name}") + # elif isinstance(item, OnlinePlaylist): + # logger.info(f"[OnlineItemDelegate] Painting playlist: {item.title}") + + # Draw cover + cover = self._load_cover(item) + cover_x = rect.x() + (rect.width() - self.COVER_SIZE) // 2 + cover_y = rect.y() + + # Draw highlight on hover + if is_hovered: + painter.setRenderHint(QPainter.Antialiasing) + + if self._data_type == "singer": + # Circular border for artists + painter.setPen(QPen(QColor(theme.highlight_hover), 3)) + painter.setBrush(Qt.NoBrush) + painter.drawEllipse(cover_x - 2, cover_y - 2, self.COVER_SIZE + 4, self.COVER_SIZE + 4) + else: + # Rounded background for albums/playlists + bg_rect = QRect( + cover_x - 4, + cover_y - 4, + self.COVER_SIZE + 8, + self.CARD_HEIGHT - 40 + ) + painter.setPen(Qt.NoPen) + # Use semi-transparent background_hover for hover background + hover_bg = QColor(theme.background_hover) + hover_bg.setAlpha(200) + painter.setBrush(hover_bg) + painter.drawRoundedRect(bg_rect, 12, 12) + + # Border + painter.setPen(QPen(QColor(theme.highlight_hover), 2)) + painter.setBrush(Qt.NoBrush) + painter.drawRoundedRect(cover_x, cover_y, self.COVER_SIZE, self.COVER_SIZE, 4, 4) + + painter.drawPixmap(cover_x, cover_y, cover) + + # Draw text based on item type + painter.setPen(QColor(theme.text)) + font = QFont() + font.setPixelSize(13) + font.setBold(True) + painter.setFont(font) + + # Get name and alignment based on type (duck-typing to support + # different model class sources) + if hasattr(item, 'avatar_url'): + # OnlineArtist + name = item.name + name_align = Qt.AlignHCenter | Qt.TextWordWrap + elif hasattr(item, 'title'): + # OnlinePlaylist + name = item.title + name_align = Qt.AlignLeft | Qt.TextWordWrap + elif hasattr(item, 'name'): + # OnlineAlbum + name = item.name + name_align = Qt.AlignLeft | Qt.TextWordWrap + else: + name = "Unknown" + name_align = Qt.AlignLeft | Qt.TextWordWrap + + name_rect = QRect( + rect.x() + 4, + rect.y() + self.COVER_SIZE + 8, + rect.width() - 8, + 36 + ) + painter.drawText(name_rect, name_align, name) + + # Draw subtitle + painter.setPen(QColor(theme.text_secondary)) + font.setBold(False) + font.setPixelSize(11) + painter.setFont(font) + + if hasattr(item, 'avatar_url'): + # OnlineArtist + if item.song_count or item.album_count: + subtitle = f"{item.song_count} {t('tracks')} • {item.album_count} {t('albums')}" + elif hasattr(item, 'fan_count') and item.fan_count: + if item.fan_count >= 10000: + subtitle = f"{item.fan_count / 10000:.1f}{t('ten_thousand')} {t('fans')}" + else: + subtitle = f"{item.fan_count:,} {t('fans')}" + else: + subtitle = "" + align = Qt.AlignHCenter + elif hasattr(item, 'title'): + # OnlinePlaylist + play_str = self._format_play_count(item.play_count) if item.play_count else "" + parts = [] + if item.song_count: + parts.append(f"{item.song_count} {t('tracks')}") + if play_str: + parts.append(play_str) + subtitle = " • ".join(parts) if parts else "" + align = Qt.AlignLeft + elif hasattr(item, 'name'): + # OnlineAlbum + subtitle = getattr(item, 'singer_name', '') + align = Qt.AlignLeft + else: + subtitle = "" + align = Qt.AlignLeft + + if subtitle: + subtitle_rect = QRect( + rect.x() + 4, + rect.y() + self.COVER_SIZE + 44, + rect.width() - 8, + 20 + ) + painter.drawText(subtitle_rect, align, subtitle) + + def _format_play_count(self, count: int) -> str: + """Format play count to human-readable string.""" + if count >= 100_000_000: + return f"{count / 100_000_000:.1f}亿" + elif count >= 10_000: + return f"{count / 10_000:.1f}万" + elif count > 0: + return str(count) + return "" + + def clear_cache(self): + """Clear cover cache and pending downloads.""" + self._cover_cache.clear() + self._pending_downloads.clear() + if self._executor: + self._executor.shutdown(wait=False) + self._executor = None + + def refresh_theme(self): + """Refresh default cover when theme changes.""" + self._default_cover = self._create_default_cover() + + +class OnlineGridView(QWidget): + """ + Grid view for online music items (artists/albums/playlists). + Supports lazy loading and custom delegate rendering. + """ + + item_clicked = Signal(object) + load_more_requested = Signal() + + _STYLE_MAIN = """ + background-color: %background%; + """ + _STYLE_LIST_VIEW = """ + QListView { + background-color: %background%; + border: none; + } + QListView::item { + background: transparent; + } + QScrollBar:vertical { + background-color: %background%; + width: 12px; + } + QScrollBar::handle:vertical { + background-color: %border%; + border-radius: 6px; + min-height: 30px; + } + QScrollBar::handle:vertical:hover { + background-color: %text_secondary%; + } + """ + _STYLE_LOAD_MORE_BTN = """ + QPushButton { + background: %background_hover%; + color: %highlight%; + border: 1px solid %highlight%; + border-radius: 20px; + font-size: 14px; + padding: 0 20px; + } + QPushButton:hover { + background: %highlight%; + color: %background%; + } + """ + _STYLE_PROGRESS_BAR = """ + QProgressBar { + background-color: %background_hover%; + border: none; + border-radius: 2px; + } + QProgressBar::chunk { + background-color: %highlight%; + border-radius: 2px; + } + """ + + def __init__(self, data_type: str, parent=None): + """ + Initialize grid view. + + Args: + data_type: Type of data ('singer', 'album', or 'playlist') + parent: Parent widget + """ + super().__init__(parent) + self._data_type = data_type + self._items: List[Any] = [] + self._data_loaded = False + self._pending_data: Optional[List[Any]] = None + + self._setup_ui() + self._connect_signals() + + # Register with theme system + register_themed_widget(self) + + def showEvent(self, event): + """Load data when view is first shown (lazy loading).""" + super().showEvent(event) + if not self._data_loaded and self._pending_data: + self._do_load(self._pending_data) + + def _setup_ui(self): + """Set up the grid view UI.""" + theme = current_theme() + self.setStyleSheet(f"background-color: {theme.background};") + self.setMouseTracking(True) + + layout = QVBoxLayout(self) + layout.setContentsMargins(0, 0, 0, 0) + layout.setSpacing(0) + + # List view + self._list_view = QListView() + self._list_view.setViewMode(QListView.IconMode) + self._list_view.setResizeMode(QListView.Adjust) + self._list_view.setMovement(QListView.Static) + self._list_view.setSelectionMode(QListView.SingleSelection) + self._list_view.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOff) + self._list_view.setVerticalScrollMode(QListView.ScrollPerPixel) + self._list_view.setMouseTracking(True) + + # Model and delegate + self._model = OnlineItemModel(self) + self._delegate = OnlineItemDelegate(self._data_type, self._list_view) + self._list_view.setModel(self._model) + self._list_view.setItemDelegate(self._delegate) + + # Set grid size + self._list_view.setGridSize(QSize( + OnlineItemDelegate.CARD_WIDTH + OnlineItemDelegate.SPACING, + OnlineItemDelegate.CARD_HEIGHT + OnlineItemDelegate.SPACING + )) + + layout.addWidget(self._list_view) + self._list_view.hide() # Hide until data is loaded + + # Load more button + self._load_more_btn = QPushButton() + self._load_more_btn.setText(t("load_more")) + self._load_more_btn.setCursor(Qt.PointingHandCursor) + self._load_more_btn.setFixedHeight(40) + self._load_more_btn.clicked.connect(self._on_load_more_clicked) + self._load_more_btn.hide() + layout.addWidget(self._load_more_btn) + + # Loading indicator + self._loading = self._create_loading_indicator() + layout.addWidget(self._loading) + self._loading.hide() # Hide initially + + def _create_loading_indicator(self) -> QWidget: + """Create loading indicator.""" + widget = QWidget() + layout = QVBoxLayout(widget) + layout.setAlignment(Qt.AlignCenter) + + progress = QProgressBar() + progress.setRange(0, 0) # Indeterminate + progress.setFixedSize(200, 4) + layout.addWidget(progress) + + self._loading_label = QLabel(t("loading")) + layout.addWidget(self._loading_label) + + return widget + + def _connect_signals(self): + """Connect signals.""" + self._list_view.clicked.connect(self._on_item_clicked) + self._list_view.entered.connect(self._on_item_entered) + + def _on_item_entered(self, index): + """Handle item entered for hover effect.""" + self._list_view.viewport().setCursor(Qt.PointingHandCursor) + + def _on_item_clicked(self, index: QModelIndex): + """Handle item click.""" + item = index.data(Qt.UserRole) + if item: + self.item_clicked.emit(item) + + def _on_load_more_clicked(self): + """Handle load more button click.""" + self.load_more_requested.emit() + + def load_data(self, items: List[Any]): + """ + Load data into the view with lazy loading. + + Args: + items: List of online items to display + """ + self._data_loaded = False + self._pending_data = items + + if self.isVisible(): + # Show loading indicator + self._loading.show() + self._list_view.hide() + # Use small delay to allow UI to update + from PySide6.QtCore import QTimer + QTimer.singleShot(50, lambda: self._do_load(items)) + + def _do_load(self, items: List[Any]): + """Actually load data into the view.""" + self._items = items + self._data_loaded = True + self._model.set_items(items) + self._loading.hide() + self._list_view.show() + + def append_data(self, items: List[Any]): + """ + Append more items to existing data (for load more functionality). + + Args: + items: Additional items to append + """ + if not items: + return + + self._items.extend(items) + self._model.set_items(self._items) + self._loading.hide() + self._list_view.show() + + def set_has_more(self, has_more: bool): + """ + Set whether there are more items to load. + + Args: + has_more: True if more items can be loaded + """ + if has_more: + self._load_more_btn.show() + else: + self._load_more_btn.hide() + + def show_loading(self): + """Show loading indicator.""" + self._loading.show() + self._list_view.hide() + self._load_more_btn.hide() + + def hide_loading(self): + """Hide loading indicator.""" + self._loading.hide() + + def clear(self): + """Clear all data from the view.""" + self._items.clear() + self._data_loaded = False + self._pending_data = None + self._model.clear() + self._delegate.clear_cache() + self._load_more_btn.hide() + + def refresh_ui(self): + """Refresh UI (for language changes).""" + # Update load more button text + if hasattr(self, '_load_more_btn'): + self._load_more_btn.setText(t("load_more")) + # Update loading label text + if hasattr(self, '_loading_label'): + self._loading_label.setText(t("loading")) + + def refresh_theme(self): + """Refresh all styles using current theme tokens.""" + theme = current_theme() + + # Main widget + self.setStyleSheet(get_qss(self._STYLE_MAIN)) + + # List view + self._list_view.setStyleSheet(get_qss(self._STYLE_LIST_VIEW)) + + # Load more button + self._load_more_btn.setStyleSheet(get_qss(self._STYLE_LOAD_MORE_BTN)) + + # Progress bar + if hasattr(self, '_loading'): + progress = self._loading.findChild(QProgressBar) + if progress: + progress.setStyleSheet(get_qss(self._STYLE_PROGRESS_BAR)) + + # Loading label + self._loading_label.setStyleSheet( + f"color: {theme.text_secondary}; font-size: 14px;" + ) + + # Refresh delegate's default cover + self._delegate.refresh_theme() diff --git a/plugins/builtin/qqmusic/lib/online_music_view.py b/plugins/builtin/qqmusic/lib/online_music_view.py new file mode 100644 index 00000000..dfcf42a9 --- /dev/null +++ b/plugins/builtin/qqmusic/lib/online_music_view.py @@ -0,0 +1,3430 @@ +""" +Legacy online music view kept only as a compatibility layer during plugin migration. +""" + +import logging +from typing import Optional, List, Dict, Any + +from PySide6.QtCore import Qt, Signal, QThread, QTimer, QStringListModel, QPoint, QEvent +from PySide6.QtGui import QColor, QBrush +from PySide6.QtWidgets import ( + QWidget, + QVBoxLayout, + QHBoxLayout, + QLabel, + QPushButton, + QLineEdit, + QTabBar, + QTableWidget, + QTableWidgetItem, + QHeaderView, + QStackedWidget, + QAbstractItemView, + QMenu, + QListWidget, + QListWidgetItem, + QFrame, + QCompleter, + QApplication, +) +from shiboken6 import isValid + +from .i18n import t +from .i18n import get_language, set_language +from .models import ( + OnlineTrack, OnlineArtist, OnlineAlbum, OnlinePlaylist, + SearchResult, SearchType, +) +from .online_detail_view import OnlineDetailView +from .online_grid_view import OnlineGridView +from .online_tracks_list_view import OnlineTracksListView +from .recommend_card import RecommendSection +from .runtime_bridge import ( + IconName, + add_track_ids_to_playlist, + bootstrap, + create_online_download_service, + create_online_music_service, + create_qqmusic_login_dialog, + create_qqmusic_service, + current_theme, + event_bus, + format_duration, + get_icon, + get_qss, + register_themed_widget, + show_information, + show_warning, +) + + +class MessageDialog: + information = staticmethod(show_information) + warning = staticmethod(show_warning) + + +class CustomQCompleter(QCompleter): + """自定义QCompleter用于搜索建议.""" + + _STYLE_POPUP = """ + QListView { + background-color: %background_hover%; + border: 1px solid %border%; + border-radius: 8px; + color: %text%; + selection-background-color: %highlight%; + selection-color: %background%; + outline: none; + } + QListView::item { + padding: 8px 12px; + border-bottom: 1px solid %border%; + } + QListView::item:selected { + background-color: %highlight%; + color: %background%; + } + QListView::item:hover { + background-color: %border%; + } + """ + + def __init__(self, parent=None): + super().__init__(parent) + # Set themed popup style + self._apply_theme() + + def _apply_theme(self): + """Apply themed styles to popup.""" + self.popup().setStyleSheet(get_qss(self._STYLE_POPUP)) + + def refresh_theme(self): + """Refresh popup styles.""" + self._apply_theme() + +logger = logging.getLogger(__name__) + + +class SearchWorker(QThread): + """Background worker for searching.""" + + search_completed = Signal(object) # SearchResult + search_failed = Signal(str) + + def __init__(self, service: Any, keyword: str, + search_type: str, page: int = 1, page_size: int = 50): + super().__init__() + self._service = service + self._keyword = keyword + self._search_type = search_type + self._page = page + self._page_size = page_size + + def run(self): + try: + result = self._service.search( + self._keyword, + self._search_type, + self._page, + self._page_size + ) + self.search_completed.emit(result) + except Exception as e: + self.search_failed.emit(str(e)) + + +class TopListWorker(QThread): + """Background worker for loading top lists.""" + + top_list_loaded = Signal(list) # List of top lists + top_songs_loaded = Signal(int, list) # (top_id, list of tracks) + + def __init__(self, service: Any, top_id: Optional[int] = None): + super().__init__() + self._service = service + self._top_id = top_id + + def run(self): + try: + if self._top_id is None: + # Get list of top lists + top_lists = self._service.get_top_lists() + self.top_list_loaded.emit(top_lists) + else: + # Get songs for specific top list + songs = self._service.get_top_list_songs(self._top_id) + self.top_songs_loaded.emit(self._top_id, songs) + except Exception as e: + logger.error(f"Failed to load top list: {e}") + + +class CompletionWorker(QThread): + """Background worker for search completion.""" + + completion_ready = Signal(list) # List of completion suggestions + + def __init__(self, qqmusic_service, keyword: str): + super().__init__() + self._qqmusic_service = qqmusic_service + self._keyword = keyword + + def run(self): + try: + # Try to get completion suggestions + if self._qqmusic_service: + suggestions = self._qqmusic_service.complete(self._keyword) + self.completion_ready.emit(suggestions) + else: + # No QQ Music service configured + logger.debug("No QQ Music service available for completion") + self.completion_ready.emit([]) + except Exception as e: + logger.error(f"Search completion failed: {e}") + self.completion_ready.emit([]) + + +class HotkeyWorker(QThread): + """Background worker for fetching hot search keywords.""" + + hotkey_ready = Signal(list) # List of hotkey suggestions + + def __init__(self, qqmusic_service): + super().__init__() + self._qqmusic_service = qqmusic_service + + def run(self): + try: + if self._qqmusic_service: + hotkeys = self._qqmusic_service.get_hotkey() + self.hotkey_ready.emit(hotkeys) + else: + logger.debug("No QQ Music service available for hotkey") + self.hotkey_ready.emit([]) + except Exception as e: + logger.error(f"Get hotkey failed: {e}") + self.hotkey_ready.emit([]) + + +class RecommendWorker(QThread): + """Background worker for fetching recommendations.""" + + recommend_ready = Signal(str, list) # (recommend_type, list of recommendations) + + def __init__(self, qqmusic_service, recommend_type: str): + super().__init__() + self._qqmusic_service = qqmusic_service + self._recommend_type = recommend_type + + def run(self): + try: + if not self._qqmusic_service: + self.recommend_ready.emit(self._recommend_type, []) + return + + result = [] + if self._recommend_type == "home_feed": + result = self._qqmusic_service.get_home_feed() + elif self._recommend_type == "guess": + result = self._qqmusic_service.get_guess_recommend() + elif self._recommend_type == "radar": + result = self._qqmusic_service.get_radar_recommend() + elif self._recommend_type == "songlist": + result = self._qqmusic_service.get_recommend_songlist() + elif self._recommend_type == "newsong": + result = self._qqmusic_service.get_recommend_newsong() + + self.recommend_ready.emit(self._recommend_type, result) + except Exception as e: + logger.error(f"Get recommendation {self._recommend_type} failed: {e}") + self.recommend_ready.emit(self._recommend_type, []) + + +class FavWorker(QThread): + """Background worker for loading favorites.""" + + fav_ready = Signal(str, list) # (fav_type, list of items) + + def __init__(self, qqmusic_service, fav_type: str, page: int = 1, num: int = 30): + super().__init__() + self._qqmusic_service = qqmusic_service + self._fav_type = fav_type + self._page = page + self._num = num + + def run(self): + try: + if not self._qqmusic_service: + self.fav_ready.emit(self._fav_type, []) + return + result = [] + if self._fav_type == "fav_songs": + result = self._qqmusic_service.get_my_fav_songs(page=self._page, num=self._num) + elif self._fav_type == "created_playlists": + result = self._qqmusic_service.get_my_created_songlists() + elif self._fav_type == "fav_playlists": + result = self._qqmusic_service.get_my_fav_songlists(page=self._page, num=self._num) + elif self._fav_type == "fav_albums": + result = self._qqmusic_service.get_my_fav_albums(page=self._page, num=self._num) + elif self._fav_type == "followed_singers": + result = self._qqmusic_service.get_followed_singers(page=self._page, size=self._num) + self.fav_ready.emit(self._fav_type, result) + except Exception as e: + logger.error(f"Get favorites {self._fav_type} failed: {e}") + self.fav_ready.emit(self._fav_type, []) + + +class HotkeyPopup(QWidget): + """Popup widget for displaying hot search keywords - autocomplete style.""" + + hotkey_clicked = Signal(str) # Emitted when a hotkey is clicked + clear_history_requested = Signal() # Emitted when clear history is requested + delete_history_requested = Signal(str) # Emitted when delete a history item is requested + + _STYLE_CONTAINER = """ + #hotkeyContainer { + background-color: %background_hover%; + border: 1px solid %border%; + border-radius: 8px; + } + """ + _STYLE_TITLE = """ + QLabel { + color: %highlight%; + font-size: 13px; + font-weight: bold; + padding: 10px 12px 6px 12px; + } + """ + _STYLE_TITLE_NO_PADDING = """ + QLabel { + color: %highlight%; + font-size: 13px; + font-weight: bold; + } + """ + _STYLE_CLEAR_BTN = """ + QPushButton { + color: %text_secondary%; + font-size: 12px; + border: none; + padding: 2px 8px; + background: transparent; + } + QPushButton:hover { + color: %highlight%; + text-decoration: underline; + } + """ + _STYLE_SEPARATOR = "background-color: %border%; border: none; max-height: 1px;" + _STYLE_HISTORY_LABEL = """ + QLabel { + color: %text%; + font-size: 13px; + background: transparent; + } + """ + _STYLE_DELETE_BTN = """ + QPushButton { + color: %text_secondary%; + font-size: 12px; + border: none; + padding: 2px 8px; + background: transparent; + } + QPushButton:hover { + color: #ff4444; + text-decoration: underline; + } + """ + _STYLE_HISTORY_ITEM = """ + QWidget { + background-color: transparent; + border-radius: 4px; + } + QWidget:hover { + background-color: %border%; + } + """ + _STYLE_HOTKEY_ITEM = """ + QLabel { + color: %text%; + font-size: 13px; + padding: 8px 12px; + border-radius: 4px; + } + QLabel:hover { + background-color: %border%; + } + """ + + def __init__(self, parent=None): + super().__init__(parent) + self.setWindowFlags(Qt.Tool | Qt.FramelessWindowHint | Qt.WindowStaysOnTopHint) + self.setAttribute(Qt.WA_TranslucentBackground) + self.setAttribute(Qt.WA_ShowWithoutActivating) + + self._setup_ui() + + # Register with theme system + register_themed_widget(self) + + def _setup_ui(self): + """Setup UI components.""" + self._main_layout = QVBoxLayout(self) + self._main_layout.setContentsMargins(0, 0, 0, 0) + + self._container = QWidget() + self._container.setObjectName("hotkeyContainer") + self._container_layout = QVBoxLayout(self._container) + self._container_layout.setContentsMargins(0, 0, 0, 0) + self._container_layout.setSpacing(0) + + self._main_layout.addWidget(self._container) + + # Apply initial theme + self.refresh_theme() + + def refresh_theme(self): + """Refresh all styles using current theme tokens.""" + # Container + self._container.setStyleSheet(get_qss(self._STYLE_CONTAINER)) + + def set_hotkeys(self, hotkeys: List[Dict[str, Any]]): + """Set hotkey list.""" + self._clear_container() + + # Title + title = QLabel(f"🔥 {t('hot_search')}") + title.setStyleSheet(get_qss(self._STYLE_TITLE)) + self._container_layout.addWidget(title) + + # Hotkey items + for item in hotkeys[:10]: + title_text = item.get('title', '') + query = item.get('query', title_text) + if not title_text: + continue + self._add_hotkey_item(title_text, query) + + self._adjust_size() + + def set_search_history(self, history: List[str]): + """Set search history list.""" + self._clear_container() + + # Title with clear button + title_layout = QHBoxLayout() + title_layout.setContentsMargins(12, 10, 12, 6) + + title = QLabel(f"📝 {t('search_history')}") + title.setStyleSheet(get_qss(self._STYLE_TITLE_NO_PADDING)) + title_layout.addWidget(title) + + title_layout.addStretch() + + clear_btn = QPushButton(t("clear_all")) + clear_btn.setStyleSheet(get_qss(self._STYLE_CLEAR_BTN)) + clear_btn.setCursor(Qt.PointingHandCursor) + clear_btn.clicked.connect(self._on_clear_clicked) + title_layout.addWidget(clear_btn) + + title_widget = QWidget() + title_widget.setLayout(title_layout) + self._container_layout.addWidget(title_widget) + + # History items + for keyword in history: + if not keyword: + continue + self._add_history_item(keyword) + + self._adjust_size() + + def set_combined(self, history: List[str], hotkeys: List[Dict[str, Any]]): + """Set both search history and hotkeys in one popup.""" + self._clear_container() + + # Add search history section + if history: + # Title with clear button + title_layout = QHBoxLayout() + title_layout.setContentsMargins(12, 10, 12, 6) + + title = QLabel(f"📝 {t('search_history')}") + title.setStyleSheet(get_qss(self._STYLE_TITLE_NO_PADDING)) + title_layout.addWidget(title) + + title_layout.addStretch() + + clear_btn = QPushButton(t("clear_all")) + clear_btn.setStyleSheet(get_qss(self._STYLE_CLEAR_BTN)) + clear_btn.setCursor(Qt.PointingHandCursor) + clear_btn.clicked.connect(self._on_clear_clicked) + title_layout.addWidget(clear_btn) + + title_widget = QWidget() + title_widget.setLayout(title_layout) + self._container_layout.addWidget(title_widget) + + for keyword in history: + if not keyword: + continue + self._add_history_item(keyword) + + # Add separator if both sections exist + if history and hotkeys: + separator = QFrame() + separator.setFrameShape(QFrame.HLine) + separator.setStyleSheet(get_qss(self._STYLE_SEPARATOR)) + self._container_layout.addWidget(separator) + + # Add hot search section + if hotkeys: + # Title + hotkey_title = QLabel(f"🔥 {t('hot_search')}") + hotkey_title.setStyleSheet(get_qss(self._STYLE_TITLE)) + self._container_layout.addWidget(hotkey_title) + + for item in hotkeys[:5]: # Limit to 5 hotkeys when combined + title_text = item.get('title', '') + query = item.get('query', title_text) + if not title_text: + continue + self._add_hotkey_item(title_text, query) + + self._adjust_size() + + def _add_history_item(self, keyword: str): + """Add a history item with delete button.""" + item_widget = QWidget() + item_layout = QHBoxLayout(item_widget) + item_layout.setContentsMargins(12, 4, 8, 4) + item_layout.setSpacing(8) + + # Keyword label + label = QLabel(keyword) + label.setStyleSheet(get_qss(self._STYLE_HISTORY_LABEL)) + item_layout.addWidget(label) + + item_layout.addStretch() + + # Delete button - same style as clear button + delete_btn = QPushButton(t("delete")) + delete_btn.setStyleSheet(get_qss(self._STYLE_DELETE_BTN)) + delete_btn.setCursor(Qt.PointingHandCursor) + delete_btn.clicked.connect(lambda: self._on_delete_clicked(keyword)) + item_layout.addWidget(delete_btn) + + item_widget.setStyleSheet(get_qss(self._STYLE_HISTORY_ITEM)) + item_widget.setCursor(Qt.PointingHandCursor) + item_widget.mousePressEvent = lambda e: self._on_item_clicked(keyword) + + self._container_layout.addWidget(item_widget) + + def _add_hotkey_item(self, title: str, query: str): + """Add a hotkey item.""" + label = QLabel(f" {title}") + label.setStyleSheet(get_qss(self._STYLE_HOTKEY_ITEM)) + label.setCursor(Qt.PointingHandCursor) + label.mousePressEvent = lambda e: self._on_item_clicked(query) + + self._container_layout.addWidget(label) + + def _clear_container(self): + """Clear all items from container.""" + while self._container_layout.count(): + item = self._container_layout.takeAt(0) + if item.widget(): + item.widget().deleteLater() + + def _on_item_clicked(self, query: str): + """Handle item click.""" + self.hide() + self.hotkey_clicked.emit(query) + + def _on_clear_clicked(self): + """Handle clear button click.""" + self.hide() + self.clear_history_requested.emit() + + def _on_delete_clicked(self, keyword: str): + """Handle delete button click.""" + self.delete_history_requested.emit(keyword) + + def _adjust_size(self): + """Adjust popup size to fit content.""" + if self._container_layout.count() == 0: + self.hide() + return + + # Force layout update + self._container_layout.update() + self._container.adjustSize() + self.adjustSize() + + # Set a minimum width + if self.width() < 200: + self.setMinimumWidth(200) + + # Limit max height + if self.height() > 400: + self.setFixedHeight(400) + + def show_at(self, global_pos: QPoint, input_width: int = 0): + """Show popup at global position.""" + if input_width > 0: + self.setMinimumWidth(input_width) + self.setMaximumWidth(input_width) + self.move(global_pos) + self.show() + self.raise_() # Ensure it's on top + + +class SearchInputWithHotkey(QLineEdit): + """Custom search input that emits focus events.""" + + focus_gained = Signal() + focus_lost = Signal() + escape_pressed = Signal() + + def focusInEvent(self, event): + super().focusInEvent(event) + self.focus_gained.emit() + + def focusOutEvent(self, event): + super().focusOutEvent(event) + self.focus_lost.emit() + + def keyPressEvent(self, event): + """Handle key press events.""" + if event.key() == Qt.Key_Escape: + # Emit escape signal and accept the event + self.escape_pressed.emit() + event.accept() + else: + # Pass other keys to parent + super().keyPressEvent(event) + + +class OnlineMusicView(QWidget): + """View for searching and browsing online music.""" + + # Signals + play_online_track = Signal(str, str, object) # (song_mid, local_path, metadata_dict) + insert_to_queue = Signal(str, object) # (song_mid, metadata_dict) + add_to_queue = Signal(str, object) # (song_mid, metadata_dict) + add_multiple_to_queue = Signal(list) # list of (song_mid, metadata_dict) + insert_multiple_to_queue = Signal(list) # list of (song_mid, metadata_dict) + play_online_tracks = Signal(int, list) # (start_index, list of (song_mid, metadata_dict)) + + _STYLE_TITLE = "color: %highlight%; font-size: 24px; font-weight: bold;" + _STYLE_STATUS_LABEL = "color: %text_secondary%; font-size: 12px;" + _STYLE_SEARCH_INPUT = """ + QLineEdit { + background-color: %background_hover%; + color: %text%; + border: 2px solid %border%; + border-radius: 25px; + padding: 10px 20px; + font-size: 14px; + } + QLineEdit:focus { + border: 2px solid %highlight%; + background-color: %background_alt%; + } + QLineEdit::placeholder { + color: %text_secondary%; + } + QLineEdit::clear-button { + subcontrol-origin: padding; + subcontrol-position: right; + width: 20px; + height: 20px; + margin-right: 10px; + border-radius: 10px; + background-color: %border%; + } + QLineEdit::clear-button:hover { + background-color: %text_secondary%; + border: 1px solid %text%; + } + QLineEdit::clear-button:pressed { + background-color: %background_hover%; + } + """ + _STYLE_TABS = """ + QTabBar::tab { + background: transparent; + color: %text_secondary%; + padding: 8px 20px; + border-bottom: 2px solid transparent; + } + QTabBar::tab:selected { + color: %highlight%; + border-bottom: 2px solid %highlight%; + } + QTabBar::tab:hover { + color: %highlight%; + } + """ + _STYLE_RANKINGS_TITLE = "color: %highlight%; font-size: 16px; font-weight: bold;" + _STYLE_FAV_BACK_BTN = """ + QPushButton { + background-color: transparent; + color: %highlight%; + border: none; + font-size: 14px; + font-weight: bold; + padding: 4px 8px; + } + QPushButton:hover { + color: %highlight_hover%; + } + """ + _STYLE_RESULTS_INFO = "color: %text_secondary%; font-size: 12px;" + _STYLE_SONGS_TABLE = """ + QTableWidget#songsTable { + background-color: %background_alt%; + border: none; + border-radius: 8px; + gridline-color: %background_hover%; + } + QTableWidget#songsTable::item { + padding: 12px 8px; + color: %text%; + border: none; + border-bottom: 1px solid %background_hover%; + } + QTableWidget#songsTable::item:alternate { + background-color: %background_hover%; + } + QTableWidget#songsTable::item:!alternate { + background-color: %background_alt%; + } + QTableWidget#songsTable::item:selected { + background-color: %highlight%; + color: %background%; + font-weight: 500; + } + QTableWidget#songsTable::item:selected:!alternate { + background-color: %highlight%; + } + QTableWidget#songsTable::item:selected:alternate { + background-color: %highlight_hover%; + } + QTableWidget#songsTable::item:hover { + background-color: %border%; + } + QTableWidget#songsTable::item:selected:hover { + background-color: %highlight_hover%; + } + QTableWidget#songsTable::item:focus { + outline: none; + border: none; + } + QTableWidget#songsTable:focus { + outline: none; + border: none; + } + QTableWidget#songsTable QHeaderView::section { + background-color: %background_hover%; + color: %highlight%; + padding: 14px 12px; + border: none; + border-bottom: 2px solid %highlight%; + font-weight: bold; + font-size: 12px; + } + QTableWidget#songsTable QTableCornerButton::section { + background-color: %background_hover%; + border: none; + border-right: 1px solid %border%; + border-bottom: 2px solid %highlight%; + } + QTableWidget#songsTable QScrollBar:vertical { + background-color: %background_alt%; + width: 12px; + border-radius: 6px; + margin: 0px; + } + QTableWidget#songsTable QScrollBar::handle:vertical { + background-color: %border%; + border-radius: 6px; + min-height: 40px; + } + QTableWidget#songsTable QScrollBar::handle:vertical:hover { + background-color: %text_secondary%; + } + QTableWidget#songsTable QScrollBar:horizontal { + background-color: %background_alt%; + height: 12px; + border-radius: 6px; + } + QTableWidget#songsTable QScrollBar::handle:horizontal { + background-color: %border%; + border-radius: 6px; + min-width: 40px; + } + QTableWidget#songsTable QScrollBar::handle:horizontal:hover { + background-color: %text_secondary%; + } + QTableWidget#songsTable QScrollBar::add-line, QScrollBar::sub-line { + height: 0px; + width: 0px; + } + """ + _STYLE_PAGE_LABEL = "color: %text_secondary%; padding: 0 10px;" + _STYLE_BUTTONS = """ + QPushButton { + background: %background_alt%; + color: %text%; + border: none; + padding: 8px 15px; + border-radius: 4px; + } + QPushButton:hover { + background: %border%; + } + QPushButton:pressed { + background: %text_secondary%; + } + QListWidget { + background: %background_alt%; + border: 1px solid %border%; + border-radius: 4px; + } + QListWidget::item { + padding: 10px; + color: %text%; + } + QListWidget::item:selected { + background: %highlight%; + color: %background%; + } + QListWidget::item:hover { + background: %background_hover%; + } + QListWidget::item:selected:hover { + background-color: %highlight_hover%; + color: %background%; + } + """ + _STYLE_MENU = """ + QMenu { + background: %background_hover%; + color: %text%; + border: 1px solid %border%; + } + QMenu::item:selected { + background: %highlight%; + color: %background%; + } + """ + + def __init__( + self, + config_manager=None, + qqmusic_service=None, + plugin_context=None, + parent=None + ): + super().__init__(parent) + + self._config = config_manager + self._qqmusic_service = qqmusic_service + self._plugin_context = plugin_context + self._language_connected = False + + # Create services + self._service = create_online_music_service( + config_manager=config_manager, + credential_provider=qqmusic_service + ) + self._download_service = create_online_download_service( + config_manager=config_manager, + credential_provider=qqmusic_service, + online_music_service=self._service + ) + + # State + self._current_search_type = SearchType.SONG + self._current_page = 1 + self._current_keyword = "" + self._current_result: Optional[SearchResult] = None + self._current_tracks: List[OnlineTrack] = [] + self._search_worker: Optional[SearchWorker] = None + self._search_request_id = 0 + self._top_list_worker: Optional[TopListWorker] = None + self._completion_worker: Optional[CompletionWorker] = None + self._completion_request_id = 0 + self._completion_timer: Optional[QTimer] = None + self._selected_top_id: Optional[int] = None + self._top_lists_loaded = False # Track if top lists have been loaded + self._is_top_list_view = True # True when viewing top list, False when viewing search results + + # Hotkey state + self._hotkey_worker: Optional[HotkeyWorker] = None + self._hotkey_request_id = 0 + self._hotkey_popup: Optional[HotkeyPopup] = None + self._hotkeys: List[Dict[str, Any]] = [] # Cached hotkeys + + # Recommend state + self._recommend_workers: List[RecommendWorker] = [] + self._recommendations: Dict[str, List[Dict[str, Any]]] = {} + self._recommendations_loaded = False + + # Favorites state + self._fav_workers: List[FavWorker] = [] + self._fav_loaded = False + self._fav_data: Dict[str, list] = {} # Store loaded favorites data + + # Navigation history stack - tracks where user came from + # Each entry is a dict: {'page': 'top_list'|'results'|'playlists'|'albums', 'data': ...} + self._navigation_stack: List[Dict[str, Any]] = [] + + # State for non-song search (load more) + self._grid_page = 1 # Current page for grid views (singer/album/playlist) + self._grid_total = 0 # Total results for current grid search + self._grid_page_size = 30 # Page size for grid views + + # Event bus + self._event_bus = event_bus() + + # Setup completion timer + self._completion_timer = QTimer() + self._completion_timer.setSingleShot(True) + self._completion_timer.timeout.connect(self._trigger_completion) + + self._setup_ui() + self._focus_filter_registered = False + self._register_focus_clear_filter() + + # Register with theme system + register_themed_widget(self) + self._connect_language_events() + self._sync_language_from_context() + self.refresh_theme() + + def _setup_ui(self): + """Setup UI components.""" + layout = QVBoxLayout(self) + layout.setContentsMargins(20, 20, 20, 10) + layout.setSpacing(10) + + # Header with login status + header = self._create_header() + layout.addWidget(header) + + # Search bar + search_bar = self._create_search_bar() + layout.addWidget(search_bar) + + # My Favorites section (shown when logged in, above recommendations) + # 4 cards: fav_songs, created_playlists, fav_playlists, fav_albums + self._favorites_section = RecommendSection(title="favorites", parent=self) + self._favorites_section.recommendation_clicked.connect(self._on_favorites_card_clicked) + self._favorites_section.hide() + layout.addWidget(self._favorites_section) + + # Recommendations section (shown when logged in) + self._recommend_section = RecommendSection(title="recommendations", parent=self) + self._recommend_section.recommendation_clicked.connect(self._on_recommendation_clicked) + layout.addWidget(self._recommend_section) + + # Type tabs (hidden by default) + self._tabs = self._create_type_tabs() + self._tabs.hide() + layout.addWidget(self._tabs) + + # Content area + self._stack = QStackedWidget() + + # Top lists page (default) + self._top_list_page = self._create_top_list_page() + self._stack.addWidget(self._top_list_page) + + # Search results page + self._results_page = self._create_results_page() + self._stack.addWidget(self._results_page) + + # Detail view page + self._detail_view = OnlineDetailView( + config_manager=self._config, + qqmusic_service=self._qqmusic_service, + parent=self + ) + self._detail_view.back_requested.connect(self._on_back_from_detail) + # Connect play_all and add_all_to_queue signals + self._detail_view.play_all.connect(self._on_play_all_from_detail) + self._detail_view.insert_all_to_queue.connect(self._on_insert_all_to_queue_from_detail) + self._detail_view.add_all_to_queue.connect(self._on_add_all_to_queue_from_detail) + # Connect all tracks signals (from all pages) + self._detail_view.play_all_tracks.connect(self._on_play_all_from_detail) + self._detail_view.insert_all_tracks_to_queue.connect(self._on_insert_all_to_queue_from_detail) + self._detail_view.add_all_tracks_to_queue.connect(self._on_add_all_to_queue_from_detail) + # Connect album click from artist detail view + self._detail_view.album_clicked.connect(self._on_album_clicked) + self._stack.addWidget(self._detail_view) + + layout.addWidget(self._stack, 1) # Give stretch factor so it doesn't push other widgets + + # Load recommendations if logged in (after UI is fully set up) + if self._service._has_qqmusic_credential(): + self._load_recommendations() + self._load_favorites() + + def showEvent(self, event): + """Handle show event - load top lists on first display.""" + super().showEvent(event) + if not self._top_lists_loaded: + self._top_lists_loaded = True + self._load_top_lists() + + def closeEvent(self, event): + """Handle close event and unregister global event filter.""" + self._unregister_focus_clear_filter() + super().closeEvent(event) + + def _register_focus_clear_filter(self): + """Install app-level event filter for clearing search focus on outside click.""" + app = QApplication.instance() + if app and not self._focus_filter_registered: + app.installEventFilter(self) + self._focus_filter_registered = True + + def _unregister_focus_clear_filter(self): + """Remove app-level event filter.""" + app = QApplication.instance() + if app and self._focus_filter_registered: + app.removeEventFilter(self) + self._focus_filter_registered = False + + def eventFilter(self, watched, event): + """Clear search input focus when clicking outside search-related popups.""" + if ( + event.type() == QEvent.MouseButtonPress + and hasattr(self, "_search_input") + and self._search_input + and self._search_input.hasFocus() + and self.isVisible() + ): + global_pos = event.globalPosition().toPoint() + clicked_widget = QApplication.widgetAt(global_pos) + if clicked_widget and not self._is_search_related_widget(clicked_widget): + self._search_input.clearFocus() + + return super().eventFilter(watched, event) + + def _is_search_related_widget(self, widget: QWidget) -> bool: + """Return whether clicked widget belongs to search input or its related popups.""" + if widget is self._search_input or self._search_input.isAncestorOf(widget): + return True + + if ( + self._hotkey_popup + and (widget is self._hotkey_popup or self._hotkey_popup.isAncestorOf(widget)) + ): + return True + + if self._completer: + popup = self._completer.popup() + if popup and (widget is popup or popup.isAncestorOf(widget)): + return True + + return False + + def _create_header(self) -> QWidget: + """Create header with QQ Music login status.""" + widget = QWidget() + layout = QHBoxLayout(widget) + layout.setContentsMargins(0, 0, 0, 0) + + # Title + self._online_music_title = QLabel(t("source_qq")) + layout.addWidget(self._online_music_title) + + layout.addStretch() + + # QQ Music login status + self._login_status_label = QLabel() + layout.addWidget(self._login_status_label) + + # Login/Logout button + self._login_btn = QPushButton() + self._login_btn.setCursor(Qt.PointingHandCursor) + self._login_btn.clicked.connect(self._on_login_clicked) + layout.addWidget(self._login_btn) + + self._update_login_status() + + return widget + + def _connect_language_events(self) -> None: + events = getattr(self._plugin_context, "events", None) + if events is None or self._language_connected: + return + signal = getattr(events, "language_changed", None) + if signal is None: + return + signal.connect(self._on_language_changed) + self._language_connected = True + + def _sync_language_from_context(self) -> None: + if self._plugin_context is None: + return + language = str(getattr(self._plugin_context, "language", get_language()) or get_language()) + if language != get_language(): + set_language(language) + + def _on_language_changed(self, language: str) -> None: + if not language: + return + if language != get_language(): + set_language(language) + self._language_connected = True + self.refresh_ui() + + def _create_search_bar(self) -> QWidget: + """Create search bar.""" + widget = QWidget() + layout = QHBoxLayout(widget) + layout.setContentsMargins(0, 0, 0, 0) + + # Search input with built-in clear button + self._search_input = SearchInputWithHotkey() + self._search_input.setPlaceholderText(t("search_online_music")) + self._search_input.returnPressed.connect(self._on_search) + self._search_input.textChanged.connect(self._on_search_text_changed) + self._search_input.setFixedHeight(50) + self._search_input.setClearButtonEnabled(True) + + # Connect focus events for hotkey popup + self._search_input.focus_gained.connect(self._on_search_focus_gained) + self._search_input.focus_lost.connect(self._on_search_focus_lost) + self._search_input.escape_pressed.connect(self._on_escape_pressed) + + # Setup completer for search suggestions + self._completer = CustomQCompleter(self) + self._completer.setCaseSensitivity(Qt.CaseInsensitive) + # Use PopupCompletion mode to show all matching suggestions + self._completer.setCompletionMode(QCompleter.PopupCompletion) + self._completer.setMaxVisibleItems(10) + # Set filter mode to show anything that contains the typed text + self._completer.setFilterMode(Qt.MatchContains) + self._search_input.setCompleter(self._completer) + + # Connect completion activation + self._completer.activated.connect(self._on_completion_selected) + + layout.addWidget(self._search_input, 1) + + # Search button + self._search_btn = QPushButton(t("search")) + self._search_btn.setCursor(Qt.PointingHandCursor) + self._search_btn.clicked.connect(self._on_search) + layout.addWidget(self._search_btn) + + return widget + + def _create_type_tabs(self) -> QTabBar: + """Create search type tabs.""" + tabs = QTabBar() + tabs.setObjectName("searchTypeTabs") + tabs.setExpanding(False) + tabs.setCursor(Qt.PointingHandCursor) + + # Add tabs + tabs.addTab(t("songs")) + tabs.addTab(t("singers")) + tabs.addTab(t("albums")) + tabs.addTab(t("playlists")) + + tabs.currentChanged.connect(self._on_tab_changed) + tabs.setStyleSheet(""" + QTabBar::tab { + background: transparent; + color: #808080; + padding: 8px 20px; + border-bottom: 2px solid transparent; + } + QTabBar::tab:selected { + color: #1db954; + border-bottom: 2px solid #1db954; + } + QTabBar::tab:hover { + color: #1db954; + } + """) + + return tabs + + def _create_top_list_page(self) -> QWidget: + """Create top list page (default view).""" + widget = QWidget() + layout = QHBoxLayout(widget) + layout.setContentsMargins(0, 10, 0, 0) + + # Left: list of top lists + left_widget = QWidget() + left_layout = QVBoxLayout(left_widget) + left_layout.setContentsMargins(0, 0, 0, 0) + + self._rankings_title = QLabel(t("rankings")) + left_layout.addWidget(self._rankings_title) + + self._top_list_list = QListWidget() + self._top_list_list.setObjectName("topListList") + self._top_list_list.setMouseTracking(True) + self._top_list_list.setCursor(Qt.PointingHandCursor) + self._top_list_list.currentRowChanged.connect(self._on_top_list_selected) + left_layout.addWidget(self._top_list_list) + + layout.addWidget(left_widget, 1) + + # Right: songs in selected top list + right_widget = QWidget() + right_layout = QVBoxLayout(right_widget) + right_layout.setContentsMargins(10, 0, 0, 0) + + # Header with title and view toggle + header_layout = QHBoxLayout() + header_layout.setContentsMargins(0, 0, 0, 0) + + self._top_list_title = QLabel(t("select_ranking")) + header_layout.addWidget(self._top_list_title) + + header_layout.addStretch() + + # View toggle button + self._ranking_view_toggle_btn = QPushButton() + self._ranking_view_toggle_btn.setFixedSize(32, 32) + self._ranking_view_toggle_btn.setToolTip(t("toggle_view")) + self._ranking_view_toggle_btn.setCursor(Qt.PointingHandCursor) + self._ranking_view_toggle_btn.clicked.connect(self._toggle_ranking_view_mode) + header_layout.addWidget(self._ranking_view_toggle_btn) + + right_layout.addLayout(header_layout) + + # Stacked widget for table and list views + self._ranking_stacked_widget = QStackedWidget() + + self._top_songs_table = self._create_songs_table() + self._ranking_stacked_widget.addWidget(self._top_songs_table) + + self._ranking_list_view = OnlineTracksListView() + self._ranking_list_view.track_activated.connect(self._on_ranking_track_activated) + self._ranking_list_view.play_requested.connect(self._play_selected_tracks) + self._ranking_list_view.insert_to_queue_requested.connect(self._insert_selected_to_queue) + self._ranking_list_view.add_to_queue_requested.connect(self._add_selected_to_queue) + self._ranking_list_view.add_to_playlist_requested.connect(self._add_selected_to_playlist) + self._ranking_list_view.favorites_toggle_requested.connect(self._on_ranking_favorites_toggle) + self._ranking_list_view.download_requested.connect(self._download_selected_tracks) + self._ranking_list_view.favorite_toggled.connect(self._on_ranking_favorite_toggled) + self._ranking_stacked_widget.addWidget(self._ranking_list_view) + + right_layout.addWidget(self._ranking_stacked_widget) + + layout.addWidget(right_widget, 3) + + # Load view mode preference + self._load_ranking_view_mode() + + return widget + + def _create_results_page(self) -> QWidget: + """Create search results page with different views for each type.""" + widget = QWidget() + layout = QVBoxLayout(widget) + layout.setContentsMargins(0, 10, 0, 0) + + # Header with back button and results info + header_widget = QWidget() + header_layout = QHBoxLayout(header_widget) + header_layout.setContentsMargins(0, 0, 0, 0) + header_layout.setSpacing(10) + + # Back button (hidden by default, shown for favorites views) + self._fav_back_btn = QPushButton(f"← {t('back')}") + self._fav_back_btn.setCursor(Qt.PointingHandCursor) + self._fav_back_btn.clicked.connect(self._on_fav_back_clicked) + self._fav_back_btn.hide() + header_layout.addWidget(self._fav_back_btn) + + # Results info + self._results_info = QLabel() + header_layout.addWidget(self._results_info) + header_layout.addStretch() + + layout.addWidget(header_widget) + + # Stacked widget for different result types + self._results_stack = QStackedWidget() + + # Songs page - table view + self._songs_page = self._create_songs_result_page() + self._results_stack.addWidget(self._songs_page) + + # Singers page - grid view with circular avatars + self._singers_page = OnlineGridView(data_type="singer", parent=self) + self._singers_page.item_clicked.connect(self._on_artist_clicked) + self._singers_page.load_more_requested.connect(self._on_load_more_artists) + self._results_stack.addWidget(self._singers_page) + + # Albums page - grid view with rounded covers + self._albums_page = OnlineGridView(data_type="album", parent=self) + self._albums_page.item_clicked.connect(self._on_album_clicked) + self._albums_page.load_more_requested.connect(self._on_load_more_albums) + self._results_stack.addWidget(self._albums_page) + + # Playlists page - grid view with rounded covers + self._playlists_page = OnlineGridView(data_type="playlist", parent=self) + self._playlists_page.item_clicked.connect(self._on_playlist_clicked) + self._playlists_page.load_more_requested.connect(self._on_load_more_playlists) + self._results_stack.addWidget(self._playlists_page) + + layout.addWidget(self._results_stack) + + # Pagination (only for songs) + pagination = self._create_pagination() + layout.addWidget(pagination) + + return widget + + def _create_songs_result_page(self) -> QWidget: + """Create songs result page with table.""" + widget = QWidget() + layout = QVBoxLayout(widget) + layout.setContentsMargins(0, 0, 0, 0) + + # Results table + self._results_table = self._create_songs_table() + layout.addWidget(self._results_table) + + return widget + + def _create_songs_table(self) -> QTableWidget: + """Create songs table widget.""" + table = QTableWidget() + table.setObjectName("songsTable") + table.setColumnCount(5) + table.setHorizontalHeaderLabels([ + "#", t("title"), t("artist"), t("album"), t("duration") + ]) + table.horizontalHeader().setSectionResizeMode(0, QHeaderView.Fixed) + table.horizontalHeader().setSectionResizeMode(1, QHeaderView.Stretch) + table.horizontalHeader().setSectionResizeMode(2, QHeaderView.Stretch) + table.horizontalHeader().setSectionResizeMode(3, QHeaderView.Stretch) + table.horizontalHeader().setSectionResizeMode(4, QHeaderView.Fixed) + table.setColumnWidth(0, 50) + table.setColumnWidth(4, 80) + table.setSelectionBehavior(QAbstractItemView.SelectRows) + table.setSelectionMode(QAbstractItemView.ExtendedSelection) + table.setAlternatingRowColors(True) + table.setEditTriggers(QAbstractItemView.NoEditTriggers) + table.verticalHeader().setVisible(False) + table.doubleClicked.connect(self._on_track_double_clicked) + table.setContextMenuPolicy(Qt.CustomContextMenu) + table.customContextMenuRequested.connect(self._show_track_context_menu) + + return table + + def _create_pagination(self) -> QWidget: + """Create pagination widget.""" + widget = QWidget() + layout = QHBoxLayout(widget) + layout.setContentsMargins(0, 0, 0, 0) + + layout.addStretch() + + self._prev_btn = QPushButton("← " + t("previous_page")) + self._prev_btn.setFixedHeight(36) + self._prev_btn.setCursor(Qt.PointingHandCursor) + self._prev_btn.clicked.connect(self._on_prev_page) + layout.addWidget(self._prev_btn) + + self._page_label = QLabel("1") + layout.addWidget(self._page_label) + + self._next_btn = QPushButton(t("next_page") + " →") + self._next_btn.setFixedHeight(36) + self._next_btn.setCursor(Qt.PointingHandCursor) + self._next_btn.clicked.connect(self._on_next_page) + layout.addWidget(self._next_btn) + + layout.addStretch() + + return widget + + def refresh_theme(self): + """Refresh all styles using current theme tokens.""" + # Main widget styles + self.setStyleSheet(get_qss(self._STYLE_BUTTONS)) + + # Header + self._online_music_title.setStyleSheet(get_qss(self._STYLE_TITLE)) + self._login_status_label.setStyleSheet(get_qss(self._STYLE_STATUS_LABEL)) + + # Search input + self._search_input.setStyleSheet(get_qss(self._STYLE_SEARCH_INPUT)) + + # Tabs + self._tabs.setStyleSheet(get_qss(self._STYLE_TABS)) + + # Top list page + self._rankings_title.setStyleSheet(get_qss(self._STYLE_RANKINGS_TITLE)) + self._top_list_title.setStyleSheet(get_qss(self._STYLE_RANKINGS_TITLE)) + + # Results page + self._fav_back_btn.setStyleSheet(get_qss(self._STYLE_FAV_BACK_BTN)) + self._results_info.setStyleSheet(get_qss(self._STYLE_RESULTS_INFO)) + + # Songs tables + self._top_songs_table.setStyleSheet(get_qss(self._STYLE_SONGS_TABLE)) + self._results_table.setStyleSheet(get_qss(self._STYLE_SONGS_TABLE)) + + # Pagination + self._page_label.setStyleSheet(get_qss(self._STYLE_PAGE_LABEL)) + + # Refresh completer popup + if hasattr(self, '_completer') and self._completer: + self._completer.refresh_theme() + + # Refresh hotkey popup + if self._hotkey_popup: + self._hotkey_popup.refresh_theme() + + def _refresh_qqmusic_service(self): + """Refresh QQ Music service with current credentials.""" + import json + + if self._config and hasattr(self._config, "get_plugin_secret"): + qqmusic_credential = self._config.get_plugin_secret("qqmusic", "credential", "") + elif self._config: + qqmusic_credential = self._config.get("qqmusic.credential") + else: + qqmusic_credential = None + if qqmusic_credential: + try: + cred_dict = json.loads(qqmusic_credential) if isinstance(qqmusic_credential, + str) else qqmusic_credential + self._qqmusic_service = create_qqmusic_service(cred_dict) + # Update service reference + self._service._provider = self._qqmusic_service + # Update download service reference too + self._download_service._provider = self._qqmusic_service + # Update detail view service references + if hasattr(self, '_detail_view') and self._detail_view: + self._detail_view._service._provider = self._qqmusic_service + self._detail_view._download_service._provider = self._qqmusic_service + logger.info(f"QQ Music service refreshed, musicid={cred_dict.get('musicid')}, " + f"has_refresh_key={bool(cred_dict.get('refresh_key'))}") + except Exception as e: + logger.error(f"Failed to refresh QQ Music service: {e}") + + def _update_login_status(self): + """Update QQ Music login status display.""" + has_credential = self._service._has_qqmusic_credential() + + if has_credential: + # Refresh QQ Music service with new credentials + self._refresh_qqmusic_service() + + # Get nickname from config + if self._config and hasattr(self._config, "get_plugin_setting"): + nick = self._config.get_plugin_setting("qqmusic", "nick", "") + else: + nick = "" + + if nick: + self._login_status_label.setText(f"{t('qqmusic_logged_in_as')} {nick}") + else: + self._login_status_label.setText(t("qqmusic_logged_in")) + + self._login_btn.setText(t("logout")) + + # Load recommendations when logged in (only if UI is fully set up) + if hasattr(self, '_recommend_section'): + self._load_recommendations() + else: + self._login_status_label.setText(t("qqmusic_not_logged_in")) + self._login_btn.setText(t("login")) + + # Hide recommendations when not logged in + if hasattr(self, '_recommend_section'): + self._recommend_section.hide() + + def _refresh_login_status(self): + """refresh QQ Music login status display.""" + has_credential = self._service._has_qqmusic_credential() + + if has_credential: + # Get nickname from config + if self._config and hasattr(self._config, "get_plugin_setting"): + nick = self._config.get_plugin_setting("qqmusic", "nick", "") + else: + nick = "" + + if nick: + self._login_status_label.setText(f"{t('qqmusic_logged_in_as')} {nick}") + else: + self._login_status_label.setText(t("qqmusic_logged_in")) + + self._login_btn.setText(t("logout")) + else: + self._login_status_label.setText(t("qqmusic_not_logged_in")) + self._login_btn.setText(t("login")) + + def _on_login_clicked(self): + """Handle login button click.""" + if self._service._has_qqmusic_credential(): + # Logout + if self._config: + if hasattr(self._config, "set_plugin_setting"): + self._config.set_plugin_setting("qqmusic", "credential", None) + self._config.set_plugin_setting("qqmusic", "nick", "") + self._update_login_status() + MessageDialog.information(self, t("logout"), t("logout_success")) + else: + # Show login dialog + self._show_login_dialog() + + def _show_login_dialog(self): + """Show QQ Music login dialog.""" + dialog = create_qqmusic_login_dialog(getattr(self, "_plugin_context", None), self) + dialog.credentials_obtained.connect(self._on_credentials_obtained) + dialog.exec() + + def _on_credentials_obtained(self, credential: dict): + """Handle credentials obtained from login dialog.""" + logger.info("QQ Music credentials obtained, refreshing service...") + if self._config and hasattr(self._config, "get_plugin_setting") and hasattr(self._config, "set_plugin_setting"): + nick = self._config.get_plugin_setting("qqmusic", "nick", "") + if not nick: + try: + verify_result = self._service.client.verify_login() + if isinstance(verify_result, dict) and verify_result.get("valid"): + fetched_nick = str(verify_result.get("nick", "") or "") + if fetched_nick: + self._config.set_plugin_setting("qqmusic", "nick", fetched_nick) + except Exception as exc: + logger.warning("Failed to refresh QQ Music nick after login: %s", exc) + self._refresh_qqmusic_service() + self._update_login_status() + # Reload favorites with new credentials + self._fav_loaded = False + self._load_favorites() + + def _load_recommendations(self): + """Load all 5 types of recommendations.""" + if self._recommendations_loaded: + return + + self._recommend_section.show_loading() + self._recommendations_loaded = True + + # Define 5 recommendation types with their display titles + recommend_types = [ + ("home_feed", "home_recommend"), + ("guess", "guess_you_like"), + ("radar", "radar_recommend"), + ("newsong", "new_songs"), + ("songlist", "recommend_playlists"), + ] + + for recommend_type, title in recommend_types: + worker = RecommendWorker(self._qqmusic_service, recommend_type) + worker.recommend_ready.connect(self._on_recommend_ready) + self._recommend_workers.append(worker) + worker.start() + + def _on_recommend_ready(self, recommend_type: str, data: Any): + """Handle recommendation data ready.""" + # Store raw data for parsing + self._recommendations[recommend_type] = data + + # Check if all recommendations are loaded + expected_types = ["home_feed", "guess", "radar", "songlist", "newsong"] + loaded_count = sum(1 for t in expected_types if t in self._recommendations) + + # Only display when all 5 are loaded + if loaded_count == len(expected_types): + self._display_recommendations() + + def _display_recommendations(self): + """Parse and display all loaded recommendations.""" + cards = [] + + # Define order and titles + recommend_config = [ + ("home_feed", "home_recommend"), + ("guess", "guess_you_like"), + ("radar", "radar_recommend"), + ("newsong", "new_songs"), + ("songlist", "recommend_playlists"), + ] + + for recommend_type, title in recommend_config: + data = self._recommendations.get(recommend_type) + if not data: + continue + + parsed = self._parse_recommendation(recommend_type, data) + if parsed: + parsed['recommend_type'] = recommend_type + parsed['title'] = title + cards.append(parsed) + + if cards: + self._recommend_section.load_recommendations(cards) + # Show recommendations section after loading + self._recommend_section.show() + + def _load_favorites(self): + """Load user's favorites counts and display 4 summary cards.""" + if self._fav_loaded: + return + + if not self._qqmusic_service or not self._qqmusic_service._credential: + return + + self._fav_loaded = True + self._favorites_section.show_loading() + self._fav_data = {} # Store data for click handling + + for fav_type in ["fav_songs", "created_playlists", "fav_playlists", "fav_albums", "followed_singers"]: + worker = FavWorker(self._qqmusic_service, fav_type) + worker.fav_ready.connect(self._on_fav_ready) + self._fav_workers.append(worker) + worker.start() + + def _on_fav_ready(self, fav_type: str, data: list): + """Handle favorites data ready - store for later use.""" + self._fav_data[fav_type] = data + + # Check if all 5 types loaded (initial load) + if len(self._fav_data) == 5: + self._display_favorites_cards() + + def _get_random_cover(self, items: list) -> str: + """Get a random cover from a list of items.""" + import random + + if not items: + return "" + + # Filter items that have cover_url + items_with_cover = [item for item in items if item.get("cover_url")] + + if not items_with_cover: + return "" + + # Select a random item + random_item = random.choice(items_with_cover) + return random_item.get("cover_url", "") + + def _get_random_cover_from_items(self, data: list, recommend_type: str) -> str: + """Extract a random cover from recommendation data based on type.""" + import random + + if not data: + return "" + + # Filter items that have valid cover data + valid_items = [] + for item in data: + if not isinstance(item, dict): + continue + + cover_url = None + + if recommend_type == 'songlist': + # Playlist structure + playlist_info = item.get('Playlist', item) + if isinstance(playlist_info, dict): + # Try basic/content structures + if 'basic' in playlist_info: + basic = playlist_info.get('basic', {}) + if isinstance(basic, dict): + cover = basic.get('cover_url') or basic.get('cover') or basic.get('picurl') + if cover: + if isinstance(cover, dict): + cover_url = cover.get('default_url') or cover.get('small_url') + else: + cover_url = cover + + if not cover_url and 'content' in playlist_info: + content = playlist_info.get('content', {}) + if isinstance(content, dict): + cover = content.get('cover_url') or content.get('cover') + if cover: + if isinstance(cover, dict): + cover_url = cover.get('default_url') or cover.get('small_url') + else: + cover_url = cover + + if not cover_url: + cover = (playlist_info.get('cover_url') or playlist_info.get('cover') or + playlist_info.get('picurl') or playlist_info.get('pic')) + if cover: + if isinstance(cover, dict): + cover_url = cover.get('default_url') or cover.get('small_url') + else: + cover_url = cover + + # Try to get from songlist + if not cover_url: + song_list = playlist_info.get('songlist', []) + if song_list: + album = song_list[0].get('album', {}) + if isinstance(album, dict): + album_mid = album.get('mid') + if album_mid: + cover_url = f"https://y.gtimg.cn/music/photo_new/T002R300x300M000{album_mid}.jpg" + + elif recommend_type == 'radar': + # Radar structure + track_info = item.get('Track', {}) + if isinstance(track_info, dict): + album = track_info.get('album', {}) + if isinstance(album, dict): + album_mid = album.get('mid') + if album_mid: + cover_url = f"https://y.gtimg.cn/music/photo_new/T002R300x300M000{album_mid}.jpg" + + else: + # Song structure (guess, home_feed, newsong) + cover_url = (item.get('cover') or item.get('picurl') or + item.get('cover_url') or item.get('pic')) + + if not cover_url: + album_mid = None + album = item.get('album', {}) + if isinstance(album, dict): + album_mid = album.get('mid') + if not album_mid: + album_mid = item.get('albummid') or item.get('album_mid') + + if album_mid: + cover_url = f"https://y.gtimg.cn/music/photo_new/T002R300x300M000{album_mid}.jpg" + + if cover_url: + valid_items.append(cover_url) + + if valid_items: + return random.choice(valid_items) + + return "" + + def _display_favorites_cards(self): + """Display 4 summary cards in the favorites section.""" + + cards = [] + + # Card 1: 收藏歌曲 + fav_songs = self._fav_data.get("fav_songs", []) + cards.append({ + "id": "fav_songs", + "title": "fav_songs", + "subtitle": f"{len(fav_songs)} {t('songs')}", + "cover_url": self._get_random_cover(fav_songs), + "card_type": "fav_songs", + }) + + # Card 2: 创建的歌单 + created_pl = self._fav_data.get("created_playlists", []) + cards.append({ + "id": "created_playlists", + "title": "created_playlists", + "subtitle": f"{len(created_pl)} {t('playlists')}", + "cover_url": self._get_random_cover(created_pl), + "card_type": "created_playlists", + }) + + # Card 3: 收藏的歌单 + fav_pl = self._fav_data.get("fav_playlists", []) + cards.append({ + "id": "fav_playlists", + "title": "fav_playlists", + "subtitle": f"{len(fav_pl)} {t('playlists')}", + "cover_url": self._get_random_cover(fav_pl), + "card_type": "fav_playlists", + }) + + # Card 4: 收藏专辑 + fav_albums = self._fav_data.get("fav_albums", []) + cards.append({ + "id": "fav_albums", + "title": "fav_albums", + "subtitle": f"{len(fav_albums)} {t('albums')}", + "cover_url": self._get_random_cover(fav_albums), + "card_type": "fav_albums", + }) + + # Card 5: 关注歌手 + followed_singers = self._fav_data.get("followed_singers", []) + cards.append({ + "id": "followed_singers", + "title": "followed_singers", + "subtitle": f"{len(followed_singers)} {t('singers')}", + "cover_url": self._get_random_cover(followed_singers), + "card_type": "followed_singers", + }) + + self._favorites_section.load_recommendations(cards) + + def _parse_recommendation(self, recommend_type: str, data: Any) -> Optional[Dict[str, Any]]: + """Parse recommendation data to extract card info.""" + try: + # Handle list response (API returns list of songs/playlists) + if isinstance(data, list): + if not data: + return None + + # Get first item for structure analysis + first_item = data[0] + if not isinstance(first_item, dict): + return None + + # Get a random cover from all items + cover_url = self._get_random_cover_from_items(data, recommend_type) + playlist_id = None + + # Handle different response structures based on type + if recommend_type == 'songlist': + # Playlist structure: {'Playlist': {...}, 'WhereFrom': ..., 'ext': ...} + # or direct structure with tid/id/disstid + + playlist_info = first_item.get('Playlist', {}) + if isinstance(playlist_info, dict) and playlist_info: + + # Try nested structures first (basic/content) + if 'basic' in playlist_info: + basic = playlist_info.get('basic', {}) + if isinstance(basic, dict): + playlist_id = basic.get('tid') or basic.get('id') or basic.get('disstid') + + if not playlist_id and 'content' in playlist_info: + content = playlist_info.get('content', {}) + if isinstance(content, dict): + playlist_id = content.get('tid') or content.get('id') or content.get('disstid') + + # Fallback to direct fields + if not playlist_id: + playlist_id = playlist_info.get('tid') or playlist_info.get('id') or playlist_info.get( + 'disstid') + + else: + # Try direct structure - check for various ID fields + playlist_id = (first_item.get('tid') or first_item.get('id') or + first_item.get('disstid') or first_item.get('dissid')) + + elif recommend_type == 'radar': + # Radar structure: {'Track': {...}, 'Abt': ..., 'Ext': ...} + # Cover is already extracted by _get_random_cover_from_items + pass + + elif recommend_type == 'home_feed': + # Home feed returns recommendation cards (playlists, rankings, songs) + # Each card has type: 200=song, 500=playlist, 700=guess, 1000=ranking + playlist_id = first_item.get('id') + + else: + # Song structure: {'album': {...}, 'singer': [...], ...} + # This handles guess, newsong types + + # Cover is already extracted by _get_random_cover_from_items + # Get playlist ID if available (for playlist-based recommendations) + playlist_id = (first_item.get('id') or first_item.get('disstid') or + first_item.get('tid') or first_item.get('playlist_id')) + + return { + 'id': playlist_id, + 'cover_url': cover_url, + 'raw_data': first_item, # Save first item for click handling + 'full_data': data, # Save full data list for song-based recommendations + 'recommend_type': recommend_type, + } + + # Handle dict response (API returns dict with embedded list) + if isinstance(data, dict): + # Log the structure for debugging + + # Try to find the main content + content = None + for key in ['songlist', 'songs', 'list', 'data', 'items', 'playlist']: + if key in data: + content = data[key] + break + + if content and isinstance(content, list) and content: + first_item = content[0] + if isinstance(first_item, dict): + # Get a random cover from all items + cover_url = self._get_random_cover_from_items(content, recommend_type) + playlist_id = (first_item.get('id') or first_item.get('disstid') or + first_item.get('tid') or data.get('id')) + + return { + 'id': playlist_id, + 'cover_url': cover_url, + 'raw_data': first_item, # Save first item for click handling + 'full_data': content, # Save full data list for song-based recommendations + 'recommend_type': recommend_type, + } + + # Check for playlist info directly + playlist_id = data.get('id') or data.get('disstid') + cover_url = data.get('cover') or data.get('picurl') or data.get('pic') + + return { + 'id': playlist_id, + 'cover_url': cover_url, + 'raw_data': data, + 'recommend_type': recommend_type, + } + + return None + except Exception as e: + logger.error(f"Failed to parse recommendation {recommend_type}: {e}") + return None + + def _on_favorites_card_clicked(self, data: Dict[str, Any]): + """Handle favorites section card click.""" + card_type = data.get("card_type", "") + + # Hide favorites and recommendations when viewing any favorites content + self._favorites_section.hide() + self._recommend_section.hide() + # Show back button + self._fav_back_btn.show() + + if card_type == "fav_songs": + tracks = self._fav_data.get("fav_songs", []) + self._show_fav_songs_in_table(tracks) + elif card_type == "created_playlists": + playlists = self._fav_data.get("created_playlists", []) + self._show_playlist_list_in_detail(t("created_playlists"), playlists) + elif card_type == "fav_playlists": + playlists = self._fav_data.get("fav_playlists", []) + self._show_playlist_list_in_detail(t("fav_playlists"), playlists) + elif card_type == "fav_albums": + albums = self._fav_data.get("fav_albums", []) + self._show_album_list_in_detail(t("fav_albums"), albums) + elif card_type == "followed_singers": + singers = self._fav_data.get("followed_singers", []) + self._show_singer_list_in_detail(t("followed_singers"), singers) + + def _show_fav_songs_in_table(self, tracks: list): + """Show favorite songs in the detail view with play all / add to queue buttons.""" + # Convert to the format expected by load_songs_directly + songs = [] + cover_url = "" + for t_data in tracks: + song = { + "mid": t_data.get("mid", ""), + "songmid": t_data.get("mid", ""), + "title": t_data.get("title", ""), + "songname": t_data.get("title", ""), + "name": t_data.get("title", ""), + "singer": [{"mid": "", "name": t_data.get("singer", "")}] if t_data.get("singer") else [], + "album": { + "mid": t_data.get("album_mid", ""), + "name": t_data.get("album", ""), + }, + "interval": t_data.get("duration", 0), + } + songs.append(song) + # Use first song's cover + if not cover_url and t_data.get("cover_url"): + cover_url = t_data.get("cover_url") + + # Use detail view to show songs with play all / add to queue buttons + self._detail_view.load_songs_directly(songs, t("fav_songs"), cover_url) + self._stack.setCurrentWidget(self._detail_view) + + def _show_playlist_list_in_detail(self, title: str, playlists: list): + """Show a list of playlists in the grid view.""" + from .models import OnlinePlaylist + + # Clear previous data + self._playlists_page.clear() + + # Convert dicts to OnlinePlaylist objects + online_playlists = [OnlinePlaylist( + id=str(pl.get("id", "")), + title=pl.get("title", ""), + cover_url=pl.get("cover_url", ""), + creator=pl.get("creator", ""), + song_count=pl.get("song_count", 0), + play_count=pl.get("play_count", 0), + ) for pl in playlists] + + self._playlists_page.load_data(online_playlists) + self._results_info.setText(t(title)) + self._tabs.hide() + self._is_top_list_view = False + self._results_stack.setCurrentWidget(self._playlists_page) + self._stack.setCurrentWidget(self._results_page) + + # Push navigation state + self._navigation_stack.append({ + 'page': 'playlists', + 'title': title, + 'data': playlists + }) + + def _show_album_list_in_detail(self, title: str, albums: list): + """Show a list of albums in the grid view.""" + from .models import OnlineAlbum + + # Clear previous data + self._albums_page.clear() + + # Convert dicts to OnlineAlbum objects + online_albums = [] + for album in albums: + singer_name = album.get("singer_name", "") + online_albums.append(OnlineAlbum( + mid=album.get("mid", ""), + name=album.get("title", ""), + singer_mid="", + singer_name=singer_name, + cover_url=album.get("cover_url", ""), + song_count=album.get("song_count", 0), + )) + + self._albums_page.load_data(online_albums) + self._results_info.setText(title) + self._tabs.hide() + self._is_top_list_view = False + self._results_stack.setCurrentWidget(self._albums_page) + self._stack.setCurrentWidget(self._results_page) + + # Push navigation state + self._navigation_stack.append({ + 'page': 'albums', + 'title': title, + 'data': albums + }) + + def _show_singer_list_in_detail(self, title: str, singers: list): + """Show a list of followed singers in the grid view.""" + from .models import OnlineArtist + + # Clear previous data + self._singers_page.clear() + + # Convert dicts to OnlineArtist objects + artists = [OnlineArtist( + mid=singer.get("mid", ""), + name=singer.get("name", ""), + avatar_url=singer.get("cover_url", ""), + fan_count=singer.get("fan_count", 0), + ) for singer in singers] + + self._singers_page.load_data(artists) + self._results_info.setText(title) + self._tabs.hide() + self._is_top_list_view = False + self._results_stack.setCurrentWidget(self._singers_page) + self._stack.setCurrentWidget(self._results_page) + + # Push navigation state + self._navigation_stack.append({ + 'page': 'singers', + 'title': title, + 'data': singers + }) + + def _on_recommendation_clicked(self, data: Dict[str, Any]): + """Handle recommendation card click.""" + # Hide favorites and recommendations when viewing details + self._favorites_section.hide() + self._recommend_section.hide() + # Show back button for playlist list view + self._fav_back_btn.show() + + recommend_type = data.get('recommend_type', '') + raw_data = data.get('raw_data') + full_data = data.get('full_data') # Full song list for song-based recommendations + + title = data.get('title', '') + cover_url = data.get('cover_url', '') + + if not isinstance(raw_data, dict): + logger.warning(f"Invalid raw_data type: {type(raw_data)}") + return + + # Handle songlist type - show list of playlists + if recommend_type == 'songlist': + # full_data contains the list of playlists + if full_data and isinstance(full_data, list): + playlists = [] + for item in full_data: + if isinstance(item, dict): + # Extract playlist info from nested structure + playlist_info = item.get('Playlist', item) + if not isinstance(playlist_info, dict): + continue + + # Try to get playlist details from various nested structures + # Structure: basic/content/diy + playlist_id = None + playlist_title = None + cover_url = None + song_count = 0 + + # Try basic structure first + if 'basic' in playlist_info: + basic = playlist_info.get('basic', {}) + if isinstance(basic, dict): + playlist_id = basic.get('tid') or basic.get('id') or basic.get('disstid') + playlist_title = basic.get('title') or basic.get('name') + # Cover can be URL string or dict + cover = basic.get('cover_url') or basic.get('cover') or basic.get('picurl') + if cover: + if isinstance(cover, dict): + cover_url = cover.get('default_url') or cover.get('small_url') + else: + cover_url = cover + + # Try content structure + if not playlist_id and 'content' in playlist_info: + content = playlist_info.get('content', {}) + if isinstance(content, dict): + playlist_id = content.get('tid') or content.get('id') or content.get('disstid') + if not playlist_title: + playlist_title = content.get('title') or content.get('name') + if not cover_url: + cover = content.get('cover_url') or content.get('cover') + if cover: + if isinstance(cover, dict): + cover_url = cover.get('default_url') or cover.get('small_url') + else: + cover_url = cover + + # Fallback to direct fields + if not playlist_id: + playlist_id = (playlist_info.get('tid') or playlist_info.get('id') or + playlist_info.get('disstid') or playlist_info.get('dissid')) + if not playlist_title: + playlist_title = playlist_info.get('title') or playlist_info.get('name') + if not cover_url: + cover = (playlist_info.get('cover_url') or playlist_info.get('cover') or + playlist_info.get('picurl') or playlist_info.get('pic')) + if cover: + if isinstance(cover, dict): + cover_url = cover.get('default_url') or cover.get('small_url') + else: + cover_url = cover + + # Try to get song count from various fields + song_count = 0 + + # Check basic/content for song_count - try multiple field name variations + if 'basic' in playlist_info: + basic = playlist_info.get('basic', {}) + if isinstance(basic, dict): + song_count = (basic.get('song_count') or basic.get('song_num') or + basic.get('songNum') or basic.get('song_cnt') or 0) + if not song_count and 'content' in playlist_info: + content = playlist_info.get('content', {}) + if isinstance(content, dict): + song_count = (content.get('song_count') or content.get('song_num') or + content.get('songNum') or content.get('song_cnt') or 0) + # Check songlist if exists + if not song_count: + song_list = playlist_info.get('songlist', []) + if song_list: + song_count = len(song_list) + # Fallback to direct field - try multiple field name variations + if not song_count: + song_count = (playlist_info.get('song_count') or playlist_info.get('song_num') or + playlist_info.get('songNum') or playlist_info.get('song_cnt') or + playlist_info.get('songnum') or 0) + + # Try to get play count + play_count = 0 + if 'basic' in playlist_info: + basic = playlist_info.get('basic', {}) + if isinstance(basic, dict): + play_count = (basic.get('play_cnt') or basic.get('listennum') or + basic.get('play_count') or 0) + if not play_count and 'content' in playlist_info: + content = playlist_info.get('content', {}) + if isinstance(content, dict): + play_count = (content.get('play_cnt') or content.get('listennum') or + content.get('play_count') or 0) + if not play_count: + play_count = (playlist_info.get('play_cnt') or playlist_info.get('listennum') or + playlist_info.get('play_count') or 0) + + if playlist_id: + playlists.append({ + 'id': str(playlist_id), + 'title': playlist_title or '', + 'cover_url': cover_url or '', + 'song_count': song_count, + 'play_count': play_count or 0, + }) + + logger.info(f"Showing {len(playlists)} recommended playlists") + if playlists: + self._show_playlist_list_in_detail(title, playlists) + else: + logger.warning("No valid playlists found in songlist data") + else: + logger.warning(f"Invalid full_data for songlist: {type(full_data)}") + return + + # Handle radar type - Track info, show all radar songs + if recommend_type == 'radar': + if full_data and isinstance(full_data, list): + # Radar data format: [{'Track': {...}, 'Abt': ..., 'Ext': ...}, ...] + # Need to extract Track from each item + songs = [] + for item in full_data: + if isinstance(item, dict): + track = item.get('Track', item) + if isinstance(track, dict): + songs.append(track) + logger.info(f"Loading radar songs: {len(songs)} songs") + if songs: + self._detail_view.load_songs_directly(songs, title, cover_url) + self._stack.setCurrentWidget(self._detail_view) + else: + logger.warning("No valid radar songs found") + else: + # Fallback to album + track_info = raw_data.get('Track', raw_data) + album = track_info.get('album', {}) + if isinstance(album, dict) and album.get('mid'): + logger.info(f"Loading radar album: {album.get('mid')}") + self._detail_view.load_album(album.get('mid'), album.get('name', title), "") + self._stack.setCurrentWidget(self._detail_view) + return + + # Handle guess, home_feed, newsong types - these return songs, show all songs + if recommend_type in ('guess', 'home_feed', 'newsong'): + if full_data and isinstance(full_data, list): + logger.info(f"Loading {recommend_type} songs: {len(full_data)} songs") + self._detail_view.load_songs_directly(full_data, title, cover_url) + self._stack.setCurrentWidget(self._detail_view) + return + + # Fallback to album if no full_data + album = raw_data.get('album', {}) + if isinstance(album, dict) and album.get('mid'): + logger.info(f"Loading album from {recommend_type}: {album.get('mid')}") + self._detail_view.load_album(album.get('mid'), album.get('name', title), "") + self._stack.setCurrentWidget(self._detail_view) + return + + logger.warning(f"Could not determine how to handle recommendation: {recommend_type}") + + def _on_search(self): + """Handle search.""" + keyword = self._search_input.text().strip() + if not keyword: + self._search_input.clearFocus() + return + + # Save to search history + if self._config: + self._config.add_search_history(keyword) + + # Hide favorites and recommendations sections when searching + self._favorites_section.hide() + self._recommend_section.hide() + + self._current_keyword = keyword + self._current_page = 1 + self._grid_page = 1 # Reset grid page for new search + self._tabs.show() + + # Immediately switch to results page and show searching state + self._stack.setCurrentWidget(self._results_page) + self._results_info.setText(t("searching")) + self._results_table.setRowCount(0) + self._prev_btn.setEnabled(False) + self._next_btn.setEnabled(False) + + # Clear all result pages (not just songs) + self._singers_page.clear() + self._albums_page.clear() + self._playlists_page.clear() + + self._do_search() + + def _on_search_focus_gained(self): + """Handle search input focus gained - show hotkey popup if empty.""" + text = self._search_input.text().strip() + if not text: + # Always show popup when gaining focus and input is empty + # Even if popup is already visible + self._show_hotkey_popup() + + def _on_search_focus_lost(self): + """Handle search input focus lost - hide hotkey popup.""" + # Delay hiding to allow click on hotkey items + QTimer.singleShot(200, self._hide_hotkey_popup) + + def _show_hotkey_popup(self): + """Show hotkey popup below search input with search history and hotkeys.""" + if not self._hotkey_popup: + self._hotkey_popup = HotkeyPopup(self) + self._hotkey_popup.hotkey_clicked.connect(self._on_hotkey_clicked) + self._hotkey_popup.clear_history_requested.connect(self._on_clear_history) + self._hotkey_popup.delete_history_requested.connect(self._on_delete_history_item) + + # Get search history + history = self._config.get_search_history() if self._config else [] + + # If we have both history and hotkeys cached, show combined + if history and self._hotkeys: + self._hotkey_popup.set_combined(history, self._hotkeys) + input_rect = self._search_input.rect() + global_pos = self._search_input.mapToGlobal(input_rect.bottomLeft()) + self._hotkey_popup.show_at(global_pos, self._search_input.width()) + # If we have history but no hotkeys, show history and load hotkeys + elif history: + self._hotkey_popup.set_search_history(history) + input_rect = self._search_input.rect() + global_pos = self._search_input.mapToGlobal(input_rect.bottomLeft()) + self._hotkey_popup.show_at(global_pos, self._search_input.width()) + # Load hotkeys in background + if not self._hotkeys and self._qqmusic_service: + self._load_hotkeys() + # If we have hotkeys but no history, show hotkeys + elif self._hotkeys: + self._hotkey_popup.set_hotkeys(self._hotkeys) + input_rect = self._search_input.rect() + global_pos = self._search_input.mapToGlobal(input_rect.bottomLeft()) + self._hotkey_popup.show_at(global_pos, self._search_input.width()) + # No history and no hotkeys - load hotkeys + elif self._qqmusic_service: + self._load_hotkeys() + + def _hide_hotkey_popup(self): + """Hide hotkey popup.""" + if self._hotkey_popup and self._hotkey_popup.isVisible(): + # Only hide if search input doesn't have focus anymore + if not self._search_input.hasFocus(): + self._hotkey_popup.hide() + + def _load_hotkeys(self): + """Load hotkey suggestions from QQ Music.""" + if not self._qqmusic_service: + return + + self._hotkey_request_id += 1 + request_id = self._hotkey_request_id + + self._hotkey_worker = HotkeyWorker(self._qqmusic_service) + self._hotkey_worker.hotkey_ready.connect( + lambda hotkeys, rid=request_id: self._on_hotkey_ready(hotkeys, rid) + ) + self._hotkey_worker.start() + + def _on_hotkey_ready( + self, + hotkeys: List[Dict[str, Any]], + request_id: int | None = None + ): + """Handle hotkey suggestions ready.""" + if request_id is not None and request_id != self._hotkey_request_id: + return + + if hotkeys: + self._hotkeys = hotkeys + # Show popup if search input is still empty and focused + text = self._search_input.text().strip() + if not text and self._search_input.hasFocus(): + if not self._hotkey_popup: + self._hotkey_popup = HotkeyPopup(self) + self._hotkey_popup.hotkey_clicked.connect(self._on_hotkey_clicked) + self._hotkey_popup.clear_history_requested.connect(self._on_clear_history) + self._hotkey_popup.delete_history_requested.connect(self._on_delete_history_item) + + # Get search history and show combined + history = self._config.get_search_history() if self._config else [] + if history and hotkeys: + self._hotkey_popup.set_combined(history, hotkeys) + elif history: + self._hotkey_popup.set_search_history(history) + else: + self._hotkey_popup.set_hotkeys(hotkeys) + + # Position popup below search input + input_rect = self._search_input.rect() + global_pos = self._search_input.mapToGlobal(input_rect.bottomLeft()) + self._hotkey_popup.show_at(global_pos, self._search_input.width()) + + def _on_hotkey_clicked(self, title: str): + """Handle hotkey button click.""" + self._search_input.setText(title) + self._on_search() + + def _on_clear_history(self): + """Handle clear all search history.""" + if self._config: + self._config.clear_search_history() + # Refresh popup if it's visible + if self._hotkey_popup and self._hotkey_popup.isVisible(): + self._show_hotkey_popup() + + def _on_delete_history_item(self, keyword: str): + """Handle delete a search history item.""" + if self._config: + self._config.remove_search_history_item(keyword) + # Refresh popup + self._show_hotkey_popup() + + def _on_escape_pressed(self): + """Handle Escape key press - hide both hotkey popup and completer popup, then clear focus.""" + # Hide hotkey popup + if self._hotkey_popup and self._hotkey_popup.isVisible(): + self._hotkey_popup.hide() + + # Hide completer popup + if self._completer and self._completer.popup().isVisible(): + self._completer.popup().hide() + + # Clear focus from search input + self._search_input.clearFocus() + + def _on_search_text_changed(self, text: str): + """Handle search text change - show top lists when cleared.""" + # Hide hotkey popup when user starts typing + if text and self._hotkey_popup and self._hotkey_popup.isVisible(): + self._hotkey_popup.hide() + + if not text and self._current_keyword: + # Text was cleared, go back to top lists + self._current_keyword = "" + self._current_page = 1 + self._grid_page = 1 + self._grid_total = 0 + # Don't clear _current_tracks - keep the top list songs that were already loaded + self._tabs.hide() + # Hide back button + self._fav_back_btn.hide() + # Clear grid views + self._singers_page.clear() + self._albums_page.clear() + self._playlists_page.clear() + # Switch to top list page + self._stack.setCurrentWidget(self._top_list_page) + # Show favorites and recommendations when returning to main view + if self._fav_loaded and self._fav_data: + self._favorites_section.show() + if self._recommendations_loaded: + self._recommend_section.show() + elif text and len(text) >= 1 and self._qqmusic_service: + # Trigger completion after delay (debounce) + self._completion_timer.start(300) # 300ms delay + + def _trigger_completion(self): + """Trigger search completion request.""" + keyword = self._search_input.text().strip() + if not keyword or len(keyword) < 1: + return + + self._completion_request_id += 1 + request_id = self._completion_request_id + + # Note: Completion API works without login too + self._completion_worker = CompletionWorker(self._qqmusic_service, keyword) + self._completion_worker.completion_ready.connect( + lambda suggestions, rid=request_id: self._on_completion_ready(suggestions, rid) + ) + self._completion_worker.start() + + def _on_completion_ready( + self, + suggestions: List[Dict[str, Any]], + request_id: int | None = None + ): + """Handle completion suggestions ready.""" + if request_id is not None and request_id != self._completion_request_id: + return + + if not suggestions: + return + + # Extract suggestion hints (the text to display) + suggestion_texts = [s.get('hint', '') for s in suggestions if s.get('hint')] + + logger.info(f"Search completion: {len(suggestion_texts)} suggestions - {suggestion_texts[:3]}") + + # Update completer model + model = QStringListModel(suggestion_texts) + self._completer.setModel(model) + + # Set the completion prefix to current text so matches work correctly + current_text = self._search_input.text() + self._completer.setCompletionPrefix(current_text) + + # Show completion popup - ensure the input still has focus + if suggestion_texts and self._search_input.hasFocus(): + self._completer.complete() + elif suggestion_texts: + # Input doesn't have focus, don't show popup + logger.debug("Search input lost focus, not showing completion") + + def _on_completion_selected(self, text: str): + """Handle completion selection.""" + # Set the selected text and trigger search + self._search_input.setText(text) + self._on_search() + + def _do_search(self): + """Execute search.""" + self._search_request_id += 1 + request_id = self._search_request_id + + self._search_worker = SearchWorker( + self._service, + self._current_keyword, + self._current_search_type, + self._current_page, + 30 + ) + self._search_worker.search_completed.connect( + lambda result, rid=request_id: self._on_search_completed(result, rid) + ) + self._search_worker.search_failed.connect( + lambda error, rid=request_id: self._on_search_failed(error, rid) + ) + self._search_worker.start() + + def _on_search_completed(self, result: SearchResult, request_id: int | None = None): + """Handle search completion.""" + if request_id is not None and request_id != self._search_request_id: + return + + self._current_result = result + self._stack.setCurrentWidget(self._results_page) + self._is_top_list_view = False # Now viewing search results + + if self._current_search_type == SearchType.SONG: + self._current_tracks = result.tracks + self._display_tracks(result.tracks) + self._results_stack.setCurrentWidget(self._songs_page) + elif self._current_search_type == SearchType.SINGER: + self._grid_total = result.total + self._display_artists(result.artists, is_append=False) + self._results_stack.setCurrentWidget(self._singers_page) + elif self._current_search_type == SearchType.ALBUM: + self._grid_total = result.total + self._display_albums(result.albums, is_append=False) + self._results_stack.setCurrentWidget(self._albums_page) + elif self._current_search_type == SearchType.PLAYLIST: + self._grid_total = result.total + self._display_playlists(result.playlists, is_append=False) + self._results_stack.setCurrentWidget(self._playlists_page) + + # Update results info + self._results_info.setText( + f"{t('search_result')}: {result.total} {t('results')}" + ) + + # Update pagination (only for songs) + self._page_label.setText(str(self._current_page)) + self._prev_btn.setEnabled(self._current_page > 1) + self._next_btn.setEnabled(len(result.tracks) == 30) + + # Hide pagination for non-song results + if self._current_search_type != SearchType.SONG: + self._prev_btn.parentWidget().hide() + else: + self._prev_btn.parentWidget().show() + + def _on_search_failed(self, error: str, request_id: int | None = None): + """Handle search failure.""" + if request_id is not None and request_id != self._search_request_id: + return + + logger.error(f"Search failed: {error}") + MessageDialog.warning(self, t("error"), t("search_failed") + f": {error}") + + def _display_tracks(self, tracks: List[OnlineTrack]): + """Display tracks in table.""" + self._results_table.setRowCount(len(tracks)) + self._results_table.setColumnCount(5) + + for i, track in enumerate(tracks): + # Index + self._results_table.setItem(i, 0, QTableWidgetItem(str(i + 1))) + + # Title + title_item = QTableWidgetItem(track.title) + if track.is_vip: + title_item.setForeground(QBrush(QColor("#ffd700"))) + self._results_table.setItem(i, 1, title_item) + + # Artist + self._results_table.setItem(i, 2, QTableWidgetItem(track.singer_name)) + + # Album + self._results_table.setItem(i, 3, QTableWidgetItem(track.album_name)) + + # Duration + duration_str = format_duration(track.duration) if track.duration else "" + self._results_table.setItem(i, 4, QTableWidgetItem(duration_str)) + + def _display_artists(self, artists: List[OnlineArtist], is_append: bool = False): + """Display artists in grid view.""" + if is_append: + self._singers_page.append_data(artists) + else: + self._singers_page.load_data(artists) + + # Show "load more" button if there are more results + has_more = len(artists) >= self._grid_page_size and ( + self._grid_total == 0 or # Unknown total, assume more + self._grid_page * self._grid_page_size < self._grid_total + ) + self._singers_page.set_has_more(has_more) + + def _display_albums(self, albums: List[OnlineAlbum], is_append: bool = False): + """Display albums in grid view.""" + if is_append: + self._albums_page.append_data(albums) + else: + self._albums_page.load_data(albums) + + # Show "load more" button if there are more results + has_more = len(albums) >= self._grid_page_size and ( + self._grid_total == 0 or + self._grid_page * self._grid_page_size < self._grid_total + ) + self._albums_page.set_has_more(has_more) + + def _display_playlists(self, playlists: List[OnlinePlaylist], is_append: bool = False): + """Display playlists in grid view.""" + if is_append: + self._playlists_page.append_data(playlists) + else: + self._playlists_page.load_data(playlists) + + # Show "load more" button if there are more results + has_more = len(playlists) >= self._grid_page_size and ( + self._grid_total == 0 or + self._grid_page * self._grid_page_size < self._grid_total + ) + self._playlists_page.set_has_more(has_more) + + def _on_tab_changed(self, index: int): + """Handle tab change.""" + type_map = { + 0: SearchType.SONG, + 1: SearchType.SINGER, + 2: SearchType.ALBUM, + 3: SearchType.PLAYLIST, + } + self._current_search_type = type_map.get(index, SearchType.SONG) + + # Re-search if there's a keyword + if self._current_keyword: + self._current_page = 1 + self._grid_page = 1 # Reset grid page for new tab + self._do_search() + + def _on_artist_clicked(self, artist: OnlineArtist): + """Handle artist click - show artist detail view.""" + # Push navigation state if we're coming from search results or grid view + if self._stack.currentWidget() in [self._results_page]: + self._navigation_stack.append({ + 'page': 'results', + 'tab': 'artists' if self._results_stack.currentWidget() == self._singers_page else 'other' + }) + self._detail_view.load_artist(artist.mid, artist.name) + self._stack.setCurrentWidget(self._detail_view) + + def _on_album_clicked(self, album: OnlineAlbum): + """Handle album click - show album detail view.""" + # Push navigation state if we're coming from search results or detail view + current_widget = self._stack.currentWidget() + if current_widget == self._results_page: + self._navigation_stack.append({ + 'page': 'results', + 'tab': 'albums' if self._results_stack.currentWidget() == self._albums_page else 'other' + }) + elif current_widget == self._detail_view: + # Coming from artist detail - push detail state + self._navigation_stack.append({ + 'page': 'detail', + 'type': self._detail_view._detail_type, + 'mid': self._detail_view._mid + }) + self._detail_view.load_album(album.mid, album.name, album.singer_name) + self._stack.setCurrentWidget(self._detail_view) + + def _on_playlist_clicked(self, playlist: OnlinePlaylist): + """Handle playlist click - show playlist detail view.""" + # Push navigation state if we're coming from search results or detail view + current_widget = self._stack.currentWidget() + if current_widget == self._results_page: + self._navigation_stack.append({ + 'page': 'results', + 'tab': 'playlists' if self._results_stack.currentWidget() == self._playlists_page else 'other' + }) + elif current_widget == self._detail_view: + # Coming from artist detail - push detail state + self._navigation_stack.append({ + 'page': 'detail', + 'type': self._detail_view._detail_type, + 'mid': self._detail_view._mid + }) + self._detail_view.load_playlist(playlist.id, playlist.title, playlist.creator) + self._stack.setCurrentWidget(self._detail_view) + + def _on_load_more_artists(self): + """Load more artists.""" + self._grid_page += 1 + self._singers_page.show_loading() + self._load_more_grid(SearchType.SINGER) + + def _on_load_more_albums(self): + """Load more albums.""" + self._grid_page += 1 + self._albums_page.show_loading() + self._load_more_grid(SearchType.ALBUM) + + def _on_load_more_playlists(self): + """Load more playlists.""" + self._grid_page += 1 + self._playlists_page.show_loading() + self._load_more_grid(SearchType.PLAYLIST) + + def _load_more_grid(self, search_type: str): + """Load more items for grid view.""" + self._search_request_id += 1 + request_id = self._search_request_id + + self._search_worker = SearchWorker( + self._service, + self._current_keyword, + search_type, + self._grid_page, + self._grid_page_size + ) + self._search_worker.search_completed.connect( + lambda result, rid=request_id: self._on_load_more_completed(result, search_type, rid) + ) + self._search_worker.search_failed.connect( + lambda error, rid=request_id: self._on_load_more_failed(error, rid) + ) + self._search_worker.start() + + def _on_load_more_completed( + self, + result: SearchResult, + search_type: str, + request_id: int | None = None + ): + """Handle load more completion.""" + if request_id is not None and request_id != self._search_request_id: + return + + if search_type == SearchType.SINGER: + self._singers_page.hide_loading() + self._display_artists(result.artists, is_append=True) + elif search_type == SearchType.ALBUM: + self._albums_page.hide_loading() + self._display_albums(result.albums, is_append=True) + elif search_type == SearchType.PLAYLIST: + self._playlists_page.hide_loading() + self._display_playlists(result.playlists, is_append=True) + + # Update total + self._grid_total = result.total + + def _on_load_more_failed(self, error: str, request_id: int | None = None): + """Handle load more failure.""" + if request_id is not None and request_id != self._search_request_id: + return + + logger.error(f"Load more failed: {error}") + # Hide loading on all grid views + self._singers_page.hide_loading() + self._albums_page.hide_loading() + self._playlists_page.hide_loading() + MessageDialog.warning(self, t("error"), t("search_failed") + f": {error}") + + def _on_back_from_detail(self): + """Handle back button clicked in detail view.""" + # Pop from navigation stack if available + if self._navigation_stack: + prev_state = self._navigation_stack.pop() + page = prev_state.get('page') + + if page == 'playlists': + # Return to playlist list + title = prev_state.get('title', '') + playlists = prev_state.get('data', []) + self._show_playlist_list_in_detail(title, playlists) + return + elif page == 'albums': + # Return to album list + title = prev_state.get('title', '') + albums = prev_state.get('data', []) + self._show_album_list_in_detail(title, albums) + return + elif page == 'results': + # Return to search results + self._stack.setCurrentWidget(self._results_page) + # Restore correct tab if specified + tab = prev_state.get('tab', '') + if tab == 'artists': + self._results_stack.setCurrentWidget(self._singers_page) + elif tab == 'albums': + self._results_stack.setCurrentWidget(self._albums_page) + elif tab == 'playlists': + self._results_stack.setCurrentWidget(self._playlists_page) + return + elif page == 'detail': + # Return to previous detail view (e.g., artist detail) + detail_type = prev_state.get('type', '') + mid = prev_state.get('mid') + if detail_type == 'artist' and mid: + # Reload artist detail + self._detail_view.load_artist(mid) + return + elif detail_type == 'album' and mid: + # Reload album detail + self._detail_view.load_album(mid) + return + elif detail_type == 'playlist' and mid: + # Reload playlist detail + self._detail_view.load_playlist(mid) + return + + # Default behavior: return to previous page based on context + # If tabs are visible, we came from search results + # Otherwise, return to top list page + if self._tabs.isVisible(): + self._stack.setCurrentWidget(self._results_page) + else: + self._stack.setCurrentWidget(self._top_list_page) + # Show favorites and recommendations when returning to main view + if self._fav_loaded and self._fav_data: + self._favorites_section.show() + if self._recommendations_loaded: + self._recommend_section.show() + + def _on_fav_back_clicked(self): + """Handle back button click from favorites view.""" + # Hide back button + self._fav_back_btn.hide() + # Clear navigation stack when returning to main view + self._navigation_stack.clear() + # Show favorites and recommendations + if self._fav_loaded and self._fav_data: + self._favorites_section.show() + if self._recommendations_loaded: + self._recommend_section.show() + # Return to top list page + self._stack.setCurrentWidget(self._top_list_page) + + def _get_cover_url(self, track: OnlineTrack) -> str: + """Get cover URL for online track.""" + if track.album and track.album.mid: + return f"https://y.qq.com/music/photo_new/T002R300x300M000{track.album.mid}.jpg" + return "" + + def _build_track_metadata(self, track: OnlineTrack) -> Dict[str, Any]: + """Build standardized metadata payload for online track playback/queue actions.""" + return { + "title": track.title, + "artist": track.singer_name, + "album": track.album_name, + "duration": track.duration, + "album_mid": track.album.mid if track.album else "", + "cover_url": self._get_cover_url(track), + } + + def _build_tracks_payload(self, tracks: List[OnlineTrack]) -> List[tuple[str, Dict[str, Any]]]: + """Build `(song_mid, metadata)` payload list while preserving input order.""" + return [(track.mid, self._build_track_metadata(track)) for track in tracks] + + def _on_play_all_from_detail(self, tracks: List[OnlineTrack], index: int = 0): + """Handle play all from detail view.""" + if not tracks: + return + + # Build list of (song_mid, metadata) for all tracks + tracks_data = self._build_tracks_payload(tracks) + + # Emit signal to play all tracks, starting from first + self.play_online_tracks.emit(index, tracks_data) + + def _on_add_all_to_queue_from_detail(self, tracks: List[OnlineTrack]): + """Handle add all to queue from detail view.""" + tracks_data = self._build_tracks_payload(tracks) + self.add_multiple_to_queue.emit(tracks_data) + + def _on_insert_all_to_queue_from_detail(self, tracks: List[OnlineTrack]): + """Handle insert all to queue from detail view.""" + tracks_data = self._build_tracks_payload(tracks) + self.insert_multiple_to_queue.emit(tracks_data) + + def _on_prev_page(self): + """Go to previous page.""" + if self._current_page > 1: + self._current_page -= 1 + self._do_search() + + def _on_next_page(self): + """Go to next page.""" + self._current_page += 1 + self._do_search() + + def _on_track_double_clicked(self, index): + """Handle track double click.""" + row = index.row() + if row < 0 or row >= len(self._current_tracks): + return + + # If viewing top list, play all songs starting from clicked + if self._is_top_list_view: + self._play_all_from_top_list(row) + else: + track = self._current_tracks[row] + self._play_track(track) + + def _play_all_from_top_list(self, start_index: int): + """Play all songs from top list starting from given index.""" + tracks_data = self._build_tracks_payload(self._current_tracks) + + self.play_online_tracks.emit(start_index, tracks_data) + + def _play_track(self, track: OnlineTrack): + """Play an online track.""" + # Build metadata from track info + metadata = self._build_track_metadata(track) + + # Check cache + if self._download_service.is_cached(track.mid): + cached_path = self._download_service.get_cached_path(track.mid) + self.play_online_track.emit(track.mid, cached_path, metadata) + return + + # Download first + self._download_and_play(track) + + def _download_and_play(self, track: OnlineTrack): + """Download track and then play.""" + from PySide6.QtWidgets import QProgressDialog + + # Show progress dialog + self._download_progress = QProgressDialog(f"{t('downloading')}: {track.title}", t("cancel"), 0, 0, self) + self._download_progress.setWindowTitle(t("downloading")) + self._download_progress.setWindowModality(Qt.WindowModal) + self._download_progress.setMinimumDuration(0) + self._download_progress.canceled.connect(lambda: self._cancel_download(track.mid)) + + # Store current track for callback + self._downloading_track = track + + # Create download worker + self._download_worker = DownloadWorker( + self._download_service, track.mid, track.title + ) + self._download_worker.download_finished.connect(self._on_download_finished) + self._attach_download_worker_cleanup( + self._download_worker, + single_attr="_download_worker", + ) + self._download_worker.start() + + def _on_download_finished(self, song_mid: str, local_path: str): + """Handle download finished.""" + logger.info(f"Download finished callback: mid={song_mid}, path={local_path}") + + # Close progress dialog + if hasattr(self, '_download_progress') and self._download_progress: + self._download_progress.close() + + # Get stored track + track = getattr(self, '_downloading_track', None) + if not track: + logger.error("No downloading_track found") + return + + # Skip if download was cancelled + if hasattr(self, '_download_worker') and self._download_worker._cancelled: + logger.info(f"Download cancelled: {song_mid}") + return + + if song_mid == track.mid and local_path: + logger.info(f"Emitting play_online_track: {song_mid}, {local_path}") + # Build metadata from track info + metadata = self._build_track_metadata(track) + self.play_online_track.emit(song_mid, local_path, metadata) + else: + logger.warning(f"Download failed or mismatch: mid={song_mid}, track.mid={track.mid}, path={local_path}") + MessageDialog.warning(self, t("error"), t("download_failed")) + + def _cancel_download(self, song_mid: str): + """Cancel ongoing download.""" + if hasattr(self, '_download_worker') and self._download_worker: + self._download_worker.cancel() + if hasattr(self, '_download_progress') and self._download_progress: + self._download_progress.close() + + def _attach_download_worker_cleanup(self, worker, *, list_attr: str = None, single_attr: str = None): + """Release finished download workers and schedule QObject cleanup.""" + + def on_thread_finished(): + if list_attr: + workers = getattr(self, list_attr, None) + if workers is not None and worker in workers: + workers.remove(worker) + if single_attr and getattr(self, single_attr, None) is worker: + setattr(self, single_attr, None) + worker.deleteLater() + + worker.finished.connect(on_thread_finished) + + def _show_track_context_menu(self, pos): + """Show context menu for track.""" + # Determine which table sent the signal + sender_table = self.sender() + if sender_table == self._top_songs_table: + table = self._top_songs_table + is_top_list = True + else: + table = self._results_table + is_top_list = False + + # Get selected rows + selected_items = table.selectedItems() + if not selected_items: + logger.debug(f"No items selected in {'top list' if is_top_list else 'search'} table") + return + + # Get unique row indices + selected_rows = sorted(set(item.row() for item in selected_items)) + if not selected_rows: + return + + # Validate row indices + if selected_rows[0] < 0 or selected_rows[-1] >= len(self._current_tracks): + logger.warning(f"Invalid row indices: {selected_rows}, tracks count: {len(self._current_tracks)}") + return + + tracks = [self._current_tracks[r] for r in selected_rows if 0 <= r < len(self._current_tracks)] + if not tracks: + logger.warning("No valid tracks found for selected rows") + return + + menu = QMenu(self) + menu.setStyleSheet(get_qss(self._STYLE_MENU)) + + play_action = menu.addAction(t("play")) + play_action.triggered.connect(lambda: self._play_selected_tracks(tracks)) + + insert_to_queue_action = menu.addAction(t("insert_to_queue")) + insert_to_queue_action.triggered.connect(lambda: self._insert_selected_to_queue(tracks)) + + add_to_queue_action = menu.addAction(t("add_to_queue")) + add_to_queue_action.triggered.connect(lambda: self._add_selected_to_queue(tracks)) + + menu.addSeparator() + + # Add to favorites action + add_to_favorites_action = menu.addAction(t("add_to_favorites")) + add_to_favorites_action.triggered.connect(lambda: self._add_selected_to_favorites(tracks)) + + # Add to playlist action + add_to_playlist_action = menu.addAction(t("add_to_playlist")) + add_to_playlist_action.triggered.connect(lambda: self._add_selected_to_playlist(tracks)) + + menu.addSeparator() + + download_action = menu.addAction(t("download")) + download_action.triggered.connect(lambda: self._download_selected_tracks(tracks)) + + menu.exec(table.viewport().mapToGlobal(pos)) + + def _download_selected_tracks(self, tracks: List[OnlineTrack]): + """Download selected tracks.""" + if not tracks: + return + + # Download each track + for track in tracks: + if not self._download_service.is_cached(track.mid): + self._start_download(track) + + def _start_download(self, track: OnlineTrack): + """Start downloading a track.""" + worker = DownloadWorker(self._download_service, track.mid, track.title) + worker.download_finished.connect(self._on_batch_download_finished) + self._attach_download_worker_cleanup(worker, list_attr="_download_workers") + worker.start() + # Keep reference to prevent garbage collection + if not hasattr(self, '_download_workers'): + self._download_workers = [] + self._download_workers.append(worker) + + def _on_batch_download_finished(self, song_mid: str, local_path: str): + """Handle batch download finished.""" + if local_path: + logger.info(f"Download completed: {song_mid} -> {local_path}") + else: + logger.warning(f"Download failed: {song_mid}") + + def _add_selected_to_favorites(self, tracks: List[OnlineTrack]): + """Add selected online tracks to favorites.""" + if not tracks: + return + + added_count = 0 + current_bootstrap = bootstrap() + if current_bootstrap is None: + return + favorites_service = current_bootstrap.favorites_service + + for track in tracks: + track_id = self._add_online_track_to_library(track) + if track_id: + favorites_service.add_favorite(track_id=track_id) + added_count += 1 + # Update ranking view UI if track is visible + if hasattr(self, '_ranking_list_view'): + self._ranking_list_view.set_track_favorite(track.mid, True) + + if added_count > 0: + logger.info(f"[OnlineMusicView] Added {added_count} tracks to favorites") + MessageDialog.information( + self, + t("success"), + t("added_x_tracks_to_favorites").format(count=added_count) + ) + + def _add_selected_to_playlist(self, tracks: List[OnlineTrack]): + """Add selected online tracks to playlist.""" + if not tracks: + return + + # Add tracks to library first and collect track IDs + track_ids = [] + for track in tracks: + track_id = self._add_online_track_to_library(track) + if track_id: + track_ids.append(track_id) + + if not track_ids: + return + + add_track_ids_to_playlist(self, track_ids, "[OnlineMusicView]") + + def _add_online_track_to_library(self, track: OnlineTrack) -> Optional[int]: + """Add online track to library, return track_id.""" + current_bootstrap = bootstrap() + if current_bootstrap is None or not current_bootstrap.library_service: + return None + + cover_url = self._get_cover_url(track) + + return current_bootstrap.library_service.add_online_track( + song_mid=track.mid, + title=track.title, + artist=track.singer_name, + album=track.album_name, + duration=float(track.duration), + cover_url=cover_url + ) + + def _play_selected_tracks(self, tracks: List[OnlineTrack]): + """Play selected tracks.""" + if not tracks: + return + # Play first track and add rest to queue + self._play_track(tracks[0]) + if len(tracks) > 1: + tracks_data = self._build_tracks_payload(tracks[1:]) + self.add_multiple_to_queue.emit(tracks_data) + + def _add_selected_to_queue(self, tracks: List[OnlineTrack]): + """Add selected tracks to queue.""" + tracks_data = self._build_tracks_payload(tracks) + self.add_multiple_to_queue.emit(tracks_data) + + def _insert_selected_to_queue(self, tracks: List[OnlineTrack]): + """Insert selected tracks after current playing track.""" + tracks_data = self._build_tracks_payload(tracks) + self.insert_multiple_to_queue.emit(tracks_data) + + def _load_top_lists(self): + """Load top lists.""" + self._stop_worker(self._top_list_worker, "top_list_worker") + + self._top_list_worker = TopListWorker(self._service) + self._top_list_worker.top_list_loaded.connect(self._on_top_lists_loaded) + self._top_list_worker.start() + + def _on_top_lists_loaded(self, top_lists: List[Dict]): + """Handle top lists loaded.""" + self._top_list_list.clear() + + for top_list in top_lists: + item = QListWidgetItem(top_list.get("title", "")) + item.setData(Qt.UserRole, top_list.get("id")) + self._top_list_list.addItem(item) + + # Select first item + if self._top_list_list.count() > 0: + self._top_list_list.setCurrentRow(0) + + def _on_top_list_selected(self, row: int): + """Handle top list selection.""" + item = self._top_list_list.item(row) + if not item: + return + + top_id = item.data(Qt.UserRole) + if not top_id: + return + + self._selected_top_id = int(top_id) + self._top_list_title.setText(item.text()) + + # Load songs + self._stop_worker(self._top_list_worker, "top_list_worker") + + self._top_list_worker = TopListWorker(self._service, self._selected_top_id) + self._top_list_worker.top_songs_loaded.connect(self._on_top_songs_loaded) + self._top_list_worker.start() + + def _stop_worker(self, worker, worker_name: str): + """Stop a running worker cooperatively without force-terminating threads.""" + if not worker or not isValid(worker): + return + if not worker.isRunning(): + return + + try: + worker.requestInterruption() + except Exception: + logger.debug(f"[OnlineMusicView] requestInterruption failed for {worker_name}", exc_info=True) + + try: + worker.quit() + except Exception: + logger.debug(f"[OnlineMusicView] quit failed for {worker_name}", exc_info=True) + + try: + if not worker.wait(1500): + logger.warning(f"[OnlineMusicView] Worker did not stop in time: {worker_name}") + except Exception: + logger.debug(f"[OnlineMusicView] wait failed for {worker_name}", exc_info=True) + + def _on_top_songs_loaded(self, top_id: int, songs: List[OnlineTrack]): + """Handle top songs loaded.""" + if top_id != self._selected_top_id: + return + + self._current_tracks = songs + self._is_top_list_view = True # Now viewing top list + self._display_top_songs(songs) + + def _display_top_songs(self, songs: List[OnlineTrack]): + """Display top songs in both table and list views.""" + # Update table view + self._top_songs_table.setRowCount(len(songs)) + + for i, song in enumerate(songs): + # Rank + self._top_songs_table.setItem(i, 0, QTableWidgetItem(str(i + 1))) + + # Title + title_item = QTableWidgetItem(song.title) + if song.is_vip: + title_item.setForeground(QBrush(QColor("#ffd700"))) + self._top_songs_table.setItem(i, 1, title_item) + + # Artist + self._top_songs_table.setItem(i, 2, QTableWidgetItem(song.singer_name)) + + # Album + self._top_songs_table.setItem(i, 3, QTableWidgetItem(song.album_name)) + + # Duration + duration_str = format_duration(song.duration) if song.duration else "" + self._top_songs_table.setItem(i, 4, QTableWidgetItem(duration_str)) + + # Update list view + self._ranking_list_view.load_tracks(songs) + + def _load_ranking_view_mode(self): + """Load ranking view mode preference from config.""" + view_mode = self._config.get("view/ranking_view_mode", "table") if self._config else "table" + self._update_ranking_view_toggle_icon() + self._ranking_stacked_widget.setCurrentIndex(0 if view_mode == "table" else 1) + + def _toggle_ranking_view_mode(self): + """Toggle between table and list view for rankings.""" + current_mode = self._config.get("view/ranking_view_mode", "table") if self._config else "table" + new_mode = "list" if current_mode == "table" else "table" + if self._config: + self._config.set("view/ranking_view_mode", new_mode) + self._update_ranking_view_toggle_icon() + self._ranking_stacked_widget.setCurrentIndex(0 if new_mode == "table" else 1) + + def _update_ranking_view_toggle_icon(self): + """Update ranking view toggle button icon.""" + view_mode = self._config.get("view/ranking_view_mode", "table") if self._config else "table" + theme = current_theme() + + if view_mode == "list": + icon = get_icon(IconName.GRID, theme.text_secondary) + self._ranking_view_toggle_btn.setToolTip(t("switch_to_table_view")) + else: + icon = get_icon(IconName.LIST, theme.text_secondary) + self._ranking_view_toggle_btn.setToolTip(t("switch_to_list_view")) + + self._ranking_view_toggle_btn.setIcon(icon) + + def _on_ranking_track_activated(self, track): + """Handle track activation from ranking list view.""" + logger.info(f"Ranking track activated: {track.title}") + self._play_track(track) + + def _on_ranking_favorite_toggled(self, track, is_favorite: bool): + """Handle favorite toggle from ranking list view star click.""" + if not track: + return + current_bootstrap = bootstrap() + if current_bootstrap is None: + return + favorites_service = current_bootstrap.favorites_service + + if is_favorite: + track_id = self._add_online_track_to_library(track) + if track_id: + favorites_service.add_favorite(track_id=track_id) + self._ranking_list_view.set_track_favorite(track.mid, True) + else: + library_track = current_bootstrap.library_service.get_track_by_cloud_file_id(track.mid) + if library_track: + favorites_service.remove_favorite(track_id=library_track.id) + self._ranking_list_view.set_track_favorite(track.mid, False) + else: + favorites_service.remove_favorite(cloud_file_id=track.mid) + self._ranking_list_view.set_track_favorite(track.mid, False) + + def _on_ranking_favorites_toggle(self, tracks: list, all_favorited: bool): + """Handle favorite toggle from ranking list view context menu.""" + for track in tracks: + self._on_ranking_favorite_toggled(track, not all_favorited) + + + def refresh_ui(self): + """Refresh UI texts after language change.""" + # Update titles + if hasattr(self, '_online_music_title'): + self._online_music_title.setText(t("source_qq")) + if hasattr(self, '_rankings_title'): + self._rankings_title.setText(t("rankings")) + + # Update search placeholder + if hasattr(self, '_search_input'): + self._search_input.setPlaceholderText(t("search_online_music")) + + # Update search button + if hasattr(self, '_search_btn'): + self._search_btn.setText(t("search")) + + # Update login button + self._refresh_login_status() + + # Update type tabs + if hasattr(self, '_tabs'): + self._tabs.setTabText(0, t("songs")) + self._tabs.setTabText(1, t("singers")) + self._tabs.setTabText(2, t("albums")) + self._tabs.setTabText(3, t("playlists")) + + # Update table headers for both tables + if hasattr(self, '_results_table'): + header = self._results_table.horizontalHeader() + if header.count() >= 5: + header.model().setHeaderData(0, Qt.Horizontal, "#") + header.model().setHeaderData(1, Qt.Horizontal, t("title")) + header.model().setHeaderData(2, Qt.Horizontal, t("artist")) + header.model().setHeaderData(3, Qt.Horizontal, t("album")) + header.model().setHeaderData(4, Qt.Horizontal, t("duration")) + if hasattr(self, '_top_songs_table'): + header = self._top_songs_table.horizontalHeader() + if header.count() >= 5: + header.model().setHeaderData(0, Qt.Horizontal, "#") + header.model().setHeaderData(1, Qt.Horizontal, t("title")) + header.model().setHeaderData(2, Qt.Horizontal, t("artist")) + header.model().setHeaderData(3, Qt.Horizontal, t("album")) + header.model().setHeaderData(4, Qt.Horizontal, t("duration")) + + # Update pagination buttons + if hasattr(self, '_prev_btn'): + self._prev_btn.setText("← " + t("previous_page")) + if hasattr(self, '_next_btn'): + self._next_btn.setText(t("next_page") + " →") + + # Update top list title if showing "select_ranking" placeholder + if hasattr(self, '_top_list_title'): + current_text = self._top_list_title.text() + # Only update if it's the placeholder text + if current_text == t("select_ranking") or current_text == "选择排行榜": + self._top_list_title.setText(t("select_ranking")) + + # Update grid views + if hasattr(self, '_singers_page'): + self._singers_page.refresh_ui() + if hasattr(self, '_albums_page'): + self._albums_page.refresh_ui() + if hasattr(self, '_playlists_page'): + self._playlists_page.refresh_ui() + + # Update recommend section + if hasattr(self, '_recommend_section'): + self._recommend_section.refresh_ui() + + # Update favorites section + if hasattr(self, '_favorites_section'): + self._favorites_section.refresh_ui() + + # Update detail view + if hasattr(self, '_detail_view'): + self._detail_view.refresh_ui() + + +class DownloadWorker(QThread): + """Background worker for downloading online music.""" + + download_finished = Signal(str, str) # (song_mid, local_path) + + def __init__(self, download_service, song_mid: str, song_title: str): + super().__init__() + self._download_service = download_service + self._song_mid = song_mid + self._song_title = song_title + self._cancelled = False + + def cancel(self): + """Cancel the download.""" + self._cancelled = True + + def run(self): + """Run download.""" + if self._cancelled: + self.download_finished.emit(self._song_mid, "") + return + try: + result = self._download_service.download(self._song_mid, self._song_title) + self.download_finished.emit(self._song_mid, result or "") + except Exception as e: + logger.error(f"Download worker error: {e}") + self.download_finished.emit(self._song_mid, "") diff --git a/plugins/builtin/qqmusic/lib/online_tracks_list_view.py b/plugins/builtin/qqmusic/lib/online_tracks_list_view.py new file mode 100644 index 00000000..8b366e3d --- /dev/null +++ b/plugins/builtin/qqmusic/lib/online_tracks_list_view.py @@ -0,0 +1,618 @@ +""" +Online tracks list view for displaying online music tracks. +""" + +import logging +from contextlib import suppress +from typing import List + +from PySide6.QtCore import Qt, Signal, QSize, QTimer, QPoint, QAbstractListModel, QModelIndex, QRunnable, QThreadPool, QRect +from PySide6.QtGui import QColor, QPainter, QImage, QCursor +from PySide6.QtWidgets import QWidget, QVBoxLayout, QListView, QStyledItemDelegate, QStyleOptionViewItem, QStyle + +from .context_menus import OnlineTrackContextMenu +from .i18n import t +from .cover_hover_popup import CoverHoverPopup +from .models import OnlineTrack +from .runtime_bridge import ( + cover_pixmap_cache_get, + cover_pixmap_cache_initialize, + cover_pixmap_cache_set, + current_theme, + event_bus, + format_duration, + http_get_content, + image_cache_path, + image_cache_set, +) + +logger = logging.getLogger(__name__) + + +def _resolve_online_cover_url(track: OnlineTrack) -> str | None: + """Build a QQ cover URL directly from the known album MID when possible.""" + if not track or not track.album or not track.album.mid: + return None + return f"https://y.gtimg.cn/music/photo_new/T002R500x500M000{track.album.mid}.jpg" + + +def _resolve_online_cover_path(track: OnlineTrack) -> str | None: + """Resolve and cache a QQ cover image without touching host services.""" + cover_url = _resolve_online_cover_url(track) + if not cover_url: + return None + + try: + cache_path = image_cache_path(cover_url) + if cache_path is not None and cache_path.exists(): + return str(cache_path) + + image_data = http_get_content( + cover_url, + timeout=5, + headers={"Referer": "https://y.qq.com/"}, + ) + if not image_data: + return None + return image_cache_set(cover_url, image_data) + except Exception: + pass + + return None + + +class OnlineTracksModel(QAbstractListModel): + """QAbstractListModel for online track data.""" + + TrackRole = Qt.UserRole + 1 + CoverRole = Qt.UserRole + 2 + IsFavoriteRole = Qt.UserRole + 3 + RankRole = Qt.UserRole + 4 + IsVipRole = Qt.UserRole + 5 + IndexRole = Qt.UserRole + 6 + + cover_ready = Signal(int) + + def __init__(self, parent=None): + super().__init__(parent) + self._tracks: List[OnlineTrack] = [] + self._favorite_mids: set = set() # QQ music song mids + + def rowCount(self, parent=QModelIndex()): + return len(self._tracks) + + def data(self, index, role=Qt.DisplayRole): + if not index.isValid() or index.row() >= len(self._tracks): + return None + row = index.row() + track = self._tracks[row] + if role == self.TrackRole: + return track + elif role == self.CoverRole: + return None + elif role == self.IsFavoriteRole: + return track.mid in self._favorite_mids if track else False + elif role == self.RankRole: + return row + 1 + elif role == self.IsVipRole: + return track.pay_play if track else False + elif role == self.IndexRole: + return row + return None + + def roleNames(self): + return { + Qt.DisplayRole: b"display", + self.TrackRole: b"track", + self.CoverRole: b"cover", + self.IsFavoriteRole: b"favorite", + self.RankRole: b"rank", + self.IsVipRole: b"vip", + self.IndexRole: b"index", + } + + def reset_tracks(self, tracks: List[OnlineTrack], favorite_mids: set): + self.beginResetModel() + self._tracks = list(tracks) + self._favorite_mids = set(favorite_mids) + self.endResetModel() + + def update_favorites(self, favorite_mids: set): + """Update favorite MIDs and emit dataChanged for affected rows.""" + old_favs = self._favorite_mids + self._favorite_mids = set(favorite_mids) + + # Find rows that changed + for i, track in enumerate(self._tracks): + if track and (track.mid in old_favs) != (track.mid in self._favorite_mids): + idx = self.index(i) + self.dataChanged.emit(idx, idx, [self.IsFavoriteRole]) + + def get_track_at(self, row: int): + if 0 <= row < len(self._tracks): + return self._tracks[row] + return None + + def notify_cover_loaded(self, row: int): + if 0 <= row < len(self._tracks): + idx = self.index(row) + self.dataChanged.emit(idx, idx, [self.CoverRole]) + + +class OnlineCoverLoadWorker(QRunnable): + """Worker to load online cover in background thread.""" + + def __init__(self, cache_key: str, track: OnlineTrack, callback_signal): + super().__init__() + self.cache_key = cache_key + self.track = track + self.callback_signal = callback_signal + self.setAutoDelete(True) + + def run(self): + try: + cover_path = self._resolve_online_cover() + qimage = None + if cover_path: + qimage = QImage(cover_path) + with suppress(RuntimeError): + self.callback_signal.emit(self.cache_key, cover_path, qimage) + except Exception: + pass + + def _resolve_online_cover(self) -> str | None: + """Resolve online cover for QQ music track.""" + return _resolve_online_cover_path(self.track) + + +class OnlineTracksDelegate(QStyledItemDelegate): + """Delegate for painting online track items without per-item QWidget overhead.""" + + _cover_loaded_signal = Signal(str, object, object) + + def __init__(self, parent=None): + super().__init__(parent) + self._cover_loaded_signal.connect(self._on_cover_loaded) + self._requested_covers: set = set() + self._failed_covers: set = set() + cover_pixmap_cache_initialize() + self._cover_size = 64 + self._rank_width = 50 + self._padding = 10 + self._star_size = 20 + + def sizeHint(self, option, index): + return QSize(0, 82) + + def paint(self, painter: QPainter, option: QStyleOptionViewItem, index: QModelIndex): + # Skip off-screen items + parent_view = self.parent() + if parent_view and (option.rect.bottom() < 0 or option.rect.top() > parent_view.height()): + return + + theme = current_theme() + + track = index.data(OnlineTracksModel.TrackRole) + rank = index.data(OnlineTracksModel.RankRole) + is_vip = index.data(OnlineTracksModel.IsVipRole) + row = index.data(OnlineTracksModel.IndexRole) + + if not track: + return + + painter.save() + painter.setRenderHint(QPainter.Antialiasing) + + rect = option.rect + + # Background + is_hovered = option.state & QStyle.StateFlag.State_MouseOver + is_selected = option.state & QStyle.StateFlag.State_Selected + + if is_selected: + painter.fillRect(rect, QColor(theme.highlight)) + elif is_hovered: + hover_bg = QColor(theme.background_hover) + hover_bg.setAlpha(220) + painter.fillRect(rect, hover_bg) + # Hand cursor on hover + if self.parent(): + self.parent().setCursor(Qt.CursorShape.PointingHandCursor) + else: + bg = QColor(theme.background) + bg.setAlpha(220) + painter.fillRect(rect, bg) + + # Separator line + if not is_selected: + painter.setPen(QColor(theme.background_hover)) + painter.drawLine(rect.left(), rect.bottom(), rect.right(), rect.bottom()) + + # Text colors + if is_selected: + text_color = QColor(theme.background) + secondary_color = QColor(theme.background) + elif is_vip: + # VIP tracks: gold title + text_color = QColor("#FFD700") + secondary_color = QColor(theme.text_secondary) + else: + text_color = QColor(theme.text) + secondary_color = QColor(theme.text_secondary) + + x = rect.left() + self._padding + + # Index number + painter.setPen(secondary_color) + font = painter.font() + font.setPixelSize(12) + font.setBold(False) + painter.setFont(font) + + painter.drawText(x, rect.top(), self._rank_width, rect.height(), + Qt.AlignVCenter | Qt.AlignHCenter, str(rank)) + x += self._rank_width + + # Cover art + cover_rect = QRect(x + 2, rect.top() + 9, self._cover_size, self._cover_size) + self._paint_cover(painter, cover_rect, track, row, theme) + x += self._cover_size + 12 + + # Title (with VIP indicator) + title = track.title or "Unknown" + if is_vip: + title = f"VIP {title}" + + painter.setPen(text_color) + font.setPixelSize(15) + font.setBold(True) + painter.setFont(font) + title_rect = QRect(x, rect.top() + 10, rect.right() - x - 100, 22) + painter.drawText(title_rect, Qt.AlignLeft | Qt.AlignVCenter, + self._elided_text(painter, title, title_rect.width())) + + # Artist + Album + artist = track.singer_name or "Unknown" + album = track.album_name or "" + artist_album = artist + (f" • {album}" if album else "") + + painter.setPen(secondary_color) + font.setPixelSize(13) + font.setBold(False) + painter.setFont(font) + info_rect = QRect(x, rect.top() + 32, rect.right() - x - 100, 20) + painter.drawText(info_rect, Qt.AlignLeft | Qt.AlignVCenter, + self._elided_text(painter, artist_album, info_rect.width())) + + # Source indicator (QQ Music) + source_text = t("source_qq") + painter.setPen(secondary_color) + font.setPixelSize(11) + font.setBold(False) + painter.setFont(font) + source_rect = QRect(x, rect.top() + 52, rect.right() - x - 100, 16) + painter.drawText(source_rect, Qt.AlignLeft | Qt.AlignVCenter, + self._elided_text(painter, source_text, source_rect.width())) + + # Duration + duration = track.duration or 0 + duration_text = format_duration(duration) + font.setPixelSize(12) + painter.setFont(font) + painter.drawText(rect.right() - self._padding - 50 - self._star_size - 10, rect.top(), 50, rect.height(), + Qt.AlignVCenter | Qt.AlignRight, duration_text) + + painter.restore() + + def _paint_cover(self, painter: QPainter, rect: QRect, track: OnlineTrack, row: int, theme): + """Paint cover art with caching and async loading.""" + from PySide6.QtGui import QPixmap as Pm + + cache_key = self._get_cover_cache_key(track) + + # Try cache + cached = cover_pixmap_cache_get(cache_key) + if cached and not cached.isNull(): + painter.drawPixmap(rect, cached) + else: + # Draw placeholder + placeholder = Pm(self._cover_size, self._cover_size) + placeholder.fill(QColor(theme.background_alt)) + p = QPainter(placeholder) + p.setRenderHint(QPainter.Antialiasing) + p.setPen(QColor(theme.border)) + font = p.font() + font.setPixelSize(28) + p.setFont(font) + p.drawText(0, 0, self._cover_size, self._cover_size, Qt.AlignCenter, "♪") + p.end() + painter.drawPixmap(rect, placeholder) + + # Request async load + if cache_key not in self._requested_covers and cache_key not in self._failed_covers: + self._requested_covers.add(cache_key) + worker = OnlineCoverLoadWorker(cache_key, track, self._cover_loaded_signal) + QThreadPool.globalInstance().start(worker) + + # Preload nearby covers (±3 rows) + parent_view = self.parent() + if parent_view and hasattr(parent_view, '_model'): + model = parent_view._model + for offset in [-3, -2, -1, 1, 2, 3]: + nearby_row = row + offset + if 0 <= nearby_row < model.rowCount(): + nearby_track = model.get_track_at(nearby_row) + if nearby_track: + nearby_key = self._get_cover_cache_key(nearby_track) + if nearby_key not in self._requested_covers and nearby_key not in self._failed_covers and not cover_pixmap_cache_get( + nearby_key + ): + self._requested_covers.add(nearby_key) + worker = OnlineCoverLoadWorker(nearby_key, nearby_track, self._cover_loaded_signal) + QThreadPool.globalInstance().start(worker) + + def _on_cover_loaded(self, cache_key: str, cover_path: str, qimage): + """Handle cover loaded from background — runs on UI thread.""" + self._requested_covers.discard(cache_key) + + parent_view = self.parent() + if parent_view and hasattr(parent_view, '_on_cover_ready'): + parent_view._on_cover_ready(cache_key, cover_path, qimage) + + def _get_cover_cache_key(self, track: OnlineTrack) -> str: + """Generate cache key for an online track.""" + return f"QQ:{track.mid}" + + def cover_rect_for_item(self, item_rect: QRect) -> QRect: + """Return the clickable cover rectangle for an item.""" + x = item_rect.left() + self._padding + self._rank_width + return QRect(x + 2, item_rect.top() + 9, self._cover_size, self._cover_size) + + @staticmethod + def _elided_text(painter, text: str, max_width: int) -> str: + """Return elided text if too wide.""" + fm = painter.fontMetrics() + if fm.horizontalAdvance(text) <= max_width: + return text + return fm.elidedText(text, Qt.ElideRight, max_width) + + +class OnlineTracksListView(QWidget): + """List view for online tracks with delegate-based rendering.""" + + track_activated = Signal(object) # OnlineTrack + favorite_toggled = Signal(object, bool) # OnlineTrack, is_favorite + play_requested = Signal(list) + insert_to_queue_requested = Signal(list) + add_to_queue_requested = Signal(list) + add_to_playlist_requested = Signal(list) + favorites_toggle_requested = Signal(list, bool) # (tracks, all_favorited) + qq_fav_toggle_requested = Signal(list, bool) # (tracks, all_favorited) - QQ Music remote + download_requested = Signal(list) + + def __init__(self, parent=None): + super().__init__(parent) + self._model = OnlineTracksModel(self) + self._delegate = OnlineTracksDelegate(self) + self._cover_popup = CoverHoverPopup() + self._hover_timer = QTimer(self) + self._hover_timer.setSingleShot(True) + self._hover_timer.timeout.connect(self._show_cover_popup) + self._hovered_row = -1 + self._last_cover_pos = QPoint() + self._setup_ui() + self._setup_connections() + self._context_menu = OnlineTrackContextMenu(self) + self._connect_context_menu() + + def _setup_ui(self): + layout = QVBoxLayout(self) + layout.setContentsMargins(0, 0, 0, 0) + layout.setSpacing(0) + + self._list_view = QListView() + self._apply_viewport_bg() + self._list_view.setModel(self._model) + self._list_view.setItemDelegate(self._delegate) + self._list_view.setSelectionMode(QListView.SelectionMode.ExtendedSelection) + self._list_view.setSelectionBehavior(QListView.SelectionBehavior.SelectRows) + self._list_view.setContextMenuPolicy(Qt.ContextMenuPolicy.CustomContextMenu) + self._list_view.setMouseTracking(True) + self._list_view.viewport().installEventFilter(self) + self._list_view.setUniformItemSizes(True) + self._list_view.setVerticalScrollMode(QListView.ScrollMode.ScrollPerPixel) + + layout.addWidget(self._list_view) + + def _setup_connections(self): + self._list_view.activated.connect(self._on_item_activated) + self._list_view.customContextMenuRequested.connect(self._show_context_menu) + self._list_view.clicked.connect(self._on_item_clicked) + + # Event bus + bus = event_bus() + bus.favorite_changed.connect(self._on_favorite_changed) + + def closeEvent(self, event): + """Clean up event bus connections before closing.""" + with suppress(RuntimeError): + event_bus().favorite_changed.disconnect(self._on_favorite_changed) + self._hover_timer.stop() + self._cover_popup.hide() + super().closeEvent(event) + + def eventFilter(self, obj, event): + """Filter viewport events to drive cover hover popup.""" + if obj == self._list_view.viewport(): + if event.type() == event.Type.MouseMove: + self._handle_mouse_move(event) + elif event.type() == event.Type.Leave: + self._handle_mouse_leave() + return super().eventFilter(obj, event) + + def _handle_mouse_move(self, event): + """Handle mouse move to detect cover hover.""" + pos = event.pos() + index = self._list_view.indexAt(pos) + + if not index.isValid(): + self._handle_mouse_leave() + return + + row = index.row() + item_rect = self._list_view.visualRect(index) + cover_rect = self._delegate.cover_rect_for_item(item_rect) + + if cover_rect.contains(pos): + if self._hovered_row != row: + self._hovered_row = row + self._last_cover_pos = QCursor.pos() + self._hover_timer.start(500) + else: + self._cover_popup.cancel_hide() + else: + self._handle_mouse_leave() + + def _handle_mouse_leave(self): + """Handle mouse leaving cover hover area.""" + # Fast-path: avoid repeated hide scheduling when already idle. + if self._hovered_row == -1 and not self._hover_timer.isActive(): + return + self._hover_timer.stop() + self._hovered_row = -1 + self._cover_popup.schedule_hide() + + def _show_cover_popup(self): + """Show cover popup for the currently hovered row.""" + if self._hovered_row < 0 or self._hovered_row >= self._model.rowCount(): + return + + track = self._model.get_track_at(self._hovered_row) + if not track: + return + + cache_key = self._delegate._get_cover_cache_key(track) + cover_path = _resolve_online_cover_path(track) + self._cover_popup.show_cover(cover_path, cache_key, self._last_cover_pos) + + def _on_item_activated(self, index): + track = index.data(OnlineTracksModel.TrackRole) + if track: + self.track_activated.emit(track) + + def _on_item_clicked(self, index): + """Handle click events - check if star icon was clicked.""" + from PySide6.QtGui import QCursor + + # Get click position + pos = self._list_view.mapFromGlobal(QCursor.pos()) + rect = self._list_view.visualRect(index) + + # Check if click is in star icon area + star_size = 20 + padding = 10 + star_area = QRect( + rect.right() - padding - star_size, + rect.top(), + star_size + padding, + rect.height() + ) + + if star_area.contains(pos): + # Toggle favorite + track = index.data(OnlineTracksModel.TrackRole) + if track: + is_favorite = index.data(OnlineTracksModel.IsFavoriteRole) + self._toggle_favorite(track, not is_favorite) + + def _toggle_favorite(self, track: OnlineTrack, new_state: bool): + """Toggle favorite status for online track.""" + self.favorite_toggled.emit(track, new_state) + + def set_track_favorite(self, mid: str, is_favorite: bool): + """Update favorite status for a specific track and refresh UI.""" + if is_favorite: + self._model._favorite_mids.add(mid) + else: + self._model._favorite_mids.discard(mid) + for i, track in enumerate(self._model._tracks): + if track.mid == mid: + idx = self._model.index(i) + self._model.dataChanged.emit(idx, idx, [OnlineTracksModel.IsFavoriteRole]) + break + + def _connect_context_menu(self): + self._context_menu.play.connect(self.play_requested) + self._context_menu.insert_to_queue.connect(self.insert_to_queue_requested) + self._context_menu.add_to_queue.connect(self.add_to_queue_requested) + self._context_menu.add_to_playlist.connect(self.add_to_playlist_requested) + self._context_menu.favorite_toggled.connect(self.favorites_toggle_requested) + self._context_menu.qq_fav_toggled.connect(self.qq_fav_toggle_requested) + self._context_menu.download.connect(self.download_requested) + + def _show_context_menu(self, pos): + """Show context menu.""" + indexes = self._list_view.selectedIndexes() + if not indexes: + return + + rows = sorted(set(idx.row() for idx in indexes)) + tracks = [self._model.get_track_at(r) for r in rows] + tracks = [t for t in tracks if t is not None] + + if not tracks: + return + + self._context_menu.show_menu(tracks, favorite_mids=self._model._favorite_mids, parent_widget=self) + + def _on_favorite_changed(self, item_id, is_favorite: bool, is_cloud: bool): + """Handle favorite changed event from EventBus.""" + if not is_cloud: + return + # item_id is cloud_file_id (mid) for cloud tracks + self.set_track_favorite(str(item_id), is_favorite) + + def _on_cover_ready(self, cache_key: str, cover_path: str, qimage): + """Handle cover loaded from background worker.""" + # Find the row for this cache_key + track_row = self._find_row_by_cover_key(cache_key) + + if qimage and not qimage.isNull(): + # Cache the cover + from PySide6.QtGui import QPixmap + pixmap = QPixmap.fromImage(qimage).scaled( + self._delegate._cover_size, + self._delegate._cover_size, + Qt.AspectRatioMode.KeepAspectRatioByExpanding, + Qt.TransformationMode.SmoothTransformation + ) + cover_pixmap_cache_set(cache_key, pixmap) + + if track_row is not None: + self._model.notify_cover_loaded(track_row) + elif track_row is not None: + # No cover found — mark as failed + self._delegate._failed_covers.add(cache_key) + + def _find_row_by_cover_key(self, cache_key: str): + """Find row index for a cover cache key.""" + for row in range(self._model.rowCount()): + track = self._model.get_track_at(row) + if track and self._delegate._get_cover_cache_key(track) == cache_key: + return row + return None + + def _apply_viewport_bg(self): + theme = current_theme() + self._list_view.setStyleSheet( + f"QListView {{ background-color: {theme.background_alt}; border: none; outline: none; }}" + ) + + def load_tracks(self, tracks: List[OnlineTrack], favorite_mids: set = None): + """Load tracks into the view.""" + self._model.reset_tracks(tracks, favorite_mids or set()) + self._apply_viewport_bg() + + def clear(self): + """Clear all tracks.""" + self._model.reset_tracks([], set()) diff --git a/plugins/builtin/qqmusic/lib/provider.py b/plugins/builtin/qqmusic/lib/provider.py index 1d797d7b..dc30df26 100644 --- a/plugins/builtin/qqmusic/lib/provider.py +++ b/plugins/builtin/qqmusic/lib/provider.py @@ -1,9 +1,16 @@ from __future__ import annotations +import logging +from typing import Any + from harmony_plugin_api.media import PluginTrack from .client import QQMusicPluginClient -from .root_view import QQMusicRootView +from .legacy_config_adapter import QQMusicLegacyConfigAdapter +from .online_music_view import OnlineMusicView +from .runtime_bridge import create_qqmusic_service + +logger = logging.getLogger(__name__) class QQMusicOnlineProvider: @@ -13,12 +20,87 @@ class QQMusicOnlineProvider: def __init__(self, context): self._context = context self._client = QQMusicPluginClient(context) + self._logger = getattr(context, "logger", logger) def create_page(self, context, parent=None): - return QQMusicRootView(context, self, parent) + self._logger.info("[QQMusic] Creating legacy online music view") + config = self._create_legacy_config_adapter(context) + credential = config.get_plugin_secret("qqmusic", "credential", "") + service = create_qqmusic_service(credential) if credential else None + return OnlineMusicView( + config_manager=config, + qqmusic_service=service, + plugin_context=context, + parent=parent, + ) + + @staticmethod + def _create_legacy_config_adapter(context): + return QQMusicLegacyConfigAdapter(context.settings) + + def is_logged_in(self) -> bool: + return self._client.is_logged_in() + + def search( + self, + keyword: str, + search_type: str = "song", + *, + page: int = 1, + page_size: int = 30, + ) -> dict[str, Any]: + return self._client.search(keyword, search_type=search_type, limit=page_size, page=page) def search_tracks(self, keyword: str) -> list[dict]: - return self._client.search(keyword, limit=20) + return self.search(keyword, search_type="song").get("tracks", []) + + def get_top_lists(self) -> list[dict]: + return self._client.get_top_lists() + + def get_top_list_tracks(self, top_id: int | str) -> list[dict]: + return self._client.get_top_list_tracks(top_id) + + def get_recommendations(self) -> list[dict]: + return self._client.get_recommendations() + + def get_favorites(self) -> list[dict]: + return self._client.get_favorites() + + def get_artist_detail(self, singer_mid: str) -> dict | None: + return self._client.get_artist_detail(singer_mid) + + def get_artist_albums(self, singer_mid: str, limit: int = 10) -> list[dict]: + return self._client.get_artist_albums(singer_mid, limit=limit) + + def follow_artist(self, singer_mid: str) -> bool: + return self._client.follow_artist(singer_mid) + + def unfollow_artist(self, singer_mid: str) -> bool: + return self._client.unfollow_artist(singer_mid) + + def get_album_detail(self, album_mid: str) -> dict | None: + return self._client.get_album_detail(album_mid) + + def fav_album(self, album_mid: str) -> bool: + return self._client.fav_album(album_mid) + + def unfav_album(self, album_mid: str) -> bool: + return self._client.unfav_album(album_mid) + + def get_playlist_detail(self, playlist_id: str) -> dict | None: + return self._client.get_playlist_detail(playlist_id) + + def fav_playlist(self, playlist_id: str) -> bool: + return self._client.fav_playlist(playlist_id) + + def unfav_playlist(self, playlist_id: str) -> bool: + return self._client.unfav_playlist(playlist_id) + + def get_hotkeys(self) -> list[dict]: + return self._client.get_hotkeys() + + def complete(self, keyword: str) -> list[dict]: + return self._client.complete(keyword) def get_demo_track(self) -> PluginTrack: return PluginTrack( @@ -29,4 +111,4 @@ def get_demo_track(self) -> PluginTrack: ) def get_playback_url_info(self, track_id: str, quality: str): - return {"url": "https://example.com/demo.mp3", "quality": quality, "extension": ".mp3"} + return self._client.get_playback_url_info(track_id, quality) diff --git a/plugins/builtin/qqmusic/lib/qr_login.py b/plugins/builtin/qqmusic/lib/qr_login.py index bac1d41f..257e6302 100644 --- a/plugins/builtin/qqmusic/lib/qr_login.py +++ b/plugins/builtin/qqmusic/lib/qr_login.py @@ -135,3 +135,275 @@ def __init__(self): "Referer": "https://y.qq.com/", } ) + + def get_qrcode(self, login_type: QRLoginType = QRLoginType.QQ) -> Optional[QR]: + if login_type == QRLoginType.WX: + return self._get_wx_qr() + return self._get_qq_qr() + + def _get_qq_qr(self) -> Optional[QR]: + try: + response = self._session.get( + self.QQ_QR_URL, + params={ + "appid": "716027609", + "e": "2", + "l": "M", + "s": "3", + "d": "72", + "v": "4", + "t": str(random.random()), + "daid": "383", + "pt_3rd_aid": "100497308", + }, + headers={"Referer": "https://xui.ptlogin2.qq.com/"}, + timeout=10, + ) + qrsig = response.cookies.get("qrsig") + if not qrsig: + return None + return QR(response.content, QRLoginType.QQ, qrsig) + except Exception: + return None + + def _get_wx_qr(self) -> Optional[QR]: + try: + response = self._session.get( + self.WX_QR_URL, + params={ + "appid": "wx48db31d50e334801", + "redirect_uri": "https://y.qq.com/portal/wx_redirect.html?login_type=2&surl=https://y.qq.com/", + "response_type": "code", + "scope": "snsapi_login", + "state": "STATE", + "href": "https://y.qq.com/mediastyle/music_v17/src/css/popup_wechat.css#wechat_redirect", + }, + timeout=10, + ) + match = re.findall(r"uuid=(.+?)\"", response.text) + if not match: + return None + uuid = match[0] + qr_response = self._session.get( + self.WX_QR_IMAGE_URL.format(uuid=uuid), + headers={"Referer": "https://open.weixin.qq.com/connect/qrconnect"}, + timeout=10, + ) + return QR(qr_response.content, QRLoginType.WX, uuid) + except Exception: + return None + + def check_qrcode(self, qrcode: QR) -> tuple[QRCodeLoginEvents, Optional[Credential]]: + if qrcode.qr_type == QRLoginType.WX: + return self._check_wx_qr(qrcode) + return self._check_qq_qr(qrcode) + + def _check_qq_qr(self, qrcode: QR) -> tuple[QRCodeLoginEvents, Optional[Credential]]: + qrsig = qrcode.identifier + try: + response = self._session.get( + self.QQ_CHECK_URL, + params={ + "u1": "https://graph.qq.com/oauth2.0/login_jump", + "ptqrtoken": hash33(qrsig), + "ptredirect": "0", + "h": "1", + "t": "1", + "g": "1", + "from_ui": "1", + "ptlang": "2052", + "action": f"0-0-{time.time() * 1000}", + "js_ver": "20102616", + "js_type": "1", + "pt_uistyle": "40", + "aid": "716027609", + "daid": "383", + "pt_3rd_aid": "100497308", + "has_onekey": "1", + }, + headers={ + "Referer": "https://xui.ptlogin2.qq.com/", + "Cookie": f"qrsig={qrsig}", + }, + timeout=10, + ) + except requests.RequestException: + return QRCodeLoginEvents.SCAN, None + + match = re.search(r"ptuiCB\((.*?)\)", response.text) + if not match: + return QRCodeLoginEvents.OTHER, None + + data = [p.strip("'") for p in match.group(1).split(",")] + if not data: + return QRCodeLoginEvents.OTHER, None + code_str = data[0] + if not code_str.isdigit(): + return QRCodeLoginEvents.OTHER, None + event = QRCodeLoginEvents.get_by_value(int(code_str)) + if event == QRCodeLoginEvents.DONE: + try: + sigx = re.findall(r"&ptsigx=(.+?)&s_url", data[2])[0] + uin = re.findall(r"&uin=(.+?)&service", data[2])[0] + credential = self._authorize_qq_qr(uin, sigx) + return event, credential + except Exception: + return QRCodeLoginEvents.OTHER, None + return event, None + + def _check_wx_qr(self, qrcode: QR) -> tuple[QRCodeLoginEvents, Optional[Credential]]: + uuid = qrcode.identifier + try: + response = self._session.get( + self.WX_CHECK_URL, + params={"uuid": uuid, "_": str(int(time.time()) * 1000)}, + headers={"Referer": "https://open.weixin.qq.com/"}, + timeout=10, + ) + except requests.Timeout: + return QRCodeLoginEvents.SCAN, None + except requests.RequestException: + return QRCodeLoginEvents.SCAN, None + + match = re.search(r"window\.wx_errcode=(\d+);window\.wx_code='([^']*)'", response.text) + if not match: + return QRCodeLoginEvents.OTHER, None + wx_errcode = match.group(1) + if not wx_errcode.isdigit(): + return QRCodeLoginEvents.OTHER, None + event = QRCodeLoginEvents.get_by_value(int(wx_errcode)) + if event == QRCodeLoginEvents.DONE: + wx_code = match.group(2) + if not wx_code: + return QRCodeLoginEvents.OTHER, None + try: + credential = self._authorize_wx_qr(wx_code) + return event, credential + except Exception: + return QRCodeLoginEvents.OTHER, None + return event, None + + def _authorize_qq_qr(self, uin: str, sigx: str) -> Credential: + response = self._session.get( + self.QQ_AUTHORIZE_URL, + params={ + "uin": uin, + "pttype": "1", + "service": "ptqrlogin", + "nodirect": "0", + "ptsigx": sigx, + "s_url": "https://graph.qq.com/oauth2.0/login_jump", + "ptlang": "2052", + "ptredirect": "100", + "aid": "716027609", + "daid": "383", + "j_later": "0", + "low_login_hour": "0", + "regmaster": "0", + "pt_login_type": "3", + "pt_aid": "0", + "pt_aaid": "16", + "pt_light": "0", + "pt_3rd_aid": "100497308", + }, + headers={"Referer": "https://xui.ptlogin2.qq.com/"}, + allow_redirects=True, + timeout=10, + ) + p_skey = self._session.cookies.get("p_skey") or response.cookies.get("p_skey") + if not p_skey and hasattr(response, "history"): + for hist_response in response.history: + if "p_skey" in hist_response.cookies: + p_skey = hist_response.cookies.get("p_skey") + break + set_cookie = hist_response.headers.get("Set-Cookie", "") + if "p_skey=" in set_cookie: + match = re.search(r"p_skey=([^;]+)", set_cookie) + if match: + p_skey = match.group(1) + break + if not p_skey: + raise ValueError("Failed to get p_skey") + response = self._session.post( + self.QQ_OAUTH_URL, + data={ + "response_type": "code", + "client_id": "100497308", + "redirect_uri": "https://y.qq.com/portal/wx_redirect.html?login_type=1&surl=https://y.qq.com/", + "scope": "get_user_info,get_app_friends", + "state": "state", + "switch": "", + "from_ptlogin": "1", + "src": "1", + "update_auth": "1", + "openapi": "1010_1030", + "g_tk": hash33(p_skey, 5381), + "auth_time": str(int(time.time()) * 1000), + "ui": str(random.randint(100000, 999999)), + }, + allow_redirects=False, + timeout=10, + ) + location = response.headers.get("Location", "") + try: + code = re.findall(r"(?<=code=)(.+?)(?=&)", location)[0] + except IndexError as exc: + raise ValueError("Failed to get code from OAuth redirect") from exc + return self._qq_connect_login(code) + + def _qq_connect_login(self, code: str) -> Credential: + request_data = { + "comm": { + "ct": "11", + "cv": "13020508", + "v": "13020508", + "tmeAppID": "qqmusic", + "format": "json", + "inCharset": "utf-8", + "outCharset": "utf-8", + "uid": "3931641530", + "tmeLoginType": "2", + }, + "QQConnectLogin.LoginServer.QQLogin": { + "module": "QQConnectLogin.LoginServer", + "method": "QQLogin", + "param": {"code": code}, + }, + } + response = self._session.post(self.MUSIC_API_URL, json=request_data, timeout=30) + response.raise_for_status() + data = response.json() + result = data.get("QQConnectLogin.LoginServer.QQLogin", {}) + if result.get("code") != 0: + raise ValueError(f"QQ Login failed with code: {result.get('code')}") + return Credential.from_cookies_dict(result.get("data", {})) + + def _authorize_wx_qr(self, code: str) -> Credential: + request_data = { + "comm": { + "ct": "11", + "cv": "13020508", + "v": "13020508", + "tmeAppID": "qqmusic", + "format": "json", + "inCharset": "utf-8", + "outCharset": "utf-8", + "uid": "3931641530", + "tmeLoginType": "1", + }, + "music.login.LoginServer.Login": { + "module": "music.login.LoginServer", + "method": "Login", + "param": { + "code": code, + "strAppid": "wx48db31d50e334801", + }, + }, + } + response = self._session.post(self.MUSIC_API_URL, json=request_data, timeout=30) + response.raise_for_status() + data = response.json() + result = data.get("music.login.LoginServer.Login", {}) + if result.get("code") != 0: + raise ValueError(f"WeChat Login failed with code: {result.get('code')}") + return Credential.from_cookies_dict(result.get("data", {})) diff --git a/plugins/builtin/qqmusic/lib/recommend_card.py b/plugins/builtin/qqmusic/lib/recommend_card.py new file mode 100644 index 00000000..1e4fdd2d --- /dev/null +++ b/plugins/builtin/qqmusic/lib/recommend_card.py @@ -0,0 +1,464 @@ +""" +Recommendation card widgets for QQ Music recommendations. +""" + +import logging +from typing import Callable, Dict, Any, Optional, List + +from PySide6.QtCore import Qt, Signal, QThread, QRect +from PySide6.QtGui import QPixmap, QColor, QPainter, QFont +from PySide6.QtWidgets import ( + QWidget, + QVBoxLayout, + QHBoxLayout, + QLabel, + QFrame, + QScrollArea, + QProgressBar, +) +from shiboken6 import isValid + +from .i18n import t +from .runtime_bridge import current_theme, get_qss, image_cache_get, image_cache_set, register_themed_widget + +logger = logging.getLogger(__name__) + + +class CoverLoader(QThread): + """Background worker for loading cover images.""" + + cover_loaded = Signal(str, QPixmap) # (cover_url, pixmap) + + def __init__(self, cover_url: str, size: int = 150, parent=None): + super().__init__(parent) + self._cover_url = cover_url + self._size = size + + def run(self): + try: + import requests + + # Check disk cache first + image_data = image_cache_get(self._cover_url) + if not image_data: + # Download from network + response = requests.get(self._cover_url, timeout=10) + response.raise_for_status() + image_data = response.content + # Save to cache + image_cache_set(self._cover_url, image_data) + + pixmap = QPixmap() + if pixmap.loadFromData(image_data): + scaled = pixmap.scaled( + self._size, self._size, + Qt.KeepAspectRatioByExpanding, + Qt.SmoothTransformation + ) + self.cover_loaded.emit(self._cover_url, scaled) + except Exception as e: + logger.debug(f"Error loading cover: {e}") + + def __del__(self): + """Ensure thread is properly stopped before deletion.""" + self._stop_thread(wait_ms=500) + + def _stop_thread(self, wait_ms: int = 1000): + """Stop worker thread cooperatively without force termination.""" + if not isValid(self): + return + if self.isRunning(): + self.requestInterruption() + self.quit() + self.wait(wait_ms) + + +class RecommendCard(QWidget): + """Card widget for displaying a recommendation.""" + + clicked = Signal(dict) # Emits recommendation data + + COVER_SIZE = 120 + CARD_WIDTH = 140 + CARD_HEIGHT = 180 + BORDER_RADIUS = 8 + + def __init__(self, data: Dict[str, Any], parent=None): + super().__init__(parent) + self._data = data + self._is_placeholder = bool(data.get("_placeholder")) + self._is_hovering = False + self._cover_loader: Optional[CoverLoader] = None + + self._setup_ui() + self._set_default_cover() + if not self._is_placeholder: + self._load_cover() + + # Register with theme manager + register_themed_widget(self) + + def _setup_ui(self): + """Set up the card UI.""" + self.setFixedSize(self.CARD_WIDTH, self.CARD_HEIGHT) + self.setCursor(Qt.ArrowCursor if self._is_placeholder else Qt.PointingHandCursor) + + layout = QVBoxLayout(self) + layout.setContentsMargins(0, 0, 0, 0) + layout.setSpacing(6) + + # Cover container + self._cover_container = QFrame() + self._cover_container.setFixedSize(self.COVER_SIZE, self.COVER_SIZE) + + # Pre-computed stylesheets for hover (H-08 optimization) + theme = current_theme() + radius = self.BORDER_RADIUS + self._style_normal = f"QFrame {{ background-color: {theme.background_hover}; border-radius: {radius}px; }}" + self._style_hover = f"QFrame {{ background-color: {theme.background_hover}; border-radius: {radius}px; border: 2px solid {theme.highlight}; }}" + self._cover_container.setStyleSheet(self._style_normal) + + # Cover label + self._cover_label = QLabel(self._cover_container) + self._cover_label.setFixedSize(self.COVER_SIZE, self.COVER_SIZE) + self._cover_label.setAlignment(Qt.AlignCenter) + self._cover_label.setStyleSheet(f""" + QLabel {{ + border-radius: {self.BORDER_RADIUS}px; + }} + """) + + # Info container + info_widget = QWidget() + info_layout = QVBoxLayout(info_widget) + info_layout.setContentsMargins(4, 0, 4, 0) + info_layout.setSpacing(2) + + # Name label + title = self._data.get('title', '') + self._name_label = QLabel(t(title)) + self._name_label.setAlignment(Qt.AlignLeft | Qt.AlignVCenter) + self._name_label.setStyleSheet(self._name_label_style()) + self._name_label.setWordWrap(True) + self._name_label.setMaximumHeight(32) + + info_layout.addWidget(self._name_label) + info_layout.addStretch() + + layout.addWidget(self._cover_container, 0, Qt.AlignHCenter) + layout.addWidget(info_widget) + + def _load_cover(self): + """Load cover image asynchronously.""" + cover_url = self._data.get('cover_url', '') + if not cover_url: + return + + self._cover_loader = CoverLoader(cover_url, self.COVER_SIZE) + self._cover_loader.cover_loaded.connect(self._on_cover_loaded) + self._cover_loader.start() + + def _on_cover_loaded(self, url: str, pixmap: QPixmap): + """Handle cover loaded.""" + if not pixmap.isNull(): + self._cover_label.setPixmap(pixmap) + + def closeEvent(self, event): + if self._cover_loader is not None and isValid(self._cover_loader): + self._cover_loader._stop_thread(wait_ms=500) + self._cover_loader.deleteLater() + self._cover_loader = None + super().closeEvent(event) + + def _set_default_cover(self): + """Set default cover when no cover is available.""" + theme = current_theme() + pixmap = QPixmap(self.COVER_SIZE, self.COVER_SIZE) + pixmap.fill(QColor(theme.background_hover)) + + painter = QPainter(pixmap) + painter.setRenderHint(QPainter.Antialiasing) + painter.setPen(QColor(theme.text_secondary)) + font = QFont() + font.setPixelSize(36) + painter.setFont(font) + painter.drawText( + QRect(0, 0, self.COVER_SIZE, self.COVER_SIZE), + Qt.AlignCenter, + "…" if self._is_placeholder else "\u266B" + ) + painter.end() + + self._cover_label.setPixmap(pixmap) + + def enterEvent(self, event): + """Handle mouse enter for hover effect.""" + if self._is_placeholder: + return + self._is_hovering = True + self._cover_container.setStyleSheet(self._style_hover) + super().enterEvent(event) + + def leaveEvent(self, event): + """Handle mouse leave for hover effect.""" + if self._is_placeholder: + return + self._is_hovering = False + self._cover_container.setStyleSheet(self._style_normal) + super().leaveEvent(event) + + def mousePressEvent(self, event): + """Handle mouse click.""" + if not self._is_placeholder and event.button() == Qt.LeftButton: + self.clicked.emit(self._data) + super().mousePressEvent(event) + + def _name_label_style(self) -> str: + if self._is_placeholder: + return get_qss(""" + QLabel { + color: %text_secondary%; + font-size: 12px; + font-weight: bold; + background: transparent; + } + """) + return get_qss(""" + QLabel { + color: %text%; + font-size: 12px; + font-weight: bold; + background: transparent; + } + """) + + def refresh_theme(self): + """Refresh theme colors when theme changes.""" + theme = current_theme() + radius = self.BORDER_RADIUS + + # Update pre-computed stylesheets + self._style_normal = f"QFrame {{ background-color: {theme.background_hover}; border-radius: {radius}px; }}" + self._style_hover = f"QFrame {{ background-color: {theme.background_hover}; border-radius: {radius}px; border: 2px solid {theme.highlight}; }}" + + # Apply current state + if self._is_hovering: + self._cover_container.setStyleSheet(self._style_hover) + else: + self._cover_container.setStyleSheet(self._style_normal) + + # Update text labels + self._name_label.setStyleSheet(self._name_label_style()) + if self._is_placeholder: + self._set_default_cover() + + def refresh_ui(self): + """Refresh UI text for language changes (placeholder cards only).""" + if self._is_placeholder: + self._name_label.setText(t("loading", "Loading...")) + else: + title = self._data.get('title', '') + self._name_label.setText(t(title)) + + +class RecommendSection(QWidget): + """Section widget displaying recommendation cards in a horizontal scroll.""" + + recommendation_clicked = Signal(dict) # Emits recommendation data + + _STYLE_TEMPLATE = """ + QLabel { + color: %highlight%; + font-size: 16px; + font-weight: bold; + } + """ + + _SCROLL_STYLE_TEMPLATE = """ + QScrollArea { + background-color: transparent; + border: none; + } + QScrollBar:horizontal { + background-color: %background%; + height: 8px; + border-radius: 4px; + } + QScrollBar::handle:horizontal { + background-color: %background_hover%; + border-radius: 4px; + min-width: 30px; + } + QScrollBar::handle:horizontal:hover { + background-color: %border%; + } + QScrollBar::add-line, QScrollBar::sub-line { + width: 0px; + } + """ + + _LOADING_STYLE_TEMPLATE = """ + QProgressBar { + background-color: %background_hover%; + border: none; + border-radius: 2px; + } + QProgressBar::chunk { + background-color: %highlight%; + border-radius: 2px; + } + """ + + def __init__(self, title: str = None, parent=None): + super().__init__(parent) + self._cards: List[RecommendCard] = [] + self._custom_title_key = title # Store translation key, not translated text + self._setup_ui() + + # Register with theme manager + register_themed_widget(self) + + def _setup_ui(self): + """Set up the section UI.""" + layout = QVBoxLayout(self) + layout.setContentsMargins(0, 5, 0, 0) + layout.setSpacing(2) + + # Set background style + self.setStyleSheet("background-color: transparent;") + + # Title + self._title_label = QLabel(t(self._custom_title_key) if self._custom_title_key else t("recommendations")) + self._title_label.setStyleSheet(get_qss(self._STYLE_TEMPLATE)) + layout.addWidget(self._title_label) + + # Scroll area for cards + self._scroll_area = QScrollArea() + self._scroll_area.setWidgetResizable(False) + self._scroll_area.setHorizontalScrollBarPolicy(Qt.ScrollBarAsNeeded) + self._scroll_area.setVerticalScrollBarPolicy(Qt.ScrollBarAlwaysOff) + self._scroll_area.setFixedHeight(200) + self._scroll_area.setStyleSheet(get_qss(self._SCROLL_STYLE_TEMPLATE)) + + # Cards container + self._cards_container = QWidget() + self._cards_container.setStyleSheet("background-color: transparent;") + self._cards_container.setFixedHeight(190) # Slightly less than scroll area height + self._cards_layout = QHBoxLayout(self._cards_container) + self._cards_layout.setContentsMargins(0, 0, 0, 0) + self._cards_layout.setSpacing(8) + self._cards_layout.setAlignment(Qt.AlignLeft | Qt.AlignVCenter) + + self._scroll_area.setWidget(self._cards_container) + layout.addWidget(self._scroll_area) + + # Loading indicator + self._loading = self._create_loading_indicator() + layout.addWidget(self._loading) + self._loading.hide() + + # Initially hidden + self.hide() + + def _create_loading_indicator(self) -> QWidget: + """Create loading indicator.""" + widget = QWidget() + layout = QHBoxLayout(widget) + layout.setAlignment(Qt.AlignCenter) + + progress = QProgressBar() + progress.setRange(0, 0) # Indeterminate + progress.setFixedSize(150, 4) + progress.setStyleSheet(get_qss(self._LOADING_STYLE_TEMPLATE)) + layout.addWidget(progress) + + return widget + + def show_loading(self, count: int = 5): + """Show placeholder cards while data is loading.""" + self._loading.hide() + self._clear_cards() + + placeholder_title = t("loading", "Loading...") + placeholders = [ + { + "_placeholder": True, + "id": f"placeholder-{index}", + "title": placeholder_title, + } + for index in range(max(count, 1)) + ] + for rec in placeholders: + card = RecommendCard(rec) + self._cards.append(card) + self._cards_layout.addWidget(card) + + total_width = len(self._cards) * (RecommendCard.CARD_WIDTH + 16) - 16 + self._cards_container.setFixedWidth(max(total_width, self.width())) + self._cards_container.adjustSize() + self.show() + + def hide_loading(self): + """Hide loading indicator.""" + self._loading.hide() + + def _clear_cards(self): + """Clear all existing cards.""" + for card in self._cards: + card.deleteLater() + self._cards.clear() + + def load_recommendations(self, recommendations: List[Dict[str, Any]]): + """ + Load recommendation cards. + + Args: + recommendations: List of recommendation data dicts + """ + import logging + logger = logging.getLogger(__name__) + + self._clear_cards() + self.hide_loading() + + if not recommendations: + logger.info("No recommendations, hiding section") + self.hide() + return + + for rec in recommendations: + card = RecommendCard(rec) + card.clicked.connect(self.recommendation_clicked.emit) + self._cards.append(card) + self._cards_layout.addWidget(card) + + # Update container width to fit all cards + total_width = len(self._cards) * (RecommendCard.CARD_WIDTH + 16) - 16 + self._cards_container.setFixedWidth(max(total_width, self.width())) + self._cards_container.adjustSize() + + self.show() + + def refresh_ui(self): + """Refresh UI for language changes.""" + if hasattr(self, '_title_label'): + if self._custom_title_key: + self._title_label.setText(t(self._custom_title_key)) + else: + self._title_label.setText(t("recommendations")) + # Refresh placeholder cards if present + for card in self._cards: + card.refresh_ui() + + def refresh_theme(self): + """Refresh theme colors when theme changes.""" + # Update title label + self._title_label.setStyleSheet(get_qss(self._STYLE_TEMPLATE)) + + # Update scroll area + self._scroll_area.setStyleSheet(get_qss(self._SCROLL_STYLE_TEMPLATE)) + + # Update loading indicator + progress = self._loading.findChild(QProgressBar) + if progress: + progress.setStyleSheet(get_qss(self._LOADING_STYLE_TEMPLATE)) diff --git a/plugins/builtin/qqmusic/lib/root_view.py b/plugins/builtin/qqmusic/lib/root_view.py index 2493b853..9d48db4f 100644 --- a/plugins/builtin/qqmusic/lib/root_view.py +++ b/plugins/builtin/qqmusic/lib/root_view.py @@ -1,69 +1 @@ from __future__ import annotations - -from PySide6.QtWidgets import ( - QHBoxLayout, - QLabel, - QLineEdit, - QListWidget, - QListWidgetItem, - QPushButton, - QVBoxLayout, - QWidget, -) - -from harmony_plugin_api.media import PluginPlaybackRequest, PluginTrack - - -class QQMusicRootView(QWidget): - def __init__(self, context, provider, parent=None): - super().__init__(parent) - self._context = context - self._provider = provider - self._status = QLabel(self._build_status_text(), self) - self._search_input = QLineEdit(self) - self._search_input.setPlaceholderText("Search QQ Music") - self._search_btn = QPushButton("Search", self) - self._search_btn.clicked.connect(self._run_search) - self._results_list = QListWidget(self) - layout = QVBoxLayout(self) - layout.addWidget(self._status) - search_row = QHBoxLayout() - search_row.addWidget(self._search_input) - search_row.addWidget(self._search_btn) - layout.addLayout(search_row) - layout.addWidget(self._results_list) - - def _build_status_text(self) -> str: - nick = self._context.settings.get("nick", "") - if nick: - return f"Logged in as {nick}" - return "Not logged in" - - def _run_search(self): - keyword = self._search_input.text().strip() - if not keyword: - return - results = self._provider.search_tracks(keyword) - self._results_list.clear() - for item in results: - text = f"{item.get('title', '')} - {item.get('singer', item.get('artist', ''))}" - row = QListWidgetItem(text) - row.setData(0x0100, item) - self._results_list.addItem(row) - - def _play_demo_track(self): - track = self._provider.get_demo_track() - request = PluginPlaybackRequest( - provider_id="qqmusic", - track_id=track.track_id, - title=track.title, - quality=self._context.settings.get("quality", "320"), - metadata={ - "title": track.title, - "artist": track.artist, - "album": track.album, - }, - ) - local_path = self._context.services.media.cache_remote_track(request) - self._context.services.media.add_online_track(request) - self._status.setText(local_path or "download failed") diff --git a/plugins/builtin/qqmusic/lib/runtime_bridge.py b/plugins/builtin/qqmusic/lib/runtime_bridge.py new file mode 100644 index 00000000..55798f35 --- /dev/null +++ b/plugins/builtin/qqmusic/lib/runtime_bridge.py @@ -0,0 +1,144 @@ +from __future__ import annotations + +import importlib +from typing import Any + + +def _runtime_module(): + return importlib.import_module("system.plugins.plugin_sdk_runtime") + + +def _ui_module(): + return importlib.import_module("system.plugins.plugin_sdk_ui") + + +def register_themed_widget(widget) -> None: + _ui_module().register_themed_widget(widget) + + +def get_qss(template: str) -> str: + return _ui_module().get_qss(template) + + +def current_theme(): + return _ui_module().current_theme() + + +def show_information(parent, title: str, message: str) -> None: + _ui_module().information(parent, title, message) + + +def show_warning(parent, title: str, message: str) -> None: + _ui_module().warning(parent, title, message) + + +def create_online_music_service(*, config_manager=None, credential_provider=None): + return _runtime_module().create_online_music_service( + config_manager=config_manager, + credential_provider=credential_provider, + ) + + +def create_online_download_service(*, config_manager=None, credential_provider=None, online_music_service=None): + return _runtime_module().create_online_download_service( + config_manager=config_manager, + credential_provider=credential_provider, + online_music_service=online_music_service, + ) + + +def get_icon(name, color, size: int = 16): + return _runtime_module().get_icon(name, color, size) + + +class IconName: + GRID = "grid.svg" + LIST = "list.svg" + + +def image_cache_get(url: str): + return _runtime_module().image_cache_get(url) + + +def image_cache_set(url: str, image_data: bytes): + return _runtime_module().image_cache_set(url, image_data) + + +def image_cache_path(url: str): + return _runtime_module().image_cache_path(url) + + +def http_get_content(url: str, *, timeout: int, headers: dict[str, str] | None = None): + return _runtime_module().http_get_content(url, timeout=timeout, headers=headers) + + +def cover_pixmap_cache_initialize() -> None: + _runtime_module().cover_pixmap_cache_initialize() + + +def cover_pixmap_cache_get(cache_key: str): + return _runtime_module().cover_pixmap_cache_get(cache_key) + + +def cover_pixmap_cache_set(cache_key: str, pixmap) -> None: + _runtime_module().cover_pixmap_cache_set(cache_key, pixmap) + + +def bootstrap(): + return _runtime_module().bootstrap() + + +def library_service(): + return _runtime_module().library_service() + + +def favorites_service(): + return _runtime_module().favorites_service() + + +def favorite_mids_from_library() -> set[str]: + return _runtime_module().favorite_mids_from_library() + + +def remove_library_favorite_by_mid(mid: str) -> bool: + return _runtime_module().remove_library_favorite_by_mid(mid) + + +def add_requests_to_favorites(requests: list[Any]) -> list[int]: + return _runtime_module().add_requests_to_favorites(requests) + + +def add_requests_to_playlist(parent, requests: list[Any], log_prefix: str) -> list[int]: + return _runtime_module().add_requests_to_playlist(parent, requests, log_prefix) + + +def add_track_ids_to_playlist(parent, track_ids: list[int], log_prefix: str) -> None: + _runtime_module().add_track_ids_to_playlist(parent, track_ids, log_prefix) + + +def event_bus(): + return _runtime_module().event_bus() + + +def create_qqmusic_service(credential): + from .legacy.qqmusic_service import QQMusicService + + return QQMusicService(credential) + + +def create_qqmusic_login_dialog(context=None, parent=None): + from .login_dialog import QQMusicLoginDialog + + return QQMusicLoginDialog(context, parent) + + +def format_duration(seconds: Any) -> str: + try: + total_seconds = int(float(seconds or 0)) + except (TypeError, ValueError): + total_seconds = 0 + minutes, seconds_part = divmod(max(total_seconds, 0), 60) + hours, minutes_part = divmod(minutes, 60) + if hours: + return f"{hours:d}:{minutes_part:02d}:{seconds_part:02d}" + return f"{minutes:d}:{seconds_part:02d}" diff --git a/plugins/builtin/qqmusic/lib/settings_tab.py b/plugins/builtin/qqmusic/lib/settings_tab.py index 855ad098..8cddde25 100644 --- a/plugins/builtin/qqmusic/lib/settings_tab.py +++ b/plugins/builtin/qqmusic/lib/settings_tab.py @@ -1,55 +1,380 @@ from __future__ import annotations -from PySide6.QtWidgets import QComboBox, QLabel, QPushButton, QVBoxLayout, QWidget +import logging +from typing import Optional +from PySide6.QtCore import QThread, Qt, Signal +from PySide6.QtWidgets import ( + QFileDialog, + QComboBox, + QGroupBox, + QHBoxLayout, + QLabel, + QLineEdit, + QPushButton, + QVBoxLayout, + QWidget, +) + +from .common import get_quality_label_key, get_selectable_qualities +from .i18n import get_language, set_language, t from .login_dialog import QQMusicLoginDialog +from .runtime_bridge import current_theme as sdk_current_theme, register_themed_widget + +logger = logging.getLogger(__name__) + + +class VerifyLoginThread(QThread): + verified = Signal(bool, str, int) + + def __init__(self, credential: dict, parent=None): + super().__init__(parent) + self._credential = credential + + def run(self): + try: + from .legacy.qqmusic_service import QQMusicService + + service = QQMusicService(self._credential) + result = service.client.verify_login() + self.verified.emit( + bool(result.get("valid")), + str(result.get("nick", "") or ""), + int(result.get("uin", 0) or 0), + ) + except Exception as exc: + logger.debug("Settings tab: verify login failed: %s", exc) + self.verified.emit(False, "", 0) class QQMusicSettingsTab(QWidget): + _STYLE_GROUP = """ + QGroupBox { + color: %text%; + border: 1px solid %border%; + border-radius: 6px; + margin-top: 10px; + padding-top: 10px; + font-size: 13px; + } + QGroupBox::title { + subcontrol-origin: margin; + subcontrol-position: top left; + padding: 0 8px; + color: %text%; + } + """ + _STYLE_STATUS = """ + QLabel { + color: %text_secondary%; + font-size: 13px; + padding: 4px 0; + } + """ + _STYLE_BUTTON = """ + QPushButton { + background-color: %background_hover%; + color: %text%; + border: 1px solid %border%; + border-radius: 4px; + padding: 8px 16px; + font-size: 13px; + } + QPushButton:hover { + background-color: %selection%; + } + """ + _STYLE_INPUT = """ + QLineEdit, QComboBox { + background-color: %background%; + color: %text%; + border: 1px solid %border%; + border-radius: 4px; + padding: 8px; + font-size: 13px; + } + QLineEdit:focus, QComboBox:focus { + border-color: %highlight%; + } + """ + def __init__(self, context, parent=None): super().__init__(parent) self._context = context - layout = QVBoxLayout(self) - layout.addWidget(QLabel("QQ Music Settings", self)) - self._status_label = QLabel(self) - self._status_label.setText(self._build_status_text()) - layout.addWidget(self._status_label) - self._quality_combo = QComboBox(self) - for quality in ("320", "flac", "master"): - self._quality_combo.addItem(quality, quality) - current_quality = str(self._context.settings.get("quality", "320")) - for index in range(self._quality_combo.count()): - if self._quality_combo.itemData(index) == current_quality: - self._quality_combo.setCurrentIndex(index) - break - layout.addWidget(self._quality_combo) + self._language_connected = False + self._verify_thread: Optional[VerifyLoginThread] = None + + self._outer_layout = QVBoxLayout(self) + self._outer_layout.setContentsMargins(0, 0, 0, 0) + + self._setup_ui() + self._load_settings() + self._connect_language_events() + + ui = getattr(self._context, "ui", None) + if ui is not None and hasattr(ui, "theme") and hasattr(ui.theme, "register_widget"): + ui.theme.register_widget(self) + else: + register_themed_widget(self) + + self.refresh_ui() + + def _setup_ui(self): + # QQ Music Settings Tab + self._qqmusic_tab = QWidget() + self._outer_layout.addWidget(self._qqmusic_tab) + + qqmusic_layout = QVBoxLayout(self._qqmusic_tab) + qqmusic_layout.setContentsMargins(9, 9, 9, 9) + qqmusic_layout.setSpacing(10) + + # Quality settings + self._quality_group = QGroupBox(t("qqmusic_quality")) + quality_layout = QHBoxLayout() + self._quality_label = QLabel(t("qqmusic_quality")) + self._quality_combo = QComboBox() + self._quality_combo.setFixedWidth(300) + for quality in get_selectable_qualities(): + label_key = get_quality_label_key(quality) + label = t(label_key, quality) + self._quality_combo.addItem(label) + self._quality_combo.setItemData(self._quality_combo.count() - 1, quality, Qt.UserRole) + self._quality_combo.currentIndexChanged.connect(lambda *_args: self._save_settings()) + quality_layout.addWidget(self._quality_label) + quality_layout.addWidget(self._quality_combo) + quality_layout.addStretch() + self._quality_group.setLayout(quality_layout) + qqmusic_layout.addWidget(self._quality_group) + + # Download directory settings + self._download_dir_group = QGroupBox(t("online_music_download_dir", "下载目录")) + download_dir_layout = QHBoxLayout() + self._download_dir_label = QLabel(t("online_music_download_dir", "下载目录")) + self._download_dir_input = QLineEdit() + self._download_dir_input.setPlaceholderText("data/online_cache") + self._download_dir_input.editingFinished.connect(self._save_settings) + self._browse_btn = QPushButton(t("online_music_browse", "浏览...")) + self._browse_btn.setCursor(Qt.PointingHandCursor) + self._browse_btn.clicked.connect(self._browse_download_dir) + download_dir_layout.addWidget(self._download_dir_label) + download_dir_layout.addWidget(self._download_dir_input) + download_dir_layout.addWidget(self._browse_btn) + self._download_dir_group.setLayout(download_dir_layout) + qqmusic_layout.addWidget(self._download_dir_group) + + # Hint label for download directory + self._download_dir_hint = QLabel(t("online_music_download_dir_hint", "设置在线音乐缓存和下载目录")) + self._download_dir_hint.setStyleSheet("font-size: 11px;") + self._download_dir_hint.setWordWrap(True) + qqmusic_layout.addWidget(self._download_dir_hint) + + # QQ Music instructions + self._qqmusic_instructions_label = QLabel( + f"{t('qqmusic_login')}

" + f"{t('qqmusic_faster_api_hint', t('qqmusic_account_hint'))}" + ) + self._qqmusic_instructions_label.setWordWrap(True) + qqmusic_layout.addWidget(self._qqmusic_instructions_label) + + # QQ Music credential status + self._qqmusic_status_label = QLabel() + self._qqmusic_status_label.setWordWrap(True) + qqmusic_layout.addWidget(self._qqmusic_status_label) + self._status_label = self._qqmusic_status_label + + # QQ Music buttons + qqmusic_button_layout = QHBoxLayout() + + self._qqmusic_qr_btn = QPushButton(t("qqmusic_qr_login", t("qqmusic_login"))) + self._qqmusic_qr_btn.setCursor(Qt.PointingHandCursor) + self._qqmusic_qr_btn.clicked.connect(self._open_qqmusic_qr_login) + qqmusic_button_layout.addWidget(self._qqmusic_qr_btn) + + self._qqmusic_logout_btn = QPushButton(t("qqmusic_clear", t("clear_credentials"))) + self._qqmusic_logout_btn.setCursor(Qt.PointingHandCursor) + self._qqmusic_logout_btn.clicked.connect(self._qqmusic_logout) + qqmusic_button_layout.addWidget(self._qqmusic_logout_btn) - login_btn = QPushButton("Login", self) - login_btn.clicked.connect(self._open_login_dialog) - layout.addWidget(login_btn) + qqmusic_layout.addLayout(qqmusic_button_layout) - clear_btn = QPushButton("Clear Credentials", self) - clear_btn.clicked.connect(self._clear_credentials) - layout.addWidget(clear_btn) + # Update status after buttons are created + self._update_qqmusic_status() - save_btn = QPushButton("Save", self) - save_btn.clicked.connect(self._save) - layout.addWidget(save_btn) + qqmusic_layout.addStretch() + + def _theme_get_qss(self, template: str) -> str: + ui = getattr(self._context, "ui", None) + if ui is not None and hasattr(ui, "theme") and hasattr(ui.theme, "get_qss"): + return ui.theme.get_qss(template) + return template + + def _theme_current(self): + ui = getattr(self._context, "ui", None) + if ui is not None and hasattr(ui, "theme") and hasattr(ui.theme, "current_theme"): + return ui.theme.current_theme() + return sdk_current_theme() + + def _connect_language_events(self) -> None: + events = getattr(self._context, "events", None) + if events is None or self._language_connected: + return + signal = getattr(events, "language_changed", None) + if signal is None: + return + signal.connect(self._on_language_changed) + self._language_connected = True + + def _sync_language_from_context(self) -> None: + if self._language_connected: + return + lang = str(getattr(self._context, "language", get_language()) or get_language()) + if lang != get_language(): + set_language(lang) + + def _on_language_changed(self, language: str) -> None: + if language and language != get_language(): + set_language(language) + self._language_connected = True + self.refresh_ui() + + def _load_settings(self) -> None: + quality = str(self._context.settings.get("quality", "320")) + for i in range(self._quality_combo.count()): + if self._quality_combo.itemData(i, Qt.UserRole) == quality: + self._quality_combo.setCurrentIndex(i) + break + + download_dir = str( + self._context.settings.get("download_dir", "data/online_cache") + or "data/online_cache" + ) + self._download_dir_input.setText(download_dir) def _save(self): - self._context.settings.set("quality", self._quality_combo.currentData()) + self._save_settings() - def _open_login_dialog(self): - dialog = QQMusicLoginDialog(self) + def _save_settings(self) -> None: + self._context.settings.set("quality", self._quality_combo.currentData(Qt.UserRole)) + self._context.settings.set( + "download_dir", + self._download_dir_input.text().strip() or "data/online_cache", + ) + + def _browse_download_dir(self) -> None: + path = QFileDialog.getExistingDirectory( + self, + t("online_music_select_dir", "选择下载目录"), + self._download_dir_input.text().strip() or "data/online_cache", + ) + if path: + self._download_dir_input.setText(path) + self._save_settings() + + def _update_qqmusic_status(self): + credential = self._context.settings.get("credential", None) + if credential: + musicid = credential.get("musicid", "") + login_type = credential.get("loginType", credential.get("login_type", 2)) + login_method = t("qqmusic_wx_login") if login_type == 1 else t("qqmusic_qq_login") + + if musicid: + self._qqmusic_status_label.setText( + f"⏳ {t('qqmusic_verifying', '正在验证...')} ({login_method}: {musicid})" + ) + self._qqmusic_logout_btn.setVisible(True) + + if self._verify_thread: + self._verify_thread.quit() + self._verify_thread.wait() + + self._verify_thread = VerifyLoginThread(credential, parent=self) + self._verify_thread.verified.connect( + lambda valid, nick, uin, musicid=musicid, login_type=login_type: self._on_login_verified( + valid, nick, uin, musicid, login_type + ) + ) + self._verify_thread.start() + else: + self._qqmusic_status_label.setText( + f"⚠️ {t('qqmusic_incomplete_config', '配置不完整')}" + ) + self._qqmusic_logout_btn.setVisible(False) + else: + self._qqmusic_status_label.setText( + f"❌ {t('qqmusic_not_configured_status', t('qqmusic_not_logged_in'))}" + ) + self._qqmusic_logout_btn.setVisible(False) + + def _on_login_verified( + self, + valid: bool, + nick: str, + _uin: int, + musicid: str, + login_type: int = 2, + ): + login_method = t("qqmusic_wx_login") if login_type == 1 else t("qqmusic_qq_login") + + if valid: + if nick: + self._context.settings.set("nick", nick) + display_name = nick or self._context.settings.get("nick", "") or musicid + self._qqmusic_status_label.setText( + f"✅ {t('qqmusic_logged_in_status', t('qqmusic_logged_in'))} ({display_name}, {login_method}: {musicid})" + ) + else: + self._qqmusic_status_label.setText( + f"❌ {t('qqmusic_login_expired', '登录已失效')} ({login_method}: {musicid})" + ) + + def _open_qqmusic_qr_login(self): + dialog = QQMusicLoginDialog(self._context, self) + dialog.credentials_obtained.connect(lambda _credential: self._update_qqmusic_status()) dialog.exec() + self._update_qqmusic_status() - def _clear_credentials(self): + def _open_login_dialog(self): + self._open_qqmusic_qr_login() + + def _qqmusic_logout(self): self._context.settings.set("credential", None) self._context.settings.set("nick", "") - self._status_label.setText(self._build_status_text()) + self._update_qqmusic_status() + + def _clear_credentials(self): + self._qqmusic_logout() + + def refresh_ui(self) -> None: + self._sync_language_from_context() + self._quality_group.setTitle(t("qqmusic_quality")) + self._quality_label.setText(t("qqmusic_quality")) + self._download_dir_group.setTitle(t("online_music_download_dir", "下载目录")) + self._download_dir_label.setText(t("online_music_download_dir", "下载目录")) + self._browse_btn.setText(t("online_music_browse", "浏览...")) + self._download_dir_hint.setText( + t("online_music_download_dir_hint", "设置在线音乐缓存和下载目录") + ) + self._qqmusic_instructions_label.setText( + f"{t('qqmusic_login')}

" + f"{t('qqmusic_faster_api_hint', t('qqmusic_account_hint'))}" + ) + self._qqmusic_qr_btn.setText(t("qqmusic_qr_login", t("qqmusic_login"))) + self._qqmusic_logout_btn.setText(t("qqmusic_clear", t("clear_credentials"))) + self._update_qqmusic_status() + self.refresh_theme() + + def refresh_theme(self) -> None: + qss = self._theme_get_qss + theme = self._theme_current() - def _build_status_text(self) -> str: - nick = self._context.settings.get("nick", "") - if nick: - return f"Logged in as {nick}" - return "Not logged in" + self._quality_group.setStyleSheet(qss(self._STYLE_GROUP)) + self._download_dir_group.setStyleSheet(qss(self._STYLE_GROUP)) + self._quality_label.setStyleSheet(qss(self._STYLE_STATUS)) + self._download_dir_label.setStyleSheet(qss(self._STYLE_STATUS)) + self._qqmusic_status_label.setStyleSheet(qss(self._STYLE_STATUS)) + self._quality_combo.setStyleSheet(qss(self._STYLE_INPUT)) + self._download_dir_input.setStyleSheet(qss(self._STYLE_INPUT)) + for button in (self._browse_btn, self._qqmusic_qr_btn, self._qqmusic_logout_btn): + button.setStyleSheet(qss(self._STYLE_BUTTON)) + self._download_dir_hint.setStyleSheet(f"color: {theme.text_secondary}; font-size: 11px;") + self._qqmusic_instructions_label.setStyleSheet(f"color: {theme.text};") diff --git a/plugins/builtin/qqmusic/plugin_main.py b/plugins/builtin/qqmusic/plugin_main.py index bf4b8ff7..0d529a61 100644 --- a/plugins/builtin/qqmusic/plugin_main.py +++ b/plugins/builtin/qqmusic/plugin_main.py @@ -1,35 +1,63 @@ from __future__ import annotations +import logging +from pathlib import Path + from harmony_plugin_api.registry_types import SettingsTabSpec, SidebarEntrySpec from .lib.artist_cover_source import QQMusicArtistCoverPluginSource from .lib.cover_source import QQMusicCoverPluginSource +from .lib.i18n import get_language, set_language, t from .lib.lyrics_source import QQMusicLyricsPluginSource from .lib.provider import QQMusicOnlineProvider from .lib.settings_tab import QQMusicSettingsTab +logger = logging.getLogger(__name__) +_SIDEBAR_ICON_PATH = str(Path(__file__).resolve().parent / "sidebar_icon.svg") + class QQMusicPlugin: plugin_id = "qqmusic" def register(self, context) -> None: + plugin_logger = getattr(context, "logger", None) + if plugin_logger is None or not hasattr(plugin_logger, "info"): + plugin_logger = logger + + # Sync initial language from app context + app_lang = getattr(context, "language", None) or "" + if app_lang and app_lang != get_language(): + set_language(app_lang) + + # Listen for language changes to update titles + events = getattr(context, "events", None) + if events is not None and hasattr(events, "language_changed"): + events.language_changed.connect(self._on_language_changed) + + def _localized_title() -> str: + return t("qqmusic_page_title", "QQ 音乐") + + plugin_logger.info("[QQMusic] Registering plugin capabilities") context.ui.register_sidebar_entry( SidebarEntrySpec( plugin_id="qqmusic", entry_id="qqmusic.sidebar", - title="QQ 音乐", + title=_localized_title(), order=80, - icon_name="GLOBE", + icon_name=None, + icon_path=_SIDEBAR_ICON_PATH, page_factory=lambda _context, parent: QQMusicOnlineProvider(context).create_page(context, parent), + title_provider=_localized_title, ) ) context.ui.register_settings_tab( SettingsTabSpec( plugin_id="qqmusic", tab_id="qqmusic.settings", - title="QQ 音乐", + title=_localized_title(), order=80, widget_factory=lambda _context, parent: QQMusicSettingsTab(context, parent), + title_provider=_localized_title, ) ) context.services.register_lyrics_source(QQMusicLyricsPluginSource(context)) @@ -38,6 +66,14 @@ def register(self, context) -> None: QQMusicArtistCoverPluginSource(context) ) context.services.register_online_music_provider(QQMusicOnlineProvider(context)) + plugin_logger.info("[QQMusic] Plugin registration completed") + + @staticmethod + def _on_language_changed(language: str) -> None: + """Handle language change from app.""" + if language and language != get_language(): + set_language(language) def unregister(self, context) -> None: + getattr(context, "logger", logger).info("[QQMusic] Plugin unregistered") return None diff --git a/plugins/builtin/qqmusic/sidebar_icon.svg b/plugins/builtin/qqmusic/sidebar_icon.svg new file mode 100644 index 00000000..73459dad --- /dev/null +++ b/plugins/builtin/qqmusic/sidebar_icon.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/plugins/builtin/qqmusic/translations/en.json b/plugins/builtin/qqmusic/translations/en.json new file mode 100644 index 00000000..152a52de --- /dev/null +++ b/plugins/builtin/qqmusic/translations/en.json @@ -0,0 +1,146 @@ +{ + "add_all_to_queue": "➕ Add All to Queue", + "add_to_favorites": "⭐ Add to Favorites", + "add_to_playlist": "Add to Playlist", + "add_to_qq_favorites": "Favorite on QQ Music", + "add_to_queue": "➕ Add to Queue", + "added_x_tracks_to_favorites": "Added {count} track{s} to favorites", + "album": "Album", + "albums": "Albums", + "artist": "Artist", + "artists": "Artists", + "back": "Back", + "cancel": "Cancel", + "clear_all": "Clear All", + "clear_credentials": "Clear Credentials", + "cover": "Cover", + "created_playlists": "My Playlists", + "delete": "Delete", + "detail_not_available": "Detail not available", + "download": "⬇ Download", + "download_failed": "Download failed", + "downloading": "Downloading", + "duration": "Duration", + "error": "Error", + "fans": "Fans", + "favorites": "Favorites", + "fav_albums": "Favorite Albums", + "fav_playlists": "Favorite Playlists", + "fav_songs": "Favorite Songs", + "follow": "Follow", + "followed": "Following", + "followed_singers": "Followed Singers", + "guess_you_like": "Guess You Like", + "home_recommend": "Home Recommendations", + "hot_search": "Hot Search", + "insert_all_to_queue": "📥 Insert All to Queue", + "insert_to_queue": "📥 Insert to Queue", + "load_more": "Load more", + "loading": "Loading...", + "login": "Login", + "logout": "Logout", + "logout_success": "Logged out", + "my_favorites": "My Favorites", + "new_songs": "New Songs", + "next_page": "Next", + "online_music": "Online Music", + "play": "▶️ Play", + "play_all": "▶️ Play All", + "play_now": "▶️ Play Now", + "playlists": "Playlists", + "plays": "plays", + "previous_page": "Previous", + "qqmusic_add_to_favorites": "Add to QQ Favorites", + "qqmusic_click_refresh_to_start": "Click \"Refresh QR Code\" to start login", + "qqmusic_confirmed_logging_in": "Confirmed. Logging in...", + "qqmusic_fetch_qr_failed": "Failed to fetch QR code", + "qqmusic_fetching_qr": "Fetching QR code...", + "qqmusic_follow": "Follow", + "qqmusic_followed": "Following", + "qqmusic_logged_in": "Logged in", + "qqmusic_logged_in_as": "Logged in as", + "qqmusic_login": "Login", + "qqmusic_login_cancelled": "Login cancelled", + "qqmusic_login_failed": "Login failed", + "qqmusic_login_required": "Please login to QQ Music to play online tracks", + "qqmusic_login_failed_detail": "Login failed: {error}", + "qqmusic_login_method": "Login Method", + "qqmusic_login_refused": "Login was rejected", + "qqmusic_login_success": "Login successful! Credentials saved.", + "qqmusic_login_success_no_credential": "Login succeeded but no credential was returned", + "qqmusic_login_subtitle": "Scan with QQ or WeChat on your phone. The session is stored for the QQ Music plugin.", + "qqmusic_loading_qr": "Loading QR Code...", + "qqmusic_login_title": "QQ Music QR Login", + "qqmusic_logging_in": "Logging in...", + "qqmusic_logout": "Logout", + "qqmusic_instructions": "Use {app} to scan the QR code and login to QQ Music", + "qqmusic_not_logged_in": "Not logged in", + "qqmusic_page_title": "QQ Music", + "qqmusic_account_hint": "After login, the plugin can sync liked songs, playlists, albums, and followed artists.", + "qqmusic_quality": "Quality", + "qqmusic_quality_hint": "Quality affects playback and cache requests. Some qualities require account access.", + "qqmusic_quality_master": "Master", + "qqmusic_quality_atmos_2": "Atmos 2.0", + "qqmusic_quality_atmos_51": "Atmos 5.1", + "qqmusic_quality_dolby": "Dolby Audio", + "qqmusic_quality_hires": "Hi-Res", + "qqmusic_quality_flac": "FLAC Lossless", + "qqmusic_quality_ape": "APE Lossless", + "qqmusic_quality_dts": "DTS", + "qqmusic_quality_ogg_640": "OGG 640kbps", + "qqmusic_quality_320": "MP3 320kbps", + "qqmusic_quality_ogg_320": "OGG 320kbps", + "qqmusic_quality_aac_320": "AAC 320kbps", + "qqmusic_quality_aac_256": "AAC 256kbps", + "qqmusic_quality_aac_192": "AAC 192kbps", + "qqmusic_quality_ogg_192": "OGG 192kbps", + "qqmusic_quality_128": "MP3 128kbps", + "qqmusic_quality_aac_128": "AAC 128kbps", + "qqmusic_quality_aac_96": "AAC 96kbps", + "qqmusic_quality_ogg_96": "OGG 96kbps", + "qqmusic_quality_aac_64": "AAC 64kbps", + "qqmusic_quality_aac_48": "AAC 48kbps", + "qqmusic_quality_aac_24": "AAC 24kbps", + "qqmusic_qq_login": "QQ Login", + "qqmusic_qr_display_failed": "Failed to display QR code", + "qqmusic_qr_expired": "QR Code Expired", + "qqmusic_qr_timeout_refresh": "QR code expired. Click refresh to get a new one", + "qqmusic_rankings": "Rankings", + "qqmusic_refresh_qr": "Refresh QR Code", + "qqmusic_related_albums": "Related Albums", + "qqmusic_remove_from_favorites": "Remove from QQ Favorites", + "qqmusic_scan_confirmed": "Scanned! Please confirm on your phone...", + "qqmusic_waiting_scan": "Waiting to scan...", + "qqmusic_scan_wechat_login": "Use WeChat to scan and login to QQ Music", + "qqmusic_user_cancelled": "Login cancelled", + "qqmusic_scan_with_app": "Scan QR code with {app} to login...", + "qqmusic_settings_title": "QQ Music Settings", + "qqmusic_wx_login": "WeChat Login", + "qqmusic_you_cancelled": "You have cancelled the login", + "radar_recommend": "Radar Recommendations", + "rankings": "Rankings", + "recommend_playlists": "Recommended Playlists", + "recommendations": "Recommendations", + "remove_from_favorites": "❌ Remove from Favorites", + "remove_from_qq_favorites": "Unfavorite", + "results": "results", + "save": "Save", + "search": "Search", + "search_failed": "Search failed", + "search_history": "Search History", + "search_online_music": "Search songs, artists, albums...", + "search_result": "Search result", + "searching": "Searching", + "select_ranking": "Select a ranking", + "singers": "Singers", + "songs": "Songs", + "source_qq": "QQ Music", + "success": "Success", + "switch_to_list_view": "Switch to List View", + "switch_to_table_view": "Switch to Table View", + "ten_thousand": "", + "title": "Title", + "toggle_view": "Toggle View", + "tracks": " tracks", + "view_details": "🔍 View Details" +} diff --git a/plugins/builtin/qqmusic/translations/zh.json b/plugins/builtin/qqmusic/translations/zh.json new file mode 100644 index 00000000..3d0bb1cf --- /dev/null +++ b/plugins/builtin/qqmusic/translations/zh.json @@ -0,0 +1,146 @@ +{ + "add_all_to_queue": "➕ 全部添加到队列", + "add_to_favorites": "⭐ 添加收藏", + "add_to_playlist": "添加到播放列表", + "add_to_qq_favorites": "收藏到QQ音乐", + "add_to_queue": "➕ 添加到队列", + "added_x_tracks_to_favorites": "已添加 {count} 首歌曲到收藏", + "album": "专辑", + "albums": "专辑", + "artist": "歌手", + "artists": "歌手", + "back": "返回", + "cancel": "取消", + "clear_all": "清空", + "clear_credentials": "清除凭证", + "cover": "封面", + "created_playlists": "创建的歌单", + "delete": "删除", + "detail_not_available": "详情不可用", + "download": "⬇ 下载", + "download_failed": "下载失败", + "downloading": "正在下载", + "duration": "时长", + "error": "错误", + "fans": "粉丝", + "favorites": "收藏", + "fav_albums": "收藏的专辑", + "fav_playlists": "收藏的歌单", + "fav_songs": "收藏的歌曲", + "follow": "关注", + "followed": "已关注", + "followed_singers": "关注的歌手", + "guess_you_like": "猜你喜欢", + "home_recommend": "今日推荐", + "hot_search": "热搜", + "insert_all_to_queue": "📥 全部插入队列", + "insert_to_queue": "📥 插入到队列", + "load_more": "加载更多", + "loading": "加载中...", + "login": "登录", + "logout": "退出登录", + "logout_success": "已退出登录", + "my_favorites": "我的收藏", + "new_songs": "新歌推荐", + "next_page": "下一页", + "online_music": "网络音乐", + "play": "▶️ 播放", + "play_all": "▶️ 播放全部", + "play_now": "▶️ 立即播放", + "playlists": "播放列表", + "plays": "次播放", + "previous_page": "上一页", + "qqmusic_add_to_favorites": "加入 QQ 收藏", + "qqmusic_click_refresh_to_start": "点击“刷新二维码”开始登录", + "qqmusic_confirmed_logging_in": "已确认,正在登录", + "qqmusic_fetch_qr_failed": "获取二维码失败", + "qqmusic_fetching_qr": "正在获取二维码...", + "qqmusic_follow": "关注", + "qqmusic_followed": "已关注", + "qqmusic_logged_in": "已登录", + "qqmusic_logged_in_as": "已登录:", + "qqmusic_login": "登录", + "qqmusic_login_cancelled": "用户取消登录", + "qqmusic_login_failed": "登录失败", + "qqmusic_login_required": "请先登录QQ音乐才能播放在线音乐", + "qqmusic_login_failed_detail": "登录失败:{error}", + "qqmusic_login_method": "登录方式", + "qqmusic_login_refused": "登录被拒绝", + "qqmusic_login_success": "登录成功!凭证已保存。", + "qqmusic_login_success_no_credential": "登录成功但未返回凭据", + "qqmusic_login_subtitle": "使用手机 QQ 或微信扫码登录,登录状态将同步到 QQ 音乐插件。", + "qqmusic_loading_qr": "加载二维码...", + "qqmusic_login_title": "QQ音乐扫码登录", + "qqmusic_logging_in": "正在登录...", + "qqmusic_logout": "退出登录", + "qqmusic_instructions": "请使用手机{app}扫描二维码登录QQ音乐", + "qqmusic_not_logged_in": "未登录", + "qqmusic_page_title": "QQ 音乐", + "qqmusic_account_hint": "登录后可同步我喜欢的歌曲、收藏歌单、专辑和关注歌手。", + "qqmusic_quality": "音质", + "qqmusic_quality_hint": "音质会影响在线播放与下载缓存请求,部分音质需要账号权限。", + "qqmusic_quality_master": "臻品母带", + "qqmusic_quality_atmos_2": "臻品全景声 2.0", + "qqmusic_quality_atmos_51": "臻品全景声 5.1", + "qqmusic_quality_dolby": "杜比音质", + "qqmusic_quality_hires": "Hi-Res", + "qqmusic_quality_flac": "FLAC 无损", + "qqmusic_quality_ape": "APE 无损", + "qqmusic_quality_dts": "DTS", + "qqmusic_quality_ogg_640": "OGG 640kbps", + "qqmusic_quality_320": "MP3 320kbps", + "qqmusic_quality_ogg_320": "OGG 320kbps", + "qqmusic_quality_aac_320": "AAC 320kbps", + "qqmusic_quality_aac_256": "AAC 256kbps", + "qqmusic_quality_aac_192": "AAC 192kbps", + "qqmusic_quality_ogg_192": "OGG 192kbps", + "qqmusic_quality_128": "MP3 128kbps", + "qqmusic_quality_aac_128": "AAC 128kbps", + "qqmusic_quality_aac_96": "AAC 96kbps", + "qqmusic_quality_ogg_96": "OGG 96kbps", + "qqmusic_quality_aac_64": "AAC 64kbps", + "qqmusic_quality_aac_48": "AAC 48kbps", + "qqmusic_quality_aac_24": "AAC 24kbps", + "qqmusic_qq_login": "QQ登录", + "qqmusic_qr_display_failed": "二维码显示失败", + "qqmusic_qr_expired": "二维码已过期", + "qqmusic_qr_timeout_refresh": "二维码已过期,请点击刷新按钮重新生成", + "qqmusic_rankings": "排行榜", + "qqmusic_refresh_qr": "刷新二维码", + "qqmusic_related_albums": "相关专辑", + "qqmusic_remove_from_favorites": "移出 QQ 收藏", + "qqmusic_scan_confirmed": "已扫码,请在手机上确认登录...", + "qqmusic_waiting_scan": "等待扫码...", + "qqmusic_scan_wechat_login": "请使用微信扫码登录 QQ 音乐", + "qqmusic_user_cancelled": "已取消登录", + "qqmusic_scan_with_app": "请使用手机{app}扫描二维码登录...", + "qqmusic_settings_title": "QQ 音乐设置", + "qqmusic_wx_login": "微信登录", + "qqmusic_you_cancelled": "您已取消登录", + "radar_recommend": "雷达推荐", + "rankings": "排行榜", + "recommend_playlists": "推荐歌单", + "recommendations": "推荐", + "remove_from_favorites": "❌ 取消收藏", + "remove_from_qq_favorites": "取消收藏", + "results": "个结果", + "save": "保存", + "search": "搜索", + "search_failed": "搜索失败", + "search_history": "搜索历史", + "search_online_music": "搜索歌曲、歌手、专辑...", + "search_result": "搜索结果", + "searching": "搜索中", + "select_ranking": "选择排行榜", + "singers": "歌手", + "songs": "歌曲", + "source_qq": "QQ音乐", + "success": "成功", + "switch_to_list_view": "切换到列表视图", + "switch_to_table_view": "切换到表格视图", + "ten_thousand": "万", + "title": "标题", + "toggle_view": "切换视图", + "tracks": "首歌曲", + "view_details": "🔍 查看详情" +} diff --git a/pyproject.toml b/pyproject.toml index 332718d1..e50b7506 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -7,6 +7,7 @@ requires-python = ">=3.11" dependencies = [ "beautifulsoup4>=4.14.3", "certifi>=2024.0.0", + "harmony-plugin-api>=0.1.0", "lxml>=6.0.2", "mpv>=1.0.0", "mutagen>=1.47.0", diff --git a/release.sh b/release.sh index 38253d0c..78565845 100755 --- a/release.sh +++ b/release.sh @@ -178,7 +178,6 @@ build_app() { --additional-hooks-dir=hooks --collect-all certifi --hidden-import mpv - --collect-all qqmusic_api --add-data "ui:ui" --add-data "translations:translations" --add-data "fonts:fonts" diff --git a/services/online/adapter.py b/services/online/adapter.py index b6674bdb..54fe7eff 100644 --- a/services/online/adapter.py +++ b/services/online/adapter.py @@ -370,17 +370,28 @@ def _normalize_qqmusic( # Get total count result.total = raw_data.get("meta", {}).get("sum", 0) - # Type keys for different search types + body = raw_data.get("body", {}) type_keys = { - SearchType.SONG: "item_song", - SearchType.SINGER: "singer", - SearchType.ALBUM: "item_album", - SearchType.PLAYLIST: "item_songlist", + SearchType.SONG: ("item_song", "song"), + SearchType.SINGER: ("item_singer", "singer"), + SearchType.ALBUM: ("item_album", "album"), + SearchType.PLAYLIST: ("item_songlist", "songlist", "playlist"), } - result_key = type_keys.get(search_type, "item_song") - body = raw_data.get("body", {}) - items = body.get(result_key, []) + items: list[dict] = [] + for key in type_keys.get(search_type, ("item_song", "song")): + payload = body.get(key, []) + if isinstance(payload, list) and payload: + items = payload + break + if isinstance(payload, dict): + for nested_key in ("list", "itemlist", "items", "data"): + nested_payload = payload.get(nested_key, []) + if isinstance(nested_payload, list) and nested_payload: + items = nested_payload + break + if items: + break if search_type == SearchType.SONG: result.tracks = OnlineMusicAdapter._parse_qqmusic_tracks(items) @@ -431,7 +442,8 @@ def _parse_qqmusic_tracks(items: List[Dict]) -> List[OnlineTrack]: album_data = item.get("album") if isinstance(album_data, str): album_name = _RE_HTML_TAG.sub('', album_data) if album_data else "" - album = AlbumInfo(mid="", name=album_name) + album_mid = item.get("album_mid", item.get("albummid", "")) + album = AlbumInfo(mid=album_mid, name=album_name) elif isinstance(album_data, dict): album_name = album_data.get("name", "") if album_name: @@ -471,12 +483,24 @@ def _parse_qqmusic_artists(items: List[Dict]) -> List[OnlineArtist]: """Parse artists from QQ Music API format.""" artists = [] for item in items: + mid = item.get("singerMID", item.get("mid", "")) + avatar_url = ( + item.get("singerPic") + or item.get("avatar") + or item.get("cover") + or item.get("cover_url") + or item.get("pic") + or "" + ) + if not avatar_url and mid: + avatar_url = f"https://y.gtimg.cn/music/photo_new/T001R300x300M000{mid}.jpg" artist = OnlineArtist( - mid=item.get("singerMID", item.get("mid", "")), - name=item.get("singerName", ""), - avatar_url=item.get("singerPic", ""), - song_count=item.get("songNum", 0), - album_count=item.get("albumNum", 0) + mid=mid, + name=item.get("singerName", item.get("name", "")), + avatar_url=avatar_url, + song_count=item.get("songNum", item.get("song_count", item.get("songnum", 0))), + album_count=item.get("albumNum", item.get("album_count", item.get("albumnum", 0))), + fan_count=item.get("fan_count", item.get("FanNum", 0)), ) artists.append(artist) return artists diff --git a/services/online/download_service.py b/services/online/download_service.py index cc374a13..3b51c344 100644 --- a/services/online/download_service.py +++ b/services/online/download_service.py @@ -5,7 +5,7 @@ import logging import os -from typing import Dict, Optional, Callable, Any, TYPE_CHECKING +from typing import Dict, Optional, Callable, Any, TYPE_CHECKING, Protocol from infrastructure.network import HttpClient from system.event_bus import EventBus @@ -14,7 +14,13 @@ if TYPE_CHECKING: from system.config import ConfigManager - from plugins.builtin.qqmusic.lib.legacy.qqmusic_service import QQMusicService + + +class PlaybackUrlProvider(Protocol): + credential: Any + + def get_playback_url_info(self, song_mid: str, quality: str = "flac") -> Optional[Dict[str, Any]]: + ... logger = logging.getLogger(__name__) @@ -42,7 +48,7 @@ class OnlineDownloadService: def __init__( self, config_manager: Optional["ConfigManager"] = None, - qqmusic_service: Optional["QQMusicService"] = None, + credential_provider: Optional["PlaybackUrlProvider"] = None, online_music_service=None, download_dir: Optional[str] = None ): @@ -51,12 +57,12 @@ def __init__( Args: config_manager: ConfigManager instance - qqmusic_service: QQMusicService instance + credential_provider: Optional credential-backed playback provider online_music_service: OnlineMusicService instance (preferred) download_dir: Download directory path """ self._config = config_manager - self._qqmusic = qqmusic_service + self._provider = credential_provider self._online_service = online_music_service self._download_dir = download_dir or self._get_default_download_dir() self._event_bus = EventBus.instance() @@ -171,11 +177,11 @@ def download( else: url = self._online_service.get_playback_url(song_mid, quality) - elif self._qqmusic: + elif self._provider: # Fallback to QQ Music direct API quality_fallback = ["320", "128", "flac"] for q in quality_fallback: - playback_info = self._qqmusic.get_playback_url_info(song_mid, q) + playback_info = self._provider.get_playback_url_info(song_mid, q) if playback_info: url = playback_info.get("url") actual_quality = playback_info.get("quality") or q diff --git a/services/online/online_music_service.py b/services/online/online_music_service.py index 98eaad0a..07553224 100644 --- a/services/online/online_music_service.py +++ b/services/online/online_music_service.py @@ -23,33 +23,33 @@ class OnlineMusicService: """ Service for online music search and browsing. - Uses api.ygking.top by default, falls back to QQ Music local API - if credential is available. + Uses api.ygking.top by default, and can optionally defer to a + credential-backed provider when one is supplied by the runtime. """ # API endpoints YGKING_BASE_URL = "https://api.ygking.top" def __init__(self, config_manager: Optional["ConfigManager"] = None, - qqmusic_service=None): + credential_provider=None): """ Initialize online music service. Args: - config_manager: ConfigManager for QQ Music credential - qqmusic_service: Optional QQMusicService instance + config_manager: ConfigManager for plugin-scoped credentials + credential_provider: Optional credential-backed provider instance """ self._config = config_manager - self._qqmusic = qqmusic_service + self._provider = credential_provider self._http_client = HttpClient.shared(default_headers={ 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36', 'Accept': 'application/json', }) def _has_qqmusic_credential(self) -> bool: - """Check if QQ Music credential is available.""" - # Check if qqmusic_service has credential - if self._qqmusic and self._qqmusic.credential: + """Check if plugin-scoped credential is available.""" + # Check if provider has credential + if self._provider and self._provider.credential: return True # Check config if available @@ -83,7 +83,7 @@ def search( SearchResult object """ # Prefer QQ Music local API if credential is available - if self._has_qqmusic_credential() and self._qqmusic: + if self._has_qqmusic_credential() and self._provider: return self._search_qqmusic(keyword, search_type, page, page_size) # Use YGKing API @@ -137,7 +137,7 @@ def _search_qqmusic( ) -> SearchResult: """Search using QQ Music local API.""" try: - result = self._qqmusic.client.search( + result = self._provider.client.search( keyword, search_type=search_type, page_num=page, @@ -165,7 +165,7 @@ def get_top_lists(self) -> List[Dict[str, Any]]: List of top lists with id and name """ # Prefer QQ Music local API if credential is available - if self._has_qqmusic_credential() and self._qqmusic: + if self._has_qqmusic_credential() and self._provider: return self._get_top_lists_qqmusic() return self._get_top_lists_ygking() @@ -173,7 +173,7 @@ def get_top_lists(self) -> List[Dict[str, Any]]: def _get_top_lists_qqmusic(self) -> List[Dict[str, Any]]: """Get top lists using QQ Music local API.""" try: - result = self._qqmusic.get_top_lists() + result = self._provider.get_top_lists() if result: logger.debug(f"Got {len(result)} top lists from QQ Music local API") return result @@ -232,7 +232,7 @@ def get_top_list_songs(self, top_id: int, num: int = 100) -> List[OnlineTrack]: List of OnlineTrack objects """ # Prefer QQ Music local API (GetDetail works without login) - if self._qqmusic: + if self._provider: return self._get_top_list_songs_qqmusic(top_id, num) return self._get_top_list_songs_ygking(top_id, num) @@ -240,7 +240,7 @@ def get_top_list_songs(self, top_id: int, num: int = 100) -> List[OnlineTrack]: def _get_top_list_songs_qqmusic(self, top_id: int, num: int) -> List[OnlineTrack]: """Get top list songs using QQ Music local API.""" try: - songs = self._qqmusic.get_top_list_songs(top_id, num) + songs = self._provider.get_top_list_songs(top_id, num) if songs: logger.debug(f"Got {len(songs)} songs from QQ Music local API for top_id={top_id}") return OnlineMusicAdapter._parse_qqmusic_tracks(songs) @@ -292,9 +292,9 @@ def get_artist_detail(self, singer_mid: str, page: int = 1, page_size: int = 50) Artist detail dict or None """ # Prefer QQ Music API for detail - if self._has_qqmusic_credential() and self._qqmusic: + if self._has_qqmusic_credential() and self._provider: # Use batch request to get both detail and follow status - result = self._qqmusic.get_singer_info_with_follow_status(singer_mid, page=page, page_size=page_size) + result = self._provider.get_singer_info_with_follow_status(singer_mid, page=page, page_size=page_size) if result: return result logger.debug("QQ Music returned no artist detail, falling back to YGKing") @@ -359,8 +359,8 @@ def get_artist_albums(self, singer_mid: str, number: int = 10, begin: int = 0) - """ logger.debug(f"get_artist_albums: singer_mid={singer_mid}, number={number}, begin={begin}") # Prefer QQ Music API if credential is available - if self._has_qqmusic_credential() and self._qqmusic: - result = self._qqmusic.get_singer_albums(singer_mid, number=number, begin=begin) + if self._has_qqmusic_credential() and self._provider: + result = self._provider.get_singer_albums(singer_mid, number=number, begin=begin) if result and result.get('albums'): logger.debug(f"get_artist_albums: QQ Music returned {len(result['albums'])} albums, total={result.get('total', 0)}") return result @@ -421,9 +421,9 @@ def get_album_detail(self, album_mid: str, page: int = 1, page_size: int = 50) - Album detail dict or None """ # Prefer QQ Music API for detail - if self._has_qqmusic_credential() and self._qqmusic: + if self._has_qqmusic_credential() and self._provider: # Use batch request to get both detail and fav status - result = self._qqmusic.get_album_info_with_fav_status(album_mid, page=page, page_size=page_size) + result = self._provider.get_album_info_with_fav_status(album_mid, page=page, page_size=page_size) if result: return result logger.debug("QQ Music returned no album detail, falling back to YGKing") @@ -516,8 +516,8 @@ def get_playlist_detail(self, playlist_id: str, page: int = 1, page_size: int = # Prefer QQ Music API for detail # Use batch API for all pages since QQ Music max return is 30 songs # First page includes fav status query, subsequent pages don't need it - if self._has_qqmusic_credential() and self._qqmusic: - result = self._qqmusic.get_playlist_info_with_fav_status(playlist_id, page=page, page_size=page_size) + if self._has_qqmusic_credential() and self._provider: + result = self._provider.get_playlist_info_with_fav_status(playlist_id, page=page, page_size=page_size) if result: return result logger.debug("QQ Music returned no playlist detail, falling back to YGKing") @@ -577,13 +577,13 @@ def get_playback_url_info(self, song_mid: str, quality: Optional[str] = None) -> quality = "320" # Prefer QQ Music local API if credential is available - if self._has_qqmusic_credential() and self._qqmusic: + if self._has_qqmusic_credential() and self._provider: # Try different qualities in order quality_fallback = ["320", "128", "flac"] start_index = quality_fallback.index(quality) if quality in quality_fallback else 0 for q in quality_fallback[start_index:]: - info = self._qqmusic.get_playback_url_info(song_mid, q) + info = self._provider.get_playback_url_info(song_mid, q) if info: return info @@ -635,8 +635,8 @@ def get_lyrics(self, song_mid: str) -> Dict[str, Optional[str]]: Returns: Dict with lyric, qrc, trans keys """ - if self._has_qqmusic_credential() and self._qqmusic: - return self._qqmusic.get_lyrics(song_mid) + if self._has_qqmusic_credential() and self._provider: + return self._provider.get_lyrics(song_mid) return {"lyric": None, "qrc": None, "trans": None} @@ -651,7 +651,7 @@ def get_song_detail(self, song_mid: str) -> Optional[Dict[str, Any]]: Dict with song details or None """ # Prefer QQ Music local API if credential is available - if self._has_qqmusic_credential() and self._qqmusic: + if self._has_qqmusic_credential() and self._provider: return self._get_song_detail_qqmusic(song_mid) # Use YGKing remote API @@ -660,7 +660,7 @@ def get_song_detail(self, song_mid: str) -> Optional[Dict[str, Any]]: def _get_song_detail_qqmusic(self, song_mid: str) -> Optional[Dict[str, Any]]: """Get song detail using QQ Music local API.""" try: - result = self._qqmusic.client.get_song_detail(song_mid) + result = self._provider.client.get_song_detail(song_mid) track_info = result.get("track_info", {}) if track_info: return { @@ -679,50 +679,50 @@ def _get_song_detail_qqmusic(self, song_mid: str) -> Optional[Dict[str, Any]]: def follow_singer(self, singer_mid: str) -> bool: """Follow a singer.""" - if self._qqmusic: - return self._qqmusic.follow_singer(singer_mid) + if self._provider: + return self._provider.follow_singer(singer_mid) return False def unfollow_singer(self, singer_mid: str) -> bool: """Unfollow a singer.""" - if self._qqmusic: - return self._qqmusic.unfollow_singer(singer_mid) + if self._provider: + return self._provider.unfollow_singer(singer_mid) return False def fav_song(self, song_id: int) -> bool: """Add a song to favorites.""" - if self._qqmusic: - return self._qqmusic.fav_song(song_id) + if self._provider: + return self._provider.fav_song(song_id) return False def unfav_song(self, song_id: int) -> bool: """Remove a song from favorites.""" - if self._qqmusic: - return self._qqmusic.unfav_song(song_id) + if self._provider: + return self._provider.unfav_song(song_id) return False def fav_album(self, album_mid: str) -> bool: """Favorite an album.""" - if self._qqmusic: - return self._qqmusic.fav_album(album_mid) + if self._provider: + return self._provider.fav_album(album_mid) return False def unfav_album(self, album_mid: str) -> bool: """Unfavorite an album.""" - if self._qqmusic: - return self._qqmusic.unfav_album(album_mid) + if self._provider: + return self._provider.unfav_album(album_mid) return False def fav_playlist(self, playlist_id) -> bool: """Favorite a playlist.""" - if self._qqmusic: - return self._qqmusic.fav_playlist(playlist_id) + if self._provider: + return self._provider.fav_playlist(playlist_id) return False def unfav_playlist(self, playlist_id) -> bool: """Unfavorite a playlist.""" - if self._qqmusic: - return self._qqmusic.unfav_playlist(playlist_id) + if self._provider: + return self._provider.unfav_playlist(playlist_id) return False def _get_song_detail_ygking(self, song_mid: str) -> Optional[Dict[str, Any]]: diff --git a/system/event_bus.py b/system/event_bus.py index 5ea6b484..a722f64b 100644 --- a/system/event_bus.py +++ b/system/event_bus.py @@ -86,6 +86,9 @@ class EventBus(QObject): # Emitted when lyrics are loaded (lyrics_text) lyrics_loaded = Signal(str) + # Emitted when UI language changes (lang code) + language_changed = Signal(str) + # Emitted when lyrics loading fails (error_message) lyrics_error = Signal(str) diff --git a/system/plugins/host_services.py b/system/plugins/host_services.py index ddb37566..0ff66b55 100644 --- a/system/plugins/host_services.py +++ b/system/plugins/host_services.py @@ -1,8 +1,10 @@ from __future__ import annotations +import json import logging from pathlib import Path +from .plugin_sdk_ui import PluginDialogBridgeImpl, PluginThemeBridgeImpl class PluginSettingsBridgeImpl: def __init__(self, plugin_id: str, config) -> None: @@ -12,10 +14,30 @@ def __init__(self, plugin_id: str, config) -> None: def _key(self, key: str) -> str: return f"plugins.{self._plugin_id}.{key}" + def _is_secret_key(self, key: str) -> bool: + return key in {"credential", "token", "secret", "api_key", "password"} + def get(self, key: str, default=None): + if self._is_secret_key(key) and hasattr(self._config, "get_plugin_secret"): + value = self._config.get_plugin_secret(self._plugin_id, key, default) + if key == "credential" and isinstance(value, str) and value: + try: + return json.loads(value) + except Exception: + return default + return value return self._config.get(self._key(key), default) def set(self, key: str, value) -> None: + if self._is_secret_key(key) and hasattr(self._config, "set_plugin_secret"): + secret_value = value + if key == "credential": + if value in (None, ""): + secret_value = "" + elif not isinstance(value, str): + secret_value = json.dumps(value, ensure_ascii=False) + self._config.set_plugin_secret(self._plugin_id, key, secret_value) + return self._config.set(self._key(key), value) @@ -32,6 +54,8 @@ class PluginUiBridgeImpl: def __init__(self, plugin_id: str, registry) -> None: self._plugin_id = plugin_id self._registry = registry + self._theme = PluginThemeBridgeImpl() + self._dialogs = PluginDialogBridgeImpl() def register_sidebar_entry(self, spec) -> None: self._registry.register_sidebar_entry(self._plugin_id, spec) @@ -39,7 +63,13 @@ def register_sidebar_entry(self, spec) -> None: def register_settings_tab(self, spec) -> None: self._registry.register_settings_tab(self._plugin_id, spec) + @property + def theme(self): + return self._theme + @property + def dialogs(self): + return self._dialogs class PluginServiceBridgeImpl: def __init__(self, plugin_id: str, registry, media_bridge) -> None: self._plugin_id = plugin_id @@ -72,7 +102,14 @@ def build(self, manifest): from harmony_plugin_api.context import PluginContext plugin_id = manifest.id - registry = self._bootstrap.plugin_manager.registry + logging.getLogger(__name__).info( + "[PluginHost] Building context for plugin %s", + plugin_id, + ) + manager = getattr(self._bootstrap, "_plugin_manager", None) + if manager is None: + manager = self._bootstrap.plugin_manager + registry = manager.registry media_bridge = PluginMediaBridge( self._bootstrap.online_download_service, self._bootstrap.playback_service, @@ -84,6 +121,7 @@ def build(self, manifest): logger=logging.getLogger(f"plugin.{plugin_id}"), http=self._bootstrap.http_client, events=self._bootstrap.event_bus, + language=self._bootstrap.config.get_language(), storage=PluginStorageBridgeImpl(self._storage_root, plugin_id), settings=PluginSettingsBridgeImpl(plugin_id, self._bootstrap.config), ui=PluginUiBridgeImpl(plugin_id, registry), diff --git a/system/plugins/loader.py b/system/plugins/loader.py index c2d6a9f7..e011455d 100644 --- a/system/plugins/loader.py +++ b/system/plugins/loader.py @@ -1,19 +1,56 @@ from __future__ import annotations +import builtins import hashlib import importlib.util import json +import logging import re import sys import types +from contextlib import contextmanager from pathlib import Path from harmony_plugin_api.manifest import PluginManifest from .errors import PluginLoadError +logger = logging.getLogger(__name__) +_FORBIDDEN_IMPORT_ROOTS = { + "app", + "domain", + "services", + "repositories", + "infrastructure", + "system", + "ui", +} + class PluginLoader: + @contextmanager + def _guard_imports(self, package_name: str): + original_import = builtins.__import__ + + def _guarded_import(name, globals=None, locals=None, fromlist=(), level=0): + if level == 0 and name: + caller_name = "" + if isinstance(globals, dict): + caller_name = str(globals.get("__name__", "") or "") + root = name.split(".")[0] + if ( + caller_name.startswith(package_name) + and root in _FORBIDDEN_IMPORT_ROOTS + ): + raise ImportError(f"Forbidden host import: {name}") + return original_import(name, globals, locals, fromlist, level) + + builtins.__import__ = _guarded_import + try: + yield + finally: + builtins.__import__ = original_import + def _package_name(self, manifest_id: str, plugin_root: Path) -> str: safe_id = re.sub(r"[^0-9a-zA-Z_]", "_", manifest_id) root_hash = hashlib.sha1( @@ -63,7 +100,13 @@ def _load_entry_module( module = importlib.util.module_from_spec(spec) sys.modules[module_name] = module try: - spec.loader.exec_module(module) + logger.debug( + "[PluginLoader] Executing entry module %s for plugin %s", + module_name, + manifest.id, + ) + with self._guard_imports(package_name): + spec.loader.exec_module(module) return module except Exception as exc: raise PluginLoadError( @@ -92,6 +135,11 @@ def load_plugin(self, plugin_root: Path, manifest: PluginManifest | None = None) ) try: plugin_class = getattr(module, manifest.entry_class) + logger.debug( + "[PluginLoader] Instantiating plugin class %s for %s", + manifest.entry_class, + manifest.id, + ) return manifest, plugin_class() except Exception as exc: raise PluginLoadError( diff --git a/system/plugins/manager.py b/system/plugins/manager.py index 20e564e7..427cdcd5 100644 --- a/system/plugins/manager.py +++ b/system/plugins/manager.py @@ -1,5 +1,7 @@ from __future__ import annotations +import logging +import time from pathlib import Path from urllib.parse import urlparse @@ -8,6 +10,8 @@ from .loader import PluginLoader from .registry import PluginRegistry +logger = logging.getLogger(__name__) + class PluginManager: def __init__(self, builtin_root: Path, external_root: Path, state_store, context_factory) -> None: @@ -45,24 +49,41 @@ def _is_plugin_root(path: Path) -> bool: return sorted(roots, key=lambda item: (item[0], item[1].name)) def load_enabled_plugins(self) -> None: - for source, plugin_root in self.discover_roots(): + roots = self.discover_roots() + logger.info("[PluginManager] Discovered %s plugin roots", len(roots)) + for source, plugin_root in roots: manifest = None state = None plugin = None context = None + started_at = time.perf_counter() try: manifest = self._loader.read_manifest(plugin_root) if manifest.id in self._loaded_plugins: + logger.debug("[PluginManager] Skip already loaded plugin %s", manifest.id) continue state = self._state_store.get(manifest.id) if state and state.get("enabled") is False: + logger.info("[PluginManager] Skip disabled plugin %s", manifest.id) continue + logger.info( + "[PluginManager] Loading plugin %s from %s (%s)", + manifest.id, + plugin_root, + source, + ) manifest, plugin = self._loader.load_plugin(plugin_root, manifest) context = self._context_factory.build(manifest) plugin.register(context) self._loaded_plugins[manifest.id] = (manifest, plugin, context) + duration_ms = (time.perf_counter() - started_at) * 1000 + logger.info( + "[PluginManager] Loaded plugin %s in %.1fms", + manifest.id, + duration_ms, + ) self._state_store.set_enabled( manifest.id, True if state is None else bool(state.get("enabled", True)), @@ -79,6 +100,11 @@ def load_enabled_plugins(self) -> None: plugin.unregister(context) except Exception: pass + logger.exception( + "[PluginManager] Failed to load plugin %s from %s", + plugin_id, + plugin_root, + ) self.registry.unregister_plugin(plugin_id) self._loaded_plugins.pop(plugin_id, None) self._state_store.set_enabled( @@ -106,6 +132,22 @@ def list_plugins(self) -> list[dict]: ) return plugins + def set_plugin_enabled(self, plugin_id: str, enabled: bool) -> None: + for source, plugin_root in self.discover_roots(): + manifest = self._loader.read_manifest(plugin_root) + if manifest.id != plugin_id: + continue + existing = self._state_store.get(plugin_id) or {} + self._state_store.set_enabled( + plugin_id, + enabled, + source=existing.get("source", source), + version=existing.get("version", manifest.version), + load_error=existing.get("load_error"), + ) + return + raise KeyError(f"Unknown plugin: {plugin_id}") + def install_zip(self, zip_path: str | Path) -> Path: return self._installer.install_zip(Path(zip_path)) diff --git a/system/plugins/media_bridge.py b/system/plugins/media_bridge.py index 5d0cfba5..5f83374e 100644 --- a/system/plugins/media_bridge.py +++ b/system/plugins/media_bridge.py @@ -1,10 +1,12 @@ from __future__ import annotations +from domain.playlist_item import PlaylistItem +from domain.track import TrackSource from harmony_plugin_api.media import PluginPlaybackRequest class PluginMediaBridge: - """Host bridge for plugin-triggered cache/download/library actions.""" + """Host bridge for plugin-triggered cache/download/library/queue actions.""" def __init__(self, download_service, playback_service, library_service) -> None: self._download_service = download_service @@ -35,3 +37,52 @@ def add_online_track(self, request: PluginPlaybackRequest): float(metadata.get("duration", 0.0) or 0.0), metadata.get("cover_url"), ) + + def play_online_track(self, request: PluginPlaybackRequest) -> int | None: + track_id = self.add_online_track(request) + item = self._build_playlist_item(request, track_id) + self._playback_service.engine.load_playlist_items([item]) + self._playback_service.engine.play() + self._playback_service.save_queue() + return track_id + + def add_online_track_to_queue(self, request: PluginPlaybackRequest) -> int | None: + track_id = self.add_online_track(request) + item = self._build_playlist_item(request, track_id) + self._playback_service.engine.add_track(item) + self._playback_service._schedule_save_queue() + return track_id + + def insert_online_track_to_queue(self, request: PluginPlaybackRequest) -> int | None: + track_id = self.add_online_track(request) + item = self._build_playlist_item(request, track_id) + current_index = self._playback_service.engine.current_index + insert_index = current_index + 1 if current_index >= 0 else 0 + self._playback_service.engine.insert_track(insert_index, item) + self._playback_service._schedule_save_queue() + return track_id + + def _build_playlist_item( + self, + request: PluginPlaybackRequest, + track_id: int | None, + ) -> PlaylistItem: + metadata = request.metadata + local_path = "" + needs_download = True + if self._download_service and self._download_service.is_cached(request.track_id): + local_path = self._download_service.get_cached_path(request.track_id) + needs_download = False + return PlaylistItem( + track_id=track_id, + source=TrackSource.QQ, + local_path=local_path, + title=metadata.get("title", request.title), + artist=metadata.get("artist", ""), + album=metadata.get("album", ""), + duration=float(metadata.get("duration", 0.0) or 0.0), + cover_path=metadata.get("cover_url"), + cloud_file_id=request.track_id, + needs_download=needs_download, + needs_metadata=False, + ) diff --git a/system/plugins/plugin_sdk_runtime.py b/system/plugins/plugin_sdk_runtime.py new file mode 100644 index 00000000..de1b76ef --- /dev/null +++ b/system/plugins/plugin_sdk_runtime.py @@ -0,0 +1,190 @@ +from __future__ import annotations + +from typing import Any + + +class IconName: + GRID = "grid.svg" + LIST = "list.svg" + + +def event_bus(): + from system.event_bus import EventBus + + return EventBus.instance() + + +def create_online_music_service(*, config_manager=None, credential_provider=None): + from services.online import OnlineMusicService + + return OnlineMusicService( + config_manager=config_manager, + credential_provider=credential_provider, + ) + + +def create_online_download_service( + *, + config_manager=None, + credential_provider=None, + online_music_service=None, +): + from services.online import OnlineDownloadService + + return OnlineDownloadService( + config_manager=config_manager, + credential_provider=credential_provider, + online_music_service=online_music_service, + ) + + +def get_host_icon(name, color, size: int = 16): + from ui.icons import get_icon as _get_icon + + return _get_icon(name, color, size) + + +def get_icon(name, color, size: int = 16): + return get_host_icon(name, color, size) + + +def image_cache_get(url: str): + from infrastructure.cache import ImageCache + + return ImageCache.get(url) + + +def image_cache_set(url: str, image_data: bytes): + from infrastructure.cache import ImageCache + + return ImageCache.set(url, image_data) + + +def image_cache_path(url: str): + from infrastructure.cache import ImageCache + + return ImageCache._get_cache_path(url) + + +def http_get_content(url: str, *, timeout: int, headers: dict[str, str] | None = None): + from infrastructure.network import HttpClient + + return HttpClient().get_content(url, timeout=timeout, headers=headers) + + +def cover_pixmap_cache_initialize() -> None: + from infrastructure.cache.pixmap_cache import CoverPixmapCache + + CoverPixmapCache.initialize() + + +def cover_pixmap_cache_get(cache_key: str): + from infrastructure.cache.pixmap_cache import CoverPixmapCache + + return CoverPixmapCache.get(cache_key) + + +def cover_pixmap_cache_set(cache_key: str, pixmap) -> None: + from infrastructure.cache.pixmap_cache import CoverPixmapCache + + CoverPixmapCache.set(cache_key, pixmap) + + +def bootstrap(): + from app.bootstrap import Bootstrap + + return Bootstrap.instance() + + +def library_service(): + instance = bootstrap() + return getattr(instance, "library_service", None) if instance else None + + +def favorites_service(): + instance = bootstrap() + return getattr(instance, "favorites_service", None) if instance else None + + +def favorite_mids_from_library() -> set[str]: + instance = bootstrap() + if not instance or not getattr(instance, "favorites_service", None) or not getattr(instance, "library_service", None): + return set() + favorite_ids = instance.favorites_service.get_all_favorite_track_ids() + if not isinstance(favorite_ids, (set, list, tuple)) or not favorite_ids: + return set() + tracks = instance.library_service.get_tracks_by_ids(list(favorite_ids)) + if not isinstance(tracks, list): + return set() + mids: set[str] = set() + for track in tracks: + cloud_file_id = getattr(track, "cloud_file_id", None) + if cloud_file_id: + mids.add(str(cloud_file_id)) + return mids + + +def remove_library_favorite_by_mid(mid: str) -> bool: + instance = bootstrap() + if not instance or not getattr(instance, "favorites_service", None) or not getattr(instance, "library_service", None): + return False + library_track = instance.library_service.get_track_by_cloud_file_id(mid) + if library_track: + instance.favorites_service.remove_favorite(track_id=library_track.id) + return True + instance.favorites_service.remove_favorite(cloud_file_id=mid) + return True + + +def add_requests_to_favorites(requests: list[Any]) -> list[int]: + instance = bootstrap() + if not instance or not getattr(instance, "library_service", None) or not getattr(instance, "favorites_service", None): + return [] + track_ids: list[int] = [] + for request in requests: + track_id = instance.library_service.add_online_track( + request.track_id, + request.metadata.get("title", request.title), + request.metadata.get("artist", ""), + request.metadata.get("album", ""), + float(request.metadata.get("duration", 0.0) or 0.0), + request.metadata.get("cover_url"), + ) + if track_id: + instance.favorites_service.add_favorite(track_id=track_id) + track_ids.append(track_id) + return track_ids + + +def add_requests_to_playlist(parent, requests: list[Any], log_prefix: str) -> list[int]: + from utils.playlist_utils import add_tracks_to_playlist + + instance = bootstrap() + if not instance or not getattr(instance, "library_service", None): + return [] + + track_ids: list[int] = [] + for request in requests: + track_id = instance.library_service.add_online_track( + request.track_id, + request.metadata.get("title", request.title), + request.metadata.get("artist", ""), + request.metadata.get("album", ""), + float(request.metadata.get("duration", 0.0) or 0.0), + request.metadata.get("cover_url"), + ) + if track_id: + track_ids.append(track_id) + + if track_ids: + add_tracks_to_playlist(parent, instance.library_service, track_ids, log_prefix) + return track_ids + + +def add_track_ids_to_playlist(parent, track_ids: list[int], log_prefix: str) -> None: + from utils.playlist_utils import add_tracks_to_playlist + + instance = bootstrap() + if not instance or not getattr(instance, "library_service", None) or not track_ids: + return + add_tracks_to_playlist(parent, instance.library_service, track_ids, log_prefix) diff --git a/system/plugins/plugin_sdk_ui.py b/system/plugins/plugin_sdk_ui.py new file mode 100644 index 00000000..e1cf1ddd --- /dev/null +++ b/system/plugins/plugin_sdk_ui.py @@ -0,0 +1,89 @@ +from __future__ import annotations + + +class PluginThemeBridgeImpl: + def register_widget(self, widget) -> None: + from system.theme import ThemeManager + + ThemeManager.instance().register_widget(widget) + + def get_qss(self, template: str) -> str: + from system.theme import ThemeManager + + return ThemeManager.instance().get_qss(template) + + def current_theme(self): + from system.theme import ThemeManager + + return ThemeManager.instance().current_theme + + +class PluginDialogBridgeImpl: + def information(self, parent, title: str, message: str, buttons=None, default_button=None): + from ui.dialogs.message_dialog import MessageDialog + + if buttons is None: + return MessageDialog.information(parent, title, message) + return MessageDialog.information(parent, title, message, buttons, default_button) + + def warning(self, parent, title: str, message: str, buttons=None, default_button=None): + from ui.dialogs.message_dialog import MessageDialog + + if buttons is None: + return MessageDialog.warning(parent, title, message) + return MessageDialog.warning(parent, title, message, buttons, default_button) + + def question(self, parent, title: str, message: str, buttons, default_button): + from ui.dialogs.message_dialog import MessageDialog + + return MessageDialog.question(parent, title, message, buttons, default_button) + + def critical(self, parent, title: str, message: str, buttons=None, default_button=None): + from ui.dialogs.message_dialog import MessageDialog + + if buttons is None: + return MessageDialog.critical(parent, title, message) + return MessageDialog.critical(parent, title, message, buttons, default_button) + + def setup_title_bar(self, dialog, container_layout, title: str, **kwargs): + from ui.dialogs.dialog_title_bar import setup_equalizer_title_layout + + return setup_equalizer_title_layout(dialog, container_layout, title, **kwargs) + + +def register_themed_widget(widget) -> None: + PluginThemeBridgeImpl().register_widget(widget) + + +def get_qss(template: str) -> str: + return PluginThemeBridgeImpl().get_qss(template) + + +def current_theme(): + return PluginThemeBridgeImpl().current_theme() + + +def information(parent, title: str, message: str, buttons=None, default_button=None): + return PluginDialogBridgeImpl().information(parent, title, message, buttons, default_button) + + +def warning(parent, title: str, message: str, buttons=None, default_button=None): + return PluginDialogBridgeImpl().warning(parent, title, message, buttons, default_button) + + +def question(parent, title: str, message: str, buttons, default_button): + return PluginDialogBridgeImpl().question(parent, title, message, buttons, default_button) + + +def critical(parent, title: str, message: str, buttons=None, default_button=None): + return PluginDialogBridgeImpl().critical(parent, title, message, buttons, default_button) + + +def setup_title_bar(dialog, container_layout, title: str, **kwargs): + return PluginDialogBridgeImpl().setup_title_bar(dialog, container_layout, title, **kwargs) + + +def get_host_icon(name, color, size: int = 16): + from ui.icons import get_icon as _get_icon + + return _get_icon(name, color, size) diff --git a/system/plugins/qqmusic_runtime_helpers.py b/system/plugins/qqmusic_runtime_helpers.py new file mode 100644 index 00000000..260aa950 --- /dev/null +++ b/system/plugins/qqmusic_runtime_helpers.py @@ -0,0 +1,13 @@ +from __future__ import annotations + + +def create_qqmusic_service(credential): + from plugins.builtin.qqmusic.lib.legacy.qqmusic_service import QQMusicService + + return QQMusicService(credential) + + +def create_qqmusic_login_dialog(parent=None): + from plugins.builtin.qqmusic.lib.login_dialog import QQMusicLoginDialog + + return QQMusicLoginDialog(parent) diff --git a/tests/test_app/test_plugin_bootstrap.py b/tests/test_app/test_plugin_bootstrap.py index 84f55e42..3d7a348d 100644 --- a/tests/test_app/test_plugin_bootstrap.py +++ b/tests/test_app/test_plugin_bootstrap.py @@ -52,11 +52,14 @@ def test_bootstrap_only_loads_plugins_once(monkeypatch): fake_manager.load_enabled_plugins.assert_called_once() -def test_online_download_service_is_created_without_host_online_music_service(monkeypatch): +def test_online_download_service_is_created_with_host_online_music_service(monkeypatch): fake_download_service = object() download_ctor = MagicMock(return_value=fake_download_service) + fake_online_service = object() + online_ctor = MagicMock(return_value=fake_online_service) monkeypatch.setattr(online_module, "OnlineDownloadService", download_ctor) + monkeypatch.setattr(online_module, "OnlineMusicService", online_ctor) bootstrap = bootstrap_module.Bootstrap(":memory:") bootstrap._config = object() @@ -66,11 +69,11 @@ def test_online_download_service_is_created_without_host_online_music_service(mo assert service is fake_download_service _, kwargs = download_ctor.call_args assert kwargs["config_manager"] is bootstrap._config - assert kwargs["qqmusic_service"] is None - assert kwargs["online_music_service"] is None + assert kwargs["credential_provider"] is None + assert kwargs["online_music_service"] is fake_online_service -def test_online_music_service_is_created_without_host_qqmusic_service(monkeypatch): +def test_online_music_service_is_created_without_host_credential_provider(monkeypatch): fake_online_service = object() online_ctor = MagicMock(return_value=fake_online_service) @@ -84,7 +87,7 @@ def test_online_music_service_is_created_without_host_qqmusic_service(monkeypatc assert service is fake_online_service _, kwargs = online_ctor.call_args assert kwargs["config_manager"] is bootstrap._config - assert kwargs["qqmusic_service"] is None + assert kwargs["credential_provider"] is None def test_bootstrap_no_longer_exposes_qqmusic_client_helpers(): diff --git a/tests/test_app/test_qqmusic_host_cleanup.py b/tests/test_app/test_qqmusic_host_cleanup.py new file mode 100644 index 00000000..7e9b78ae --- /dev/null +++ b/tests/test_app/test_qqmusic_host_cleanup.py @@ -0,0 +1,159 @@ +from pathlib import Path + + +def test_main_entry_no_longer_mentions_qqmusic_api(): + source = Path("main.py").read_text(encoding="utf-8") + + assert "QQMusicApiCachePathInjector" not in source + assert "qqmusic_api.utils.device" not in source + + +def test_packaging_scripts_no_longer_collect_qqmusic_api(): + build_source = Path("build.py").read_text(encoding="utf-8") + release_source = Path("release.sh").read_text(encoding="utf-8") + + assert "qqmusic_api" not in build_source + assert "qqmusic_api" not in release_source + + +def test_online_download_service_no_longer_imports_plugin_qqmusic_impl(): + source = Path("services/online/download_service.py").read_text(encoding="utf-8") + + assert "plugins.builtin.qqmusic" not in source + + +def test_online_music_view_is_legacy_compat_shim(): + source = Path("ui/views/online_music_view.py").read_text(encoding="utf-8") + + assert "legacy_online_music_view" in source + assert "Compatibility shim" in source + + +def test_legacy_online_music_view_is_now_a_plugin_compat_shim(): + source = Path("ui/views/legacy_online_music_view.py").read_text(encoding="utf-8") + + assert "plugins.builtin.qqmusic.lib.online_music_view" in source + assert "Compatibility shim" in source + + +def test_host_online_views_are_plugin_compat_shims(): + for relative_path in ( + "ui/views/online_detail_view.py", + "ui/views/online_grid_view.py", + "ui/views/online_tracks_list_view.py", + ): + source = Path(relative_path).read_text(encoding="utf-8") + + assert "plugins.builtin.qqmusic.lib" in source + assert "Compatibility shim" in source + + +def test_plugin_root_view_uses_plugin_local_online_views(): + source = Path("plugins/builtin/qqmusic/lib/root_view.py").read_text(encoding="utf-8") + + assert "from .online_grid_view import OnlineGridView" in source + assert "from .online_tracks_list_view import OnlineTracksListView" in source + assert "from ui.views.online_grid_view import OnlineGridView" not in source + assert "from ui.views.online_tracks_list_view import OnlineTracksListView" not in source + + +def test_plugin_provider_now_uses_legacy_online_music_view_entry(): + source = Path("plugins/builtin/qqmusic/lib/provider.py").read_text(encoding="utf-8") + + assert "from .online_music_view import OnlineMusicView" in source + assert "from .root_view import QQMusicRootView" not in source + assert "return OnlineMusicView(" in source + + +def test_online_track_context_menu_lives_in_plugin_module(): + source = Path("ui/widgets/context_menus.py").read_text(encoding="utf-8") + + assert "plugins.builtin.qqmusic.lib.context_menus" in source + assert "class OnlineTrackContextMenu" not in source + + +def test_qqmusic_plugin_has_private_translation_files(): + assert Path("plugins/builtin/qqmusic/translations/en.json").exists() + assert Path("plugins/builtin/qqmusic/translations/zh.json").exists() + + +def test_qqmusic_plugin_modules_use_plugin_local_i18n(): + for relative_path in ( + "plugins/builtin/qqmusic/lib/context_menus.py", + "plugins/builtin/qqmusic/lib/login_dialog.py", + "plugins/builtin/qqmusic/lib/online_detail_view.py", + "plugins/builtin/qqmusic/lib/online_grid_view.py", + "plugins/builtin/qqmusic/lib/online_music_view.py", + "plugins/builtin/qqmusic/lib/online_tracks_list_view.py", + "plugins/builtin/qqmusic/lib/root_view.py", + "plugins/builtin/qqmusic/lib/settings_tab.py", + ): + source = Path(relative_path).read_text(encoding="utf-8") + assert "system.i18n" not in source + assert "from .i18n import" in source or "from .i18n import t" in source + + +def test_qqmusic_plugin_no_longer_imports_host_online_models_or_widgets(): + for relative_path in ( + "plugins/builtin/qqmusic/lib/root_view.py", + "plugins/builtin/qqmusic/lib/online_music_view.py", + "plugins/builtin/qqmusic/lib/online_detail_view.py", + "plugins/builtin/qqmusic/lib/online_grid_view.py", + "plugins/builtin/qqmusic/lib/online_tracks_list_view.py", + ): + source = Path(relative_path).read_text(encoding="utf-8") + assert "domain.online_music" not in source + assert "ui.widgets.recommend_card" not in source + assert "ui.views.cover_hover_popup" not in source + + +def test_online_services_no_longer_expose_qqmusic_service_parameter_names(): + online_service_source = Path("services/online/online_music_service.py").read_text(encoding="utf-8") + download_service_source = Path("services/online/download_service.py").read_text(encoding="utf-8") + bootstrap_source = Path("app/bootstrap.py").read_text(encoding="utf-8") + + assert "qqmusic_service" not in online_service_source + assert "qqmusic_service" not in download_service_source + assert "qqmusic_service" not in bootstrap_source + + +def test_online_services_no_longer_store_private_qqmusic_field_names(): + online_service_source = Path("services/online/online_music_service.py").read_text(encoding="utf-8") + download_service_source = Path("services/online/download_service.py").read_text(encoding="utf-8") + + assert "self._qqmusic =" not in online_service_source + assert "self._qqmusic =" not in download_service_source + + +def test_plugin_page_modules_do_not_directly_import_host_layers(): + forbidden_prefixes = ( + "from app.", + "import app.", + "from domain", + "import domain", + "from infrastructure", + "import infrastructure", + "from services.", + "import services.", + "from system.", + "import system.", + "from ui.", + "import ui.", + "from utils", + "import utils", + ) + + for relative_path in ( + "plugins/builtin/qqmusic/lib/context_menus.py", + "plugins/builtin/qqmusic/lib/cover_hover_popup.py", + "plugins/builtin/qqmusic/lib/recommend_card.py", + "plugins/builtin/qqmusic/lib/root_view.py", + "plugins/builtin/qqmusic/lib/online_detail_view.py", + "plugins/builtin/qqmusic/lib/online_grid_view.py", + "plugins/builtin/qqmusic/lib/online_music_view.py", + "plugins/builtin/qqmusic/lib/online_tracks_list_view.py", + "plugins/builtin/qqmusic/lib/login_dialog.py", + "plugins/builtin/qqmusic/lib/settings_tab.py", + ): + source = Path(relative_path).read_text(encoding="utf-8") + assert not any(prefix in source for prefix in forbidden_prefixes), relative_path diff --git a/tests/test_plugins/test_qqmusic_plugin.py b/tests/test_plugins/test_qqmusic_plugin.py index 836cb36a..9575172a 100644 --- a/tests/test_plugins/test_qqmusic_plugin.py +++ b/tests/test_plugins/test_qqmusic_plugin.py @@ -1,12 +1,36 @@ +import threading +import time from unittest.mock import Mock -from PySide6.QtWidgets import QListWidget +import pytest +from PySide6.QtCore import Qt +from PySide6.QtGui import QShowEvent +from PySide6.QtWidgets import QApplication, QListWidget, QWidget +from plugins.builtin.qqmusic.lib.client import QQMusicPluginClient +from plugins.builtin.qqmusic.lib import i18n as plugin_i18n +from plugins.builtin.qqmusic.lib.online_music_view import OnlineMusicView from plugins.builtin.qqmusic.lib.login_dialog import QQMusicLoginDialog +from plugins.builtin.qqmusic.lib.provider import QQMusicOnlineProvider +from plugins.builtin.qqmusic.lib.root_view import HomeSectionsWorker from plugins.builtin.qqmusic.lib.root_view import QQMusicRootView -from plugins.builtin.qqmusic.lib.qr_login import QQMusicQRLogin +from plugins.builtin.qqmusic.lib.qr_login import QQMusicQRLogin, QRLoginType from plugins.builtin.qqmusic.plugin_main import QQMusicPlugin from plugins.builtin.qqmusic.lib.settings_tab import QQMusicSettingsTab +from system.event_bus import EventBus +from system.i18n import get_language, set_language +from system.i18n import t +from system.theme import ThemeManager + + +@pytest.fixture(autouse=True) +def reset_theme_manager(): + ThemeManager._instance = None + config = Mock() + config.get.return_value = "dark" + ThemeManager.instance(config) + yield + ThemeManager._instance = None def test_qqmusic_plugin_registers_expected_capabilities(): @@ -16,6 +40,9 @@ def test_qqmusic_plugin_registers_expected_capabilities(): plugin.register(context) assert context.ui.register_sidebar_entry.call_count == 1 + sidebar_spec = context.ui.register_sidebar_entry.call_args.args[0] + assert sidebar_spec.icon_path.endswith("sidebar_icon.svg") + assert sidebar_spec.icon_name is None assert context.ui.register_settings_tab.call_count == 1 assert context.services.register_lyrics_source.call_count == 1 assert context.services.register_cover_source.call_count == 1 @@ -23,6 +50,117 @@ def test_qqmusic_plugin_registers_expected_capabilities(): assert context.services.register_online_music_provider.call_count == 1 +def test_qqmusic_provider_create_page_uses_legacy_online_music_view(monkeypatch): + context = Mock() + context.settings.get.side_effect = lambda key, default=None: default + created = {} + + def _capture_view(config_manager=None, qqmusic_service=None, plugin_context=None, parent=None): + created["config_manager"] = config_manager + created["qqmusic_service"] = qqmusic_service + created["plugin_context"] = plugin_context + created["parent"] = parent + return Mock(spec=OnlineMusicView) + + monkeypatch.setattr( + "plugins.builtin.qqmusic.lib.provider.OnlineMusicView", + _capture_view, + ) + monkeypatch.setattr( + "plugins.builtin.qqmusic.lib.provider.create_qqmusic_service", + lambda credential: {"credential": credential}, + ) + + provider = QQMusicOnlineProvider(context) + page = provider.create_page(context, parent="host-parent") + + assert page is not None + assert created["parent"] == "host-parent" + assert created["plugin_context"] is context + assert created["config_manager"].get_search_history() == [] + assert created["config_manager"].get_plugin_secret("qqmusic", "credential", "") == "" + assert created["qqmusic_service"] is None + + +def test_qqmusic_provider_create_page_passes_adapter_with_download_dir(monkeypatch): + settings = Mock() + store = { + "credential": "", + "quality": "320", + "search_history": [], + "online_music_download_dir": "data/online_cache", + } + settings.get.side_effect = lambda key, default=None: store.get(key, default) + settings.set.side_effect = lambda key, value: store.__setitem__(key, value) + context = Mock(settings=settings) + context.logger = Mock() + captured = {} + + def _capture_view(*, config_manager=None, qqmusic_service=None, plugin_context=None, parent=None): + captured["config_manager"] = config_manager + captured["qqmusic_service"] = qqmusic_service + captured["plugin_context"] = plugin_context + captured["parent"] = parent + return Mock(spec=OnlineMusicView) + + monkeypatch.setattr( + "plugins.builtin.qqmusic.lib.provider.OnlineMusicView", + _capture_view, + ) + + provider = QQMusicOnlineProvider(context) + page = provider.create_page(context, parent=None) + + assert page is not None + assert captured["config_manager"].get_online_music_download_dir() == "data/online_cache" + assert captured["plugin_context"] is context + assert captured["qqmusic_service"] is None + + +def test_qqmusic_plugin_uses_private_translations_not_global(monkeypatch): + import system.i18n as global_i18n + + original = global_i18n._translations.get("zh", {}).get("qqmusic_page_title") + global_i18n._translations.setdefault("zh", {})["qqmusic_page_title"] = "全局错误文案" + plugin_i18n.set_language("zh") + + try: + assert plugin_i18n.t("qqmusic_page_title") == "QQ 音乐" + finally: + if original is None: + global_i18n._translations["zh"].pop("qqmusic_page_title", None) + else: + global_i18n._translations["zh"]["qqmusic_page_title"] = original + + +def test_qqmusic_provider_config_adapter_tracks_search_history_and_plugin_settings(): + settings = Mock() + store = {"search_history": ["A"], "credential": {"musicid": "1"}, "quality": "320"} + settings.get.side_effect = lambda key, default=None: store.get(key, default) + settings.set.side_effect = lambda key, value: store.__setitem__(key, value) + + adapter = QQMusicOnlineProvider._create_legacy_config_adapter(Mock(settings=settings)) + + adapter.add_search_history("B") + adapter.add_search_history("A") + adapter.remove_search_history_item("B") + + assert adapter.get_search_history() == ["A"] + assert adapter.get_plugin_secret("qqmusic", "credential", "") == {"musicid": "1"} + assert adapter.get_plugin_setting("qqmusic", "quality", "") == "320" + + +def test_qqmusic_provider_config_adapter_exposes_download_dir(): + settings = Mock() + settings.get.side_effect = lambda key, default=None: { + "online_music_download_dir": "data/online_cache", + }.get(key, default) + + adapter = QQMusicOnlineProvider._create_legacy_config_adapter(Mock(settings=settings)) + + assert adapter.get_online_music_download_dir() == "data/online_cache" + + def test_qqmusic_settings_tab_reads_and_saves_quality(qtbot): settings = Mock() settings.get.return_value = "flac" @@ -37,6 +175,22 @@ def test_qqmusic_settings_tab_reads_and_saves_quality(qtbot): tab._save() settings.set.assert_called_once_with("quality", tab._quality_combo.currentData()) + assert hasattr(tab, "_account_group") + assert hasattr(tab, "_quality_group") + + +def test_qqmusic_settings_tab_exposes_section_hint_labels(qtbot): + settings = Mock() + settings.get.return_value = "320" + context = Mock(settings=settings) + + tab = QQMusicSettingsTab(context) + qtbot.addWidget(tab) + + assert hasattr(tab, "_account_hint_label") + assert hasattr(tab, "_quality_hint_label") + assert tab._account_hint_label.wordWrap() is True + assert tab._quality_hint_label.wordWrap() is True def test_qqmusic_settings_tab_opens_login_dialog(monkeypatch, qtbot): @@ -56,10 +210,12 @@ def test_qqmusic_settings_tab_opens_login_dialog(monkeypatch, qtbot): tab._open_login_dialog() - dialog_ctor.assert_called_once_with(tab) + dialog_ctor.assert_called_once_with(context, tab) dialog.exec.assert_called_once_with() + + def test_plugin_local_qr_login_client_builds_session(): client = QQMusicQRLogin() @@ -77,6 +233,301 @@ def test_plugin_login_dialog_uses_local_qr_client(qtbot): assert isinstance(dialog._client, QQMusicQRLogin) +def test_plugin_login_dialog_auto_starts_qr_loading(monkeypatch, qtbot): + start_calls = [] + + def _capture_start(self, login_type=None): + start_calls.append(login_type) + + monkeypatch.setattr( + "plugins.builtin.qqmusic.lib.login_dialog.QQMusicLoginDialog._start_login", + _capture_start, + ) + + dialog = QQMusicLoginDialog() + qtbot.addWidget(dialog) + qtbot.waitUntil(lambda: len(start_calls) == 1) + + assert start_calls[0] == QRLoginType.QQ + + +def test_plugin_login_dialog_can_switch_between_qq_and_wechat_qr(monkeypatch, qtbot): + settings = Mock() + context = Mock(settings=settings) + start_calls = [] + + def _capture_start(self, login_type=None): + start_calls.append(login_type) + + monkeypatch.setattr( + "plugins.builtin.qqmusic.lib.login_dialog.QQMusicLoginDialog._start_login", + _capture_start, + ) + + dialog = QQMusicLoginDialog(context) + qtbot.addWidget(dialog) + qtbot.waitUntil(lambda: len(start_calls) == 1) + + dialog._wx_login_btn.click() + dialog._qq_login_btn.click() + + assert start_calls[1:] == [QRLoginType.WX, QRLoginType.QQ] + + +def test_plugin_login_dialog_persists_credentials_and_nick(qtbot): + settings = Mock() + context = Mock(settings=settings) + dialog = QQMusicLoginDialog(context) + qtbot.addWidget(dialog) + + dialog._handle_login_success({"musicid": "1", "musickey": "secret", "nick": "Tester"}) + + settings.set.assert_any_call("credential", {"musicid": "1", "musickey": "secret", "nick": "Tester"}) + settings.set.assert_any_call("nick", "Tester") + + +def test_plugin_login_dialog_does_not_fallback_to_uid_for_nick(qtbot): + settings = Mock() + context = Mock(settings=settings) + dialog = QQMusicLoginDialog(context) + qtbot.addWidget(dialog) + + dialog._handle_login_success({"musicid": "1", "musickey": "secret"}) + + settings.set.assert_any_call("credential", {"musicid": "1", "musickey": "secret"}) + settings.set.assert_any_call("nick", "") + + +def test_plugin_login_dialog_fetches_missing_nick_from_verify_login(monkeypatch, qtbot): + settings = Mock() + context = Mock(settings=settings) + dialog = QQMusicLoginDialog(context) + qtbot.addWidget(dialog) + + service = Mock() + service.client.verify_login.return_value = {"valid": True, "nick": "Tester", "uin": 1} + monkeypatch.setattr( + "plugins.builtin.qqmusic.lib.login_dialog.QQMusicService", + Mock(return_value=service), + ) + + dialog._handle_login_success({"musicid": "1", "musickey": "secret"}) + + settings.set.assert_any_call("nick", "Tester") + + +def test_plugin_login_dialog_reject_stops_worker(qtbot): + dialog = QQMusicLoginDialog() + qtbot.addWidget(dialog) + worker = Mock() + dialog._worker = worker + + dialog.reject() + + worker.stop.assert_called_once_with() + worker.wait.assert_called_once_with(1000) + assert dialog._worker is None + + +def test_plugin_login_dialog_exposes_legacy_style_support_widgets(qtbot): + dialog = QQMusicLoginDialog() + qtbot.addWidget(dialog) + + assert hasattr(dialog, "_subtitle_label") + assert hasattr(dialog, "_qr_frame") + assert hasattr(dialog, "_cancel_btn") + assert dialog._status_label.wordWrap() is True + assert dialog.minimumWidth() >= 420 + + +def test_plugin_client_normalizes_legacy_top_list_dict_tracks(monkeypatch): + settings = Mock() + settings.get.side_effect = lambda key, default=None: { + "credential": {"musicid": "1", "musickey": "secret"}, + }.get(key, default) + context = Mock(settings=settings) + + service = Mock() + service.get_top_list_songs.return_value = [ + { + "mid": "song-1", + "title": "Song 1", + "singer": [{"name": "Singer 1"}], + "album": {"name": "Album 1", "mid": "album-mid-1"}, + "interval": 210, + } + ] + monkeypatch.setattr( + "plugins.builtin.qqmusic.lib.client.QQMusicService", + Mock(return_value=service), + ) + + client = QQMusicPluginClient(context) + + tracks = client.get_top_list_tracks(26) + + assert tracks == [ + { + "mid": "song-1", + "title": "Song 1", + "artist": "Singer 1", + "album": "Album 1", + "album_mid": "album-mid-1", + "duration": 210, + } + ] + + +def test_plugin_client_extracts_cover_from_nested_recommendation_payloads(monkeypatch): + settings = Mock() + settings.get.side_effect = lambda key, default=None: { + "credential": {"musicid": "1", "musickey": "secret"}, + }.get(key, default) + context = Mock(settings=settings) + + service = Mock() + service.get_home_feed.return_value = [ + { + "Track": { + "mid": "song-1", + "title": "Song 1", + "album": {"mid": "album-mid-1"}, + } + } + ] + service.get_guess_recommend.return_value = [] + service.get_radar_recommend.return_value = [] + service.get_recommend_songlist.return_value = [ + { + "Playlist": { + "basic": { + "id": "pl-1", + "title": "Playlist 1", + "cover_url": "http://example.com/playlist-cover.jpg", + } + } + } + ] + service.get_recommend_newsong.return_value = [] + monkeypatch.setattr( + "plugins.builtin.qqmusic.lib.client.QQMusicService", + Mock(return_value=service), + ) + + client = QQMusicPluginClient(context) + + recommendations = client.get_recommendations() + + assert recommendations[0]["cover_url"].endswith("T002R300x300M000album-mid-1.jpg") + assert recommendations[1]["cover_url"] == "http://example.com/playlist-cover.jpg" + + +def test_plugin_client_exposes_detail_status_and_qq_actions(monkeypatch): + settings = Mock() + settings.get.side_effect = lambda key, default=None: { + "credential": {"musicid": "1", "musickey": "secret"}, + }.get(key, default) + context = Mock(settings=settings) + + service = Mock() + service.get_singer_info_with_follow_status.return_value = { + "name": "Singer 1", + "songs": [], + "follow_status": True, + } + service.get_album_info_with_fav_status.return_value = { + "name": "Album 1", + "songs": [], + "fav_status": True, + } + service.get_playlist_info_with_fav_status.return_value = { + "name": "Playlist 1", + "songs": [], + "fav_status": True, + } + service.follow_singer.return_value = True + service.unfollow_singer.return_value = True + service.fav_album.return_value = True + service.unfav_album.return_value = True + service.fav_playlist.return_value = True + service.unfav_playlist.return_value = True + monkeypatch.setattr( + "plugins.builtin.qqmusic.lib.client.QQMusicService", + Mock(return_value=service), + ) + + client = QQMusicPluginClient(context) + monkeypatch.setattr(client, "_can_use_legacy_network", lambda: True) + + assert client.get_artist_detail("artist-1")["follow_status"] is True + assert client.get_album_detail("album-1")["is_faved"] is True + assert client.get_playlist_detail("playlist-1")["is_faved"] is True + assert client.follow_artist("artist-1") is True + assert client.unfollow_artist("artist-1") is True + assert client.fav_album("album-1") is True + assert client.unfav_album("album-1") is True + assert client.fav_playlist("playlist-1") is True + assert client.unfav_playlist("playlist-1") is True + + +def test_plugin_client_prefers_public_api_for_top_lists_and_hotkeys(monkeypatch): + settings = Mock() + settings.get.side_effect = lambda key, default=None: { + "credential": {"musicid": "1", "musickey": "secret"}, + }.get(key, default) + context = Mock(settings=settings) + + service = Mock() + api = Mock() + api.get_top_lists.return_value = [{"id": 26, "title": "热歌榜"}] + api.get_top_list_tracks.return_value = [{"mid": "song-1", "title": "Song 1"}] + api.get_hotkeys.return_value = [{"title": "周杰伦", "query": "周杰伦"}] + api.complete.return_value = [{"hint": "周杰伦 晴天"}] + monkeypatch.setattr( + "plugins.builtin.qqmusic.lib.client.QQMusicService", + Mock(return_value=service), + ) + monkeypatch.setattr( + "plugins.builtin.qqmusic.lib.client.QQMusicPluginAPI", + Mock(return_value=api), + ) + + client = QQMusicPluginClient(context) + + assert client.get_top_lists() == [{"id": 26, "title": "热歌榜"}] + assert client.get_top_list_tracks(26) == [{"mid": "song-1", "title": "Song 1"}] + assert client.get_hotkeys() == [{"title": "周杰伦", "query": "周杰伦"}] + assert client.complete("周杰伦") == [{"hint": "周杰伦 晴天"}] + + service.get_top_lists.assert_not_called() + service.get_top_list_songs.assert_not_called() + service.get_hotkey.assert_not_called() + service.complete.assert_not_called() + + +def test_plugin_client_skips_private_legacy_calls_when_network_unreachable(monkeypatch): + settings = Mock() + settings.get.side_effect = lambda key, default=None: { + "credential": {"musicid": "1", "musickey": "secret"}, + }.get(key, default) + context = Mock(settings=settings) + + service = Mock() + monkeypatch.setattr( + "plugins.builtin.qqmusic.lib.client.QQMusicService", + Mock(return_value=service), + ) + + client = QQMusicPluginClient(context) + monkeypatch.setattr(client, "_can_use_legacy_network", lambda: False) + + assert client.get_recommendations() == [] + assert client.get_favorites() == [] + + service.get_home_feed.assert_not_called() + service.get_my_fav_songs.assert_not_called() + + def test_qqmusic_settings_tab_clears_plugin_credentials(qtbot): settings = Mock() settings.get.side_effect = lambda key, default=None: { @@ -115,3 +566,3446 @@ def test_root_view_search_populates_results(qtbot, monkeypatch): assert view._results_list.count() == 1 assert "Song 1" in view._results_list.item(0).text() provider.search_tracks.assert_called_once_with("Song 1") + + +def test_root_view_initializes_home_sections(qtbot): + settings = Mock() + settings.get.side_effect = lambda key, default=None: { + "nick": "", + "quality": "320", + }.get(key, default) + context = Mock(settings=settings) + provider = Mock() + provider.is_logged_in.return_value = False + provider.get_top_lists.return_value = [ + {"id": 26, "title": "热歌榜"}, + {"id": 27, "title": "新歌榜"}, + ] + provider.get_top_list_tracks.return_value = [ + {"mid": "song-1", "title": "Song 1", "artist": "Singer 1", "album": "Album 1", "duration": 210}, + ] + provider.get_recommendations.return_value = [] + provider.get_favorites.return_value = [] + + view = QQMusicRootView(context, provider) + qtbot.addWidget(view) + + assert view._home_stack.currentWidget() is view._home_page + assert view._search_type_tabs.count() == 4 + assert view._search_type_tabs.isHidden() is True + assert view._top_list_widget.count() == 2 + assert view._top_tracks_table.columnCount() == 5 + assert view._top_tracks_table.rowCount() == 1 + assert view._ranking_title_label.text() == "热歌榜" + provider.get_top_lists.assert_called_once_with() + provider.get_top_list_tracks.assert_called_once_with(26) + + +def test_root_view_supports_multi_type_search(qtbot): + settings = Mock() + settings.get.side_effect = lambda key, default=None: { + "nick": "Tester", + "quality": "320", + }.get(key, default) + context = Mock(settings=settings) + provider = Mock() + provider.is_logged_in.return_value = True + provider.get_top_lists.return_value = [] + provider.get_top_list_tracks.return_value = [] + provider.search.return_value = { + "tracks": [{"mid": "song-1", "title": "Song 1", "artist": "Singer 1"}], + "total": 1, + "page": 1, + "page_size": 30, + } + provider.get_recommendations.return_value = [] + provider.get_favorites.return_value = [] + provider.search.return_value = { + "artists": [ + {"mid": "artist-1", "name": "Singer 1", "song_count": 12}, + ] + } + + view = QQMusicRootView(context, provider) + qtbot.addWidget(view) + view.show() + + view._search_input.setText("Singer 1") + view._search_type_tabs.setCurrentIndex(1) + view._run_search() + + assert view._home_stack.currentWidget() is view._results_page + assert view._results_stack.currentWidget() is view._artists_page + assert view._artists_list.count() == 1 + assert "Singer 1" in view._artists_list.item(0).text() + provider.search.assert_called_once_with("Singer 1", "singer", page=1, page_size=30) + + +def test_root_view_switching_search_tab_requeries_current_keyword(qtbot): + settings = Mock() + settings.get.side_effect = lambda key, default=None: { + "nick": "Tester", + "quality": "320", + }.get(key, default) + context = Mock(settings=settings) + provider = Mock() + provider.is_logged_in.return_value = True + provider.get_top_lists.return_value = [] + provider.get_top_list_tracks.return_value = [] + provider.search.return_value = { + "tracks": [{"mid": "song-1", "title": "Song 1", "artist": "Singer 1"}], + "total": 1, + "page": 1, + "page_size": 30, + } + provider.get_recommendations.return_value = [] + provider.get_favorites.return_value = [] + provider.search.side_effect = [ + {"tracks": [{"mid": "song-1", "title": "Song 1", "artist": "Singer 1"}], "total": 1, "page": 1, "page_size": 30}, + {"albums": [{"mid": "album-1", "name": "Album 1", "singer_name": "Singer 1"}], "total": 1, "page": 1, "page_size": 30}, + ] + + view = QQMusicRootView(context, provider) + qtbot.addWidget(view) + view.show() + + view._search_input.setText("Singer 1") + view._run_search() + view._search_type_tabs.setCurrentIndex(2) + + assert provider.search.call_args_list[0].args[:2] == ("Singer 1", "song") + assert provider.search.call_args_list[1].args[:2] == ("Singer 1", "album") + assert view._results_stack.currentWidget() is view._albums_page + + +def test_root_view_song_search_uses_table_and_pagination(qtbot): + settings = Mock() + settings.get.side_effect = lambda key, default=None: { + "nick": "Tester", + "quality": "320", + }.get(key, default) + context = Mock(settings=settings) + context.services.media = Mock() + provider = Mock() + provider.is_logged_in.return_value = True + provider.get_top_lists.return_value = [] + provider.get_top_list_tracks.return_value = [] + provider.search.return_value = { + "tracks": [{"mid": "song-x", "title": "Song X", "artist": "Singer X"}], + "total": 1, + "page": 1, + "page_size": 30, + } + provider.get_recommendations.return_value = [] + provider.get_favorites.return_value = [] + provider.search.return_value = { + "tracks": [ + {"mid": "song-1", "title": "Song 1", "artist": "Singer 1", "album": "Album 1", "duration": 210}, + ], + "total": 61, + "page": 1, + "page_size": 30, + } + + view = QQMusicRootView(context, provider) + qtbot.addWidget(view) + + view._search_input.setText("Song 1") + view._run_search() + + results_table = getattr(view, "_results_table", None) + page_label = getattr(view, "_page_label", None) + next_btn = getattr(view, "_next_btn", None) + + assert results_table is not None + assert page_label is not None + assert next_btn is not None + assert results_table.columnCount() == 5 + assert view._results_stack.currentWidget() is view._songs_page + assert results_table.rowCount() == 1 + assert page_label.text() == "1" + assert next_btn.isEnabled() is True + assert "Song 1" in view._results_info_label.text() + assert "61" in view._results_info_label.text() + assert view._pagination_widget.isHidden() is False + provider.search.assert_called_once_with("Song 1", "song", page=1, page_size=30) + + +def test_root_view_artist_search_uses_grid_and_load_more(qtbot): + settings = Mock() + settings.get.side_effect = lambda key, default=None: { + "nick": "Tester", + "quality": "320", + }.get(key, default) + context = Mock(settings=settings) + context.services.media = Mock() + provider = Mock() + provider.is_logged_in.return_value = True + provider.get_top_lists.return_value = [] + provider.get_top_list_tracks.return_value = [] + provider.search.return_value = { + "tracks": [{"mid": "song-x", "title": "Song X", "artist": "Singer X"}], + "total": 1, + "page": 1, + "page_size": 30, + } + provider.get_recommendations.return_value = [] + provider.get_favorites.return_value = [] + provider.search.side_effect = [ + { + "artists": [{"mid": "artist-1", "name": "Singer 1", "song_count": 12}], + "total": 61, + "page": 1, + "page_size": 30, + }, + { + "artists": [{"mid": "artist-2", "name": "Singer 2", "song_count": 8}], + "total": 61, + "page": 2, + "page_size": 30, + }, + ] + + view = QQMusicRootView(context, provider) + qtbot.addWidget(view) + + view._search_input.setText("Singer") + view._search_type_tabs.setCurrentIndex(1) + view._run_search() + + assert hasattr(view, "_on_load_more_artists") + view._on_load_more_artists() + + assert view._results_stack.currentWidget() is view._artists_page + assert view._pagination_widget.isHidden() is True + assert provider.search.call_args_list[0].args[:2] == ("Singer", "singer") + assert provider.search.call_args_list[1].kwargs == {"page": 2, "page_size": 30} + + +def test_root_view_loads_logged_in_sections(qtbot): + settings = Mock() + settings.get.side_effect = lambda key, default=None: { + "nick": "Tester", + "quality": "320", + }.get(key, default) + context = Mock(settings=settings) + provider = Mock() + provider.is_logged_in.return_value = True + provider.get_top_lists.return_value = [] + provider.get_top_list_tracks.return_value = [] + provider.get_recommendations.return_value = [ + {"title": "每日推荐", "subtitle": "猜你想听"}, + ] + provider.get_favorites.return_value = [ + {"title": "我喜欢的歌曲", "count": 42}, + ] + + view = QQMusicRootView(context, provider) + qtbot.addWidget(view) + + assert "Tester" in view._status.text() + assert view._recommend_list.count() == 1 + assert view._favorites_list.count() == 1 + assert view._recommend_section.isHidden() is False + assert view._favorites_section.isHidden() is False + assert view._recommend_group.isHidden() is True + assert view._favorites_group.isHidden() is True + + +def test_home_sections_worker_parallelizes_home_requests(): + provider = Mock() + release = threading.Event() + started = { + "top_lists": threading.Event(), + "hotkeys": threading.Event(), + "favorites": threading.Event(), + "recommendations": threading.Event(), + } + + def _slow_list(name, value): + def _inner(): + started[name].set() + release.wait(0.5) + return value + return _inner + + provider.get_top_lists.side_effect = _slow_list("top_lists", [{"id": 26, "title": "热歌榜"}]) + provider.get_hotkeys.side_effect = _slow_list("hotkeys", [{"title": "周杰伦"}]) + provider.get_favorites.side_effect = _slow_list("favorites", [{"title": "收藏"}]) + provider.get_recommendations.side_effect = _slow_list("recommendations", [{"title": "推荐"}]) + provider.get_top_list_tracks.return_value = [] + + worker = HomeSectionsWorker( + provider, + load_private=True, + logged_in=True, + history=["林俊杰"], + ) + + runner = threading.Thread(target=worker.run) + runner.start() + + assert started["top_lists"].wait(0.2) is True + time.sleep(0.05) + started_count = sum(1 for event in started.values() if event.is_set()) + release.set() + runner.join(timeout=1) + + assert started_count == 4 + + +def test_root_view_home_payload_prefills_initial_ranking_tracks(qtbot): + settings = Mock() + settings.get.side_effect = lambda key, default=None: { + "nick": "", + "quality": "320", + }.get(key, default) + context = Mock(settings=settings) + context.services.media = Mock() + provider = Mock() + provider.is_logged_in.return_value = False + provider.get_top_lists.return_value = [] + provider.get_top_list_tracks.return_value = [] + provider.get_recommendations.return_value = [] + provider.get_favorites.return_value = [] + provider.get_hotkeys.return_value = [] + provider.complete.return_value = [] + + view = QQMusicRootView(context, provider) + qtbot.addWidget(view) + + payload = { + "top_lists": [{"id": 26, "title": "热歌榜"}], + "top_tracks": [{"mid": "song-1", "title": "Song 1", "artist": "Singer 1", "album": "Album 1", "duration": 210}], + "top_tracks_id": "26", + "hotkeys": [], + "history": [], + "favorites": [], + "recommendations": [], + "logged_in": False, + "load_private": False, + } + + view._on_home_sections_loaded(payload) + + assert view._top_tracks_table.rowCount() == 1 + provider.get_top_list_tracks.assert_not_called() + + +def test_root_view_home_payload_does_not_show_hotkey_popup_without_search_focus(qtbot): + settings = Mock() + settings.get.side_effect = lambda key, default=None: { + "nick": "", + "quality": "320", + "search_history": [], + }.get(key, default) + context = Mock(settings=settings) + context.services.media = Mock() + provider = Mock() + provider.is_logged_in.return_value = False + provider.get_top_lists.return_value = [] + provider.get_top_list_tracks.return_value = [] + provider.get_recommendations.return_value = [] + provider.get_favorites.return_value = [] + provider.get_hotkeys.return_value = [] + provider.complete.return_value = [] + + view = QQMusicRootView(context, provider) + qtbot.addWidget(view) + view.show() + view._hotkey_popup = view._hotkey_popup or None + view._show_hotkey_popup() + assert view._hotkey_popup is not None + view._on_app_focus_changed(view._search_input, view._login_btn) + + view._on_home_sections_loaded( + { + "top_lists": [], + "top_tracks": [], + "top_tracks_id": "", + "hotkeys": [{"title": "周杰伦", "query": "周杰伦"}], + "history": [], + "favorites": [], + "recommendations": [], + "logged_in": False, + "load_private": False, + } + ) + + assert view._hotkey_popup.isVisible() is False + + +def test_root_view_show_does_not_auto_open_hotkey_popup(qtbot): + settings = Mock() + store = {"nick": "", "quality": "320", "search_history": ["林俊杰"]} + settings.get.side_effect = lambda key, default=None: store.get(key, default) + context = Mock(settings=settings) + context.services.media = Mock() + provider = Mock() + provider.is_logged_in.return_value = False + provider.get_top_lists.return_value = [] + provider.get_top_list_tracks.return_value = [] + provider.get_recommendations.return_value = [] + provider.get_favorites.return_value = [] + provider.get_hotkeys.return_value = [{"title": "周杰伦", "query": "周杰伦"}] + provider.complete.return_value = [] + + view = QQMusicRootView(context, provider) + qtbot.addWidget(view) + view.show() + + qtbot.waitUntil(lambda: view._search_input.hasFocus(), timeout=1000) + + assert view._hotkey_popup is None or view._hotkey_popup.isVisible() is False + + +def test_root_view_internal_collection_lists_are_hidden(qtbot): + settings = Mock() + settings.get.side_effect = lambda key, default=None: { + "nick": "", + "quality": "320", + "search_history": [], + }.get(key, default) + context = Mock(settings=settings) + context.services.media = Mock() + provider = Mock() + provider.is_logged_in.return_value = False + provider.get_top_lists.return_value = [] + provider.get_top_list_tracks.return_value = [] + provider.get_recommendations.return_value = [] + provider.get_favorites.return_value = [] + provider.get_hotkeys.return_value = [] + provider.complete.return_value = [] + + view = QQMusicRootView(context, provider) + qtbot.addWidget(view) + view.show() + + assert view._artists_list.isHidden() is True + assert view._albums_list.isHidden() is True + assert view._playlists_list.isHidden() is True + + +def test_root_view_ranking_table_uses_legacy_column_layout(qtbot): + settings = Mock() + settings.get.side_effect = lambda key, default=None: { + "nick": "", + "quality": "320", + }.get(key, default) + context = Mock(settings=settings) + context.services.media = Mock() + provider = Mock() + provider.is_logged_in.return_value = False + provider.get_top_lists.return_value = [{"id": 26, "title": "热歌榜"}] + provider.get_top_list_tracks.return_value = [{"mid": "song-1", "title": "Song 1", "artist": "Singer 1", "album": "Album 1", "duration": 210}] + provider.get_recommendations.return_value = [] + provider.get_favorites.return_value = [] + provider.get_hotkeys.return_value = [] + provider.complete.return_value = [] + + view = QQMusicRootView(context, provider) + qtbot.addWidget(view) + + header = view._top_tracks_table.horizontalHeader() + assert header.stretchLastSection() is False + assert header.sectionResizeMode(0) == header.ResizeMode.Fixed + assert header.sectionResizeMode(4) == header.ResizeMode.Fixed + assert view._top_tracks_table.columnWidth(0) == 50 + assert view._top_tracks_table.columnWidth(4) == 80 + + +def test_root_view_public_home_payload_does_not_clear_private_sections(qtbot): + settings = Mock() + settings.get.side_effect = lambda key, default=None: { + "nick": "Tester", + "quality": "320", + }.get(key, default) + context = Mock(settings=settings) + context.services.media = Mock() + provider = Mock() + provider.is_logged_in.return_value = True + provider.get_top_lists.return_value = [] + provider.get_top_list_tracks.return_value = [] + provider.get_recommendations.return_value = [] + provider.get_favorites.return_value = [] + provider.get_hotkeys.return_value = [] + provider.complete.return_value = [] + + view = QQMusicRootView(context, provider) + qtbot.addWidget(view) + + view._favorites_cache = [{"title": "我喜欢的歌曲", "subtitle": "1 首", "items": [{"mid": "song-1"}]}] + view._recommendations_cache = [{"title": "猜你喜欢", "subtitle": "1 项", "items": [{"mid": "song-1"}]}] + view._apply_logged_in_sections_from_cache() + + view._on_home_sections_loaded( + { + "top_lists": [], + "top_tracks": [], + "top_tracks_id": "", + "hotkeys": [], + "history": [], + "favorites": [], + "recommendations": [], + "logged_in": True, + "load_private": False, + } + ) + + assert view._favorites_section.isHidden() is False + assert view._recommend_section.isHidden() is False + assert view._favorites_list.count() == 1 + + +def test_root_view_ranking_context_menu_uses_translated_labels(monkeypatch, qtbot): + settings = Mock() + settings.get.side_effect = lambda key, default=None: { + "nick": "", + "quality": "320", + }.get(key, default) + context = Mock(settings=settings) + context.services.media = Mock() + provider = Mock() + provider.is_logged_in.return_value = False + provider.get_top_lists.return_value = [{"id": 26, "title": "热歌榜"}] + provider.get_top_list_tracks.return_value = [ + {"mid": "song-1", "title": "Song 1", "artist": "Singer 1", "album": "Album 1", "duration": 210}, + ] + provider.get_recommendations.return_value = [] + provider.get_favorites.return_value = [] + provider.get_hotkeys.return_value = [] + provider.complete.return_value = [] + + labels = [] + + class _FakeSignal: + def connect(self, *_args, **_kwargs): + return None + + class _FakeAction: + def __init__(self): + self.triggered = _FakeSignal() + + class _FakeMenu: + def __init__(self, *_args, **_kwargs): + pass + + def setStyleSheet(self, *_args, **_kwargs): + return None + + def addAction(self, text): + labels.append(text) + return _FakeAction() + + def addSeparator(self): + return None + + def exec(self, *_args, **_kwargs): + return None + + monkeypatch.setattr("plugins.builtin.qqmusic.lib.root_view.QMenu", _FakeMenu) + monkeypatch.setattr( + "plugins.builtin.qqmusic.lib.root_view.t", + lambda key, default=None: f"tr:{key}", + ) + + view = QQMusicRootView(context, provider) + qtbot.addWidget(view) + view._top_tracks_table.selectRow(0) + monkeypatch.setattr(view, "sender", lambda: view._top_tracks_table) + + view._show_track_context_menu(view._top_tracks_table.visualItemRect(view._top_tracks_table.item(0, 0)).center()) + + assert labels == [ + "tr:play", + "tr:insert_to_queue", + "tr:add_to_queue", + "tr:add_to_favorites", + "tr:add_to_playlist", + "tr:download", + ] + + +def test_root_view_detail_toggle_texts_use_translation_keys(qtbot, monkeypatch): + settings = Mock() + settings.get.side_effect = lambda key, default=None: { + "nick": "", + "quality": "320", + }.get(key, default) + context = Mock(settings=settings) + context.services.media = Mock() + provider = Mock() + provider.is_logged_in.return_value = True + provider.get_top_lists.return_value = [] + provider.get_top_list_tracks.return_value = [] + provider.get_recommendations.return_value = [] + provider.get_favorites.return_value = [] + provider.get_hotkeys.return_value = [] + provider.complete.return_value = [] + monkeypatch.setattr( + "plugins.builtin.qqmusic.lib.root_view.t", + lambda key, default=None: f"tr:{key}", + ) + + view = QQMusicRootView(context, provider) + qtbot.addWidget(view) + + view._show_detail( + {"title": "Singer 1", "songs": [], "follow_status": True}, + detail_type="artist", + source_id="artist-1", + ) + assert view._detail_follow_btn.text() == "tr:qqmusic_followed" + + view._show_detail( + {"title": "Album 1", "songs": [], "is_faved": True}, + detail_type="album", + source_id="album-1", + ) + assert view._detail_fav_btn.text() == "tr:qqmusic_remove_from_favorites" + + +def test_root_view_syncs_language_from_context_and_listens_for_changes(qtbot): + plugin_i18n.set_language("en") + store = {"nick": "", "quality": "320"} + settings = Mock() + settings.get.side_effect = lambda key, default=None: store.get(key, default) + settings.set.side_effect = lambda key, value: store.__setitem__(key, value) + context = Mock(settings=settings) + context.services.media = Mock() + context.events = EventBus.instance() + context.language = "zh" + provider = Mock() + provider.is_logged_in.return_value = False + provider.get_top_lists.return_value = [] + provider.get_top_list_tracks.return_value = [] + provider.get_recommendations.return_value = [] + provider.get_favorites.return_value = [] + provider.get_hotkeys.return_value = [] + provider.complete.return_value = [] + + view = QQMusicRootView(context, provider) + qtbot.addWidget(view) + + assert plugin_i18n.get_language() == "zh" + + context.events.language_changed.emit("en") + + assert plugin_i18n.get_language() == "en" + + +def test_root_view_show_event_uses_async_home_loader_for_embedded_page(qtbot, monkeypatch): + settings = Mock() + settings.get.side_effect = lambda key, default=None: { + "nick": "", + "quality": "320", + }.get(key, default) + context = Mock(settings=settings) + context.services.media = Mock() + provider = Mock() + provider.is_logged_in.return_value = False + provider.get_top_lists.return_value = [{"id": 26, "title": "热歌榜"}] + provider.get_top_list_tracks.return_value = [{"mid": "song-1", "title": "Song 1"}] + provider.get_recommendations.return_value = [] + provider.get_favorites.return_value = [] + provider.get_hotkeys.return_value = [] + provider.complete.return_value = [] + + started = {} + + class _FakeWorker: + def __init__(self, *_args, **_kwargs): + self.home_loaded = Mock(connect=Mock()) + self.failed = Mock(connect=Mock()) + self.finished = Mock(connect=Mock()) + + def start(self): + started["started"] = True + + def isRunning(self): + return False + + def deleteLater(self): + started["deleted"] = True + + monkeypatch.setattr( + "plugins.builtin.qqmusic.lib.root_view.HomeSectionsWorker", + _FakeWorker, + ) + + parent = QWidget() + view = QQMusicRootView(context, provider, parent=parent) + qtbot.addWidget(parent) + + assert provider.get_top_lists.call_count == 0 + + view.showEvent(QShowEvent()) + qtbot.waitUntil(lambda: started.get("started") is True, timeout=1000) + + assert provider.get_top_lists.call_count == 0 + + +def test_root_view_embedded_init_does_not_block_on_logged_in_sections(qtbot): + settings = Mock() + settings.get.side_effect = lambda key, default=None: { + "nick": "Tester", + "quality": "320", + }.get(key, default) + context = Mock(settings=settings) + context.services.media = Mock() + provider = Mock() + provider.is_logged_in.return_value = True + provider.get_top_lists.return_value = [{"id": 26, "title": "热歌榜"}] + provider.get_top_list_tracks.return_value = [{"mid": "song-1", "title": "Song 1"}] + provider.get_recommendations.return_value = [{"title": "推荐"}] + provider.get_favorites.return_value = [{"title": "收藏"}] + provider.get_hotkeys.return_value = [] + provider.complete.return_value = [] + + parent = QWidget() + view = QQMusicRootView(context, provider, parent=parent) + qtbot.addWidget(parent) + + provider.get_favorites.assert_not_called() + provider.get_recommendations.assert_not_called() + assert view._favorites_section.isHidden() is True + assert view._recommend_section.isHidden() is True + + +def test_root_view_embedded_init_lazy_builds_grid_pages(qtbot, monkeypatch): + settings = Mock() + settings.get.side_effect = lambda key, default=None: { + "nick": "", + "quality": "320", + }.get(key, default) + context = Mock(settings=settings) + context.services.media = Mock() + provider = Mock() + provider.is_logged_in.return_value = False + provider.get_top_lists.return_value = [] + provider.get_top_list_tracks.return_value = [] + provider.get_recommendations.return_value = [] + provider.get_favorites.return_value = [] + provider.get_hotkeys.return_value = [] + provider.complete.return_value = [] + + grid_ctor = Mock() + tracks_list_ctor = Mock() + monkeypatch.setattr( + "plugins.builtin.qqmusic.lib.root_view.OnlineGridView", + grid_ctor, + ) + monkeypatch.setattr( + "plugins.builtin.qqmusic.lib.root_view.OnlineTracksListView", + tracks_list_ctor, + ) + monkeypatch.setattr( + "plugins.builtin.qqmusic.lib.root_view.QTimer.singleShot", + lambda *_args, **_kwargs: None, + ) + + parent = QWidget() + QQMusicRootView(context, provider, parent=parent) + qtbot.addWidget(parent) + + grid_ctor.assert_not_called() + tracks_list_ctor.assert_not_called() + + +def test_root_view_embedded_init_lazy_builds_detail_ui(qtbot): + settings = Mock() + settings.get.side_effect = lambda key, default=None: { + "nick": "", + "quality": "320", + }.get(key, default) + context = Mock(settings=settings) + context.services.media = Mock() + provider = Mock() + provider.is_logged_in.return_value = False + provider.get_top_lists.return_value = [] + provider.get_top_list_tracks.return_value = [] + provider.get_recommendations.return_value = [] + provider.get_favorites.return_value = [] + provider.get_hotkeys.return_value = [] + provider.complete.return_value = [] + + import plugins.builtin.qqmusic.lib.root_view as root_view_module + original_single_shot = root_view_module.QTimer.singleShot + root_view_module.QTimer.singleShot = lambda *_args, **_kwargs: None + + parent = QWidget() + view = QQMusicRootView(context, provider, parent=parent) + qtbot.addWidget(parent) + + assert view._detail_ui_built is False + root_view_module.QTimer.singleShot = original_single_shot + + +def test_root_view_embedded_init_schedules_public_home_prefetch(qtbot, monkeypatch): + settings = Mock() + settings.get.side_effect = lambda key, default=None: { + "nick": "", + "quality": "320", + }.get(key, default) + context = Mock(settings=settings) + context.services.media = Mock() + provider = Mock() + provider.is_logged_in.return_value = False + provider.get_top_lists.return_value = [] + provider.get_top_list_tracks.return_value = [] + provider.get_recommendations.return_value = [] + provider.get_favorites.return_value = [] + provider.get_hotkeys.return_value = [] + provider.complete.return_value = [] + + scheduled = [] + + parent = QWidget() + view = QQMusicRootView(context, provider, parent=parent) + qtbot.addWidget(parent) + monkeypatch.setattr( + view, + "_schedule_home_sections_load", + lambda **kwargs: scheduled.append(kwargs), + ) + view._public_home_prefetch_scheduled = False + view._schedule_public_home_prefetch() + + assert scheduled == [{"load_private": False, "force": True}] + + +def test_root_view_embedded_logged_in_init_schedules_private_home_prefetch(qtbot, monkeypatch): + settings = Mock() + settings.get.side_effect = lambda key, default=None: { + "nick": "Tester", + "quality": "320", + }.get(key, default) + context = Mock(settings=settings) + context.services.media = Mock() + provider = Mock() + provider.is_logged_in.return_value = True + provider.get_top_lists.return_value = [] + provider.get_top_list_tracks.return_value = [] + provider.get_recommendations.return_value = [] + provider.get_favorites.return_value = [] + provider.get_hotkeys.return_value = [] + provider.complete.return_value = [] + + calls = [] + def _capture(delay, callback): + calls.append(delay) + return None + + monkeypatch.setattr("plugins.builtin.qqmusic.lib.root_view.QTimer.singleShot", _capture) + + parent = QWidget() + QQMusicRootView(context, provider, parent=parent) + qtbot.addWidget(parent) + + assert 600 in calls + + +def test_root_view_embedded_init_lazy_builds_results_ui(qtbot): + settings = Mock() + settings.get.side_effect = lambda key, default=None: { + "nick": "", + "quality": "320", + }.get(key, default) + context = Mock(settings=settings) + context.services.media = Mock() + provider = Mock() + provider.is_logged_in.return_value = False + provider.get_top_lists.return_value = [] + provider.get_top_list_tracks.return_value = [] + provider.get_recommendations.return_value = [] + provider.get_favorites.return_value = [] + provider.get_hotkeys.return_value = [] + provider.complete.return_value = [] + + import plugins.builtin.qqmusic.lib.root_view as root_view_module + original_single_shot = root_view_module.QTimer.singleShot + root_view_module.QTimer.singleShot = lambda *_args, **_kwargs: None + + parent = QWidget() + view = QQMusicRootView(context, provider, parent=parent) + qtbot.addWidget(parent) + + assert view._results_ui_built is False + root_view_module.QTimer.singleShot = original_single_shot + + +def test_root_view_embedded_search_builds_results_ui(qtbot): + settings = Mock() + settings.get.side_effect = lambda key, default=None: { + "nick": "", + "quality": "320", + "search_history": [], + }.get(key, default) + context = Mock(settings=settings) + context.services.media = Mock() + provider = Mock() + provider.is_logged_in.return_value = False + provider.get_top_lists.return_value = [] + provider.get_top_list_tracks.return_value = [] + provider.get_recommendations.return_value = [] + provider.get_favorites.return_value = [] + provider.get_hotkeys.return_value = [] + provider.complete.return_value = [] + provider.search.return_value = { + "tracks": [{"mid": "song-1", "title": "Song 1", "artist": "Singer 1"}], + "total": 1, + "page": 1, + "page_size": 30, + } + + parent = QWidget() + view = QQMusicRootView(context, provider, parent=parent) + qtbot.addWidget(parent) + + view._search_input.setText("Song 1") + view._run_search() + + assert view._results_ui_built is True + assert view._results_table.rowCount() == 1 + + +def test_root_view_show_event_loads_private_sections_after_public_prefetch(qtbot, monkeypatch): + settings = Mock() + settings.get.side_effect = lambda key, default=None: { + "nick": "Tester", + "quality": "320", + }.get(key, default) + context = Mock(settings=settings) + context.services.media = Mock() + provider = Mock() + provider.is_logged_in.return_value = True + provider.get_top_lists.return_value = [] + provider.get_top_list_tracks.return_value = [] + provider.get_recommendations.return_value = [] + provider.get_favorites.return_value = [] + provider.get_hotkeys.return_value = [] + provider.complete.return_value = [] + + scheduled = [] + parent = QWidget() + view = QQMusicRootView(context, provider, parent=parent) + qtbot.addWidget(parent) + view._home_sections_loaded = True + view._private_home_loaded = False + + monkeypatch.setattr( + view, + "_schedule_private_sections_load", + lambda **kwargs: scheduled.append(kwargs), + ) + + view.showEvent(QShowEvent()) + + assert scheduled == [{"force": False}] + + +def test_root_view_top_list_change_uses_async_track_loader(qtbot, monkeypatch): + settings = Mock() + settings.get.side_effect = lambda key, default=None: { + "nick": "", + "quality": "320", + }.get(key, default) + context = Mock(settings=settings) + context.services.media = Mock() + provider = Mock() + provider.is_logged_in.return_value = False + provider.get_top_lists.return_value = [{"id": 26, "title": "热歌榜"}] + provider.get_top_list_tracks.return_value = [{"mid": "song-1", "title": "Song 1"}] + provider.get_recommendations.return_value = [] + provider.get_favorites.return_value = [] + provider.get_hotkeys.return_value = [] + provider.complete.return_value = [] + + started = {} + + class _FakeWorker: + def __init__(self, *_args, **_kwargs): + self.tracks_loaded = Mock(connect=Mock()) + self.failed = Mock(connect=Mock()) + self.finished = Mock(connect=Mock()) + + def start(self): + started["started"] = True + + def isRunning(self): + return False + + def deleteLater(self): + started["deleted"] = True + + monkeypatch.setattr( + "plugins.builtin.qqmusic.lib.root_view.TopListTracksWorker", + _FakeWorker, + ) + + view = QQMusicRootView(context, provider) + qtbot.addWidget(view) + provider.get_top_list_tracks.reset_mock() + + view._on_top_list_changed(0) + + assert started.get("started") is True + provider.get_top_list_tracks.assert_not_called() + + +def test_root_view_top_list_async_load_shows_placeholder_rows(qtbot, monkeypatch): + settings = Mock() + settings.get.side_effect = lambda key, default=None: { + "nick": "", + "quality": "320", + }.get(key, default) + context = Mock(settings=settings) + context.services.media = Mock() + provider = Mock() + provider.is_logged_in.return_value = False + provider.get_top_lists.return_value = [{"id": 26, "title": "热歌榜"}] + provider.get_top_list_tracks.return_value = [{"mid": "song-1", "title": "Song 1"}] + provider.get_recommendations.return_value = [] + provider.get_favorites.return_value = [] + provider.get_hotkeys.return_value = [] + provider.complete.return_value = [] + + class _FakeWorker: + def __init__(self, *_args, **_kwargs): + self.tracks_loaded = Mock(connect=Mock()) + self.failed = Mock(connect=Mock()) + self.finished = Mock(connect=Mock()) + + def start(self): + return None + + def isRunning(self): + return False + + def deleteLater(self): + return None + + monkeypatch.setattr( + "plugins.builtin.qqmusic.lib.root_view.TopListTracksWorker", + _FakeWorker, + ) + monkeypatch.setattr( + "plugins.builtin.qqmusic.lib.root_view.QTimer.singleShot", + lambda *_args, **_kwargs: None, + ) + + parent = QWidget() + view = QQMusicRootView(context, provider, parent=parent) + qtbot.addWidget(parent) + + view._load_top_list_tracks_async(26) + + assert view._top_tracks_table.rowCount() == 10 + + +def test_root_view_async_home_load_shows_placeholder_cards_for_private_sections(qtbot, monkeypatch): + settings = Mock() + settings.get.side_effect = lambda key, default=None: { + "nick": "Tester", + "quality": "320", + }.get(key, default) + context = Mock(settings=settings) + context.services.media = Mock() + provider = Mock() + provider.is_logged_in.return_value = True + provider.get_top_lists.return_value = [{"id": 26, "title": "热歌榜"}] + provider.get_top_list_tracks.return_value = [{"mid": "song-1", "title": "Song 1"}] + provider.get_recommendations.return_value = [{"title": "推荐"}] + provider.get_favorites.return_value = [{"title": "收藏"}] + provider.get_hotkeys.return_value = [] + provider.complete.return_value = [] + + class _FakeWorker: + def __init__(self, *_args, **_kwargs): + self.home_loaded = Mock(connect=Mock()) + self.failed = Mock(connect=Mock()) + self.finished = Mock(connect=Mock()) + + def start(self): + return None + + def isRunning(self): + return False + + def deleteLater(self): + return None + + monkeypatch.setattr( + "plugins.builtin.qqmusic.lib.root_view.HomeSectionsWorker", + _FakeWorker, + ) + + parent = QWidget() + view = QQMusicRootView(context, provider, parent=parent) + qtbot.addWidget(parent) + + view._start_home_sections_worker(load_private=True, force=True) + + assert view._favorites_section.isHidden() is False + assert view._recommend_section.isHidden() is False + assert len(view._favorites_section._cards) == 5 + assert len(view._recommend_section._cards) == 5 + + +def test_root_view_async_home_load_shows_top_list_placeholders(qtbot, monkeypatch): + settings = Mock() + settings.get.side_effect = lambda key, default=None: { + "nick": "", + "quality": "320", + }.get(key, default) + context = Mock(settings=settings) + context.services.media = Mock() + provider = Mock() + provider.is_logged_in.return_value = False + provider.get_top_lists.return_value = [{"id": 26, "title": "热歌榜"}] + provider.get_top_list_tracks.return_value = [{"mid": "song-1", "title": "Song 1"}] + provider.get_recommendations.return_value = [] + provider.get_favorites.return_value = [] + provider.get_hotkeys.return_value = [] + provider.complete.return_value = [] + + class _FakeWorker: + def __init__(self, *_args, **_kwargs): + self.home_loaded = Mock(connect=Mock()) + self.failed = Mock(connect=Mock()) + self.finished = Mock(connect=Mock()) + + def start(self): + return None + + def isRunning(self): + return False + + def deleteLater(self): + return None + + monkeypatch.setattr( + "plugins.builtin.qqmusic.lib.root_view.HomeSectionsWorker", + _FakeWorker, + ) + + parent = QWidget() + view = QQMusicRootView(context, provider, parent=parent) + qtbot.addWidget(parent) + + view._start_home_sections_worker(load_private=False, force=True) + + assert view._top_list_widget.count() == 8 + assert view._top_tracks_table.rowCount() == 10 + + +def test_root_view_loads_recommendation_cards(qtbot): + settings = Mock() + settings.get.side_effect = lambda key, default=None: { + "nick": "Tester", + "quality": "320", + }.get(key, default) + context = Mock(settings=settings) + context.services.media = Mock() + provider = Mock() + provider.is_logged_in.return_value = True + provider.get_top_lists.return_value = [] + provider.get_top_list_tracks.return_value = [] + provider.get_recommendations.return_value = [ + { + "id": "guess", + "title": "猜你喜欢", + "subtitle": "2 项", + "cover_url": "", + "items": [{"mid": "song-1", "title": "Song 1"}], + "entry_type": "songs", + }, + ] + provider.get_favorites.return_value = [] + + view = QQMusicRootView(context, provider) + qtbot.addWidget(view) + + recommend_section = getattr(view, "_recommend_section", None) + assert recommend_section is not None + assert recommend_section.isHidden() is False + assert len(recommend_section._cards) == 1 + + +def test_root_view_favorite_song_card_opens_detail_view(qtbot): + settings = Mock() + settings.get.side_effect = lambda key, default=None: { + "nick": "Tester", + "quality": "320", + }.get(key, default) + context = Mock(settings=settings) + context.services.media = Mock() + provider = Mock() + provider.is_logged_in.return_value = True + provider.get_top_lists.return_value = [] + provider.get_top_list_tracks.return_value = [] + provider.search.return_value = { + "tracks": [{"mid": "song-x", "title": "Song X", "artist": "Singer X"}], + "total": 1, + "page": 1, + "page_size": 30, + } + provider.get_recommendations.return_value = [] + provider.get_favorites.return_value = [ + { + "id": "fav_songs", + "title": "我喜欢的歌曲", + "subtitle": "1 首", + "cover_url": "", + "items": [{"mid": "song-1", "title": "Song 1", "artist": "Singer 1", "cover_url": "http://example/song-cover.jpg"}], + "entry_type": "songs", + }, + ] + + view = QQMusicRootView(context, provider) + qtbot.addWidget(view) + view._search_input.setText("abc") + view._run_search() + assert view._search_type_tabs.isHidden() is False + + assert hasattr(view, "_open_favorite_card") + view._open_favorite_card(provider.get_favorites.return_value[0]) + + assert view._home_stack.currentWidget() is view._detail_page + assert view._detail_title.text() == "我喜欢的歌曲" + assert view._detail_tracks[0]["cover_url"] == "http://example/song-cover.jpg" + assert hasattr(view, "_detail_tracks_view") + assert view._detail_tracks_stack.currentWidget() is view._detail_tracks_view + assert view._detail_cover_url == "http://example/song-cover.jpg" + assert view._search_type_tabs.isHidden() is True + + +def test_root_view_recommendation_playlist_card_opens_playlist_results(qtbot): + settings = Mock() + settings.get.side_effect = lambda key, default=None: { + "nick": "Tester", + "quality": "320", + }.get(key, default) + context = Mock(settings=settings) + context.services.media = Mock() + provider = Mock() + provider.is_logged_in.return_value = True + provider.get_top_lists.return_value = [] + provider.get_top_list_tracks.return_value = [] + provider.get_recommendations.return_value = [ + { + "id": "songlist", + "title": "推荐歌单", + "subtitle": "1 项", + "cover_url": "", + "items": [{"id": "pl-1", "title": "Playlist 1", "creator": "Tester", "song_count": 12}], + "entry_type": "playlists", + }, + ] + provider.get_favorites.return_value = [] + + view = QQMusicRootView(context, provider) + qtbot.addWidget(view) + + assert hasattr(view, "_open_recommendation_card") + view._open_recommendation_card(provider.get_recommendations.return_value[0]) + + assert view._home_stack.currentWidget() is view._results_page + assert view._results_stack.currentWidget() is view._playlists_page + + +def test_root_view_recommendation_playlist_card_handles_nested_playlist_payload(qtbot): + settings = Mock() + settings.get.side_effect = lambda key, default=None: { + "nick": "Tester", + "quality": "320", + }.get(key, default) + context = Mock(settings=settings) + context.services.media = Mock() + provider = Mock() + provider.is_logged_in.return_value = True + provider.get_top_lists.return_value = [] + provider.get_top_list_tracks.return_value = [] + provider.get_recommendations.return_value = [ + { + "id": "songlist", + "title": "推荐歌单", + "subtitle": "1 项", + "cover_url": "", + "items": [ + { + "Playlist": { + "basic": {"id": "pl-1", "title": "Playlist 1", "cover_url": "http://example/cover.jpg"}, + "content": {"song_count": 12}, + } + } + ], + "entry_type": "playlists", + }, + ] + provider.get_favorites.return_value = [] + + view = QQMusicRootView(context, provider) + qtbot.addWidget(view) + + view._open_recommendation_card(provider.get_recommendations.return_value[0]) + + assert view._results_stack.currentWidget() is view._playlists_page + assert len(view._playlists_page._items) == 1 + + +def test_root_view_recommendation_song_card_handles_nested_track_payload(qtbot): + settings = Mock() + settings.get.side_effect = lambda key, default=None: { + "nick": "Tester", + "quality": "320", + }.get(key, default) + context = Mock(settings=settings) + context.services.media = Mock() + provider = Mock() + provider.is_logged_in.return_value = True + provider.get_top_lists.return_value = [] + provider.get_top_list_tracks.return_value = [] + provider.get_recommendations.return_value = [ + { + "id": "radar", + "title": "雷达歌单", + "subtitle": "1 项", + "cover_url": "", + "items": [ + { + "Track": { + "mid": "song-1", + "title": "Song 1", + "singer": [{"name": "Singer 1"}], + "album": {"name": "Album 1", "mid": "album-mid-1"}, + } + } + ], + "entry_type": "songs", + }, + ] + provider.get_favorites.return_value = [] + + view = QQMusicRootView(context, provider) + qtbot.addWidget(view) + + view._open_recommendation_card(provider.get_recommendations.return_value[0]) + + assert view._home_stack.currentWidget() is view._detail_page + assert view._detail_tracks_list.count() == 1 + assert "Singer 1" in view._detail_tracks_list.item(0).text() + assert view._detail_cover_url.endswith("T002R300x300M000album-mid-1.jpg") + + +def test_root_view_song_actions_use_media_bridge(qtbot): + settings = Mock() + settings.get.side_effect = lambda key, default=None: { + "nick": "Tester", + "quality": "flac", + }.get(key, default) + media = Mock() + context = Mock(settings=settings) + context.services.media = media + provider = Mock() + provider.is_logged_in.return_value = False + provider.get_top_lists.return_value = [] + provider.get_top_list_tracks.return_value = [] + provider.get_recommendations.return_value = [] + provider.get_favorites.return_value = [] + provider.search_tracks.return_value = [ + {"mid": "song-1", "title": "Song 1", "artist": "Singer 1", "album": "Album 1", "duration": 210} + ] + + view = QQMusicRootView(context, provider) + qtbot.addWidget(view) + + view._search_input.setText("Song 1") + view._run_search() + view._results_list.setCurrentRow(0) + + view._play_selected_song() + view._add_selected_song_to_queue() + view._insert_selected_song_to_queue() + view._download_selected_song() + + assert media.play_online_track.call_count == 1 + assert media.add_online_track_to_queue.call_count == 1 + assert media.insert_online_track_to_queue.call_count == 1 + assert media.cache_remote_track.call_count == 1 + + +def test_root_view_build_playback_request_normalizes_nested_song_fields(qtbot): + settings = Mock() + settings.get.side_effect = lambda key, default=None: { + "nick": "Tester", + "quality": "flac", + }.get(key, default) + context = Mock(settings=settings) + context.services.media = Mock() + provider = Mock() + provider.is_logged_in.return_value = False + provider.get_top_lists.return_value = [] + provider.get_top_list_tracks.return_value = [] + provider.get_recommendations.return_value = [] + provider.get_favorites.return_value = [] + + view = QQMusicRootView(context, provider) + qtbot.addWidget(view) + + request = view._build_playback_request( + { + "mid": "song-1", + "title": "Song 1", + "singer": [{"name": "Singer 1"}, {"name": "Singer 2"}], + "album": {"name": "Album 1", "mid": "album-mid-1"}, + "interval": 210, + } + ) + + assert request.metadata["artist"] == "Singer 1, Singer 2" + assert request.metadata["album"] == "Album 1" + assert request.metadata["duration"] == 210.0 + assert request.metadata["cover_url"].endswith("T002R300x300M000album-mid-1.jpg") + + +def test_root_view_top_track_activation_uses_media_bridge(qtbot): + settings = Mock() + settings.get.side_effect = lambda key, default=None: { + "nick": "", + "quality": "320", + }.get(key, default) + media = Mock() + context = Mock(settings=settings) + context.services.media = media + provider = Mock() + provider.is_logged_in.return_value = False + provider.get_top_lists.return_value = [ + {"id": 26, "title": "热歌榜"}, + ] + provider.get_top_list_tracks.return_value = [ + {"mid": "song-1", "title": "Song 1", "artist": "Singer 1", "album": "Album 1", "duration": 210} + ] + provider.get_recommendations.return_value = [] + provider.get_favorites.return_value = [] + + view = QQMusicRootView(context, provider) + qtbot.addWidget(view) + + view._play_top_track(0, 0) + + media.play_online_track.assert_called_once() + media.add_online_track_to_queue.assert_not_called() + + +def test_root_view_top_track_activation_queues_remaining_top_tracks(qtbot): + settings = Mock() + settings.get.side_effect = lambda key, default=None: { + "nick": "", + "quality": "320", + }.get(key, default) + media = Mock() + context = Mock(settings=settings) + context.services.media = media + provider = Mock() + provider.is_logged_in.return_value = False + provider.get_top_lists.return_value = [ + {"id": 26, "title": "热歌榜"}, + ] + provider.get_top_list_tracks.return_value = [ + {"mid": "song-1", "title": "Song 1", "artist": "Singer 1", "album": "Album 1", "duration": 210}, + {"mid": "song-2", "title": "Song 2", "artist": "Singer 2", "album": "Album 2", "duration": 180}, + {"mid": "song-3", "title": "Song 3", "artist": "Singer 3", "album": "Album 3", "duration": 200}, + ] + provider.get_recommendations.return_value = [] + provider.get_favorites.return_value = [] + + view = QQMusicRootView(context, provider) + qtbot.addWidget(view) + + view._play_top_track(1, 0) + + media.play_online_track.assert_called_once() + assert media.add_online_track_to_queue.call_count == 1 + + +def test_root_view_ranking_list_activation_queues_remaining_top_tracks(qtbot): + settings = Mock() + settings.get.side_effect = lambda key, default=None: { + "nick": "", + "quality": "320", + "ranking_view_mode": "list", + }.get(key, default) + media = Mock() + context = Mock(settings=settings) + context.services.media = media + provider = Mock() + provider.is_logged_in.return_value = False + provider.get_top_lists.return_value = [ + {"id": 26, "title": "热歌榜"}, + ] + provider.get_top_list_tracks.return_value = [ + {"mid": "song-1", "title": "Song 1", "artist": "Singer 1", "album": "Album 1", "duration": 210}, + {"mid": "song-2", "title": "Song 2", "artist": "Singer 2", "album": "Album 2", "duration": 180}, + {"mid": "song-3", "title": "Song 3", "artist": "Singer 3", "album": "Album 3", "duration": 200}, + ] + provider.get_recommendations.return_value = [] + provider.get_favorites.return_value = [] + + view = QQMusicRootView(context, provider) + qtbot.addWidget(view) + + view._on_ranking_track_activated(view._ranking_tracks[1]) + + media.play_online_track.assert_called_once() + assert media.add_online_track_to_queue.call_count == 1 + + +def test_root_view_ranking_toggle_switches_between_table_and_list(qtbot): + settings = Mock() + state = {"nick": "", "quality": "320", "ranking_view_mode": "table"} + settings.get.side_effect = lambda key, default=None: state.get(key, default) + settings.set.side_effect = lambda key, value: state.__setitem__(key, value) + context = Mock(settings=settings) + context.services.media = Mock() + provider = Mock() + provider.is_logged_in.return_value = False + provider.get_top_lists.return_value = [{"id": 26, "title": "热歌榜"}] + provider.get_top_list_tracks.return_value = [ + {"mid": "song-1", "title": "Song 1", "artist": "Singer 1", "album": "Album 1", "duration": 210}, + ] + provider.get_recommendations.return_value = [] + provider.get_favorites.return_value = [] + + view = QQMusicRootView(context, provider) + qtbot.addWidget(view) + + assert hasattr(view, "_toggle_ranking_view_mode") + assert getattr(view, "_ranking_stacked_widget", None) is not None + initial_tooltip = view._ranking_view_toggle_btn.toolTip() + view._toggle_ranking_view_mode() + + assert state["ranking_view_mode"] == "list" + assert view._ranking_stacked_widget.currentWidget() is view._ranking_list_view + assert view._ranking_view_toggle_btn.toolTip() != initial_tooltip + + +def test_root_view_ranking_batch_queue_actions_use_media_bridge(qtbot): + settings = Mock() + settings.get.side_effect = lambda key, default=None: { + "nick": "", + "quality": "320", + "ranking_view_mode": "table", + }.get(key, default) + context = Mock(settings=settings) + context.services.media = Mock() + provider = Mock() + provider.is_logged_in.return_value = False + provider.get_top_lists.return_value = [{"id": 26, "title": "热歌榜"}] + provider.get_top_list_tracks.return_value = [ + {"mid": "song-1", "title": "Song 1", "artist": "Singer 1", "album": "Album 1", "duration": 210}, + {"mid": "song-2", "title": "Song 2", "artist": "Singer 2", "album": "Album 2", "duration": 180}, + ] + provider.get_recommendations.return_value = [] + provider.get_favorites.return_value = [] + + view = QQMusicRootView(context, provider) + qtbot.addWidget(view) + + assert hasattr(view, "_add_selected_tracks_to_queue") + assert hasattr(view, "_insert_selected_tracks_to_queue") + assert hasattr(view, "_download_selected_tracks") + + tracks = [view._top_track_item(0), view._top_track_item(1)] + view._add_selected_tracks_to_queue(tracks) + view._insert_selected_tracks_to_queue(tracks) + view._download_selected_tracks(tracks) + + assert context.services.media.add_online_track_to_queue.call_count == 2 + assert context.services.media.insert_online_track_to_queue.call_count == 2 + assert context.services.media.cache_remote_track.call_count == 2 + + +def test_root_view_ranking_favorite_toggle_adds_to_favorites(qtbot, monkeypatch): + settings = Mock() + settings.get.side_effect = lambda key, default=None: { + "nick": "", + "quality": "320", + "ranking_view_mode": "list", + }.get(key, default) + context = Mock(settings=settings) + context.services.media = Mock() + provider = Mock() + provider.is_logged_in.return_value = False + provider.get_top_lists.return_value = [{"id": 26, "title": "热歌榜"}] + provider.get_top_list_tracks.return_value = [ + {"mid": "song-1", "title": "Song 1", "artist": "Singer 1", "album": "Album 1", "duration": 210}, + ] + provider.get_recommendations.return_value = [] + provider.get_favorites.return_value = [] + + bootstrap = Mock() + bootstrap.library_service.add_online_track.return_value = 301 + bootstrap.favorites_service = Mock() + monkeypatch.setattr("app.bootstrap.Bootstrap.instance", Mock(return_value=bootstrap)) + + view = QQMusicRootView(context, provider) + qtbot.addWidget(view) + view._ranking_list_view.set_track_favorite = Mock() + + view._on_ranking_favorite_toggled(view._ranking_tracks[0], True) + + bootstrap.favorites_service.add_favorite.assert_called_once_with(track_id=301) + view._ranking_list_view.set_track_favorite.assert_called_once_with("song-1", True) + + +def test_root_view_ranking_favorite_toggle_removes_existing_favorite(qtbot, monkeypatch): + settings = Mock() + settings.get.side_effect = lambda key, default=None: { + "nick": "", + "quality": "320", + "ranking_view_mode": "list", + }.get(key, default) + context = Mock(settings=settings) + context.services.media = Mock() + provider = Mock() + provider.is_logged_in.return_value = False + provider.get_top_lists.return_value = [{"id": 26, "title": "热歌榜"}] + provider.get_top_list_tracks.return_value = [ + {"mid": "song-1", "title": "Song 1", "artist": "Singer 1", "album": "Album 1", "duration": 210}, + ] + provider.get_recommendations.return_value = [] + provider.get_favorites.return_value = [] + + bootstrap = Mock() + bootstrap.library_service.get_track_by_cloud_file_id.return_value = Mock(id=401) + bootstrap.favorites_service = Mock() + monkeypatch.setattr("app.bootstrap.Bootstrap.instance", Mock(return_value=bootstrap)) + + view = QQMusicRootView(context, provider) + qtbot.addWidget(view) + view._ranking_list_view.set_track_favorite = Mock() + + view._on_ranking_favorite_toggled(view._ranking_tracks[0], False) + + bootstrap.favorites_service.remove_favorite.assert_called_once_with(track_id=401) + view._ranking_list_view.set_track_favorite.assert_called_once_with("song-1", False) + + +def test_root_view_ranking_list_loads_initial_favorite_mids(qtbot, monkeypatch): + settings = Mock() + settings.get.side_effect = lambda key, default=None: { + "nick": "", + "quality": "320", + "ranking_view_mode": "list", + }.get(key, default) + context = Mock(settings=settings) + context.services.media = Mock() + provider = Mock() + provider.is_logged_in.return_value = False + provider.get_top_lists.return_value = [{"id": 26, "title": "热歌榜"}] + provider.get_top_list_tracks.return_value = [ + {"mid": "song-1", "title": "Song 1", "artist": "Singer 1", "album": "Album 1", "duration": 210}, + {"mid": "song-2", "title": "Song 2", "artist": "Singer 2", "album": "Album 2", "duration": 180}, + ] + provider.get_recommendations.return_value = [] + provider.get_favorites.return_value = [] + + bootstrap = Mock() + bootstrap.favorites_service.get_all_favorite_track_ids.return_value = {11} + bootstrap.library_service.get_tracks_by_ids.return_value = [Mock(cloud_file_id="song-2")] + monkeypatch.setattr("app.bootstrap.Bootstrap.instance", Mock(return_value=bootstrap)) + + view = QQMusicRootView(context, provider) + qtbot.addWidget(view) + + favorite_mids = view._ranking_list_view._model._favorite_mids + + assert favorite_mids == {"song-2"} + + +def test_root_view_selected_tracks_from_ranking_list_supports_multi_select(qtbot): + settings = Mock() + settings.get.side_effect = lambda key, default=None: { + "nick": "", + "quality": "320", + "ranking_view_mode": "list", + }.get(key, default) + context = Mock(settings=settings) + context.services.media = Mock() + provider = Mock() + provider.is_logged_in.return_value = False + provider.get_top_lists.return_value = [{"id": 26, "title": "热歌榜"}] + provider.get_top_list_tracks.return_value = [ + {"mid": "song-1", "title": "Song 1", "artist": "Singer 1", "album": "Album 1", "duration": 210}, + {"mid": "song-2", "title": "Song 2", "artist": "Singer 2", "album": "Album 2", "duration": 180}, + ] + provider.get_recommendations.return_value = [] + provider.get_favorites.return_value = [] + + view = QQMusicRootView(context, provider) + qtbot.addWidget(view) + + selection_model = view._ranking_list_view._list_view.selectionModel() + selection_model.select( + view._ranking_list_view._model.index(0), + selection_model.SelectionFlag.Select | selection_model.SelectionFlag.Rows, + ) + selection_model.select( + view._ranking_list_view._model.index(1), + selection_model.SelectionFlag.Select | selection_model.SelectionFlag.Rows, + ) + + assert hasattr(view, "_selected_tracks_from_tracks_view") + tracks = view._selected_tracks_from_tracks_view(view._ranking_list_view) + + assert [track["mid"] for track in tracks] == ["song-1", "song-2"] + + +def test_root_view_selected_tracks_from_results_table_supports_multi_select(qtbot): + settings = Mock() + settings.get.side_effect = lambda key, default=None: { + "nick": "Tester", + "quality": "320", + }.get(key, default) + context = Mock(settings=settings) + context.services.media = Mock() + provider = Mock() + provider.is_logged_in.return_value = True + provider.get_top_lists.return_value = [] + provider.get_top_list_tracks.return_value = [] + provider.get_recommendations.return_value = [] + provider.get_favorites.return_value = [] + provider.search.return_value = { + "tracks": [ + {"mid": "song-1", "title": "Song 1", "artist": "Singer 1", "album": "Album 1", "duration": 210}, + {"mid": "song-2", "title": "Song 2", "artist": "Singer 2", "album": "Album 2", "duration": 180}, + ], + "total": 2, + "page": 1, + "page_size": 30, + } + + view = QQMusicRootView(context, provider) + qtbot.addWidget(view) + + view._search_input.setText("Song") + view._run_search() + view._results_table.setSelectionMode(view._results_table.SelectionMode.MultiSelection) + view._results_table.selectRow(0) + view._results_table.selectRow(1) + + assert hasattr(view, "_selected_tracks_from_table") + tracks = view._selected_tracks_from_table(view._results_table) + + assert [track["mid"] for track in tracks] == ["song-1", "song-2"] + + +def test_root_view_song_buttons_use_multi_selected_results_tracks(qtbot): + settings = Mock() + settings.get.side_effect = lambda key, default=None: { + "nick": "Tester", + "quality": "320", + }.get(key, default) + context = Mock(settings=settings) + context.services.media = Mock() + provider = Mock() + provider.is_logged_in.return_value = True + provider.get_top_lists.return_value = [] + provider.get_top_list_tracks.return_value = [] + provider.get_recommendations.return_value = [] + provider.get_favorites.return_value = [] + provider.search.return_value = { + "tracks": [ + {"mid": "song-1", "title": "Song 1", "artist": "Singer 1", "album": "Album 1", "duration": 210}, + {"mid": "song-2", "title": "Song 2", "artist": "Singer 2", "album": "Album 2", "duration": 180}, + ], + "total": 2, + "page": 1, + "page_size": 30, + } + + view = QQMusicRootView(context, provider) + qtbot.addWidget(view) + + view._search_input.setText("Song") + view._run_search() + selection_model = view._results_table.selectionModel() + selection_model.select( + view._results_table.model().index(0, 0), + selection_model.SelectionFlag.Select | selection_model.SelectionFlag.Rows, + ) + selection_model.select( + view._results_table.model().index(1, 0), + selection_model.SelectionFlag.Select | selection_model.SelectionFlag.Rows, + ) + + view._add_selected_song_to_queue() + view._insert_selected_song_to_queue() + view._download_selected_song() + + assert context.services.media.add_online_track_to_queue.call_count == 2 + assert context.services.media.insert_online_track_to_queue.call_count == 2 + assert context.services.media.cache_remote_track.call_count == 2 + + +def test_root_view_play_button_plays_first_selected_result_and_queues_rest(qtbot): + settings = Mock() + settings.get.side_effect = lambda key, default=None: { + "nick": "Tester", + "quality": "320", + }.get(key, default) + context = Mock(settings=settings) + context.services.media = Mock() + provider = Mock() + provider.is_logged_in.return_value = True + provider.get_top_lists.return_value = [] + provider.get_top_list_tracks.return_value = [] + provider.get_recommendations.return_value = [] + provider.get_favorites.return_value = [] + provider.search.return_value = { + "tracks": [ + {"mid": "song-1", "title": "Song 1", "artist": "Singer 1", "album": "Album 1", "duration": 210}, + {"mid": "song-2", "title": "Song 2", "artist": "Singer 2", "album": "Album 2", "duration": 180}, + ], + "total": 2, + "page": 1, + "page_size": 30, + } + + view = QQMusicRootView(context, provider) + qtbot.addWidget(view) + + view._search_input.setText("Song") + view._run_search() + selection_model = view._results_table.selectionModel() + selection_model.select( + view._results_table.model().index(0, 0), + selection_model.SelectionFlag.Select | selection_model.SelectionFlag.Rows, + ) + selection_model.select( + view._results_table.model().index(1, 0), + selection_model.SelectionFlag.Select | selection_model.SelectionFlag.Rows, + ) + + view._play_selected_song() + + context.services.media.play_online_track.assert_called_once() + assert context.services.media.add_online_track_to_queue.call_count == 1 + + +def test_root_view_add_selected_to_favorites_uses_bootstrap_services(qtbot, monkeypatch): + settings = Mock() + settings.get.side_effect = lambda key, default=None: { + "nick": "Tester", + "quality": "320", + }.get(key, default) + context = Mock(settings=settings) + context.services.media = Mock() + provider = Mock() + provider.is_logged_in.return_value = True + provider.get_top_lists.return_value = [] + provider.get_top_list_tracks.return_value = [] + provider.get_recommendations.return_value = [] + provider.get_favorites.return_value = [] + + bootstrap = Mock() + bootstrap.library_service.add_online_track.side_effect = [101, 102] + bootstrap.favorites_service = Mock() + monkeypatch.setattr("app.bootstrap.Bootstrap.instance", Mock(return_value=bootstrap)) + + view = QQMusicRootView(context, provider) + qtbot.addWidget(view) + + tracks = [ + {"mid": "song-1", "title": "Song 1", "artist": "Singer 1", "album": "Album 1", "duration": 210}, + {"mid": "song-2", "title": "Song 2", "artist": "Singer 2", "album": "Album 2", "duration": 180}, + ] + + view._add_selected_to_favorites(tracks) + + assert bootstrap.library_service.add_online_track.call_count == 2 + assert bootstrap.favorites_service.add_favorite.call_count == 2 + + +def test_root_view_add_selected_to_playlist_uses_playlist_helper(qtbot, monkeypatch): + settings = Mock() + settings.get.side_effect = lambda key, default=None: { + "nick": "Tester", + "quality": "320", + }.get(key, default) + context = Mock(settings=settings) + context.services.media = Mock() + provider = Mock() + provider.is_logged_in.return_value = True + provider.get_top_lists.return_value = [] + provider.get_top_list_tracks.return_value = [] + provider.get_recommendations.return_value = [] + provider.get_favorites.return_value = [] + + bootstrap = Mock() + bootstrap.library_service.add_online_track.side_effect = [201, 202] + monkeypatch.setattr("app.bootstrap.Bootstrap.instance", Mock(return_value=bootstrap)) + playlist_adder = Mock() + monkeypatch.setattr("utils.playlist_utils.add_tracks_to_playlist", playlist_adder) + + view = QQMusicRootView(context, provider) + qtbot.addWidget(view) + + tracks = [ + {"mid": "song-1", "title": "Song 1", "artist": "Singer 1", "album": "Album 1", "duration": 210}, + {"mid": "song-2", "title": "Song 2", "artist": "Singer 2", "album": "Album 2", "duration": 180}, + ] + + view._add_selected_to_playlist(tracks) + + playlist_adder.assert_called_once() + + +def test_root_view_artist_detail_navigation(qtbot): + settings = Mock() + settings.get.side_effect = lambda key, default=None: { + "nick": "Tester", + "quality": "320", + }.get(key, default) + media = Mock() + context = Mock(settings=settings) + context.services.media = media + provider = Mock() + provider.is_logged_in.return_value = True + provider.get_top_lists.return_value = [] + provider.get_top_list_tracks.return_value = [] + provider.get_recommendations.return_value = [] + provider.get_favorites.return_value = [] + provider.search.return_value = { + "artists": [{"mid": "artist-1", "name": "Singer 1", "song_count": 12}], + } + provider.get_artist_detail.return_value = { + "title": "Singer 1", + "songs": [ + {"mid": "song-1", "title": "Song 1", "artist": "Singer 1", "album": "Album 1", "duration": 210}, + ], + } + + view = QQMusicRootView(context, provider) + qtbot.addWidget(view) + + view._search_input.setText("Singer 1") + view._search_type_tabs.setCurrentIndex(1) + view._run_search() + view._artists_list.setCurrentRow(0) + + view._open_artist_detail(view._artists_list.item(0)) + + assert view._home_stack.currentWidget() is view._detail_page + assert hasattr(view, "_detail_info_section") + assert hasattr(view, "_detail_songs_section") + assert view._detail_type_label.text() == t("artist") + assert view._detail_title.text() == "Singer 1" + assert view._detail_stats_label.text() == f"12 {t('songs')}" + assert view._detail_tracks_list.count() == 1 + provider.get_artist_detail.assert_called_once_with("artist-1") + view._go_back_from_detail() + assert view._results_stack.currentWidget() is view._artists_page + + +def test_root_view_artist_detail_exposes_follow_toggle(qtbot): + settings = Mock() + settings.get.side_effect = lambda key, default=None: { + "nick": "Tester", + "quality": "320", + }.get(key, default) + context = Mock(settings=settings) + context.services.media = Mock() + provider = Mock() + provider.is_logged_in.return_value = True + provider.get_top_lists.return_value = [] + provider.get_top_list_tracks.return_value = [] + provider.get_recommendations.return_value = [] + provider.get_favorites.return_value = [] + provider.search.return_value = { + "artists": [{"mid": "artist-1", "name": "Singer 1", "song_count": 12}], + } + provider.get_artist_detail.return_value = { + "title": "Singer 1", + "songs": [{"mid": "song-1", "title": "Song 1", "artist": "Singer 1"}], + "follow_status": False, + } + provider.follow_artist.return_value = True + + view = QQMusicRootView(context, provider) + qtbot.addWidget(view) + + view._search_input.setText("Singer 1") + view._search_type_tabs.setCurrentIndex(1) + view._run_search() + view._open_artist_detail(view._artists_list.item(0)) + + assert view._detail_follow_btn.isHidden() is False + + view._detail_follow_btn.click() + + provider.follow_artist.assert_called_once_with("artist-1") + assert view._detail_follow_btn.text() == t("qqmusic_followed", "Following") + + +def test_root_view_album_detail_exposes_qq_favorite_toggle(qtbot): + settings = Mock() + settings.get.side_effect = lambda key, default=None: { + "nick": "Tester", + "quality": "320", + }.get(key, default) + context = Mock(settings=settings) + context.services.media = Mock() + provider = Mock() + provider.is_logged_in.return_value = True + provider.get_top_lists.return_value = [] + provider.get_top_list_tracks.return_value = [] + provider.get_recommendations.return_value = [] + provider.get_favorites.return_value = [] + provider.search.return_value = { + "albums": [{"mid": "album-1", "name": "Album 1", "singer_name": "Singer 1"}], + } + provider.get_album_detail.return_value = { + "title": "Album 1", + "songs": [{"mid": "song-1", "title": "Song 1", "artist": "Singer 1"}], + "is_faved": False, + } + provider.fav_album.return_value = True + + view = QQMusicRootView(context, provider) + qtbot.addWidget(view) + + view._search_input.setText("Album 1") + view._search_type_tabs.setCurrentIndex(2) + view._run_search() + view._open_album_detail(view._albums_list.item(0)) + + assert view._detail_fav_btn.isHidden() is False + assert view._detail_type_label.text() == t("album") + assert "Singer 1" in view._detail_meta_label.text() + + view._detail_fav_btn.click() + + provider.fav_album.assert_called_once_with("album-1") + assert view._detail_fav_btn.text() == t("qqmusic_remove_from_favorites", "Remove from QQ Favorites") + + +def test_root_view_playlist_detail_exposes_qq_favorite_toggle(qtbot): + settings = Mock() + settings.get.side_effect = lambda key, default=None: { + "nick": "Tester", + "quality": "320", + }.get(key, default) + context = Mock(settings=settings) + context.services.media = Mock() + provider = Mock() + provider.is_logged_in.return_value = True + provider.get_top_lists.return_value = [] + provider.get_top_list_tracks.return_value = [] + provider.get_recommendations.return_value = [] + provider.get_favorites.return_value = [] + provider.search.return_value = { + "playlists": [{"id": "playlist-1", "title": "Playlist 1", "creator": "Tester"}], + } + provider.get_playlist_detail.return_value = { + "title": "Playlist 1", + "songs": [{"mid": "song-1", "title": "Song 1", "artist": "Singer 1"}], + "is_faved": False, + } + provider.fav_playlist.return_value = True + + view = QQMusicRootView(context, provider) + qtbot.addWidget(view) + + view._search_input.setText("Playlist 1") + view._search_type_tabs.setCurrentIndex(3) + view._run_search() + view._open_playlist_detail(view._playlists_list.item(0)) + + assert view._detail_fav_btn.isHidden() is False + assert view._detail_type_label.text() == t("playlists") + + view._detail_fav_btn.click() + + provider.fav_playlist.assert_called_once_with("playlist-1") + assert view._detail_fav_btn.text() == t("qqmusic_remove_from_favorites", "Remove from QQ Favorites") + + +def test_root_view_artist_detail_shows_related_albums_and_opens_album_detail(qtbot): + settings = Mock() + settings.get.side_effect = lambda key, default=None: { + "nick": "Tester", + "quality": "320", + }.get(key, default) + context = Mock(settings=settings) + context.services.media = Mock() + provider = Mock() + provider.is_logged_in.return_value = True + provider.get_top_lists.return_value = [] + provider.get_top_list_tracks.return_value = [] + provider.get_recommendations.return_value = [] + provider.get_favorites.return_value = [] + provider.search.return_value = { + "artists": [{"mid": "artist-1", "name": "Singer 1", "song_count": 12}], + } + provider.get_artist_detail.return_value = { + "title": "Singer 1", + "songs": [{"mid": "song-1", "title": "Song 1", "artist": "Singer 1"}], + } + provider.get_artist_albums.return_value = [ + {"mid": "album-1", "name": "Album 1", "singer_name": "Singer 1"}, + ] + provider.get_album_detail.return_value = { + "title": "Album 1", + "songs": [{"mid": "song-2", "title": "Song 2", "artist": "Singer 1"}], + } + + view = QQMusicRootView(context, provider) + qtbot.addWidget(view) + + view._search_input.setText("Singer 1") + view._search_type_tabs.setCurrentIndex(1) + view._run_search() + view._open_artist_detail(view._artists_list.item(0)) + + assert hasattr(view, "_detail_albums_list") + assert view._detail_albums_list.count() == 1 + + view._open_album_from_detail(view._detail_albums_list.item(0)) + + assert view._detail_title.text() == "Album 1" + + +def test_root_view_back_from_album_detail_restores_artist_detail(qtbot): + settings = Mock() + settings.get.side_effect = lambda key, default=None: { + "nick": "Tester", + "quality": "320", + }.get(key, default) + context = Mock(settings=settings) + context.services.media = Mock() + provider = Mock() + provider.is_logged_in.return_value = True + provider.get_top_lists.return_value = [] + provider.get_top_list_tracks.return_value = [] + provider.get_recommendations.return_value = [] + provider.get_favorites.return_value = [] + provider.search.return_value = { + "artists": [{"mid": "artist-1", "name": "Singer 1", "song_count": 12}], + } + provider.get_artist_detail.return_value = { + "title": "Singer 1", + "songs": [{"mid": "song-1", "title": "Song 1", "artist": "Singer 1"}], + } + provider.get_artist_albums.return_value = [ + {"mid": "album-1", "name": "Album 1", "singer_name": "Singer 1"}, + ] + provider.get_album_detail.return_value = { + "title": "Album 1", + "songs": [{"mid": "song-2", "title": "Song 2", "artist": "Singer 1"}], + } + + view = QQMusicRootView(context, provider) + qtbot.addWidget(view) + + view._search_input.setText("Singer 1") + view._search_type_tabs.setCurrentIndex(1) + view._run_search() + view._open_artist_detail(view._artists_list.item(0)) + view._open_album_from_detail(view._detail_albums_list.item(0)) + view._go_back_from_detail() + + assert view._detail_title.text() == "Singer 1" + assert view._detail_albums_list.count() == 1 + + +def test_root_view_album_and_playlist_detail_navigation(qtbot): + settings = Mock() + settings.get.side_effect = lambda key, default=None: { + "nick": "Tester", + "quality": "320", + }.get(key, default) + media = Mock() + context = Mock(settings=settings) + context.services.media = media + provider = Mock() + provider.is_logged_in.return_value = True + provider.get_top_lists.return_value = [] + provider.get_top_list_tracks.return_value = [] + provider.get_recommendations.return_value = [] + provider.get_favorites.return_value = [] + provider.search.side_effect = [ + {"albums": [{"mid": "album-1", "name": "Album 1", "singer_name": "Singer 1"}]}, + {"albums": [{"mid": "album-1", "name": "Album 1", "singer_name": "Singer 1"}]}, + {"playlists": [{"id": "playlist-1", "title": "Playlist 1", "creator": "Tester"}]}, + {"playlists": [{"id": "playlist-1", "title": "Playlist 1", "creator": "Tester"}]}, + ] + provider.get_album_detail.return_value = { + "title": "Album 1", + "songs": [{"mid": "song-1", "title": "Song 1", "artist": "Singer 1"}], + } + provider.get_playlist_detail.return_value = { + "title": "Playlist 1", + "songs": [{"mid": "song-2", "title": "Song 2", "artist": "Singer 2"}], + } + + view = QQMusicRootView(context, provider) + qtbot.addWidget(view) + + view._search_input.setText("Album 1") + view._search_type_tabs.setCurrentIndex(2) + view._run_search() + view._open_album_detail(view._albums_list.item(0)) + assert view._detail_title.text() == "Album 1" + view._go_back_from_detail() + assert view._results_stack.currentWidget() is view._albums_page + + view._search_input.setText("Playlist 1") + view._search_type_tabs.setCurrentIndex(3) + view._run_search() + view._open_playlist_detail(view._playlists_list.item(0)) + assert view._detail_title.text() == "Playlist 1" + view._go_back_from_detail() + assert view._results_stack.currentWidget() is view._playlists_page + + +def test_root_view_detail_actions_use_media_bridge(qtbot): + settings = Mock() + settings.get.side_effect = lambda key, default=None: { + "nick": "Tester", + "quality": "flac", + }.get(key, default) + media = Mock() + context = Mock(settings=settings) + context.services.media = media + provider = Mock() + provider.is_logged_in.return_value = True + provider.get_top_lists.return_value = [] + provider.get_top_list_tracks.return_value = [] + provider.get_recommendations.return_value = [] + provider.get_favorites.return_value = [] + provider.search.return_value = { + "artists": [{"mid": "artist-1", "name": "Singer 1", "song_count": 12}], + } + provider.get_artist_detail.return_value = { + "title": "Singer 1", + "songs": [{"mid": "song-1", "title": "Song 1", "artist": "Singer 1", "album": "Album 1"}], + } + + view = QQMusicRootView(context, provider) + qtbot.addWidget(view) + + view._search_input.setText("Singer 1") + view._search_type_tabs.setCurrentIndex(1) + view._run_search() + view._open_artist_detail(view._artists_list.item(0)) + view._detail_tracks_list.setCurrentRow(0) + + view._play_selected_detail_track() + view._add_selected_detail_track_to_queue() + view._insert_selected_detail_track_to_queue() + view._download_selected_detail_track() + + assert media.play_online_track.call_count == 1 + assert media.add_online_track_to_queue.call_count == 1 + assert media.insert_online_track_to_queue.call_count == 1 + assert media.cache_remote_track.call_count == 1 + + +def test_root_view_detail_view_supports_batch_actions(qtbot): + settings = Mock() + settings.get.side_effect = lambda key, default=None: { + "nick": "Tester", + "quality": "flac", + }.get(key, default) + context = Mock(settings=settings) + context.services.media = Mock() + provider = Mock() + provider.is_logged_in.return_value = True + provider.get_top_lists.return_value = [] + provider.get_top_list_tracks.return_value = [] + provider.get_recommendations.return_value = [] + provider.get_favorites.return_value = [] + provider.search.return_value = { + "artists": [{"mid": "artist-1", "name": "Singer 1", "song_count": 12}], + "total": 1, + "page": 1, + "page_size": 30, + } + provider.get_artist_detail.return_value = { + "title": "Singer 1", + "description": "desc", + "songs": [ + {"mid": "song-1", "title": "Song 1", "artist": "Singer 1", "album": "Album 1", "duration": 210}, + {"mid": "song-2", "title": "Song 2", "artist": "Singer 1", "album": "Album 1", "duration": 180}, + ], + } + + view = QQMusicRootView(context, provider) + qtbot.addWidget(view) + + view._search_input.setText("Singer 1") + view._search_type_tabs.setCurrentIndex(1) + view._run_search() + + assert hasattr(view, "_open_artist_detail_from_grid") + assert hasattr(view, "_play_all_from_detail_tracks") + assert hasattr(view, "_add_all_detail_tracks_to_queue") + assert hasattr(view, "_insert_all_detail_tracks_to_queue") + + view._open_artist_detail_from_grid({"mid": "artist-1", "name": "Singer 1"}) + view._play_all_from_detail_tracks() + view._add_all_detail_tracks_to_queue() + view._insert_all_detail_tracks_to_queue() + + assert context.services.media.play_online_track.call_count == 1 + assert context.services.media.add_online_track_to_queue.call_count == 3 + assert context.services.media.insert_online_track_to_queue.call_count == 2 + + +def test_root_view_detail_has_visible_all_track_action_buttons(qtbot): + settings = Mock() + settings.get.side_effect = lambda key, default=None: { + "nick": "Tester", + "quality": "320", + }.get(key, default) + context = Mock(settings=settings) + context.services.media = Mock() + provider = Mock() + provider.is_logged_in.return_value = True + provider.get_top_lists.return_value = [] + provider.get_top_list_tracks.return_value = [] + provider.get_recommendations.return_value = [] + provider.get_favorites.return_value = [] + provider.search.return_value = { + "artists": [{"mid": "artist-1", "name": "Singer 1", "song_count": 12}], + "total": 1, + "page": 1, + "page_size": 30, + } + provider.get_artist_detail.return_value = { + "title": "Singer 1", + "songs": [ + {"mid": "song-1", "title": "Song 1", "artist": "Singer 1", "album": "Album 1"}, + {"mid": "song-2", "title": "Song 2", "artist": "Singer 1", "album": "Album 1"}, + ], + } + + view = QQMusicRootView(context, provider) + qtbot.addWidget(view) + + view._search_input.setText("Singer 1") + view._search_type_tabs.setCurrentIndex(1) + view._run_search() + view._open_artist_detail(view._artists_list.item(0)) + + assert hasattr(view, "_detail_play_all_btn") + assert hasattr(view, "_detail_queue_all_btn") + assert hasattr(view, "_detail_insert_all_btn") + assert view._detail_play_all_btn.isHidden() is False + assert view._detail_queue_all_btn.isHidden() is False + assert view._detail_insert_all_btn.isHidden() is False + + +def test_root_view_detail_all_track_buttons_use_all_tracks(qtbot): + settings = Mock() + settings.get.side_effect = lambda key, default=None: { + "nick": "Tester", + "quality": "320", + }.get(key, default) + context = Mock(settings=settings) + context.services.media = Mock() + provider = Mock() + provider.is_logged_in.return_value = True + provider.get_top_lists.return_value = [] + provider.get_top_list_tracks.return_value = [] + provider.get_recommendations.return_value = [] + provider.get_favorites.return_value = [] + provider.search.return_value = { + "artists": [{"mid": "artist-1", "name": "Singer 1", "song_count": 12}], + "total": 1, + "page": 1, + "page_size": 30, + } + provider.get_artist_detail.return_value = { + "title": "Singer 1", + "songs": [ + {"mid": "song-1", "title": "Song 1", "artist": "Singer 1", "album": "Album 1"}, + {"mid": "song-2", "title": "Song 2", "artist": "Singer 1", "album": "Album 1"}, + ], + } + + view = QQMusicRootView(context, provider) + qtbot.addWidget(view) + + view._search_input.setText("Singer 1") + view._search_type_tabs.setCurrentIndex(1) + view._run_search() + view._open_artist_detail(view._artists_list.item(0)) + + view._detail_play_all_btn.click() + view._detail_queue_all_btn.click() + view._detail_insert_all_btn.click() + + assert context.services.media.play_online_track.call_count == 1 + assert context.services.media.add_online_track_to_queue.call_count == 3 + assert context.services.media.insert_online_track_to_queue.call_count == 2 + + +def test_root_view_detail_tracks_support_multi_select(qtbot): + settings = Mock() + settings.get.side_effect = lambda key, default=None: { + "nick": "Tester", + "quality": "320", + }.get(key, default) + context = Mock(settings=settings) + context.services.media = Mock() + provider = Mock() + provider.is_logged_in.return_value = True + provider.get_top_lists.return_value = [] + provider.get_top_list_tracks.return_value = [] + provider.get_recommendations.return_value = [] + provider.get_favorites.return_value = [] + provider.search.return_value = { + "artists": [{"mid": "artist-1", "name": "Singer 1", "song_count": 12}], + "total": 1, + "page": 1, + "page_size": 30, + } + provider.get_artist_detail.return_value = { + "title": "Singer 1", + "songs": [ + {"mid": "song-1", "title": "Song 1", "artist": "Singer 1", "album": "Album 1"}, + {"mid": "song-2", "title": "Song 2", "artist": "Singer 1", "album": "Album 1"}, + ], + } + + view = QQMusicRootView(context, provider) + qtbot.addWidget(view) + + view._search_input.setText("Singer 1") + view._search_type_tabs.setCurrentIndex(1) + view._run_search() + view._open_artist_detail(view._artists_list.item(0)) + view._detail_tracks_list.setSelectionMode(view._detail_tracks_list.SelectionMode.MultiSelection) + view._detail_tracks_list.item(0).setSelected(True) + view._detail_tracks_list.item(1).setSelected(True) + + assert hasattr(view, "_selected_detail_tracks") + tracks = view._selected_detail_tracks() + + assert [track["mid"] for track in tracks] == ["song-1", "song-2"] + + +def test_root_view_detail_actions_use_selected_tracks_when_multi_selected(qtbot): + settings = Mock() + settings.get.side_effect = lambda key, default=None: { + "nick": "Tester", + "quality": "320", + }.get(key, default) + context = Mock(settings=settings) + context.services.media = Mock() + provider = Mock() + provider.is_logged_in.return_value = True + provider.get_top_lists.return_value = [] + provider.get_top_list_tracks.return_value = [] + provider.get_recommendations.return_value = [] + provider.get_favorites.return_value = [] + provider.search.return_value = { + "artists": [{"mid": "artist-1", "name": "Singer 1", "song_count": 12}], + "total": 1, + "page": 1, + "page_size": 30, + } + provider.get_artist_detail.return_value = { + "title": "Singer 1", + "songs": [ + {"mid": "song-1", "title": "Song 1", "artist": "Singer 1", "album": "Album 1"}, + {"mid": "song-2", "title": "Song 2", "artist": "Singer 1", "album": "Album 1"}, + ], + } + + view = QQMusicRootView(context, provider) + qtbot.addWidget(view) + + view._search_input.setText("Singer 1") + view._search_type_tabs.setCurrentIndex(1) + view._run_search() + view._open_artist_detail(view._artists_list.item(0)) + view._detail_tracks_list.item(0).setSelected(True) + view._detail_tracks_list.item(1).setSelected(True) + + view._add_selected_detail_track_to_queue() + view._insert_selected_detail_track_to_queue() + view._download_selected_detail_track() + + assert context.services.media.add_online_track_to_queue.call_count == 2 + assert context.services.media.insert_online_track_to_queue.call_count == 2 + assert context.services.media.cache_remote_track.call_count == 2 + + +def test_root_view_detail_back_returns_to_previous_page(qtbot): + settings = Mock() + settings.get.side_effect = lambda key, default=None: { + "nick": "Tester", + "quality": "320", + }.get(key, default) + media = Mock() + context = Mock(settings=settings) + context.services.media = media + provider = Mock() + provider.is_logged_in.return_value = True + provider.get_top_lists.return_value = [] + provider.get_top_list_tracks.return_value = [] + provider.get_recommendations.return_value = [ + { + "title": "猜你喜欢", + "subtitle": "1 项", + "items": [{"mid": "song-1", "title": "Song 1", "artist": "Singer 1"}], + }, + ] + provider.get_favorites.return_value = [] + + view = QQMusicRootView(context, provider) + qtbot.addWidget(view) + + view._open_recommendation_section(view._recommend_list.item(0)) + view._detail_back_btn.click() + + assert view._home_stack.currentWidget() is view._home_page + + +def test_root_view_favorites_navigation(qtbot): + settings = Mock() + settings.get.side_effect = lambda key, default=None: { + "nick": "Tester", + "quality": "320", + }.get(key, default) + media = Mock() + context = Mock(settings=settings) + context.services.media = media + provider = Mock() + provider.is_logged_in.return_value = True + provider.get_top_lists.return_value = [] + provider.get_top_list_tracks.return_value = [] + provider.get_recommendations.return_value = [] + provider.get_favorites.return_value = [ + { + "title": "我喜欢的歌曲", + "count": 1, + "items": [{"mid": "song-1", "title": "Song 1", "artist": "Singer 1", "album": "Album 1"}], + }, + ] + + view = QQMusicRootView(context, provider) + qtbot.addWidget(view) + + view._open_favorite_section(view._favorites_list.item(0)) + + assert view._home_stack.currentWidget() is view._detail_page + assert view._detail_tracks_list.count() == 1 + + +def test_root_view_recommendation_navigation(qtbot): + settings = Mock() + settings.get.side_effect = lambda key, default=None: { + "nick": "Tester", + "quality": "320", + }.get(key, default) + media = Mock() + context = Mock(settings=settings) + context.services.media = media + provider = Mock() + provider.is_logged_in.return_value = True + provider.get_top_lists.return_value = [] + provider.get_top_list_tracks.return_value = [] + provider.get_recommendations.return_value = [ + { + "title": "猜你喜欢", + "subtitle": "1 项", + "cover_url": "http://example.com/card-cover.jpg", + "items": [{"mid": "song-1", "title": "Song 1", "artist": "Singer 1", "album": "Album 1"}], + }, + ] + provider.get_favorites.return_value = [] + + view = QQMusicRootView(context, provider) + qtbot.addWidget(view) + + view._open_recommendation_section(view._recommend_list.item(0)) + + assert view._home_stack.currentWidget() is view._detail_page + assert view._detail_tracks_list.count() == 1 + assert view._detail_tracks_stack.currentWidget() is view._detail_tracks_view + assert view._detail_title.text() == "猜你喜欢" + assert view._detail_cover_url == "http://example.com/card-cover.jpg" + + +def test_root_view_artist_detail_uses_tracks_list_view(qtbot): + settings = Mock() + settings.get.side_effect = lambda key, default=None: { + "nick": "Tester", + "quality": "320", + }.get(key, default) + context = Mock(settings=settings) + context.services.media = Mock() + provider = Mock() + provider.is_logged_in.return_value = True + provider.get_top_lists.return_value = [] + provider.get_top_list_tracks.return_value = [] + provider.get_recommendations.return_value = [] + provider.get_favorites.return_value = [] + provider.search.return_value = { + "artists": [{"mid": "artist-1", "name": "Singer 1", "song_count": 2}], + "total": 1, + "page": 1, + "page_size": 30, + } + provider.get_artist_detail.return_value = { + "title": "Singer 1", + "songs": [ + {"mid": "song-1", "title": "Song 1", "artist": "Singer 1", "album": "Album 1"}, + {"mid": "song-2", "title": "Song 2", "artist": "Singer 1", "album": "Album 1"}, + ], + } + + view = QQMusicRootView(context, provider) + qtbot.addWidget(view) + + view._search_input.setText("Singer 1") + view._search_type_tabs.setCurrentIndex(1) + view._run_search() + view._open_artist_detail(view._artists_list.item(0)) + + assert view._detail_tracks_stack.currentWidget() is view._detail_tracks_view + + +def test_root_view_artist_detail_shows_related_albums_grid(qtbot): + settings = Mock() + settings.get.side_effect = lambda key, default=None: { + "nick": "Tester", + "quality": "320", + }.get(key, default) + context = Mock(settings=settings) + context.services.media = Mock() + provider = Mock() + provider.is_logged_in.return_value = True + provider.get_top_lists.return_value = [] + provider.get_top_list_tracks.return_value = [] + provider.get_recommendations.return_value = [] + provider.get_favorites.return_value = [] + provider.search.return_value = { + "artists": [{"mid": "artist-1", "name": "Singer 1", "song_count": 2, "album_count": 1}], + "total": 1, + "page": 1, + "page_size": 30, + } + provider.get_artist_detail.return_value = { + "title": "Singer 1", + "songs": [{"mid": "song-1", "title": "Song 1", "artist": "Singer 1", "album": "Album 1"}], + } + provider.get_artist_albums.return_value = [ + {"mid": "album-1", "name": "Album 1", "singer_name": "Singer 1", "publish_date": "2024-01-01"}, + ] + + view = QQMusicRootView(context, provider) + qtbot.addWidget(view) + + view._search_input.setText("Singer 1") + view._search_type_tabs.setCurrentIndex(1) + view._run_search() + view._open_artist_detail(view._artists_list.item(0)) + + assert hasattr(view, "_detail_albums_grid") + assert view._detail_albums_grid.isHidden() is False + assert len(view._detail_albums_grid._items) == 1 + + +def test_root_view_album_detail_uses_tracks_list_view(qtbot): + settings = Mock() + settings.get.side_effect = lambda key, default=None: { + "nick": "Tester", + "quality": "320", + }.get(key, default) + context = Mock(settings=settings) + context.services.media = Mock() + provider = Mock() + provider.is_logged_in.return_value = True + provider.get_top_lists.return_value = [] + provider.get_top_list_tracks.return_value = [] + provider.get_recommendations.return_value = [] + provider.get_favorites.return_value = [] + provider.search.return_value = { + "albums": [{"mid": "album-1", "name": "Album 1", "singer_name": "Singer 1"}], + "total": 1, + "page": 1, + "page_size": 30, + } + provider.get_album_detail.return_value = { + "title": "Album 1", + "songs": [ + {"mid": "song-1", "title": "Song 1", "artist": "Singer 1", "album": "Album 1"}, + ], + } + + view = QQMusicRootView(context, provider) + qtbot.addWidget(view) + + view._search_input.setText("Album 1") + view._search_type_tabs.setCurrentIndex(2) + view._run_search() + view._open_album_detail(view._albums_list.item(0)) + + assert view._detail_tracks_stack.currentWidget() is view._detail_tracks_view + + +def test_root_view_album_detail_shows_extra_meta_line(qtbot): + settings = Mock() + settings.get.side_effect = lambda key, default=None: { + "nick": "Tester", + "quality": "320", + }.get(key, default) + context = Mock(settings=settings) + context.services.media = Mock() + provider = Mock() + provider.is_logged_in.return_value = True + provider.get_top_lists.return_value = [] + provider.get_top_list_tracks.return_value = [] + provider.get_recommendations.return_value = [] + provider.get_favorites.return_value = [] + provider.search.return_value = { + "albums": [{"mid": "album-1", "name": "Album 1", "singer_name": "Singer 1"}], + "total": 1, + "page": 1, + "page_size": 30, + } + provider.get_album_detail.return_value = { + "title": "Album 1", + "songs": [{"mid": "song-1", "title": "Song 1", "artist": "Singer 1", "album": "Album 1"}], + "company": "QQ Music", + "language": "国语", + "album_type": "录音室专辑", + } + + view = QQMusicRootView(context, provider) + qtbot.addWidget(view) + + view._search_input.setText("Album 1") + view._search_type_tabs.setCurrentIndex(2) + view._run_search() + view._open_album_detail(view._albums_list.item(0)) + + assert hasattr(view, "_detail_extra_label") + assert "QQ Music" in view._detail_extra_label.text() + assert "国语" in view._detail_extra_label.text() + assert "录音室专辑" in view._detail_extra_label.text() + + +def test_root_view_playlist_detail_uses_tracks_list_view(qtbot): + settings = Mock() + settings.get.side_effect = lambda key, default=None: { + "nick": "Tester", + "quality": "320", + }.get(key, default) + context = Mock(settings=settings) + context.services.media = Mock() + provider = Mock() + provider.is_logged_in.return_value = True + provider.get_top_lists.return_value = [] + provider.get_top_list_tracks.return_value = [] + provider.get_recommendations.return_value = [] + provider.get_favorites.return_value = [] + provider.search.return_value = { + "playlists": [{"id": "playlist-1", "title": "Playlist 1", "creator": "Tester"}], + "total": 1, + "page": 1, + "page_size": 30, + } + provider.get_playlist_detail.return_value = { + "title": "Playlist 1", + "songs": [ + {"mid": "song-1", "title": "Song 1", "artist": "Singer 1", "album": "Album 1"}, + ], + } + + view = QQMusicRootView(context, provider) + qtbot.addWidget(view) + + view._search_input.setText("Playlist 1") + view._search_type_tabs.setCurrentIndex(3) + view._run_search() + view._open_playlist_detail(view._playlists_list.item(0)) + + assert view._detail_tracks_stack.currentWidget() is view._detail_tracks_view + + +def test_root_view_show_hotkey_popup_does_not_sync_fetch_hotkeys_when_history_exists(qtbot): + settings = Mock() + store = {"nick": "", "quality": "320", "search_history": ["林俊杰"]} + settings.get.side_effect = lambda key, default=None: store.get(key, default) + settings.set.side_effect = lambda key, value: store.__setitem__(key, value) + context = Mock(settings=settings) + context.services.media = Mock() + provider = Mock() + provider.is_logged_in.return_value = False + provider.get_top_lists.return_value = [] + provider.get_top_list_tracks.return_value = [] + provider.get_recommendations.return_value = [] + provider.get_favorites.return_value = [] + provider.get_hotkeys.return_value = [{"title": "周杰伦", "query": "周杰伦"}] + provider.complete.return_value = [] + + view = QQMusicRootView(context, provider) + qtbot.addWidget(view) + view._hotkeys_cache = [] + view._hotkeys_list.clear() + view._search_input.setFocus() + initial_calls = provider.get_hotkeys.call_count + + view._show_hotkey_popup() + + assert provider.get_hotkeys.call_count == initial_calls + assert view._hotkey_popup is not None + assert view._hotkey_popup.count() == 1 + + +def test_root_view_favorites_playlist_navigation(qtbot): + settings = Mock() + settings.get.side_effect = lambda key, default=None: { + "nick": "Tester", + "quality": "320", + }.get(key, default) + media = Mock() + context = Mock(settings=settings) + context.services.media = media + provider = Mock() + provider.is_logged_in.return_value = True + provider.get_top_lists.return_value = [] + provider.get_top_list_tracks.return_value = [] + provider.search.return_value = { + "tracks": [{"mid": "song-1", "title": "Song 1", "artist": "Singer 1"}], + "total": 1, + "page": 1, + "page_size": 30, + } + provider.get_recommendations.return_value = [] + provider.get_favorites.return_value = [ + { + "title": "我收藏的歌单", + "count": 1, + "items": [{"id": "pl-1", "title": "Playlist 1", "creator": "Tester", "song_count": 12}], + }, + ] + + view = QQMusicRootView(context, provider) + qtbot.addWidget(view) + view._search_input.setText("abc") + view._run_search() + assert view._search_type_tabs.isHidden() is False + + view._open_favorite_section(view._favorites_list.item(0)) + + assert view._home_stack.currentWidget() is view._results_page + assert view._results_stack.currentWidget() is view._playlists_page + assert view._playlists_list.count() == 1 + assert len(view._playlists_page._items) == 1 + assert view._search_type_tabs.isHidden() is True + + +def test_root_view_collection_album_navigation_loads_visible_grid(qtbot): + settings = Mock() + settings.get.side_effect = lambda key, default=None: { + "nick": "Tester", + "quality": "320", + }.get(key, default) + media = Mock() + context = Mock(settings=settings) + context.services.media = media + provider = Mock() + provider.is_logged_in.return_value = True + provider.get_top_lists.return_value = [] + provider.get_top_list_tracks.return_value = [] + provider.get_recommendations.return_value = [] + provider.get_favorites.return_value = [ + { + "title": "我收藏的专辑", + "count": 1, + "items": [{"mid": "album-1", "title": "Album 1", "singer_name": "Singer 1"}], + }, + ] + + view = QQMusicRootView(context, provider) + qtbot.addWidget(view) + + view._open_favorite_section(view._favorites_list.item(0)) + + assert view._results_stack.currentWidget() is view._albums_page + assert len(view._albums_page._items) == 1 + + +def test_root_view_followed_singers_collection_opens_artist_grid(qtbot): + settings = Mock() + settings.get.side_effect = lambda key, default=None: { + "nick": "Tester", + "quality": "320", + }.get(key, default) + media = Mock() + context = Mock(settings=settings) + context.services.media = media + provider = Mock() + provider.is_logged_in.return_value = True + provider.get_top_lists.return_value = [] + provider.get_top_list_tracks.return_value = [] + provider.get_recommendations.return_value = [] + provider.get_favorites.return_value = [ + { + "title": "我关注的歌手", + "count": 1, + "items": [{"mid": "artist-1", "name": "Singer 1", "fan_count": 10, "cover_url": "http://example/avatar.jpg"}], + }, + ] + + view = QQMusicRootView(context, provider) + qtbot.addWidget(view) + + view._open_favorite_section(view._favorites_list.item(0)) + + assert view._results_stack.currentWidget() is view._artists_page + assert len(view._artists_page._items) == 1 + + +def test_root_view_collection_back_button_returns_home(qtbot): + settings = Mock() + settings.get.side_effect = lambda key, default=None: { + "nick": "Tester", + "quality": "320", + }.get(key, default) + media = Mock() + context = Mock(settings=settings) + context.services.media = media + provider = Mock() + provider.is_logged_in.return_value = True + provider.get_top_lists.return_value = [] + provider.get_top_list_tracks.return_value = [] + provider.search.return_value = { + "tracks": [{"mid": "song-1", "title": "Song 1", "artist": "Singer 1"}], + "total": 1, + "page": 1, + "page_size": 30, + } + provider.get_recommendations.return_value = [] + provider.get_favorites.return_value = [ + { + "title": "我收藏的歌单", + "count": 1, + "items": [{"id": "pl-1", "title": "Playlist 1", "creator": "Tester", "song_count": 12}], + }, + ] + + view = QQMusicRootView(context, provider) + qtbot.addWidget(view) + + view._search_input.setText("abc") + view._run_search() + assert view._search_type_tabs.isHidden() is False + + view._open_favorite_section(view._favorites_list.item(0)) + + assert hasattr(view, "_results_back_btn") + assert view._results_back_btn.isHidden() is False + assert "我收藏的歌单" in view._results_info_label.text() + assert view._search_type_tabs.isHidden() is True + + view._go_back_from_results() + + assert view._home_stack.currentWidget() is view._home_page + assert view._results_back_btn.isHidden() is True + + +def test_root_view_loads_hotkeys_and_completion(qtbot): + settings = Mock() + settings.get.side_effect = lambda key, default=None: { + "nick": "", + "quality": "320", + }.get(key, default) + media = Mock() + context = Mock(settings=settings) + context.services.media = media + provider = Mock() + provider.is_logged_in.return_value = False + provider.get_top_lists.return_value = [] + provider.get_top_list_tracks.return_value = [] + provider.get_recommendations.return_value = [] + provider.get_favorites.return_value = [] + provider.get_hotkeys.return_value = [ + {"title": "周杰伦"}, + {"title": "林俊杰"}, + ] + provider.complete.return_value = [ + {"hint": "周杰伦 晴天"}, + {"hint": "周杰伦 七里香"}, + ] + provider.search_tracks.return_value = [] + + view = QQMusicRootView(context, provider) + qtbot.addWidget(view) + + assert view._hotkeys_list.count() == 2 + + view._update_completion("周杰伦") + model = view._completer.model() + assert model.rowCount() == 2 + + +def test_root_view_async_home_load_refreshes_hotkey_popup_when_search_is_focused(qtbot): + settings = Mock() + settings.get.side_effect = lambda key, default=None: { + "nick": "", + "quality": "320", + "search_history": [], + }.get(key, default) + context = Mock(settings=settings) + context.services.media = Mock() + provider = Mock() + provider.is_logged_in.return_value = False + provider.get_top_lists.return_value = [] + provider.get_top_list_tracks.return_value = [] + provider.get_recommendations.return_value = [] + provider.get_favorites.return_value = [] + provider.get_hotkeys.return_value = [] + provider.complete.return_value = [] + + view = QQMusicRootView(context, provider) + qtbot.addWidget(view) + view.show() + view._search_input.setFocus() + view._show_hotkey_popup() + + payload = { + "top_lists": [], + "top_tracks": [], + "top_tracks_id": "", + "hotkeys": [{"title": "周杰伦", "query": "周杰伦"}], + "history": [], + "favorites": [], + "recommendations": [], + "logged_in": False, + "load_private": False, + } + + view._on_home_sections_loaded(payload) + + assert view._hotkey_popup is not None + assert view._hotkey_popup.isVisible() is True + assert view._hotkey_popup.count() == 1 + + +def test_root_view_completion_is_debounced(qtbot): + settings = Mock() + settings.get.side_effect = lambda key, default=None: { + "nick": "", + "quality": "320", + }.get(key, default) + media = Mock() + context = Mock(settings=settings) + context.services.media = media + provider = Mock() + provider.is_logged_in.return_value = False + provider.get_top_lists.return_value = [] + provider.get_top_list_tracks.return_value = [] + provider.get_recommendations.return_value = [] + provider.get_favorites.return_value = [] + provider.get_hotkeys.return_value = [] + provider.complete.return_value = [{"hint": "周杰伦 晴天"}] + + view = QQMusicRootView(context, provider) + qtbot.addWidget(view) + + view._search_input.setText("周杰伦") + + assert provider.complete.call_count == 0 + qtbot.waitUntil(lambda: provider.complete.call_count == 1) + + +def test_root_view_stale_completion_results_are_ignored(qtbot): + settings = Mock() + settings.get.side_effect = lambda key, default=None: { + "nick": "", + "quality": "320", + }.get(key, default) + media = Mock() + context = Mock(settings=settings) + context.services.media = media + provider = Mock() + provider.is_logged_in.return_value = False + provider.get_top_lists.return_value = [] + provider.get_top_list_tracks.return_value = [] + provider.get_recommendations.return_value = [] + provider.get_favorites.return_value = [] + provider.get_hotkeys.return_value = [] + provider.complete.return_value = [] + + view = QQMusicRootView(context, provider) + qtbot.addWidget(view) + + assert hasattr(view, "_on_completion_ready") + view._completion_request_id = 2 + view._on_completion_ready([{"hint": "old"}], 1) + + model = view._completer.model() + assert model is None or model.rowCount() == 0 + + +def test_root_view_hotkey_popup_shows_and_escape_hides(qtbot): + settings = Mock() + store = {"nick": "", "quality": "320", "search_history": ["林俊杰"]} + settings.get.side_effect = lambda key, default=None: store.get(key, default) + settings.set.side_effect = lambda key, value: store.__setitem__(key, value) + media = Mock() + context = Mock(settings=settings) + context.services.media = media + provider = Mock() + provider.is_logged_in.return_value = False + provider.get_top_lists.return_value = [] + provider.get_top_list_tracks.return_value = [] + provider.get_recommendations.return_value = [] + provider.get_favorites.return_value = [] + provider.get_hotkeys.return_value = [{"title": "周杰伦", "query": "周杰伦"}] + provider.complete.return_value = [] + + view = QQMusicRootView(context, provider) + qtbot.addWidget(view) + view.show() + + assert hasattr(view, "_show_hotkey_popup") + assert hasattr(view, "_on_escape_pressed") + view._show_hotkey_popup() + + assert view._hotkey_popup is not None + assert view._hotkey_popup.isVisible() is True + assert bool(view._hotkey_popup.windowFlags() & Qt.Popup) is False + assert bool(view._hotkey_popup.windowFlags() & Qt.Tool) is False + + view._on_escape_pressed() + + assert view._hotkey_popup.isVisible() is False + + +def test_root_view_focusing_search_after_suppression_shows_hotkey_popup(qtbot): + settings = Mock() + store = {"nick": "", "quality": "320", "search_history": ["林俊杰"]} + settings.get.side_effect = lambda key, default=None: store.get(key, default) + settings.set.side_effect = lambda key, value: store.__setitem__(key, value) + context = Mock(settings=settings) + context.services.media = Mock() + provider = Mock() + provider.is_logged_in.return_value = False + provider.get_top_lists.return_value = [] + provider.get_top_list_tracks.return_value = [] + provider.get_recommendations.return_value = [] + provider.get_favorites.return_value = [] + provider.get_hotkeys.return_value = [{"title": "周杰伦", "query": "周杰伦"}] + provider.complete.return_value = [] + + view = QQMusicRootView(context, provider) + qtbot.addWidget(view) + view.show() + + view._on_app_focus_changed(view._search_input, view._login_btn) + assert view._suppress_hotkey_popup is True + + view._request_search_popup() + + assert view._hotkey_popup is not None + assert view._hotkey_popup.isVisible() is True + + +def test_click_outside_search_clears_focus_and_hides_popup(qtbot): + settings = Mock() + store = {"nick": "", "quality": "320", "search_history": ["林俊杰"]} + settings.get.side_effect = lambda key, default=None: store.get(key, default) + settings.set.side_effect = lambda key, value: store.__setitem__(key, value) + media = Mock() + context = Mock(settings=settings) + context.services.media = media + provider = Mock() + provider.is_logged_in.return_value = False + provider.get_top_lists.return_value = [] + provider.get_top_list_tracks.return_value = [] + provider.get_recommendations.return_value = [] + provider.get_favorites.return_value = [] + provider.get_hotkeys.return_value = [{"title": "周杰伦", "query": "周杰伦"}] + provider.complete.return_value = [] + + view = QQMusicRootView(context, provider) + qtbot.addWidget(view) + view.show() + view._search_input.setFocus() + view._show_hotkey_popup() + + qtbot.waitUntil(lambda: view._hotkey_popup.isVisible()) + + qtbot.mouseClick(view._home_stack, Qt.LeftButton) + + qtbot.waitUntil(lambda: not view._hotkey_popup.isVisible()) + + +def test_search_focus_loss_hides_popup(qtbot): + settings = Mock() + store = {"nick": "", "quality": "320", "search_history": ["林俊杰"]} + settings.get.side_effect = lambda key, default=None: store.get(key, default) + settings.set.side_effect = lambda key, value: store.__setitem__(key, value) + media = Mock() + context = Mock(settings=settings) + context.services.media = media + provider = Mock() + provider.is_logged_in.return_value = False + provider.get_top_lists.return_value = [] + provider.get_top_list_tracks.return_value = [] + provider.get_recommendations.return_value = [] + provider.get_favorites.return_value = [] + provider.get_hotkeys.return_value = [{"title": "周杰伦", "query": "周杰伦"}] + provider.complete.return_value = [] + + view = QQMusicRootView(context, provider) + qtbot.addWidget(view) + view.show() + view._search_input.setFocus() + view._show_hotkey_popup() + + qtbot.waitUntil(lambda: view._hotkey_popup.isVisible()) + view._on_app_focus_changed(view._search_input, view._login_btn) + + qtbot.waitUntil(lambda: not view._hotkey_popup.isVisible()) + + +def test_root_view_clear_search_history_updates_store_and_popup(qtbot): + settings = Mock() + store = {"nick": "", "quality": "320", "search_history": ["林俊杰", "周杰伦"]} + settings.get.side_effect = lambda key, default=None: store.get(key, default) + settings.set.side_effect = lambda key, value: store.__setitem__(key, value) + media = Mock() + context = Mock(settings=settings) + context.services.media = media + provider = Mock() + provider.is_logged_in.return_value = False + provider.get_top_lists.return_value = [] + provider.get_top_list_tracks.return_value = [] + provider.get_recommendations.return_value = [] + provider.get_favorites.return_value = [] + provider.get_hotkeys.return_value = [{"title": "周杰伦", "query": "周杰伦"}] + provider.complete.return_value = [] + + view = QQMusicRootView(context, provider) + qtbot.addWidget(view) + view.show() + view._show_hotkey_popup() + + assert hasattr(view, "_clear_search_history") + view._clear_search_history() + + assert store["search_history"] == [] + assert view._history_list.count() == 0 + assert view._hotkey_popup.count() == 1 + + +def test_root_view_delete_search_history_item_updates_store_and_popup(qtbot): + settings = Mock() + store = {"nick": "", "quality": "320", "search_history": ["林俊杰", "周杰伦"]} + settings.get.side_effect = lambda key, default=None: store.get(key, default) + settings.set.side_effect = lambda key, value: store.__setitem__(key, value) + media = Mock() + context = Mock(settings=settings) + context.services.media = media + provider = Mock() + provider.is_logged_in.return_value = False + provider.get_top_lists.return_value = [] + provider.get_top_list_tracks.return_value = [] + provider.get_recommendations.return_value = [] + provider.get_favorites.return_value = [] + provider.get_hotkeys.return_value = [{"title": "周杰伦", "query": "周杰伦"}] + provider.complete.return_value = [] + + view = QQMusicRootView(context, provider) + qtbot.addWidget(view) + view.show() + view._show_hotkey_popup() + + assert hasattr(view, "_delete_search_history_item") + view._delete_search_history_item("林俊杰") + + assert store["search_history"] == ["周杰伦"] + assert view._history_list.count() == 1 +def test_root_view_records_search_history_and_can_reuse_it(qtbot): + settings = Mock() + store = {} + settings.get.side_effect = lambda key, default=None: store.get(key, default) + settings.set.side_effect = lambda key, value: store.__setitem__(key, value) + media = Mock() + context = Mock(settings=settings) + context.services.media = media + provider = Mock() + provider.is_logged_in.return_value = False + provider.get_top_lists.return_value = [] + provider.get_top_list_tracks.return_value = [] + provider.get_recommendations.return_value = [] + provider.get_favorites.return_value = [] + provider.get_hotkeys.return_value = [] + provider.complete.return_value = [] + provider.search_tracks.return_value = [] + + view = QQMusicRootView(context, provider) + qtbot.addWidget(view) + + view._search_input.setText("周杰伦") + view._run_search() + + assert store["search_history"] == ["周杰伦"] + assert view._history_list.count() == 1 + + view._open_history_search(view._history_list.item(0)) + assert provider.search_tracks.call_count >= 2 + + +def test_root_view_clearing_search_returns_home_sections(qtbot): + settings = Mock() + store = {"nick": "Tester", "quality": "320", "search_history": []} + settings.get.side_effect = lambda key, default=None: store.get(key, default) + settings.set.side_effect = lambda key, value: store.__setitem__(key, value) + context = Mock(settings=settings) + context.services.media = Mock() + provider = Mock() + provider.is_logged_in.return_value = True + provider.get_top_lists.return_value = [] + provider.get_top_list_tracks.return_value = [] + provider.get_recommendations.return_value = [ + { + "id": "guess", + "title": "猜你喜欢", + "subtitle": "1 项", + "cover_url": "", + "items": [{"mid": "song-1"}], + "entry_type": "songs", + }, + ] + provider.get_favorites.return_value = [ + { + "id": "fav_songs", + "title": "我喜欢的歌曲", + "subtitle": "1 首", + "cover_url": "", + "items": [{"mid": "song-1"}], + "entry_type": "songs", + }, + ] + provider.search.return_value = {"tracks": [], "total": 0, "page": 1, "page_size": 30} + + view = QQMusicRootView(context, provider) + qtbot.addWidget(view) + + view._search_input.setText("abc") + view._run_search() + view._on_search_text_changed("") + + assert view._home_stack.currentWidget() is view._home_page + assert view._favorites_section.isHidden() is False + assert view._recommend_section.isHidden() is False + + +def test_root_view_login_toggle_updates_status(monkeypatch, qtbot): + settings = Mock() + state = {"nick": "", "credential": None, "quality": "320"} + settings.get.side_effect = lambda key, default=None: state.get(key, default) + settings.set.side_effect = lambda key, value: state.__setitem__(key, value) + media = Mock() + context = Mock(settings=settings) + context.services.media = media + provider = Mock() + provider.is_logged_in.side_effect = lambda: bool(state["nick"] or state["credential"]) + provider.get_top_lists.return_value = [] + provider.get_top_list_tracks.return_value = [] + provider.get_recommendations.return_value = [] + provider.get_favorites.return_value = [] + provider.get_hotkeys.return_value = [] + provider.complete.return_value = [] + + dialog = Mock() + dialog.exec.side_effect = lambda: state.update({"nick": "Tester", "credential": {"musicid": "1"}}) + monkeypatch.setattr("plugins.builtin.qqmusic.lib.root_view.QQMusicLoginDialog", Mock(return_value=dialog)) + + view = QQMusicRootView(context, provider) + qtbot.addWidget(view) + + view._handle_login_toggle() + assert "Tester" in view._status.text() + + view._handle_login_toggle() + assert state["nick"] == "" + + +def test_root_view_refresh_ui_reloads_sections(qtbot): + settings = Mock() + state = {"nick": "", "quality": "320", "search_history": []} + settings.get.side_effect = lambda key, default=None: state.get(key, default) + settings.set.side_effect = lambda key, value: state.__setitem__(key, value) + context = Mock(settings=settings) + context.services.media = Mock() + provider = Mock() + provider.is_logged_in.side_effect = lambda: bool(state["nick"]) + provider.get_top_lists.return_value = [] + provider.get_top_list_tracks.return_value = [] + provider.get_recommendations.side_effect = lambda: [{"title": "推荐", "subtitle": "1 项", "items": []}] if state["nick"] else [] + provider.get_favorites.side_effect = lambda: [{"title": "收藏", "count": 1, "items": []}] if state["nick"] else [] + provider.get_hotkeys.return_value = [{"title": "周杰伦"}] + provider.complete.return_value = [] + + view = QQMusicRootView(context, provider) + qtbot.addWidget(view) + + state["nick"] = "Tester" + view._favorites_cache = [{"title": "收藏", "count": 1, "items": []}] + view._recommendations_cache = [{"title": "推荐", "subtitle": "1 项", "items": []}] + view.refresh_ui() + + assert "Tester" in view._status.text() + assert view._recommend_section.isHidden() is False + assert view._favorites_section.isHidden() is False + assert view._recommend_group.isHidden() is True + assert view._favorites_group.isHidden() is True + provider.get_recommendations.assert_not_called() + provider.get_favorites.assert_not_called() + + +def test_root_view_applies_theme_styles_on_init(qtbot): + settings = Mock() + settings.get.side_effect = lambda key, default=None: { + "nick": "", + "quality": "320", + "search_history": [], + "ranking_view_mode": "table", + }.get(key, default) + context = Mock(settings=settings) + context.services.media = Mock() + provider = Mock() + provider.is_logged_in.return_value = False + provider.get_top_lists.return_value = [] + provider.get_top_list_tracks.return_value = [] + provider.get_recommendations.return_value = [] + provider.get_favorites.return_value = [] + provider.get_hotkeys.return_value = [] + provider.complete.return_value = [] + + view = QQMusicRootView(context, provider) + qtbot.addWidget(view) + + assert view.styleSheet() + assert view._search_input.styleSheet() + assert view._search_type_tabs.styleSheet() + assert view._results_table.styleSheet() + assert view._top_tracks_table.styleSheet() + assert view._ranking_view_toggle_btn.styleSheet() + assert view._top_list_widget.styleSheet() + assert view._ranking_list_view.styleSheet() + assert view._detail_ui_built is False + assert view._completer.popup().styleSheet() diff --git a/tests/test_services/test_online_adapter.py b/tests/test_services/test_online_adapter.py index bb435f74..16fd2daa 100644 --- a/tests/test_services/test_online_adapter.py +++ b/tests/test_services/test_online_adapter.py @@ -1,6 +1,7 @@ """OnlineMusicAdapter normalization behavior tests.""" -from services.online.adapter import OnlineMusicAdapter +from services.online.adapter import ApiSource, OnlineMusicAdapter +from domain.online_music import SearchType def test_parse_ygking_song_info_list_parses_singers(): @@ -54,3 +55,171 @@ def test_parse_ygking_playlist_detail_parses_songlist(): assert parsed["id"] == "pl-1" assert len(parsed["songs"]) == 1 assert parsed["songs"][0]["mid"] == "song-1" + + +def test_normalize_qqmusic_singer_search_reads_item_singer_body_key(): + raw_data = { + "meta": {"sum": 1}, + "body": { + "item_singer": [ + {"singerMID": "artist-1", "singerName": "Singer 1", "songNum": 12, "albumNum": 3} + ] + }, + } + + result = OnlineMusicAdapter.normalize_search_result( + ApiSource.QQMUSIC, + raw_data, + SearchType.SINGER, + keyword="Singer", + page=1, + page_size=30, + ) + + assert result.total == 1 + assert len(result.artists) == 1 + assert result.artists[0].mid == "artist-1" + + +def test_normalize_qqmusic_singer_search_accepts_legacy_singer_body_key(): + raw_data = { + "meta": {"sum": 1}, + "body": { + "singer": [ + {"singerMID": "artist-legacy", "singerName": "Legacy Singer", "songNum": 6, "albumNum": 1} + ] + }, + } + + result = OnlineMusicAdapter.normalize_search_result( + ApiSource.QQMUSIC, + raw_data, + SearchType.SINGER, + keyword="Singer", + page=1, + page_size=30, + ) + + assert len(result.artists) == 1 + assert result.artists[0].mid == "artist-legacy" + + +def test_normalize_qqmusic_singer_search_accepts_mid_name_fields(): + raw_data = { + "meta": {"sum": 1}, + "body": { + "item_singer": [ + {"mid": "artist-2", "name": "Singer 2", "song_count": 8, "album_count": 2} + ] + }, + } + + result = OnlineMusicAdapter.normalize_search_result( + ApiSource.QQMUSIC, + raw_data, + SearchType.SINGER, + keyword="Singer", + page=1, + page_size=30, + ) + + assert len(result.artists) == 1 + assert result.artists[0].mid == "artist-2" + assert result.artists[0].name == "Singer 2" + + +def test_normalize_qqmusic_singer_search_builds_avatar_and_counts_from_fallback_fields(): + raw_data = { + "meta": {"sum": 1}, + "body": { + "item_singer": [ + {"mid": "artist-3", "name": "Singer 3", "songnum": 18, "albumnum": 4, "FanNum": 12345} + ] + }, + } + + result = OnlineMusicAdapter.normalize_search_result( + ApiSource.QQMUSIC, + raw_data, + SearchType.SINGER, + keyword="Singer", + page=1, + page_size=30, + ) + + assert len(result.artists) == 1 + assert result.artists[0].avatar_url.endswith("T001R300x300M000artist-3.jpg") + assert result.artists[0].song_count == 18 + assert result.artists[0].album_count == 4 + assert result.artists[0].fan_count == 12345 + + +def test_normalize_qqmusic_playlist_search_accepts_legacy_songlist_body_key(): + raw_data = { + "meta": {"sum": 1}, + "body": { + "songlist": [ + {"dissid": "playlist-legacy", "dissname": "Legacy Playlist", "songnum": 9, "listennum": 123} + ] + }, + } + + result = OnlineMusicAdapter.normalize_search_result( + ApiSource.QQMUSIC, + raw_data, + SearchType.PLAYLIST, + keyword="Playlist", + page=1, + page_size=30, + ) + + assert len(result.playlists) == 1 + assert result.playlists[0].id == "playlist-legacy" + + +def test_normalize_qqmusic_album_search_reads_item_album_body_key(): + raw_data = { + "meta": {"sum": 1}, + "body": { + "item_album": [ + {"albummid": "album-1", "name": "Album 1", "singer": "Singer 1", "song_count": 8} + ] + }, + } + + result = OnlineMusicAdapter.normalize_search_result( + ApiSource.QQMUSIC, + raw_data, + SearchType.ALBUM, + keyword="Album", + page=1, + page_size=30, + ) + + assert result.total == 1 + assert len(result.albums) == 1 + assert result.albums[0].mid == "album-1" + + +def test_normalize_qqmusic_playlist_search_reads_item_songlist_body_key(): + raw_data = { + "meta": {"sum": 1}, + "body": { + "item_songlist": [ + {"dissid": "playlist-1", "dissname": "Playlist 1", "song_count": 16, "play_count": 200} + ] + }, + } + + result = OnlineMusicAdapter.normalize_search_result( + ApiSource.QQMUSIC, + raw_data, + SearchType.PLAYLIST, + keyword="Playlist", + page=1, + page_size=30, + ) + + assert result.total == 1 + assert len(result.playlists) == 1 + assert result.playlists[0].id == "playlist-1" diff --git a/tests/test_services/test_qqmusic_verify_login.py b/tests/test_services/test_qqmusic_verify_login.py new file mode 100644 index 00000000..cbde819d --- /dev/null +++ b/tests/test_services/test_qqmusic_verify_login.py @@ -0,0 +1,18 @@ +from unittest.mock import Mock + +from plugins.builtin.qqmusic.lib.legacy.client import QQMusicClient + + +def test_verify_login_accepts_hostname_when_profile_request_succeeds(monkeypatch): + client = QQMusicClient({"musicid": "1", "musickey": "secret"}) + monkeypatch.setattr( + client, + "_make_request", + Mock(return_value={"code": 0, "data": {"hostname": "Tester"}}), + ) + monkeypatch.setattr(client, "_verify_login_fallback", Mock()) + + result = client.verify_login() + + assert result["valid"] is True + assert result["nick"] == "Tester" diff --git a/tests/test_system/test_harmony_plugin_api_package.py b/tests/test_system/test_harmony_plugin_api_package.py new file mode 100644 index 00000000..b0307cf2 --- /dev/null +++ b/tests/test_system/test_harmony_plugin_api_package.py @@ -0,0 +1,68 @@ +from __future__ import annotations + +import ast +import importlib.util +from pathlib import Path + + +PACKAGE_ROOT = Path("packages/harmony-plugin-api") +PACKAGE_SRC = PACKAGE_ROOT / "src" / "harmony_plugin_api" +FORBIDDEN_ROOT_IMPORTS = { + "app", + "domain", + "services", + "repositories", + "infrastructure", + "system", + "ui", +} + + +def test_harmony_plugin_api_package_has_standalone_pyproject(): + pyproject = PACKAGE_ROOT / "pyproject.toml" + + assert pyproject.exists() + content = pyproject.read_text(encoding="utf-8") + assert 'name = "harmony-plugin-api"' in content + assert 'version = "0.1.0"' in content + + +def test_harmony_plugin_api_package_excludes_host_runtime_modules(): + assert PACKAGE_SRC.exists() + assert (PACKAGE_SRC / "context.py").exists() + assert not (PACKAGE_SRC / "ui.py").exists() + assert not (PACKAGE_SRC / "runtime.py").exists() + + +def test_harmony_plugin_api_package_has_no_host_imports(): + assert PACKAGE_SRC.exists() + + violations: list[tuple[Path, list[str]]] = [] + for py_file in PACKAGE_SRC.rglob("*.py"): + tree = ast.parse(py_file.read_text(encoding="utf-8"), filename=str(py_file)) + for node in ast.walk(tree): + names = None + if isinstance(node, ast.Import): + names = [alias.name.split(".")[0] for alias in node.names] + elif isinstance(node, ast.ImportFrom): + if node.level and node.level > 0: + continue + if node.module: + names = [node.module.split(".")[0]] + if names and any(name in FORBIDDEN_ROOT_IMPORTS for name in names): + violations.append((py_file, names)) + + assert violations == [] + + +def test_harmony_plugin_api_package_can_be_built(): + dist_dir = PACKAGE_ROOT / "dist" + assert any(path.suffix == ".whl" for path in dist_dir.glob("*.whl")) + + +def test_runtime_import_resolves_to_installed_harmony_plugin_api(): + spec = importlib.util.find_spec("harmony_plugin_api") + + assert spec is not None + assert spec.origin is not None + assert "site-packages/harmony_plugin_api/__init__.py" in spec.origin diff --git a/tests/test_system/test_plugin_import_guard.py b/tests/test_system/test_plugin_import_guard.py index db67fb92..c6fc328c 100644 --- a/tests/test_system/test_plugin_import_guard.py +++ b/tests/test_system/test_plugin_import_guard.py @@ -1,6 +1,11 @@ +import json from pathlib import Path +import pytest + +from system.plugins.errors import PluginLoadError from system.plugins.installer import audit_plugin_imports +from system.plugins.loader import PluginLoader def test_plugin_import_audit_allows_sdk_only_imports(tmp_path: Path): @@ -16,3 +21,63 @@ def test_plugin_import_audit_allows_sdk_only_imports(tmp_path: Path): def test_builtin_qqmusic_plugin_passes_import_audit(): audit_plugin_imports(Path("plugins/builtin/qqmusic")) + + +def test_plugin_import_audit_rejects_host_imports(tmp_path: Path): + plugin_root = tmp_path / "bad_imports" + plugin_root.mkdir() + (plugin_root / "plugin_main.py").write_text( + "from ui.dialogs.message_dialog import MessageDialog\n", + encoding="utf-8", + ) + + with pytest.raises(Exception): + audit_plugin_imports(plugin_root) + + +def test_runtime_import_guard_rejects_host_module_import(tmp_path: Path): + plugin_root = tmp_path / "bad_runtime" + plugin_root.mkdir() + (plugin_root / "plugin.json").write_text( + json.dumps( + { + "id": "bad-runtime", + "name": "Bad Runtime", + "version": "1.0.0", + "api_version": "1", + "entrypoint": "plugin_main.py", + "entry_class": "BadRuntimePlugin", + "capabilities": ["sidebar"], + "min_app_version": "0.1.0", + } + ), + encoding="utf-8", + ) + (plugin_root / "plugin_main.py").write_text( + "from ui.dialogs.message_dialog import MessageDialog\n" + "\n" + "class BadRuntimePlugin:\n" + " plugin_id = 'bad-runtime'\n" + " def register(self, context):\n" + " return None\n" + " def unregister(self, context):\n" + " return None\n", + encoding="utf-8", + ) + + with pytest.raises(PluginLoadError): + PluginLoader().load_plugin(plugin_root) + + +def test_qqmusic_ui_modules_do_not_import_sdk_runtime_modules_directly(): + plugin_files = [ + Path("plugins/builtin/qqmusic/lib/dialog_title_bar.py"), + Path("plugins/builtin/qqmusic/lib/login_dialog.py"), + Path("plugins/builtin/qqmusic/lib/settings_tab.py"), + Path("plugins/builtin/qqmusic/lib/runtime_bridge.py"), + ] + + for path in plugin_files: + source = path.read_text(encoding="utf-8") + assert "from harmony_plugin_api.ui import" not in source + assert "from harmony_plugin_api.runtime import" not in source diff --git a/tests/test_system/test_plugin_manager.py b/tests/test_system/test_plugin_manager.py index 039074f5..7dcecd60 100644 --- a/tests/test_system/test_plugin_manager.py +++ b/tests/test_system/test_plugin_manager.py @@ -134,6 +134,56 @@ def test_manager_skips_import_for_disabled_external_plugin(tmp_path: Path): assert state["enabled"] is False +def test_manager_can_toggle_plugin_enabled_state_without_loading(tmp_path: Path): + builtin_root = tmp_path / "builtin" + plugin_root = builtin_root / "qqmusic" + plugin_root.mkdir(parents=True) + + (plugin_root / "plugin.json").write_text( + json.dumps( + { + "id": "qqmusic", + "name": "QQ Music", + "version": "1.0.0", + "api_version": "1", + "entrypoint": "plugin_main.py", + "entry_class": "QQMusicPlugin", + "capabilities": ["sidebar"], + "min_app_version": "0.1.0", + } + ), + encoding="utf-8", + ) + (plugin_root / "plugin_main.py").write_text( + "class QQMusicPlugin:\n" + " plugin_id = 'qqmusic'\n" + " def register(self, context):\n" + " pass\n" + " def unregister(self, context):\n" + " pass\n", + encoding="utf-8", + ) + + store = PluginStateStore(tmp_path / "state.json") + manager = PluginManager( + builtin_root=builtin_root, + external_root=tmp_path / "external", + state_store=store, + context_factory=_ContextFactory(), + ) + + manager.set_plugin_enabled("qqmusic", False) + disabled_state = store.get("qqmusic") + manager.set_plugin_enabled("qqmusic", True) + enabled_state = store.get("qqmusic") + + assert disabled_state is not None + assert disabled_state["enabled"] is False + assert enabled_state is not None + assert enabled_state["enabled"] is True + assert enabled_state["version"] == "1.0.0" + + def test_manager_loads_plugin_with_relative_import(tmp_path: Path): builtin_root = tmp_path / "builtin" plugin_root = builtin_root / "relative" diff --git a/tests/test_system/test_plugin_online_bridge.py b/tests/test_system/test_plugin_online_bridge.py index 0722ac73..2f9ea374 100644 --- a/tests/test_system/test_plugin_online_bridge.py +++ b/tests/test_system/test_plugin_online_bridge.py @@ -4,6 +4,7 @@ from harmony_plugin_api.media import PluginPlaybackRequest from harmony_plugin_api.registry_types import SettingsTabSpec, SidebarEntrySpec from system.plugins.host_services import ( + BootstrapPluginContextFactory, PluginServiceBridgeImpl, PluginSettingsBridgeImpl, PluginStorageBridgeImpl, @@ -25,6 +26,55 @@ def test_plugin_settings_bridge_namespaces_keys(): config.set.assert_called_once_with("plugins.qqmusic.quality", "320") +def test_plugin_settings_bridge_uses_secret_store_for_credentials(): + config = Mock() + config.get_plugin_secret.return_value = '{"musicid":"1"}' + bridge = PluginSettingsBridgeImpl("qqmusic", config) + + assert bridge.get("credential") == {"musicid": "1"} + config.get_plugin_secret.assert_called_once_with("qqmusic", "credential", None) + + bridge.set("credential", {"musicid": "2"}) + config.set_plugin_secret.assert_called_once_with("qqmusic", "credential", '{"musicid": "2"}') + + +def test_plugin_settings_bridge_namespaces_language_key(): + config = Mock() + config.get.return_value = "zh" + bridge = PluginSettingsBridgeImpl("qqmusic", config) + + assert bridge.get("language") == "zh" + config.get.assert_called_once_with("plugins.qqmusic.language", None) + + bridge.set("language", "en") + config.set.assert_called_once_with("plugins.qqmusic.language", "en") + + +def test_bootstrap_plugin_context_factory_uses_existing_manager_without_reentry(tmp_path: Path): + registry = PluginRegistry() + + class _Bootstrap: + def __init__(self): + self._plugin_manager = Mock(registry=registry) + self.online_download_service = Mock() + self.playback_service = Mock() + self.library_service = Mock() + self.http_client = Mock() + self.event_bus = Mock() + self.config = Mock() + + @property + def plugin_manager(self): + raise AssertionError("plugin_manager property should not be re-entered") + + manifest = Mock(id="qqmusic") + factory = BootstrapPluginContextFactory(_Bootstrap(), tmp_path) + + context = factory.build(manifest) + + assert context.plugin_id == "qqmusic" + + def test_plugin_storage_bridge_creates_private_directories(tmp_path: Path): bridge = PluginStorageBridgeImpl(tmp_path, "qqmusic") @@ -86,6 +136,7 @@ def test_plugin_service_bridge_registers_sources_and_exposes_media(): def test_media_bridge_passes_explicit_quality_to_download_service(): download_service = Mock() playback_service = Mock() + playback_service.engine = Mock() library_service = Mock() bridge = PluginMediaBridge(download_service, playback_service, library_service) request = PluginPlaybackRequest( @@ -120,3 +171,62 @@ def test_media_bridge_passes_explicit_quality_to_download_service(): 180.0, "https://example.com/cover.jpg", ) + + +def test_media_bridge_can_play_online_track(): + download_service = Mock() + download_service.is_cached.return_value = False + playback_service = Mock() + playback_service.engine = Mock() + library_service = Mock() + library_service.add_online_track.return_value = 42 + bridge = PluginMediaBridge(download_service, playback_service, library_service) + request = PluginPlaybackRequest( + provider_id="qqmusic", + track_id="mid-1", + title="Song 1", + quality="flac", + metadata={ + "title": "Song 1", + "artist": "Singer 1", + "album": "Album 1", + "duration": 180.0, + "cover_url": "https://example.com/cover.jpg", + }, + ) + + bridge.play_online_track(request) + + playback_service.engine.load_playlist_items.assert_called_once() + playback_service.engine.play.assert_called_once_with() + playback_service.save_queue.assert_called_once_with() + + +def test_media_bridge_can_add_and_insert_online_track_to_queue(): + download_service = Mock() + download_service.is_cached.return_value = False + playback_service = Mock() + playback_service.engine = Mock() + playback_service.engine.current_index = 3 + library_service = Mock() + library_service.add_online_track.return_value = 42 + bridge = PluginMediaBridge(download_service, playback_service, library_service) + request = PluginPlaybackRequest( + provider_id="qqmusic", + track_id="mid-1", + title="Song 1", + quality="320", + metadata={ + "title": "Song 1", + "artist": "Singer 1", + "album": "Album 1", + "duration": 180.0, + }, + ) + + bridge.add_online_track_to_queue(request) + bridge.insert_online_track_to_queue(request) + + assert playback_service.engine.add_track.call_count == 1 + playback_service.engine.insert_track.assert_called_once() + assert playback_service._schedule_save_queue.call_count == 2 diff --git a/tests/test_system/test_plugin_ui_bridge.py b/tests/test_system/test_plugin_ui_bridge.py new file mode 100644 index 00000000..c910adb5 --- /dev/null +++ b/tests/test_system/test_plugin_ui_bridge.py @@ -0,0 +1,88 @@ +from pathlib import Path +from types import SimpleNamespace +from unittest.mock import Mock + +from harmony_plugin_api.manifest import PluginManifest +from system.theme import ThemeManager +from system.plugins.host_services import BootstrapPluginContextFactory + + +def test_plugin_context_ui_bridge_exposes_theme_and_dialog_helpers(tmp_path: Path): + config = Mock() + config.get.return_value = "dark" + config.get_language.return_value = "zh" + + ThemeManager._instance = None + ThemeManager.instance(config) + + registry = Mock() + bootstrap = SimpleNamespace( + _plugin_manager=SimpleNamespace(registry=registry), + online_download_service=Mock(), + playback_service=Mock(), + library_service=Mock(), + http_client=Mock(), + event_bus=Mock(), + config=config, + ) + manifest = PluginManifest.from_dict( + { + "id": "qqmusic", + "name": "QQ Music", + "version": "1.0.0", + "api_version": "1", + "entrypoint": "plugin_main.py", + "entry_class": "QQMusicPlugin", + "capabilities": ["sidebar"], + "min_app_version": "0.1.0", + } + ) + + context = BootstrapPluginContextFactory(bootstrap, tmp_path).build(manifest) + + assert callable(context.ui.register_sidebar_entry) + assert callable(context.ui.register_settings_tab) + assert callable(context.ui.theme.get_qss) + assert callable(context.ui.theme.register_widget) + assert context.ui.theme.current_theme().text + assert callable(context.ui.dialogs.information) + assert callable(context.ui.dialogs.warning) + assert callable(context.ui.dialogs.question) + assert callable(context.ui.dialogs.critical) + assert callable(context.ui.dialogs.setup_title_bar) + + +def test_plugin_context_ui_bridge_uses_host_bridge_modules(tmp_path: Path): + config = Mock() + config.get.return_value = "dark" + config.get_language.return_value = "zh" + + ThemeManager._instance = None + ThemeManager.instance(config) + + bootstrap = SimpleNamespace( + _plugin_manager=SimpleNamespace(registry=Mock()), + online_download_service=Mock(), + playback_service=Mock(), + library_service=Mock(), + http_client=Mock(), + event_bus=Mock(), + config=config, + ) + manifest = PluginManifest.from_dict( + { + "id": "qqmusic", + "name": "QQ Music", + "version": "1.0.0", + "api_version": "1", + "entrypoint": "plugin_main.py", + "entry_class": "QQMusicPlugin", + "capabilities": ["sidebar"], + "min_app_version": "0.1.0", + } + ) + + context = BootstrapPluginContextFactory(bootstrap, tmp_path).build(manifest) + + assert context.ui.theme.__class__.__module__ == "system.plugins.plugin_sdk_ui" + assert context.ui.dialogs.__class__.__module__ == "system.plugins.plugin_sdk_ui" diff --git a/tests/test_ui/test_online_music_view_async.py b/tests/test_ui/test_online_music_view_async.py index 609de2e6..13ee885e 100644 --- a/tests/test_ui/test_online_music_view_async.py +++ b/tests/test_ui/test_online_music_view_async.py @@ -3,6 +3,7 @@ from unittest.mock import Mock, patch from domain.online_music import OnlineTrack, SearchResult, SearchType +from plugins.builtin.qqmusic.lib import i18n as plugin_i18n from ui.views.online_music_view import OnlineMusicView import ui.views.online_music_view as online_music_view @@ -169,18 +170,27 @@ def test_on_login_clicked_clears_plugin_namespaced_credential(): def test_show_login_dialog_uses_plugin_local_dialog(monkeypatch): + class _Signal: + def __init__(self): + self.connected = None + + def connect(self, callback): + self.connected = callback + view = OnlineMusicView.__new__(OnlineMusicView) view._on_credentials_obtained = Mock() dialog = Mock() + dialog.credentials_obtained = _Signal() dialog_ctor = Mock(return_value=dialog) monkeypatch.setattr( - "plugins.builtin.qqmusic.lib.login_dialog.QQMusicLoginDialog", + "plugins.builtin.qqmusic.lib.online_music_view.create_qqmusic_login_dialog", dialog_ctor, ) OnlineMusicView._show_login_dialog(view) - dialog_ctor.assert_called_once_with(view) + dialog_ctor.assert_called_once_with(None, view) + assert dialog.credentials_obtained.connected == view._on_credentials_obtained dialog.exec.assert_called_once_with() @@ -196,8 +206,10 @@ class _FakeQQMusicService: def __init__(self, credential): self.credential = credential - monkeypatch.setattr("ui.views.online_music_view.QQMusicService", _FakeQQMusicService, raising=False) - monkeypatch.setattr("plugins.builtin.qqmusic.lib.legacy.qqmusic_service.QQMusicService", _FakeQQMusicService) + monkeypatch.setattr( + "system.plugins.qqmusic_runtime_helpers.create_qqmusic_service", + lambda credential: _FakeQQMusicService(credential), + ) OnlineMusicView._refresh_qqmusic_service(view) @@ -205,6 +217,55 @@ def __init__(self, credential): assert view._qqmusic_service.credential["musicid"] == "1" +def test_online_music_view_syncs_plugin_language_from_context_events(qtbot): + plugin_i18n.set_language("en") + theme_manager = Mock() + theme = Mock() + theme.background = "#101010" + theme.background_alt = "#1a1a1a" + theme.background_hover = "#202020" + theme.text = "#ffffff" + theme.text_secondary = "#b3b3b3" + theme.highlight = "#1db954" + theme.highlight_hover = "#1ed760" + theme.border = "#404040" + theme_manager.current_theme = theme + theme_manager.get_qss.side_effect = lambda qss: qss + theme_manager.register_widget = Mock() + config = Mock() + config.get_plugin_secret.return_value = "" + config.get.side_effect = lambda key, default=None: { + "view/ranking_view_mode": "table", + }.get(key, default) + config.get_search_history.return_value = [] + config.get_online_music_download_dir.return_value = "data/online_cache" + + class _Signal: + def __init__(self): + self._callbacks = [] + + def connect(self, cb): + self._callbacks.append(cb) + + def emit(self, value): + for cb in list(self._callbacks): + cb(value) + + events = Mock() + events.language_changed = _Signal() + context = Mock(language="zh", events=events) + + with patch("system.theme.ThemeManager.instance", return_value=theme_manager): + view = OnlineMusicView(config_manager=config, qqmusic_service=None, plugin_context=context) + qtbot.addWidget(view) + + assert plugin_i18n.get_language() == "zh" + + events.language_changed.emit("en") + + assert plugin_i18n.get_language() == "en" + + class _FakeSignal: def __init__(self): self.connected = None @@ -269,6 +330,58 @@ def test_load_top_lists_stops_existing_worker_cooperatively(): assert new_worker.started is True +def test_show_login_dialog_passes_plugin_context_and_refresh_callback(monkeypatch): + class _Signal: + def __init__(self): + self.connected = None + + def connect(self, callback): + self.connected = callback + + dialog = Mock() + dialog.credentials_obtained = _Signal() + create_dialog = Mock(return_value=dialog) + monkeypatch.setattr( + "plugins.builtin.qqmusic.lib.online_music_view.create_qqmusic_login_dialog", + create_dialog, + ) + + view = OnlineMusicView.__new__(OnlineMusicView) + view._plugin_context = "plugin-context" + view._on_credentials_obtained = Mock() + + OnlineMusicView._show_login_dialog(view) + + create_dialog.assert_called_once() + args, kwargs = create_dialog.call_args + assert args[0] == "plugin-context" + assert args[1] is view + assert kwargs == {} + assert dialog.credentials_obtained.connected == view._on_credentials_obtained + dialog.exec.assert_called_once_with() + + +def test_on_credentials_obtained_fetches_missing_nick_from_service(): + view = OnlineMusicView.__new__(OnlineMusicView) + view._plugin_context = Mock() + view._config = Mock() + view._config.get_plugin_setting.return_value = "" + view._refresh_qqmusic_service = Mock() + view._update_login_status = Mock() + view._load_favorites = Mock() + view._service = Mock() + view._service.client.verify_login.return_value = {"valid": True, "nick": "Tester", "uin": 1} + view._fav_loaded = True + + OnlineMusicView._on_credentials_obtained(view, {"musicid": "1", "musickey": "secret"}) + + view._config.set_plugin_setting.assert_any_call("qqmusic", "nick", "Tester") + assert view._fav_loaded is False + view._refresh_qqmusic_service.assert_called_once_with() + view._update_login_status.assert_called_once_with() + view._load_favorites.assert_called_once_with() + + def test_build_track_metadata_uses_unified_fields(): """Track metadata helper should populate standard online playback fields.""" from domain.online_music import AlbumInfo, OnlineSinger diff --git a/tests/test_ui/test_online_tracks_list_view.py b/tests/test_ui/test_online_tracks_list_view.py index 17cbf1d2..8b9e4b81 100644 --- a/tests/test_ui/test_online_tracks_list_view.py +++ b/tests/test_ui/test_online_tracks_list_view.py @@ -8,6 +8,7 @@ from PySide6.QtWidgets import QApplication from domain.online_music import OnlineTrack +import ui.views.online_tracks_list_view as online_tracks_list_view from ui.views.online_tracks_list_view import OnlineTracksListView @@ -89,3 +90,25 @@ def test_online_tracks_handle_mouse_leave_is_idempotent_when_idle(): view._cover_popup.schedule_hide.assert_not_called() view.close() app.processEvents() + + +def test_online_tracks_cover_resolution_uses_existing_cover_service_only(monkeypatch): + """Background cover workers must not bootstrap host services from scratch.""" + + class _BootstrapStub: + def __init__(self): + self._cover_service = None + self.cover_service_accessed = False + + @property + def cover_service(self): + self.cover_service_accessed = True + raise RuntimeError("cover_service should not be initialized in worker") + + bootstrap = _BootstrapStub() + monkeypatch.setattr("app.bootstrap.Bootstrap.instance", lambda: bootstrap) + + track = OnlineTrack(mid="mid-1", title="Song", duration=180) + + assert online_tracks_list_view._resolve_online_cover_path(track) is None + assert bootstrap.cover_service_accessed is False diff --git a/tests/test_ui/test_plugin_settings_tab.py b/tests/test_ui/test_plugin_settings_tab.py index a3740d6e..2306b5bd 100644 --- a/tests/test_ui/test_plugin_settings_tab.py +++ b/tests/test_ui/test_plugin_settings_tab.py @@ -1,10 +1,43 @@ from unittest.mock import Mock -from PySide6.QtWidgets import QTabWidget +from PySide6.QtWidgets import QTabWidget, QWidget +from plugins.builtin.qqmusic.lib.login_dialog import QQMusicLoginDialog +from plugins.builtin.qqmusic.lib.settings_tab import QQMusicSettingsTab from system.theme import ThemeManager from ui.dialogs.plugin_management_tab import PluginManagementTab from ui.dialogs.settings_dialog import GeneralSettingsDialog +from plugins.builtin.qqmusic.lib import i18n as plugin_i18n + + +class _Signal: + def connect(self, _callback): + return None + + +def _build_plugin_context(settings: Mock) -> Mock: + theme = type( + "Theme", + (), + { + "background": "#101010", + "background_alt": "#1a1a1a", + "background_hover": "#202020", + "text": "#ffffff", + "text_secondary": "#999999", + "highlight": "#1db954", + "highlight_hover": "#1ed760", + "selection": "#333333", + "border": "#404040", + }, + )() + ui = Mock() + ui.theme.get_qss.side_effect = lambda template: template + ui.theme.current_theme.return_value = theme + ui.theme.register_widget = Mock() + ui.dialogs = Mock() + events = Mock(language_changed=_Signal()) + return Mock(settings=settings, ui=ui, events=events, language="zh") def test_plugin_management_tab_shows_plugin_rows(qtbot): @@ -31,7 +64,43 @@ def test_plugin_management_tab_shows_plugin_rows(qtbot): widget = PluginManagementTab(manager) qtbot.addWidget(widget) - assert widget._table.rowCount() == 2 + assert widget._list.count() == 2 + assert "qqmusic" not in widget._list.item(1).text().lower() + assert "QQ Music" in widget._list.item(1).text() + + +def test_plugin_management_tab_can_toggle_selected_plugin_enabled_state(qtbot): + manager = Mock() + manager.list_plugins.return_value = [ + { + "id": "qqmusic", + "name": "QQ Music", + "version": "1.0.0", + "source": "builtin", + "enabled": True, + "load_error": None, + }, + { + "id": "lrclib", + "name": "LRCLIB", + "version": "1.0.0", + "source": "builtin", + "enabled": False, + "load_error": None, + }, + ] + + widget = PluginManagementTab(manager) + qtbot.addWidget(widget) + + widget._list.setCurrentRow(0) + widget._disable_btn.click() + widget._list.setCurrentRow(1) + widget._enable_btn.click() + + manager.set_plugin_enabled.assert_any_call("qqmusic", False) + manager.set_plugin_enabled.assert_any_call("lrclib", True) + assert manager.list_plugins.call_count >= 3 def test_settings_dialog_includes_plugins_tab(monkeypatch, qtbot): @@ -123,6 +192,7 @@ def test_settings_dialog_with_real_builtins_includes_plugin_tabs(monkeypatch, qt config.get_cache_cleanup_count.return_value = 100 config.get_cache_cleanup_interval_hours.return_value = 1 config.get_audio_engine.return_value = "mpv" + config.get_language.return_value = "zh" config.get_plugin_setting.side_effect = lambda plugin_id, key, default=None: default config.get_plugin_secret.side_effect = lambda plugin_id, key, default="": default @@ -134,6 +204,55 @@ def test_settings_dialog_with_real_builtins_includes_plugin_tabs(monkeypatch, qt bootstrap._library_service = Mock() bootstrap._online_download_service = Mock() + plugin_i18n.set_language("zh") + + monkeypatch.setattr("ui.dialogs.settings_dialog.Bootstrap.instance", lambda: bootstrap) + ThemeManager._instance = None + ThemeManager.instance(config) + + dialog = GeneralSettingsDialog(config) + qtbot.addWidget(dialog) + tab_widget = dialog.findChild(QTabWidget) + + tab_labels = [tab_widget.tabText(index) for index in range(tab_widget.count())] + assert "QQ 音乐" in tab_labels + + +def test_settings_dialog_uses_plugin_title_provider(monkeypatch, qtbot): + config = Mock() + config.get.return_value = "dark" + config.get_ai_enabled.return_value = False + config.get_ai_base_url.return_value = "" + config.get_ai_api_key.return_value = "" + config.get_ai_model.return_value = "" + config.get_acoustid_enabled.return_value = False + config.get_acoustid_api_key.return_value = "" + config.get_online_music_download_dir.return_value = "data/online_cache" + config.get_cache_cleanup_strategy.return_value = "manual" + config.get_cache_cleanup_auto_enabled.return_value = False + config.get_cache_cleanup_time_days.return_value = 30 + config.get_cache_cleanup_size_mb.return_value = 1000 + config.get_cache_cleanup_count.return_value = 100 + config.get_cache_cleanup_interval_hours.return_value = 1 + config.get_audio_engine.return_value = "mpv" + + fake_manager = Mock() + fake_manager.list_plugins.return_value = [] + fake_manager.registry.settings_tabs.return_value = [ + type( + "Spec", + (), + { + "plugin_id": "qqmusic", + "tab_id": "qqmusic.settings", + "title": "QQ Music", + "order": 80, + "title_provider": staticmethod(lambda: "QQ 音乐"), + "widget_factory": staticmethod(lambda _context, parent: QWidget(parent)), + }, + )() + ] + bootstrap = Mock(plugin_manager=fake_manager) monkeypatch.setattr("ui.dialogs.settings_dialog.Bootstrap.instance", lambda: bootstrap) ThemeManager._instance = None ThemeManager.instance(config) @@ -144,3 +263,88 @@ def test_settings_dialog_with_real_builtins_includes_plugin_tabs(monkeypatch, qt tab_labels = [tab_widget.tabText(index) for index in range(tab_widget.count())] assert "QQ 音乐" in tab_labels + + +def test_qqmusic_settings_tab_matches_legacy_sections(qtbot): + settings = Mock() + settings.get.side_effect = lambda key, default=None: { + "quality": "320", + "download_dir": "data/online_cache", + "credential": {"musicid": "12345", "loginType": 2}, + "nick": "Tester", + }.get(key, default) + context = _build_plugin_context(settings) + + widget = QQMusicSettingsTab(context) + qtbot.addWidget(widget) + + assert widget._quality_combo.count() >= 3 + assert widget._download_dir_input.text() == "data/online_cache" + assert widget._qqmusic_qr_btn.isHidden() is False + assert widget._qqmusic_logout_btn.isHidden() is False + assert widget._qqmusic_status_label.text() + assert hasattr(widget, "_open_qqmusic_qr_login") + assert hasattr(widget, "_qqmusic_logout") + + +def test_qqmusic_settings_tab_save_writes_plugin_scoped_settings(qtbot): + settings = Mock() + settings.get.side_effect = lambda key, default=None: { + "quality": "320", + "download_dir": "", + "credential": None, + "nick": "", + }.get(key, default) + context = _build_plugin_context(settings) + + widget = QQMusicSettingsTab(context) + qtbot.addWidget(widget) + widget._download_dir_input.setText("/tmp/music") + widget._quality_combo.setCurrentIndex(1) + widget._save_settings() + + settings.set.assert_any_call("download_dir", "/tmp/music") + settings.set.assert_any_call("quality", widget._quality_combo.currentData()) + + +def test_qqmusic_settings_tab_translates_quality_labels(qtbot): + settings = Mock() + settings.get.side_effect = lambda key, default=None: { + "quality": "320", + "download_dir": "data/online_cache", + "credential": None, + "nick": "", + }.get(key, default) + context = _build_plugin_context(settings) + + widget = QQMusicSettingsTab(context) + qtbot.addWidget(widget) + + assert widget._quality_group.title() != "qqmusic_quality" + assert widget._quality_label.text() != "qqmusic_quality" + assert widget._quality_combo.itemText(0) != "qqmusic_quality_master" + + +def test_qqmusic_settings_tab_keeps_content_padding(qtbot): + settings = Mock() + settings.get.side_effect = lambda key, default=None: { + "quality": "320", + "download_dir": "data/online_cache", + "credential": None, + "nick": "", + }.get(key, default) + context = _build_plugin_context(settings) + + widget = QQMusicSettingsTab(context) + qtbot.addWidget(widget) + + layout = widget._qqmusic_tab.layout() + margins = layout.contentsMargins() + assert margins.left() > 0 + assert margins.top() > 0 + + +def test_qqmusic_login_dialog_uses_dialog_container_selector_and_scoped_button_style(): + assert "QWidget#dialogContainer" in QQMusicLoginDialog._STYLE_TEMPLATE + assert "QWidget#settingsContainer" not in QQMusicLoginDialog._STYLE_TEMPLATE + assert "QPushButton#loginDialogActionBtn" in QQMusicLoginDialog._STYLE_TEMPLATE diff --git a/tests/test_ui/test_plugin_sidebar_integration.py b/tests/test_ui/test_plugin_sidebar_integration.py index cb8a80ca..d7565dcb 100644 --- a/tests/test_ui/test_plugin_sidebar_integration.py +++ b/tests/test_ui/test_plugin_sidebar_integration.py @@ -1,8 +1,12 @@ from unittest.mock import Mock, patch import pytest -from PySide6.QtWidgets import QApplication, QLabel, QMainWindow, QStackedWidget +from PySide6.QtCore import QEvent, Signal +from PySide6.QtGui import QShowEvent +from PySide6.QtWidgets import QApplication, QLabel, QMainWindow, QStackedWidget, QWidget +from plugins.builtin.qqmusic.lib.online_music_view import OnlineMusicView +from plugins.builtin.qqmusic.lib.provider import QQMusicOnlineProvider from system.theme import ThemeManager from ui.windows.components.sidebar import Sidebar from ui.windows.main_window import MainWindow @@ -35,9 +39,92 @@ def test_sidebar_can_add_plugin_entry(qapp, mock_config): ThemeManager.instance(mock_config) sidebar = Sidebar(config_manager=mock_config) - sidebar.add_plugin_entry(page_index=200, title="QQ Music", icon_name="GLOBE") + sidebar.add_plugin_entry(page_index=200, title="QQ Music", icon_name="GLOBE", title_provider=lambda: "QQ 音乐") assert any(index == 200 for index, _button in sidebar._nav_buttons) + plugin_button = next(button for index, button in sidebar._nav_buttons if index == 200) + assert plugin_button.styleSheet() + + +def test_sidebar_refresh_texts_updates_plugin_entry_title_provider(qapp, mock_config): + ThemeManager.instance(mock_config) + sidebar = Sidebar(config_manager=mock_config) + sidebar.add_plugin_entry(page_index=200, title="QQ Music", icon_name="GLOBE", title_provider=lambda: "QQ 音乐") + + sidebar.refresh_texts() + + plugin_button = next(button for index, button in sidebar._nav_buttons if index == 200) + assert plugin_button.text() == "QQ 音乐" + + +def test_sidebar_can_add_plugin_entry_with_custom_icon_path(qapp, mock_config, tmp_path): + ThemeManager.instance(mock_config) + sidebar = Sidebar(config_manager=mock_config) + icon_path = tmp_path / "qqmusic-icon.png" + icon_path.write_bytes(b"not-a-real-png-but-qicon-can-handle-empty") + + sidebar.add_plugin_entry( + page_index=201, + title="QQ Music", + icon_path=str(icon_path), + title_provider=lambda: "QQ 音乐", + ) + + plugin_button = next(button for index, button in sidebar._nav_buttons if index == 201) + assert plugin_button.property("plugin_icon_path") == str(icon_path) + + +def test_sidebar_custom_svg_plugin_icon_updates_when_checked(qapp, mock_config, tmp_path): + ThemeManager.instance(mock_config) + sidebar = Sidebar(config_manager=mock_config) + icon_path = tmp_path / "qqmusic-icon.svg" + icon_path.write_text( + "" + "", + encoding="utf-8", + ) + + sidebar.add_plugin_entry( + page_index=202, + title="QQ Music", + icon_path=str(icon_path), + title_provider=lambda: "QQ 音乐", + ) + + plugin_button = next(button for index, button in sidebar._nav_buttons if index == 202) + default_key = plugin_button.icon().cacheKey() + + plugin_button.setChecked(True) + + assert plugin_button.icon().cacheKey() != default_key + + +def test_sidebar_custom_svg_plugin_icon_updates_on_hover(qapp, mock_config, tmp_path): + ThemeManager.instance(mock_config) + sidebar = Sidebar(config_manager=mock_config) + icon_path = tmp_path / "qqmusic-icon.svg" + icon_path.write_text( + "" + "", + encoding="utf-8", + ) + + sidebar.add_plugin_entry( + page_index=203, + title="QQ Music", + icon_path=str(icon_path), + title_provider=lambda: "QQ 音乐", + ) + + plugin_button = next(button for index, button in sidebar._nav_buttons if index == 203) + default_key = plugin_button.icon().cacheKey() + + QApplication.sendEvent(plugin_button, QEvent(QEvent.Enter)) + + hover_key = plugin_button.icon().cacheKey() + QApplication.sendEvent(plugin_button, QEvent(QEvent.Leave)) + + assert hover_key != default_key def test_main_window_mounts_plugin_pages(qapp, mock_config): @@ -70,3 +157,277 @@ def test_main_window_mounts_plugin_pages(qapp, mock_config): assert "qqmusic" in window._plugin_page_keys.values() assert window._stacked_widget.count() == 1 + + +def test_main_window_prewarms_plugin_page_during_mount(qapp, qtbot, mock_config): + ThemeManager.instance(mock_config) + window = MainWindow.__new__(MainWindow) + QMainWindow.__init__(window) + window._stacked_widget = QStackedWidget() + window._sidebar = Sidebar(config_manager=mock_config) + window._library_view = Mock() + window._plugin_prewarm_timer = None + + page_factory = Mock(return_value=QLabel("QQ Music View")) + bootstrap = Mock() + bootstrap.plugin_manager.registry.sidebar_entries.return_value = [ + type( + "Spec", + (), + { + "plugin_id": "qqmusic", + "entry_id": "qqmusic.sidebar", + "title": "QQ Music", + "order": 80, + "icon_name": "GLOBE", + "icon_path": None, + "page_factory": staticmethod(page_factory), + }, + )() + ] + + with patch("ui.windows.main_window.Bootstrap.instance", return_value=bootstrap): + window._mount_plugin_pages() + assert page_factory.call_count == 1 + + assert window._plugin_pages[0].text() == "QQ Music View" + + +def test_main_window_passes_host_container_to_plugin_page_factory(qapp, mock_config): + ThemeManager.instance(mock_config) + window = MainWindow.__new__(MainWindow) + QMainWindow.__init__(window) + window._stacked_widget = QStackedWidget() + window._sidebar = Sidebar(config_manager=mock_config) + window._plugin_page_loading = set() + window._plugin_pages = {} + + captured = {} + + def _page_factory(_context, parent): + captured["parent"] = parent + return QLabel("QQ Music View") + + spec = type( + "Spec", + (), + { + "plugin_id": "qqmusic", + "entry_id": "qqmusic.sidebar", + "title": "QQ Music", + "order": 80, + "icon_name": "GLOBE", + "icon_path": None, + "page_factory": staticmethod(_page_factory), + }, + )() + + host = QWidget(window) + window._stacked_widget.addWidget(host) + window._plugin_page_specs = {0: spec} + + with patch("ui.windows.main_window.Bootstrap.instance", return_value=Mock(plugin_manager=Mock())): + window._ensure_plugin_page_loaded(0) + + assert captured["parent"] is host + + +def test_main_window_connects_plugin_online_music_signals(qapp, mock_config): + ThemeManager.instance(mock_config) + window = MainWindow.__new__(MainWindow) + QMainWindow.__init__(window) + window._stacked_widget = QStackedWidget() + window._sidebar = Sidebar(config_manager=mock_config) + window._plugin_page_loading = set() + window._plugin_pages = {} + window._play_online_track = Mock() + window._add_online_track_to_queue = Mock() + window._insert_online_track_to_queue = Mock() + window._add_multiple_online_tracks_to_queue = Mock() + window._insert_multiple_online_tracks_to_queue = Mock() + window._play_online_tracks = Mock() + + class _PluginPage(QWidget): + play_online_track = Signal(str, str, object) + add_to_queue = Signal(str, object) + insert_to_queue = Signal(str, object) + add_multiple_to_queue = Signal(list) + insert_multiple_to_queue = Signal(list) + play_online_tracks = Signal(int, list) + + page = _PluginPage() + spec = type( + "Spec", + (), + { + "plugin_id": "qqmusic", + "entry_id": "qqmusic.sidebar", + "title": "QQ Music", + "order": 80, + "icon_name": None, + "icon_path": None, + "page_factory": staticmethod(lambda _context, _parent: page), + }, + )() + + host = QWidget(window) + window._stacked_widget.addWidget(host) + window._plugin_page_specs = {0: spec} + + with patch("ui.windows.main_window.Bootstrap.instance", return_value=Mock(plugin_manager=Mock())): + window._ensure_plugin_page_loaded(0) + + page.play_online_track.emit("mid-1", "/tmp/song.mp3", {"title": "Song 1"}) + page.add_to_queue.emit("mid-2", {"title": "Song 2"}) + page.insert_to_queue.emit("mid-3", {"title": "Song 3"}) + page.add_multiple_to_queue.emit([("mid-4", {"title": "Song 4"})]) + page.insert_multiple_to_queue.emit([("mid-5", {"title": "Song 5"})]) + page.play_online_tracks.emit(0, [("mid-6", {"title": "Song 6"})]) + + window._play_online_track.assert_called_once_with("mid-1", "/tmp/song.mp3", {"title": "Song 1"}) + window._add_online_track_to_queue.assert_called_once_with("mid-2", {"title": "Song 2"}) + window._insert_online_track_to_queue.assert_called_once_with("mid-3", {"title": "Song 3"}) + window._add_multiple_online_tracks_to_queue.assert_called_once_with([("mid-4", {"title": "Song 4"})]) + window._insert_multiple_online_tracks_to_queue.assert_called_once_with([("mid-5", {"title": "Song 5"})]) + window._play_online_tracks.assert_called_once_with(0, [("mid-6", {"title": "Song 6"})]) + + +def test_main_window_materializes_real_qqmusic_plugin_page(qapp, qtbot, mock_config): + ThemeManager.instance(mock_config) + window = MainWindow.__new__(MainWindow) + QMainWindow.__init__(window) + window._stacked_widget = QStackedWidget() + window._sidebar = Sidebar(config_manager=mock_config) + window._plugin_page_loading = set() + window._plugin_pages = {} + + settings = Mock() + store = { + "credential": "", + "quality": "320", + "search_history": [], + "online_music_download_dir": "data/online_cache", + } + settings.get.side_effect = lambda key, default=None: store.get(key, default) + settings.set.side_effect = lambda key, value: store.__setitem__(key, value) + context = Mock(settings=settings, logger=Mock()) + provider = QQMusicOnlineProvider(context) + + spec = type( + "Spec", + (), + { + "plugin_id": "qqmusic", + "entry_id": "qqmusic.sidebar", + "title": "QQ Music", + "order": 80, + "icon_name": None, + "icon_path": None, + "page_factory": staticmethod(lambda _context, parent: provider.create_page(context, parent)), + }, + )() + + host = QWidget(window) + window._stacked_widget.addWidget(host) + window._plugin_page_specs = {0: spec} + + with patch("ui.windows.main_window.Bootstrap.instance", return_value=Mock(plugin_manager=Mock())): + window._ensure_plugin_page_loaded(0) + + page = window._plugin_pages[0] + + assert isinstance(page, OnlineMusicView) + + page.close() + + +def test_main_window_passes_plugin_icon_path_to_sidebar(qapp, mock_config, tmp_path): + ThemeManager.instance(mock_config) + window = MainWindow.__new__(MainWindow) + QMainWindow.__init__(window) + window._stacked_widget = QStackedWidget() + window._sidebar = Mock() + + icon_path = tmp_path / "qqmusic-icon.png" + icon_path.write_bytes(b"png") + + bootstrap = Mock() + bootstrap.plugin_manager.registry.sidebar_entries.return_value = [ + type( + "Spec", + (), + { + "plugin_id": "qqmusic", + "entry_id": "qqmusic.sidebar", + "title": "QQ Music", + "order": 80, + "icon_name": None, + "icon_path": str(icon_path), + "page_factory": staticmethod(lambda _context, _parent: QLabel("QQ Music View")), + }, + )() + ] + + with patch("ui.windows.main_window.Bootstrap.instance", return_value=bootstrap): + window._mount_plugin_pages() + + kwargs = window._sidebar.add_plugin_entry.call_args.kwargs + assert kwargs["icon_path"] == str(icon_path) + + +def test_main_window_refreshes_plugin_pages_with_refresh_ui(qapp, mock_config): + ThemeManager.instance(mock_config) + window = MainWindow.__new__(MainWindow) + QMainWindow.__init__(window) + window._plugin_pages = {10: Mock(refresh_ui=Mock()), 11: QLabel("static")} + window._sidebar = Mock() + window._lyrics_panel = Mock() + window._player_controls = Mock() + window._library_view = Mock() + window._cloud_drive_view = Mock() + window._playlist_view = Mock() + window._queue_view = Mock() + window._albums_view = Mock() + window._artists_view = Mock() + window._artist_view = Mock() + window._album_view = Mock() + window._genres_view = Mock() + window._genre_view = Mock() + window._title_bar = Mock() + window._config = mock_config + window.setWindowTitle = Mock() + + window._refresh_ui_texts() + + window._plugin_pages[10].refresh_ui.assert_called_once_with() + + +def test_main_window_show_event_schedules_plugin_page_prewarm(qapp, mock_config, monkeypatch): + ThemeManager.instance(mock_config) + window = MainWindow.__new__(MainWindow) + QMainWindow.__init__(window) + window._plugin_page_specs = {10: Mock()} + window._plugin_prewarm_scheduled = False + window._plugin_prewarm_timer = None + + class _FakeTimer: + def __init__(self, *_args, **_kwargs): + self.started_with = None + self._timeout = Mock(connect=Mock()) + + @property + def timeout(self): + return self._timeout + + def setSingleShot(self, _value): + return None + + def start(self, delay): + self.started_with = delay + + monkeypatch.setattr("ui.windows.main_window.QTimer", _FakeTimer) + + window.showEvent(QShowEvent()) + + assert window._plugin_prewarm_timer is not None + assert window._plugin_prewarm_timer.started_with == 0 diff --git a/translations/en.json b/translations/en.json index 2770b754..6d6fdd27 100644 --- a/translations/en.json +++ b/translations/en.json @@ -314,85 +314,12 @@ "error": "Error", "ai_tab": "AI Enhancement", "acoustid_tab": "AcoustID", - "qqmusic_tab": "QQ Music", "plugins_tab": "Plugins", "plugins_install_zip": "Install Zip", "plugins_install_url": "Install URL", "plugins_load_error": "Load Error", "plugins_enabled": "Enabled", "plugins_disabled": "Disabled", - "qqmusic_login": "QQ Music Login", - "qqmusic_qr_login": "QR Login", - "qqmusic_manual_login": "Manual Input", - "qqmusic_configured": "Configured", - "qqmusic_not_configured": "Not Configured", - "qqmusic_config_incomplete": "Incomplete Configuration", - "qqmusic_login_title": "QQ Music QR Login", - "qqmusic_login_method": "Login Method:", - "qqmusic_qq_login": "QQ Login", - "qqmusic_wx_login": "WeChat Login", - "qqmusic_loading_qr": "Loading QR code...", - "qqmusic_refresh_qr": "Refresh QR Code", - "qqmusic_login_success": "Login successful! Credentials saved.", - "qqmusic_login_failed": "Login failed", - "qqmusic_qr_expired": "QR code expired", - "qqmusic_login_cancelled": "Login cancelled", - "qqmusic_api_not_installed": "qqmusic_api library not installed", - "qqmusic_login_failed_detail": "Login failed: {error}", - "qqmusic_fetching_qr": "Fetching QR code...", - "qqmusic_scan_with_app": "Scan QR code with {app} to login...", - "qqmusic_waiting_scan": "Waiting for scan...", - "qqmusic_scan_confirmed": "Scanned! Please confirm on your phone...", - "qqmusic_logging_in": "Login successful!", - "qqmusic_qr_display_failed": "Failed to display QR code", - "qqmusic_user_cancelled": "User cancelled login", - "qqmusic_you_cancelled": "You have cancelled login", - "qqmusic_qr_timeout_refresh": "QR code expired. Click refresh to get a new one", - "qqmusic_verifying": "Verifying...", - "qqmusic_incomplete_config": "Incomplete configuration", - "qqmusic_not_configured_status": "Not configured", - "qqmusic_logged_in_status": "Logged in", - "qqmusic_login_expired": "Login expired", - "qqmusic_faster_api_hint": "Use local API after login for faster access!", - "qqmusic_instructions": "1. Open {app} on your phone
2. Scan the QR code above
3. Confirm login on your phone
4. Credentials will be saved automatically", - "qqmusic_manual_title": "QQ Music Login", - "qqmusic_manual_instructions": "How to get QQ Music credentials:
1. Open browser and visit https://y.qq.com and login
2. Press F12 to open developer tools, switch to Network tab
3. Refresh page, find any request's Cookie field
4. Extract uin (QQ number) and qqmusic_key (key) from Cookie", - "qqmusic_uin": "QQ Number (uin)", - "qqmusic_key": "Key (qqmusic_key)", - "qqmusic_uin_placeholder": "uin value from Cookie", - "qqmusic_key_placeholder": "qqmusic_key value from Cookie", - "qqmusic_test_connection": "Test Connection", - "qqmusic_clear": "Clear Credentials", - "qqmusic_test_success": "Credentials verified successfully!", - "qqmusic_test_failed": "Credentials verification failed", - "qqmusic_clear_confirm": "Are you sure you want to clear saved QQ Music credentials?", - "qqmusic_cleared": "Credentials cleared", - "qqmusic_credential_saved": "Credentials saved", - "qqmusic_fill_all_fields": "Please fill in all credential fields", - "qqmusic_quality": "Audio Quality", - "qqmusic_quality_master": "Master (24Bit 192kHz FLAC)", - "qqmusic_quality_atmos": "Atmos (16Bit 44.1kHz FLAC)", - "qqmusic_quality_atmos_2": "Atmos 2.0 (FLAC)", - "qqmusic_quality_atmos_51": "Atmos 5.1 (FLAC)", - "qqmusic_quality_dolby": "Dolby Atmos (FLAC)", - "qqmusic_quality_hires": "Hi-Res (FLAC)", - "qqmusic_quality_flac": "Lossless (FLAC)", - "qqmusic_quality_ape": "APE Lossless (APE)", - "qqmusic_quality_dts": "DTS Quality (DTS)", - "qqmusic_quality_ogg_640": "OGG Ultra (640kbps)", - "qqmusic_quality_ogg_320": "OGG High (320kbps)", - "qqmusic_quality_ogg_192": "OGG Standard (192kbps)", - "qqmusic_quality_ogg_96": "OGG Low (96kbps)", - "qqmusic_quality_320": "High Quality (MP3 320kbps)", - "qqmusic_quality_128": "Standard Quality (MP3 128kbps)", - "qqmusic_quality_aac_320": "AAC Ultra (320kbps)", - "qqmusic_quality_aac_256": "AAC High (256kbps)", - "qqmusic_quality_aac_192": "AAC High (192kbps)", - "qqmusic_quality_aac_128": "AAC Standard (128kbps)", - "qqmusic_quality_aac_96": "AAC Standard (96kbps)", - "qqmusic_quality_aac_64": "AAC Low (64kbps)", - "qqmusic_quality_aac_48": "AAC Low (48kbps)", - "qqmusic_quality_aac_24": "AAC Very Low (24kbps)", "redownload": "⬇ Re-download", "redownload_hint": "Note: High quality may not be available due to copyright, will auto-downgrade to available quality", "select_quality": "Select Quality", @@ -554,6 +481,7 @@ "search_history": "Search History", "clear_all": "Clear All", "songs": "Songs", + "plays": "plays", "singers": "Singers", "fans": "Fans", "ten_thousand": "", @@ -567,20 +495,16 @@ "search_failed": "Search failed", "previous_page": "Previous", "next_page": "Next", - "qqmusic_logged_in": "QQ Music logged in", - "qqmusic_not_logged_in": "QQ Music not logged in", "logout": "Logout", "logout_success": "Logged out", "login_success": "Login successful", "login_required": "Login required", - "qqmusic_login_required": "Please login to QQ Music to play online tracks", "song_count": "Song count", "detail_not_available": "Detail not available", "add_all_to_queue": "➕ Add All to Queue", "load_more": "Load more", "cover": "Cover", "playlist_type": "PLAYLIST", - "qqmusic_logged_in_as": "QQ Music: {nick}", "cache_tab": "Cache", "cache_cleanup_title": "Cache Management", "cache_cleanup_strategy": "Cleanup Strategy", diff --git a/translations/zh.json b/translations/zh.json index 03ba11a1..97cde3f2 100644 --- a/translations/zh.json +++ b/translations/zh.json @@ -314,85 +314,12 @@ "error": "错误", "ai_tab": "AI 增强", "acoustid_tab": "AcoustID", - "qqmusic_tab": "QQ音乐", "plugins_tab": "插件", "plugins_install_zip": "安装 Zip", "plugins_install_url": "在线安装", "plugins_load_error": "加载错误", "plugins_enabled": "已启用", "plugins_disabled": "已禁用", - "qqmusic_login": "QQ音乐登录", - "qqmusic_qr_login": "扫码登录", - "qqmusic_manual_login": "手动输入", - "qqmusic_configured": "已配置", - "qqmusic_not_configured": "未配置", - "qqmusic_config_incomplete": "配置不完整", - "qqmusic_login_title": "QQ音乐扫码登录", - "qqmusic_login_method": "登录方式:", - "qqmusic_qq_login": "QQ登录", - "qqmusic_wx_login": "微信登录", - "qqmusic_loading_qr": "正在加载二维码...", - "qqmusic_refresh_qr": "刷新二维码", - "qqmusic_login_success": "登录成功!凭证已保存。", - "qqmusic_login_failed": "登录失败", - "qqmusic_qr_expired": "二维码已过期", - "qqmusic_login_cancelled": "用户取消登录", - "qqmusic_api_not_installed": "qqmusic_api库未安装", - "qqmusic_login_failed_detail": "登录失败: {error}", - "qqmusic_fetching_qr": "正在获取二维码...", - "qqmusic_scan_with_app": "请使用手机{app}扫描二维码登录...", - "qqmusic_waiting_scan": "等待扫码...", - "qqmusic_scan_confirmed": "已扫码,请在手机上确认登录...", - "qqmusic_logging_in": "登录成功!", - "qqmusic_qr_display_failed": "二维码显示失败", - "qqmusic_user_cancelled": "用户取消登录", - "qqmusic_you_cancelled": "您已取消登录", - "qqmusic_qr_timeout_refresh": "二维码已过期,请点击刷新按钮重新生成", - "qqmusic_verifying": "验证中...", - "qqmusic_incomplete_config": "配置不完整", - "qqmusic_not_configured_status": "未配置", - "qqmusic_logged_in_status": "已登录", - "qqmusic_login_expired": "登录已失效", - "qqmusic_faster_api_hint": "登录后可使用本地API,速度更快!", - "qqmusic_instructions": "1. 打开手机{app}
2. 扫描上方二维码
3. 在手机上确认登录
4. 登录成功后凭证将自动保存", - "qqmusic_manual_title": "QQ音乐登录", - "qqmusic_manual_instructions": "如何获取QQ音乐凭证:
1. 打开浏览器访问 https://y.qq.com 并登录
2. 按F12打开开发者工具,切换到Network标签
3. 刷新页面,找到任意请求的Cookie字段
4. 从Cookie中提取 uin (QQ号) 和 qqmusic_key (密钥)", - "qqmusic_uin": "QQ号", - "qqmusic_key": "密钥 (qqmusic_key)", - "qqmusic_uin_placeholder": "从Cookie中提取的uin值", - "qqmusic_key_placeholder": "从Cookie中提取的qqmusic_key值", - "qqmusic_test_connection": "测试连接", - "qqmusic_clear": "清除凭证", - "qqmusic_test_success": "凭证验证成功!", - "qqmusic_test_failed": "凭证验证失败", - "qqmusic_clear_confirm": "确定要清除已保存的QQ音乐凭证吗?", - "qqmusic_cleared": "凭证已清除", - "qqmusic_credential_saved": "凭证已保存", - "qqmusic_fill_all_fields": "请输入完整的凭证信息", - "qqmusic_quality": "音质设置", - "qqmusic_quality_master": "臻品母带 (24Bit 192kHz FLAC)", - "qqmusic_quality_atmos": "臻品全景声 (16Bit 44.1kHz FLAC)", - "qqmusic_quality_atmos_2": "臻品全景声2.0 (FLAC)", - "qqmusic_quality_atmos_51": "臻品音质2.0 (Atmos 5.1 FLAC)", - "qqmusic_quality_dolby": "杜比全景声 (Dolby Atmos FLAC)", - "qqmusic_quality_hires": "Hi-Res (FLAC)", - "qqmusic_quality_flac": "无损音质 (FLAC)", - "qqmusic_quality_ape": "APE 无损 (APE)", - "qqmusic_quality_dts": "DTS 音质 (DTS)", - "qqmusic_quality_ogg_640": "OGG 超高品质 (640kbps)", - "qqmusic_quality_ogg_320": "OGG 高品质 (320kbps)", - "qqmusic_quality_ogg_192": "OGG 标准 (192kbps)", - "qqmusic_quality_ogg_96": "OGG 低码率 (96kbps)", - "qqmusic_quality_320": "高品质 (MP3 320kbps)", - "qqmusic_quality_128": "标准音质 (MP3 128kbps)", - "qqmusic_quality_aac_320": "AAC 超高品质 (320kbps)", - "qqmusic_quality_aac_256": "AAC 高品质 (256kbps)", - "qqmusic_quality_aac_192": "AAC 高品质 (192kbps)", - "qqmusic_quality_aac_128": "AAC 标准 (128kbps)", - "qqmusic_quality_aac_96": "AAC 标准 (96kbps)", - "qqmusic_quality_aac_64": "AAC 低码率 (64kbps)", - "qqmusic_quality_aac_48": "AAC 低码率 (48kbps)", - "qqmusic_quality_aac_24": "AAC 极低码率 (24kbps)", "redownload": "⬇ 重新下载", "redownload_hint": "提示:高音质可能因版权限制无法下载,将自动降级到可用音质", "select_quality": "选择音质", @@ -554,6 +481,7 @@ "search_history": "搜索历史", "clear_all": "清空", "songs": "歌曲", + "plays": "次播放", "singers": "歌手", "fans": "粉丝", "ten_thousand": "万", @@ -567,20 +495,16 @@ "search_failed": "搜索失败", "previous_page": "上一页", "next_page": "下一页", - "qqmusic_logged_in": "QQ音乐已登录", - "qqmusic_not_logged_in": "QQ音乐未登录", "logout": "退出登录", "logout_success": "已退出登录", "login_success": "登录成功", "login_required": "需要登录", - "qqmusic_login_required": "请先登录QQ音乐才能播放在线音乐", "song_count": "歌曲数", "detail_not_available": "详情不可用", "add_all_to_queue": "➕ 全部添加到队列", "load_more": "加载更多", "cover": "封面", "playlist_type": "歌单", - "qqmusic_logged_in_as": "QQ音乐: {nick}", "cache_tab": "缓存", "cache_cleanup_title": "缓存管理", "cache_cleanup_strategy": "清理策略", diff --git a/ui/dialogs/plugin_management_tab.py b/ui/dialogs/plugin_management_tab.py index 490b322a..04198d3b 100644 --- a/ui/dialogs/plugin_management_tab.py +++ b/ui/dialogs/plugin_management_tab.py @@ -4,9 +4,9 @@ QFileDialog, QHBoxLayout, QLineEdit, + QListWidget, + QListWidgetItem, QPushButton, - QTableWidget, - QTableWidgetItem, QVBoxLayout, QWidget, ) @@ -18,23 +18,24 @@ class PluginManagementTab(QWidget): def __init__(self, plugin_manager, parent=None): super().__init__(parent) self._plugin_manager = plugin_manager - self._table = QTableWidget(0, 5, self) + self._list = QListWidget(self) self._url_input = QLineEdit(self) + self._enable_btn = QPushButton(t("plugins_enabled"), self) + self._disable_btn = QPushButton(t("plugins_disabled"), self) self._setup_ui() self.refresh() def _setup_ui(self) -> None: layout = QVBoxLayout(self) - self._table.setHorizontalHeaderLabels( - [ - t("name"), - t("version"), - t("source"), - t("status"), - t("plugins_load_error"), - ] - ) - layout.addWidget(self._table) + layout.addWidget(self._list) + self._list.currentItemChanged.connect(lambda *_args: self._sync_action_buttons()) + + state_controls = QHBoxLayout() + self._enable_btn.clicked.connect(lambda: self._set_selected_plugin_enabled(True)) + self._disable_btn.clicked.connect(lambda: self._set_selected_plugin_enabled(False)) + state_controls.addWidget(self._enable_btn) + state_controls.addWidget(self._disable_btn) + layout.addLayout(state_controls) controls = QHBoxLayout() self._url_input.setPlaceholderText("https://example.com/plugin.zip") @@ -49,14 +50,52 @@ def _setup_ui(self) -> None: def refresh(self) -> None: rows = self._plugin_manager.list_plugins() - self._table.setRowCount(len(rows)) - for row_index, row in enumerate(rows): - self._table.setItem(row_index, 0, QTableWidgetItem(row["name"])) - self._table.setItem(row_index, 1, QTableWidgetItem(row["version"])) - self._table.setItem(row_index, 2, QTableWidgetItem(row["source"])) + self._list.clear() + for row in rows: status = t("plugins_enabled") if row["enabled"] else t("plugins_disabled") - self._table.setItem(row_index, 3, QTableWidgetItem(status)) - self._table.setItem(row_index, 4, QTableWidgetItem(row["load_error"] or "")) + parts = [ + row["name"], + row["version"], + row["source"], + status, + ] + if row["load_error"]: + parts.append(row["load_error"]) + item = QListWidgetItem(" · ".join(parts)) + item.setData(0x0100, row) + self._list.addItem(item) + self._sync_action_buttons() + + def _set_selected_plugin_enabled(self, enabled: bool) -> None: + item = self._list.currentItem() + if item is None: + return + row = item.data(0x0100) or {} + plugin_id = row.get("id") + if not plugin_id: + return + self._plugin_manager.set_plugin_enabled(plugin_id, enabled) + self.refresh() + self._restore_selection(plugin_id) + + def _restore_selection(self, plugin_id: str) -> None: + for index in range(self._list.count()): + item = self._list.item(index) + row = item.data(0x0100) or {} + if row.get("id") == plugin_id: + self._list.setCurrentRow(index) + break + + def _sync_action_buttons(self) -> None: + item = self._list.currentItem() + if item is None: + self._enable_btn.setEnabled(False) + self._disable_btn.setEnabled(False) + return + row = item.data(0x0100) or {} + enabled = bool(row.get("enabled", True)) + self._enable_btn.setEnabled(not enabled) + self._disable_btn.setEnabled(enabled) def _install_zip(self) -> None: path, _ = QFileDialog.getOpenFileName( diff --git a/ui/dialogs/settings_dialog.py b/ui/dialogs/settings_dialog.py index 6c519833..a3db5ecb 100644 --- a/ui/dialogs/settings_dialog.py +++ b/ui/dialogs/settings_dialog.py @@ -758,7 +758,7 @@ def _setup_ui(self): for spec in bootstrap.plugin_manager.registry.settings_tabs(): tab_widget.addTab( spec.widget_factory(bootstrap.plugin_manager, self), - spec.title, + spec.title_provider() if callable(getattr(spec, "title_provider", None)) else spec.title, ) layout.addWidget(tab_widget) diff --git a/ui/icons.py b/ui/icons.py index 8b921481..69d1044b 100644 --- a/ui/icons.py +++ b/ui/icons.py @@ -19,6 +19,7 @@ # Icon cache: key = f"{icon_name}_{color}_{size}", value = QIcon _ICON_CACHE: dict = {} +_PATH_ICON_CACHE: dict = {} # Icon colors for different states @@ -187,6 +188,36 @@ def get_icon(icon_name: str, color: str | None = IconColor.DEFAULT, size: int = return QIcon() +def get_icon_from_path(icon_path: str, color: str | None = IconColor.DEFAULT, size: int = 24) -> QIcon: + cache_key = f"{icon_path}_{color}_{size}" + if cache_key in _PATH_ICON_CACHE: + return _PATH_ICON_CACHE[cache_key] + + path = Path(icon_path) + if not path.exists(): + logger.warning(f"Icon file not found: {icon_path}") + return QIcon() + + try: + if path.suffix.lower() == ".svg": + svg_content = path.read_bytes() + colored_svg = _colorize_svg(svg_content, color) if color else svg_content + renderer = QSvgRenderer(colored_svg) + pixmap = QPixmap(size, size) + pixmap.fill(Qt.transparent) + painter = QPainter(pixmap) + renderer.render(painter) + painter.end() + icon = QIcon(pixmap) + else: + icon = QIcon(str(path)) + _PATH_ICON_CACHE[cache_key] = icon + return icon + except Exception as e: + logger.error(f"Error loading icon from path {icon_path}: {e}") + return QIcon() + + def get_pixmap(icon_name: str, color: str = IconColor.DEFAULT, size: int = 24) -> QPixmap: """ Get QPixmap from SVG file with specified color. @@ -289,6 +320,73 @@ def setEnabled(self, enabled: bool): self._update_icon() +class PathIconButton(QPushButton): + """QPushButton with a custom SVG icon path that changes color by state.""" + + def __init__(self, icon_path: str, text: str = "", parent=None, size: int = 24): + super().__init__(text, parent) + self._icon_path = icon_path + self._icon_size = size + + try: + from system.theme import ThemeManager + tm = ThemeManager.instance() + colors = IconColor.get_colors_from_theme(tm.current_theme) + self._default_color = colors['default'] + self._hover_color = colors['hover'] + self._active_color = colors['active'] + self._disabled_color = colors['disabled'] + except Exception: + self._default_color = IconColor.DEFAULT + self._hover_color = IconColor.HOVER + self._active_color = IconColor.ACTIVE + self._disabled_color = IconColor.DISABLED + + self._update_icon() + self.setIconSize(QSize(size, size)) + self.toggled.connect(self._on_toggled) + + def _on_toggled(self, checked: bool): + self._update_icon(self._active_color if checked else self._default_color) + + def _update_icon(self, color: str = None): + if color is None: + if not self.isEnabled(): + color = self._disabled_color + elif self.isChecked(): + color = self._active_color + else: + color = self._default_color + self.setIcon(get_icon_from_path(self._icon_path, color, self._icon_size)) + + def enterEvent(self, event): + super().enterEvent(event) + if not self.isChecked(): + self._update_icon(self._hover_color) + + def leaveEvent(self, event): + super().leaveEvent(event) + if not self.isChecked(): + self._update_icon(self._default_color) + + def setEnabled(self, enabled: bool): + super().setEnabled(enabled) + self._update_icon() + + def refresh_theme(self): + try: + from system.theme import ThemeManager + tm = ThemeManager.instance() + colors = IconColor.get_colors_from_theme(tm.current_theme) + self._default_color = colors['default'] + self._hover_color = colors['hover'] + self._active_color = colors['active'] + self._disabled_color = colors['disabled'] + self._update_icon() + except Exception: + pass + + def icon_button(icon_name: str, text: str = "", size: int = 24, parent=None) -> IconButton: """ Create an IconButton with icon and optional text. diff --git a/ui/views/legacy_online_music_view.py b/ui/views/legacy_online_music_view.py new file mode 100644 index 00000000..00f140e0 --- /dev/null +++ b/ui/views/legacy_online_music_view.py @@ -0,0 +1,13 @@ +""" +Compatibility shim for the retired QQ Music legacy page. + +The concrete implementation now lives in +`plugins.builtin.qqmusic.lib.online_music_view` so host-side QQ code can be +retired while preserving old imports and tests. +""" + +import sys + +from plugins.builtin.qqmusic.lib import online_music_view as _online_music_view + +sys.modules[__name__] = _online_music_view diff --git a/ui/views/online_detail_view.py b/ui/views/online_detail_view.py index 97a9a478..fba6b0df 100644 --- a/ui/views/online_detail_view.py +++ b/ui/views/online_detail_view.py @@ -1,2310 +1,12 @@ """ -Online music detail view. -Shows details for artist, album, or playlist. -""" - -import logging -from typing import Optional, List, Dict, Any - -from PySide6.QtCore import Qt, Signal, QThread, QRect, QTimer -from PySide6.QtGui import QColor, QPixmap, QPainter, QFont -from PySide6.QtWidgets import ( - QWidget, - QVBoxLayout, - QHBoxLayout, - QLabel, - QPushButton, - QTableWidget, - QTableWidgetItem, - QHeaderView, - QAbstractItemView, - QScrollArea, - QFrame, - QMenu, -) -from shiboken6 import isValid - -from domain.online_music import OnlineTrack, OnlineAlbum, OnlineSinger, AlbumInfo -from services.online import OnlineMusicService, OnlineDownloadService -from system.event_bus import EventBus -from system.i18n import t -from ui.dialogs.message_dialog import MessageDialog -from utils import format_duration - -logger = logging.getLogger(__name__) - - -class DetailWorker(QThread): - """Background worker for loading detail data.""" - - detail_loaded = Signal(str, object, int) # (type, data, request_id) - - def __init__(self, service: OnlineMusicService, detail_type: str, mid: str, - page: int = 1, page_size: int = 30, request_id: int = 0): - super().__init__() - self._service = service - self._detail_type = detail_type - self._mid = mid - self._page = page - self._page_size = page_size - self._request_id = request_id - - def run(self): - try: - if self._detail_type == "artist": - data = self._service.get_artist_detail(self._mid, page=self._page, page_size=self._page_size) - elif self._detail_type == "album": - data = self._service.get_album_detail(self._mid, page=self._page, page_size=self._page_size) - elif self._detail_type == "playlist": - data = self._service.get_playlist_detail(self._mid, page=self._page, page_size=self._page_size) - else: - data = None - - self.detail_loaded.emit(self._detail_type, data, self._request_id) - except Exception as e: - logger.error(f"Failed to load detail: {e}") - - -class AlbumListWorker(QThread): - """Background worker for loading artist albums.""" - - albums_loaded = Signal(list, int, int) # (albums list, total count, request_id) - - def __init__(self, service: OnlineMusicService, singer_mid: str, number: int = 10, begin: int = 0, - request_id: int = 0): - super().__init__() - self._service = service - self._singer_mid = singer_mid - self._number = number - self._begin = begin - self._request_id = request_id - - def run(self): - try: - result = self._service.get_artist_albums(self._singer_mid, number=self._number, begin=self._begin) - albums = result.get('albums', []) - total = result.get('total', 0) - self.albums_loaded.emit(albums, total, self._request_id) - except Exception as e: - logger.error(f"Failed to load artist albums: {e}", exc_info=True) - self.albums_loaded.emit([], 0, self._request_id) - - -class AlbumCoverLoader(QThread): - """Background worker for loading album cover images with disk caching.""" - - cover_loaded = Signal(QPixmap) - - def __init__(self, url: str, size: int): - super().__init__() - self._url = url - self._size = size - - def run(self): - try: - from infrastructure.cache import ImageCache - import requests - - # Check disk cache first - image_data = ImageCache.get(self._url) - if not image_data: - # Download from network - response = requests.get(self._url, timeout=10) - response.raise_for_status() - image_data = response.content - # Save to cache - ImageCache.set(self._url, image_data) - - pixmap = QPixmap() - if pixmap.loadFromData(image_data): - scaled = pixmap.scaled( - self._size, self._size, - Qt.KeepAspectRatioByExpanding, - Qt.SmoothTransformation - ) - try: - self.cover_loaded.emit(scaled) - except RuntimeError: - pass # Target widget already deleted - except Exception as e: - logger.debug(f"Error loading album cover: {e}") - - -class AllTracksWorker(QThread): - """Background worker for fetching all tracks from all pages.""" - - all_tracks_loaded = Signal(list) # List of OnlineTrack - - def __init__(self, service: OnlineMusicService, detail_type: str, mid: str, - total_songs: int, page_size: int = 30): - super().__init__() - self._service = service - self._detail_type = detail_type - self._mid = mid - self._total_songs = total_songs - self._page_size = page_size - - def run(self): - """Fetch all tracks from all pages.""" - try: - all_tracks = [] - total_pages = (self._total_songs + self._page_size - 1) // self._page_size - - for page in range(1, total_pages + 1): - # Get detail for this page - if self._detail_type == "artist": - data = self._service.get_artist_detail(self._mid, page=page, page_size=self._page_size) - elif self._detail_type == "album": - data = self._service.get_album_detail(self._mid, page=page, page_size=self._page_size) - elif self._detail_type == "playlist": - data = self._service.get_playlist_detail(self._mid, page=page, page_size=self._page_size) - else: - break - - if not data: - break - - # Parse songs - songs = data.get("songs", []) - if not songs: - break - - # Parse tracks - tracks = self._parse_songs(songs) - all_tracks.extend(tracks) - - self.all_tracks_loaded.emit(all_tracks) - except Exception as e: - logger.error(f"Failed to fetch all tracks: {e}", exc_info=True) - self.all_tracks_loaded.emit([]) - - def _parse_songs(self, songs: List[Dict]) -> List[OnlineTrack]: - """Parse song data into OnlineTrack objects.""" - tracks = [] - for song in songs: - try: - # Parse singers - singers_data = song.get("singer", []) - singers = [OnlineSinger( - mid=s.get("mid", ""), - name=s.get("name", "") - ) for s in singers_data] - - # Parse album - album_data = song.get("album", {}) - album = None - if album_data or song.get("albummid"): - album = AlbumInfo( - mid=album_data.get("mid", song.get("albummid", "")), - name=album_data.get("name", song.get("albumname", "")), - ) - - # Create track - track = OnlineTrack( - mid=song.get("mid", ""), - id=song.get("id"), - title=song.get("name", song.get("title", "")), - singer=singers, - album=album, - duration=song.get("interval", song.get("duration", 0)), - pay_play=song.get("pay_play", 0) - ) - tracks.append(track) - except Exception as e: - logger.debug(f"Failed to parse song: {e}") - continue - - return tracks - - -class OnlineAlbumCard(QWidget): - """Card widget for displaying online album information.""" - - clicked = Signal(object) # Emits OnlineAlbum object - - COVER_SIZE = 150 - CARD_WIDTH = 150 - CARD_HEIGHT = 200 - BORDER_RADIUS = 8 - - def __init__(self, album_data: Dict[str, Any], parent=None): - super().__init__(parent) - self._album_data = album_data - self._album = OnlineAlbum( - mid=album_data.get("mid", ""), - name=album_data.get("name", ""), - singer_mid=album_data.get("singer_mid", ""), - singer_name=album_data.get("singer_name", ""), - cover_url=album_data.get("cover_url", ""), - song_count=album_data.get("song_count", 0), - publish_date=album_data.get("publish_date", ""), - ) - self._is_hovering = False - self._cover_loaded = False - - self._setup_ui() - self._set_default_cover() - QTimer.singleShot(10, self._load_cover) - - # Register with theme system - from system.theme import ThemeManager - ThemeManager.instance().register_widget(self) - - def _setup_ui(self): - """Set up the card UI.""" - from system.theme import ThemeManager - - self.setFixedSize(self.CARD_WIDTH, self.CARD_HEIGHT) - self.setCursor(Qt.PointingHandCursor) - - # Main layout - layout = QVBoxLayout(self) - layout.setContentsMargins(0, 0, 0, 0) - layout.setSpacing(6) - - # Cover container - self._cover_container = QFrame() - self._cover_container.setFixedSize(self.COVER_SIZE, self.COVER_SIZE) - - # Pre-computed stylesheets for hover (H-08 optimization) - theme = ThemeManager.instance().current_theme - radius = self.BORDER_RADIUS - self._style_normal = f"QFrame {{ background-color: {theme.background_hover}; border-radius: {radius}px; }}" - self._style_hover = f"QFrame {{ background-color: {theme.background_hover}; border-radius: {radius}px; border: 2px solid {theme.highlight}; }}" - self._cover_container.setStyleSheet(self._style_normal) - - # Cover label - self._cover_label = QLabel(self._cover_container) - self._cover_label.setFixedSize(self.COVER_SIZE, self.COVER_SIZE) - self._cover_label.setAlignment(Qt.AlignCenter) - - # Info container - info_widget = QWidget() - info_layout = QVBoxLayout(info_widget) - info_layout.setContentsMargins(4, 0, 4, 0) - info_layout.setSpacing(2) - - # Album name - self._name_label = QLabel(self._album.name or "Unknown") - self._name_label.setAlignment(Qt.AlignLeft | Qt.AlignVCenter) - self._name_label.setStyleSheet(ThemeManager.instance().get_qss(""" - QLabel { - color: %text%; - font-size: 12px; - font-weight: bold; - background: transparent; - } - """)) - self._name_label.setWordWrap(True) - self._name_label.setMaximumHeight(32) - - info_layout.addWidget(self._name_label) - info_layout.addStretch() - - layout.addWidget(self._cover_container, 0, Qt.AlignHCenter) - layout.addWidget(info_widget) - - def _load_cover(self, force: bool = False): - """Load album cover image asynchronously.""" - if self._cover_loaded and not force: - return - - cover_url = self._album.cover_url - if not cover_url: - return - - # Create a worker thread for loading cover - self._cover_loader = AlbumCoverLoader(cover_url, self.COVER_SIZE) - self._cover_loader.cover_loaded.connect(self._on_cover_loaded) - self._cover_loader.start() - - def _on_cover_loaded(self, pixmap: QPixmap): - """Handle cover loaded.""" - if not pixmap.isNull(): - self._cover_label.setPixmap(pixmap) - self._cover_loaded = True - - def _set_default_cover(self): - """Set default cover when no cover is available.""" - from system.theme import ThemeManager - tm = ThemeManager.instance() - - pixmap = QPixmap(self.COVER_SIZE, self.COVER_SIZE) - pixmap.fill(QColor(tm.current_theme.border)) - - painter = QPainter(pixmap) - painter.setRenderHint(QPainter.Antialiasing) - painter.setPen(QColor(tm.current_theme.text_secondary)) - font = QFont() - font.setPixelSize(48) - painter.setFont(font) - painter.drawText( - QRect(0, 0, self.COVER_SIZE, self.COVER_SIZE), - Qt.AlignCenter, "\u266B" - ) - painter.end() - - self._cover_label.setPixmap(pixmap) - - def enterEvent(self, event): - """Handle mouse enter for hover effect.""" - self._is_hovering = True - self._cover_container.setStyleSheet(self._style_hover) - super().enterEvent(event) - - def leaveEvent(self, event): - """Handle mouse leave for hover effect.""" - self._is_hovering = False - self._cover_container.setStyleSheet(self._style_normal) - super().leaveEvent(event) - - def mousePressEvent(self, event): - """Handle mouse click.""" - if event.button() == Qt.LeftButton: - self.clicked.emit(self._album) - super().mousePressEvent(event) - - def get_album(self) -> OnlineAlbum: - """Get the album object.""" - return self._album - - def refresh_theme(self): - """Refresh all styles using current theme tokens.""" - from system.theme import ThemeManager - - theme = ThemeManager.instance().current_theme - radius = self.BORDER_RADIUS - - # Update pre-computed stylesheets - self._style_normal = f"QFrame {{ background-color: {theme.background_hover}; border-radius: {radius}px; }}" - self._style_hover = f"QFrame {{ background-color: {theme.background_hover}; border-radius: {radius}px; border: 2px solid {theme.highlight}; }}" - - # Apply current state - if self._is_hovering: - self._cover_container.setStyleSheet(self._style_hover) - else: - self._cover_container.setStyleSheet(self._style_normal) - - # Update text labels - self._name_label.setStyleSheet(ThemeManager.instance().get_qss(""" - QLabel { - color: %text%; - font-size: 12px; - font-weight: bold; - background: transparent; - } - """)) - - # Update default cover with new theme colors - self._set_default_cover() - - -class OnlineDetailView(QWidget): - """Detail view for artist, album, or playlist.""" - - back_requested = Signal() - play_all = Signal(list, int) # List of OnlineTrack (current page) - insert_all_to_queue = Signal(list) # List of OnlineTrack (current page) - add_all_to_queue = Signal(list) # List of OnlineTrack (current page) - play_all_tracks = Signal(list) # List of OnlineTrack (all tracks) - insert_all_tracks_to_queue = Signal(list) # List of OnlineTrack (all tracks) - add_all_tracks_to_queue = Signal(list) # List of OnlineTrack (all tracks) - album_clicked = Signal(object) # OnlineAlbum - - _STYLE_BUTTONS = """ - QPushButton { - background: %background_alt%; - color: %text%; - border: none; - padding: 4px 16px; - border-radius: 14px; - font-size: 12px; - } - QPushButton:hover { - background: %border%; - } - QPushButton#primaryBtn { - background: %highlight%; - color: %background%; - font-weight: bold; - } - QPushButton#primaryBtn:hover { - background: %highlight_hover%; - } - """ - _STYLE_COVER_LABEL = """ - background: %background_alt%; - border-radius: 8px; - """ - _STYLE_TYPE_LABEL = "color: %text_secondary%; font-size: 11px;" - _STYLE_NAME_LABEL = "color: %text%; font-size: 18px; font-weight: bold;" - _STYLE_SECONDARY_LABEL = "color: %text_secondary%; font-size: 12px;" - _STYLE_EXTRA_LABEL = "color: %text_secondary%; font-size: 11px;" - _STYLE_STATS_LABEL = "color: %highlight%; font-size: 12px;" - _STYLE_DESC_LABEL = "color: %text_secondary%; font-size: 11px;" - _STYLE_PAGE_LABEL = "color: %text_secondary%; padding: 0 10px;" - _STYLE_ALBUMS_SECTION = "background-color: %background_alt%;" - _STYLE_ALBUMS_TITLE = """ - QLabel { - color: %highlight%; - font-size: 18px; - font-weight: bold; - padding: 4px 0; - } - """ - _STYLE_LOAD_MORE_ALBUMS = """ - QPushButton { - background: transparent; - color: %highlight%; - border: 1px solid %highlight%; - border-radius: 14px; - padding: 4px 16px; - font-size: 12px; - } - QPushButton:hover { - background: %highlight%; - color: %text%; - } - """ - _STYLE_SCROLL_AREA = """ - QScrollArea { - background-color: transparent; - border: none; - } - QScrollBar:horizontal { - background-color: %background_alt%; - height: 8px; - border-radius: 4px; - } - QScrollBar::handle:horizontal { - background-color: %border%; - border-radius: 4px; - min-width: 30px; - } - QScrollBar::handle:horizontal:hover { - background-color: %text_secondary%; - } - QScrollBar::add-line, QScrollBar::sub-line { - width: 0px; - } - """ - _STYLE_SONGS_TITLE = """ - QLabel { - color: %highlight%; - font-size: 18px; - font-weight: bold; - padding: 4px 0; - } - """ - _STYLE_SONGS_TABLE = """ - QTableWidget#detailSongsTable { - background-color: %background_alt%; - border: none; - border-radius: 8px; - gridline-color: %background_hover%; - } - QTableWidget#detailSongsTable::item { - padding: 12px 8px; - color: %text%; - border: none; - border-bottom: 1px solid %background_hover%; - } - QTableWidget#detailSongsTable::item:alternate { - background-color: %background_hover%; - } - QTableWidget#detailSongsTable::item:!alternate { - background-color: %background_alt%; - } - QTableWidget#detailSongsTable::item:selected { - background-color: %highlight%; - color: %background%; - font-weight: 500; - } - QTableWidget#detailSongsTable::item:selected:!alternate { - background-color: %highlight%; - } - QTableWidget#detailSongsTable::item:selected:alternate { - background-color: %highlight_hover%; - } - QTableWidget#detailSongsTable::item:hover { - background-color: %border%; - } - QTableWidget#detailSongsTable::item:selected:hover { - background-color: %highlight_hover%; - } - QTableWidget#detailSongsTable::item:focus { - outline: none; - border: none; - } - QTableWidget#detailSongsTable:focus { - outline: none; - border: none; - } - QTableWidget#detailSongsTable QHeaderView::section { - background-color: %background_hover%; - color: %highlight%; - padding: 14px 12px; - border: none; - border-bottom: 2px solid %highlight%; - font-weight: bold; - font-size: 12px; - letter-spacing: 0.5px; - } - QTableWidget#detailSongsTable QTableCornerButton::section { - background-color: %background_hover%; - border: none; - border-right: 1px solid %border%; - border-bottom: 2px solid %highlight%; - } - QTableWidget#detailSongsTable QScrollBar:vertical { - background-color: %background_alt%; - width: 12px; - border-radius: 6px; - margin: 0px; - } - QTableWidget#detailSongsTable QScrollBar::handle:vertical { - background-color: %border%; - border-radius: 6px; - min-height: 40px; - } - QTableWidget#detailSongsTable QScrollBar::handle:vertical:hover { - background-color: %text_secondary%; - } - QTableWidget#detailSongsTable QScrollBar:horizontal { - background-color: %background_alt%; - height: 12px; - border-radius: 6px; - } - QTableWidget#detailSongsTable QScrollBar::handle:horizontal { - background-color: %border%; - border-radius: 6px; - min-width: 40px; - } - QTableWidget#detailSongsTable QScrollBar::handle:horizontal:hover { - background-color: %text_secondary%; - } - QTableWidget#detailSongsTable QScrollBar::add-line, QScrollBar::sub-line { - height: 0px; - width: 0px; - } - """ - _STYLE_MENU = """ - QMenu { - background: %background_hover%; - color: %text%; - border: 1px solid %border%; - } - QMenu::item:selected { - background: %highlight%; - color: %background%; - } - """ - - def __init__( - self, - config_manager=None, - qqmusic_service=None, - parent=None - ): - super().__init__(parent) - - self._config = config_manager - self._service = OnlineMusicService( - config_manager=config_manager, - qqmusic_service=qqmusic_service - ) - self._download_service = OnlineDownloadService( - config_manager=config_manager, - qqmusic_service=qqmusic_service, - online_music_service=self._service - ) - self._event_bus = EventBus.instance() - - self._detail_type = "" # "artist", "album", "playlist" - self._mid = "" - self._cover_url = "" # Store actual cover URL for full-size display - self._tracks: List[OnlineTrack] = [] - self._detail_worker: Optional[DetailWorker] = None - self._album_list_worker: Optional[AlbumListWorker] = None - self._all_tracks_worker: Optional[AllTracksWorker] = None - self._album_cards: List[OnlineAlbumCard] = [] - self._albums_loaded = 0 # Track how many albums have been loaded - self._albums_total = 0 # Total album count from API - self._albums_append = False # Flag for append mode - self._is_faved = False - self._album_request_id = 0 - - # Pagination state - self._current_page = 1 - self._total_pages = 1 - self._total_songs = 0 - self._page_size = 30 # QQ Music API max per page - self._full_description = "" # Full description for dialog - self._use_tracks_list_view = False # Use OnlineTracksListView for recommendations - - self._setup_ui() - - # Register with theme system - from system.theme import ThemeManager - ThemeManager.instance().register_widget(self) - self.refresh_theme() - - def _setup_ui(self): - """Setup UI components.""" - layout = QVBoxLayout(self) - layout.setContentsMargins(20, 10, 20, 10) - layout.setSpacing(8) - - # Header - header = self._create_header() - layout.addWidget(header) - - # Info section - self._info_section = self._create_info_section() - layout.addWidget(self._info_section) - - # Albums section (for artist detail) - self._albums_section = self._create_albums_section() - layout.addWidget(self._albums_section) - - # Songs section (title + table) - self._songs_section = self._create_songs_section() - layout.addWidget(self._songs_section, 1) # Give stretch priority - - def _create_header(self) -> QWidget: - """Create header with back button.""" - widget = QWidget() - widget.setFixedHeight(28) - layout = QHBoxLayout(widget) - layout.setContentsMargins(0, 0, 0, 0) - - # Back button - self._back_btn = QPushButton("← " + t("back")) - self._back_btn.setCursor(Qt.PointingHandCursor) - self._back_btn.clicked.connect(self.back_requested.emit) - layout.addWidget(self._back_btn) - - layout.addStretch() - - return widget - - def _create_info_section(self) -> QWidget: - """Create info section.""" - widget = QWidget() - self._info_layout = QHBoxLayout(widget) - self._info_layout.setContentsMargins(0, 0, 0, 0) - self._info_layout.setSpacing(12) - - # Cover/Avatar placeholder - clickable - self._cover_label = QLabel() - self._cover_label.setFixedSize(120, 120) - self._cover_label.setAlignment(Qt.AlignCenter) - self._cover_label.setCursor(Qt.PointingHandCursor) - self._cover_label.mousePressEvent = self._on_cover_clicked - self._info_layout.addWidget(self._cover_label) - - # Info - info_widget = QWidget() - info_layout = QVBoxLayout(info_widget) - info_layout.setContentsMargins(0, 0, 0, 0) - info_layout.setSpacing(2) - - # Type label - self._type_label = QLabel() - info_layout.addWidget(self._type_label) - - # Name - self._name_label = QLabel() - info_layout.addWidget(self._name_label) - - # Secondary info (artist/creator) - self._secondary_label = QLabel() - info_layout.addWidget(self._secondary_label) - - # Extra info row (company, genre, language, etc.) - self._extra_label = QLabel() - self._extra_label.setWordWrap(True) - info_layout.addWidget(self._extra_label) - - # Stats - self._stats_label = QLabel() - info_layout.addWidget(self._stats_label) - - # Follow button (for artist detail) - self._follow_btn = QPushButton(t("follow")) - self._follow_btn.setFixedHeight(28) - self._follow_btn.setFixedWidth(80) - self._follow_btn.setCursor(Qt.PointingHandCursor) - self._follow_btn.hide() - self._follow_btn.clicked.connect(self._on_follow_clicked) - info_layout.addWidget(self._follow_btn) - - self._is_followed = False - - # Favorite button (for album/playlist detail) - self._fav_btn = QPushButton(t("add_to_qq_favorites")) - self._fav_btn.setFixedHeight(28) - self._fav_btn.setFixedWidth(120) - self._fav_btn.setCursor(Qt.PointingHandCursor) - self._fav_btn.hide() - self._fav_btn.clicked.connect(self._on_fav_clicked) - info_layout.addWidget(self._fav_btn) - - self._is_faved = False - - # Description (truncated, click to show full) - self._desc_label = QLabel() - self._desc_label.setWordWrap(True) - self._desc_label.setCursor(Qt.CursorShape.PointingHandCursor) - self._desc_label.mousePressEvent = self._on_desc_clicked - info_layout.addWidget(self._desc_label) - - info_layout.addStretch() - self._info_layout.addWidget(info_widget, 1) - - return widget - - def _create_actions(self) -> QWidget: - """Create action buttons.""" - widget = QWidget() - widget.setFixedHeight(32) - layout = QHBoxLayout(widget) - layout.setContentsMargins(0, 0, 0, 0) - layout.setSpacing(8) - - # 立即播放 (current page) - self._play_btn = QPushButton(t("play_now")) - self._play_btn.setObjectName("primaryBtn") - self._play_btn.setCursor(Qt.PointingHandCursor) - self._play_btn.setFixedHeight(28) - self._play_btn.clicked.connect(self._on_play_current) - layout.addWidget(self._play_btn) - - # 插入到队列 (current page) - self._insert_queue_btn = QPushButton(t("insert_to_queue")) - self._insert_queue_btn.setCursor(Qt.PointingHandCursor) - self._insert_queue_btn.setFixedHeight(28) - self._insert_queue_btn.clicked.connect(self._on_insert_current_to_queue) - layout.addWidget(self._insert_queue_btn) - - # 添加到队列 (current page) - self._add_queue_btn = QPushButton(t("add_to_queue")) - self._add_queue_btn.setCursor(Qt.PointingHandCursor) - self._add_queue_btn.setFixedHeight(28) - self._add_queue_btn.clicked.connect(self._on_add_current_to_queue) - layout.addWidget(self._add_queue_btn) - - # 播放全部 (all pages) - self._play_all_btn = QPushButton(t("play_all")) - self._play_all_btn.setCursor(Qt.PointingHandCursor) - self._play_all_btn.setFixedHeight(28) - self._play_all_btn.clicked.connect(self._on_play_all) - layout.addWidget(self._play_all_btn) - - # 全部插入队列 (all pages) - self._insert_all_queue_btn = QPushButton(t("insert_all_to_queue")) - self._insert_all_queue_btn.setCursor(Qt.PointingHandCursor) - self._insert_all_queue_btn.setFixedHeight(28) - self._insert_all_queue_btn.clicked.connect(self._on_insert_all_to_queue) - layout.addWidget(self._insert_all_queue_btn) - - # 全部加到队列 (all pages) - self._add_all_queue_btn = QPushButton(t("add_all_to_queue")) - self._add_all_queue_btn.setCursor(Qt.PointingHandCursor) - self._add_all_queue_btn.setFixedHeight(28) - self._add_all_queue_btn.clicked.connect(self._on_add_all_to_queue) - layout.addWidget(self._add_all_queue_btn) - - layout.addStretch() - - return widget - - def _create_pagination(self) -> QWidget: - """Create pagination widget.""" - widget = QWidget() - widget.setFixedHeight(32) - layout = QHBoxLayout(widget) - layout.setContentsMargins(0, 0, 0, 0) - - # Previous button - self._prev_page_btn = QPushButton("← " + t("previous_page")) - self._prev_page_btn.setFixedHeight(28) - self._prev_page_btn.setCursor(Qt.PointingHandCursor) - self._prev_page_btn.clicked.connect(self._on_prev_page) - layout.addWidget(self._prev_page_btn) - - # Page label - self._page_label = QLabel("1 / 1") - layout.addWidget(self._page_label) - - # Next button - self._next_page_btn = QPushButton(t("next_page") + " →") - self._next_page_btn.setFixedHeight(28) - self._next_page_btn.setCursor(Qt.PointingHandCursor) - self._next_page_btn.clicked.connect(self._on_next_page) - layout.addWidget(self._next_page_btn) - - layout.addStretch() - - # Initially hidden - widget.hide() - - return widget - - def _create_albums_section(self) -> QWidget: - """Create albums grid section for artist detail.""" - section = QWidget() - section_layout = QVBoxLayout(section) - section_layout.setContentsMargins(0, 8, 0, 8) - section_layout.setSpacing(12) - - # Header with title and load more button - header_widget = QWidget() - header_layout = QHBoxLayout(header_widget) - header_layout.setContentsMargins(0, 0, 0, 0) - - # Section title - self._albums_title_label = QLabel(t("albums")) - header_layout.addWidget(self._albums_title_label) - - header_layout.addStretch() - - # Load more button - self._load_more_albums_btn = QPushButton(t("load_more")) - self._load_more_albums_btn.setCursor(Qt.PointingHandCursor) - self._load_more_albums_btn.setFixedHeight(28) - self._load_more_albums_btn.clicked.connect(self._on_load_more_albums) - header_layout.addWidget(self._load_more_albums_btn) - - section_layout.addWidget(header_widget) - - # Albums container with horizontal scroll area - scroll_area = QScrollArea() - scroll_area.setWidgetResizable(False) - scroll_area.setHorizontalScrollBarPolicy(Qt.ScrollBarAsNeeded) - scroll_area.setVerticalScrollBarPolicy(Qt.ScrollBarAlwaysOff) - scroll_area.setFixedHeight(210) - - # Albums container - self._albums_container = QWidget() - self._albums_container.setMinimumHeight(200) - self._albums_layout = QHBoxLayout(self._albums_container) - self._albums_layout.setContentsMargins(0, 0, 0, 0) - self._albums_layout.setSpacing(16) - self._albums_layout.setAlignment(Qt.AlignLeft | Qt.AlignVCenter) - - scroll_area.setWidget(self._albums_container) - section_layout.addWidget(scroll_area) - - # Initially hidden - section.hide() - - return section - - def _create_songs_section(self) -> QWidget: - """Create songs section with title, table (for playlist) and list view (for album).""" - section = QWidget() - section_layout = QVBoxLayout(section) - section_layout.setContentsMargins(0, 0, 0, 0) - section_layout.setSpacing(8) - - # Section title - self._songs_title_label = QLabel(t("songs")) - section_layout.addWidget(self._songs_title_label) - - # Actions - actions = self._create_actions() - section_layout.addWidget(actions) - - # Pagination - self._pagination_widget = self._create_pagination() - section_layout.addWidget(self._pagination_widget) - - # Songs table (for playlist / artist) - self._songs_table = self._create_songs_table() - section_layout.addWidget(self._songs_table, 1) - - # Online tracks list view (for album) - from ui.views.online_tracks_list_view import OnlineTracksListView - self._tracks_list_view = OnlineTracksListView() - self._tracks_list_view.track_activated.connect(self._on_track_activated) - self._tracks_list_view.play_requested.connect(self._on_list_play_requested) - self._tracks_list_view.insert_to_queue_requested.connect(self._on_list_insert_to_queue) - self._tracks_list_view.add_to_queue_requested.connect(self._on_list_add_to_queue) - self._tracks_list_view.add_to_playlist_requested.connect(self._on_list_add_to_playlist) - self._tracks_list_view.favorites_toggle_requested.connect(self._on_list_favorites_toggle) - self._tracks_list_view.qq_fav_toggle_requested.connect(self._on_list_qq_fav_toggle) - self._tracks_list_view.download_requested.connect(self._on_list_download_requested) - self._tracks_list_view.hide() - section_layout.addWidget(self._tracks_list_view, 1) - - return section - - def _create_songs_table(self) -> QTableWidget: - """Create songs table.""" - table = QTableWidget() - table.setObjectName("detailSongsTable") - table.setColumnCount(5) - table.setHorizontalHeaderLabels([ - "#", t("title"), t("artist"), t("album"), t("duration") - ]) - table.horizontalHeader().setSectionResizeMode(0, QHeaderView.Fixed) - table.horizontalHeader().setSectionResizeMode(1, QHeaderView.Stretch) - table.horizontalHeader().setSectionResizeMode(2, QHeaderView.Stretch) - table.horizontalHeader().setSectionResizeMode(3, QHeaderView.Stretch) - table.horizontalHeader().setSectionResizeMode(4, QHeaderView.Fixed) - table.setColumnWidth(0, 50) - table.setColumnWidth(4, 80) - table.setSelectionBehavior(QAbstractItemView.SelectRows) - table.setSelectionMode(QAbstractItemView.ExtendedSelection) - table.setAlternatingRowColors(True) - table.setEditTriggers(QAbstractItemView.NoEditTriggers) - table.verticalHeader().setVisible(False) - table.doubleClicked.connect(self._on_track_double_clicked) - table.setContextMenuPolicy(Qt.CustomContextMenu) - table.customContextMenuRequested.connect(self._show_track_context_menu) - - return table - - def refresh_theme(self): - """Refresh all styles using current theme tokens.""" - from system.theme import ThemeManager - tm = ThemeManager.instance() - - # Main button styles - self.setStyleSheet(tm.get_qss(self._STYLE_BUTTONS)) - - # Info section labels - self._cover_label.setStyleSheet(tm.get_qss(self._STYLE_COVER_LABEL)) - self._type_label.setStyleSheet(tm.get_qss(self._STYLE_TYPE_LABEL)) - self._name_label.setStyleSheet(tm.get_qss(self._STYLE_NAME_LABEL)) - self._secondary_label.setStyleSheet(tm.get_qss(self._STYLE_SECONDARY_LABEL)) - self._extra_label.setStyleSheet(tm.get_qss(self._STYLE_EXTRA_LABEL)) - self._stats_label.setStyleSheet(tm.get_qss(self._STYLE_STATS_LABEL)) - self._desc_label.setStyleSheet(tm.get_qss(self._STYLE_DESC_LABEL)) - - # Follow button - self._update_follow_btn_style() - # Favorite button - self._update_fav_btn_style() - - # Page label - self._page_label.setStyleSheet(tm.get_qss(self._STYLE_PAGE_LABEL)) - - # Albums section - self._albums_section.setStyleSheet(tm.get_qss(self._STYLE_ALBUMS_SECTION)) - self._albums_title_label.setStyleSheet(tm.get_qss(self._STYLE_ALBUMS_TITLE)) - self._load_more_albums_btn.setStyleSheet(tm.get_qss(self._STYLE_LOAD_MORE_ALBUMS)) - - # Scroll area in albums section - scroll_area = self._albums_section.findChild(QScrollArea) - if scroll_area: - scroll_area.setStyleSheet(tm.get_qss(self._STYLE_SCROLL_AREA)) - - # Albums container - if hasattr(self, '_albums_container'): - self._albums_container.setStyleSheet("background-color: transparent;") - - # Songs section - self._songs_title_label.setStyleSheet(tm.get_qss(self._STYLE_SONGS_TITLE)) - - # Songs table - self._songs_table.setStyleSheet(tm.get_qss(self._STYLE_SONGS_TABLE)) - - # Refresh all album cards - for card in self._album_cards: - if hasattr(card, 'refresh_theme'): - card.refresh_theme() - - def load_artist(self, mid: str, name: str = ""): - """Load artist detail.""" - self._detail_type = "artist" - self._use_tracks_list_view = False - self._mid = mid - self._current_page = 1 # Reset to first page - - # Set placeholder info - self._type_label.setText(t("artist")) - self._name_label.setText(name) - self._secondary_label.setText("") - self._extra_label.setText("") - self._stats_label.setText("") - self._set_description("") - - # Show albums section for artist - self._albums_section.show() - - # Show follow button for artist - self._fav_btn.hide() - self._follow_btn.show() - self._is_followed = False - self._update_follow_btn_style() - - # Note: Follow status will be fetched together with detail in batch request - # No need to call _query_follow_status() separately - - self._load_detail() - - def load_album(self, mid: str, name: str = "", singer_name: str = ""): - """Load album detail.""" - self._detail_type = "album" - self._use_tracks_list_view = False - self._mid = mid - self._current_page = 1 # Reset to first page - - # Hide follow button for non-artist views - self._follow_btn.hide() - - # Set placeholder info - self._type_label.setText(t("album")) - self._name_label.setText(name) - self._secondary_label.setText(singer_name) - self._extra_label.setText("") - self._stats_label.setText("") - self._set_description("") - - # Hide albums section for album detail - self._albums_section.hide() - - # Show favorite button for album - self._fav_btn.show() - self._follow_btn.hide() - self._is_faved = False - self._update_fav_btn_style() - - # Note: Fav status will be fetched together with detail in batch request - # No need to call _query_fav_status() separately - - self._load_detail() - - def load_playlist(self, playlist_id: str, title: str = "", creator: str = ""): - """Load playlist detail.""" - self._detail_type = "playlist" - self._use_tracks_list_view = False - self._mid = playlist_id - self._current_page = 1 # Reset to first page - - # Hide follow button for non-artist views - self._follow_btn.hide() - - # Show favorite button for playlist - self._fav_btn.show() - self._is_faved = False - self._update_fav_btn_style() - - # Set placeholder info - self._type_label.setText(t("playlists")) - self._name_label.setText(title) - self._secondary_label.setText(creator) - self._extra_label.setText("") - self._stats_label.setText("") - self._set_description("") - - # Hide albums section for playlist detail - self._albums_section.hide() - - self._load_detail() - - def load_songs_directly(self, songs: List[Dict], title: str = "", cover_url: str = ""): - """ - Load songs directly without API call. - Used for displaying recommendation song lists. - - Args: - songs: List of song dictionaries from API - title: Title for the song list - cover_url: Cover URL for the song list - """ - self._detail_type = "playlist" - self._mid = "" # No playlist ID for direct songs - self._current_page = 1 - self._use_tracks_list_view = True # Use OnlineTracksListView for recommendations - - # Hide follow/fav buttons for recommendation song lists - self._follow_btn.hide() - self._fav_btn.hide() - - # Set info - self._type_label.setText(t("playlists")) - self._name_label.setText(title) - self._secondary_label.setText("") - self._extra_label.setText("") - self._stats_label.setText("") - self._set_description("") - - # Hide albums section - self._albums_section.hide() - - # Load cover if provided - if cover_url: - self._cover_url = cover_url - self._load_cover(cover_url) - - # Parse and display songs directly - self._total_songs = len(songs) - self._page_size = 50 - self._total_pages = 1 # All songs are already loaded - self._tracks = self._parse_songs(songs) - - # Display stats - self._stats_label.setText(f"{len(self._tracks)} {t('songs')}") - - # Update pagination controls (disable since all songs are loaded) - self._update_pagination() - - # Display songs - self._display_songs(self._tracks) - - def _load_detail(self): - """Load detail data.""" - # Increment request ID to invalidate any pending requests - self._request_id = getattr(self, '_request_id', 0) + 1 - current_request_id = self._request_id +Compatibility shim for the QQ Music online detail view. - # Clean up old worker if exists - if hasattr(self, '_detail_worker') and self._detail_worker: - if isValid(self._detail_worker) and self._detail_worker.isRunning(): - self._detail_worker.quit() - self._detail_worker.wait() - self._detail_worker.deleteLater() - - self._detail_worker = DetailWorker( - self._service, - self._detail_type, - self._mid, - self._current_page, - self._page_size, - request_id=current_request_id - ) - self._detail_worker.detail_loaded.connect(self._on_detail_loaded, Qt.QueuedConnection) - - # Clean up worker after thread has fully stopped - def on_thread_finished(): - if hasattr(self, '_detail_worker') and self._detail_worker: - self._detail_worker.deleteLater() - self._detail_worker = None - - self._detail_worker.finished.connect(on_thread_finished) - self._detail_worker.start() - - def _on_detail_loaded(self, detail_type: str, data: Optional[Dict], request_id: int): - """Handle detail loaded.""" - # Ignore outdated requests - if request_id != self._request_id: - return - - if not data: - self._name_label.setText(t("detail_not_available")) - self._secondary_label.setText(t("qqmusic_login_required")) - return - - try: - if detail_type == "artist": - self._display_artist_detail(data) - elif detail_type == "album": - self._display_album_detail(data) - elif detail_type == "playlist": - self._display_playlist_detail(data) - except Exception as e: - logger.error(f"Failed to display detail: {e}", exc_info=True) - - def _set_description(self, text: str): - """Display truncated description. Click to show full text in dialog.""" - if not text or not text.strip(): - self._full_description = "" - self._desc_label.setText("") - self._desc_label.setVisible(False) - return - self._full_description = text - text = text.replace("\n", " ") - max_len = 100 - if len(text) > max_len: - self._desc_label.setText(f"{text[:max_len]}...") - else: - self._desc_label.setText(text) - self._desc_label.setVisible(True) - - def _on_desc_clicked(self, event): - """Show full description in a dialog.""" - if not self._full_description: - return - MessageDialog.information( - self.window(), - self._name_label.text() or t("view_details"), - self._full_description, - ) - - def _display_artist_detail(self, data: Dict): - """Display artist detail.""" - self._name_label.setText(data.get("name", "")) - self._secondary_label.setText(data.get("desc", "")[:100] + "..." if data.get("desc") else "") - self._extra_label.setText("") - self._set_description(data.get("desc", "")) - - # Load artist cover - avatar_url = data.get("avatar", "") - if avatar_url: - self._cover_url = avatar_url - self._load_cover(avatar_url) - - songs = data.get("songs", []) - albums = data.get("albums", []) - total = data.get("total", len(songs)) - page_size = data.get("page_size", 50) - - # Update pagination state - # Only update total_songs on first page (API returns accurate total then) - if self._current_page == 1: - self._total_songs = total - self._page_size = page_size if page_size > 0 else 30 - self._total_pages = ( - self._total_songs + self._page_size - 1) // self._page_size if self._total_songs > 0 else 1 - - self._tracks = self._parse_songs(songs) - - # Display stats showing loaded vs total songs and album count - self._albums_total = data.get("album_count", 0) - stats_parts = [] - if total > len(self._tracks): - stats_parts.append(f"{len(self._tracks)} / {total} {t('songs')}") - else: - stats_parts.append(f"{total} {t('songs')}") - if self._albums_total > 0: - stats_parts.append(f"{self._albums_total} {t('albums')}") - self._stats_label.setText(" · ".join(stats_parts)) - - # Update follow status from batch request response - if "follow_status" in data: - self._is_followed = data["follow_status"] - self._update_follow_btn_style() - - # Update pagination controls - self._update_pagination() - self._display_songs(self._tracks) - self._on_albums_loaded(albums) - - def _update_pagination(self): - """Update pagination controls visibility and state.""" - show_all_actions = self._total_pages > 1 - self._play_all_btn.setVisible(show_all_actions) - self._insert_all_queue_btn.setVisible(show_all_actions) - self._add_all_queue_btn.setVisible(show_all_actions) - - # Show pagination for any detail type with multiple pages - if show_all_actions: - self._pagination_widget.show() - self._page_label.setText(f"{self._current_page} / {self._total_pages}") - self._prev_page_btn.setEnabled(self._current_page > 1) - self._next_page_btn.setEnabled(self._current_page < self._total_pages) - else: - self._pagination_widget.hide() - - def _update_artist_stats(self): - """Update artist stats label with song and album counts.""" - if self._detail_type != "artist": - return - - stats_parts = [] - # Song count - total = self._total_songs - if total > len(self._tracks): - stats_parts.append(f"{len(self._tracks)} / {total} {t('songs')}") - else: - stats_parts.append(f"{total} {t('songs')}") - - # Album count - if self._albums_total > 0: - stats_parts.append(f"{self._albums_total} {t('albums')}") - - self._stats_label.setText(" · ".join(stats_parts)) - - def _on_prev_page(self): - """Handle previous page button click.""" - if self._current_page > 1: - self._current_page -= 1 - self._load_detail() - - def _on_next_page(self): - """Handle next page button click.""" - if self._current_page < self._total_pages: - self._current_page += 1 - self._load_detail() - - def _load_cover(self, url: str): - """Load cover image from URL.""" - from PySide6.QtGui import QPixmap - from PySide6.QtCore import QThread, Signal - from infrastructure.cache import ImageCache - import requests - - class CoverLoader(QThread): - loaded = Signal(QPixmap, int) # (pixmap, request_id) - - def __init__(self, url, request_id=0): - super().__init__() - self.url = url - self._request_id = request_id - - def run(self): - try: - # Check disk cache first - image_data = ImageCache.get(self.url) - if not image_data: - response = requests.get(self.url, timeout=10) - response.raise_for_status() - image_data = response.content - ImageCache.set(self.url, image_data) - pixmap = QPixmap() - if pixmap.loadFromData(image_data): - self.loaded.emit(pixmap, self._request_id) - except Exception as e: - logger.debug(f"Failed to load cover: {e}") - - # Increment cover request ID - self._cover_request_id = getattr(self, '_cover_request_id', 0) + 1 - current_request_id = self._cover_request_id - - self._cover_loader = CoverLoader(url, request_id=current_request_id) - - def on_cover_loaded(pixmap, request_id): - # Ignore outdated requests - if request_id != self._cover_request_id: - return - self._cover_label.setPixmap( - pixmap.scaled(self._cover_label.size(), Qt.KeepAspectRatioByExpanding, Qt.SmoothTransformation) - ) - - self._cover_loader.loaded.connect(on_cover_loaded, Qt.QueuedConnection) - self._cover_loader.start() - - def _on_cover_clicked(self, event): - """Handle cover click to show full size image.""" - if not self._cover_url: - return - - cover_url = self._cover_url - - # Try to get high-res version for y.gtimg.cn URLs - if "y.gtimg.cn" in cover_url: - if "R300x300" in cover_url: - cover_url = cover_url.replace("R300x300", "R800x800") - - # For qpic.y.qq.com (playlist covers), use original URL as-is - # The /600 suffix is already a reasonable size - - self._show_cover_dialog_async(cover_url) - - def _show_cover_dialog_async(self, url: str): - """Show cover image in a dialog (async loading).""" - from PySide6.QtWidgets import QDialog, QVBoxLayout, QLabel - from PySide6.QtGui import QPixmap - from system.theme import ThemeManager - - # Create dialog first - dialog = QDialog(self) - dialog.setWindowTitle(self._name_label.text() or t("cover")) - dialog.setWindowFlags(dialog.windowFlags() | Qt.FramelessWindowHint) - layout = QVBoxLayout(dialog) - layout.setContentsMargins(0, 0, 0, 0) - - # Image label with loading state - image_label = QLabel() - image_label.setAlignment(Qt.AlignCenter) - image_label.setStyleSheet(f"background: {ThemeManager.instance().current_theme.background_alt};") - image_label.setText(t("loading")) - image_label.setMinimumSize(200, 200) - - # Close on click - dialog.mousePressEvent = lambda e: dialog.close() - - layout.addWidget(image_label) - - # Async load - class FullCoverLoader(QThread): - loaded = Signal(QPixmap) - - def __init__(self, url): - super().__init__() - self.url = url - - def run(self): - try: - from infrastructure.cache import ImageCache - import requests - # Check disk cache first - image_data = ImageCache.get(self.url) - if not image_data: - response = requests.get(self.url, timeout=10) - response.raise_for_status() - image_data = response.content - ImageCache.set(self.url, image_data) - pixmap = QPixmap() - if pixmap.loadFromData(image_data): - self.loaded.emit(pixmap) - except Exception as e: - logger.debug(f"Failed to load cover for dialog: {e}") - - def on_cover_loaded(pixmap): - if dialog.isVisible(): - # Scale to fit screen - screen = self.screen() if self.screen() else None - max_size = 600 - if screen: - max_size = min(screen.availableGeometry().width() - 100, - screen.availableGeometry().height() - 100, - 600) - - if pixmap.width() > max_size or pixmap.height() > max_size: - pixmap = pixmap.scaled(max_size, max_size, - Qt.KeepAspectRatio, - Qt.SmoothTransformation) - - image_label.setPixmap(pixmap) - image_label.setMinimumSize(pixmap.size()) - dialog.setFixedSize(pixmap.size()) - - self._stop_full_cover_loader() - - self._full_cover_loader = FullCoverLoader(url) - self._full_cover_loader.loaded.connect(on_cover_loaded) - self._full_cover_loader.start() - - dialog.exec() - - def _stop_full_cover_loader(self): - """Stop full-cover loader thread cooperatively.""" - loader = getattr(self, "_full_cover_loader", None) - if not loader or not isValid(loader): - self._full_cover_loader = None - return - if loader.isRunning(): - loader.requestInterruption() - loader.quit() - if not loader.wait(1000): - logger.warning("[OnlineDetailView] Full cover loader did not stop in time") - - def _display_album_detail(self, data: Dict): - """Display album detail.""" - self._name_label.setText(data.get("name", "")) - self._secondary_label.setText(data.get("singer", "")) - - # Extra info: company, genre, language, publish date - extra_parts = [] - if data.get("publish_date"): - extra_parts.append(data.get("publish_date", "")[:10]) - if data.get("company"): - extra_parts.append(data.get("company", "")) - if data.get("language"): - extra_parts.append(data.get("language", "")) - if data.get("album_type"): - extra_parts.append(data.get("album_type", "")) - self._extra_label.setText(" · ".join(extra_parts)) - - # Description - self._set_description(data.get("description", "")) - - # Load album cover - cover_url = data.get("cover_url", "") - if not cover_url: - album_mid = data.get("mid", "") - if album_mid: - cover_url = f"https://y.gtimg.cn/music/photo_new/T002R300x300M000{album_mid}.jpg" - if cover_url: - self._cover_url = cover_url - self._load_cover(cover_url) - - songs = data.get("songs", []) - total = data.get("total", len(songs)) - page_size = data.get("page_size", 50) - - # Update pagination state - self._total_songs = total - self._page_size = page_size # Update page_size from response - self._total_pages = (total + page_size - 1) // page_size if total > 0 else 1 - - self._tracks = self._parse_songs(songs) - - # Display stats - if total > len(self._tracks): - self._stats_label.setText(f"{len(self._tracks)} / {total} {t('songs')}") - else: - self._stats_label.setText(f"{len(self._tracks)} {t('songs')}") - - # Update fav status from batch request response - if "fav_status" in data: - self._is_faved = data["fav_status"] - logger.info(f"[OnlineDetailView] Album fav status from batch request: {self._is_faved}") - self._update_fav_btn_style() - - # Update pagination controls - self._update_pagination() - self._display_songs(self._tracks) - - def _display_playlist_detail(self, data: Dict): - """Display playlist detail.""" - self._name_label.setText(data.get("name", "")) - self._secondary_label.setText(data.get("creator", "")) - self._extra_label.setText("") - - # Description - self._set_description(data.get("description", "")) - - # Load playlist cover - cover_url = data.get("cover_url", "") or data.get("cover", "") - if cover_url: - self._cover_url = cover_url - self._load_cover(cover_url) - - songs = data.get("songs", []) - total = data.get("total", len(songs)) - page_size = data.get("page_size", 50) - - # Update pagination state - self._total_songs = total - self._page_size = page_size # Update page_size from response - self._total_pages = (total + page_size - 1) // page_size if total > 0 else 1 - - self._tracks = self._parse_songs(songs) - - # Display stats - if total > len(self._tracks): - self._stats_label.setText(f"{len(self._tracks)} / {total} {t('songs')}") - else: - self._stats_label.setText(f"{len(self._tracks)} {t('songs')}") - - # Update fav status from batch request response - if "fav_status" in data: - self._is_faved = data["fav_status"] - logger.info(f"[OnlineDetailView] Playlist fav status from batch request: {self._is_faved}") - self._update_fav_btn_style() - - # Update pagination controls - self._update_pagination() - self._display_songs(self._tracks) - - def _parse_songs(self, songs: List[Dict]) -> List[OnlineTrack]: - """Parse songs from API response.""" - from domain.online_music import OnlineSinger, AlbumInfo - - tracks = [] - for song in songs: - # Parse singers - handle different formats - singers = [] - singer_data = song.get("singer", []) - if isinstance(singer_data, list): - for s in singer_data: - if isinstance(s, dict): - singers.append(OnlineSinger( - mid=s.get("mid", ""), - name=s.get("name", "") - )) - elif isinstance(s, str): - singers.append(OnlineSinger(mid="", name=s)) - elif isinstance(singer_data, dict): - singers.append(OnlineSinger( - mid=singer_data.get("mid", ""), - name=singer_data.get("name", "") - )) - elif isinstance(singer_data, str): - singers.append(OnlineSinger(mid="", name=singer_data)) - - # Parse album - handle different formats - album_data = song.get("album") - if isinstance(album_data, dict): - album = AlbumInfo( - mid=album_data.get("mid", ""), - name=album_data.get("name", "") - ) - elif isinstance(album_data, str): - album = AlbumInfo(mid="", name=album_data) - else: - album = AlbumInfo( - mid=song.get("albummid", song.get("albumMid", "")), - name=song.get("albumname", song.get("albumName", "")) - ) - - track = OnlineTrack( - mid=song.get("mid", song.get("songmid", song.get("songMid", ""))), - id=song.get("id", song.get("songid", song.get("songId"))), - title=song.get("name", song.get("songname", song.get("songName", song.get("title", "")))), - singer=singers, - album=album, - duration=song.get("interval", song.get("duration", 0)) - ) - tracks.append(track) - - return tracks - - def _display_songs(self, songs: List[OnlineTrack]): - """Display songs — use list view for album/recommendations, table for playlist/artist.""" - if self._detail_type == "album" or self._use_tracks_list_view: - self._songs_table.hide() - self._tracks_list_view.show() - self._tracks_list_view.load_tracks(songs) - else: - self._tracks_list_view.hide() - self._songs_table.show() - try: - self._songs_table.setRowCount(len(songs)) - start = (self._current_page - 1) * self._page_size - - for i, song in enumerate(songs): - # Index - self._songs_table.setItem(i, 0, QTableWidgetItem(str(start + i + 1))) - - # Title - self._songs_table.setItem(i, 1, QTableWidgetItem(song.title)) - - # Artist - self._songs_table.setItem(i, 2, QTableWidgetItem(song.singer_name)) - - # Album - self._songs_table.setItem(i, 3, QTableWidgetItem(song.album_name)) - - # Duration - duration_str = format_duration(song.duration) if song.duration else "" - self._songs_table.setItem(i, 4, QTableWidgetItem(duration_str)) - except Exception as e: - logger.error(f"Failed to display songs: {e}", exc_info=True) - - def _load_artist_albums(self, append: bool = False): - """Load artist albums in background. - - Args: - append: If True, append to existing albums; otherwise replace - """ - if self._detail_type != "artist" or not self._mid: - self._albums_section.hide() - return - - # Increment request ID to invalidate any pending requests - self._album_request_id = getattr(self, '_album_request_id', 0) + 1 - current_request_id = self._album_request_id - - begin = self._albums_loaded if append else 0 - number = 10 - - # Store append flag for callback - self._albums_append = append - - # Clean up old worker if exists - if hasattr(self, '_album_list_worker') and self._album_list_worker: - if isValid(self._album_list_worker) and self._album_list_worker.isRunning(): - self._album_list_worker.quit() - self._album_list_worker.wait() - self._album_list_worker.deleteLater() - - self._album_list_worker = AlbumListWorker(self._service, self._mid, number=number, begin=begin, - request_id=current_request_id) - self._album_list_worker.albums_loaded.connect(self._on_albums_loaded, Qt.QueuedConnection) - - # Clean up worker after thread has fully stopped - def on_thread_finished(): - if hasattr(self, '_album_list_worker') and self._album_list_worker: - self._album_list_worker.deleteLater() - self._album_list_worker = None - - self._album_list_worker.finished.connect(on_thread_finished) - self._album_list_worker.start() - - def _on_albums_loaded(self, albums: List[Dict[str, Any]], total: int = 0, request_id: int = 0): - """Handle artist albums loaded. - - Args: - albums: List of album data - total: Total album count from API - request_id: Request ID for validation - """ - # Ignore outdated requests - if request_id != self._album_request_id: - return - - append = getattr(self, '_albums_append', False) - - if not append: - # Clear existing cards - for card in self._album_cards: - self._albums_layout.removeWidget(card) - card.deleteLater() - self._album_cards.clear() - self._albums_loaded = 0 - self._update_artist_stats() - - if not albums: - if not append: - self._albums_section.hide() - self._load_more_albums_btn.hide() - return - - # Create album cards - for album_data in albums: - card = OnlineAlbumCard(album_data) - card.clicked.connect(self._on_album_card_clicked) - self._albums_layout.addWidget(card) - self._album_cards.append(card) - - # Update loaded count - add to existing if appending - self._albums_loaded += len(albums) - - # Update container width - total_width = len(self._album_cards) * (OnlineAlbumCard.CARD_WIDTH + 16) - self._albums_container.setFixedWidth(max(total_width, self.width())) - self._albums_container.setMinimumHeight(200) - - # Force layout update - self._albums_layout.update() - self._albums_container.updateGeometry() - - # Show/hide load more button based on whether there are more albums - if self._albums_loaded < self._albums_total: - self._load_more_albums_btn.show() - else: - self._load_more_albums_btn.hide() - - self._albums_section.show() - self._albums_section.raise_() # Bring to front - - def _on_load_more_albums(self): - """Handle load more albums button click.""" - self._load_artist_albums(append=True) - - def _on_album_card_clicked(self, album: OnlineAlbum): - """Handle album card click.""" - self.album_clicked.emit(album) - - def set_followed(self, is_followed: bool): - """Set follow state and update button.""" - self._is_followed = is_followed - self._update_follow_btn_style() - - def _update_follow_btn_style(self): - """Update follow button text and style.""" - from system.theme import ThemeManager - tm = ThemeManager.instance() - if self._is_followed: - self._follow_btn.setText(t("followed")) - self._follow_btn.setStyleSheet(tm.get_qss(""" - QPushButton { - background: transparent; - color: %text_secondary%; - border: 1px solid %border%; - border-radius: 14px; - font-size: 12px; - padding: 4px 16px; - } - QPushButton:hover { - border-color: #ff4444; - } - """)) - else: - self._follow_btn.setText(t("follow")) - self._follow_btn.setStyleSheet(tm.get_qss(""" - QPushButton { - background: %highlight%; - color: %background%; - border: none; - border-radius: 14px; - font-size: 12px; - font-weight: bold; - padding: 4px 16px; - } - QPushButton:hover { - background: %highlight_hover%; - } - """)) - - def _on_follow_clicked(self): - """Handle follow/unfollow button click.""" - if self._detail_type != "artist" or not self._mid: - return - if self._is_followed: - success = self._service.unfollow_singer(self._mid) - else: - success = self._service.follow_singer(self._mid) - if success: - self._is_followed = not self._is_followed - self._update_follow_btn_style() - - def _update_fav_btn_style(self): - """Update favorite button text and style for album/playlist.""" - from system.theme import ThemeManager - tm = ThemeManager.instance() - if self._is_faved: - self._fav_btn.setText(t("remove_from_qq_favorites")) - self._fav_btn.setStyleSheet(tm.get_qss(""" - QPushButton { - background: transparent; - color: %text_secondary%; - border: 1px solid %border%; - border-radius: 14px; - font-size: 12px; - padding: 4px 16px; - } - QPushButton:hover { - border-color: #ff4444; - } - """)) - else: - self._fav_btn.setText(t("add_to_qq_favorites")) - self._fav_btn.setStyleSheet(tm.get_qss(""" - QPushButton { - background: %highlight%; - color: %background%; - border: none; - border-radius: 14px; - font-size: 12px; - font-weight: bold; - padding: 4px 16px; - } - QPushButton:hover { - background: %highlight_hover%; - } - """)) - - def _on_fav_clicked(self): - """Handle favorite/unfavorite button click for album/playlist.""" - if not self._mid: - return - if self._detail_type == "album": - if self._is_faved: - success = self._service.unfav_album(self._mid) - else: - success = self._service.fav_album(self._mid) - elif self._detail_type == "playlist": - playlist_id = int(self._mid) if self._mid.isdigit() else self._mid - if self._is_faved: - success = self._service.unfav_playlist(playlist_id) - else: - success = self._service.fav_playlist(playlist_id) - else: - return - if success: - self._is_faved = not self._is_faved - self._update_fav_btn_style() - - def _on_play_current(self): - """Play current page tracks.""" - if self._tracks: - self.play_all.emit(self._tracks, 0) - - def _on_insert_current_to_queue(self): - """Insert current page tracks to queue.""" - if self._tracks: - self.insert_all_to_queue.emit(self._tracks) - - def _on_add_current_to_queue(self): - """Add current page tracks to queue.""" - if self._tracks: - self.add_all_to_queue.emit(self._tracks) - - def _on_play_all(self): - """Play all tracks (from all pages).""" - if self._total_songs <= len(self._tracks): - # All tracks already loaded - self.play_all_tracks.emit(self._tracks) - else: - # Need to fetch all tracks - self._fetch_all_tracks(callback=lambda tracks: self.play_all_tracks.emit(tracks)) - - def _on_insert_all_to_queue(self): - """Insert all tracks to queue (from all pages).""" - if self._total_songs <= len(self._tracks): - # All tracks already loaded - self.insert_all_tracks_to_queue.emit(self._tracks) - else: - # Need to fetch all tracks - self._fetch_all_tracks(callback=lambda tracks: self.insert_all_tracks_to_queue.emit(tracks)) - - def _on_add_all_to_queue(self): - """Add all tracks to queue (from all pages).""" - if self._total_songs <= len(self._tracks): - # All tracks already loaded - self.add_all_tracks_to_queue.emit(self._tracks) - else: - # Need to fetch all tracks - self._fetch_all_tracks(callback=lambda tracks: self.add_all_tracks_to_queue.emit(tracks)) - - def _fetch_all_tracks(self, callback): - """ - Fetch all tracks from all pages. - - Args: - callback: Function to call with all tracks when complete - """ - # Show loading state - self._play_all_btn.setEnabled(False) - self._insert_all_queue_btn.setEnabled(False) - self._add_all_queue_btn.setEnabled(False) - self._play_all_btn.setText(t("loading")) - self._insert_all_queue_btn.setText(t("loading")) - self._add_all_queue_btn.setText(t("loading")) - - # Clean up old worker if exists - if hasattr(self, '_all_tracks_worker') and self._all_tracks_worker: - if isValid(self._all_tracks_worker) and self._all_tracks_worker.isRunning(): - self._all_tracks_worker.quit() - self._all_tracks_worker.wait() - self._all_tracks_worker.deleteLater() - - # Start background worker to fetch all tracks - self._all_tracks_worker = AllTracksWorker( - service=self._service, - detail_type=self._detail_type, - mid=self._mid, - total_songs=self._total_songs, - page_size=self._page_size - ) - self._all_tracks_worker.all_tracks_loaded.connect( - lambda tracks: self._on_all_tracks_loaded(tracks, callback) - ) - - # Clean up worker after thread has fully stopped - def on_thread_finished(): - if hasattr(self, '_all_tracks_worker') and self._all_tracks_worker: - self._all_tracks_worker.deleteLater() - self._all_tracks_worker = None - - self._all_tracks_worker.finished.connect(on_thread_finished) - self._all_tracks_worker.start() - - def _on_all_tracks_loaded(self, tracks, callback): - """Handle all tracks loaded.""" - # Restore button state - self._play_all_btn.setEnabled(True) - self._insert_all_queue_btn.setEnabled(True) - self._add_all_queue_btn.setEnabled(True) - self._play_all_btn.setText(t("play_all")) - self._insert_all_queue_btn.setText(t("insert_all_to_queue")) - self._add_all_queue_btn.setText(t("add_all_to_queue")) - - # Call callback with all tracks - callback(tracks) - - def _on_track_double_clicked(self, index): - """Handle track double click on table.""" - row = index.row() - if 0 <= row < len(self._tracks): - self._play_track(self._tracks[row]) - - def _show_track_context_menu(self, pos): - """Show context menu for track on table.""" - selected_rows = self._songs_table.selectionModel().selectedRows() - if not selected_rows: - return - - selected_tracks = [] - for index in sorted(selected_rows, key=lambda x: x.row()): - row = index.row() - if 0 <= row < len(self._tracks): - selected_tracks.append(self._tracks[row]) - - if not selected_tracks: - return - - is_single = len(selected_tracks) == 1 - track = selected_tracks[0] if is_single else None - - menu = QMenu(self) - from system.theme import ThemeManager - menu.setStyleSheet(ThemeManager.instance().get_qss(self._STYLE_MENU)) - - play_action = menu.addAction(t("play")) - insert_action = menu.addAction(t("insert_to_queue")) - add_action = menu.addAction(t("add_to_queue")) - menu.addSeparator() - add_to_favorites_action = menu.addAction(t("add_to_favorites")) - add_to_playlist_action = menu.addAction(t("add_to_playlist")) - menu.addSeparator() - download_action = menu.addAction(t("download")) - - if is_single: - play_action.triggered.connect(lambda: self._play_track(track)) - insert_action.triggered.connect(lambda: self._insert_track_to_queue(track)) - add_action.triggered.connect(lambda: self._add_track_to_queue(track)) - add_to_favorites_action.triggered.connect(lambda: self._add_track_to_favorites(track)) - add_to_playlist_action.triggered.connect(lambda: self._add_track_to_playlist(track)) - download_action.triggered.connect(lambda: self._download_track(track)) - else: - play_action.triggered.connect(lambda: self._play_tracks(selected_tracks)) - insert_action.triggered.connect(lambda: self._insert_tracks_to_queue(selected_tracks)) - add_action.triggered.connect(lambda: self._add_tracks_to_queue(selected_tracks)) - add_to_favorites_action.triggered.connect(lambda: self._add_tracks_to_favorites(selected_tracks)) - add_to_playlist_action.triggered.connect(lambda: self._add_tracks_to_playlist(selected_tracks)) - download_action.triggered.connect(lambda: self._download_tracks(selected_tracks)) - - menu.exec(self._songs_table.viewport().mapToGlobal(pos)) - - def _on_track_activated(self, track: OnlineTrack): - """Handle track double click from list view.""" - self._play_track(track) - - def _on_list_play_requested(self, tracks: list): - """Handle play requested from list view context menu.""" - if tracks: - self._play_tracks(tracks) - - def _on_list_insert_to_queue(self, tracks: list): - """Handle insert to queue from list view context menu.""" - self.insert_all_to_queue.emit(tracks) - - def _on_list_add_to_queue(self, tracks: list): - """Handle add to queue from list view context menu.""" - self.add_all_to_queue.emit(tracks) - - def _on_list_add_to_playlist(self, tracks: list): - """Handle add to playlist from list view context menu.""" - self._add_tracks_to_playlist(tracks) - - def _on_list_favorites_toggle(self, tracks: list, all_favorited: bool): - """Handle favorites toggle from list view context menu.""" - if all_favorited: - for track in tracks: - self._remove_track_from_favorites(track) - else: - self._add_tracks_to_favorites(tracks) - - def _on_list_qq_fav_toggle(self, tracks: list, all_favorited: bool): - """Handle QQ Music favorites toggle from list view context menu.""" - for track in tracks: - if not track.id: - logger.warning(f"Cannot toggle QQ favorite for track without id: {track.title}") - continue - if all_favorited: - self._service.unfav_song(track.id) - else: - self._service.fav_song(track.id) - - def _on_list_download_requested(self, tracks: list): - """Handle download from list view context menu.""" - for track in tracks: - self._download_track(track) - - def _play_track(self, track: OnlineTrack): - """Play a single track.""" - if self._tracks: - # Find the track index and play all from that track - try: - index = self._tracks.index(track) - self.play_all.emit(self._tracks, index) - except ValueError: - logger.warning(f"Track not found in list: {track.title}") - - def _add_track_to_queue(self, track: OnlineTrack): - """Add track to queue.""" - self.add_all_to_queue.emit([track]) - - def _add_tracks_to_queue(self, tracks: list): - """Add multiple tracks to queue.""" - self.add_all_to_queue.emit(tracks) - - def _insert_track_to_queue(self, track: OnlineTrack): - """Insert track after current playing track.""" - self.insert_all_to_queue.emit([track]) - - def _insert_tracks_to_queue(self, tracks: list): - """Insert multiple tracks after current playing track.""" - self.insert_all_to_queue.emit(tracks) - - def _play_tracks(self, tracks: list): - """Play multiple tracks.""" - if tracks: - self.play_all.emit(tracks, 0) - - def _download_track(self, track: OnlineTrack): - """Download a track.""" - if self._download_service.is_cached(track.mid): - logger.info(f"Track already cached: {track.title}") - return - - # Start download - worker = DownloadWorker(self._download_service, track.mid, track.title) - - # Handle download result - worker.download_finished.connect(self._on_download_finished) - - # Clean up worker after thread has fully stopped - def on_thread_finished(): - if hasattr(self, '_download_workers') and worker in self._download_workers: - self._download_workers.remove(worker) - worker.deleteLater() - - worker.finished.connect(on_thread_finished) - worker.start() - - # Keep reference to prevent garbage collection - if not hasattr(self, '_download_workers'): - self._download_workers = [] - self._download_workers.append(worker) - - def _download_tracks(self, tracks: list): - """Download multiple tracks.""" - for track in tracks: - self._download_track(track) - - def _on_download_finished(self, song_mid: str, local_path: str): - """Handle download finished.""" - if local_path: - logger.info(f"Download completed: {song_mid} -> {local_path}") - else: - logger.warning(f"Download failed: {song_mid}") - - def _add_track_to_favorites(self, track: OnlineTrack): - """Add track to favorites.""" - self._add_tracks_to_favorites([track]) - - def _add_tracks_to_favorites(self, tracks: list): - """Add multiple tracks to favorites.""" - from app.bootstrap import Bootstrap - - bootstrap = Bootstrap.instance() - favorites_service = bootstrap.favorites_service - - added_count = 0 - for track in tracks: - track_id = self._add_online_track_to_library(track) - if track_id: - favorites_service.add_favorite(track_id=track_id) - added_count += 1 - - if added_count > 0: - logger.info(f"[OnlineDetailView] Added {added_count} tracks to favorites") - MessageDialog.information( - self, - t("success"), - t("added_x_tracks_to_favorites").format(count=added_count) - ) - - def _remove_track_from_favorites(self, track: OnlineTrack): - """Remove a track from favorites.""" - from app.bootstrap import Bootstrap - - bootstrap = Bootstrap.instance() - library_track = bootstrap.library_service.get_track_by_cloud_file_id(track.mid) - if library_track: - bootstrap.favorites_service.remove_favorite(track_id=library_track.id) - return - - bootstrap.favorites_service.remove_favorite(cloud_file_id=track.mid) - - def _add_track_to_playlist(self, track: OnlineTrack): - """Add track to playlist.""" - self._add_tracks_to_playlist([track]) - - def _add_tracks_to_playlist(self, tracks: list): - """Add multiple tracks to playlist.""" - from app.bootstrap import Bootstrap - from utils.playlist_utils import add_tracks_to_playlist - - bootstrap = Bootstrap.instance() - - # Add tracks to library first and collect track IDs - track_ids = [] - for track in tracks: - track_id = self._add_online_track_to_library(track) - if track_id: - track_ids.append(track_id) - - if not track_ids: - return - - add_tracks_to_playlist( - self, - bootstrap.library_service, - track_ids, - "[OnlineDetailView]" - ) - - def _add_online_track_to_library(self, track: OnlineTrack): - """Add online track to library, return track_id.""" - from app.bootstrap import Bootstrap - - bootstrap = Bootstrap.instance() - if not bootstrap.library_service: - return None - - cover_url = self._get_cover_url(track) - - return bootstrap.library_service.add_online_track( - song_mid=track.mid, - title=track.title, - artist=track.singer_name, - album=track.album_name, - duration=float(track.duration), - cover_url=cover_url - ) - - def _get_cover_url(self, track: OnlineTrack) -> str: - """Get cover URL for online track.""" - if track.album and track.album.mid: - return f"https://y.qq.com/music/photo_new/T002R300x300M000{track.album.mid}.jpg" - return "" - - def refresh_ui(self): - """Refresh UI texts after language change.""" - # Update back button - if hasattr(self, '_back_btn'): - self._back_btn.setText("← " + t("back")) - - # Update action buttons - if hasattr(self, '_play_all_btn'): - self._play_all_btn.setText(t("play_all")) - if hasattr(self, '_insert_queue_btn'): - self._insert_queue_btn.setText(t("insert_to_queue")) - if hasattr(self, '_add_queue_btn'): - self._add_queue_btn.setText(t("add_to_queue")) - if hasattr(self, '_insert_all_queue_btn'): - self._insert_all_queue_btn.setText(t("insert_all_to_queue")) - if hasattr(self, '_add_all_queue_btn'): - self._add_all_queue_btn.setText(t("add_all_to_queue")) - - # Update follow button - if hasattr(self, '_follow_btn'): - self._follow_btn.setText(t("followed") if self._is_followed else t("follow")) - # Update favorite button - if hasattr(self, '_fav_btn'): - self._fav_btn.setText(t("remove_from_qq_favorites") if self._is_faved else t("add_to_qq_favorites")) - - # Update pagination buttons - if hasattr(self, '_prev_page_btn'): - self._prev_page_btn.setText("← " + t("previous_page")) - if hasattr(self, '_next_page_btn'): - self._next_page_btn.setText(t("next_page") + " →") - - # Update albums section title - if hasattr(self, '_albums_title_label'): - self._albums_title_label.setText(t("albums")) - - # Update songs section title - if hasattr(self, '_songs_title_label'): - self._songs_title_label.setText(t("songs")) - - # Update table headers - if hasattr(self, '_songs_table'): - header = self._songs_table.horizontalHeader() - if header.count() >= 5: - header.model().setHeaderData(0, Qt.Horizontal, "#") - header.model().setHeaderData(1, Qt.Horizontal, t("title")) - header.model().setHeaderData(2, Qt.Horizontal, t("artist")) - header.model().setHeaderData(3, Qt.Horizontal, t("album")) - header.model().setHeaderData(4, Qt.Horizontal, t("duration")) - - -class DownloadWorker(QThread): - """Background worker for downloading online music.""" +The concrete implementation now lives in +`plugins.builtin.qqmusic.lib.online_detail_view`. +""" - download_finished = Signal(str, str) # (song_mid, local_path) +import sys - def __init__(self, download_service: OnlineDownloadService, song_mid: str, song_title: str): - super().__init__() - self._download_service = download_service - self._song_mid = song_mid - self._song_title = song_title +from plugins.builtin.qqmusic.lib import online_detail_view as _online_detail_view - def run(self): - """Run download.""" - try: - result = self._download_service.download(self._song_mid, self._song_title) - self.download_finished.emit(self._song_mid, result or "") - except Exception as e: - logger.error(f"Download worker error: {e}") - self.download_finished.emit(self._song_mid, "") +sys.modules[__name__] = _online_detail_view diff --git a/ui/views/online_grid_view.py b/ui/views/online_grid_view.py index 69322161..e660c75e 100644 --- a/ui/views/online_grid_view.py +++ b/ui/views/online_grid_view.py @@ -1,711 +1,12 @@ """ -Online music grid view for displaying artists/albums/playlists in a grid layout. -Uses QListView + Model/Delegate for high-performance rendering with lazy loading. -""" - -import logging -from collections import OrderedDict -from typing import List, Optional, Union - -from PySide6.QtCore import ( - Qt, Signal, - QAbstractListModel, QModelIndex, QSize, QRect -) -from PySide6.QtGui import QPixmap, QColor, QPainter, QFont, QPen -from PySide6.QtWidgets import ( - QWidget, - QVBoxLayout, - QLabel, - QListView, - QProgressBar, - QStyledItemDelegate, - QStyle, - QPushButton, -) - -from domain.online_music import OnlineArtist, OnlineAlbum, OnlinePlaylist -from system.i18n import t - -logger = logging.getLogger(__name__) - -# Type alias for online items -OnlineItem = Union[OnlineArtist, OnlineAlbum, OnlinePlaylist] - - -class OnlineItemModel(QAbstractListModel): - """Model for online item data.""" - - def __init__(self, parent=None): - super().__init__(parent) - self._items: List[OnlineItem] = [] - - def rowCount(self, parent=QModelIndex()): - return len(self._items) - - def data(self, index, role=Qt.DisplayRole): - if not index.isValid() or index.row() >= len(self._items): - return None - - item = self._items[index.row()] - - if role == Qt.DisplayRole: - if isinstance(item, OnlineArtist): - return item.name - elif isinstance(item, OnlineAlbum): - return item.name - elif isinstance(item, OnlinePlaylist): - return item.title - elif role == Qt.UserRole: - return item - - return None - - def set_items(self, items: List[OnlineItem]): - self.beginResetModel() - self._items = items - self.endResetModel() - - def get_item(self, row: int) -> Optional[OnlineItem]: - if 0 <= row < len(self._items): - return self._items[row] - return None - - def clear(self): - self.beginResetModel() - self._items.clear() - self.endResetModel() - - -class OnlineItemDelegate(QStyledItemDelegate): - """Delegate for rendering online item cards.""" - - # Card size constants - COVER_SIZE = 180 - CARD_WIDTH = 180 - CARD_HEIGHT = 240 - SPACING = 20 - - def __init__(self, data_type: str, parent=None): - """ - Initialize delegate. - - Args: - data_type: Type of data ('singer', 'album', or 'playlist') - parent: Parent widget - """ - super().__init__(parent) - self._data_type = data_type - - # Set border radius based on data type - if data_type == "singer": - self._border_radius = 90 # Circular - else: - self._border_radius = 8 # Rounded rectangle - - self._cover_cache = OrderedDict() # LRU cache for loaded covers - self._cache_max_size = 500 - self._pending_downloads = set() # Track URLs being downloaded - self._executor = None # ThreadPoolExecutor for async downloads - - self._default_cover = self._create_default_cover() - - def _create_default_cover(self) -> QPixmap: - """Create default cover pixmap.""" - from system.theme import ThemeManager - - theme = ThemeManager.instance().current_theme - - pixmap = QPixmap(self.COVER_SIZE, self.COVER_SIZE) - pixmap.fill(Qt.transparent) - - painter = QPainter(pixmap) - painter.setRenderHint(QPainter.Antialiasing) - - if self._data_type == "singer": - # Circular background for artists with theme color - painter.setBrush(QColor(theme.text_secondary)) - painter.setPen(Qt.NoPen) - painter.drawEllipse(0, 0, self.COVER_SIZE, self.COVER_SIZE) - icon = "\u265A" # Chess queen (person icon) - else: - # Rounded rectangle for albums/playlists with theme color - painter.setBrush(QColor(theme.text_secondary)) - painter.setPen(Qt.NoPen) - painter.drawRoundedRect(0, 0, self.COVER_SIZE, self.COVER_SIZE, 8, 8) - icon = "\u266B" # Music note - - # Draw icon with contrasting theme color - painter.setPen(QColor(theme.text)) - font = QFont() - font.setPixelSize(60 if self._data_type == "singer" else 80) - painter.setFont(font) - painter.drawText( - QRect(0, 0, self.COVER_SIZE, self.COVER_SIZE), - Qt.AlignCenter, icon - ) - painter.end() - return pixmap - - def _load_cover(self, item: OnlineItem) -> QPixmap: - """Load cover from URL with caching.""" - cover_url = None - - if isinstance(item, OnlineArtist): - cover_url = item.avatar_url - elif isinstance(item, OnlineAlbum): - cover_url = item.cover_url - elif isinstance(item, OnlinePlaylist): - cover_url = item.cover_url - - if not cover_url: - return self._default_cover - - if cover_url in self._cover_cache: - self._cover_cache.move_to_end(cover_url) - return self._cover_cache[cover_url] - - # Start async download if not already pending - if cover_url not in self._pending_downloads: - self._pending_downloads.add(cover_url) - self._download_cover_async(cover_url) - - return self._default_cover - - def _download_cover_async(self, url: str): - """Download cover image asynchronously with disk caching.""" - from concurrent.futures import ThreadPoolExecutor - from infrastructure.cache import ImageCache - from infrastructure.network import HttpClient - - try: - # Check disk cache first - cached_data = ImageCache.get(url) - if cached_data: - self._load_cached_cover(url, cached_data) - return - - http_client = HttpClient() - - def download(): - try: - return http_client.get_content(url, timeout=5, headers={ - 'Referer': 'https://y.qq.com/' - }) - except Exception as e: - logger.warning(f"Failed to download cover from {url}: {e}") - return None - - # Reuse single executor instance - if self._executor is None: - self._executor = ThreadPoolExecutor(max_workers=1) - - future = self._executor.submit(download) - - # Check completion after a short delay - from PySide6.QtCore import QTimer - def check_download(): - if future.done(): - image_data = future.result() - if image_data: - # Save to disk cache - ImageCache.set(url, image_data) - self._load_cached_cover(url, image_data) - self._pending_downloads.discard(url) - else: - # Check again later - QTimer.singleShot(100, check_download) - - QTimer.singleShot(100, check_download) - - except Exception as e: - logger.warning(f"Failed to start cover download: {e}") - self._pending_downloads.discard(url) - - def _load_cached_cover(self, url: str, image_data: bytes): - """Load cover from cached data.""" - pixmap = QPixmap() - if pixmap.loadFromData(image_data): - # Scale image - scaled = pixmap.scaled( - self.COVER_SIZE, self.COVER_SIZE, - Qt.KeepAspectRatioByExpanding, - Qt.SmoothTransformation - ) - - # Apply mask based on data type - if self._data_type == "singer": - # Create circular mask - circular = QPixmap(self.COVER_SIZE, self.COVER_SIZE) - circular.fill(Qt.transparent) - - painter = QPainter(circular) - painter.setRenderHint(QPainter.Antialiasing) - painter.setBrush(Qt.white) - painter.setPen(Qt.NoPen) - painter.drawEllipse(0, 0, self.COVER_SIZE, self.COVER_SIZE) - painter.setCompositionMode(QPainter.CompositionMode_SourceIn) - painter.drawPixmap(0, 0, scaled) - painter.end() - - final = circular - else: - # Use rounded rectangle mask - masked = QPixmap(self.COVER_SIZE, self.COVER_SIZE) - masked.fill(Qt.transparent) - - painter = QPainter(masked) - painter.setRenderHint(QPainter.Antialiasing) - painter.setBrush(Qt.white) - painter.setPen(Qt.NoPen) - painter.drawRoundedRect(0, 0, self.COVER_SIZE, self.COVER_SIZE, 8, 8) - painter.setCompositionMode(QPainter.CompositionMode_SourceIn) - painter.drawPixmap(0, 0, scaled) - painter.end() - - final = masked - - self._cover_cache[url] = final - if len(self._cover_cache) > self._cache_max_size: - self._cover_cache.popitem(last=False) - - # Trigger repaint - if self.parent(): - widget = self.parent() - if hasattr(widget, 'viewport'): - widget.viewport().update() - - def sizeHint(self, option, index): - return QSize(self.CARD_WIDTH, self.CARD_HEIGHT) - - def paint(self, painter, option, index): - item = index.data(Qt.UserRole) - if not item: - logger.warning(f"[OnlineItemDelegate] paint called but item is None, index={index.row()}") - return - - from system.theme import ThemeManager - theme = ThemeManager.instance().current_theme - - rect = option.rect - is_hovered = option.state & QStyle.State_MouseOver +Compatibility shim for the QQ Music online grid view. - # Debug log for first item - # if index.row() == 0: - # if isinstance(item, OnlineArtist): - # logger.info(f"[OnlineItemDelegate] Painting artist: {item.name}, rect: {rect.x()}, {rect.y()}, {rect.width()}, {rect.height()}") - # logger.info(f"[OnlineItemDelegate] Name rect will be: x={rect.x() + 4}, y={rect.y() + self.COVER_SIZE + 8}, w={rect.width() - 8}, h=36") - # elif isinstance(item, OnlineAlbum): - # logger.info(f"[OnlineItemDelegate] Painting album: {item.name}") - # elif isinstance(item, OnlinePlaylist): - # logger.info(f"[OnlineItemDelegate] Painting playlist: {item.title}") - - # Draw cover - cover = self._load_cover(item) - cover_x = rect.x() + (rect.width() - self.COVER_SIZE) // 2 - cover_y = rect.y() - - # Draw highlight on hover - if is_hovered: - painter.setRenderHint(QPainter.Antialiasing) - - if self._data_type == "singer": - # Circular border for artists - painter.setPen(QPen(QColor(theme.highlight_hover), 3)) - painter.setBrush(Qt.NoBrush) - painter.drawEllipse(cover_x - 2, cover_y - 2, self.COVER_SIZE + 4, self.COVER_SIZE + 4) - else: - # Rounded background for albums/playlists - bg_rect = QRect( - cover_x - 4, - cover_y - 4, - self.COVER_SIZE + 8, - self.CARD_HEIGHT - 40 - ) - painter.setPen(Qt.NoPen) - # Use semi-transparent background_hover for hover background - hover_bg = QColor(theme.background_hover) - hover_bg.setAlpha(200) - painter.setBrush(hover_bg) - painter.drawRoundedRect(bg_rect, 12, 12) - - # Border - painter.setPen(QPen(QColor(theme.highlight_hover), 2)) - painter.setBrush(Qt.NoBrush) - painter.drawRoundedRect(cover_x, cover_y, self.COVER_SIZE, self.COVER_SIZE, 4, 4) - - painter.drawPixmap(cover_x, cover_y, cover) - - # Draw text based on item type - painter.setPen(QColor(theme.text)) - font = QFont() - font.setPixelSize(13) - font.setBold(True) - painter.setFont(font) - - # Get name and alignment based on type - if isinstance(item, OnlineArtist): - name = item.name - name_align = Qt.AlignHCenter | Qt.TextWordWrap - elif isinstance(item, OnlineAlbum): - name = item.name - name_align = Qt.AlignLeft | Qt.TextWordWrap - elif isinstance(item, OnlinePlaylist): - name = item.title - name_align = Qt.AlignLeft | Qt.TextWordWrap - else: - name = "Unknown" - name_align = Qt.AlignLeft | Qt.TextWordWrap - - name_rect = QRect( - rect.x() + 4, - rect.y() + self.COVER_SIZE + 8, - rect.width() - 8, - 36 - ) - painter.drawText(name_rect, name_align, name) - - # Draw subtitle - painter.setPen(QColor(theme.text_secondary)) - font.setBold(False) - font.setPixelSize(11) - painter.setFont(font) - - if isinstance(item, OnlineArtist): - from system.i18n import t - if item.song_count or item.album_count: - subtitle = f"{item.song_count} {t('tracks')} • {item.album_count} {t('albums')}" - elif item.fan_count: - if item.fan_count >= 10000: - subtitle = f"{item.fan_count / 10000:.1f}{t('ten_thousand')} {t('fans')}" - else: - subtitle = f"{item.fan_count:,} {t('fans')}" - else: - subtitle = "" - align = Qt.AlignHCenter - elif isinstance(item, OnlineAlbum): - subtitle = item.singer_name - align = Qt.AlignLeft - elif isinstance(item, OnlinePlaylist): - from system.i18n import t - play_str = self._format_play_count(item.play_count) if item.play_count else "" - parts = [] - if item.song_count: - parts.append(f"{item.song_count} {t('tracks')}") - if play_str: - parts.append(play_str) - subtitle = " • ".join(parts) if parts else "" - align = Qt.AlignLeft - else: - subtitle = "" - align = Qt.AlignLeft - - if subtitle: - subtitle_rect = QRect( - rect.x() + 4, - rect.y() + self.COVER_SIZE + 44, - rect.width() - 8, - 20 - ) - painter.drawText(subtitle_rect, align, subtitle) - - def _format_play_count(self, count: int) -> str: - """Format play count to human-readable string.""" - if count >= 100_000_000: - return f"{count / 100_000_000:.1f}亿" - elif count >= 10_000: - return f"{count / 10_000:.1f}万" - elif count > 0: - return str(count) - return "" - - def clear_cache(self): - """Clear cover cache and pending downloads.""" - self._cover_cache.clear() - self._pending_downloads.clear() - if self._executor: - self._executor.shutdown(wait=False) - self._executor = None - - def refresh_theme(self): - """Refresh default cover when theme changes.""" - self._default_cover = self._create_default_cover() - - -class OnlineGridView(QWidget): - """ - Grid view for online music items (artists/albums/playlists). - Supports lazy loading and custom delegate rendering. - """ - - item_clicked = Signal(object) # Emits OnlineItem object - load_more_requested = Signal() # Emitted when "load more" button is clicked - - _STYLE_MAIN = """ - background-color: %background%; - """ - _STYLE_LIST_VIEW = """ - QListView { - background-color: %background%; - border: none; - } - QListView::item { - background: transparent; - } - QScrollBar:vertical { - background-color: %background%; - width: 12px; - } - QScrollBar::handle:vertical { - background-color: %border%; - border-radius: 6px; - min-height: 30px; - } - QScrollBar::handle:vertical:hover { - background-color: %text_secondary%; - } - """ - _STYLE_LOAD_MORE_BTN = """ - QPushButton { - background: %background_hover%; - color: %highlight%; - border: 1px solid %highlight%; - border-radius: 20px; - font-size: 14px; - padding: 0 20px; - } - QPushButton:hover { - background: %highlight%; - color: %background%; - } - """ - _STYLE_PROGRESS_BAR = """ - QProgressBar { - background-color: %background_hover%; - border: none; - border-radius: 2px; - } - QProgressBar::chunk { - background-color: %highlight%; - border-radius: 2px; - } - """ - - def __init__(self, data_type: str, parent=None): - """ - Initialize grid view. - - Args: - data_type: Type of data ('singer', 'album', or 'playlist') - parent: Parent widget - """ - super().__init__(parent) - self._data_type = data_type - self._items: List[OnlineItem] = [] - self._data_loaded = False - self._pending_data: Optional[List[OnlineItem]] = None - - self._setup_ui() - self._connect_signals() - - # Register with theme system - from system.theme import ThemeManager - ThemeManager.instance().register_widget(self) - - def showEvent(self, event): - """Load data when view is first shown (lazy loading).""" - super().showEvent(event) - if not self._data_loaded and self._pending_data: - self._do_load(self._pending_data) - - def _setup_ui(self): - """Set up the grid view UI.""" - from system.theme import ThemeManager - theme = ThemeManager.instance().current_theme - self.setStyleSheet(f"background-color: {theme.background};") - self.setMouseTracking(True) - - layout = QVBoxLayout(self) - layout.setContentsMargins(0, 0, 0, 0) - layout.setSpacing(0) - - # List view - self._list_view = QListView() - self._list_view.setViewMode(QListView.IconMode) - self._list_view.setResizeMode(QListView.Adjust) - self._list_view.setMovement(QListView.Static) - self._list_view.setSelectionMode(QListView.SingleSelection) - self._list_view.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOff) - self._list_view.setVerticalScrollMode(QListView.ScrollPerPixel) - self._list_view.setMouseTracking(True) - - # Model and delegate - self._model = OnlineItemModel(self) - self._delegate = OnlineItemDelegate(self._data_type, self._list_view) - self._list_view.setModel(self._model) - self._list_view.setItemDelegate(self._delegate) - - # Set grid size - self._list_view.setGridSize(QSize( - OnlineItemDelegate.CARD_WIDTH + OnlineItemDelegate.SPACING, - OnlineItemDelegate.CARD_HEIGHT + OnlineItemDelegate.SPACING - )) - - layout.addWidget(self._list_view) - self._list_view.hide() # Hide until data is loaded - - # Load more button - self._load_more_btn = QPushButton() - self._load_more_btn.setText(t("load_more")) - self._load_more_btn.setCursor(Qt.PointingHandCursor) - self._load_more_btn.setFixedHeight(40) - self._load_more_btn.clicked.connect(self._on_load_more_clicked) - self._load_more_btn.hide() - layout.addWidget(self._load_more_btn) - - # Loading indicator - self._loading = self._create_loading_indicator() - layout.addWidget(self._loading) - self._loading.hide() # Hide initially - - def _create_loading_indicator(self) -> QWidget: - """Create loading indicator.""" - widget = QWidget() - layout = QVBoxLayout(widget) - layout.setAlignment(Qt.AlignCenter) - - progress = QProgressBar() - progress.setRange(0, 0) # Indeterminate - progress.setFixedSize(200, 4) - layout.addWidget(progress) - - self._loading_label = QLabel(t("loading")) - layout.addWidget(self._loading_label) - - return widget - - def _connect_signals(self): - """Connect signals.""" - self._list_view.clicked.connect(self._on_item_clicked) - self._list_view.entered.connect(self._on_item_entered) - - def _on_item_entered(self, index): - """Handle item entered for hover effect.""" - self._list_view.viewport().setCursor(Qt.PointingHandCursor) - - def _on_item_clicked(self, index: QModelIndex): - """Handle item click.""" - item = index.data(Qt.UserRole) - if item: - self.item_clicked.emit(item) - - def _on_load_more_clicked(self): - """Handle load more button click.""" - self.load_more_requested.emit() - - def load_data(self, items: List[OnlineItem]): - """ - Load data into the view with lazy loading. - - Args: - items: List of online items to display - """ - self._pending_data = items - - if self.isVisible(): - # Show loading indicator - self._loading.show() - self._list_view.hide() - # Use small delay to allow UI to update - from PySide6.QtCore import QTimer - QTimer.singleShot(50, lambda: self._do_load(items)) - - def _do_load(self, items: List[OnlineItem]): - """Actually load data into the view.""" - self._items = items - self._data_loaded = True - self._model.set_items(items) - self._loading.hide() - self._list_view.show() - - def append_data(self, items: List[OnlineItem]): - """ - Append more items to existing data (for load more functionality). - - Args: - items: Additional items to append - """ - if not items: - return - - self._items.extend(items) - self._model.set_items(self._items) - self._loading.hide() - self._list_view.show() - - def set_has_more(self, has_more: bool): - """ - Set whether there are more items to load. - - Args: - has_more: True if more items can be loaded - """ - if has_more: - self._load_more_btn.show() - else: - self._load_more_btn.hide() - - def show_loading(self): - """Show loading indicator.""" - self._loading.show() - self._list_view.hide() - self._load_more_btn.hide() - - def hide_loading(self): - """Hide loading indicator.""" - self._loading.hide() - - def clear(self): - """Clear all data from the view.""" - self._items.clear() - self._data_loaded = False - self._pending_data = None - self._model.clear() - self._delegate.clear_cache() - self._load_more_btn.hide() - - def refresh_ui(self): - """Refresh UI (for language changes).""" - # Update load more button text - if hasattr(self, '_load_more_btn'): - self._load_more_btn.setText(t("load_more")) - # Update loading label text - if hasattr(self, '_loading_label'): - self._loading_label.setText(t("loading")) - - def refresh_theme(self): - """Refresh all styles using current theme tokens.""" - from system.theme import ThemeManager - tm = ThemeManager.instance() - - # Main widget - self.setStyleSheet(tm.get_qss(self._STYLE_MAIN)) - - # List view - self._list_view.setStyleSheet(tm.get_qss(self._STYLE_LIST_VIEW)) - - # Load more button - self._load_more_btn.setStyleSheet(tm.get_qss(self._STYLE_LOAD_MORE_BTN)) +The concrete implementation now lives in +`plugins.builtin.qqmusic.lib.online_grid_view`. +""" - # Progress bar - if hasattr(self, '_loading'): - progress = self._loading.findChild(QProgressBar) - if progress: - progress.setStyleSheet(tm.get_qss(self._STYLE_PROGRESS_BAR)) +import sys - # Loading label - self._loading_label.setStyleSheet( - f"color: {tm.current_theme.text_secondary}; font-size: 14px;" - ) +from plugins.builtin.qqmusic.lib import online_grid_view as _online_grid_view - # Refresh delegate's default cover - self._delegate.refresh_theme() +sys.modules[__name__] = _online_grid_view diff --git a/ui/views/online_music_view.py b/ui/views/online_music_view.py index 2cbdac1b..851a3d77 100644 --- a/ui/views/online_music_view.py +++ b/ui/views/online_music_view.py @@ -1,3381 +1,13 @@ """ -Online music view for searching and browsing online music. -""" - -import logging -from typing import Optional, List, Dict, Any - -from PySide6.QtCore import Qt, Signal, QThread, QTimer, QStringListModel, QPoint, QEvent -from PySide6.QtGui import QColor, QBrush -from PySide6.QtWidgets import ( - QWidget, - QVBoxLayout, - QHBoxLayout, - QLabel, - QPushButton, - QLineEdit, - QTabBar, - QTableWidget, - QTableWidgetItem, - QHeaderView, - QStackedWidget, - QAbstractItemView, - QMenu, - QListWidget, - QListWidgetItem, - QFrame, - QCompleter, - QApplication, -) -from shiboken6 import isValid - -from domain.online_music import ( - OnlineTrack, OnlineArtist, OnlineAlbum, OnlinePlaylist, - SearchResult, SearchType -) -from services.online import OnlineMusicService, OnlineDownloadService -from system.event_bus import EventBus -from system.i18n import t -from system.theme import ThemeManager -from ui.dialogs.message_dialog import MessageDialog -from ui.icons import IconName, get_icon -from ui.views.online_detail_view import OnlineDetailView -from ui.views.online_grid_view import OnlineGridView -from ui.views.online_tracks_list_view import OnlineTracksListView -from ui.widgets.recommend_card import RecommendSection -from utils import format_duration - - -class CustomQCompleter(QCompleter): - """自定义QCompleter用于搜索建议.""" - - _STYLE_POPUP = """ - QListView { - background-color: %background_hover%; - border: 1px solid %border%; - border-radius: 8px; - color: %text%; - selection-background-color: %highlight%; - selection-color: %background%; - outline: none; - } - QListView::item { - padding: 8px 12px; - border-bottom: 1px solid %border%; - } - QListView::item:selected { - background-color: %highlight%; - color: %background%; - } - QListView::item:hover { - background-color: %border%; - } - """ - - def __init__(self, parent=None): - super().__init__(parent) - # Set themed popup style - self._apply_theme() - - def _apply_theme(self): - """Apply themed styles to popup.""" - from system.theme import ThemeManager - self.popup().setStyleSheet(ThemeManager.instance().get_qss(self._STYLE_POPUP)) - - def refresh_theme(self): - """Refresh popup styles.""" - self._apply_theme() - -logger = logging.getLogger(__name__) - - -class SearchWorker(QThread): - """Background worker for searching.""" - - search_completed = Signal(object) # SearchResult - search_failed = Signal(str) - - def __init__(self, service: OnlineMusicService, keyword: str, - search_type: str, page: int = 1, page_size: int = 50): - super().__init__() - self._service = service - self._keyword = keyword - self._search_type = search_type - self._page = page - self._page_size = page_size - - def run(self): - try: - result = self._service.search( - self._keyword, - self._search_type, - self._page, - self._page_size - ) - self.search_completed.emit(result) - except Exception as e: - self.search_failed.emit(str(e)) - - -class TopListWorker(QThread): - """Background worker for loading top lists.""" - - top_list_loaded = Signal(list) # List of top lists - top_songs_loaded = Signal(int, list) # (top_id, list of tracks) - - def __init__(self, service: OnlineMusicService, top_id: Optional[int] = None): - super().__init__() - self._service = service - self._top_id = top_id - - def run(self): - try: - if self._top_id is None: - # Get list of top lists - top_lists = self._service.get_top_lists() - self.top_list_loaded.emit(top_lists) - else: - # Get songs for specific top list - songs = self._service.get_top_list_songs(self._top_id) - self.top_songs_loaded.emit(self._top_id, songs) - except Exception as e: - logger.error(f"Failed to load top list: {e}") - - -class CompletionWorker(QThread): - """Background worker for search completion.""" - - completion_ready = Signal(list) # List of completion suggestions - - def __init__(self, qqmusic_service, keyword: str): - super().__init__() - self._qqmusic_service = qqmusic_service - self._keyword = keyword - - def run(self): - try: - # Try to get completion suggestions - if self._qqmusic_service: - suggestions = self._qqmusic_service.complete(self._keyword) - self.completion_ready.emit(suggestions) - else: - # No QQ Music service configured - logger.debug("No QQ Music service available for completion") - self.completion_ready.emit([]) - except Exception as e: - logger.error(f"Search completion failed: {e}") - self.completion_ready.emit([]) - - -class HotkeyWorker(QThread): - """Background worker for fetching hot search keywords.""" - - hotkey_ready = Signal(list) # List of hotkey suggestions - - def __init__(self, qqmusic_service): - super().__init__() - self._qqmusic_service = qqmusic_service - - def run(self): - try: - if self._qqmusic_service: - hotkeys = self._qqmusic_service.get_hotkey() - self.hotkey_ready.emit(hotkeys) - else: - logger.debug("No QQ Music service available for hotkey") - self.hotkey_ready.emit([]) - except Exception as e: - logger.error(f"Get hotkey failed: {e}") - self.hotkey_ready.emit([]) - - -class RecommendWorker(QThread): - """Background worker for fetching recommendations.""" - - recommend_ready = Signal(str, list) # (recommend_type, list of recommendations) - - def __init__(self, qqmusic_service, recommend_type: str): - super().__init__() - self._qqmusic_service = qqmusic_service - self._recommend_type = recommend_type - - def run(self): - try: - if not self._qqmusic_service: - self.recommend_ready.emit(self._recommend_type, []) - return - - result = [] - if self._recommend_type == "home_feed": - result = self._qqmusic_service.get_home_feed() - elif self._recommend_type == "guess": - result = self._qqmusic_service.get_guess_recommend() - elif self._recommend_type == "radar": - result = self._qqmusic_service.get_radar_recommend() - elif self._recommend_type == "songlist": - result = self._qqmusic_service.get_recommend_songlist() - elif self._recommend_type == "newsong": - result = self._qqmusic_service.get_recommend_newsong() - - self.recommend_ready.emit(self._recommend_type, result) - except Exception as e: - logger.error(f"Get recommendation {self._recommend_type} failed: {e}") - self.recommend_ready.emit(self._recommend_type, []) - - -class FavWorker(QThread): - """Background worker for loading favorites.""" - - fav_ready = Signal(str, list) # (fav_type, list of items) - - def __init__(self, qqmusic_service, fav_type: str, page: int = 1, num: int = 30): - super().__init__() - self._qqmusic_service = qqmusic_service - self._fav_type = fav_type - self._page = page - self._num = num - - def run(self): - try: - if not self._qqmusic_service: - self.fav_ready.emit(self._fav_type, []) - return - result = [] - if self._fav_type == "fav_songs": - result = self._qqmusic_service.get_my_fav_songs(page=self._page, num=self._num) - elif self._fav_type == "created_playlists": - result = self._qqmusic_service.get_my_created_songlists() - elif self._fav_type == "fav_playlists": - result = self._qqmusic_service.get_my_fav_songlists(page=self._page, num=self._num) - elif self._fav_type == "fav_albums": - result = self._qqmusic_service.get_my_fav_albums(page=self._page, num=self._num) - elif self._fav_type == "followed_singers": - result = self._qqmusic_service.get_followed_singers(page=self._page, size=self._num) - self.fav_ready.emit(self._fav_type, result) - except Exception as e: - logger.error(f"Get favorites {self._fav_type} failed: {e}") - self.fav_ready.emit(self._fav_type, []) - - -class HotkeyPopup(QWidget): - """Popup widget for displaying hot search keywords - autocomplete style.""" - - hotkey_clicked = Signal(str) # Emitted when a hotkey is clicked - clear_history_requested = Signal() # Emitted when clear history is requested - delete_history_requested = Signal(str) # Emitted when delete a history item is requested - - _STYLE_CONTAINER = """ - #hotkeyContainer { - background-color: %background_hover%; - border: 1px solid %border%; - border-radius: 8px; - } - """ - _STYLE_TITLE = """ - QLabel { - color: %highlight%; - font-size: 13px; - font-weight: bold; - padding: 10px 12px 6px 12px; - } - """ - _STYLE_TITLE_NO_PADDING = """ - QLabel { - color: %highlight%; - font-size: 13px; - font-weight: bold; - } - """ - _STYLE_CLEAR_BTN = """ - QPushButton { - color: %text_secondary%; - font-size: 12px; - border: none; - padding: 2px 8px; - background: transparent; - } - QPushButton:hover { - color: %highlight%; - text-decoration: underline; - } - """ - _STYLE_SEPARATOR = "background-color: %border%; border: none; max-height: 1px;" - _STYLE_HISTORY_LABEL = """ - QLabel { - color: %text%; - font-size: 13px; - background: transparent; - } - """ - _STYLE_DELETE_BTN = """ - QPushButton { - color: %text_secondary%; - font-size: 12px; - border: none; - padding: 2px 8px; - background: transparent; - } - QPushButton:hover { - color: #ff4444; - text-decoration: underline; - } - """ - _STYLE_HISTORY_ITEM = """ - QWidget { - background-color: transparent; - border-radius: 4px; - } - QWidget:hover { - background-color: %border%; - } - """ - _STYLE_HOTKEY_ITEM = """ - QLabel { - color: %text%; - font-size: 13px; - padding: 8px 12px; - border-radius: 4px; - } - QLabel:hover { - background-color: %border%; - } - """ - - def __init__(self, parent=None): - super().__init__(parent) - self.setWindowFlags(Qt.Tool | Qt.FramelessWindowHint | Qt.WindowStaysOnTopHint) - self.setAttribute(Qt.WA_TranslucentBackground) - self.setAttribute(Qt.WA_ShowWithoutActivating) - - self._setup_ui() - - # Register with theme system - from system.theme import ThemeManager - ThemeManager.instance().register_widget(self) - - def _setup_ui(self): - """Setup UI components.""" - self._main_layout = QVBoxLayout(self) - self._main_layout.setContentsMargins(0, 0, 0, 0) - - self._container = QWidget() - self._container.setObjectName("hotkeyContainer") - self._container_layout = QVBoxLayout(self._container) - self._container_layout.setContentsMargins(0, 0, 0, 0) - self._container_layout.setSpacing(0) - - self._main_layout.addWidget(self._container) - - # Apply initial theme - self.refresh_theme() - - def refresh_theme(self): - """Refresh all styles using current theme tokens.""" - from system.theme import ThemeManager - tm = ThemeManager.instance() - - # Container - self._container.setStyleSheet(tm.get_qss(self._STYLE_CONTAINER)) - - def set_hotkeys(self, hotkeys: List[Dict[str, Any]]): - """Set hotkey list.""" - self._clear_container() - - # Title - title = QLabel(f"🔥 {t('hot_search')}") - from system.theme import ThemeManager - title.setStyleSheet(ThemeManager.instance().get_qss(self._STYLE_TITLE)) - self._container_layout.addWidget(title) - - # Hotkey items - for item in hotkeys[:10]: - title_text = item.get('title', '') - query = item.get('query', title_text) - if not title_text: - continue - self._add_hotkey_item(title_text, query) - - self._adjust_size() - - def set_search_history(self, history: List[str]): - """Set search history list.""" - self._clear_container() - - # Title with clear button - title_layout = QHBoxLayout() - title_layout.setContentsMargins(12, 10, 12, 6) - - title = QLabel(f"📝 {t('search_history')}") - from system.theme import ThemeManager - title.setStyleSheet(ThemeManager.instance().get_qss(self._STYLE_TITLE_NO_PADDING)) - title_layout.addWidget(title) - - title_layout.addStretch() - - clear_btn = QPushButton(t("clear_all")) - clear_btn.setStyleSheet(ThemeManager.instance().get_qss(self._STYLE_CLEAR_BTN)) - clear_btn.setCursor(Qt.PointingHandCursor) - clear_btn.clicked.connect(self._on_clear_clicked) - title_layout.addWidget(clear_btn) - - title_widget = QWidget() - title_widget.setLayout(title_layout) - self._container_layout.addWidget(title_widget) - - # History items - for keyword in history: - if not keyword: - continue - self._add_history_item(keyword) - - self._adjust_size() - - def set_combined(self, history: List[str], hotkeys: List[Dict[str, Any]]): - """Set both search history and hotkeys in one popup.""" - self._clear_container() - - from system.theme import ThemeManager - tm = ThemeManager.instance() - - # Add search history section - if history: - # Title with clear button - title_layout = QHBoxLayout() - title_layout.setContentsMargins(12, 10, 12, 6) - - title = QLabel(f"📝 {t('search_history')}") - title.setStyleSheet(tm.get_qss(self._STYLE_TITLE_NO_PADDING)) - title_layout.addWidget(title) - - title_layout.addStretch() - - clear_btn = QPushButton(t("clear_all")) - clear_btn.setStyleSheet(tm.get_qss(self._STYLE_CLEAR_BTN)) - clear_btn.setCursor(Qt.PointingHandCursor) - clear_btn.clicked.connect(self._on_clear_clicked) - title_layout.addWidget(clear_btn) - - title_widget = QWidget() - title_widget.setLayout(title_layout) - self._container_layout.addWidget(title_widget) - - for keyword in history: - if not keyword: - continue - self._add_history_item(keyword) - - # Add separator if both sections exist - if history and hotkeys: - separator = QFrame() - separator.setFrameShape(QFrame.HLine) - separator.setStyleSheet(tm.get_qss(self._STYLE_SEPARATOR)) - self._container_layout.addWidget(separator) - - # Add hot search section - if hotkeys: - # Title - hotkey_title = QLabel(f"🔥 {t('hot_search')}") - hotkey_title.setStyleSheet(tm.get_qss(self._STYLE_TITLE)) - self._container_layout.addWidget(hotkey_title) - - for item in hotkeys[:5]: # Limit to 5 hotkeys when combined - title_text = item.get('title', '') - query = item.get('query', title_text) - if not title_text: - continue - self._add_hotkey_item(title_text, query) - - self._adjust_size() - - def _add_history_item(self, keyword: str): - """Add a history item with delete button.""" - from system.theme import ThemeManager - tm = ThemeManager.instance() - - item_widget = QWidget() - item_layout = QHBoxLayout(item_widget) - item_layout.setContentsMargins(12, 4, 8, 4) - item_layout.setSpacing(8) - - # Keyword label - label = QLabel(keyword) - label.setStyleSheet(tm.get_qss(self._STYLE_HISTORY_LABEL)) - item_layout.addWidget(label) - - item_layout.addStretch() - - # Delete button - same style as clear button - delete_btn = QPushButton(t("delete")) - delete_btn.setStyleSheet(tm.get_qss(self._STYLE_DELETE_BTN)) - delete_btn.setCursor(Qt.PointingHandCursor) - delete_btn.clicked.connect(lambda: self._on_delete_clicked(keyword)) - item_layout.addWidget(delete_btn) - - item_widget.setStyleSheet(tm.get_qss(self._STYLE_HISTORY_ITEM)) - item_widget.setCursor(Qt.PointingHandCursor) - item_widget.mousePressEvent = lambda e: self._on_item_clicked(keyword) - - self._container_layout.addWidget(item_widget) - - def _add_hotkey_item(self, title: str, query: str): - """Add a hotkey item.""" - from system.theme import ThemeManager - label = QLabel(f" {title}") - label.setStyleSheet(ThemeManager.instance().get_qss(self._STYLE_HOTKEY_ITEM)) - label.setCursor(Qt.PointingHandCursor) - label.mousePressEvent = lambda e: self._on_item_clicked(query) - - self._container_layout.addWidget(label) - - def _clear_container(self): - """Clear all items from container.""" - while self._container_layout.count(): - item = self._container_layout.takeAt(0) - if item.widget(): - item.widget().deleteLater() - - def _on_item_clicked(self, query: str): - """Handle item click.""" - self.hide() - self.hotkey_clicked.emit(query) - - def _on_clear_clicked(self): - """Handle clear button click.""" - self.hide() - self.clear_history_requested.emit() - - def _on_delete_clicked(self, keyword: str): - """Handle delete button click.""" - self.delete_history_requested.emit(keyword) - - def _adjust_size(self): - """Adjust popup size to fit content.""" - if self._container_layout.count() == 0: - self.hide() - return - - # Force layout update - self._container_layout.update() - self._container.adjustSize() - self.adjustSize() - - # Set a minimum width - if self.width() < 200: - self.setMinimumWidth(200) - - # Limit max height - if self.height() > 400: - self.setFixedHeight(400) - - def show_at(self, global_pos: QPoint, input_width: int = 0): - """Show popup at global position.""" - if input_width > 0: - self.setMinimumWidth(input_width) - self.setMaximumWidth(input_width) - self.move(global_pos) - self.show() - self.raise_() # Ensure it's on top - - -class SearchInputWithHotkey(QLineEdit): - """Custom search input that emits focus events.""" - - focus_gained = Signal() - focus_lost = Signal() - escape_pressed = Signal() - - def focusInEvent(self, event): - super().focusInEvent(event) - self.focus_gained.emit() - - def focusOutEvent(self, event): - super().focusOutEvent(event) - self.focus_lost.emit() - - def keyPressEvent(self, event): - """Handle key press events.""" - if event.key() == Qt.Key_Escape: - # Emit escape signal and accept the event - self.escape_pressed.emit() - event.accept() - else: - # Pass other keys to parent - super().keyPressEvent(event) - - -class OnlineMusicView(QWidget): - """View for searching and browsing online music.""" - - # Signals - play_online_track = Signal(str, str, object) # (song_mid, local_path, metadata_dict) - insert_to_queue = Signal(str, object) # (song_mid, metadata_dict) - add_to_queue = Signal(str, object) # (song_mid, metadata_dict) - add_multiple_to_queue = Signal(list) # list of (song_mid, metadata_dict) - insert_multiple_to_queue = Signal(list) # list of (song_mid, metadata_dict) - play_online_tracks = Signal(int, list) # (start_index, list of (song_mid, metadata_dict)) - - _STYLE_TITLE = "color: %highlight%; font-size: 24px; font-weight: bold;" - _STYLE_STATUS_LABEL = "color: %text_secondary%; font-size: 12px;" - _STYLE_SEARCH_INPUT = """ - QLineEdit { - background-color: %background_hover%; - color: %text%; - border: 2px solid %border%; - border-radius: 25px; - padding: 10px 20px; - font-size: 14px; - } - QLineEdit:focus { - border: 2px solid %highlight%; - background-color: %background_alt%; - } - QLineEdit::placeholder { - color: %text_secondary%; - } - QLineEdit::clear-button { - subcontrol-origin: padding; - subcontrol-position: right; - width: 20px; - height: 20px; - margin-right: 10px; - border-radius: 10px; - background-color: %border%; - } - QLineEdit::clear-button:hover { - background-color: %text_secondary%; - border: 1px solid %text%; - cursor: pointer; - } - QLineEdit::clear-button:pressed { - background-color: %background_hover%; - } - """ - _STYLE_TABS = """ - QTabBar::tab { - background: transparent; - color: %text_secondary%; - padding: 8px 20px; - border-bottom: 2px solid transparent; - } - QTabBar::tab:selected { - color: %highlight%; - border-bottom: 2px solid %highlight%; - } - QTabBar::tab:hover { - color: %highlight%; - } - """ - _STYLE_RANKINGS_TITLE = "color: %highlight%; font-size: 16px; font-weight: bold;" - _STYLE_FAV_BACK_BTN = """ - QPushButton { - background-color: transparent; - color: %highlight%; - border: none; - font-size: 14px; - font-weight: bold; - padding: 4px 8px; - } - QPushButton:hover { - color: %highlight_hover%; - } - """ - _STYLE_RESULTS_INFO = "color: %text_secondary%; font-size: 12px;" - _STYLE_SONGS_TABLE = """ - QTableWidget#songsTable { - background-color: %background_alt%; - border: none; - border-radius: 8px; - gridline-color: %background_hover%; - } - QTableWidget#songsTable::item { - padding: 12px 8px; - color: %text%; - border: none; - border-bottom: 1px solid %background_hover%; - } - QTableWidget#songsTable::item:alternate { - background-color: %background_hover%; - } - QTableWidget#songsTable::item:!alternate { - background-color: %background_alt%; - } - QTableWidget#songsTable::item:selected { - background-color: %highlight%; - color: %background%; - font-weight: 500; - } - QTableWidget#songsTable::item:selected:!alternate { - background-color: %highlight%; - } - QTableWidget#songsTable::item:selected:alternate { - background-color: %highlight_hover%; - } - QTableWidget#songsTable::item:hover { - background-color: %border%; - } - QTableWidget#songsTable::item:selected:hover { - background-color: %highlight_hover%; - } - QTableWidget#songsTable::item:focus { - outline: none; - border: none; - } - QTableWidget#songsTable:focus { - outline: none; - border: none; - } - QTableWidget#songsTable QHeaderView::section { - background-color: %background_hover%; - color: %highlight%; - padding: 14px 12px; - border: none; - border-bottom: 2px solid %highlight%; - font-weight: bold; - font-size: 12px; - letter-spacing: 0.5px; - } - QTableWidget#songsTable QTableCornerButton::section { - background-color: %background_hover%; - border: none; - border-right: 1px solid %border%; - border-bottom: 2px solid %highlight%; - } - QTableWidget#songsTable QScrollBar:vertical { - background-color: %background_alt%; - width: 12px; - border-radius: 6px; - margin: 0px; - } - QTableWidget#songsTable QScrollBar::handle:vertical { - background-color: %border%; - border-radius: 6px; - min-height: 40px; - } - QTableWidget#songsTable QScrollBar::handle:vertical:hover { - background-color: %text_secondary%; - } - QTableWidget#songsTable QScrollBar:horizontal { - background-color: %background_alt%; - height: 12px; - border-radius: 6px; - } - QTableWidget#songsTable QScrollBar::handle:horizontal { - background-color: %border%; - border-radius: 6px; - min-width: 40px; - } - QTableWidget#songsTable QScrollBar::handle:horizontal:hover { - background-color: %text_secondary%; - } - QTableWidget#songsTable QScrollBar::add-line, QScrollBar::sub-line { - height: 0px; - width: 0px; - } - """ - _STYLE_PAGE_LABEL = "color: %text_secondary%; padding: 0 10px;" - _STYLE_BUTTONS = """ - QPushButton { - background: %background_alt%; - color: %text%; - border: none; - padding: 8px 15px; - border-radius: 4px; - } - QPushButton:hover { - background: %border%; - } - QPushButton:pressed { - background: %text_secondary%; - } - QListWidget { - background: %background_alt%; - border: 1px solid %border%; - border-radius: 4px; - } - QListWidget::item { - padding: 10px; - color: %text%; - } - QListWidget::item:selected { - background: %highlight%; - color: %background%; - } - QListWidget::item:hover { - background: %background_hover%; - } - QListWidget::item:selected:hover { - background-color: %highlight_hover%; - color: %background%; - } - """ - _STYLE_MENU = """ - QMenu { - background: %background_hover%; - color: %text%; - border: 1px solid %border%; - } - QMenu::item:selected { - background: %highlight%; - color: %background%; - } - """ - - def __init__( - self, - config_manager=None, - qqmusic_service=None, - parent=None - ): - super().__init__(parent) - - self._config = config_manager - self._qqmusic_service = qqmusic_service - - # Create services - self._service = OnlineMusicService( - config_manager=config_manager, - qqmusic_service=qqmusic_service - ) - self._download_service = OnlineDownloadService( - config_manager=config_manager, - qqmusic_service=qqmusic_service, - online_music_service=self._service - ) - - # State - self._current_search_type = SearchType.SONG - self._current_page = 1 - self._current_keyword = "" - self._current_result: Optional[SearchResult] = None - self._current_tracks: List[OnlineTrack] = [] - self._search_worker: Optional[SearchWorker] = None - self._search_request_id = 0 - self._top_list_worker: Optional[TopListWorker] = None - self._completion_worker: Optional[CompletionWorker] = None - self._completion_request_id = 0 - self._completion_timer: Optional[QTimer] = None - self._selected_top_id: Optional[int] = None - self._top_lists_loaded = False # Track if top lists have been loaded - self._is_top_list_view = True # True when viewing top list, False when viewing search results - - # Hotkey state - self._hotkey_worker: Optional[HotkeyWorker] = None - self._hotkey_request_id = 0 - self._hotkey_popup: Optional[HotkeyPopup] = None - self._hotkeys: List[Dict[str, Any]] = [] # Cached hotkeys - - # Recommend state - self._recommend_workers: List[RecommendWorker] = [] - self._recommendations: Dict[str, List[Dict[str, Any]]] = {} - self._recommendations_loaded = False - - # Favorites state - self._fav_workers: List[FavWorker] = [] - self._fav_loaded = False - self._fav_data: Dict[str, list] = {} # Store loaded favorites data - - # Navigation history stack - tracks where user came from - # Each entry is a dict: {'page': 'top_list'|'results'|'playlists'|'albums', 'data': ...} - self._navigation_stack: List[Dict[str, Any]] = [] - - # State for non-song search (load more) - self._grid_page = 1 # Current page for grid views (singer/album/playlist) - self._grid_total = 0 # Total results for current grid search - self._grid_page_size = 30 # Page size for grid views - - # Event bus - self._event_bus = EventBus.instance() - - # Setup completion timer - self._completion_timer = QTimer() - self._completion_timer.setSingleShot(True) - self._completion_timer.timeout.connect(self._trigger_completion) - - self._setup_ui() - self._focus_filter_registered = False - self._register_focus_clear_filter() - - # Register with theme system - from system.theme import ThemeManager - ThemeManager.instance().register_widget(self) - self.refresh_theme() - - def _setup_ui(self): - """Setup UI components.""" - layout = QVBoxLayout(self) - layout.setContentsMargins(20, 20, 20, 10) - layout.setSpacing(10) - - # Header with login status - header = self._create_header() - layout.addWidget(header) - - # Search bar - search_bar = self._create_search_bar() - layout.addWidget(search_bar) - - # My Favorites section (shown when logged in, above recommendations) - # 4 cards: fav_songs, created_playlists, fav_playlists, fav_albums - self._favorites_section = RecommendSection(title=t("my_favorites"), parent=self) - self._favorites_section.recommendation_clicked.connect(self._on_favorites_card_clicked) - self._favorites_section.hide() - layout.addWidget(self._favorites_section) - - # Recommendations section (shown when logged in) - self._recommend_section = RecommendSection(title=t("recommendations"), parent=self) - self._recommend_section.recommendation_clicked.connect(self._on_recommendation_clicked) - layout.addWidget(self._recommend_section) - - # Type tabs (hidden by default) - self._tabs = self._create_type_tabs() - self._tabs.hide() - layout.addWidget(self._tabs) - - # Content area - self._stack = QStackedWidget() - - # Top lists page (default) - self._top_list_page = self._create_top_list_page() - self._stack.addWidget(self._top_list_page) - - # Search results page - self._results_page = self._create_results_page() - self._stack.addWidget(self._results_page) - - # Detail view page - self._detail_view = OnlineDetailView( - config_manager=self._config, - qqmusic_service=self._qqmusic_service, - parent=self - ) - self._detail_view.back_requested.connect(self._on_back_from_detail) - # Connect play_all and add_all_to_queue signals - self._detail_view.play_all.connect(self._on_play_all_from_detail) - self._detail_view.insert_all_to_queue.connect(self._on_insert_all_to_queue_from_detail) - self._detail_view.add_all_to_queue.connect(self._on_add_all_to_queue_from_detail) - # Connect all tracks signals (from all pages) - self._detail_view.play_all_tracks.connect(self._on_play_all_from_detail) - self._detail_view.insert_all_tracks_to_queue.connect(self._on_insert_all_to_queue_from_detail) - self._detail_view.add_all_tracks_to_queue.connect(self._on_add_all_to_queue_from_detail) - # Connect album click from artist detail view - self._detail_view.album_clicked.connect(self._on_album_clicked) - self._stack.addWidget(self._detail_view) - - layout.addWidget(self._stack, 1) # Give stretch factor so it doesn't push other widgets - - # Load recommendations if logged in (after UI is fully set up) - if self._service._has_qqmusic_credential(): - self._load_recommendations() - self._load_favorites() - - def showEvent(self, event): - """Handle show event - load top lists on first display.""" - super().showEvent(event) - if not self._top_lists_loaded: - self._top_lists_loaded = True - self._load_top_lists() - - def closeEvent(self, event): - """Handle close event and unregister global event filter.""" - self._unregister_focus_clear_filter() - super().closeEvent(event) - - def _register_focus_clear_filter(self): - """Install app-level event filter for clearing search focus on outside click.""" - app = QApplication.instance() - if app and not self._focus_filter_registered: - app.installEventFilter(self) - self._focus_filter_registered = True - - def _unregister_focus_clear_filter(self): - """Remove app-level event filter.""" - app = QApplication.instance() - if app and self._focus_filter_registered: - app.removeEventFilter(self) - self._focus_filter_registered = False - - def eventFilter(self, watched, event): - """Clear search input focus when clicking outside search-related popups.""" - if ( - event.type() == QEvent.MouseButtonPress - and hasattr(self, "_search_input") - and self._search_input - and self._search_input.hasFocus() - and self.isVisible() - ): - global_pos = event.globalPosition().toPoint() - clicked_widget = QApplication.widgetAt(global_pos) - if clicked_widget and not self._is_search_related_widget(clicked_widget): - self._search_input.clearFocus() - - return super().eventFilter(watched, event) - - def _is_search_related_widget(self, widget: QWidget) -> bool: - """Return whether clicked widget belongs to search input or its related popups.""" - if widget is self._search_input or self._search_input.isAncestorOf(widget): - return True - - if ( - self._hotkey_popup - and (widget is self._hotkey_popup or self._hotkey_popup.isAncestorOf(widget)) - ): - return True - - if self._completer: - popup = self._completer.popup() - if popup and (widget is popup or popup.isAncestorOf(widget)): - return True - - return False - - def _create_header(self) -> QWidget: - """Create header with QQ Music login status.""" - widget = QWidget() - layout = QHBoxLayout(widget) - layout.setContentsMargins(0, 0, 0, 0) - - # Title - self._online_music_title = QLabel(t("online_music")) - layout.addWidget(self._online_music_title) - - layout.addStretch() - - # QQ Music login status - self._login_status_label = QLabel() - layout.addWidget(self._login_status_label) - - # Login/Logout button - self._login_btn = QPushButton() - self._login_btn.setCursor(Qt.PointingHandCursor) - self._login_btn.clicked.connect(self._on_login_clicked) - layout.addWidget(self._login_btn) - - self._update_login_status() - - return widget - - def _create_search_bar(self) -> QWidget: - """Create search bar.""" - widget = QWidget() - layout = QHBoxLayout(widget) - layout.setContentsMargins(0, 0, 0, 0) - - # Search input with built-in clear button - self._search_input = SearchInputWithHotkey() - self._search_input.setPlaceholderText(t("search_online_music")) - self._search_input.returnPressed.connect(self._on_search) - self._search_input.textChanged.connect(self._on_search_text_changed) - self._search_input.setFixedHeight(50) - self._search_input.setClearButtonEnabled(True) - - # Connect focus events for hotkey popup - self._search_input.focus_gained.connect(self._on_search_focus_gained) - self._search_input.focus_lost.connect(self._on_search_focus_lost) - self._search_input.escape_pressed.connect(self._on_escape_pressed) - - # Setup completer for search suggestions - self._completer = CustomQCompleter(self) - self._completer.setCaseSensitivity(Qt.CaseInsensitive) - # Use PopupCompletion mode to show all matching suggestions - self._completer.setCompletionMode(QCompleter.PopupCompletion) - self._completer.setMaxVisibleItems(10) - # Set filter mode to show anything that contains the typed text - self._completer.setFilterMode(Qt.MatchContains) - self._search_input.setCompleter(self._completer) - - # Connect completion activation - self._completer.activated.connect(self._on_completion_selected) - - layout.addWidget(self._search_input, 1) - - # Search button - self._search_btn = QPushButton(t("search")) - self._search_btn.setCursor(Qt.PointingHandCursor) - self._search_btn.clicked.connect(self._on_search) - layout.addWidget(self._search_btn) - - return widget - - def _create_type_tabs(self) -> QTabBar: - """Create search type tabs.""" - tabs = QTabBar() - tabs.setObjectName("searchTypeTabs") - tabs.setExpanding(False) - tabs.setCursor(Qt.PointingHandCursor) - - # Add tabs - tabs.addTab(t("songs")) - tabs.addTab(t("singers")) - tabs.addTab(t("albums")) - tabs.addTab(t("playlists")) - - tabs.currentChanged.connect(self._on_tab_changed) - tabs.setStyleSheet(""" - QTabBar::tab { - background: transparent; - color: #808080; - padding: 8px 20px; - border-bottom: 2px solid transparent; - } - QTabBar::tab:selected { - color: #1db954; - border-bottom: 2px solid #1db954; - } - QTabBar::tab:hover { - color: #1db954; - } - """) - - return tabs - - def _create_top_list_page(self) -> QWidget: - """Create top list page (default view).""" - widget = QWidget() - layout = QHBoxLayout(widget) - layout.setContentsMargins(0, 10, 0, 0) - - # Left: list of top lists - left_widget = QWidget() - left_layout = QVBoxLayout(left_widget) - left_layout.setContentsMargins(0, 0, 0, 0) - - self._rankings_title = QLabel(t("rankings")) - left_layout.addWidget(self._rankings_title) - - self._top_list_list = QListWidget() - self._top_list_list.setObjectName("topListList") - self._top_list_list.setMouseTracking(True) - self._top_list_list.setCursor(Qt.PointingHandCursor) - self._top_list_list.currentRowChanged.connect(self._on_top_list_selected) - left_layout.addWidget(self._top_list_list) - - layout.addWidget(left_widget, 1) - - # Right: songs in selected top list - right_widget = QWidget() - right_layout = QVBoxLayout(right_widget) - right_layout.setContentsMargins(10, 0, 0, 0) - - # Header with title and view toggle - header_layout = QHBoxLayout() - header_layout.setContentsMargins(0, 0, 0, 0) - - self._top_list_title = QLabel(t("select_ranking")) - header_layout.addWidget(self._top_list_title) - - header_layout.addStretch() - - # View toggle button - self._ranking_view_toggle_btn = QPushButton() - self._ranking_view_toggle_btn.setFixedSize(32, 32) - self._ranking_view_toggle_btn.setToolTip(t("toggle_view")) - self._ranking_view_toggle_btn.setCursor(Qt.PointingHandCursor) - self._ranking_view_toggle_btn.clicked.connect(self._toggle_ranking_view_mode) - header_layout.addWidget(self._ranking_view_toggle_btn) - - right_layout.addLayout(header_layout) - - # Stacked widget for table and list views - self._ranking_stacked_widget = QStackedWidget() - - self._top_songs_table = self._create_songs_table() - self._ranking_stacked_widget.addWidget(self._top_songs_table) - - self._ranking_list_view = OnlineTracksListView() - self._ranking_list_view.track_activated.connect(self._on_ranking_track_activated) - self._ranking_list_view.play_requested.connect(self._play_selected_tracks) - self._ranking_list_view.insert_to_queue_requested.connect(self._insert_selected_to_queue) - self._ranking_list_view.add_to_queue_requested.connect(self._add_selected_to_queue) - self._ranking_list_view.add_to_playlist_requested.connect(self._add_selected_to_playlist) - self._ranking_list_view.favorites_toggle_requested.connect(self._on_ranking_favorites_toggle) - self._ranking_list_view.download_requested.connect(self._download_selected_tracks) - self._ranking_list_view.favorite_toggled.connect(self._on_ranking_favorite_toggled) - self._ranking_stacked_widget.addWidget(self._ranking_list_view) - - right_layout.addWidget(self._ranking_stacked_widget) - - layout.addWidget(right_widget, 3) - - # Load view mode preference - self._load_ranking_view_mode() - - return widget - - def _create_results_page(self) -> QWidget: - """Create search results page with different views for each type.""" - widget = QWidget() - layout = QVBoxLayout(widget) - layout.setContentsMargins(0, 10, 0, 0) - - # Header with back button and results info - header_widget = QWidget() - header_layout = QHBoxLayout(header_widget) - header_layout.setContentsMargins(0, 0, 0, 0) - header_layout.setSpacing(10) - - # Back button (hidden by default, shown for favorites views) - self._fav_back_btn = QPushButton(f"← {t('back')}") - self._fav_back_btn.setCursor(Qt.PointingHandCursor) - self._fav_back_btn.clicked.connect(self._on_fav_back_clicked) - self._fav_back_btn.hide() - header_layout.addWidget(self._fav_back_btn) - - # Results info - self._results_info = QLabel() - header_layout.addWidget(self._results_info) - header_layout.addStretch() - - layout.addWidget(header_widget) - - # Stacked widget for different result types - self._results_stack = QStackedWidget() - - # Songs page - table view - self._songs_page = self._create_songs_result_page() - self._results_stack.addWidget(self._songs_page) - - # Singers page - grid view with circular avatars - self._singers_page = OnlineGridView(data_type="singer", parent=self) - self._singers_page.item_clicked.connect(self._on_artist_clicked) - self._singers_page.load_more_requested.connect(self._on_load_more_artists) - self._results_stack.addWidget(self._singers_page) - - # Albums page - grid view with rounded covers - self._albums_page = OnlineGridView(data_type="album", parent=self) - self._albums_page.item_clicked.connect(self._on_album_clicked) - self._albums_page.load_more_requested.connect(self._on_load_more_albums) - self._results_stack.addWidget(self._albums_page) - - # Playlists page - grid view with rounded covers - self._playlists_page = OnlineGridView(data_type="playlist", parent=self) - self._playlists_page.item_clicked.connect(self._on_playlist_clicked) - self._playlists_page.load_more_requested.connect(self._on_load_more_playlists) - self._results_stack.addWidget(self._playlists_page) - - layout.addWidget(self._results_stack) - - # Pagination (only for songs) - pagination = self._create_pagination() - layout.addWidget(pagination) - - return widget - - def _create_songs_result_page(self) -> QWidget: - """Create songs result page with table.""" - widget = QWidget() - layout = QVBoxLayout(widget) - layout.setContentsMargins(0, 0, 0, 0) - - # Results table - self._results_table = self._create_songs_table() - layout.addWidget(self._results_table) - - return widget - - def _create_songs_table(self) -> QTableWidget: - """Create songs table widget.""" - table = QTableWidget() - table.setObjectName("songsTable") - table.setColumnCount(5) - table.setHorizontalHeaderLabels([ - "#", t("title"), t("artist"), t("album"), t("duration") - ]) - table.horizontalHeader().setSectionResizeMode(0, QHeaderView.Fixed) - table.horizontalHeader().setSectionResizeMode(1, QHeaderView.Stretch) - table.horizontalHeader().setSectionResizeMode(2, QHeaderView.Stretch) - table.horizontalHeader().setSectionResizeMode(3, QHeaderView.Stretch) - table.horizontalHeader().setSectionResizeMode(4, QHeaderView.Fixed) - table.setColumnWidth(0, 50) - table.setColumnWidth(4, 80) - table.setSelectionBehavior(QAbstractItemView.SelectRows) - table.setSelectionMode(QAbstractItemView.ExtendedSelection) - table.setAlternatingRowColors(True) - table.setEditTriggers(QAbstractItemView.NoEditTriggers) - table.verticalHeader().setVisible(False) - table.doubleClicked.connect(self._on_track_double_clicked) - table.setContextMenuPolicy(Qt.CustomContextMenu) - table.customContextMenuRequested.connect(self._show_track_context_menu) - - return table - - def _create_pagination(self) -> QWidget: - """Create pagination widget.""" - widget = QWidget() - layout = QHBoxLayout(widget) - layout.setContentsMargins(0, 0, 0, 0) - - layout.addStretch() - - self._prev_btn = QPushButton("← " + t("previous_page")) - self._prev_btn.setFixedHeight(36) - self._prev_btn.setCursor(Qt.PointingHandCursor) - self._prev_btn.clicked.connect(self._on_prev_page) - layout.addWidget(self._prev_btn) - - self._page_label = QLabel("1") - layout.addWidget(self._page_label) - - self._next_btn = QPushButton(t("next_page") + " →") - self._next_btn.setFixedHeight(36) - self._next_btn.setCursor(Qt.PointingHandCursor) - self._next_btn.clicked.connect(self._on_next_page) - layout.addWidget(self._next_btn) - - layout.addStretch() - - return widget - - def refresh_theme(self): - """Refresh all styles using current theme tokens.""" - from system.theme import ThemeManager - tm = ThemeManager.instance() - - # Main widget styles - self.setStyleSheet(tm.get_qss(self._STYLE_BUTTONS)) - - # Header - self._online_music_title.setStyleSheet(tm.get_qss(self._STYLE_TITLE)) - self._login_status_label.setStyleSheet(tm.get_qss(self._STYLE_STATUS_LABEL)) - - # Search input - self._search_input.setStyleSheet(tm.get_qss(self._STYLE_SEARCH_INPUT)) - - # Tabs - self._tabs.setStyleSheet(tm.get_qss(self._STYLE_TABS)) - - # Top list page - self._rankings_title.setStyleSheet(tm.get_qss(self._STYLE_RANKINGS_TITLE)) - self._top_list_title.setStyleSheet(tm.get_qss(self._STYLE_RANKINGS_TITLE)) - - # Results page - self._fav_back_btn.setStyleSheet(tm.get_qss(self._STYLE_FAV_BACK_BTN)) - self._results_info.setStyleSheet(tm.get_qss(self._STYLE_RESULTS_INFO)) - - # Songs tables - self._top_songs_table.setStyleSheet(tm.get_qss(self._STYLE_SONGS_TABLE)) - self._results_table.setStyleSheet(tm.get_qss(self._STYLE_SONGS_TABLE)) - - # Pagination - self._page_label.setStyleSheet(tm.get_qss(self._STYLE_PAGE_LABEL)) - - # Refresh completer popup - if hasattr(self, '_completer') and self._completer: - self._completer.refresh_theme() - - # Refresh hotkey popup - if self._hotkey_popup: - self._hotkey_popup.refresh_theme() - - def _refresh_qqmusic_service(self): - """Refresh QQ Music service with current credentials.""" - import json - from plugins.builtin.qqmusic.lib.legacy.qqmusic_service import QQMusicService - - if self._config and hasattr(self._config, "get_plugin_secret"): - qqmusic_credential = self._config.get_plugin_secret("qqmusic", "credential", "") - elif self._config: - qqmusic_credential = self._config.get("qqmusic.credential") - else: - qqmusic_credential = None - if qqmusic_credential: - try: - cred_dict = json.loads(qqmusic_credential) if isinstance(qqmusic_credential, - str) else qqmusic_credential - self._qqmusic_service = QQMusicService(cred_dict) - # Update service reference - self._service._qqmusic = self._qqmusic_service - # Update download service reference too - self._download_service._qqmusic = self._qqmusic_service - # Update detail view service references - if hasattr(self, '_detail_view') and self._detail_view: - self._detail_view._service._qqmusic = self._qqmusic_service - self._detail_view._download_service._qqmusic = self._qqmusic_service - logger.info(f"QQ Music service refreshed, musicid={cred_dict.get('musicid')}, " - f"has_refresh_key={bool(cred_dict.get('refresh_key'))}") - except Exception as e: - logger.error(f"Failed to refresh QQ Music service: {e}") - - def _update_login_status(self): - """Update QQ Music login status display.""" - has_credential = self._service._has_qqmusic_credential() - - if has_credential: - # Refresh QQ Music service with new credentials - self._refresh_qqmusic_service() - - # Get nickname from config - if self._config and hasattr(self._config, "get_plugin_setting"): - nick = self._config.get_plugin_setting("qqmusic", "nick", "") - else: - nick = "" - - if nick: - self._login_status_label.setText(t("qqmusic_logged_in_as").format(nick=nick)) - else: - self._login_status_label.setText(t("qqmusic_logged_in")) - - self._login_btn.setText(t("logout")) - - # Load recommendations when logged in (only if UI is fully set up) - if hasattr(self, '_recommend_section'): - self._load_recommendations() - else: - self._login_status_label.setText(t("qqmusic_not_logged_in")) - self._login_btn.setText(t("login")) - - # Hide recommendations when not logged in - if hasattr(self, '_recommend_section'): - self._recommend_section.hide() - - def _on_login_clicked(self): - """Handle login button click.""" - if self._service._has_qqmusic_credential(): - # Logout - if self._config: - if hasattr(self._config, "set_plugin_setting"): - self._config.set_plugin_setting("qqmusic", "credential", None) - self._config.set_plugin_setting("qqmusic", "nick", "") - self._update_login_status() - MessageDialog.information(self, t("logout"), t("logout_success")) - else: - # Show login dialog - self._show_login_dialog() - - def _show_login_dialog(self): - """Show QQ Music login dialog.""" - from plugins.builtin.qqmusic.lib.login_dialog import QQMusicLoginDialog - - dialog = QQMusicLoginDialog(self) - dialog.exec() - - def _on_credentials_obtained(self, credential: dict): - """Handle credentials obtained from login dialog.""" - logger.info("QQ Music credentials obtained, refreshing service...") - self._refresh_qqmusic_service() - self._update_login_status() - # Reload favorites with new credentials - self._fav_loaded = False - self._load_favorites() - - def _load_recommendations(self): - """Load all 5 types of recommendations.""" - if self._recommendations_loaded: - return - - self._recommend_section.show_loading() - self._recommendations_loaded = True - - # Define 5 recommendation types with their display titles - recommend_types = [ - ("home_feed", t("home_recommend")), - ("guess", t("guess_you_like")), - ("radar", t("radar_recommend")), - ("newsong", t("new_songs")), - ("songlist", t("recommend_playlists")), - ] - - for recommend_type, title in recommend_types: - worker = RecommendWorker(self._qqmusic_service, recommend_type) - worker.recommend_ready.connect(self._on_recommend_ready) - self._recommend_workers.append(worker) - worker.start() - - def _on_recommend_ready(self, recommend_type: str, data: Any): - """Handle recommendation data ready.""" - # Store raw data for parsing - self._recommendations[recommend_type] = data - - # Check if all recommendations are loaded - expected_types = ["home_feed", "guess", "radar", "songlist", "newsong"] - loaded_count = sum(1 for t in expected_types if t in self._recommendations) - - # Only display when all 5 are loaded - if loaded_count == len(expected_types): - self._display_recommendations() - - def _display_recommendations(self): - """Parse and display all loaded recommendations.""" - cards = [] - - # Define order and titles - recommend_config = [ - ("home_feed", t("home_recommend")), - ("guess", t("guess_you_like")), - ("radar", t("radar_recommend")), - ("newsong", t("new_songs")), - ("songlist", t("recommend_playlists")), - ] - - for recommend_type, title in recommend_config: - data = self._recommendations.get(recommend_type) - if not data: - continue - - parsed = self._parse_recommendation(recommend_type, data) - if parsed: - parsed['recommend_type'] = recommend_type - parsed['title'] = title - cards.append(parsed) - - if cards: - self._recommend_section.load_recommendations(cards) - # Show recommendations section after loading - self._recommend_section.show() - - def _load_favorites(self): - """Load user's favorites counts and display 4 summary cards.""" - if self._fav_loaded: - return - - if not self._qqmusic_service or not self._qqmusic_service._credential: - return - - self._fav_loaded = True - self._favorites_section.show_loading() - self._fav_data = {} # Store data for click handling - - for fav_type in ["fav_songs", "created_playlists", "fav_playlists", "fav_albums", "followed_singers"]: - worker = FavWorker(self._qqmusic_service, fav_type) - worker.fav_ready.connect(self._on_fav_ready) - self._fav_workers.append(worker) - worker.start() - - def _on_fav_ready(self, fav_type: str, data: list): - """Handle favorites data ready - store for later use.""" - self._fav_data[fav_type] = data - - # Check if all 5 types loaded (initial load) - if len(self._fav_data) == 5: - self._display_favorites_cards() - - def _get_random_cover(self, items: list) -> str: - """Get a random cover from a list of items.""" - import random - - if not items: - return "" - - # Filter items that have cover_url - items_with_cover = [item for item in items if item.get("cover_url")] - - if not items_with_cover: - return "" - - # Select a random item - random_item = random.choice(items_with_cover) - return random_item.get("cover_url", "") - - def _get_random_cover_from_items(self, data: list, recommend_type: str) -> str: - """Extract a random cover from recommendation data based on type.""" - import random - - if not data: - return "" - - # Filter items that have valid cover data - valid_items = [] - for item in data: - if not isinstance(item, dict): - continue - - cover_url = None - - if recommend_type == 'songlist': - # Playlist structure - playlist_info = item.get('Playlist', item) - if isinstance(playlist_info, dict): - # Try basic/content structures - if 'basic' in playlist_info: - basic = playlist_info.get('basic', {}) - if isinstance(basic, dict): - cover = basic.get('cover_url') or basic.get('cover') or basic.get('picurl') - if cover: - if isinstance(cover, dict): - cover_url = cover.get('default_url') or cover.get('small_url') - else: - cover_url = cover - - if not cover_url and 'content' in playlist_info: - content = playlist_info.get('content', {}) - if isinstance(content, dict): - cover = content.get('cover_url') or content.get('cover') - if cover: - if isinstance(cover, dict): - cover_url = cover.get('default_url') or cover.get('small_url') - else: - cover_url = cover - - if not cover_url: - cover = (playlist_info.get('cover_url') or playlist_info.get('cover') or - playlist_info.get('picurl') or playlist_info.get('pic')) - if cover: - if isinstance(cover, dict): - cover_url = cover.get('default_url') or cover.get('small_url') - else: - cover_url = cover - - # Try to get from songlist - if not cover_url: - song_list = playlist_info.get('songlist', []) - if song_list: - album = song_list[0].get('album', {}) - if isinstance(album, dict): - album_mid = album.get('mid') - if album_mid: - cover_url = f"https://y.gtimg.cn/music/photo_new/T002R300x300M000{album_mid}.jpg" - - elif recommend_type == 'radar': - # Radar structure - track_info = item.get('Track', {}) - if isinstance(track_info, dict): - album = track_info.get('album', {}) - if isinstance(album, dict): - album_mid = album.get('mid') - if album_mid: - cover_url = f"https://y.gtimg.cn/music/photo_new/T002R300x300M000{album_mid}.jpg" - - else: - # Song structure (guess, home_feed, newsong) - cover_url = (item.get('cover') or item.get('picurl') or - item.get('cover_url') or item.get('pic')) - - if not cover_url: - album_mid = None - album = item.get('album', {}) - if isinstance(album, dict): - album_mid = album.get('mid') - if not album_mid: - album_mid = item.get('albummid') or item.get('album_mid') - - if album_mid: - cover_url = f"https://y.gtimg.cn/music/photo_new/T002R300x300M000{album_mid}.jpg" - - if cover_url: - valid_items.append(cover_url) - - if valid_items: - return random.choice(valid_items) - - return "" - - def _display_favorites_cards(self): - """Display 4 summary cards in the favorites section.""" - - cards = [] - - # Card 1: 收藏歌曲 - fav_songs = self._fav_data.get("fav_songs", []) - cards.append({ - "id": "fav_songs", - "title": t("fav_songs"), - "subtitle": f"{len(fav_songs)} {t('songs')}", - "cover_url": self._get_random_cover(fav_songs), - "card_type": "fav_songs", - }) - - # Card 2: 创建的歌单 - created_pl = self._fav_data.get("created_playlists", []) - cards.append({ - "id": "created_playlists", - "title": t("created_playlists"), - "subtitle": f"{len(created_pl)} {t('playlists')}", - "cover_url": self._get_random_cover(created_pl), - "card_type": "created_playlists", - }) - - # Card 3: 收藏的歌单 - fav_pl = self._fav_data.get("fav_playlists", []) - cards.append({ - "id": "fav_playlists", - "title": t("fav_playlists"), - "subtitle": f"{len(fav_pl)} {t('playlists')}", - "cover_url": self._get_random_cover(fav_pl), - "card_type": "fav_playlists", - }) +Compatibility shim for the retired host online music view. - # Card 4: 收藏专辑 - fav_albums = self._fav_data.get("fav_albums", []) - cards.append({ - "id": "fav_albums", - "title": t("fav_albums"), - "subtitle": f"{len(fav_albums)} {t('albums')}", - "cover_url": self._get_random_cover(fav_albums), - "card_type": "fav_albums", - }) - - # Card 5: 关注歌手 - followed_singers = self._fav_data.get("followed_singers", []) - cards.append({ - "id": "followed_singers", - "title": t("followed_singers"), - "subtitle": f"{len(followed_singers)} {t('singers')}", - "cover_url": self._get_random_cover(followed_singers), - "card_type": "followed_singers", - }) - - self._favorites_section.load_recommendations(cards) - - def _parse_recommendation(self, recommend_type: str, data: Any) -> Optional[Dict[str, Any]]: - """Parse recommendation data to extract card info.""" - try: - # Handle list response (API returns list of songs/playlists) - if isinstance(data, list): - if not data: - return None - - # Get first item for structure analysis - first_item = data[0] - if not isinstance(first_item, dict): - return None - - # Get a random cover from all items - cover_url = self._get_random_cover_from_items(data, recommend_type) - playlist_id = None - - # Handle different response structures based on type - if recommend_type == 'songlist': - # Playlist structure: {'Playlist': {...}, 'WhereFrom': ..., 'ext': ...} - # or direct structure with tid/id/disstid - - playlist_info = first_item.get('Playlist', {}) - if isinstance(playlist_info, dict) and playlist_info: - - # Try nested structures first (basic/content) - if 'basic' in playlist_info: - basic = playlist_info.get('basic', {}) - if isinstance(basic, dict): - playlist_id = basic.get('tid') or basic.get('id') or basic.get('disstid') - - if not playlist_id and 'content' in playlist_info: - content = playlist_info.get('content', {}) - if isinstance(content, dict): - playlist_id = content.get('tid') or content.get('id') or content.get('disstid') - - # Fallback to direct fields - if not playlist_id: - playlist_id = playlist_info.get('tid') or playlist_info.get('id') or playlist_info.get( - 'disstid') - - else: - # Try direct structure - check for various ID fields - playlist_id = (first_item.get('tid') or first_item.get('id') or - first_item.get('disstid') or first_item.get('dissid')) - - elif recommend_type == 'radar': - # Radar structure: {'Track': {...}, 'Abt': ..., 'Ext': ...} - # Cover is already extracted by _get_random_cover_from_items - pass - - elif recommend_type == 'home_feed': - # Home feed returns recommendation cards (playlists, rankings, songs) - # Each card has type: 200=song, 500=playlist, 700=guess, 1000=ranking - playlist_id = first_item.get('id') - - else: - # Song structure: {'album': {...}, 'singer': [...], ...} - # This handles guess, newsong types - - # Cover is already extracted by _get_random_cover_from_items - # Get playlist ID if available (for playlist-based recommendations) - playlist_id = (first_item.get('id') or first_item.get('disstid') or - first_item.get('tid') or first_item.get('playlist_id')) - - return { - 'id': playlist_id, - 'cover_url': cover_url, - 'raw_data': first_item, # Save first item for click handling - 'full_data': data, # Save full data list for song-based recommendations - 'recommend_type': recommend_type, - } - - # Handle dict response (API returns dict with embedded list) - if isinstance(data, dict): - # Log the structure for debugging - - # Try to find the main content - content = None - for key in ['songlist', 'songs', 'list', 'data', 'items', 'playlist']: - if key in data: - content = data[key] - break - - if content and isinstance(content, list) and content: - first_item = content[0] - if isinstance(first_item, dict): - # Get a random cover from all items - cover_url = self._get_random_cover_from_items(content, recommend_type) - playlist_id = (first_item.get('id') or first_item.get('disstid') or - first_item.get('tid') or data.get('id')) - - return { - 'id': playlist_id, - 'cover_url': cover_url, - 'raw_data': first_item, # Save first item for click handling - 'full_data': content, # Save full data list for song-based recommendations - 'recommend_type': recommend_type, - } - - # Check for playlist info directly - playlist_id = data.get('id') or data.get('disstid') - cover_url = data.get('cover') or data.get('picurl') or data.get('pic') - - return { - 'id': playlist_id, - 'cover_url': cover_url, - 'raw_data': data, - 'recommend_type': recommend_type, - } - - return None - except Exception as e: - logger.error(f"Failed to parse recommendation {recommend_type}: {e}") - return None - - def _on_favorites_card_clicked(self, data: Dict[str, Any]): - """Handle favorites section card click.""" - card_type = data.get("card_type", "") - - # Hide favorites and recommendations when viewing any favorites content - self._favorites_section.hide() - self._recommend_section.hide() - # Show back button - self._fav_back_btn.show() - - if card_type == "fav_songs": - tracks = self._fav_data.get("fav_songs", []) - self._show_fav_songs_in_table(tracks) - elif card_type == "created_playlists": - playlists = self._fav_data.get("created_playlists", []) - self._show_playlist_list_in_detail(t("created_playlists"), playlists) - elif card_type == "fav_playlists": - playlists = self._fav_data.get("fav_playlists", []) - self._show_playlist_list_in_detail(t("fav_playlists"), playlists) - elif card_type == "fav_albums": - albums = self._fav_data.get("fav_albums", []) - self._show_album_list_in_detail(t("fav_albums"), albums) - elif card_type == "followed_singers": - singers = self._fav_data.get("followed_singers", []) - self._show_singer_list_in_detail(t("followed_singers"), singers) - - def _show_fav_songs_in_table(self, tracks: list): - """Show favorite songs in the detail view with play all / add to queue buttons.""" - # Convert to the format expected by load_songs_directly - songs = [] - cover_url = "" - for t_data in tracks: - song = { - "mid": t_data.get("mid", ""), - "songmid": t_data.get("mid", ""), - "title": t_data.get("title", ""), - "songname": t_data.get("title", ""), - "name": t_data.get("title", ""), - "singer": [{"mid": "", "name": t_data.get("singer", "")}] if t_data.get("singer") else [], - "album": { - "mid": t_data.get("album_mid", ""), - "name": t_data.get("album", ""), - }, - "interval": t_data.get("duration", 0), - } - songs.append(song) - # Use first song's cover - if not cover_url and t_data.get("cover_url"): - cover_url = t_data.get("cover_url") - - # Use detail view to show songs with play all / add to queue buttons - self._detail_view.load_songs_directly(songs, t("fav_songs"), cover_url) - self._stack.setCurrentWidget(self._detail_view) - - def _show_playlist_list_in_detail(self, title: str, playlists: list): - """Show a list of playlists in the grid view.""" - from domain.online_music import OnlinePlaylist - - # Clear previous data - self._playlists_page.clear() - - # Convert dicts to OnlinePlaylist objects - online_playlists = [OnlinePlaylist( - id=str(pl.get("id", "")), - title=pl.get("title", ""), - cover_url=pl.get("cover_url", ""), - creator=pl.get("creator", ""), - song_count=pl.get("song_count", 0), - play_count=pl.get("play_count", 0), - ) for pl in playlists] - - self._playlists_page.load_data(online_playlists) - self._results_info.setText(title) - self._tabs.hide() - self._is_top_list_view = False - self._results_stack.setCurrentWidget(self._playlists_page) - self._stack.setCurrentWidget(self._results_page) - - # Push navigation state - self._navigation_stack.append({ - 'page': 'playlists', - 'title': title, - 'data': playlists - }) - - def _show_album_list_in_detail(self, title: str, albums: list): - """Show a list of albums in the grid view.""" - from domain.online_music import OnlineAlbum - - # Clear previous data - self._albums_page.clear() - - # Convert dicts to OnlineAlbum objects - online_albums = [] - for album in albums: - singer_name = album.get("singer_name", "") - online_albums.append(OnlineAlbum( - mid=album.get("mid", ""), - name=album.get("title", ""), - singer_mid="", - singer_name=singer_name, - cover_url=album.get("cover_url", ""), - song_count=album.get("song_count", 0), - )) - - self._albums_page.load_data(online_albums) - self._results_info.setText(title) - self._tabs.hide() - self._is_top_list_view = False - self._results_stack.setCurrentWidget(self._albums_page) - self._stack.setCurrentWidget(self._results_page) - - # Push navigation state - self._navigation_stack.append({ - 'page': 'albums', - 'title': title, - 'data': albums - }) - - def _show_singer_list_in_detail(self, title: str, singers: list): - """Show a list of followed singers in the grid view.""" - from domain.online_music import OnlineArtist - - # Clear previous data - self._singers_page.clear() - - # Convert dicts to OnlineArtist objects - artists = [OnlineArtist( - mid=singer.get("mid", ""), - name=singer.get("name", ""), - avatar_url=singer.get("cover_url", ""), - fan_count=singer.get("fan_count", 0), - ) for singer in singers] - - self._singers_page.load_data(artists) - self._results_info.setText(title) - self._tabs.hide() - self._is_top_list_view = False - self._results_stack.setCurrentWidget(self._singers_page) - self._stack.setCurrentWidget(self._results_page) - - # Push navigation state - self._navigation_stack.append({ - 'page': 'singers', - 'title': title, - 'data': singers - }) - - def _on_recommendation_clicked(self, data: Dict[str, Any]): - """Handle recommendation card click.""" - # Hide favorites and recommendations when viewing details - self._favorites_section.hide() - self._recommend_section.hide() - # Show back button for playlist list view - self._fav_back_btn.show() - - recommend_type = data.get('recommend_type', '') - raw_data = data.get('raw_data') - full_data = data.get('full_data') # Full song list for song-based recommendations - - title = data.get('title', '') - cover_url = data.get('cover_url', '') - - if not isinstance(raw_data, dict): - logger.warning(f"Invalid raw_data type: {type(raw_data)}") - return - - # Handle songlist type - show list of playlists - if recommend_type == 'songlist': - # full_data contains the list of playlists - if full_data and isinstance(full_data, list): - playlists = [] - for item in full_data: - if isinstance(item, dict): - # Extract playlist info from nested structure - playlist_info = item.get('Playlist', item) - if not isinstance(playlist_info, dict): - continue - - # Try to get playlist details from various nested structures - # Structure: basic/content/diy - playlist_id = None - playlist_title = None - cover_url = None - song_count = 0 - - # Try basic structure first - if 'basic' in playlist_info: - basic = playlist_info.get('basic', {}) - if isinstance(basic, dict): - playlist_id = basic.get('tid') or basic.get('id') or basic.get('disstid') - playlist_title = basic.get('title') or basic.get('name') - # Cover can be URL string or dict - cover = basic.get('cover_url') or basic.get('cover') or basic.get('picurl') - if cover: - if isinstance(cover, dict): - cover_url = cover.get('default_url') or cover.get('small_url') - else: - cover_url = cover - - # Try content structure - if not playlist_id and 'content' in playlist_info: - content = playlist_info.get('content', {}) - if isinstance(content, dict): - playlist_id = content.get('tid') or content.get('id') or content.get('disstid') - if not playlist_title: - playlist_title = content.get('title') or content.get('name') - if not cover_url: - cover = content.get('cover_url') or content.get('cover') - if cover: - if isinstance(cover, dict): - cover_url = cover.get('default_url') or cover.get('small_url') - else: - cover_url = cover - - # Fallback to direct fields - if not playlist_id: - playlist_id = (playlist_info.get('tid') or playlist_info.get('id') or - playlist_info.get('disstid') or playlist_info.get('dissid')) - if not playlist_title: - playlist_title = playlist_info.get('title') or playlist_info.get('name') - if not cover_url: - cover = (playlist_info.get('cover_url') or playlist_info.get('cover') or - playlist_info.get('picurl') or playlist_info.get('pic')) - if cover: - if isinstance(cover, dict): - cover_url = cover.get('default_url') or cover.get('small_url') - else: - cover_url = cover - - # Try to get song count from various fields - song_count = 0 - - # Check basic/content for song_count - try multiple field name variations - if 'basic' in playlist_info: - basic = playlist_info.get('basic', {}) - if isinstance(basic, dict): - song_count = (basic.get('song_count') or basic.get('song_num') or - basic.get('songNum') or basic.get('song_cnt') or 0) - if not song_count and 'content' in playlist_info: - content = playlist_info.get('content', {}) - if isinstance(content, dict): - song_count = (content.get('song_count') or content.get('song_num') or - content.get('songNum') or content.get('song_cnt') or 0) - # Check songlist if exists - if not song_count: - song_list = playlist_info.get('songlist', []) - if song_list: - song_count = len(song_list) - # Fallback to direct field - try multiple field name variations - if not song_count: - song_count = (playlist_info.get('song_count') or playlist_info.get('song_num') or - playlist_info.get('songNum') or playlist_info.get('song_cnt') or - playlist_info.get('songnum') or 0) - - # Try to get play count - play_count = 0 - if 'basic' in playlist_info: - basic = playlist_info.get('basic', {}) - if isinstance(basic, dict): - play_count = (basic.get('play_cnt') or basic.get('listennum') or - basic.get('play_count') or 0) - if not play_count and 'content' in playlist_info: - content = playlist_info.get('content', {}) - if isinstance(content, dict): - play_count = (content.get('play_cnt') or content.get('listennum') or - content.get('play_count') or 0) - if not play_count: - play_count = (playlist_info.get('play_cnt') or playlist_info.get('listennum') or - playlist_info.get('play_count') or 0) - - if playlist_id: - playlists.append({ - 'id': str(playlist_id), - 'title': playlist_title or '', - 'cover_url': cover_url or '', - 'song_count': song_count, - 'play_count': play_count or 0, - }) - - logger.info(f"Showing {len(playlists)} recommended playlists") - if playlists: - self._show_playlist_list_in_detail(title, playlists) - else: - logger.warning("No valid playlists found in songlist data") - else: - logger.warning(f"Invalid full_data for songlist: {type(full_data)}") - return - - # Handle radar type - Track info, show all radar songs - if recommend_type == 'radar': - if full_data and isinstance(full_data, list): - # Radar data format: [{'Track': {...}, 'Abt': ..., 'Ext': ...}, ...] - # Need to extract Track from each item - songs = [] - for item in full_data: - if isinstance(item, dict): - track = item.get('Track', item) - if isinstance(track, dict): - songs.append(track) - logger.info(f"Loading radar songs: {len(songs)} songs") - if songs: - self._detail_view.load_songs_directly(songs, title, cover_url) - self._stack.setCurrentWidget(self._detail_view) - else: - logger.warning("No valid radar songs found") - else: - # Fallback to album - track_info = raw_data.get('Track', raw_data) - album = track_info.get('album', {}) - if isinstance(album, dict) and album.get('mid'): - logger.info(f"Loading radar album: {album.get('mid')}") - self._detail_view.load_album(album.get('mid'), album.get('name', title), "") - self._stack.setCurrentWidget(self._detail_view) - return - - # Handle guess, home_feed, newsong types - these return songs, show all songs - if recommend_type in ('guess', 'home_feed', 'newsong'): - if full_data and isinstance(full_data, list): - logger.info(f"Loading {recommend_type} songs: {len(full_data)} songs") - self._detail_view.load_songs_directly(full_data, title, cover_url) - self._stack.setCurrentWidget(self._detail_view) - return - - # Fallback to album if no full_data - album = raw_data.get('album', {}) - if isinstance(album, dict) and album.get('mid'): - logger.info(f"Loading album from {recommend_type}: {album.get('mid')}") - self._detail_view.load_album(album.get('mid'), album.get('name', title), "") - self._stack.setCurrentWidget(self._detail_view) - return - - logger.warning(f"Could not determine how to handle recommendation: {recommend_type}") - - def _on_search(self): - """Handle search.""" - keyword = self._search_input.text().strip() - if not keyword: - self._search_input.clearFocus() - return - - # Save to search history - if self._config: - self._config.add_search_history(keyword) - - # Hide favorites and recommendations sections when searching - self._favorites_section.hide() - self._recommend_section.hide() - - self._current_keyword = keyword - self._current_page = 1 - self._grid_page = 1 # Reset grid page for new search - self._tabs.show() - - # Immediately switch to results page and show searching state - self._stack.setCurrentWidget(self._results_page) - self._results_info.setText(t("searching")) - self._results_table.setRowCount(0) - self._prev_btn.setEnabled(False) - self._next_btn.setEnabled(False) - - # Clear all result pages (not just songs) - self._singers_page.clear() - self._albums_page.clear() - self._playlists_page.clear() - - self._do_search() - - def _on_search_focus_gained(self): - """Handle search input focus gained - show hotkey popup if empty.""" - text = self._search_input.text().strip() - if not text: - # Always show popup when gaining focus and input is empty - # Even if popup is already visible - self._show_hotkey_popup() - - def _on_search_focus_lost(self): - """Handle search input focus lost - hide hotkey popup.""" - # Delay hiding to allow click on hotkey items - QTimer.singleShot(200, self._hide_hotkey_popup) - - def _show_hotkey_popup(self): - """Show hotkey popup below search input with search history and hotkeys.""" - if not self._hotkey_popup: - self._hotkey_popup = HotkeyPopup(self) - self._hotkey_popup.hotkey_clicked.connect(self._on_hotkey_clicked) - self._hotkey_popup.clear_history_requested.connect(self._on_clear_history) - self._hotkey_popup.delete_history_requested.connect(self._on_delete_history_item) - - # Get search history - history = self._config.get_search_history() if self._config else [] - - # If we have both history and hotkeys cached, show combined - if history and self._hotkeys: - self._hotkey_popup.set_combined(history, self._hotkeys) - input_rect = self._search_input.rect() - global_pos = self._search_input.mapToGlobal(input_rect.bottomLeft()) - self._hotkey_popup.show_at(global_pos, self._search_input.width()) - # If we have history but no hotkeys, show history and load hotkeys - elif history: - self._hotkey_popup.set_search_history(history) - input_rect = self._search_input.rect() - global_pos = self._search_input.mapToGlobal(input_rect.bottomLeft()) - self._hotkey_popup.show_at(global_pos, self._search_input.width()) - # Load hotkeys in background - if not self._hotkeys and self._qqmusic_service: - self._load_hotkeys() - # If we have hotkeys but no history, show hotkeys - elif self._hotkeys: - self._hotkey_popup.set_hotkeys(self._hotkeys) - input_rect = self._search_input.rect() - global_pos = self._search_input.mapToGlobal(input_rect.bottomLeft()) - self._hotkey_popup.show_at(global_pos, self._search_input.width()) - # No history and no hotkeys - load hotkeys - elif self._qqmusic_service: - self._load_hotkeys() - - def _hide_hotkey_popup(self): - """Hide hotkey popup.""" - if self._hotkey_popup and self._hotkey_popup.isVisible(): - # Only hide if search input doesn't have focus anymore - if not self._search_input.hasFocus(): - self._hotkey_popup.hide() - - def _load_hotkeys(self): - """Load hotkey suggestions from QQ Music.""" - if not self._qqmusic_service: - return - - self._hotkey_request_id += 1 - request_id = self._hotkey_request_id - - self._hotkey_worker = HotkeyWorker(self._qqmusic_service) - self._hotkey_worker.hotkey_ready.connect( - lambda hotkeys, rid=request_id: self._on_hotkey_ready(hotkeys, rid) - ) - self._hotkey_worker.start() - - def _on_hotkey_ready( - self, - hotkeys: List[Dict[str, Any]], - request_id: int | None = None - ): - """Handle hotkey suggestions ready.""" - if request_id is not None and request_id != self._hotkey_request_id: - return - - if hotkeys: - self._hotkeys = hotkeys - # Show popup if search input is still empty and focused - text = self._search_input.text().strip() - if not text and self._search_input.hasFocus(): - if not self._hotkey_popup: - self._hotkey_popup = HotkeyPopup(self) - self._hotkey_popup.hotkey_clicked.connect(self._on_hotkey_clicked) - self._hotkey_popup.clear_history_requested.connect(self._on_clear_history) - self._hotkey_popup.delete_history_requested.connect(self._on_delete_history_item) - - # Get search history and show combined - history = self._config.get_search_history() if self._config else [] - if history and hotkeys: - self._hotkey_popup.set_combined(history, hotkeys) - elif history: - self._hotkey_popup.set_search_history(history) - else: - self._hotkey_popup.set_hotkeys(hotkeys) - - # Position popup below search input - input_rect = self._search_input.rect() - global_pos = self._search_input.mapToGlobal(input_rect.bottomLeft()) - self._hotkey_popup.show_at(global_pos, self._search_input.width()) - - def _on_hotkey_clicked(self, title: str): - """Handle hotkey button click.""" - self._search_input.setText(title) - self._on_search() - - def _on_clear_history(self): - """Handle clear all search history.""" - if self._config: - self._config.clear_search_history() - # Refresh popup if it's visible - if self._hotkey_popup and self._hotkey_popup.isVisible(): - self._show_hotkey_popup() - - def _on_delete_history_item(self, keyword: str): - """Handle delete a search history item.""" - if self._config: - self._config.remove_search_history_item(keyword) - # Refresh popup - self._show_hotkey_popup() - - def _on_escape_pressed(self): - """Handle Escape key press - hide both hotkey popup and completer popup, then clear focus.""" - # Hide hotkey popup - if self._hotkey_popup and self._hotkey_popup.isVisible(): - self._hotkey_popup.hide() - - # Hide completer popup - if self._completer and self._completer.popup().isVisible(): - self._completer.popup().hide() - - # Clear focus from search input - self._search_input.clearFocus() - - def _on_search_text_changed(self, text: str): - """Handle search text change - show top lists when cleared.""" - # Hide hotkey popup when user starts typing - if text and self._hotkey_popup and self._hotkey_popup.isVisible(): - self._hotkey_popup.hide() - - if not text and self._current_keyword: - # Text was cleared, go back to top lists - self._current_keyword = "" - self._current_page = 1 - self._grid_page = 1 - self._grid_total = 0 - # Don't clear _current_tracks - keep the top list songs that were already loaded - self._tabs.hide() - # Hide back button - self._fav_back_btn.hide() - # Clear grid views - self._singers_page.clear() - self._albums_page.clear() - self._playlists_page.clear() - # Switch to top list page - self._stack.setCurrentWidget(self._top_list_page) - # Show favorites and recommendations when returning to main view - if self._fav_loaded and self._fav_data: - self._favorites_section.show() - if self._recommendations_loaded: - self._recommend_section.show() - elif text and len(text) >= 1 and self._qqmusic_service: - # Trigger completion after delay (debounce) - self._completion_timer.start(300) # 300ms delay - - def _trigger_completion(self): - """Trigger search completion request.""" - keyword = self._search_input.text().strip() - if not keyword or len(keyword) < 1: - return - - self._completion_request_id += 1 - request_id = self._completion_request_id - - # Note: Completion API works without login too - self._completion_worker = CompletionWorker(self._qqmusic_service, keyword) - self._completion_worker.completion_ready.connect( - lambda suggestions, rid=request_id: self._on_completion_ready(suggestions, rid) - ) - self._completion_worker.start() - - def _on_completion_ready( - self, - suggestions: List[Dict[str, Any]], - request_id: int | None = None - ): - """Handle completion suggestions ready.""" - if request_id is not None and request_id != self._completion_request_id: - return - - if not suggestions: - return - - # Extract suggestion hints (the text to display) - suggestion_texts = [s.get('hint', '') for s in suggestions if s.get('hint')] - - logger.info(f"Search completion: {len(suggestion_texts)} suggestions - {suggestion_texts[:3]}") - - # Update completer model - model = QStringListModel(suggestion_texts) - self._completer.setModel(model) - - # Set the completion prefix to current text so matches work correctly - current_text = self._search_input.text() - self._completer.setCompletionPrefix(current_text) - - # Show completion popup - ensure the input still has focus - if suggestion_texts and self._search_input.hasFocus(): - self._completer.complete() - elif suggestion_texts: - # Input doesn't have focus, don't show popup - logger.debug("Search input lost focus, not showing completion") - - def _on_completion_selected(self, text: str): - """Handle completion selection.""" - # Set the selected text and trigger search - self._search_input.setText(text) - self._on_search() - - def _do_search(self): - """Execute search.""" - self._search_request_id += 1 - request_id = self._search_request_id - - self._search_worker = SearchWorker( - self._service, - self._current_keyword, - self._current_search_type, - self._current_page, - 30 - ) - self._search_worker.search_completed.connect( - lambda result, rid=request_id: self._on_search_completed(result, rid) - ) - self._search_worker.search_failed.connect( - lambda error, rid=request_id: self._on_search_failed(error, rid) - ) - self._search_worker.start() - - def _on_search_completed(self, result: SearchResult, request_id: int | None = None): - """Handle search completion.""" - if request_id is not None and request_id != self._search_request_id: - return - - self._current_result = result - self._stack.setCurrentWidget(self._results_page) - self._is_top_list_view = False # Now viewing search results - - if self._current_search_type == SearchType.SONG: - self._current_tracks = result.tracks - self._display_tracks(result.tracks) - self._results_stack.setCurrentWidget(self._songs_page) - elif self._current_search_type == SearchType.SINGER: - self._grid_total = result.total - self._display_artists(result.artists, is_append=False) - self._results_stack.setCurrentWidget(self._singers_page) - elif self._current_search_type == SearchType.ALBUM: - self._grid_total = result.total - self._display_albums(result.albums, is_append=False) - self._results_stack.setCurrentWidget(self._albums_page) - elif self._current_search_type == SearchType.PLAYLIST: - self._grid_total = result.total - self._display_playlists(result.playlists, is_append=False) - self._results_stack.setCurrentWidget(self._playlists_page) - - # Update results info - self._results_info.setText( - f"{t('search_result')}: {result.total} {t('results')}" - ) - - # Update pagination (only for songs) - self._page_label.setText(str(self._current_page)) - self._prev_btn.setEnabled(self._current_page > 1) - self._next_btn.setEnabled(len(result.tracks) == 30) - - # Hide pagination for non-song results - if self._current_search_type != SearchType.SONG: - self._prev_btn.parentWidget().hide() - else: - self._prev_btn.parentWidget().show() - - def _on_search_failed(self, error: str, request_id: int | None = None): - """Handle search failure.""" - if request_id is not None and request_id != self._search_request_id: - return - - logger.error(f"Search failed: {error}") - MessageDialog.warning(self, t("error"), t("search_failed") + f": {error}") - - def _display_tracks(self, tracks: List[OnlineTrack]): - """Display tracks in table.""" - self._results_table.setRowCount(len(tracks)) - self._results_table.setColumnCount(5) - - for i, track in enumerate(tracks): - # Index - self._results_table.setItem(i, 0, QTableWidgetItem(str(i + 1))) - - # Title - title_item = QTableWidgetItem(track.title) - if track.is_vip: - title_item.setForeground(QBrush(QColor("#ffd700"))) - self._results_table.setItem(i, 1, title_item) - - # Artist - self._results_table.setItem(i, 2, QTableWidgetItem(track.singer_name)) - - # Album - self._results_table.setItem(i, 3, QTableWidgetItem(track.album_name)) - - # Duration - duration_str = format_duration(track.duration) if track.duration else "" - self._results_table.setItem(i, 4, QTableWidgetItem(duration_str)) - - def _display_artists(self, artists: List[OnlineArtist], is_append: bool = False): - """Display artists in grid view.""" - if is_append: - self._singers_page.append_data(artists) - else: - self._singers_page.load_data(artists) - - # Show "load more" button if there are more results - has_more = len(artists) >= self._grid_page_size and ( - self._grid_total == 0 or # Unknown total, assume more - self._grid_page * self._grid_page_size < self._grid_total - ) - self._singers_page.set_has_more(has_more) - - def _display_albums(self, albums: List[OnlineAlbum], is_append: bool = False): - """Display albums in grid view.""" - if is_append: - self._albums_page.append_data(albums) - else: - self._albums_page.load_data(albums) - - # Show "load more" button if there are more results - has_more = len(albums) >= self._grid_page_size and ( - self._grid_total == 0 or - self._grid_page * self._grid_page_size < self._grid_total - ) - self._albums_page.set_has_more(has_more) - - def _display_playlists(self, playlists: List[OnlinePlaylist], is_append: bool = False): - """Display playlists in grid view.""" - if is_append: - self._playlists_page.append_data(playlists) - else: - self._playlists_page.load_data(playlists) - - # Show "load more" button if there are more results - has_more = len(playlists) >= self._grid_page_size and ( - self._grid_total == 0 or - self._grid_page * self._grid_page_size < self._grid_total - ) - self._playlists_page.set_has_more(has_more) - - def _on_tab_changed(self, index: int): - """Handle tab change.""" - type_map = { - 0: SearchType.SONG, - 1: SearchType.SINGER, - 2: SearchType.ALBUM, - 3: SearchType.PLAYLIST, - } - self._current_search_type = type_map.get(index, SearchType.SONG) - - # Re-search if there's a keyword - if self._current_keyword: - self._current_page = 1 - self._grid_page = 1 # Reset grid page for new tab - self._do_search() - - def _on_artist_clicked(self, artist: OnlineArtist): - """Handle artist click - show artist detail view.""" - # Push navigation state if we're coming from search results or grid view - if self._stack.currentWidget() in [self._results_page]: - self._navigation_stack.append({ - 'page': 'results', - 'tab': 'artists' if self._results_stack.currentWidget() == self._singers_page else 'other' - }) - self._detail_view.load_artist(artist.mid, artist.name) - self._stack.setCurrentWidget(self._detail_view) - - def _on_album_clicked(self, album: OnlineAlbum): - """Handle album click - show album detail view.""" - # Push navigation state if we're coming from search results or detail view - current_widget = self._stack.currentWidget() - if current_widget == self._results_page: - self._navigation_stack.append({ - 'page': 'results', - 'tab': 'albums' if self._results_stack.currentWidget() == self._albums_page else 'other' - }) - elif current_widget == self._detail_view: - # Coming from artist detail - push detail state - self._navigation_stack.append({ - 'page': 'detail', - 'type': self._detail_view._detail_type, - 'mid': self._detail_view._mid - }) - self._detail_view.load_album(album.mid, album.name, album.singer_name) - self._stack.setCurrentWidget(self._detail_view) - - def _on_playlist_clicked(self, playlist: OnlinePlaylist): - """Handle playlist click - show playlist detail view.""" - # Push navigation state if we're coming from search results or detail view - current_widget = self._stack.currentWidget() - if current_widget == self._results_page: - self._navigation_stack.append({ - 'page': 'results', - 'tab': 'playlists' if self._results_stack.currentWidget() == self._playlists_page else 'other' - }) - elif current_widget == self._detail_view: - # Coming from artist detail - push detail state - self._navigation_stack.append({ - 'page': 'detail', - 'type': self._detail_view._detail_type, - 'mid': self._detail_view._mid - }) - self._detail_view.load_playlist(playlist.id, playlist.title, playlist.creator) - self._stack.setCurrentWidget(self._detail_view) - - def _on_load_more_artists(self): - """Load more artists.""" - self._grid_page += 1 - self._singers_page.show_loading() - self._load_more_grid(SearchType.SINGER) - - def _on_load_more_albums(self): - """Load more albums.""" - self._grid_page += 1 - self._albums_page.show_loading() - self._load_more_grid(SearchType.ALBUM) - - def _on_load_more_playlists(self): - """Load more playlists.""" - self._grid_page += 1 - self._playlists_page.show_loading() - self._load_more_grid(SearchType.PLAYLIST) - - def _load_more_grid(self, search_type: str): - """Load more items for grid view.""" - self._search_request_id += 1 - request_id = self._search_request_id - - self._search_worker = SearchWorker( - self._service, - self._current_keyword, - search_type, - self._grid_page, - self._grid_page_size - ) - self._search_worker.search_completed.connect( - lambda result, rid=request_id: self._on_load_more_completed(result, search_type, rid) - ) - self._search_worker.search_failed.connect( - lambda error, rid=request_id: self._on_load_more_failed(error, rid) - ) - self._search_worker.start() - - def _on_load_more_completed( - self, - result: SearchResult, - search_type: str, - request_id: int | None = None - ): - """Handle load more completion.""" - if request_id is not None and request_id != self._search_request_id: - return - - if search_type == SearchType.SINGER: - self._singers_page.hide_loading() - self._display_artists(result.artists, is_append=True) - elif search_type == SearchType.ALBUM: - self._albums_page.hide_loading() - self._display_albums(result.albums, is_append=True) - elif search_type == SearchType.PLAYLIST: - self._playlists_page.hide_loading() - self._display_playlists(result.playlists, is_append=True) - - # Update total - self._grid_total = result.total - - def _on_load_more_failed(self, error: str, request_id: int | None = None): - """Handle load more failure.""" - if request_id is not None and request_id != self._search_request_id: - return - - logger.error(f"Load more failed: {error}") - # Hide loading on all grid views - self._singers_page.hide_loading() - self._albums_page.hide_loading() - self._playlists_page.hide_loading() - MessageDialog.warning(self, t("error"), t("search_failed") + f": {error}") - - def _on_back_from_detail(self): - """Handle back button clicked in detail view.""" - # Pop from navigation stack if available - if self._navigation_stack: - prev_state = self._navigation_stack.pop() - page = prev_state.get('page') - - if page == 'playlists': - # Return to playlist list - title = prev_state.get('title', '') - playlists = prev_state.get('data', []) - self._show_playlist_list_in_detail(title, playlists) - return - elif page == 'albums': - # Return to album list - title = prev_state.get('title', '') - albums = prev_state.get('data', []) - self._show_album_list_in_detail(title, albums) - return - elif page == 'results': - # Return to search results - self._stack.setCurrentWidget(self._results_page) - # Restore correct tab if specified - tab = prev_state.get('tab', '') - if tab == 'artists': - self._results_stack.setCurrentWidget(self._singers_page) - elif tab == 'albums': - self._results_stack.setCurrentWidget(self._albums_page) - elif tab == 'playlists': - self._results_stack.setCurrentWidget(self._playlists_page) - return - elif page == 'detail': - # Return to previous detail view (e.g., artist detail) - detail_type = prev_state.get('type', '') - mid = prev_state.get('mid') - if detail_type == 'artist' and mid: - # Reload artist detail - self._detail_view.load_artist(mid) - return - elif detail_type == 'album' and mid: - # Reload album detail - self._detail_view.load_album(mid) - return - elif detail_type == 'playlist' and mid: - # Reload playlist detail - self._detail_view.load_playlist(mid) - return - - # Default behavior: return to previous page based on context - # If tabs are visible, we came from search results - # Otherwise, return to top list page - if self._tabs.isVisible(): - self._stack.setCurrentWidget(self._results_page) - else: - self._stack.setCurrentWidget(self._top_list_page) - # Show favorites and recommendations when returning to main view - if self._fav_loaded and self._fav_data: - self._favorites_section.show() - if self._recommendations_loaded: - self._recommend_section.show() - - def _on_fav_back_clicked(self): - """Handle back button click from favorites view.""" - # Hide back button - self._fav_back_btn.hide() - # Clear navigation stack when returning to main view - self._navigation_stack.clear() - # Show favorites and recommendations - if self._fav_loaded and self._fav_data: - self._favorites_section.show() - if self._recommendations_loaded: - self._recommend_section.show() - # Return to top list page - self._stack.setCurrentWidget(self._top_list_page) - - def _get_cover_url(self, track: OnlineTrack) -> str: - """Get cover URL for online track.""" - if track.album and track.album.mid: - return f"https://y.qq.com/music/photo_new/T002R300x300M000{track.album.mid}.jpg" - return "" - - def _build_track_metadata(self, track: OnlineTrack) -> Dict[str, Any]: - """Build standardized metadata payload for online track playback/queue actions.""" - return { - "title": track.title, - "artist": track.singer_name, - "album": track.album_name, - "duration": track.duration, - "album_mid": track.album.mid if track.album else "", - "cover_url": self._get_cover_url(track), - } - - def _build_tracks_payload(self, tracks: List[OnlineTrack]) -> List[tuple[str, Dict[str, Any]]]: - """Build `(song_mid, metadata)` payload list while preserving input order.""" - return [(track.mid, self._build_track_metadata(track)) for track in tracks] - - def _on_play_all_from_detail(self, tracks: List[OnlineTrack], index: int = 0): - """Handle play all from detail view.""" - if not tracks: - return - - # Build list of (song_mid, metadata) for all tracks - tracks_data = self._build_tracks_payload(tracks) - - # Emit signal to play all tracks, starting from first - self.play_online_tracks.emit(index, tracks_data) - - def _on_add_all_to_queue_from_detail(self, tracks: List[OnlineTrack]): - """Handle add all to queue from detail view.""" - tracks_data = self._build_tracks_payload(tracks) - self.add_multiple_to_queue.emit(tracks_data) - - def _on_insert_all_to_queue_from_detail(self, tracks: List[OnlineTrack]): - """Handle insert all to queue from detail view.""" - tracks_data = self._build_tracks_payload(tracks) - self.insert_multiple_to_queue.emit(tracks_data) - - def _on_prev_page(self): - """Go to previous page.""" - if self._current_page > 1: - self._current_page -= 1 - self._do_search() - - def _on_next_page(self): - """Go to next page.""" - self._current_page += 1 - self._do_search() - - def _on_track_double_clicked(self, index): - """Handle track double click.""" - row = index.row() - if row < 0 or row >= len(self._current_tracks): - return - - # If viewing top list, play all songs starting from clicked - if self._is_top_list_view: - self._play_all_from_top_list(row) - else: - track = self._current_tracks[row] - self._play_track(track) - - def _play_all_from_top_list(self, start_index: int): - """Play all songs from top list starting from given index.""" - tracks_data = self._build_tracks_payload(self._current_tracks) - - self.play_online_tracks.emit(start_index, tracks_data) - - def _play_track(self, track: OnlineTrack): - """Play an online track.""" - # Build metadata from track info - metadata = self._build_track_metadata(track) - - # Check cache - if self._download_service.is_cached(track.mid): - cached_path = self._download_service.get_cached_path(track.mid) - self.play_online_track.emit(track.mid, cached_path, metadata) - return - - # Download first - self._download_and_play(track) - - def _download_and_play(self, track: OnlineTrack): - """Download track and then play.""" - from PySide6.QtWidgets import QProgressDialog - - # Show progress dialog - self._download_progress = QProgressDialog(f"{t('downloading')}: {track.title}", t("cancel"), 0, 0, self) - self._download_progress.setWindowTitle(t("downloading")) - self._download_progress.setWindowModality(Qt.WindowModal) - self._download_progress.setMinimumDuration(0) - self._download_progress.canceled.connect(lambda: self._cancel_download(track.mid)) - - # Store current track for callback - self._downloading_track = track - - # Create download worker - self._download_worker = DownloadWorker( - self._download_service, track.mid, track.title - ) - self._download_worker.download_finished.connect(self._on_download_finished) - self._attach_download_worker_cleanup( - self._download_worker, - single_attr="_download_worker", - ) - self._download_worker.start() - - def _on_download_finished(self, song_mid: str, local_path: str): - """Handle download finished.""" - logger.info(f"Download finished callback: mid={song_mid}, path={local_path}") - - # Close progress dialog - if hasattr(self, '_download_progress') and self._download_progress: - self._download_progress.close() - - # Get stored track - track = getattr(self, '_downloading_track', None) - if not track: - logger.error("No downloading_track found") - return - - # Skip if download was cancelled - if hasattr(self, '_download_worker') and self._download_worker._cancelled: - logger.info(f"Download cancelled: {song_mid}") - return - - if song_mid == track.mid and local_path: - logger.info(f"Emitting play_online_track: {song_mid}, {local_path}") - # Build metadata from track info - metadata = self._build_track_metadata(track) - self.play_online_track.emit(song_mid, local_path, metadata) - else: - logger.warning(f"Download failed or mismatch: mid={song_mid}, track.mid={track.mid}, path={local_path}") - MessageDialog.warning(self, t("error"), t("download_failed")) - - def _cancel_download(self, song_mid: str): - """Cancel ongoing download.""" - if hasattr(self, '_download_worker') and self._download_worker: - self._download_worker.cancel() - if hasattr(self, '_download_progress') and self._download_progress: - self._download_progress.close() - - def _attach_download_worker_cleanup(self, worker, *, list_attr: str = None, single_attr: str = None): - """Release finished download workers and schedule QObject cleanup.""" - - def on_thread_finished(): - if list_attr: - workers = getattr(self, list_attr, None) - if workers is not None and worker in workers: - workers.remove(worker) - if single_attr and getattr(self, single_attr, None) is worker: - setattr(self, single_attr, None) - worker.deleteLater() - - worker.finished.connect(on_thread_finished) - - def _show_track_context_menu(self, pos): - """Show context menu for track.""" - # Determine which table sent the signal - sender_table = self.sender() - if sender_table == self._top_songs_table: - table = self._top_songs_table - is_top_list = True - else: - table = self._results_table - is_top_list = False - - # Get selected rows - selected_items = table.selectedItems() - if not selected_items: - logger.debug(f"No items selected in {'top list' if is_top_list else 'search'} table") - return - - # Get unique row indices - selected_rows = sorted(set(item.row() for item in selected_items)) - if not selected_rows: - return - - # Validate row indices - if selected_rows[0] < 0 or selected_rows[-1] >= len(self._current_tracks): - logger.warning(f"Invalid row indices: {selected_rows}, tracks count: {len(self._current_tracks)}") - return - - tracks = [self._current_tracks[r] for r in selected_rows if 0 <= r < len(self._current_tracks)] - if not tracks: - logger.warning("No valid tracks found for selected rows") - return - - menu = QMenu(self) - from system.theme import ThemeManager - menu.setStyleSheet(ThemeManager.instance().get_qss(self._STYLE_MENU)) - - play_action = menu.addAction(t("play")) - play_action.triggered.connect(lambda: self._play_selected_tracks(tracks)) - - insert_to_queue_action = menu.addAction(t("insert_to_queue")) - insert_to_queue_action.triggered.connect(lambda: self._insert_selected_to_queue(tracks)) - - add_to_queue_action = menu.addAction(t("add_to_queue")) - add_to_queue_action.triggered.connect(lambda: self._add_selected_to_queue(tracks)) - - menu.addSeparator() - - # Add to favorites action - add_to_favorites_action = menu.addAction(t("add_to_favorites")) - add_to_favorites_action.triggered.connect(lambda: self._add_selected_to_favorites(tracks)) - - # Add to playlist action - add_to_playlist_action = menu.addAction(t("add_to_playlist")) - add_to_playlist_action.triggered.connect(lambda: self._add_selected_to_playlist(tracks)) - - menu.addSeparator() - - download_action = menu.addAction(t("download")) - download_action.triggered.connect(lambda: self._download_selected_tracks(tracks)) - - menu.exec(table.viewport().mapToGlobal(pos)) - - def _download_selected_tracks(self, tracks: List[OnlineTrack]): - """Download selected tracks.""" - if not tracks: - return - - # Download each track - for track in tracks: - if not self._download_service.is_cached(track.mid): - self._start_download(track) - - def _start_download(self, track: OnlineTrack): - """Start downloading a track.""" - worker = DownloadWorker(self._download_service, track.mid, track.title) - worker.download_finished.connect(self._on_batch_download_finished) - self._attach_download_worker_cleanup(worker, list_attr="_download_workers") - worker.start() - # Keep reference to prevent garbage collection - if not hasattr(self, '_download_workers'): - self._download_workers = [] - self._download_workers.append(worker) - - def _on_batch_download_finished(self, song_mid: str, local_path: str): - """Handle batch download finished.""" - if local_path: - logger.info(f"Download completed: {song_mid} -> {local_path}") - else: - logger.warning(f"Download failed: {song_mid}") - - def _add_selected_to_favorites(self, tracks: List[OnlineTrack]): - """Add selected online tracks to favorites.""" - if not tracks: - return - - added_count = 0 - from app.bootstrap import Bootstrap - - bootstrap = Bootstrap.instance() - favorites_service = bootstrap.favorites_service - - for track in tracks: - track_id = self._add_online_track_to_library(track) - if track_id: - favorites_service.add_favorite(track_id=track_id) - added_count += 1 - # Update ranking view UI if track is visible - if hasattr(self, '_ranking_list_view'): - self._ranking_list_view.set_track_favorite(track.mid, True) - - if added_count > 0: - logger.info(f"[OnlineMusicView] Added {added_count} tracks to favorites") - MessageDialog.information( - self, - t("success"), - t("added_x_tracks_to_favorites").format(count=added_count) - ) - - def _add_selected_to_playlist(self, tracks: List[OnlineTrack]): - """Add selected online tracks to playlist.""" - if not tracks: - return - - from app.bootstrap import Bootstrap - from utils.playlist_utils import add_tracks_to_playlist - - bootstrap = Bootstrap.instance() - - # Add tracks to library first and collect track IDs - track_ids = [] - for track in tracks: - track_id = self._add_online_track_to_library(track) - if track_id: - track_ids.append(track_id) - - if not track_ids: - return - - add_tracks_to_playlist( - self, - bootstrap.library_service, - track_ids, - "[OnlineMusicView]" - ) - - def _add_online_track_to_library(self, track: OnlineTrack) -> Optional[int]: - """Add online track to library, return track_id.""" - from app.bootstrap import Bootstrap - - bootstrap = Bootstrap.instance() - if not bootstrap.library_service: - return None - - cover_url = self._get_cover_url(track) - - return bootstrap.library_service.add_online_track( - song_mid=track.mid, - title=track.title, - artist=track.singer_name, - album=track.album_name, - duration=float(track.duration), - cover_url=cover_url - ) - - def _play_selected_tracks(self, tracks: List[OnlineTrack]): - """Play selected tracks.""" - if not tracks: - return - # Play first track and add rest to queue - self._play_track(tracks[0]) - if len(tracks) > 1: - tracks_data = self._build_tracks_payload(tracks[1:]) - self.add_multiple_to_queue.emit(tracks_data) - - def _add_selected_to_queue(self, tracks: List[OnlineTrack]): - """Add selected tracks to queue.""" - tracks_data = self._build_tracks_payload(tracks) - self.add_multiple_to_queue.emit(tracks_data) - - def _insert_selected_to_queue(self, tracks: List[OnlineTrack]): - """Insert selected tracks after current playing track.""" - tracks_data = self._build_tracks_payload(tracks) - self.insert_multiple_to_queue.emit(tracks_data) - - def _load_top_lists(self): - """Load top lists.""" - self._stop_worker(self._top_list_worker, "top_list_worker") - - self._top_list_worker = TopListWorker(self._service) - self._top_list_worker.top_list_loaded.connect(self._on_top_lists_loaded) - self._top_list_worker.start() - - def _on_top_lists_loaded(self, top_lists: List[Dict]): - """Handle top lists loaded.""" - self._top_list_list.clear() - - for top_list in top_lists: - item = QListWidgetItem(top_list.get("title", "")) - item.setData(Qt.UserRole, top_list.get("id")) - self._top_list_list.addItem(item) - - # Select first item - if self._top_list_list.count() > 0: - self._top_list_list.setCurrentRow(0) - - def _on_top_list_selected(self, row: int): - """Handle top list selection.""" - item = self._top_list_list.item(row) - if not item: - return - - top_id = item.data(Qt.UserRole) - if not top_id: - return - - self._selected_top_id = int(top_id) - self._top_list_title.setText(item.text()) - - # Load songs - self._stop_worker(self._top_list_worker, "top_list_worker") - - self._top_list_worker = TopListWorker(self._service, self._selected_top_id) - self._top_list_worker.top_songs_loaded.connect(self._on_top_songs_loaded) - self._top_list_worker.start() - - def _stop_worker(self, worker, worker_name: str): - """Stop a running worker cooperatively without force-terminating threads.""" - if not worker or not isValid(worker): - return - if not worker.isRunning(): - return - - try: - worker.requestInterruption() - except Exception: - logger.debug(f"[OnlineMusicView] requestInterruption failed for {worker_name}", exc_info=True) - - try: - worker.quit() - except Exception: - logger.debug(f"[OnlineMusicView] quit failed for {worker_name}", exc_info=True) - - try: - if not worker.wait(1500): - logger.warning(f"[OnlineMusicView] Worker did not stop in time: {worker_name}") - except Exception: - logger.debug(f"[OnlineMusicView] wait failed for {worker_name}", exc_info=True) - - def _on_top_songs_loaded(self, top_id: int, songs: List[OnlineTrack]): - """Handle top songs loaded.""" - if top_id != self._selected_top_id: - return - - self._current_tracks = songs - self._is_top_list_view = True # Now viewing top list - self._display_top_songs(songs) - - def _display_top_songs(self, songs: List[OnlineTrack]): - """Display top songs in both table and list views.""" - # Update table view - self._top_songs_table.setRowCount(len(songs)) - - for i, song in enumerate(songs): - # Rank - self._top_songs_table.setItem(i, 0, QTableWidgetItem(str(i + 1))) - - # Title - title_item = QTableWidgetItem(song.title) - if song.is_vip: - title_item.setForeground(QBrush(QColor("#ffd700"))) - self._top_songs_table.setItem(i, 1, title_item) - - # Artist - self._top_songs_table.setItem(i, 2, QTableWidgetItem(song.singer_name)) - - # Album - self._top_songs_table.setItem(i, 3, QTableWidgetItem(song.album_name)) - - # Duration - duration_str = format_duration(song.duration) if song.duration else "" - self._top_songs_table.setItem(i, 4, QTableWidgetItem(duration_str)) - - # Update list view - self._ranking_list_view.load_tracks(songs) - - def _load_ranking_view_mode(self): - """Load ranking view mode preference from config.""" - view_mode = self._config.get("view/ranking_view_mode", "table") if self._config else "table" - self._update_ranking_view_toggle_icon() - self._ranking_stacked_widget.setCurrentIndex(0 if view_mode == "table" else 1) - - def _toggle_ranking_view_mode(self): - """Toggle between table and list view for rankings.""" - current_mode = self._config.get("view/ranking_view_mode", "table") if self._config else "table" - new_mode = "list" if current_mode == "table" else "table" - if self._config: - self._config.set("view/ranking_view_mode", new_mode) - self._update_ranking_view_toggle_icon() - self._ranking_stacked_widget.setCurrentIndex(0 if new_mode == "table" else 1) - - def _update_ranking_view_toggle_icon(self): - """Update ranking view toggle button icon.""" - view_mode = self._config.get("view/ranking_view_mode", "table") if self._config else "table" - theme = ThemeManager.instance().current_theme - - if view_mode == "list": - icon = get_icon(IconName.GRID, theme.text_secondary) - self._ranking_view_toggle_btn.setToolTip(t("switch_to_table_view")) - else: - icon = get_icon(IconName.LIST, theme.text_secondary) - self._ranking_view_toggle_btn.setToolTip(t("switch_to_list_view")) - - self._ranking_view_toggle_btn.setIcon(icon) - - def _on_ranking_track_activated(self, track): - """Handle track activation from ranking list view.""" - logger.info(f"Ranking track activated: {track.title}") - self._play_track(track) - - def _on_ranking_favorite_toggled(self, track, is_favorite: bool): - """Handle favorite toggle from ranking list view star click.""" - if not track: - return - from app.bootstrap import Bootstrap - bootstrap = Bootstrap.instance() - favorites_service = bootstrap.favorites_service - - if is_favorite: - track_id = self._add_online_track_to_library(track) - if track_id: - favorites_service.add_favorite(track_id=track_id) - self._ranking_list_view.set_track_favorite(track.mid, True) - else: - library_track = bootstrap.library_service.get_track_by_cloud_file_id(track.mid) - if library_track: - favorites_service.remove_favorite(track_id=library_track.id) - self._ranking_list_view.set_track_favorite(track.mid, False) - else: - favorites_service.remove_favorite(cloud_file_id=track.mid) - self._ranking_list_view.set_track_favorite(track.mid, False) - - def _on_ranking_favorites_toggle(self, tracks: list, all_favorited: bool): - """Handle favorite toggle from ranking list view context menu.""" - for track in tracks: - self._on_ranking_favorite_toggled(track, not all_favorited) - - - def refresh_ui(self): - """Refresh UI texts after language change.""" - # Update titles - if hasattr(self, '_online_music_title'): - self._online_music_title.setText(t("online_music")) - if hasattr(self, '_rankings_title'): - self._rankings_title.setText(t("rankings")) - - # Update search placeholder - if hasattr(self, '_search_input'): - self._search_input.setPlaceholderText(t("search_online_music")) - - # Update search button - if hasattr(self, '_search_btn'): - self._search_btn.setText(t("search")) - - # Update login button - self._update_login_status() - - # Update type tabs - if hasattr(self, '_tabs'): - self._tabs.setTabText(0, t("songs")) - self._tabs.setTabText(1, t("singers")) - self._tabs.setTabText(2, t("albums")) - self._tabs.setTabText(3, t("playlists")) - - # Update table headers for both tables - if hasattr(self, '_results_table'): - header = self._results_table.horizontalHeader() - if header.count() >= 5: - header.model().setHeaderData(0, Qt.Horizontal, "#") - header.model().setHeaderData(1, Qt.Horizontal, t("title")) - header.model().setHeaderData(2, Qt.Horizontal, t("artist")) - header.model().setHeaderData(3, Qt.Horizontal, t("album")) - header.model().setHeaderData(4, Qt.Horizontal, t("duration")) - if hasattr(self, '_top_songs_table'): - header = self._top_songs_table.horizontalHeader() - if header.count() >= 5: - header.model().setHeaderData(0, Qt.Horizontal, "#") - header.model().setHeaderData(1, Qt.Horizontal, t("title")) - header.model().setHeaderData(2, Qt.Horizontal, t("artist")) - header.model().setHeaderData(3, Qt.Horizontal, t("album")) - header.model().setHeaderData(4, Qt.Horizontal, t("duration")) - - # Update pagination buttons - if hasattr(self, '_prev_btn'): - self._prev_btn.setText("← " + t("previous_page")) - if hasattr(self, '_next_btn'): - self._next_btn.setText(t("next_page") + " →") - - # Update top list title if showing "select_ranking" placeholder - if hasattr(self, '_top_list_title'): - current_text = self._top_list_title.text() - # Only update if it's the placeholder text - if current_text == t("select_ranking") or current_text == "选择排行榜": - self._top_list_title.setText(t("select_ranking")) - - # Update grid views - if hasattr(self, '_singers_page'): - self._singers_page.refresh_ui() - if hasattr(self, '_albums_page'): - self._albums_page.refresh_ui() - if hasattr(self, '_playlists_page'): - self._playlists_page.refresh_ui() - - # Update recommend section - if hasattr(self, '_recommend_section'): - self._recommend_section.refresh_ui() - - # Update detail view - if hasattr(self, '_detail_view'): - self._detail_view.refresh_ui() - - -class DownloadWorker(QThread): - """Background worker for downloading online music.""" - - download_finished = Signal(str, str) # (song_mid, local_path) +The concrete implementation now lives in `legacy_online_music_view.py` so the +runtime can make its legacy-only status explicit while keeping older tests and +imports working during the plugin migration. +""" - def __init__(self, download_service, song_mid: str, song_title: str): - super().__init__() - self._download_service = download_service - self._song_mid = song_mid - self._song_title = song_title - self._cancelled = False +import sys - def cancel(self): - """Cancel the download.""" - self._cancelled = True +from . import legacy_online_music_view as _legacy_online_music_view - def run(self): - """Run download.""" - if self._cancelled: - self.download_finished.emit(self._song_mid, "") - return - try: - result = self._download_service.download(self._song_mid, self._song_title) - self.download_finished.emit(self._song_mid, result or "") - except Exception as e: - logger.error(f"Download worker error: {e}") - self.download_finished.emit(self._song_mid, "") +sys.modules[__name__] = _legacy_online_music_view diff --git a/ui/views/online_tracks_list_view.py b/ui/views/online_tracks_list_view.py index e834ac20..88eee29b 100644 --- a/ui/views/online_tracks_list_view.py +++ b/ui/views/online_tracks_list_view.py @@ -1,601 +1,12 @@ """ -Online tracks list view for displaying online music tracks. -""" - -import logging -from contextlib import suppress -from typing import List - -from PySide6.QtCore import Qt, Signal, QSize, QTimer, QPoint, QAbstractListModel, QModelIndex, QRunnable, QThreadPool, QRect -from PySide6.QtGui import QColor, QPainter, QImage, QCursor -from PySide6.QtWidgets import QWidget, QVBoxLayout, QListView, QStyledItemDelegate, QStyleOptionViewItem, QStyle - -from domain import TrackSource -from domain.online_music import OnlineTrack -from infrastructure.cache.pixmap_cache import CoverPixmapCache -from system import t -from system.event_bus import EventBus -from ui.views.cover_hover_popup import CoverHoverPopup -from ui.widgets.context_menus import OnlineTrackContextMenu -from utils.helpers import format_duration - -logger = logging.getLogger(__name__) - - -def _resolve_online_cover_path(track: OnlineTrack) -> str | None: - """Resolve online cover for QQ music track.""" - if not track: - return None - - try: - from app.bootstrap import Bootstrap - bootstrap = Bootstrap.instance() - if bootstrap and hasattr(bootstrap, 'cover_service'): - return bootstrap.cover_service.get_online_cover( - song_mid=track.mid, - album_mid=None, - artist=track.singer_name, - title=track.title, - ) - except Exception: - pass - - return None - - -class OnlineTracksModel(QAbstractListModel): - """QAbstractListModel for online track data.""" - - TrackRole = Qt.UserRole + 1 - CoverRole = Qt.UserRole + 2 - IsFavoriteRole = Qt.UserRole + 3 - RankRole = Qt.UserRole + 4 - IsVipRole = Qt.UserRole + 5 - IndexRole = Qt.UserRole + 6 - - cover_ready = Signal(int) - - def __init__(self, parent=None): - super().__init__(parent) - self._tracks: List[OnlineTrack] = [] - self._favorite_mids: set = set() # QQ music song mids - - def rowCount(self, parent=QModelIndex()): - return len(self._tracks) - - def data(self, index, role=Qt.DisplayRole): - if not index.isValid() or index.row() >= len(self._tracks): - return None - row = index.row() - track = self._tracks[row] - if role == self.TrackRole: - return track - elif role == self.CoverRole: - return None - elif role == self.IsFavoriteRole: - return track.mid in self._favorite_mids if track else False - elif role == self.RankRole: - return row + 1 - elif role == self.IsVipRole: - return track.pay_play if track else False - elif role == self.IndexRole: - return row - return None - - def roleNames(self): - return { - Qt.DisplayRole: b"display", - self.TrackRole: b"track", - self.CoverRole: b"cover", - self.IsFavoriteRole: b"favorite", - self.RankRole: b"rank", - self.IsVipRole: b"vip", - self.IndexRole: b"index", - } - - def reset_tracks(self, tracks: List[OnlineTrack], favorite_mids: set): - self.beginResetModel() - self._tracks = list(tracks) - self._favorite_mids = set(favorite_mids) - self.endResetModel() - - def update_favorites(self, favorite_mids: set): - """Update favorite MIDs and emit dataChanged for affected rows.""" - old_favs = self._favorite_mids - self._favorite_mids = set(favorite_mids) - - # Find rows that changed - for i, track in enumerate(self._tracks): - if track and (track.mid in old_favs) != (track.mid in self._favorite_mids): - idx = self.index(i) - self.dataChanged.emit(idx, idx, [self.IsFavoriteRole]) - - def get_track_at(self, row: int): - if 0 <= row < len(self._tracks): - return self._tracks[row] - return None - - def notify_cover_loaded(self, row: int): - if 0 <= row < len(self._tracks): - idx = self.index(row) - self.dataChanged.emit(idx, idx, [self.CoverRole]) - - -class OnlineCoverLoadWorker(QRunnable): - """Worker to load online cover in background thread.""" - - def __init__(self, cache_key: str, track: OnlineTrack, callback_signal): - super().__init__() - self.cache_key = cache_key - self.track = track - self.callback_signal = callback_signal - self.setAutoDelete(True) - - def run(self): - try: - cover_path = self._resolve_online_cover() - qimage = None - if cover_path: - qimage = QImage(cover_path) - with suppress(RuntimeError): - self.callback_signal.emit(self.cache_key, cover_path, qimage) - except Exception: - pass - - def _resolve_online_cover(self) -> str | None: - """Resolve online cover for QQ music track.""" - return _resolve_online_cover_path(self.track) - - -class OnlineTracksDelegate(QStyledItemDelegate): - """Delegate for painting online track items without per-item QWidget overhead.""" - - _cover_loaded_signal = Signal(str, object, object) - - def __init__(self, parent=None): - super().__init__(parent) - self._cover_loaded_signal.connect(self._on_cover_loaded) - self._requested_covers: set = set() - self._failed_covers: set = set() - CoverPixmapCache.initialize() - self._cover_size = 64 - self._rank_width = 50 - self._padding = 10 - self._star_size = 20 - - def sizeHint(self, option, index): - return QSize(0, 82) - - def paint(self, painter: QPainter, option: QStyleOptionViewItem, index: QModelIndex): - # Skip off-screen items - parent_view = self.parent() - if parent_view and (option.rect.bottom() < 0 or option.rect.top() > parent_view.height()): - return - - from system.theme import ThemeManager - theme = ThemeManager.instance().current_theme - - track = index.data(OnlineTracksModel.TrackRole) - rank = index.data(OnlineTracksModel.RankRole) - is_vip = index.data(OnlineTracksModel.IsVipRole) - row = index.data(OnlineTracksModel.IndexRole) - - if not track: - return - - painter.save() - painter.setRenderHint(QPainter.Antialiasing) - - rect = option.rect - - # Background - is_hovered = option.state & QStyle.StateFlag.State_MouseOver - is_selected = option.state & QStyle.StateFlag.State_Selected - - if is_selected: - painter.fillRect(rect, QColor(theme.highlight)) - elif is_hovered: - hover_bg = QColor(theme.background_hover) - hover_bg.setAlpha(220) - painter.fillRect(rect, hover_bg) - # Hand cursor on hover - if self.parent(): - self.parent().setCursor(Qt.CursorShape.PointingHandCursor) - else: - bg = QColor(theme.background) - bg.setAlpha(220) - painter.fillRect(rect, bg) - - # Separator line - if not is_selected: - painter.setPen(QColor(theme.background_hover)) - painter.drawLine(rect.left(), rect.bottom(), rect.right(), rect.bottom()) - - # Text colors - if is_selected: - text_color = QColor(theme.background) - secondary_color = QColor(theme.background) - elif is_vip: - # VIP tracks: gold title - text_color = QColor("#FFD700") - secondary_color = QColor(theme.text_secondary) - else: - text_color = QColor(theme.text) - secondary_color = QColor(theme.text_secondary) - - x = rect.left() + self._padding - - # Index number - painter.setPen(secondary_color) - font = painter.font() - font.setPixelSize(12) - font.setBold(False) - painter.setFont(font) - - painter.drawText(x, rect.top(), self._rank_width, rect.height(), - Qt.AlignVCenter | Qt.AlignHCenter, str(rank)) - x += self._rank_width - - # Cover art - cover_rect = QRect(x + 2, rect.top() + 9, self._cover_size, self._cover_size) - self._paint_cover(painter, cover_rect, track, row, theme) - x += self._cover_size + 12 - - # Title (with VIP indicator) - title = track.title or "Unknown" - if is_vip: - title = f"VIP {title}" - - painter.setPen(text_color) - font.setPixelSize(15) - font.setBold(True) - painter.setFont(font) - title_rect = QRect(x, rect.top() + 10, rect.right() - x - 100, 22) - painter.drawText(title_rect, Qt.AlignLeft | Qt.AlignVCenter, - self._elided_text(painter, title, title_rect.width())) - - # Artist + Album - artist = track.singer_name or "Unknown" - album = track.album_name or "" - artist_album = artist + (f" • {album}" if album else "") - - painter.setPen(secondary_color) - font.setPixelSize(13) - font.setBold(False) - painter.setFont(font) - info_rect = QRect(x, rect.top() + 32, rect.right() - x - 100, 20) - painter.drawText(info_rect, Qt.AlignLeft | Qt.AlignVCenter, - self._elided_text(painter, artist_album, info_rect.width())) +Compatibility shim for the QQ Music online tracks list view. - # Source indicator (QQ Music) - source_text = t("source_qq") - painter.setPen(secondary_color) - font.setPixelSize(11) - font.setBold(False) - painter.setFont(font) - source_rect = QRect(x, rect.top() + 52, rect.right() - x - 100, 16) - painter.drawText(source_rect, Qt.AlignLeft | Qt.AlignVCenter, - self._elided_text(painter, source_text, source_rect.width())) - - # Duration - duration = track.duration or 0 - duration_text = format_duration(duration) - font.setPixelSize(12) - painter.setFont(font) - painter.drawText(rect.right() - self._padding - 50 - self._star_size - 10, rect.top(), 50, rect.height(), - Qt.AlignVCenter | Qt.AlignRight, duration_text) - - painter.restore() - - def _paint_cover(self, painter: QPainter, rect: QRect, track: OnlineTrack, row: int, theme): - """Paint cover art with caching and async loading.""" - from PySide6.QtGui import QPixmap as Pm - - cache_key = self._get_cover_cache_key(track) - - # Try cache - cached = CoverPixmapCache.get(cache_key) - if cached and not cached.isNull(): - painter.drawPixmap(rect, cached) - else: - # Draw placeholder - placeholder = Pm(self._cover_size, self._cover_size) - placeholder.fill(QColor(theme.background_alt)) - p = QPainter(placeholder) - p.setRenderHint(QPainter.Antialiasing) - p.setPen(QColor(theme.border)) - font = p.font() - font.setPixelSize(28) - p.setFont(font) - p.drawText(0, 0, self._cover_size, self._cover_size, Qt.AlignCenter, "♪") - p.end() - painter.drawPixmap(rect, placeholder) - - # Request async load - if cache_key not in self._requested_covers and cache_key not in self._failed_covers: - self._requested_covers.add(cache_key) - worker = OnlineCoverLoadWorker(cache_key, track, self._cover_loaded_signal) - QThreadPool.globalInstance().start(worker) - - # Preload nearby covers (±3 rows) - parent_view = self.parent() - if parent_view and hasattr(parent_view, '_model'): - model = parent_view._model - for offset in [-3, -2, -1, 1, 2, 3]: - nearby_row = row + offset - if 0 <= nearby_row < model.rowCount(): - nearby_track = model.get_track_at(nearby_row) - if nearby_track: - nearby_key = self._get_cover_cache_key(nearby_track) - if nearby_key not in self._requested_covers and nearby_key not in self._failed_covers and not CoverPixmapCache.get( - nearby_key): - self._requested_covers.add(nearby_key) - worker = OnlineCoverLoadWorker(nearby_key, nearby_track, self._cover_loaded_signal) - QThreadPool.globalInstance().start(worker) - - def _on_cover_loaded(self, cache_key: str, cover_path: str, qimage): - """Handle cover loaded from background — runs on UI thread.""" - self._requested_covers.discard(cache_key) - - parent_view = self.parent() - if parent_view and hasattr(parent_view, '_on_cover_ready'): - parent_view._on_cover_ready(cache_key, cover_path, qimage) - - def _get_cover_cache_key(self, track: OnlineTrack) -> str: - """Generate cache key for an online track.""" - return f"{TrackSource.QQ.name}:{track.mid}" - - def cover_rect_for_item(self, item_rect: QRect) -> QRect: - """Return the clickable cover rectangle for an item.""" - x = item_rect.left() + self._padding + self._rank_width - return QRect(x + 2, item_rect.top() + 9, self._cover_size, self._cover_size) - - @staticmethod - def _elided_text(painter, text: str, max_width: int) -> str: - """Return elided text if too wide.""" - fm = painter.fontMetrics() - if fm.horizontalAdvance(text) <= max_width: - return text - return fm.elidedText(text, Qt.ElideRight, max_width) - - -class OnlineTracksListView(QWidget): - """List view for online tracks with delegate-based rendering.""" - - track_activated = Signal(object) # OnlineTrack - favorite_toggled = Signal(object, bool) # OnlineTrack, is_favorite - play_requested = Signal(list) - insert_to_queue_requested = Signal(list) - add_to_queue_requested = Signal(list) - add_to_playlist_requested = Signal(list) - favorites_toggle_requested = Signal(list, bool) # (tracks, all_favorited) - qq_fav_toggle_requested = Signal(list, bool) # (tracks, all_favorited) - QQ Music remote - download_requested = Signal(list) - - def __init__(self, parent=None): - super().__init__(parent) - self._model = OnlineTracksModel(self) - self._delegate = OnlineTracksDelegate(self) - self._cover_popup = CoverHoverPopup() - self._hover_timer = QTimer(self) - self._hover_timer.setSingleShot(True) - self._hover_timer.timeout.connect(self._show_cover_popup) - self._hovered_row = -1 - self._last_cover_pos = QPoint() - self._setup_ui() - self._setup_connections() - self._context_menu = OnlineTrackContextMenu(self) - self._connect_context_menu() - - def _setup_ui(self): - layout = QVBoxLayout(self) - layout.setContentsMargins(0, 0, 0, 0) - layout.setSpacing(0) - - self._list_view = QListView() - self._apply_viewport_bg() - self._list_view.setModel(self._model) - self._list_view.setItemDelegate(self._delegate) - self._list_view.setSelectionMode(QListView.SelectionMode.ExtendedSelection) - self._list_view.setSelectionBehavior(QListView.SelectionBehavior.SelectRows) - self._list_view.setContextMenuPolicy(Qt.ContextMenuPolicy.CustomContextMenu) - self._list_view.setMouseTracking(True) - self._list_view.viewport().installEventFilter(self) - self._list_view.setUniformItemSizes(True) - self._list_view.setVerticalScrollMode(QListView.ScrollMode.ScrollPerPixel) - - layout.addWidget(self._list_view) - - def _setup_connections(self): - self._list_view.activated.connect(self._on_item_activated) - self._list_view.customContextMenuRequested.connect(self._show_context_menu) - self._list_view.clicked.connect(self._on_item_clicked) - - # Event bus - bus = EventBus.instance() - bus.favorite_changed.connect(self._on_favorite_changed) - - def closeEvent(self, event): - """Clean up event bus connections before closing.""" - with suppress(RuntimeError): - EventBus.instance().favorite_changed.disconnect(self._on_favorite_changed) - self._hover_timer.stop() - self._cover_popup.hide() - super().closeEvent(event) - - def eventFilter(self, obj, event): - """Filter viewport events to drive cover hover popup.""" - if obj == self._list_view.viewport(): - if event.type() == event.Type.MouseMove: - self._handle_mouse_move(event) - elif event.type() == event.Type.Leave: - self._handle_mouse_leave() - return super().eventFilter(obj, event) - - def _handle_mouse_move(self, event): - """Handle mouse move to detect cover hover.""" - pos = event.pos() - index = self._list_view.indexAt(pos) - - if not index.isValid(): - self._handle_mouse_leave() - return - - row = index.row() - item_rect = self._list_view.visualRect(index) - cover_rect = self._delegate.cover_rect_for_item(item_rect) - - if cover_rect.contains(pos): - if self._hovered_row != row: - self._hovered_row = row - self._last_cover_pos = QCursor.pos() - self._hover_timer.start(500) - else: - self._cover_popup.cancel_hide() - else: - self._handle_mouse_leave() - - def _handle_mouse_leave(self): - """Handle mouse leaving cover hover area.""" - # Fast-path: avoid repeated hide scheduling when already idle. - if self._hovered_row == -1 and not self._hover_timer.isActive(): - return - self._hover_timer.stop() - self._hovered_row = -1 - self._cover_popup.schedule_hide() - - def _show_cover_popup(self): - """Show cover popup for the currently hovered row.""" - if self._hovered_row < 0 or self._hovered_row >= self._model.rowCount(): - return - - track = self._model.get_track_at(self._hovered_row) - if not track: - return - - cache_key = self._delegate._get_cover_cache_key(track) - cover_path = _resolve_online_cover_path(track) - self._cover_popup.show_cover(cover_path, cache_key, self._last_cover_pos) - - def _on_item_activated(self, index): - track = index.data(OnlineTracksModel.TrackRole) - if track: - self.track_activated.emit(track) - - def _on_item_clicked(self, index): - """Handle click events - check if star icon was clicked.""" - from PySide6.QtGui import QCursor - - # Get click position - pos = self._list_view.mapFromGlobal(QCursor.pos()) - rect = self._list_view.visualRect(index) - - # Check if click is in star icon area - star_size = 20 - padding = 10 - star_area = QRect( - rect.right() - padding - star_size, - rect.top(), - star_size + padding, - rect.height() - ) - - if star_area.contains(pos): - # Toggle favorite - track = index.data(OnlineTracksModel.TrackRole) - if track: - is_favorite = index.data(OnlineTracksModel.IsFavoriteRole) - self._toggle_favorite(track, not is_favorite) - - def _toggle_favorite(self, track: OnlineTrack, new_state: bool): - """Toggle favorite status for online track.""" - self.favorite_toggled.emit(track, new_state) - - def set_track_favorite(self, mid: str, is_favorite: bool): - """Update favorite status for a specific track and refresh UI.""" - if is_favorite: - self._model._favorite_mids.add(mid) - else: - self._model._favorite_mids.discard(mid) - for i, track in enumerate(self._model._tracks): - if track.mid == mid: - idx = self._model.index(i) - self._model.dataChanged.emit(idx, idx, [OnlineTracksModel.IsFavoriteRole]) - break - - def _connect_context_menu(self): - self._context_menu.play.connect(self.play_requested) - self._context_menu.insert_to_queue.connect(self.insert_to_queue_requested) - self._context_menu.add_to_queue.connect(self.add_to_queue_requested) - self._context_menu.add_to_playlist.connect(self.add_to_playlist_requested) - self._context_menu.favorite_toggled.connect(self.favorites_toggle_requested) - self._context_menu.qq_fav_toggled.connect(self.qq_fav_toggle_requested) - self._context_menu.download.connect(self.download_requested) - - def _show_context_menu(self, pos): - """Show context menu.""" - indexes = self._list_view.selectedIndexes() - if not indexes: - return - - rows = sorted(set(idx.row() for idx in indexes)) - tracks = [self._model.get_track_at(r) for r in rows] - tracks = [t for t in tracks if t is not None] - - if not tracks: - return - - self._context_menu.show_menu(tracks, favorite_mids=self._model._favorite_mids, parent_widget=self) - - def _on_favorite_changed(self, item_id, is_favorite: bool, is_cloud: bool): - """Handle favorite changed event from EventBus.""" - if not is_cloud: - return - # item_id is cloud_file_id (mid) for cloud tracks - self.set_track_favorite(str(item_id), is_favorite) - - def _on_cover_ready(self, cache_key: str, cover_path: str, qimage): - """Handle cover loaded from background worker.""" - # Find the row for this cache_key - track_row = self._find_row_by_cover_key(cache_key) - - if qimage and not qimage.isNull(): - # Cache the cover - from PySide6.QtGui import QPixmap - pixmap = QPixmap.fromImage(qimage).scaled( - self._delegate._cover_size, - self._delegate._cover_size, - Qt.AspectRatioMode.KeepAspectRatioByExpanding, - Qt.TransformationMode.SmoothTransformation - ) - CoverPixmapCache.set(cache_key, pixmap) - - if track_row is not None: - self._model.notify_cover_loaded(track_row) - elif track_row is not None: - # No cover found — mark as failed - self._delegate._failed_covers.add(cache_key) - - def _find_row_by_cover_key(self, cache_key: str): - """Find row index for a cover cache key.""" - for row in range(self._model.rowCount()): - track = self._model.get_track_at(row) - if track and self._delegate._get_cover_cache_key(track) == cache_key: - return row - return None +The concrete implementation now lives in +`plugins.builtin.qqmusic.lib.online_tracks_list_view`. +""" - def _apply_viewport_bg(self): - from system.theme import ThemeManager - theme = ThemeManager.instance().current_theme - self._list_view.setStyleSheet( - f"QListView {{ background-color: {theme.background_alt}; border: none; outline: none; }}" - ) +import sys - def load_tracks(self, tracks: List[OnlineTrack], favorite_mids: set = None): - """Load tracks into the view.""" - self._model.reset_tracks(tracks, favorite_mids or set()) - self._apply_viewport_bg() +from plugins.builtin.qqmusic.lib import online_tracks_list_view as _online_tracks_list_view - def clear(self): - """Clear all tracks.""" - self._model.reset_tracks([], set()) +sys.modules[__name__] = _online_tracks_list_view diff --git a/ui/widgets/context_menus.py b/ui/widgets/context_menus.py index 66e135bd..e696242d 100644 --- a/ui/widgets/context_menus.py +++ b/ui/widgets/context_menus.py @@ -7,6 +7,7 @@ from PySide6.QtWidgets import QMenu from domain.track import TrackSource +from plugins.builtin.qqmusic.lib.context_menus import OnlineTrackContextMenu from system.i18n import t @@ -115,67 +116,6 @@ def show_menu(self, tracks: list, favorite_ids: set, parent_widget=None): menu.exec_(QCursor.pos()) -class OnlineTrackContextMenu(QObject): - """Context menu for online tracks. Emits signals for each action.""" - - play = Signal(list) - insert_to_queue = Signal(list) - add_to_queue = Signal(list) - add_to_playlist = Signal(list) - favorite_toggled = Signal(list, bool) # (tracks, all_favorited) - qq_fav_toggled = Signal(list, bool) # (tracks, all_favorited) - QQ Music remote favorite - download = Signal(list) - - def show_menu(self, tracks: list, favorite_mids: set = None, parent_widget=None): - from system.theme import ThemeManager - - if not tracks: - return - - menu = QMenu(parent_widget) - menu.setStyleSheet(ThemeManager.instance().get_qss(_CONTEXT_MENU_STYLE)) - - a = menu.addAction(t("play")) - a.triggered.connect(lambda: self.play.emit(tracks)) - - a = menu.addAction(t("insert_to_queue")) - a.triggered.connect(lambda: self.insert_to_queue.emit(tracks)) - - a = menu.addAction(t("add_to_queue")) - a.triggered.connect(lambda: self.add_to_queue.emit(tracks)) - - menu.addSeparator() - - all_favorited = False - if favorite_mids: - all_favorited = all( - getattr(track, 'mid', None) and track.mid in favorite_mids - for track in tracks - ) - - if all_favorited: - a = menu.addAction(t("remove_from_favorites")) - else: - a = menu.addAction(t("add_to_favorites")) - a.triggered.connect(lambda: self.favorite_toggled.emit(tracks, all_favorited)) - - if all_favorited: - a = menu.addAction(t("remove_from_qq_favorites")) - else: - a = menu.addAction(t("add_to_qq_favorites")) - a.triggered.connect(lambda: self.qq_fav_toggled.emit(tracks, all_favorited)) - - a = menu.addAction(t("add_to_playlist")) - a.triggered.connect(lambda: self.add_to_playlist.emit(tracks)) - - menu.addSeparator() - - a = menu.addAction(t("download")) - a.triggered.connect(lambda: self.download.emit(tracks)) - - menu.exec_(QCursor.pos()) - - class PlaylistTrackContextMenu(LocalTrackContextMenu): """Context menu for playlist tracks. Extends local track menu with remove from playlist.""" diff --git a/ui/widgets/recommend_card.py b/ui/widgets/recommend_card.py index 8dcc5c8b..d5de6321 100644 --- a/ui/widgets/recommend_card.py +++ b/ui/widgets/recommend_card.py @@ -3,7 +3,7 @@ """ import logging -from typing import Dict, Any, Optional, List +from typing import Callable, Dict, Any, Optional, List from PySide6.QtCore import Qt, Signal, QThread, QRect from PySide6.QtGui import QPixmap, QColor, QPainter, QFont @@ -86,12 +86,14 @@ class RecommendCard(QWidget): def __init__(self, data: Dict[str, Any], parent=None): super().__init__(parent) self._data = data + self._is_placeholder = bool(data.get("_placeholder")) self._is_hovering = False self._cover_loader: Optional[CoverLoader] = None self._setup_ui() self._set_default_cover() - self._load_cover() + if not self._is_placeholder: + self._load_cover() # Register with theme manager from system.theme import ThemeManager @@ -102,7 +104,7 @@ def _setup_ui(self): from system.theme import ThemeManager self.setFixedSize(self.CARD_WIDTH, self.CARD_HEIGHT) - self.setCursor(Qt.PointingHandCursor) + self.setCursor(Qt.ArrowCursor if self._is_placeholder else Qt.PointingHandCursor) layout = QVBoxLayout(self) layout.setContentsMargins(0, 0, 0, 0) @@ -139,14 +141,7 @@ def _setup_ui(self): title = self._data.get('title', '') or self._data.get('name', '') self._name_label = QLabel(title) self._name_label.setAlignment(Qt.AlignLeft | Qt.AlignVCenter) - self._name_label.setStyleSheet(ThemeManager.instance().get_qss(""" - QLabel { - color: %text%; - font-size: 12px; - font-weight: bold; - background: transparent; - } - """)) + self._name_label.setStyleSheet(self._name_label_style()) self._name_label.setWordWrap(True) self._name_label.setMaximumHeight(32) @@ -173,18 +168,22 @@ def _on_cover_loaded(self, url: str, pixmap: QPixmap): def _set_default_cover(self): """Set default cover when no cover is available.""" + from system.theme import ThemeManager + + theme = ThemeManager.instance().current_theme pixmap = QPixmap(self.COVER_SIZE, self.COVER_SIZE) - pixmap.fill(QColor("#3d3d3d")) + pixmap.fill(QColor(theme.background_hover)) painter = QPainter(pixmap) painter.setRenderHint(QPainter.Antialiasing) - painter.setPen(QColor("#666666")) + painter.setPen(QColor(theme.text_secondary)) font = QFont() font.setPixelSize(36) painter.setFont(font) painter.drawText( QRect(0, 0, self.COVER_SIZE, self.COVER_SIZE), - Qt.AlignCenter, "\u266B" + Qt.AlignCenter, + "…" if self._is_placeholder else "\u266B" ) painter.end() @@ -192,22 +191,47 @@ def _set_default_cover(self): def enterEvent(self, event): """Handle mouse enter for hover effect.""" + if self._is_placeholder: + return self._is_hovering = True self._cover_container.setStyleSheet(self._style_hover) super().enterEvent(event) def leaveEvent(self, event): """Handle mouse leave for hover effect.""" + if self._is_placeholder: + return self._is_hovering = False self._cover_container.setStyleSheet(self._style_normal) super().leaveEvent(event) def mousePressEvent(self, event): """Handle mouse click.""" - if event.button() == Qt.LeftButton: + if not self._is_placeholder and event.button() == Qt.LeftButton: self.clicked.emit(self._data) super().mousePressEvent(event) + def _name_label_style(self) -> str: + from system.theme import ThemeManager + + if self._is_placeholder: + return ThemeManager.instance().get_qss(""" + QLabel { + color: %text_secondary%; + font-size: 12px; + font-weight: bold; + background: transparent; + } + """) + return ThemeManager.instance().get_qss(""" + QLabel { + color: %text%; + font-size: 12px; + font-weight: bold; + background: transparent; + } + """) + def refresh_theme(self): """Refresh theme colors when theme changes.""" from system.theme import ThemeManager @@ -226,14 +250,9 @@ def refresh_theme(self): self._cover_container.setStyleSheet(self._style_normal) # Update text labels - self._name_label.setStyleSheet(ThemeManager.instance().get_qss(""" - QLabel { - color: %text%; - font-size: 12px; - font-weight: bold; - background: transparent; - } - """)) + self._name_label.setStyleSheet(self._name_label_style()) + if self._is_placeholder: + self._set_default_cover() class RecommendSection(QWidget): @@ -284,10 +303,11 @@ class RecommendSection(QWidget): } """ - def __init__(self, title: str = None, parent=None): + def __init__(self, title: str = None, parent=None, translator: Callable[[str, Optional[str]], str] = t): super().__init__(parent) self._cards: List[RecommendCard] = [] self._custom_title = title + self._translate = translator self._setup_ui() # Register with theme manager @@ -306,7 +326,7 @@ def _setup_ui(self): self.setStyleSheet("background-color: transparent;") # Title - self._title_label = QLabel(self._custom_title if self._custom_title else t("recommendations")) + self._title_label = QLabel(self._custom_title if self._custom_title else self._translate("recommendations")) self._title_label.setStyleSheet(ThemeManager.instance().get_qss(self._STYLE_TEMPLATE)) layout.addWidget(self._title_label) @@ -354,12 +374,28 @@ def _create_loading_indicator(self) -> QWidget: return widget - def show_loading(self): - """Show loading indicator.""" - self._loading.show() - # Clear existing cards + def show_loading(self, count: int = 5): + """Show placeholder cards while data is loading.""" + self._loading.hide() self._clear_cards() - # Show section while loading + + placeholder_title = self._translate("loading", "Loading...") + placeholders = [ + { + "_placeholder": True, + "id": f"placeholder-{index}", + "title": placeholder_title, + } + for index in range(max(count, 1)) + ] + for rec in placeholders: + card = RecommendCard(rec) + self._cards.append(card) + self._cards_layout.addWidget(card) + + total_width = len(self._cards) * (RecommendCard.CARD_WIDTH + 16) - 16 + self._cards_container.setFixedWidth(max(total_width, self.width())) + self._cards_container.adjustSize() self.show() def hide_loading(self): @@ -409,7 +445,7 @@ def refresh_ui(self): if self._custom_title: self._title_label.setText(self._custom_title) else: - self._title_label.setText(t("recommendations")) + self._title_label.setText(self._translate("recommendations")) def refresh_theme(self): """Refresh theme colors when theme changes.""" diff --git a/ui/windows/components/sidebar.py b/ui/windows/components/sidebar.py index ab6d073e..87af05eb 100644 --- a/ui/windows/components/sidebar.py +++ b/ui/windows/components/sidebar.py @@ -5,10 +5,11 @@ from typing import List, Tuple, TYPE_CHECKING from PySide6.QtCore import Qt, Signal +from PySide6.QtGui import QIcon from PySide6.QtWidgets import QWidget, QVBoxLayout, QPushButton, QLabel from system.i18n import t, get_language -from ui.icons import IconName, IconButton +from ui.icons import IconName, IconButton, PathIconButton if TYPE_CHECKING: from system.config import ConfigManager @@ -184,6 +185,8 @@ def refresh_theme(self): nav_style = tm.get_qss(self._NAV_STYLE) for _, btn in self._nav_buttons: btn.setStyleSheet(nav_style) + if hasattr(btn, 'refresh_theme'): + btn.refresh_theme() language_style = tm.get_qss(self._ACTION_BTN_STYLE).replace("{btn_id}", "languageBtn") self._language_btn.setStyleSheet(language_style) @@ -196,13 +199,23 @@ def add_plugin_entry( page_index: int, title: str, icon_name: str | None = None, + icon_path: str | None = None, + title_provider=None, ) -> None: """Add a plugin-provided navigation button before the footer actions.""" - resolved_icon = getattr(IconName, icon_name, IconName.GLOBE) if icon_name else IconName.GLOBE - btn = IconButton(resolved_icon, title, size=18) + from system.theme import ThemeManager + + if icon_path: + btn = PathIconButton(icon_path, title, size=18) + else: + resolved_icon = getattr(IconName, icon_name, IconName.GLOBE) if icon_name else IconName.GLOBE + btn = IconButton(resolved_icon, title, size=18) btn.setCheckable(True) btn.setCursor(Qt.PointingHandCursor) + btn.setProperty("plugin_title_provider", title_provider) + btn.setProperty("plugin_icon_path", icon_path) btn.clicked.connect(lambda checked, idx=page_index: self._on_nav_clicked(idx)) + btn.setStyleSheet(ThemeManager.instance().get_qss(self._NAV_STYLE)) insert_index = max(self.layout().count() - 4, 0) self.layout().insertWidget(insert_index, btn) self._nav_buttons.append((page_index, btn)) @@ -259,6 +272,10 @@ def refresh_texts(self): for i, (idx, btn) in enumerate(self._nav_buttons): if i < len(nav_texts): btn.setText(nav_texts[i]) + else: + title_provider = btn.property("plugin_title_provider") + if callable(title_provider): + btn.setText(title_provider()) self._add_music_btn.setText(t("add_music")) self.update_language_button() diff --git a/ui/windows/main_window.py b/ui/windows/main_window.py index 3310cc23..8dbfc7f8 100644 --- a/ui/windows/main_window.py +++ b/ui/windows/main_window.py @@ -8,6 +8,7 @@ - ScanDialog: Music folder scanning """ import logging +import time from contextlib import suppress from typing import Optional @@ -21,6 +22,7 @@ QDialog, QFileDialog, QHBoxLayout, + QLabel, QMainWindow, QMenu, QSizeGrip, @@ -390,8 +392,6 @@ def _setup_ui(self): self._genres_view = GenresView(bootstrap.library_service, bootstrap.cover_service) self._genre_view = GenreView(bootstrap.library_service, self._playback, bootstrap.cover_service) - self._online_music_view = None - self._stacked_widget.addWidget(self._library_view) # 0 self._stacked_widget.addWidget(self._cloud_drive_view) # 1 self._stacked_widget.addWidget(self._playlist_view) # 2 @@ -453,17 +453,117 @@ def _create_sidebar(self) -> QWidget: def _mount_plugin_pages(self) -> None: """Mount plugin-provided pages into the stacked widget and sidebar.""" self._plugin_page_keys = {} + self._plugin_pages = {} + self._plugin_page_specs = {} + self._plugin_page_loading = set() + self._plugin_prewarm_scheduled = False + self._plugin_prewarm_timer = None bootstrap = Bootstrap.instance() for spec in bootstrap.plugin_manager.registry.sidebar_entries(): page_index = self._stacked_widget.count() - widget = spec.page_factory(bootstrap.plugin_manager, self) - self._stacked_widget.addWidget(widget) + host = QWidget(self) + host_layout = QVBoxLayout(host) + host_layout.setContentsMargins(0, 0, 0, 0) + loading_label = QLabel(t("loading", "Loading..."), host) + loading_label.setAlignment(Qt.AlignCenter) + host_layout.addWidget(loading_label) + self._stacked_widget.addWidget(host) self._sidebar.add_plugin_entry( page_index=page_index, - title=spec.title, + title=spec.title_provider() if callable(getattr(spec, "title_provider", None)) else spec.title, icon_name=spec.icon_name, + icon_path=getattr(spec, "icon_path", None), + title_provider=getattr(spec, "title_provider", None), ) self._plugin_page_keys[page_index] = spec.plugin_id + self._plugin_page_specs[page_index] = spec + logger.info( + "[PluginUI] Mounted placeholder for plugin page %s at index %s", + spec.plugin_id, + page_index, + ) + self._prewarm_plugin_page() + + def showEvent(self, event) -> None: + super().showEvent(event) + self._schedule_plugin_page_prewarm() + + def _schedule_plugin_page_prewarm(self) -> None: + if getattr(self, "_plugin_prewarm_scheduled", False): + return + if not getattr(self, "_plugin_page_specs", None): + return + self._plugin_prewarm_scheduled = True + if self._plugin_prewarm_timer is None: + self._plugin_prewarm_timer = QTimer(self) + self._plugin_prewarm_timer.setSingleShot(True) + self._plugin_prewarm_timer.timeout.connect(self._prewarm_plugin_page) + self._plugin_prewarm_timer.start(0) + + def _prewarm_plugin_page(self) -> None: + for index in sorted(self._plugin_page_specs): + if index not in self._plugin_pages: + logger.info("[PluginUI] Prewarming plugin page at index %s", index) + self._ensure_plugin_page_loaded(index) + break + + def _ensure_plugin_page_loaded(self, index: int) -> None: + spec = getattr(self, "_plugin_page_specs", {}).get(index) + if spec is None or index in self._plugin_pages: + return + if index in self._plugin_page_loading: + return + + self._plugin_page_loading.add(index) + started_at = time.perf_counter() + try: + bootstrap = Bootstrap.instance() + logger.info( + "[PluginUI] Materializing plugin page %s at index %s", + spec.plugin_id, + index, + ) + host = self._stacked_widget.widget(index) + widget = spec.page_factory(bootstrap.plugin_manager, host) + layout = host.layout() if isinstance(host, QWidget) else None + if layout is not None: + while layout.count(): + item = layout.takeAt(0) + child = item.widget() + if child is not None: + child.deleteLater() + layout.addWidget(widget) + self._connect_plugin_page_signals(widget) + self._plugin_pages[index] = widget + logger.info( + "[PluginUI] Plugin page %s ready at index %s in %.1fms", + spec.plugin_id, + index, + (time.perf_counter() - started_at) * 1000, + ) + except Exception: + logger.exception( + "[PluginUI] Failed to materialize plugin page %s at index %s", + getattr(spec, "plugin_id", ""), + index, + ) + finally: + self._plugin_page_loading.discard(index) + + def _connect_plugin_page_signals(self, widget: QWidget) -> None: + signal_map = ( + ("play_online_track", self._play_online_track), + ("add_to_queue", self._add_online_track_to_queue), + ("insert_to_queue", self._insert_online_track_to_queue), + ("add_multiple_to_queue", self._add_multiple_online_tracks_to_queue), + ("insert_multiple_to_queue", self._insert_multiple_online_tracks_to_queue), + ("play_online_tracks", self._play_online_tracks), + ) + for signal_name, handler in signal_map: + signal = getattr(widget, signal_name, None) + if signal is None or not hasattr(signal, "connect"): + continue + signal.connect(handler) def _on_sidebar_page_requested(self, page_index: int): """Handle sidebar page request.""" @@ -648,6 +748,7 @@ def _show_page(self, index: int): # Switch view self._stacked_widget.setCurrentIndex(index) + self._ensure_plugin_page_loaded(index) # Auto-select first playlist when showing playlists if index == 2: # Playlists is now at index 2 @@ -1166,6 +1267,8 @@ def _toggle_language(self): # Save language preference self._config.set_language(new_lang) + EventBus.instance().language_changed.emit(new_lang) + # Update language button in sidebar self._sidebar.update_language_button() @@ -1198,8 +1301,18 @@ def _refresh_ui_texts(self): self._album_view.refresh_ui() self._genres_view.refresh_ui() self._genre_view.refresh_ui() - if self._online_music_view: - self._online_music_view.refresh_ui() + for widget in getattr(self, "_plugin_pages", {}).values(): + refresh_ui = getattr(widget, "refresh_ui", None) + if callable(refresh_ui): + refresh_ui() + for page_index, spec in getattr(self, "_plugin_page_specs", {}).items(): + title_provider = getattr(spec, "title_provider", None) + if not callable(title_provider): + continue + for idx, btn in getattr(self._sidebar, "_nav_buttons", []): + if idx == page_index: + btn.setText(title_provider()) + break # Update settings button status in sidebar self._sidebar.update_settings_status(self._config.get_ai_enabled()) diff --git a/uv.lock b/uv.lock index d7768aff..09d9dd3b 100644 --- a/uv.lock +++ b/uv.lock @@ -256,6 +256,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" }, ] +[[package]] +name = "harmony-plugin-api" +version = "0.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/cf/85/157889a54b773e181c1d368521111b8310a3f27e9b1313dabbb9a075c6d1/harmony_plugin_api-0.1.0.tar.gz", hash = "sha256:9832e6e93342a675c28cb18cfc23a6b3b3123fdb385555db5a39d0374b84a510", size = 4159, upload-time = "2026-04-07T06:59:27.12Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f7/37/885b1aea27915bb5dad5f1eca6e936e158fbeb3dd94b2a33c39b00ca3d36/harmony_plugin_api-0.1.0-py3-none-any.whl", hash = "sha256:fb9b8163d897c897ab047c8e7ad94ccbe673f0abe328576552329d04b5edc077", size = 6476, upload-time = "2026-04-07T06:59:25.939Z" }, +] + [[package]] name = "httpcore" version = "1.0.9" @@ -517,6 +526,7 @@ source = { virtual = "." } dependencies = [ { name = "beautifulsoup4" }, { name = "certifi" }, + { name = "harmony-plugin-api" }, { name = "lxml" }, { name = "mpv" }, { name = "mutagen" }, @@ -555,6 +565,7 @@ requires-dist = [ { name = "beautifulsoup4", specifier = ">=4.14.3" }, { name = "certifi", specifier = ">=2024.0.0" }, { name = "dbus-python", marker = "extra == 'linux'", specifier = ">=1.4.0" }, + { name = "harmony-plugin-api", specifier = ">=0.1.0" }, { name = "lxml", specifier = ">=6.0.2" }, { name = "mpv", specifier = ">=1.0.0" }, { name = "mutagen", specifier = ">=1.47.0" }, From 9ec3de43e108c65d8ea1236b68d85a7dae8c4d9c Mon Sep 17 00:00:00 2001 From: Har01d Date: Tue, 7 Apr 2026 16:02:08 +0800 Subject: [PATCH 053/157] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E7=99=BB=E5=BD=95?= =?UTF-8?q?=E7=8A=B6=E6=80=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- plugins/builtin/qqmusic/lib/legacy/client.py | 118 +++++-------------- 1 file changed, 31 insertions(+), 87 deletions(-) diff --git a/plugins/builtin/qqmusic/lib/legacy/client.py b/plugins/builtin/qqmusic/lib/legacy/client.py index c733fd10..cc4831ea 100644 --- a/plugins/builtin/qqmusic/lib/legacy/client.py +++ b/plugins/builtin/qqmusic/lib/legacy/client.py @@ -948,106 +948,50 @@ def verify_login(self) -> Dict[str, Any]: try: musicid = self.credential.get('musicid', '') - # Use musicu.fcg endpoint via _make_request (same as other API calls) - data = self._make_request( - 'music.userInfo.Profile', - 'GetUserProfile', - {'user_id': str(musicid)}, - ) - - if data and data.get('code') == 0: - profile = data.get('data', {}) - identity = self._extract_profile_identity(profile) - result['valid'] = bool(identity.get('valid')) - result['nick'] = str(identity.get('nick', '') or '') - result['uin'] = int(identity.get('uin', 0) or 0) - - logger.debug(f"=== result: {musicid} {data}") - # Fallback: try the profile homepage API if musicu.fcg didn't work - if not result['valid']: - self._verify_login_fallback(result) - - return result - - except Exception as e: - logger.warning("musicu.fcg verify_login failed, trying fallback: %s", e) - self._verify_login_fallback(result) - return result - - @staticmethod - def _extract_profile_identity(profile: Dict[str, Any]) -> Dict[str, Any]: - candidates: list[Dict[str, Any]] = [] - if isinstance(profile, dict): - for key in ("creator", "user", "profile", "owner"): - value = profile.get(key) - if isinstance(value, dict): - candidates.append(value) - candidates.append(profile) - - identity = {"valid": False, "nick": "", "uin": 0} - for candidate in candidates: - if not isinstance(candidate, dict) or not candidate: - continue - nick = ( - candidate.get("nick") - or candidate.get("nickname") - or candidate.get("name") - or candidate.get("hostname") - or "" - ) - uin = ( - candidate.get("uin") - or candidate.get("userid") - or candidate.get("user_id") - or 0 - ) - if nick or uin: - identity["valid"] = True - identity["nick"] = str(nick or "") - try: - identity["uin"] = int(uin or 0) - except (TypeError, ValueError): - identity["uin"] = 0 - return identity + # Use profile homepage API to verify login + url = 'https://c6.y.qq.com/rsc/fcgi-bin/fcg_get_profile_homepage.fcg' - if isinstance(profile, dict) and profile: - identity["valid"] = True - return identity + # Build cookies from credential + cookies = { + 'uin': str(musicid), + 'qqmusic_key': self.credential.get('musickey', ''), + 'qm_keyst': self.credential.get('musickey', ''), + 'tmeLoginType': str(self.credential.get('login_type', 2)), + } - def _verify_login_fallback(self, result: Dict[str, Any]) -> None: - """Fallback: use profile homepage API to verify login and get nick.""" - try: - musicid = self.credential.get('musicid', '') - url = 'https://c6.y.qq.com/rsc/fcgi-bin/fcg_get_profile_homepage.fcg' + headers = { + 'User-Agent': 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/146.0.0.0 Safari/537.36', + 'Referer': 'https://y.qq.com/', + } params = { 'format': 'json', - 'userid': musicid, + 'uin': musicid, 'cid': '205360838', 'reqfrom': '1', + 'reqtype': '0', } - # Use a separate request without session cookie header conflicts - import requests as _req - sess = _req.Session() - sess.headers.update({ - 'User-Agent': 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/146.0.0.0 Safari/537.36', - 'Referer': 'https://y.qq.com/', - }) - response = sess.get(url, params=params, timeout=10) + response = self.session.get( + url, + params=params, + cookies=cookies, + headers=headers, + timeout=10 + ) response.raise_for_status() data = response.json() if data.get('code') == 0: - result['valid'] = True - resp_data = data.get('data', {}) - # Try creator.nick first, then top-level hostname - creator = resp_data.get('creator', {}) + creator = data.get('data', {}).get('creator', {}) if creator: - result['nick'] = creator.get('nick', '') or '' - if not result['nick']: - result['nick'] = resp_data.get('hostname', '') or '' - result['uin'] = (creator.get('uin', 0) or resp_data.get('uin', 0) or 0) + result['valid'] = True + result['nick'] = creator.get('nick', '') + result['uin'] = creator.get('uin', 0) + + return result + except Exception as e: - logger.debug("verify_login fallback also failed: %s", e) + logger.error(f"Failed to verify login: {e}") + return result From c93415b1e0655db212d2b1b0964a5393c30eff51 Mon Sep 17 00:00:00 2001 From: Har01d Date: Tue, 7 Apr 2026 16:37:46 +0800 Subject: [PATCH 054/157] =?UTF-8?q?=E8=A1=A5=E5=85=85=E6=8F=92=E4=BB=B6?= =?UTF-8?q?=E7=AE=A1=E7=90=86=E9=A1=B5=E5=BC=80=E5=85=B3=E8=AE=BE=E8=AE=A1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...6-04-07-plugin-management-toggle-design.md | 110 ++++++++++++++++++ 1 file changed, 110 insertions(+) create mode 100644 docs/superpowers/specs/2026-04-07-plugin-management-toggle-design.md diff --git a/docs/superpowers/specs/2026-04-07-plugin-management-toggle-design.md b/docs/superpowers/specs/2026-04-07-plugin-management-toggle-design.md new file mode 100644 index 00000000..9aafe14f --- /dev/null +++ b/docs/superpowers/specs/2026-04-07-plugin-management-toggle-design.md @@ -0,0 +1,110 @@ +# Plugin Management Toggle Design + +## Overview + +This change refines the host-owned plugin management tab in settings. + +Current behavior uses one shared enable button and one shared disable button below the plugin list. The list itself is rendered as plain text rows and exposes raw source values such as `builtin` and `external`. + +The goal is to make plugin state control local to each plugin row and make plugin source labels translatable. + +## Goals + +- Give each plugin row its own enabled or disabled toggle control. +- Remove the shared action buttons from the bottom of the plugin tab. +- Translate plugin source labels for built-in and external plugins. +- Preserve the existing install-from-zip and install-from-url actions. +- Keep the implementation scoped to the plugin management tab and its tests. + +## Non-Goals + +- No switch to a table-based plugin manager UI. +- No plugin uninstall flow in this change. +- No plugin metadata expansion such as descriptions, authors, or icons. +- No change to plugin manager persistence behavior. + +## Current State + +`ui/dialogs/plugin_management_tab.py` currently: + +- renders plugins through `QListWidgetItem` text +- stores the full plugin row dict in item data +- toggles plugin state through two shared buttons +- shows raw `row["source"]` values directly in the list text + +This creates two issues: + +- enabling or disabling a plugin requires selecting the row and then using a separate control area +- built-in plugins display untranslated source values + +## Recommended Approach + +Keep `QListWidget` but replace plain text entries with row widgets. + +Each row widget should: + +- show plugin name prominently +- show version, translated source label, and translated status label as secondary metadata +- show load error text when present +- expose a per-row toggle implemented with a checkbox-style control + +The parent tab remains responsible for: + +- fetching rows from `plugin_manager.list_plugins()` +- handling toggle callbacks through `plugin_manager.set_plugin_enabled(plugin_id, enabled)` +- refreshing the list after state changes + +This keeps the change localized and avoids a broader migration to `QTableWidget`. + +## UI Structure + +Each plugin row should render as: + +- primary line: plugin name +- secondary line: version, translated source, translated status +- optional tertiary line: load error +- right side: enabled toggle + +The bottom shared enable and disable buttons should be removed entirely. + +The install controls stay below the list unchanged. + +## Translation + +Add dedicated host translation keys: + +- `plugins_source_builtin` +- `plugins_source_external` + +The plugin management tab should map known source ids to these keys and fall back to the raw source string only for unexpected values. + +## Data Flow + +1. `refresh()` requests plugin rows from the manager. +2. For each row, the tab creates a list item and a companion row widget. +3. The row widget emits the desired enabled state when its toggle changes. +4. The tab calls `set_plugin_enabled(plugin_id, enabled)`. +5. The tab refreshes the list so rendered status and persisted state stay in sync. + +## Error Handling + +- Unknown source ids fall back to raw source text. +- Rows without a plugin id ignore toggle actions. +- Refresh after toggle is authoritative; the UI does not try to maintain speculative local state. + +## Testing + +Add or update UI tests to verify: + +- plugin rows still render correctly with translated source labels +- toggling a row-level control calls `set_plugin_enabled` with the correct plugin id and boolean +- the plugin list refreshes after toggling +- raw source ids are no longer visible for built-in and external plugins + +## Scope Check + +This design is intentionally small and can be implemented as a single focused change touching: + +- plugin management tab UI +- host translations +- plugin management tab tests From c1fb5d89ef87f85df9c3dce27ce01e1674ef52cf Mon Sep 17 00:00:00 2001 From: Har01d Date: Tue, 7 Apr 2026 16:42:12 +0800 Subject: [PATCH 055/157] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E4=B8=8B=E6=8B=89?= =?UTF-8?q?=E6=A1=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../builtin/qqmusic/lib/online_music_view.py | 4 +- plugins/builtin/qqmusic/lib/settings_tab.py | 64 ++++++++--- plugins/builtin/qqmusic/translations/zh.json | 4 +- tests/test_ui/test_library_view_redownload.py | 22 ++++ tests/test_ui/test_plugin_settings_tab.py | 100 ++++++++++++++++++ ui/dialogs/redownload_dialog.py | 41 ++++++- ui/dialogs/settings_dialog.py | 24 ++++- 7 files changed, 240 insertions(+), 19 deletions(-) diff --git a/plugins/builtin/qqmusic/lib/online_music_view.py b/plugins/builtin/qqmusic/lib/online_music_view.py index dfcf42a9..ae77b515 100644 --- a/plugins/builtin/qqmusic/lib/online_music_view.py +++ b/plugins/builtin/qqmusic/lib/online_music_view.py @@ -1040,7 +1040,7 @@ def _create_header(self) -> QWidget: layout.setContentsMargins(0, 0, 0, 0) # Title - self._online_music_title = QLabel(t("source_qq")) + self._online_music_title = QLabel(t("qqmusic_page_title")) layout.addWidget(self._online_music_title) layout.addStretch() @@ -3327,7 +3327,7 @@ def refresh_ui(self): """Refresh UI texts after language change.""" # Update titles if hasattr(self, '_online_music_title'): - self._online_music_title.setText(t("source_qq")) + self._online_music_title.setText(t("qqmusic_page_title")) if hasattr(self, '_rankings_title'): self._rankings_title.setText(t("rankings")) diff --git a/plugins/builtin/qqmusic/lib/settings_tab.py b/plugins/builtin/qqmusic/lib/settings_tab.py index 8cddde25..97ffe90f 100644 --- a/plugins/builtin/qqmusic/lib/settings_tab.py +++ b/plugins/builtin/qqmusic/lib/settings_tab.py @@ -97,12 +97,41 @@ class QQMusicSettingsTab(QWidget): border-color: %highlight%; } """ + _STYLE_POPUP = """ + QListView { + background-color: %background_alt%; + border: 1px solid %border%; + color: %text%; + selection-background-color: %highlight%; + selection-color: %background%; + outline: none; + } + QListView::item { + padding: 6px 10px; + min-height: 20px; + } + QListView::item:hover { + background-color: %highlight%; + color: %background%; + } + QListView::item:selected { + background-color: %highlight%; + color: %background%; + } + """ + _STYLE_POPUP_CONTAINER = """ + QFrame { + background-color: %background_alt%; + border: 1px solid %border%; + } + """ def __init__(self, context, parent=None): super().__init__(parent) self._context = context self._language_connected = False self._verify_thread: Optional[VerifyLoginThread] = None + self._loading_settings = False self._outer_layout = QVBoxLayout(self) self._outer_layout.setContentsMargins(0, 0, 0, 0) @@ -139,7 +168,7 @@ def _setup_ui(self): label = t(label_key, quality) self._quality_combo.addItem(label) self._quality_combo.setItemData(self._quality_combo.count() - 1, quality, Qt.UserRole) - self._quality_combo.currentIndexChanged.connect(lambda *_args: self._save_settings()) + self._quality_combo.currentIndexChanged.connect(self._on_quality_changed) quality_layout.addWidget(self._quality_label) quality_layout.addWidget(self._quality_combo) quality_layout.addStretch() @@ -238,21 +267,30 @@ def _on_language_changed(self, language: str) -> None: self.refresh_ui() def _load_settings(self) -> None: - quality = str(self._context.settings.get("quality", "320")) - for i in range(self._quality_combo.count()): - if self._quality_combo.itemData(i, Qt.UserRole) == quality: - self._quality_combo.setCurrentIndex(i) - break - - download_dir = str( - self._context.settings.get("download_dir", "data/online_cache") - or "data/online_cache" - ) - self._download_dir_input.setText(download_dir) + self._loading_settings = True + try: + quality = str(self._context.settings.get("quality", "320")) + for i in range(self._quality_combo.count()): + if self._quality_combo.itemData(i, Qt.UserRole) == quality: + self._quality_combo.setCurrentIndex(i) + break + + download_dir = str( + self._context.settings.get("download_dir", "data/online_cache") + or "data/online_cache" + ) + self._download_dir_input.setText(download_dir) + finally: + self._loading_settings = False def _save(self): self._save_settings() + def _on_quality_changed(self, *_args) -> None: + if self._loading_settings: + return + self._save_settings() + def _save_settings(self) -> None: self._context.settings.set("quality", self._quality_combo.currentData(Qt.UserRole)) self._context.settings.set( @@ -373,6 +411,8 @@ def refresh_theme(self) -> None: self._download_dir_label.setStyleSheet(qss(self._STYLE_STATUS)) self._qqmusic_status_label.setStyleSheet(qss(self._STYLE_STATUS)) self._quality_combo.setStyleSheet(qss(self._STYLE_INPUT)) + self._quality_combo.view().setStyleSheet(qss(self._STYLE_POPUP)) + self._quality_combo.view().window().setStyleSheet(qss(self._STYLE_POPUP_CONTAINER)) self._download_dir_input.setStyleSheet(qss(self._STYLE_INPUT)) for button in (self._browse_btn, self._qqmusic_qr_btn, self._qqmusic_logout_btn): button.setStyleSheet(qss(self._STYLE_BUTTON)) diff --git a/plugins/builtin/qqmusic/translations/zh.json b/plugins/builtin/qqmusic/translations/zh.json index 3d0bb1cf..b722085a 100644 --- a/plugins/builtin/qqmusic/translations/zh.json +++ b/plugins/builtin/qqmusic/translations/zh.json @@ -75,9 +75,9 @@ "qqmusic_logout": "退出登录", "qqmusic_instructions": "请使用手机{app}扫描二维码登录QQ音乐", "qqmusic_not_logged_in": "未登录", - "qqmusic_page_title": "QQ 音乐", + "qqmusic_page_title": "QQ音乐", "qqmusic_account_hint": "登录后可同步我喜欢的歌曲、收藏歌单、专辑和关注歌手。", - "qqmusic_quality": "音质", + "qqmusic_quality": "音质设置", "qqmusic_quality_hint": "音质会影响在线播放与下载缓存请求,部分音质需要账号权限。", "qqmusic_quality_master": "臻品母带", "qqmusic_quality_atmos_2": "臻品全景声 2.0", diff --git a/tests/test_ui/test_library_view_redownload.py b/tests/test_ui/test_library_view_redownload.py index 43338a71..0a8ec73a 100644 --- a/tests/test_ui/test_library_view_redownload.py +++ b/tests/test_ui/test_library_view_redownload.py @@ -6,6 +6,7 @@ from domain.track import Track, TrackSource from services.online.quality import get_quality_label_key from system.i18n import t +from ui.dialogs.redownload_dialog import RedownloadDialog from ui.views.library_view import LibraryView @@ -70,6 +71,27 @@ def test_redownload_qq_track_uses_configured_quality_as_dialog_default( show_dialog.assert_called_once_with(track.title, current_quality="flac", parent=view) +def test_redownload_dialog_applies_popup_stylesheet_directly( + qapp, +): + from system.theme import ThemeManager + + ThemeManager._instance = None + config = MagicMock() + config.get.return_value = "dark" + ThemeManager.instance(config) + + dialog = RedownloadDialog("Two", current_quality="flac") + qapp.processEvents() + + stylesheet = dialog._quality_combo.view().styleSheet() + popup_stylesheet = dialog._quality_combo.view().window().styleSheet() + assert "background-color" in stylesheet + assert "selection-background-color" in stylesheet + assert "QListView::item" in stylesheet + assert "background-color" in popup_stylesheet + + def test_history_redownload_completion_refreshes_updated_track_path( qapp, mock_theme_config, diff --git a/tests/test_ui/test_plugin_settings_tab.py b/tests/test_ui/test_plugin_settings_tab.py index 2306b5bd..f67f07d7 100644 --- a/tests/test_ui/test_plugin_settings_tab.py +++ b/tests/test_ui/test_plugin_settings_tab.py @@ -4,6 +4,7 @@ from plugins.builtin.qqmusic.lib.login_dialog import QQMusicLoginDialog from plugins.builtin.qqmusic.lib.settings_tab import QQMusicSettingsTab +from system.plugins.host_services import PluginSettingsBridgeImpl from system.theme import ThemeManager from ui.dialogs.plugin_management_tab import PluginManagementTab from ui.dialogs.settings_dialog import GeneralSettingsDialog @@ -40,6 +41,36 @@ def _build_plugin_context(settings: Mock) -> Mock: return Mock(settings=settings, ui=ui, events=events, language="zh") +def _build_dialog_config(store: dict) -> Mock: + config = Mock() + config.get.side_effect = lambda key, default=None: store.get(key, default) + config.set.side_effect = lambda key, value: store.__setitem__(key, value) + config.get_ai_enabled.return_value = False + config.get_ai_base_url.return_value = "" + config.get_ai_api_key.return_value = "" + config.get_ai_model.return_value = "" + config.get_acoustid_enabled.return_value = False + config.get_acoustid_api_key.return_value = "" + config.get_cache_cleanup_strategy.return_value = "manual" + config.get_cache_cleanup_auto_enabled.return_value = False + config.get_cache_cleanup_time_days.return_value = 30 + config.get_cache_cleanup_size_mb.return_value = 1000 + config.get_cache_cleanup_count.return_value = 100 + config.get_cache_cleanup_interval_hours.return_value = 1 + config.get_audio_engine.return_value = "mpv" + config.get_language.return_value = "zh" + config.get_plugin_setting.side_effect = ( + lambda plugin_id, key, default=None: store.get(f"plugins.{plugin_id}.{key}", default) + ) + config.get_plugin_secret.side_effect = ( + lambda plugin_id, key, default="": store.get(f"plugins.{plugin_id}.{key}", default) + ) + config.set_plugin_secret.side_effect = ( + lambda plugin_id, key, value: store.__setitem__(f"plugins.{plugin_id}.{key}", value) + ) + return config + + def test_plugin_management_tab_shows_plugin_rows(qtbot): manager = Mock() manager.list_plugins.return_value = [ @@ -218,6 +249,54 @@ def test_settings_dialog_with_real_builtins_includes_plugin_tabs(monkeypatch, qt assert "QQ 音乐" in tab_labels +def test_settings_dialog_save_persists_qqmusic_download_dir(monkeypatch, qtbot): + store = {} + config = _build_dialog_config(store) + settings_spec = type( + "Spec", + (), + { + "plugin_id": "qqmusic", + "tab_id": "qqmusic.settings", + "title": "QQ Music", + "order": 80, + "title_provider": staticmethod(lambda: "QQ 音乐"), + "widget_factory": staticmethod( + lambda _context, parent: QQMusicSettingsTab( + _build_plugin_context(PluginSettingsBridgeImpl("qqmusic", config)), + parent, + ) + ), + }, + )() + fake_manager = Mock() + fake_manager.list_plugins.return_value = [] + fake_manager.registry.settings_tabs.return_value = [settings_spec] + bootstrap = Mock(plugin_manager=fake_manager) + + plugin_i18n.set_language("zh") + + monkeypatch.setattr("ui.dialogs.settings_dialog.Bootstrap.instance", lambda: bootstrap) + monkeypatch.setattr("ui.dialogs.settings_dialog.MessageDialog.information", lambda *args, **kwargs: None) + ThemeManager._instance = None + ThemeManager.instance(config) + + dialog = GeneralSettingsDialog(config) + qtbot.addWidget(dialog) + + tab = next(widget for widget in dialog.findChildren(QQMusicSettingsTab)) + tab._download_dir_input.setText("/tmp/music") + + dialog._save_settings() + + reopened = GeneralSettingsDialog(config) + qtbot.addWidget(reopened) + reopened_tab = next(widget for widget in reopened.findChildren(QQMusicSettingsTab)) + + assert store["plugins.qqmusic.download_dir"] == "/tmp/music" + assert reopened_tab._download_dir_input.text() == "/tmp/music" + + def test_settings_dialog_uses_plugin_title_provider(monkeypatch, qtbot): config = Mock() config.get.return_value = "dark" @@ -325,6 +404,27 @@ def test_qqmusic_settings_tab_translates_quality_labels(qtbot): assert widget._quality_combo.itemText(0) != "qqmusic_quality_master" +def test_qqmusic_settings_tab_applies_popup_stylesheet_directly(qtbot): + settings = Mock() + settings.get.side_effect = lambda key, default=None: { + "quality": "320", + "download_dir": "data/online_cache", + "credential": None, + "nick": "", + }.get(key, default) + context = _build_plugin_context(settings) + + widget = QQMusicSettingsTab(context) + qtbot.addWidget(widget) + + stylesheet = widget._quality_combo.view().styleSheet() + popup_stylesheet = widget._quality_combo.view().window().styleSheet() + assert "background-color" in stylesheet + assert "selection-background-color" in stylesheet + assert "QListView::item" in stylesheet + assert "background-color" in popup_stylesheet + + def test_qqmusic_settings_tab_keeps_content_padding(qtbot): settings = Mock() settings.get.side_effect = lambda key, default=None: { diff --git a/ui/dialogs/redownload_dialog.py b/ui/dialogs/redownload_dialog.py index bb4a63ed..7df0f8b4 100644 --- a/ui/dialogs/redownload_dialog.py +++ b/ui/dialogs/redownload_dialog.py @@ -66,6 +66,34 @@ class RedownloadDialog(QDialog): } """ + ThemeManager.get_combobox_style() + """ """ + _POPUP_STYLE_TEMPLATE = """ + QListView { + background-color: %background_alt%; + border: 1px solid %border%; + color: %text%; + selection-background-color: %highlight%; + selection-color: %background%; + outline: none; + } + QListView::item { + padding: 6px 10px; + min-height: 20px; + } + QListView::item:hover { + background-color: %highlight%; + color: %background%; + } + QListView::item:selected { + background-color: %highlight%; + color: %background%; + } + """ + _POPUP_CONTAINER_STYLE_TEMPLATE = """ + QFrame { + background-color: %background_alt%; + border: 1px solid %border%; + } + """ def __init__(self, track_title: str, current_quality: str = None, parent=None): super().__init__(parent) @@ -77,7 +105,7 @@ def __init__(self, track_title: str, current_quality: str = None, parent=None): self._setup_shadow() self._setup_ui(track_title, current_quality) - self.setStyleSheet(ThemeManager.instance().get_qss(self._STYLE_TEMPLATE)) + self._apply_theme() ThemeManager.instance().register_widget(self) def _setup_shadow(self): @@ -162,8 +190,17 @@ def show_dialog(track_title: str, current_quality: str = None, parent=None): return dialog.get_quality() return None + def _apply_theme(self): + theme_manager = ThemeManager.instance() + self.setStyleSheet(theme_manager.get_qss(self._STYLE_TEMPLATE)) + popup_view = self._quality_combo.view() + popup_view.setStyleSheet(theme_manager.get_qss(self._POPUP_STYLE_TEMPLATE)) + popup_view.window().setStyleSheet( + theme_manager.get_qss(self._POPUP_CONTAINER_STYLE_TEMPLATE) + ) + def refresh_theme(self): - self.setStyleSheet(ThemeManager.instance().get_qss(self._STYLE_TEMPLATE)) + self._apply_theme() self._title_bar_controller.refresh_theme() def resizeEvent(self, event): diff --git a/ui/dialogs/settings_dialog.py b/ui/dialogs/settings_dialog.py index a3db5ecb..73fab759 100644 --- a/ui/dialogs/settings_dialog.py +++ b/ui/dialogs/settings_dialog.py @@ -139,6 +139,7 @@ def __init__(self, config_manager, parent=None): self._config = config_manager self._batch_worker = None self._drag_pos = None + self._plugin_settings_tabs = [] # Make dialog frameless self.setWindowFlags(Qt.WindowType.Dialog | Qt.FramelessWindowHint) @@ -756,8 +757,10 @@ def _setup_ui(self): t("plugins_tab"), ) for spec in bootstrap.plugin_manager.registry.settings_tabs(): + plugin_tab = spec.widget_factory(bootstrap.plugin_manager, self) + self._plugin_settings_tabs.append(plugin_tab) tab_widget.addTab( - spec.widget_factory(bootstrap.plugin_manager, self), + plugin_tab, spec.title_provider() if callable(getattr(spec, "title_provider", None)) else spec.title, ) @@ -922,6 +925,9 @@ def _save_settings(self): MessageDialog.warning(self, t("warning"), t("acoustid_api_key_required")) return + if not self._save_plugin_settings_tabs(): + return + # Save AI settings self._config.set_ai_enabled(enabled) self._config.set_ai_base_url(base_url) @@ -985,6 +991,22 @@ def _save_settings(self): MessageDialog.information(self, t("success"), t("ai_settings_saved")) self.accept() + def _save_plugin_settings_tabs(self) -> bool: + """Persist mounted plugin settings tabs before closing the dialog.""" + for plugin_tab in self._plugin_settings_tabs: + save_hook = getattr(plugin_tab, "_save", None) + if not callable(save_hook): + save_hook = getattr(plugin_tab, "_save_settings", None) + if not callable(save_hook): + continue + try: + save_hook() + except Exception as exc: + logger.warning("Failed to save plugin settings tab %r: %s", plugin_tab, exc, exc_info=True) + MessageDialog.warning(self, t("warning"), f"Failed to save plugin settings: {exc}") + return False + return True + def _get_runtime_audio_engine(self) -> str: """Get currently running engine name from parent window playback service.""" parent = self.parent() From 9cfbe1ea638cfc36fa150125fc2ed36e132164e2 Mon Sep 17 00:00:00 2001 From: Har01d Date: Tue, 7 Apr 2026 17:24:14 +0800 Subject: [PATCH 056/157] =?UTF-8?q?=E8=A1=A5=E5=85=85Kugou=E6=AD=8C?= =?UTF-8?q?=E8=AF=8D=E6=8F=92=E4=BB=B6=E8=AE=BE=E8=AE=A1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../2026-04-07-kugou-lyrics-plugin-design.md | 167 ++++++++++++++++++ 1 file changed, 167 insertions(+) create mode 100644 docs/superpowers/specs/2026-04-07-kugou-lyrics-plugin-design.md diff --git a/docs/superpowers/specs/2026-04-07-kugou-lyrics-plugin-design.md b/docs/superpowers/specs/2026-04-07-kugou-lyrics-plugin-design.md new file mode 100644 index 00000000..feb2b84a --- /dev/null +++ b/docs/superpowers/specs/2026-04-07-kugou-lyrics-plugin-design.md @@ -0,0 +1,167 @@ +# Kugou Lyrics Plugin Design + +## Overview + +This change moves Kugou lyrics support out of the host-owned built-in source list and into a built-in plugin. + +The goal is to make Kugou follow the same extension boundary already used by LRCLIB and QQ Music for lyrics registration. After the migration, the host still exposes the same end-user behavior, but Kugou is discovered and loaded through the plugin runtime. + +## Goals + +- Move Kugou lyrics registration from host code to a built-in plugin. +- Remove `KugouLyricsSource` from host-owned built-in source assembly. +- Preserve existing Kugou lyrics search and download behavior. +- Keep Kugou enabled by default after migration. +- Keep runtime lyrics source identifiers compatible with existing host logic. + +## Non-Goals + +- No new Kugou settings tab. +- No Kugou sidebar page or online music provider. +- No protocol changes to Kugou lyrics search or download requests. +- No refactor of unrelated host lyrics sources beyond removing Kugou ownership. + +## Current State + +Kugou is currently implemented as a host-owned lyrics source in [`services/sources/lyrics_sources.py`](/home/harold/workspace/music-player/services/sources/lyrics_sources.py). + +[`services/lyrics/lyrics_service.py`](/home/harold/workspace/music-player/services/lyrics/lyrics_service.py) constructs built-in lyrics sources by directly instantiating: + +- `NetEaseLyricsSource` +- `KugouLyricsSource` + +This means Kugou does not participate in the plugin lifecycle even though the application already supports plugin-provided lyrics sources through the registry. + +## Recommended Approach + +Create a new built-in plugin at `plugins/builtin/kugou/` and migrate the Kugou implementation into that plugin. + +The plugin manifest id should be `kuogo_lyrics`, while the runtime lyrics source identifier should remain `kugou`. + +This split is intentional: + +- `manifest.id = "kuogo_lyrics"` controls plugin discovery, enable state, and plugin management UI. +- `source_id = "kugou"` and search result `source = "kugou"` preserve compatibility with existing lyrics download and source matching flows. + +## Architecture + +### Plugin Layout + +Add a new built-in plugin directory: + +```text +plugins/builtin/kugou/ +├── __init__.py +├── plugin.json +├── plugin_main.py +└── lib/ + ├── __init__.py + └── lyrics_source.py +``` + +### Host Boundary + +After migration: + +- the host owns only `NetEaseLyricsSource` as a built-in lyrics source +- the Kugou implementation lives entirely under `plugins/builtin/kugou/` +- `LyricsService._get_sources()` continues to merge host sources with plugin-registered sources + +### Plugin Registration + +`plugin_main.py` should expose a plugin class with: + +- `plugin_id = "kuogo_lyrics"` +- `register(context)` calling `context.services.register_lyrics_source(...)` +- `unregister(context)` as a no-op + +The manifest should declare: + +- `"id": "kuogo_lyrics"` +- `"capabilities": ["lyrics_source"]` + +Because built-in plugins default to enabled unless persisted otherwise, Kugou remains active after the migration without adding special logic. + +## Runtime Behavior + +### Lyrics Source Identity + +The plugin source object should expose: + +- `source_id = "kugou"` +- `display_name = "Kugou"` +- `name = "Kugou"` + +Each returned `PluginLyricsResult` should set: + +- `source = "kugou"` +- `song_id` from Kugou candidate `id` +- `accesskey` from Kugou candidate `accesskey` + +This preserves compatibility with `LyricsService.download_lyrics_by_id()` and existing result-to-dict behavior. + +### Search Flow + +The plugin search flow should preserve the existing request shape: + +- endpoint: `https://lyrics.kugou.com/search` +- params: `keyword`, `page`, `pagesize` +- user agent header + +The plugin should continue returning an empty list on request or decoding errors instead of raising to the host. + +### Download Flow + +The plugin lyrics download flow should preserve the current protocol: + +- endpoint: `https://lyrics.kugou.com/download` +- params: `id`, `accesskey`, `fmt=krc`, `charset=utf8` +- base64 decode response content +- strip `krc1` header when present +- zlib decompress payload +- decode UTF-8 with `errors="ignore"` + +On failure, the plugin should return `None` and log the error. + +## File Changes + +### Create + +- `plugins/builtin/kugou/__init__.py` +- `plugins/builtin/kugou/plugin.json` +- `plugins/builtin/kugou/plugin_main.py` +- `plugins/builtin/kugou/lib/__init__.py` +- `plugins/builtin/kugou/lib/lyrics_source.py` +- `tests/test_plugins/test_kugou_plugin.py` + +### Modify + +- `services/lyrics/lyrics_service.py` +- `services/sources/lyrics_sources.py` +- `services/sources/__init__.py` +- `tests/test_services/test_lyrics_sources_perf_paths.py` +- `tests/test_services/test_plugin_lyrics_registry.py` + +## Testing + +Add or update tests to cover: + +- Kugou plugin registers one lyrics source through the plugin context. +- Kugou plugin search maps API candidate data into `PluginLyricsResult`. +- `LyricsService._get_builtin_sources()` no longer includes Kugou. +- Existing plugin lyrics registry merging still works when built-in sources are empty. + +Regression commands should focus on the changed area: + +- `uv run pytest tests/test_plugins/test_kugou_plugin.py` +- `uv run pytest tests/test_services/test_lyrics_sources_perf_paths.py tests/test_services/test_plugin_lyrics_registry.py` + +## Risks and Mitigations + +- Plugin id typo risk: keep `kuogo_lyrics` limited to plugin manifest and plugin class identity, while preserving runtime source id as `kugou`. +- Behavior regression risk in download flow: preserve the existing decode and decompress logic byte-for-byte where possible. +- Hidden host dependency risk: move all Kugou-specific code under plugin paths and remove host imports of `KugouLyricsSource`. + +## Scope Check + +This design is intentionally narrow. It changes only Kugou lyrics ownership and the associated tests. It does not introduce new plugin capabilities, new UI, or new persistence rules. From 0d73757daf07fad1102ebe361019a33cc4300180 Mon Sep 17 00:00:00 2001 From: Har01d Date: Tue, 7 Apr 2026 17:34:22 +0800 Subject: [PATCH 057/157] =?UTF-8?q?=E6=96=B0=E5=A2=9E=E9=85=B7=E7=8B=97?= =?UTF-8?q?=E6=AD=8C=E8=AF=8D=E6=8F=92=E4=BB=B6=E9=AA=A8=E6=9E=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- plugins/builtin/kugou/__init__.py | 1 + plugins/builtin/kugou/lib/__init__.py | 1 + plugins/builtin/kugou/lib/lyrics_source.py | 63 ++++++++++++++++++++++ plugins/builtin/kugou/plugin.json | 10 ++++ plugins/builtin/kugou/plugin_main.py | 13 +++++ tests/test_plugins/test_kugou_plugin.py | 43 +++++++++++++++ 6 files changed, 131 insertions(+) create mode 100644 plugins/builtin/kugou/__init__.py create mode 100644 plugins/builtin/kugou/lib/__init__.py create mode 100644 plugins/builtin/kugou/lib/lyrics_source.py create mode 100644 plugins/builtin/kugou/plugin.json create mode 100644 plugins/builtin/kugou/plugin_main.py create mode 100644 tests/test_plugins/test_kugou_plugin.py diff --git a/plugins/builtin/kugou/__init__.py b/plugins/builtin/kugou/__init__.py new file mode 100644 index 00000000..547b4ab6 --- /dev/null +++ b/plugins/builtin/kugou/__init__.py @@ -0,0 +1 @@ +"""Kugou lyrics built-in plugin.""" diff --git a/plugins/builtin/kugou/lib/__init__.py b/plugins/builtin/kugou/lib/__init__.py new file mode 100644 index 00000000..bf45fe9c --- /dev/null +++ b/plugins/builtin/kugou/lib/__init__.py @@ -0,0 +1 @@ +"""Kugou plugin internals.""" diff --git a/plugins/builtin/kugou/lib/lyrics_source.py b/plugins/builtin/kugou/lib/lyrics_source.py new file mode 100644 index 00000000..a5e19466 --- /dev/null +++ b/plugins/builtin/kugou/lib/lyrics_source.py @@ -0,0 +1,63 @@ +from __future__ import annotations + +import base64 +import logging +import zlib + +from harmony_plugin_api.lyrics import PluginLyricsResult + +logger = logging.getLogger(__name__) + + +class KugouLyricsPluginSource: + source_id = "kugou" + display_name = "Kugou" + name = "Kugou" + + def __init__(self, http_client) -> None: + self._http_client = http_client + + def search(self, title: str, artist: str, limit: int = 10) -> list[PluginLyricsResult]: + keyword = f"{title} {artist}".strip() + response = self._http_client.get( + "https://lyrics.kugou.com/search", + params={"keyword": keyword, "page": 1, "pagesize": limit}, + headers={"User-Agent": "Mozilla/5.0"}, + timeout=3, + ) + payload = response.json() + return [ + PluginLyricsResult( + song_id=str(item["id"]), + title=item.get("name", item.get("song", "")), + artist=item.get("singer", ""), + source="kugou", + accesskey=item.get("accesskey", ""), + ) + for item in payload.get("candidates", []) + ] + + def get_lyrics(self, result: PluginLyricsResult) -> str | None: + try: + response = self._http_client.get( + "https://lyrics.kugou.com/download", + params={ + "id": result.song_id, + "accesskey": result.accesskey, + "fmt": "krc", + "charset": "utf8", + }, + headers={"User-Agent": "Mozilla/5.0"}, + timeout=5, + ) + payload = response.json() + content = payload.get("content") + if not content: + return None + krc = base64.b64decode(content) + if krc[:4] == b"krc1": + krc = krc[4:] + return zlib.decompress(krc).decode("utf-8", errors="ignore") + except Exception: + logger.exception("Error downloading Kugou lyrics") + return None diff --git a/plugins/builtin/kugou/plugin.json b/plugins/builtin/kugou/plugin.json new file mode 100644 index 00000000..0813cd72 --- /dev/null +++ b/plugins/builtin/kugou/plugin.json @@ -0,0 +1,10 @@ +{ + "id": "kuogo_lyrics", + "name": "Kugou Lyrics", + "version": "1.0.0", + "api_version": "1", + "entrypoint": "plugin_main.py", + "entry_class": "KugouLyricsPlugin", + "capabilities": ["lyrics_source"], + "min_app_version": "0.1.0" +} diff --git a/plugins/builtin/kugou/plugin_main.py b/plugins/builtin/kugou/plugin_main.py new file mode 100644 index 00000000..70711614 --- /dev/null +++ b/plugins/builtin/kugou/plugin_main.py @@ -0,0 +1,13 @@ +from __future__ import annotations + +from .lib.lyrics_source import KugouLyricsPluginSource + + +class KugouLyricsPlugin: + plugin_id = "kuogo_lyrics" + + def register(self, context) -> None: + context.services.register_lyrics_source(KugouLyricsPluginSource(context.http)) + + def unregister(self, context) -> None: + return None diff --git a/tests/test_plugins/test_kugou_plugin.py b/tests/test_plugins/test_kugou_plugin.py new file mode 100644 index 00000000..e715cb06 --- /dev/null +++ b/tests/test_plugins/test_kugou_plugin.py @@ -0,0 +1,43 @@ +from types import SimpleNamespace +from unittest.mock import Mock + +from plugins.builtin.kugou.lib.lyrics_source import KugouLyricsPluginSource +from plugins.builtin.kugou.plugin_main import KugouLyricsPlugin + + +def test_kugou_plugin_registers_lyrics_source(): + context = Mock() + plugin = KugouLyricsPlugin() + + plugin.register(context) + + context.services.register_lyrics_source.assert_called_once() + registered = context.services.register_lyrics_source.call_args.args[0] + assert isinstance(registered, KugouLyricsPluginSource) + + +def test_kugou_plugin_source_search_builds_results(): + fake_response = SimpleNamespace( + json=lambda: { + "candidates": [ + { + "id": 1, + "name": "Song 1", + "singer": "Singer 1", + "accesskey": "k1", + } + ] + } + ) + source = KugouLyricsPluginSource( + SimpleNamespace(get=lambda *_args, **_kwargs: fake_response) + ) + + results = source.search("Song 1", "Singer 1") + + assert len(results) == 1 + assert results[0].song_id == "1" + assert results[0].title == "Song 1" + assert results[0].artist == "Singer 1" + assert results[0].source == "kugou" + assert results[0].accesskey == "k1" From b36c8b2f49da0fb50cbb9f35bac99e51247e0ce9 Mon Sep 17 00:00:00 2001 From: Har01d Date: Tue, 7 Apr 2026 17:34:43 +0800 Subject: [PATCH 058/157] =?UTF-8?q?=E6=8F=92=E4=BB=B6=E7=AE=A1=E7=90=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../2026-04-07-plugin-management-toggle.md | 432 ++++++++++++++++++ tests/test_ui/test_plugin_settings_tab.py | 226 ++++++++- translations/en.json | 3 + translations/zh.json | 3 + ui/dialogs/plugin_management_tab.py | 216 ++++++--- ui/dialogs/settings_dialog.py | 1 + ui/widgets/toggle_switch.py | 149 ++++++ 7 files changed, 964 insertions(+), 66 deletions(-) create mode 100644 docs/superpowers/plans/2026-04-07-plugin-management-toggle.md create mode 100644 ui/widgets/toggle_switch.py diff --git a/docs/superpowers/plans/2026-04-07-plugin-management-toggle.md b/docs/superpowers/plans/2026-04-07-plugin-management-toggle.md new file mode 100644 index 00000000..e8744fb5 --- /dev/null +++ b/docs/superpowers/plans/2026-04-07-plugin-management-toggle.md @@ -0,0 +1,432 @@ +# Plugin Management Toggle Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Update the settings plugin management tab so each plugin row owns its own enable or disable toggle and built-in or external source labels are localized. + +**Architecture:** Keep `QListWidget` as the container, replace plain text entries with a compact row widget that renders plugin metadata and a row-level `QCheckBox`, and route toggle callbacks back through `PluginManagementTab.refresh()`. Localize source ids in the tab with dedicated host translation keys so the change stays confined to the existing settings UI and translation JSON files. + +**Tech Stack:** Python 3.11, PySide6, pytest, pytest-qt, host JSON translations + +--- + +## File Map + +- Modify: `ui/dialogs/plugin_management_tab.py` — replace plain text rows and shared buttons with row widgets and per-plugin toggle wiring +- Modify: `tests/test_ui/test_plugin_settings_tab.py` — cover localized source labels, row-level toggles, and load error rendering +- Modify: `translations/zh.json` — add localized source labels for `builtin` and `external` +- Modify: `translations/en.json` — add English source labels for `builtin` and `external` + +### Task 1: Localize Plugin Source Labels in Row Widgets + +**Files:** +- Modify: `tests/test_ui/test_plugin_settings_tab.py` +- Modify: `ui/dialogs/plugin_management_tab.py` +- Modify: `translations/zh.json` +- Modify: `translations/en.json` + +- [ ] **Step 1: Write the failing source label test** + +```python +from PySide6.QtWidgets import QLabel, QTabWidget, QWidget + + +def _plugin_row_text(widget: PluginManagementTab, index: int) -> str: + item = widget._list.item(index) + row_widget = widget._list.itemWidget(item) + assert row_widget is not None + labels = row_widget.findChildren(QLabel) + return " ".join(label.text() for label in labels) + + +def test_plugin_management_tab_localizes_plugin_sources(qtbot): + manager = Mock() + manager.list_plugins.return_value = [ + { + "id": "qqmusic", + "name": "QQ Music", + "version": "1.0.0", + "source": "builtin", + "enabled": True, + "load_error": None, + }, + { + "id": "lrclib", + "name": "LRCLIB", + "version": "1.0.0", + "source": "external", + "enabled": False, + "load_error": None, + }, + ] + + widget = PluginManagementTab(manager) + qtbot.addWidget(widget) + + first_row = _plugin_row_text(widget, 0) + second_row = _plugin_row_text(widget, 1) + + assert "内置" in first_row + assert "builtin" not in first_row.lower() + assert "外部" in second_row + assert "external" not in second_row.lower() +``` + +- [ ] **Step 2: Run the source label test to verify it fails** + +Run: `uv run pytest tests/test_ui/test_plugin_settings_tab.py::test_plugin_management_tab_localizes_plugin_sources -v` +Expected: FAIL because `PluginManagementTab` still renders plain text `QListWidgetItem` rows, so `itemWidget()` returns `None` + +- [ ] **Step 3: Implement row widgets and localized source mapping** + +```python +# ui/dialogs/plugin_management_tab.py +from PySide6.QtWidgets import ( + QFileDialog, + QHBoxLayout, + QLabel, + QLineEdit, + QListWidget, + QListWidgetItem, + QPushButton, + QVBoxLayout, + QWidget, +) + + +class _PluginListRow(QWidget): + def __init__(self, row: dict, source_label: str, parent=None): + super().__init__(parent) + layout = QHBoxLayout(self) + layout.setContentsMargins(12, 8, 12, 8) + layout.setSpacing(12) + + info_layout = QVBoxLayout() + info_layout.setContentsMargins(0, 0, 0, 0) + info_layout.setSpacing(4) + + name_label = QLabel(row["name"], self) + meta_label = QLabel(f'{row["version"]} · {source_label}', self) + + info_layout.addWidget(name_label) + info_layout.addWidget(meta_label) + layout.addLayout(info_layout, 1) + + +class PluginManagementTab(QWidget): + def _source_label(self, source: str) -> str: + key = { + "builtin": "plugins_source_builtin", + "external": "plugins_source_external", + }.get(source) + return t(key) if key else source + + def refresh(self) -> None: + rows = self._plugin_manager.list_plugins() + self._list.clear() + for row in rows: + item = QListWidgetItem() + item.setData(0x0100, row) + row_widget = _PluginListRow( + row, + self._source_label(row.get("source", "")), + self, + ) + item.setSizeHint(row_widget.sizeHint()) + self._list.addItem(item) + self._list.setItemWidget(item, row_widget) +``` + +```json +// translations/zh.json +"plugins_source_builtin": "内置", +"plugins_source_external": "外部", +``` + +```json +// translations/en.json +"plugins_source_builtin": "Built-in", +"plugins_source_external": "External", +``` + +- [ ] **Step 4: Run the source label test again** + +Run: `uv run pytest tests/test_ui/test_plugin_settings_tab.py::test_plugin_management_tab_localizes_plugin_sources -v` +Expected: PASS + +- [ ] **Step 5: Commit Task 1** + +```bash +git add tests/test_ui/test_plugin_settings_tab.py ui/dialogs/plugin_management_tab.py translations/zh.json translations/en.json +git commit -m "翻译插件来源文案" +``` + +### Task 2: Replace Shared Buttons with Per-Row Toggle Controls + +**Files:** +- Modify: `tests/test_ui/test_plugin_settings_tab.py` +- Modify: `ui/dialogs/plugin_management_tab.py` + +- [ ] **Step 1: Write the failing row toggle test** + +```python +from PySide6.QtWidgets import QCheckBox, QLabel, QTabWidget, QWidget + + +def _plugin_toggle(widget: PluginManagementTab, plugin_id: str) -> QCheckBox: + toggle = widget.findChild(QCheckBox, f"pluginToggle:{plugin_id}") + assert toggle is not None + return toggle + + +def test_plugin_management_tab_uses_row_level_toggles(qtbot): + manager = Mock() + manager.list_plugins.side_effect = [ + [ + { + "id": "qqmusic", + "name": "QQ Music", + "version": "1.0.0", + "source": "builtin", + "enabled": True, + "load_error": None, + }, + { + "id": "lrclib", + "name": "LRCLIB", + "version": "1.0.0", + "source": "external", + "enabled": False, + "load_error": None, + }, + ], + [ + { + "id": "qqmusic", + "name": "QQ Music", + "version": "1.0.0", + "source": "builtin", + "enabled": False, + "load_error": None, + }, + { + "id": "lrclib", + "name": "LRCLIB", + "version": "1.0.0", + "source": "external", + "enabled": False, + "load_error": None, + }, + ], + [ + { + "id": "qqmusic", + "name": "QQ Music", + "version": "1.0.0", + "source": "builtin", + "enabled": False, + "load_error": None, + }, + { + "id": "lrclib", + "name": "LRCLIB", + "version": "1.0.0", + "source": "external", + "enabled": True, + "load_error": None, + }, + ], + ] + + widget = PluginManagementTab(manager) + qtbot.addWidget(widget) + + _plugin_toggle(widget, "qqmusic").click() + _plugin_toggle(widget, "lrclib").click() + + manager.set_plugin_enabled.assert_any_call("qqmusic", False) + manager.set_plugin_enabled.assert_any_call("lrclib", True) + assert manager.list_plugins.call_count == 3 +``` + +- [ ] **Step 2: Run the row toggle test to verify it fails** + +Run: `uv run pytest tests/test_ui/test_plugin_settings_tab.py::test_plugin_management_tab_uses_row_level_toggles -v` +Expected: FAIL because the tab still has shared action buttons and no row-level `QCheckBox` named `pluginToggle:` + +- [ ] **Step 3: Implement row-level toggles and remove shared enable or disable buttons** + +```python +# ui/dialogs/plugin_management_tab.py +from PySide6.QtCore import Signal + + +class _PluginListRow(QWidget): + toggled = Signal(str, bool) + + def __init__(self, row: dict, source_label: str, parent=None): + super().__init__(parent) + layout = QHBoxLayout(self) + layout.setContentsMargins(12, 8, 12, 8) + layout.setSpacing(12) + + info_layout = QVBoxLayout() + info_layout.setContentsMargins(0, 0, 0, 0) + info_layout.setSpacing(4) + + name_label = QLabel(row["name"], self) + status = t("plugins_enabled") if row.get("enabled", True) else t("plugins_disabled") + meta_label = QLabel(f'{row["version"]} · {source_label} · {status}', self) + info_layout.addWidget(name_label) + info_layout.addWidget(meta_label) + layout.addLayout(info_layout, 1) + + plugin_id = row.get("id", "") + toggle = QCheckBox(t("plugins_enabled"), self) + toggle.setObjectName(f"pluginToggle:{plugin_id}") + toggle.setChecked(bool(row.get("enabled", True))) + toggle.toggled.connect(lambda enabled: self.toggled.emit(plugin_id, enabled)) + layout.addWidget(toggle) + + +class PluginManagementTab(QWidget): + def __init__(self, plugin_manager, parent=None): + super().__init__(parent) + self._plugin_manager = plugin_manager + self._list = QListWidget(self) + self._url_input = QLineEdit(self) + self._setup_ui() + self.refresh() + + def _setup_ui(self) -> None: + layout = QVBoxLayout(self) + layout.addWidget(self._list) + + controls = QHBoxLayout() + self._url_input.setPlaceholderText("https://example.com/plugin.zip") + install_zip_btn = QPushButton(t("plugins_install_zip"), self) + install_zip_btn.clicked.connect(self._install_zip) + install_url_btn = QPushButton(t("plugins_install_url"), self) + install_url_btn.clicked.connect(self._install_url) + controls.addWidget(self._url_input) + controls.addWidget(install_zip_btn) + controls.addWidget(install_url_btn) + layout.addLayout(controls) + + def _set_plugin_enabled(self, plugin_id: str, enabled: bool) -> None: + if not plugin_id: + return + self._plugin_manager.set_plugin_enabled(plugin_id, enabled) + self.refresh() + + def refresh(self) -> None: + rows = self._plugin_manager.list_plugins() + self._list.clear() + for row in rows: + item = QListWidgetItem() + item.setData(0x0100, row) + row_widget = _PluginListRow( + row, + self._source_label(row.get("source", "")), + self, + ) + row_widget.toggled.connect(self._set_plugin_enabled) + item.setSizeHint(row_widget.sizeHint()) + self._list.addItem(item) + self._list.setItemWidget(item, row_widget) +``` + +- [ ] **Step 4: Run the row toggle test again** + +Run: `uv run pytest tests/test_ui/test_plugin_settings_tab.py::test_plugin_management_tab_uses_row_level_toggles -v` +Expected: PASS + +- [ ] **Step 5: Commit Task 2** + +```bash +git add tests/test_ui/test_plugin_settings_tab.py ui/dialogs/plugin_management_tab.py +git commit -m "改为插件行内启用开关" +``` + +### Task 3: Preserve Load Error Rendering in the New Row Layout + +**Files:** +- Modify: `tests/test_ui/test_plugin_settings_tab.py` +- Modify: `ui/dialogs/plugin_management_tab.py` + +- [ ] **Step 1: Write the failing load error regression test** + +```python +def test_plugin_management_tab_shows_load_errors_in_custom_rows(qtbot): + manager = Mock() + manager.list_plugins.return_value = [ + { + "id": "qqmusic", + "name": "QQ Music", + "version": "1.0.0", + "source": "builtin", + "enabled": False, + "load_error": "load failed", + } + ] + + widget = PluginManagementTab(manager) + qtbot.addWidget(widget) + + row_text = _plugin_row_text(widget, 0) + assert "load failed" in row_text +``` + +- [ ] **Step 2: Run the load error regression test to verify it fails** + +Run: `uv run pytest tests/test_ui/test_plugin_settings_tab.py::test_plugin_management_tab_shows_load_errors_in_custom_rows -v` +Expected: FAIL because the first row-widget implementation only renders plugin name and metadata, not `load_error` + +- [ ] **Step 3: Add an optional error label to the row widget** + +```python +# ui/dialogs/plugin_management_tab.py +class _PluginListRow(QWidget): + def __init__(self, row: dict, source_label: str, parent=None): + super().__init__(parent) + layout = QHBoxLayout(self) + layout.setContentsMargins(12, 8, 12, 8) + layout.setSpacing(12) + + info_layout = QVBoxLayout() + info_layout.setContentsMargins(0, 0, 0, 0) + info_layout.setSpacing(4) + + name_label = QLabel(row["name"], self) + status = t("plugins_enabled") if row.get("enabled", True) else t("plugins_disabled") + meta_label = QLabel(f'{row["version"]} · {source_label} · {status}', self) + info_layout.addWidget(name_label) + info_layout.addWidget(meta_label) + + plugin_id = row.get("id", "") + toggle = QCheckBox(t("plugins_enabled"), self) + toggle.setObjectName(f"pluginToggle:{plugin_id}") + toggle.setChecked(bool(row.get("enabled", True))) + toggle.toggled.connect(lambda enabled: self.toggled.emit(plugin_id, enabled)) + + load_error = row.get("load_error") + if load_error: + error_label = QLabel(load_error, self) + error_label.setWordWrap(True) + info_layout.addWidget(error_label) + + layout.addLayout(info_layout, 1) + layout.addWidget(toggle) +``` + +- [ ] **Step 4: Run the focused plugin management tests** + +Run: `uv run pytest tests/test_ui/test_plugin_settings_tab.py::test_plugin_management_tab_localizes_plugin_sources tests/test_ui/test_plugin_settings_tab.py::test_plugin_management_tab_uses_row_level_toggles tests/test_ui/test_plugin_settings_tab.py::test_plugin_management_tab_shows_load_errors_in_custom_rows -v` +Expected: PASS + +- [ ] **Step 5: Commit Task 3** + +```bash +git add tests/test_ui/test_plugin_settings_tab.py ui/dialogs/plugin_management_tab.py +git commit -m "补齐插件管理页错误展示" +``` diff --git a/tests/test_ui/test_plugin_settings_tab.py b/tests/test_ui/test_plugin_settings_tab.py index f67f07d7..fd557168 100644 --- a/tests/test_ui/test_plugin_settings_tab.py +++ b/tests/test_ui/test_plugin_settings_tab.py @@ -1,13 +1,16 @@ from unittest.mock import Mock -from PySide6.QtWidgets import QTabWidget, QWidget +from PySide6.QtCore import QRect, Qt +from PySide6.QtWidgets import QLabel, QTabWidget, QTableWidget, QWidget from plugins.builtin.qqmusic.lib.login_dialog import QQMusicLoginDialog from plugins.builtin.qqmusic.lib.settings_tab import QQMusicSettingsTab +from system.i18n import set_language from system.plugins.host_services import PluginSettingsBridgeImpl from system.theme import ThemeManager from ui.dialogs.plugin_management_tab import PluginManagementTab from ui.dialogs.settings_dialog import GeneralSettingsDialog +from ui.widgets.toggle_switch import ToggleSwitch from plugins.builtin.qqmusic.lib import i18n as plugin_i18n @@ -71,6 +74,37 @@ def _build_dialog_config(store: dict) -> Mock: return config +def _plugin_table(widget: PluginManagementTab) -> QTableWidget: + table = widget.findChild(QTableWidget) + assert table is not None + return table + + +def _plugin_toggle(widget: PluginManagementTab, plugin_id: str) -> ToggleSwitch: + toggle = widget.findChild(ToggleSwitch, f"pluginToggle:{plugin_id}") + assert toggle is not None + return toggle + + +def _plugin_row_widget(widget: PluginManagementTab, index: int) -> QWidget: + table = _plugin_table(widget) + row_widget = table.cellWidget(index, 0) + assert row_widget is not None + return row_widget + + +def _plugin_row_text(widget: PluginManagementTab, index: int) -> str: + row_widget = _plugin_row_widget(widget, index) + labels = row_widget.findChildren(QLabel) + cells = [] + table = _plugin_table(widget) + for column in (1, 2, 3): + item = table.item(index, column) + if item is not None: + cells.append(item.text()) + return " ".join(label.text() for label in labels) + " " + " ".join(cells) + + def test_plugin_management_tab_shows_plugin_rows(qtbot): manager = Mock() manager.list_plugins.return_value = [ @@ -95,12 +129,68 @@ def test_plugin_management_tab_shows_plugin_rows(qtbot): widget = PluginManagementTab(manager) qtbot.addWidget(widget) - assert widget._list.count() == 2 - assert "qqmusic" not in widget._list.item(1).text().lower() - assert "QQ Music" in widget._list.item(1).text() + table = _plugin_table(widget) + assert table.rowCount() == 2 + assert table.columnCount() == 5 + assert table.verticalHeader().defaultSectionSize() >= 48 + row_text = _plugin_row_text(widget, 1) + assert "qqmusic" not in row_text.lower() + assert "QQ Music" in row_text -def test_plugin_management_tab_can_toggle_selected_plugin_enabled_state(qtbot): +def test_plugin_management_tab_shows_load_errors_in_custom_rows(qtbot): + manager = Mock() + manager.list_plugins.return_value = [ + { + "id": "qqmusic", + "name": "QQ Music", + "version": "1.0.0", + "source": "builtin", + "enabled": False, + "load_error": "load failed", + } + ] + + widget = PluginManagementTab(manager) + qtbot.addWidget(widget) + + row_text = _plugin_row_text(widget, 0) + assert "load failed" in row_text + + +def test_plugin_management_tab_grows_row_height_for_wrapped_text(qtbot): + manager = Mock() + manager.list_plugins.return_value = [ + { + "id": "qqmusic", + "name": "QQ Music Plugin With A Very Long Display Name That Needs Wrapping", + "version": "2026.04.07-build-with-extra-long-metadata", + "source": "builtin", + "enabled": False, + "load_error": "This load error is intentionally long so the plugin row must wrap across multiple lines when the settings panel is narrow.", + } + ] + + widget = PluginManagementTab(manager) + qtbot.addWidget(widget) + widget.resize(220, 240) + widget.show() + qtbot.waitExposed(widget) + + row_widget = _plugin_row_widget(widget, 0) + table = _plugin_table(widget) + labels = row_widget.findChildren(QLabel) + name_label = labels[0] + + assert name_label.wordWrap() + assert name_label.height() > name_label.fontMetrics().height() + assert table.rowHeight(0) >= 56 + assert table.columnWidth(4) >= 46 + assert row_widget.width() <= table.viewport().width() + + +def test_plugin_management_tab_localizes_plugin_sources(qtbot): + set_language("zh") manager = Mock() manager.list_plugins.return_value = [ { @@ -115,7 +205,7 @@ def test_plugin_management_tab_can_toggle_selected_plugin_enabled_state(qtbot): "id": "lrclib", "name": "LRCLIB", "version": "1.0.0", - "source": "builtin", + "source": "external", "enabled": False, "load_error": None, }, @@ -124,14 +214,117 @@ def test_plugin_management_tab_can_toggle_selected_plugin_enabled_state(qtbot): widget = PluginManagementTab(manager) qtbot.addWidget(widget) - widget._list.setCurrentRow(0) - widget._disable_btn.click() - widget._list.setCurrentRow(1) - widget._enable_btn.click() + table = _plugin_table(widget) + first_row = _plugin_row_text(widget, 0) + second_row = _plugin_row_text(widget, 1) + + assert "内置" in first_row + assert "builtin" not in first_row.lower() + assert "外部" in second_row + assert "external" not in second_row.lower() + assert table.item(0, 2).text() == "内置" + assert table.item(1, 2).text() == "外部" + + +def test_plugin_management_tab_localizes_version_header(qtbot): + set_language("zh") + manager = Mock() + manager.list_plugins.return_value = [] + + widget = PluginManagementTab(manager) + qtbot.addWidget(widget) + + table = _plugin_table(widget) + assert table.horizontalHeaderItem(1).text() == "版本" + + +def test_plugin_management_tab_applies_themed_header_stylesheet(qtbot): + config = Mock() + config.get.return_value = "dark" + ThemeManager._instance = None + ThemeManager.instance(config) + + manager = Mock() + manager.list_plugins.return_value = [] + + widget = PluginManagementTab(manager) + qtbot.addWidget(widget) + + table = _plugin_table(widget) + stylesheet = table.styleSheet() + assert "QHeaderView::section" in stylesheet + assert "%background%" not in stylesheet + assert "%text%" not in stylesheet + + +def test_plugin_management_tab_uses_row_level_toggles(qtbot): + manager = Mock() + manager.list_plugins.side_effect = [ + [ + { + "id": "qqmusic", + "name": "QQ Music", + "version": "1.0.0", + "source": "builtin", + "enabled": True, + "load_error": None, + }, + { + "id": "lrclib", + "name": "LRCLIB", + "version": "1.0.0", + "source": "external", + "enabled": False, + "load_error": None, + }, + ], + [ + { + "id": "qqmusic", + "name": "QQ Music", + "version": "1.0.0", + "source": "builtin", + "enabled": False, + "load_error": None, + }, + { + "id": "lrclib", + "name": "LRCLIB", + "version": "1.0.0", + "source": "external", + "enabled": False, + "load_error": None, + }, + ], + [ + { + "id": "qqmusic", + "name": "QQ Music", + "version": "1.0.0", + "source": "builtin", + "enabled": False, + "load_error": None, + }, + { + "id": "lrclib", + "name": "LRCLIB", + "version": "1.0.0", + "source": "external", + "enabled": True, + "load_error": None, + }, + ], + ] + + widget = PluginManagementTab(manager) + qtbot.addWidget(widget) + + qtbot.mouseClick(_plugin_toggle(widget, "qqmusic"), Qt.LeftButton) + qtbot.mouseClick(_plugin_toggle(widget, "lrclib"), Qt.LeftButton) manager.set_plugin_enabled.assert_any_call("qqmusic", False) manager.set_plugin_enabled.assert_any_call("lrclib", True) - assert manager.list_plugins.call_count >= 3 + assert manager.list_plugins.call_count == 3 def test_settings_dialog_includes_plugins_tab(monkeypatch, qtbot): @@ -236,6 +429,15 @@ def test_settings_dialog_with_real_builtins_includes_plugin_tabs(monkeypatch, qt bootstrap._online_download_service = Mock() plugin_i18n.set_language("zh") + manager = bootstrap.plugin_manager + original_get = manager._state_store.get + monkeypatch.setattr( + manager._state_store, + "get", + lambda plugin_id: None if plugin_id == "qqmusic" else original_get(plugin_id), + ) + monkeypatch.setattr(manager._state_store, "set_enabled", lambda *args, **kwargs: None) + manager.load_enabled_plugins() monkeypatch.setattr("ui.dialogs.settings_dialog.Bootstrap.instance", lambda: bootstrap) ThemeManager._instance = None @@ -246,7 +448,7 @@ def test_settings_dialog_with_real_builtins_includes_plugin_tabs(monkeypatch, qt tab_widget = dialog.findChild(QTabWidget) tab_labels = [tab_widget.tabText(index) for index in range(tab_widget.count())] - assert "QQ 音乐" in tab_labels + assert "QQ音乐" in tab_labels def test_settings_dialog_save_persists_qqmusic_download_dir(monkeypatch, qtbot): diff --git a/translations/en.json b/translations/en.json index 6d6fdd27..bba841f5 100644 --- a/translations/en.json +++ b/translations/en.json @@ -55,6 +55,7 @@ "previous": "Previous", "quit": "Quit", "title": "Title", + "version": "Version", "source": "Source", "source_local": "Local", "source_quark": "Quark", @@ -320,6 +321,8 @@ "plugins_load_error": "Load Error", "plugins_enabled": "Enabled", "plugins_disabled": "Disabled", + "plugins_source_builtin": "Built-in", + "plugins_source_external": "External", "redownload": "⬇ Re-download", "redownload_hint": "Note: High quality may not be available due to copyright, will auto-downgrade to available quality", "select_quality": "Select Quality", diff --git a/translations/zh.json b/translations/zh.json index 97cde3f2..962da14d 100644 --- a/translations/zh.json +++ b/translations/zh.json @@ -54,6 +54,7 @@ "previous": "上一首", "quit": "退出", "title": "标题", + "version": "版本", "source": "来源", "source_local": "本地文件", "source_quark": "夸克网盘", @@ -320,6 +321,8 @@ "plugins_load_error": "加载错误", "plugins_enabled": "已启用", "plugins_disabled": "已禁用", + "plugins_source_builtin": "内置", + "plugins_source_external": "外部", "redownload": "⬇ 重新下载", "redownload_hint": "提示:高音质可能因版权限制无法下载,将自动降级到可用音质", "select_quality": "选择音质", diff --git a/ui/dialogs/plugin_management_tab.py b/ui/dialogs/plugin_management_tab.py index 04198d3b..93529c47 100644 --- a/ui/dialogs/plugin_management_tab.py +++ b/ui/dialogs/plugin_management_tab.py @@ -1,41 +1,123 @@ from __future__ import annotations +from PySide6.QtCore import Qt from PySide6.QtWidgets import ( QFileDialog, + QHeaderView, QHBoxLayout, + QLabel, QLineEdit, - QListWidget, - QListWidgetItem, QPushButton, + QTableWidget, + QTableWidgetItem, QVBoxLayout, QWidget, ) from system.i18n import t +from system.theme import ThemeManager +from ui.widgets.toggle_switch import ToggleSwitch + + +class _PluginNameCell(QWidget): + def __init__(self, name: str, parent=None): + super().__init__(parent) + + layout = QVBoxLayout(self) + layout.setContentsMargins(8, 6, 8, 6) + layout.setSpacing(0) + + name_label = QLabel(name, self) + name_label.setWordWrap(True) + layout.addWidget(name_label) class PluginManagementTab(QWidget): + _COLUMN_NAME = 0 + _COLUMN_VERSION = 1 + _COLUMN_SOURCE = 2 + _COLUMN_ERROR = 3 + _COLUMN_ENABLED = 4 + _STYLE_TEMPLATE = """ + QTableWidget#pluginManagementTable { + background-color: %background%; + border: 1px solid %border%; + border-radius: 8px; + gridline-color: %background_hover%; + } + QTableWidget#pluginManagementTable::item { + padding: 8px 10px; + color: %text%; + border: none; + border-bottom: 1px solid %background_hover%; + } + QTableWidget#pluginManagementTable::item:selected { + background-color: %selection%; + color: %text%; + } + QTableWidget#pluginManagementTable QHeaderView::section { + background-color: %background_alt%; + color: %text%; + padding: 10px 12px; + border: none; + border-bottom: 1px solid %border%; + font-weight: bold; + } + QTableWidget#pluginManagementTable QTableCornerButton::section { + background-color: %background_alt%; + border: none; + border-bottom: 1px solid %border%; + } + """ + def __init__(self, plugin_manager, parent=None): super().__init__(parent) self._plugin_manager = plugin_manager - self._list = QListWidget(self) + self._table = QTableWidget(self) self._url_input = QLineEdit(self) - self._enable_btn = QPushButton(t("plugins_enabled"), self) - self._disable_btn = QPushButton(t("plugins_disabled"), self) + self._theme_manager = self._resolve_theme_manager() + if self._theme_manager is not None: + self._theme_manager.register_widget(self) self._setup_ui() self.refresh() def _setup_ui(self) -> None: layout = QVBoxLayout(self) - layout.addWidget(self._list) - self._list.currentItemChanged.connect(lambda *_args: self._sync_action_buttons()) - state_controls = QHBoxLayout() - self._enable_btn.clicked.connect(lambda: self._set_selected_plugin_enabled(True)) - self._disable_btn.clicked.connect(lambda: self._set_selected_plugin_enabled(False)) - state_controls.addWidget(self._enable_btn) - state_controls.addWidget(self._disable_btn) - layout.addLayout(state_controls) + self._table.setObjectName("pluginManagementTable") + self._table.setColumnCount(5) + self._table.setHorizontalHeaderLabels( + [ + t("plugins_tab"), + t("version"), + t("source"), + t("plugins_load_error"), + t("plugins_enabled"), + ] + ) + self._table.setEditTriggers(QTableWidget.NoEditTriggers) + self._table.setSelectionMode(QTableWidget.NoSelection) + self._table.setFocusPolicy(Qt.NoFocus) + self._table.setShowGrid(False) + self._table.setAlternatingRowColors(False) + self._table.setWordWrap(True) + self._table.verticalHeader().setVisible(False) + self._table.verticalHeader().setDefaultSectionSize(56) + self._table.verticalHeader().setMinimumSectionSize(56) + self._table.verticalHeader().setSectionResizeMode(QHeaderView.Fixed) + + header = self._table.horizontalHeader() + header.setStretchLastSection(False) + header.setSectionResizeMode(self._COLUMN_NAME, QHeaderView.Stretch) + header.setSectionResizeMode(self._COLUMN_VERSION, QHeaderView.ResizeToContents) + header.setSectionResizeMode(self._COLUMN_SOURCE, QHeaderView.ResizeToContents) + header.setSectionResizeMode(self._COLUMN_ERROR, QHeaderView.Stretch) + header.setSectionResizeMode(self._COLUMN_ENABLED, QHeaderView.Fixed) + self._table.setColumnWidth(self._COLUMN_ENABLED, 68) + self._table.setColumnWidth(self._COLUMN_ERROR, 180) + self.refresh_theme() + + layout.addWidget(self._table) controls = QHBoxLayout() self._url_input.setPlaceholderText("https://example.com/plugin.zip") @@ -50,52 +132,78 @@ def _setup_ui(self) -> None: def refresh(self) -> None: rows = self._plugin_manager.list_plugins() - self._list.clear() - for row in rows: - status = t("plugins_enabled") if row["enabled"] else t("plugins_disabled") - parts = [ - row["name"], - row["version"], - row["source"], - status, - ] - if row["load_error"]: - parts.append(row["load_error"]) - item = QListWidgetItem(" · ".join(parts)) - item.setData(0x0100, row) - self._list.addItem(item) - self._sync_action_buttons() - - def _set_selected_plugin_enabled(self, enabled: bool) -> None: - item = self._list.currentItem() - if item is None: - return - row = item.data(0x0100) or {} - plugin_id = row.get("id") + self._table.setRowCount(len(rows)) + + for index, row in enumerate(rows): + self._table.setCellWidget( + index, + self._COLUMN_NAME, + _PluginNameCell(row["name"], self._table), + ) + + self._set_text_item(index, self._COLUMN_VERSION, row["version"]) + self._set_text_item(index, self._COLUMN_SOURCE, self._source_label(row.get("source", ""))) + + load_error = row.get("load_error") or "" + self._set_text_item(index, self._COLUMN_ERROR, load_error) + error_item = self._table.item(index, self._COLUMN_ERROR) + if error_item is not None and load_error: + error_item.setToolTip(load_error) + + plugin_id = row.get("id", "") + toggle = ToggleSwitch(bool(row.get("enabled", True)), self._table) + toggle.setObjectName(f"pluginToggle:{plugin_id}") + status = t("plugins_enabled") if row.get("enabled", True) else t("plugins_disabled") + toggle.setToolTip(status) + toggle.toggled.connect( + lambda enabled, plugin_id=plugin_id: self._set_plugin_enabled(plugin_id, enabled) + ) + + toggle_cell = QWidget(self._table) + toggle_layout = QHBoxLayout(toggle_cell) + toggle_layout.setContentsMargins(0, 0, 0, 0) + toggle_layout.addStretch() + toggle_layout.addWidget(toggle) + toggle_layout.addStretch() + self._table.setCellWidget(index, self._COLUMN_ENABLED, toggle_cell) + + self._table.setRowHeight(index, 56) + + def _set_text_item(self, row: int, column: int, text: str) -> None: + item = QTableWidgetItem(text) + item.setFlags(Qt.ItemIsEnabled) + item.setTextAlignment(Qt.AlignLeft | Qt.AlignVCenter) + self._table.setItem(row, column, item) + + def _source_label(self, source: str) -> str: + key = { + "builtin": "plugins_source_builtin", + "external": "plugins_source_external", + }.get(source) + return t(key) if key else source + + def _set_plugin_enabled(self, plugin_id: str, enabled: bool) -> None: if not plugin_id: return self._plugin_manager.set_plugin_enabled(plugin_id, enabled) self.refresh() - self._restore_selection(plugin_id) - - def _restore_selection(self, plugin_id: str) -> None: - for index in range(self._list.count()): - item = self._list.item(index) - row = item.data(0x0100) or {} - if row.get("id") == plugin_id: - self._list.setCurrentRow(index) - break - - def _sync_action_buttons(self) -> None: - item = self._list.currentItem() - if item is None: - self._enable_btn.setEnabled(False) - self._disable_btn.setEnabled(False) + + def resizeEvent(self, event) -> None: + super().resizeEvent(event) + self._table.resizeRowsToContents() + for row in range(self._table.rowCount()): + self._table.setRowHeight(row, max(56, self._table.rowHeight(row))) + + def refresh_theme(self) -> None: + if self._theme_manager is None: return - row = item.data(0x0100) or {} - enabled = bool(row.get("enabled", True)) - self._enable_btn.setEnabled(not enabled) - self._disable_btn.setEnabled(enabled) + self._table.setStyleSheet(self._theme_manager.get_qss(self._STYLE_TEMPLATE)) + + def _resolve_theme_manager(self): + try: + return ThemeManager.instance() + except ValueError: + return None def _install_zip(self) -> None: path, _ = QFileDialog.getOpenFileName( diff --git a/ui/dialogs/settings_dialog.py b/ui/dialogs/settings_dialog.py index 73fab759..c70fddd6 100644 --- a/ui/dialogs/settings_dialog.py +++ b/ui/dialogs/settings_dialog.py @@ -1,4 +1,5 @@ """General Settings Dialog for configuring host and plugin settings.""" +import importlib import logging import os from typing import Optional diff --git a/ui/widgets/toggle_switch.py b/ui/widgets/toggle_switch.py new file mode 100644 index 00000000..2c8f2693 --- /dev/null +++ b/ui/widgets/toggle_switch.py @@ -0,0 +1,149 @@ +import sys + +from PySide6.QtCore import Qt, Property, QPropertyAnimation, QEasingCurve, Signal +from PySide6.QtGui import QPainter, QColor +from PySide6.QtWidgets import QWidget, QSizePolicy, QGraphicsDropShadowEffect, QApplication + + +class ToggleSwitch(QWidget): + toggled = Signal(bool) + + def __init__(self, checked=False, parent=None): + super().__init__(parent) + + # ✅ 自适应布局 + self.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed) + self.setMinimumHeight(22) + self.setMinimumWidth(46) + + # 状态 + self._checked = checked + self._circle_pos = 0 + + # 动画 + self.anim = QPropertyAnimation(self, b"circle_pos", self) + self.anim.setDuration(180) + self.anim.setEasingCurve(QEasingCurve.OutCubic) + + # 主题 + self.bg_on = QColor("#22c55e") + self.bg_off = QColor("#3f3f46") + self.bg_disabled = QColor("#2a2a2a") + self.circle_color = QColor("#ffffff") + + # 阴影 + shadow = QGraphicsDropShadowEffect(self) + shadow.setBlurRadius(12) + shadow.setOffset(0, 2) + self.setGraphicsEffect(shadow) + + self.setCursor(Qt.PointingHandCursor) + + # ========= Property ========= + def get_circle_pos(self): + return self._circle_pos + + def set_circle_pos(self, pos): + self._circle_pos = pos + self.update() + + circle_pos = Property(float, get_circle_pos, set_circle_pos) + + # ========= 状态 ========= + def isChecked(self): + return self._checked + + def setChecked(self, checked, animate=True): + if self._checked == checked: + return + + self._checked = checked + + end_pos = self._end_pos() + + if animate: + self.anim.stop() + self.anim.setStartValue(self._circle_pos) + self.anim.setEndValue(end_pos) + self.anim.start() + else: + self._circle_pos = end_pos + self.update() + + self.toggled.emit(self._checked) + + def toggle(self): + self.setChecked(not self._checked) + + # ========= 位置计算 ========= + def margin(self): + # 根据高度动态计算边距 + return max(2, int(self.height() * 0.13)) + + def diameter(self): + return self.height() - self.margin() * 2 + + def _end_pos(self): + return self.width() - self.diameter() - self.margin() if self._checked else self.margin() + + # ========= 点击事件 ========= + def mousePressEvent(self, event): + if not self.isEnabled(): + return + if event.button() == Qt.LeftButton: + self.toggle() + + # ========= Resize 自动修正 ========= + def resizeEvent(self, event): + # 保证尺寸变化时滑块位置正确 + if self._checked: + self._circle_pos = self._end_pos() + else: + self._circle_pos = self._end_pos() + + # ========= 绘制 ========= + def paintEvent(self, event): + painter = QPainter(self) + painter.setRenderHint(QPainter.Antialiasing) + + m = self.margin() + d = self.diameter() + + # 背景 + if not self.isEnabled(): + bg_color = self.bg_disabled + else: + bg_color = self.bg_on if self._checked else self.bg_off + + painter.setBrush(bg_color) + painter.setPen(Qt.NoPen) + painter.drawRoundedRect(0, 0, self.width(), self.height(), self.height() / 2, self.height() / 2) + + # 滑块阴影(轻微模拟) + painter.setBrush(QColor(0, 0, 0, 30)) + painter.drawEllipse(int(self._circle_pos), m + 1, d, d) + + # 滑块 + painter.setBrush(self.circle_color) + painter.drawEllipse(int(self._circle_pos), m, d, d) + + +# ========= Demo ========= +if __name__ == "__main__": + app = QApplication(sys.argv) + from PySide6.QtWidgets import QVBoxLayout, QWidget + + w = QWidget() + w.resize(300, 150) + layout = QVBoxLayout(w) + + toggle1 = ToggleSwitch(True) + toggle2 = ToggleSwitch(False) + toggle3 = ToggleSwitch(True) + + layout.addWidget(toggle1) + layout.addWidget(toggle2) + layout.addWidget(toggle3) + + w.show() + sys.exit(app.exec()) From cb776912e9a60c6e83eec9f37871beda15f8275d Mon Sep 17 00:00:00 2001 From: Har01d Date: Tue, 7 Apr 2026 17:35:32 +0800 Subject: [PATCH 059/157] =?UTF-8?q?=E7=A7=BB=E9=99=A4=E5=AE=BF=E4=B8=BB?= =?UTF-8?q?=E5=86=85=E7=BD=AE=E9=85=B7=E7=8B=97=E6=AD=8C=E8=AF=8D=E6=BA=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- services/lyrics/lyrics_service.py | 2 - services/sources/__init__.py | 2 - services/sources/lyrics_sources.py | 86 ------------------- .../test_plugin_lyrics_registry.py | 2 + 4 files changed, 2 insertions(+), 90 deletions(-) diff --git a/services/lyrics/lyrics_service.py b/services/lyrics/lyrics_service.py index 8fdbcb60..4656ef42 100644 --- a/services/lyrics/lyrics_service.py +++ b/services/lyrics/lyrics_service.py @@ -60,12 +60,10 @@ def _get_builtin_sources(cls) -> List["LyricsSource"]: """Get built-in host lyrics sources.""" from services.sources import ( NetEaseLyricsSource, - KugouLyricsSource, ) http_client = _get_http_client() return [ NetEaseLyricsSource(http_client), - KugouLyricsSource(http_client), ] @classmethod diff --git a/services/sources/__init__.py b/services/sources/__init__.py index 4699e3a9..77260e11 100644 --- a/services/sources/__init__.py +++ b/services/sources/__init__.py @@ -15,7 +15,6 @@ ) from .lyrics_sources import ( NetEaseLyricsSource, - KugouLyricsSource, ) from .artist_cover_sources import ( NetEaseArtistCoverSource, @@ -36,7 +35,6 @@ "SpotifyCoverSource", # Lyrics sources "NetEaseLyricsSource", - "KugouLyricsSource", # Artist cover sources "NetEaseArtistCoverSource", "ITunesArtistCoverSource", diff --git a/services/sources/lyrics_sources.py b/services/sources/lyrics_sources.py index 924abafd..0658d0c0 100644 --- a/services/sources/lyrics_sources.py +++ b/services/sources/lyrics_sources.py @@ -2,9 +2,7 @@ Lyrics source implementations. """ -import base64 import logging -import zlib from typing import Optional, List from .base import LyricsSource, LyricsSearchResult @@ -133,90 +131,6 @@ def get_lyrics(self, result: LyricsSearchResult) -> Optional[str]: def __init__(self, http_client): self._http_client = http_client -class KugouLyricsSource(LyricsSource): - """Kugou lyrics source.""" - - @property - def name(self) -> str: - return "Kugou" - - def search( - self, - title: str, - artist: str, - limit: int = 10 - ) -> List[LyricsSearchResult]: - """Search for lyrics from Kugou.""" - results = [] - - keyword = f"{title} {artist}" - search_url = "https://lyrics.kugou.com/search" - headers = {"User-Agent": "Mozilla/5.0"} - - params = { - "keyword": keyword, - "page": 1, - "pagesize": limit - } - - try: - r = self._http_client.get(search_url, params=params, headers=headers, timeout=3) - data = r.json() - - candidates = data.get("candidates", []) - results.extend(LyricsSearchResult( - id=str(item['id']), - title=item.get('name', item.get('song', '')), - artist=item.get('singer', ''), - album='', - source='kugou', - accesskey=item.get('accesskey', '') - ) for item in candidates) - - except Exception as e: - logger.debug(f"Kugou search error: {e}") - - return results - - def get_lyrics(self, result: LyricsSearchResult) -> Optional[str]: - """Download lyrics from Kugou by song ID.""" - try: - download_url = "https://lyrics.kugou.com/download" - headers = {"User-Agent": "Mozilla/5.0"} - - params = { - "id": result.id, - "accesskey": result.accesskey, - "fmt": "krc", - "charset": "utf8" - } - - r = self._http_client.get(download_url, params=params, headers=headers, timeout=5) - data = r.json() - - content = data.get("content") - if not content: - return None - - # base64 decode - krc = base64.b64decode(content) - - # Remove KRC header - if krc[:4] == b'krc1': - krc = krc[4:] - - # zlib decompress - lyric = zlib.decompress(krc) - return lyric.decode("utf-8", errors="ignore") - - except Exception as e: - logger.error(f"Error downloading Kugou lyrics: {e}") - - return None - - def __init__(self, http_client): - self._http_client = http_client - class LRCLIBLyricsSource(LyricsSource): """LRCLIB (free, open source lyrics API) source.""" diff --git a/tests/test_services/test_plugin_lyrics_registry.py b/tests/test_services/test_plugin_lyrics_registry.py index 6c3beba1..5313d331 100644 --- a/tests/test_services/test_plugin_lyrics_registry.py +++ b/tests/test_services/test_plugin_lyrics_registry.py @@ -43,3 +43,5 @@ def test_builtin_lyrics_sources_exclude_plugin_owned_sources(): assert "LRCLIB" not in names assert "QQMusic" not in names + assert "Kugou" not in names + assert names == {"NetEase"} From 667f3f5d395303bc01b5a1d26a9b4eda7dcb28e1 Mon Sep 17 00:00:00 2001 From: Har01d Date: Tue, 7 Apr 2026 17:37:58 +0800 Subject: [PATCH 060/157] =?UTF-8?q?=E8=A1=A5=E5=85=85=E9=85=B7=E7=8B=97?= =?UTF-8?q?=E6=AD=8C=E8=AF=8D=E6=8F=92=E4=BB=B6=E6=B5=8B=E8=AF=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tests/test_plugins/test_kugou_plugin.py | 18 ++++++++++++++++++ .../test_lyrics_sources_perf_paths.py | 14 -------------- 2 files changed, 18 insertions(+), 14 deletions(-) diff --git a/tests/test_plugins/test_kugou_plugin.py b/tests/test_plugins/test_kugou_plugin.py index e715cb06..1e7ef4b5 100644 --- a/tests/test_plugins/test_kugou_plugin.py +++ b/tests/test_plugins/test_kugou_plugin.py @@ -1,3 +1,5 @@ +import base64 +import zlib from types import SimpleNamespace from unittest.mock import Mock @@ -41,3 +43,19 @@ def test_kugou_plugin_source_search_builds_results(): assert results[0].artist == "Singer 1" assert results[0].source == "kugou" assert results[0].accesskey == "k1" + + +def test_kugou_plugin_source_decodes_krc_payload(): + content = base64.b64encode( + b"krc1" + zlib.compress("[00:01.00]line".encode("utf-8")) + ).decode("utf-8") + fake_response = SimpleNamespace(json=lambda: {"content": content}) + source = KugouLyricsPluginSource( + SimpleNamespace(get=lambda *_args, **_kwargs: fake_response) + ) + + lyrics = source.get_lyrics( + SimpleNamespace(song_id="1", accesskey="k1") + ) + + assert lyrics == "[00:01.00]line" diff --git a/tests/test_services/test_lyrics_sources_perf_paths.py b/tests/test_services/test_lyrics_sources_perf_paths.py index b9a96a09..ec09635c 100644 --- a/tests/test_services/test_lyrics_sources_perf_paths.py +++ b/tests/test_services/test_lyrics_sources_perf_paths.py @@ -4,7 +4,6 @@ from plugins.builtin.qqmusic.lib.api import QQMusicPluginAPI from plugins.builtin.qqmusic.lib.lyrics_source import QQMusicLyricsPluginSource -from services.sources.lyrics_sources import KugouLyricsSource def test_qqmusic_lyrics_source_search_builds_results(monkeypatch): @@ -34,16 +33,3 @@ def test_qqmusic_lyrics_source_search_builds_results(monkeypatch): assert len(results) == 1 assert results[0].song_id == "song-1" assert results[0].title == "Song 1" - - -def test_kugou_lyrics_source_search_builds_results(): - fake_response = SimpleNamespace( - json=lambda: {"candidates": [{"id": 1, "name": "Song 1", "singer": "Singer 1", "accesskey": "k1"}]} - ) - source = KugouLyricsSource(SimpleNamespace(get=lambda *_args, **_kwargs: fake_response)) - - results = source.search("Song 1", "Singer 1") - - assert len(results) == 1 - assert results[0].id == "1" - assert results[0].accesskey == "k1" From 84543bd25402a04cdbcbdad15b6dcbf3f65c06bd Mon Sep 17 00:00:00 2001 From: Har01d Date: Tue, 7 Apr 2026 17:44:35 +0800 Subject: [PATCH 061/157] =?UTF-8?q?=E4=BF=AE=E5=A4=8DQQ=E9=9F=B3=E4=B9=90?= =?UTF-8?q?=E6=AD=8C=E8=AF=8D=E4=B8=8B=E8=BD=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- plugins/builtin/qqmusic/lib/api.py | 13 ++ .../qqmusic/lib/artist_cover_source.py | 18 ++- plugins/builtin/qqmusic/lib/cover_source.py | 32 ++-- plugins/builtin/qqmusic/lib/lyrics_source.py | 32 +++- .../test_qqmusic_plugin_source_adapters.py | 139 ++++++++++++++++++ ui/widgets/toggle_switch.py | 4 +- 6 files changed, 216 insertions(+), 22 deletions(-) create mode 100644 tests/test_services/test_qqmusic_plugin_source_adapters.py diff --git a/plugins/builtin/qqmusic/lib/api.py b/plugins/builtin/qqmusic/lib/api.py index d9f48e31..2d562059 100644 --- a/plugins/builtin/qqmusic/lib/api.py +++ b/plugins/builtin/qqmusic/lib/api.py @@ -70,6 +70,19 @@ def search( ] } + def search_artist( + self, + keyword: str, + limit: int = 20, + page: int = 1, + ) -> list[dict]: + return self.search( + keyword, + search_type="singer", + limit=limit, + page=page, + ).get("artists", []) + def get_top_lists(self) -> list[dict]: response = self._context.http.get(f"{self.REMOTE_BASE_URL}/top", timeout=20) data = response.json() diff --git a/plugins/builtin/qqmusic/lib/artist_cover_source.py b/plugins/builtin/qqmusic/lib/artist_cover_source.py index cb32d9d3..71bb1dbe 100644 --- a/plugins/builtin/qqmusic/lib/artist_cover_source.py +++ b/plugins/builtin/qqmusic/lib/artist_cover_source.py @@ -31,17 +31,25 @@ def search(self, artist_name: str, limit: int = 10) -> list[PluginArtistCoverRes artists = self._api.search_artist(artist_name, limit) results = [] for artist in artists: - name = artist.get("singerName", "") or artist.get("name", "") - singer_mid = artist.get("singerMID", "") or artist.get("mid", "") - cover_url = artist.get("singerPic", "") - album_count = artist.get("albumNum", 0) + name = artist.get("name", "") or artist.get("singerName", "") + singer_mid = artist.get("mid", "") or artist.get("singerMID", "") + cover_url = ( + artist.get("avatar_url", "") + or artist.get("singerPic", "") + or artist.get("pic_url", "") + ) + album_count = artist.get("album_count", artist.get("albumNum", 0)) if name and singer_mid: results.append( PluginArtistCoverResult( artist_id=singer_mid, name=name, source="qqmusic", - cover_url=self._convert_cover_url(cover_url) if cover_url else None, + cover_url=( + self._convert_cover_url(cover_url) + if cover_url + else self._api.get_artist_cover_url(singer_mid) + ), album_count=album_count, ) ) diff --git a/plugins/builtin/qqmusic/lib/cover_source.py b/plugins/builtin/qqmusic/lib/cover_source.py index 86a63718..96f74686 100644 --- a/plugins/builtin/qqmusic/lib/cover_source.py +++ b/plugins/builtin/qqmusic/lib/cover_source.py @@ -24,29 +24,41 @@ def search( ) -> list[PluginCoverResult]: try: keyword = f"{artist} {title}" if artist else title - songs = self._api.search(keyword, limit=5) + search_payload = self._api.search( + keyword, + search_type="song", + limit=5, + ) + songs = ( + search_payload.get("tracks", []) + if isinstance(search_payload, dict) + else search_payload + ) results = [] for song in songs: - artist_name = "" - if isinstance(song.get("singer"), list) and song["singer"]: - artist_name = song["singer"][0].get("name", "") - elif isinstance(song.get("singer"), str): - artist_name = song.get("singer", "") + artist_name = song.get("artist", "") + singer_data = song.get("singer") + if not artist_name: + if isinstance(singer_data, list) and singer_data: + artist_name = singer_data[0].get("name", "") + elif isinstance(singer_data, str): + artist_name = singer_data - album_name = "" - album_mid = "" album_data = song.get("album") if isinstance(album_data, dict): album_name = album_data.get("name", "") album_mid = album_data.get("mid", "") + else: + album_name = album_data or "" + album_mid = song.get("album_mid", "") results.append( PluginCoverResult( item_id=song.get("mid", ""), - title=song.get("name", ""), + title=song.get("name", "") or song.get("title", ""), artist=artist_name, album=album_name, - duration=song.get("interval"), + duration=song.get("duration") or song.get("interval"), source="qqmusic", cover_url=None, extra_id=album_mid, diff --git a/plugins/builtin/qqmusic/lib/lyrics_source.py b/plugins/builtin/qqmusic/lib/lyrics_source.py index 5288157c..43d17869 100644 --- a/plugins/builtin/qqmusic/lib/lyrics_source.py +++ b/plugins/builtin/qqmusic/lib/lyrics_source.py @@ -17,18 +17,38 @@ def __init__(self, context): def search(self, title: str, artist: str, limit: int = 10) -> list[PluginLyricsResult]: try: keyword = f"{title} {artist}" if artist else title - search_results = self._api.search(keyword, limit) + search_payload = self._api.search( + keyword, + search_type="song", + limit=limit, + ) + search_results = ( + search_payload.get("tracks", []) + if isinstance(search_payload, dict) + else search_payload + ) return [ PluginLyricsResult( song_id=item.get("mid", ""), - title=item.get("title", ""), - artist=item.get("singer", ""), - album=item.get("album", ""), - duration=item.get("interval"), + title=item.get("title", "") or item.get("name", ""), + artist=item.get("singer", "") or item.get("artist", ""), + album=( + item.get("album", {}).get("name", "") + if isinstance(item.get("album"), dict) + else item.get("album", "") + ), + duration=item.get("duration") or item.get("interval"), source="qqmusic", cover_url=self._api.get_cover_url( mid=item.get("mid", ""), - album_mid=item.get("album_mid", ""), + album_mid=( + item.get("album_mid", "") + or ( + item.get("album", {}).get("mid", "") + if isinstance(item.get("album"), dict) + else "" + ) + ), size=500, ), ) diff --git a/tests/test_services/test_qqmusic_plugin_source_adapters.py b/tests/test_services/test_qqmusic_plugin_source_adapters.py new file mode 100644 index 00000000..2c215747 --- /dev/null +++ b/tests/test_services/test_qqmusic_plugin_source_adapters.py @@ -0,0 +1,139 @@ +from types import SimpleNamespace + +from plugins.builtin.qqmusic.lib.api import QQMusicPluginAPI +from plugins.builtin.qqmusic.lib.artist_cover_source import QQMusicArtistCoverPluginSource +from plugins.builtin.qqmusic.lib.cover_source import QQMusicCoverPluginSource +from plugins.builtin.qqmusic.lib.lyrics_source import QQMusicLyricsPluginSource + + +def test_qqmusic_api_search_artist_uses_singer_search(monkeypatch): + captured = {} + + def fake_search(self, keyword, search_type="song", limit=20, page=1): + captured.update( + keyword=keyword, + search_type=search_type, + limit=limit, + page=page, + ) + return {"artists": [{"mid": "artist-1", "name": "Singer 1"}]} + + monkeypatch.setattr(QQMusicPluginAPI, "search", fake_search) + + api = QQMusicPluginAPI(SimpleNamespace()) + + assert api.search_artist("Singer 1", limit=5) == [{"mid": "artist-1", "name": "Singer 1"}] + assert captured == { + "keyword": "Singer 1", + "search_type": "singer", + "limit": 5, + "page": 1, + } + + +def test_qqmusic_lyrics_source_search_reads_tracks_payload(monkeypatch): + captured = {} + + def fake_search(self, keyword, search_type="song", limit=20, page=1): + captured.update( + keyword=keyword, + search_type=search_type, + limit=limit, + page=page, + ) + return { + "tracks": [ + { + "mid": "song-1", + "title": "Song 1", + "singer": "Singer 1", + "album": "Album 1", + "album_mid": "album-1", + "duration": 180, + } + ] + } + + monkeypatch.setattr(QQMusicPluginAPI, "search", fake_search) + monkeypatch.setattr( + QQMusicPluginAPI, + "get_cover_url", + lambda *_args, **_kwargs: "cover-1", + ) + + source = QQMusicLyricsPluginSource(SimpleNamespace()) + + results = source.search("Song 1", "Singer 1", limit=7) + + assert captured == { + "keyword": "Song 1 Singer 1", + "search_type": "song", + "limit": 7, + "page": 1, + } + assert len(results) == 1 + assert results[0].song_id == "song-1" + assert results[0].title == "Song 1" + assert results[0].artist == "Singer 1" + assert results[0].album == "Album 1" + assert results[0].duration == 180 + assert results[0].cover_url == "cover-1" + + +def test_qqmusic_cover_source_search_reads_tracks_payload(monkeypatch): + def fake_search(self, keyword, search_type="song", limit=20, page=1): + assert keyword == "Singer 1 Song 1" + assert search_type == "song" + assert limit == 5 + assert page == 1 + return { + "tracks": [ + { + "mid": "song-1", + "name": "Song 1", + "artist": "Singer 1", + "album": "Album 1", + "album_mid": "album-1", + "duration": 180, + } + ] + } + + monkeypatch.setattr(QQMusicPluginAPI, "search", fake_search) + + source = QQMusicCoverPluginSource(SimpleNamespace()) + + results = source.search("Song 1", "Singer 1") + + assert len(results) == 1 + assert results[0].item_id == "song-1" + assert results[0].title == "Song 1" + assert results[0].artist == "Singer 1" + assert results[0].album == "Album 1" + assert results[0].duration == 180 + assert results[0].extra_id == "album-1" + + +def test_qqmusic_artist_cover_source_search_reads_normalized_artist_payload(monkeypatch): + monkeypatch.setattr( + QQMusicPluginAPI, + "search_artist", + lambda self, artist_name, limit=10: [ + { + "mid": "artist-1", + "name": "Singer 1", + "avatar_url": "https://y.gtimg.cn/music/photo_new/T001R150x150M000artist1.jpg", + "album_count": 12, + } + ], + ) + + source = QQMusicArtistCoverPluginSource(SimpleNamespace()) + + results = source.search("Singer 1", limit=5) + + assert len(results) == 1 + assert results[0].artist_id == "artist-1" + assert results[0].name == "Singer 1" + assert results[0].album_count == 12 + assert results[0].cover_url == "https://y.gtimg.cn/music/photo_new/T001R500x500M000artist1.jpg" diff --git a/ui/widgets/toggle_switch.py b/ui/widgets/toggle_switch.py index 2c8f2693..7ed1f196 100644 --- a/ui/widgets/toggle_switch.py +++ b/ui/widgets/toggle_switch.py @@ -4,6 +4,8 @@ from PySide6.QtGui import QPainter, QColor from PySide6.QtWidgets import QWidget, QSizePolicy, QGraphicsDropShadowEffect, QApplication +from system.theme import ThemeManager + class ToggleSwitch(QWidget): toggled = Signal(bool) @@ -26,7 +28,7 @@ def __init__(self, checked=False, parent=None): self.anim.setEasingCurve(QEasingCurve.OutCubic) # 主题 - self.bg_on = QColor("#22c55e") + self.bg_on = QColor(ThemeManager.instance().current_theme.highlight) self.bg_off = QColor("#3f3f46") self.bg_disabled = QColor("#2a2a2a") self.circle_color = QColor("#ffffff") From 631242bc4609fe5487926f8522ec172c277f8453 Mon Sep 17 00:00:00 2001 From: Har01d Date: Tue, 7 Apr 2026 17:57:38 +0800 Subject: [PATCH 062/157] ITunesCoverPlugin --- .../plans/2026-04-07-itunes-cover-plugin.md | 146 ++++++++++++++++++ .../2026-04-07-itunes-cover-plugin-design.md | 142 +++++++++++++++++ plugins/builtin/itunes_cover/__init__.py | 1 + plugins/builtin/itunes_cover/lib/__init__.py | 7 + .../itunes_cover/lib/artist_cover_source.py | 67 ++++++++ .../builtin/itunes_cover/lib/cover_source.py | 84 ++++++++++ plugins/builtin/itunes_cover/plugin.json | 10 ++ plugins/builtin/itunes_cover/plugin_main.py | 17 ++ services/metadata/cover_service.py | 12 +- services/sources/__init__.py | 4 - services/sources/artist_cover_sources.py | 58 ------- services/sources/cover_sources.py | 85 ---------- services/sources/lyrics_sources.py | 114 -------------- system/plugins/manager.py | 145 +++++++++-------- .../test_plugins/test_itunes_cover_plugin.py | 90 +++++++++++ .../test_cover_service_perf_paths.py | 25 +++ .../test_plugin_cover_registry.py | 2 + tests/test_system/test_plugin_manager.py | 65 ++++++++ 18 files changed, 745 insertions(+), 329 deletions(-) create mode 100644 docs/superpowers/plans/2026-04-07-itunes-cover-plugin.md create mode 100644 docs/superpowers/specs/2026-04-07-itunes-cover-plugin-design.md create mode 100644 plugins/builtin/itunes_cover/__init__.py create mode 100644 plugins/builtin/itunes_cover/lib/__init__.py create mode 100644 plugins/builtin/itunes_cover/lib/artist_cover_source.py create mode 100644 plugins/builtin/itunes_cover/lib/cover_source.py create mode 100644 plugins/builtin/itunes_cover/plugin.json create mode 100644 plugins/builtin/itunes_cover/plugin_main.py create mode 100644 tests/test_plugins/test_itunes_cover_plugin.py diff --git a/docs/superpowers/plans/2026-04-07-itunes-cover-plugin.md b/docs/superpowers/plans/2026-04-07-itunes-cover-plugin.md new file mode 100644 index 00000000..e9a0154b --- /dev/null +++ b/docs/superpowers/plans/2026-04-07-itunes-cover-plugin.md @@ -0,0 +1,146 @@ +# iTunes Cover Plugin Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Move host-owned iTunes album cover and artist cover sources into a built-in plugin with id `itunes_cover`. + +**Architecture:** Add a built-in plugin under `plugins/builtin/itunes_cover/` that registers one album cover source and one artist cover source through the plugin service bridge. Remove direct host ownership from `CoverService` and `services/sources` exports so iTunes cover behavior flows only through plugin loading. + +**Tech Stack:** Python 3.11, pytest, `uv`, Harmony plugin runtime + +--- + +## File Map + +- Create: `plugins/builtin/itunes_cover/__init__.py` +- Create: `plugins/builtin/itunes_cover/plugin.json` +- Create: `plugins/builtin/itunes_cover/plugin_main.py` +- Create: `plugins/builtin/itunes_cover/lib/__init__.py` +- Create: `plugins/builtin/itunes_cover/lib/cover_source.py` +- Create: `plugins/builtin/itunes_cover/lib/artist_cover_source.py` +- Create: `tests/test_plugins/test_itunes_cover_plugin.py` +- Modify: `services/metadata/cover_service.py` +- Modify: `services/sources/cover_sources.py` +- Modify: `services/sources/artist_cover_sources.py` +- Modify: `services/sources/__init__.py` +- Modify: `tests/test_services/test_plugin_cover_registry.py` + +## Task 1: Lock In Failing Tests + +**Files:** +- Create: `tests/test_plugins/test_itunes_cover_plugin.py` +- Modify: `tests/test_services/test_plugin_cover_registry.py` + +- [ ] **Step 1: Write the failing test** + +```python +from types import SimpleNamespace +from unittest.mock import Mock + +from plugins.builtin.itunes_cover.plugin_main import ITunesCoverPlugin + + +def test_itunes_plugin_registers_cover_and_artist_sources(): + context = Mock() + plugin = ITunesCoverPlugin() + + plugin.register(context) + + assert context.services.register_cover_source.call_count == 1 + assert context.services.register_artist_cover_source.call_count == 1 +``` + +```python +from services.metadata.cover_service import CoverService + + +def test_builtin_cover_sources_exclude_plugin_owned_sources(): + service = CoverService(http_client=SimpleNamespace(), sources=None) + + names = {source.name for source in service._get_builtin_sources()} + artist_names = {source.name for source in service._get_builtin_artist_sources()} + + assert "iTunes" not in names + assert "iTunes" not in artist_names +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `uv run pytest tests/test_plugins/test_itunes_cover_plugin.py tests/test_services/test_plugin_cover_registry.py -v` +Expected: FAIL with `ModuleNotFoundError` for `plugins.builtin.itunes_cover` and/or assertions that built-in sources still contain `iTunes` + +## Task 2: Add the Built-In Plugin + +**Files:** +- Create: `plugins/builtin/itunes_cover/__init__.py` +- Create: `plugins/builtin/itunes_cover/plugin.json` +- Create: `plugins/builtin/itunes_cover/plugin_main.py` +- Create: `plugins/builtin/itunes_cover/lib/__init__.py` +- Create: `plugins/builtin/itunes_cover/lib/cover_source.py` +- Create: `plugins/builtin/itunes_cover/lib/artist_cover_source.py` +- Test: `tests/test_plugins/test_itunes_cover_plugin.py` + +- [ ] **Step 1: Write minimal implementation** + +```python +class ITunesCoverPlugin: + plugin_id = "itunes_cover" + + def register(self, context) -> None: + context.services.register_cover_source(ITunesCoverPluginSource(context.http)) + context.services.register_artist_cover_source( + ITunesArtistCoverPluginSource(context.http) + ) +``` + +- [ ] **Step 2: Run plugin tests** + +Run: `uv run pytest tests/test_plugins/test_itunes_cover_plugin.py -v` +Expected: PASS + +## Task 3: Remove Host Ownership + +**Files:** +- Modify: `services/metadata/cover_service.py` +- Modify: `services/sources/cover_sources.py` +- Modify: `services/sources/artist_cover_sources.py` +- Modify: `services/sources/__init__.py` +- Test: `tests/test_services/test_plugin_cover_registry.py` + +- [ ] **Step 1: Remove iTunes built-in source wiring** + +```python +def _get_builtin_sources(self) -> List["CoverSource"]: + from services.sources import NetEaseCoverSource, LastFmCoverSource + return [ + NetEaseCoverSource(self.http_client), + LastFmCoverSource(self.http_client), + ] +``` + +```python +def _get_builtin_artist_sources(self) -> List["ArtistCoverSource"]: + from services.sources import NetEaseArtistCoverSource + return [NetEaseArtistCoverSource(self.http_client)] +``` + +- [ ] **Step 2: Delete host exports for migrated classes and rerun tests** + +Run: `uv run pytest tests/test_services/test_plugin_cover_registry.py tests/test_plugins/test_itunes_cover_plugin.py -v` +Expected: PASS + +## Task 4: Focused Verification + +**Files:** +- Test: `tests/test_plugins/test_itunes_cover_plugin.py` +- Test: `tests/test_services/test_plugin_cover_registry.py` + +- [ ] **Step 1: Run focused verification** + +Run: `uv run pytest tests/test_plugins/test_itunes_cover_plugin.py tests/test_services/test_plugin_cover_registry.py -v` +Expected: PASS + +- [ ] **Step 2: Review diff** + +Run: `git diff -- plugins/builtin/itunes_cover services/metadata/cover_service.py services/sources/cover_sources.py services/sources/artist_cover_sources.py services/sources/__init__.py tests/test_plugins/test_itunes_cover_plugin.py tests/test_services/test_plugin_cover_registry.py docs/superpowers/specs/2026-04-07-itunes-cover-plugin-design.md docs/superpowers/plans/2026-04-07-itunes-cover-plugin.md` +Expected: iTunes source ownership moves from host code to plugin code with no unrelated edits diff --git a/docs/superpowers/specs/2026-04-07-itunes-cover-plugin-design.md b/docs/superpowers/specs/2026-04-07-itunes-cover-plugin-design.md new file mode 100644 index 00000000..2789bcd4 --- /dev/null +++ b/docs/superpowers/specs/2026-04-07-itunes-cover-plugin-design.md @@ -0,0 +1,142 @@ +# iTunes Cover Plugin Design + +## Overview + +This change moves the host-owned iTunes album cover source and iTunes artist cover source into a built-in plugin. + +The goal is to make iTunes follow the same ownership boundary already used by built-in lyrics and QQ Music plugin features. After the migration, the host still queries iTunes-backed cover data, but the plugin runtime owns registration and lifecycle. + +## Goals + +- Move `ITunesCoverSource` into a built-in plugin with manifest id `itunes_cover`. +- Move `ITunesArtistCoverSource` into the same built-in plugin. +- Remove host-owned iTunes source registration from `CoverService`. +- Preserve existing iTunes search behavior, including enlarged artwork URLs and artist de-duplication. +- Keep iTunes enabled by default through normal built-in plugin loading. + +## Non-Goals + +- No new plugin settings tab or sidebar page. +- No change to iTunes search endpoints or query parameters. +- No refactor of unrelated host cover sources beyond removing iTunes ownership. + +## Current State + +iTunes album cover search lives in [`services/sources/cover_sources.py`](/home/harold/workspace/music-player/services/sources/cover_sources.py) as `ITunesCoverSource`. + +iTunes artist cover search lives in [`services/sources/artist_cover_sources.py`](/home/harold/workspace/music-player/services/sources/artist_cover_sources.py) as `ITunesArtistCoverSource`. + +[`services/metadata/cover_service.py`](/home/harold/workspace/music-player/services/metadata/cover_service.py) still constructs both sources directly as built-in host sources, so they do not participate in plugin enable/disable lifecycle. + +## Recommended Approach + +Create a built-in plugin at `plugins/builtin/itunes_cover/` and move both iTunes source implementations under that directory. + +The plugin manifest id should be `itunes_cover`. The runtime source identifiers should remain iTunes-specific values so result payloads and logging continue to describe the source as iTunes. + +## Architecture + +### Plugin Layout + +Add a built-in plugin directory: + +```text +plugins/builtin/itunes_cover/ +├── __init__.py +├── plugin.json +├── plugin_main.py +└── lib/ + ├── __init__.py + ├── artist_cover_source.py + └── cover_source.py +``` + +### Host Boundary + +After migration: + +- the host owns `NetEaseCoverSource` and `LastFmCoverSource` as built-in album cover sources +- the host owns `NetEaseArtistCoverSource` as a built-in artist cover source +- the iTunes implementations live entirely under `plugins/builtin/itunes_cover/` +- `CoverService` continues to merge host sources with plugin-registered cover and artist-cover sources + +### Plugin Registration + +`plugin_main.py` should expose a plugin class with: + +- `plugin_id = "itunes_cover"` +- `register(context)` calling both `context.services.register_cover_source(...)` and `context.services.register_artist_cover_source(...)` +- `unregister(context)` as a no-op + +The manifest should declare: + +- `"id": "itunes_cover"` +- `"capabilities": ["cover"]` + +The existing `cover` plugin capability already covers cover-related registrations, including artist cover sources. + +## Runtime Behavior + +### Album Cover Search + +The plugin album cover source should preserve current iTunes behavior: + +- endpoint: `https://itunes.apple.com/search` +- album search using `term = "{artist} {album or title}"`, `media = "music"`, `entity = "album"`, `limit = 5` +- optional album-only retry when `album` is provided +- transform `artworkUrl100` into a larger image by replacing `100x100` with `600x600` +- return an empty list on request or decoding errors instead of raising to the host + +### Artist Cover Search + +The plugin artist cover source should preserve current iTunes behavior: + +- endpoint: `https://itunes.apple.com/search` +- query using `term = artist_name`, `media = "music"`, `entity = "album"`, `limit = limit` +- de-duplicate results by lower-cased artist name +- enlarge `artworkUrl100` to `600x600` +- return an empty list on request or decoding errors instead of raising to the host + +## File Changes + +### Create + +- `plugins/builtin/itunes_cover/__init__.py` +- `plugins/builtin/itunes_cover/plugin.json` +- `plugins/builtin/itunes_cover/plugin_main.py` +- `plugins/builtin/itunes_cover/lib/__init__.py` +- `plugins/builtin/itunes_cover/lib/cover_source.py` +- `plugins/builtin/itunes_cover/lib/artist_cover_source.py` +- `tests/test_plugins/test_itunes_cover_plugin.py` + +### Modify + +- `services/metadata/cover_service.py` +- `services/sources/cover_sources.py` +- `services/sources/artist_cover_sources.py` +- `services/sources/__init__.py` +- `tests/test_services/test_plugin_cover_registry.py` + +## Testing + +Add or update tests to cover: + +- the iTunes plugin registers both a cover source and an artist cover source +- the plugin album cover source keeps current iTunes result mapping +- the plugin artist cover source de-duplicates artists and enlarges artwork URLs +- `CoverService._get_builtin_sources()` and `_get_builtin_artist_sources()` no longer include iTunes + +Regression commands should focus on the changed area: + +- `uv run pytest tests/test_plugins/test_itunes_cover_plugin.py` +- `uv run pytest tests/test_services/test_plugin_cover_registry.py` + +## Risks and Mitigations + +- Plugin load risk: keep the plugin minimal and mirror the existing built-in plugin structure exactly. +- Behavior regression risk in iTunes result mapping: preserve the current request parameters and artwork URL transformation logic. +- Hidden host dependency risk: remove all host imports and exports of the migrated iTunes source classes. + +## Scope Check + +This design is intentionally narrow. It changes only iTunes cover ownership and associated tests. It does not introduce new plugin UI, new settings, or new cover-matching logic. diff --git a/plugins/builtin/itunes_cover/__init__.py b/plugins/builtin/itunes_cover/__init__.py new file mode 100644 index 00000000..fbcbfab1 --- /dev/null +++ b/plugins/builtin/itunes_cover/__init__.py @@ -0,0 +1 @@ +"""iTunes cover built-in plugin.""" diff --git a/plugins/builtin/itunes_cover/lib/__init__.py b/plugins/builtin/itunes_cover/lib/__init__.py new file mode 100644 index 00000000..05397af3 --- /dev/null +++ b/plugins/builtin/itunes_cover/lib/__init__.py @@ -0,0 +1,7 @@ +from .artist_cover_source import ITunesArtistCoverPluginSource +from .cover_source import ITunesCoverPluginSource + +__all__ = [ + "ITunesArtistCoverPluginSource", + "ITunesCoverPluginSource", +] diff --git a/plugins/builtin/itunes_cover/lib/artist_cover_source.py b/plugins/builtin/itunes_cover/lib/artist_cover_source.py new file mode 100644 index 00000000..1ff5aaad --- /dev/null +++ b/plugins/builtin/itunes_cover/lib/artist_cover_source.py @@ -0,0 +1,67 @@ +from __future__ import annotations + +import logging + +from harmony_plugin_api.cover import PluginArtistCoverResult + +logger = logging.getLogger(__name__) + + +class ITunesArtistCoverPluginSource: + source = "itunes" + source_id = "itunes-artist-cover" + display_name = "iTunes Artist" + name = "iTunes" + + def __init__(self, http_client): + self._http_client = http_client + + def search( + self, + artist_name: str, + limit: int = 10, + ) -> list[PluginArtistCoverResult]: + results: list[PluginArtistCoverResult] = [] + + try: + search_url = "https://itunes.apple.com/search" + params = { + "term": artist_name, + "media": "music", + "entity": "album", + "limit": limit, + } + logger.debug("iTunes artist cover search: %s", artist_name) + response = self._http_client.get(search_url, params=params, timeout=5) + + if response.status_code == 200: + data = response.json() + seen_artists: set[str] = set() + for item in data.get("results", []): + name = item.get("artistName", "") + normalized_name = name.lower() + if not name or normalized_name in seen_artists: + continue + seen_artists.add(normalized_name) + + artwork_url = item.get("artworkUrl100") + if not artwork_url: + continue + + results.append( + PluginArtistCoverResult( + artist_id=str(item.get("artistId", "")), + name=name, + source="itunes", + cover_url=artwork_url.replace("100x100", "600x600"), + album_count=None, + ) + ) + + except Exception as exc: + logger.debug("iTunes artist cover search error: %s", exc) + + return results + + def is_available(self) -> bool: + return True diff --git a/plugins/builtin/itunes_cover/lib/cover_source.py b/plugins/builtin/itunes_cover/lib/cover_source.py new file mode 100644 index 00000000..42f8d56b --- /dev/null +++ b/plugins/builtin/itunes_cover/lib/cover_source.py @@ -0,0 +1,84 @@ +from __future__ import annotations + +import logging + +from harmony_plugin_api.cover import PluginCoverResult + +logger = logging.getLogger(__name__) + + +class ITunesCoverPluginSource: + source = "itunes" + source_id = "itunes-cover" + display_name = "iTunes" + name = "iTunes" + + def __init__(self, http_client): + self._http_client = http_client + + def search( + self, + title: str, + artist: str, + album: str = "", + duration: float | None = None, + ) -> list[PluginCoverResult]: + results: list[PluginCoverResult] = [] + + try: + search_url = "https://itunes.apple.com/search" + + params = { + "term": f"{artist} {album or title}", + "media": "music", + "entity": "album", + "limit": 5, + } + response = self._http_client.get(search_url, params=params, timeout=3) + + if response.status_code == 200: + data = response.json() + results.extend(self._build_results(data.get("results", []))) + + if album: + params_album_only = { + "term": album, + "media": "music", + "entity": "album", + "limit": 5, + } + response = self._http_client.get( + search_url, + params=params_album_only, + timeout=3, + ) + + if response.status_code == 200: + data = response.json() + results.extend(self._build_results(data.get("results", []))) + + except Exception as exc: + logger.debug("iTunes search error: %s", exc) + + return results + + def is_available(self) -> bool: + return True + + def _build_results(self, items: list[dict]) -> list[PluginCoverResult]: + results: list[PluginCoverResult] = [] + for item in items: + artwork_url = item.get("artworkUrl100") + if not artwork_url: + continue + results.append( + PluginCoverResult( + item_id=str(item.get("collectionId", "")), + title=item.get("collectionName", ""), + artist=item.get("artistName", ""), + album=item.get("collectionName", ""), + source="itunes", + cover_url=artwork_url.replace("100x100", "600x600"), + ) + ) + return results diff --git a/plugins/builtin/itunes_cover/plugin.json b/plugins/builtin/itunes_cover/plugin.json new file mode 100644 index 00000000..66aa32f0 --- /dev/null +++ b/plugins/builtin/itunes_cover/plugin.json @@ -0,0 +1,10 @@ +{ + "id": "itunes_cover", + "name": "iTunes Cover", + "version": "1.0.0", + "api_version": "1", + "entrypoint": "plugin_main.py", + "entry_class": "ITunesCoverPlugin", + "capabilities": ["cover"], + "min_app_version": "0.1.0" +} diff --git a/plugins/builtin/itunes_cover/plugin_main.py b/plugins/builtin/itunes_cover/plugin_main.py new file mode 100644 index 00000000..a34016f0 --- /dev/null +++ b/plugins/builtin/itunes_cover/plugin_main.py @@ -0,0 +1,17 @@ +from __future__ import annotations + +from .lib.artist_cover_source import ITunesArtistCoverPluginSource +from .lib.cover_source import ITunesCoverPluginSource + + +class ITunesCoverPlugin: + plugin_id = "itunes_cover" + + def register(self, context) -> None: + context.services.register_cover_source(ITunesCoverPluginSource(context.http)) + context.services.register_artist_cover_source( + ITunesArtistCoverPluginSource(context.http) + ) + + def unregister(self, context) -> None: + return None diff --git a/services/metadata/cover_service.py b/services/metadata/cover_service.py index 3e6eea11..fa983c05 100644 --- a/services/metadata/cover_service.py +++ b/services/metadata/cover_service.py @@ -47,12 +47,10 @@ def _get_builtin_sources(self) -> List["CoverSource"]: """Get built-in host cover sources.""" from services.sources import ( NetEaseCoverSource, - ITunesCoverSource, LastFmCoverSource, ) return [ NetEaseCoverSource(self.http_client), - ITunesCoverSource(self.http_client), LastFmCoverSource(self.http_client), ] @@ -68,11 +66,9 @@ def _get_builtin_artist_sources(self) -> List["ArtistCoverSource"]: """Get built-in host artist cover sources.""" from services.sources import ( NetEaseArtistCoverSource, - ITunesArtistCoverSource, ) return [ NetEaseArtistCoverSource(self.http_client), - ITunesArtistCoverSource(self.http_client), ] def _get_artist_sources(self) -> List["ArtistCoverSource"]: @@ -563,13 +559,17 @@ def search_artist_covers(self, artist_name: str, limit: int = 10) -> List[dict]: # Convert ArtistCoverSearchResult to dict for compatibility for r in search_results: score = self._calculate_artist_name_score(artist_name, r.name) + artist_id = getattr(r, "id", None) or getattr(r, "artist_id", "") + singer_mid = getattr(r, "singer_mid", None) + if singer_mid is None and getattr(r, "source", "") == "qqmusic": + singer_mid = getattr(r, "artist_id", None) results.append({ 'name': r.name, - 'id': r.id, + 'id': artist_id, 'cover_url': r.cover_url, 'album_count': r.album_count, 'source': r.source, - 'singer_mid': r.singer_mid, + 'singer_mid': singer_mid, 'score': score, }) except Exception as e: diff --git a/services/sources/__init__.py b/services/sources/__init__.py index 77260e11..03e73466 100644 --- a/services/sources/__init__.py +++ b/services/sources/__init__.py @@ -8,7 +8,6 @@ from .base import CoverSource, LyricsSource, ArtistCoverSource from .cover_sources import ( NetEaseCoverSource, - ITunesCoverSource, LastFmCoverSource, MusicBrainzCoverSource, SpotifyCoverSource, @@ -18,7 +17,6 @@ ) from .artist_cover_sources import ( NetEaseArtistCoverSource, - ITunesArtistCoverSource, SpotifyArtistCoverSource, ) @@ -29,7 +27,6 @@ "ArtistCoverSource", # Cover sources "NetEaseCoverSource", - "ITunesCoverSource", "LastFmCoverSource", "MusicBrainzCoverSource", "SpotifyCoverSource", @@ -37,6 +34,5 @@ "NetEaseLyricsSource", # Artist cover sources "NetEaseArtistCoverSource", - "ITunesArtistCoverSource", "SpotifyArtistCoverSource", ] diff --git a/services/sources/artist_cover_sources.py b/services/sources/artist_cover_sources.py index c85dd818..f339614a 100644 --- a/services/sources/artist_cover_sources.py +++ b/services/sources/artist_cover_sources.py @@ -76,64 +76,6 @@ def __init__(self, http_client): self._http_client = http_client -class ITunesArtistCoverSource(ArtistCoverSource): - """iTunes Search API artist cover source.""" - - @property - def name(self) -> str: - return "iTunes" - - def search( - self, - artist_name: str, - limit: int = 10 - ) -> List[ArtistCoverSearchResult]: - """Search for artist covers from iTunes Search API.""" - results = [] - - try: - search_url = "https://itunes.apple.com/search" - params = { - 'term': artist_name, - 'media': 'music', - 'entity': 'album', - 'limit': limit - } - - response = self._http_client.get(search_url, params=params, timeout=5) - - if response.status_code == 200: - data = response.json() - if data.get('results'): - seen_artists = set() - for item in data['results']: - name = item.get('artistName', '') - # Skip duplicate artists - if name.lower() in seen_artists: - continue - seen_artists.add(name.lower()) - - artwork_url = item.get('artworkUrl100') - if artwork_url: - artwork_url = artwork_url.replace('100x100', '600x600') - - results.append(ArtistCoverSearchResult( - id=str(item.get('artistId', '')), - name=name, - cover_url=artwork_url, - album_count=None, - source='itunes' - )) - - except Exception as e: - logger.debug(f"iTunes artist cover search error: {e}") - - return results - - def __init__(self, http_client): - self._http_client = http_client - - class SpotifyArtistCoverSource(ArtistCoverSource): """Spotify Web API artist cover source.""" diff --git a/services/sources/cover_sources.py b/services/sources/cover_sources.py index 38f03615..08753a11 100644 --- a/services/sources/cover_sources.py +++ b/services/sources/cover_sources.py @@ -118,91 +118,6 @@ def __init__(self, http_client): self._http_client = http_client -class ITunesCoverSource(CoverSource): - """iTunes Search API cover source.""" - - @property - def name(self) -> str: - return "iTunes" - - def search( - self, - title: str, - artist: str, - album: str = "", - duration: Optional[float] = None - ) -> List[CoverSearchResult]: - """Search for covers from iTunes Search API.""" - results = [] - - try: - search_url = "https://itunes.apple.com/search" - - # Search for albums - params = { - 'term': f'{artist} {album or title}', - 'media': 'music', - 'entity': 'album', - 'limit': 5 - } - - response = self._http_client.get(search_url, params=params, timeout=3) - - if response.status_code == 200: - data = response.json() - if data.get('results'): - for item in data['results']: - artwork_url = item.get('artworkUrl100') - if artwork_url: - # Get larger version - artwork_url = artwork_url.replace('100x100', '600x600') - - results.append(CoverSearchResult( - title=item.get('collectionName', ''), - artist=item.get('artistName', ''), - album=item.get('collectionName', ''), - source='itunes', - id=str(item.get('collectionId', '')), - cover_url=artwork_url - )) - - # If album has value, also search with album only (without artist) - if album: - params_album_only = { - 'term': album, - 'media': 'music', - 'entity': 'album', - 'limit': 5 - } - - response = self._http_client.get(search_url, params=params_album_only, timeout=3) - - if response.status_code == 200: - data = response.json() - if data.get('results'): - for item in data['results']: - artwork_url = item.get('artworkUrl100') - if artwork_url: - artwork_url = artwork_url.replace('100x100', '600x600') - - results.append(CoverSearchResult( - title=item.get('collectionName', ''), - artist=item.get('artistName', ''), - album=item.get('collectionName', ''), - source='itunes', - id=str(item.get('collectionId', '')), - cover_url=artwork_url - )) - - except Exception as e: - logger.debug(f"iTunes search error: {e}") - - return results - - def __init__(self, http_client): - self._http_client = http_client - - class LastFmCoverSource(CoverSource): """Last.fm API cover source.""" diff --git a/services/sources/lyrics_sources.py b/services/sources/lyrics_sources.py index 0658d0c0..01d06270 100644 --- a/services/sources/lyrics_sources.py +++ b/services/sources/lyrics_sources.py @@ -130,117 +130,3 @@ def get_lyrics(self, result: LyricsSearchResult) -> Optional[str]: def __init__(self, http_client): self._http_client = http_client - - -class LRCLIBLyricsSource(LyricsSource): - """LRCLIB (free, open source lyrics API) source.""" - - HEADERS = { - 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36' - } - - @property - def name(self) -> str: - return "LRCLIB" - - def search( - self, - title: str, - artist: str, - limit: int = 10 - ) -> List[LyricsSearchResult]: - """Search for lyrics from LRCLIB.""" - results = [] - - search_url = "https://lrclib.net/api/search" - params = { - 'track_name': title, - 'artist_name': artist - } - - try: - response = self._http_client.get( - search_url, - params=params, - headers=self.HEADERS, - timeout=3 - ) - - if response.status_code != 200: - return results - - data = response.json() - - if not isinstance(data, list): - return results - - for song in data[:limit]: - # Include songs with synced lyrics or plain lyrics - synced = song.get('syncedLyrics') - plain = song.get('plainLyrics') - if synced or plain: - # Store lyrics directly in the result for later use - lyrics = synced if synced else plain - results.append(LyricsSearchResult( - id=str(song.get('id', '')), - title=song.get('trackName', ''), - artist=song.get('artistName', ''), - album=song.get('albumName', ''), - duration=song.get('duration'), - source='lrclib', - lyrics=lyrics # Pre-fetch lyrics from search result - )) - - except Exception as e: - logger.debug(f"LRCLIB search error: {e}") - - return results - - def get_lyrics(self, result: LyricsSearchResult) -> Optional[str]: - """Get lyrics from LRCLIB (may already be in result).""" - # Lyrics may already be pre-fetched in the search result - if result.lyrics: - return result.lyrics - - # Otherwise, search again to get lyrics - try: - search_url = "https://lrclib.net/api/search" - params = { - 'q': result.id # Search by ID as query - } - - response = self._http_client.get( - search_url, - params=params, - headers=self.HEADERS, - timeout=3 - ) - - if response.status_code != 200: - return None - - data = response.json() - - if not isinstance(data, list) or not data: - return None - - # Find the matching song by ID - for song in data: - if str(song.get('id')) == str(result.id): - # Prioritize synced lyrics - synced_lyrics = song.get('syncedLyrics') - if synced_lyrics: - return synced_lyrics - - # Fall back to plain lyrics - plain_lyrics = song.get('plainLyrics') - if plain_lyrics: - return plain_lyrics - - except Exception as e: - logger.error(f"Error downloading LRCLIB lyrics: {e}") - - return None - - def __init__(self, http_client): - self._http_client = http_client diff --git a/system/plugins/manager.py b/system/plugins/manager.py index 427cdcd5..8782aec9 100644 --- a/system/plugins/manager.py +++ b/system/plugins/manager.py @@ -27,6 +27,84 @@ def __init__(self, builtin_root: Path, external_root: Path, state_store, context self.registry = PluginRegistry() self._loaded_plugins: dict[str, tuple[object, object, object]] = {} + def _load_plugin_root(self, source: str, plugin_root: Path) -> None: + manifest = None + state = None + plugin = None + context = None + started_at = time.perf_counter() + try: + manifest = self._loader.read_manifest(plugin_root) + if manifest.id in self._loaded_plugins: + logger.debug("[PluginManager] Skip already loaded plugin %s", manifest.id) + return + + state = self._state_store.get(manifest.id) + if state and state.get("enabled") is False: + logger.info("[PluginManager] Skip disabled plugin %s", manifest.id) + return + + logger.info( + "[PluginManager] Loading plugin %s from %s (%s)", + manifest.id, + plugin_root, + source, + ) + manifest, plugin = self._loader.load_plugin(plugin_root, manifest) + context = self._context_factory.build(manifest) + plugin.register(context) + self._loaded_plugins[manifest.id] = (manifest, plugin, context) + duration_ms = (time.perf_counter() - started_at) * 1000 + logger.info( + "[PluginManager] Loaded plugin %s in %.1fms", + manifest.id, + duration_ms, + ) + self._state_store.set_enabled( + manifest.id, + True if state is None else bool(state.get("enabled", True)), + source=source, + version=manifest.version, + load_error=None, + ) + except Exception as exc: + plugin_id = manifest.id if manifest is not None else plugin_root.name + version = manifest.version if manifest is not None else "" + enabled_on_error = True if state is None else bool(state.get("enabled", True)) + if plugin is not None and context is not None: + try: + plugin.unregister(context) + except Exception: + pass + logger.exception( + "[PluginManager] Failed to load plugin %s from %s", + plugin_id, + plugin_root, + ) + self.registry.unregister_plugin(plugin_id) + self._loaded_plugins.pop(plugin_id, None) + self._state_store.set_enabled( + plugin_id, + enabled_on_error, + source=source, + version=version, + load_error=str(exc), + ) + + def _unload_plugin(self, plugin_id: str) -> None: + loaded = self._loaded_plugins.pop(plugin_id, None) + if loaded is None: + self.registry.unregister_plugin(plugin_id) + return + + _manifest, plugin, context = loaded + try: + plugin.unregister(context) + except Exception: + logger.exception("[PluginManager] Failed to unregister plugin %s", plugin_id) + finally: + self.registry.unregister_plugin(plugin_id) + def discover_roots(self) -> list[tuple[str, Path]]: def _is_plugin_root(path: Path) -> bool: return path.is_dir() and (path / "plugin.json").exists() @@ -52,68 +130,7 @@ def load_enabled_plugins(self) -> None: roots = self.discover_roots() logger.info("[PluginManager] Discovered %s plugin roots", len(roots)) for source, plugin_root in roots: - manifest = None - state = None - plugin = None - context = None - started_at = time.perf_counter() - try: - manifest = self._loader.read_manifest(plugin_root) - if manifest.id in self._loaded_plugins: - logger.debug("[PluginManager] Skip already loaded plugin %s", manifest.id) - continue - - state = self._state_store.get(manifest.id) - if state and state.get("enabled") is False: - logger.info("[PluginManager] Skip disabled plugin %s", manifest.id) - continue - - logger.info( - "[PluginManager] Loading plugin %s from %s (%s)", - manifest.id, - plugin_root, - source, - ) - manifest, plugin = self._loader.load_plugin(plugin_root, manifest) - context = self._context_factory.build(manifest) - plugin.register(context) - self._loaded_plugins[manifest.id] = (manifest, plugin, context) - duration_ms = (time.perf_counter() - started_at) * 1000 - logger.info( - "[PluginManager] Loaded plugin %s in %.1fms", - manifest.id, - duration_ms, - ) - self._state_store.set_enabled( - manifest.id, - True if state is None else bool(state.get("enabled", True)), - source=source, - version=manifest.version, - load_error=None, - ) - except Exception as exc: - plugin_id = manifest.id if manifest is not None else plugin_root.name - version = manifest.version if manifest is not None else "" - enabled_on_error = True if state is None else bool(state.get("enabled", True)) - if plugin is not None and context is not None: - try: - plugin.unregister(context) - except Exception: - pass - logger.exception( - "[PluginManager] Failed to load plugin %s from %s", - plugin_id, - plugin_root, - ) - self.registry.unregister_plugin(plugin_id) - self._loaded_plugins.pop(plugin_id, None) - self._state_store.set_enabled( - plugin_id, - enabled_on_error, - source=source, - version=version, - load_error=str(exc), - ) + self._load_plugin_root(source, plugin_root) def list_plugins(self) -> list[dict]: plugins = [] @@ -145,6 +162,10 @@ def set_plugin_enabled(self, plugin_id: str, enabled: bool) -> None: version=existing.get("version", manifest.version), load_error=existing.get("load_error"), ) + if enabled: + self._load_plugin_root(source, plugin_root) + else: + self._unload_plugin(plugin_id) return raise KeyError(f"Unknown plugin: {plugin_id}") diff --git a/tests/test_plugins/test_itunes_cover_plugin.py b/tests/test_plugins/test_itunes_cover_plugin.py new file mode 100644 index 00000000..9dcd6c61 --- /dev/null +++ b/tests/test_plugins/test_itunes_cover_plugin.py @@ -0,0 +1,90 @@ +from types import SimpleNamespace +from unittest.mock import Mock + +from plugins.builtin.itunes_cover.lib.artist_cover_source import ( + ITunesArtistCoverPluginSource, +) +from plugins.builtin.itunes_cover.lib.cover_source import ITunesCoverPluginSource +from plugins.builtin.itunes_cover.plugin_main import ITunesCoverPlugin + + +def test_itunes_plugin_registers_cover_and_artist_sources(): + context = Mock() + plugin = ITunesCoverPlugin() + + plugin.register(context) + + assert context.services.register_cover_source.call_count == 1 + assert context.services.register_artist_cover_source.call_count == 1 + + registered_cover = context.services.register_cover_source.call_args.args[0] + registered_artist_cover = ( + context.services.register_artist_cover_source.call_args.args[0] + ) + + assert isinstance(registered_cover, ITunesCoverPluginSource) + assert isinstance(registered_artist_cover, ITunesArtistCoverPluginSource) + + +def test_itunes_cover_source_search_maps_album_results(): + responses = [ + SimpleNamespace( + status_code=200, + json=lambda: { + "results": [ + { + "collectionId": 1, + "collectionName": "Album 1", + "artistName": "Singer 1", + "artworkUrl100": "https://example.com/100x100bb.jpg", + } + ] + }, + ), + SimpleNamespace(status_code=200, json=lambda: {"results": []}), + ] + http = SimpleNamespace( + get=lambda *_args, **_kwargs: responses.pop(0), + ) + source = ITunesCoverPluginSource(http) + + results = source.search("Song 1", "Singer 1", "Album 1") + + assert len(results) == 1 + assert results[0].item_id == "1" + assert results[0].title == "Album 1" + assert results[0].artist == "Singer 1" + assert results[0].album == "Album 1" + assert results[0].source == "itunes" + assert results[0].cover_url == "https://example.com/600x600bb.jpg" + + +def test_itunes_artist_cover_source_deduplicates_artists(): + response = SimpleNamespace( + status_code=200, + json=lambda: { + "results": [ + { + "artistId": 1, + "artistName": "Singer 1", + "artworkUrl100": "https://example.com/100x100bb.jpg", + }, + { + "artistId": 2, + "artistName": "singer 1", + "artworkUrl100": "https://example.com/100x100cc.jpg", + }, + ] + }, + ) + source = ITunesArtistCoverPluginSource( + SimpleNamespace(get=lambda *_args, **_kwargs: response) + ) + + results = source.search("Singer 1", limit=5) + + assert len(results) == 1 + assert results[0].artist_id == "1" + assert results[0].name == "Singer 1" + assert results[0].source == "itunes" + assert results[0].cover_url == "https://example.com/600x600bb.jpg" diff --git a/tests/test_services/test_cover_service_perf_paths.py b/tests/test_services/test_cover_service_perf_paths.py index 5fcfc550..c1c90213 100644 --- a/tests/test_services/test_cover_service_perf_paths.py +++ b/tests/test_services/test_cover_service_perf_paths.py @@ -3,6 +3,7 @@ from types import SimpleNamespace import services.metadata.cover_service as cover_service_module +from harmony_plugin_api.cover import PluginArtistCoverResult from services.metadata.cover_service import CoverService from services.sources.base import CoverSearchResult @@ -70,3 +71,27 @@ def test_search_covers_converts_and_scores_results(monkeypatch): assert len(results) == 1 assert results[0]["id"] == "song-1" assert results[0]["score"] == 88.0 + + +def test_search_artist_covers_supports_plugin_artist_result_shape(monkeypatch): + source = SimpleNamespace( + name="QQMusic", + search=lambda *_args, **_kwargs: [ + PluginArtistCoverResult( + artist_id="artist-1", + name="Singer 1", + source="qqmusic", + cover_url=None, + album_count=12, + ) + ], + ) + service = CoverService(http_client=SimpleNamespace()) + monkeypatch.setattr(service, "_get_artist_sources", lambda: [source]) + + results = service.search_artist_covers("Singer 1", limit=5) + + assert len(results) == 1 + assert results[0]["id"] == "artist-1" + assert results[0]["singer_mid"] == "artist-1" + assert results[0]["album_count"] == 12 diff --git a/tests/test_services/test_plugin_cover_registry.py b/tests/test_services/test_plugin_cover_registry.py index d1ebaf37..d7b79c75 100644 --- a/tests/test_services/test_plugin_cover_registry.py +++ b/tests/test_services/test_plugin_cover_registry.py @@ -41,3 +41,5 @@ def test_builtin_cover_sources_exclude_plugin_owned_sources(): assert "QQMusic" not in names assert "QQMusic" not in artist_names + assert "iTunes" not in names + assert "iTunes" not in artist_names diff --git a/tests/test_system/test_plugin_manager.py b/tests/test_system/test_plugin_manager.py index 7dcecd60..90a21131 100644 --- a/tests/test_system/test_plugin_manager.py +++ b/tests/test_system/test_plugin_manager.py @@ -34,6 +34,24 @@ class _Context: return _Context() +class _LyricsRegistryContextFactory: + def __init__(self, registry): + self._registry = registry + + def build(self, manifest): + registry = self._registry + plugin_id = manifest.id + + class _ServicesBridge: + def register_lyrics_source(self, source): + registry.register_lyrics_source(plugin_id, source) + + class _Context: + services = _ServicesBridge() + + return _Context() + + def test_state_store_persists_enabled_flag(tmp_path: Path): store = PluginStateStore(tmp_path / "state.json") store.set_enabled("qqmusic", True, source="builtin", version="1.0.0") @@ -184,6 +202,53 @@ def test_manager_can_toggle_plugin_enabled_state_without_loading(tmp_path: Path) assert enabled_state["version"] == "1.0.0" +def test_manager_disabling_loaded_plugin_unregisters_runtime_lyrics_sources(tmp_path: Path): + builtin_root = tmp_path / "builtin" + plugin_root = builtin_root / "lyrics" + plugin_root.mkdir(parents=True) + + (plugin_root / "plugin.json").write_text( + json.dumps( + { + "id": "lyrics", + "name": "Lyrics Plugin", + "version": "1.0.0", + "api_version": "1", + "entrypoint": "plugin_main.py", + "entry_class": "LyricsPlugin", + "capabilities": ["lyrics_source"], + "min_app_version": "0.1.0", + } + ), + encoding="utf-8", + ) + (plugin_root / "plugin_main.py").write_text( + "class LyricsPlugin:\n" + " plugin_id = 'lyrics'\n" + " def register(self, context):\n" + " context.services.register_lyrics_source(object())\n" + " def unregister(self, context):\n" + " pass\n", + encoding="utf-8", + ) + + store = PluginStateStore(tmp_path / "state.json") + manager = PluginManager( + builtin_root=builtin_root, + external_root=tmp_path / "external", + state_store=store, + context_factory=_LyricsRegistryContextFactory(None), + ) + manager._context_factory = _LyricsRegistryContextFactory(manager.registry) + + manager.load_enabled_plugins() + assert len(manager.registry.lyrics_sources()) == 1 + + manager.set_plugin_enabled("lyrics", False) + + assert manager.registry.lyrics_sources() == [] + + def test_manager_loads_plugin_with_relative_import(tmp_path: Path): builtin_root = tmp_path / "builtin" plugin_root = builtin_root / "relative" From c7ca57e474c0c59a443fd0ce94cbc8ac5e10f51a Mon Sep 17 00:00:00 2001 From: Har01d Date: Tue, 7 Apr 2026 18:05:46 +0800 Subject: [PATCH 063/157] =?UTF-8?q?=E7=BB=9F=E4=B8=80=E4=B8=BB=E9=A2=98?= =?UTF-8?q?=E6=8E=A7=E4=BB=B6=E6=A0=B7=E5=BC=8F=E8=AE=BE=E8=AE=A1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...4-07-unified-widget-theme-styles-design.md | 202 ++++++++++++++++++ 1 file changed, 202 insertions(+) create mode 100644 docs/superpowers/specs/2026-04-07-unified-widget-theme-styles-design.md diff --git a/docs/superpowers/specs/2026-04-07-unified-widget-theme-styles-design.md b/docs/superpowers/specs/2026-04-07-unified-widget-theme-styles-design.md new file mode 100644 index 00000000..e7ca3ede --- /dev/null +++ b/docs/superpowers/specs/2026-04-07-unified-widget-theme-styles-design.md @@ -0,0 +1,202 @@ +# Unified Widget Theme Styles Design + +## Overview + +This change consolidates the styling of foundational input and popup widgets under the host theme system. + +The target widgets are: + +- `DialogTitleBar` +- `QLineEdit` +- `QCheckBox` +- `QGroupBox` +- `QComboBox` +- all popup surfaces, including completer popups, `QMenu`, custom hover popups, and frameless `Qt.Popup` dialogs + +The goal is to stop defining these base styles inside individual components. Host widgets and plugins must both receive the same base styling from the theme system. Component-level variation remains allowed, but only through theme-owned selectors such as object names and dynamic properties. + +## Goals + +- Move base styles for the target widgets into the theme system. +- Ensure host UI and plugin UI use the same styling source. +- Remove duplicated inline QSS for these target widgets from dialogs, views, and plugin components. +- Preserve room for controlled variants through object names or dynamic properties. +- Keep real-time theme switching working for all affected widgets. + +## Non-Goals + +- No attempt to centralize every widget type in the application in this change. +- No visual redesign of unrelated custom widgets such as cards, tables, sliders, or artwork containers. +- No plugin-specific theme fork. +- No generic "component style registry" abstraction beyond what is needed to make popups and global QSS work reliably. + +## Current Problems + +The repository already has a global stylesheet and token replacement via `ThemeManager`, but the target widgets are still styled in multiple layers: + +- global QSS in `ui/styles.qss` +- ad hoc inline `setStyleSheet()` calls inside dialogs and views +- duplicated title bar styling in host and plugin code +- plugin-local popup styles using `get_qss(...)` with their own widget templates + +This causes three issues: + +1. The same widget class has different visual rules depending on where it is created. +2. Theme updates require touching many files instead of one theme-owned surface. +3. Plugins can drift away from host behavior even though they already route through the host theme bridge. + +## Recommended Approach + +Use the theme system as the single owner of base styles. + +### 1. Expand the global theme stylesheet + +`ui/styles.qss` becomes the base stylesheet source for the target widget classes and theme-owned variants. + +It should define: + +- global base rules for `QLineEdit`, `QCheckBox`, `QGroupBox`, `QComboBox`, `QMenu`, and common popup containers +- title bar rules keyed by object names such as `#dialogTitleBar`, `#dialogTitle`, and `#dialogCloseBtn` +- variant selectors keyed by dynamic properties or object names where the app needs approved deviations + +Examples of allowed variant hooks: + +- `QLineEdit[variant="search"]` +- `QGroupBox[variant="settings"]` +- `QWidget[popupSurface="true"]` +- `QComboBox[compact="true"]` + +The theme file remains token-based, so all colors continue to come from `ThemeManager.get_qss(...)`. + +### 2. Keep popup-specific helper entry points inside the theme system + +Some popup surfaces are not reliably covered by application-wide selectors alone because they may be separate top-level widgets or created lazily by Qt. For those cases, the theme system should expose small helper templates owned by `ThemeManager`, for example: + +- popup list view style for `QCompleter.popup()` +- generic popup surface style for custom `QWidget` popups +- optional frameless popup dialog wrapper style + +These helpers remain part of the theme system. Components may apply them, but they may not define their own base popup QSS. + +This is not a second styling system. It is a delivery mechanism for theme-owned styles in cases where Qt global QSS attachment is insufficient. + +### 3. Remove duplicated title bar styling from components + +Both host and plugin `DialogTitleBarController` implementations should stop embedding their own QSS templates for: + +- `dialogTitleBar` +- `dialogTitle` +- `dialogCloseBtn` + +They should only: + +- build the widget tree +- assign object names +- refresh icons if needed + +The actual styling must come from the global theme stylesheet. + +### 4. Make plugin popups use host-owned theme helpers + +Plugin code already reaches the host theme bridge through `system.plugins.plugin_sdk_ui`. + +Extend that bridge only as needed so plugin popups can consume the same theme-owned popup helpers as host widgets. The plugin must not carry its own popup base style definitions for completers, hotkey popups, hover popups, or title bars. + +## Styling Rules + +The following rules define what components may and may not do after this change. + +### Allowed + +- Set object names needed by theme selectors. +- Set dynamic properties needed by theme selectors. +- Apply theme-owned helper QSS returned from `ThemeManager` for popup surfaces that cannot be covered robustly by global QSS. +- Apply highly local styles for non-target widgets or purely content-driven decoration. + +### Not Allowed + +- Embed base QSS for `QLineEdit`, `QCheckBox`, `QGroupBox`, `QComboBox`, `DialogTitleBar`, or popup surfaces inside component classes. +- Duplicate host style templates in plugins. +- Introduce new per-component styling for the target widgets when a theme selector or theme helper can express it. + +## Host and Plugin Coverage + +This change applies to both: + +- host code under `ui/` +- built-in plugin UI under `plugins/` + +The expected path is: + +1. `ThemeManager` owns style templates and global stylesheet expansion. +2. `plugin_sdk_ui` exposes any required theme-owned popup helper accessors. +3. plugin widgets call those host-owned helpers rather than embedding their own base QSS. + +This preserves one visual language across host and plugin boundaries. + +## Migration Plan + +### Theme system changes + +- update `ui/styles.qss` with the base rules and approved variant selectors +- add small popup helper accessors in `system/theme.py` if global QSS alone is not enough +- extend plugin theme bridge access only if popup helpers need to be callable from plugins + +### Host cleanup + +Remove inline base styles from host files that currently define target widget QSS locally, such as dialogs, settings pages, library views, album or artist search inputs, equalizer controls, and popup widgets. + +Those files should instead: + +- rely on global styling for normal controls +- set object names or dynamic properties for approved variants +- call theme-owned popup helpers where required + +### Plugin cleanup + +Remove duplicated base styles from plugin files, especially: + +- plugin dialog title bar styling +- plugin search input styling +- plugin combo box styling +- completer popup styling +- hotkey popup and cover hover popup base styling + +Plugins should use host-owned selectors and popup helpers only. + +## Testing + +Add focused tests that validate the new ownership model instead of pixel-perfect appearance. + +### Theme manager tests + +- verify popup helper methods return themed QSS with token replacement +- verify the global stylesheet contains the expected selectors for the target widgets + +### Host UI tests + +- verify dialog title bars rely on object names and no longer inject local title bar QSS +- verify representative views still construct and refresh correctly after local base styles are removed +- verify popup widgets still receive themed styles through the theme system + +### Plugin tests + +- verify plugin title bar setup resolves to host-owned styling behavior +- verify plugin completer and popup widgets use theme bridge helpers instead of local hardcoded templates + +## Error Handling + +- Missing helper access in the plugin bridge should fail during tests rather than silently falling back to plugin-local QSS. +- Unknown dynamic properties simply fall back to the base global selector. +- Theme switching still relies on `ThemeManager.apply_global_stylesheet()` plus registered widget refresh hooks for popup helper reapplication. + +## Scope Check + +This is a focused theme-architecture cleanup. It is larger than a one-file tweak, but still bounded: + +- theme system +- a small number of host dialogs and views that currently override target widget styles +- plugin theme bridge +- built-in plugin UI files that currently duplicate the same control or popup styling + +It should be implemented as one coordinated cleanup with tests, not as a new styling framework. From e2a9f9de1758401ebe96f588bea8b2adb5427b31 Mon Sep 17 00:00:00 2001 From: Har01d Date: Tue, 7 Apr 2026 18:09:26 +0800 Subject: [PATCH 064/157] LastFmCoverPlugin --- .../plans/2026-04-07-last-fm-cover-plugin.md | 262 ++++++++++++++++++ .../2026-04-07-last-fm-cover-plugin-design.md | 138 +++++++++ ...4-07-unified-widget-theme-styles-design.md | 93 +++++-- plugins/builtin/last_fm_cover/__init__.py | 1 + plugins/builtin/last_fm_cover/lib/__init__.py | 3 + .../builtin/last_fm_cover/lib/cover_source.py | 81 ++++++ plugins/builtin/last_fm_cover/plugin.json | 10 + plugins/builtin/last_fm_cover/plugin_main.py | 13 + services/metadata/cover_service.py | 2 - services/sources/__init__.py | 2 - services/sources/cover_sources.py | 75 ----- .../test_plugins/test_last_fm_cover_plugin.py | 66 +++++ .../test_plugin_cover_registry.py | 1 + 13 files changed, 638 insertions(+), 109 deletions(-) create mode 100644 docs/superpowers/plans/2026-04-07-last-fm-cover-plugin.md create mode 100644 docs/superpowers/specs/2026-04-07-last-fm-cover-plugin-design.md create mode 100644 plugins/builtin/last_fm_cover/__init__.py create mode 100644 plugins/builtin/last_fm_cover/lib/__init__.py create mode 100644 plugins/builtin/last_fm_cover/lib/cover_source.py create mode 100644 plugins/builtin/last_fm_cover/plugin.json create mode 100644 plugins/builtin/last_fm_cover/plugin_main.py create mode 100644 tests/test_plugins/test_last_fm_cover_plugin.py diff --git a/docs/superpowers/plans/2026-04-07-last-fm-cover-plugin.md b/docs/superpowers/plans/2026-04-07-last-fm-cover-plugin.md new file mode 100644 index 00000000..67bc7687 --- /dev/null +++ b/docs/superpowers/plans/2026-04-07-last-fm-cover-plugin.md @@ -0,0 +1,262 @@ +# Last.fm Cover Plugin Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Move the host-owned Last.fm album cover source into a built-in plugin with id `last_fm_cover` while preserving the current default API key fallback behavior. + +**Architecture:** Add a built-in plugin under `plugins/builtin/last_fm_cover/` that registers one album cover source through the plugin service bridge. Remove direct host ownership from `CoverService` and `services/sources` exports so Last.fm cover behavior flows only through plugin loading, but keep the current `LASTFM_API_KEY` resolution and built-in fallback key unchanged inside the plugin implementation. + +**Tech Stack:** Python 3.11, pytest, `uv`, Harmony plugin runtime, environment-variable based API key resolution + +--- + +## File Map + +- Create: `plugins/builtin/last_fm_cover/__init__.py` +- Create: `plugins/builtin/last_fm_cover/plugin.json` +- Create: `plugins/builtin/last_fm_cover/plugin_main.py` +- Create: `plugins/builtin/last_fm_cover/lib/__init__.py` +- Create: `plugins/builtin/last_fm_cover/lib/cover_source.py` +- Create: `tests/test_plugins/test_last_fm_cover_plugin.py` +- Modify: `services/metadata/cover_service.py` +- Modify: `services/sources/cover_sources.py` +- Modify: `services/sources/__init__.py` +- Modify: `tests/test_services/test_plugin_cover_registry.py` + +## Task 1: Lock In Failing Tests + +**Files:** +- Create: `tests/test_plugins/test_last_fm_cover_plugin.py` +- Modify: `tests/test_services/test_plugin_cover_registry.py` + +- [ ] **Step 1: Write the failing test** + +```python +from unittest.mock import Mock + +from plugins.builtin.last_fm_cover.plugin_main import LastFmCoverPlugin + + +def test_last_fm_plugin_registers_cover_source(): + context = Mock() + plugin = LastFmCoverPlugin() + + plugin.register(context) + + assert context.services.register_cover_source.call_count == 1 +``` + +```python +from types import SimpleNamespace + +from services.metadata.cover_service import CoverService + + +def test_builtin_cover_sources_exclude_plugin_owned_sources(): + service = CoverService(http_client=SimpleNamespace(), sources=None) + + names = {source.name for source in service._get_builtin_sources()} + + assert "Last.fm" not in names +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `uv run pytest tests/test_plugins/test_last_fm_cover_plugin.py tests/test_services/test_plugin_cover_registry.py -v` +Expected: FAIL with `ModuleNotFoundError` for `plugins.builtin.last_fm_cover` and/or an assertion showing `Last.fm` is still present in built-in host sources + +## Task 2: Add the Built-In Plugin + +**Files:** +- Create: `plugins/builtin/last_fm_cover/__init__.py` +- Create: `plugins/builtin/last_fm_cover/plugin.json` +- Create: `plugins/builtin/last_fm_cover/plugin_main.py` +- Create: `plugins/builtin/last_fm_cover/lib/__init__.py` +- Create: `plugins/builtin/last_fm_cover/lib/cover_source.py` +- Test: `tests/test_plugins/test_last_fm_cover_plugin.py` + +- [ ] **Step 1: Write minimal plugin implementation** + +```python +from .lib.cover_source import LastFmCoverPluginSource + + +class LastFmCoverPlugin: + plugin_id = "last_fm_cover" + + def register(self, context) -> None: + context.services.register_cover_source( + LastFmCoverPluginSource(context.http) + ) + + def unregister(self, context) -> None: + return None +``` + +```python +import os + +from harmony_plugin_api.cover import PluginCoverResult + + +class LastFmCoverPluginSource: + source = "lastfm" + source_id = "lastfm-cover" + display_name = "Last.fm" + name = "Last.fm" + _DEFAULT_API_KEY = "9b0cdcf446cc96dea3e747787ad23575" + + def __init__(self, http_client): + self._http_client = http_client + + def _get_api_key(self) -> str: + api_key = os.getenv("LASTFM_API_KEY") + if not api_key or api_key == "YOUR_LASTFM_API_KEY": + return self._DEFAULT_API_KEY + return api_key +``` + +- [ ] **Step 2: Run plugin tests** + +Run: `uv run pytest tests/test_plugins/test_last_fm_cover_plugin.py -v` +Expected: PASS + +## Task 3: Remove Host Ownership + +**Files:** +- Modify: `services/metadata/cover_service.py` +- Modify: `services/sources/cover_sources.py` +- Modify: `services/sources/__init__.py` +- Modify: `tests/test_services/test_plugin_cover_registry.py` + +- [ ] **Step 1: Remove Last.fm from built-in host source wiring** + +```python +def _get_builtin_sources(self) -> List["CoverSource"]: + from services.sources import NetEaseCoverSource + return [ + NetEaseCoverSource(self.http_client), + ] +``` + +- [ ] **Step 2: Delete host export for `LastFmCoverSource` and rerun tests** + +Run: `uv run pytest tests/test_services/test_plugin_cover_registry.py tests/test_plugins/test_last_fm_cover_plugin.py -v` +Expected: PASS + +## Task 4: Preserve Last.fm Behavior + +**Files:** +- Modify: `tests/test_plugins/test_last_fm_cover_plugin.py` +- Modify: `plugins/builtin/last_fm_cover/lib/cover_source.py` + +- [ ] **Step 1: Add a failing behavior test for default API key fallback and result mapping** + +```python +from types import SimpleNamespace + +from plugins.builtin.last_fm_cover.lib.cover_source import LastFmCoverPluginSource + + +def test_last_fm_plugin_source_uses_default_api_key_when_env_missing(monkeypatch): + captured = {} + + def fake_get(url, params=None, timeout=0): + captured["url"] = url + captured["params"] = params + return SimpleNamespace( + status_code=200, + json=lambda: { + "album": { + "name": "Album 1", + "artist": "Singer 1", + "image": [ + {"#text": ""}, + {"#text": "https://example.com/cover-large.jpg"}, + ], + } + }, + ) + + monkeypatch.delenv("LASTFM_API_KEY", raising=False) + source = LastFmCoverPluginSource(SimpleNamespace(get=fake_get)) + + results = source.search("Song 1", "Singer 1", "Album 1") + + assert captured["url"] == "http://ws.audioscrobbler.com/2.0/" + assert captured["params"]["api_key"] == "9b0cdcf446cc96dea3e747787ad23575" + assert results[0].source == "lastfm" + assert results[0].cover_url == "https://example.com/cover-large.jpg" +``` + +- [ ] **Step 2: Run the test to verify it fails for the right reason** + +Run: `uv run pytest tests/test_plugins/test_last_fm_cover_plugin.py::test_last_fm_plugin_source_uses_default_api_key_when_env_missing -v` +Expected: FAIL until `LastFmCoverPluginSource.search()` preserves the current host behavior + +- [ ] **Step 3: Implement the minimal Last.fm search logic in the plugin** + +```python +def search( + self, + title: str, + artist: str, + album: str = "", + duration: float | None = None, +) -> list[PluginCoverResult]: + results = [] + params = { + "method": "album.getinfo", + "api_key": self._get_api_key(), + "artist": artist, + "album": album or title, + "format": "json", + } + response = self._http_client.get( + "http://ws.audioscrobbler.com/2.0/", + params=params, + timeout=5, + ) + if response.status_code == 200: + data = response.json() + album_info = data.get("album") + if album_info: + image_url = None + for image in reversed(album_info.get("image", [])): + if image.get("#text"): + image_url = image["#text"] + break + if image_url: + results.append( + PluginCoverResult( + item_id=album_info.get("mbid", ""), + title=album_info.get("name", ""), + artist=album_info.get("artist", ""), + album=album_info.get("name", ""), + source="lastfm", + cover_url=image_url, + ) + ) + return results +``` + +- [ ] **Step 4: Run plugin tests to verify they pass** + +Run: `uv run pytest tests/test_plugins/test_last_fm_cover_plugin.py -v` +Expected: PASS + +## Task 5: Focused Verification + +**Files:** +- Test: `tests/test_plugins/test_last_fm_cover_plugin.py` +- Test: `tests/test_services/test_plugin_cover_registry.py` + +- [ ] **Step 1: Run focused verification** + +Run: `uv run pytest tests/test_plugins/test_last_fm_cover_plugin.py tests/test_services/test_plugin_cover_registry.py -v` +Expected: PASS + +- [ ] **Step 2: Review diff** + +Run: `git diff -- plugins/builtin/last_fm_cover services/metadata/cover_service.py services/sources/cover_sources.py services/sources/__init__.py tests/test_plugins/test_last_fm_cover_plugin.py tests/test_services/test_plugin_cover_registry.py docs/superpowers/specs/2026-04-07-last-fm-cover-plugin-design.md docs/superpowers/plans/2026-04-07-last-fm-cover-plugin.md` +Expected: Last.fm source ownership moves from host code to plugin code with no unrelated edits diff --git a/docs/superpowers/specs/2026-04-07-last-fm-cover-plugin-design.md b/docs/superpowers/specs/2026-04-07-last-fm-cover-plugin-design.md new file mode 100644 index 00000000..6edc2544 --- /dev/null +++ b/docs/superpowers/specs/2026-04-07-last-fm-cover-plugin-design.md @@ -0,0 +1,138 @@ +# Last.fm Cover Plugin Design + +## Overview + +This change moves the host-owned Last.fm album cover source into a built-in plugin. + +The goal is to make Last.fm follow the same ownership boundary already used by the new iTunes cover plugin and the existing lyrics plugins. After the migration, the host still queries Last.fm-backed album cover data, but the plugin runtime owns registration and lifecycle. + +## Goals + +- Move `LastFmCoverSource` into a built-in plugin with manifest id `last_fm_cover`. +- Remove host-owned Last.fm source registration from `CoverService`. +- Preserve current Last.fm album cover search behavior. +- Preserve the current default API key fallback behavior when `LASTFM_API_KEY` is missing or left at the placeholder value. +- Keep Last.fm enabled by default through normal built-in plugin loading. + +## Non-Goals + +- No new plugin settings tab. +- No new artist-cover source for Last.fm. +- No change to the Last.fm request method, parameters, or matching logic. +- No refactor of unrelated host cover sources beyond removing Last.fm ownership. + +## Current State + +Last.fm album cover search lives in [`services/sources/cover_sources.py`](/home/harold/workspace/music-player/services/sources/cover_sources.py) as `LastFmCoverSource`. + +[`services/metadata/cover_service.py`](/home/harold/workspace/music-player/services/metadata/cover_service.py) still constructs that source directly as a built-in host source, so it does not participate in plugin enable/disable lifecycle. + +The current implementation resolves the API key as follows: + +- use `LASTFM_API_KEY` when present and not equal to the placeholder value +- otherwise fall back to the built-in default API key + +That behavior must remain unchanged after migration. + +## Recommended Approach + +Create a built-in plugin at `plugins/builtin/last_fm_cover/` and move the Last.fm cover implementation under that directory. + +The plugin manifest id should be `last_fm_cover`. The runtime source identifier should remain `lastfm` so returned search results keep the same source label they use today. + +## Architecture + +### Plugin Layout + +Add a built-in plugin directory: + +```text +plugins/builtin/last_fm_cover/ +├── __init__.py +├── plugin.json +├── plugin_main.py +└── lib/ + ├── __init__.py + └── cover_source.py +``` + +### Host Boundary + +After migration: + +- the host owns `NetEaseCoverSource` as the only built-in album cover source +- the Last.fm implementation lives entirely under `plugins/builtin/last_fm_cover/` +- `CoverService._get_sources()` continues to merge host sources with plugin-registered cover sources + +### Plugin Registration + +`plugin_main.py` should expose a plugin class with: + +- `plugin_id = "last_fm_cover"` +- `register(context)` calling `context.services.register_cover_source(...)` +- `unregister(context)` as a no-op + +The manifest should declare: + +- `"id": "last_fm_cover"` +- `"capabilities": ["cover"]` + +## Runtime Behavior + +### Album Cover Search + +The plugin cover source should preserve current Last.fm behavior: + +- endpoint: `http://ws.audioscrobbler.com/2.0/` +- params: `method=album.getinfo`, `artist`, `album`, `format=json`, plus resolved API key +- API key resolution: + - use `LASTFM_API_KEY` when present and not equal to `YOUR_LASTFM_API_KEY` + - otherwise use the current built-in default key +- on a successful album payload, choose the largest available image entry with a non-empty `#text` +- return an empty list on request, JSON, or API errors instead of raising to the host + +### Availability Check + +The plugin `is_available()` behavior should remain effectively unchanged from today. Because the implementation always has a built-in fallback key, the source continues to report itself as available. + +## File Changes + +### Create + +- `plugins/builtin/last_fm_cover/__init__.py` +- `plugins/builtin/last_fm_cover/plugin.json` +- `plugins/builtin/last_fm_cover/plugin_main.py` +- `plugins/builtin/last_fm_cover/lib/__init__.py` +- `plugins/builtin/last_fm_cover/lib/cover_source.py` +- `tests/test_plugins/test_last_fm_cover_plugin.py` + +### Modify + +- `services/metadata/cover_service.py` +- `services/sources/cover_sources.py` +- `services/sources/__init__.py` +- `tests/test_services/test_plugin_cover_registry.py` + +## Testing + +Add or update tests to cover: + +- the Last.fm plugin registers one cover source through the plugin context +- the plugin cover source preserves current Last.fm result mapping +- the plugin cover source still falls back to the built-in default API key when the env var is missing or placeholder-valued +- `CoverService._get_builtin_sources()` no longer includes `Last.fm` + +Regression commands should focus on the changed area: + +- `uv run pytest tests/test_plugins/test_last_fm_cover_plugin.py` +- `uv run pytest tests/test_services/test_plugin_cover_registry.py` + +## Risks and Mitigations + +- Behavior regression risk in API key selection: preserve the current key-resolution logic exactly, including placeholder detection and built-in fallback key. +- Hidden host dependency risk: remove all host imports and exports of `LastFmCoverSource`. +- Scope creep risk from plugin settings: explicitly keep this migration limited to ownership and registration. + +## Scope Check + +This design is intentionally narrow. It changes only Last.fm album cover ownership and the associated tests. It does not introduce new UI, new settings, or new cover-matching behavior. diff --git a/docs/superpowers/specs/2026-04-07-unified-widget-theme-styles-design.md b/docs/superpowers/specs/2026-04-07-unified-widget-theme-styles-design.md index e7ca3ede..e86f6ff2 100644 --- a/docs/superpowers/specs/2026-04-07-unified-widget-theme-styles-design.md +++ b/docs/superpowers/specs/2026-04-07-unified-widget-theme-styles-design.md @@ -1,38 +1,64 @@ -# Unified Widget Theme Styles Design +# Unified Foundation Theme Styles Design ## Overview -This change consolidates the styling of foundational input and popup widgets under the host theme system. +This change consolidates the styling of common Qt foundation widgets and project-wide shared wrapper components under the host theme system. -The target widgets are: +The earlier request named several examples such as `DialogTitleBar`, `QLineEdit`, `QCheckBox`, `QGroupBox`, `QComboBox`, and popup widgets. After inventorying the codebase, the actual scope should be broader: all commonly reused Qt foundation controls plus the project's shared wrapper components should be owned by the theme system. -- `DialogTitleBar` -- `QLineEdit` -- `QCheckBox` -- `QGroupBox` -- `QComboBox` -- all popup surfaces, including completer popups, `QMenu`, custom hover popups, and frameless `Qt.Popup` dialogs - -The goal is to stop defining these base styles inside individual components. Host widgets and plugins must both receive the same base styling from the theme system. Component-level variation remains allowed, but only through theme-owned selectors such as object names and dynamic properties. +The goal is to stop defining base styles for these shared building blocks inside individual components. Host widgets and plugins must both receive the same baseline styling from the theme system. Component-level variation remains allowed, but only through theme-owned selectors such as object names and dynamic properties. ## Goals -- Move base styles for the target widgets into the theme system. +- Move base styles for common foundation widgets into the theme system. +- Move base styles for shared wrapper components into the theme system. - Ensure host UI and plugin UI use the same styling source. -- Remove duplicated inline QSS for these target widgets from dialogs, views, and plugin components. +- Remove duplicated inline QSS for these foundation widgets and wrappers from dialogs, views, widgets, and plugin components. - Preserve room for controlled variants through object names or dynamic properties. - Keep real-time theme switching working for all affected widgets. ## Non-Goals -- No attempt to centralize every widget type in the application in this change. +- No attempt to centralize highly bespoke business widgets whose visuals are their primary value. - No visual redesign of unrelated custom widgets such as cards, tables, sliders, or artwork containers. - No plugin-specific theme fork. - No generic "component style registry" abstraction beyond what is needed to make popups and global QSS work reliably. +## Scope Inventory + +The codebase inventory shows that the foundation layer is larger than the initial example list. This change should cover two tiers. + +### Tier 1: Common Qt foundation widgets + +These are the shared Qt building blocks that appear broadly across host UI and plugins and should have a theme-owned baseline: + +- application shell and containers: `QWidget`, `QDialog`, `QMainWindow`, `QFrame`, `QSplitter`, `QScrollArea`, `QStackedWidget`, `QTabWidget`, `QTabBar` +- text and display primitives: `QLabel`, `QProgressBar` +- command controls: `QPushButton`, `QDialogButtonBox` +- text input controls: `QLineEdit`, `QTextEdit` +- selection controls: `QCheckBox`, `QRadioButton`, `QComboBox`, `QSpinBox` +- grouping and layout framing: `QGroupBox` +- item and data views with shared baseline chrome: `QListWidget`, `QListView`, `QTableWidget`, headers, and common `QAbstractItemView` popup surfaces +- menu and popup surfaces: `QMenu`, completer popups, custom hover popups, and frameless `Qt.Popup` dialogs + +This tier is the baseline for "all common Qt base widgets" in this repository, not an exhaustive list of every Qt class that exists. + +### Tier 2: Project-wide shared wrapper components + +These are repository-level reusable components that should also be theme-owned because they are part of the app foundation rather than one-off business presentation: + +- `TitleBar` +- `DialogTitleBar` +- `ToggleSwitch` +- shared context menu builders +- shared popup wrappers such as cover hover popups and hotkey popups +- shared dialog shells such as message, input, rename, provider-select, progress, and cover-download dialog scaffolding + +These wrappers may still contain structure and behavior, but their shared visual rules should be driven from the same theme layer as Tier 1. + ## Current Problems -The repository already has a global stylesheet and token replacement via `ThemeManager`, but the target widgets are still styled in multiple layers: +The repository already has a global stylesheet and token replacement via `ThemeManager`, but foundation widgets and wrappers are still styled in multiple layers: - global QSS in `ui/styles.qss` - ad hoc inline `setStyleSheet()` calls inside dialogs and views @@ -47,16 +73,17 @@ This causes three issues: ## Recommended Approach -Use the theme system as the single owner of base styles. +Use the theme system as the single owner of foundation styles. ### 1. Expand the global theme stylesheet -`ui/styles.qss` becomes the base stylesheet source for the target widget classes and theme-owned variants. +`ui/styles.qss` becomes the base stylesheet source for foundation widget classes and theme-owned variants. It should define: -- global base rules for `QLineEdit`, `QCheckBox`, `QGroupBox`, `QComboBox`, `QMenu`, and common popup containers -- title bar rules keyed by object names such as `#dialogTitleBar`, `#dialogTitle`, and `#dialogCloseBtn` +- global base rules for the Tier 1 widget families listed above +- wrapper rules keyed by object names and properties for Tier 2 shared components +- title bar rules keyed by object names such as `#dialogTitleBar`, `#dialogTitle`, `#dialogCloseBtn`, and corresponding main window title bar selectors - variant selectors keyed by dynamic properties or object names where the app needs approved deviations Examples of allowed variant hooks: @@ -65,16 +92,19 @@ Examples of allowed variant hooks: - `QGroupBox[variant="settings"]` - `QWidget[popupSurface="true"]` - `QComboBox[compact="true"]` +- `QPushButton[role="primary"]` +- `QDialog[shell="true"]` The theme file remains token-based, so all colors continue to come from `ThemeManager.get_qss(...)`. ### 2. Keep popup-specific helper entry points inside the theme system -Some popup surfaces are not reliably covered by application-wide selectors alone because they may be separate top-level widgets or created lazily by Qt. For those cases, the theme system should expose small helper templates owned by `ThemeManager`, for example: +Some surfaces are not reliably covered by application-wide selectors alone because they may be separate top-level widgets, created lazily by Qt, or painted by reusable wrappers. For those cases, the theme system should expose small helper templates owned by `ThemeManager`, for example: - popup list view style for `QCompleter.popup()` - generic popup surface style for custom `QWidget` popups - optional frameless popup dialog wrapper style +- shared wrapper accents such as toggle switch token access if a widget is painted manually rather than styled by QSS These helpers remain part of the theme system. Components may apply them, but they may not define their own base popup QSS. @@ -82,7 +112,7 @@ This is not a second styling system. It is a delivery mechanism for theme-owned ### 3. Remove duplicated title bar styling from components -Both host and plugin `DialogTitleBarController` implementations should stop embedding their own QSS templates for: +Both host and plugin shared title bar implementations should stop embedding their own QSS templates for: - `dialogTitleBar` - `dialogTitle` @@ -111,13 +141,13 @@ The following rules define what components may and may not do after this change. - Set object names needed by theme selectors. - Set dynamic properties needed by theme selectors. - Apply theme-owned helper QSS returned from `ThemeManager` for popup surfaces that cannot be covered robustly by global QSS. -- Apply highly local styles for non-target widgets or purely content-driven decoration. +- Apply highly local styles for non-foundation business widgets or purely content-driven decoration. ### Not Allowed -- Embed base QSS for `QLineEdit`, `QCheckBox`, `QGroupBox`, `QComboBox`, `DialogTitleBar`, or popup surfaces inside component classes. +- Embed base QSS for common foundation widgets or shared wrapper components inside feature component classes. - Duplicate host style templates in plugins. -- Introduce new per-component styling for the target widgets when a theme selector or theme helper can express it. +- Introduce new per-component styling for foundation widgets when a theme selector or theme helper can express it. ## Host and Plugin Coverage @@ -144,7 +174,7 @@ This preserves one visual language across host and plugin boundaries. ### Host cleanup -Remove inline base styles from host files that currently define target widget QSS locally, such as dialogs, settings pages, library views, album or artist search inputs, equalizer controls, and popup widgets. +Remove inline base styles from host files that currently define foundation widget QSS locally, such as dialogs, settings pages, library views, album or artist search inputs, equalizer controls, context menus, title bars, dialog shells, and popup widgets. Those files should instead: @@ -161,6 +191,7 @@ Remove duplicated base styles from plugin files, especially: - plugin combo box styling - completer popup styling - hotkey popup and cover hover popup base styling +- plugin menu, dialog shell, and shared wrapper styling where it duplicates host-owned foundation rules Plugins should use host-owned selectors and popup helpers only. @@ -171,18 +202,20 @@ Add focused tests that validate the new ownership model instead of pixel-perfect ### Theme manager tests - verify popup helper methods return themed QSS with token replacement -- verify the global stylesheet contains the expected selectors for the target widgets +- verify the global stylesheet contains the expected selectors for the foundation widget families and shared wrapper selectors ### Host UI tests - verify dialog title bars rely on object names and no longer inject local title bar QSS -- verify representative views still construct and refresh correctly after local base styles are removed +- verify representative views and shared dialogs still construct and refresh correctly after local base styles are removed - verify popup widgets still receive themed styles through the theme system +- verify reusable wrapper components such as `ToggleSwitch` and shared menus still follow theme-owned tokens or selectors ### Plugin tests - verify plugin title bar setup resolves to host-owned styling behavior - verify plugin completer and popup widgets use theme bridge helpers instead of local hardcoded templates +- verify plugin shared settings or login surfaces inherit the same foundation baselines as host widgets ## Error Handling @@ -192,11 +225,11 @@ Add focused tests that validate the new ownership model instead of pixel-perfect ## Scope Check -This is a focused theme-architecture cleanup. It is larger than a one-file tweak, but still bounded: +This is a foundation-layer theme cleanup. It is larger than a one-file tweak, but still bounded: - theme system -- a small number of host dialogs and views that currently override target widget styles +- host dialogs, views, and shared widgets that currently override foundation styles - plugin theme bridge -- built-in plugin UI files that currently duplicate the same control or popup styling +- built-in plugin UI files that currently duplicate the same control, dialog shell, menu, or popup styling It should be implemented as one coordinated cleanup with tests, not as a new styling framework. diff --git a/plugins/builtin/last_fm_cover/__init__.py b/plugins/builtin/last_fm_cover/__init__.py new file mode 100644 index 00000000..23705039 --- /dev/null +++ b/plugins/builtin/last_fm_cover/__init__.py @@ -0,0 +1 @@ +"""Last.fm cover built-in plugin.""" diff --git a/plugins/builtin/last_fm_cover/lib/__init__.py b/plugins/builtin/last_fm_cover/lib/__init__.py new file mode 100644 index 00000000..d4b9b4e2 --- /dev/null +++ b/plugins/builtin/last_fm_cover/lib/__init__.py @@ -0,0 +1,3 @@ +from .cover_source import LastFmCoverPluginSource + +__all__ = ["LastFmCoverPluginSource"] diff --git a/plugins/builtin/last_fm_cover/lib/cover_source.py b/plugins/builtin/last_fm_cover/lib/cover_source.py new file mode 100644 index 00000000..0ca40ce9 --- /dev/null +++ b/plugins/builtin/last_fm_cover/lib/cover_source.py @@ -0,0 +1,81 @@ +from __future__ import annotations + +import logging +import os + +from harmony_plugin_api.cover import PluginCoverResult + +logger = logging.getLogger(__name__) + + +class LastFmCoverPluginSource: + source = "lastfm" + source_id = "lastfm-cover" + display_name = "Last.fm" + name = "Last.fm" + _DEFAULT_API_KEY = "9b0cdcf446cc96dea3e747787ad23575" + + def __init__(self, http_client): + self._http_client = http_client + + def _get_api_key(self) -> str: + api_key = os.getenv("LASTFM_API_KEY") + if not api_key or api_key == "YOUR_LASTFM_API_KEY": + return self._DEFAULT_API_KEY + return api_key + + def is_available(self) -> bool: + return True + + def search( + self, + title: str, + artist: str, + album: str = "", + duration: float | None = None, + ) -> list[PluginCoverResult]: + results: list[PluginCoverResult] = [] + + try: + response = self._http_client.get( + "http://ws.audioscrobbler.com/2.0/", + params={ + "method": "album.getinfo", + "api_key": self._get_api_key(), + "artist": artist, + "album": album or title, + "format": "json", + }, + timeout=5, + ) + + if response.status_code == 200: + data = response.json() + if "error" in data: + logger.debug("Last.fm API error: %s", data.get("message")) + return results + + album_info = data.get("album") + if album_info: + image_url = None + for image in reversed(album_info.get("image", [])): + if image.get("#text"): + image_url = image["#text"] + break + + if image_url: + results.append( + PluginCoverResult( + item_id=album_info.get("mbid", ""), + title=album_info.get("name", ""), + artist=album_info.get("artist", ""), + album=album_info.get("name", ""), + source="lastfm", + cover_url=image_url, + ) + ) + + except Exception as exc: + logger.debug("Last.fm search error: %s", exc) + + return results diff --git a/plugins/builtin/last_fm_cover/plugin.json b/plugins/builtin/last_fm_cover/plugin.json new file mode 100644 index 00000000..1897e2b1 --- /dev/null +++ b/plugins/builtin/last_fm_cover/plugin.json @@ -0,0 +1,10 @@ +{ + "id": "last_fm_cover", + "name": "Last.fm Cover", + "version": "1.0.0", + "api_version": "1", + "entrypoint": "plugin_main.py", + "entry_class": "LastFmCoverPlugin", + "capabilities": ["cover"], + "min_app_version": "0.1.0" +} diff --git a/plugins/builtin/last_fm_cover/plugin_main.py b/plugins/builtin/last_fm_cover/plugin_main.py new file mode 100644 index 00000000..338c14b7 --- /dev/null +++ b/plugins/builtin/last_fm_cover/plugin_main.py @@ -0,0 +1,13 @@ +from __future__ import annotations + +from .lib.cover_source import LastFmCoverPluginSource + + +class LastFmCoverPlugin: + plugin_id = "last_fm_cover" + + def register(self, context) -> None: + context.services.register_cover_source(LastFmCoverPluginSource(context.http)) + + def unregister(self, context) -> None: + return None diff --git a/services/metadata/cover_service.py b/services/metadata/cover_service.py index fa983c05..8f7646bd 100644 --- a/services/metadata/cover_service.py +++ b/services/metadata/cover_service.py @@ -47,11 +47,9 @@ def _get_builtin_sources(self) -> List["CoverSource"]: """Get built-in host cover sources.""" from services.sources import ( NetEaseCoverSource, - LastFmCoverSource, ) return [ NetEaseCoverSource(self.http_client), - LastFmCoverSource(self.http_client), ] def _get_sources(self) -> List["CoverSource"]: diff --git a/services/sources/__init__.py b/services/sources/__init__.py index 03e73466..ae793762 100644 --- a/services/sources/__init__.py +++ b/services/sources/__init__.py @@ -8,7 +8,6 @@ from .base import CoverSource, LyricsSource, ArtistCoverSource from .cover_sources import ( NetEaseCoverSource, - LastFmCoverSource, MusicBrainzCoverSource, SpotifyCoverSource, ) @@ -27,7 +26,6 @@ "ArtistCoverSource", # Cover sources "NetEaseCoverSource", - "LastFmCoverSource", "MusicBrainzCoverSource", "SpotifyCoverSource", # Lyrics sources diff --git a/services/sources/cover_sources.py b/services/sources/cover_sources.py index 08753a11..3e608add 100644 --- a/services/sources/cover_sources.py +++ b/services/sources/cover_sources.py @@ -118,81 +118,6 @@ def __init__(self, http_client): self._http_client = http_client -class LastFmCoverSource(CoverSource): - """Last.fm API cover source.""" - - @property - def name(self) -> str: - return "Last.fm" - - def is_available(self) -> bool: - """Check if API key is available.""" - api_key = os.getenv("LASTFM_API_KEY") - return bool(api_key and api_key != "YOUR_LASTFM_API_KEY") or True # Has default key - - def search( - self, - title: str, - artist: str, - album: str = "", - duration: Optional[float] = None - ) -> List[CoverSearchResult]: - """Search for covers from Last.fm API.""" - results = [] - - api_key = os.getenv("LASTFM_API_KEY") - if not api_key or api_key == "YOUR_LASTFM_API_KEY": - api_key = "9b0cdcf446cc96dea3e747787ad23575" - - try: - url = "http://ws.audioscrobbler.com/2.0/" - params = { - 'method': 'album.getinfo', - 'api_key': api_key, - 'artist': artist, - 'album': album or title, - 'format': 'json' - } - - response = self._http_client.get(url, params=params, timeout=5) - - if response.status_code == 200: - data = response.json() - - if 'error' in data: - logger.debug(f"Last.fm API error: {data.get('message')}") - return results - - if 'album' in data: - album_info = data['album'] - image_url = None - - # Get the largest image - if 'image' in album_info: - for img in reversed(album_info['image']): - if img.get('#text'): - image_url = img['#text'] - break - - if image_url: - results.append(CoverSearchResult( - title=album_info.get('name', ''), - artist=album_info.get('artist', ''), - album=album_info.get('name', ''), - source='lastfm', - id=album_info.get('mbid', ''), - cover_url=image_url - )) - - except Exception as e: - logger.debug(f"Last.fm search error: {e}") - - return results - - def __init__(self, http_client): - self._http_client = http_client - - class MusicBrainzCoverSource(CoverSource): """MusicBrainz Cover Art Archive source.""" diff --git a/tests/test_plugins/test_last_fm_cover_plugin.py b/tests/test_plugins/test_last_fm_cover_plugin.py new file mode 100644 index 00000000..4858ffaa --- /dev/null +++ b/tests/test_plugins/test_last_fm_cover_plugin.py @@ -0,0 +1,66 @@ +from types import SimpleNamespace +from unittest.mock import Mock + +from plugins.builtin.last_fm_cover.lib.cover_source import LastFmCoverPluginSource +from plugins.builtin.last_fm_cover.plugin_main import LastFmCoverPlugin + + +def test_last_fm_plugin_registers_cover_source(): + context = Mock() + plugin = LastFmCoverPlugin() + + plugin.register(context) + + assert context.services.register_cover_source.call_count == 1 + registered_cover = context.services.register_cover_source.call_args.args[0] + assert isinstance(registered_cover, LastFmCoverPluginSource) + + +def test_last_fm_plugin_source_uses_default_api_key_when_env_missing(monkeypatch): + captured = {} + + def fake_get(url, params=None, timeout=0): + captured["url"] = url + captured["params"] = params + return SimpleNamespace( + status_code=200, + json=lambda: { + "album": { + "name": "Album 1", + "artist": "Singer 1", + "image": [ + {"#text": ""}, + {"#text": "https://example.com/cover-large.jpg"}, + ], + } + }, + ) + + monkeypatch.delenv("LASTFM_API_KEY", raising=False) + source = LastFmCoverPluginSource(SimpleNamespace(get=fake_get)) + + results = source.search("Song 1", "Singer 1", "Album 1") + + assert captured["url"] == "http://ws.audioscrobbler.com/2.0/" + assert captured["params"]["api_key"] == "9b0cdcf446cc96dea3e747787ad23575" + assert len(results) == 1 + assert results[0].title == "Album 1" + assert results[0].artist == "Singer 1" + assert results[0].album == "Album 1" + assert results[0].source == "lastfm" + assert results[0].cover_url == "https://example.com/cover-large.jpg" + + +def test_last_fm_plugin_source_uses_default_api_key_when_env_is_placeholder(monkeypatch): + captured = {} + + def fake_get(url, params=None, timeout=0): + captured["params"] = params + return SimpleNamespace(status_code=200, json=lambda: {"album": {"image": []}}) + + monkeypatch.setenv("LASTFM_API_KEY", "YOUR_LASTFM_API_KEY") + source = LastFmCoverPluginSource(SimpleNamespace(get=fake_get)) + + source.search("Song 1", "Singer 1", "Album 1") + + assert captured["params"]["api_key"] == "9b0cdcf446cc96dea3e747787ad23575" diff --git a/tests/test_services/test_plugin_cover_registry.py b/tests/test_services/test_plugin_cover_registry.py index d7b79c75..f5cbb453 100644 --- a/tests/test_services/test_plugin_cover_registry.py +++ b/tests/test_services/test_plugin_cover_registry.py @@ -43,3 +43,4 @@ def test_builtin_cover_sources_exclude_plugin_owned_sources(): assert "QQMusic" not in artist_names assert "iTunes" not in names assert "iTunes" not in artist_names + assert "Last.fm" not in names From 949fae6427d5a9c94ff5cc08a9edc584c028338e Mon Sep 17 00:00:00 2001 From: Har01d Date: Tue, 7 Apr 2026 18:09:58 +0800 Subject: [PATCH 065/157] =?UTF-8?q?=E6=89=A9=E5=B1=95=E7=BB=9F=E4=B8=80?= =?UTF-8?q?=E4=B8=BB=E9=A2=98=E5=9F=BA=E7=A1=80=E5=B1=82=E8=AE=BE=E8=AE=A1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../specs/2026-04-07-unified-widget-theme-styles-design.md | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/docs/superpowers/specs/2026-04-07-unified-widget-theme-styles-design.md b/docs/superpowers/specs/2026-04-07-unified-widget-theme-styles-design.md index e86f6ff2..d5a214c5 100644 --- a/docs/superpowers/specs/2026-04-07-unified-widget-theme-styles-design.md +++ b/docs/superpowers/specs/2026-04-07-unified-widget-theme-styles-design.md @@ -20,7 +20,8 @@ The goal is to stop defining base styles for these shared building blocks inside ## Non-Goals - No attempt to centralize highly bespoke business widgets whose visuals are their primary value. -- No visual redesign of unrelated custom widgets such as cards, tables, sliders, or artwork containers. +- No visual redesign of bespoke business presentation such as album cards, artwork containers, or data-specific content rendering inside complex views. +- Shared baseline chrome for common controls such as list, table, and slider widgets is in scope, but business-specific presentation layered on top of them is not. - No plugin-specific theme fork. - No generic "component style registry" abstraction beyond what is needed to make popups and global QSS work reliably. @@ -36,7 +37,7 @@ These are the shared Qt building blocks that appear broadly across host UI and p - text and display primitives: `QLabel`, `QProgressBar` - command controls: `QPushButton`, `QDialogButtonBox` - text input controls: `QLineEdit`, `QTextEdit` -- selection controls: `QCheckBox`, `QRadioButton`, `QComboBox`, `QSpinBox` +- selection controls: `QCheckBox`, `QRadioButton`, `QComboBox`, `QSpinBox`, `QSlider` - grouping and layout framing: `QGroupBox` - item and data views with shared baseline chrome: `QListWidget`, `QListView`, `QTableWidget`, headers, and common `QAbstractItemView` popup surfaces - menu and popup surfaces: `QMenu`, completer popups, custom hover popups, and frameless `Qt.Popup` dialogs From 6afa3fc48f8c1392c5d51fe7a7e0d9c9acea09fb Mon Sep 17 00:00:00 2001 From: Har01d Date: Tue, 7 Apr 2026 18:17:30 +0800 Subject: [PATCH 066/157] LastFmCoverPlugin --- .../builtin/itunes_cover/lib/cover_source.py | 1 + plugins/builtin/kugou/lib/lyrics_source.py | 1 + .../builtin/last_fm_cover/lib/cover_source.py | 1 + plugins/builtin/lrclib/lib/lrclib_source.py | 5 ++ services/metadata/cover_service.py | 41 +++++----- .../test_cover_service_perf_paths.py | 78 ++++++++++++++++++- 6 files changed, 106 insertions(+), 21 deletions(-) diff --git a/plugins/builtin/itunes_cover/lib/cover_source.py b/plugins/builtin/itunes_cover/lib/cover_source.py index 42f8d56b..d4c476d7 100644 --- a/plugins/builtin/itunes_cover/lib/cover_source.py +++ b/plugins/builtin/itunes_cover/lib/cover_source.py @@ -34,6 +34,7 @@ def search( "entity": "album", "limit": 5, } + logger.debug(f"ITunes cover search: {artist} {album or title}") response = self._http_client.get(search_url, params=params, timeout=3) if response.status_code == 200: diff --git a/plugins/builtin/kugou/lib/lyrics_source.py b/plugins/builtin/kugou/lib/lyrics_source.py index a5e19466..2d9a2f12 100644 --- a/plugins/builtin/kugou/lib/lyrics_source.py +++ b/plugins/builtin/kugou/lib/lyrics_source.py @@ -19,6 +19,7 @@ def __init__(self, http_client) -> None: def search(self, title: str, artist: str, limit: int = 10) -> list[PluginLyricsResult]: keyword = f"{title} {artist}".strip() + logger.debug(f"Kugou lyrics search: {keyword}") response = self._http_client.get( "https://lyrics.kugou.com/search", params={"keyword": keyword, "page": 1, "pagesize": limit}, diff --git a/plugins/builtin/last_fm_cover/lib/cover_source.py b/plugins/builtin/last_fm_cover/lib/cover_source.py index 0ca40ce9..05bbd6ab 100644 --- a/plugins/builtin/last_fm_cover/lib/cover_source.py +++ b/plugins/builtin/last_fm_cover/lib/cover_source.py @@ -35,6 +35,7 @@ def search( duration: float | None = None, ) -> list[PluginCoverResult]: results: list[PluginCoverResult] = [] + logger.debug(f"Last.fm cover search: {title} {artist} {album} {duration}") try: response = self._http_client.get( diff --git a/plugins/builtin/lrclib/lib/lrclib_source.py b/plugins/builtin/lrclib/lib/lrclib_source.py index d2a760b7..55cc0c89 100644 --- a/plugins/builtin/lrclib/lib/lrclib_source.py +++ b/plugins/builtin/lrclib/lib/lrclib_source.py @@ -1,7 +1,11 @@ from __future__ import annotations +import logging + from harmony_plugin_api.lyrics import PluginLyricsResult +logger = logging.getLogger(__name__) + class LRCLIBPluginSource: source_id = "lrclib" @@ -16,6 +20,7 @@ def search( artist: str, limit: int = 10, ) -> list[PluginLyricsResult]: + logger.debug(f"LRCLIB lyrics search: {title} by {artist}") response = self._http_client.get( "https://lrclib.net/api/search", params={"track_name": title, "artist_name": artist}, diff --git a/services/metadata/cover_service.py b/services/metadata/cover_service.py index 8f7646bd..f247e8eb 100644 --- a/services/metadata/cover_service.py +++ b/services/metadata/cover_service.py @@ -225,6 +225,19 @@ def _get_cached_cover(self, cache_key: str) -> Optional[Path]: return cover_path return None + def _to_search_result(self, result) -> SearchResult: + """Normalize host and plugin cover results to SearchResult.""" + return SearchResult( + title=result.title, + artist=result.artist, + album=result.album, + duration=result.duration, + source=result.source, + id=getattr(result, "id", None) or getattr(result, "item_id", ""), + cover_url=result.cover_url, + album_mid=getattr(result, "album_mid", None) or getattr(result, "extra_id", None), + ) + def fetch_online_cover(self, title: str, artist: str, album: str = "", duration: float = None) -> Optional[str]: """ Fetch cover art from online sources (public method). @@ -334,16 +347,10 @@ def _fetch_online_cover(self, title: str, artist: str, album: str, cache_key: st try: search_results = future.result() # Convert CoverSearchResult to SearchResult for compatibility - all_results.extend(SearchResult( - title=r.title, - artist=r.artist, - album=r.album, - duration=r.duration, - source=r.source, - id=r.id, - cover_url=r.cover_url, - album_mid=getattr(r, 'album_mid', None), - ) for r in search_results) + all_results.extend( + self._to_search_result(result) + for result in search_results + ) logger.debug(f"{source_name} found {len(search_results)} results") except Exception as e: logger.warning(f"Error searching cover from {source_name}: {e}") @@ -400,16 +407,10 @@ def search_covers(self, title: str, artist: str, album: str = "", duration: floa try: search_results = future.result() # Convert CoverSearchResult to SearchResult for compatibility - all_search_results.extend(SearchResult( - title=r.title, - artist=r.artist, - album=r.album, - duration=r.duration, - source=r.source, - id=r.id, - cover_url=r.cover_url, - album_mid=getattr(r, 'album_mid', None), - ) for r in search_results) + all_search_results.extend( + self._to_search_result(result) + for result in search_results + ) except Exception as e: logger.error(f"Error searching {source_name} covers: {e}", exc_info=True) diff --git a/tests/test_services/test_cover_service_perf_paths.py b/tests/test_services/test_cover_service_perf_paths.py index c1c90213..55211794 100644 --- a/tests/test_services/test_cover_service_perf_paths.py +++ b/tests/test_services/test_cover_service_perf_paths.py @@ -3,6 +3,7 @@ from types import SimpleNamespace import services.metadata.cover_service as cover_service_module +from harmony_plugin_api.cover import PluginCoverResult from harmony_plugin_api.cover import PluginArtistCoverResult from services.metadata.cover_service import CoverService from services.sources.base import CoverSearchResult @@ -44,6 +45,48 @@ def test_fetch_online_cover_uses_best_match_and_cache(monkeypatch): assert cover_path == "/tmp/cover.jpg" +def test_fetch_online_cover_supports_plugin_cover_result_shape(monkeypatch): + source = SimpleNamespace( + name="QQMusic", + search=lambda *_args, **_kwargs: [ + PluginCoverResult( + item_id="song-1", + title="Song 1", + artist="Singer 1", + album="Album 1", + source="qqmusic", + cover_url="https://example.com/cover.jpg", + extra_id="album-1", + ) + ], + is_available=lambda: True, + ) + service = CoverService( + http_client=SimpleNamespace(get_content=lambda *_args, **_kwargs: b"img"), + sources=[source], + ) + monkeypatch.setattr( + cover_service_module.MatchScorer, + "find_best_match", + staticmethod( + lambda *_args, **_kwargs: ( + SimpleNamespace( + title="Song 1", + artist="Singer 1", + source="qqmusic", + cover_url="https://example.com/cover.jpg", + ), + 80.0, + ) + ), + ) + monkeypatch.setattr(service, "_save_cover_to_cache", lambda *_args, **_kwargs: "/tmp/cover.jpg") + + cover_path = service._fetch_online_cover("Song 1", "Singer 1", "Album 1", "cache-key") + + assert cover_path == "/tmp/cover.jpg" + + def test_search_covers_converts_and_scores_results(monkeypatch): source = SimpleNamespace( name="FakeCoverSource", @@ -59,7 +102,39 @@ def test_search_covers_converts_and_scores_results(monkeypatch): ], is_available=lambda: True, ) - service = CoverService(http_client=SimpleNamespace(), sources=[source]) + service = CoverService(http_client=SimpleNamespace()) + monkeypatch.setattr(service, "_get_sources", lambda: [source]) + monkeypatch.setattr( + cover_service_module.MatchScorer, + "calculate_score", + staticmethod(lambda *_args, **_kwargs: 88.0), + ) + + results = service.search_covers("Song 1", "Singer 1", "Album 1") + + assert len(results) == 1 + assert results[0]["id"] == "song-1" + assert results[0]["score"] == 88.0 + + +def test_search_covers_supports_plugin_cover_result_shape(monkeypatch): + source = SimpleNamespace( + name="QQMusic", + search=lambda *_args, **_kwargs: [ + PluginCoverResult( + item_id="song-1", + title="Song 1", + artist="Singer 1", + album="Album 1", + source="qqmusic", + cover_url="https://example.com/cover.jpg", + extra_id="album-1", + ) + ], + is_available=lambda: True, + ) + service = CoverService(http_client=SimpleNamespace()) + monkeypatch.setattr(service, "_get_sources", lambda: [source]) monkeypatch.setattr( cover_service_module.MatchScorer, "calculate_score", @@ -70,6 +145,7 @@ def test_search_covers_converts_and_scores_results(monkeypatch): assert len(results) == 1 assert results[0]["id"] == "song-1" + assert results[0]["album_mid"] == "album-1" assert results[0]["score"] == 88.0 From 84d33b72d05034bd7ea509b5f405ebc2e5f08bf2 Mon Sep 17 00:00:00 2001 From: Har01d Date: Tue, 7 Apr 2026 18:24:49 +0800 Subject: [PATCH 067/157] =?UTF-8?q?=E6=96=B0=E5=A2=9E=E7=BD=91=E6=98=93?= =?UTF-8?q?=E6=8F=92=E4=BB=B6=E6=8B=86=E5=88=86=E8=AE=BE=E8=AE=A1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../2026-04-07-netease-plugin-split-design.md | 266 ++++++++++++++++++ 1 file changed, 266 insertions(+) create mode 100644 docs/superpowers/specs/2026-04-07-netease-plugin-split-design.md diff --git a/docs/superpowers/specs/2026-04-07-netease-plugin-split-design.md b/docs/superpowers/specs/2026-04-07-netease-plugin-split-design.md new file mode 100644 index 00000000..85d79f83 --- /dev/null +++ b/docs/superpowers/specs/2026-04-07-netease-plugin-split-design.md @@ -0,0 +1,266 @@ +# NetEase Plugin Split Design + +## Overview + +This change moves the host-owned NetEase integrations out of the built-in source lists and into two built-in plugins: + +- a lyrics plugin for NetEase lyrics search and download +- a cover plugin for NetEase album cover and artist cover search + +The goal is to make NetEase follow the same ownership boundary already used by the existing built-in lyrics and cover plugins. After the migration, the host still exposes the same end-user behavior, but the plugin runtime owns registration and lifecycle. + +## Goals + +- Move `NetEaseLyricsSource` into a built-in lyrics plugin. +- Move `NetEaseCoverSource` and `NetEaseArtistCoverSource` into a built-in cover plugin. +- Remove all host-owned NetEase source registration from `LyricsService` and `CoverService`. +- Preserve current NetEase request behavior, result mapping, and source identifiers. +- Keep NetEase enabled by default through normal built-in plugin loading. + +## Non-Goals + +- No NetEase settings tab. +- No NetEase sidebar page or online music provider. +- No change to NetEase API endpoints, request parameters, or result matching rules. +- No unrelated refactor of non-NetEase host sources or plugin registry behavior. + +## Current State + +NetEase is still implemented as host-owned sources: + +- [`services/sources/lyrics_sources.py`](/home/harold/workspace/music-player/services/sources/lyrics_sources.py) defines `NetEaseLyricsSource` +- [`services/sources/cover_sources.py`](/home/harold/workspace/music-player/services/sources/cover_sources.py) defines `NetEaseCoverSource` +- [`services/sources/artist_cover_sources.py`](/home/harold/workspace/music-player/services/sources/artist_cover_sources.py) defines `NetEaseArtistCoverSource` + +The host currently wires those sources directly: + +- [`services/lyrics/lyrics_service.py`](/home/harold/workspace/music-player/services/lyrics/lyrics_service.py) constructs `NetEaseLyricsSource` in `_get_builtin_sources()` +- [`services/metadata/cover_service.py`](/home/harold/workspace/music-player/services/metadata/cover_service.py) constructs `NetEaseCoverSource` and `NetEaseArtistCoverSource` in the built-in source helpers + +This means NetEase does not participate in plugin enable/disable lifecycle even though the application already supports plugin-provided lyrics, cover, and artist-cover sources. + +## Recommended Approach + +Create two built-in plugins and migrate NetEase ownership into them: + +- `plugins/builtin/netease_lyrics/` +- `plugins/builtin/netease_cover/` + +Use a small shared NetEase helper module for request headers, search requests, and field normalization so the lyrics and cover plugins do not duplicate low-level API logic. + +The plugin manifest ids should be distinct from the runtime result source identifier: + +- plugin ids: `netease_lyrics`, `netease_cover` +- runtime result source: `netease` + +This split is intentional: + +- manifest ids control plugin discovery, enable state, and plugin management UI +- runtime `source = "netease"` preserves compatibility with existing matching, download, and UI flows + +## Architecture + +### Plugin Layout + +Add built-in plugin directories: + +```text +plugins/builtin/netease_lyrics/ +├── __init__.py +├── plugin.json +├── plugin_main.py +└── lib/ + ├── __init__.py + └── lyrics_source.py + +plugins/builtin/netease_cover/ +├── __init__.py +├── plugin.json +├── plugin_main.py +└── lib/ + ├── __init__.py + ├── artist_cover_source.py + └── cover_source.py +``` + +Add a shared helper package at `plugins/builtin/netease_shared/` for NetEase-specific request and parsing code. This package is not a plugin and should not include `plugin.json`. + +The helper must stay narrow: + +- request headers +- shared search request helper +- shared field extraction and image URL normalization + +It must not own plugin registration or host integration. + +### Host Boundary + +After migration: + +- `LyricsService` no longer owns any built-in NetEase lyrics source +- `CoverService` no longer owns any built-in NetEase album cover or artist cover source +- NetEase behavior enters the app only through plugin registration +- the host still owns orchestration, result merging, matching, caching, and file download + +### Plugin Registration + +`plugins/builtin/netease_lyrics/plugin_main.py` should expose a plugin class with: + +- `plugin_id = "netease_lyrics"` +- `register(context)` calling `context.services.register_lyrics_source(...)` +- `unregister(context)` as a no-op + +The manifest should declare: + +- `"id": "netease_lyrics"` +- `"capabilities": ["lyrics_source"]` + +`plugins/builtin/netease_cover/plugin_main.py` should expose a plugin class with: + +- `plugin_id = "netease_cover"` +- `register(context)` calling `context.services.register_cover_source(...)` +- `register(context)` also calling `context.services.register_artist_cover_source(...)` +- `unregister(context)` as a no-op + +The manifest should declare: + +- `"id": "netease_cover"` +- `"capabilities": ["cover", "artist_cover"]` + +Because built-in plugins default to enabled unless persisted otherwise, NetEase remains active after the migration without extra host logic. + +## Runtime Behavior + +### Source Identity + +The migrated plugin sources must preserve current user-visible and runtime identifiers: + +- lyrics source display name remains `NetEase` +- cover source display name remains `NetEase` +- artist cover source display name remains `NetEase` +- returned search results keep `source = "netease"` + +This preserves compatibility with: + +- lyrics download routing in [`services/lyrics/lyrics_service.py`](/home/harold/workspace/music-player/services/lyrics/lyrics_service.py) +- source priority and matching logic in `utils/match_scorer.py` +- existing UI source labels and result handling + +### Lyrics Flow + +The lyrics plugin should preserve the current NetEase flow: + +- search endpoint: `https://music.163.com/api/search/get/web` +- search params: `s`, `type=1`, `limit` +- lyrics endpoint: `https://music.163.com/api/song/lyric` +- first request path prefers YRC when present +- fallback request path returns LRC when YRC is absent + +Search results should continue to map: + +- `id` from song id +- `title`, `artist`, `album` +- `duration` converted from milliseconds to seconds +- `cover_url` from album picture fields +- `supports_yrc = True` + +On request or decoding failures, the plugin should return empty results or `None` instead of raising to the host. + +### Cover Flow + +The cover plugin should preserve the current NetEase album cover flow: + +- use `https://music.163.com/api/search/get/web` +- first perform album search with `type=10` +- then perform song search with `type=1` +- normalize album artwork URLs to request high-resolution images when possible + +Album cover search results should continue to map: + +- `title` +- `artist` +- `album` +- `duration` when available from song results +- `source = "netease"` +- `id` +- `cover_url` + +The host `CoverService` continues to own candidate ranking, downloading, and cache persistence. + +### Artist Cover Flow + +The cover plugin should also preserve the current NetEase artist cover flow: + +- use `https://music.163.com/api/search/get/web` +- search with `type=100` +- keep `source = "netease"` +- normalize artist image URLs to request high-resolution images when possible + +Artist cover results should continue to map: + +- `id` +- `name` +- `cover_url` +- `album_count` +- `source = "netease"` + +## File Changes + +### Create + +- `plugins/builtin/netease_lyrics/__init__.py` +- `plugins/builtin/netease_lyrics/plugin.json` +- `plugins/builtin/netease_lyrics/plugin_main.py` +- `plugins/builtin/netease_lyrics/lib/__init__.py` +- `plugins/builtin/netease_lyrics/lib/lyrics_source.py` +- `plugins/builtin/netease_cover/__init__.py` +- `plugins/builtin/netease_cover/plugin.json` +- `plugins/builtin/netease_cover/plugin_main.py` +- `plugins/builtin/netease_cover/lib/__init__.py` +- `plugins/builtin/netease_cover/lib/cover_source.py` +- `plugins/builtin/netease_cover/lib/artist_cover_source.py` +- `plugins/builtin/netease_shared/__init__.py` +- `plugins/builtin/netease_shared/common.py` +- `tests/test_plugins/test_netease_lyrics_plugin.py` +- `tests/test_plugins/test_netease_cover_plugin.py` + +### Modify + +- `services/lyrics/lyrics_service.py` +- `services/metadata/cover_service.py` +- `services/sources/lyrics_sources.py` +- `services/sources/cover_sources.py` +- `services/sources/artist_cover_sources.py` +- `services/sources/__init__.py` +- `tests/test_services/test_plugin_lyrics_registry.py` +- `tests/test_services/test_plugin_cover_registry.py` + +## Testing + +Add or update tests to cover: + +- the NetEase lyrics plugin registers one lyrics source through the plugin context +- the NetEase cover plugin registers one cover source and one artist-cover source +- NetEase lyrics search preserves current result mapping, including `supports_yrc` +- NetEase lyrics download preserves YRC-first and LRC-fallback behavior +- NetEase cover search preserves album-search and song-search result mapping +- NetEase artist cover search preserves current result mapping +- `LyricsService._get_builtin_sources()` no longer includes `NetEase` +- `CoverService._get_builtin_sources()` and `_get_builtin_artist_sources()` no longer include `NetEase` + +Regression commands should focus on the changed area: + +- `uv run pytest tests/test_plugins/test_netease_lyrics_plugin.py` +- `uv run pytest tests/test_plugins/test_netease_cover_plugin.py` +- `uv run pytest tests/test_services/test_plugin_lyrics_registry.py tests/test_services/test_plugin_cover_registry.py` + +## Risks and Mitigations + +- Duplicate-results risk: if host-owned NetEase source registration is not fully removed, the UI will show repeated NetEase entries. Remove all NetEase source construction from host built-in source lists. +- Compatibility risk from source identifiers: keep plugin manifest ids separate from runtime `source = "netease"`. +- Shared-helper coupling risk: keep the shared NetEase helper limited to pure request and parsing utilities so the two plugins do not depend on each other's plugin classes or context. +- Scope creep risk into online music features: explicitly keep this migration limited to lyrics, album cover, and artist cover ownership. + +## Scope Check + +This design is intentionally narrow. It changes only NetEase ownership boundaries and the associated tests. It does not add new UI, new plugin settings, or new NetEase online music features. From b6df0aa3e255acb2778e9071e7e855532414e9de Mon Sep 17 00:00:00 2001 From: Har01d Date: Tue, 7 Apr 2026 18:26:06 +0800 Subject: [PATCH 068/157] =?UTF-8?q?=E7=BB=9F=E4=B8=80=E4=B8=BB=E9=A2=98?= =?UTF-8?q?=E5=9F=BA=E7=A1=80=E6=A0=B7=E5=BC=8F=E6=8E=A5=E5=8F=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- system/plugins/plugin_sdk_ui.py | 18 +++ system/theme.py | 46 ++++++++ tests/test_system/test_plugin_ui_bridge.py | 39 +++++++ .../test_theme_foundation_styles.py | 50 ++++++++ ui/styles.qss | 108 +++++++++++++++++- 5 files changed, 256 insertions(+), 5 deletions(-) create mode 100644 tests/test_system/test_theme_foundation_styles.py diff --git a/system/plugins/plugin_sdk_ui.py b/system/plugins/plugin_sdk_ui.py index e1cf1ddd..169599cd 100644 --- a/system/plugins/plugin_sdk_ui.py +++ b/system/plugins/plugin_sdk_ui.py @@ -17,6 +17,16 @@ def current_theme(self): return ThemeManager.instance().current_theme + def get_popup_surface_style(self) -> str: + from system.theme import ThemeManager + + return ThemeManager.instance().get_themed_popup_surface_style() + + def get_completer_popup_style(self) -> str: + from system.theme import ThemeManager + + return ThemeManager.instance().get_themed_completer_popup_style() + class PluginDialogBridgeImpl: def information(self, parent, title: str, message: str, buttons=None, default_button=None): @@ -63,6 +73,14 @@ def current_theme(): return PluginThemeBridgeImpl().current_theme() +def get_popup_surface_style() -> str: + return PluginThemeBridgeImpl().get_popup_surface_style() + + +def get_completer_popup_style() -> str: + return PluginThemeBridgeImpl().get_completer_popup_style() + + def information(parent, title: str, message: str, buttons=None, default_button=None): return PluginDialogBridgeImpl().information(parent, title, message, buttons, default_button) diff --git a/system/theme.py b/system/theme.py index 236054b3..aa9a92e6 100644 --- a/system/theme.py +++ b/system/theme.py @@ -370,6 +370,52 @@ def get_combobox_style() -> str: } """ + @staticmethod + def get_completer_popup_style() -> str: + """Get themed QListView popup style for completers.""" + return """ + QListView { + background-color: %background_alt%; + border: 1px solid %border%; + border-radius: 8px; + color: %text%; + selection-background-color: %highlight%; + selection-color: %background%; + outline: none; + } + QListView::item { + padding: 8px 12px; + border-bottom: 1px solid %border%; + } + QListView::item:selected { + background-color: %highlight%; + color: %background%; + } + QListView::item:hover { + background-color: %border%; + } + """ + + @staticmethod + def get_popup_surface_style() -> str: + """Get themed popup surface style for custom popup widgets.""" + return """ + QWidget[popupSurface="true"] { + background-color: %background_alt%; + border: 1px solid %border%; + border-radius: 10px; + color: %text%; + } + """ + + def get_themed_completer_popup_style(self) -> str: + """Return popup completer style with current theme tokens resolved.""" + return self.get_qss(self.get_completer_popup_style()) + + def get_themed_popup_surface_style(self) -> str: + """Return popup surface style with current theme tokens resolved.""" + return self.get_qss(self.get_popup_surface_style()) + def apply_global_stylesheet(self): """Load and apply themed global stylesheet to QApplication.""" app = QApplication.instance() diff --git a/tests/test_system/test_plugin_ui_bridge.py b/tests/test_system/test_plugin_ui_bridge.py index c910adb5..b7d8b7ae 100644 --- a/tests/test_system/test_plugin_ui_bridge.py +++ b/tests/test_system/test_plugin_ui_bridge.py @@ -45,6 +45,8 @@ def test_plugin_context_ui_bridge_exposes_theme_and_dialog_helpers(tmp_path: Pat assert callable(context.ui.theme.get_qss) assert callable(context.ui.theme.register_widget) assert context.ui.theme.current_theme().text + assert callable(context.ui.theme.get_popup_surface_style) + assert callable(context.ui.theme.get_completer_popup_style) assert callable(context.ui.dialogs.information) assert callable(context.ui.dialogs.warning) assert callable(context.ui.dialogs.question) @@ -52,6 +54,43 @@ def test_plugin_context_ui_bridge_exposes_theme_and_dialog_helpers(tmp_path: Pat assert callable(context.ui.dialogs.setup_title_bar) +def test_plugin_context_ui_bridge_exposes_foundation_theme_helpers(tmp_path: Path): + config = Mock() + config.get.return_value = "dark" + config.get_language.return_value = "zh" + + ThemeManager._instance = None + ThemeManager.instance(config) + + registry = Mock() + bootstrap = SimpleNamespace( + _plugin_manager=SimpleNamespace(registry=registry), + online_download_service=Mock(), + playback_service=Mock(), + library_service=Mock(), + http_client=Mock(), + event_bus=Mock(), + config=config, + ) + manifest = PluginManifest.from_dict( + { + "id": "qqmusic", + "name": "QQ Music", + "version": "1.0.0", + "api_version": "1", + "entrypoint": "plugin_main.py", + "entry_class": "QQMusicPlugin", + "capabilities": ["sidebar"], + "min_app_version": "0.1.0", + } + ) + + context = BootstrapPluginContextFactory(bootstrap, tmp_path).build(manifest) + + assert callable(context.ui.theme.get_popup_surface_style) + assert callable(context.ui.theme.get_completer_popup_style) + + def test_plugin_context_ui_bridge_uses_host_bridge_modules(tmp_path: Path): config = Mock() config.get.return_value = "dark" diff --git a/tests/test_system/test_theme_foundation_styles.py b/tests/test_system/test_theme_foundation_styles.py new file mode 100644 index 00000000..951d775a --- /dev/null +++ b/tests/test_system/test_theme_foundation_styles.py @@ -0,0 +1,50 @@ +from unittest.mock import Mock + +from system.theme import ThemeManager + + +def _build_theme_manager(): + config = Mock() + config.get.return_value = "dark" + ThemeManager._instance = None + return ThemeManager.instance(config) + + +def test_theme_manager_exposes_foundation_popup_helpers(): + tm = _build_theme_manager() + + completer_qss = tm.get_themed_completer_popup_style() + popup_qss = tm.get_themed_popup_surface_style() + + assert "#121212" in completer_qss or "#282828" in completer_qss + assert "QListView" in completer_qss + assert "popupSurface" in popup_qss + assert tm.current_theme.highlight in completer_qss + + +def test_theme_manager_global_stylesheet_covers_foundation_selectors(qapp): + tm = _build_theme_manager() + + tm.apply_global_stylesheet() + stylesheet = qapp.styleSheet() + + assert "QLineEdit" in stylesheet + assert "QCheckBox::indicator" in stylesheet + assert "QGroupBox" in stylesheet + assert "QComboBox" in stylesheet + assert "QDialog[shell=\"true\"]" in stylesheet + assert "QWidget#dialogTitleBar" in stylesheet + + +def test_theme_manager_global_stylesheet_includes_wrapper_variants(qapp): + tm = _build_theme_manager() + + tm.apply_global_stylesheet() + stylesheet = qapp.styleSheet() + + assert "QPushButton[role=\"primary\"]" in stylesheet + assert "QLineEdit[variant=\"search\"]" in stylesheet + assert "QComboBox[compact=\"true\"]" in stylesheet + assert "QWidget#titleBar" in stylesheet + assert "QPushButton#winBtn" in stylesheet + assert "QPushButton#dialogCloseBtn" in stylesheet diff --git a/ui/styles.qss b/ui/styles.qss index c40e1b8b..27232f95 100644 --- a/ui/styles.qss +++ b/ui/styles.qss @@ -14,6 +14,13 @@ QMainWindow { color: %text%; } +QDialog[shell="true"] { + background-color: %background_alt%; + color: %text%; + border: 1px solid %border%; + border-radius: 12px; +} + /* Scrollbar */ QScrollBar:vertical { background: transparent; @@ -100,19 +107,58 @@ QPushButton[primary="true"]:hover { background-color: %highlight_hover%; } +QPushButton[role="primary"] { + background-color: %highlight%; + color: %text%; + font-weight: bold; + padding: 12px 24px; + border-radius: 25px; +} + +QPushButton[role="primary"]:hover { + background-color: %highlight_hover%; +} + +QWidget#titleBar, +QWidget#dialogTitleBar { + color: %text%; +} + +QPushButton#winBtn, +QPushButton#closeBtn, +QPushButton#dialogCloseBtn { + background: transparent; + border: none; + color: %text_secondary%; + min-width: 28px; + min-height: 28px; + border-radius: 6px; + padding: 0; +} + +QPushButton#winBtn:hover, +QPushButton#dialogCloseBtn:hover { + background-color: %selection%; + color: %text%; +} + /* Input Fields */ QLineEdit, QTextEdit { - background-color: %text%; - border: none; - border-radius: 4px; - padding: 10px; - color: %background%; + background-color: %background_hover%; + border: 1px solid %border%; + border-radius: 8px; + padding: 8px 12px; + color: %text%; } QLineEdit:focus, QTextEdit:focus { border: 2px solid %highlight%; } +QLineEdit[variant="search"] { + padding-right: 30px; +} + /* Labels */ QLabel { color: %text%; @@ -123,6 +169,58 @@ QLabel[secondary="true"] { color: %text_secondary%; } +QCheckBox, +QRadioButton, +QGroupBox, +QComboBox, +QSpinBox, +QProgressBar { + color: %text%; +} + +QCheckBox::indicator, +QRadioButton::indicator { + width: 18px; + height: 18px; + border: 2px solid %border%; + border-radius: 9px; + background-color: %background_alt%; +} + +QCheckBox::indicator:checked, +QRadioButton::indicator:checked { + border-color: %highlight%; + background-color: %highlight%; +} + +QGroupBox { + border: 1px solid %border%; + border-radius: 10px; + margin-top: 12px; + padding-top: 12px; +} + +QGroupBox::title { + subcontrol-origin: margin; + left: 12px; + padding: 0 6px; + color: %text%; +} + +QComboBox, +QSpinBox { + background-color: %background_hover%; + border: 1px solid %border%; + border-radius: 8px; + padding: 4px 12px; + min-height: 32px; +} + +QComboBox[compact="true"] { + min-height: 28px; + padding: 2px 10px; +} + /* Lists */ QListWidget { background-color: transparent; From cbda74280a9b9db661333664c882187693066144 Mon Sep 17 00:00:00 2001 From: Har01d Date: Tue, 7 Apr 2026 18:28:04 +0800 Subject: [PATCH 069/157] =?UTF-8?q?=E6=94=B6=E6=95=9B=E5=AE=BF=E4=B8=BB?= =?UTF-8?q?=E5=85=B1=E4=BA=AB=E7=BB=84=E4=BB=B6=E4=B8=BB=E9=A2=98=E6=A0=B7?= =?UTF-8?q?=E5=BC=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tests/test_title_bar.py | 1 + tests/test_ui/test_dialog_title_bar.py | 60 ++++++++++++++++++++++++++ ui/dialogs/dialog_title_bar.py | 41 ++++-------------- ui/styles.qss | 28 ++++++++++++ ui/widgets/context_menus.py | 22 ---------- ui/widgets/title_bar.py | 53 +++-------------------- 6 files changed, 103 insertions(+), 102 deletions(-) create mode 100644 tests/test_ui/test_dialog_title_bar.py diff --git a/tests/test_title_bar.py b/tests/test_title_bar.py index abf2e585..36d94e58 100644 --- a/tests/test_title_bar.py +++ b/tests/test_title_bar.py @@ -176,6 +176,7 @@ def test_refresh_theme(qtbot, patch_theme): # Should not raise bar.refresh_theme() + assert bar.styleSheet() == "" def test_drag_to_move(qtbot, patch_theme): diff --git a/tests/test_ui/test_dialog_title_bar.py b/tests/test_ui/test_dialog_title_bar.py new file mode 100644 index 00000000..460f5765 --- /dev/null +++ b/tests/test_ui/test_dialog_title_bar.py @@ -0,0 +1,60 @@ +from unittest.mock import Mock + +from PySide6.QtWidgets import QDialog, QMainWindow, QVBoxLayout + +from domain.track import TrackSource +from system.theme import ThemeManager +from ui.dialogs.dialog_title_bar import setup_equalizer_title_layout +from ui.widgets.context_menus import LocalTrackContextMenu + + +def _init_theme(): + config = Mock() + config.get.return_value = "dark" + ThemeManager._instance = None + return ThemeManager.instance(config) + + +def test_dialog_title_bar_uses_global_theme_selectors(qtbot): + _init_theme() + dialog = QDialog() + qtbot.addWidget(dialog) + container = QVBoxLayout(dialog) + + _, controller = setup_equalizer_title_layout(dialog, container, "Title") + + assert controller.title_bar.objectName() == "dialogTitleBar" + assert controller.title_label.objectName() == "dialogTitle" + assert controller.close_btn.objectName() == "dialogCloseBtn" + assert controller.title_bar.styleSheet() == "" + assert controller.title_label.styleSheet() == "" + assert controller.close_btn.styleSheet() == "" + + +def test_title_bar_relies_on_object_names_instead_of_local_qss(qtbot): + _init_theme() + from ui.widgets.title_bar import TitleBar + + window = QMainWindow() + qtbot.addWidget(window) + bar = TitleBar(window) + + assert bar.objectName() == "titleBar" + assert bar._btn_min.objectName() == "winBtn" + assert bar._btn_close.objectName() == "closeBtn" + assert bar.styleSheet() == "" + + +def test_local_track_context_menu_uses_global_qmenu_styling(qtbot): + _init_theme() + menu_builder = LocalTrackContextMenu() + track = Mock() + track.id = 1 + track.path = "/tmp/song.mp3" + track.source = TrackSource.LOCAL + + menu = menu_builder.build_menu([track], set(), None) + qtbot.addWidget(menu) + + assert menu is not None + assert menu.styleSheet() == "" diff --git a/ui/dialogs/dialog_title_bar.py b/ui/dialogs/dialog_title_bar.py index 473a8354..876864a1 100644 --- a/ui/dialogs/dialog_title_bar.py +++ b/ui/dialogs/dialog_title_bar.py @@ -21,39 +21,13 @@ class DialogTitleBarController: close_btn: QPushButton def refresh_theme(self): - """Apply theme to title bar widgets.""" - tm = ThemeManager.instance() - self.title_bar.setStyleSheet( - tm.get_qss( - """ - QWidget#dialogTitleBar { - background-color: %background_alt%; - border-top-left-radius: 12px; - border-top-right-radius: 12px; - border-bottom: 1px solid %border%; - } - """ - ) - ) - self.title_label.setStyleSheet( - tm.get_qss("color: %text%; font-size: 14px; font-weight: bold;") - ) - self.close_btn.setStyleSheet( - tm.get_qss( - """ - QPushButton#dialogCloseBtn { - background: transparent; - border: none; - color: %text_secondary%; - border-radius: 4px; - } - QPushButton#dialogCloseBtn:hover { - background-color: %selection%; - color: %text%; - } - """ - ) - ) + """Refresh icons and re-polish global theme selectors.""" + self.close_btn.setIcon(get_icon(IconName.TIMES, None, 14)) + for widget in (self.title_bar, self.title_label, self.close_btn): + style = widget.style() + if style is not None: + style.unpolish(widget) + style.polish(widget) def setup_equalizer_title_layout( @@ -98,6 +72,7 @@ def setup_equalizer_title_layout( controller = DialogTitleBarController(dialog, title_bar, title_label, close_btn) controller.refresh_theme() + ThemeManager.instance().register_widget(title_bar) _bind_title_bar_drag(dialog, title_bar) diff --git a/ui/styles.qss b/ui/styles.qss index 27232f95..4c3254cf 100644 --- a/ui/styles.qss +++ b/ui/styles.qss @@ -124,6 +124,17 @@ QWidget#dialogTitleBar { color: %text%; } +QWidget#titleBar { + background-color: %background%; +} + +QWidget#dialogTitleBar { + background-color: %background_alt%; + border-top-left-radius: 12px; + border-top-right-radius: 12px; + border-bottom: 1px solid %border%; +} + QPushButton#winBtn, QPushButton#closeBtn, QPushButton#dialogCloseBtn { @@ -142,6 +153,11 @@ QPushButton#dialogCloseBtn:hover { color: %text%; } +QPushButton#closeBtn:hover { + background-color: #e81123; + color: white; +} + /* Input Fields */ QLineEdit, QTextEdit { background-color: %background_hover%; @@ -169,6 +185,18 @@ QLabel[secondary="true"] { color: %text_secondary%; } +QLabel#titleLabel, +QLabel#dialogTitle { + color: %text%; + font-size: 14px; + font-weight: bold; +} + +QLabel#trackLabel { + color: %text_secondary%; + font-size: 13px; +} + QCheckBox, QRadioButton, QGroupBox, diff --git a/ui/widgets/context_menus.py b/ui/widgets/context_menus.py index e696242d..a45e3265 100644 --- a/ui/widgets/context_menus.py +++ b/ui/widgets/context_menus.py @@ -11,25 +11,6 @@ from system.i18n import t -_CONTEXT_MENU_STYLE = """ - QMenu { - background-color: %background_alt%; - color: %text%; - border: 1px solid %border%; - } - QMenu::item { - padding: 8px 20px; - } - QMenu::item:selected { - background-color: %highlight%; - color: %background%; - } - QMenu::item:disabled { - color: %text_secondary%; - } -""" - - class LocalTrackContextMenu(QObject): """Context menu for local tracks. Emits signals for each action.""" @@ -47,13 +28,10 @@ class LocalTrackContextMenu(QObject): def build_menu(self, tracks: list, favorite_ids: set, parent_widget=None): """Build and return the context menu (without showing).""" - from system.theme import ThemeManager - if not tracks: return None menu = QMenu(parent_widget) - menu.setStyleSheet(ThemeManager.instance().get_qss(_CONTEXT_MENU_STYLE)) all_favorited = all( getattr(track, 'id', None) and track.id in favorite_ids diff --git a/ui/widgets/title_bar.py b/ui/widgets/title_bar.py index 06da1058..4e8f1631 100644 --- a/ui/widgets/title_bar.py +++ b/ui/widgets/title_bar.py @@ -29,44 +29,6 @@ class TitleBar(QWidget): """Custom Spotify-style title bar widget.""" - _STYLE_TEMPLATE = """ - QWidget#titleBar { - background-color: %background%; - } - QPushButton#winBtn { - border: none; - color: %text%; - background: transparent; - width: 36px; - height: 28px; - border-radius: 6px; - } - QPushButton#winBtn:hover { - background-color: %background_hover%; - } - QPushButton#closeBtn { - border: none; - color: %text%; - background: transparent; - width: 36px; - height: 28px; - border-radius: 6px; - } - QPushButton#closeBtn:hover { - background-color: #e81123; - color: white; - } - QLabel#titleLabel { - color: %text%; - font-size: 14px; - font-weight: bold; - } - QLabel#trackLabel { - color: %text_secondary%; - font-size: 13px; - } - """ - def __init__(self, parent=None): super().__init__(parent) self.setObjectName("titleBar") @@ -77,7 +39,6 @@ def __init__(self, parent=None): self._drag_pos = None self._setup_ui() - self._apply_style() # Register for theme changes ThemeManager.instance().register_widget(self) @@ -152,18 +113,16 @@ def _toggle_maximize(self): else: win.showMaximized() - def _apply_style(self): - """Apply themed stylesheet.""" - theme = ThemeManager.instance() - style = theme.get_qss(self._STYLE_TEMPLATE) - self.setStyleSheet(style) - def refresh_theme(self): - """Called by ThemeManager on theme change.""" - self._apply_style() + """Refresh icons and re-polish global theme selectors.""" self._btn_min.setIcon(get_icon(IconName.MINIMIZE, None, 14)) self._btn_max.setIcon(get_icon(IconName.MAXIMIZE, None, 14)) self._btn_close.setIcon(get_icon(IconName.TIMES, None, 14)) + for widget in (self, self._title_label, self._btn_min, self._btn_max, self._btn_close): + style = widget.style() + if style is not None: + style.unpolish(widget) + style.polish(widget) self.update() # === Track title display === From 4d95f832a21a6d7383ea770cb8e1463d477cdc39 Mon Sep 17 00:00:00 2001 From: Har01d Date: Tue, 7 Apr 2026 18:48:00 +0800 Subject: [PATCH 070/157] =?UTF-8?q?=E7=BB=9F=E4=B8=80=E5=AE=BF=E4=B8=BB?= =?UTF-8?q?=E4=B8=8E=E6=8F=92=E4=BB=B6=E5=9F=BA=E7=A1=80=E6=8E=A7=E4=BB=B6?= =?UTF-8?q?=E4=B8=BB=E9=A2=98=E5=85=A5=E5=8F=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- plugins/builtin/qqmusic/lib/context_menus.py | 21 ---- .../builtin/qqmusic/lib/cover_hover_popup.py | 4 +- .../builtin/qqmusic/lib/dialog_title_bar.py | 42 ++----- plugins/builtin/qqmusic/lib/login_dialog.py | 1 + .../builtin/qqmusic/lib/online_music_view.py | 42 ++----- plugins/builtin/qqmusic/lib/runtime_bridge.py | 8 ++ .../test_qqmusic_theme_integration.py | 48 ++++++++ .../test_ui/test_foundation_theme_cleanup.py | 32 +++++ ui/dialogs/base_cover_download_dialog.py | 35 +----- ui/dialogs/base_rename_dialog.py | 51 +------- ui/dialogs/edit_media_info_dialog.py | 58 +-------- ui/dialogs/input_dialog.py | 59 +-------- ui/dialogs/lyrics_download_dialog.py | 51 +------- ui/dialogs/settings_dialog.py | 58 +-------- ui/styles.qss | 112 ++++++++++++++++++ ui/views/albums_view.py | 90 +------------- ui/views/artists_view.py | 93 +-------------- ui/views/cloud/cloud_drive_view.py | 40 +------ ui/views/genres_view.py | 91 +------------- ui/views/library_view.py | 34 +----- 20 files changed, 256 insertions(+), 714 deletions(-) create mode 100644 tests/test_plugins/test_qqmusic_theme_integration.py create mode 100644 tests/test_ui/test_foundation_theme_cleanup.py diff --git a/plugins/builtin/qqmusic/lib/context_menus.py b/plugins/builtin/qqmusic/lib/context_menus.py index 955f93e9..198f681c 100644 --- a/plugins/builtin/qqmusic/lib/context_menus.py +++ b/plugins/builtin/qqmusic/lib/context_menus.py @@ -7,26 +7,6 @@ from PySide6.QtWidgets import QMenu from .i18n import t -from .runtime_bridge import get_qss - - -_CONTEXT_MENU_STYLE = """ - QMenu { - background-color: %background_alt%; - color: %text%; - border: 1px solid %border%; - } - QMenu::item { - padding: 8px 20px; - } - QMenu::item:selected { - background-color: %highlight%; - color: %background%; - } - QMenu::item:disabled { - color: %text_secondary%; - } -""" class OnlineTrackContextMenu(QObject): @@ -45,7 +25,6 @@ def show_menu(self, tracks: list, favorite_mids: set | None = None, parent_widge return menu = QMenu(parent_widget) - menu.setStyleSheet(get_qss(_CONTEXT_MENU_STYLE)) action = menu.addAction(t("play")) action.triggered.connect(lambda: self.play.emit(tracks)) diff --git a/plugins/builtin/qqmusic/lib/cover_hover_popup.py b/plugins/builtin/qqmusic/lib/cover_hover_popup.py index c1aa265a..74d6837c 100644 --- a/plugins/builtin/qqmusic/lib/cover_hover_popup.py +++ b/plugins/builtin/qqmusic/lib/cover_hover_popup.py @@ -4,7 +4,7 @@ from PySide6.QtGui import QColor, QPixmap, QPainter from PySide6.QtWidgets import QWidget, QVBoxLayout, QLabel, QApplication -from .runtime_bridge import current_theme +from .runtime_bridge import current_theme, register_themed_widget class CoverHoverPopup(QWidget): @@ -15,6 +15,7 @@ def __init__(self, parent=None, size: int = 300): self.setWindowFlags(Qt.Tool | Qt.FramelessWindowHint | Qt.WindowStaysOnTopHint) self.setAttribute(Qt.WA_TranslucentBackground) self.setAttribute(Qt.WA_ShowWithoutActivating) + self.setProperty("popupSurface", True) self._size = size @@ -31,6 +32,7 @@ def __init__(self, parent=None, size: int = 300): self._hide_timer = QTimer(self) self._hide_timer.setSingleShot(True) self._hide_timer.timeout.connect(self.hide) + register_themed_widget(self) def show_cover(self, cover_path: str | None, track_id: str, pos: QPoint): """Show cover at specified position.""" diff --git a/plugins/builtin/qqmusic/lib/dialog_title_bar.py b/plugins/builtin/qqmusic/lib/dialog_title_bar.py index e95a032b..157ef81a 100644 --- a/plugins/builtin/qqmusic/lib/dialog_title_bar.py +++ b/plugins/builtin/qqmusic/lib/dialog_title_bar.py @@ -7,7 +7,7 @@ from PySide6.QtCore import QSize, Qt from PySide6.QtWidgets import QDialog, QHBoxLayout, QLabel, QPushButton, QVBoxLayout, QWidget -from .runtime_bridge import get_icon, get_qss +from .runtime_bridge import get_icon, register_themed_widget @dataclass @@ -20,38 +20,13 @@ class DialogTitleBarController: close_btn: QPushButton def refresh_theme(self): - """Apply theme to title bar widgets.""" - self.title_bar.setStyleSheet( - get_qss( - """ - QWidget#dialogTitleBar { - background-color: %background_alt%; - border-top-left-radius: 12px; - border-top-right-radius: 12px; - border-bottom: 1px solid %border%; - } - """ - ) - ) - self.title_label.setStyleSheet( - get_qss("color: %text%; font-size: 14px; font-weight: bold;") - ) - self.close_btn.setStyleSheet( - get_qss( - """ - QPushButton#dialogCloseBtn { - background: transparent; - border: none; - color: %text_secondary%; - border-radius: 4px; - } - QPushButton#dialogCloseBtn:hover { - background-color: %selection%; - color: %text%; - } - """ - ) - ) + """Refresh icons and re-polish global theme selectors.""" + self.close_btn.setIcon(get_icon("times.svg", None, 14)) + for widget in (self.title_bar, self.title_label, self.close_btn): + style = widget.style() + if style is not None: + style.unpolish(widget) + style.polish(widget) def setup_dialog_title_layout( @@ -96,6 +71,7 @@ def setup_dialog_title_layout( controller = DialogTitleBarController(dialog, title_bar, title_label, close_btn) controller.refresh_theme() + register_themed_widget(title_bar) _bind_title_bar_drag(dialog, title_bar) diff --git a/plugins/builtin/qqmusic/lib/login_dialog.py b/plugins/builtin/qqmusic/lib/login_dialog.py index 2b70995e..5cf37806 100644 --- a/plugins/builtin/qqmusic/lib/login_dialog.py +++ b/plugins/builtin/qqmusic/lib/login_dialog.py @@ -273,6 +273,7 @@ def __init__(self, context=None, parent=None): self.setWindowFlags(Qt.WindowType.Dialog | Qt.FramelessWindowHint) self.setAttribute(Qt.WidgetAttribute.WA_TranslucentBackground) + self.setProperty("shell", True) self._setup_shadow() diff --git a/plugins/builtin/qqmusic/lib/online_music_view.py b/plugins/builtin/qqmusic/lib/online_music_view.py index ae77b515..ab317bb7 100644 --- a/plugins/builtin/qqmusic/lib/online_music_view.py +++ b/plugins/builtin/qqmusic/lib/online_music_view.py @@ -48,6 +48,7 @@ create_qqmusic_login_dialog, create_qqmusic_service, current_theme, + get_completer_popup_style, event_bus, format_duration, get_icon, @@ -96,7 +97,7 @@ def __init__(self, parent=None): def _apply_theme(self): """Apply themed styles to popup.""" - self.popup().setStyleSheet(get_qss(self._STYLE_POPUP)) + self.popup().setStyleSheet(get_completer_popup_style()) def refresh_theme(self): """Refresh popup styles.""" @@ -619,39 +620,6 @@ class OnlineMusicView(QWidget): _STYLE_TITLE = "color: %highlight%; font-size: 24px; font-weight: bold;" _STYLE_STATUS_LABEL = "color: %text_secondary%; font-size: 12px;" - _STYLE_SEARCH_INPUT = """ - QLineEdit { - background-color: %background_hover%; - color: %text%; - border: 2px solid %border%; - border-radius: 25px; - padding: 10px 20px; - font-size: 14px; - } - QLineEdit:focus { - border: 2px solid %highlight%; - background-color: %background_alt%; - } - QLineEdit::placeholder { - color: %text_secondary%; - } - QLineEdit::clear-button { - subcontrol-origin: padding; - subcontrol-position: right; - width: 20px; - height: 20px; - margin-right: 10px; - border-radius: 10px; - background-color: %border%; - } - QLineEdit::clear-button:hover { - background-color: %text_secondary%; - border: 1px solid %text%; - } - QLineEdit::clear-button:pressed { - background-color: %background_hover%; - } - """ _STYLE_TABS = """ QTabBar::tab { background: transparent; @@ -1093,6 +1061,7 @@ def _create_search_bar(self) -> QWidget: # Search input with built-in clear button self._search_input = SearchInputWithHotkey() self._search_input.setPlaceholderText(t("search_online_music")) + self._search_input.setProperty("variant", "search") self._search_input.returnPressed.connect(self._on_search) self._search_input.textChanged.connect(self._on_search_text_changed) self._search_input.setFixedHeight(50) @@ -1365,7 +1334,10 @@ def refresh_theme(self): self._login_status_label.setStyleSheet(get_qss(self._STYLE_STATUS_LABEL)) # Search input - self._search_input.setStyleSheet(get_qss(self._STYLE_SEARCH_INPUT)) + style = self._search_input.style() + if style is not None: + style.unpolish(self._search_input) + style.polish(self._search_input) # Tabs self._tabs.setStyleSheet(get_qss(self._STYLE_TABS)) diff --git a/plugins/builtin/qqmusic/lib/runtime_bridge.py b/plugins/builtin/qqmusic/lib/runtime_bridge.py index 55798f35..9bc56d55 100644 --- a/plugins/builtin/qqmusic/lib/runtime_bridge.py +++ b/plugins/builtin/qqmusic/lib/runtime_bridge.py @@ -24,6 +24,14 @@ def current_theme(): return _ui_module().current_theme() +def get_popup_surface_style() -> str: + return _ui_module().get_popup_surface_style() + + +def get_completer_popup_style() -> str: + return _ui_module().get_completer_popup_style() + + def show_information(parent, title: str, message: str) -> None: _ui_module().information(parent, title, message) diff --git a/tests/test_plugins/test_qqmusic_theme_integration.py b/tests/test_plugins/test_qqmusic_theme_integration.py new file mode 100644 index 00000000..e12308a9 --- /dev/null +++ b/tests/test_plugins/test_qqmusic_theme_integration.py @@ -0,0 +1,48 @@ +from pathlib import Path +from unittest.mock import Mock + +from plugins.builtin.qqmusic.lib.login_dialog import QQMusicLoginDialog +from plugins.builtin.qqmusic.lib.online_music_view import OnlineMusicView +from system.theme import ThemeManager + + +def _plugin_settings(tmp_path: Path): + settings = Mock() + settings.get.side_effect = lambda key, default=None: { + "nick": "", + "quality": "320", + "search_history": [], + "ranking_view_mode": "table", + }.get(key, default) + settings.set.side_effect = lambda key, value: None + settings.get_language.return_value = "zh" + settings.get_online_music_download_dir.return_value = str(tmp_path / "online-cache") + return settings + + +def test_plugin_login_dialog_uses_host_owned_shell_and_title_bar_styles(qtbot, monkeypatch, tmp_path): + ThemeManager._instance = None + ThemeManager.instance(_plugin_settings(tmp_path)) + monkeypatch.setattr( + "plugins.builtin.qqmusic.lib.login_dialog.QQMusicLoginDialog._start_login", + lambda self, login_type=None: None, + ) + + dialog = QQMusicLoginDialog() + qtbot.addWidget(dialog) + + assert dialog.property("shell") is True + assert dialog._title_bar_controller.title_bar.styleSheet() == "" + assert dialog._title_bar_controller.close_btn.styleSheet() == "" + +def test_online_music_view_search_input_uses_theme_variant_and_host_popup_helper(qtbot, tmp_path): + settings = _plugin_settings(tmp_path) + ThemeManager._instance = None + ThemeManager.instance(settings) + + view = OnlineMusicView(config_manager=settings, qqmusic_service=None) + qtbot.addWidget(view) + + assert view._search_input.property("variant") == "search" + assert view._search_input.styleSheet() == "" + assert view._completer.popup().styleSheet() diff --git a/tests/test_ui/test_foundation_theme_cleanup.py b/tests/test_ui/test_foundation_theme_cleanup.py new file mode 100644 index 00000000..b5affb55 --- /dev/null +++ b/tests/test_ui/test_foundation_theme_cleanup.py @@ -0,0 +1,32 @@ +from unittest.mock import Mock + +from system.theme import ThemeManager +from ui.dialogs.input_dialog import InputDialog +from ui.views.albums_view import AlbumsView + + +def _init_theme(): + config = Mock() + config.get.return_value = "dark" + ThemeManager._instance = None + return ThemeManager.instance(config) + + +def test_input_dialog_marks_shell_and_uses_unstyled_foundation_children(qtbot): + _init_theme() + + dialog = InputDialog("Title", "Prompt", "value") + qtbot.addWidget(dialog) + + assert dialog.property("shell") is True + assert dialog._input.styleSheet() == "" + + +def test_albums_view_search_input_uses_theme_variant_instead_of_local_qss(qtbot): + _init_theme() + + view = AlbumsView(library_service=Mock()) + qtbot.addWidget(view) + + assert view._search_input.property("variant") == "search" + assert view._search_input.styleSheet() == "" diff --git a/ui/dialogs/base_cover_download_dialog.py b/ui/dialogs/base_cover_download_dialog.py index 2207d999..330ff0f9 100644 --- a/ui/dialogs/base_cover_download_dialog.py +++ b/ui/dialogs/base_cover_download_dialog.py @@ -341,40 +341,7 @@ def _setup_common_ui(self, info_text: str, cover_size: int = 350, circular: bool self._search_input = QLineEdit() self._search_input.setPlaceholderText(t("search")) self._search_input.setClearButtonEnabled(True) - theme = ThemeManager.instance().current_theme - self._search_input.setStyleSheet(f""" - QLineEdit {{ - background-color: {theme.background_hover}; - color: {theme.text}; - border: 2px solid {theme.border}; - border-radius: 20px; - padding: 10px 15px; - font-size: 14px; - }} - QLineEdit:focus {{ - border: 2px solid {theme.highlight}; - background-color: {theme.background_hover}; - }} - QLineEdit::placeholder {{ - color: {theme.text_secondary}; - }} - QLineEdit::clear-button {{ - subcontrol-origin: padding; - subcontrol-position: right; - width: 18px; - height: 18px; - margin-right: 8px; - border-radius: 9px; - background-color: {theme.border}; - }} - QLineEdit::clear-button:hover {{ - background-color: {theme.background_hover}; - border: 1px solid {theme.border}; - }} - QLineEdit::clear-button:pressed {{ - background-color: {theme.background_alt}; - }} - """) + self._search_input.setProperty("variant", "search") self._search_input.returnPressed.connect(self._search_covers) query_layout.addWidget(self._search_input) left_layout.addLayout(query_layout) diff --git a/ui/dialogs/base_rename_dialog.py b/ui/dialogs/base_rename_dialog.py index db0f5f07..ee4a5ea8 100644 --- a/ui/dialogs/base_rename_dialog.py +++ b/ui/dialogs/base_rename_dialog.py @@ -52,55 +52,6 @@ class BaseRenameDialog(QDialog): color: %text%; font-size: 13px; } - QLineEdit { - background-color: %background%; - color: %text%; - border: 1px solid %border%; - border-radius: 4px; - padding: 10px; - font-size: 14px; - } - QLineEdit:focus { - border: 1px solid %highlight%; - } - QLineEdit:read-only { - background-color: %background%; - color: %text_secondary%; - } - QPushButton { - background-color: %highlight%; - color: %background%; - border: none; - padding: 10px 24px; - border-radius: 4px; - font-weight: bold; - font-size: 14px; - } - QPushButton:hover { - background-color: %highlight_hover%; - } - QPushButton:disabled { - background-color: %border%; - color: %text_secondary%; - } - QPushButton[role="cancel"] { - background-color: %border%; - color: %text%; - } - QPushButton[role="cancel"]:hover { - background-color: %background_hover%; - } - QProgressBar { - background-color: %background%; - border: none; - border-radius: 4px; - height: 6px; - text-align: center; - } - QProgressBar::chunk { - background-color: %highlight%; - border-radius: 4px; - } """ def __init__(self, parent=None): @@ -116,6 +67,7 @@ def __init__(self, parent=None): # Make dialog frameless self.setWindowFlags(Qt.WindowType.Dialog | Qt.FramelessWindowHint) self.setAttribute(Qt.WidgetAttribute.WA_TranslucentBackground) + self.setProperty("shell", True) self._setup_shadow() ThemeManager.instance().register_widget(self) @@ -214,6 +166,7 @@ def _add_buttons(self, layout: QVBoxLayout): button_layout.addWidget(self._cancel_btn) self._rename_btn = QPushButton(t("rename")) + self._rename_btn.setProperty("role", "primary") self._rename_btn.setCursor(Qt.PointingHandCursor) self._rename_btn.clicked.connect(self._on_rename_clicked) button_layout.addWidget(self._rename_btn) diff --git a/ui/dialogs/edit_media_info_dialog.py b/ui/dialogs/edit_media_info_dialog.py index a75ad890..758f145d 100644 --- a/ui/dialogs/edit_media_info_dialog.py +++ b/ui/dialogs/edit_media_info_dialog.py @@ -52,62 +52,6 @@ class EditMediaInfoDialog(QDialog): color: %text%; font-size: 13px; } - QLineEdit { - background-color: %background%; - color: %text%; - border: 1px solid %border%; - border-radius: 4px; - padding: 8px; - font-size: 13px; - } - QLineEdit:focus { - border: 1px solid %highlight%; - } - QLineEdit:read-only { - background-color: %background_hover%; - color: %text_secondary%; - } - QCheckBox { - color: %text%; - font-size: 13px; - spacing: 8px; - } - QCheckBox::indicator { - width: 18px; - height: 18px; - } - QCheckBox::indicator:checked { - background-color: %highlight%; - border: 2px solid %highlight%; - border-radius: 3px; - } - QCheckBox::indicator:unchecked { - background-color: %background%; - border: 2px solid %border%; - border-radius: 3px; - } - QPushButton { - background-color: %highlight%; - color: %background%; - border: none; - padding: 8px 20px; - border-radius: 4px; - font-weight: bold; - } - QPushButton:hover { - background-color: %highlight_hover%; - } - QPushButton[role="cancel"] { - background-color: %border%; - color: %text%; - } - QPushButton[role="cancel"]:hover { - background-color: %background_hover%; - } - QPushButton:disabled { - background-color: %border%; - color: %text_secondary%; - } """ _PROGRESS_STYLE_TEMPLATE = """ @@ -141,6 +85,7 @@ def __init__(self, track_ids: List[int], library_service, parent=None): # Make dialog frameless self.setWindowFlags(Qt.WindowType.Dialog | Qt.FramelessWindowHint) self.setAttribute(Qt.WidgetAttribute.WA_TranslucentBackground) + self.setProperty("shell", True) self._setup_shadow() self._setup_ui() @@ -297,6 +242,7 @@ def _setup_ui(self): buttons = QDialogButtonBox() self._ok_button = QPushButton(t("save")) self._ok_button.setObjectName("saveBtn") + self._ok_button.setProperty("role", "primary") self._ok_button.setCursor(Qt.PointingHandCursor) self._ok_button.setEnabled(self._can_save) cancel_button = QPushButton(t("cancel")) diff --git a/ui/dialogs/input_dialog.py b/ui/dialogs/input_dialog.py index 40ca1d04..c4272ed6 100644 --- a/ui/dialogs/input_dialog.py +++ b/ui/dialogs/input_dialog.py @@ -19,63 +19,13 @@ class InputDialog(QDialog): """Custom input dialog with dark theme styling and frameless window.""" - _STYLE_TEMPLATE = """ - QWidget#dialogContainer { - background-color: %background_alt%; - color: %text%; - border: 1px solid %border%; - border-radius: 12px; - } - QLabel#dialogTitle { - color: %text%; - font-size: 15px; - font-weight: bold; - } - QLabel#dialogLabel { - color: %text_secondary%; - font-size: 13px; - } - QLineEdit { - background-color: %background%; - color: %text%; - border: 1px solid %border%; - border-radius: 6px; - padding: 8px; - } - QLineEdit:focus { - border: 1px solid %highlight%; - } - QPushButton { - background-color: %background_hover%; - color: %text%; - border: 1px solid %border%; - border-radius: 6px; - padding: 8px 20px; - min-width: 80px; - font-weight: bold; - } - QPushButton:hover { - background-color: %border%; - } - QPushButton#primaryBtn { - background-color: %highlight%; - color: %background%; - border: 1px solid %highlight%; - } - QPushButton#primaryBtn:hover { - background-color: %highlight_hover%; - } - QDialogButtonBox { - button-layout: 2; - } - """ - def __init__(self, title: str, label: str, text: str = "", parent=None): super().__init__(parent) self._drag_pos = None self.setWindowFlags(Qt.WindowType.Dialog | Qt.FramelessWindowHint) self.setAttribute(Qt.WidgetAttribute.WA_TranslucentBackground) + self.setProperty("shell", True) self.setFixedSize(380, 200) self.setWindowTitle(title) @@ -124,7 +74,7 @@ def _setup_ui(self, title, label, text): btn_layout.addWidget(cancel_btn) ok_btn = QPushButton(t("ok")) - ok_btn.setObjectName("primaryBtn") + ok_btn.setProperty("role", "primary") ok_btn.clicked.connect(self.accept) ok_btn.setCursor(Qt.CursorShape.PointingHandCursor) btn_layout.addWidget(ok_btn) @@ -132,7 +82,10 @@ def _setup_ui(self, title, label, text): layout.addLayout(btn_layout) def _apply_style(self): - self.setStyleSheet(ThemeManager.instance().get_qss(self._STYLE_TEMPLATE)) + style = self.style() + if style is not None: + style.unpolish(self) + style.polish(self) def refresh_theme(self): self._apply_style() diff --git a/ui/dialogs/lyrics_download_dialog.py b/ui/dialogs/lyrics_download_dialog.py index 9671a09f..36eae4ce 100644 --- a/ui/dialogs/lyrics_download_dialog.py +++ b/ui/dialogs/lyrics_download_dialog.py @@ -114,55 +114,6 @@ class LyricsDownloadDialog(QDialog): background-color: %highlight%; color: %background%; } - QPushButton { - background-color: %highlight%; - color: %background%; - border: none; - padding: 8px 20px; - border-radius: 4px; - font-weight: bold; - } - QPushButton:hover { - background-color: %highlight_hover%; - } - QPushButton:disabled { - background-color: %border%; - color: %text_secondary%; - } - QPushButton[role="cancel"] { - background-color: %border%; - color: %text%; - } - QPushButton[role="cancel"]:hover { - background-color: %background_hover%; - } - QCheckBox { - color: %text%; - font-size: 13px; - spacing: 8px; - } - QCheckBox::indicator { - width: 18px; - height: 18px; - border-radius: 3px; - border: 2px solid %border%; - background-color: %background%; - } - QCheckBox::indicator:checked { - background-color: %highlight%; - border-color: %highlight%; - } - QProgressBar { - background-color: %border%; - border: 1px solid %background_hover%; - border-radius: 4px; - text-align: center; - color: %text%; - } - QProgressBar::chunk { - background-color: %highlight%; - border-radius: 3px; - } """ def __init__( @@ -198,6 +149,7 @@ def __init__( # Make dialog frameless self.setWindowFlags(Qt.WindowType.Dialog | Qt.FramelessWindowHint) self.setAttribute(Qt.WidgetAttribute.WA_TranslucentBackground) + self.setProperty("shell", True) self._setup_shadow() self._setup_ui() @@ -274,6 +226,7 @@ def _setup_ui(self): cancel_btn.clicked.connect(self._on_cancel_clicked) self._download_btn = QPushButton(t("download")) + self._download_btn.setProperty("role", "primary") self._download_btn.setEnabled(False) # Disabled until search completes and selection made self._download_btn.setCursor(QCursor(Qt.PointingHandCursor)) self._download_btn.clicked.connect(self.accept) diff --git a/ui/dialogs/settings_dialog.py b/ui/dialogs/settings_dialog.py index c70fddd6..131174e5 100644 --- a/ui/dialogs/settings_dialog.py +++ b/ui/dialogs/settings_dialog.py @@ -55,21 +55,6 @@ class GeneralSettingsDialog(QDialog): color: %text%; font-size: 13px; } - QLineEdit { - background-color: %background%; - color: %text%; - border: 1px solid %border%; - border-radius: 4px; - padding: 8px; - font-size: 13px; - } - QLineEdit:focus { - border: 1px solid %highlight%; - } - QLineEdit:disabled { - background-color: %background_hover%; - color: %text_secondary%; - } QPushButton { background-color: %background_hover%; color: %text%; @@ -84,48 +69,6 @@ class GeneralSettingsDialog(QDialog): QPushButton:pressed { background-color: %background%; } - QCheckBox { - color: %text%; - font-size: 13px; - } - QCheckBox::indicator { - width: 18px; - height: 18px; - } - QGroupBox { - color: %text%; - border: 1px solid %border%; - border-radius: 6px; - margin-top: 10px; - padding-top: 10px; - font-size: 13px; - } - QGroupBox::title { - subcontrol-origin: margin; - subcontrol-position: top left; - padding: 0 8px; - color: %text%; - } - QTabWidget::pane { - border: 1px solid %border%; - background-color: %background_alt%; - } - QTabBar::tab { - background-color: %background%; - color: %text_secondary%; - padding: 8px 16px; - border: 1px solid %border%; - font-size: 13px; - } - QTabBar::tab:selected { - background-color: %background_hover%; - color: %text%; - border-bottom-color: %background_hover%; - } - QTabBar::tab:hover:!selected { - background-color: %selection%; - } - """ + ThemeManager.get_combobox_style() + """ """ def __init__(self, config_manager, parent=None): @@ -145,6 +88,7 @@ def __init__(self, config_manager, parent=None): # Make dialog frameless self.setWindowFlags(Qt.WindowType.Dialog | Qt.FramelessWindowHint) self.setAttribute(Qt.WidgetAttribute.WA_TranslucentBackground) + self.setProperty("shell", True) self._setup_shadow() self._setup_ui() diff --git a/ui/styles.qss b/ui/styles.qss index 4c3254cf..2d913913 100644 --- a/ui/styles.qss +++ b/ui/styles.qss @@ -21,6 +21,14 @@ QDialog[shell="true"] { border-radius: 12px; } +QWidget#dialogContainer, +QWidget#settingsContainer { + background-color: %background_alt%; + color: %text%; + border: 1px solid %border%; + border-radius: 12px; +} + /* Scrollbar */ QScrollBar:vertical { background: transparent; @@ -119,6 +127,15 @@ QPushButton[role="primary"]:hover { background-color: %highlight_hover%; } +QPushButton[role="cancel"] { + background-color: %border%; + color: %text%; +} + +QPushButton[role="cancel"]:hover { + background-color: %background_hover%; +} + QWidget#titleBar, QWidget#dialogTitleBar { color: %text%; @@ -171,8 +188,44 @@ QLineEdit:focus, QTextEdit:focus { border: 2px solid %highlight%; } +QLineEdit:read-only { + background-color: %background_hover%; + color: %text_secondary%; +} + QLineEdit[variant="search"] { + border: 2px solid %border%; + border-radius: 20px; + padding: 10px 15px; padding-right: 30px; + font-size: 14px; +} + +QLineEdit[variant="search"]:focus { + border: 2px solid %highlight%; +} + +QLineEdit[variant="search"]::placeholder { + color: %text_secondary%; +} + +QLineEdit[variant="search"]::clear-button { + subcontrol-origin: padding; + subcontrol-position: right; + width: 18px; + height: 18px; + margin-right: 8px; + border-radius: 9px; + background-color: %border%; +} + +QLineEdit[variant="search"]::clear-button:hover { + background-color: %background_hover%; + border: 1px solid %border%; +} + +QLineEdit[variant="search"]::clear-button:pressed { + background-color: %background_alt%; } /* Labels */ @@ -197,6 +250,11 @@ QLabel#trackLabel { font-size: 13px; } +QLabel#dialogLabel { + color: %text_secondary%; + font-size: 13px; +} + QCheckBox, QRadioButton, QGroupBox, @@ -249,6 +307,60 @@ QComboBox[compact="true"] { padding: 2px 10px; } +QComboBox::drop-down { + border: none; + width: 28px; +} + +QComboBox QAbstractItemView { + background-color: %background_alt%; + border: 1px solid %border%; + color: %text%; + selection-background-color: %highlight%; + selection-color: %background%; + outline: none; +} + +QComboBox QAbstractItemView::item { + padding: 6px 10px; + min-height: 20px; +} + +QProgressBar { + background-color: %background_hover%; + border: none; + border-radius: 4px; + text-align: center; + color: %text%; +} + +QProgressBar::chunk { + background-color: %highlight%; + border-radius: 4px; +} + +QTabWidget::pane { + border: 1px solid %border%; + background-color: %background_alt%; +} + +QTabBar::tab { + background-color: %background%; + color: %text_secondary%; + padding: 8px 16px; + border: 1px solid %border%; +} + +QTabBar::tab:selected { + background-color: %background_hover%; + color: %text%; + border-bottom-color: %background_hover%; +} + +QTabBar::tab:hover:!selected { + background-color: %selection%; +} + /* Lists */ QListWidget { background-color: transparent; diff --git a/ui/views/albums_view.py b/ui/views/albums_view.py index c014a43d..11dce138 100644 --- a/ui/views/albums_view.py +++ b/ui/views/albums_view.py @@ -469,41 +469,7 @@ def _create_header(self) -> QWidget: self._search_input.setPlaceholderText(t("search")) self._search_input.setFixedWidth(300) self._search_input.setClearButtonEnabled(True) # 启用清除按钮 - self._search_input.setStyleSheet(f""" - QLineEdit {{ - background-color: {theme.background_hover}; - color: {theme.text}; - border: 2px solid {theme.border}; - border-radius: 20px; - padding: 10px 15px; - font-size: 14px; - }} - QLineEdit:focus {{ - border: 2px solid {theme.highlight}; - background-color: {theme.background_hover}; - }} - /* 占位符文本样式 */ - QLineEdit::placeholder {{ - color: {theme.text_secondary}; - }} - /* 清除按钮样式 */ - QLineEdit::clear-button {{ - subcontrol-origin: padding; - subcontrol-position: right; - width: 18px; - height: 18px; - margin-right: 8px; - border-radius: 9px; - background-color: {theme.border}; - }} - QLineEdit::clear-button:hover {{ - background-color: {theme.background_hover}; - border: 1px solid {theme.border}; - }} - QLineEdit::clear-button:pressed {{ - background-color: {theme.background_alt}; - }} - """) + self._search_input.setProperty("variant", "search") layout.addWidget(self._search_input) return header @@ -633,39 +599,10 @@ def refresh_theme(self): # Update search input if hasattr(self, '_search_input'): - self._search_input.setStyleSheet(f""" - QLineEdit {{ - background-color: {theme.background_hover}; - color: {theme.text}; - border: 2px solid {theme.border}; - border-radius: 20px; - padding: 10px 15px; - font-size: 14px; - }} - QLineEdit:focus {{ - border: 2px solid {theme.highlight}; - background-color: {theme.background_hover}; - }} - QLineEdit::placeholder {{ - color: {theme.text_secondary}; - }} - QLineEdit::clear-button {{ - subcontrol-origin: padding; - subcontrol-position: right; - width: 18px; - height: 18px; - margin-right: 8px; - border-radius: 9px; - background-color: {theme.border}; - }} - QLineEdit::clear-button:hover {{ - background-color: {theme.background_hover}; - border: 1px solid {theme.border}; - }} - QLineEdit::clear-button:pressed {{ - background-color: {theme.background_alt}; - }} - """) + style = self._search_input.style() + if style is not None: + style.unpolish(self._search_input) + style.polish(self._search_input) # Update loading label if hasattr(self, '_loading_label'): @@ -693,23 +630,6 @@ def _show_context_menu(self, pos): theme = ThemeManager.instance().current_theme menu = QMenu(self) - menu.setStyleSheet(f""" - QMenu {{ - background-color: {theme.background_hover}; - color: {theme.text}; - border: 1px solid {theme.border}; - border-radius: 6px; - padding: 4px; - }} - QMenu::item {{ - padding: 8px 24px; - border-radius: 4px; - }} - QMenu::item:selected {{ - background-color: {theme.highlight}; - color: {theme.background}; - }} - """) # View details action view_action = QAction(t("view_details"), self) diff --git a/ui/views/artists_view.py b/ui/views/artists_view.py index 90b183be..57a9b73d 100644 --- a/ui/views/artists_view.py +++ b/ui/views/artists_view.py @@ -389,41 +389,7 @@ def _create_header(self) -> QWidget: self._search_input.setPlaceholderText(t("search")) self._search_input.setFixedWidth(300) self._search_input.setClearButtonEnabled(True) # 启用清除按钮 - self._search_input.setStyleSheet(f""" - QLineEdit {{ - background-color: {theme.background_hover}; - color: {theme.text}; - border: 2px solid {theme.border}; - border-radius: 20px; - padding: 10px 15px; - font-size: 14px; - }} - QLineEdit:focus {{ - border: 2px solid {theme.highlight}; - background-color: {theme.background_hover}; - }} - /* 占位符文本样式 */ - QLineEdit::placeholder {{ - color: {theme.text_secondary}; - }} - /* 清除按钮样式 */ - QLineEdit::clear-button {{ - subcontrol-origin: padding; - subcontrol-position: right; - width: 18px; - height: 18px; - margin-right: 8px; - border-radius: 9px; - background-color: {theme.border}; - }} - QLineEdit::clear-button:hover {{ - background-color: {theme.background_hover}; - border: 1px solid {theme.border}; - }} - QLineEdit::clear-button:pressed {{ - background-color: {theme.background_alt}; - }} - """) + self._search_input.setProperty("variant", "search") layout.addWidget(self._search_input) return header @@ -554,39 +520,10 @@ def refresh_theme(self): # Update search input if hasattr(self, '_search_input'): - self._search_input.setStyleSheet(f""" - QLineEdit {{ - background-color: {theme.background_hover}; - color: {theme.text}; - border: 2px solid {theme.border}; - border-radius: 20px; - padding: 10px 15px; - font-size: 14px; - }} - QLineEdit:focus {{ - border: 2px solid {theme.highlight}; - background-color: {theme.background_hover}; - }} - QLineEdit::placeholder {{ - color: {theme.text_secondary}; - }} - QLineEdit::clear-button {{ - subcontrol-origin: padding; - subcontrol-position: right; - width: 18px; - height: 18px; - margin-right: 8px; - border-radius: 9px; - background-color: {theme.border}; - }} - QLineEdit::clear-button:hover {{ - background-color: {theme.background_hover}; - border: 1px solid {theme.border}; - }} - QLineEdit::clear-button:pressed {{ - background-color: {theme.background_alt}; - }} - """) + style = self._search_input.style() + if style is not None: + style.unpolish(self._search_input) + style.polish(self._search_input) # Update loading label if hasattr(self, '_loading_label'): @@ -609,27 +546,7 @@ def _show_context_menu(self, pos): if not artist: return - from system.theme import ThemeManager - theme = ThemeManager.instance().current_theme - menu = QMenu(self) - menu.setStyleSheet(f""" - QMenu {{ - background-color: {theme.background_hover}; - color: {theme.text}; - border: 1px solid {theme.border}; - border-radius: 6px; - padding: 4px; - }} - QMenu::item {{ - padding: 8px 24px; - border-radius: 4px; - }} - QMenu::item:selected {{ - background-color: {theme.highlight}; - color: {theme.background}; - }} - """) # View details action view_action = QAction(t("view_details"), self) diff --git a/ui/views/cloud/cloud_drive_view.py b/ui/views/cloud/cloud_drive_view.py index dcb61411..b62cc4a8 100644 --- a/ui/views/cloud/cloud_drive_view.py +++ b/ui/views/cloud/cloud_drive_view.py @@ -231,40 +231,6 @@ class CloudDriveView(QWidget): } """ - _SEARCH_INPUT_STYLE_TEMPLATE = """ - QLineEdit { - background-color: %background_hover%; - color: %text%; - border: 2px solid %border%; - border-radius: 20px; - padding: 10px 15px; - font-size: 14px; - } - QLineEdit:focus { - border: 2px solid %highlight%; - background-color: %background_hover%; - } - QLineEdit::placeholder { - color: %text_secondary%; - } - QLineEdit::clear-button { - subcontrol-origin: padding; - subcontrol-position: right; - width: 18px; - height: 18px; - margin-right: 8px; - border-radius: 9px; - background-color: %border%; - } - QLineEdit::clear-button:hover { - background-color: %background_hover%; - border: 1px solid %border%; - } - QLineEdit::clear-button:pressed { - background-color: %background_alt%; - } - """ - _SEARCH_BUTTON_STYLE_TEMPLATE = """ QPushButton { background: %background_alt%; @@ -441,6 +407,7 @@ def _create_file_content(self) -> QWidget: self._share_search_input = QLineEdit() self._share_search_input.setPlaceholderText(t("cloud_share_search_placeholder")) self._share_search_input.setClearButtonEnabled(True) + self._share_search_input.setProperty("variant", "search") self._share_search_input.returnPressed.connect(self._search_shares) self._share_search_input.textChanged.connect(self._on_share_search_text_changed) search_layout.addWidget(self._share_search_input) @@ -2327,7 +2294,10 @@ def refresh_theme(self): from system.theme import ThemeManager tm = ThemeManager.instance() self.setStyleSheet(tm.get_qss(self._STYLE_TEMPLATE)) - self._share_search_input.setStyleSheet(tm.get_qss(self._SEARCH_INPUT_STYLE_TEMPLATE)) + style = self._share_search_input.style() + if style is not None: + style.unpolish(self._share_search_input) + style.polish(self._share_search_input) self._share_search_btn.setStyleSheet(tm.get_qss(self._SEARCH_BUTTON_STYLE_TEMPLATE)) self._path_label.set_breadcrumb_color(tm.current_theme.text) diff --git a/ui/views/genres_view.py b/ui/views/genres_view.py index e4548fb1..78df4e20 100644 --- a/ui/views/genres_view.py +++ b/ui/views/genres_view.py @@ -505,39 +505,7 @@ def _create_header(self) -> QWidget: self._search_input.setPlaceholderText(t("search")) self._search_input.setFixedWidth(300) self._search_input.setClearButtonEnabled(True) - self._search_input.setStyleSheet(f""" - QLineEdit {{ - background-color: {theme.background_hover}; - color: {theme.text}; - border: 2px solid {theme.border}; - border-radius: 20px; - padding: 10px 15px; - font-size: 14px; - }} - QLineEdit:focus {{ - border: 2px solid {theme.highlight}; - background-color: {theme.background_hover}; - }} - QLineEdit::placeholder {{ - color: {theme.text_secondary}; - }} - QLineEdit::clear-button {{ - subcontrol-origin: padding; - subcontrol-position: right; - width: 18px; - height: 18px; - margin-right: 8px; - border-radius: 9px; - background-color: {theme.border}; - }} - QLineEdit::clear-button:hover {{ - background-color: {theme.background_hover}; - border: 1px solid {theme.border}; - }} - QLineEdit::clear-button:pressed {{ - background-color: {theme.background_alt}; - }} - """) + self._search_input.setProperty("variant", "search") layout.addWidget(self._search_input) return header @@ -665,39 +633,10 @@ def refresh_theme(self): # Update search input if hasattr(self, '_search_input'): - self._search_input.setStyleSheet(f""" - QLineEdit {{ - background-color: {theme.background_hover}; - color: {theme.text}; - border: 2px solid {theme.border}; - border-radius: 20px; - padding: 10px 15px; - font-size: 14px; - }} - QLineEdit:focus {{ - border: 2px solid {theme.highlight}; - background-color: {theme.background_hover}; - }} - QLineEdit::placeholder {{ - color: {theme.text_secondary}; - }} - QLineEdit::clear-button {{ - subcontrol-origin: padding; - subcontrol-position: right; - width: 18px; - height: 18px; - margin-right: 8px; - border-radius: 9px; - background-color: {theme.border}; - }} - QLineEdit::clear-button:hover {{ - background-color: {theme.background_hover}; - border: 1px solid {theme.border}; - }} - QLineEdit::clear-button:pressed {{ - background-color: {theme.background_alt}; - }} - """) + style = self._search_input.style() + if style is not None: + style.unpolish(self._search_input) + style.polish(self._search_input) # Update loading label if hasattr(self, '_loading_label'): @@ -721,27 +660,7 @@ def _show_context_menu(self, pos): if not genre: return - from system.theme import ThemeManager - theme = ThemeManager.instance().current_theme - menu = QMenu(self) - menu.setStyleSheet(f""" - QMenu {{ - background-color: {theme.background_hover}; - color: {theme.text}; - border: 1px solid {theme.border}; - border-radius: 6px; - padding: 4px; - }} - QMenu::item {{ - padding: 8px 24px; - border-radius: 4px; - }} - QMenu::item:selected {{ - background-color: {theme.highlight}; - color: {theme.background}; - }} - """) # View details action view_action = QAction(t("view_details"), self) diff --git a/ui/views/library_view.py b/ui/views/library_view.py index 97506256..f90ffc9d 100644 --- a/ui/views/library_view.py +++ b/ui/views/library_view.py @@ -49,38 +49,6 @@ class LibraryView(QWidget): font-weight: bold; padding: 10px; } - """ + ThemeManager.get_combobox_style() + """ - QLineEdit { - background-color: %background_hover%; - color: %text%; - border: 2px solid %border%; - border-radius: 20px; - padding: 10px 15px; - font-size: 14px; - } - QLineEdit:focus { - border: 2px solid %highlight%; - background-color: %background_hover%; - } - QLineEdit::placeholder { - color: %text_secondary%; - } - QLineEdit::clear-button { - subcontrol-origin: padding; - subcontrol-position: right; - width: 18px; - height: 18px; - margin-right: 8px; - border-radius: 9px; - background-color: %border%; - } - QLineEdit::clear-button:hover { - background-color: %background_hover%; - border: 1px solid %border%; - } - QLineEdit::clear-button:pressed { - background-color: %background_alt%; - } """ track_double_clicked = Signal(int) # Signal when track is double-clicked @@ -164,6 +132,7 @@ def _setup_ui(self): self._source_filter.addItem(t("source_baidu"), "BAIDU") self._source_filter.addItem(t("source_qq"), "QQ") self._source_filter.setFixedWidth(120) + self._source_filter.setProperty("compact", True) header_layout.addWidget(self._source_filter) # Add spacing between filter and search box @@ -174,6 +143,7 @@ def _setup_ui(self): self._search_input.setPlaceholderText(t("search_tracks")) self._search_input.setFixedWidth(300) self._search_input.setClearButtonEnabled(True) # 启用清除按钮 + self._search_input.setProperty("variant", "search") header_layout.addWidget(self._search_input) layout.addLayout(header_layout) From e89bfd8045484b4ac6bcdcee8a0a8f5877db38a3 Mon Sep 17 00:00:00 2001 From: Har01d Date: Tue, 7 Apr 2026 18:54:28 +0800 Subject: [PATCH 071/157] plan --- .../plans/2026-04-07-netease-plugin-split.md | 848 ++++++++++++++++++ ...6-04-07-unified-foundation-theme-styles.md | 658 ++++++++++++++ 2 files changed, 1506 insertions(+) create mode 100644 docs/superpowers/plans/2026-04-07-netease-plugin-split.md create mode 100644 docs/superpowers/plans/2026-04-07-unified-foundation-theme-styles.md diff --git a/docs/superpowers/plans/2026-04-07-netease-plugin-split.md b/docs/superpowers/plans/2026-04-07-netease-plugin-split.md new file mode 100644 index 00000000..c1b65aa7 --- /dev/null +++ b/docs/superpowers/plans/2026-04-07-netease-plugin-split.md @@ -0,0 +1,848 @@ +# NetEase Plugin Split Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Move host-owned NetEase lyrics, album cover, and artist cover sources into two built-in plugins while preserving the current `source="netease"` runtime behavior. + +**Architecture:** Add `plugins/builtin/netease_lyrics/` and `plugins/builtin/netease_cover/`, plus a non-plugin shared helper package at `plugins/builtin/netease_shared/` for low-level request and parsing helpers. Remove NetEase ownership from `LyricsService`, `CoverService`, and `services/sources` exports so all NetEase capabilities flow through plugin loading, while keeping the same HTTP endpoints, result fields, and source identifiers. + +**Tech Stack:** Python 3.11, pytest, `uv`, Harmony plugin runtime, `harmony_plugin_api` + +--- + +## File Map + +- Create: `plugins/builtin/netease_shared/__init__.py` +- Create: `plugins/builtin/netease_shared/common.py` +- Create: `plugins/builtin/netease_lyrics/__init__.py` +- Create: `plugins/builtin/netease_lyrics/plugin.json` +- Create: `plugins/builtin/netease_lyrics/plugin_main.py` +- Create: `plugins/builtin/netease_lyrics/lib/__init__.py` +- Create: `plugins/builtin/netease_lyrics/lib/lyrics_source.py` +- Create: `plugins/builtin/netease_cover/__init__.py` +- Create: `plugins/builtin/netease_cover/plugin.json` +- Create: `plugins/builtin/netease_cover/plugin_main.py` +- Create: `plugins/builtin/netease_cover/lib/__init__.py` +- Create: `plugins/builtin/netease_cover/lib/cover_source.py` +- Create: `plugins/builtin/netease_cover/lib/artist_cover_source.py` +- Create: `tests/test_plugins/test_netease_lyrics_plugin.py` +- Create: `tests/test_plugins/test_netease_cover_plugin.py` +- Modify: `services/lyrics/lyrics_service.py` +- Modify: `services/metadata/cover_service.py` +- Modify: `services/sources/lyrics_sources.py` +- Modify: `services/sources/cover_sources.py` +- Modify: `services/sources/artist_cover_sources.py` +- Modify: `services/sources/__init__.py` +- Modify: `tests/test_services/test_plugin_lyrics_registry.py` +- Modify: `tests/test_services/test_plugin_cover_registry.py` + +## Task 1: Lock In Failing Registration Tests + +**Files:** +- Create: `tests/test_plugins/test_netease_lyrics_plugin.py` +- Create: `tests/test_plugins/test_netease_cover_plugin.py` +- Modify: `tests/test_services/test_plugin_lyrics_registry.py` +- Modify: `tests/test_services/test_plugin_cover_registry.py` + +- [ ] **Step 1: Write the failing plugin registration tests** + +```python +from unittest.mock import Mock + +from plugins.builtin.netease_lyrics.plugin_main import NetEaseLyricsPlugin + + +def test_netease_lyrics_plugin_registers_lyrics_source(): + context = Mock() + plugin = NetEaseLyricsPlugin() + + plugin.register(context) + + context.services.register_lyrics_source.assert_called_once() +``` + +```python +from unittest.mock import Mock + +from plugins.builtin.netease_cover.plugin_main import NetEaseCoverPlugin + + +def test_netease_cover_plugin_registers_cover_and_artist_sources(): + context = Mock() + plugin = NetEaseCoverPlugin() + + plugin.register(context) + + assert context.services.register_cover_source.call_count == 1 + assert context.services.register_artist_cover_source.call_count == 1 +``` + +```python +def test_builtin_lyrics_sources_exclude_plugin_owned_sources(): + sources = LyricsService._get_builtin_sources() + names = {source.name for source in sources} + + assert "NetEase" not in names +``` + +```python +def test_builtin_cover_sources_exclude_plugin_owned_sources(): + service = CoverService(http_client=SimpleNamespace(), sources=None) + + names = {source.name for source in service._get_builtin_sources()} + artist_names = {source.name for source in service._get_builtin_artist_sources()} + + assert "NetEase" not in names + assert "NetEase" not in artist_names +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `uv run pytest tests/test_plugins/test_netease_lyrics_plugin.py tests/test_plugins/test_netease_cover_plugin.py tests/test_services/test_plugin_lyrics_registry.py tests/test_services/test_plugin_cover_registry.py -v` +Expected: FAIL with `ModuleNotFoundError` for the new plugins and/or assertions showing built-in host source lists still contain `NetEase` + +## Task 2: Add Shared NetEase Helpers + +**Files:** +- Create: `plugins/builtin/netease_shared/__init__.py` +- Create: `plugins/builtin/netease_shared/common.py` +- Test: `tests/test_plugins/test_netease_lyrics_plugin.py` +- Test: `tests/test_plugins/test_netease_cover_plugin.py` + +- [ ] **Step 1: Write a failing behavior test that depends on shared header and image-url normalization helpers** + +```python +from plugins.builtin.netease_shared.common import build_netease_image_url, netease_headers + + +def test_netease_shared_helpers_normalize_headers_and_image_urls(): + headers = netease_headers() + + assert headers["Referer"] == "https://music.163.com/" + assert "Mozilla/5.0" in headers["User-Agent"] + assert build_netease_image_url("https://example.com/cover.jpg", "500y500") == ( + "https://example.com/cover.jpg?param=500y500" + ) + assert build_netease_image_url("https://example.com/cover.jpg?foo=1", "500y500") == ( + "https://example.com/cover.jpg?foo=1" + ) +``` + +- [ ] **Step 2: Run the targeted test to verify it fails** + +Run: `uv run pytest tests/test_plugins/test_netease_lyrics_plugin.py::test_netease_shared_helpers_normalize_headers_and_image_urls -v` +Expected: FAIL with `ModuleNotFoundError` for `plugins.builtin.netease_shared.common` + +- [ ] **Step 3: Write the minimal shared helper implementation** + +```python +from __future__ import annotations + + +def netease_headers() -> dict[str, str]: + return { + "User-Agent": ( + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) " + "AppleWebKit/537.36" + ), + "Referer": "https://music.163.com/", + } + + +def build_netease_image_url(url: str | None, size: str) -> str | None: + if not url: + return None + if "?" in url: + return url + return f"{url}?param={size}" +``` + +- [ ] **Step 4: Run the targeted test to verify it passes** + +Run: `uv run pytest tests/test_plugins/test_netease_lyrics_plugin.py::test_netease_shared_helpers_normalize_headers_and_image_urls -v` +Expected: PASS + +## Task 3: Add the NetEase Lyrics Plugin + +**Files:** +- Create: `plugins/builtin/netease_lyrics/__init__.py` +- Create: `plugins/builtin/netease_lyrics/plugin.json` +- Create: `plugins/builtin/netease_lyrics/plugin_main.py` +- Create: `plugins/builtin/netease_lyrics/lib/__init__.py` +- Create: `plugins/builtin/netease_lyrics/lib/lyrics_source.py` +- Test: `tests/test_plugins/test_netease_lyrics_plugin.py` + +- [ ] **Step 1: Extend the failing lyrics plugin test with result mapping and YRC fallback behavior** + +```python +from types import SimpleNamespace +from unittest.mock import Mock + +from plugins.builtin.netease_lyrics.lib.lyrics_source import NetEaseLyricsPluginSource +from plugins.builtin.netease_lyrics.plugin_main import NetEaseLyricsPlugin + + +def test_netease_lyrics_plugin_registers_lyrics_source(): + context = Mock() + plugin = NetEaseLyricsPlugin() + + plugin.register(context) + + context.services.register_lyrics_source.assert_called_once() + registered = context.services.register_lyrics_source.call_args.args[0] + assert isinstance(registered, NetEaseLyricsPluginSource) + + +def test_netease_lyrics_plugin_source_search_maps_results(): + response = SimpleNamespace( + status_code=200, + json=lambda: { + "code": 200, + "result": { + "songs": [ + { + "id": 1, + "name": "Song 1", + "artists": [{"name": "Singer 1"}], + "album": { + "name": "Album 1", + "picUrl": "https://example.com/cover.jpg", + }, + "duration": 225000, + } + ] + }, + }, + ) + source = NetEaseLyricsPluginSource( + SimpleNamespace(get=lambda *_args, **_kwargs: response) + ) + + results = source.search("Song 1", "Singer 1") + + assert len(results) == 1 + assert results[0].song_id == "1" + assert results[0].title == "Song 1" + assert results[0].artist == "Singer 1" + assert results[0].album == "Album 1" + assert results[0].duration == 225.0 + assert results[0].source == "netease" + assert results[0].cover_url == "https://example.com/cover.jpg" + assert results[0].supports_yrc is True + + +def test_netease_lyrics_plugin_source_prefers_yrc_then_falls_back_to_lrc(): + responses = [ + SimpleNamespace( + status_code=200, + json=lambda: {"code": 200, "yrc": {}, "lrc": {"lyric": "[00:01.00]line"}}, + ) + ] + source = NetEaseLyricsPluginSource( + SimpleNamespace(get=lambda *_args, **_kwargs: responses.pop(0)) + ) + + lyrics = source.get_lyrics(SimpleNamespace(song_id="1")) + + assert lyrics == "[00:01.00]line" +``` + +- [ ] **Step 2: Run lyrics plugin tests to verify they fail** + +Run: `uv run pytest tests/test_plugins/test_netease_lyrics_plugin.py -v` +Expected: FAIL with missing plugin files and missing `NetEaseLyricsPluginSource` + +- [ ] **Step 3: Write the minimal lyrics plugin implementation** + +```python +from .lib.lyrics_source import NetEaseLyricsPluginSource + + +class NetEaseLyricsPlugin: + plugin_id = "netease_lyrics" + + def register(self, context) -> None: + context.services.register_lyrics_source( + NetEaseLyricsPluginSource(context.http) + ) + + def unregister(self, context) -> None: + return None +``` + +```python +{ + "id": "netease_lyrics", + "name": "NetEase Lyrics", + "version": "1.0.0", + "api_version": "1", + "entrypoint": "plugin_main.py", + "entry_class": "NetEaseLyricsPlugin", + "capabilities": ["lyrics_source"], + "min_app_version": "0.1.0" +} +``` + +```python +from __future__ import annotations + +import logging + +from harmony_plugin_api.lyrics import PluginLyricsResult +from plugins.builtin.netease_shared.common import netease_headers + +logger = logging.getLogger(__name__) + + +class NetEaseLyricsPluginSource: + source_id = "netease" + display_name = "NetEase" + name = "NetEase" + + def __init__(self, http_client) -> None: + self._http_client = http_client + + def search( + self, + title: str, + artist: str, + limit: int = 10, + ) -> list[PluginLyricsResult]: + response = self._http_client.get( + "https://music.163.com/api/search/get/web", + params={"s": f"{artist} {title}", "type": "1", "limit": str(limit)}, + headers=netease_headers(), + timeout=3, + ) + if response.status_code != 200: + return [] + payload = response.json() + songs = payload.get("result", {}).get("songs", []) + return [ + PluginLyricsResult( + song_id=str(song["id"]), + title=song.get("name", ""), + artist=song["artists"][0]["name"] if song.get("artists") else "", + album=song.get("album", {}).get("name", ""), + duration=(song.get("duration") / 1000) if song.get("duration") else None, + source="netease", + cover_url=song.get("album", {}).get("picUrl"), + supports_yrc=True, + ) + for song in songs + ] + + def get_lyrics(self, result: PluginLyricsResult) -> str | None: + try: + response = self._http_client.get( + f"https://music.163.com/api/song/lyric?id={result.song_id}&lv=1&kv=0&tv=0&yv=0", + headers=netease_headers(), + timeout=3, + ) + if response.status_code == 200: + payload = response.json() + yrc = payload.get("yrc", {}).get("lyric") + if yrc: + return yrc + lrc = payload.get("lrc", {}).get("lyric") + if lrc: + return lrc + except Exception: + logger.exception("Error downloading NetEase lyrics") + return None +``` + +- [ ] **Step 4: Run lyrics plugin tests to verify they pass** + +Run: `uv run pytest tests/test_plugins/test_netease_lyrics_plugin.py -v` +Expected: PASS + +## Task 4: Add the NetEase Cover Plugin + +**Files:** +- Create: `plugins/builtin/netease_cover/__init__.py` +- Create: `plugins/builtin/netease_cover/plugin.json` +- Create: `plugins/builtin/netease_cover/plugin_main.py` +- Create: `plugins/builtin/netease_cover/lib/__init__.py` +- Create: `plugins/builtin/netease_cover/lib/cover_source.py` +- Create: `plugins/builtin/netease_cover/lib/artist_cover_source.py` +- Test: `tests/test_plugins/test_netease_cover_plugin.py` + +- [ ] **Step 1: Extend the failing cover plugin test with album-cover and artist-cover mapping** + +```python +from types import SimpleNamespace +from unittest.mock import Mock + +from plugins.builtin.netease_cover.lib.artist_cover_source import ( + NetEaseArtistCoverPluginSource, +) +from plugins.builtin.netease_cover.lib.cover_source import NetEaseCoverPluginSource +from plugins.builtin.netease_cover.plugin_main import NetEaseCoverPlugin + + +def test_netease_cover_plugin_registers_cover_and_artist_sources(): + context = Mock() + plugin = NetEaseCoverPlugin() + + plugin.register(context) + + assert context.services.register_cover_source.call_count == 1 + assert context.services.register_artist_cover_source.call_count == 1 + assert isinstance( + context.services.register_cover_source.call_args.args[0], + NetEaseCoverPluginSource, + ) + assert isinstance( + context.services.register_artist_cover_source.call_args.args[0], + NetEaseArtistCoverPluginSource, + ) + + +def test_netease_cover_source_search_maps_album_and_song_results(): + responses = [ + SimpleNamespace( + status_code=200, + json=lambda: { + "code": 200, + "result": { + "albums": [ + { + "id": 1, + "name": "Album 1", + "artist": {"name": "Singer 1"}, + "picUrl": "https://example.com/album.jpg", + } + ] + }, + }, + ), + SimpleNamespace( + status_code=200, + json=lambda: { + "code": 200, + "result": { + "songs": [ + { + "id": 2, + "name": "Song 1", + "artists": [{"name": "Singer 1"}], + "duration": 180000, + "album": { + "name": "Album 1", + "picUrl": "https://example.com/song.jpg", + }, + } + ] + }, + }, + ), + ] + source = NetEaseCoverPluginSource( + SimpleNamespace(get=lambda *_args, **_kwargs: responses.pop(0)) + ) + + results = source.search("Song 1", "Singer 1", "Album 1") + + assert len(results) == 2 + assert results[0].item_id == "1" + assert results[0].album == "Album 1" + assert results[0].source == "netease" + assert results[0].cover_url == "https://example.com/album.jpg?param=500y500" + assert results[1].item_id == "2" + assert results[1].duration == 180.0 + + +def test_netease_artist_cover_source_search_maps_results(): + response = SimpleNamespace( + status_code=200, + json=lambda: { + "code": 200, + "result": { + "artists": [ + { + "id": 1, + "name": "Singer 1", + "albumSize": 8, + "picUrl": "https://example.com/artist.jpg", + } + ] + }, + }, + ) + source = NetEaseArtistCoverPluginSource( + SimpleNamespace(get=lambda *_args, **_kwargs: response) + ) + + results = source.search("Singer 1", limit=5) + + assert len(results) == 1 + assert results[0].artist_id == "1" + assert results[0].name == "Singer 1" + assert results[0].album_count == 8 + assert results[0].source == "netease" + assert results[0].cover_url == "https://example.com/artist.jpg?param=512y512" +``` + +- [ ] **Step 2: Run cover plugin tests to verify they fail** + +Run: `uv run pytest tests/test_plugins/test_netease_cover_plugin.py -v` +Expected: FAIL with missing plugin files and missing NetEase cover source classes + +- [ ] **Step 3: Write the minimal cover plugin implementation** + +```python +from .lib.artist_cover_source import NetEaseArtistCoverPluginSource +from .lib.cover_source import NetEaseCoverPluginSource + + +class NetEaseCoverPlugin: + plugin_id = "netease_cover" + + def register(self, context) -> None: + context.services.register_cover_source( + NetEaseCoverPluginSource(context.http) + ) + context.services.register_artist_cover_source( + NetEaseArtistCoverPluginSource(context.http) + ) + + def unregister(self, context) -> None: + return None +``` + +```python +{ + "id": "netease_cover", + "name": "NetEase Cover", + "version": "1.0.0", + "api_version": "1", + "entrypoint": "plugin_main.py", + "entry_class": "NetEaseCoverPlugin", + "capabilities": ["cover"], + "min_app_version": "0.1.0" +} +``` + +```python +from __future__ import annotations + +import logging + +from harmony_plugin_api.cover import PluginCoverResult +from plugins.builtin.netease_shared.common import ( + build_netease_image_url, + netease_headers, +) + +logger = logging.getLogger(__name__) + + +class NetEaseCoverPluginSource: + source = "netease" + source_id = "netease-cover" + display_name = "NetEase" + name = "NetEase" + + def __init__(self, http_client): + self._http_client = http_client + + def search( + self, + title: str, + artist: str, + album: str = "", + duration: float | None = None, + ) -> list[PluginCoverResult]: + results: list[PluginCoverResult] = [] + album_response = self._http_client.get( + "https://music.163.com/api/search/get/web", + params={"s": f"{artist} {album or title}", "type": 10, "limit": 5}, + headers=netease_headers(), + timeout=5, + ) + if album_response.status_code == 200: + payload = album_response.json() + for item in payload.get("result", {}).get("albums", []): + cover_url = build_netease_image_url( + item.get("picUrl") or item.get("blurPicUrl"), + "500y500", + ) + if not cover_url: + continue + results.append( + PluginCoverResult( + item_id=str(item.get("id", "")), + title=item.get("name", ""), + artist=item.get("artist", {}).get("name", ""), + album=item.get("name", ""), + source="netease", + cover_url=cover_url, + ) + ) + song_response = self._http_client.get( + "https://music.163.com/api/search/get/web", + params={"s": f"{artist} {title}", "type": 1, "limit": 5}, + headers=netease_headers(), + timeout=5, + ) + if song_response.status_code == 200: + payload = song_response.json() + for song in payload.get("result", {}).get("songs", []): + album_info = song.get("album", {}) + cover_url = build_netease_image_url( + album_info.get("picUrl") or album_info.get("blurPicUrl"), + "500y500", + ) + if not cover_url: + continue + results.append( + PluginCoverResult( + item_id=str(song.get("id", "")), + title=song.get("name", ""), + artist=song["artists"][0]["name"] if song.get("artists") else "", + album=album_info.get("name", ""), + duration=(song.get("duration") / 1000) if song.get("duration") else None, + source="netease", + cover_url=cover_url, + ) + ) + return results +``` + +```python +from __future__ import annotations + +import logging + +from harmony_plugin_api.cover import PluginArtistCoverResult +from plugins.builtin.netease_shared.common import ( + build_netease_image_url, + netease_headers, +) + +logger = logging.getLogger(__name__) + + +class NetEaseArtistCoverPluginSource: + source = "netease" + source_id = "netease-artist-cover" + display_name = "NetEase" + name = "NetEase" + + def __init__(self, http_client): + self._http_client = http_client + + def search( + self, + artist_name: str, + limit: int = 10, + ) -> list[PluginArtistCoverResult]: + response = self._http_client.get( + "https://music.163.com/api/search/get/web", + params={"s": artist_name, "type": 100, "limit": limit, "offset": 0}, + headers=netease_headers(), + timeout=5, + ) + if response.status_code != 200: + return [] + payload = response.json() + results: list[PluginArtistCoverResult] = [] + for item in payload.get("result", {}).get("artists", []): + cover_url = build_netease_image_url( + item.get("picUrl") or item.get("img1v1Url"), + "512y512", + ) + if not cover_url: + continue + results.append( + PluginArtistCoverResult( + artist_id=str(item.get("id", "")), + name=item.get("name", ""), + cover_url=cover_url, + album_count=item.get("albumSize", 0), + source="netease", + ) + ) + return results +``` + +- [ ] **Step 4: Run cover plugin tests to verify they pass** + +Run: `uv run pytest tests/test_plugins/test_netease_cover_plugin.py -v` +Expected: PASS + +## Task 5: Remove Host Ownership of NetEase Sources + +**Files:** +- Modify: `services/lyrics/lyrics_service.py` +- Modify: `services/metadata/cover_service.py` +- Modify: `services/sources/lyrics_sources.py` +- Modify: `services/sources/cover_sources.py` +- Modify: `services/sources/artist_cover_sources.py` +- Modify: `services/sources/__init__.py` +- Test: `tests/test_services/test_plugin_lyrics_registry.py` +- Test: `tests/test_services/test_plugin_cover_registry.py` + +- [ ] **Step 1: Remove NetEase from the built-in host source lists** + +```python +@classmethod +def _get_builtin_sources(cls) -> List["LyricsSource"]: + return [] +``` + +```python +def _get_builtin_sources(self) -> List["CoverSource"]: + return [] +``` + +```python +def _get_builtin_artist_sources(self) -> List["ArtistCoverSource"]: + return [] +``` + +- [ ] **Step 2: Remove the migrated NetEase source classes from `services/sources` host modules and package exports** + +```python +from .base import CoverSource, LyricsSource, ArtistCoverSource +from .cover_sources import MusicBrainzCoverSource, SpotifyCoverSource +from .artist_cover_sources import SpotifyArtistCoverSource + +__all__ = [ + "CoverSource", + "LyricsSource", + "ArtistCoverSource", + "MusicBrainzCoverSource", + "SpotifyCoverSource", + "SpotifyArtistCoverSource", +] +``` + +- [ ] **Step 3: Run registry tests to verify host ownership is gone and plugin merging still works** + +Run: `uv run pytest tests/test_services/test_plugin_lyrics_registry.py tests/test_services/test_plugin_cover_registry.py -v` +Expected: PASS + +## Task 6: Restore Full NetEase Behavior and Regression Coverage + +**Files:** +- Modify: `tests/test_plugins/test_netease_lyrics_plugin.py` +- Modify: `tests/test_plugins/test_netease_cover_plugin.py` +- Modify: `plugins/builtin/netease_lyrics/lib/lyrics_source.py` +- Modify: `plugins/builtin/netease_cover/lib/cover_source.py` +- Modify: `plugins/builtin/netease_cover/lib/artist_cover_source.py` + +- [ ] **Step 1: Add failing tests for error handling and NetEase-specific fallbacks** + +```python +def test_netease_lyrics_plugin_source_uses_lrc_fallback_request_when_first_call_has_no_lyrics(): + responses = [ + SimpleNamespace(status_code=200, json=lambda: {"code": 200, "yrc": {}, "lrc": {}}), + SimpleNamespace(status_code=200, json=lambda: {"code": 200, "lrc": {"lyric": "[00:02.00]fallback"}}), + ] + source = NetEaseLyricsPluginSource( + SimpleNamespace(get=lambda *_args, **_kwargs: responses.pop(0)) + ) + + lyrics = source.get_lyrics(SimpleNamespace(song_id="1")) + + assert lyrics == "[00:02.00]fallback" +``` + +```python +def test_netease_cover_source_returns_empty_list_on_request_error(): + source = NetEaseCoverPluginSource( + SimpleNamespace(get=lambda *_args, **_kwargs: (_ for _ in ()).throw(RuntimeError("boom"))) + ) + + assert source.search("Song 1", "Singer 1", "Album 1") == [] +``` + +```python +def test_netease_artist_cover_source_uses_img1v1_url_when_pic_url_missing(): + response = SimpleNamespace( + status_code=200, + json=lambda: { + "code": 200, + "result": { + "artists": [ + { + "id": 1, + "name": "Singer 1", + "albumSize": 8, + "img1v1Url": "https://example.com/artist-alt.jpg", + } + ] + }, + }, + ) + source = NetEaseArtistCoverPluginSource( + SimpleNamespace(get=lambda *_args, **_kwargs: response) + ) + + results = source.search("Singer 1", limit=5) + + assert results[0].cover_url == "https://example.com/artist-alt.jpg?param=512y512" +``` + +- [ ] **Step 2: Run the focused plugin tests to verify they fail** + +Run: `uv run pytest tests/test_plugins/test_netease_lyrics_plugin.py tests/test_plugins/test_netease_cover_plugin.py -v` +Expected: FAIL until the plugins preserve the full host behavior for fallback requests and error handling + +- [ ] **Step 3: Implement the remaining NetEase behavior in the plugins** + +```python +if response.status_code == 200: + payload = response.json() + yrc = payload.get("yrc", {}).get("lyric") + if yrc: + return yrc + lrc = payload.get("lrc", {}).get("lyric") + if lrc: + return lrc + +fallback = self._http_client.get( + f"https://music.163.com/api/song/lyric?id={result.song_id}&lv=1&kv=1&tv=-1", + headers=netease_headers(), + timeout=3, +) +if fallback.status_code != 200: + return None +payload = fallback.json() +if payload.get("code") != 200: + return None +return payload.get("lrc", {}).get("lyric") or payload.get("lyric") +``` + +```python +try: + response = self._http_client.get(...) +except Exception as exc: + logger.debug("NetEase cover search error: %s", exc) + return [] +``` + +- [ ] **Step 4: Run the focused plugin tests to verify they pass** + +Run: `uv run pytest tests/test_plugins/test_netease_lyrics_plugin.py tests/test_plugins/test_netease_cover_plugin.py -v` +Expected: PASS + +## Task 7: Final Verification and Diff Review + +**Files:** +- Test: `tests/test_plugins/test_netease_lyrics_plugin.py` +- Test: `tests/test_plugins/test_netease_cover_plugin.py` +- Test: `tests/test_services/test_plugin_lyrics_registry.py` +- Test: `tests/test_services/test_plugin_cover_registry.py` + +- [ ] **Step 1: Run final focused verification** + +Run: `uv run pytest tests/test_plugins/test_netease_lyrics_plugin.py tests/test_plugins/test_netease_cover_plugin.py tests/test_services/test_plugin_lyrics_registry.py tests/test_services/test_plugin_cover_registry.py -v` +Expected: PASS + +- [ ] **Step 2: Review the final diff** + +Run: `git diff -- plugins/builtin/netease_shared plugins/builtin/netease_lyrics plugins/builtin/netease_cover services/lyrics/lyrics_service.py services/metadata/cover_service.py services/sources/lyrics_sources.py services/sources/cover_sources.py services/sources/artist_cover_sources.py services/sources/__init__.py tests/test_plugins/test_netease_lyrics_plugin.py tests/test_plugins/test_netease_cover_plugin.py tests/test_services/test_plugin_lyrics_registry.py tests/test_services/test_plugin_cover_registry.py docs/superpowers/specs/2026-04-07-netease-plugin-split-design.md docs/superpowers/plans/2026-04-07-netease-plugin-split.md` +Expected: NetEase lyrics, album cover, and artist cover ownership moves from host code to two built-in plugins with a small shared helper package and no unrelated edits diff --git a/docs/superpowers/plans/2026-04-07-unified-foundation-theme-styles.md b/docs/superpowers/plans/2026-04-07-unified-foundation-theme-styles.md new file mode 100644 index 00000000..024e086b --- /dev/null +++ b/docs/superpowers/plans/2026-04-07-unified-foundation-theme-styles.md @@ -0,0 +1,658 @@ +# Unified Foundation Theme Styles Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Make the theme system the single owner of common Qt foundation widget styles and shared wrapper component styles across host UI and built-in plugins. + +**Architecture:** Expand the global stylesheet in `ui/styles.qss` so common Qt controls and shared wrappers are themed centrally, then add a small set of `ThemeManager` popup helper accessors for Qt surfaces that cannot be styled reliably through application-global QSS alone. Refactor host wrappers, host feature views, and built-in plugin UI to stop embedding base QSS for foundation controls and instead use object names, dynamic properties, and host-owned popup helpers. + +**Tech Stack:** Python 3.11, PySide6, pytest, `uv`, Harmony `ThemeManager`, built-in plugin UI bridge + +--- + +## File Map + +- Create: `tests/test_system/test_theme_foundation_styles.py` +- Create: `tests/test_ui/test_dialog_title_bar.py` +- Create: `tests/test_ui/test_foundation_theme_cleanup.py` +- Modify: `system/theme.py` +- Modify: `ui/styles.qss` +- Modify: `system/plugins/plugin_sdk_ui.py` +- Modify: `plugins/builtin/qqmusic/lib/runtime_bridge.py` +- Modify: `ui/dialogs/dialog_title_bar.py` +- Modify: `ui/widgets/title_bar.py` +- Modify: `ui/widgets/toggle_switch.py` +- Modify: `ui/widgets/context_menus.py` +- Modify: `ui/views/cover_hover_popup.py` +- Modify: `ui/views/queue_view.py` +- Modify: `ui/dialogs/base_rename_dialog.py` +- Modify: `ui/dialogs/input_dialog.py` +- Modify: `ui/dialogs/settings_dialog.py` +- Modify: `ui/dialogs/edit_media_info_dialog.py` +- Modify: `ui/dialogs/lyrics_download_dialog.py` +- Modify: `ui/dialogs/base_cover_download_dialog.py` +- Modify: `ui/views/library_view.py` +- Modify: `ui/views/albums_view.py` +- Modify: `ui/views/artists_view.py` +- Modify: `ui/views/genres_view.py` +- Modify: `ui/views/cloud/cloud_drive_view.py` +- Modify: `ui/widgets/equalizer_widget.py` +- Modify: `plugins/builtin/qqmusic/lib/dialog_title_bar.py` +- Modify: `plugins/builtin/qqmusic/lib/login_dialog.py` +- Modify: `plugins/builtin/qqmusic/lib/settings_tab.py` +- Modify: `plugins/builtin/qqmusic/lib/online_music_view.py` +- Modify: `plugins/builtin/qqmusic/lib/context_menus.py` +- Modify: `plugins/builtin/qqmusic/lib/cover_hover_popup.py` +- Modify: `tests/test_system/test_plugin_ui_bridge.py` +- Modify: `tests/test_title_bar.py` +- Modify: `tests/test_plugins/test_qqmusic_plugin.py` + +## Task 1: Lock In Theme API And Selector Contracts + +**Files:** +- Create: `tests/test_system/test_theme_foundation_styles.py` +- Modify: `tests/test_system/test_plugin_ui_bridge.py` +- Test: `tests/test_system/test_theme_foundation_styles.py` +- Test: `tests/test_system/test_plugin_ui_bridge.py` + +- [ ] **Step 1: Write the failing tests** + +```python +from unittest.mock import Mock + +from system.theme import ThemeManager + + +def _build_theme_manager(): + config = Mock() + config.get.return_value = "dark" + ThemeManager._instance = None + return ThemeManager.instance(config) + + +def test_theme_manager_exposes_foundation_popup_helpers(): + tm = _build_theme_manager() + + completer_qss = tm.get_themed_completer_popup_style() + popup_qss = tm.get_themed_popup_surface_style() + + assert "#121212" in completer_qss or "#282828" in completer_qss + assert "QListView" in completer_qss + assert "popupSurface" in popup_qss + assert tm.current_theme.highlight in completer_qss + + +def test_theme_manager_global_stylesheet_covers_foundation_selectors(qapp): + tm = _build_theme_manager() + + tm.apply_global_stylesheet() + stylesheet = qapp.styleSheet() + + assert "QLineEdit" in stylesheet + assert "QCheckBox::indicator" in stylesheet + assert "QGroupBox" in stylesheet + assert "QComboBox" in stylesheet + assert "QDialog[shell=\"true\"]" in stylesheet + assert "QWidget#dialogTitleBar" in stylesheet +``` + +```python +def test_plugin_context_ui_bridge_exposes_foundation_theme_helpers(tmp_path: Path): + config = Mock() + config.get.return_value = "dark" + config.get_language.return_value = "zh" + + ThemeManager._instance = None + ThemeManager.instance(config) + + registry = Mock() + bootstrap = SimpleNamespace( + _plugin_manager=SimpleNamespace(registry=registry), + online_download_service=Mock(), + playback_service=Mock(), + library_service=Mock(), + http_client=Mock(), + event_bus=Mock(), + config=config, + ) + manifest = PluginManifest.from_dict( + { + "id": "qqmusic", + "name": "QQ Music", + "version": "1.0.0", + "api_version": "1", + "entrypoint": "plugin_main.py", + "entry_class": "QQMusicPlugin", + "capabilities": ["sidebar"], + "min_app_version": "0.1.0", + } + ) + + context = BootstrapPluginContextFactory(bootstrap, tmp_path).build(manifest) + + assert callable(context.ui.theme.get_popup_surface_style) + assert callable(context.ui.theme.get_completer_popup_style) +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `uv run pytest tests/test_system/test_theme_foundation_styles.py tests/test_system/test_plugin_ui_bridge.py -v` +Expected: FAIL with `AttributeError` for missing theme helper methods and/or missing foundation selectors in the application stylesheet + +- [ ] **Step 3: Write minimal implementation** + +```python +class ThemeManager(QObject): + @staticmethod + def get_completer_popup_style() -> str: + return """ + QListView { + background-color: %background_alt%; + border: 1px solid %border%; + color: %text%; + selection-background-color: %highlight%; + selection-color: %background%; + outline: none; + } + QListView::item { + padding: 8px 12px; + } + """ + + @staticmethod + def get_popup_surface_style() -> str: + return """ + QWidget[popupSurface="true"] { + background-color: %background_alt%; + border: 1px solid %border%; + border-radius: 10px; + color: %text%; + } + """ + + def get_themed_completer_popup_style(self) -> str: + return self.get_qss(self.get_completer_popup_style()) + + def get_themed_popup_surface_style(self) -> str: + return self.get_qss(self.get_popup_surface_style()) +``` + +```python +class PluginThemeBridgeImpl: + def get_popup_surface_style(self) -> str: + from system.theme import ThemeManager + return ThemeManager.instance().get_themed_popup_surface_style() + + def get_completer_popup_style(self) -> str: + from system.theme import ThemeManager + return ThemeManager.instance().get_themed_completer_popup_style() +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `uv run pytest tests/test_system/test_theme_foundation_styles.py tests/test_system/test_plugin_ui_bridge.py -v` +Expected: PASS + +- [ ] **Step 5: Commit** + +```bash +git add tests/test_system/test_theme_foundation_styles.py tests/test_system/test_plugin_ui_bridge.py system/theme.py system/plugins/plugin_sdk_ui.py +git commit -m "统一主题基础样式接口" +``` + +## Task 2: Expand The Global Foundation Stylesheet + +**Files:** +- Modify: `ui/styles.qss` +- Modify: `system/theme.py` +- Test: `tests/test_system/test_theme_foundation_styles.py` + +- [ ] **Step 1: Write the failing test for concrete foundation selectors** + +```python +def test_theme_manager_global_stylesheet_includes_wrapper_variants(qapp): + tm = _build_theme_manager() + + tm.apply_global_stylesheet() + stylesheet = qapp.styleSheet() + + assert "QPushButton[role=\"primary\"]" in stylesheet + assert "QLineEdit[variant=\"search\"]" in stylesheet + assert "QComboBox[compact=\"true\"]" in stylesheet + assert "QWidget#titleBar" in stylesheet + assert "QPushButton#winBtn" in stylesheet + assert "QPushButton#dialogCloseBtn" in stylesheet +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `uv run pytest tests/test_system/test_theme_foundation_styles.py::test_theme_manager_global_stylesheet_includes_wrapper_variants -v` +Expected: FAIL because the current `ui/styles.qss` does not yet define the required wrapper and variant selectors + +- [ ] **Step 3: Write minimal stylesheet expansion** + +```css +QDialog[shell="true"] { + background-color: %background_alt%; + color: %text%; + border: 1px solid %border%; + border-radius: 12px; +} + +QLineEdit, +QTextEdit { + background-color: %background_hover%; + color: %text%; + border: 1px solid %border%; + border-radius: 8px; + padding: 8px 12px; +} + +QLineEdit[variant="search"] { + padding-right: 30px; +} + +QCheckBox, +QRadioButton, +QGroupBox, +QComboBox, +QSpinBox, +QProgressBar, +QMenu, +QWidget#titleBar, +QWidget#dialogTitleBar, +QPushButton#winBtn, +QPushButton#closeBtn, +QPushButton#dialogCloseBtn { + color: %text%; +} + +QPushButton#winBtn, +QPushButton#closeBtn, +QPushButton#dialogCloseBtn { + background: transparent; + border: none; + min-width: 28px; + min-height: 28px; + border-radius: 6px; +} + +QWidget#dialogTitleBar { + background-color: %background_alt%; + border-top-left-radius: 12px; + border-top-right-radius: 12px; + border-bottom: 1px solid %border%; +} +``` + +```python +def apply_global_stylesheet(self): + app = QApplication.instance() + if not app: + return + + if self._global_qss_template is None: + qss_path = Path(__file__).parent.parent / "ui" / "styles.qss" + self._global_qss_template = qss_path.read_text(encoding="utf-8") + + themed_qss = self.get_qss(self._global_qss_template) + themed_qss += "\n" + self.get_themed_popup_surface_style() + app.setStyleSheet(themed_qss) +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `uv run pytest tests/test_system/test_theme_foundation_styles.py -v` +Expected: PASS + +- [ ] **Step 5: Commit** + +```bash +git add ui/styles.qss system/theme.py tests/test_system/test_theme_foundation_styles.py +git commit -m "扩展全局基础控件主题样式" +``` + +## Task 3: Refactor Shared Host Wrappers To Theme-Owned Styling + +**Files:** +- Create: `tests/test_ui/test_dialog_title_bar.py` +- Modify: `tests/test_title_bar.py` +- Modify: `ui/dialogs/dialog_title_bar.py` +- Modify: `ui/widgets/title_bar.py` +- Modify: `ui/widgets/toggle_switch.py` +- Modify: `ui/widgets/context_menus.py` +- Modify: `ui/views/cover_hover_popup.py` +- Modify: `ui/views/queue_view.py` + +- [ ] **Step 1: Write the failing wrapper tests** + +```python +from PySide6.QtWidgets import QDialog, QVBoxLayout + +from ui.dialogs.dialog_title_bar import setup_equalizer_title_layout + + +def test_dialog_title_bar_uses_global_theme_selectors(qtbot): + dialog = QDialog() + qtbot.addWidget(dialog) + container = QVBoxLayout(dialog) + + _, controller = setup_equalizer_title_layout(dialog, container, "Title") + + assert controller.title_bar.objectName() == "dialogTitleBar" + assert controller.title_label.objectName() == "dialogTitle" + assert controller.close_btn.objectName() == "dialogCloseBtn" + assert controller.title_bar.styleSheet() == "" + assert controller.close_btn.styleSheet() == "" +``` + +```python +def test_title_bar_relies_on_object_names_instead_of_local_qss(qtbot, patch_theme): + from ui.widgets.title_bar import TitleBar + + window = QMainWindow() + qtbot.addWidget(window) + bar = TitleBar(window) + + assert bar.objectName() == "titleBar" + assert bar._btn_min.objectName() == "winBtn" + assert bar._btn_close.objectName() == "closeBtn" + assert bar.styleSheet() == "" +``` + +```python +def test_local_track_context_menu_uses_theme_owned_qmenu_styles(qtbot): + menu = LocalTrackContextMenu().build_menu([], set()) + assert menu is None +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `uv run pytest tests/test_ui/test_dialog_title_bar.py tests/test_title_bar.py -v` +Expected: FAIL because shared title bars and wrappers still set local QSS templates directly + +- [ ] **Step 3: Write minimal wrapper refactor** + +```python +@dataclass +class DialogTitleBarController: + dialog: QDialog + title_bar: QWidget + title_label: QLabel + close_btn: QPushButton + + def refresh_theme(self): + self.close_btn.setIcon(get_icon(IconName.TIMES, None, 14)) + for widget in (self.title_bar, self.title_label, self.close_btn): + style = widget.style() + if style is not None: + style.unpolish(widget) + style.polish(widget) +``` + +```python +class TitleBar(QWidget): + def __init__(self, parent=None): + super().__init__(parent) + self.setObjectName("titleBar") + self._title_label.setObjectName("titleLabel") + self._btn_min.setObjectName("winBtn") + self._btn_max.setObjectName("winBtn") + self._btn_close.setObjectName("closeBtn") + + def refresh_theme(self): + self._btn_min.setIcon(get_icon(IconName.MINIMIZE, None, 14)) + self._btn_max.setIcon(get_icon(IconName.MAXIMIZE, None, 14)) + self._btn_close.setIcon(get_icon(IconName.TIMES, None, 14)) + for widget in (self, self._title_label, self._btn_min, self._btn_max, self._btn_close): + style = widget.style() + if style is not None: + style.unpolish(widget) + style.polish(widget) +``` + +```python +class ToggleSwitch(QWidget): + def refresh_theme(self): + theme = ThemeManager.instance().current_theme + self.bg_on = QColor(theme.highlight) + self.bg_off = QColor(theme.border) + self.bg_disabled = QColor(theme.background_hover) + self.update() +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `uv run pytest tests/test_ui/test_dialog_title_bar.py tests/test_title_bar.py -v` +Expected: PASS + +- [ ] **Step 5: Commit** + +```bash +git add tests/test_ui/test_dialog_title_bar.py tests/test_title_bar.py ui/dialogs/dialog_title_bar.py ui/widgets/title_bar.py ui/widgets/toggle_switch.py ui/widgets/context_menus.py ui/views/cover_hover_popup.py ui/views/queue_view.py +git commit -m "收敛宿主共享组件主题样式" +``` + +## Task 4: Remove Host Feature-Level Foundation QSS Overrides + +**Files:** +- Create: `tests/test_ui/test_foundation_theme_cleanup.py` +- Modify: `ui/dialogs/base_rename_dialog.py` +- Modify: `ui/dialogs/input_dialog.py` +- Modify: `ui/dialogs/settings_dialog.py` +- Modify: `ui/dialogs/edit_media_info_dialog.py` +- Modify: `ui/dialogs/lyrics_download_dialog.py` +- Modify: `ui/dialogs/base_cover_download_dialog.py` +- Modify: `ui/views/library_view.py` +- Modify: `ui/views/albums_view.py` +- Modify: `ui/views/artists_view.py` +- Modify: `ui/views/genres_view.py` +- Modify: `ui/views/cloud/cloud_drive_view.py` +- Modify: `ui/widgets/equalizer_widget.py` + +- [ ] **Step 1: Write the failing cleanup tests** + +```python +from unittest.mock import Mock + +from system.theme import ThemeManager +from ui.dialogs.input_dialog import InputDialog +from ui.views.albums_view import AlbumsView + + +def test_input_dialog_marks_shell_and_uses_unstyled_foundation_children(qtbot): + config = Mock() + config.get.return_value = "dark" + ThemeManager._instance = None + ThemeManager.instance(config) + + dialog = InputDialog("Title", "Prompt", "value") + qtbot.addWidget(dialog) + + assert dialog.property("shell") is True + assert dialog._input.styleSheet() == "" + + +def test_albums_view_search_input_uses_theme_variant_instead_of_local_qss(qtbot, mock_theme_config): + ThemeManager._instance = None + ThemeManager.instance(mock_theme_config) + view = AlbumsView() + qtbot.addWidget(view) + + assert view._search_input.property("variant") == "search" + assert view._search_input.styleSheet() == "" +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `uv run pytest tests/test_ui/test_foundation_theme_cleanup.py -v` +Expected: FAIL because dialogs and views still assign local `QLineEdit`, `QComboBox`, `QCheckBox`, `QGroupBox`, and shell styles + +- [ ] **Step 3: Write minimal host cleanup** + +```python +self.setProperty("shell", True) +self._input.setProperty("variant", "form") +self._search_input.setProperty("variant", "search") +self._quality_combo.setProperty("variant", "settings") +self._effects_enabled_checkbox.setProperty("variant", "settings") +self._effects_group.setProperty("variant", "settings") +``` + +```python +def refresh_theme(self): + for widget in ( + self, + self._input, + self._search_input, + self._quality_combo, + self._effects_enabled_checkbox, + self._effects_group, + ): + style = widget.style() + if style is not None: + style.unpolish(widget) + style.polish(widget) +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `uv run pytest tests/test_ui/test_foundation_theme_cleanup.py tests/test_ui/test_cover_download_dialog.py tests/test_ui/test_equalizer_widget.py -v` +Expected: PASS + +- [ ] **Step 5: Commit** + +```bash +git add tests/test_ui/test_foundation_theme_cleanup.py ui/dialogs/base_rename_dialog.py ui/dialogs/input_dialog.py ui/dialogs/settings_dialog.py ui/dialogs/edit_media_info_dialog.py ui/dialogs/lyrics_download_dialog.py ui/dialogs/base_cover_download_dialog.py ui/views/library_view.py ui/views/albums_view.py ui/views/artists_view.py ui/views/genres_view.py ui/views/cloud/cloud_drive_view.py ui/widgets/equalizer_widget.py +git commit -m "移除页面级基础控件样式重复定义" +``` + +## Task 5: Unify Plugin Foundation Styles Through The Host Theme Bridge + +**Files:** +- Modify: `system/plugins/plugin_sdk_ui.py` +- Modify: `plugins/builtin/qqmusic/lib/runtime_bridge.py` +- Modify: `plugins/builtin/qqmusic/lib/dialog_title_bar.py` +- Modify: `plugins/builtin/qqmusic/lib/login_dialog.py` +- Modify: `plugins/builtin/qqmusic/lib/settings_tab.py` +- Modify: `plugins/builtin/qqmusic/lib/online_music_view.py` +- Modify: `plugins/builtin/qqmusic/lib/context_menus.py` +- Modify: `plugins/builtin/qqmusic/lib/cover_hover_popup.py` +- Modify: `tests/test_plugins/test_qqmusic_plugin.py` + +- [ ] **Step 1: Write the failing plugin tests** + +```python +def test_root_view_search_input_uses_theme_variant_and_host_popup_helpers(qtbot): + settings = Mock() + settings.get.side_effect = lambda key, default=None: { + "nick": "", + "quality": "320", + "search_history": [], + "ranking_view_mode": "table", + }.get(key, default) + context = Mock(settings=settings) + context.services.media = Mock() + provider = Mock() + provider.is_logged_in.return_value = False + provider.get_top_lists.return_value = [] + provider.get_top_list_tracks.return_value = [] + provider.get_recommendations.return_value = [] + provider.get_favorites.return_value = [] + provider.get_hotkeys.return_value = [] + provider.complete.return_value = [] + + view = QQMusicRootView(context, provider) + qtbot.addWidget(view) + + assert view._search_input.property("variant") == "search" + assert view._search_input.styleSheet() == "" + assert view._completer.popup().styleSheet() +``` + +```python +def test_login_dialog_uses_host_owned_dialog_title_bar_and_shell_property(qtbot): + context = Mock() + dialog = QQMusicLoginDialog(context) + qtbot.addWidget(dialog) + + assert dialog.property("shell") is True + assert dialog._title_bar_controller.title_bar.styleSheet() == "" +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `uv run pytest tests/test_plugins/test_qqmusic_plugin.py::test_root_view_search_input_uses_theme_variant_and_host_popup_helpers tests/test_plugins/test_qqmusic_plugin.py::test_login_dialog_uses_host_owned_dialog_title_bar_and_shell_property -v` +Expected: FAIL because plugin UI still embeds local `QLineEdit`, `QComboBox`, popup, and dialog title bar templates + +- [ ] **Step 3: Write minimal plugin bridge refactor** + +```python +def get_popup_surface_style() -> str: + return _ui_module().get_popup_surface_style() + + +def get_completer_popup_style() -> str: + return _ui_module().get_completer_popup_style() +``` + +```python +class CustomQCompleter(QCompleter): + def _apply_theme(self): + self.popup().setStyleSheet(get_completer_popup_style()) +``` + +```python +self.setProperty("shell", True) +self._search_input.setProperty("variant", "search") +self._combo.setProperty("variant", "settings") +self.setProperty("popupSurface", True) +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `uv run pytest tests/test_plugins/test_qqmusic_plugin.py -v` +Expected: PASS + +- [ ] **Step 5: Commit** + +```bash +git add system/plugins/plugin_sdk_ui.py plugins/builtin/qqmusic/lib/runtime_bridge.py plugins/builtin/qqmusic/lib/dialog_title_bar.py plugins/builtin/qqmusic/lib/login_dialog.py plugins/builtin/qqmusic/lib/settings_tab.py plugins/builtin/qqmusic/lib/online_music_view.py plugins/builtin/qqmusic/lib/context_menus.py plugins/builtin/qqmusic/lib/cover_hover_popup.py tests/test_plugins/test_qqmusic_plugin.py +git commit -m "统一插件基础控件主题入口" +``` + +## Task 6: Focused And Broad Verification + +**Files:** +- Test: `tests/test_system/test_theme_foundation_styles.py` +- Test: `tests/test_ui/test_dialog_title_bar.py` +- Test: `tests/test_ui/test_foundation_theme_cleanup.py` +- Test: `tests/test_system/test_plugin_ui_bridge.py` +- Test: `tests/test_plugins/test_qqmusic_plugin.py` + +- [ ] **Step 1: Run focused verification** + +Run: `uv run pytest tests/test_system/test_theme_foundation_styles.py tests/test_ui/test_dialog_title_bar.py tests/test_ui/test_foundation_theme_cleanup.py tests/test_system/test_plugin_ui_bridge.py tests/test_plugins/test_qqmusic_plugin.py -v` +Expected: PASS + +- [ ] **Step 2: Run regression coverage for nearby UI surfaces** + +Run: `uv run pytest tests/test_ui/test_cover_download_dialog.py tests/test_ui/test_equalizer_widget.py tests/test_ui/test_online_music_view_focus.py tests/test_ui/test_plugin_settings_tab.py -v` +Expected: PASS + +- [ ] **Step 3: Run lint on touched files** + +Run: `uv run ruff check system/theme.py system/plugins/plugin_sdk_ui.py ui/dialogs/dialog_title_bar.py ui/widgets/title_bar.py ui/widgets/toggle_switch.py ui/widgets/context_menus.py ui/views/cover_hover_popup.py ui/dialogs/base_rename_dialog.py ui/dialogs/input_dialog.py ui/dialogs/settings_dialog.py ui/dialogs/edit_media_info_dialog.py ui/dialogs/lyrics_download_dialog.py ui/dialogs/base_cover_download_dialog.py ui/views/library_view.py ui/views/albums_view.py ui/views/artists_view.py ui/views/genres_view.py ui/views/cloud/cloud_drive_view.py ui/widgets/equalizer_widget.py plugins/builtin/qqmusic/lib/runtime_bridge.py plugins/builtin/qqmusic/lib/dialog_title_bar.py plugins/builtin/qqmusic/lib/login_dialog.py plugins/builtin/qqmusic/lib/settings_tab.py plugins/builtin/qqmusic/lib/online_music_view.py plugins/builtin/qqmusic/lib/context_menus.py plugins/builtin/qqmusic/lib/cover_hover_popup.py` +Expected: PASS + +- [ ] **Step 4: Review diff** + +Run: `git diff --stat HEAD~5..HEAD` +Expected: only theme-system, host wrapper, host view cleanup, plugin bridge, and related test files are present + +- [ ] **Step 5: Commit final verification if needed** + +```bash +git add tests/test_system/test_theme_foundation_styles.py tests/test_ui/test_dialog_title_bar.py tests/test_ui/test_foundation_theme_cleanup.py tests/test_system/test_plugin_ui_bridge.py tests/test_plugins/test_qqmusic_plugin.py +git commit -m "验证统一基础主题样式改造" +``` From ca731abc7f67ee819010d1fa1ef8d717b15164a8 Mon Sep 17 00:00:00 2001 From: Har01d Date: Tue, 7 Apr 2026 19:20:17 +0800 Subject: [PATCH 072/157] =?UTF-8?q?=E7=BD=91=E6=98=93=E4=BA=91=E6=8F=92?= =?UTF-8?q?=E4=BB=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- plugins/builtin/netease_cover/__init__.py | 3 + plugins/builtin/netease_cover/lib/__init__.py | 4 + .../netease_cover/lib/artist_cover_source.py | 62 + .../builtin/netease_cover/lib/cover_source.py | 92 + plugins/builtin/netease_cover/plugin.json | 10 + plugins/builtin/netease_cover/plugin_main.py | 17 + plugins/builtin/netease_lyrics/__init__.py | 3 + .../builtin/netease_lyrics/lib/__init__.py | 3 + .../netease_lyrics/lib/lyrics_source.py | 92 + plugins/builtin/netease_lyrics/plugin.json | 10 + plugins/builtin/netease_lyrics/plugin_main.py | 13 + plugins/builtin/netease_shared/__init__.py | 3 + plugins/builtin/netease_shared/common.py | 19 + plugins/builtin/qqmusic/lib/root_view.py | 1 - services/lyrics/lyrics_service.py | 8 +- services/metadata/cover_service.py | 14 +- services/sources/__init__.py | 9 - services/sources/artist_cover_sources.py | 64 - services/sources/cover_sources.py | 107 - services/sources/lyrics_sources.py | 133 +- tests/test_app/test_qqmusic_host_cleanup.py | 12 +- tests/test_artist_navigation.py | 142 +- .../test_plugins/test_netease_cover_plugin.py | 145 + .../test_netease_lyrics_plugin.py | 108 + tests/test_plugins/test_qqmusic_plugin.py | 3692 +---------------- tests/test_qthread_fix.py | 20 +- .../test_plugin_cover_registry.py | 2 + .../test_plugin_lyrics_registry.py | 3 +- 28 files changed, 691 insertions(+), 4100 deletions(-) create mode 100644 plugins/builtin/netease_cover/__init__.py create mode 100644 plugins/builtin/netease_cover/lib/__init__.py create mode 100644 plugins/builtin/netease_cover/lib/artist_cover_source.py create mode 100644 plugins/builtin/netease_cover/lib/cover_source.py create mode 100644 plugins/builtin/netease_cover/plugin.json create mode 100644 plugins/builtin/netease_cover/plugin_main.py create mode 100644 plugins/builtin/netease_lyrics/__init__.py create mode 100644 plugins/builtin/netease_lyrics/lib/__init__.py create mode 100644 plugins/builtin/netease_lyrics/lib/lyrics_source.py create mode 100644 plugins/builtin/netease_lyrics/plugin.json create mode 100644 plugins/builtin/netease_lyrics/plugin_main.py create mode 100644 plugins/builtin/netease_shared/__init__.py create mode 100644 plugins/builtin/netease_shared/common.py delete mode 100644 plugins/builtin/qqmusic/lib/root_view.py create mode 100644 tests/test_plugins/test_netease_cover_plugin.py create mode 100644 tests/test_plugins/test_netease_lyrics_plugin.py diff --git a/plugins/builtin/netease_cover/__init__.py b/plugins/builtin/netease_cover/__init__.py new file mode 100644 index 00000000..01b31893 --- /dev/null +++ b/plugins/builtin/netease_cover/__init__.py @@ -0,0 +1,3 @@ +from .plugin_main import NetEaseCoverPlugin + +__all__ = ["NetEaseCoverPlugin"] diff --git a/plugins/builtin/netease_cover/lib/__init__.py b/plugins/builtin/netease_cover/lib/__init__.py new file mode 100644 index 00000000..7300d1a1 --- /dev/null +++ b/plugins/builtin/netease_cover/lib/__init__.py @@ -0,0 +1,4 @@ +from .artist_cover_source import NetEaseArtistCoverPluginSource +from .cover_source import NetEaseCoverPluginSource + +__all__ = ["NetEaseArtistCoverPluginSource", "NetEaseCoverPluginSource"] diff --git a/plugins/builtin/netease_cover/lib/artist_cover_source.py b/plugins/builtin/netease_cover/lib/artist_cover_source.py new file mode 100644 index 00000000..d477c112 --- /dev/null +++ b/plugins/builtin/netease_cover/lib/artist_cover_source.py @@ -0,0 +1,62 @@ +from __future__ import annotations + +import logging + +from harmony_plugin_api.cover import PluginArtistCoverResult +from plugins.builtin.netease_shared.common import ( + build_netease_image_url, + netease_headers, +) + +logger = logging.getLogger(__name__) + + +class NetEaseArtistCoverPluginSource: + source = "netease" + source_id = "netease-artist-cover" + display_name = "NetEase" + name = "NetEase" + + def __init__(self, http_client): + self._http_client = http_client + + def search( + self, + artist_name: str, + limit: int = 10, + ) -> list[PluginArtistCoverResult]: + try: + response = self._http_client.get( + "https://music.163.com/api/search/get/web", + params={"s": artist_name, "type": 100, "limit": limit, "offset": 0}, + headers=netease_headers(), + timeout=5, + ) + if response.status_code != 200: + return [] + + payload = response.json() + if payload.get("code") != 200: + return [] + + results: list[PluginArtistCoverResult] = [] + for item in payload.get("result", {}).get("artists", []): + cover_url = build_netease_image_url( + item.get("picUrl") or item.get("img1v1Url"), + "512y512", + ) + if not cover_url: + continue + results.append( + PluginArtistCoverResult( + artist_id=str(item.get("id", "")), + name=item.get("name", ""), + source="netease", + cover_url=cover_url, + album_count=item.get("albumSize", 0), + ) + ) + return results + except Exception as exc: + logger.debug("NetEase artist cover search error: %s", exc) + return [] diff --git a/plugins/builtin/netease_cover/lib/cover_source.py b/plugins/builtin/netease_cover/lib/cover_source.py new file mode 100644 index 00000000..c8a6b4af --- /dev/null +++ b/plugins/builtin/netease_cover/lib/cover_source.py @@ -0,0 +1,92 @@ +from __future__ import annotations + +import logging + +from harmony_plugin_api.cover import PluginCoverResult +from plugins.builtin.netease_shared.common import ( + build_netease_image_url, + netease_headers, +) + +logger = logging.getLogger(__name__) + + +class NetEaseCoverPluginSource: + source = "netease" + source_id = "netease-cover" + display_name = "NetEase" + name = "NetEase" + + def __init__(self, http_client): + self._http_client = http_client + + def search( + self, + title: str, + artist: str, + album: str = "", + duration: float | None = None, + ) -> list[PluginCoverResult]: + results: list[PluginCoverResult] = [] + + try: + album_response = self._http_client.get( + "https://music.163.com/api/search/get/web", + params={"s": f"{artist} {album or title}", "type": 10, "limit": 5}, + headers=netease_headers(), + timeout=5, + ) + if album_response.status_code == 200: + payload = album_response.json() + if payload.get("code") == 200: + for item in payload.get("result", {}).get("albums", []): + cover_url = build_netease_image_url( + item.get("picUrl") or item.get("blurPicUrl"), + "500y500", + ) + if not cover_url: + continue + results.append( + PluginCoverResult( + item_id=str(item.get("id", "")), + title=item.get("name", ""), + artist=item.get("artist", {}).get("name", ""), + album=item.get("name", ""), + source="netease", + cover_url=cover_url, + ) + ) + + song_response = self._http_client.get( + "https://music.163.com/api/search/get/web", + params={"s": f"{artist} {title}", "type": 1, "limit": 5}, + headers=netease_headers(), + timeout=5, + ) + if song_response.status_code == 200: + payload = song_response.json() + if payload.get("code") == 200: + for song in payload.get("result", {}).get("songs", []): + album_info = song.get("album", {}) + cover_url = build_netease_image_url( + album_info.get("picUrl") or album_info.get("blurPicUrl"), + "500y500", + ) + if not cover_url: + continue + results.append( + PluginCoverResult( + item_id=str(song.get("id", "")), + title=song.get("name", ""), + artist=song["artists"][0]["name"] if song.get("artists") else "", + album=album_info.get("name", ""), + duration=(song.get("duration") / 1000) if song.get("duration") else None, + source="netease", + cover_url=cover_url, + ) + ) + except Exception as exc: + logger.debug("NetEase cover search error: %s", exc) + return [] + + return results diff --git a/plugins/builtin/netease_cover/plugin.json b/plugins/builtin/netease_cover/plugin.json new file mode 100644 index 00000000..2fea9d9e --- /dev/null +++ b/plugins/builtin/netease_cover/plugin.json @@ -0,0 +1,10 @@ +{ + "id": "netease_cover", + "name": "NetEase Cover", + "version": "1.0.0", + "api_version": "1", + "entrypoint": "plugin_main.py", + "entry_class": "NetEaseCoverPlugin", + "capabilities": ["cover"], + "min_app_version": "0.1.0" +} diff --git a/plugins/builtin/netease_cover/plugin_main.py b/plugins/builtin/netease_cover/plugin_main.py new file mode 100644 index 00000000..bf6d8fcc --- /dev/null +++ b/plugins/builtin/netease_cover/plugin_main.py @@ -0,0 +1,17 @@ +from __future__ import annotations + +from .lib.artist_cover_source import NetEaseArtistCoverPluginSource +from .lib.cover_source import NetEaseCoverPluginSource + + +class NetEaseCoverPlugin: + plugin_id = "netease_cover" + + def register(self, context) -> None: + context.services.register_cover_source(NetEaseCoverPluginSource(context.http)) + context.services.register_artist_cover_source( + NetEaseArtistCoverPluginSource(context.http) + ) + + def unregister(self, context) -> None: + return None diff --git a/plugins/builtin/netease_lyrics/__init__.py b/plugins/builtin/netease_lyrics/__init__.py new file mode 100644 index 00000000..1ecda1b2 --- /dev/null +++ b/plugins/builtin/netease_lyrics/__init__.py @@ -0,0 +1,3 @@ +from .plugin_main import NetEaseLyricsPlugin + +__all__ = ["NetEaseLyricsPlugin"] diff --git a/plugins/builtin/netease_lyrics/lib/__init__.py b/plugins/builtin/netease_lyrics/lib/__init__.py new file mode 100644 index 00000000..6e0c8908 --- /dev/null +++ b/plugins/builtin/netease_lyrics/lib/__init__.py @@ -0,0 +1,3 @@ +from .lyrics_source import NetEaseLyricsPluginSource + +__all__ = ["NetEaseLyricsPluginSource"] diff --git a/plugins/builtin/netease_lyrics/lib/lyrics_source.py b/plugins/builtin/netease_lyrics/lib/lyrics_source.py new file mode 100644 index 00000000..36cd988f --- /dev/null +++ b/plugins/builtin/netease_lyrics/lib/lyrics_source.py @@ -0,0 +1,92 @@ +from __future__ import annotations + +import logging + +from harmony_plugin_api.lyrics import PluginLyricsResult +from plugins.builtin.netease_shared.common import netease_headers + +logger = logging.getLogger(__name__) + + +class NetEaseLyricsPluginSource: + source_id = "netease" + display_name = "NetEase" + name = "NetEase" + + def __init__(self, http_client) -> None: + self._http_client = http_client + + def search( + self, + title: str, + artist: str, + limit: int = 10, + ) -> list[PluginLyricsResult]: + response = self._http_client.get( + "https://music.163.com/api/search/get/web", + params={"s": f"{artist} {title}", "type": "1", "limit": str(limit)}, + headers=netease_headers(), + timeout=3, + ) + if response.status_code != 200: + return [] + + payload = response.json() + if payload.get("code") != 200: + return [] + + results: list[PluginLyricsResult] = [] + for song in payload.get("result", {}).get("songs", []): + album = song.get("album") or {} + cover_url = album.get("picUrl") + if not cover_url and album.get("pic"): + pic = str(album.get("pic")) + cover_url = f"https://p1.music.126.net/{pic}/{pic}.jpg" + + results.append( + PluginLyricsResult( + song_id=str(song["id"]), + title=song.get("name", ""), + artist=song["artists"][0]["name"] if song.get("artists") else "", + album=album.get("name", ""), + duration=(song.get("duration") / 1000) if song.get("duration") else None, + source="netease", + cover_url=cover_url, + supports_yrc=True, + ) + ) + return results + + def get_lyrics(self, result: PluginLyricsResult) -> str | None: + try: + response = self._http_client.get( + f"https://music.163.com/api/song/lyric?id={result.song_id}&lv=1&kv=0&tv=0&yv=0", + headers=netease_headers(), + timeout=3, + ) + if response.status_code == 200: + payload = response.json() + if payload.get("code") == 200: + yrc = payload.get("yrc", {}).get("lyric") + if yrc: + return yrc + lrc = payload.get("lrc", {}).get("lyric") + if lrc: + return lrc + + fallback = self._http_client.get( + f"https://music.163.com/api/song/lyric?id={result.song_id}&lv=1&kv=1&tv=-1", + headers=netease_headers(), + timeout=3, + ) + if fallback.status_code != 200: + return None + + payload = fallback.json() + if payload.get("code") != 200: + return None + + return payload.get("lrc", {}).get("lyric") or payload.get("lyric") + except Exception: + logger.exception("Error downloading NetEase lyrics") + return None diff --git a/plugins/builtin/netease_lyrics/plugin.json b/plugins/builtin/netease_lyrics/plugin.json new file mode 100644 index 00000000..429bce29 --- /dev/null +++ b/plugins/builtin/netease_lyrics/plugin.json @@ -0,0 +1,10 @@ +{ + "id": "netease_lyrics", + "name": "NetEase Lyrics", + "version": "1.0.0", + "api_version": "1", + "entrypoint": "plugin_main.py", + "entry_class": "NetEaseLyricsPlugin", + "capabilities": ["lyrics_source"], + "min_app_version": "0.1.0" +} diff --git a/plugins/builtin/netease_lyrics/plugin_main.py b/plugins/builtin/netease_lyrics/plugin_main.py new file mode 100644 index 00000000..1d2a50ab --- /dev/null +++ b/plugins/builtin/netease_lyrics/plugin_main.py @@ -0,0 +1,13 @@ +from __future__ import annotations + +from .lib.lyrics_source import NetEaseLyricsPluginSource + + +class NetEaseLyricsPlugin: + plugin_id = "netease_lyrics" + + def register(self, context) -> None: + context.services.register_lyrics_source(NetEaseLyricsPluginSource(context.http)) + + def unregister(self, context) -> None: + return None diff --git a/plugins/builtin/netease_shared/__init__.py b/plugins/builtin/netease_shared/__init__.py new file mode 100644 index 00000000..1cc567e8 --- /dev/null +++ b/plugins/builtin/netease_shared/__init__.py @@ -0,0 +1,3 @@ +from .common import build_netease_image_url, netease_headers + +__all__ = ["build_netease_image_url", "netease_headers"] diff --git a/plugins/builtin/netease_shared/common.py b/plugins/builtin/netease_shared/common.py new file mode 100644 index 00000000..53fa9d60 --- /dev/null +++ b/plugins/builtin/netease_shared/common.py @@ -0,0 +1,19 @@ +from __future__ import annotations + + +def netease_headers() -> dict[str, str]: + return { + "User-Agent": ( + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) " + "AppleWebKit/537.36" + ), + "Referer": "https://music.163.com/", + } + + +def build_netease_image_url(url: str | None, size: str) -> str | None: + if not url: + return None + if "?" in url: + return url + return f"{url}?param={size}" diff --git a/plugins/builtin/qqmusic/lib/root_view.py b/plugins/builtin/qqmusic/lib/root_view.py deleted file mode 100644 index 9d48db4f..00000000 --- a/plugins/builtin/qqmusic/lib/root_view.py +++ /dev/null @@ -1 +0,0 @@ -from __future__ import annotations diff --git a/services/lyrics/lyrics_service.py b/services/lyrics/lyrics_service.py index 4656ef42..ab388e89 100644 --- a/services/lyrics/lyrics_service.py +++ b/services/lyrics/lyrics_service.py @@ -58,13 +58,7 @@ class LyricsService: @classmethod def _get_builtin_sources(cls) -> List["LyricsSource"]: """Get built-in host lyrics sources.""" - from services.sources import ( - NetEaseLyricsSource, - ) - http_client = _get_http_client() - return [ - NetEaseLyricsSource(http_client), - ] + return [] @classmethod def _get_sources(cls) -> List["LyricsSource"]: diff --git a/services/metadata/cover_service.py b/services/metadata/cover_service.py index f247e8eb..cb82939a 100644 --- a/services/metadata/cover_service.py +++ b/services/metadata/cover_service.py @@ -45,12 +45,7 @@ def __init__( def _get_builtin_sources(self) -> List["CoverSource"]: """Get built-in host cover sources.""" - from services.sources import ( - NetEaseCoverSource, - ) - return [ - NetEaseCoverSource(self.http_client), - ] + return [] def _get_sources(self) -> List["CoverSource"]: """Get cover sources, including plugin-provided sources.""" @@ -62,12 +57,7 @@ def _get_sources(self) -> List["CoverSource"]: def _get_builtin_artist_sources(self) -> List["ArtistCoverSource"]: """Get built-in host artist cover sources.""" - from services.sources import ( - NetEaseArtistCoverSource, - ) - return [ - NetEaseArtistCoverSource(self.http_client), - ] + return [] def _get_artist_sources(self) -> List["ArtistCoverSource"]: """Get artist cover sources, including plugin-provided sources.""" diff --git a/services/sources/__init__.py b/services/sources/__init__.py index ae793762..b84bd401 100644 --- a/services/sources/__init__.py +++ b/services/sources/__init__.py @@ -7,15 +7,10 @@ from .base import CoverSource, LyricsSource, ArtistCoverSource from .cover_sources import ( - NetEaseCoverSource, MusicBrainzCoverSource, SpotifyCoverSource, ) -from .lyrics_sources import ( - NetEaseLyricsSource, -) from .artist_cover_sources import ( - NetEaseArtistCoverSource, SpotifyArtistCoverSource, ) @@ -25,12 +20,8 @@ "LyricsSource", "ArtistCoverSource", # Cover sources - "NetEaseCoverSource", "MusicBrainzCoverSource", "SpotifyCoverSource", - # Lyrics sources - "NetEaseLyricsSource", # Artist cover sources - "NetEaseArtistCoverSource", "SpotifyArtistCoverSource", ] diff --git a/services/sources/artist_cover_sources.py b/services/sources/artist_cover_sources.py index f339614a..4204f6c2 100644 --- a/services/sources/artist_cover_sources.py +++ b/services/sources/artist_cover_sources.py @@ -12,70 +12,6 @@ logger = logging.getLogger(__name__) -class NetEaseArtistCoverSource(ArtistCoverSource): - """NetEase Cloud Music artist cover source.""" - - @property - def name(self) -> str: - return "NetEase" - - def search( - self, - artist_name: str, - limit: int = 10 - ) -> List[ArtistCoverSearchResult]: - """Search for artist covers from NetEase Cloud Music.""" - results = [] - - try: - search_url = "https://music.163.com/api/search/get/web" - headers = { - 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36', - 'Referer': 'https://music.163.com/' - } - - params = { - 's': artist_name, - 'type': 100, # Artist search - 'limit': limit, - 'offset': 0 - } - - response = self._http_client.get( - search_url, - params=params, - headers=headers, - timeout=5 - ) - - if response.status_code == 200: - data = response.json() - - if data.get('code') == 200 and data.get('result', {}).get('artists'): - for artist_info in data['result']['artists']: - pic_url = artist_info.get('picUrl') or artist_info.get('img1v1Url') - if pic_url: - # Get high quality version - if '?' not in pic_url: - pic_url += '?param=512y512' - - results.append(ArtistCoverSearchResult( - id=str(artist_info.get('id', '')), - name=artist_info.get('name', ''), - cover_url=pic_url, - album_count=artist_info.get('albumSize', 0), - source='netease' - )) - - except Exception as e: - logger.debug(f"NetEase artist cover search error: {e}") - - return results - - def __init__(self, http_client): - self._http_client = http_client - - class SpotifyArtistCoverSource(ArtistCoverSource): """Spotify Web API artist cover source.""" diff --git a/services/sources/cover_sources.py b/services/sources/cover_sources.py index 3e608add..21ee47eb 100644 --- a/services/sources/cover_sources.py +++ b/services/sources/cover_sources.py @@ -11,113 +11,6 @@ logger = logging.getLogger(__name__) -class NetEaseCoverSource(CoverSource): - """NetEase Cloud Music cover source.""" - - @property - def name(self) -> str: - return "NetEase" - - def search( - self, - title: str, - artist: str, - album: str = "", - duration: Optional[float] = None - ) -> List[CoverSearchResult]: - """Search for covers from NetEase Cloud Music.""" - results = [] - - try: - search_url = "https://music.163.com/api/search/get/web" - headers = { - 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36', - 'Referer': 'https://music.163.com/' - } - - # First try album search - params = { - 's': f'{artist} {album or title}', - 'type': 10, # album search - 'limit': 5 - } - - response = self._http_client.get( - search_url, - params=params, - headers=headers, - timeout=5 - ) - - if response.status_code == 200: - data = response.json() - - if data.get('code') == 200 and data.get('result', {}).get('albums'): - for album_info in data['result']['albums']: - pic_url = album_info.get('picUrl') or album_info.get('blurPicUrl') - if pic_url: - # Get high quality version - if '?' not in pic_url: - pic_url += '?param=500y500' - - results.append(CoverSearchResult( - title=album_info.get('name', ''), - artist=album_info.get('artist', {}).get('name', ''), - album=album_info.get('name', ''), - source='netease', - id=str(album_info.get('id', '')), - cover_url=pic_url - )) - - # Also try song search for more accurate matching - params = { - 's': f'{artist} {title}', - 'type': 1, # song search - 'limit': 5 - } - - response = self._http_client.get( - search_url, - params=params, - headers=headers, - timeout=5 - ) - - if response.status_code == 200: - data = response.json() - - if data.get('code') == 200 and data.get('result', {}).get('songs'): - for song in data['result']['songs']: - album_info = song.get('album', {}) - pic_url = album_info.get('picUrl') or album_info.get('blurPicUrl') - - if pic_url: - if '?' not in pic_url: - pic_url += '?param=500y500' - - song_duration = None - if song.get('duration'): - song_duration = song['duration'] / 1000 - - results.append(CoverSearchResult( - title=song.get('name', ''), - artist=song['artists'][0]['name'] if song.get('artists') else '', - album=album_info.get('name', ''), - duration=song_duration, - source='netease', - id=str(song.get('id', '')), - cover_url=pic_url - )) - - except Exception as e: - logger.debug(f"NetEase cover search error: {e}") - - return results - - def __init__(self, http_client): - self._http_client = http_client - - class MusicBrainzCoverSource(CoverSource): """MusicBrainz Cover Art Archive source.""" diff --git a/services/sources/lyrics_sources.py b/services/sources/lyrics_sources.py index 01d06270..1c61537c 100644 --- a/services/sources/lyrics_sources.py +++ b/services/sources/lyrics_sources.py @@ -1,132 +1 @@ -""" -Lyrics source implementations. -""" - -import logging -from typing import Optional, List - -from .base import LyricsSource, LyricsSearchResult - -logger = logging.getLogger(__name__) - - -class NetEaseLyricsSource(LyricsSource): - """NetEase Cloud Music lyrics source.""" - - HEADERS = { - 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36' - } - - @property - def name(self) -> str: - return "NetEase" - - def search( - self, - title: str, - artist: str, - limit: int = 10 - ) -> List[LyricsSearchResult]: - """Search for lyrics from NetEase Cloud Music.""" - results = [] - - search_url = "https://music.163.com/api/search/get/web" - params = { - 's': f'{artist} {title}', - 'type': '1', - 'limit': str(limit) - } - - response = self._http_client.get( - search_url, - params=params, - headers=self.HEADERS, - timeout=3 - ) - - if response.status_code != 200: - return results - - data = response.json() - - if data.get('code') != 200 or not data.get('result', {}).get('songs'): - return results - - for song in data['result']['songs']: - # Get album cover URL (300x300 size) - cover_url = None - if song.get('album') and song['album'].get('picUrl'): - cover_url = song['album']['picUrl'] - elif song.get('album') and song['album'].get('pic'): - pic_str = str(song['album']['pic']) - cover_url = f"https://p1.music.126.net/{pic_str}/{pic_str}.jpg" - - # Get duration (convert from milliseconds to seconds) - duration = None - if song.get('duration'): - duration = song['duration'] / 1000 - - results.append(LyricsSearchResult( - id=str(song['id']), - title=song.get('name', ''), - artist=song['artists'][0]['name'] if song.get('artists') else '', - album=song['album']['name'] if song.get('album') else '', - duration=duration, - source='netease', - cover_url=cover_url, - supports_yrc=True # NetEase supports YRC word-by-word lyrics - )) - - return results - - def get_lyrics(self, result: LyricsSearchResult) -> Optional[str]: - """Download lyrics from NetEase by song ID.""" - try: - # Request both YRC and LRC at the same time - api_url = f"https://music.163.com/api/song/lyric?id={result.id}&lv=1&kv=0&tv=0&yv=0" - response = self._http_client.get( - api_url, - headers=self.HEADERS, - timeout=3 - ) - - if response.status_code == 200: - data = response.json() - if data.get('code') == 200: - # Check for YRC (word-by-word lyrics) first - yrc_data = data.get('yrc') - if yrc_data and yrc_data.get('lyric'): - return yrc_data['lyric'] - - # Fall back to LRC if no YRC - lrc_data = data.get('lrc') - if lrc_data and lrc_data.get('lyric'): - return lrc_data['lyric'] - - # Fallback to original API - lyrics_url = f"https://music.163.com/api/song/lyric?id={result.id}&lv=1&kv=1&tv=-1" - response = self._http_client.get( - lyrics_url, - headers=self.HEADERS, - timeout=3 - ) - - if response.status_code != 200: - return None - - data = response.json() - if data.get('code') != 200: - return None - - if 'lrc' in data: - return data['lrc'].get('lyric', '') - elif 'lyric' in data: - return data['lyric'] - - except Exception as e: - logger.error(f"Error downloading NetEase lyrics: {e}") - - return None - - def __init__(self, http_client): - self._http_client = http_client +"""Lyrics source implementations that remain host-owned.""" diff --git a/tests/test_app/test_qqmusic_host_cleanup.py b/tests/test_app/test_qqmusic_host_cleanup.py index 7e9b78ae..bc5eb8fd 100644 --- a/tests/test_app/test_qqmusic_host_cleanup.py +++ b/tests/test_app/test_qqmusic_host_cleanup.py @@ -48,13 +48,8 @@ def test_host_online_views_are_plugin_compat_shims(): assert "Compatibility shim" in source -def test_plugin_root_view_uses_plugin_local_online_views(): - source = Path("plugins/builtin/qqmusic/lib/root_view.py").read_text(encoding="utf-8") - - assert "from .online_grid_view import OnlineGridView" in source - assert "from .online_tracks_list_view import OnlineTracksListView" in source - assert "from ui.views.online_grid_view import OnlineGridView" not in source - assert "from ui.views.online_tracks_list_view import OnlineTracksListView" not in source +def test_plugin_root_view_module_has_been_removed(): + assert not Path("plugins/builtin/qqmusic/lib/root_view.py").exists() def test_plugin_provider_now_uses_legacy_online_music_view_entry(): @@ -85,7 +80,6 @@ def test_qqmusic_plugin_modules_use_plugin_local_i18n(): "plugins/builtin/qqmusic/lib/online_grid_view.py", "plugins/builtin/qqmusic/lib/online_music_view.py", "plugins/builtin/qqmusic/lib/online_tracks_list_view.py", - "plugins/builtin/qqmusic/lib/root_view.py", "plugins/builtin/qqmusic/lib/settings_tab.py", ): source = Path(relative_path).read_text(encoding="utf-8") @@ -95,7 +89,6 @@ def test_qqmusic_plugin_modules_use_plugin_local_i18n(): def test_qqmusic_plugin_no_longer_imports_host_online_models_or_widgets(): for relative_path in ( - "plugins/builtin/qqmusic/lib/root_view.py", "plugins/builtin/qqmusic/lib/online_music_view.py", "plugins/builtin/qqmusic/lib/online_detail_view.py", "plugins/builtin/qqmusic/lib/online_grid_view.py", @@ -147,7 +140,6 @@ def test_plugin_page_modules_do_not_directly_import_host_layers(): "plugins/builtin/qqmusic/lib/context_menus.py", "plugins/builtin/qqmusic/lib/cover_hover_popup.py", "plugins/builtin/qqmusic/lib/recommend_card.py", - "plugins/builtin/qqmusic/lib/root_view.py", "plugins/builtin/qqmusic/lib/online_detail_view.py", "plugins/builtin/qqmusic/lib/online_grid_view.py", "plugins/builtin/qqmusic/lib/online_music_view.py", diff --git a/tests/test_artist_navigation.py b/tests/test_artist_navigation.py index 9c9b2072..b84c45f3 100644 --- a/tests/test_artist_navigation.py +++ b/tests/test_artist_navigation.py @@ -1,72 +1,86 @@ -"""Test artist navigation from player controls.""" - -import sys -from pathlib import Path - -sys.path.insert(0, str(Path(__file__).parent.parent)) - -from app.bootstrap import Bootstrap - - -def test_artist_navigation(): - """Test that get_artist_by_name works for various artists.""" - bootstrap = Bootstrap.instance() - library = bootstrap.library_service +"""Artist navigation regression tests.""" + +from PySide6.QtCore import QCoreApplication + +from domain.track import Track +from infrastructure.database import DatabaseManager +from repositories.album_repository import SqliteAlbumRepository +from repositories.artist_repository import SqliteArtistRepository +from repositories.genre_repository import SqliteGenreRepository +from repositories.playlist_repository import SqlitePlaylistRepository +from repositories.track_repository import SqliteTrackRepository +from services.library import LibraryService +from services.metadata import split_artists +from system.event_bus import EventBus + + +def _build_library_service(db_path: str) -> LibraryService: + db = DatabaseManager(db_path) + track_repo = SqliteTrackRepository(db_path, db_manager=db) + playlist_repo = SqlitePlaylistRepository(db_path, db_manager=db) + album_repo = SqliteAlbumRepository(db_path, db_manager=db) + artist_repo = SqliteArtistRepository(db_path, db_manager=db) + genre_repo = SqliteGenreRepository(db_path, db_manager=db) + return LibraryService( + track_repo=track_repo, + playlist_repo=playlist_repo, + album_repo=album_repo, + artist_repo=artist_repo, + genre_repo=genre_repo, + event_bus=EventBus.instance(), + ) + + +def test_artist_navigation(tmp_path): + """get_artist_by_name should resolve normalized names from cached artist rows.""" + QCoreApplication.instance() or QCoreApplication([]) + + db_path = str(tmp_path / "artist-navigation.db") + library = _build_library_service(db_path) + + tracks = [ + Track(path="/music/a-lin-1.mp3", title="Song A", artist="A-Lin", album="Album A"), + Track(path="/music/taylor-1.mp3", title="Song B", artist="Taylor Swift", album="Album B"), + Track(path="/music/jay-1.mp3", title="Song C", artist="周杰伦", album="Album C"), + Track(path="/music/huang-1.mp3", title="Song D", artist="黄霄雲", album="Album D"), + Track( + path="/music/collab-1.mp3", + title="Collab 1", + artist="A-Lin, 李佳薇, 汪苏泷", + album="Collab Album", + ), + Track( + path="/music/collab-2.mp3", + title="Collab 2", + artist="Taylor Swift, Ed Sheeran", + album="Collab Album 2", + ), + ] + library.add_tracks_bulk(tracks) + library.refresh_albums_artists(immediate=True) - # Test cases - test_artists = [ + expected_artists = [ "A-Lin", "Taylor Swift", "周杰伦", "黄霄雲", + "李佳薇", + "汪苏泷", + "Ed Sheeran", ] - print("Testing artist navigation...") - print("-" * 50) - - all_passed = True - for artist_name in test_artists: + for artist_name in expected_artists: artist = library.get_artist_by_name(artist_name) - if artist: - print(f"✓ Found: {artist.name}") - print(f" Songs: {artist.song_count}") - print(f" Albums: {artist.album_count}") - else: - print(f"✗ NOT FOUND: {artist_name}") - all_passed = False - - print("-" * 50) - - # Test multi-artist track parsing - print("\nTesting multi-artist track...") - from services.metadata import split_artists - - test_strings = [ - "A-Lin, 李佳薇, 汪苏泷", - "Taylor Swift, Ed Sheeran", - "周杰伦", - ] - - for artist_string in test_strings: - artists = split_artists(artist_string) - print(f"Input: {artist_string}") - print(f" Parsed: {artists}") - - # Verify each artist exists - for artist_name in artists: - artist = library.get_artist_by_name(artist_name) - status = "✓" if artist else "✗" - found = "found" if artist else "NOT FOUND" - print(f" {status} {artist_name}: {found}") - - print("\n" + "=" * 50) - if all_passed: - print("✓ All tests passed!") - else: - print("✗ Some tests failed") - - assert all_passed is True - - -if __name__ == "__main__": - test_artist_navigation() + assert artist is not None, artist_name + assert artist.name == artist_name + + multi_artist_cases = { + "A-Lin, 李佳薇, 汪苏泷": ["A-Lin", "李佳薇", "汪苏泷"], + "Taylor Swift, Ed Sheeran": ["Taylor Swift", "Ed Sheeran"], + "周杰伦": ["周杰伦"], + } + for artist_string, expected in multi_artist_cases.items(): + parsed = split_artists(artist_string) + assert parsed == expected + for artist_name in parsed: + assert library.get_artist_by_name(artist_name) is not None diff --git a/tests/test_plugins/test_netease_cover_plugin.py b/tests/test_plugins/test_netease_cover_plugin.py new file mode 100644 index 00000000..f853cc3e --- /dev/null +++ b/tests/test_plugins/test_netease_cover_plugin.py @@ -0,0 +1,145 @@ +from types import SimpleNamespace +from unittest.mock import Mock + +from plugins.builtin.netease_cover.lib.artist_cover_source import ( + NetEaseArtistCoverPluginSource, +) +from plugins.builtin.netease_cover.lib.cover_source import NetEaseCoverPluginSource +from plugins.builtin.netease_cover.plugin_main import NetEaseCoverPlugin + + +def test_netease_cover_plugin_registers_cover_and_artist_sources(): + context = Mock() + plugin = NetEaseCoverPlugin() + + plugin.register(context) + + assert context.services.register_cover_source.call_count == 1 + assert context.services.register_artist_cover_source.call_count == 1 + assert isinstance( + context.services.register_cover_source.call_args.args[0], + NetEaseCoverPluginSource, + ) + assert isinstance( + context.services.register_artist_cover_source.call_args.args[0], + NetEaseArtistCoverPluginSource, + ) + + +def test_netease_cover_source_search_maps_album_and_song_results(): + responses = [ + SimpleNamespace( + status_code=200, + json=lambda: { + "code": 200, + "result": { + "albums": [ + { + "id": 1, + "name": "Album 1", + "artist": {"name": "Singer 1"}, + "picUrl": "https://example.com/album.jpg", + } + ] + }, + }, + ), + SimpleNamespace( + status_code=200, + json=lambda: { + "code": 200, + "result": { + "songs": [ + { + "id": 2, + "name": "Song 1", + "artists": [{"name": "Singer 1"}], + "duration": 180000, + "album": { + "name": "Album 1", + "picUrl": "https://example.com/song.jpg", + }, + } + ] + }, + }, + ), + ] + source = NetEaseCoverPluginSource( + SimpleNamespace(get=lambda *_args, **_kwargs: responses.pop(0)) + ) + + results = source.search("Song 1", "Singer 1", "Album 1") + + assert len(results) == 2 + assert results[0].item_id == "1" + assert results[0].album == "Album 1" + assert results[0].source == "netease" + assert results[0].cover_url == "https://example.com/album.jpg?param=500y500" + assert results[1].item_id == "2" + assert results[1].duration == 180.0 + + +def test_netease_cover_source_returns_empty_list_on_request_error(): + source = NetEaseCoverPluginSource( + SimpleNamespace(get=lambda *_args, **_kwargs: (_ for _ in ()).throw(RuntimeError("boom"))) + ) + + assert source.search("Song 1", "Singer 1", "Album 1") == [] + + +def test_netease_artist_cover_source_search_maps_results(): + response = SimpleNamespace( + status_code=200, + json=lambda: { + "code": 200, + "result": { + "artists": [ + { + "id": 1, + "name": "Singer 1", + "albumSize": 8, + "picUrl": "https://example.com/artist.jpg", + } + ] + }, + }, + ) + source = NetEaseArtistCoverPluginSource( + SimpleNamespace(get=lambda *_args, **_kwargs: response) + ) + + results = source.search("Singer 1", limit=5) + + assert len(results) == 1 + assert results[0].artist_id == "1" + assert results[0].name == "Singer 1" + assert results[0].album_count == 8 + assert results[0].source == "netease" + assert results[0].cover_url == "https://example.com/artist.jpg?param=512y512" + + +def test_netease_artist_cover_source_uses_img1v1_url_when_pic_url_missing(): + response = SimpleNamespace( + status_code=200, + json=lambda: { + "code": 200, + "result": { + "artists": [ + { + "id": 1, + "name": "Singer 1", + "albumSize": 8, + "img1v1Url": "https://example.com/artist-alt.jpg", + } + ] + }, + }, + ) + source = NetEaseArtistCoverPluginSource( + SimpleNamespace(get=lambda *_args, **_kwargs: response) + ) + + results = source.search("Singer 1", limit=5) + + assert results[0].cover_url == "https://example.com/artist-alt.jpg?param=512y512" diff --git a/tests/test_plugins/test_netease_lyrics_plugin.py b/tests/test_plugins/test_netease_lyrics_plugin.py new file mode 100644 index 00000000..84879db0 --- /dev/null +++ b/tests/test_plugins/test_netease_lyrics_plugin.py @@ -0,0 +1,108 @@ +from types import SimpleNamespace +from unittest.mock import Mock + +from plugins.builtin.netease_shared.common import ( + build_netease_image_url, + netease_headers, +) +from plugins.builtin.netease_lyrics.lib.lyrics_source import NetEaseLyricsPluginSource +from plugins.builtin.netease_lyrics.plugin_main import NetEaseLyricsPlugin + + +def test_netease_shared_helpers_normalize_headers_and_image_urls(): + headers = netease_headers() + + assert headers["Referer"] == "https://music.163.com/" + assert "Mozilla/5.0" in headers["User-Agent"] + assert build_netease_image_url("https://example.com/cover.jpg", "500y500") == ( + "https://example.com/cover.jpg?param=500y500" + ) + assert build_netease_image_url("https://example.com/cover.jpg?foo=1", "500y500") == ( + "https://example.com/cover.jpg?foo=1" + ) + + +def test_netease_lyrics_plugin_registers_lyrics_source(): + context = Mock() + plugin = NetEaseLyricsPlugin() + + plugin.register(context) + + context.services.register_lyrics_source.assert_called_once() + registered = context.services.register_lyrics_source.call_args.args[0] + assert isinstance(registered, NetEaseLyricsPluginSource) + + +def test_netease_lyrics_plugin_source_search_maps_results(): + response = SimpleNamespace( + status_code=200, + json=lambda: { + "code": 200, + "result": { + "songs": [ + { + "id": 1, + "name": "Song 1", + "artists": [{"name": "Singer 1"}], + "album": { + "name": "Album 1", + "picUrl": "https://example.com/cover.jpg", + }, + "duration": 225000, + } + ] + }, + }, + ) + source = NetEaseLyricsPluginSource( + SimpleNamespace(get=lambda *_args, **_kwargs: response) + ) + + results = source.search("Song 1", "Singer 1") + + assert len(results) == 1 + assert results[0].song_id == "1" + assert results[0].title == "Song 1" + assert results[0].artist == "Singer 1" + assert results[0].album == "Album 1" + assert results[0].duration == 225.0 + assert results[0].source == "netease" + assert results[0].cover_url == "https://example.com/cover.jpg" + assert results[0].supports_yrc is True + + +def test_netease_lyrics_plugin_source_prefers_yrc_then_falls_back_to_lrc(): + responses = [ + SimpleNamespace( + status_code=200, + json=lambda: { + "code": 200, + "yrc": {}, + "lrc": {"lyric": "[00:01.00]line"}, + }, + ) + ] + source = NetEaseLyricsPluginSource( + SimpleNamespace(get=lambda *_args, **_kwargs: responses.pop(0)) + ) + + lyrics = source.get_lyrics(SimpleNamespace(song_id="1")) + + assert lyrics == "[00:01.00]line" + + +def test_netease_lyrics_plugin_source_uses_lrc_fallback_request_when_first_call_has_no_lyrics(): + responses = [ + SimpleNamespace(status_code=200, json=lambda: {"code": 200, "yrc": {}, "lrc": {}}), + SimpleNamespace( + status_code=200, + json=lambda: {"code": 200, "lrc": {"lyric": "[00:02.00]fallback"}}, + ), + ] + source = NetEaseLyricsPluginSource( + SimpleNamespace(get=lambda *_args, **_kwargs: responses.pop(0)) + ) + + lyrics = source.get_lyrics(SimpleNamespace(song_id="1")) + + assert lyrics == "[00:02.00]fallback" diff --git a/tests/test_plugins/test_qqmusic_plugin.py b/tests/test_plugins/test_qqmusic_plugin.py index 9575172a..f516bfa3 100644 --- a/tests/test_plugins/test_qqmusic_plugin.py +++ b/tests/test_plugins/test_qqmusic_plugin.py @@ -1,36 +1,10 @@ -import threading -import time from unittest.mock import Mock -import pytest -from PySide6.QtCore import Qt -from PySide6.QtGui import QShowEvent -from PySide6.QtWidgets import QApplication, QListWidget, QWidget - -from plugins.builtin.qqmusic.lib.client import QQMusicPluginClient from plugins.builtin.qqmusic.lib import i18n as plugin_i18n +from plugins.builtin.qqmusic.lib.client import QQMusicPluginClient from plugins.builtin.qqmusic.lib.online_music_view import OnlineMusicView -from plugins.builtin.qqmusic.lib.login_dialog import QQMusicLoginDialog from plugins.builtin.qqmusic.lib.provider import QQMusicOnlineProvider -from plugins.builtin.qqmusic.lib.root_view import HomeSectionsWorker -from plugins.builtin.qqmusic.lib.root_view import QQMusicRootView -from plugins.builtin.qqmusic.lib.qr_login import QQMusicQRLogin, QRLoginType from plugins.builtin.qqmusic.plugin_main import QQMusicPlugin -from plugins.builtin.qqmusic.lib.settings_tab import QQMusicSettingsTab -from system.event_bus import EventBus -from system.i18n import get_language, set_language -from system.i18n import t -from system.theme import ThemeManager - - -@pytest.fixture(autouse=True) -def reset_theme_manager(): - ThemeManager._instance = None - config = Mock() - config.get.return_value = "dark" - ThemeManager.instance(config) - yield - ThemeManager._instance = None def test_qqmusic_plugin_registers_expected_capabilities(): @@ -125,7 +99,7 @@ def test_qqmusic_plugin_uses_private_translations_not_global(monkeypatch): plugin_i18n.set_language("zh") try: - assert plugin_i18n.t("qqmusic_page_title") == "QQ 音乐" + assert plugin_i18n.t("qqmusic_page_title") == "QQ音乐" finally: if original is None: global_i18n._translations["zh"].pop("qqmusic_page_title", None) @@ -161,185 +135,6 @@ def test_qqmusic_provider_config_adapter_exposes_download_dir(): assert adapter.get_online_music_download_dir() == "data/online_cache" -def test_qqmusic_settings_tab_reads_and_saves_quality(qtbot): - settings = Mock() - settings.get.return_value = "flac" - context = Mock(settings=settings) - - tab = QQMusicSettingsTab(context) - qtbot.addWidget(tab) - - assert tab._quality_combo.currentData() == "flac" - - tab._quality_combo.setCurrentIndex(0) - tab._save() - - settings.set.assert_called_once_with("quality", tab._quality_combo.currentData()) - assert hasattr(tab, "_account_group") - assert hasattr(tab, "_quality_group") - - -def test_qqmusic_settings_tab_exposes_section_hint_labels(qtbot): - settings = Mock() - settings.get.return_value = "320" - context = Mock(settings=settings) - - tab = QQMusicSettingsTab(context) - qtbot.addWidget(tab) - - assert hasattr(tab, "_account_hint_label") - assert hasattr(tab, "_quality_hint_label") - assert tab._account_hint_label.wordWrap() is True - assert tab._quality_hint_label.wordWrap() is True - - -def test_qqmusic_settings_tab_opens_login_dialog(monkeypatch, qtbot): - settings = Mock() - settings.get.return_value = "320" - context = Mock(settings=settings) - - dialog = Mock() - dialog_ctor = Mock(return_value=dialog) - monkeypatch.setattr( - "plugins.builtin.qqmusic.lib.settings_tab.QQMusicLoginDialog", - dialog_ctor, - ) - - tab = QQMusicSettingsTab(context) - qtbot.addWidget(tab) - - tab._open_login_dialog() - - dialog_ctor.assert_called_once_with(context, tab) - dialog.exec.assert_called_once_with() - - - - -def test_plugin_local_qr_login_client_builds_session(): - client = QQMusicQRLogin() - - https_adapter = client._session.get_adapter("https://u.y.qq.com/cgi-bin/musicu.fcg") - - assert https_adapter._pool_connections == 20 - assert https_adapter._pool_maxsize == 20 - assert https_adapter._pool_block is True - - -def test_plugin_login_dialog_uses_local_qr_client(qtbot): - dialog = QQMusicLoginDialog() - qtbot.addWidget(dialog) - - assert isinstance(dialog._client, QQMusicQRLogin) - - -def test_plugin_login_dialog_auto_starts_qr_loading(monkeypatch, qtbot): - start_calls = [] - - def _capture_start(self, login_type=None): - start_calls.append(login_type) - - monkeypatch.setattr( - "plugins.builtin.qqmusic.lib.login_dialog.QQMusicLoginDialog._start_login", - _capture_start, - ) - - dialog = QQMusicLoginDialog() - qtbot.addWidget(dialog) - qtbot.waitUntil(lambda: len(start_calls) == 1) - - assert start_calls[0] == QRLoginType.QQ - - -def test_plugin_login_dialog_can_switch_between_qq_and_wechat_qr(monkeypatch, qtbot): - settings = Mock() - context = Mock(settings=settings) - start_calls = [] - - def _capture_start(self, login_type=None): - start_calls.append(login_type) - - monkeypatch.setattr( - "plugins.builtin.qqmusic.lib.login_dialog.QQMusicLoginDialog._start_login", - _capture_start, - ) - - dialog = QQMusicLoginDialog(context) - qtbot.addWidget(dialog) - qtbot.waitUntil(lambda: len(start_calls) == 1) - - dialog._wx_login_btn.click() - dialog._qq_login_btn.click() - - assert start_calls[1:] == [QRLoginType.WX, QRLoginType.QQ] - - -def test_plugin_login_dialog_persists_credentials_and_nick(qtbot): - settings = Mock() - context = Mock(settings=settings) - dialog = QQMusicLoginDialog(context) - qtbot.addWidget(dialog) - - dialog._handle_login_success({"musicid": "1", "musickey": "secret", "nick": "Tester"}) - - settings.set.assert_any_call("credential", {"musicid": "1", "musickey": "secret", "nick": "Tester"}) - settings.set.assert_any_call("nick", "Tester") - - -def test_plugin_login_dialog_does_not_fallback_to_uid_for_nick(qtbot): - settings = Mock() - context = Mock(settings=settings) - dialog = QQMusicLoginDialog(context) - qtbot.addWidget(dialog) - - dialog._handle_login_success({"musicid": "1", "musickey": "secret"}) - - settings.set.assert_any_call("credential", {"musicid": "1", "musickey": "secret"}) - settings.set.assert_any_call("nick", "") - - -def test_plugin_login_dialog_fetches_missing_nick_from_verify_login(monkeypatch, qtbot): - settings = Mock() - context = Mock(settings=settings) - dialog = QQMusicLoginDialog(context) - qtbot.addWidget(dialog) - - service = Mock() - service.client.verify_login.return_value = {"valid": True, "nick": "Tester", "uin": 1} - monkeypatch.setattr( - "plugins.builtin.qqmusic.lib.login_dialog.QQMusicService", - Mock(return_value=service), - ) - - dialog._handle_login_success({"musicid": "1", "musickey": "secret"}) - - settings.set.assert_any_call("nick", "Tester") - - -def test_plugin_login_dialog_reject_stops_worker(qtbot): - dialog = QQMusicLoginDialog() - qtbot.addWidget(dialog) - worker = Mock() - dialog._worker = worker - - dialog.reject() - - worker.stop.assert_called_once_with() - worker.wait.assert_called_once_with(1000) - assert dialog._worker is None - - -def test_plugin_login_dialog_exposes_legacy_style_support_widgets(qtbot): - dialog = QQMusicLoginDialog() - qtbot.addWidget(dialog) - - assert hasattr(dialog, "_subtitle_label") - assert hasattr(dialog, "_qr_frame") - assert hasattr(dialog, "_cancel_btn") - assert dialog._status_label.wordWrap() is True - assert dialog.minimumWidth() >= 420 - - def test_plugin_client_normalizes_legacy_top_list_dict_tracks(monkeypatch): settings = Mock() settings.get.side_effect = lambda key, default=None: { @@ -526,3486 +321,3 @@ def test_plugin_client_skips_private_legacy_calls_when_network_unreachable(monke service.get_home_feed.assert_not_called() service.get_my_fav_songs.assert_not_called() - - -def test_qqmusic_settings_tab_clears_plugin_credentials(qtbot): - settings = Mock() - settings.get.side_effect = lambda key, default=None: { - "quality": "320", - "nick": "Tester", - }.get(key, default) - context = Mock(settings=settings) - - tab = QQMusicSettingsTab(context) - qtbot.addWidget(tab) - - tab._clear_credentials() - - settings.set.assert_any_call("credential", None) - settings.set.assert_any_call("nick", "") - - -def test_root_view_search_populates_results(qtbot, monkeypatch): - settings = Mock() - settings.get.side_effect = lambda key, default=None: { - "nick": "Tester", - "quality": "320", - }.get(key, default) - context = Mock(settings=settings) - provider = Mock() - provider.search_tracks.return_value = [ - {"mid": "song-1", "title": "Song 1", "artist": "Singer 1"} - ] - - view = QQMusicRootView(context, provider) - qtbot.addWidget(view) - - view._search_input.setText("Song 1") - view._run_search() - - assert view._results_list.count() == 1 - assert "Song 1" in view._results_list.item(0).text() - provider.search_tracks.assert_called_once_with("Song 1") - - -def test_root_view_initializes_home_sections(qtbot): - settings = Mock() - settings.get.side_effect = lambda key, default=None: { - "nick": "", - "quality": "320", - }.get(key, default) - context = Mock(settings=settings) - provider = Mock() - provider.is_logged_in.return_value = False - provider.get_top_lists.return_value = [ - {"id": 26, "title": "热歌榜"}, - {"id": 27, "title": "新歌榜"}, - ] - provider.get_top_list_tracks.return_value = [ - {"mid": "song-1", "title": "Song 1", "artist": "Singer 1", "album": "Album 1", "duration": 210}, - ] - provider.get_recommendations.return_value = [] - provider.get_favorites.return_value = [] - - view = QQMusicRootView(context, provider) - qtbot.addWidget(view) - - assert view._home_stack.currentWidget() is view._home_page - assert view._search_type_tabs.count() == 4 - assert view._search_type_tabs.isHidden() is True - assert view._top_list_widget.count() == 2 - assert view._top_tracks_table.columnCount() == 5 - assert view._top_tracks_table.rowCount() == 1 - assert view._ranking_title_label.text() == "热歌榜" - provider.get_top_lists.assert_called_once_with() - provider.get_top_list_tracks.assert_called_once_with(26) - - -def test_root_view_supports_multi_type_search(qtbot): - settings = Mock() - settings.get.side_effect = lambda key, default=None: { - "nick": "Tester", - "quality": "320", - }.get(key, default) - context = Mock(settings=settings) - provider = Mock() - provider.is_logged_in.return_value = True - provider.get_top_lists.return_value = [] - provider.get_top_list_tracks.return_value = [] - provider.search.return_value = { - "tracks": [{"mid": "song-1", "title": "Song 1", "artist": "Singer 1"}], - "total": 1, - "page": 1, - "page_size": 30, - } - provider.get_recommendations.return_value = [] - provider.get_favorites.return_value = [] - provider.search.return_value = { - "artists": [ - {"mid": "artist-1", "name": "Singer 1", "song_count": 12}, - ] - } - - view = QQMusicRootView(context, provider) - qtbot.addWidget(view) - view.show() - - view._search_input.setText("Singer 1") - view._search_type_tabs.setCurrentIndex(1) - view._run_search() - - assert view._home_stack.currentWidget() is view._results_page - assert view._results_stack.currentWidget() is view._artists_page - assert view._artists_list.count() == 1 - assert "Singer 1" in view._artists_list.item(0).text() - provider.search.assert_called_once_with("Singer 1", "singer", page=1, page_size=30) - - -def test_root_view_switching_search_tab_requeries_current_keyword(qtbot): - settings = Mock() - settings.get.side_effect = lambda key, default=None: { - "nick": "Tester", - "quality": "320", - }.get(key, default) - context = Mock(settings=settings) - provider = Mock() - provider.is_logged_in.return_value = True - provider.get_top_lists.return_value = [] - provider.get_top_list_tracks.return_value = [] - provider.search.return_value = { - "tracks": [{"mid": "song-1", "title": "Song 1", "artist": "Singer 1"}], - "total": 1, - "page": 1, - "page_size": 30, - } - provider.get_recommendations.return_value = [] - provider.get_favorites.return_value = [] - provider.search.side_effect = [ - {"tracks": [{"mid": "song-1", "title": "Song 1", "artist": "Singer 1"}], "total": 1, "page": 1, "page_size": 30}, - {"albums": [{"mid": "album-1", "name": "Album 1", "singer_name": "Singer 1"}], "total": 1, "page": 1, "page_size": 30}, - ] - - view = QQMusicRootView(context, provider) - qtbot.addWidget(view) - view.show() - - view._search_input.setText("Singer 1") - view._run_search() - view._search_type_tabs.setCurrentIndex(2) - - assert provider.search.call_args_list[0].args[:2] == ("Singer 1", "song") - assert provider.search.call_args_list[1].args[:2] == ("Singer 1", "album") - assert view._results_stack.currentWidget() is view._albums_page - - -def test_root_view_song_search_uses_table_and_pagination(qtbot): - settings = Mock() - settings.get.side_effect = lambda key, default=None: { - "nick": "Tester", - "quality": "320", - }.get(key, default) - context = Mock(settings=settings) - context.services.media = Mock() - provider = Mock() - provider.is_logged_in.return_value = True - provider.get_top_lists.return_value = [] - provider.get_top_list_tracks.return_value = [] - provider.search.return_value = { - "tracks": [{"mid": "song-x", "title": "Song X", "artist": "Singer X"}], - "total": 1, - "page": 1, - "page_size": 30, - } - provider.get_recommendations.return_value = [] - provider.get_favorites.return_value = [] - provider.search.return_value = { - "tracks": [ - {"mid": "song-1", "title": "Song 1", "artist": "Singer 1", "album": "Album 1", "duration": 210}, - ], - "total": 61, - "page": 1, - "page_size": 30, - } - - view = QQMusicRootView(context, provider) - qtbot.addWidget(view) - - view._search_input.setText("Song 1") - view._run_search() - - results_table = getattr(view, "_results_table", None) - page_label = getattr(view, "_page_label", None) - next_btn = getattr(view, "_next_btn", None) - - assert results_table is not None - assert page_label is not None - assert next_btn is not None - assert results_table.columnCount() == 5 - assert view._results_stack.currentWidget() is view._songs_page - assert results_table.rowCount() == 1 - assert page_label.text() == "1" - assert next_btn.isEnabled() is True - assert "Song 1" in view._results_info_label.text() - assert "61" in view._results_info_label.text() - assert view._pagination_widget.isHidden() is False - provider.search.assert_called_once_with("Song 1", "song", page=1, page_size=30) - - -def test_root_view_artist_search_uses_grid_and_load_more(qtbot): - settings = Mock() - settings.get.side_effect = lambda key, default=None: { - "nick": "Tester", - "quality": "320", - }.get(key, default) - context = Mock(settings=settings) - context.services.media = Mock() - provider = Mock() - provider.is_logged_in.return_value = True - provider.get_top_lists.return_value = [] - provider.get_top_list_tracks.return_value = [] - provider.search.return_value = { - "tracks": [{"mid": "song-x", "title": "Song X", "artist": "Singer X"}], - "total": 1, - "page": 1, - "page_size": 30, - } - provider.get_recommendations.return_value = [] - provider.get_favorites.return_value = [] - provider.search.side_effect = [ - { - "artists": [{"mid": "artist-1", "name": "Singer 1", "song_count": 12}], - "total": 61, - "page": 1, - "page_size": 30, - }, - { - "artists": [{"mid": "artist-2", "name": "Singer 2", "song_count": 8}], - "total": 61, - "page": 2, - "page_size": 30, - }, - ] - - view = QQMusicRootView(context, provider) - qtbot.addWidget(view) - - view._search_input.setText("Singer") - view._search_type_tabs.setCurrentIndex(1) - view._run_search() - - assert hasattr(view, "_on_load_more_artists") - view._on_load_more_artists() - - assert view._results_stack.currentWidget() is view._artists_page - assert view._pagination_widget.isHidden() is True - assert provider.search.call_args_list[0].args[:2] == ("Singer", "singer") - assert provider.search.call_args_list[1].kwargs == {"page": 2, "page_size": 30} - - -def test_root_view_loads_logged_in_sections(qtbot): - settings = Mock() - settings.get.side_effect = lambda key, default=None: { - "nick": "Tester", - "quality": "320", - }.get(key, default) - context = Mock(settings=settings) - provider = Mock() - provider.is_logged_in.return_value = True - provider.get_top_lists.return_value = [] - provider.get_top_list_tracks.return_value = [] - provider.get_recommendations.return_value = [ - {"title": "每日推荐", "subtitle": "猜你想听"}, - ] - provider.get_favorites.return_value = [ - {"title": "我喜欢的歌曲", "count": 42}, - ] - - view = QQMusicRootView(context, provider) - qtbot.addWidget(view) - - assert "Tester" in view._status.text() - assert view._recommend_list.count() == 1 - assert view._favorites_list.count() == 1 - assert view._recommend_section.isHidden() is False - assert view._favorites_section.isHidden() is False - assert view._recommend_group.isHidden() is True - assert view._favorites_group.isHidden() is True - - -def test_home_sections_worker_parallelizes_home_requests(): - provider = Mock() - release = threading.Event() - started = { - "top_lists": threading.Event(), - "hotkeys": threading.Event(), - "favorites": threading.Event(), - "recommendations": threading.Event(), - } - - def _slow_list(name, value): - def _inner(): - started[name].set() - release.wait(0.5) - return value - return _inner - - provider.get_top_lists.side_effect = _slow_list("top_lists", [{"id": 26, "title": "热歌榜"}]) - provider.get_hotkeys.side_effect = _slow_list("hotkeys", [{"title": "周杰伦"}]) - provider.get_favorites.side_effect = _slow_list("favorites", [{"title": "收藏"}]) - provider.get_recommendations.side_effect = _slow_list("recommendations", [{"title": "推荐"}]) - provider.get_top_list_tracks.return_value = [] - - worker = HomeSectionsWorker( - provider, - load_private=True, - logged_in=True, - history=["林俊杰"], - ) - - runner = threading.Thread(target=worker.run) - runner.start() - - assert started["top_lists"].wait(0.2) is True - time.sleep(0.05) - started_count = sum(1 for event in started.values() if event.is_set()) - release.set() - runner.join(timeout=1) - - assert started_count == 4 - - -def test_root_view_home_payload_prefills_initial_ranking_tracks(qtbot): - settings = Mock() - settings.get.side_effect = lambda key, default=None: { - "nick": "", - "quality": "320", - }.get(key, default) - context = Mock(settings=settings) - context.services.media = Mock() - provider = Mock() - provider.is_logged_in.return_value = False - provider.get_top_lists.return_value = [] - provider.get_top_list_tracks.return_value = [] - provider.get_recommendations.return_value = [] - provider.get_favorites.return_value = [] - provider.get_hotkeys.return_value = [] - provider.complete.return_value = [] - - view = QQMusicRootView(context, provider) - qtbot.addWidget(view) - - payload = { - "top_lists": [{"id": 26, "title": "热歌榜"}], - "top_tracks": [{"mid": "song-1", "title": "Song 1", "artist": "Singer 1", "album": "Album 1", "duration": 210}], - "top_tracks_id": "26", - "hotkeys": [], - "history": [], - "favorites": [], - "recommendations": [], - "logged_in": False, - "load_private": False, - } - - view._on_home_sections_loaded(payload) - - assert view._top_tracks_table.rowCount() == 1 - provider.get_top_list_tracks.assert_not_called() - - -def test_root_view_home_payload_does_not_show_hotkey_popup_without_search_focus(qtbot): - settings = Mock() - settings.get.side_effect = lambda key, default=None: { - "nick": "", - "quality": "320", - "search_history": [], - }.get(key, default) - context = Mock(settings=settings) - context.services.media = Mock() - provider = Mock() - provider.is_logged_in.return_value = False - provider.get_top_lists.return_value = [] - provider.get_top_list_tracks.return_value = [] - provider.get_recommendations.return_value = [] - provider.get_favorites.return_value = [] - provider.get_hotkeys.return_value = [] - provider.complete.return_value = [] - - view = QQMusicRootView(context, provider) - qtbot.addWidget(view) - view.show() - view._hotkey_popup = view._hotkey_popup or None - view._show_hotkey_popup() - assert view._hotkey_popup is not None - view._on_app_focus_changed(view._search_input, view._login_btn) - - view._on_home_sections_loaded( - { - "top_lists": [], - "top_tracks": [], - "top_tracks_id": "", - "hotkeys": [{"title": "周杰伦", "query": "周杰伦"}], - "history": [], - "favorites": [], - "recommendations": [], - "logged_in": False, - "load_private": False, - } - ) - - assert view._hotkey_popup.isVisible() is False - - -def test_root_view_show_does_not_auto_open_hotkey_popup(qtbot): - settings = Mock() - store = {"nick": "", "quality": "320", "search_history": ["林俊杰"]} - settings.get.side_effect = lambda key, default=None: store.get(key, default) - context = Mock(settings=settings) - context.services.media = Mock() - provider = Mock() - provider.is_logged_in.return_value = False - provider.get_top_lists.return_value = [] - provider.get_top_list_tracks.return_value = [] - provider.get_recommendations.return_value = [] - provider.get_favorites.return_value = [] - provider.get_hotkeys.return_value = [{"title": "周杰伦", "query": "周杰伦"}] - provider.complete.return_value = [] - - view = QQMusicRootView(context, provider) - qtbot.addWidget(view) - view.show() - - qtbot.waitUntil(lambda: view._search_input.hasFocus(), timeout=1000) - - assert view._hotkey_popup is None or view._hotkey_popup.isVisible() is False - - -def test_root_view_internal_collection_lists_are_hidden(qtbot): - settings = Mock() - settings.get.side_effect = lambda key, default=None: { - "nick": "", - "quality": "320", - "search_history": [], - }.get(key, default) - context = Mock(settings=settings) - context.services.media = Mock() - provider = Mock() - provider.is_logged_in.return_value = False - provider.get_top_lists.return_value = [] - provider.get_top_list_tracks.return_value = [] - provider.get_recommendations.return_value = [] - provider.get_favorites.return_value = [] - provider.get_hotkeys.return_value = [] - provider.complete.return_value = [] - - view = QQMusicRootView(context, provider) - qtbot.addWidget(view) - view.show() - - assert view._artists_list.isHidden() is True - assert view._albums_list.isHidden() is True - assert view._playlists_list.isHidden() is True - - -def test_root_view_ranking_table_uses_legacy_column_layout(qtbot): - settings = Mock() - settings.get.side_effect = lambda key, default=None: { - "nick": "", - "quality": "320", - }.get(key, default) - context = Mock(settings=settings) - context.services.media = Mock() - provider = Mock() - provider.is_logged_in.return_value = False - provider.get_top_lists.return_value = [{"id": 26, "title": "热歌榜"}] - provider.get_top_list_tracks.return_value = [{"mid": "song-1", "title": "Song 1", "artist": "Singer 1", "album": "Album 1", "duration": 210}] - provider.get_recommendations.return_value = [] - provider.get_favorites.return_value = [] - provider.get_hotkeys.return_value = [] - provider.complete.return_value = [] - - view = QQMusicRootView(context, provider) - qtbot.addWidget(view) - - header = view._top_tracks_table.horizontalHeader() - assert header.stretchLastSection() is False - assert header.sectionResizeMode(0) == header.ResizeMode.Fixed - assert header.sectionResizeMode(4) == header.ResizeMode.Fixed - assert view._top_tracks_table.columnWidth(0) == 50 - assert view._top_tracks_table.columnWidth(4) == 80 - - -def test_root_view_public_home_payload_does_not_clear_private_sections(qtbot): - settings = Mock() - settings.get.side_effect = lambda key, default=None: { - "nick": "Tester", - "quality": "320", - }.get(key, default) - context = Mock(settings=settings) - context.services.media = Mock() - provider = Mock() - provider.is_logged_in.return_value = True - provider.get_top_lists.return_value = [] - provider.get_top_list_tracks.return_value = [] - provider.get_recommendations.return_value = [] - provider.get_favorites.return_value = [] - provider.get_hotkeys.return_value = [] - provider.complete.return_value = [] - - view = QQMusicRootView(context, provider) - qtbot.addWidget(view) - - view._favorites_cache = [{"title": "我喜欢的歌曲", "subtitle": "1 首", "items": [{"mid": "song-1"}]}] - view._recommendations_cache = [{"title": "猜你喜欢", "subtitle": "1 项", "items": [{"mid": "song-1"}]}] - view._apply_logged_in_sections_from_cache() - - view._on_home_sections_loaded( - { - "top_lists": [], - "top_tracks": [], - "top_tracks_id": "", - "hotkeys": [], - "history": [], - "favorites": [], - "recommendations": [], - "logged_in": True, - "load_private": False, - } - ) - - assert view._favorites_section.isHidden() is False - assert view._recommend_section.isHidden() is False - assert view._favorites_list.count() == 1 - - -def test_root_view_ranking_context_menu_uses_translated_labels(monkeypatch, qtbot): - settings = Mock() - settings.get.side_effect = lambda key, default=None: { - "nick": "", - "quality": "320", - }.get(key, default) - context = Mock(settings=settings) - context.services.media = Mock() - provider = Mock() - provider.is_logged_in.return_value = False - provider.get_top_lists.return_value = [{"id": 26, "title": "热歌榜"}] - provider.get_top_list_tracks.return_value = [ - {"mid": "song-1", "title": "Song 1", "artist": "Singer 1", "album": "Album 1", "duration": 210}, - ] - provider.get_recommendations.return_value = [] - provider.get_favorites.return_value = [] - provider.get_hotkeys.return_value = [] - provider.complete.return_value = [] - - labels = [] - - class _FakeSignal: - def connect(self, *_args, **_kwargs): - return None - - class _FakeAction: - def __init__(self): - self.triggered = _FakeSignal() - - class _FakeMenu: - def __init__(self, *_args, **_kwargs): - pass - - def setStyleSheet(self, *_args, **_kwargs): - return None - - def addAction(self, text): - labels.append(text) - return _FakeAction() - - def addSeparator(self): - return None - - def exec(self, *_args, **_kwargs): - return None - - monkeypatch.setattr("plugins.builtin.qqmusic.lib.root_view.QMenu", _FakeMenu) - monkeypatch.setattr( - "plugins.builtin.qqmusic.lib.root_view.t", - lambda key, default=None: f"tr:{key}", - ) - - view = QQMusicRootView(context, provider) - qtbot.addWidget(view) - view._top_tracks_table.selectRow(0) - monkeypatch.setattr(view, "sender", lambda: view._top_tracks_table) - - view._show_track_context_menu(view._top_tracks_table.visualItemRect(view._top_tracks_table.item(0, 0)).center()) - - assert labels == [ - "tr:play", - "tr:insert_to_queue", - "tr:add_to_queue", - "tr:add_to_favorites", - "tr:add_to_playlist", - "tr:download", - ] - - -def test_root_view_detail_toggle_texts_use_translation_keys(qtbot, monkeypatch): - settings = Mock() - settings.get.side_effect = lambda key, default=None: { - "nick": "", - "quality": "320", - }.get(key, default) - context = Mock(settings=settings) - context.services.media = Mock() - provider = Mock() - provider.is_logged_in.return_value = True - provider.get_top_lists.return_value = [] - provider.get_top_list_tracks.return_value = [] - provider.get_recommendations.return_value = [] - provider.get_favorites.return_value = [] - provider.get_hotkeys.return_value = [] - provider.complete.return_value = [] - monkeypatch.setattr( - "plugins.builtin.qqmusic.lib.root_view.t", - lambda key, default=None: f"tr:{key}", - ) - - view = QQMusicRootView(context, provider) - qtbot.addWidget(view) - - view._show_detail( - {"title": "Singer 1", "songs": [], "follow_status": True}, - detail_type="artist", - source_id="artist-1", - ) - assert view._detail_follow_btn.text() == "tr:qqmusic_followed" - - view._show_detail( - {"title": "Album 1", "songs": [], "is_faved": True}, - detail_type="album", - source_id="album-1", - ) - assert view._detail_fav_btn.text() == "tr:qqmusic_remove_from_favorites" - - -def test_root_view_syncs_language_from_context_and_listens_for_changes(qtbot): - plugin_i18n.set_language("en") - store = {"nick": "", "quality": "320"} - settings = Mock() - settings.get.side_effect = lambda key, default=None: store.get(key, default) - settings.set.side_effect = lambda key, value: store.__setitem__(key, value) - context = Mock(settings=settings) - context.services.media = Mock() - context.events = EventBus.instance() - context.language = "zh" - provider = Mock() - provider.is_logged_in.return_value = False - provider.get_top_lists.return_value = [] - provider.get_top_list_tracks.return_value = [] - provider.get_recommendations.return_value = [] - provider.get_favorites.return_value = [] - provider.get_hotkeys.return_value = [] - provider.complete.return_value = [] - - view = QQMusicRootView(context, provider) - qtbot.addWidget(view) - - assert plugin_i18n.get_language() == "zh" - - context.events.language_changed.emit("en") - - assert plugin_i18n.get_language() == "en" - - -def test_root_view_show_event_uses_async_home_loader_for_embedded_page(qtbot, monkeypatch): - settings = Mock() - settings.get.side_effect = lambda key, default=None: { - "nick": "", - "quality": "320", - }.get(key, default) - context = Mock(settings=settings) - context.services.media = Mock() - provider = Mock() - provider.is_logged_in.return_value = False - provider.get_top_lists.return_value = [{"id": 26, "title": "热歌榜"}] - provider.get_top_list_tracks.return_value = [{"mid": "song-1", "title": "Song 1"}] - provider.get_recommendations.return_value = [] - provider.get_favorites.return_value = [] - provider.get_hotkeys.return_value = [] - provider.complete.return_value = [] - - started = {} - - class _FakeWorker: - def __init__(self, *_args, **_kwargs): - self.home_loaded = Mock(connect=Mock()) - self.failed = Mock(connect=Mock()) - self.finished = Mock(connect=Mock()) - - def start(self): - started["started"] = True - - def isRunning(self): - return False - - def deleteLater(self): - started["deleted"] = True - - monkeypatch.setattr( - "plugins.builtin.qqmusic.lib.root_view.HomeSectionsWorker", - _FakeWorker, - ) - - parent = QWidget() - view = QQMusicRootView(context, provider, parent=parent) - qtbot.addWidget(parent) - - assert provider.get_top_lists.call_count == 0 - - view.showEvent(QShowEvent()) - qtbot.waitUntil(lambda: started.get("started") is True, timeout=1000) - - assert provider.get_top_lists.call_count == 0 - - -def test_root_view_embedded_init_does_not_block_on_logged_in_sections(qtbot): - settings = Mock() - settings.get.side_effect = lambda key, default=None: { - "nick": "Tester", - "quality": "320", - }.get(key, default) - context = Mock(settings=settings) - context.services.media = Mock() - provider = Mock() - provider.is_logged_in.return_value = True - provider.get_top_lists.return_value = [{"id": 26, "title": "热歌榜"}] - provider.get_top_list_tracks.return_value = [{"mid": "song-1", "title": "Song 1"}] - provider.get_recommendations.return_value = [{"title": "推荐"}] - provider.get_favorites.return_value = [{"title": "收藏"}] - provider.get_hotkeys.return_value = [] - provider.complete.return_value = [] - - parent = QWidget() - view = QQMusicRootView(context, provider, parent=parent) - qtbot.addWidget(parent) - - provider.get_favorites.assert_not_called() - provider.get_recommendations.assert_not_called() - assert view._favorites_section.isHidden() is True - assert view._recommend_section.isHidden() is True - - -def test_root_view_embedded_init_lazy_builds_grid_pages(qtbot, monkeypatch): - settings = Mock() - settings.get.side_effect = lambda key, default=None: { - "nick": "", - "quality": "320", - }.get(key, default) - context = Mock(settings=settings) - context.services.media = Mock() - provider = Mock() - provider.is_logged_in.return_value = False - provider.get_top_lists.return_value = [] - provider.get_top_list_tracks.return_value = [] - provider.get_recommendations.return_value = [] - provider.get_favorites.return_value = [] - provider.get_hotkeys.return_value = [] - provider.complete.return_value = [] - - grid_ctor = Mock() - tracks_list_ctor = Mock() - monkeypatch.setattr( - "plugins.builtin.qqmusic.lib.root_view.OnlineGridView", - grid_ctor, - ) - monkeypatch.setattr( - "plugins.builtin.qqmusic.lib.root_view.OnlineTracksListView", - tracks_list_ctor, - ) - monkeypatch.setattr( - "plugins.builtin.qqmusic.lib.root_view.QTimer.singleShot", - lambda *_args, **_kwargs: None, - ) - - parent = QWidget() - QQMusicRootView(context, provider, parent=parent) - qtbot.addWidget(parent) - - grid_ctor.assert_not_called() - tracks_list_ctor.assert_not_called() - - -def test_root_view_embedded_init_lazy_builds_detail_ui(qtbot): - settings = Mock() - settings.get.side_effect = lambda key, default=None: { - "nick": "", - "quality": "320", - }.get(key, default) - context = Mock(settings=settings) - context.services.media = Mock() - provider = Mock() - provider.is_logged_in.return_value = False - provider.get_top_lists.return_value = [] - provider.get_top_list_tracks.return_value = [] - provider.get_recommendations.return_value = [] - provider.get_favorites.return_value = [] - provider.get_hotkeys.return_value = [] - provider.complete.return_value = [] - - import plugins.builtin.qqmusic.lib.root_view as root_view_module - original_single_shot = root_view_module.QTimer.singleShot - root_view_module.QTimer.singleShot = lambda *_args, **_kwargs: None - - parent = QWidget() - view = QQMusicRootView(context, provider, parent=parent) - qtbot.addWidget(parent) - - assert view._detail_ui_built is False - root_view_module.QTimer.singleShot = original_single_shot - - -def test_root_view_embedded_init_schedules_public_home_prefetch(qtbot, monkeypatch): - settings = Mock() - settings.get.side_effect = lambda key, default=None: { - "nick": "", - "quality": "320", - }.get(key, default) - context = Mock(settings=settings) - context.services.media = Mock() - provider = Mock() - provider.is_logged_in.return_value = False - provider.get_top_lists.return_value = [] - provider.get_top_list_tracks.return_value = [] - provider.get_recommendations.return_value = [] - provider.get_favorites.return_value = [] - provider.get_hotkeys.return_value = [] - provider.complete.return_value = [] - - scheduled = [] - - parent = QWidget() - view = QQMusicRootView(context, provider, parent=parent) - qtbot.addWidget(parent) - monkeypatch.setattr( - view, - "_schedule_home_sections_load", - lambda **kwargs: scheduled.append(kwargs), - ) - view._public_home_prefetch_scheduled = False - view._schedule_public_home_prefetch() - - assert scheduled == [{"load_private": False, "force": True}] - - -def test_root_view_embedded_logged_in_init_schedules_private_home_prefetch(qtbot, monkeypatch): - settings = Mock() - settings.get.side_effect = lambda key, default=None: { - "nick": "Tester", - "quality": "320", - }.get(key, default) - context = Mock(settings=settings) - context.services.media = Mock() - provider = Mock() - provider.is_logged_in.return_value = True - provider.get_top_lists.return_value = [] - provider.get_top_list_tracks.return_value = [] - provider.get_recommendations.return_value = [] - provider.get_favorites.return_value = [] - provider.get_hotkeys.return_value = [] - provider.complete.return_value = [] - - calls = [] - def _capture(delay, callback): - calls.append(delay) - return None - - monkeypatch.setattr("plugins.builtin.qqmusic.lib.root_view.QTimer.singleShot", _capture) - - parent = QWidget() - QQMusicRootView(context, provider, parent=parent) - qtbot.addWidget(parent) - - assert 600 in calls - - -def test_root_view_embedded_init_lazy_builds_results_ui(qtbot): - settings = Mock() - settings.get.side_effect = lambda key, default=None: { - "nick": "", - "quality": "320", - }.get(key, default) - context = Mock(settings=settings) - context.services.media = Mock() - provider = Mock() - provider.is_logged_in.return_value = False - provider.get_top_lists.return_value = [] - provider.get_top_list_tracks.return_value = [] - provider.get_recommendations.return_value = [] - provider.get_favorites.return_value = [] - provider.get_hotkeys.return_value = [] - provider.complete.return_value = [] - - import plugins.builtin.qqmusic.lib.root_view as root_view_module - original_single_shot = root_view_module.QTimer.singleShot - root_view_module.QTimer.singleShot = lambda *_args, **_kwargs: None - - parent = QWidget() - view = QQMusicRootView(context, provider, parent=parent) - qtbot.addWidget(parent) - - assert view._results_ui_built is False - root_view_module.QTimer.singleShot = original_single_shot - - -def test_root_view_embedded_search_builds_results_ui(qtbot): - settings = Mock() - settings.get.side_effect = lambda key, default=None: { - "nick": "", - "quality": "320", - "search_history": [], - }.get(key, default) - context = Mock(settings=settings) - context.services.media = Mock() - provider = Mock() - provider.is_logged_in.return_value = False - provider.get_top_lists.return_value = [] - provider.get_top_list_tracks.return_value = [] - provider.get_recommendations.return_value = [] - provider.get_favorites.return_value = [] - provider.get_hotkeys.return_value = [] - provider.complete.return_value = [] - provider.search.return_value = { - "tracks": [{"mid": "song-1", "title": "Song 1", "artist": "Singer 1"}], - "total": 1, - "page": 1, - "page_size": 30, - } - - parent = QWidget() - view = QQMusicRootView(context, provider, parent=parent) - qtbot.addWidget(parent) - - view._search_input.setText("Song 1") - view._run_search() - - assert view._results_ui_built is True - assert view._results_table.rowCount() == 1 - - -def test_root_view_show_event_loads_private_sections_after_public_prefetch(qtbot, monkeypatch): - settings = Mock() - settings.get.side_effect = lambda key, default=None: { - "nick": "Tester", - "quality": "320", - }.get(key, default) - context = Mock(settings=settings) - context.services.media = Mock() - provider = Mock() - provider.is_logged_in.return_value = True - provider.get_top_lists.return_value = [] - provider.get_top_list_tracks.return_value = [] - provider.get_recommendations.return_value = [] - provider.get_favorites.return_value = [] - provider.get_hotkeys.return_value = [] - provider.complete.return_value = [] - - scheduled = [] - parent = QWidget() - view = QQMusicRootView(context, provider, parent=parent) - qtbot.addWidget(parent) - view._home_sections_loaded = True - view._private_home_loaded = False - - monkeypatch.setattr( - view, - "_schedule_private_sections_load", - lambda **kwargs: scheduled.append(kwargs), - ) - - view.showEvent(QShowEvent()) - - assert scheduled == [{"force": False}] - - -def test_root_view_top_list_change_uses_async_track_loader(qtbot, monkeypatch): - settings = Mock() - settings.get.side_effect = lambda key, default=None: { - "nick": "", - "quality": "320", - }.get(key, default) - context = Mock(settings=settings) - context.services.media = Mock() - provider = Mock() - provider.is_logged_in.return_value = False - provider.get_top_lists.return_value = [{"id": 26, "title": "热歌榜"}] - provider.get_top_list_tracks.return_value = [{"mid": "song-1", "title": "Song 1"}] - provider.get_recommendations.return_value = [] - provider.get_favorites.return_value = [] - provider.get_hotkeys.return_value = [] - provider.complete.return_value = [] - - started = {} - - class _FakeWorker: - def __init__(self, *_args, **_kwargs): - self.tracks_loaded = Mock(connect=Mock()) - self.failed = Mock(connect=Mock()) - self.finished = Mock(connect=Mock()) - - def start(self): - started["started"] = True - - def isRunning(self): - return False - - def deleteLater(self): - started["deleted"] = True - - monkeypatch.setattr( - "plugins.builtin.qqmusic.lib.root_view.TopListTracksWorker", - _FakeWorker, - ) - - view = QQMusicRootView(context, provider) - qtbot.addWidget(view) - provider.get_top_list_tracks.reset_mock() - - view._on_top_list_changed(0) - - assert started.get("started") is True - provider.get_top_list_tracks.assert_not_called() - - -def test_root_view_top_list_async_load_shows_placeholder_rows(qtbot, monkeypatch): - settings = Mock() - settings.get.side_effect = lambda key, default=None: { - "nick": "", - "quality": "320", - }.get(key, default) - context = Mock(settings=settings) - context.services.media = Mock() - provider = Mock() - provider.is_logged_in.return_value = False - provider.get_top_lists.return_value = [{"id": 26, "title": "热歌榜"}] - provider.get_top_list_tracks.return_value = [{"mid": "song-1", "title": "Song 1"}] - provider.get_recommendations.return_value = [] - provider.get_favorites.return_value = [] - provider.get_hotkeys.return_value = [] - provider.complete.return_value = [] - - class _FakeWorker: - def __init__(self, *_args, **_kwargs): - self.tracks_loaded = Mock(connect=Mock()) - self.failed = Mock(connect=Mock()) - self.finished = Mock(connect=Mock()) - - def start(self): - return None - - def isRunning(self): - return False - - def deleteLater(self): - return None - - monkeypatch.setattr( - "plugins.builtin.qqmusic.lib.root_view.TopListTracksWorker", - _FakeWorker, - ) - monkeypatch.setattr( - "plugins.builtin.qqmusic.lib.root_view.QTimer.singleShot", - lambda *_args, **_kwargs: None, - ) - - parent = QWidget() - view = QQMusicRootView(context, provider, parent=parent) - qtbot.addWidget(parent) - - view._load_top_list_tracks_async(26) - - assert view._top_tracks_table.rowCount() == 10 - - -def test_root_view_async_home_load_shows_placeholder_cards_for_private_sections(qtbot, monkeypatch): - settings = Mock() - settings.get.side_effect = lambda key, default=None: { - "nick": "Tester", - "quality": "320", - }.get(key, default) - context = Mock(settings=settings) - context.services.media = Mock() - provider = Mock() - provider.is_logged_in.return_value = True - provider.get_top_lists.return_value = [{"id": 26, "title": "热歌榜"}] - provider.get_top_list_tracks.return_value = [{"mid": "song-1", "title": "Song 1"}] - provider.get_recommendations.return_value = [{"title": "推荐"}] - provider.get_favorites.return_value = [{"title": "收藏"}] - provider.get_hotkeys.return_value = [] - provider.complete.return_value = [] - - class _FakeWorker: - def __init__(self, *_args, **_kwargs): - self.home_loaded = Mock(connect=Mock()) - self.failed = Mock(connect=Mock()) - self.finished = Mock(connect=Mock()) - - def start(self): - return None - - def isRunning(self): - return False - - def deleteLater(self): - return None - - monkeypatch.setattr( - "plugins.builtin.qqmusic.lib.root_view.HomeSectionsWorker", - _FakeWorker, - ) - - parent = QWidget() - view = QQMusicRootView(context, provider, parent=parent) - qtbot.addWidget(parent) - - view._start_home_sections_worker(load_private=True, force=True) - - assert view._favorites_section.isHidden() is False - assert view._recommend_section.isHidden() is False - assert len(view._favorites_section._cards) == 5 - assert len(view._recommend_section._cards) == 5 - - -def test_root_view_async_home_load_shows_top_list_placeholders(qtbot, monkeypatch): - settings = Mock() - settings.get.side_effect = lambda key, default=None: { - "nick": "", - "quality": "320", - }.get(key, default) - context = Mock(settings=settings) - context.services.media = Mock() - provider = Mock() - provider.is_logged_in.return_value = False - provider.get_top_lists.return_value = [{"id": 26, "title": "热歌榜"}] - provider.get_top_list_tracks.return_value = [{"mid": "song-1", "title": "Song 1"}] - provider.get_recommendations.return_value = [] - provider.get_favorites.return_value = [] - provider.get_hotkeys.return_value = [] - provider.complete.return_value = [] - - class _FakeWorker: - def __init__(self, *_args, **_kwargs): - self.home_loaded = Mock(connect=Mock()) - self.failed = Mock(connect=Mock()) - self.finished = Mock(connect=Mock()) - - def start(self): - return None - - def isRunning(self): - return False - - def deleteLater(self): - return None - - monkeypatch.setattr( - "plugins.builtin.qqmusic.lib.root_view.HomeSectionsWorker", - _FakeWorker, - ) - - parent = QWidget() - view = QQMusicRootView(context, provider, parent=parent) - qtbot.addWidget(parent) - - view._start_home_sections_worker(load_private=False, force=True) - - assert view._top_list_widget.count() == 8 - assert view._top_tracks_table.rowCount() == 10 - - -def test_root_view_loads_recommendation_cards(qtbot): - settings = Mock() - settings.get.side_effect = lambda key, default=None: { - "nick": "Tester", - "quality": "320", - }.get(key, default) - context = Mock(settings=settings) - context.services.media = Mock() - provider = Mock() - provider.is_logged_in.return_value = True - provider.get_top_lists.return_value = [] - provider.get_top_list_tracks.return_value = [] - provider.get_recommendations.return_value = [ - { - "id": "guess", - "title": "猜你喜欢", - "subtitle": "2 项", - "cover_url": "", - "items": [{"mid": "song-1", "title": "Song 1"}], - "entry_type": "songs", - }, - ] - provider.get_favorites.return_value = [] - - view = QQMusicRootView(context, provider) - qtbot.addWidget(view) - - recommend_section = getattr(view, "_recommend_section", None) - assert recommend_section is not None - assert recommend_section.isHidden() is False - assert len(recommend_section._cards) == 1 - - -def test_root_view_favorite_song_card_opens_detail_view(qtbot): - settings = Mock() - settings.get.side_effect = lambda key, default=None: { - "nick": "Tester", - "quality": "320", - }.get(key, default) - context = Mock(settings=settings) - context.services.media = Mock() - provider = Mock() - provider.is_logged_in.return_value = True - provider.get_top_lists.return_value = [] - provider.get_top_list_tracks.return_value = [] - provider.search.return_value = { - "tracks": [{"mid": "song-x", "title": "Song X", "artist": "Singer X"}], - "total": 1, - "page": 1, - "page_size": 30, - } - provider.get_recommendations.return_value = [] - provider.get_favorites.return_value = [ - { - "id": "fav_songs", - "title": "我喜欢的歌曲", - "subtitle": "1 首", - "cover_url": "", - "items": [{"mid": "song-1", "title": "Song 1", "artist": "Singer 1", "cover_url": "http://example/song-cover.jpg"}], - "entry_type": "songs", - }, - ] - - view = QQMusicRootView(context, provider) - qtbot.addWidget(view) - view._search_input.setText("abc") - view._run_search() - assert view._search_type_tabs.isHidden() is False - - assert hasattr(view, "_open_favorite_card") - view._open_favorite_card(provider.get_favorites.return_value[0]) - - assert view._home_stack.currentWidget() is view._detail_page - assert view._detail_title.text() == "我喜欢的歌曲" - assert view._detail_tracks[0]["cover_url"] == "http://example/song-cover.jpg" - assert hasattr(view, "_detail_tracks_view") - assert view._detail_tracks_stack.currentWidget() is view._detail_tracks_view - assert view._detail_cover_url == "http://example/song-cover.jpg" - assert view._search_type_tabs.isHidden() is True - - -def test_root_view_recommendation_playlist_card_opens_playlist_results(qtbot): - settings = Mock() - settings.get.side_effect = lambda key, default=None: { - "nick": "Tester", - "quality": "320", - }.get(key, default) - context = Mock(settings=settings) - context.services.media = Mock() - provider = Mock() - provider.is_logged_in.return_value = True - provider.get_top_lists.return_value = [] - provider.get_top_list_tracks.return_value = [] - provider.get_recommendations.return_value = [ - { - "id": "songlist", - "title": "推荐歌单", - "subtitle": "1 项", - "cover_url": "", - "items": [{"id": "pl-1", "title": "Playlist 1", "creator": "Tester", "song_count": 12}], - "entry_type": "playlists", - }, - ] - provider.get_favorites.return_value = [] - - view = QQMusicRootView(context, provider) - qtbot.addWidget(view) - - assert hasattr(view, "_open_recommendation_card") - view._open_recommendation_card(provider.get_recommendations.return_value[0]) - - assert view._home_stack.currentWidget() is view._results_page - assert view._results_stack.currentWidget() is view._playlists_page - - -def test_root_view_recommendation_playlist_card_handles_nested_playlist_payload(qtbot): - settings = Mock() - settings.get.side_effect = lambda key, default=None: { - "nick": "Tester", - "quality": "320", - }.get(key, default) - context = Mock(settings=settings) - context.services.media = Mock() - provider = Mock() - provider.is_logged_in.return_value = True - provider.get_top_lists.return_value = [] - provider.get_top_list_tracks.return_value = [] - provider.get_recommendations.return_value = [ - { - "id": "songlist", - "title": "推荐歌单", - "subtitle": "1 项", - "cover_url": "", - "items": [ - { - "Playlist": { - "basic": {"id": "pl-1", "title": "Playlist 1", "cover_url": "http://example/cover.jpg"}, - "content": {"song_count": 12}, - } - } - ], - "entry_type": "playlists", - }, - ] - provider.get_favorites.return_value = [] - - view = QQMusicRootView(context, provider) - qtbot.addWidget(view) - - view._open_recommendation_card(provider.get_recommendations.return_value[0]) - - assert view._results_stack.currentWidget() is view._playlists_page - assert len(view._playlists_page._items) == 1 - - -def test_root_view_recommendation_song_card_handles_nested_track_payload(qtbot): - settings = Mock() - settings.get.side_effect = lambda key, default=None: { - "nick": "Tester", - "quality": "320", - }.get(key, default) - context = Mock(settings=settings) - context.services.media = Mock() - provider = Mock() - provider.is_logged_in.return_value = True - provider.get_top_lists.return_value = [] - provider.get_top_list_tracks.return_value = [] - provider.get_recommendations.return_value = [ - { - "id": "radar", - "title": "雷达歌单", - "subtitle": "1 项", - "cover_url": "", - "items": [ - { - "Track": { - "mid": "song-1", - "title": "Song 1", - "singer": [{"name": "Singer 1"}], - "album": {"name": "Album 1", "mid": "album-mid-1"}, - } - } - ], - "entry_type": "songs", - }, - ] - provider.get_favorites.return_value = [] - - view = QQMusicRootView(context, provider) - qtbot.addWidget(view) - - view._open_recommendation_card(provider.get_recommendations.return_value[0]) - - assert view._home_stack.currentWidget() is view._detail_page - assert view._detail_tracks_list.count() == 1 - assert "Singer 1" in view._detail_tracks_list.item(0).text() - assert view._detail_cover_url.endswith("T002R300x300M000album-mid-1.jpg") - - -def test_root_view_song_actions_use_media_bridge(qtbot): - settings = Mock() - settings.get.side_effect = lambda key, default=None: { - "nick": "Tester", - "quality": "flac", - }.get(key, default) - media = Mock() - context = Mock(settings=settings) - context.services.media = media - provider = Mock() - provider.is_logged_in.return_value = False - provider.get_top_lists.return_value = [] - provider.get_top_list_tracks.return_value = [] - provider.get_recommendations.return_value = [] - provider.get_favorites.return_value = [] - provider.search_tracks.return_value = [ - {"mid": "song-1", "title": "Song 1", "artist": "Singer 1", "album": "Album 1", "duration": 210} - ] - - view = QQMusicRootView(context, provider) - qtbot.addWidget(view) - - view._search_input.setText("Song 1") - view._run_search() - view._results_list.setCurrentRow(0) - - view._play_selected_song() - view._add_selected_song_to_queue() - view._insert_selected_song_to_queue() - view._download_selected_song() - - assert media.play_online_track.call_count == 1 - assert media.add_online_track_to_queue.call_count == 1 - assert media.insert_online_track_to_queue.call_count == 1 - assert media.cache_remote_track.call_count == 1 - - -def test_root_view_build_playback_request_normalizes_nested_song_fields(qtbot): - settings = Mock() - settings.get.side_effect = lambda key, default=None: { - "nick": "Tester", - "quality": "flac", - }.get(key, default) - context = Mock(settings=settings) - context.services.media = Mock() - provider = Mock() - provider.is_logged_in.return_value = False - provider.get_top_lists.return_value = [] - provider.get_top_list_tracks.return_value = [] - provider.get_recommendations.return_value = [] - provider.get_favorites.return_value = [] - - view = QQMusicRootView(context, provider) - qtbot.addWidget(view) - - request = view._build_playback_request( - { - "mid": "song-1", - "title": "Song 1", - "singer": [{"name": "Singer 1"}, {"name": "Singer 2"}], - "album": {"name": "Album 1", "mid": "album-mid-1"}, - "interval": 210, - } - ) - - assert request.metadata["artist"] == "Singer 1, Singer 2" - assert request.metadata["album"] == "Album 1" - assert request.metadata["duration"] == 210.0 - assert request.metadata["cover_url"].endswith("T002R300x300M000album-mid-1.jpg") - - -def test_root_view_top_track_activation_uses_media_bridge(qtbot): - settings = Mock() - settings.get.side_effect = lambda key, default=None: { - "nick": "", - "quality": "320", - }.get(key, default) - media = Mock() - context = Mock(settings=settings) - context.services.media = media - provider = Mock() - provider.is_logged_in.return_value = False - provider.get_top_lists.return_value = [ - {"id": 26, "title": "热歌榜"}, - ] - provider.get_top_list_tracks.return_value = [ - {"mid": "song-1", "title": "Song 1", "artist": "Singer 1", "album": "Album 1", "duration": 210} - ] - provider.get_recommendations.return_value = [] - provider.get_favorites.return_value = [] - - view = QQMusicRootView(context, provider) - qtbot.addWidget(view) - - view._play_top_track(0, 0) - - media.play_online_track.assert_called_once() - media.add_online_track_to_queue.assert_not_called() - - -def test_root_view_top_track_activation_queues_remaining_top_tracks(qtbot): - settings = Mock() - settings.get.side_effect = lambda key, default=None: { - "nick": "", - "quality": "320", - }.get(key, default) - media = Mock() - context = Mock(settings=settings) - context.services.media = media - provider = Mock() - provider.is_logged_in.return_value = False - provider.get_top_lists.return_value = [ - {"id": 26, "title": "热歌榜"}, - ] - provider.get_top_list_tracks.return_value = [ - {"mid": "song-1", "title": "Song 1", "artist": "Singer 1", "album": "Album 1", "duration": 210}, - {"mid": "song-2", "title": "Song 2", "artist": "Singer 2", "album": "Album 2", "duration": 180}, - {"mid": "song-3", "title": "Song 3", "artist": "Singer 3", "album": "Album 3", "duration": 200}, - ] - provider.get_recommendations.return_value = [] - provider.get_favorites.return_value = [] - - view = QQMusicRootView(context, provider) - qtbot.addWidget(view) - - view._play_top_track(1, 0) - - media.play_online_track.assert_called_once() - assert media.add_online_track_to_queue.call_count == 1 - - -def test_root_view_ranking_list_activation_queues_remaining_top_tracks(qtbot): - settings = Mock() - settings.get.side_effect = lambda key, default=None: { - "nick": "", - "quality": "320", - "ranking_view_mode": "list", - }.get(key, default) - media = Mock() - context = Mock(settings=settings) - context.services.media = media - provider = Mock() - provider.is_logged_in.return_value = False - provider.get_top_lists.return_value = [ - {"id": 26, "title": "热歌榜"}, - ] - provider.get_top_list_tracks.return_value = [ - {"mid": "song-1", "title": "Song 1", "artist": "Singer 1", "album": "Album 1", "duration": 210}, - {"mid": "song-2", "title": "Song 2", "artist": "Singer 2", "album": "Album 2", "duration": 180}, - {"mid": "song-3", "title": "Song 3", "artist": "Singer 3", "album": "Album 3", "duration": 200}, - ] - provider.get_recommendations.return_value = [] - provider.get_favorites.return_value = [] - - view = QQMusicRootView(context, provider) - qtbot.addWidget(view) - - view._on_ranking_track_activated(view._ranking_tracks[1]) - - media.play_online_track.assert_called_once() - assert media.add_online_track_to_queue.call_count == 1 - - -def test_root_view_ranking_toggle_switches_between_table_and_list(qtbot): - settings = Mock() - state = {"nick": "", "quality": "320", "ranking_view_mode": "table"} - settings.get.side_effect = lambda key, default=None: state.get(key, default) - settings.set.side_effect = lambda key, value: state.__setitem__(key, value) - context = Mock(settings=settings) - context.services.media = Mock() - provider = Mock() - provider.is_logged_in.return_value = False - provider.get_top_lists.return_value = [{"id": 26, "title": "热歌榜"}] - provider.get_top_list_tracks.return_value = [ - {"mid": "song-1", "title": "Song 1", "artist": "Singer 1", "album": "Album 1", "duration": 210}, - ] - provider.get_recommendations.return_value = [] - provider.get_favorites.return_value = [] - - view = QQMusicRootView(context, provider) - qtbot.addWidget(view) - - assert hasattr(view, "_toggle_ranking_view_mode") - assert getattr(view, "_ranking_stacked_widget", None) is not None - initial_tooltip = view._ranking_view_toggle_btn.toolTip() - view._toggle_ranking_view_mode() - - assert state["ranking_view_mode"] == "list" - assert view._ranking_stacked_widget.currentWidget() is view._ranking_list_view - assert view._ranking_view_toggle_btn.toolTip() != initial_tooltip - - -def test_root_view_ranking_batch_queue_actions_use_media_bridge(qtbot): - settings = Mock() - settings.get.side_effect = lambda key, default=None: { - "nick": "", - "quality": "320", - "ranking_view_mode": "table", - }.get(key, default) - context = Mock(settings=settings) - context.services.media = Mock() - provider = Mock() - provider.is_logged_in.return_value = False - provider.get_top_lists.return_value = [{"id": 26, "title": "热歌榜"}] - provider.get_top_list_tracks.return_value = [ - {"mid": "song-1", "title": "Song 1", "artist": "Singer 1", "album": "Album 1", "duration": 210}, - {"mid": "song-2", "title": "Song 2", "artist": "Singer 2", "album": "Album 2", "duration": 180}, - ] - provider.get_recommendations.return_value = [] - provider.get_favorites.return_value = [] - - view = QQMusicRootView(context, provider) - qtbot.addWidget(view) - - assert hasattr(view, "_add_selected_tracks_to_queue") - assert hasattr(view, "_insert_selected_tracks_to_queue") - assert hasattr(view, "_download_selected_tracks") - - tracks = [view._top_track_item(0), view._top_track_item(1)] - view._add_selected_tracks_to_queue(tracks) - view._insert_selected_tracks_to_queue(tracks) - view._download_selected_tracks(tracks) - - assert context.services.media.add_online_track_to_queue.call_count == 2 - assert context.services.media.insert_online_track_to_queue.call_count == 2 - assert context.services.media.cache_remote_track.call_count == 2 - - -def test_root_view_ranking_favorite_toggle_adds_to_favorites(qtbot, monkeypatch): - settings = Mock() - settings.get.side_effect = lambda key, default=None: { - "nick": "", - "quality": "320", - "ranking_view_mode": "list", - }.get(key, default) - context = Mock(settings=settings) - context.services.media = Mock() - provider = Mock() - provider.is_logged_in.return_value = False - provider.get_top_lists.return_value = [{"id": 26, "title": "热歌榜"}] - provider.get_top_list_tracks.return_value = [ - {"mid": "song-1", "title": "Song 1", "artist": "Singer 1", "album": "Album 1", "duration": 210}, - ] - provider.get_recommendations.return_value = [] - provider.get_favorites.return_value = [] - - bootstrap = Mock() - bootstrap.library_service.add_online_track.return_value = 301 - bootstrap.favorites_service = Mock() - monkeypatch.setattr("app.bootstrap.Bootstrap.instance", Mock(return_value=bootstrap)) - - view = QQMusicRootView(context, provider) - qtbot.addWidget(view) - view._ranking_list_view.set_track_favorite = Mock() - - view._on_ranking_favorite_toggled(view._ranking_tracks[0], True) - - bootstrap.favorites_service.add_favorite.assert_called_once_with(track_id=301) - view._ranking_list_view.set_track_favorite.assert_called_once_with("song-1", True) - - -def test_root_view_ranking_favorite_toggle_removes_existing_favorite(qtbot, monkeypatch): - settings = Mock() - settings.get.side_effect = lambda key, default=None: { - "nick": "", - "quality": "320", - "ranking_view_mode": "list", - }.get(key, default) - context = Mock(settings=settings) - context.services.media = Mock() - provider = Mock() - provider.is_logged_in.return_value = False - provider.get_top_lists.return_value = [{"id": 26, "title": "热歌榜"}] - provider.get_top_list_tracks.return_value = [ - {"mid": "song-1", "title": "Song 1", "artist": "Singer 1", "album": "Album 1", "duration": 210}, - ] - provider.get_recommendations.return_value = [] - provider.get_favorites.return_value = [] - - bootstrap = Mock() - bootstrap.library_service.get_track_by_cloud_file_id.return_value = Mock(id=401) - bootstrap.favorites_service = Mock() - monkeypatch.setattr("app.bootstrap.Bootstrap.instance", Mock(return_value=bootstrap)) - - view = QQMusicRootView(context, provider) - qtbot.addWidget(view) - view._ranking_list_view.set_track_favorite = Mock() - - view._on_ranking_favorite_toggled(view._ranking_tracks[0], False) - - bootstrap.favorites_service.remove_favorite.assert_called_once_with(track_id=401) - view._ranking_list_view.set_track_favorite.assert_called_once_with("song-1", False) - - -def test_root_view_ranking_list_loads_initial_favorite_mids(qtbot, monkeypatch): - settings = Mock() - settings.get.side_effect = lambda key, default=None: { - "nick": "", - "quality": "320", - "ranking_view_mode": "list", - }.get(key, default) - context = Mock(settings=settings) - context.services.media = Mock() - provider = Mock() - provider.is_logged_in.return_value = False - provider.get_top_lists.return_value = [{"id": 26, "title": "热歌榜"}] - provider.get_top_list_tracks.return_value = [ - {"mid": "song-1", "title": "Song 1", "artist": "Singer 1", "album": "Album 1", "duration": 210}, - {"mid": "song-2", "title": "Song 2", "artist": "Singer 2", "album": "Album 2", "duration": 180}, - ] - provider.get_recommendations.return_value = [] - provider.get_favorites.return_value = [] - - bootstrap = Mock() - bootstrap.favorites_service.get_all_favorite_track_ids.return_value = {11} - bootstrap.library_service.get_tracks_by_ids.return_value = [Mock(cloud_file_id="song-2")] - monkeypatch.setattr("app.bootstrap.Bootstrap.instance", Mock(return_value=bootstrap)) - - view = QQMusicRootView(context, provider) - qtbot.addWidget(view) - - favorite_mids = view._ranking_list_view._model._favorite_mids - - assert favorite_mids == {"song-2"} - - -def test_root_view_selected_tracks_from_ranking_list_supports_multi_select(qtbot): - settings = Mock() - settings.get.side_effect = lambda key, default=None: { - "nick": "", - "quality": "320", - "ranking_view_mode": "list", - }.get(key, default) - context = Mock(settings=settings) - context.services.media = Mock() - provider = Mock() - provider.is_logged_in.return_value = False - provider.get_top_lists.return_value = [{"id": 26, "title": "热歌榜"}] - provider.get_top_list_tracks.return_value = [ - {"mid": "song-1", "title": "Song 1", "artist": "Singer 1", "album": "Album 1", "duration": 210}, - {"mid": "song-2", "title": "Song 2", "artist": "Singer 2", "album": "Album 2", "duration": 180}, - ] - provider.get_recommendations.return_value = [] - provider.get_favorites.return_value = [] - - view = QQMusicRootView(context, provider) - qtbot.addWidget(view) - - selection_model = view._ranking_list_view._list_view.selectionModel() - selection_model.select( - view._ranking_list_view._model.index(0), - selection_model.SelectionFlag.Select | selection_model.SelectionFlag.Rows, - ) - selection_model.select( - view._ranking_list_view._model.index(1), - selection_model.SelectionFlag.Select | selection_model.SelectionFlag.Rows, - ) - - assert hasattr(view, "_selected_tracks_from_tracks_view") - tracks = view._selected_tracks_from_tracks_view(view._ranking_list_view) - - assert [track["mid"] for track in tracks] == ["song-1", "song-2"] - - -def test_root_view_selected_tracks_from_results_table_supports_multi_select(qtbot): - settings = Mock() - settings.get.side_effect = lambda key, default=None: { - "nick": "Tester", - "quality": "320", - }.get(key, default) - context = Mock(settings=settings) - context.services.media = Mock() - provider = Mock() - provider.is_logged_in.return_value = True - provider.get_top_lists.return_value = [] - provider.get_top_list_tracks.return_value = [] - provider.get_recommendations.return_value = [] - provider.get_favorites.return_value = [] - provider.search.return_value = { - "tracks": [ - {"mid": "song-1", "title": "Song 1", "artist": "Singer 1", "album": "Album 1", "duration": 210}, - {"mid": "song-2", "title": "Song 2", "artist": "Singer 2", "album": "Album 2", "duration": 180}, - ], - "total": 2, - "page": 1, - "page_size": 30, - } - - view = QQMusicRootView(context, provider) - qtbot.addWidget(view) - - view._search_input.setText("Song") - view._run_search() - view._results_table.setSelectionMode(view._results_table.SelectionMode.MultiSelection) - view._results_table.selectRow(0) - view._results_table.selectRow(1) - - assert hasattr(view, "_selected_tracks_from_table") - tracks = view._selected_tracks_from_table(view._results_table) - - assert [track["mid"] for track in tracks] == ["song-1", "song-2"] - - -def test_root_view_song_buttons_use_multi_selected_results_tracks(qtbot): - settings = Mock() - settings.get.side_effect = lambda key, default=None: { - "nick": "Tester", - "quality": "320", - }.get(key, default) - context = Mock(settings=settings) - context.services.media = Mock() - provider = Mock() - provider.is_logged_in.return_value = True - provider.get_top_lists.return_value = [] - provider.get_top_list_tracks.return_value = [] - provider.get_recommendations.return_value = [] - provider.get_favorites.return_value = [] - provider.search.return_value = { - "tracks": [ - {"mid": "song-1", "title": "Song 1", "artist": "Singer 1", "album": "Album 1", "duration": 210}, - {"mid": "song-2", "title": "Song 2", "artist": "Singer 2", "album": "Album 2", "duration": 180}, - ], - "total": 2, - "page": 1, - "page_size": 30, - } - - view = QQMusicRootView(context, provider) - qtbot.addWidget(view) - - view._search_input.setText("Song") - view._run_search() - selection_model = view._results_table.selectionModel() - selection_model.select( - view._results_table.model().index(0, 0), - selection_model.SelectionFlag.Select | selection_model.SelectionFlag.Rows, - ) - selection_model.select( - view._results_table.model().index(1, 0), - selection_model.SelectionFlag.Select | selection_model.SelectionFlag.Rows, - ) - - view._add_selected_song_to_queue() - view._insert_selected_song_to_queue() - view._download_selected_song() - - assert context.services.media.add_online_track_to_queue.call_count == 2 - assert context.services.media.insert_online_track_to_queue.call_count == 2 - assert context.services.media.cache_remote_track.call_count == 2 - - -def test_root_view_play_button_plays_first_selected_result_and_queues_rest(qtbot): - settings = Mock() - settings.get.side_effect = lambda key, default=None: { - "nick": "Tester", - "quality": "320", - }.get(key, default) - context = Mock(settings=settings) - context.services.media = Mock() - provider = Mock() - provider.is_logged_in.return_value = True - provider.get_top_lists.return_value = [] - provider.get_top_list_tracks.return_value = [] - provider.get_recommendations.return_value = [] - provider.get_favorites.return_value = [] - provider.search.return_value = { - "tracks": [ - {"mid": "song-1", "title": "Song 1", "artist": "Singer 1", "album": "Album 1", "duration": 210}, - {"mid": "song-2", "title": "Song 2", "artist": "Singer 2", "album": "Album 2", "duration": 180}, - ], - "total": 2, - "page": 1, - "page_size": 30, - } - - view = QQMusicRootView(context, provider) - qtbot.addWidget(view) - - view._search_input.setText("Song") - view._run_search() - selection_model = view._results_table.selectionModel() - selection_model.select( - view._results_table.model().index(0, 0), - selection_model.SelectionFlag.Select | selection_model.SelectionFlag.Rows, - ) - selection_model.select( - view._results_table.model().index(1, 0), - selection_model.SelectionFlag.Select | selection_model.SelectionFlag.Rows, - ) - - view._play_selected_song() - - context.services.media.play_online_track.assert_called_once() - assert context.services.media.add_online_track_to_queue.call_count == 1 - - -def test_root_view_add_selected_to_favorites_uses_bootstrap_services(qtbot, monkeypatch): - settings = Mock() - settings.get.side_effect = lambda key, default=None: { - "nick": "Tester", - "quality": "320", - }.get(key, default) - context = Mock(settings=settings) - context.services.media = Mock() - provider = Mock() - provider.is_logged_in.return_value = True - provider.get_top_lists.return_value = [] - provider.get_top_list_tracks.return_value = [] - provider.get_recommendations.return_value = [] - provider.get_favorites.return_value = [] - - bootstrap = Mock() - bootstrap.library_service.add_online_track.side_effect = [101, 102] - bootstrap.favorites_service = Mock() - monkeypatch.setattr("app.bootstrap.Bootstrap.instance", Mock(return_value=bootstrap)) - - view = QQMusicRootView(context, provider) - qtbot.addWidget(view) - - tracks = [ - {"mid": "song-1", "title": "Song 1", "artist": "Singer 1", "album": "Album 1", "duration": 210}, - {"mid": "song-2", "title": "Song 2", "artist": "Singer 2", "album": "Album 2", "duration": 180}, - ] - - view._add_selected_to_favorites(tracks) - - assert bootstrap.library_service.add_online_track.call_count == 2 - assert bootstrap.favorites_service.add_favorite.call_count == 2 - - -def test_root_view_add_selected_to_playlist_uses_playlist_helper(qtbot, monkeypatch): - settings = Mock() - settings.get.side_effect = lambda key, default=None: { - "nick": "Tester", - "quality": "320", - }.get(key, default) - context = Mock(settings=settings) - context.services.media = Mock() - provider = Mock() - provider.is_logged_in.return_value = True - provider.get_top_lists.return_value = [] - provider.get_top_list_tracks.return_value = [] - provider.get_recommendations.return_value = [] - provider.get_favorites.return_value = [] - - bootstrap = Mock() - bootstrap.library_service.add_online_track.side_effect = [201, 202] - monkeypatch.setattr("app.bootstrap.Bootstrap.instance", Mock(return_value=bootstrap)) - playlist_adder = Mock() - monkeypatch.setattr("utils.playlist_utils.add_tracks_to_playlist", playlist_adder) - - view = QQMusicRootView(context, provider) - qtbot.addWidget(view) - - tracks = [ - {"mid": "song-1", "title": "Song 1", "artist": "Singer 1", "album": "Album 1", "duration": 210}, - {"mid": "song-2", "title": "Song 2", "artist": "Singer 2", "album": "Album 2", "duration": 180}, - ] - - view._add_selected_to_playlist(tracks) - - playlist_adder.assert_called_once() - - -def test_root_view_artist_detail_navigation(qtbot): - settings = Mock() - settings.get.side_effect = lambda key, default=None: { - "nick": "Tester", - "quality": "320", - }.get(key, default) - media = Mock() - context = Mock(settings=settings) - context.services.media = media - provider = Mock() - provider.is_logged_in.return_value = True - provider.get_top_lists.return_value = [] - provider.get_top_list_tracks.return_value = [] - provider.get_recommendations.return_value = [] - provider.get_favorites.return_value = [] - provider.search.return_value = { - "artists": [{"mid": "artist-1", "name": "Singer 1", "song_count": 12}], - } - provider.get_artist_detail.return_value = { - "title": "Singer 1", - "songs": [ - {"mid": "song-1", "title": "Song 1", "artist": "Singer 1", "album": "Album 1", "duration": 210}, - ], - } - - view = QQMusicRootView(context, provider) - qtbot.addWidget(view) - - view._search_input.setText("Singer 1") - view._search_type_tabs.setCurrentIndex(1) - view._run_search() - view._artists_list.setCurrentRow(0) - - view._open_artist_detail(view._artists_list.item(0)) - - assert view._home_stack.currentWidget() is view._detail_page - assert hasattr(view, "_detail_info_section") - assert hasattr(view, "_detail_songs_section") - assert view._detail_type_label.text() == t("artist") - assert view._detail_title.text() == "Singer 1" - assert view._detail_stats_label.text() == f"12 {t('songs')}" - assert view._detail_tracks_list.count() == 1 - provider.get_artist_detail.assert_called_once_with("artist-1") - view._go_back_from_detail() - assert view._results_stack.currentWidget() is view._artists_page - - -def test_root_view_artist_detail_exposes_follow_toggle(qtbot): - settings = Mock() - settings.get.side_effect = lambda key, default=None: { - "nick": "Tester", - "quality": "320", - }.get(key, default) - context = Mock(settings=settings) - context.services.media = Mock() - provider = Mock() - provider.is_logged_in.return_value = True - provider.get_top_lists.return_value = [] - provider.get_top_list_tracks.return_value = [] - provider.get_recommendations.return_value = [] - provider.get_favorites.return_value = [] - provider.search.return_value = { - "artists": [{"mid": "artist-1", "name": "Singer 1", "song_count": 12}], - } - provider.get_artist_detail.return_value = { - "title": "Singer 1", - "songs": [{"mid": "song-1", "title": "Song 1", "artist": "Singer 1"}], - "follow_status": False, - } - provider.follow_artist.return_value = True - - view = QQMusicRootView(context, provider) - qtbot.addWidget(view) - - view._search_input.setText("Singer 1") - view._search_type_tabs.setCurrentIndex(1) - view._run_search() - view._open_artist_detail(view._artists_list.item(0)) - - assert view._detail_follow_btn.isHidden() is False - - view._detail_follow_btn.click() - - provider.follow_artist.assert_called_once_with("artist-1") - assert view._detail_follow_btn.text() == t("qqmusic_followed", "Following") - - -def test_root_view_album_detail_exposes_qq_favorite_toggle(qtbot): - settings = Mock() - settings.get.side_effect = lambda key, default=None: { - "nick": "Tester", - "quality": "320", - }.get(key, default) - context = Mock(settings=settings) - context.services.media = Mock() - provider = Mock() - provider.is_logged_in.return_value = True - provider.get_top_lists.return_value = [] - provider.get_top_list_tracks.return_value = [] - provider.get_recommendations.return_value = [] - provider.get_favorites.return_value = [] - provider.search.return_value = { - "albums": [{"mid": "album-1", "name": "Album 1", "singer_name": "Singer 1"}], - } - provider.get_album_detail.return_value = { - "title": "Album 1", - "songs": [{"mid": "song-1", "title": "Song 1", "artist": "Singer 1"}], - "is_faved": False, - } - provider.fav_album.return_value = True - - view = QQMusicRootView(context, provider) - qtbot.addWidget(view) - - view._search_input.setText("Album 1") - view._search_type_tabs.setCurrentIndex(2) - view._run_search() - view._open_album_detail(view._albums_list.item(0)) - - assert view._detail_fav_btn.isHidden() is False - assert view._detail_type_label.text() == t("album") - assert "Singer 1" in view._detail_meta_label.text() - - view._detail_fav_btn.click() - - provider.fav_album.assert_called_once_with("album-1") - assert view._detail_fav_btn.text() == t("qqmusic_remove_from_favorites", "Remove from QQ Favorites") - - -def test_root_view_playlist_detail_exposes_qq_favorite_toggle(qtbot): - settings = Mock() - settings.get.side_effect = lambda key, default=None: { - "nick": "Tester", - "quality": "320", - }.get(key, default) - context = Mock(settings=settings) - context.services.media = Mock() - provider = Mock() - provider.is_logged_in.return_value = True - provider.get_top_lists.return_value = [] - provider.get_top_list_tracks.return_value = [] - provider.get_recommendations.return_value = [] - provider.get_favorites.return_value = [] - provider.search.return_value = { - "playlists": [{"id": "playlist-1", "title": "Playlist 1", "creator": "Tester"}], - } - provider.get_playlist_detail.return_value = { - "title": "Playlist 1", - "songs": [{"mid": "song-1", "title": "Song 1", "artist": "Singer 1"}], - "is_faved": False, - } - provider.fav_playlist.return_value = True - - view = QQMusicRootView(context, provider) - qtbot.addWidget(view) - - view._search_input.setText("Playlist 1") - view._search_type_tabs.setCurrentIndex(3) - view._run_search() - view._open_playlist_detail(view._playlists_list.item(0)) - - assert view._detail_fav_btn.isHidden() is False - assert view._detail_type_label.text() == t("playlists") - - view._detail_fav_btn.click() - - provider.fav_playlist.assert_called_once_with("playlist-1") - assert view._detail_fav_btn.text() == t("qqmusic_remove_from_favorites", "Remove from QQ Favorites") - - -def test_root_view_artist_detail_shows_related_albums_and_opens_album_detail(qtbot): - settings = Mock() - settings.get.side_effect = lambda key, default=None: { - "nick": "Tester", - "quality": "320", - }.get(key, default) - context = Mock(settings=settings) - context.services.media = Mock() - provider = Mock() - provider.is_logged_in.return_value = True - provider.get_top_lists.return_value = [] - provider.get_top_list_tracks.return_value = [] - provider.get_recommendations.return_value = [] - provider.get_favorites.return_value = [] - provider.search.return_value = { - "artists": [{"mid": "artist-1", "name": "Singer 1", "song_count": 12}], - } - provider.get_artist_detail.return_value = { - "title": "Singer 1", - "songs": [{"mid": "song-1", "title": "Song 1", "artist": "Singer 1"}], - } - provider.get_artist_albums.return_value = [ - {"mid": "album-1", "name": "Album 1", "singer_name": "Singer 1"}, - ] - provider.get_album_detail.return_value = { - "title": "Album 1", - "songs": [{"mid": "song-2", "title": "Song 2", "artist": "Singer 1"}], - } - - view = QQMusicRootView(context, provider) - qtbot.addWidget(view) - - view._search_input.setText("Singer 1") - view._search_type_tabs.setCurrentIndex(1) - view._run_search() - view._open_artist_detail(view._artists_list.item(0)) - - assert hasattr(view, "_detail_albums_list") - assert view._detail_albums_list.count() == 1 - - view._open_album_from_detail(view._detail_albums_list.item(0)) - - assert view._detail_title.text() == "Album 1" - - -def test_root_view_back_from_album_detail_restores_artist_detail(qtbot): - settings = Mock() - settings.get.side_effect = lambda key, default=None: { - "nick": "Tester", - "quality": "320", - }.get(key, default) - context = Mock(settings=settings) - context.services.media = Mock() - provider = Mock() - provider.is_logged_in.return_value = True - provider.get_top_lists.return_value = [] - provider.get_top_list_tracks.return_value = [] - provider.get_recommendations.return_value = [] - provider.get_favorites.return_value = [] - provider.search.return_value = { - "artists": [{"mid": "artist-1", "name": "Singer 1", "song_count": 12}], - } - provider.get_artist_detail.return_value = { - "title": "Singer 1", - "songs": [{"mid": "song-1", "title": "Song 1", "artist": "Singer 1"}], - } - provider.get_artist_albums.return_value = [ - {"mid": "album-1", "name": "Album 1", "singer_name": "Singer 1"}, - ] - provider.get_album_detail.return_value = { - "title": "Album 1", - "songs": [{"mid": "song-2", "title": "Song 2", "artist": "Singer 1"}], - } - - view = QQMusicRootView(context, provider) - qtbot.addWidget(view) - - view._search_input.setText("Singer 1") - view._search_type_tabs.setCurrentIndex(1) - view._run_search() - view._open_artist_detail(view._artists_list.item(0)) - view._open_album_from_detail(view._detail_albums_list.item(0)) - view._go_back_from_detail() - - assert view._detail_title.text() == "Singer 1" - assert view._detail_albums_list.count() == 1 - - -def test_root_view_album_and_playlist_detail_navigation(qtbot): - settings = Mock() - settings.get.side_effect = lambda key, default=None: { - "nick": "Tester", - "quality": "320", - }.get(key, default) - media = Mock() - context = Mock(settings=settings) - context.services.media = media - provider = Mock() - provider.is_logged_in.return_value = True - provider.get_top_lists.return_value = [] - provider.get_top_list_tracks.return_value = [] - provider.get_recommendations.return_value = [] - provider.get_favorites.return_value = [] - provider.search.side_effect = [ - {"albums": [{"mid": "album-1", "name": "Album 1", "singer_name": "Singer 1"}]}, - {"albums": [{"mid": "album-1", "name": "Album 1", "singer_name": "Singer 1"}]}, - {"playlists": [{"id": "playlist-1", "title": "Playlist 1", "creator": "Tester"}]}, - {"playlists": [{"id": "playlist-1", "title": "Playlist 1", "creator": "Tester"}]}, - ] - provider.get_album_detail.return_value = { - "title": "Album 1", - "songs": [{"mid": "song-1", "title": "Song 1", "artist": "Singer 1"}], - } - provider.get_playlist_detail.return_value = { - "title": "Playlist 1", - "songs": [{"mid": "song-2", "title": "Song 2", "artist": "Singer 2"}], - } - - view = QQMusicRootView(context, provider) - qtbot.addWidget(view) - - view._search_input.setText("Album 1") - view._search_type_tabs.setCurrentIndex(2) - view._run_search() - view._open_album_detail(view._albums_list.item(0)) - assert view._detail_title.text() == "Album 1" - view._go_back_from_detail() - assert view._results_stack.currentWidget() is view._albums_page - - view._search_input.setText("Playlist 1") - view._search_type_tabs.setCurrentIndex(3) - view._run_search() - view._open_playlist_detail(view._playlists_list.item(0)) - assert view._detail_title.text() == "Playlist 1" - view._go_back_from_detail() - assert view._results_stack.currentWidget() is view._playlists_page - - -def test_root_view_detail_actions_use_media_bridge(qtbot): - settings = Mock() - settings.get.side_effect = lambda key, default=None: { - "nick": "Tester", - "quality": "flac", - }.get(key, default) - media = Mock() - context = Mock(settings=settings) - context.services.media = media - provider = Mock() - provider.is_logged_in.return_value = True - provider.get_top_lists.return_value = [] - provider.get_top_list_tracks.return_value = [] - provider.get_recommendations.return_value = [] - provider.get_favorites.return_value = [] - provider.search.return_value = { - "artists": [{"mid": "artist-1", "name": "Singer 1", "song_count": 12}], - } - provider.get_artist_detail.return_value = { - "title": "Singer 1", - "songs": [{"mid": "song-1", "title": "Song 1", "artist": "Singer 1", "album": "Album 1"}], - } - - view = QQMusicRootView(context, provider) - qtbot.addWidget(view) - - view._search_input.setText("Singer 1") - view._search_type_tabs.setCurrentIndex(1) - view._run_search() - view._open_artist_detail(view._artists_list.item(0)) - view._detail_tracks_list.setCurrentRow(0) - - view._play_selected_detail_track() - view._add_selected_detail_track_to_queue() - view._insert_selected_detail_track_to_queue() - view._download_selected_detail_track() - - assert media.play_online_track.call_count == 1 - assert media.add_online_track_to_queue.call_count == 1 - assert media.insert_online_track_to_queue.call_count == 1 - assert media.cache_remote_track.call_count == 1 - - -def test_root_view_detail_view_supports_batch_actions(qtbot): - settings = Mock() - settings.get.side_effect = lambda key, default=None: { - "nick": "Tester", - "quality": "flac", - }.get(key, default) - context = Mock(settings=settings) - context.services.media = Mock() - provider = Mock() - provider.is_logged_in.return_value = True - provider.get_top_lists.return_value = [] - provider.get_top_list_tracks.return_value = [] - provider.get_recommendations.return_value = [] - provider.get_favorites.return_value = [] - provider.search.return_value = { - "artists": [{"mid": "artist-1", "name": "Singer 1", "song_count": 12}], - "total": 1, - "page": 1, - "page_size": 30, - } - provider.get_artist_detail.return_value = { - "title": "Singer 1", - "description": "desc", - "songs": [ - {"mid": "song-1", "title": "Song 1", "artist": "Singer 1", "album": "Album 1", "duration": 210}, - {"mid": "song-2", "title": "Song 2", "artist": "Singer 1", "album": "Album 1", "duration": 180}, - ], - } - - view = QQMusicRootView(context, provider) - qtbot.addWidget(view) - - view._search_input.setText("Singer 1") - view._search_type_tabs.setCurrentIndex(1) - view._run_search() - - assert hasattr(view, "_open_artist_detail_from_grid") - assert hasattr(view, "_play_all_from_detail_tracks") - assert hasattr(view, "_add_all_detail_tracks_to_queue") - assert hasattr(view, "_insert_all_detail_tracks_to_queue") - - view._open_artist_detail_from_grid({"mid": "artist-1", "name": "Singer 1"}) - view._play_all_from_detail_tracks() - view._add_all_detail_tracks_to_queue() - view._insert_all_detail_tracks_to_queue() - - assert context.services.media.play_online_track.call_count == 1 - assert context.services.media.add_online_track_to_queue.call_count == 3 - assert context.services.media.insert_online_track_to_queue.call_count == 2 - - -def test_root_view_detail_has_visible_all_track_action_buttons(qtbot): - settings = Mock() - settings.get.side_effect = lambda key, default=None: { - "nick": "Tester", - "quality": "320", - }.get(key, default) - context = Mock(settings=settings) - context.services.media = Mock() - provider = Mock() - provider.is_logged_in.return_value = True - provider.get_top_lists.return_value = [] - provider.get_top_list_tracks.return_value = [] - provider.get_recommendations.return_value = [] - provider.get_favorites.return_value = [] - provider.search.return_value = { - "artists": [{"mid": "artist-1", "name": "Singer 1", "song_count": 12}], - "total": 1, - "page": 1, - "page_size": 30, - } - provider.get_artist_detail.return_value = { - "title": "Singer 1", - "songs": [ - {"mid": "song-1", "title": "Song 1", "artist": "Singer 1", "album": "Album 1"}, - {"mid": "song-2", "title": "Song 2", "artist": "Singer 1", "album": "Album 1"}, - ], - } - - view = QQMusicRootView(context, provider) - qtbot.addWidget(view) - - view._search_input.setText("Singer 1") - view._search_type_tabs.setCurrentIndex(1) - view._run_search() - view._open_artist_detail(view._artists_list.item(0)) - - assert hasattr(view, "_detail_play_all_btn") - assert hasattr(view, "_detail_queue_all_btn") - assert hasattr(view, "_detail_insert_all_btn") - assert view._detail_play_all_btn.isHidden() is False - assert view._detail_queue_all_btn.isHidden() is False - assert view._detail_insert_all_btn.isHidden() is False - - -def test_root_view_detail_all_track_buttons_use_all_tracks(qtbot): - settings = Mock() - settings.get.side_effect = lambda key, default=None: { - "nick": "Tester", - "quality": "320", - }.get(key, default) - context = Mock(settings=settings) - context.services.media = Mock() - provider = Mock() - provider.is_logged_in.return_value = True - provider.get_top_lists.return_value = [] - provider.get_top_list_tracks.return_value = [] - provider.get_recommendations.return_value = [] - provider.get_favorites.return_value = [] - provider.search.return_value = { - "artists": [{"mid": "artist-1", "name": "Singer 1", "song_count": 12}], - "total": 1, - "page": 1, - "page_size": 30, - } - provider.get_artist_detail.return_value = { - "title": "Singer 1", - "songs": [ - {"mid": "song-1", "title": "Song 1", "artist": "Singer 1", "album": "Album 1"}, - {"mid": "song-2", "title": "Song 2", "artist": "Singer 1", "album": "Album 1"}, - ], - } - - view = QQMusicRootView(context, provider) - qtbot.addWidget(view) - - view._search_input.setText("Singer 1") - view._search_type_tabs.setCurrentIndex(1) - view._run_search() - view._open_artist_detail(view._artists_list.item(0)) - - view._detail_play_all_btn.click() - view._detail_queue_all_btn.click() - view._detail_insert_all_btn.click() - - assert context.services.media.play_online_track.call_count == 1 - assert context.services.media.add_online_track_to_queue.call_count == 3 - assert context.services.media.insert_online_track_to_queue.call_count == 2 - - -def test_root_view_detail_tracks_support_multi_select(qtbot): - settings = Mock() - settings.get.side_effect = lambda key, default=None: { - "nick": "Tester", - "quality": "320", - }.get(key, default) - context = Mock(settings=settings) - context.services.media = Mock() - provider = Mock() - provider.is_logged_in.return_value = True - provider.get_top_lists.return_value = [] - provider.get_top_list_tracks.return_value = [] - provider.get_recommendations.return_value = [] - provider.get_favorites.return_value = [] - provider.search.return_value = { - "artists": [{"mid": "artist-1", "name": "Singer 1", "song_count": 12}], - "total": 1, - "page": 1, - "page_size": 30, - } - provider.get_artist_detail.return_value = { - "title": "Singer 1", - "songs": [ - {"mid": "song-1", "title": "Song 1", "artist": "Singer 1", "album": "Album 1"}, - {"mid": "song-2", "title": "Song 2", "artist": "Singer 1", "album": "Album 1"}, - ], - } - - view = QQMusicRootView(context, provider) - qtbot.addWidget(view) - - view._search_input.setText("Singer 1") - view._search_type_tabs.setCurrentIndex(1) - view._run_search() - view._open_artist_detail(view._artists_list.item(0)) - view._detail_tracks_list.setSelectionMode(view._detail_tracks_list.SelectionMode.MultiSelection) - view._detail_tracks_list.item(0).setSelected(True) - view._detail_tracks_list.item(1).setSelected(True) - - assert hasattr(view, "_selected_detail_tracks") - tracks = view._selected_detail_tracks() - - assert [track["mid"] for track in tracks] == ["song-1", "song-2"] - - -def test_root_view_detail_actions_use_selected_tracks_when_multi_selected(qtbot): - settings = Mock() - settings.get.side_effect = lambda key, default=None: { - "nick": "Tester", - "quality": "320", - }.get(key, default) - context = Mock(settings=settings) - context.services.media = Mock() - provider = Mock() - provider.is_logged_in.return_value = True - provider.get_top_lists.return_value = [] - provider.get_top_list_tracks.return_value = [] - provider.get_recommendations.return_value = [] - provider.get_favorites.return_value = [] - provider.search.return_value = { - "artists": [{"mid": "artist-1", "name": "Singer 1", "song_count": 12}], - "total": 1, - "page": 1, - "page_size": 30, - } - provider.get_artist_detail.return_value = { - "title": "Singer 1", - "songs": [ - {"mid": "song-1", "title": "Song 1", "artist": "Singer 1", "album": "Album 1"}, - {"mid": "song-2", "title": "Song 2", "artist": "Singer 1", "album": "Album 1"}, - ], - } - - view = QQMusicRootView(context, provider) - qtbot.addWidget(view) - - view._search_input.setText("Singer 1") - view._search_type_tabs.setCurrentIndex(1) - view._run_search() - view._open_artist_detail(view._artists_list.item(0)) - view._detail_tracks_list.item(0).setSelected(True) - view._detail_tracks_list.item(1).setSelected(True) - - view._add_selected_detail_track_to_queue() - view._insert_selected_detail_track_to_queue() - view._download_selected_detail_track() - - assert context.services.media.add_online_track_to_queue.call_count == 2 - assert context.services.media.insert_online_track_to_queue.call_count == 2 - assert context.services.media.cache_remote_track.call_count == 2 - - -def test_root_view_detail_back_returns_to_previous_page(qtbot): - settings = Mock() - settings.get.side_effect = lambda key, default=None: { - "nick": "Tester", - "quality": "320", - }.get(key, default) - media = Mock() - context = Mock(settings=settings) - context.services.media = media - provider = Mock() - provider.is_logged_in.return_value = True - provider.get_top_lists.return_value = [] - provider.get_top_list_tracks.return_value = [] - provider.get_recommendations.return_value = [ - { - "title": "猜你喜欢", - "subtitle": "1 项", - "items": [{"mid": "song-1", "title": "Song 1", "artist": "Singer 1"}], - }, - ] - provider.get_favorites.return_value = [] - - view = QQMusicRootView(context, provider) - qtbot.addWidget(view) - - view._open_recommendation_section(view._recommend_list.item(0)) - view._detail_back_btn.click() - - assert view._home_stack.currentWidget() is view._home_page - - -def test_root_view_favorites_navigation(qtbot): - settings = Mock() - settings.get.side_effect = lambda key, default=None: { - "nick": "Tester", - "quality": "320", - }.get(key, default) - media = Mock() - context = Mock(settings=settings) - context.services.media = media - provider = Mock() - provider.is_logged_in.return_value = True - provider.get_top_lists.return_value = [] - provider.get_top_list_tracks.return_value = [] - provider.get_recommendations.return_value = [] - provider.get_favorites.return_value = [ - { - "title": "我喜欢的歌曲", - "count": 1, - "items": [{"mid": "song-1", "title": "Song 1", "artist": "Singer 1", "album": "Album 1"}], - }, - ] - - view = QQMusicRootView(context, provider) - qtbot.addWidget(view) - - view._open_favorite_section(view._favorites_list.item(0)) - - assert view._home_stack.currentWidget() is view._detail_page - assert view._detail_tracks_list.count() == 1 - - -def test_root_view_recommendation_navigation(qtbot): - settings = Mock() - settings.get.side_effect = lambda key, default=None: { - "nick": "Tester", - "quality": "320", - }.get(key, default) - media = Mock() - context = Mock(settings=settings) - context.services.media = media - provider = Mock() - provider.is_logged_in.return_value = True - provider.get_top_lists.return_value = [] - provider.get_top_list_tracks.return_value = [] - provider.get_recommendations.return_value = [ - { - "title": "猜你喜欢", - "subtitle": "1 项", - "cover_url": "http://example.com/card-cover.jpg", - "items": [{"mid": "song-1", "title": "Song 1", "artist": "Singer 1", "album": "Album 1"}], - }, - ] - provider.get_favorites.return_value = [] - - view = QQMusicRootView(context, provider) - qtbot.addWidget(view) - - view._open_recommendation_section(view._recommend_list.item(0)) - - assert view._home_stack.currentWidget() is view._detail_page - assert view._detail_tracks_list.count() == 1 - assert view._detail_tracks_stack.currentWidget() is view._detail_tracks_view - assert view._detail_title.text() == "猜你喜欢" - assert view._detail_cover_url == "http://example.com/card-cover.jpg" - - -def test_root_view_artist_detail_uses_tracks_list_view(qtbot): - settings = Mock() - settings.get.side_effect = lambda key, default=None: { - "nick": "Tester", - "quality": "320", - }.get(key, default) - context = Mock(settings=settings) - context.services.media = Mock() - provider = Mock() - provider.is_logged_in.return_value = True - provider.get_top_lists.return_value = [] - provider.get_top_list_tracks.return_value = [] - provider.get_recommendations.return_value = [] - provider.get_favorites.return_value = [] - provider.search.return_value = { - "artists": [{"mid": "artist-1", "name": "Singer 1", "song_count": 2}], - "total": 1, - "page": 1, - "page_size": 30, - } - provider.get_artist_detail.return_value = { - "title": "Singer 1", - "songs": [ - {"mid": "song-1", "title": "Song 1", "artist": "Singer 1", "album": "Album 1"}, - {"mid": "song-2", "title": "Song 2", "artist": "Singer 1", "album": "Album 1"}, - ], - } - - view = QQMusicRootView(context, provider) - qtbot.addWidget(view) - - view._search_input.setText("Singer 1") - view._search_type_tabs.setCurrentIndex(1) - view._run_search() - view._open_artist_detail(view._artists_list.item(0)) - - assert view._detail_tracks_stack.currentWidget() is view._detail_tracks_view - - -def test_root_view_artist_detail_shows_related_albums_grid(qtbot): - settings = Mock() - settings.get.side_effect = lambda key, default=None: { - "nick": "Tester", - "quality": "320", - }.get(key, default) - context = Mock(settings=settings) - context.services.media = Mock() - provider = Mock() - provider.is_logged_in.return_value = True - provider.get_top_lists.return_value = [] - provider.get_top_list_tracks.return_value = [] - provider.get_recommendations.return_value = [] - provider.get_favorites.return_value = [] - provider.search.return_value = { - "artists": [{"mid": "artist-1", "name": "Singer 1", "song_count": 2, "album_count": 1}], - "total": 1, - "page": 1, - "page_size": 30, - } - provider.get_artist_detail.return_value = { - "title": "Singer 1", - "songs": [{"mid": "song-1", "title": "Song 1", "artist": "Singer 1", "album": "Album 1"}], - } - provider.get_artist_albums.return_value = [ - {"mid": "album-1", "name": "Album 1", "singer_name": "Singer 1", "publish_date": "2024-01-01"}, - ] - - view = QQMusicRootView(context, provider) - qtbot.addWidget(view) - - view._search_input.setText("Singer 1") - view._search_type_tabs.setCurrentIndex(1) - view._run_search() - view._open_artist_detail(view._artists_list.item(0)) - - assert hasattr(view, "_detail_albums_grid") - assert view._detail_albums_grid.isHidden() is False - assert len(view._detail_albums_grid._items) == 1 - - -def test_root_view_album_detail_uses_tracks_list_view(qtbot): - settings = Mock() - settings.get.side_effect = lambda key, default=None: { - "nick": "Tester", - "quality": "320", - }.get(key, default) - context = Mock(settings=settings) - context.services.media = Mock() - provider = Mock() - provider.is_logged_in.return_value = True - provider.get_top_lists.return_value = [] - provider.get_top_list_tracks.return_value = [] - provider.get_recommendations.return_value = [] - provider.get_favorites.return_value = [] - provider.search.return_value = { - "albums": [{"mid": "album-1", "name": "Album 1", "singer_name": "Singer 1"}], - "total": 1, - "page": 1, - "page_size": 30, - } - provider.get_album_detail.return_value = { - "title": "Album 1", - "songs": [ - {"mid": "song-1", "title": "Song 1", "artist": "Singer 1", "album": "Album 1"}, - ], - } - - view = QQMusicRootView(context, provider) - qtbot.addWidget(view) - - view._search_input.setText("Album 1") - view._search_type_tabs.setCurrentIndex(2) - view._run_search() - view._open_album_detail(view._albums_list.item(0)) - - assert view._detail_tracks_stack.currentWidget() is view._detail_tracks_view - - -def test_root_view_album_detail_shows_extra_meta_line(qtbot): - settings = Mock() - settings.get.side_effect = lambda key, default=None: { - "nick": "Tester", - "quality": "320", - }.get(key, default) - context = Mock(settings=settings) - context.services.media = Mock() - provider = Mock() - provider.is_logged_in.return_value = True - provider.get_top_lists.return_value = [] - provider.get_top_list_tracks.return_value = [] - provider.get_recommendations.return_value = [] - provider.get_favorites.return_value = [] - provider.search.return_value = { - "albums": [{"mid": "album-1", "name": "Album 1", "singer_name": "Singer 1"}], - "total": 1, - "page": 1, - "page_size": 30, - } - provider.get_album_detail.return_value = { - "title": "Album 1", - "songs": [{"mid": "song-1", "title": "Song 1", "artist": "Singer 1", "album": "Album 1"}], - "company": "QQ Music", - "language": "国语", - "album_type": "录音室专辑", - } - - view = QQMusicRootView(context, provider) - qtbot.addWidget(view) - - view._search_input.setText("Album 1") - view._search_type_tabs.setCurrentIndex(2) - view._run_search() - view._open_album_detail(view._albums_list.item(0)) - - assert hasattr(view, "_detail_extra_label") - assert "QQ Music" in view._detail_extra_label.text() - assert "国语" in view._detail_extra_label.text() - assert "录音室专辑" in view._detail_extra_label.text() - - -def test_root_view_playlist_detail_uses_tracks_list_view(qtbot): - settings = Mock() - settings.get.side_effect = lambda key, default=None: { - "nick": "Tester", - "quality": "320", - }.get(key, default) - context = Mock(settings=settings) - context.services.media = Mock() - provider = Mock() - provider.is_logged_in.return_value = True - provider.get_top_lists.return_value = [] - provider.get_top_list_tracks.return_value = [] - provider.get_recommendations.return_value = [] - provider.get_favorites.return_value = [] - provider.search.return_value = { - "playlists": [{"id": "playlist-1", "title": "Playlist 1", "creator": "Tester"}], - "total": 1, - "page": 1, - "page_size": 30, - } - provider.get_playlist_detail.return_value = { - "title": "Playlist 1", - "songs": [ - {"mid": "song-1", "title": "Song 1", "artist": "Singer 1", "album": "Album 1"}, - ], - } - - view = QQMusicRootView(context, provider) - qtbot.addWidget(view) - - view._search_input.setText("Playlist 1") - view._search_type_tabs.setCurrentIndex(3) - view._run_search() - view._open_playlist_detail(view._playlists_list.item(0)) - - assert view._detail_tracks_stack.currentWidget() is view._detail_tracks_view - - -def test_root_view_show_hotkey_popup_does_not_sync_fetch_hotkeys_when_history_exists(qtbot): - settings = Mock() - store = {"nick": "", "quality": "320", "search_history": ["林俊杰"]} - settings.get.side_effect = lambda key, default=None: store.get(key, default) - settings.set.side_effect = lambda key, value: store.__setitem__(key, value) - context = Mock(settings=settings) - context.services.media = Mock() - provider = Mock() - provider.is_logged_in.return_value = False - provider.get_top_lists.return_value = [] - provider.get_top_list_tracks.return_value = [] - provider.get_recommendations.return_value = [] - provider.get_favorites.return_value = [] - provider.get_hotkeys.return_value = [{"title": "周杰伦", "query": "周杰伦"}] - provider.complete.return_value = [] - - view = QQMusicRootView(context, provider) - qtbot.addWidget(view) - view._hotkeys_cache = [] - view._hotkeys_list.clear() - view._search_input.setFocus() - initial_calls = provider.get_hotkeys.call_count - - view._show_hotkey_popup() - - assert provider.get_hotkeys.call_count == initial_calls - assert view._hotkey_popup is not None - assert view._hotkey_popup.count() == 1 - - -def test_root_view_favorites_playlist_navigation(qtbot): - settings = Mock() - settings.get.side_effect = lambda key, default=None: { - "nick": "Tester", - "quality": "320", - }.get(key, default) - media = Mock() - context = Mock(settings=settings) - context.services.media = media - provider = Mock() - provider.is_logged_in.return_value = True - provider.get_top_lists.return_value = [] - provider.get_top_list_tracks.return_value = [] - provider.search.return_value = { - "tracks": [{"mid": "song-1", "title": "Song 1", "artist": "Singer 1"}], - "total": 1, - "page": 1, - "page_size": 30, - } - provider.get_recommendations.return_value = [] - provider.get_favorites.return_value = [ - { - "title": "我收藏的歌单", - "count": 1, - "items": [{"id": "pl-1", "title": "Playlist 1", "creator": "Tester", "song_count": 12}], - }, - ] - - view = QQMusicRootView(context, provider) - qtbot.addWidget(view) - view._search_input.setText("abc") - view._run_search() - assert view._search_type_tabs.isHidden() is False - - view._open_favorite_section(view._favorites_list.item(0)) - - assert view._home_stack.currentWidget() is view._results_page - assert view._results_stack.currentWidget() is view._playlists_page - assert view._playlists_list.count() == 1 - assert len(view._playlists_page._items) == 1 - assert view._search_type_tabs.isHidden() is True - - -def test_root_view_collection_album_navigation_loads_visible_grid(qtbot): - settings = Mock() - settings.get.side_effect = lambda key, default=None: { - "nick": "Tester", - "quality": "320", - }.get(key, default) - media = Mock() - context = Mock(settings=settings) - context.services.media = media - provider = Mock() - provider.is_logged_in.return_value = True - provider.get_top_lists.return_value = [] - provider.get_top_list_tracks.return_value = [] - provider.get_recommendations.return_value = [] - provider.get_favorites.return_value = [ - { - "title": "我收藏的专辑", - "count": 1, - "items": [{"mid": "album-1", "title": "Album 1", "singer_name": "Singer 1"}], - }, - ] - - view = QQMusicRootView(context, provider) - qtbot.addWidget(view) - - view._open_favorite_section(view._favorites_list.item(0)) - - assert view._results_stack.currentWidget() is view._albums_page - assert len(view._albums_page._items) == 1 - - -def test_root_view_followed_singers_collection_opens_artist_grid(qtbot): - settings = Mock() - settings.get.side_effect = lambda key, default=None: { - "nick": "Tester", - "quality": "320", - }.get(key, default) - media = Mock() - context = Mock(settings=settings) - context.services.media = media - provider = Mock() - provider.is_logged_in.return_value = True - provider.get_top_lists.return_value = [] - provider.get_top_list_tracks.return_value = [] - provider.get_recommendations.return_value = [] - provider.get_favorites.return_value = [ - { - "title": "我关注的歌手", - "count": 1, - "items": [{"mid": "artist-1", "name": "Singer 1", "fan_count": 10, "cover_url": "http://example/avatar.jpg"}], - }, - ] - - view = QQMusicRootView(context, provider) - qtbot.addWidget(view) - - view._open_favorite_section(view._favorites_list.item(0)) - - assert view._results_stack.currentWidget() is view._artists_page - assert len(view._artists_page._items) == 1 - - -def test_root_view_collection_back_button_returns_home(qtbot): - settings = Mock() - settings.get.side_effect = lambda key, default=None: { - "nick": "Tester", - "quality": "320", - }.get(key, default) - media = Mock() - context = Mock(settings=settings) - context.services.media = media - provider = Mock() - provider.is_logged_in.return_value = True - provider.get_top_lists.return_value = [] - provider.get_top_list_tracks.return_value = [] - provider.search.return_value = { - "tracks": [{"mid": "song-1", "title": "Song 1", "artist": "Singer 1"}], - "total": 1, - "page": 1, - "page_size": 30, - } - provider.get_recommendations.return_value = [] - provider.get_favorites.return_value = [ - { - "title": "我收藏的歌单", - "count": 1, - "items": [{"id": "pl-1", "title": "Playlist 1", "creator": "Tester", "song_count": 12}], - }, - ] - - view = QQMusicRootView(context, provider) - qtbot.addWidget(view) - - view._search_input.setText("abc") - view._run_search() - assert view._search_type_tabs.isHidden() is False - - view._open_favorite_section(view._favorites_list.item(0)) - - assert hasattr(view, "_results_back_btn") - assert view._results_back_btn.isHidden() is False - assert "我收藏的歌单" in view._results_info_label.text() - assert view._search_type_tabs.isHidden() is True - - view._go_back_from_results() - - assert view._home_stack.currentWidget() is view._home_page - assert view._results_back_btn.isHidden() is True - - -def test_root_view_loads_hotkeys_and_completion(qtbot): - settings = Mock() - settings.get.side_effect = lambda key, default=None: { - "nick": "", - "quality": "320", - }.get(key, default) - media = Mock() - context = Mock(settings=settings) - context.services.media = media - provider = Mock() - provider.is_logged_in.return_value = False - provider.get_top_lists.return_value = [] - provider.get_top_list_tracks.return_value = [] - provider.get_recommendations.return_value = [] - provider.get_favorites.return_value = [] - provider.get_hotkeys.return_value = [ - {"title": "周杰伦"}, - {"title": "林俊杰"}, - ] - provider.complete.return_value = [ - {"hint": "周杰伦 晴天"}, - {"hint": "周杰伦 七里香"}, - ] - provider.search_tracks.return_value = [] - - view = QQMusicRootView(context, provider) - qtbot.addWidget(view) - - assert view._hotkeys_list.count() == 2 - - view._update_completion("周杰伦") - model = view._completer.model() - assert model.rowCount() == 2 - - -def test_root_view_async_home_load_refreshes_hotkey_popup_when_search_is_focused(qtbot): - settings = Mock() - settings.get.side_effect = lambda key, default=None: { - "nick": "", - "quality": "320", - "search_history": [], - }.get(key, default) - context = Mock(settings=settings) - context.services.media = Mock() - provider = Mock() - provider.is_logged_in.return_value = False - provider.get_top_lists.return_value = [] - provider.get_top_list_tracks.return_value = [] - provider.get_recommendations.return_value = [] - provider.get_favorites.return_value = [] - provider.get_hotkeys.return_value = [] - provider.complete.return_value = [] - - view = QQMusicRootView(context, provider) - qtbot.addWidget(view) - view.show() - view._search_input.setFocus() - view._show_hotkey_popup() - - payload = { - "top_lists": [], - "top_tracks": [], - "top_tracks_id": "", - "hotkeys": [{"title": "周杰伦", "query": "周杰伦"}], - "history": [], - "favorites": [], - "recommendations": [], - "logged_in": False, - "load_private": False, - } - - view._on_home_sections_loaded(payload) - - assert view._hotkey_popup is not None - assert view._hotkey_popup.isVisible() is True - assert view._hotkey_popup.count() == 1 - - -def test_root_view_completion_is_debounced(qtbot): - settings = Mock() - settings.get.side_effect = lambda key, default=None: { - "nick": "", - "quality": "320", - }.get(key, default) - media = Mock() - context = Mock(settings=settings) - context.services.media = media - provider = Mock() - provider.is_logged_in.return_value = False - provider.get_top_lists.return_value = [] - provider.get_top_list_tracks.return_value = [] - provider.get_recommendations.return_value = [] - provider.get_favorites.return_value = [] - provider.get_hotkeys.return_value = [] - provider.complete.return_value = [{"hint": "周杰伦 晴天"}] - - view = QQMusicRootView(context, provider) - qtbot.addWidget(view) - - view._search_input.setText("周杰伦") - - assert provider.complete.call_count == 0 - qtbot.waitUntil(lambda: provider.complete.call_count == 1) - - -def test_root_view_stale_completion_results_are_ignored(qtbot): - settings = Mock() - settings.get.side_effect = lambda key, default=None: { - "nick": "", - "quality": "320", - }.get(key, default) - media = Mock() - context = Mock(settings=settings) - context.services.media = media - provider = Mock() - provider.is_logged_in.return_value = False - provider.get_top_lists.return_value = [] - provider.get_top_list_tracks.return_value = [] - provider.get_recommendations.return_value = [] - provider.get_favorites.return_value = [] - provider.get_hotkeys.return_value = [] - provider.complete.return_value = [] - - view = QQMusicRootView(context, provider) - qtbot.addWidget(view) - - assert hasattr(view, "_on_completion_ready") - view._completion_request_id = 2 - view._on_completion_ready([{"hint": "old"}], 1) - - model = view._completer.model() - assert model is None or model.rowCount() == 0 - - -def test_root_view_hotkey_popup_shows_and_escape_hides(qtbot): - settings = Mock() - store = {"nick": "", "quality": "320", "search_history": ["林俊杰"]} - settings.get.side_effect = lambda key, default=None: store.get(key, default) - settings.set.side_effect = lambda key, value: store.__setitem__(key, value) - media = Mock() - context = Mock(settings=settings) - context.services.media = media - provider = Mock() - provider.is_logged_in.return_value = False - provider.get_top_lists.return_value = [] - provider.get_top_list_tracks.return_value = [] - provider.get_recommendations.return_value = [] - provider.get_favorites.return_value = [] - provider.get_hotkeys.return_value = [{"title": "周杰伦", "query": "周杰伦"}] - provider.complete.return_value = [] - - view = QQMusicRootView(context, provider) - qtbot.addWidget(view) - view.show() - - assert hasattr(view, "_show_hotkey_popup") - assert hasattr(view, "_on_escape_pressed") - view._show_hotkey_popup() - - assert view._hotkey_popup is not None - assert view._hotkey_popup.isVisible() is True - assert bool(view._hotkey_popup.windowFlags() & Qt.Popup) is False - assert bool(view._hotkey_popup.windowFlags() & Qt.Tool) is False - - view._on_escape_pressed() - - assert view._hotkey_popup.isVisible() is False - - -def test_root_view_focusing_search_after_suppression_shows_hotkey_popup(qtbot): - settings = Mock() - store = {"nick": "", "quality": "320", "search_history": ["林俊杰"]} - settings.get.side_effect = lambda key, default=None: store.get(key, default) - settings.set.side_effect = lambda key, value: store.__setitem__(key, value) - context = Mock(settings=settings) - context.services.media = Mock() - provider = Mock() - provider.is_logged_in.return_value = False - provider.get_top_lists.return_value = [] - provider.get_top_list_tracks.return_value = [] - provider.get_recommendations.return_value = [] - provider.get_favorites.return_value = [] - provider.get_hotkeys.return_value = [{"title": "周杰伦", "query": "周杰伦"}] - provider.complete.return_value = [] - - view = QQMusicRootView(context, provider) - qtbot.addWidget(view) - view.show() - - view._on_app_focus_changed(view._search_input, view._login_btn) - assert view._suppress_hotkey_popup is True - - view._request_search_popup() - - assert view._hotkey_popup is not None - assert view._hotkey_popup.isVisible() is True - - -def test_click_outside_search_clears_focus_and_hides_popup(qtbot): - settings = Mock() - store = {"nick": "", "quality": "320", "search_history": ["林俊杰"]} - settings.get.side_effect = lambda key, default=None: store.get(key, default) - settings.set.side_effect = lambda key, value: store.__setitem__(key, value) - media = Mock() - context = Mock(settings=settings) - context.services.media = media - provider = Mock() - provider.is_logged_in.return_value = False - provider.get_top_lists.return_value = [] - provider.get_top_list_tracks.return_value = [] - provider.get_recommendations.return_value = [] - provider.get_favorites.return_value = [] - provider.get_hotkeys.return_value = [{"title": "周杰伦", "query": "周杰伦"}] - provider.complete.return_value = [] - - view = QQMusicRootView(context, provider) - qtbot.addWidget(view) - view.show() - view._search_input.setFocus() - view._show_hotkey_popup() - - qtbot.waitUntil(lambda: view._hotkey_popup.isVisible()) - - qtbot.mouseClick(view._home_stack, Qt.LeftButton) - - qtbot.waitUntil(lambda: not view._hotkey_popup.isVisible()) - - -def test_search_focus_loss_hides_popup(qtbot): - settings = Mock() - store = {"nick": "", "quality": "320", "search_history": ["林俊杰"]} - settings.get.side_effect = lambda key, default=None: store.get(key, default) - settings.set.side_effect = lambda key, value: store.__setitem__(key, value) - media = Mock() - context = Mock(settings=settings) - context.services.media = media - provider = Mock() - provider.is_logged_in.return_value = False - provider.get_top_lists.return_value = [] - provider.get_top_list_tracks.return_value = [] - provider.get_recommendations.return_value = [] - provider.get_favorites.return_value = [] - provider.get_hotkeys.return_value = [{"title": "周杰伦", "query": "周杰伦"}] - provider.complete.return_value = [] - - view = QQMusicRootView(context, provider) - qtbot.addWidget(view) - view.show() - view._search_input.setFocus() - view._show_hotkey_popup() - - qtbot.waitUntil(lambda: view._hotkey_popup.isVisible()) - view._on_app_focus_changed(view._search_input, view._login_btn) - - qtbot.waitUntil(lambda: not view._hotkey_popup.isVisible()) - - -def test_root_view_clear_search_history_updates_store_and_popup(qtbot): - settings = Mock() - store = {"nick": "", "quality": "320", "search_history": ["林俊杰", "周杰伦"]} - settings.get.side_effect = lambda key, default=None: store.get(key, default) - settings.set.side_effect = lambda key, value: store.__setitem__(key, value) - media = Mock() - context = Mock(settings=settings) - context.services.media = media - provider = Mock() - provider.is_logged_in.return_value = False - provider.get_top_lists.return_value = [] - provider.get_top_list_tracks.return_value = [] - provider.get_recommendations.return_value = [] - provider.get_favorites.return_value = [] - provider.get_hotkeys.return_value = [{"title": "周杰伦", "query": "周杰伦"}] - provider.complete.return_value = [] - - view = QQMusicRootView(context, provider) - qtbot.addWidget(view) - view.show() - view._show_hotkey_popup() - - assert hasattr(view, "_clear_search_history") - view._clear_search_history() - - assert store["search_history"] == [] - assert view._history_list.count() == 0 - assert view._hotkey_popup.count() == 1 - - -def test_root_view_delete_search_history_item_updates_store_and_popup(qtbot): - settings = Mock() - store = {"nick": "", "quality": "320", "search_history": ["林俊杰", "周杰伦"]} - settings.get.side_effect = lambda key, default=None: store.get(key, default) - settings.set.side_effect = lambda key, value: store.__setitem__(key, value) - media = Mock() - context = Mock(settings=settings) - context.services.media = media - provider = Mock() - provider.is_logged_in.return_value = False - provider.get_top_lists.return_value = [] - provider.get_top_list_tracks.return_value = [] - provider.get_recommendations.return_value = [] - provider.get_favorites.return_value = [] - provider.get_hotkeys.return_value = [{"title": "周杰伦", "query": "周杰伦"}] - provider.complete.return_value = [] - - view = QQMusicRootView(context, provider) - qtbot.addWidget(view) - view.show() - view._show_hotkey_popup() - - assert hasattr(view, "_delete_search_history_item") - view._delete_search_history_item("林俊杰") - - assert store["search_history"] == ["周杰伦"] - assert view._history_list.count() == 1 -def test_root_view_records_search_history_and_can_reuse_it(qtbot): - settings = Mock() - store = {} - settings.get.side_effect = lambda key, default=None: store.get(key, default) - settings.set.side_effect = lambda key, value: store.__setitem__(key, value) - media = Mock() - context = Mock(settings=settings) - context.services.media = media - provider = Mock() - provider.is_logged_in.return_value = False - provider.get_top_lists.return_value = [] - provider.get_top_list_tracks.return_value = [] - provider.get_recommendations.return_value = [] - provider.get_favorites.return_value = [] - provider.get_hotkeys.return_value = [] - provider.complete.return_value = [] - provider.search_tracks.return_value = [] - - view = QQMusicRootView(context, provider) - qtbot.addWidget(view) - - view._search_input.setText("周杰伦") - view._run_search() - - assert store["search_history"] == ["周杰伦"] - assert view._history_list.count() == 1 - - view._open_history_search(view._history_list.item(0)) - assert provider.search_tracks.call_count >= 2 - - -def test_root_view_clearing_search_returns_home_sections(qtbot): - settings = Mock() - store = {"nick": "Tester", "quality": "320", "search_history": []} - settings.get.side_effect = lambda key, default=None: store.get(key, default) - settings.set.side_effect = lambda key, value: store.__setitem__(key, value) - context = Mock(settings=settings) - context.services.media = Mock() - provider = Mock() - provider.is_logged_in.return_value = True - provider.get_top_lists.return_value = [] - provider.get_top_list_tracks.return_value = [] - provider.get_recommendations.return_value = [ - { - "id": "guess", - "title": "猜你喜欢", - "subtitle": "1 项", - "cover_url": "", - "items": [{"mid": "song-1"}], - "entry_type": "songs", - }, - ] - provider.get_favorites.return_value = [ - { - "id": "fav_songs", - "title": "我喜欢的歌曲", - "subtitle": "1 首", - "cover_url": "", - "items": [{"mid": "song-1"}], - "entry_type": "songs", - }, - ] - provider.search.return_value = {"tracks": [], "total": 0, "page": 1, "page_size": 30} - - view = QQMusicRootView(context, provider) - qtbot.addWidget(view) - - view._search_input.setText("abc") - view._run_search() - view._on_search_text_changed("") - - assert view._home_stack.currentWidget() is view._home_page - assert view._favorites_section.isHidden() is False - assert view._recommend_section.isHidden() is False - - -def test_root_view_login_toggle_updates_status(monkeypatch, qtbot): - settings = Mock() - state = {"nick": "", "credential": None, "quality": "320"} - settings.get.side_effect = lambda key, default=None: state.get(key, default) - settings.set.side_effect = lambda key, value: state.__setitem__(key, value) - media = Mock() - context = Mock(settings=settings) - context.services.media = media - provider = Mock() - provider.is_logged_in.side_effect = lambda: bool(state["nick"] or state["credential"]) - provider.get_top_lists.return_value = [] - provider.get_top_list_tracks.return_value = [] - provider.get_recommendations.return_value = [] - provider.get_favorites.return_value = [] - provider.get_hotkeys.return_value = [] - provider.complete.return_value = [] - - dialog = Mock() - dialog.exec.side_effect = lambda: state.update({"nick": "Tester", "credential": {"musicid": "1"}}) - monkeypatch.setattr("plugins.builtin.qqmusic.lib.root_view.QQMusicLoginDialog", Mock(return_value=dialog)) - - view = QQMusicRootView(context, provider) - qtbot.addWidget(view) - - view._handle_login_toggle() - assert "Tester" in view._status.text() - - view._handle_login_toggle() - assert state["nick"] == "" - - -def test_root_view_refresh_ui_reloads_sections(qtbot): - settings = Mock() - state = {"nick": "", "quality": "320", "search_history": []} - settings.get.side_effect = lambda key, default=None: state.get(key, default) - settings.set.side_effect = lambda key, value: state.__setitem__(key, value) - context = Mock(settings=settings) - context.services.media = Mock() - provider = Mock() - provider.is_logged_in.side_effect = lambda: bool(state["nick"]) - provider.get_top_lists.return_value = [] - provider.get_top_list_tracks.return_value = [] - provider.get_recommendations.side_effect = lambda: [{"title": "推荐", "subtitle": "1 项", "items": []}] if state["nick"] else [] - provider.get_favorites.side_effect = lambda: [{"title": "收藏", "count": 1, "items": []}] if state["nick"] else [] - provider.get_hotkeys.return_value = [{"title": "周杰伦"}] - provider.complete.return_value = [] - - view = QQMusicRootView(context, provider) - qtbot.addWidget(view) - - state["nick"] = "Tester" - view._favorites_cache = [{"title": "收藏", "count": 1, "items": []}] - view._recommendations_cache = [{"title": "推荐", "subtitle": "1 项", "items": []}] - view.refresh_ui() - - assert "Tester" in view._status.text() - assert view._recommend_section.isHidden() is False - assert view._favorites_section.isHidden() is False - assert view._recommend_group.isHidden() is True - assert view._favorites_group.isHidden() is True - provider.get_recommendations.assert_not_called() - provider.get_favorites.assert_not_called() - - -def test_root_view_applies_theme_styles_on_init(qtbot): - settings = Mock() - settings.get.side_effect = lambda key, default=None: { - "nick": "", - "quality": "320", - "search_history": [], - "ranking_view_mode": "table", - }.get(key, default) - context = Mock(settings=settings) - context.services.media = Mock() - provider = Mock() - provider.is_logged_in.return_value = False - provider.get_top_lists.return_value = [] - provider.get_top_list_tracks.return_value = [] - provider.get_recommendations.return_value = [] - provider.get_favorites.return_value = [] - provider.get_hotkeys.return_value = [] - provider.complete.return_value = [] - - view = QQMusicRootView(context, provider) - qtbot.addWidget(view) - - assert view.styleSheet() - assert view._search_input.styleSheet() - assert view._search_type_tabs.styleSheet() - assert view._results_table.styleSheet() - assert view._top_tracks_table.styleSheet() - assert view._ranking_view_toggle_btn.styleSheet() - assert view._top_list_widget.styleSheet() - assert view._ranking_list_view.styleSheet() - assert view._detail_ui_built is False - assert view._completer.popup().styleSheet() diff --git a/tests/test_qthread_fix.py b/tests/test_qthread_fix.py index 3f725795..83818ed5 100644 --- a/tests/test_qthread_fix.py +++ b/tests/test_qthread_fix.py @@ -10,6 +10,7 @@ # Add parent directory to path sys.path.insert(0, str(Path(__file__).parent.parent)) +from app.bootstrap import Bootstrap from system.theme import ThemeManager @@ -21,12 +22,14 @@ def _init_theme(): ThemeManager.instance(config) -def test_main_window_close(): +def test_main_window_close(tmp_path): """Test that MainWindow closes without QThread errors.""" from ui.windows.main_window import MainWindow app = QApplication.instance() or QApplication(sys.argv) _init_theme() + Bootstrap._instance = None + bootstrap = Bootstrap.instance(str(tmp_path / "qthread-main-window.db")) # Create main window window = MainWindow() @@ -43,15 +46,20 @@ def test_main_window_close(): app.processEvents() time.sleep(0.5) + bootstrap.db.close() + Bootstrap._instance = None + print("MainWindow closed without QThread errors") -def test_lyrics_panel_cleanup(): +def test_lyrics_panel_cleanup(monkeypatch): """Test that LyricsController properly cleans up threads.""" from ui.windows.components.lyrics_panel import LyricsPanel, LyricsController + from services.lyrics.lyrics_service import LyricsService app = QApplication.instance() or QApplication(sys.argv) _init_theme() + monkeypatch.setattr(LyricsService, "get_lyrics", classmethod(lambda cls, *_args, **_kwargs: "")) # Create panel and controller panel = LyricsPanel() @@ -82,11 +90,17 @@ def test_lyrics_panel_cleanup(): print("LyricsController cleanup works correctly") -def test_lyrics_loader_interruption(): +def test_lyrics_loader_interruption(monkeypatch): """Test that LyricsLoader respects interruption requests.""" from services.lyrics.lyrics_loader import LyricsLoader + from services.lyrics.lyrics_service import LyricsService QApplication.instance() or QApplication(sys.argv) + monkeypatch.setattr( + LyricsService, + "get_lyrics", + classmethod(lambda cls, *_args, **_kwargs: ""), + ) # Create a loader with a fake path (will take time to fail) loader = LyricsLoader("/fake/path.mp3", "Test", "Artist") diff --git a/tests/test_services/test_plugin_cover_registry.py b/tests/test_services/test_plugin_cover_registry.py index f5cbb453..5e8362c2 100644 --- a/tests/test_services/test_plugin_cover_registry.py +++ b/tests/test_services/test_plugin_cover_registry.py @@ -39,6 +39,8 @@ def test_builtin_cover_sources_exclude_plugin_owned_sources(): names = {source.name for source in service._get_builtin_sources()} artist_names = {source.name for source in service._get_builtin_artist_sources()} + assert "NetEase" not in names + assert "NetEase" not in artist_names assert "QQMusic" not in names assert "QQMusic" not in artist_names assert "iTunes" not in names diff --git a/tests/test_services/test_plugin_lyrics_registry.py b/tests/test_services/test_plugin_lyrics_registry.py index 5313d331..3d8296a9 100644 --- a/tests/test_services/test_plugin_lyrics_registry.py +++ b/tests/test_services/test_plugin_lyrics_registry.py @@ -44,4 +44,5 @@ def test_builtin_lyrics_sources_exclude_plugin_owned_sources(): assert "LRCLIB" not in names assert "QQMusic" not in names assert "Kugou" not in names - assert names == {"NetEase"} + assert "NetEase" not in names + assert names == set() From 0c6f875692bd2b51c83c644abe345549b5a5c6fe Mon Sep 17 00:00:00 2001 From: Har01d Date: Tue, 7 Apr 2026 19:33:40 +0800 Subject: [PATCH 073/157] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E6=AD=8C=E8=AF=8D?= =?UTF-8?q?=E4=B8=8B=E8=BD=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- services/sources/lyrics_sources.py | 1 - ...t_lyrics_download_dialog_thread_cleanup.py | 95 +++++++++++++++ ui/dialogs/lyrics_download_dialog.py | 108 +++++++++++++++--- 3 files changed, 184 insertions(+), 20 deletions(-) delete mode 100644 services/sources/lyrics_sources.py create mode 100644 tests/test_ui/test_lyrics_download_dialog_thread_cleanup.py diff --git a/services/sources/lyrics_sources.py b/services/sources/lyrics_sources.py deleted file mode 100644 index 1c61537c..00000000 --- a/services/sources/lyrics_sources.py +++ /dev/null @@ -1 +0,0 @@ -"""Lyrics source implementations that remain host-owned.""" diff --git a/tests/test_ui/test_lyrics_download_dialog_thread_cleanup.py b/tests/test_ui/test_lyrics_download_dialog_thread_cleanup.py new file mode 100644 index 00000000..3b605956 --- /dev/null +++ b/tests/test_ui/test_lyrics_download_dialog_thread_cleanup.py @@ -0,0 +1,95 @@ +"""LyricsDownloadDialog thread cleanup behavior tests.""" + +from types import SimpleNamespace +from unittest.mock import MagicMock + +import ui.dialogs.lyrics_download_dialog as dialog_module +from ui.dialogs.lyrics_download_dialog import LyricsDownloadDialog + + +def _make_fake_thread(**attrs): + class FakeThread: + pass + + thread = FakeThread() + for name, value in attrs.items(): + setattr(thread, name, value) + return thread + + +def test_stop_search_thread_detaches_running_thread_from_dialog(monkeypatch): + """Closing with an active search thread should detach it from the dialog lifecycle.""" + fake_thread = _make_fake_thread( + isRunning=MagicMock(side_effect=[True, True]), + cancel=MagicMock(), + requestInterruption=MagicMock(), + quit=MagicMock(), + wait=MagicMock(return_value=False), + search_completed=SimpleNamespace(disconnect=MagicMock()), + search_failed=SimpleNamespace(disconnect=MagicMock()), + search_progress=SimpleNamespace(disconnect=MagicMock()), + finished=SimpleNamespace(disconnect=MagicMock()), + deleteLater=MagicMock(), + ) + fake = SimpleNamespace( + _search_thread=fake_thread, + _on_search_completed=MagicMock(), + _on_search_failed=MagicMock(), + _on_search_progress=MagicMock(), + _on_search_thread_finished=MagicMock(), + ) + monkeypatch.setattr(dialog_module, "isValid", lambda _obj: True) + dialog_module._ACTIVE_LYRICS_SEARCH_THREADS.clear() + + LyricsDownloadDialog._stop_search_thread(fake, wait_ms=250, cleanup_signals=True) + + fake_thread.cancel.assert_called_once() + fake_thread.requestInterruption.assert_called_once() + fake_thread.quit.assert_called_once() + fake_thread.wait.assert_called_once_with(250) + fake_thread.search_completed.disconnect.assert_called_once() + fake_thread.search_failed.disconnect.assert_called_once() + fake_thread.search_progress.disconnect.assert_called_once() + fake_thread.finished.disconnect.assert_called_once() + fake_thread.deleteLater.assert_not_called() + assert fake._search_thread is None + assert fake_thread in dialog_module._ACTIVE_LYRICS_SEARCH_THREADS + + +def test_stop_search_thread_cleans_up_finished_thread(monkeypatch): + """Finished search threads should be deleted immediately and not retained.""" + fake_thread = _make_fake_thread( + isRunning=MagicMock(return_value=False), + cancel=MagicMock(), + requestInterruption=MagicMock(), + quit=MagicMock(), + wait=MagicMock(), + search_completed=SimpleNamespace(disconnect=MagicMock()), + search_failed=SimpleNamespace(disconnect=MagicMock()), + search_progress=SimpleNamespace(disconnect=MagicMock()), + finished=SimpleNamespace(disconnect=MagicMock()), + deleteLater=MagicMock(), + ) + fake = SimpleNamespace( + _search_thread=fake_thread, + _on_search_completed=MagicMock(), + _on_search_failed=MagicMock(), + _on_search_progress=MagicMock(), + _on_search_thread_finished=MagicMock(), + ) + monkeypatch.setattr(dialog_module, "isValid", lambda _obj: True) + dialog_module._ACTIVE_LYRICS_SEARCH_THREADS.clear() + + LyricsDownloadDialog._stop_search_thread(fake, wait_ms=250, cleanup_signals=True) + + fake_thread.cancel.assert_not_called() + fake_thread.requestInterruption.assert_not_called() + fake_thread.quit.assert_not_called() + fake_thread.wait.assert_not_called() + fake_thread.search_completed.disconnect.assert_called_once() + fake_thread.search_failed.disconnect.assert_called_once() + fake_thread.search_progress.disconnect.assert_called_once() + fake_thread.finished.disconnect.assert_called_once() + fake_thread.deleteLater.assert_called_once() + assert fake._search_thread is None + assert fake_thread not in dialog_module._ACTIVE_LYRICS_SEARCH_THREADS diff --git a/ui/dialogs/lyrics_download_dialog.py b/ui/dialogs/lyrics_download_dialog.py index 36eae4ce..508fdba7 100644 --- a/ui/dialogs/lyrics_download_dialog.py +++ b/ui/dialogs/lyrics_download_dialog.py @@ -29,6 +29,8 @@ logger = logging.getLogger(__name__) +_ACTIVE_LYRICS_SEARCH_THREADS: set[QThread] = set() + class LyricsSearchThread(QThread): """Thread for searching lyrics with progressive updates.""" @@ -237,20 +239,84 @@ def _setup_ui(self): def _on_cancel_clicked(self): """Handle cancel button click.""" - if self._search_thread and isValid(self._search_thread) and self._search_thread.isRunning(): - self._search_thread.cancel() - # Give the thread a moment to clean up - self._search_thread.wait(100) # Wait up to 100ms self.reject() def _start_search(self): """Start the search thread with progressive updates.""" - self._search_thread = LyricsSearchThread(self._track_title, self._track_artist) - self._search_thread.search_completed.connect(self._on_search_completed) - self._search_thread.search_failed.connect(self._on_search_failed) - self._search_thread.search_progress.connect(self._on_search_progress) - self._search_thread.finished.connect(self._search_thread.deleteLater) - self._search_thread.start() + LyricsDownloadDialog._stop_search_thread(self, wait_ms=100, cleanup_signals=True) + + thread = LyricsSearchThread(self._track_title, self._track_artist) + self._search_thread = thread + _ACTIVE_LYRICS_SEARCH_THREADS.add(thread) + + thread.search_completed.connect(self._on_search_completed) + thread.search_failed.connect(self._on_search_failed) + thread.search_progress.connect(self._on_search_progress) + thread.finished.connect(self._on_search_thread_finished) + thread.finished.connect(thread.deleteLater) + thread.finished.connect(lambda thread=thread: _ACTIVE_LYRICS_SEARCH_THREADS.discard(thread)) + thread.start() + + @staticmethod + def _disconnect_signal(signal, slot): + """Disconnect a specific slot, ignoring already-disconnected signals.""" + if signal is None or slot is None: + return + try: + signal.disconnect(slot) + except (RuntimeError, TypeError): + pass + + def _disconnect_search_thread_signals(self, thread: LyricsSearchThread): + """Disconnect dialog-owned slots from a search thread.""" + on_search_completed = getattr(self, "_on_search_completed", None) + on_search_failed = getattr(self, "_on_search_failed", None) + on_search_progress = getattr(self, "_on_search_progress", None) + on_search_thread_finished = getattr(self, "_on_search_thread_finished", None) + + LyricsDownloadDialog._disconnect_signal( + getattr(thread, "search_completed", None), + on_search_completed, + ) + LyricsDownloadDialog._disconnect_signal( + getattr(thread, "search_failed", None), + on_search_failed, + ) + LyricsDownloadDialog._disconnect_signal( + getattr(thread, "search_progress", None), + on_search_progress, + ) + LyricsDownloadDialog._disconnect_signal( + getattr(thread, "finished", None), + on_search_thread_finished, + ) + + def _stop_search_thread(self, wait_ms: int = 1000, cleanup_signals: bool = False): + """Stop the search thread and detach it from the dialog if needed.""" + thread = getattr(self, "_search_thread", None) + if not thread or not isValid(thread): + self._search_thread = None + return + + if cleanup_signals: + LyricsDownloadDialog._disconnect_search_thread_signals(self, thread) + + if thread.isRunning(): + thread.cancel() + thread.requestInterruption() + thread.quit() + if not thread.wait(wait_ms): + logger.warning( + "[LyricsDownloadDialog] Search thread still running after close request; " + "detaching cleanup from dialog lifecycle" + ) + _ACTIVE_LYRICS_SEARCH_THREADS.add(thread) + self._search_thread = None + return + + _ACTIVE_LYRICS_SEARCH_THREADS.discard(thread) + thread.deleteLater() + self._search_thread = None def _on_search_progress(self, new_results: list, source_name: str): """Handle progressive search updates from each source.""" @@ -349,6 +415,12 @@ def _on_search_failed(self, error_message: str): self._progress_bar.setVisible(False) self._status_label.setText(error_message) + def _on_search_thread_finished(self): + """Clear the dialog reference once the current search thread fully stops.""" + sender = self.sender() + if sender and sender == self._search_thread: + self._search_thread = None + def _format_result_text(self, result: dict) -> str: """Format a search result for display in the list. @@ -411,19 +483,17 @@ def accept(self): if current_item: self._selected_song = current_item.data(Qt.UserRole) self._download_cover = self._download_cover_checkbox.isChecked() + LyricsDownloadDialog._stop_search_thread(self, wait_ms=100, cleanup_signals=True) super().accept() + def reject(self): + """Handle dialog rejection.""" + LyricsDownloadDialog._stop_search_thread(self, wait_ms=100, cleanup_signals=True) + super().reject() + def closeEvent(self, event): """Clean up on close.""" - if self._search_thread and isValid(self._search_thread) and self._search_thread.isRunning(): - self._search_thread.cancel() - self._search_thread.wait(500) # Wait up to 500ms for clean shutdown - # Force terminate if still running (shouldn't happen normally) - if self._search_thread and isValid(self._search_thread) and self._search_thread.isRunning(): - self._search_thread.requestInterruption() - self._search_thread.wait(3000) # Wait up to 3 seconds - if self._search_thread and isValid(self._search_thread) and self._search_thread.isRunning(): - logger.warning("[LyricsDownloadDialog] Thread did not stop gracefully") + LyricsDownloadDialog._stop_search_thread(self, wait_ms=100, cleanup_signals=True) super().closeEvent(event) @staticmethod From f614d18e318e17b67d51df4ea9db1414495a40ed Mon Sep 17 00:00:00 2001 From: Har01d Date: Tue, 7 Apr 2026 20:04:01 +0800 Subject: [PATCH 074/157] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E6=A0=B7=E5=BC=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- plugins/builtin/qqmusic/lib/login_dialog.py | 14 +- .../test_qqmusic_theme_integration.py | 1 + tests/test_ui/test_dialog_action_buttons.py | 122 ++++++++++++++++++ tests/test_ui/test_plugin_settings_tab.py | 2 +- ui/dialogs/base_cover_download_dialog.py | 12 +- ui/dialogs/cloud_login_dialog.py | 26 ++-- ui/dialogs/lyrics_edit_dialog.py | 19 +-- ui/dialogs/redownload_dialog.py | 23 +--- ui/dialogs/sleep_timer_dialog.py | 6 +- ui/widgets/player_controls.py | 2 +- 10 files changed, 163 insertions(+), 64 deletions(-) create mode 100644 tests/test_ui/test_dialog_action_buttons.py diff --git a/plugins/builtin/qqmusic/lib/login_dialog.py b/plugins/builtin/qqmusic/lib/login_dialog.py index 5cf37806..2af05a30 100644 --- a/plugins/builtin/qqmusic/lib/login_dialog.py +++ b/plugins/builtin/qqmusic/lib/login_dialog.py @@ -193,7 +193,7 @@ class QQMusicLoginDialog(QDialog): QRadioButton::indicator:hover { border: 2px solid %highlight%; } - QPushButton#loginDialogActionBtn { + QPushButton#qqmusicRefreshBtn { background-color: %border%; color: %text%; font-size: 13px; @@ -201,16 +201,16 @@ class QQMusicLoginDialog(QDialog): border-radius: 4px; padding: 8px 16px; } - QPushButton#loginDialogActionBtn:hover { + QPushButton#qqmusicRefreshBtn:hover { background-color: %background_hover%; border: 1px solid %highlight%; } - QPushButton#loginDialogActionBtn:pressed { + QPushButton#qqmusicRefreshBtn:pressed { background-color: %background_alt%; } - QPushButton#loginDialogActionBtn:disabled { + QPushButton#qqmusicRefreshBtn:disabled { background-color: %background_alt%; - color: %border%; + color: %text_secondary%; } QProgressBar { border: none; @@ -379,14 +379,14 @@ def _setup_ui(self): button_layout = QHBoxLayout() self._refresh_button = QPushButton(t("qqmusic_refresh_qr")) - self._refresh_button.setObjectName("loginDialogActionBtn") + self._refresh_button.setObjectName("qqmusicRefreshBtn") self._refresh_button.setCursor(Qt.PointingHandCursor) self._refresh_button.clicked.connect(self._refresh_qr) self._refresh_button.setEnabled(False) button_layout.addWidget(self._refresh_button) self._cancel_button = QPushButton(t("cancel")) - self._cancel_button.setObjectName("loginDialogActionBtn") + self._cancel_button.setProperty("role", "cancel") self._cancel_button.setCursor(Qt.PointingHandCursor) self._cancel_button.clicked.connect(self._cancel_login) button_layout.addWidget(self._cancel_button) diff --git a/tests/test_plugins/test_qqmusic_theme_integration.py b/tests/test_plugins/test_qqmusic_theme_integration.py index e12308a9..69a166c9 100644 --- a/tests/test_plugins/test_qqmusic_theme_integration.py +++ b/tests/test_plugins/test_qqmusic_theme_integration.py @@ -34,6 +34,7 @@ def test_plugin_login_dialog_uses_host_owned_shell_and_title_bar_styles(qtbot, m assert dialog.property("shell") is True assert dialog._title_bar_controller.title_bar.styleSheet() == "" assert dialog._title_bar_controller.close_btn.styleSheet() == "" + assert dialog._cancel_button.property("role") == "cancel" def test_online_music_view_search_input_uses_theme_variant_and_host_popup_helper(qtbot, tmp_path): settings = _plugin_settings(tmp_path) diff --git a/tests/test_ui/test_dialog_action_buttons.py b/tests/test_ui/test_dialog_action_buttons.py new file mode 100644 index 00000000..41a567db --- /dev/null +++ b/tests/test_ui/test_dialog_action_buttons.py @@ -0,0 +1,122 @@ +from unittest.mock import Mock, patch + +import pytest +from PySide6.QtCore import QObject, Signal +from PySide6.QtWidgets import QPushButton + +from domain.track import Track +from services import LyricsService +from services.metadata import CoverService +from system.theme import ThemeManager +from ui.controllers.cover_controller import CoverController +from ui.dialogs.base_cover_download_dialog import BaseCoverDownloadDialog +from ui.dialogs.cloud_login_dialog import CloudLoginDialog +from ui.dialogs.lyrics_edit_dialog import LyricsEditDialog +from ui.dialogs.redownload_dialog import RedownloadDialog +from ui.dialogs.sleep_timer_dialog import SleepTimerDialog +from ui.dialogs.universal_cover_download_dialog import UniversalCoverDownloadDialog +from ui.strategies.track_search_strategy import TrackSearchStrategy + + +class _FakeSleepTimerService(QObject): + timer_started = Signal() + timer_stopped = Signal() + timer_triggered = Signal() + + def __init__(self): + super().__init__() + self.is_active = False + + def start(self, _config): + self.is_active = True + self.timer_started.emit() + + def cancel(self): + self.is_active = False + self.timer_stopped.emit() + + +@pytest.fixture(autouse=True) +def _init_theme(): + config = Mock() + config.get.return_value = "dark" + ThemeManager._instance = None + ThemeManager.instance(config) + yield + ThemeManager._instance = None + + +def _buttons_by_role(dialog): + buttons = dialog.findChildren(QPushButton) + return { + "primary": [button for button in buttons if button.property("role") == "primary"], + "cancel": [button for button in buttons if button.property("role") == "cancel"], + } + + +def test_lyrics_edit_dialog_uses_foundation_action_button_roles(qtbot, monkeypatch): + monkeypatch.setattr(LyricsService, "get_lyrics", lambda *_args, **_kwargs: "") + + dialog = LyricsEditDialog("/tmp/song.mp3", "Song", "Artist") + qtbot.addWidget(dialog) + + roles = _buttons_by_role(dialog) + + assert "QPushButton {" not in LyricsEditDialog._STYLE_TEMPLATE + assert len(roles["primary"]) == 1 + assert len(roles["cancel"]) == 1 + + +def test_redownload_dialog_uses_foundation_action_button_roles(qtbot): + dialog = RedownloadDialog("Song", current_quality="320") + qtbot.addWidget(dialog) + + roles = _buttons_by_role(dialog) + + assert "QPushButton {" not in RedownloadDialog._STYLE_TEMPLATE + assert len(roles["primary"]) == 1 + assert len(roles["cancel"]) == 1 + + +def test_cloud_login_dialog_uses_foundation_cancel_button_role(qtbot, monkeypatch): + monkeypatch.setattr(CloudLoginDialog, "_start_login_flow", lambda self: None) + + dialog = CloudLoginDialog("quark") + qtbot.addWidget(dialog) + + roles = _buttons_by_role(dialog) + + assert "QPushButton {" not in CloudLoginDialog._STYLE_TEMPLATE + assert len(roles["cancel"]) == 1 + + +def test_sleep_timer_dialog_uses_foundation_action_button_roles(qtbot): + dialog = SleepTimerDialog(_FakeSleepTimerService()) + qtbot.addWidget(dialog) + + roles = _buttons_by_role(dialog) + + assert len(roles["primary"]) == 1 + assert len(roles["cancel"]) == 2 + + +def test_cover_download_dialog_uses_foundation_action_button_roles(qtbot): + track = Track( + id=1, + path="/tmp/song.mp3", + title="Song", + artist="Artist", + album="Album", + ) + strategy = TrackSearchStrategy([track], Mock(), Mock()) + cover_service = Mock(spec=CoverService) + + with patch.object(CoverController, "search", return_value=None): + dialog = UniversalCoverDownloadDialog(strategy, cover_service) + + qtbot.addWidget(dialog) + roles = _buttons_by_role(dialog) + + assert "QPushButton {" not in BaseCoverDownloadDialog._STYLE_TEMPLATE + assert len(roles["primary"]) == 1 + assert len(roles["cancel"]) == 1 diff --git a/tests/test_ui/test_plugin_settings_tab.py b/tests/test_ui/test_plugin_settings_tab.py index fd557168..e9fd4ac9 100644 --- a/tests/test_ui/test_plugin_settings_tab.py +++ b/tests/test_ui/test_plugin_settings_tab.py @@ -649,4 +649,4 @@ def test_qqmusic_settings_tab_keeps_content_padding(qtbot): def test_qqmusic_login_dialog_uses_dialog_container_selector_and_scoped_button_style(): assert "QWidget#dialogContainer" in QQMusicLoginDialog._STYLE_TEMPLATE assert "QWidget#settingsContainer" not in QQMusicLoginDialog._STYLE_TEMPLATE - assert "QPushButton#loginDialogActionBtn" in QQMusicLoginDialog._STYLE_TEMPLATE + assert "QPushButton {" not in QQMusicLoginDialog._STYLE_TEMPLATE diff --git a/ui/dialogs/base_cover_download_dialog.py b/ui/dialogs/base_cover_download_dialog.py index 330ff0f9..b40c7719 100644 --- a/ui/dialogs/base_cover_download_dialog.py +++ b/ui/dialogs/base_cover_download_dialog.py @@ -183,7 +183,7 @@ class BaseCoverDownloadDialog(QDialog): QLabel { color: %text%; } - QPushButton { + QPushButton#coverSearchBtn { background-color: %border%; color: %text%; border: 1px solid %background_hover%; @@ -191,13 +191,14 @@ class BaseCoverDownloadDialog(QDialog): padding: 8px 16px; min-width: 80px; } - QPushButton:hover { + QPushButton#coverSearchBtn:hover { background-color: %background_hover%; } - QPushButton:pressed { + QPushButton#coverSearchBtn:pressed { background-color: %background_alt%; } - QPushButton:disabled { + QPushButton#coverSearchBtn:disabled, + QPushButton[role="primary"]:disabled { background-color: %background_alt%; color: %border%; border-color: %border%; @@ -354,6 +355,7 @@ def _setup_common_ui(self, info_text: str, cover_size: int = 350, circular: bool # Search button self._search_btn = QPushButton(t("search")) + self._search_btn.setObjectName("coverSearchBtn") self._search_btn.setCursor(Qt.PointingHandCursor) self._search_btn.clicked.connect(self._search_covers) left_layout.addWidget(self._search_btn) @@ -434,12 +436,14 @@ def _setup_common_ui(self, info_text: str, cover_size: int = 350, circular: bool button_layout = QHBoxLayout() self._save_btn = QPushButton(t("save")) + self._save_btn.setProperty("role", "primary") self._save_btn.setCursor(Qt.PointingHandCursor) self._save_btn.setEnabled(False) self._save_btn.clicked.connect(self._save_cover) button_layout.addWidget(self._save_btn) close_btn = QPushButton(t("cancel")) + close_btn.setProperty("role", "cancel") close_btn.setCursor(Qt.PointingHandCursor) close_btn.clicked.connect(self.reject) button_layout.addWidget(close_btn) diff --git a/ui/dialogs/cloud_login_dialog.py b/ui/dialogs/cloud_login_dialog.py index a0a46276..c7f5fa21 100644 --- a/ui/dialogs/cloud_login_dialog.py +++ b/ui/dialogs/cloud_login_dialog.py @@ -41,25 +41,31 @@ class CloudLoginDialog(QDialog): QLabel { color: %text%; } - QPushButton { + QPushButton#cloudLoginModeBtn, + QPushButton#cloudLoginActionBtn { background-color: %border%; color: %text%; border: 1px solid %background_hover%; border-radius: 4px; padding: 8px 16px; } - QPushButton:hover { + QPushButton#cloudLoginModeBtn:hover, + QPushButton#cloudLoginActionBtn:hover { background-color: %background_hover%; } - QPushButton:pressed { + QPushButton#cloudLoginModeBtn:pressed, + QPushButton#cloudLoginActionBtn:pressed { background-color: %background_alt%; } - QPushButton[role="cancel"] { - background-color: %border%; - color: %text%; + QPushButton#cloudLoginModeBtn:checked { + background-color: %highlight%; + border-color: %highlight%; + color: %background%; } - QPushButton[role="cancel"]:hover { - background-color: %background_hover%; + QPushButton#cloudLoginActionBtn:disabled { + background-color: %background_alt%; + color: %text_secondary%; + border-color: %border%; } QProgressBar { background-color: %border%; @@ -140,12 +146,14 @@ def _setup_ui(self): mode_layout.setSpacing(10) self._qr_mode_btn = QPushButton(t("scan_qr_code")) + self._qr_mode_btn.setObjectName("cloudLoginModeBtn") self._qr_mode_btn.setCursor(Qt.PointingHandCursor) self._qr_mode_btn.setCheckable(True) self._qr_mode_btn.setChecked(True) self._qr_mode_btn.clicked.connect(self._switch_to_qr_mode) self._cookie_mode_btn = QPushButton(t("input_cookie")) + self._cookie_mode_btn.setObjectName("cloudLoginModeBtn") self._cookie_mode_btn.setCursor(Qt.PointingHandCursor) self._cookie_mode_btn.setCheckable(True) self._cookie_mode_btn.clicked.connect(self._switch_to_cookie_mode) @@ -171,6 +179,7 @@ def _setup_ui(self): button_layout = QHBoxLayout() self._refresh_btn = QPushButton(t("refresh_qr")) + self._refresh_btn.setObjectName("cloudLoginActionBtn") self._refresh_btn.setCursor(Qt.PointingHandCursor) self._refresh_btn.clicked.connect(self._refresh_qr) button_layout.addWidget(self._refresh_btn) @@ -243,6 +252,7 @@ def _create_cookie_widget(self): # Validate button self._validate_btn = QPushButton(t("validate_cookie")) + self._validate_btn.setObjectName("cloudLoginActionBtn") self._validate_btn.setCursor(Qt.PointingHandCursor) self._validate_btn.clicked.connect(self._validate_cookie) layout.addWidget(self._validate_btn) diff --git a/ui/dialogs/lyrics_edit_dialog.py b/ui/dialogs/lyrics_edit_dialog.py index 5640c2a9..017638b8 100644 --- a/ui/dialogs/lyrics_edit_dialog.py +++ b/ui/dialogs/lyrics_edit_dialog.py @@ -55,24 +55,6 @@ class LyricsEditDialog(QDialog): font-family: 'Consolas', 'Monaco', monospace; font-size: 13px; } - QPushButton { - background-color: %highlight%; - color: %background%; - border: none; - padding: 8px 20px; - border-radius: 4px; - font-weight: bold; - } - QPushButton:hover { - background-color: %highlight_hover%; - } - QPushButton[role="cancel"] { - background-color: %border%; - color: %text%; - } - QPushButton[role="cancel"]:hover { - background-color: %background_hover%; - } """ def __init__( @@ -172,6 +154,7 @@ def _setup_ui(self): btn_layout.addWidget(cancel_btn) save_btn = QPushButton(t("save")) + save_btn.setProperty("role", "primary") save_btn.setCursor(QCursor(Qt.PointingHandCursor)) save_btn.clicked.connect(self._save_lyrics) btn_layout.addWidget(save_btn) diff --git a/ui/dialogs/redownload_dialog.py b/ui/dialogs/redownload_dialog.py index 7df0f8b4..b19d2838 100644 --- a/ui/dialogs/redownload_dialog.py +++ b/ui/dialogs/redownload_dialog.py @@ -42,28 +42,6 @@ class RedownloadDialog(QDialog): color: %text_secondary%; font-size: 12px; } - QPushButton { - background-color: %highlight%; - color: %background%; - border: none; - padding: 8px 20px; - border-radius: 4px; - font-weight: bold; - } - QPushButton:hover { - background-color: %highlight_hover%; - } - QPushButton:disabled { - background-color: %border%; - color: %text_secondary%; - } - QPushButton[role="cancel"] { - background-color: %border%; - color: %text%; - } - QPushButton[role="cancel"]:hover { - background-color: %background_hover%; - } """ + ThemeManager.get_combobox_style() + """ """ _POPUP_STYLE_TEMPLATE = """ @@ -171,6 +149,7 @@ def _setup_ui(self, track_title: str, current_quality: str = None): cancel_btn.clicked.connect(self.reject) confirm_btn = QPushButton(t("ok")) + confirm_btn.setProperty("role", "primary") confirm_btn.setCursor(Qt.PointingHandCursor) confirm_btn.clicked.connect(self.accept) diff --git a/ui/dialogs/sleep_timer_dialog.py b/ui/dialogs/sleep_timer_dialog.py index 6ceeb4a3..8a731bf2 100644 --- a/ui/dialogs/sleep_timer_dialog.py +++ b/ui/dialogs/sleep_timer_dialog.py @@ -242,10 +242,13 @@ def _add_buttons(self): layout.setSpacing(12) self._start_btn = QPushButton(t("start")) + self._start_btn.setProperty("role", "primary") self._start_btn.setCursor(Qt.PointingHandCursor) self._cancel_btn = QPushButton(t("cancel_timer")) + self._cancel_btn.setProperty("role", "cancel") self._cancel_btn.setCursor(Qt.PointingHandCursor) self._close_btn = QPushButton(t("close")) + self._close_btn.setProperty("role", "cancel") self._close_btn.setCursor(Qt.PointingHandCursor) self._cancel_btn.setVisible(False) @@ -293,9 +296,6 @@ def _apply_styles(self): width: 20px; } """ + ThemeManager.get_combobox_style() + """ -QPushButton { background-color: %highlight%; color: %background%; border: none; border-radius: 6px; padding: 8px 24px; font-size: 14px; min-width: 80px; } -QPushButton:hover { background-color: %highlight_hover%; } -QPushButton:pressed { background-color: %selection%; } QPushButton#presetBtn { background-color: %background%; color: %text%; border: 1px solid %border%; padding: 6px 12px; font-size: 12px; min-width: 60px; } QPushButton#presetBtn:hover { background-color: %background_hover%; border-color: %highlight%; } #statusLabel { color: %highlight%; font-size: 14px; font-weight: bold; padding: 8px; background-color: %background_hover%; border-radius: 6px; } diff --git a/ui/widgets/player_controls.py b/ui/widgets/player_controls.py index d302b98f..c65a642f 100644 --- a/ui/widgets/player_controls.py +++ b/ui/widgets/player_controls.py @@ -448,7 +448,7 @@ def _create_volume_widget(self) -> QWidget: layout.addWidget(self._volume_btn) # Volume slider - self._volume_slider = QSlider(Qt.Horizontal) + self._volume_slider = ClickableSlider(Qt.Horizontal) self._volume_slider.setRange(0, 100) self._volume_slider.setValue(70) self._volume_slider.setFixedWidth(100) From 20ea8dd0caba8de8c4b046b8d7f9370128502715 Mon Sep 17 00:00:00 2001 From: Har01d Date: Tue, 7 Apr 2026 20:40:15 +0800 Subject: [PATCH 075/157] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E6=A0=B7=E5=BC=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...7-organize-files-dialog-theme-alignment.md | 102 +++++++++++++ .../test_theme_foundation_styles.py | 13 ++ tests/test_ui/test_dialog_action_buttons.py | 139 ++++++++++++++++++ tests/test_ui/test_dialog_title_bar.py | 23 +++ .../test_ui/test_foundation_theme_cleanup.py | 33 +++++ tests/test_ui/test_library_view.py | 44 +++++- tests/test_ui/test_plugin_settings_tab.py | 48 +++++- ui/dialogs/add_to_playlist_dialog.py | 43 +----- ui/dialogs/base_cover_download_dialog.py | 9 -- ui/dialogs/base_rename_dialog.py | 16 -- ui/dialogs/cloud_login_dialog.py | 14 -- ui/dialogs/edit_media_info_dialog.py | 20 --- ui/dialogs/help_dialog.py | 23 +-- ui/dialogs/input_dialog.py | 1 + ui/dialogs/lyrics_download_dialog.py | 15 -- ui/dialogs/lyrics_edit_dialog.py | 15 -- ui/dialogs/message_dialog.py | 32 +--- ui/dialogs/organize_files_dialog.py | 122 ++------------- ui/dialogs/plugin_management_tab.py | 37 +---- ui/dialogs/progress_dialog.py | 47 +----- ui/dialogs/provider_select_dialog.py | 50 +------ ui/dialogs/redownload_dialog.py | 15 -- ui/dialogs/settings_dialog.py | 2 + ui/dialogs/sleep_timer_dialog.py | 4 - ui/styles.qss | 81 ++++++++-- ui/views/library_view.py | 39 +++++ ui/views/local_tracks_list_view.py | 2 + ui/widgets/context_menus.py | 10 +- ui/widgets/equalizer_widget.py | 31 ---- 29 files changed, 547 insertions(+), 483 deletions(-) create mode 100644 docs/superpowers/plans/2026-04-07-organize-files-dialog-theme-alignment.md diff --git a/docs/superpowers/plans/2026-04-07-organize-files-dialog-theme-alignment.md b/docs/superpowers/plans/2026-04-07-organize-files-dialog-theme-alignment.md new file mode 100644 index 00000000..9cc18680 --- /dev/null +++ b/docs/superpowers/plans/2026-04-07-organize-files-dialog-theme-alignment.md @@ -0,0 +1,102 @@ +# OrganizeFilesDialog Theme Alignment Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Align `OrganizeFilesDialog` with the unified dialog theme pattern used by `EditMediaInfoDialog`. + +**Architecture:** Keep the dialog's frameless shell and container-level stylesheet, but remove local button/progress/table color rules that duplicate global theme behavior. Drive action buttons through shared `role` properties so the foundation theme stylesheet owns their appearance. + +**Tech Stack:** Python, PySide6, pytest-qt, Harmony theme system (`ThemeManager`, `ui/styles.qss`) + +--- + +### Task 1: Lock the desired dialog theme behavior with a regression test + +**Files:** +- Modify: `tests/test_ui/test_dialog_action_buttons.py` +- Test: `tests/test_ui/test_dialog_action_buttons.py` + +- [ ] **Step 1: Write the failing test** + +```python +def test_organize_files_dialog_uses_foundation_action_button_roles(qtbot): + dialog = OrganizeFilesDialog( + tracks=[Track(id=1, path="/tmp/song.mp3", title="Song", artist="Artist")], + file_org_service=Mock(), + config_manager=Mock(), + ) + qtbot.addWidget(dialog) + + roles = _buttons_by_role(dialog) + + assert "QPushButton {" not in OrganizeFilesDialog._STYLE_TEMPLATE + assert "QProgressBar {" not in OrganizeFilesDialog._STYLE_TEMPLATE + assert len(roles["primary"]) == 1 + assert len(roles["cancel"]) == 1 +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `uv run pytest tests/test_ui/test_dialog_action_buttons.py::test_organize_files_dialog_uses_foundation_action_button_roles -v` +Expected: FAIL because `OrganizeFilesDialog` still includes local button/progress styling and its buttons do not yet declare shared `role` properties. + +- [ ] **Step 3: Write minimal implementation** + +```python +class OrganizeFilesDialog(QDialog): + _STYLE_TEMPLATE = """ + QWidget#dialogContainer { + background-color: %background_alt%; + color: %text%; + border: 1px solid %border%; + border-radius: 12px; + } + QLabel#dialogTitle { + color: %text%; + font-size: 15px; + font-weight: bold; + } + QLabel { + color: %text%; + font-size: 13px; + } + """ + + def __init__(...): + ... + self.setProperty("shell", True) + + def _setup_ui(self): + ... + self.organize_btn.setProperty("role", "primary") + close_btn.setProperty("role", "cancel") +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `uv run pytest tests/test_ui/test_dialog_action_buttons.py::test_organize_files_dialog_uses_foundation_action_button_roles -v` +Expected: PASS + +- [ ] **Step 5: Commit** + +```bash +git add tests/test_ui/test_dialog_action_buttons.py ui/dialogs/organize_files_dialog.py docs/superpowers/plans/2026-04-07-organize-files-dialog-theme-alignment.md +git commit -m "统一整理文件对话框主题样式" +``` + +### Task 2: Verify no regression in focused dialog theme coverage + +**Files:** +- Test: `tests/test_ui/test_dialog_action_buttons.py` + +- [ ] **Step 1: Run focused dialog theme tests** + +Run: `uv run pytest tests/test_ui/test_dialog_action_buttons.py -v` +Expected: PASS + +- [ ] **Step 2: Commit if verification remains green** + +```bash +git add tests/test_ui/test_dialog_action_buttons.py ui/dialogs/organize_files_dialog.py docs/superpowers/plans/2026-04-07-organize-files-dialog-theme-alignment.md +git commit -m "补充整理文件对话框主题回归测试" +``` diff --git a/tests/test_system/test_theme_foundation_styles.py b/tests/test_system/test_theme_foundation_styles.py index 951d775a..df9b99aa 100644 --- a/tests/test_system/test_theme_foundation_styles.py +++ b/tests/test_system/test_theme_foundation_styles.py @@ -48,3 +48,16 @@ def test_theme_manager_global_stylesheet_includes_wrapper_variants(qapp): assert "QWidget#titleBar" in stylesheet assert "QPushButton#winBtn" in stylesheet assert "QPushButton#dialogCloseBtn" in stylesheet + + +def test_theme_manager_global_stylesheet_uses_playlist_dialog_action_button_foundation(qapp): + tm = _build_theme_manager() + + tm.apply_global_stylesheet() + stylesheet = qapp.styleSheet() + + assert "QPushButton[role=\"primary\"]" in stylesheet + assert "QPushButton[role=\"cancel\"]" in stylesheet + assert "min-width: 80px;" in stylesheet + assert "padding: 8px 20px;" in stylesheet + assert "border-radius: 6px;" in stylesheet diff --git a/tests/test_ui/test_dialog_action_buttons.py b/tests/test_ui/test_dialog_action_buttons.py index 41a567db..f9f33109 100644 --- a/tests/test_ui/test_dialog_action_buttons.py +++ b/tests/test_ui/test_dialog_action_buttons.py @@ -11,10 +11,18 @@ from ui.controllers.cover_controller import CoverController from ui.dialogs.base_cover_download_dialog import BaseCoverDownloadDialog from ui.dialogs.cloud_login_dialog import CloudLoginDialog +from ui.dialogs.help_dialog import HelpDialog +from ui.dialogs.input_dialog import InputDialog from ui.dialogs.lyrics_edit_dialog import LyricsEditDialog +from ui.dialogs.message_dialog import MessageDialog, Ok, Cancel +from ui.dialogs.add_to_playlist_dialog import AddToPlaylistDialog +from ui.dialogs.organize_files_dialog import OrganizeFilesDialog +from ui.dialogs.progress_dialog import ProgressDialog +from ui.dialogs.provider_select_dialog import ProviderSelectDialog from ui.dialogs.redownload_dialog import RedownloadDialog from ui.dialogs.sleep_timer_dialog import SleepTimerDialog from ui.dialogs.universal_cover_download_dialog import UniversalCoverDownloadDialog +from ui.dialogs.welcome_dialog import WelcomeDialog from ui.strategies.track_search_strategy import TrackSearchStrategy @@ -120,3 +128,134 @@ def test_cover_download_dialog_uses_foundation_action_button_roles(qtbot): assert "QPushButton {" not in BaseCoverDownloadDialog._STYLE_TEMPLATE assert len(roles["primary"]) == 1 assert len(roles["cancel"]) == 1 + + +def test_organize_files_dialog_uses_foundation_action_button_roles(qtbot): + track = Track( + id=1, + path="/tmp/song.mp3", + title="Song", + artist="Artist", + album="Album", + ) + file_org_service = Mock() + config_manager = Mock() + config_manager.get.return_value = "" + + dialog = OrganizeFilesDialog( + tracks=[track], + file_org_service=file_org_service, + config_manager=config_manager, + ) + qtbot.addWidget(dialog) + + roles = _buttons_by_role(dialog) + + assert dialog.styleSheet() == "" + assert len(roles["primary"]) == 1 + assert len(roles["cancel"]) == 1 + + +def test_organize_files_dialog_uses_global_panel_table_variant(qtbot): + track = Track( + id=1, + path="/tmp/song.mp3", + title="Song", + artist="Artist", + album="Album", + ) + file_org_service = Mock() + config_manager = Mock() + config_manager.get.return_value = "" + + dialog = OrganizeFilesDialog( + tracks=[track], + file_org_service=file_org_service, + config_manager=config_manager, + ) + qtbot.addWidget(dialog) + + assert dialog.preview_table.property("variant") == "panel" + assert dialog.preview_table.styleSheet() == "" + + +def test_provider_select_dialog_uses_foundation_action_button_roles(qtbot): + dialog = ProviderSelectDialog() + qtbot.addWidget(dialog) + + roles = _buttons_by_role(dialog) + + assert dialog.styleSheet() == "" + assert len(roles["primary"]) == 2 + assert len(roles["cancel"]) == 1 + + +def test_help_dialog_uses_foundation_action_button_roles(qtbot): + dialog = HelpDialog() + qtbot.addWidget(dialog) + + roles = _buttons_by_role(dialog) + + assert len(roles["primary"]) == 1 + + +def test_progress_dialog_uses_foundation_cancel_button_role(qtbot): + dialog = ProgressDialog("Title", "Loading", "Cancel", 0, 100) + qtbot.addWidget(dialog) + + roles = _buttons_by_role(dialog) + + assert dialog.styleSheet() == "" + assert len(roles["cancel"]) == 1 + + +def test_welcome_dialog_keeps_custom_onboarding_actions(qtbot): + dialog = WelcomeDialog(library_service=Mock()) + qtbot.addWidget(dialog) + + buttons = {button.objectName() for button in dialog.findChildren(QPushButton)} + + assert "QPushButton#addFolderBtn" in WelcomeDialog._STYLE_TEMPLATE + assert "QPushButton#skipBtn" in WelcomeDialog._STYLE_TEMPLATE + assert "addFolderBtn" in buttons + assert "skipBtn" in buttons + + +def test_input_dialog_uses_foundation_action_button_roles(qtbot): + dialog = InputDialog("Title", "Prompt", "value") + qtbot.addWidget(dialog) + + roles = _buttons_by_role(dialog) + + assert dialog.styleSheet() == "" + assert len(roles["primary"]) == 1 + assert len(roles["cancel"]) == 1 + + +def test_add_to_playlist_dialog_uses_foundation_action_button_roles(qtbot): + library_service = Mock() + library_service.get_all_playlists.return_value = [] + + dialog = AddToPlaylistDialog(library_service) + qtbot.addWidget(dialog) + + roles = _buttons_by_role(dialog) + + assert "QPushButton#cancelBtn" not in AddToPlaylistDialog._STYLE_TEMPLATE + assert "QPushButton#okBtn" not in AddToPlaylistDialog._STYLE_TEMPLATE + assert len(roles["primary"]) == 1 + assert len(roles["cancel"]) == 1 + + +def test_message_dialog_uses_foundation_action_button_roles(qtbot): + dialog = MessageDialog(None, "information") + dialog._add_button("OK", Ok, is_primary=True) + dialog._add_button("Cancel", Cancel, is_primary=False) + qtbot.addWidget(dialog) + + roles = _buttons_by_role(dialog) + + assert "QPushButton#msgPrimaryBtn" not in MessageDialog._STYLE_TEMPLATE + assert "QPushButton#msgBtn" not in MessageDialog._STYLE_TEMPLATE + assert len(roles["primary"]) == 1 + assert len(roles["cancel"]) == 1 diff --git a/tests/test_ui/test_dialog_title_bar.py b/tests/test_ui/test_dialog_title_bar.py index 460f5765..5b35884e 100644 --- a/tests/test_ui/test_dialog_title_bar.py +++ b/tests/test_ui/test_dialog_title_bar.py @@ -3,6 +3,7 @@ from PySide6.QtWidgets import QDialog, QMainWindow, QVBoxLayout from domain.track import TrackSource +from system.i18n import t from system.theme import ThemeManager from ui.dialogs.dialog_title_bar import setup_equalizer_title_layout from ui.widgets.context_menus import LocalTrackContextMenu @@ -58,3 +59,25 @@ def test_local_track_context_menu_uses_global_qmenu_styling(qtbot): assert menu is not None assert menu.styleSheet() == "" + + +def test_local_track_context_menu_exposes_organize_files_action(qtbot): + _init_theme() + menu_builder = LocalTrackContextMenu() + track = Mock() + track.id = 1 + track.path = "/tmp/song.mp3" + track.source = TrackSource.LOCAL + emitted = [] + + menu_builder.organize_files.connect(lambda tracks: emitted.append(tracks)) + + menu = menu_builder.build_menu([track], set(), None) + qtbot.addWidget(menu) + + organize_action = next( + action for action in menu.actions() if action.text() == t("organize_files") + ) + organize_action.trigger() + + assert emitted == [[track]] diff --git a/tests/test_ui/test_foundation_theme_cleanup.py b/tests/test_ui/test_foundation_theme_cleanup.py index b5affb55..4c28b6f7 100644 --- a/tests/test_ui/test_foundation_theme_cleanup.py +++ b/tests/test_ui/test_foundation_theme_cleanup.py @@ -1,7 +1,11 @@ from unittest.mock import Mock from system.theme import ThemeManager +from ui.dialogs.edit_media_info_dialog import EditMediaInfoDialog from ui.dialogs.input_dialog import InputDialog +from ui.dialogs.organize_files_dialog import OrganizeFilesDialog +from ui.dialogs.progress_dialog import ProgressDialog +from ui.dialogs.provider_select_dialog import ProviderSelectDialog from ui.views.albums_view import AlbumsView @@ -30,3 +34,32 @@ def test_albums_view_search_input_uses_theme_variant_instead_of_local_qss(qtbot) assert view._search_input.property("variant") == "search" assert view._search_input.styleSheet() == "" + + +def test_dialog_shells_use_global_foundation_container_styles(qtbot): + _init_theme() + + library_service = Mock() + first_track = Mock() + first_track.title = "Song" + first_track.artist = "Artist" + first_track.album = "Album" + first_track.genre = "Genre" + first_track.path = "/tmp/song.mp3" + library_service.get_track.return_value = first_track + + dialogs = [ + EditMediaInfoDialog([1], library_service), + OrganizeFilesDialog( + [first_track], + Mock(), + Mock(get=Mock(return_value="")), + ), + ProviderSelectDialog(), + ProgressDialog("Title", "Label", "Cancel", 0, 100), + ] + + for dialog in dialogs: + qtbot.addWidget(dialog) + assert dialog.property("shell") is True + assert dialog.styleSheet() == "" diff --git a/tests/test_ui/test_library_view.py b/tests/test_ui/test_library_view.py index 5279bacb..60b40bde 100644 --- a/tests/test_ui/test_library_view.py +++ b/tests/test_ui/test_library_view.py @@ -9,8 +9,9 @@ import pytest from PySide6.QtGui import QCloseEvent -from PySide6.QtWidgets import QApplication +from PySide6.QtWidgets import QApplication, QDialog +from app.application import Application from domain.history import PlayHistory from domain.playlist_item import PlaylistItem from domain.track import Track, TrackSource @@ -254,3 +255,44 @@ def test_library_view_close_event_disconnects_external_signals( assert view._on_player_state_changed not in engine.state_changed.connected assert view._on_tracks_organized not in fake_bus.tracks_organized.connected assert view._on_favorite_changed not in fake_bus.favorite_changed.connected + + +def test_library_view_opens_organize_dialog_when_list_view_requests_it( + qapp, mock_theme_config, sample_tracks, monkeypatch +): + view, _, _, _ = _build_library_view(mock_theme_config, sample_tracks) + + fake_file_org_service = MagicMock() + fake_app = SimpleNamespace( + bootstrap=SimpleNamespace(file_org_service=fake_file_org_service) + ) + monkeypatch.setattr( + Application, + "instance", + classmethod(lambda cls: fake_app), + ) + + import ui.dialogs.organize_files_dialog as organize_dialog_module + + created = {} + + class FakeDialog: + def __init__(self, tracks, file_org_service, config_manager, parent=None): + created["tracks"] = tracks + created["file_org_service"] = file_org_service + created["config_manager"] = config_manager + created["parent"] = parent + + def exec(self): + return QDialog.Accepted + + monkeypatch.setattr(organize_dialog_module, "OrganizeFilesDialog", FakeDialog) + view.refresh = MagicMock() + + view._all_tracks_list_view.organize_files_requested.emit([sample_tracks[0]]) + + assert created["tracks"] == [sample_tracks[0]] + assert created["file_org_service"] is fake_file_org_service + assert created["config_manager"] is view._config + assert created["parent"] is view + view.refresh.assert_called_once() diff --git a/tests/test_ui/test_plugin_settings_tab.py b/tests/test_ui/test_plugin_settings_tab.py index e9fd4ac9..5c013af7 100644 --- a/tests/test_ui/test_plugin_settings_tab.py +++ b/tests/test_ui/test_plugin_settings_tab.py @@ -1,11 +1,11 @@ from unittest.mock import Mock from PySide6.QtCore import QRect, Qt -from PySide6.QtWidgets import QLabel, QTabWidget, QTableWidget, QWidget +from PySide6.QtWidgets import QLabel, QPushButton, QTabWidget, QTableWidget, QWidget from plugins.builtin.qqmusic.lib.login_dialog import QQMusicLoginDialog from plugins.builtin.qqmusic.lib.settings_tab import QQMusicSettingsTab -from system.i18n import set_language +from system.i18n import set_language, t from system.plugins.host_services import PluginSettingsBridgeImpl from system.theme import ThemeManager from ui.dialogs.plugin_management_tab import PluginManagementTab @@ -238,7 +238,7 @@ def test_plugin_management_tab_localizes_version_header(qtbot): assert table.horizontalHeaderItem(1).text() == "版本" -def test_plugin_management_tab_applies_themed_header_stylesheet(qtbot): +def test_plugin_management_tab_uses_global_panel_table_variant(qtbot): config = Mock() config.get.return_value = "dark" ThemeManager._instance = None @@ -251,10 +251,44 @@ def test_plugin_management_tab_applies_themed_header_stylesheet(qtbot): qtbot.addWidget(widget) table = _plugin_table(widget) - stylesheet = table.styleSheet() - assert "QHeaderView::section" in stylesheet - assert "%background%" not in stylesheet - assert "%text%" not in stylesheet + assert table.property("variant") == "panel" + assert table.styleSheet() == "" + + +def test_settings_dialog_footer_cancel_button_uses_foundation_cancel_role(monkeypatch, qtbot): + config = Mock() + config.get.return_value = "dark" + config.get_ai_enabled.return_value = False + config.get_ai_base_url.return_value = "" + config.get_ai_api_key.return_value = "" + config.get_ai_model.return_value = "" + config.get_acoustid_enabled.return_value = False + config.get_acoustid_api_key.return_value = "" + config.get_online_music_download_dir.return_value = "data/online_cache" + config.get_cache_cleanup_strategy.return_value = "manual" + config.get_cache_cleanup_auto_enabled.return_value = False + config.get_cache_cleanup_time_days.return_value = 30 + config.get_cache_cleanup_size_mb.return_value = 1000 + config.get_cache_cleanup_count.return_value = 100 + config.get_cache_cleanup_interval_hours.return_value = 1 + config.get_audio_engine.return_value = "mpv" + + fake_manager = Mock() + fake_manager.list_plugins.return_value = [] + fake_manager.registry.settings_tabs.return_value = [] + bootstrap = Mock(plugin_manager=fake_manager) + monkeypatch.setattr("ui.dialogs.settings_dialog.Bootstrap.instance", lambda: bootstrap) + ThemeManager._instance = None + ThemeManager.instance(config) + + dialog = GeneralSettingsDialog(config) + qtbot.addWidget(dialog) + + cancel_button = next( + button for button in dialog.findChildren(QPushButton) if button.text() == t("cancel") + ) + + assert cancel_button.property("role") == "cancel" def test_plugin_management_tab_uses_row_level_toggles(qtbot): diff --git a/ui/dialogs/add_to_playlist_dialog.py b/ui/dialogs/add_to_playlist_dialog.py index bb2d90c5..d8c052d5 100644 --- a/ui/dialogs/add_to_playlist_dialog.py +++ b/ui/dialogs/add_to_playlist_dialog.py @@ -24,21 +24,6 @@ class AddToPlaylistDialog(QDialog): """Dialog for selecting a playlist to add tracks to.""" _STYLE_TEMPLATE = """ - QWidget#dialogContainer { - background-color: %background_alt%; - color: %text%; - border: 1px solid %border%; - border-radius: 12px; - } - QLabel#dialogTitle { - color: %text%; - font-size: 15px; - font-weight: bold; - } - QLabel#dialogLabel { - color: %text_secondary%; - font-size: 13px; - } QListWidget { background-color: %background%; border: 1px solid %border%; @@ -57,30 +42,6 @@ class AddToPlaylistDialog(QDialog): QListWidget::item:hover { background-color: %background_hover%; } - QPushButton#cancelBtn { - background-color: %background_hover%; - color: %text%; - border: 1px solid %border%; - border-radius: 6px; - padding: 8px 20px; - min-width: 80px; - font-weight: bold; - } - QPushButton#cancelBtn:hover { - background-color: %border%; - } - QPushButton#okBtn { - background-color: %highlight%; - color: %background%; - border: 1px solid %highlight%; - border-radius: 6px; - padding: 8px 20px; - min-width: 80px; - font-weight: bold; - } - QPushButton#okBtn:hover { - background-color: %highlight_hover%; - } """ def __init__(self, library_service, parent=None): @@ -148,13 +109,13 @@ def _setup_ui(self): btn_layout.addStretch() cancel_btn = QPushButton(t("cancel")) - cancel_btn.setObjectName("cancelBtn") + cancel_btn.setProperty("role", "cancel") cancel_btn.clicked.connect(self.reject) cancel_btn.setCursor(Qt.CursorShape.PointingHandCursor) btn_layout.addWidget(cancel_btn) ok_btn = QPushButton(t("ok")) - ok_btn.setObjectName("okBtn") + ok_btn.setProperty("role", "primary") ok_btn.clicked.connect(self.accept) ok_btn.setCursor(Qt.CursorShape.PointingHandCursor) btn_layout.addWidget(ok_btn) diff --git a/ui/dialogs/base_cover_download_dialog.py b/ui/dialogs/base_cover_download_dialog.py index b40c7719..9a79c832 100644 --- a/ui/dialogs/base_cover_download_dialog.py +++ b/ui/dialogs/base_cover_download_dialog.py @@ -174,15 +174,6 @@ class BaseCoverDownloadDialog(QDialog): # Common stylesheet template for all dialogs _STYLE_TEMPLATE = """ - QWidget#dialogContainer { - background-color: %background_alt%; - color: %text%; - border: 1px solid %border%; - border-radius: 12px; - } - QLabel { - color: %text%; - } QPushButton#coverSearchBtn { background-color: %border%; color: %text%; diff --git a/ui/dialogs/base_rename_dialog.py b/ui/dialogs/base_rename_dialog.py index ee4a5ea8..6a52fbbb 100644 --- a/ui/dialogs/base_rename_dialog.py +++ b/ui/dialogs/base_rename_dialog.py @@ -40,20 +40,6 @@ def run(self): class BaseRenameDialog(QDialog): """Base class for rename dialogs.""" - # Common stylesheet template for all rename dialogs - _STYLE_TEMPLATE = """ - QWidget#dialogContainer { - background-color: %background_alt%; - color: %text%; - border: 1px solid %border%; - border-radius: 12px; - } - QLabel { - color: %text%; - font-size: 13px; - } - """ - def __init__(self, parent=None): super().__init__(parent) self._worker = None @@ -89,7 +75,6 @@ def _setup_common_ui(self, title: str, min_width: int = 450): """ self.setWindowTitle(title) self.setMinimumWidth(min_width) - self.setStyleSheet(ThemeManager.instance().get_qss(self._STYLE_TEMPLATE)) # Outer layout with 0 margins — container fills the dialog outer = QVBoxLayout(self) @@ -316,7 +301,6 @@ def _emit_success_signal(self): def refresh_theme(self): """Refresh theme when changed.""" - self.setStyleSheet(ThemeManager.instance().get_qss(self._STYLE_TEMPLATE)) self._title_bar_controller.refresh_theme() # Update inline styles that use theme colors if self._warning_label: diff --git a/ui/dialogs/cloud_login_dialog.py b/ui/dialogs/cloud_login_dialog.py index c7f5fa21..bdd8e6d3 100644 --- a/ui/dialogs/cloud_login_dialog.py +++ b/ui/dialogs/cloud_login_dialog.py @@ -27,20 +27,6 @@ class CloudLoginDialog(QDialog): login_success = Signal(dict) # Emits account info on success _STYLE_TEMPLATE = """ - QWidget#dialogContainer { - background-color: %background_alt%; - color: %text%; - border: 1px solid %border%; - border-radius: 12px; - } - QLabel#dialogTitle { - color: %text%; - font-size: 15px; - font-weight: bold; - } - QLabel { - color: %text%; - } QPushButton#cloudLoginModeBtn, QPushButton#cloudLoginActionBtn { background-color: %border%; diff --git a/ui/dialogs/edit_media_info_dialog.py b/ui/dialogs/edit_media_info_dialog.py index 758f145d..cf772803 100644 --- a/ui/dialogs/edit_media_info_dialog.py +++ b/ui/dialogs/edit_media_info_dialog.py @@ -36,24 +36,6 @@ class EditMediaInfoDialog(QDialog): tracks_updated = Signal(list) # Emitted when tracks are updated with list of track IDs - _STYLE_TEMPLATE = """ - QWidget#dialogContainer { - background-color: %background_alt%; - color: %text%; - border: 1px solid %border%; - border-radius: 12px; - } - QLabel#dialogTitle { - color: %text%; - font-size: 15px; - font-weight: bold; - } - QLabel { - color: %text%; - font-size: 13px; - } - """ - _PROGRESS_STYLE_TEMPLATE = """ QProgressBar { border: 2px solid %border%; @@ -132,7 +114,6 @@ def _setup_ui(self): title_text = t("edit_media_info_title") self.setWindowTitle(title_text) self.setMinimumWidth(450) - self.setStyleSheet(ThemeManager.instance().get_qss(self._STYLE_TEMPLATE)) # Outer layout with 0 margins — container fills the dialog outer = QVBoxLayout(self) @@ -519,7 +500,6 @@ def get_updated_track_ids(self) -> List[int]: def refresh_theme(self): """Refresh theme when changed.""" - self.setStyleSheet(ThemeManager.instance().get_qss(self._STYLE_TEMPLATE)) self._title_bar_controller.refresh_theme() if self._progress_bar: self._progress_bar.setStyleSheet(ThemeManager.instance().get_qss(self._PROGRESS_STYLE_TEMPLATE)) diff --git a/ui/dialogs/help_dialog.py b/ui/dialogs/help_dialog.py index d3ae5d39..d01e6bf5 100644 --- a/ui/dialogs/help_dialog.py +++ b/ui/dialogs/help_dialog.py @@ -50,26 +50,6 @@ class HelpDialog(QDialog): left: 12px; padding: 0 8px; } - QPushButton { - background-color: %background_hover%; - color: %text%; - border: 1px solid %border%; - border-radius: 6px; - padding: 8px 16px; - font-size: 13px; - } - QPushButton:hover { - background-color: %border%; - border: 1px solid %highlight%; - } - QPushButton#rebuildBtn { - background-color: %highlight%; - color: %background%; - font-weight: bold; - } - QPushButton#rebuildBtn:hover { - background-color: %highlight_hover%; - } QScrollArea { border: none; background-color: transparent; @@ -88,7 +68,7 @@ def __init__(self, parent=None): self.setAttribute(Qt.WidgetAttribute.WA_TranslucentBackground) self.setWindowTitle(t("help")) - self.setMinimumSize(500, 590) + self.setMinimumSize(500, 630) self.setStyleSheet(ThemeManager.instance().get_qss(self._STYLE_TEMPLATE)) self._setup_shadow() @@ -204,6 +184,7 @@ def _setup_ui(self): # Close button close_btn = QPushButton(t("ok")) + close_btn.setProperty("role", "primary") close_btn.clicked.connect(self.accept) close_btn.setFixedWidth(100) close_btn.setCursor(Qt.PointingHandCursor) diff --git a/ui/dialogs/input_dialog.py b/ui/dialogs/input_dialog.py index c4272ed6..03f07903 100644 --- a/ui/dialogs/input_dialog.py +++ b/ui/dialogs/input_dialog.py @@ -69,6 +69,7 @@ def _setup_ui(self, title, label, text): btn_layout.addStretch() cancel_btn = QPushButton(t("cancel")) + cancel_btn.setProperty("role", "cancel") cancel_btn.clicked.connect(self.reject) cancel_btn.setCursor(Qt.CursorShape.PointingHandCursor) btn_layout.addWidget(cancel_btn) diff --git a/ui/dialogs/lyrics_download_dialog.py b/ui/dialogs/lyrics_download_dialog.py index 508fdba7..2a9c438a 100644 --- a/ui/dialogs/lyrics_download_dialog.py +++ b/ui/dialogs/lyrics_download_dialog.py @@ -87,21 +87,6 @@ class LyricsDownloadDialog(QDialog): download_requested = Signal(dict, bool) # Emits (song_info, download_cover) _STYLE_TEMPLATE = """ - QWidget#dialogContainer { - background-color: %background_alt%; - color: %text%; - border: 1px solid %border%; - border-radius: 12px; - } - QLabel#dialogTitle { - color: %text%; - font-size: 15px; - font-weight: bold; - } - QLabel { - color: %text%; - font-size: 13px; - } QListWidget { background-color: %background%; color: %text%; diff --git a/ui/dialogs/lyrics_edit_dialog.py b/ui/dialogs/lyrics_edit_dialog.py index 017638b8..b94e82a2 100644 --- a/ui/dialogs/lyrics_edit_dialog.py +++ b/ui/dialogs/lyrics_edit_dialog.py @@ -31,21 +31,6 @@ class LyricsEditDialog(QDialog): lyrics_saved = Signal(str, str) # Emitted when lyrics are saved (track_path, lyrics) _STYLE_TEMPLATE = """ - QWidget#dialogContainer { - background-color: %background_alt%; - color: %text%; - border: 1px solid %border%; - border-radius: 12px; - } - QLabel#dialogTitle { - color: %text%; - font-size: 15px; - font-weight: bold; - } - QLabel { - color: %text%; - font-size: 13px; - } QTextEdit { background-color: %background%; color: %text%; diff --git a/ui/dialogs/message_dialog.py b/ui/dialogs/message_dialog.py index 7c2cebef..9ac89ae0 100644 --- a/ui/dialogs/message_dialog.py +++ b/ui/dialogs/message_dialog.py @@ -33,12 +33,6 @@ class MessageDialog(QDialog): """Theme-aware frameless message dialog.""" _STYLE_TEMPLATE = """ - QWidget#dialogContainer { - background-color: %background_alt%; - color: %text%; - border: 1px solid %border%; - border-radius: 12px; - } QLabel#msgText { color: %text_secondary%; font-size: 13px; @@ -48,30 +42,6 @@ class MessageDialog(QDialog): background-color: transparent; border: none; } - QPushButton#msgBtn { - background-color: %background_hover%; - color: %text%; - border: 1px solid %border%; - border-radius: 6px; - padding: 8px 20px; - min-width: 80px; - font-weight: bold; - } - QPushButton#msgBtn:hover { - background-color: %border%; - } - QPushButton#msgPrimaryBtn { - background-color: %highlight%; - color: %background%; - border: 1px solid %highlight%; - border-radius: 6px; - padding: 8px 20px; - min-width: 80px; - font-weight: bold; - } - QPushButton#msgPrimaryBtn:hover { - background-color: %highlight_hover%; - } """ _ICON_MAP = { @@ -150,7 +120,7 @@ def _setup_ui(self): def _add_button(self, text, role, is_primary=False): btn = QPushButton(text) - btn.setObjectName("msgPrimaryBtn" if is_primary else "msgBtn") + btn.setProperty("role", "primary" if is_primary else "cancel") btn.setCursor(Qt.CursorShape.PointingHandCursor) btn.clicked.connect(lambda checked, r=role: self._on_clicked(r)) self._btn_layout.addWidget(btn) diff --git a/ui/dialogs/organize_files_dialog.py b/ui/dialogs/organize_files_dialog.py index 2023ad27..89bee9bf 100644 --- a/ui/dialogs/organize_files_dialog.py +++ b/ui/dialogs/organize_files_dialog.py @@ -9,7 +9,7 @@ from PySide6.QtWidgets import ( QDialog, QVBoxLayout, QHBoxLayout, QLabel, QPushButton, QTableWidget, QTableWidgetItem, - QHeaderView, QFileDialog, QProgressBar, + QHeaderView, QFileDialog, QProgressBar, QLineEdit, QWidget, QGraphicsDropShadowEffect, ) from shiboken6 import isValid @@ -56,79 +56,6 @@ def run(self): class OrganizeFilesDialog(QDialog): """Dialog for organizing music files into structured directories.""" - _STYLE_TEMPLATE = """ - QWidget#dialogContainer { - background-color: %background_alt%; - color: %text%; - border: 1px solid %border%; - border-radius: 12px; - } - QLabel#dialogTitle { - color: %text%; - font-size: 15px; - font-weight: bold; - } - QLabel { - color: %text%; - } - QPushButton { - background-color: %border%; - color: %text%; - border: 1px solid %background_hover%; - border-radius: 4px; - padding: 8px 16px; - min-width: 80px; - } - QPushButton:hover { - background-color: %background_hover%; - } - QPushButton:pressed { - background-color: %background_alt%; - } - QPushButton:disabled { - background-color: %background_alt%; - color: %border%; - border-color: %border%; - } - QTableWidget { - background-color: %background_hover%; - color: %text%; - border: 1px solid %background_hover%; - border-radius: 4px; - gridline-color: %border%; - } - QTableWidget::item { - padding: 8px; - border-bottom: 1px solid %border%; - } - QTableWidget::item:hover { - background-color: %border%; - } - QTableWidget::item:selected { - background-color: %highlight%; - color: %background%; - } - QHeaderView::section { - background-color: #383838; - color: %text%; - padding: 10px; - border: none; - border-bottom: 2px solid %background_hover%; - font-weight: bold; - } - QProgressBar { - background-color: %border%; - border: 1px solid %background_hover%; - border-radius: 4px; - text-align: center; - color: %text%; - } - QProgressBar::chunk { - background-color: %highlight%; - border-radius: 3px; - } - """ - def __init__(self, tracks: List[Track], file_org_service, config_manager, parent=None): super().__init__(parent) self.tracks = tracks @@ -142,15 +69,16 @@ def __init__(self, tracks: List[Track], file_org_service, config_manager, parent self.setWindowFlags(Qt.WindowType.Dialog | Qt.FramelessWindowHint) self.setAttribute(Qt.WidgetAttribute.WA_TranslucentBackground) + self.setProperty("shell", True) shadow = QGraphicsDropShadowEffect(self) shadow.setBlurRadius(30) shadow.setOffset(0, 8) shadow.setColor(QColor(0, 0, 0, 80)) self.setGraphicsEffect(shadow) - ThemeManager.instance().register_widget(self) self._setup_ui() self._load_tracks() + ThemeManager.instance().register_widget(self) # If we have a saved directory, update the preview if self.target_dir: @@ -164,9 +92,6 @@ def _setup_ui(self): self.setMinimumSize(900, 632) self.resize(1000, 732) - # Apply dark theme styling - self.setStyleSheet(ThemeManager.instance().get_qss(self._STYLE_TEMPLATE)) - # Root layout for frameless dialog root_layout = QVBoxLayout(self) root_layout.setContentsMargins(0, 0, 0, 0) @@ -188,8 +113,7 @@ def _setup_ui(self): info_label = QLabel( f"{t('selected_tracks')}: {len(self.tracks)}" ) - theme = ThemeManager.instance().current_theme - info_label.setStyleSheet(f"color: {theme.text_secondary};") + info_label.setProperty("secondary", True) layout.addWidget(info_label) # Directory selection @@ -198,17 +122,9 @@ def _setup_ui(self): dir_label.setStyleSheet("font-weight: bold;") dir_layout.addWidget(dir_label) - self.dir_edit = QLabel() - theme = ThemeManager.instance().current_theme - self.dir_edit.setStyleSheet(f""" - QLabel {{ - background-color: {theme.background_hover}; - border: 1px solid {theme.background_hover}; - border-radius: 4px; - padding: 6px 12px; - }} - """) - self.dir_edit.setText(t("select_directory")) + self.dir_edit = QLineEdit() + self.dir_edit.setReadOnly(True) + self.dir_edit.setPlaceholderText(t("select_directory")) dir_layout.addWidget(self.dir_edit, 1) self.browse_btn = QPushButton(t("browse")) @@ -224,6 +140,8 @@ def _setup_ui(self): layout.addWidget(preview_label) self.preview_table = QTableWidget() + self.preview_table.setObjectName("organizeFilesPreviewTable") + self.preview_table.setProperty("variant", "panel") self.preview_table.setColumnCount(4) self.preview_table.setHorizontalHeaderLabels([ t("track"), t("old_path"), t("new_path"), t("lyrics") @@ -243,6 +161,8 @@ def _setup_ui(self): self.preview_table.setMinimumHeight(300) self.preview_table.setSelectionBehavior(QTableWidget.SelectRows) self.preview_table.setFocusPolicy(Qt.NoFocus) + self.preview_table.setShowGrid(False) + self.preview_table.verticalHeader().setVisible(False) layout.addWidget(self.preview_table) # Progress bar @@ -253,20 +173,22 @@ def _setup_ui(self): # Status label self.status_label = QLabel() self.status_label.setAlignment(Qt.AlignCenter) - theme = ThemeManager.instance().current_theme - self.status_label.setStyleSheet(f"color: {theme.text_secondary};") + self.status_label.setProperty("secondary", True) layout.addWidget(self.status_label) # Buttons button_layout = QHBoxLayout() + button_layout.addStretch() self.organize_btn = QPushButton(t("organize")) + self.organize_btn.setProperty("role", "primary") self.organize_btn.setCursor(Qt.PointingHandCursor) self.organize_btn.setEnabled(False) self.organize_btn.clicked.connect(self._organize_files) button_layout.addWidget(self.organize_btn) close_btn = QPushButton(t("cancel")) + close_btn.setProperty("role", "cancel") close_btn.setCursor(Qt.PointingHandCursor) close_btn.clicked.connect(self.reject) button_layout.addWidget(close_btn) @@ -516,18 +438,4 @@ def _stop_organize_thread(self, wait_ms: int = 1000): def refresh_theme(self): """Refresh theme when changed.""" - self.setStyleSheet(ThemeManager.instance().get_qss(self._STYLE_TEMPLATE)) self._title_bar_controller.refresh_theme() - # Update inline styles that use theme colors - theme = ThemeManager.instance().current_theme - if self.status_label: - self.status_label.setStyleSheet(f"color: {theme.text_secondary};") - if self.dir_edit: - self.dir_edit.setStyleSheet(f""" - QLabel {{ - background-color: {theme.background_hover}; - border: 1px solid {theme.background_hover}; - border-radius: 4px; - padding: 6px 12px; - }} - """) diff --git a/ui/dialogs/plugin_management_tab.py b/ui/dialogs/plugin_management_tab.py index 93529c47..f451d78d 100644 --- a/ui/dialogs/plugin_management_tab.py +++ b/ui/dialogs/plugin_management_tab.py @@ -38,38 +38,6 @@ class PluginManagementTab(QWidget): _COLUMN_SOURCE = 2 _COLUMN_ERROR = 3 _COLUMN_ENABLED = 4 - _STYLE_TEMPLATE = """ - QTableWidget#pluginManagementTable { - background-color: %background%; - border: 1px solid %border%; - border-radius: 8px; - gridline-color: %background_hover%; - } - QTableWidget#pluginManagementTable::item { - padding: 8px 10px; - color: %text%; - border: none; - border-bottom: 1px solid %background_hover%; - } - QTableWidget#pluginManagementTable::item:selected { - background-color: %selection%; - color: %text%; - } - QTableWidget#pluginManagementTable QHeaderView::section { - background-color: %background_alt%; - color: %text%; - padding: 10px 12px; - border: none; - border-bottom: 1px solid %border%; - font-weight: bold; - } - QTableWidget#pluginManagementTable QTableCornerButton::section { - background-color: %background_alt%; - border: none; - border-bottom: 1px solid %border%; - } - """ - def __init__(self, plugin_manager, parent=None): super().__init__(parent) self._plugin_manager = plugin_manager @@ -85,6 +53,7 @@ def _setup_ui(self) -> None: layout = QVBoxLayout(self) self._table.setObjectName("pluginManagementTable") + self._table.setProperty("variant", "panel") self._table.setColumnCount(5) self._table.setHorizontalHeaderLabels( [ @@ -195,9 +164,7 @@ def resizeEvent(self, event) -> None: self._table.setRowHeight(row, max(56, self._table.rowHeight(row))) def refresh_theme(self) -> None: - if self._theme_manager is None: - return - self._table.setStyleSheet(self._theme_manager.get_qss(self._STYLE_TEMPLATE)) + return def _resolve_theme_manager(self): try: diff --git a/ui/dialogs/progress_dialog.py b/ui/dialogs/progress_dialog.py index ad57eeff..a68dd7fc 100644 --- a/ui/dialogs/progress_dialog.py +++ b/ui/dialogs/progress_dialog.py @@ -22,48 +22,6 @@ class ProgressDialog(QDialog): canceled = Signal() - _STYLE_TEMPLATE = """ - QWidget#dialogContainer { - background-color: %background_alt%; - color: %text%; - border: 1px solid %border%; - border-radius: 12px; - } - QLabel#dialogTitle { - color: %text%; - font-size: 15px; - font-weight: bold; - } - QLabel { - color: %text%; - font-size: 13px; - } - QProgressBar { - border: 2px solid %border%; - border-radius: 5px; - text-align: center; - color: %text%; - } - QProgressBar::chunk { - background-color: %highlight%; - border-radius: 3px; - } - QPushButton { - background-color: %border%; - color: %text%; - border: none; - padding: 8px 20px; - border-radius: 4px; - } - QPushButton:hover { - background-color: %background_hover%; - } - QPushButton:disabled { - background-color: %border%; - color: %text_secondary%; - } - """ - def __init__(self, title: str, label_text: str, cancel_text: str, minimum: int, maximum: int, parent=None): super().__init__(parent) self._drag_pos = None @@ -75,6 +33,7 @@ def __init__(self, title: str, label_text: str, cancel_text: str, minimum: int, self.setWindowFlags(Qt.WindowType.Dialog | Qt.FramelessWindowHint) self.setAttribute(Qt.WidgetAttribute.WA_TranslucentBackground) self.setWindowModality(Qt.WindowModal) + self.setProperty("shell", True) self._setup_shadow() self._setup_ui(title, label_text, cancel_text) @@ -93,8 +52,6 @@ def _setup_shadow(self): self.setGraphicsEffect(shadow) def _setup_ui(self, title: str, label_text: str, cancel_text: str): - self.setStyleSheet(ThemeManager.instance().get_qss(self._STYLE_TEMPLATE)) - outer = QVBoxLayout(self) outer.setContentsMargins(0, 0, 0, 0) @@ -122,6 +79,7 @@ def _setup_ui(self, title: str, label_text: str, cancel_text: str): # Cancel button self._cancel_button = QPushButton(cancel_text) + self._cancel_button.setProperty("role", "cancel") self._cancel_button.clicked.connect(self._on_cancel) layout.addWidget(self._cancel_button, alignment=Qt.AlignRight) @@ -145,7 +103,6 @@ def wasCanceled(self) -> bool: return self.result() == QDialog.DialogCode.Rejected def refresh_theme(self): - self.setStyleSheet(ThemeManager.instance().get_qss(self._STYLE_TEMPLATE)) self._title_bar_controller.refresh_theme() def resizeEvent(self, event): diff --git a/ui/dialogs/provider_select_dialog.py b/ui/dialogs/provider_select_dialog.py index 0d5c4997..e3e4a09c 100644 --- a/ui/dialogs/provider_select_dialog.py +++ b/ui/dialogs/provider_select_dialog.py @@ -14,47 +14,6 @@ class ProviderSelectDialog(QDialog): """Dialog for selecting cloud provider""" - _STYLE_TEMPLATE = """ - QWidget#dialogContainer { - background-color: %background_alt%; - color: %text%; - border: 1px solid %border%; - border-radius: 12px; - } - QLabel#dialogTitle { - color: %text%; - font-size: 15px; - font-weight: bold; - } - QLabel { - color: %text%; - } - QPushButton { - background-color: %border%; - color: %text%; - border: 1px solid %background_hover%; - border-radius: 8px; - padding: 16px 24px; - font-size: 16px; - } - QPushButton:hover { - background-color: %background_hover%; - border: 1px solid %highlight%; - } - QPushButton:pressed { - background-color: %background_alt%; - } - QPushButton[role="cancel"] { - background-color: %border%; - color: %text%; - padding: 8px 24px; - font-size: 14px; - } - QPushButton[role="cancel"]:hover { - background-color: %background_hover%; - } - """ - def __init__(self, parent=None): super().__init__(parent) self._selected_provider = None @@ -63,11 +22,11 @@ def __init__(self, parent=None): # Make dialog frameless self.setWindowFlags(Qt.WindowType.Dialog | Qt.FramelessWindowHint) self.setAttribute(Qt.WidgetAttribute.WA_TranslucentBackground) + self.setProperty("shell", True) self._setup_shadow() - self.setStyleSheet(ThemeManager.instance().get_qss(self._STYLE_TEMPLATE)) - ThemeManager.instance().register_widget(self) self._setup_ui() + ThemeManager.instance().register_widget(self) def _setup_shadow(self): """Setup drop shadow effect.""" @@ -105,12 +64,16 @@ def _setup_ui(self): # Quark button self._quark_btn = QPushButton(t("quark_drive")) + self._quark_btn.setProperty("role", "primary") + self._quark_btn.setMinimumHeight(56) self._quark_btn.setCursor(Qt.PointingHandCursor) self._quark_btn.clicked.connect(lambda: self._select_provider("quark")) provider_layout.addWidget(self._quark_btn) # Baidu button self._baidu_btn = QPushButton(t("baidu_drive")) + self._baidu_btn.setProperty("role", "primary") + self._baidu_btn.setMinimumHeight(56) self._baidu_btn.setCursor(Qt.PointingHandCursor) self._baidu_btn.clicked.connect(lambda: self._select_provider("baidu")) provider_layout.addWidget(self._baidu_btn) @@ -141,7 +104,6 @@ def get_selected_provider(self) -> str: def refresh_theme(self): """Refresh theme when changed.""" - self.setStyleSheet(ThemeManager.instance().get_qss(self._STYLE_TEMPLATE)) self._title_bar_controller.refresh_theme() def resizeEvent(self, event): diff --git a/ui/dialogs/redownload_dialog.py b/ui/dialogs/redownload_dialog.py index b19d2838..2bddf991 100644 --- a/ui/dialogs/redownload_dialog.py +++ b/ui/dialogs/redownload_dialog.py @@ -23,21 +23,6 @@ class RedownloadDialog(QDialog): """Dialog for selecting audio quality when re-downloading a QQ Music track.""" _STYLE_TEMPLATE = """ - QWidget#dialogContainer { - background-color: %background_alt%; - color: %text%; - border: 1px solid %border%; - border-radius: 12px; - } - QLabel#dialogTitle { - color: %text%; - font-size: 15px; - font-weight: bold; - } - QLabel { - color: %text%; - font-size: 13px; - } QLabel#hintLabel { color: %text_secondary%; font-size: 12px; diff --git a/ui/dialogs/settings_dialog.py b/ui/dialogs/settings_dialog.py index 131174e5..3bbf04c5 100644 --- a/ui/dialogs/settings_dialog.py +++ b/ui/dialogs/settings_dialog.py @@ -716,11 +716,13 @@ def _setup_ui(self): button_layout.addStretch() save_btn = QPushButton(t("save")) + save_btn.setProperty("role", "primary") save_btn.setCursor(Qt.PointingHandCursor) save_btn.clicked.connect(self._save_settings) button_layout.addWidget(save_btn) cancel_btn = QPushButton(t("cancel")) + cancel_btn.setProperty("role", "cancel") cancel_btn.setCursor(Qt.PointingHandCursor) cancel_btn.clicked.connect(self.reject) button_layout.addWidget(cancel_btn) diff --git a/ui/dialogs/sleep_timer_dialog.py b/ui/dialogs/sleep_timer_dialog.py index 8a731bf2..920a0800 100644 --- a/ui/dialogs/sleep_timer_dialog.py +++ b/ui/dialogs/sleep_timer_dialog.py @@ -273,10 +273,6 @@ def _connect_signals(self): # ----------------------- 样式 ----------------------- def _apply_styles(self): style_template = """ -#dialogContainer { background-color: %background_alt%; border-radius: 12px; } -#dialogTitle { font-size: 16px; font-weight: bold; color: %text%; } -QLabel { color: %text%; } - QRadioButton, QCheckBox { color: %text%; spacing: 8px; } QCheckBox::indicator { width: 18px; height: 18px; border-radius: 4px; border: 2px solid %border%; background-color: %background%; } QRadioButton::indicator { width: 18px; height: 18px; border-radius: 9px; border: 2px solid %border%; background-color: %background%; } diff --git a/ui/styles.qss b/ui/styles.qss index 2d913913..3f76baf9 100644 --- a/ui/styles.qss +++ b/ui/styles.qss @@ -29,6 +29,11 @@ QWidget#settingsContainer { border-radius: 12px; } +QWidget#dialogContainer QLabel { + color: %text%; + font-size: 13px; +} + /* Scrollbar */ QScrollBar:vertical { background: transparent; @@ -105,35 +110,59 @@ QPushButton:checked { /* Primary Button */ QPushButton[primary="true"] { background-color: %highlight%; - color: %text%; + color: %background%; + border: 1px solid %highlight%; + border-radius: 6px; + padding: 8px 20px; + min-width: 80px; + font-size: 13px; font-weight: bold; - padding: 12px 24px; - border-radius: 25px; } QPushButton[primary="true"]:hover { background-color: %highlight_hover%; } +QPushButton[primary="true"]:pressed { + background-color: %highlight_hover%; +} + QPushButton[role="primary"] { background-color: %highlight%; - color: %text%; + color: %background%; + border: 1px solid %highlight%; + border-radius: 6px; + padding: 8px 20px; + min-width: 80px; + font-size: 13px; font-weight: bold; - padding: 12px 24px; - border-radius: 25px; } QPushButton[role="primary"]:hover { background-color: %highlight_hover%; } +QPushButton[role="primary"]:pressed { + background-color: %highlight_hover%; +} + QPushButton[role="cancel"] { - background-color: %border%; + background-color: %background_hover%; color: %text%; + border: 1px solid %border%; + border-radius: 6px; + padding: 8px 20px; + min-width: 80px; + font-size: 13px; + font-weight: bold; } QPushButton[role="cancel"]:hover { - background-color: %background_hover%; + background-color: %border%; +} + +QPushButton[role="cancel"]:pressed { + background-color: %border%; } QWidget#titleBar, @@ -241,7 +270,7 @@ QLabel[secondary="true"] { QLabel#titleLabel, QLabel#dialogTitle { color: %text%; - font-size: 14px; + font-size: 15px; font-weight: bold; } @@ -414,6 +443,40 @@ QTableWidget QHeaderView::section { font-weight: bold; } +QTableWidget[variant="panel"] { + background-color: %background%; + border: 1px solid %border%; + border-radius: 8px; + gridline-color: %background_hover%; +} + +QTableWidget[variant="panel"]::item { + padding: 8px 10px; + color: %text%; + border: none; + border-bottom: 1px solid %background_hover%; +} + +QTableWidget[variant="panel"]::item:selected { + background-color: %selection%; + color: %text%; +} + +QTableWidget[variant="panel"] QHeaderView::section { + background-color: %background_alt%; + color: %text%; + padding: 10px 12px; + border: none; + border-bottom: 1px solid %border%; + font-weight: bold; +} + +QTableWidget[variant="panel"] QTableCornerButton::section { + background-color: %background_alt%; + border: none; + border-bottom: 1px solid %border%; +} + /* Sliders */ QSlider::groove:horizontal { height: 4px; diff --git a/ui/views/library_view.py b/ui/views/library_view.py index f90ffc9d..d5dc2882 100644 --- a/ui/views/library_view.py +++ b/ui/views/library_view.py @@ -10,6 +10,7 @@ from PySide6.QtCore import Qt, Signal, QTimer from PySide6.QtWidgets import ( + QDialog, QWidget, QVBoxLayout, QHBoxLayout, @@ -193,6 +194,7 @@ def _setup_connections(self): self._all_tracks_list_view.favorites_toggle_requested.connect(self._on_all_tracks_favorites_toggle) self._all_tracks_list_view.edit_info_requested.connect(self._on_all_tracks_edit_info) self._all_tracks_list_view.download_cover_requested.connect(self._on_all_tracks_download_cover) + self._all_tracks_list_view.organize_files_requested.connect(self._on_all_tracks_organize_files) self._all_tracks_list_view.open_file_location_requested.connect(self._on_all_tracks_open_file_location) self._all_tracks_list_view.remove_from_library_requested.connect(self._on_all_tracks_remove_from_library) self._all_tracks_list_view.delete_file_requested.connect(self._on_all_tracks_delete_file) @@ -210,6 +212,7 @@ def _setup_connections(self): self._favorites_list_view.favorites_toggle_requested.connect(self._on_favorites_favorites_toggle) self._favorites_list_view.edit_info_requested.connect(self._on_all_tracks_edit_info) self._favorites_list_view.download_cover_requested.connect(self._on_all_tracks_download_cover) + self._favorites_list_view.organize_files_requested.connect(self._on_all_tracks_organize_files) self._favorites_list_view.open_file_location_requested.connect(self._on_all_tracks_open_file_location) self._favorites_list_view.remove_from_library_requested.connect(self._on_all_tracks_remove_from_library) self._favorites_list_view.delete_file_requested.connect(self._on_all_tracks_delete_file) @@ -224,6 +227,7 @@ def _setup_connections(self): self._history_list_view.favorites_toggle_requested.connect(self._on_history_favorites_toggle) self._history_list_view.edit_info_requested.connect(self._on_history_edit_info) self._history_list_view.download_cover_requested.connect(self._on_history_download_cover) + self._history_list_view.organize_files_requested.connect(self._on_history_organize_files) self._history_list_view.open_file_location_requested.connect(self._on_history_open_file_location) self._history_list_view.remove_from_library_requested.connect(self._on_history_remove_from_library) self._history_list_view.delete_file_requested.connect(self._on_history_delete_file) @@ -800,6 +804,10 @@ def _on_all_tracks_open_file_location(self, track): """Open file location for a track from the all-tracks list.""" self._on_history_open_file_location(track) + def _on_all_tracks_organize_files(self, tracks: list): + """Open the organize-files dialog from the all-tracks list.""" + self._open_organize_files_dialog(tracks) + def _on_all_tracks_remove_from_library(self, tracks: list): """Remove tracks from the library from the all-tracks list.""" self._on_history_remove_from_library(tracks) @@ -906,6 +914,37 @@ def _on_history_open_file_location(self, track): logger.error(f"Failed to open file location: {e}", exc_info=True) MessageDialog.warning(self, "Error", f"{t('open_file_location_failed')}: {e}") + def _on_history_organize_files(self, tracks: list): + """Open the organize-files dialog from the history list.""" + self._open_organize_files_dialog(tracks) + + def _open_organize_files_dialog(self, tracks: list): + """Open the organize-files dialog for the selected tracks.""" + if not tracks: + return + + from app.application import Application + + app = Application.instance() + if not app or not app.bootstrap or not hasattr(app.bootstrap, 'file_org_service'): + MessageDialog.warning( + self, + t("error"), + t("file_org_service_not_available") + ) + return + + from ui.dialogs.organize_files_dialog import OrganizeFilesDialog + + dialog = OrganizeFilesDialog( + tracks, + app.bootstrap.file_org_service, + self._config, + self, + ) + if dialog.exec() == QDialog.Accepted: + self.refresh() + def _redownload_qq_track(self, track): """Re-download a QQ Music track with quality selection.""" from ui.dialogs.redownload_dialog import RedownloadDialog diff --git a/ui/views/local_tracks_list_view.py b/ui/views/local_tracks_list_view.py index ade4b51b..27164993 100644 --- a/ui/views/local_tracks_list_view.py +++ b/ui/views/local_tracks_list_view.py @@ -523,6 +523,7 @@ class LocalTracksListView(QWidget): favorites_toggle_requested = Signal(list, bool) # (tracks, all_favorited) edit_info_requested = Signal(object) download_cover_requested = Signal(object) + organize_files_requested = Signal(list) open_file_location_requested = Signal(object) remove_from_library_requested = Signal(list) delete_file_requested = Signal(list) @@ -706,6 +707,7 @@ def _connect_context_menu(self): self._context_menu.favorite_toggled.connect(self.favorites_toggle_requested) self._context_menu.edit_info.connect(self.edit_info_requested) self._context_menu.download_cover.connect(self.download_cover_requested) + self._context_menu.organize_files.connect(self.organize_files_requested) self._context_menu.open_file_location.connect(self.open_file_location_requested) self._context_menu.remove_from_library.connect(self.remove_from_library_requested) self._context_menu.delete_file.connect(self.delete_file_requested) diff --git a/ui/widgets/context_menus.py b/ui/widgets/context_menus.py index a45e3265..f73ea915 100644 --- a/ui/widgets/context_menus.py +++ b/ui/widgets/context_menus.py @@ -21,6 +21,7 @@ class LocalTrackContextMenu(QObject): favorite_toggled = Signal(list, bool) # (tracks, all_favorited) edit_info = Signal(object) download_cover = Signal(object) + organize_files = Signal(list) open_file_location = Signal(object) remove_from_library = Signal(list) delete_file = Signal(list) @@ -68,9 +69,12 @@ def build_menu(self, tracks: list, favorite_ids: set, parent_widget=None): a.triggered.connect(lambda: self.download_cover.emit(tracks[0])) # Re-download for QQ Music - if tracks[0].source == TrackSource.QQ: - a = menu.addAction(t("redownload")) - a.triggered.connect(lambda: self.redownload.emit(tracks[0])) + # if tracks[0].source == TrackSource.QQ: + # a = menu.addAction(t("redownload")) + # a.triggered.connect(lambda: self.redownload.emit(tracks[0])) + + a = menu.addAction(t("organize_files")) + a.triggered.connect(lambda: self.organize_files.emit(tracks)) if len(tracks) == 1 and tracks[0].path: a = menu.addAction(t("open_file_location")) diff --git a/ui/widgets/equalizer_widget.py b/ui/widgets/equalizer_widget.py index 6aa91375..bb961dda 100644 --- a/ui/widgets/equalizer_widget.py +++ b/ui/widgets/equalizer_widget.py @@ -547,36 +547,6 @@ def _refresh_capability_ui(self): class EqualizerDialog: """Standalone themed equalizer dialog with custom title bar.""" - _STYLE_TEMPLATE = """ - QWidget#dialogContainer { - background-color: %background_alt%; - color: %text%; - border: 1px solid %border%; - border-radius: 12px; - } - QWidget#dialogTitleBar { - background-color: %background_alt%; - border-top-left-radius: 12px; - border-top-right-radius: 12px; - border-bottom: 1px solid %border%; - } - QLabel#dialogTitle { - color: %text%; - font-size: 14px; - font-weight: bold; - } - QPushButton#dialogCloseBtn { - background: transparent; - border: none; - color: %text_secondary%; - border-radius: 4px; - } - QPushButton#dialogCloseBtn:hover { - background-color: %selection%; - color: %text%; - } - """ - def __init__(self, backend=None, parent=None, config_manager=None): self._dialog = QDialog(parent) self._drag_pos = None @@ -658,7 +628,6 @@ def apply_to_backend(self, backend): self._eq_widget.apply_to_backend(backend) def refresh_theme(self): - self._dialog.setStyleSheet(ThemeManager.instance().get_qss(self._STYLE_TEMPLATE)) self._eq_widget.refresh_theme() def _resize_event(self, event): From ab74cc050f17ca2deb441bc1152c3ac700f62f64 Mon Sep 17 00:00:00 2001 From: Har01d Date: Tue, 7 Apr 2026 20:42:18 +0800 Subject: [PATCH 076/157] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E6=A0=B7=E5=BC=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tests/test_queue_view.py | 11 +++++++++++ ui/views/queue_view.py | 1 + 2 files changed, 12 insertions(+) diff --git a/tests/test_queue_view.py b/tests/test_queue_view.py index 45c001ac..9a594add 100644 --- a/tests/test_queue_view.py +++ b/tests/test_queue_view.py @@ -4,6 +4,7 @@ sys.path.insert(0, str(Path(__file__).parent.parent)) from unittest.mock import patch, MagicMock +from PySide6.QtCore import Qt from PySide6.QtWidgets import QApplication app = QApplication.instance() or QApplication(sys.argv) @@ -118,6 +119,16 @@ def test_queue_view_signals(): assert hasattr(view, 'queue_reordered') +def test_queue_view_items_use_pointing_hand_cursor(): + """Queue list items should show a pointing hand cursor on hover.""" + with patch('system.theme.ThemeManager', MockThemeManager): + from ui.views.queue_view import QueueView + + view = QueueView(make_mock_player(), MagicMock(), MagicMock(), MagicMock()) + + assert view._list_view.viewport().cursor().shape() == Qt.PointingHandCursor + + def test_get_tracks_by_ids_uses_batch_api_when_available(): """QueueView should use batch track lookup when library service supports it.""" from domain.track import Track, TrackSource diff --git a/ui/views/queue_view.py b/ui/views/queue_view.py index c3319dc2..279f08c3 100644 --- a/ui/views/queue_view.py +++ b/ui/views/queue_view.py @@ -961,6 +961,7 @@ def _setup_ui(self): self._list_view = QListView(list_container) self._list_view.setObjectName("queueList") self._list_view.setMouseTracking(True) + self._list_view.viewport().setCursor(Qt.PointingHandCursor) self._list_view.viewport().installEventFilter(self) self._model = QueueTrackModel(self) self._delegate = QueueItemDelegate(self) From d135a88c77952a53bd896fd42c28e7a05eefc6b1 Mon Sep 17 00:00:00 2001 From: Har01d Date: Tue, 7 Apr 2026 20:50:51 +0800 Subject: [PATCH 077/157] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E6=A0=B7=E5=BC=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../builtin/qqmusic/lib/online_music_view.py | 31 +--------------- .../test_qqmusic_theme_integration.py | 29 ++++++++++++++- tests/test_ui/test_plugin_settings_tab.py | 34 ++++++++++++++++++ ui/dialogs/settings_dialog.py | 35 +------------------ ui/styles.qss | 13 +++---- 5 files changed, 71 insertions(+), 71 deletions(-) diff --git a/plugins/builtin/qqmusic/lib/online_music_view.py b/plugins/builtin/qqmusic/lib/online_music_view.py index ab317bb7..2cd00953 100644 --- a/plugins/builtin/qqmusic/lib/online_music_view.py +++ b/plugins/builtin/qqmusic/lib/online_music_view.py @@ -620,21 +620,7 @@ class OnlineMusicView(QWidget): _STYLE_TITLE = "color: %highlight%; font-size: 24px; font-weight: bold;" _STYLE_STATUS_LABEL = "color: %text_secondary%; font-size: 12px;" - _STYLE_TABS = """ - QTabBar::tab { - background: transparent; - color: %text_secondary%; - padding: 8px 20px; - border-bottom: 2px solid transparent; - } - QTabBar::tab:selected { - color: %highlight%; - border-bottom: 2px solid %highlight%; - } - QTabBar::tab:hover { - color: %highlight%; - } - """ + _STYLE_TABS = "" _STYLE_RANKINGS_TITLE = "color: %highlight%; font-size: 16px; font-weight: bold;" _STYLE_FAV_BACK_BTN = """ QPushButton { @@ -1109,21 +1095,6 @@ def _create_type_tabs(self) -> QTabBar: tabs.addTab(t("playlists")) tabs.currentChanged.connect(self._on_tab_changed) - tabs.setStyleSheet(""" - QTabBar::tab { - background: transparent; - color: #808080; - padding: 8px 20px; - border-bottom: 2px solid transparent; - } - QTabBar::tab:selected { - color: #1db954; - border-bottom: 2px solid #1db954; - } - QTabBar::tab:hover { - color: #1db954; - } - """) return tabs diff --git a/tests/test_plugins/test_qqmusic_theme_integration.py b/tests/test_plugins/test_qqmusic_theme_integration.py index 69a166c9..7b1a737f 100644 --- a/tests/test_plugins/test_qqmusic_theme_integration.py +++ b/tests/test_plugins/test_qqmusic_theme_integration.py @@ -36,10 +36,24 @@ def test_plugin_login_dialog_uses_host_owned_shell_and_title_bar_styles(qtbot, m assert dialog._title_bar_controller.close_btn.styleSheet() == "" assert dialog._cancel_button.property("role") == "cancel" -def test_online_music_view_search_input_uses_theme_variant_and_host_popup_helper(qtbot, tmp_path): +def _stub_online_services(monkeypatch): + service = Mock() + service._has_qqmusic_credential.return_value = False + monkeypatch.setattr( + "plugins.builtin.qqmusic.lib.online_music_view.create_online_music_service", + lambda **kwargs: service, + ) + monkeypatch.setattr( + "plugins.builtin.qqmusic.lib.online_music_view.create_online_download_service", + lambda **kwargs: Mock(), + ) + + +def test_online_music_view_search_input_uses_theme_variant_and_host_popup_helper(qtbot, monkeypatch, tmp_path): settings = _plugin_settings(tmp_path) ThemeManager._instance = None ThemeManager.instance(settings) + _stub_online_services(monkeypatch) view = OnlineMusicView(config_manager=settings, qqmusic_service=None) qtbot.addWidget(view) @@ -47,3 +61,16 @@ def test_online_music_view_search_input_uses_theme_variant_and_host_popup_helper assert view._search_input.property("variant") == "search" assert view._search_input.styleSheet() == "" assert view._completer.popup().styleSheet() + + +def test_online_music_view_tabs_use_global_style_and_pointing_cursor(qtbot, monkeypatch, tmp_path): + settings = _plugin_settings(tmp_path) + ThemeManager._instance = None + ThemeManager.instance(settings) + _stub_online_services(monkeypatch) + + view = OnlineMusicView(config_manager=settings, qqmusic_service=None) + qtbot.addWidget(view) + + assert view._tabs.cursor().shape() == view._search_btn.cursor().shape() + assert view._tabs.styleSheet() == "" diff --git a/tests/test_ui/test_plugin_settings_tab.py b/tests/test_ui/test_plugin_settings_tab.py index 5c013af7..f7730a2d 100644 --- a/tests/test_ui/test_plugin_settings_tab.py +++ b/tests/test_ui/test_plugin_settings_tab.py @@ -395,6 +395,40 @@ def test_settings_dialog_includes_plugins_tab(monkeypatch, qtbot): assert "Plugins" in tab_labels or "插件" in tab_labels +def test_settings_dialog_tab_bar_uses_pointing_hand_cursor(monkeypatch, qtbot): + config = Mock() + config.get.return_value = "dark" + config.get_ai_enabled.return_value = False + config.get_ai_base_url.return_value = "" + config.get_ai_api_key.return_value = "" + config.get_ai_model.return_value = "" + config.get_acoustid_enabled.return_value = False + config.get_acoustid_api_key.return_value = "" + config.get_online_music_download_dir.return_value = "data/online_cache" + config.get_cache_cleanup_strategy.return_value = "manual" + config.get_cache_cleanup_auto_enabled.return_value = False + config.get_cache_cleanup_time_days.return_value = 30 + config.get_cache_cleanup_size_mb.return_value = 1000 + config.get_cache_cleanup_count.return_value = 100 + config.get_cache_cleanup_interval_hours.return_value = 1 + config.get_audio_engine.return_value = "mpv" + + fake_manager = Mock() + fake_manager.list_plugins.return_value = [] + fake_manager.registry.settings_tabs.return_value = [] + bootstrap = Mock(plugin_manager=fake_manager) + monkeypatch.setattr("ui.dialogs.settings_dialog.Bootstrap.instance", lambda: bootstrap) + ThemeManager._instance = None + ThemeManager.instance(config) + + dialog = GeneralSettingsDialog(config) + qtbot.addWidget(dialog) + tab_widget = dialog.findChild(QTabWidget) + + assert tab_widget is not None + assert tab_widget.tabBar().cursor().shape() == Qt.PointingHandCursor + + def test_settings_dialog_omits_qqmusic_tab_without_plugin(monkeypatch, qtbot): config = Mock() config.get.return_value = "dark" diff --git a/ui/dialogs/settings_dialog.py b/ui/dialogs/settings_dialog.py index 3bbf04c5..9e8e52a0 100644 --- a/ui/dialogs/settings_dialog.py +++ b/ui/dialogs/settings_dialog.py @@ -39,38 +39,6 @@ def _get_audio_engine_options() -> list[tuple[str, str]]: class GeneralSettingsDialog(QDialog): """Dialog for configuring host and plugin settings.""" - _STYLE_TEMPLATE = """ - QWidget#settingsContainer { - background-color: %background_alt%; - color: %text%; - border: 1px solid %border%; - border-radius: 12px; - } - QLabel#dialogTitle { - color: %text%; - font-size: 15px; - font-weight: bold; - } - QLabel { - color: %text%; - font-size: 13px; - } - QPushButton { - background-color: %background_hover%; - color: %text%; - border: 1px solid %border%; - border-radius: 4px; - padding: 8px 16px; - font-size: 13px; - } - QPushButton:hover { - background-color: %selection%; - } - QPushButton:pressed { - background-color: %background%; - } - """ - def __init__(self, config_manager, parent=None): """ Initialize the AI settings dialog. @@ -108,7 +76,6 @@ def _setup_ui(self): self.setWindowTitle(t("settings")) self.setMinimumWidth(550) theme = ThemeManager.instance().current_theme - self.setStyleSheet(ThemeManager.instance().get_qss(self._STYLE_TEMPLATE)) # Outer layout with 0 margins — container fills the dialog outer = QVBoxLayout(self) @@ -130,6 +97,7 @@ def _setup_ui(self): # Tab widget for AI and AcoustID settings tab_widget = QTabWidget() + tab_widget.tabBar().setCursor(Qt.PointingHandCursor) # AI Settings Tab ai_tab = QWidget() @@ -1652,7 +1620,6 @@ def _reset_theme_colors(self): def refresh_theme(self): """Refresh theme when changed.""" - self.setStyleSheet(ThemeManager.instance().get_qss(self._STYLE_TEMPLATE)) self._title_bar_controller.refresh_theme() def resizeEvent(self, event): diff --git a/ui/styles.qss b/ui/styles.qss index 3f76baf9..0cb1eee8 100644 --- a/ui/styles.qss +++ b/ui/styles.qss @@ -370,24 +370,25 @@ QProgressBar::chunk { QTabWidget::pane { border: 1px solid %border%; + border-top: none; background-color: %background_alt%; } QTabBar::tab { - background-color: %background%; + background-color: transparent; color: %text_secondary%; padding: 8px 16px; - border: 1px solid %border%; + border: none; + border-bottom: 2px solid transparent; } QTabBar::tab:selected { - background-color: %background_hover%; - color: %text%; - border-bottom-color: %background_hover%; + color: %highlight%; + border-bottom-color: %highlight%; } QTabBar::tab:hover:!selected { - background-color: %selection%; + color: %highlight%; } /* Lists */ From 6b5be880d05dee9d3fe2920511b07c841badae88 Mon Sep 17 00:00:00 2001 From: Har01d Date: Tue, 7 Apr 2026 20:55:29 +0800 Subject: [PATCH 078/157] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E6=A0=B7=E5=BC=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- system/theme.py | 48 -------------------------------- ui/dialogs/redownload_dialog.py | 1 - ui/dialogs/sleep_timer_dialog.py | 1 - ui/styles.qss | 30 ++++++++++++++++++++ ui/widgets/equalizer_widget.py | 6 ---- 5 files changed, 30 insertions(+), 56 deletions(-) diff --git a/system/theme.py b/system/theme.py index aa9a92e6..3c1a6347 100644 --- a/system/theme.py +++ b/system/theme.py @@ -322,54 +322,6 @@ def get_qss(self, template: str) -> str: self._qss_cache[cache_key] = result return result - @staticmethod - def get_combobox_style() -> str: - """ - Get unified QComboBox style template. - - Returns: - QSS string with theme tokens for QComboBox styling - """ - return """ - QComboBox { - background-color: %background%; - border: 1px solid %border%; - border-radius: 6px; - padding: 0px 12px; - min-height: 32px; - color: %text%; - min-width: 80px; - } - QComboBox:hover { - background-color: %background_hover%; - border: 1px solid %highlight%; - } - QComboBox::drop-down { - border: none; - width: 30px; - } - QComboBox QAbstractItemView { - background-color: %background_alt%; - border: 1px solid %border%; - color: %text%; - selection-background-color: %highlight%; - selection-color: %background%; - outline: none; - } - QComboBox QAbstractItemView::item { - padding: 6px 10px; - min-height: 20px; - } - QComboBox QAbstractItemView::item:hover { - background-color: %highlight%; - color: %background%; - } - QComboBox QAbstractItemView::item:selected { - background-color: %highlight%; - color: %background%; - } - """ - @staticmethod def get_completer_popup_style() -> str: """Get themed QListView popup style for completers.""" diff --git a/ui/dialogs/redownload_dialog.py b/ui/dialogs/redownload_dialog.py index 2bddf991..bf0f4332 100644 --- a/ui/dialogs/redownload_dialog.py +++ b/ui/dialogs/redownload_dialog.py @@ -27,7 +27,6 @@ class RedownloadDialog(QDialog): color: %text_secondary%; font-size: 12px; } - """ + ThemeManager.get_combobox_style() + """ """ _POPUP_STYLE_TEMPLATE = """ QListView { diff --git a/ui/dialogs/sleep_timer_dialog.py b/ui/dialogs/sleep_timer_dialog.py index 920a0800..e21664cf 100644 --- a/ui/dialogs/sleep_timer_dialog.py +++ b/ui/dialogs/sleep_timer_dialog.py @@ -291,7 +291,6 @@ def _apply_styles(self): QSpinBox::up-button, QSpinBox::down-button { width: 20px; } -""" + ThemeManager.get_combobox_style() + """ QPushButton#presetBtn { background-color: %background%; color: %text%; border: 1px solid %border%; padding: 6px 12px; font-size: 12px; min-width: 60px; } QPushButton#presetBtn:hover { background-color: %background_hover%; border-color: %highlight%; } #statusLabel { color: %highlight%; font-size: 14px; font-weight: bold; padding: 8px; background-color: %background_hover%; border-radius: 6px; } diff --git a/ui/styles.qss b/ui/styles.qss index 0cb1eee8..fa74d6ef 100644 --- a/ui/styles.qss +++ b/ui/styles.qss @@ -341,6 +341,26 @@ QComboBox::drop-down { width: 28px; } +QComboBox { + background-color: %background%; + border: 1px solid %border%; + border-radius: 6px; + padding: 0px 12px; + min-height: 32px; + color: %text%; + min-width: 80px; +} + +QComboBox:hover { + background-color: %background_hover%; + border: 1px solid %highlight%; +} + +QComboBox::drop-down { + border: none; + width: 30px; +} + QComboBox QAbstractItemView { background-color: %background_alt%; border: 1px solid %border%; @@ -355,6 +375,16 @@ QComboBox QAbstractItemView::item { min-height: 20px; } +QComboBox QAbstractItemView::item:hover { + background-color: %highlight%; + color: %background%; +} + +QComboBox QAbstractItemView::item:selected { + background-color: %highlight%; + color: %background%; +} + QProgressBar { background-color: %background_hover%; border: none; diff --git a/ui/widgets/equalizer_widget.py b/ui/widgets/equalizer_widget.py index bb961dda..6775722d 100644 --- a/ui/widgets/equalizer_widget.py +++ b/ui/widgets/equalizer_widget.py @@ -94,8 +94,6 @@ class EqualizerWidget(QWidget): _PRESET_LABEL_STYLE = "color: %text_secondary%;" - _COMBO_STYLE = ThemeManager.get_combobox_style() - _BUTTON_STYLE = """ QPushButton { background-color: %background_alt%; @@ -192,7 +190,6 @@ def _setup_ui(self): for preset in self.PRESETS: self._preset_combo.addItem(t(preset.label_key), preset.key) self._preset_combo.currentIndexChanged.connect(self._on_preset_changed) - self._preset_combo.setStyleSheet(ThemeManager.instance().get_qss(self._COMBO_STYLE)) preset_layout.addWidget(self._preset_combo) preset_layout.addStretch() @@ -234,7 +231,6 @@ def _setup_ui(self): self._effects_preset_combo.addItem(t("effects_preset_theater"), "effects_theater") self._effects_preset_combo.addItem(t("effects_preset_wide"), "effects_wide") self._effects_preset_combo.currentIndexChanged.connect(self._on_effects_preset_changed) - self._effects_preset_combo.setStyleSheet(ThemeManager.instance().get_qss(self._COMBO_STYLE)) top_row.addWidget(self._effects_preset_combo) top_row.addStretch() effects_layout.addLayout(top_row) @@ -483,11 +479,9 @@ def refresh_theme(self): child.setStyleSheet(ThemeManager.instance().get_qss(self._VALUE_LABEL_STYLE)) # Update combo box - self._preset_combo.setStyleSheet(ThemeManager.instance().get_qss(self._COMBO_STYLE)) self._effects_enabled_checkbox.setStyleSheet( ThemeManager.instance().get_qss(self._EFFECTS_ENABLED_CHECKBOX_STYLE) ) - self._effects_preset_combo.setStyleSheet(ThemeManager.instance().get_qss(self._COMBO_STYLE)) # Update sliders for slider in self.findChildren(QSlider): From a116330cb3011041d7b4d94a0f31168b757c1808 Mon Sep 17 00:00:00 2001 From: Har01d Date: Tue, 7 Apr 2026 21:31:25 +0800 Subject: [PATCH 079/157] =?UTF-8?q?=E6=B8=85=E7=90=86QQ=E6=8F=92=E4=BB=B6?= =?UTF-8?q?=E7=8B=AC=E7=AB=8B=E5=8F=91=E5=B8=83=E8=BE=B9=E7=95=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 17 +- plugins/builtin/qqmusic/lib/login_dialog.py | 2 + .../builtin/qqmusic/lib/online_music_view.py | 2 + plugins/builtin/qqmusic/lib/provider.py | 4 +- plugins/builtin/qqmusic/lib/runtime_bridge.py | 86 +++--- plugins/builtin/qqmusic/lib/settings_tab.py | 3 +- plugins/builtin/qqmusic/plugin_main.py | 3 + system/plugins/host_services.py | 90 +++++- system/plugins/installer.py | 46 +++- system/plugins/loader.py | 7 + system/plugins/manager.py | 8 +- system/plugins/qqmusic_runtime_helpers.py | 13 - tests/test_app/test_qqmusic_host_cleanup.py | 30 +- tests/test_plugins/qqmusic_test_context.py | 189 +++++++++++++ .../test_qqmusic_theme_integration.py | 8 +- tests/test_system/test_plugin_import_guard.py | 35 +++ tests/test_system/test_plugin_manager.py | 260 ++++++++++++++++++ tests/test_system/test_plugin_ui_bridge.py | 5 + tests/test_ui/test_online_album_card.py | 8 +- .../test_online_detail_view_actions.py | 5 +- .../test_online_detail_view_thread_cleanup.py | 4 +- tests/test_ui/test_online_music_view_async.py | 17 +- tests/test_ui/test_online_music_view_focus.py | 6 +- tests/test_ui/test_online_tracks_list_view.py | 21 +- .../test_ui/test_online_views_architecture.py | 20 +- tests/test_ui/test_plugin_settings_tab.py | 28 ++ translations/en.json | 1 + translations/zh.json | 1 + ui/dialogs/plugin_management_tab.py | 4 + ui/views/legacy_online_music_view.py | 13 - ui/views/online_detail_view.py | 12 - ui/views/online_grid_view.py | 12 - ui/views/online_music_view.py | 13 - ui/views/online_tracks_list_view.py | 12 - ui/widgets/context_menus.py | 2 - 35 files changed, 801 insertions(+), 186 deletions(-) delete mode 100644 system/plugins/qqmusic_runtime_helpers.py create mode 100644 tests/test_plugins/qqmusic_test_context.py delete mode 100644 ui/views/legacy_online_music_view.py delete mode 100644 ui/views/online_detail_view.py delete mode 100644 ui/views/online_grid_view.py delete mode 100644 ui/views/online_music_view.py delete mode 100644 ui/views/online_tracks_list_view.py diff --git a/README.md b/README.md index 584a414f..5447aa5f 100644 --- a/README.md +++ b/README.md @@ -161,14 +161,15 @@ Dependencies are managed by `pyproject.toml` and installed using uv: 4. Browse cloud folders and click music files to start playback 5. Music will automatically download to the local cache directory -### QQ Music Login - -1. Go to Settings -> QQ Music Configuration -2. Click the "Scan to Login" button -3. Select login method: QQ or WeChat -4. Scan the QR code with mobile QQ or WeChat -5. Confirm login on your phone -6. Credentials will be automatically saved after successful login +### QQ Music Plugin + +1. Open `Settings -> Plugins` +2. Install the QQ Music plugin zip or enable the external QQ Music plugin if it is already installed +3. Open the plugin's own settings tab and click the login button +4. Select login method: QQ or WeChat +5. Scan the QR code with mobile QQ or WeChat +6. Confirm login on your phone +7. Credentials will be saved in the plugin's own settings ### Playback Control diff --git a/plugins/builtin/qqmusic/lib/login_dialog.py b/plugins/builtin/qqmusic/lib/login_dialog.py index 2af05a30..f65d16cb 100644 --- a/plugins/builtin/qqmusic/lib/login_dialog.py +++ b/plugins/builtin/qqmusic/lib/login_dialog.py @@ -21,6 +21,7 @@ from .i18n import get_language, set_language, t from .qr_login import QQMusicQRLogin, QRLoginType, QRCodeLoginEvents from .runtime_bridge import ( + bind_context, current_theme, get_qss, show_information, @@ -264,6 +265,7 @@ class QQMusicLoginDialog(QDialog): def __init__(self, context=None, parent=None): super().__init__(parent) self._context = context + bind_context(context) self._drag_pos = None self.setWindowTitle(t("qqmusic_login_title")) diff --git a/plugins/builtin/qqmusic/lib/online_music_view.py b/plugins/builtin/qqmusic/lib/online_music_view.py index 2cd00953..4b65b122 100644 --- a/plugins/builtin/qqmusic/lib/online_music_view.py +++ b/plugins/builtin/qqmusic/lib/online_music_view.py @@ -42,6 +42,7 @@ from .runtime_bridge import ( IconName, add_track_ids_to_playlist, + bind_context, bootstrap, create_online_download_service, create_online_music_service, @@ -787,6 +788,7 @@ def __init__( self._config = config_manager self._qqmusic_service = qqmusic_service self._plugin_context = plugin_context + bind_context(plugin_context) self._language_connected = False # Create services diff --git a/plugins/builtin/qqmusic/lib/provider.py b/plugins/builtin/qqmusic/lib/provider.py index dc30df26..df7e25ad 100644 --- a/plugins/builtin/qqmusic/lib/provider.py +++ b/plugins/builtin/qqmusic/lib/provider.py @@ -8,7 +8,7 @@ from .client import QQMusicPluginClient from .legacy_config_adapter import QQMusicLegacyConfigAdapter from .online_music_view import OnlineMusicView -from .runtime_bridge import create_qqmusic_service +from .runtime_bridge import bind_context, create_qqmusic_service logger = logging.getLogger(__name__) @@ -19,10 +19,12 @@ class QQMusicOnlineProvider: def __init__(self, context): self._context = context + bind_context(context) self._client = QQMusicPluginClient(context) self._logger = getattr(context, "logger", logger) def create_page(self, context, parent=None): + bind_context(context) self._logger.info("[QQMusic] Creating legacy online music view") config = self._create_legacy_config_adapter(context) credential = config.get_plugin_secret("qqmusic", "credential", "") diff --git a/plugins/builtin/qqmusic/lib/runtime_bridge.py b/plugins/builtin/qqmusic/lib/runtime_bridge.py index 9bc56d55..5908129f 100644 --- a/plugins/builtin/qqmusic/lib/runtime_bridge.py +++ b/plugins/builtin/qqmusic/lib/runtime_bridge.py @@ -1,54 +1,70 @@ from __future__ import annotations -import importlib from typing import Any +_context = None -def _runtime_module(): - return importlib.import_module("system.plugins.plugin_sdk_runtime") +def bind_context(context) -> None: + global _context + if context is not None: + _context = context -def _ui_module(): - return importlib.import_module("system.plugins.plugin_sdk_ui") + +def clear_context(context=None) -> None: + global _context + if context is None or _context is context: + _context = None + + +def _require_context(): + if _context is None: + raise RuntimeError("QQ Music plugin context is not bound") + return _context def register_themed_widget(widget) -> None: - _ui_module().register_themed_widget(widget) + _require_context().ui.theme.register_widget(widget) def get_qss(template: str) -> str: - return _ui_module().get_qss(template) + return _require_context().ui.theme.get_qss(template) def current_theme(): - return _ui_module().current_theme() + return _require_context().ui.theme.current_theme() def get_popup_surface_style() -> str: - return _ui_module().get_popup_surface_style() + return _require_context().ui.theme.get_popup_surface_style() def get_completer_popup_style() -> str: - return _ui_module().get_completer_popup_style() + return _require_context().ui.theme.get_completer_popup_style() def show_information(parent, title: str, message: str) -> None: - _ui_module().information(parent, title, message) + _require_context().ui.dialogs.information(parent, title, message) def show_warning(parent, title: str, message: str) -> None: - _ui_module().warning(parent, title, message) + _require_context().ui.dialogs.warning(parent, title, message) def create_online_music_service(*, config_manager=None, credential_provider=None): - return _runtime_module().create_online_music_service( + return _require_context().runtime.create_online_music_service( config_manager=config_manager, credential_provider=credential_provider, ) -def create_online_download_service(*, config_manager=None, credential_provider=None, online_music_service=None): - return _runtime_module().create_online_download_service( +def create_online_download_service( + *, + config_manager=None, + credential_provider=None, + online_music_service=None, +): + return _require_context().runtime.create_online_download_service( config_manager=config_manager, credential_provider=credential_provider, online_music_service=online_music_service, @@ -56,7 +72,7 @@ def create_online_download_service(*, config_manager=None, credential_provider=N def get_icon(name, color, size: int = 16): - return _runtime_module().get_icon(name, color, size) + return _require_context().runtime.get_icon(name, color, size) class IconName: @@ -65,67 +81,71 @@ class IconName: def image_cache_get(url: str): - return _runtime_module().image_cache_get(url) + return _require_context().runtime.image_cache_get(url) def image_cache_set(url: str, image_data: bytes): - return _runtime_module().image_cache_set(url, image_data) + return _require_context().runtime.image_cache_set(url, image_data) def image_cache_path(url: str): - return _runtime_module().image_cache_path(url) + return _require_context().runtime.image_cache_path(url) def http_get_content(url: str, *, timeout: int, headers: dict[str, str] | None = None): - return _runtime_module().http_get_content(url, timeout=timeout, headers=headers) + return _require_context().runtime.http_get_content( + url, + timeout=timeout, + headers=headers, + ) def cover_pixmap_cache_initialize() -> None: - _runtime_module().cover_pixmap_cache_initialize() + _require_context().runtime.cover_pixmap_cache_initialize() def cover_pixmap_cache_get(cache_key: str): - return _runtime_module().cover_pixmap_cache_get(cache_key) + return _require_context().runtime.cover_pixmap_cache_get(cache_key) def cover_pixmap_cache_set(cache_key: str, pixmap) -> None: - _runtime_module().cover_pixmap_cache_set(cache_key, pixmap) + _require_context().runtime.cover_pixmap_cache_set(cache_key, pixmap) def bootstrap(): - return _runtime_module().bootstrap() + return _require_context().runtime.bootstrap() def library_service(): - return _runtime_module().library_service() + return _require_context().runtime.library_service() def favorites_service(): - return _runtime_module().favorites_service() + return _require_context().runtime.favorites_service() def favorite_mids_from_library() -> set[str]: - return _runtime_module().favorite_mids_from_library() + return _require_context().runtime.favorite_mids_from_library() def remove_library_favorite_by_mid(mid: str) -> bool: - return _runtime_module().remove_library_favorite_by_mid(mid) + return _require_context().runtime.remove_library_favorite_by_mid(mid) def add_requests_to_favorites(requests: list[Any]) -> list[int]: - return _runtime_module().add_requests_to_favorites(requests) + return _require_context().runtime.add_requests_to_favorites(requests) def add_requests_to_playlist(parent, requests: list[Any], log_prefix: str) -> list[int]: - return _runtime_module().add_requests_to_playlist(parent, requests, log_prefix) + return _require_context().runtime.add_requests_to_playlist(parent, requests, log_prefix) def add_track_ids_to_playlist(parent, track_ids: list[int], log_prefix: str) -> None: - _runtime_module().add_track_ids_to_playlist(parent, track_ids, log_prefix) + _require_context().runtime.add_track_ids_to_playlist(parent, track_ids, log_prefix) def event_bus(): - return _runtime_module().event_bus() + return _require_context().runtime.event_bus() def create_qqmusic_service(credential): @@ -137,6 +157,8 @@ def create_qqmusic_service(credential): def create_qqmusic_login_dialog(context=None, parent=None): from .login_dialog import QQMusicLoginDialog + if context is not None: + bind_context(context) return QQMusicLoginDialog(context, parent) diff --git a/plugins/builtin/qqmusic/lib/settings_tab.py b/plugins/builtin/qqmusic/lib/settings_tab.py index 97ffe90f..74bed830 100644 --- a/plugins/builtin/qqmusic/lib/settings_tab.py +++ b/plugins/builtin/qqmusic/lib/settings_tab.py @@ -19,7 +19,7 @@ from .common import get_quality_label_key, get_selectable_qualities from .i18n import get_language, set_language, t from .login_dialog import QQMusicLoginDialog -from .runtime_bridge import current_theme as sdk_current_theme, register_themed_widget +from .runtime_bridge import bind_context, current_theme as sdk_current_theme, register_themed_widget logger = logging.getLogger(__name__) @@ -129,6 +129,7 @@ class QQMusicSettingsTab(QWidget): def __init__(self, context, parent=None): super().__init__(parent) self._context = context + bind_context(context) self._language_connected = False self._verify_thread: Optional[VerifyLoginThread] = None self._loading_settings = False diff --git a/plugins/builtin/qqmusic/plugin_main.py b/plugins/builtin/qqmusic/plugin_main.py index 0d529a61..4c819b68 100644 --- a/plugins/builtin/qqmusic/plugin_main.py +++ b/plugins/builtin/qqmusic/plugin_main.py @@ -10,6 +10,7 @@ from .lib.i18n import get_language, set_language, t from .lib.lyrics_source import QQMusicLyricsPluginSource from .lib.provider import QQMusicOnlineProvider +from .lib.runtime_bridge import bind_context, clear_context from .lib.settings_tab import QQMusicSettingsTab logger = logging.getLogger(__name__) @@ -20,6 +21,7 @@ class QQMusicPlugin: plugin_id = "qqmusic" def register(self, context) -> None: + bind_context(context) plugin_logger = getattr(context, "logger", None) if plugin_logger is None or not hasattr(plugin_logger, "info"): plugin_logger = logger @@ -75,5 +77,6 @@ def _on_language_changed(language: str) -> None: set_language(language) def unregister(self, context) -> None: + clear_context(context) getattr(context, "logger", logger).info("[QQMusic] Plugin unregistered") return None diff --git a/system/plugins/host_services.py b/system/plugins/host_services.py index 0ff66b55..7286e14c 100644 --- a/system/plugins/host_services.py +++ b/system/plugins/host_services.py @@ -4,6 +4,7 @@ import logging from pathlib import Path +from . import plugin_sdk_runtime from .plugin_sdk_ui import PluginDialogBridgeImpl, PluginThemeBridgeImpl class PluginSettingsBridgeImpl: @@ -70,6 +71,90 @@ def theme(self): @property def dialogs(self): return self._dialogs + + +class PluginRuntimeBridgeImpl: + def create_online_music_service(self, *, config_manager=None, credential_provider=None): + return plugin_sdk_runtime.create_online_music_service( + config_manager=config_manager, + credential_provider=credential_provider, + ) + + def create_online_download_service( + self, + *, + config_manager=None, + credential_provider=None, + online_music_service=None, + ): + return plugin_sdk_runtime.create_online_download_service( + config_manager=config_manager, + credential_provider=credential_provider, + online_music_service=online_music_service, + ) + + def get_icon(self, name, color, size: int = 16): + return plugin_sdk_runtime.get_icon(name, color, size) + + def image_cache_get(self, url: str): + return plugin_sdk_runtime.image_cache_get(url) + + def image_cache_set(self, url: str, image_data: bytes): + return plugin_sdk_runtime.image_cache_set(url, image_data) + + def image_cache_path(self, url: str): + return plugin_sdk_runtime.image_cache_path(url) + + def http_get_content( + self, + url: str, + *, + timeout: int, + headers: dict[str, str] | None = None, + ): + return plugin_sdk_runtime.http_get_content( + url, + timeout=timeout, + headers=headers, + ) + + def cover_pixmap_cache_initialize(self) -> None: + plugin_sdk_runtime.cover_pixmap_cache_initialize() + + def cover_pixmap_cache_get(self, cache_key: str): + return plugin_sdk_runtime.cover_pixmap_cache_get(cache_key) + + def cover_pixmap_cache_set(self, cache_key: str, pixmap) -> None: + plugin_sdk_runtime.cover_pixmap_cache_set(cache_key, pixmap) + + def bootstrap(self): + return plugin_sdk_runtime.bootstrap() + + def library_service(self): + return plugin_sdk_runtime.library_service() + + def favorites_service(self): + return plugin_sdk_runtime.favorites_service() + + def favorite_mids_from_library(self) -> set[str]: + return plugin_sdk_runtime.favorite_mids_from_library() + + def remove_library_favorite_by_mid(self, mid: str) -> bool: + return plugin_sdk_runtime.remove_library_favorite_by_mid(mid) + + def add_requests_to_favorites(self, requests): + return plugin_sdk_runtime.add_requests_to_favorites(requests) + + def add_requests_to_playlist(self, parent, requests, log_prefix: str): + return plugin_sdk_runtime.add_requests_to_playlist(parent, requests, log_prefix) + + def add_track_ids_to_playlist(self, parent, track_ids, log_prefix: str) -> None: + plugin_sdk_runtime.add_track_ids_to_playlist(parent, track_ids, log_prefix) + + def event_bus(self): + return plugin_sdk_runtime.event_bus() + + class PluginServiceBridgeImpl: def __init__(self, plugin_id: str, registry, media_bridge) -> None: self._plugin_id = plugin_id @@ -115,7 +200,7 @@ def build(self, manifest): self._bootstrap.playback_service, self._bootstrap.library_service, ) - return PluginContext( + context = PluginContext( plugin_id=plugin_id, manifest=manifest, logger=logging.getLogger(f"plugin.{plugin_id}"), @@ -127,6 +212,8 @@ def build(self, manifest): ui=PluginUiBridgeImpl(plugin_id, registry), services=PluginServiceBridgeImpl(plugin_id, registry, media_bridge), ) + object.__setattr__(context, "runtime", PluginRuntimeBridgeImpl()) + return context from .media_bridge import PluginMediaBridge @@ -134,6 +221,7 @@ def build(self, manifest): __all__ = [ "BootstrapPluginContextFactory", "PluginMediaBridge", + "PluginRuntimeBridgeImpl", "PluginServiceBridgeImpl", "PluginSettingsBridgeImpl", "PluginStorageBridgeImpl", diff --git a/system/plugins/installer.py b/system/plugins/installer.py index 15320065..effbd5d2 100644 --- a/system/plugins/installer.py +++ b/system/plugins/installer.py @@ -21,19 +21,47 @@ } +def _import_roots_from_node(node: ast.AST) -> list[str]: + if isinstance(node, ast.Import): + return [alias.name.split(".")[0] for alias in node.names] + if isinstance(node, ast.ImportFrom): + if node.level and node.level > 0: + return [] + if not node.module: + return [] + return [node.module.split(".")[0]] + if not isinstance(node, ast.Call): + return [] + + if ( + isinstance(node.func, ast.Attribute) + and isinstance(node.func.value, ast.Name) + and node.func.value.id == "importlib" + and node.func.attr == "import_module" + and node.args + and isinstance(node.args[0], ast.Constant) + and isinstance(node.args[0].value, str) + ): + return [node.args[0].value.split(".")[0]] + + if ( + isinstance(node.func, ast.Name) + and node.func.id == "__import__" + and node.args + and isinstance(node.args[0], ast.Constant) + and isinstance(node.args[0].value, str) + ): + return [node.args[0].value.split(".")[0]] + + return [] + + def audit_plugin_imports(plugin_root: Path) -> None: for py_file in plugin_root.rglob("*.py"): tree = ast.parse(py_file.read_text(encoding="utf-8"), filename=str(py_file)) for node in ast.walk(tree): - if isinstance(node, ast.Import): - names = [alias.name.split(".")[0] for alias in node.names] - elif isinstance(node, ast.ImportFrom): - if node.level and node.level > 0: - continue - if not node.module: - continue - names = [node.module.split(".")[0]] - else: + names = _import_roots_from_node(node) + if not names: continue if any(name in _FORBIDDEN_ROOT_IMPORTS for name in names): diff --git a/system/plugins/loader.py b/system/plugins/loader.py index e011455d..87a37652 100644 --- a/system/plugins/loader.py +++ b/system/plugins/loader.py @@ -14,6 +14,7 @@ from harmony_plugin_api.manifest import PluginManifest from .errors import PluginLoadError +from .installer import audit_plugin_imports logger = logging.getLogger(__name__) _FORBIDDEN_IMPORT_ROOTS = { @@ -79,6 +80,12 @@ def _load_entry_module( ): if manifest is None: manifest = self.read_manifest(plugin_root) + try: + audit_plugin_imports(plugin_root) + except Exception as exc: + raise PluginLoadError( + f"Failed to load plugin '{manifest.id}': {exc}" + ) from exc module_path = plugin_root / manifest.entrypoint if not module_path.exists(): raise PluginLoadError(f"Entrypoint file does not exist: {module_path}") diff --git a/system/plugins/manager.py b/system/plugins/manager.py index 8782aec9..a1288a85 100644 --- a/system/plugins/manager.py +++ b/system/plugins/manager.py @@ -124,7 +124,13 @@ def _is_plugin_root(path: Path) -> bool: and not path.name.endswith(".staging") and not path.name.endswith(".backup") ) - return sorted(roots, key=lambda item: (item[0], item[1].name)) + selected: dict[str, tuple[str, Path]] = {} + for source, plugin_root in sorted(roots, key=lambda item: (item[0], item[1].name)): + manifest = self._loader.read_manifest(plugin_root) + current = selected.get(manifest.id) + if current is None or source == "external": + selected[manifest.id] = (source, plugin_root) + return sorted(selected.values(), key=lambda item: (item[0], item[1].name)) def load_enabled_plugins(self) -> None: roots = self.discover_roots() diff --git a/system/plugins/qqmusic_runtime_helpers.py b/system/plugins/qqmusic_runtime_helpers.py deleted file mode 100644 index 260aa950..00000000 --- a/system/plugins/qqmusic_runtime_helpers.py +++ /dev/null @@ -1,13 +0,0 @@ -from __future__ import annotations - - -def create_qqmusic_service(credential): - from plugins.builtin.qqmusic.lib.legacy.qqmusic_service import QQMusicService - - return QQMusicService(credential) - - -def create_qqmusic_login_dialog(parent=None): - from plugins.builtin.qqmusic.lib.login_dialog import QQMusicLoginDialog - - return QQMusicLoginDialog(parent) diff --git a/tests/test_app/test_qqmusic_host_cleanup.py b/tests/test_app/test_qqmusic_host_cleanup.py index bc5eb8fd..52e52876 100644 --- a/tests/test_app/test_qqmusic_host_cleanup.py +++ b/tests/test_app/test_qqmusic_host_cleanup.py @@ -22,37 +22,26 @@ def test_online_download_service_no_longer_imports_plugin_qqmusic_impl(): assert "plugins.builtin.qqmusic" not in source -def test_online_music_view_is_legacy_compat_shim(): - source = Path("ui/views/online_music_view.py").read_text(encoding="utf-8") - - assert "legacy_online_music_view" in source - assert "Compatibility shim" in source - - -def test_legacy_online_music_view_is_now_a_plugin_compat_shim(): - source = Path("ui/views/legacy_online_music_view.py").read_text(encoding="utf-8") - - assert "plugins.builtin.qqmusic.lib.online_music_view" in source - assert "Compatibility shim" in source - - -def test_host_online_views_are_plugin_compat_shims(): +def test_host_qqmusic_compatibility_view_modules_are_removed(): for relative_path in ( + "ui/views/online_music_view.py", + "ui/views/legacy_online_music_view.py", "ui/views/online_detail_view.py", "ui/views/online_grid_view.py", "ui/views/online_tracks_list_view.py", ): - source = Path(relative_path).read_text(encoding="utf-8") + assert not Path(relative_path).exists(), relative_path + - assert "plugins.builtin.qqmusic.lib" in source - assert "Compatibility shim" in source +def test_host_qqmusic_runtime_helpers_are_removed(): + assert not Path("system/plugins/qqmusic_runtime_helpers.py").exists() def test_plugin_root_view_module_has_been_removed(): assert not Path("plugins/builtin/qqmusic/lib/root_view.py").exists() -def test_plugin_provider_now_uses_legacy_online_music_view_entry(): +def test_plugin_provider_now_uses_plugin_online_music_view_entry(): source = Path("plugins/builtin/qqmusic/lib/provider.py").read_text(encoding="utf-8") assert "from .online_music_view import OnlineMusicView" in source @@ -63,8 +52,7 @@ def test_plugin_provider_now_uses_legacy_online_music_view_entry(): def test_online_track_context_menu_lives_in_plugin_module(): source = Path("ui/widgets/context_menus.py").read_text(encoding="utf-8") - assert "plugins.builtin.qqmusic.lib.context_menus" in source - assert "class OnlineTrackContextMenu" not in source + assert "plugins.builtin.qqmusic.lib.context_menus" not in source def test_qqmusic_plugin_has_private_translation_files(): diff --git a/tests/test_plugins/qqmusic_test_context.py b/tests/test_plugins/qqmusic_test_context.py new file mode 100644 index 00000000..30112857 --- /dev/null +++ b/tests/test_plugins/qqmusic_test_context.py @@ -0,0 +1,189 @@ +from __future__ import annotations + +from types import SimpleNamespace +from unittest.mock import Mock + +from PySide6.QtGui import QIcon + +from plugins.builtin.qqmusic.lib.runtime_bridge import bind_context +from system.theme import ThemeManager + + +class _Signal: + def __init__(self): + self._callbacks = [] + + def connect(self, callback): + self._callbacks.append(callback) + + def disconnect(self, callback): + if callback in self._callbacks: + self._callbacks.remove(callback) + + def emit(self, *args, **kwargs): + for callback in list(self._callbacks): + callback(*args, **kwargs) + + +class _ThemeBridge: + def __init__(self, theme_manager=None): + self._theme_manager = theme_manager + + def _manager(self): + return self._theme_manager or ThemeManager.instance() + + def register_widget(self, widget) -> None: + manager = self._manager() + if hasattr(manager, "register_widget"): + manager.register_widget(widget) + + def get_qss(self, template: str) -> str: + manager = self._manager() + if hasattr(manager, "get_qss"): + return manager.get_qss(template) + return template + + def current_theme(self): + theme = getattr(self._manager(), "current_theme", None) + if theme is None: + return None + if hasattr(theme, "background") or hasattr(theme, "text"): + return theme + return theme() if callable(theme) else theme + + def get_popup_surface_style(self) -> str: + manager = self._manager() + getter = getattr(manager, "get_themed_popup_surface_style", None) + if callable(getter): + value = getter() + return value if isinstance(value, str) else "" + return "" + + def get_completer_popup_style(self) -> str: + manager = self._manager() + getter = getattr(manager, "get_themed_completer_popup_style", None) + if callable(getter): + value = getter() + return value if isinstance(value, str) else "" + return "" + + +class _DialogBridge: + def information(self, *_args, **_kwargs): + return None + + def warning(self, *_args, **_kwargs): + return None + + def question(self, *_args, **_kwargs): + return None + + def critical(self, *_args, **_kwargs): + return None + + def setup_title_bar(self, *_args, **_kwargs): + return None + + +class _RuntimeBridge: + def __init__( + self, + *, + online_service=None, + download_service=None, + event_bus=None, + bootstrap=None, + ) -> None: + self._online_service = online_service or SimpleNamespace( + _has_qqmusic_credential=lambda: False + ) + self._download_service = download_service or Mock() + self._event_bus = event_bus or SimpleNamespace( + language_changed=_Signal(), + favorite_changed=_Signal(), + ) + self._bootstrap = bootstrap + + def create_online_music_service(self, **_kwargs): + return self._online_service + + def create_online_download_service(self, **_kwargs): + return self._download_service + + def get_icon(self, *_args, **_kwargs): + return QIcon() + + def image_cache_get(self, _url: str): + return None + + def image_cache_set(self, _url: str, _image_data: bytes): + return None + + def image_cache_path(self, _url: str): + return None + + def http_get_content(self, _url: str, **_kwargs): + return None + + def cover_pixmap_cache_initialize(self) -> None: + return None + + def cover_pixmap_cache_get(self, _cache_key: str): + return None + + def cover_pixmap_cache_set(self, _cache_key: str, _pixmap) -> None: + return None + + def bootstrap(self): + return self._bootstrap + + def library_service(self): + return getattr(self._bootstrap, "library_service", None) if self._bootstrap else None + + def favorites_service(self): + return getattr(self._bootstrap, "favorites_service", None) if self._bootstrap else None + + def favorite_mids_from_library(self) -> set[str]: + return set() + + def remove_library_favorite_by_mid(self, _mid: str) -> bool: + return False + + def add_requests_to_favorites(self, _requests): + return [] + + def add_requests_to_playlist(self, _parent, _requests, _log_prefix: str): + return [] + + def add_track_ids_to_playlist(self, _parent, _track_ids, _log_prefix: str) -> None: + return None + + def event_bus(self): + return self._event_bus + + +def bind_test_context( + *, + theme_manager=None, + online_service=None, + download_service=None, + event_bus=None, + bootstrap=None, + language: str = "zh", +): + context = SimpleNamespace( + ui=SimpleNamespace( + theme=_ThemeBridge(theme_manager), + dialogs=_DialogBridge(), + ), + runtime=_RuntimeBridge( + online_service=online_service, + download_service=download_service, + event_bus=event_bus, + bootstrap=bootstrap, + ), + events=SimpleNamespace(language_changed=_Signal()), + language=language, + ) + bind_context(context) + return context diff --git a/tests/test_plugins/test_qqmusic_theme_integration.py b/tests/test_plugins/test_qqmusic_theme_integration.py index 7b1a737f..1dc75bf0 100644 --- a/tests/test_plugins/test_qqmusic_theme_integration.py +++ b/tests/test_plugins/test_qqmusic_theme_integration.py @@ -4,6 +4,7 @@ from plugins.builtin.qqmusic.lib.login_dialog import QQMusicLoginDialog from plugins.builtin.qqmusic.lib.online_music_view import OnlineMusicView from system.theme import ThemeManager +from tests.test_plugins.qqmusic_test_context import bind_test_context def _plugin_settings(tmp_path: Path): @@ -23,6 +24,7 @@ def _plugin_settings(tmp_path: Path): def test_plugin_login_dialog_uses_host_owned_shell_and_title_bar_styles(qtbot, monkeypatch, tmp_path): ThemeManager._instance = None ThemeManager.instance(_plugin_settings(tmp_path)) + bind_test_context() monkeypatch.setattr( "plugins.builtin.qqmusic.lib.login_dialog.QQMusicLoginDialog._start_login", lambda self, login_type=None: None, @@ -53,9 +55,10 @@ def test_online_music_view_search_input_uses_theme_variant_and_host_popup_helper settings = _plugin_settings(tmp_path) ThemeManager._instance = None ThemeManager.instance(settings) + context = bind_test_context() _stub_online_services(monkeypatch) - view = OnlineMusicView(config_manager=settings, qqmusic_service=None) + view = OnlineMusicView(config_manager=settings, qqmusic_service=None, plugin_context=context) qtbot.addWidget(view) assert view._search_input.property("variant") == "search" @@ -67,9 +70,10 @@ def test_online_music_view_tabs_use_global_style_and_pointing_cursor(qtbot, monk settings = _plugin_settings(tmp_path) ThemeManager._instance = None ThemeManager.instance(settings) + context = bind_test_context() _stub_online_services(monkeypatch) - view = OnlineMusicView(config_manager=settings, qqmusic_service=None) + view = OnlineMusicView(config_manager=settings, qqmusic_service=None, plugin_context=context) qtbot.addWidget(view) assert view._tabs.cursor().shape() == view._search_btn.cursor().shape() diff --git a/tests/test_system/test_plugin_import_guard.py b/tests/test_system/test_plugin_import_guard.py index c6fc328c..9fd11000 100644 --- a/tests/test_system/test_plugin_import_guard.py +++ b/tests/test_system/test_plugin_import_guard.py @@ -35,6 +35,32 @@ def test_plugin_import_audit_rejects_host_imports(tmp_path: Path): audit_plugin_imports(plugin_root) +def test_plugin_import_audit_rejects_dynamic_host_imports(tmp_path: Path): + plugin_root = tmp_path / "bad_dynamic_imports" + plugin_root.mkdir() + (plugin_root / "plugin_main.py").write_text( + "import importlib\n" + "\n" + "importlib.import_module('system.plugins.plugin_sdk_runtime')\n", + encoding="utf-8", + ) + + with pytest.raises(Exception): + audit_plugin_imports(plugin_root) + + +def test_plugin_import_audit_rejects_dynamic_dunder_imports(tmp_path: Path): + plugin_root = tmp_path / "bad_dunder_imports" + plugin_root.mkdir() + (plugin_root / "plugin_main.py").write_text( + "__import__('ui.dialogs.message_dialog')\n", + encoding="utf-8", + ) + + with pytest.raises(Exception): + audit_plugin_imports(plugin_root) + + def test_runtime_import_guard_rejects_host_module_import(tmp_path: Path): plugin_root = tmp_path / "bad_runtime" plugin_root.mkdir() @@ -81,3 +107,12 @@ def test_qqmusic_ui_modules_do_not_import_sdk_runtime_modules_directly(): source = path.read_text(encoding="utf-8") assert "from harmony_plugin_api.ui import" not in source assert "from harmony_plugin_api.runtime import" not in source + + +def test_qqmusic_runtime_bridge_does_not_import_host_bridge_modules_by_name(): + source = Path("plugins/builtin/qqmusic/lib/runtime_bridge.py").read_text( + encoding="utf-8" + ) + + assert "system.plugins.plugin_sdk_runtime" not in source + assert "system.plugins.plugin_sdk_ui" not in source diff --git a/tests/test_system/test_plugin_manager.py b/tests/test_system/test_plugin_manager.py index 90a21131..e7fc50ed 100644 --- a/tests/test_system/test_plugin_manager.py +++ b/tests/test_system/test_plugin_manager.py @@ -3,6 +3,9 @@ from types import SimpleNamespace import zipfile +from PySide6.QtGui import QIcon + +from scripts.build_plugin_zip import build_plugin_zip from system.plugins.installer import PluginInstaller from system.plugins.manager import PluginManager from system.plugins.state_store import PluginStateStore @@ -943,6 +946,263 @@ def build(self, manifest): assert "qqmusic" in loaded_ids +def test_external_plugin_overrides_builtin_with_same_id(tmp_path: Path): + builtin_root = tmp_path / "builtin" + external_root = tmp_path / "external" + builtin_plugin = builtin_root / "qqmusic" + external_plugin = external_root / "qqmusic" + builtin_plugin.mkdir(parents=True) + external_plugin.mkdir(parents=True) + + for plugin_root, version, title in ( + (builtin_plugin, "1.0.0", "Builtin QQ Music"), + (external_plugin, "1.1.0", "External QQ Music"), + ): + (plugin_root / "plugin.json").write_text( + json.dumps( + { + "id": "qqmusic", + "name": "QQ Music", + "version": version, + "api_version": "1", + "entrypoint": "plugin_main.py", + "entry_class": "QQMusicPlugin", + "capabilities": ["sidebar"], + "min_app_version": "0.1.0", + } + ), + encoding="utf-8", + ) + (plugin_root / "plugin_main.py").write_text( + "from harmony_plugin_api.registry_types import SidebarEntrySpec\n\n" + "class QQMusicPlugin:\n" + " plugin_id = 'qqmusic'\n" + " def register(self, context):\n" + " context.ui.register_sidebar_entry(\n" + " SidebarEntrySpec(\n" + f" plugin_id='qqmusic', entry_id='qqmusic.sidebar', title='{title}', order=1, icon_name=None, page_factory=lambda _context, _parent: object(),\n" + " )\n" + " )\n" + " def unregister(self, context):\n" + " pass\n", + encoding="utf-8", + ) + + store = PluginStateStore(tmp_path / "state.json") + manager = PluginManager( + builtin_root=builtin_root, + external_root=external_root, + state_store=store, + context_factory=_RegistryContextFactory(None), + ) + manager._context_factory = _RegistryContextFactory(manager.registry) + + manager.load_enabled_plugins() + + listed = manager.list_plugins() + assert [item["id"] for item in listed] == ["qqmusic"] + assert listed[0]["source"] == "external" + assert listed[0]["version"] == "1.1.0" + assert [item.title for item in manager.registry.sidebar_entries()] == ["External QQ Music"] + + +def test_external_only_qqmusic_plugin_loads_without_builtin_root(tmp_path: Path, qtbot): + class _Signal: + def connect(self, _callback): + return None + + def disconnect(self, _callback): + return None + + class _ThemeBridge: + def register_widget(self, _widget): + return None + + def get_qss(self, template: str) -> str: + return template + + def current_theme(self): + return SimpleNamespace( + background="#101010", + background_alt="#1a1a1a", + background_hover="#202020", + text="#ffffff", + text_secondary="#b3b3b3", + highlight="#1db954", + highlight_hover="#1ed760", + border="#404040", + selection="#333333", + ) + + def get_popup_surface_style(self) -> str: + return "" + + def get_completer_popup_style(self) -> str: + return "" + + class _DialogBridge: + def information(self, *_args, **_kwargs): + return None + + def warning(self, *_args, **_kwargs): + return None + + def question(self, *_args, **_kwargs): + return None + + def critical(self, *_args, **_kwargs): + return None + + def setup_title_bar(self, *_args, **_kwargs): + return None + + class _UiBridge: + def __init__(self): + self._sidebar_entries = [] + self._settings_tabs = [] + self.theme = _ThemeBridge() + self.dialogs = _DialogBridge() + + def register_sidebar_entry(self, spec): + self._sidebar_entries.append(spec) + + def register_settings_tab(self, spec): + self._settings_tabs.append(spec) + + class _RuntimeBridge: + def create_online_music_service(self, **_kwargs): + return SimpleNamespace(_has_qqmusic_credential=lambda: False) + + def create_online_download_service(self, **_kwargs): + return SimpleNamespace() + + def get_icon(self, *_args, **_kwargs): + return QIcon() + + def image_cache_get(self, _url: str): + return None + + def image_cache_set(self, _url: str, _image_data: bytes): + return None + + def image_cache_path(self, _url: str): + return None + + def http_get_content(self, _url: str, **_kwargs): + return None + + def cover_pixmap_cache_initialize(self): + return None + + def cover_pixmap_cache_get(self, _cache_key: str): + return None + + def cover_pixmap_cache_set(self, _cache_key: str, _pixmap): + return None + + def bootstrap(self): + return None + + def library_service(self): + return None + + def favorites_service(self): + return None + + def favorite_mids_from_library(self) -> set[str]: + return set() + + def remove_library_favorite_by_mid(self, _mid: str) -> bool: + return False + + def add_requests_to_favorites(self, _requests): + return [] + + def add_requests_to_playlist(self, _parent, _requests, _log_prefix: str): + return [] + + def add_track_ids_to_playlist(self, _parent, _track_ids, _log_prefix: str): + return None + + def event_bus(self): + return SimpleNamespace( + language_changed=_Signal(), + favorite_changed=_Signal(), + ) + + class _ServiceBridge: + def __init__(self): + self.media = SimpleNamespace() + self.lyrics_sources = [] + self.cover_sources = [] + self.artist_cover_sources = [] + self.online_providers = [] + + def register_lyrics_source(self, source): + self.lyrics_sources.append(source) + + def register_cover_source(self, source): + self.cover_sources.append(source) + + def register_artist_cover_source(self, source): + self.artist_cover_sources.append(source) + + def register_online_music_provider(self, provider): + self.online_providers.append(provider) + + class _ExternalOnlyContextFactory: + def __init__(self): + self.ui = _UiBridge() + self.services = _ServiceBridge() + self.runtime = _RuntimeBridge() + + def build(self, manifest): + return SimpleNamespace( + plugin_id=manifest.id, + manifest=manifest, + logger=SimpleNamespace(info=lambda *_args, **_kwargs: None), + http=SimpleNamespace(get=lambda *_args, **_kwargs: None), + events=SimpleNamespace(language_changed=_Signal()), + language="zh", + settings=SimpleNamespace( + get=lambda *_args, **_kwargs: None, + set=lambda *_args, **_kwargs: None, + ), + storage=SimpleNamespace(), + ui=self.ui, + runtime=self.runtime, + services=self.services, + ) + + plugin_root = Path("plugins/builtin/qqmusic") + output_zip = tmp_path / "qqmusic.zip" + build_plugin_zip(plugin_root, output_zip) + + installer = PluginInstaller( + external_root=tmp_path / "external", + temp_root=tmp_path / "temp", + ) + installer.install_zip(output_zip) + + context_factory = _ExternalOnlyContextFactory() + manager = PluginManager( + builtin_root=tmp_path / "builtin-empty", + external_root=tmp_path / "external", + state_store=PluginStateStore(tmp_path / "state.json"), + context_factory=context_factory, + ) + + manager.load_enabled_plugins() + + assert "qqmusic" in manager._loaded_plugins + assert len(context_factory.ui._sidebar_entries) == 1 + assert len(context_factory.ui._settings_tabs) == 1 + page = context_factory.ui._sidebar_entries[0].page_factory(None, None) + qtbot.addWidget(page) + assert page is not None + assert len(context_factory.services.online_providers) == 1 + + def test_discover_roots_ignores_non_plugin_directories(tmp_path: Path): builtin_root = tmp_path / "builtin" builtin_root.mkdir() diff --git a/tests/test_system/test_plugin_ui_bridge.py b/tests/test_system/test_plugin_ui_bridge.py index b7d8b7ae..15c62264 100644 --- a/tests/test_system/test_plugin_ui_bridge.py +++ b/tests/test_system/test_plugin_ui_bridge.py @@ -52,6 +52,11 @@ def test_plugin_context_ui_bridge_exposes_theme_and_dialog_helpers(tmp_path: Pat assert callable(context.ui.dialogs.question) assert callable(context.ui.dialogs.critical) assert callable(context.ui.dialogs.setup_title_bar) + assert callable(context.runtime.create_online_music_service) + assert callable(context.runtime.create_online_download_service) + assert callable(context.runtime.get_icon) + assert callable(context.runtime.http_get_content) + assert callable(context.runtime.event_bus) def test_plugin_context_ui_bridge_exposes_foundation_theme_helpers(tmp_path: Path): diff --git a/tests/test_ui/test_online_album_card.py b/tests/test_ui/test_online_album_card.py index 99ea12fb..0a239d67 100644 --- a/tests/test_ui/test_online_album_card.py +++ b/tests/test_ui/test_online_album_card.py @@ -7,7 +7,8 @@ from PySide6.QtWidgets import QApplication from system.theme import ThemeManager -from ui.views.online_detail_view import OnlineAlbumCard +from plugins.builtin.qqmusic.lib.online_detail_view import OnlineAlbumCard +from tests.test_plugins.qqmusic_test_context import bind_test_context @pytest.fixture(autouse=True) @@ -38,6 +39,7 @@ def qt_app(): def test_online_album_card_has_theme_attributes(mock_config, qt_app): """Test that OnlineAlbumCard has required theme attributes.""" ThemeManager.instance(mock_config) + bind_test_context() test_data = { 'mid': 'test123', @@ -59,6 +61,7 @@ def test_online_album_card_has_theme_attributes(mock_config, qt_app): def test_online_album_card_registered_with_theme_manager(mock_config, qt_app): """Test that OnlineAlbumCard is registered with theme manager.""" tm = ThemeManager.instance(mock_config) + bind_test_context() test_data = { 'mid': 'test123', @@ -80,6 +83,7 @@ def test_online_album_card_registered_with_theme_manager(mock_config, qt_app): def test_online_album_card_theme_change(mock_config, qt_app): """Test that OnlineAlbumCard properly updates on theme change.""" tm = ThemeManager.instance(mock_config) + bind_test_context() test_data = { 'mid': 'test123', @@ -103,6 +107,7 @@ def test_online_album_card_theme_change(mock_config, qt_app): def test_online_album_card_hover_styles(mock_config, qt_app): """Test that OnlineAlbumCard has proper hover styles.""" ThemeManager.instance(mock_config) + bind_test_context() test_data = { 'mid': 'test123', @@ -124,6 +129,7 @@ def test_online_album_card_hover_styles(mock_config, qt_app): def test_online_album_card_refresh_theme(mock_config, qt_app): """Test that refresh_theme method works correctly.""" tm = ThemeManager.instance(mock_config) + bind_test_context() test_data = { 'mid': 'test123', diff --git a/tests/test_ui/test_online_detail_view_actions.py b/tests/test_ui/test_online_detail_view_actions.py index 6c58260b..63859746 100644 --- a/tests/test_ui/test_online_detail_view_actions.py +++ b/tests/test_ui/test_online_detail_view_actions.py @@ -8,7 +8,8 @@ from PySide6.QtWidgets import QApplication from system.theme import ThemeManager -from ui.views.online_detail_view import OnlineDetailView +from plugins.builtin.qqmusic.lib.online_detail_view import OnlineDetailView +from tests.test_plugins.qqmusic_test_context import bind_test_context def _app(): @@ -29,6 +30,7 @@ def test_all_actions_hidden_when_only_one_page(): """All-pages action buttons should be hidden when there is only one page.""" _app() _init_theme_manager() + bind_test_context() view = OnlineDetailView() view._total_pages = 1 @@ -43,6 +45,7 @@ def test_all_actions_visible_when_multiple_pages(): """All-pages action buttons should be visible when there are multiple pages.""" _app() _init_theme_manager() + bind_test_context() view = OnlineDetailView() view._total_pages = 1 diff --git a/tests/test_ui/test_online_detail_view_thread_cleanup.py b/tests/test_ui/test_online_detail_view_thread_cleanup.py index d98aca98..41f00045 100644 --- a/tests/test_ui/test_online_detail_view_thread_cleanup.py +++ b/tests/test_ui/test_online_detail_view_thread_cleanup.py @@ -3,8 +3,8 @@ from types import SimpleNamespace from unittest.mock import MagicMock -import ui.views.online_detail_view as detail_module -from ui.views.online_detail_view import OnlineDetailView +import plugins.builtin.qqmusic.lib.online_detail_view as detail_module +from plugins.builtin.qqmusic.lib.online_detail_view import OnlineDetailView def test_stop_full_cover_loader_uses_cooperative_shutdown(monkeypatch): diff --git a/tests/test_ui/test_online_music_view_async.py b/tests/test_ui/test_online_music_view_async.py index 13ee885e..fb838875 100644 --- a/tests/test_ui/test_online_music_view_async.py +++ b/tests/test_ui/test_online_music_view_async.py @@ -4,8 +4,9 @@ from domain.online_music import OnlineTrack, SearchResult, SearchType from plugins.builtin.qqmusic.lib import i18n as plugin_i18n -from ui.views.online_music_view import OnlineMusicView -import ui.views.online_music_view as online_music_view +from plugins.builtin.qqmusic.lib.online_music_view import OnlineMusicView +import plugins.builtin.qqmusic.lib.online_music_view as online_music_view +from tests.test_plugins.qqmusic_test_context import bind_test_context def _make_view_for_search_callbacks(): @@ -162,7 +163,7 @@ def test_on_login_clicked_clears_plugin_namespaced_credential(): view._config = Mock() view._update_login_status = Mock() - with patch("ui.views.online_music_view.MessageDialog.information"): + with patch("plugins.builtin.qqmusic.lib.online_music_view.MessageDialog.information"): OnlineMusicView._on_login_clicked(view) view._config.set_plugin_setting.assert_any_call("qqmusic", "credential", None) @@ -207,7 +208,7 @@ def __init__(self, credential): self.credential = credential monkeypatch.setattr( - "system.plugins.qqmusic_runtime_helpers.create_qqmusic_service", + "plugins.builtin.qqmusic.lib.online_music_view.create_qqmusic_service", lambda credential: _FakeQQMusicService(credential), ) @@ -251,17 +252,15 @@ def emit(self, value): for cb in list(self._callbacks): cb(value) - events = Mock() - events.language_changed = _Signal() - context = Mock(language="zh", events=events) - with patch("system.theme.ThemeManager.instance", return_value=theme_manager): + context = bind_test_context(theme_manager=theme_manager, language="zh") + context.events.language_changed = _Signal() view = OnlineMusicView(config_manager=config, qqmusic_service=None, plugin_context=context) qtbot.addWidget(view) assert plugin_i18n.get_language() == "zh" - events.language_changed.emit("en") + context.events.language_changed.emit("en") assert plugin_i18n.get_language() == "en" diff --git a/tests/test_ui/test_online_music_view_focus.py b/tests/test_ui/test_online_music_view_focus.py index 68b5fdbd..94d2d5fd 100644 --- a/tests/test_ui/test_online_music_view_focus.py +++ b/tests/test_ui/test_online_music_view_focus.py @@ -5,7 +5,8 @@ from PySide6.QtCore import Qt -from ui.views.online_music_view import OnlineMusicView +from plugins.builtin.qqmusic.lib.online_music_view import OnlineMusicView +from tests.test_plugins.qqmusic_test_context import bind_test_context def test_click_outside_search_input_clears_focus(qtbot): @@ -16,7 +17,8 @@ def test_click_outside_search_input_clears_focus(qtbot): theme_manager.current_theme = MagicMock(highlight="#1db954") with patch("system.theme.ThemeManager.instance", return_value=theme_manager): - view = OnlineMusicView(config_manager=None, qqmusic_service=None) + context = bind_test_context(theme_manager=theme_manager) + view = OnlineMusicView(config_manager=None, qqmusic_service=None, plugin_context=context) view._top_lists_loaded = True # Avoid loading top list workers in this test. qtbot.addWidget(view) view.show() diff --git a/tests/test_ui/test_online_tracks_list_view.py b/tests/test_ui/test_online_tracks_list_view.py index 8b9e4b81..612a07f5 100644 --- a/tests/test_ui/test_online_tracks_list_view.py +++ b/tests/test_ui/test_online_tracks_list_view.py @@ -8,8 +8,9 @@ from PySide6.QtWidgets import QApplication from domain.online_music import OnlineTrack -import ui.views.online_tracks_list_view as online_tracks_list_view -from ui.views.online_tracks_list_view import OnlineTracksListView +import plugins.builtin.qqmusic.lib.online_tracks_list_view as online_tracks_list_view +from plugins.builtin.qqmusic.lib.online_tracks_list_view import OnlineTracksListView +from tests.test_plugins.qqmusic_test_context import bind_test_context def test_online_tracks_cover_hover_starts_timer_on_cover_area(): @@ -28,6 +29,9 @@ def test_online_tracks_cover_hover_starts_timer_on_cover_area(): type(theme_manager).current_theme = PropertyMock(return_value=theme) bus = MagicMock() + bus.favorite_changed = MagicMock() + bus.favorite_changed.connect = MagicMock() + bus.favorite_changed.disconnect = MagicMock() class _MouseEvent: def __init__(self, pos): @@ -36,8 +40,8 @@ def __init__(self, pos): def pos(self): return self._pos - with patch("system.theme.ThemeManager.instance", return_value=theme_manager), \ - patch("system.event_bus.EventBus.instance", return_value=bus): + with patch("system.theme.ThemeManager.instance", return_value=theme_manager): + bind_test_context(theme_manager=theme_manager, event_bus=bus) view = OnlineTracksListView() view.resize(900, 300) view.show() @@ -74,9 +78,12 @@ def test_online_tracks_handle_mouse_leave_is_idempotent_when_idle(): type(theme_manager).current_theme = PropertyMock(return_value=theme) bus = MagicMock() + bus.favorite_changed = MagicMock() + bus.favorite_changed.connect = MagicMock() + bus.favorite_changed.disconnect = MagicMock() - with patch("system.theme.ThemeManager.instance", return_value=theme_manager), \ - patch("system.event_bus.EventBus.instance", return_value=bus): + with patch("system.theme.ThemeManager.instance", return_value=theme_manager): + bind_test_context(theme_manager=theme_manager, event_bus=bus) view = OnlineTracksListView() view.show() app.processEvents() @@ -106,8 +113,6 @@ def cover_service(self): raise RuntimeError("cover_service should not be initialized in worker") bootstrap = _BootstrapStub() - monkeypatch.setattr("app.bootstrap.Bootstrap.instance", lambda: bootstrap) - track = OnlineTrack(mid="mid-1", title="Song", duration=180) assert online_tracks_list_view._resolve_online_cover_path(track) is None diff --git a/tests/test_ui/test_online_views_architecture.py b/tests/test_ui/test_online_views_architecture.py index 2d99bc46..c76ce0e5 100644 --- a/tests/test_ui/test_online_views_architecture.py +++ b/tests/test_ui/test_online_views_architecture.py @@ -4,8 +4,8 @@ from types import SimpleNamespace from unittest.mock import Mock, patch -from ui.views.online_detail_view import OnlineDetailView -from ui.views.online_music_view import OnlineMusicView +from plugins.builtin.qqmusic.lib.online_detail_view import OnlineDetailView +from plugins.builtin.qqmusic.lib.online_music_view import OnlineMusicView from services.playback.playback_service import PlaybackService @@ -28,9 +28,9 @@ def test_online_music_view_add_to_favorites_uses_favorites_service(): library_service=SimpleNamespace(), ) - with patch("app.bootstrap.Bootstrap.instance", return_value=bootstrap): - with patch("ui.views.online_music_view.MessageDialog.information"): - with patch("ui.views.online_music_view.t", return_value="{count}"): + with patch("plugins.builtin.qqmusic.lib.online_music_view.bootstrap", return_value=bootstrap): + with patch("plugins.builtin.qqmusic.lib.online_music_view.MessageDialog.information"): + with patch("plugins.builtin.qqmusic.lib.online_music_view.t", return_value="{count}"): OnlineMusicView._add_selected_to_favorites(view, [track]) bootstrap.favorites_service.add_favorite.assert_called_once_with(track_id=123) @@ -48,7 +48,7 @@ def test_online_music_view_remove_favorite_uses_favorites_service(): library_service=SimpleNamespace(get_track_by_cloud_file_id=Mock(return_value=library_track)), ) - with patch("app.bootstrap.Bootstrap.instance", return_value=bootstrap): + with patch("plugins.builtin.qqmusic.lib.online_music_view.bootstrap", return_value=bootstrap): OnlineMusicView._on_ranking_favorite_toggled(view, track, False) bootstrap.favorites_service.remove_favorite.assert_called_once_with(track_id=321) @@ -64,7 +64,7 @@ def test_online_music_view_remove_favorite_falls_back_to_cloud_file_id(): library_service=SimpleNamespace(get_track_by_cloud_file_id=Mock(return_value=None)), ) - with patch("app.bootstrap.Bootstrap.instance", return_value=bootstrap): + with patch("plugins.builtin.qqmusic.lib.online_music_view.bootstrap", return_value=bootstrap): OnlineMusicView._on_ranking_favorite_toggled(view, track, False) bootstrap.favorites_service.remove_favorite.assert_called_once_with(cloud_file_id="m-fallback") @@ -83,9 +83,9 @@ def test_online_detail_view_favorites_flow_uses_favorites_service(): library_service=SimpleNamespace(get_track_by_cloud_file_id=Mock(return_value=library_track)), ) - with patch("app.bootstrap.Bootstrap.instance", return_value=bootstrap): - with patch("ui.views.online_detail_view.MessageDialog.information"): - with patch("ui.views.online_detail_view.t", return_value="{count}"): + with patch("plugins.builtin.qqmusic.lib.online_detail_view.bootstrap", return_value=bootstrap): + with patch("plugins.builtin.qqmusic.lib.online_detail_view.show_information"): + with patch("plugins.builtin.qqmusic.lib.online_detail_view.t", return_value="{count}"): OnlineDetailView._add_tracks_to_favorites(view, [track]) OnlineDetailView._remove_track_from_favorites(view, track) diff --git a/tests/test_ui/test_plugin_settings_tab.py b/tests/test_ui/test_plugin_settings_tab.py index f7730a2d..4f62fe7d 100644 --- a/tests/test_ui/test_plugin_settings_tab.py +++ b/tests/test_ui/test_plugin_settings_tab.py @@ -74,6 +74,13 @@ def _build_dialog_config(store: dict) -> Mock: return config +def _init_theme_manager(): + config = Mock() + config.get.return_value = "dark" + ThemeManager._instance = None + ThemeManager.instance(config) + + def _plugin_table(widget: PluginManagementTab) -> QTableWidget: table = widget.findChild(QTableWidget) assert table is not None @@ -106,6 +113,7 @@ def _plugin_row_text(widget: PluginManagementTab, index: int) -> str: def test_plugin_management_tab_shows_plugin_rows(qtbot): + _init_theme_manager() manager = Mock() manager.list_plugins.return_value = [ { @@ -139,6 +147,7 @@ def test_plugin_management_tab_shows_plugin_rows(qtbot): def test_plugin_management_tab_shows_load_errors_in_custom_rows(qtbot): + _init_theme_manager() manager = Mock() manager.list_plugins.return_value = [ { @@ -159,6 +168,7 @@ def test_plugin_management_tab_shows_load_errors_in_custom_rows(qtbot): def test_plugin_management_tab_grows_row_height_for_wrapped_text(qtbot): + _init_theme_manager() manager = Mock() manager.list_plugins.return_value = [ { @@ -191,6 +201,7 @@ def test_plugin_management_tab_grows_row_height_for_wrapped_text(qtbot): def test_plugin_management_tab_localizes_plugin_sources(qtbot): set_language("zh") + _init_theme_manager() manager = Mock() manager.list_plugins.return_value = [ { @@ -228,6 +239,7 @@ def test_plugin_management_tab_localizes_plugin_sources(qtbot): def test_plugin_management_tab_localizes_version_header(qtbot): set_language("zh") + _init_theme_manager() manager = Mock() manager.list_plugins.return_value = [] @@ -255,6 +267,22 @@ def test_plugin_management_tab_uses_global_panel_table_variant(qtbot): assert table.styleSheet() == "" +def test_plugin_management_tab_shows_install_safety_warning(qtbot): + manager = Mock() + manager.list_plugins.return_value = [] + + widget = PluginManagementTab(manager) + qtbot.addWidget(widget) + + warning_labels = [ + label.text() + for label in widget.findChildren(QLabel) + if "trusted" in label.text().lower() or "受信任" in label.text() + ] + + assert warning_labels + + def test_settings_dialog_footer_cancel_button_uses_foundation_cancel_role(monkeypatch, qtbot): config = Mock() config.get.return_value = "dark" diff --git a/translations/en.json b/translations/en.json index bba841f5..6f153afa 100644 --- a/translations/en.json +++ b/translations/en.json @@ -318,6 +318,7 @@ "plugins_tab": "Plugins", "plugins_install_zip": "Install Zip", "plugins_install_url": "Install URL", + "plugins_install_warning": "Only install plugins from trusted sources. Plugins run trusted Python code inside the app.", "plugins_load_error": "Load Error", "plugins_enabled": "Enabled", "plugins_disabled": "Disabled", diff --git a/translations/zh.json b/translations/zh.json index 962da14d..5fa39da8 100644 --- a/translations/zh.json +++ b/translations/zh.json @@ -318,6 +318,7 @@ "plugins_tab": "插件", "plugins_install_zip": "安装 Zip", "plugins_install_url": "在线安装", + "plugins_install_warning": "只安装来自受信任来源的插件。插件会在应用内执行受信任的 Python 代码。", "plugins_load_error": "加载错误", "plugins_enabled": "已启用", "plugins_disabled": "已禁用", diff --git a/ui/dialogs/plugin_management_tab.py b/ui/dialogs/plugin_management_tab.py index f451d78d..2e1a2ae7 100644 --- a/ui/dialogs/plugin_management_tab.py +++ b/ui/dialogs/plugin_management_tab.py @@ -88,6 +88,10 @@ def _setup_ui(self) -> None: layout.addWidget(self._table) + warning_label = QLabel(t("plugins_install_warning"), self) + warning_label.setWordWrap(True) + layout.addWidget(warning_label) + controls = QHBoxLayout() self._url_input.setPlaceholderText("https://example.com/plugin.zip") install_zip_btn = QPushButton(t("plugins_install_zip"), self) diff --git a/ui/views/legacy_online_music_view.py b/ui/views/legacy_online_music_view.py deleted file mode 100644 index 00f140e0..00000000 --- a/ui/views/legacy_online_music_view.py +++ /dev/null @@ -1,13 +0,0 @@ -""" -Compatibility shim for the retired QQ Music legacy page. - -The concrete implementation now lives in -`plugins.builtin.qqmusic.lib.online_music_view` so host-side QQ code can be -retired while preserving old imports and tests. -""" - -import sys - -from plugins.builtin.qqmusic.lib import online_music_view as _online_music_view - -sys.modules[__name__] = _online_music_view diff --git a/ui/views/online_detail_view.py b/ui/views/online_detail_view.py deleted file mode 100644 index fba6b0df..00000000 --- a/ui/views/online_detail_view.py +++ /dev/null @@ -1,12 +0,0 @@ -""" -Compatibility shim for the QQ Music online detail view. - -The concrete implementation now lives in -`plugins.builtin.qqmusic.lib.online_detail_view`. -""" - -import sys - -from plugins.builtin.qqmusic.lib import online_detail_view as _online_detail_view - -sys.modules[__name__] = _online_detail_view diff --git a/ui/views/online_grid_view.py b/ui/views/online_grid_view.py deleted file mode 100644 index e660c75e..00000000 --- a/ui/views/online_grid_view.py +++ /dev/null @@ -1,12 +0,0 @@ -""" -Compatibility shim for the QQ Music online grid view. - -The concrete implementation now lives in -`plugins.builtin.qqmusic.lib.online_grid_view`. -""" - -import sys - -from plugins.builtin.qqmusic.lib import online_grid_view as _online_grid_view - -sys.modules[__name__] = _online_grid_view diff --git a/ui/views/online_music_view.py b/ui/views/online_music_view.py deleted file mode 100644 index 851a3d77..00000000 --- a/ui/views/online_music_view.py +++ /dev/null @@ -1,13 +0,0 @@ -""" -Compatibility shim for the retired host online music view. - -The concrete implementation now lives in `legacy_online_music_view.py` so the -runtime can make its legacy-only status explicit while keeping older tests and -imports working during the plugin migration. -""" - -import sys - -from . import legacy_online_music_view as _legacy_online_music_view - -sys.modules[__name__] = _legacy_online_music_view diff --git a/ui/views/online_tracks_list_view.py b/ui/views/online_tracks_list_view.py deleted file mode 100644 index 88eee29b..00000000 --- a/ui/views/online_tracks_list_view.py +++ /dev/null @@ -1,12 +0,0 @@ -""" -Compatibility shim for the QQ Music online tracks list view. - -The concrete implementation now lives in -`plugins.builtin.qqmusic.lib.online_tracks_list_view`. -""" - -import sys - -from plugins.builtin.qqmusic.lib import online_tracks_list_view as _online_tracks_list_view - -sys.modules[__name__] = _online_tracks_list_view diff --git a/ui/widgets/context_menus.py b/ui/widgets/context_menus.py index f73ea915..af78d7d7 100644 --- a/ui/widgets/context_menus.py +++ b/ui/widgets/context_menus.py @@ -6,8 +6,6 @@ from PySide6.QtGui import QCursor from PySide6.QtWidgets import QMenu -from domain.track import TrackSource -from plugins.builtin.qqmusic.lib.context_menus import OnlineTrackContextMenu from system.i18n import t From 43c5c388a5fa5e9764271ccd086a9bbe7c6c9ce6 Mon Sep 17 00:00:00 2001 From: Har01d Date: Tue, 7 Apr 2026 21:33:18 +0800 Subject: [PATCH 080/157] doc --- ...6-04-07-qqmusic-externalization-cleanup.md | 309 ++++++++++++++++++ 1 file changed, 309 insertions(+) create mode 100644 docs/superpowers/plans/2026-04-07-qqmusic-externalization-cleanup.md diff --git a/docs/superpowers/plans/2026-04-07-qqmusic-externalization-cleanup.md b/docs/superpowers/plans/2026-04-07-qqmusic-externalization-cleanup.md new file mode 100644 index 00000000..ffdcb610 --- /dev/null +++ b/docs/superpowers/plans/2026-04-07-qqmusic-externalization-cleanup.md @@ -0,0 +1,309 @@ +# QQMusic Externalization Cleanup Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Remove the remaining host-side QQMusic-specific online code so QQ Music can ship and run only as an external plugin. + +**Architecture:** The host keeps only generic plugin runtime, registry, settings, media, and UI bridge capabilities. All QQ Music-specific UI, runtime helpers, and online behavior move behind the plugin package, and the host must still boot and function when `plugins/builtin/qqmusic` is absent. External plugin precedence must be explicit so an installed external `qqmusic` package can replace any bundled copy during transition. + +**Tech Stack:** Python 3.11+, PySide6, pytest, Harmony plugin runtime, `harmony-plugin-api` + +--- + +## File Map + +**Remove or stop referencing** +- `system/plugins/qqmusic_runtime_helpers.py` — legacy host helper that imports QQMusic plugin internals directly +- `ui/views/legacy_online_music_view.py` — QQMusic compatibility shim +- `ui/views/online_detail_view.py` — QQMusic compatibility shim +- `ui/views/online_grid_view.py` — QQMusic compatibility shim +- `ui/views/online_tracks_list_view.py` — QQMusic compatibility shim + +**Modify** +- `system/plugins/manager.py` — resolve duplicate plugin ids and define external-vs-builtin precedence +- `system/plugins/installer.py` — strengthen plugin import audit to reject dynamic host bridge imports +- `system/plugins/loader.py` — align runtime import guard with the stricter audit boundary if needed +- `ui/widgets/context_menus.py` — remove direct dependency on `plugins.builtin.qqmusic` +- `ui/dialogs/plugin_management_tab.py` — surface install safety warning and, if in scope, uninstall entry points for external plugins only +- `README.md` — replace built-in QQ settings assumptions with plugin-based installation and usage + +**Test** +- `tests/test_app/test_qqmusic_host_cleanup.py` +- `tests/test_system/test_plugin_import_guard.py` +- `tests/test_system/test_plugin_manager.py` +- `tests/test_system/test_plugin_packaging.py` +- `tests/test_ui/test_plugin_settings_tab.py` + +### Task 1: Tighten The Plugin Boundary + +**Files:** +- Modify: `system/plugins/installer.py` +- Modify: `system/plugins/loader.py` +- Test: `tests/test_system/test_plugin_import_guard.py` + +- [ ] **Step 1: Add failing tests for dynamic host bridge imports** + +Add a test plugin fixture that uses: + +```python +import importlib + +class BadRuntimePlugin: + plugin_id = "bad-runtime" + + def register(self, context): + importlib.import_module("system.plugins.plugin_sdk_runtime") + + def unregister(self, context): + return None +``` + +and assert install-time audit or runtime load rejects it. Add a second assertion covering the real QQ plugin source so the test fails until `plugins/builtin/qqmusic/lib/runtime_bridge.py` stops importing `system.plugins.*`. + +- [ ] **Step 2: Run the focused guard tests** + +Run: `uv run pytest tests/test_system/test_plugin_import_guard.py -v` + +Expected: FAIL on the new dynamic-import test and/or the QQ plugin boundary assertion. + +- [ ] **Step 3: Harden import auditing** + +Extend `audit_plugin_imports()` in `system/plugins/installer.py` to reject dynamic imports targeting forbidden roots such as: + +```python +importlib.import_module("system.plugins.plugin_sdk_runtime") +__import__("ui.dialogs.message_dialog") +``` + +The simplest acceptable approach is AST checks for string literal arguments on `importlib.import_module(...)` and `__import__(...)`. + +- [ ] **Step 4: Align runtime loading if audit and loader differ** + +If needed, extend `PluginLoader._guard_imports()` so plugin code cannot reach `system`, `ui`, `services`, or similar forbidden roots through absolute imports even when they are executed indirectly during plugin module import. + +- [ ] **Step 5: Re-run guard tests** + +Run: `uv run pytest tests/test_system/test_plugin_import_guard.py -v` + +Expected: PASS. + +- [ ] **Step 6: Commit** + +```bash +git add system/plugins/installer.py system/plugins/loader.py tests/test_system/test_plugin_import_guard.py +git commit -m "收紧插件宿主导入边界" +``` + +### Task 2: Move QQ Plugin Off Host Bridge Internals + +**Files:** +- Modify: `plugins/builtin/qqmusic/lib/runtime_bridge.py` +- Modify: `plugins/builtin/qqmusic/lib/provider.py` +- Modify: `plugins/builtin/qqmusic/lib/settings_tab.py` +- Modify: `plugins/builtin/qqmusic/lib/login_dialog.py` +- Modify: `plugins/builtin/qqmusic/lib/dialog_title_bar.py` +- Test: `tests/test_plugins/test_qqmusic_plugin.py` +- Test: `tests/test_system/test_plugin_import_guard.py` + +- [ ] **Step 1: Write failing tests for SDK-only access** + +Add assertions that QQ plugin runtime and UI code obtain host services from `context.ui`, `context.services`, `context.http`, `context.events`, and plugin-local helpers only. The tests should fail if `runtime_bridge.py` still references `system.plugins.plugin_sdk_runtime` or `system.plugins.plugin_sdk_ui`. + +- [ ] **Step 2: Run QQ plugin boundary tests** + +Run: `uv run pytest tests/test_plugins/test_qqmusic_plugin.py tests/test_system/test_plugin_import_guard.py -v` + +Expected: FAIL until the runtime bridge no longer imports host internals by module path. + +- [ ] **Step 3: Replace the dynamic bridge** + +Refactor `plugins/builtin/qqmusic/lib/runtime_bridge.py` so it is a thin wrapper over plugin context objects instead of `importlib.import_module("system.plugins...")`. The plugin should use the typed bridges already exposed through `PluginContext` where possible, and any missing host capability should be added to `harmony_plugin_api.context` plus `system/plugins/host_services.py` rather than imported from `system.plugins.*` inside the plugin. + +- [ ] **Step 4: Update call sites** + +Adjust provider, login dialog, settings tab, title bar, and any other plugin UI modules to pass explicit `context` or bridge objects down to the places that currently depend on the implicit runtime bridge. + +- [ ] **Step 5: Re-run QQ plugin tests** + +Run: `uv run pytest tests/test_plugins/test_qqmusic_plugin.py tests/test_system/test_plugin_import_guard.py -v` + +Expected: PASS. + +- [ ] **Step 6: Commit** + +```bash +git add plugins/builtin/qqmusic/lib/runtime_bridge.py plugins/builtin/qqmusic/lib/provider.py plugins/builtin/qqmusic/lib/settings_tab.py plugins/builtin/qqmusic/lib/login_dialog.py plugins/builtin/qqmusic/lib/dialog_title_bar.py tests/test_plugins/test_qqmusic_plugin.py tests/test_system/test_plugin_import_guard.py +git commit -m "改造QQ插件宿主桥接方式" +``` + +### Task 3: Remove Host-Side QQ Compatibility Shims + +**Files:** +- Delete: `system/plugins/qqmusic_runtime_helpers.py` +- Delete: `ui/views/legacy_online_music_view.py` +- Delete: `ui/views/online_detail_view.py` +- Delete: `ui/views/online_grid_view.py` +- Delete: `ui/views/online_tracks_list_view.py` +- Modify: `ui/widgets/context_menus.py` +- Test: `tests/test_app/test_qqmusic_host_cleanup.py` + +- [ ] **Step 1: Update the cleanup tests to require full removal** + +Replace assertions that currently expect compatibility shims to exist with assertions that: + +```python +assert not Path("ui/views/legacy_online_music_view.py").exists() +assert not Path("ui/views/online_detail_view.py").exists() +assert not Path("ui/views/online_grid_view.py").exists() +assert not Path("ui/views/online_tracks_list_view.py").exists() +assert not Path("system/plugins/qqmusic_runtime_helpers.py").exists() +``` + +Also add assertions that `ui/widgets/context_menus.py` no longer imports `plugins.builtin.qqmusic`. + +- [ ] **Step 2: Run cleanup tests** + +Run: `uv run pytest tests/test_app/test_qqmusic_host_cleanup.py -v` + +Expected: FAIL because the files and imports still exist. + +- [ ] **Step 3: Remove the host imports** + +Delete the shim/helper files and refactor `ui/widgets/context_menus.py` to provide only host-owned local playlist/library menus. Any QQ-specific online context menu must be instantiated inside the plugin page instead of being imported from host code. + +- [ ] **Step 4: Re-run cleanup tests** + +Run: `uv run pytest tests/test_app/test_qqmusic_host_cleanup.py -v` + +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add ui/widgets/context_menus.py tests/test_app/test_qqmusic_host_cleanup.py +git commit -m "移除宿主QQ兼容遗留代码" +``` + +### Task 4: Make External QQ Plugin Override Bundled Copies + +**Files:** +- Modify: `system/plugins/manager.py` +- Test: `tests/test_system/test_plugin_manager.py` + +- [ ] **Step 1: Add a failing duplicate-id precedence test** + +Create a test with both: + +```text +builtin/qqmusic/plugin.json version 1.0.0 +external/qqmusic/plugin.json version 1.1.0 +``` + +and assert the manager loads exactly one `qqmusic` plugin and that the loaded source is `external`. + +- [ ] **Step 2: Run plugin manager tests** + +Run: `uv run pytest tests/test_system/test_plugin_manager.py -v` + +Expected: FAIL because builtin roots are currently loaded first and duplicate ids are skipped as already loaded. + +- [ ] **Step 3: Implement precedence** + +Change discovery or load planning so duplicate plugin ids are collapsed before loading, with `external` overriding `builtin`. `list_plugins()` should also avoid showing two rows for the same plugin id in that case. + +- [ ] **Step 4: Re-run plugin manager tests** + +Run: `uv run pytest tests/test_system/test_plugin_manager.py -v` + +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add system/plugins/manager.py tests/test_system/test_plugin_manager.py +git commit -m "支持外部插件覆盖内置插件" +``` + +### Task 5: Verify External-Only QQ Plugin Boot + +**Files:** +- Modify: `tests/test_system/test_plugin_packaging.py` +- Modify: `tests/test_system/test_plugin_manager.py` + +- [ ] **Step 1: Add a failing end-to-end external-only test** + +Write a test that: + +1. builds `plugins/builtin/qqmusic` into a zip +2. installs it into a temp external plugin root +3. does not provide any builtin `qqmusic` root +4. loads plugins through `PluginManager` +5. asserts the external plugin registers expected capabilities + +This test should fail if the plugin still depends on removed built-in-only files or host-side QQ shims. + +- [ ] **Step 2: Run the packaging and manager tests** + +Run: `uv run pytest tests/test_system/test_plugin_packaging.py tests/test_system/test_plugin_manager.py -v` + +Expected: FAIL until external-only boot works. + +- [ ] **Step 3: Fix whatever still assumes builtin placement** + +Keep the fix scope narrow. Only touch host/plugin code that still assumes the QQ plugin lives under `plugins/builtin/qqmusic`. + +- [ ] **Step 4: Re-run the external-only tests** + +Run: `uv run pytest tests/test_system/test_plugin_packaging.py tests/test_system/test_plugin_manager.py -v` + +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add tests/test_system/test_plugin_packaging.py tests/test_system/test_plugin_manager.py +git commit -m "验证QQ外部插件独立加载" +``` + +### Task 6: Finish The Distribution Surface + +**Files:** +- Modify: `ui/dialogs/plugin_management_tab.py` +- Modify: `README.md` +- Test: `tests/test_ui/test_plugin_settings_tab.py` + +- [ ] **Step 1: Add failing UI/doc expectations** + +Add focused tests for plugin install UX where appropriate, including a warning that external plugins execute trusted Python code. Update README expectations away from `Settings -> QQ Music Configuration` as a host-owned built-in feature. + +- [ ] **Step 2: Run focused UI tests** + +Run: `uv run pytest tests/test_ui/test_plugin_settings_tab.py -v` + +Expected: FAIL if the new warning or labels are not implemented yet. + +- [ ] **Step 3: Implement the final UX/documentation cleanup** + +Update the plugin management tab to show the install safety warning and, if desired in this scope, add uninstall support for external plugins only. Update README to describe plugin installation, enabling, and QQ Music login through the plugin-provided settings tab. + +- [ ] **Step 4: Re-run UI/doc tests** + +Run: `uv run pytest tests/test_ui/test_plugin_settings_tab.py -v` + +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add ui/dialogs/plugin_management_tab.py README.md tests/test_ui/test_plugin_settings_tab.py +git commit -m "完善插件发布入口与文档" +``` + +## Acceptance Checklist + +- [ ] No host file under `app/`, `services/`, `system/`, `ui/`, `repositories/`, `domain/`, `infrastructure/`, or `utils/` imports `plugins.builtin.qqmusic`. +- [ ] `plugins/builtin/qqmusic` can be zipped, installed externally, and loaded without relying on any host-side QQ compatibility shim. +- [ ] Deleting the builtin QQ plugin from the repository does not break host startup. +- [ ] An external `qqmusic` plugin overrides any bundled plugin with the same id. +- [ ] Plugin audit rejects both static and simple dynamic imports of forbidden host modules. +- [ ] README and settings UI describe QQ Music as a plugin, not a baked-in host feature. From a72af600b56b41852b1703ab64a1733303f55a58 Mon Sep 17 00:00:00 2001 From: Har01d Date: Wed, 8 Apr 2026 15:36:01 +0800 Subject: [PATCH 081/157] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E5=BA=94=E7=94=A8UI?= =?UTF-8?q?=E5=9B=9E=E8=B0=83=E7=AD=BE=E5=90=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/application.py | 2 +- tests/test_app/test_application.py | 24 ++++++++++++++++++++++++ 2 files changed, 25 insertions(+), 1 deletion(-) create mode 100644 tests/test_app/test_application.py diff --git a/app/application.py b/app/application.py index 202452a8..415543b5 100644 --- a/app/application.py +++ b/app/application.py @@ -105,7 +105,7 @@ def set_main_window(self, window): """Set main window.""" self._main_window = window - def _dispatch_to_ui(fn, *args, **kwargs): + def _dispatch_to_ui(self, fn, *args, **kwargs): QTimer.singleShot(0, lambda: fn(*args, **kwargs)) def run(self) -> int: diff --git a/tests/test_app/test_application.py b/tests/test_app/test_application.py new file mode 100644 index 00000000..6b6c6a59 --- /dev/null +++ b/tests/test_app/test_application.py @@ -0,0 +1,24 @@ +from types import SimpleNamespace + +from PySide6.QtWidgets import QApplication + +from app.application import Application + + +def test_dispatch_to_ui_invokes_callback_with_bound_instance(monkeypatch): + qt_app = QApplication.instance() or QApplication([]) + bootstrap = SimpleNamespace() + + monkeypatch.setattr("app.application.Bootstrap.instance", lambda db_path="Harmony.db": bootstrap) + + app = Application(qt_app) + received: list[str] = [] + + monkeypatch.setattr( + "app.application.QTimer.singleShot", + lambda _delay, callback: callback(), + ) + + app._dispatch_to_ui(received.append, "ok") + + assert received == ["ok"] From 2c17580c287d6faf0b570354955fae5ba4c622f8 Mon Sep 17 00:00:00 2001 From: Har01d Date: Wed, 8 Apr 2026 15:36:11 +0800 Subject: [PATCH 082/157] =?UTF-8?q?=E4=BF=AE=E5=A4=8DSingleFlight=E5=AE=8C?= =?UTF-8?q?=E6=88=90=E7=AB=9E=E6=80=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- services/_singleflight.py | 2 +- tests/test_services/test_singleflight.py | 71 ++++++++++++++++++++++++ 2 files changed, 72 insertions(+), 1 deletion(-) create mode 100644 tests/test_services/test_singleflight.py diff --git a/services/_singleflight.py b/services/_singleflight.py index fe9d2f69..89bdfeba 100644 --- a/services/_singleflight.py +++ b/services/_singleflight.py @@ -39,9 +39,9 @@ def do(self, key: Hashable, fn: Callable[[], T]) -> T: except BaseException as exc: state.error = exc finally: + state.event.set() with self._lock: self._calls.pop(key, None) - state.event.set() if state.error is not None: raise state.error diff --git a/tests/test_services/test_singleflight.py b/tests/test_services/test_singleflight.py new file mode 100644 index 00000000..c3abadc7 --- /dev/null +++ b/tests/test_services/test_singleflight.py @@ -0,0 +1,71 @@ +"""Behavioral tests for SingleFlight concurrency guarantees.""" + +from __future__ import annotations + +import threading +import time + +from services._singleflight import SingleFlight + + +def test_do_deduplicates_new_call_arriving_during_leader_completion(): + singleflight = SingleFlight[str]() + before_set = threading.Event() + allow_set = threading.Event() + release_leader = threading.Event() + release_third = threading.Event() + call_count = 0 + count_lock = threading.Lock() + original_event_set = threading.Event.set + + def wrapped_set(event: threading.Event) -> None: + before_set.set() + assert allow_set.wait(timeout=1) + original_event_set(event) + + def work(label: str, release: threading.Event) -> str: + nonlocal call_count + with count_lock: + call_count += 1 + assert release.wait(timeout=1) + return label + + def leader() -> None: + assert singleflight.do("same-key", lambda: work("leader", release_leader)) == "leader" + + def follower(results: list[str]) -> None: + results.append(singleflight.do("same-key", lambda: work("follower", release_third))) + + first_results: list[str] = [] + second_results: list[str] = [] + + leader_thread = threading.Thread(target=leader) + follower_thread = threading.Thread(target=follower, args=(first_results,)) + + leader_thread.start() + time.sleep(0.05) + follower_thread.start() + time.sleep(0.05) + + leader_state = singleflight._calls["same-key"] + monkeypatch = None + + try: + leader_state.event.set = lambda: wrapped_set(leader_state.event) # type: ignore[method-assign] + release_leader.set() + assert before_set.wait(timeout=1) + + third_thread = threading.Thread(target=follower, args=(second_results,)) + third_thread.start() + time.sleep(0.05) + allow_set.set() + release_third.set() + + third_thread.join(timeout=2) + finally: + leader_thread.join(timeout=2) + follower_thread.join(timeout=2) + + assert first_results == ["leader"] + assert second_results == ["leader"] + assert call_count == 1 From 7086ee972ce8eb9a1065448aa8ba0fc9ec73dcce Mon Sep 17 00:00:00 2001 From: Har01d Date: Wed, 8 Apr 2026 15:43:40 +0800 Subject: [PATCH 083/157] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E6=92=AD=E6=94=BE?= =?UTF-8?q?=E7=B4=A2=E5=BC=95=E7=AB=9E=E6=80=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- infrastructure/audio/audio_engine.py | 44 ++++++++++- .../test_audio_engine_play_race.py | 77 +++++++++++++++++++ 2 files changed, 120 insertions(+), 1 deletion(-) create mode 100644 tests/test_infrastructure/test_audio_engine_play_race.py diff --git a/infrastructure/audio/audio_engine.py b/infrastructure/audio/audio_engine.py index 3cef8488..ec652bdd 100644 --- a/infrastructure/audio/audio_engine.py +++ b/infrastructure/audio/audio_engine.py @@ -652,7 +652,8 @@ def play(self): # Load track if not already loaded (outside lock) current_source = self._backend.get_source_path() if current_source != local_path: - self._load_track(current_index) + if not self._load_track_if_match(current_index, item, require_current=True): + return self._backend.play() @@ -1195,6 +1196,47 @@ def _load_track(self, index: int): self._backend.set_source(local_path) self.current_track_changed.emit(item_dict) + def _get_playlist_item_if_match( + self, + index: int, + expected_item: PlaylistItem, + *, + require_current: bool = False, + ) -> Optional[PlaylistItem]: + with self._playlist_lock: + if not (0 <= index < len(self._playlist)): + return None + if require_current and self._current_index != index: + return None + + item = self._playlist[index] + if item is not expected_item: + return None + return item + + def _load_track_if_match( + self, + index: int, + expected_item: PlaylistItem, + *, + require_current: bool = False, + ) -> bool: + item = self._get_playlist_item_if_match( + index, + expected_item, + require_current=require_current, + ) + if item is None: + return False + + item_dict = item.to_dict() + local_path = item.local_path + + self._media_loaded_flag = False + self._backend.set_source(local_path) + self.current_track_changed.emit(item_dict) + return True + def _on_position_changed(self, position_ms: int): """Handle position change.""" self.position_changed.emit(position_ms) diff --git a/tests/test_infrastructure/test_audio_engine_play_race.py b/tests/test_infrastructure/test_audio_engine_play_race.py new file mode 100644 index 00000000..1bcf9220 --- /dev/null +++ b/tests/test_infrastructure/test_audio_engine_play_race.py @@ -0,0 +1,77 @@ +from __future__ import annotations + +import tempfile +import threading +from pathlib import Path +from types import SimpleNamespace + +from domain.playlist_item import PlaylistItem +from domain.track import TrackSource +from infrastructure.audio.audio_engine import PlayerEngine + + +class _FakeBackend: + def __init__(self): + self.set_source_calls: list[str] = [] + self.play_calls = 0 + self._source_path = "" + + def set_source(self, path: str): + self.set_source_calls.append(path) + self._source_path = path + + def play(self): + self.play_calls += 1 + + def cleanup(self): + return None + + def stop(self): + return None + + def get_source_path(self) -> str: + return self._source_path + + +def _build_engine(items: list[PlaylistItem], current_index: int) -> PlayerEngine: + engine = PlayerEngine.__new__(PlayerEngine) + engine._playlist_lock = threading.RLock() + engine._playlist = items + engine._original_playlist = items.copy() + engine._current_index = current_index + engine._play_mode = None + engine._backend = _FakeBackend() + engine._cloud_file_id_to_index = {} + engine.current_track_changed = SimpleNamespace(emit=lambda _x: None) + engine.current_track_pending = SimpleNamespace(emit=lambda _x: None) + engine.track_needs_download = SimpleNamespace(emit=lambda _x: None) + engine.error_occurred = SimpleNamespace(emit=lambda _x: None) + engine.playlist_changed = SimpleNamespace(emit=lambda: None) + engine._media_loaded_flag = False + engine._temp_files = [] + return engine + + +def test_play_does_not_load_replacement_track_after_current_item_changes(): + with tempfile.TemporaryDirectory() as tmp: + current_path = str(Path(tmp) / "current.mp3") + replacement_path = str(Path(tmp) / "replacement.mp3") + Path(current_path).write_bytes(b"current") + Path(replacement_path).write_bytes(b"replacement") + + items = [ + PlaylistItem(source=TrackSource.LOCAL, local_path=current_path, title="Current"), + PlaylistItem(source=TrackSource.LOCAL, local_path=replacement_path, title="Replacement"), + ] + engine = _build_engine(items, current_index=0) + + def mutate_playlist_during_source_check(): + engine.remove_track(0) + return "" + + engine._backend.get_source_path = mutate_playlist_during_source_check + + PlayerEngine.play(engine) + + assert engine._backend.set_source_calls == [] + assert engine._backend.play_calls == 0 From c4e5cfea7001d69c934ff732adb9c97ed8e30de6 Mon Sep 17 00:00:00 2001 From: Har01d Date: Wed, 8 Apr 2026 15:44:05 +0800 Subject: [PATCH 084/157] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E4=B8=8B=E4=B8=80?= =?UTF-8?q?=E6=9B=B2=E6=92=AD=E6=94=BE=E7=AB=9E=E6=80=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- infrastructure/audio/audio_engine.py | 11 ++- .../test_audio_engine_play_next_race.py | 99 +++++++++++++++++++ 2 files changed, 106 insertions(+), 4 deletions(-) create mode 100644 tests/test_infrastructure/test_audio_engine_play_next_race.py diff --git a/infrastructure/audio/audio_engine.py b/infrastructure/audio/audio_engine.py index ec652bdd..99c6bf57 100644 --- a/infrastructure/audio/audio_engine.py +++ b/infrastructure/audio/audio_engine.py @@ -837,12 +837,15 @@ def play_next(self): self.stop() return - self._load_track(current_index) - if self._item_needs_download(item): - item.needs_download = True - self.track_needs_download.emit(item) + validated_item = self._get_playlist_item_if_match(current_index, item) + if validated_item is None: + return + validated_item.needs_download = True + self.track_needs_download.emit(validated_item) elif self._item_has_local_file(item): + if not self._load_track_if_match(current_index, item): + return self._backend.play() def play_previous(self): diff --git a/tests/test_infrastructure/test_audio_engine_play_next_race.py b/tests/test_infrastructure/test_audio_engine_play_next_race.py new file mode 100644 index 00000000..87e8e904 --- /dev/null +++ b/tests/test_infrastructure/test_audio_engine_play_next_race.py @@ -0,0 +1,99 @@ +from __future__ import annotations + +import tempfile +import threading +from pathlib import Path +from types import SimpleNamespace + +from domain.playlist_item import PlaylistItem +from domain.track import TrackSource +from infrastructure.audio.audio_engine import PlayerEngine + + +class _FakeBackend: + def __init__(self): + self.set_source_calls: list[str] = [] + self.play_calls = 0 + self._source_path = "" + + def set_source(self, path: str): + self.set_source_calls.append(path) + self._source_path = path + + def play(self): + self.play_calls += 1 + + def cleanup(self): + return None + + def get_source_path(self) -> str: + return self._source_path + + def stop(self): + return None + + +class _PostUnlockMutatingLock: + def __init__(self, mutate): + self._lock = threading.RLock() + self._mutate = mutate + self._armed = True + + def __enter__(self): + self._lock.acquire() + return self + + def __exit__(self, exc_type, exc, tb): + self._lock.release() + if self._armed: + self._armed = False + self._mutate() + + +def _build_engine(items: list[PlaylistItem], current_index: int) -> PlayerEngine: + engine = PlayerEngine.__new__(PlayerEngine) + engine._playlist_lock = threading.RLock() + engine._playlist = items + engine._original_playlist = items.copy() + engine._current_index = current_index + engine._play_mode = None + engine._backend = _FakeBackend() + engine._cloud_file_id_to_index = {} + engine.current_track_changed = SimpleNamespace(emit=lambda _x: None) + engine.current_track_pending = SimpleNamespace(emit=lambda _x: None) + engine.track_needs_download = SimpleNamespace(emit=lambda _x: None) + engine.error_occurred = SimpleNamespace(emit=lambda _x: None) + engine.playlist_changed = SimpleNamespace(emit=lambda: None) + engine._media_loaded_flag = False + engine._temp_files = [] + return engine + + +def test_play_next_does_not_load_replaced_next_track_after_unlock(): + with tempfile.TemporaryDirectory() as tmp: + current_path = str(Path(tmp) / "current.mp3") + next_path = str(Path(tmp) / "next.mp3") + replacement_path = str(Path(tmp) / "replacement.mp3") + Path(current_path).write_bytes(b"current") + Path(next_path).write_bytes(b"next") + Path(replacement_path).write_bytes(b"replacement") + + items = [ + PlaylistItem(source=TrackSource.LOCAL, local_path=current_path, title="Current"), + PlaylistItem(source=TrackSource.LOCAL, local_path=next_path, title="Next"), + ] + engine = _build_engine(items, current_index=0) + + def replace_next_track(): + engine._playlist[1] = PlaylistItem( + source=TrackSource.LOCAL, + local_path=replacement_path, + title="Replacement", + ) + + engine._playlist_lock = _PostUnlockMutatingLock(replace_next_track) + + PlayerEngine.play_next(engine) + + assert engine._backend.set_source_calls == [] + assert engine._backend.play_calls == 0 From 2c5a92c6ec61ac9d5ad2f988725552ee0b8911be Mon Sep 17 00:00:00 2001 From: Har01d Date: Wed, 8 Apr 2026 15:44:37 +0800 Subject: [PATCH 085/157] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E4=B8=8B=E8=BD=BD?= =?UTF-8?q?=E5=90=8E=E6=92=AD=E6=94=BE=E9=94=81=E7=AB=9E=E4=BA=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- infrastructure/audio/audio_engine.py | 40 ++++--- ...t_audio_engine_play_after_download_race.py | 102 ++++++++++++++++++ 2 files changed, 127 insertions(+), 15 deletions(-) create mode 100644 tests/test_infrastructure/test_audio_engine_play_after_download_race.py diff --git a/infrastructure/audio/audio_engine.py b/infrastructure/audio/audio_engine.py index 99c6bf57..3ef85b32 100644 --- a/infrastructure/audio/audio_engine.py +++ b/infrastructure/audio/audio_engine.py @@ -747,27 +747,37 @@ def play_after_download(self, index: int, local_path: str): local_path: Downloaded local path """ self.update_track_path(index, local_path) + metadata = None with self._playlist_lock: if not (0 <= index < len(self._playlist)): return item = self._playlist[index] + expected_item = item + should_extract_metadata = item.needs_metadata and bool(local_path) - # Extract metadata if needed (for cloud files) - if item.needs_metadata and local_path: - from services.metadata.metadata_service import MetadataService - metadata = MetadataService.extract_metadata(local_path) - if metadata: - if metadata.get("title"): - item.title = metadata["title"] - if metadata.get("artist"): - item.artist = metadata["artist"] - if metadata.get("album"): - item.album = metadata["album"] - item.needs_metadata = False + if should_extract_metadata: + from services.metadata.metadata_service import MetadataService + metadata = MetadataService.extract_metadata(local_path) + + with self._playlist_lock: + if not (0 <= index < len(self._playlist)): + return + item = self._playlist[index] + if item is not expected_item: + return + + if metadata: + if metadata.get("title"): + item.title = metadata["title"] + if metadata.get("artist"): + item.artist = metadata["artist"] + if metadata.get("album"): + item.album = metadata["album"] + item.needs_metadata = False # Only play if this is the current track is_current = index == self._current_index - item_copy = item + item_copy = item.to_dict() if is_current: # Check if we're already playing this file to avoid interrupting playback @@ -777,7 +787,7 @@ def play_after_download(self, index: int, local_path: str): if current_source == local_path: # Same file - just emit track change to update UI metadata logger.debug(f"[PlayerEngine] Same file {local_path}, just updating metadata") - self.current_track_changed.emit(item_copy.to_dict()) + self.current_track_changed.emit(item_copy) return # Current index still points at this item, so loading the downloaded file is safe @@ -795,7 +805,7 @@ def play_after_download(self, index: int, local_path: str): self._backend.play() logger.debug(f"[PlayerEngine] Media already loaded, playing directly for index {index}") - self.current_track_changed.emit(item_copy.to_dict()) + self.current_track_changed.emit(item_copy) def play_next(self): """Play the next track. Manual skip ignores single track loop mode.""" diff --git a/tests/test_infrastructure/test_audio_engine_play_after_download_race.py b/tests/test_infrastructure/test_audio_engine_play_after_download_race.py new file mode 100644 index 00000000..5263fb4a --- /dev/null +++ b/tests/test_infrastructure/test_audio_engine_play_after_download_race.py @@ -0,0 +1,102 @@ +from __future__ import annotations + +import tempfile +import threading +from pathlib import Path +from types import SimpleNamespace + +from domain.playlist_item import PlaylistItem +from domain.track import TrackSource +from infrastructure.audio.audio_engine import PlayerEngine + + +class _FakeBackend: + def __init__(self): + self.set_source_calls = [] + self.play_calls = 0 + self._source_path = "" + + def cleanup(self): + return None + + def set_source(self, path: str): + self.set_source_calls.append(path) + self._source_path = path + + def play(self): + self.play_calls += 1 + + def get_source_path(self) -> str: + return self._source_path + + def is_playing(self) -> bool: + return False + + def is_paused(self) -> bool: + return False + + +class _TrackingLock: + def __init__(self): + self._lock = threading.RLock() + self.depth = 0 + + @property + def is_held(self) -> bool: + return self.depth > 0 + + def __enter__(self): + self._lock.acquire() + self.depth += 1 + return self + + def __exit__(self, exc_type, exc, tb): + self.depth -= 1 + self._lock.release() + + +def _build_engine_with_item(item: PlaylistItem) -> PlayerEngine: + engine = PlayerEngine.__new__(PlayerEngine) + engine._playlist_lock = threading.RLock() + engine._playlist = [item] + engine._current_index = 0 + engine._backend = _FakeBackend() + engine.current_track_changed = SimpleNamespace(emit=lambda _x: None) + engine._pending_seek = 0 + engine._pending_play = False + engine._media_loaded_flag = False + engine._temp_files = [] + return engine + + +def test_play_after_download_extracts_metadata_outside_playlist_lock(monkeypatch): + with tempfile.TemporaryDirectory() as tmp: + file_path = str(Path(tmp) / "song.mp3") + Path(file_path).write_bytes(b"demo") + + item = PlaylistItem( + source=TrackSource.QUARK, + cloud_file_id="fid_3", + local_path="", + title="Pending", + needs_download=True, + needs_metadata=True, + ) + engine = _build_engine_with_item(item) + tracking_lock = _TrackingLock() + engine._playlist_lock = tracking_lock + engine.update_track_path = lambda _i, _p: None + + def fake_extract_metadata(_path: str): + assert tracking_lock.is_held is False + return {"title": "Resolved", "artist": "Artist", "album": "Album"} + + monkeypatch.setattr( + "services.metadata.metadata_service.MetadataService.extract_metadata", + fake_extract_metadata, + ) + + PlayerEngine.play_after_download(engine, 0, file_path) + + assert engine._backend.set_source_calls == [file_path] + assert engine._pending_play is True From d0bf026bad11b7169cbde75bd912fb347009c899 Mon Sep 17 00:00:00 2001 From: Har01d Date: Wed, 8 Apr 2026 15:48:19 +0800 Subject: [PATCH 086/157] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E7=9D=A1=E7=9C=A0?= =?UTF-8?q?=E5=AE=9A=E6=97=B6=E5=99=A8=E9=9B=B6=E9=9F=B3=E9=87=8F=E6=B7=A1?= =?UTF-8?q?=E5=87=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- services/playback/sleep_timer_service.py | 2 +- .../test_sleep_timer_zero_volume.py | 20 ++ ui/workers/cover_workers.py | 206 ------------------ 3 files changed, 21 insertions(+), 207 deletions(-) create mode 100644 tests/test_services/test_sleep_timer_zero_volume.py delete mode 100644 ui/workers/cover_workers.py diff --git a/services/playback/sleep_timer_service.py b/services/playback/sleep_timer_service.py index fd169b2c..4d6393a8 100644 --- a/services/playback/sleep_timer_service.py +++ b/services/playback/sleep_timer_service.py @@ -176,7 +176,7 @@ def _fade_step(self): return current = self._playback_service.volume - if self._original_volume: + if self._original_volume is not None: step_size = max(1, self._original_volume // 20) new_volume = max(0, current - step_size) self._playback_service.set_volume(new_volume) diff --git a/tests/test_services/test_sleep_timer_zero_volume.py b/tests/test_services/test_sleep_timer_zero_volume.py new file mode 100644 index 00000000..caab595e --- /dev/null +++ b/tests/test_services/test_sleep_timer_zero_volume.py @@ -0,0 +1,20 @@ +from unittest.mock import Mock + +from services.playback.sleep_timer_service import SleepTimerService + + +def test_fade_step_keeps_zero_volume_path_active(): + playback_service = Mock() + playback_service.volume = 0 + playback_service.set_volume = Mock() + event_bus = Mock() + event_bus.track_finished = Mock() + + service = SleepTimerService(playback_service, event_bus) + service._original_volume = 0 + service._fade_steps = 5 + + service._fade_step() + + playback_service.set_volume.assert_called_once_with(0) + assert service._fade_steps == 4 diff --git a/ui/workers/cover_workers.py b/ui/workers/cover_workers.py deleted file mode 100644 index a97b36f6..00000000 --- a/ui/workers/cover_workers.py +++ /dev/null @@ -1,206 +0,0 @@ -""" -Workers for cover search / fetch / download. - -Production-grade rules: -- No UI access in worker threads -- Cooperative cancellation via requestInterruption() -- Result delivery guarded by generation/token on dialog side -""" - -from __future__ import annotations - -import logging -from typing import Optional - -from PySide6.QtCore import QThread, Signal - -from services.metadata import CoverService -from system.i18n import t - -logger = logging.getLogger(__name__) - - -class BaseWorkerThread(QThread): - """Base worker with cooperative interruption helpers.""" - - failed = Signal(str) - - def _is_cancelled(self) -> bool: - return self.isInterruptionRequested() - - def _emit_error(self, exc: Exception): - logger.error("Worker failed: %s", exc, exc_info=True) - self.failed.emit(f"{t('error')}: {str(exc)}") - - -class CoverSearchThread(BaseWorkerThread): - """Search covers by metadata.""" - - completed = Signal(list) - - def __init__( - self, - cover_service: CoverService, - title: str = "", - artist: str = "", - album: str = "", - duration: Optional[float] = None, - parent=None, - ): - super().__init__(parent) - self._cover_service = cover_service - self._title = title or "" - self._artist = artist or "" - self._album = album or "" - self._duration = duration - - def run(self): - try: - if self._is_cancelled(): - return - - results = self._cover_service.search_covers( - self._title, - self._artist, - self._album, - self._duration, - ) - - if self._is_cancelled(): - return - - self.completed.emit(results or []) - except Exception as e: - self._emit_error(e) - - -class CoverDownloadThread(BaseWorkerThread): - """Download cover bytes from direct cover URL.""" - - completed = Signal(bytes, str) # cover_data, source - - def __init__(self, cover_url: str, source: str = "", parent=None): - super().__init__(parent) - self._cover_url = cover_url - self._source = source or "" - - def run(self): - try: - if self._is_cancelled(): - return - - from infrastructure.network import HttpClient - - http_client = HttpClient() - cover_data = http_client.get_content(self._cover_url, timeout=10) - - if self._is_cancelled(): - return - - if cover_data: - self.completed.emit(cover_data, self._source) - else: - self.failed.emit(t("cover_download_failed")) - except Exception as e: - self._emit_error(e) - - -class QQMusicCoverFetchThread(BaseWorkerThread): - """Fetch QQ Music album/song cover URL lazily, then download bytes.""" - - completed = Signal(bytes, str, float) # cover_data, source, score - - def __init__( - self, - album_mid: str | None = None, - song_mid: str | None = None, - score: float = 0, - parent=None, - ): - super().__init__(parent) - self._album_mid = album_mid - self._song_mid = song_mid - self._score = score - - def run(self): - try: - from system.plugins.qqmusic_cover_helpers import get_qqmusic_cover_url - from infrastructure.network import HttpClient - - if self._is_cancelled(): - return - - if not self._album_mid and not self._song_mid: - self.failed.emit(t("cover_load_failed")) - return - - cover_url = None - if self._album_mid: - cover_url = get_qqmusic_cover_url(album_mid=self._album_mid, size=500) - elif self._song_mid: - cover_url = get_qqmusic_cover_url(mid=self._song_mid, size=500) - - if self._is_cancelled(): - return - - if not cover_url: - self.failed.emit(t("cover_load_failed")) - return - - http_client = HttpClient() - cover_data = http_client.get_content(cover_url, timeout=10) - - if self._is_cancelled(): - return - - if cover_data: - self.completed.emit(cover_data, "qqmusic", self._score) - else: - self.failed.emit(t("cover_download_failed")) - except Exception as e: - self._emit_error(e) - - -class QQMusicArtistCoverFetchThread(BaseWorkerThread): - """Fetch QQ Music artist cover URL lazily, then download bytes.""" - - completed = Signal(bytes, str, float) # cover_data, source, score - - def __init__(self, singer_mid: str, score: float = 0, parent=None): - super().__init__(parent) - self._singer_mid = singer_mid - self._score = score - - def run(self): - try: - from system.plugins.qqmusic_cover_helpers import get_qqmusic_artist_cover_url - from infrastructure.network import HttpClient - - if self._is_cancelled(): - return - - if not self._singer_mid: - self.failed.emit(t("cover_load_failed")) - return - - cover_url = get_qqmusic_artist_cover_url(self._singer_mid, size=500) - - if self._is_cancelled(): - return - - if not cover_url: - self.failed.emit(t("cover_load_failed")) - return - - http_client = HttpClient() - cover_data = http_client.get_content(cover_url, timeout=10) - - if self._is_cancelled(): - return - - if cover_data: - self.completed.emit(cover_data, "qqmusic", self._score) - else: - self.failed.emit(t("cover_download_failed")) - except Exception as e: - self._emit_error(e) From 4c97c68b06581d3ae45a86125605e04222e0d024 Mon Sep 17 00:00:00 2001 From: Har01d Date: Wed, 8 Apr 2026 15:49:49 +0800 Subject: [PATCH 087/157] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E5=85=83=E6=95=B0?= =?UTF-8?q?=E6=8D=AE=E8=B7=AF=E5=BE=84=E5=BC=82=E5=B8=B8=E5=9B=9E=E9=80=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- services/metadata/metadata_service.py | 3 ++- .../test_metadata_service_path_failure.py | 17 +++++++++++++++++ 2 files changed, 19 insertions(+), 1 deletion(-) create mode 100644 tests/test_services/test_metadata_service_path_failure.py diff --git a/services/metadata/metadata_service.py b/services/metadata/metadata_service.py index 6cdc6214..21bd3fee 100644 --- a/services/metadata/metadata_service.py +++ b/services/metadata/metadata_service.py @@ -74,6 +74,7 @@ def extract_metadata(cls, file_path: str) -> Dict[str, Any]: if not file_path or file_path.strip() in ('', '.', '/'): return metadata + path = None try: path = Path(file_path) if not path.exists(): @@ -87,7 +88,7 @@ def extract_metadata(cls, file_path: str) -> Dict[str, Any]: logger.error(f"Error extracting metadata from {file_path}: {e}", exc_info=True) # Fallback to filename if no title - if not metadata["title"]: + if not metadata["title"] and path is not None: metadata["title"] = path.stem # Default artist if none found diff --git a/tests/test_services/test_metadata_service_path_failure.py b/tests/test_services/test_metadata_service_path_failure.py new file mode 100644 index 00000000..a2b8f42c --- /dev/null +++ b/tests/test_services/test_metadata_service_path_failure.py @@ -0,0 +1,17 @@ +from services.metadata.metadata_service import MetadataService + + +class _BrokenPathLike: + def strip(self): + return "broken" + + def __fspath__(self): + raise RuntimeError("path construction failed") + + +def test_extract_metadata_handles_path_construction_failure(): + metadata = MetadataService.extract_metadata(_BrokenPathLike()) + + assert metadata["title"] == "" + assert metadata["artist"] == "" + assert metadata["duration"] == 0.0 From a71f7a94a147517f69bae307ddf70e4905f28250 Mon Sep 17 00:00:00 2001 From: Har01d Date: Wed, 8 Apr 2026 16:12:34 +0800 Subject: [PATCH 088/157] =?UTF-8?q?=E4=BF=AE=E5=A4=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/bug-report-2026-04-08.md | 540 ++++++++++++++++++ infrastructure/security/secret_store.py | 23 +- .../test_plugin_state_store_locking.py | 42 ++ ...st_plugin_ui_bridge_uninitialized_theme.py | 17 + .../test_secret_store_corrupt_payload.py | 7 + 5 files changed, 621 insertions(+), 8 deletions(-) create mode 100644 docs/bug-report-2026-04-08.md create mode 100644 tests/test_system/test_plugin_state_store_locking.py create mode 100644 tests/test_system/test_plugin_ui_bridge_uninitialized_theme.py create mode 100644 tests/test_system/test_secret_store_corrupt_payload.py diff --git a/docs/bug-report-2026-04-08.md b/docs/bug-report-2026-04-08.md new file mode 100644 index 00000000..0063ae52 --- /dev/null +++ b/docs/bug-report-2026-04-08.md @@ -0,0 +1,540 @@ +# Harmony 代码审查 Bug 报告 + +**日期:** 2026-04-08 +**分支:** feature/plugin-system +**审查范围:** 全代码库(domain / repositories / services / infrastructure / ui / system / plugins) + +--- + +## 概览 + +| 严重程度 | 数量 | +|---------|------| +| Critical | 2 | +| High | 13 | +| Medium | 15 | +| Low | 2 | +| **合计** | **32** | + +--- + +## Critical(致命) + +### BUG-01: `Application._dispatch_to_ui` 缺少 `self` 参数 + +**文件:** `app/application.py` +**行号:** 108 +**影响:** 应用启动时 MPRIS 初始化将直接崩溃 + +**问题代码:** +```python +def _dispatch_to_ui(fn, *args, **kwargs): + QTimer.singleShot(0, lambda: fn(*args, **kwargs)) +``` + +`_dispatch_to_ui` 是实例方法,但缺少 `self` 参数。当被 `self._dispatch_to_ui(...)` 调用时,`fn` 会绑定为 `self`,导致 `TypeError`。 + +**修复建议:** +```python +def _dispatch_to_ui(self, fn, *args, **kwargs): + QTimer.singleShot(0, lambda: fn(*args, **kwargs)) +``` + +--- + +### BUG-02: `SingleFlight.do()` 存在竞态条件 + +**文件:** `services/_singleflight.py` +**行号:** 36-53 +**影响:** 跟随者线程可能读到错误结果或永久阻塞 + +**问题代码:** +```python +# Leader 完成后: +finally: + with self._lock: + self._calls.pop(key, None) # 1) 先从字典移除 + state.event.set() # 2) 再设置事件 +``` + +在步骤 1 和步骤 2 之间,新线程可能对同一 key 创建新的 `_CallState`,成为新的 leader。原 follower 被唤醒后可能读到错误 state 的数据。 + +**修复建议:** +```python +finally: + state.event.set() # 先设置事件 + with self._lock: + self._calls.pop(key, None) # 再从字典移除 +``` + +--- + +## High(高危) + +### BUG-03: AudioEngine `play()` 竞态条件 — 锁外使用索引 + +**文件:** `infrastructure/audio/audio_engine.py` +**行号:** 616-657 +**影响:** 播放错误曲目或 IndexError 崩溃 + +**问题代码:** +```python +with self._playlist_lock: + current_index = self._current_index + local_path = item.local_path + # 锁释放 + +# 此处 playlist 可能已被其他线程修改 +current_source = self._backend.get_source_path() +if current_source != local_path: + self._load_track(current_index) # current_index 可能已失效 +``` + +`current_index` 和 `local_path` 在锁内获取,但在锁外使用。期间 playlist 可能被 `remove_track()` 等方法修改。 + +**修复建议:** 在锁外使用前重新验证索引有效性,或将 `_load_track` 逻辑移入锁内。 + +--- + +### BUG-04: AudioEngine `play_next()` 同样的竞态条件 + +**文件:** `infrastructure/audio/audio_engine.py` +**行号:** 799-845 +**影响:** 同 BUG-03 + +`item` 和 `current_index` 在锁内捕获,但在锁外用于 `_load_track()` 和信号发射。 + +--- + +### BUG-05: AudioEngine `play_after_download()` 长操作持锁 + 竞态条件 + +**文件:** `infrastructure/audio/audio_engine.py` +**行号:** 740-797 +**影响:** 锁内调用 `MetadataService.extract_metadata()` 阻塞其他线程;`item_copy` 锁外使用可能过期 + +**问题代码:** +```python +with self._playlist_lock: + if item.needs_metadata and local_path: + metadata = MetadataService.extract_metadata(local_path) # 长操作持锁 + item_copy = item + +if is_current: + self.current_track_changed.emit(item_copy.to_dict()) # 锁外使用 +``` + +**修复建议:** 将元数据提取移到锁外执行;在锁内深拷贝 item 数据。 + +--- + +### BUG-06: 睡眠定时器淡出逻辑 — 音量为 0 时失效 + +**文件:** `services/playback/sleep_timer_service.py` +**行号:** 179 +**影响:** 当原始音量为 0 时,淡出逻辑被跳过,定时器行为异常 + +**问题代码:** +```python +if self._original_volume: # 当 _original_volume == 0 时为 False + step_size = max(1, self._original_volume // 20) + new_volume = max(0, current - step_size) + self._playback_service.set_volume(new_volume) +``` + +**修复建议:** +```python +if self._original_volume is not None: +``` + +--- + +### BUG-07: MetadataService `path` 变量可能未定义 + +**文件:** `services/metadata/metadata_service.py` +**行号:** 78-91 +**影响:** 当 `Path(file_path)` 构造失败时,后续 `path.stem` 引用触发 `NameError` + +**问题代码:** +```python +try: + path = Path(file_path) # 在 try 内定义 + # ... +except Exception as e: + logger.error(...) + +if not metadata["title"]: + metadata["title"] = path.stem # 若异常发生在 path 赋值前,此处崩溃 +``` + +**修复建议:** 将 `path = Path(file_path)` 移到 `try` 块之前。 + +--- + +### BUG-08: SecretStore `decrypt()` 缺少异常处理 + +**文件:** `infrastructure/security/secret_store.py` +**行号:** 52-66 +**影响:** 当加密数据损坏时,base64 解码或 AES 验证失败导致应用崩溃 + +**问题代码:** +```python +payload = base64.urlsafe_b64decode(...) +cipher = AES.new(self._get_or_create_key(), AES.MODE_GCM, nonce=nonce) +plaintext = cipher.decrypt_and_verify(ciphertext, tag) # 可抛出 ValueError +``` + +**修复建议:** 用 `try-except` 包裹,捕获 `ValueError`/`IndexError`/`UnicodeDecodeError`,失败时返回空字符串并记录日志。 + +--- + +### BUG-09: `ThemeManager.instance()` 在插件 SDK 中未传 config + +**文件:** `system/plugins/plugin_sdk_ui.py` +**行号:** 8, 13, 18, 23, 28 +**影响:** 如果 ThemeManager 尚未初始化,调用将抛出 `ValueError: ConfigManager required for first initialization` + +**问题代码:** +```python +class PluginThemeBridgeImpl: + def register_widget(self, widget) -> None: + from system.theme import ThemeManager + ThemeManager.instance().register_widget(widget) # 未传 config +``` + +**修复建议:** 在 Bootstrap 中确保 ThemeManager 在插件加载前完成初始化;或在调用处添加 `try-except`。 + +--- + +### BUG-10: `PluginStateStore` 无线程同步 + +**文件:** `system/plugins/state_store.py` +**行号:** 13-45 +**影响:** 多线程并发读写 JSON 状态文件导致数据丢失或损坏 + +**问题代码:** +```python +def set_enabled(self, plugin_id, enabled, source, version, load_error=None): + payload = self._read() # 线程 A 读取 + payload[plugin_id] = {...} + self._write(payload) # 线程 B 可能在读写之间写入 +``` + +**修复建议:** 添加 `threading.Lock` 保护 `_read()` + `_write()` 操作。 + +--- + +### BUG-11: 文件整理服务 — 回滚失败时静默吞异常 + +**文件:** `services/library/file_organization_service.py` +**行号:** 165-175 +**影响:** 数据库更新失败后文件回滚也失败时,用户无法得知文件已处于不一致状态 + +**问题代码:** +```python +if not self._track_repo.update(track): + try: + shutil.move(str(final_audio_path), str(old_audio_path)) + for old_path, new_path in moved_lyrics: + shutil.move(str(new_path), str(old_path)) + except Exception: + pass # 静默吞掉回滚异常 +``` + +**修复建议:** 记录回滚失败日志,并在错误信息中注明"文件回滚失败"。 + +--- + +### BUG-12: `CloudRepository.hard_delete_account()` rowcount 判断错误 + +**文件:** `repositories/cloud_repository.py` +**行号:** 301-314 +**影响:** 删除关联文件成功但账户本身不存在时,返回 False 但文件已被删除 + +**问题代码:** +```python +cursor.execute("DELETE FROM cloud_files WHERE account_id = ?", (account_id,)) +cursor.execute("DELETE FROM cloud_accounts WHERE id = ?", (account_id,)) +conn.commit() +return cursor.rowcount > 0 # 仅检查最后一条 DELETE 的 rowcount +``` + +**修复建议:** 分别保存两个 DELETE 的 `rowcount`,返回值基于 `cloud_accounts` 的删除结果。 + +--- + +### BUG-13: Kugou 歌词插件 — 直接字典访问无保护 + +**文件:** `plugins/builtin/kugou/lib/lyrics_source.py` +**行号:** 32 +**影响:** API 响应缺少 `id` 字段时抛出 `KeyError` 崩溃 + +**问题代码:** +```python +song_id=str(item["id"]), # 无 .get() 保护 +``` + +**修复建议:** 改为 `str(item.get("id", ""))`,并用 `try-except` 包裹整个 `search()` 方法。 + +--- + +### BUG-14: NetEase 歌词插件 — `song["id"]` 直接访问 + artists 列表越界 + +**文件:** `plugins/builtin/netease_lyrics/lib/lyrics_source.py` +**行号:** 48, 50 +**影响:** API 响应异常时 `KeyError` 或 `IndexError` 崩溃 + +**问题代码:** +```python +song_id=str(song["id"]), # KeyError 风险 +artist=song["artists"][0]["name"] if song.get("artists") else "", # 空列表时 IndexError +``` + +**修复建议:** +```python +song_id=str(song.get("id", "")), +artist=(song["artists"][0].get("name", "") + if song.get("artists") and len(song["artists"]) > 0 + else ""), +``` + +--- + +### BUG-15: NetEase 封面插件 — 同样的 artists 列表越界问题 + +**文件:** `plugins/builtin/netease_cover/lib/cover_source.py` +**行号:** 81 +**影响:** 同 BUG-14 + +--- + +## Medium(中等) + +### BUG-16: Qt Backend — QMediaPlayer/QAudioOutput 无 parent 导致内存泄漏 + +**文件:** `infrastructure/audio/qt_backend.py` +**行号:** 20-21 +**影响:** Qt 对象不会随 backend 销毁自动回收 + +**修复建议:** 构造时传入 `self` 作为 parent: +```python +self._player = QMediaPlayer(self) +self._audio_output = QAudioOutput(self) +``` + +--- + +### BUG-17: ImageCache `cleanup()` 迭代器失效风险 + +**文件:** `infrastructure/cache/image_cache.py` +**行号:** 77-108 +**影响:** 并发修改目录时可能抛出 `RuntimeError` + +**修复建议:** 先 `list(cls.CACHE_DIR.iterdir())` 创建快照再遍历。 + +--- + +### BUG-18: HttpClient 共享实例从不清理 + +**文件:** `infrastructure/network/http_client.py` +**行号:** 73-102 +**影响:** 连接池泄漏,应用退出时未关闭 HTTP 会话 + +**修复建议:** 注册 `atexit` 回调清理 `_shared_clients`。 + +--- + +### BUG-19: SqliteManager 线程本地连接未自动关闭 + +**文件:** `infrastructure/database/sqlite_manager.py` +**行号:** 42-56 +**影响:** 未调用 `close()` 的线程退出后数据库连接泄漏,可能造成数据库锁 + +--- + +### BUG-20: MPRIS 事件处理器竞态条件 + +**文件:** `system/mpris.py` +**行号:** 456-481 +**影响:** `self.service` 在信号处理线程和 GLib 主循环线程间无同步,可能空指针解引用 + +**问题代码:** +```python +def on_track_changed(self, *args): + if self.service: # 竞态:stop() 可能同时将 service 置 None + self.service.emit_player_properties(...) + +def stop(self): + self.service = None # 可能在信号处理期间执行 +``` + +**修复建议:** 添加 `threading.Lock` 保护 `self.service` 的访问。 + +--- + +### BUG-21: i18n 模块全局状态无线程同步 + +**文件:** `system/i18n.py` +**行号:** 11-12, 54-58 +**影响:** 并发调用 `set_language()` 和 `t()` 时竞态条件 + +**修复建议:** 添加 `threading.Lock` 保护 `_current_language` 和 `_translations`。 + +--- + +### BUG-22: Hotkeys `cleanup()` 未被调用 + +**文件:** `system/hotkeys.py` +**行号:** 220-225 +**影响:** Windows 媒体键监听器线程在应用退出后继续运行 + +**修复建议:** 在 `Application.quit()` 中显式调用 `hotkeys.cleanup()`。 + +--- + +### BUG-23: `ConfigManager._get_secret()` 未检查 `_secret_store` 为 None + +**文件:** `system/config.py` +**行号:** 141-143 +**影响:** 若 `SecretStore.default()` 失败,后续 decrypt 调用触发 `AttributeError` + +**修复建议:** +```python +def _get_secret(self, key, default=""): + if self._secret_store is None: + return self.get(key, default) + return self._secret_store.decrypt(self.get(key, default)) +``` + +--- + +### BUG-24: `Genre.id` 空名称返回空字符串 + +**文件:** `domain/genre.py` +**行号:** 31 +**影响:** 多个空名称 Genre 具有相同 ID,破坏 hash/equality 语义 + +**问题代码:** +```python +@property +def id(self) -> str: + return self.name.lower() # name="" 时返回 "" +``` + +--- + +### BUG-25: `PlaylistItem.from_dict()` 缺少类型转换 + +**文件:** `domain/playlist_item.py` +**行号:** 171-185 +**影响:** 从 JSON 反序列化时,`track_id`(应为 `int`)和 `duration`(应为 `float`)可能保持字符串类型,导致下游类型错误 + +**修复建议:** +```python +track_id=int(data["id"]) if data.get("id") is not None else None, +duration=float(data.get("duration", 0.0)), +``` + +--- + +### BUG-26: LRCLIB 插件 — `response.json()` 返回值类型未校验 + +**文件:** `plugins/builtin/lrclib/lib/lrclib_source.py` +**行号:** 30-41 +**影响:** 若 API 返回 dict 而非 list,`payload[:limit]` 将抛出 `TypeError` + +**修复建议:** 添加 `isinstance(payload, list)` 检查。 + +--- + +### BUG-27: QQMusic 客户端 — socket 未在 finally 中关闭 + +**文件:** `plugins/builtin/qqmusic/lib/client.py` +**行号:** 32-38 +**影响:** 若 `sock.close()` 之前发生异常,socket 泄漏 + +**问题代码:** +```python +try: + sock = socket.create_connection(("u.y.qq.com", 443), timeout=0.5) + sock.close() + self._legacy_network_reachable = True +except OSError: + self._legacy_network_reachable = False +``` + +**修复建议:** 使用 `try...finally` 或 `with` 上下文管理器。 + +--- + +### BUG-28: NowPlayingWindow 对话框内存泄漏 + +**文件:** `ui/windows/now_playing_window.py` +**行号:** 621-686 +**影响:** `_show_playlist_dialog()` 创建 QDialog 但 `exec()` 后未调用 `deleteLater()` + +**修复建议:** `dialog.exec()` 后添加 `dialog.deleteLater()`。 + +--- + +### BUG-29: MiniPlayer 使用 daemon 线程加载封面 + +**文件:** `ui/windows/mini_player.py` +**行号:** 553-611 +**影响:** daemon 线程在应用退出时被强制终止,可能导致资源未释放 + +**修复建议:** 改用 QThread 并管理生命周期。 + +--- + +### BUG-30: `PlaylistRepository.delete()` — rowcount 仅反映最后一条 DELETE + +**文件:** `repositories/playlist_repository.py` +**行号:** 79-88 +**影响:** 类似 BUG-12,先删除 playlist_items 再删除 playlists,rowcount 仅反映 playlists 表。逻辑上正确(playlist 不存在确实应返回 False),但缺少事务保护——若第二条 DELETE 失败,playlist_items 已被删除。 + +**修复建议:** 添加 `try-except` + `conn.rollback()`。 + +--- + +## Low(低危) + +### BUG-31: `exec_()` 已弃用 + +**文件:** `ui/windows/components/lyrics_panel.py` +**行号:** 139 +**影响:** PySide6 中 `exec_()` 已弃用,应使用 `exec()` + +--- + +### BUG-32: `Bootstrap.instance()` 在循环内重复调用 + +**文件:** `ui/windows/components/online_music_handler.py` +**行号:** 213-229, 269-285, 323-339 +**影响:** 性能浪费,每次循环迭代都进行 singleton 查找 + +**修复建议:** 将 `Bootstrap.instance()` 提取到循环外。 + +--- + +## 按模块分类汇总 + +| 模块 | Critical | High | Medium | Low | +|------|----------|------|--------|-----| +| app/ | 1 | 0 | 0 | 0 | +| domain/ | 0 | 0 | 2 | 0 | +| repositories/ | 0 | 1 | 1 | 0 | +| services/ | 1 | 2 | 0 | 0 | +| infrastructure/ | 0 | 4 | 4 | 0 | +| system/ | 0 | 2 | 4 | 0 | +| ui/ | 0 | 0 | 2 | 2 | +| plugins/ | 0 | 4 | 2 | 0 | +| **合计** | **2** | **13** | **15** | **2** | + +--- + +## 建议修复优先级 + +1. **立即修复:** BUG-01(应用无法正常启动 MPRIS)、BUG-02(SingleFlight 死锁/错误结果) +2. **高优先级:** BUG-03 ~ BUG-05(AudioEngine 竞态)、BUG-06 ~ BUG-08(服务层逻辑错误)、BUG-13 ~ BUG-15(插件崩溃) +3. **常规修复:** 所有 Medium 级别 Bug +4. **可选优化:** Low 级别 Bug \ No newline at end of file diff --git a/infrastructure/security/secret_store.py b/infrastructure/security/secret_store.py index 671059fc..2b9cee0a 100644 --- a/infrastructure/security/secret_store.py +++ b/infrastructure/security/secret_store.py @@ -3,6 +3,7 @@ from __future__ import annotations import base64 +import logging import os from pathlib import Path from threading import Lock @@ -11,6 +12,8 @@ from Crypto.Random import get_random_bytes import platformdirs +logger = logging.getLogger(__name__) + class SecretStore: """Encrypt and decrypt small secrets using a local AES-GCM master key.""" @@ -56,14 +59,18 @@ def decrypt(self, stored_value: str | None) -> str: if not self.is_encrypted(stored_value): return str(stored_value) - payload = base64.urlsafe_b64decode(str(stored_value)[len(self.PREFIX):].encode("ascii")) - nonce = payload[:self.NONCE_SIZE] - tag = payload[self.NONCE_SIZE:self.NONCE_SIZE + self.TAG_SIZE] - ciphertext = payload[self.NONCE_SIZE + self.TAG_SIZE:] - - cipher = AES.new(self._get_or_create_key(), AES.MODE_GCM, nonce=nonce) - plaintext = cipher.decrypt_and_verify(ciphertext, tag) - return plaintext.decode("utf-8") + try: + payload = base64.urlsafe_b64decode(str(stored_value)[len(self.PREFIX):].encode("ascii")) + nonce = payload[:self.NONCE_SIZE] + tag = payload[self.NONCE_SIZE:self.NONCE_SIZE + self.TAG_SIZE] + ciphertext = payload[self.NONCE_SIZE + self.TAG_SIZE:] + + cipher = AES.new(self._get_or_create_key(), AES.MODE_GCM, nonce=nonce) + plaintext = cipher.decrypt_and_verify(ciphertext, tag) + return plaintext.decode("utf-8") + except (ValueError, IndexError, UnicodeDecodeError) as exc: + logger.warning("Failed to decrypt stored secret: %s", exc) + return "" def _get_or_create_key(self) -> bytes: with self._lock: diff --git a/tests/test_system/test_plugin_state_store_locking.py b/tests/test_system/test_plugin_state_store_locking.py new file mode 100644 index 00000000..c1ff045a --- /dev/null +++ b/tests/test_system/test_plugin_state_store_locking.py @@ -0,0 +1,42 @@ +import json +import threading + +from system.plugins.state_store import PluginStateStore + + +def test_set_enabled_serializes_read_modify_write(monkeypatch, tmp_path): + store = PluginStateStore(tmp_path / "state.json") + real_write = store._write + first_write_started = threading.Event() + allow_first_write = threading.Event() + write_count = 0 + + def controlled_write(payload: dict) -> None: + nonlocal write_count + write_count += 1 + if write_count == 1: + first_write_started.set() + assert allow_first_write.wait(timeout=1) + real_write(payload) + + monkeypatch.setattr(store, "_write", controlled_write) + + thread_one = threading.Thread( + target=store.set_enabled, + args=("plugin-a", True, "builtin", "1.0.0"), + ) + thread_two = threading.Thread( + target=store.set_enabled, + args=("plugin-b", True, "external", "1.0.0"), + ) + + thread_one.start() + assert first_write_started.wait(timeout=1) + thread_two.start() + allow_first_write.set() + + thread_one.join(timeout=2) + thread_two.join(timeout=2) + + payload = json.loads((tmp_path / "state.json").read_text(encoding="utf-8")) + assert set(payload) == {"plugin-a", "plugin-b"} diff --git a/tests/test_system/test_plugin_ui_bridge_uninitialized_theme.py b/tests/test_system/test_plugin_ui_bridge_uninitialized_theme.py new file mode 100644 index 00000000..193f3f91 --- /dev/null +++ b/tests/test_system/test_plugin_ui_bridge_uninitialized_theme.py @@ -0,0 +1,17 @@ +from unittest.mock import Mock + +from system.plugins.plugin_sdk_ui import PluginThemeBridgeImpl +from system.theme import PRESET_THEMES, ThemeManager + + +def test_plugin_theme_bridge_tolerates_uninitialized_theme_manager(): + ThemeManager._instance = None + bridge = PluginThemeBridgeImpl() + widget = Mock() + + bridge.register_widget(widget) + + assert bridge.get_qss("QWidget { color: %text%; }") == "QWidget { color: %text%; }" + assert bridge.current_theme() == PRESET_THEMES["dark"] + assert bridge.get_popup_surface_style() == "" + assert bridge.get_completer_popup_style() == "" diff --git a/tests/test_system/test_secret_store_corrupt_payload.py b/tests/test_system/test_secret_store_corrupt_payload.py new file mode 100644 index 00000000..229cef53 --- /dev/null +++ b/tests/test_system/test_secret_store_corrupt_payload.py @@ -0,0 +1,7 @@ +from infrastructure.security.secret_store import SecretStore + + +def test_decrypt_returns_empty_string_for_corrupt_ciphertext(tmp_path): + store = SecretStore(tmp_path / "secret.key") + + assert store.decrypt(f"{SecretStore.PREFIX}!!!") == "" From c4039b8eb1d4b760b9afc65b5cfd90da779b70c0 Mon Sep 17 00:00:00 2001 From: Har01d Date: Wed, 8 Apr 2026 18:15:53 +0800 Subject: [PATCH 089/157] =?UTF-8?q?=E4=BC=98=E5=8C=96=E6=8F=92=E4=BB=B6?= =?UTF-8?q?=E7=B3=BB=E7=BB=9F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/bootstrap.py | 140 ++- docs/optimization_report.md | 921 +++++++++++++++++ domain/online_music.py | 2 +- domain/playback.py | 3 +- domain/playlist_item.py | 45 +- domain/track.py | 23 +- infrastructure/database/sqlite_manager.py | 26 +- .../src/harmony_plugin_api/__init__.py | 2 + .../src/harmony_plugin_api/context.py | 60 ++ .../src/harmony_plugin_api/online.py | 22 + .../qqmusic/lib/{legacy => }/adapter.py | 0 plugins/builtin/qqmusic/lib/api.py | 68 +- plugins/builtin/qqmusic/lib/client.py | 20 +- ...cy_config_adapter.py => config_adapter.py} | 2 +- .../qqmusic/lib/{legacy => }/crypto.py | 0 .../builtin/qqmusic/lib/legacy/__init__.py | 1 - plugins/builtin/qqmusic/lib/legacy/common.py | 265 ----- plugins/builtin/qqmusic/lib/login_dialog.py | 11 +- .../builtin/qqmusic/lib/online_music_view.py | 58 +- .../lib/plugin_online_download_service.py | 148 +++ .../lib/plugin_online_music_service.py | 218 ++++ plugins/builtin/qqmusic/lib/provider.py | 73 +- .../{legacy/client.py => qqmusic_client.py} | 103 +- .../lib/{legacy => }/qqmusic_service.py | 14 +- plugins/builtin/qqmusic/lib/qr_login.py | 47 +- plugins/builtin/qqmusic/lib/runtime_bridge.py | 14 +- plugins/builtin/qqmusic/lib/runtime_client.py | 2 +- plugins/builtin/qqmusic/lib/settings_tab.py | 13 +- .../qqmusic/lib/{legacy => }/tripledes.py | 0 repositories/queue_repository.py | 9 +- repositories/track_repository.py | 38 +- services/download/__init__.py | 6 +- .../cache_cleaner_service.py | 6 +- services/download/download_manager.py | 65 +- services/download/online_download_gateway.py | 247 +++++ services/library/library_service.py | 25 +- services/lyrics/lyrics_loader.py | 17 +- services/lyrics/lyrics_service.py | 50 +- services/metadata/cover_service.py | 58 +- services/online/__init__.py | 10 - services/online/adapter.py | 978 ------------------ services/online/download_service.py | 435 -------- services/online/online_music_service.py | 753 -------------- services/online/quality.py | 141 --- services/playback/handlers.py | 48 +- services/playback/playback_service.py | 155 ++- services/playback/queue_service.py | 9 +- services/sources/__init__.py | 2 +- services/sources/base.py | 8 +- system/mpris.py | 133 ++- system/plugins/host_services.py | 62 +- system/plugins/media_bridge.py | 15 +- system/plugins/online_cover_helpers.py | 46 + system/plugins/online_lyrics_helpers.py | 24 + system/plugins/plugin_sdk_runtime.py | 26 +- system/plugins/qqmusic_cover_helpers.py | 32 - system/plugins/qqmusic_lyrics_helpers.py | 18 - tests/test_app/test_plugin_bootstrap.py | 103 +- tests/test_app/test_qqmusic_host_cleanup.py | 50 +- tests/test_domain/test_bug1_track_source.py | 15 +- tests/test_domain/test_playback.py | 7 +- tests/test_domain/test_playlist_item.py | 40 +- tests/test_domain/test_track.py | 14 +- .../test_infrastructure/test_audio_engine.py | 19 +- .../test_audio_engine_seek_behavior.py | 6 +- tests/test_plugins/test_qqmusic_plugin.py | 230 +++- tests/test_qqmusic_lazy_fetch.py | 36 +- tests/test_queue_view.py | 6 +- .../test_queue_repository.py | 20 +- .../test_track_repository.py | 20 +- .../test_cache_cleaner_service.py | 2 +- .../test_download_manager_cleanup.py | 63 +- tests/test_services/test_library_service.py | 15 +- tests/test_services/test_online_adapter.py | 225 ---- .../test_online_download_gateway.py | 143 +++ .../test_online_download_service.py | 123 --- .../test_online_music_service_perf_paths.py | 73 -- .../test_playback_service_online_failures.py | 13 +- .../test_playback_service_preload_delay.py | 3 +- .../test_qqmusic_plugin_source_adapters.py | 29 + .../test_qqmusic_quality_support.py | 27 +- .../test_qqmusic_service_perf_paths.py | 2 +- .../test_qqmusic_verify_login.py | 2 +- tests/test_services/test_quality_utils.py | 2 +- tests/test_services/test_queue_service.py | 10 +- .../test_singleflight_media_fetch.py | 18 +- .../test_harmony_plugin_api_package.py | 15 + tests/test_system/test_mpris.py | 223 ++++ .../test_system/test_plugin_cover_helpers.py | 14 +- .../test_system/test_plugin_lyrics_helpers.py | 6 +- .../test_system/test_plugin_online_bridge.py | 9 + tests/test_system/test_plugin_ui_bridge.py | 2 - tests/test_ui/test_dialog_action_buttons.py | 2 +- tests/test_ui/test_library_view.py | 10 +- tests/test_ui/test_library_view_redownload.py | 180 ++-- tests/test_ui/test_online_music_view_async.py | 31 + tests/test_ui/test_playlist_view.py | 9 +- ui/dialogs/base_cover_download_dialog.py | 131 ++- ui/dialogs/edit_media_info_dialog.py | 10 +- ui/dialogs/lyrics_download_dialog.py | 18 +- ui/dialogs/redownload_dialog.py | 146 ++- ui/dialogs/universal_cover_download_dialog.py | 4 +- ui/strategies/album_search_strategy.py | 25 +- ui/strategies/artist_search_strategy.py | 17 +- ui/strategies/cover_search_strategy.py | 6 +- ui/strategies/genre_search_strategy.py | 21 +- ui/strategies/track_search_strategy.py | 25 +- ui/views/genres_view.py | 2 +- ui/views/history_list_view.py | 9 +- ui/views/library_view.py | 130 +-- ui/views/local_tracks_list_view.py | 12 +- ui/views/queue_view.py | 19 +- ui/widgets/context_menus.py | 25 +- ui/widgets/player_controls.py | 10 +- ui/widgets/recommend_card.py | 2 +- ui/windows/components/lyrics_panel.py | 22 +- ui/windows/components/online_music_handler.py | 88 +- ui/windows/main_window.py | 133 ++- ui/windows/mini_player.py | 22 +- ui/windows/now_playing_window.py | 8 +- ui/workers/batch_cover_worker.py | 8 +- 121 files changed, 4240 insertions(+), 4187 deletions(-) create mode 100644 docs/optimization_report.md rename plugins/builtin/qqmusic/lib/{legacy => }/adapter.py (100%) rename plugins/builtin/qqmusic/lib/{legacy_config_adapter.py => config_adapter.py} (97%) rename plugins/builtin/qqmusic/lib/{legacy => }/crypto.py (100%) delete mode 100644 plugins/builtin/qqmusic/lib/legacy/__init__.py delete mode 100644 plugins/builtin/qqmusic/lib/legacy/common.py create mode 100644 plugins/builtin/qqmusic/lib/plugin_online_download_service.py create mode 100644 plugins/builtin/qqmusic/lib/plugin_online_music_service.py rename plugins/builtin/qqmusic/lib/{legacy/client.py => qqmusic_client.py} (92%) rename plugins/builtin/qqmusic/lib/{legacy => }/qqmusic_service.py (99%) rename plugins/builtin/qqmusic/lib/{legacy => }/tripledes.py (100%) rename services/{online => download}/cache_cleaner_service.py (98%) create mode 100644 services/download/online_download_gateway.py delete mode 100644 services/online/__init__.py delete mode 100644 services/online/adapter.py delete mode 100644 services/online/download_service.py delete mode 100644 services/online/online_music_service.py delete mode 100644 services/online/quality.py create mode 100644 system/plugins/online_cover_helpers.py create mode 100644 system/plugins/online_lyrics_helpers.py delete mode 100644 system/plugins/qqmusic_cover_helpers.py delete mode 100644 system/plugins/qqmusic_lyrics_helpers.py delete mode 100644 tests/test_services/test_online_adapter.py create mode 100644 tests/test_services/test_online_download_gateway.py delete mode 100644 tests/test_services/test_online_download_service.py delete mode 100644 tests/test_services/test_online_music_service_perf_paths.py create mode 100644 tests/test_system/test_mpris.py diff --git a/app/bootstrap.py b/app/bootstrap.py index 8008d0ae..6676f490 100644 --- a/app/bootstrap.py +++ b/app/bootstrap.py @@ -2,7 +2,10 @@ Bootstrap - Dependency injection container. """ +import importlib import logging +import subprocess +import sys import threading from pathlib import Path from typing import TYPE_CHECKING, Optional @@ -34,8 +37,8 @@ from system.plugins.state_store import PluginStateStore if TYPE_CHECKING: - from services.online import OnlineDownloadService, OnlineMusicService - from services.online.cache_cleaner_service import CacheCleanerService + from services.download.online_download_gateway import OnlineDownloadGateway + from services.download.cache_cleaner_service import CacheCleanerService from services.playback.sleep_timer_service import SleepTimerService from system.mpris import MPRISController from system.theme import ThemeManager @@ -43,6 +46,81 @@ logger = logging.getLogger(__name__) +def _can_import_linux_mpris_runtime() -> tuple[bool, Optional[str]]: + try: + import dbus + import dbus.mainloop.glib + import dbus.service + from gi.repository import GLib + + _ = (dbus.mainloop.glib, dbus.service, GLib) + return True, None + except ImportError as exc: + return False, str(exc) + + +def _discover_linux_python_module_roots() -> list[str]: + python_bin = Path("/usr/bin/python3") + if not python_bin.exists(): + return [] + + try: + result = subprocess.run( + [ + str(python_bin), + "-c", + ( + "import importlib, os\n" + "roots = []\n" + "for name in ('dbus', 'gi'):\n" + " try:\n" + " module = importlib.import_module(name)\n" + " except Exception:\n" + " continue\n" + " path = getattr(module, '__file__', None)\n" + " if not path:\n" + " continue\n" + " root = os.path.dirname(os.path.dirname(path))\n" + " if root not in roots:\n" + " roots.append(root)\n" + "print('\\n'.join(roots))\n" + ), + ], + check=True, + capture_output=True, + text=True, + ) + except (OSError, subprocess.CalledProcessError): + return [] + + roots = [] + for line in result.stdout.splitlines(): + root = line.strip() + if root and root not in roots: + roots.append(root) + return roots + + +def _ensure_linux_mpris_runtime() -> tuple[bool, Optional[str]]: + if sys.platform != "linux": + return True, None + + ready, reason = _can_import_linux_mpris_runtime() + if ready: + return True, None + + added = False + for root in reversed(_discover_linux_python_module_roots()): + if root and root not in sys.path: + sys.path.insert(0, root) + added = True + + if added: + importlib.invalidate_caches() + + return _can_import_linux_mpris_runtime() + + class Bootstrap: """ Dependency injection container. @@ -88,13 +166,13 @@ def __init__(self, db_path: str = "Harmony.db"): self._cloud_file_service: Optional[CloudFileService] = None self._cover_service: Optional[CoverService] = None self._file_org_service: Optional["FileOrganizationService"] = None - self._online_music_service: Optional["OnlineMusicService"] = None - self._online_download_service: Optional["OnlineDownloadService"] = None + self._online_download_service: Optional["OnlineDownloadGateway"] = None # Services self._cache_cleaner_service: Optional["CacheCleanerService"] = None self._sleep_timer_service: Optional["SleepTimerService"] = None self._mpris_controller: Optional["MPRISController"] = None + self._mpris_disabled_reason: Optional[str] = None self._plugin_manager: Optional[PluginManager] = None self._plugins_loaded = False @@ -364,34 +442,22 @@ def plugin_manager(self) -> PluginManager: logger.info("[Bootstrap] Plugin loading finished") return self._plugin_manager - def refresh_online_music_service(self) -> "OnlineMusicService": - """Force refresh of online music service with current credentials.""" - self._online_music_service = None + def refresh_online_download_service(self) -> "OnlineDownloadGateway": + """Force refresh of host online download gateway.""" self._online_download_service = None - return self.online_music_service + return self.online_download_service # ===== Online Music ===== @property - def online_music_service(self) -> "OnlineMusicService": - """Get online music service.""" - if self._online_music_service is None: - from services.online import OnlineMusicService - self._online_music_service = OnlineMusicService( - config_manager=self.config, - credential_provider=None - ) - return self._online_music_service - - @property - def online_download_service(self) -> "OnlineDownloadService": - """Get online download service.""" + def online_download_service(self) -> "OnlineDownloadGateway": + """Get host online download gateway.""" if self._online_download_service is None: - from services.online import OnlineDownloadService - self._online_download_service = OnlineDownloadService( + from services.download.online_download_gateway import OnlineDownloadGateway + self._online_download_service = OnlineDownloadGateway( config_manager=self.config, - credential_provider=None, - online_music_service=self.online_music_service + plugin_manager=lambda: self._plugin_manager, + event_bus=self.event_bus, ) return self._online_download_service @@ -399,7 +465,7 @@ def online_download_service(self) -> "OnlineDownloadService": def cache_cleaner_service(self) -> "CacheCleanerService": """Get cache cleaner service.""" if self._cache_cleaner_service is None: - from services.online.cache_cleaner_service import CacheCleanerService + from services.download.cache_cleaner_service import CacheCleanerService self._cache_cleaner_service = CacheCleanerService( config_manager=self.config, download_service=self.online_download_service, @@ -423,18 +489,18 @@ def sleep_timer_service(self) -> "SleepTimerService": def mpris_controller(self) -> "MPRISController": """Get MPRIS D-Bus controller (Linux only).""" if self._mpris_controller is None: - import sys if sys.platform == "linux": - ready = False - try: - import dbus - import dbus.mainloop.glib - import dbus.service - from gi.repository import GLib - _ = (dbus.mainloop.glib, dbus.service, GLib) - ready = True - except ImportError: - pass + if self._mpris_disabled_reason is not None: + return None + ready, reason = _ensure_linux_mpris_runtime() + if not ready: + self._mpris_disabled_reason = reason or "unknown import error" + logger.warning( + "MPRIS disabled: missing Linux D-Bus runtime (%s). " + "Install the optional 'linux' dependencies and ensure system " + "PyGObject bindings are available to the application.", + self._mpris_disabled_reason, + ) if ready: from system.mpris import MPRISController diff --git a/docs/optimization_report.md b/docs/optimization_report.md new file mode 100644 index 00000000..72754f79 --- /dev/null +++ b/docs/optimization_report.md @@ -0,0 +1,921 @@ +# Harmony 代码优化分析报告 + +> 基于对 113,000+ 行 Python 源码的全面审查,覆盖所有架构层。 + +--- + +## 目录 + +1. [概述与优先级总览](#1-概述与优先级总览) +2. [Domain 层优化](#2-domain-层优化) +3. [Repositories 层优化](#3-repositories-层优化) +4. [Services 层优化](#4-services-层优化) +5. [Infrastructure 层优化](#5-infrastructure-层优化) +6. [UI 层优化](#6-ui-层优化) +7. [System 层与启动优化](#7-system-层与启动优化) +8. [Plugin 系统优化](#8-plugin-系统优化) +9. [测试套件优化](#9-测试套件优化) +10. [实施路线图](#10-实施路线图) + +--- + +## 1. 概述与优先级总览 + +### 关键指标 + +| 维度 | 发现数量 | 严重 | 高 | 中 | 低 | +|------|---------|------|---|---|---| +| 性能 | 28 | 5 | 10 | 9 | 4 | +| 内存 | 14 | 3 | 5 | 4 | 2 | +| 线程安全 | 12 | 3 | 4 | 3 | 2 | +| 代码质量 | 22 | 0 | 6 | 10 | 6 | +| 错误处理 | 15 | 2 | 5 | 5 | 3 | +| 安全 | 5 | 1 | 2 | 2 | 0 | +| 测试覆盖 | 10 | 2 | 4 | 3 | 1 | +| **合计** | **106** | **16** | **36** | **36** | **18** | + +### TOP 10 最高优先级问题 + +| # | 问题 | 层 | 影响 | +|---|------|---|------| +| 1 | UI 线程阻塞(数据库查询在主线程执行) | UI | 界面冻结 | +| 2 | N+1 查询(Album/Artist/Genre 封面查找) | Repositories | 数据库性能 | +| 3 | 信号连接未断开导致内存泄漏 | UI / System | 内存增长 | +| 4 | 缓存无大小限制(ImageCache/QSS/SingleFlight) | Infrastructure / System | 内存溢出 | +| 5 | DBWriteWorker 队列无上限 | Infrastructure | 内存溢出 | +| 6 | HTTP 客户端缺少重试逻辑 | Infrastructure | 网络不稳定 | +| 7 | 云服务线程安全问题(Baidu bdstoken、Quark cookie) | Services | 数据损坏 | +| 8 | 播放引擎 `play_after_download()` 竞态条件 | Infrastructure | 播放故障 | +| 9 | 插件全局上下文缺少线程安全保护 | Plugins | 插件崩溃传播 | +| 10 | 关键服务缺少测试(PlaylistService、CoverService 等) | Tests | 质量风险 | + +--- + +## 2. Domain 层优化 + +### 2.1 性能:ID 属性重复计算 [高] + +**文件**: `domain/album.py:35-38`, `domain/artist.py:28-30`, `domain/genre.py:29-31` + +`id` 属性每次访问都执行 `.lower()` 字符串操作。在大型音乐库中,这些对象频繁用于 set/dict 查找,导致不必要的计算开销。 + +```python +# 当前实现 +@property +def id(self) -> str: + return f"{self.artist}:{self.name}".lower() + +# 建议:使用 cached_property +from functools import cached_property + +@cached_property +def id(self) -> str: + return f"{self.artist}:{self.name}".lower() +``` + +### 2.2 内存:缺少 `__slots__` [高] + +**文件**: 所有 dataclass 文件 + +Track、PlaylistItem、OnlineTrack 等高频实例化类未使用 `__slots__`,每个实例额外消耗约 280 字节。以 10,000 首曲目计算,浪费约 2.8 MB。 + +```python +@dataclass +class Track: + __slots__ = ('id', 'title', 'artist', 'album', 'duration', 'path', ...) + # ... +``` + +### 2.3 代码质量:`__hash__`/`__eq__` 重复实现 [中] + +**文件**: `album.py:40-48`, `artist.py:32-40`, `genre.py:33-41` + +三个实体类有完全相同的哈希/相等性实现模式。 + +```python +# 建议:提取 mixin +class HashableById: + @property + def id(self) -> str: + raise NotImplementedError + + def __hash__(self): + return hash(self.id) + + def __eq__(self, other): + if type(self) is type(other): + return self.id == other.id + return False +``` + +### 2.4 时区处理不一致 [中] + +**文件**: `cloud.py`, `history.py`, `playback.py`, `playlist.py`, `track.py` + +多个类在 `__post_init__` 中使用 `datetime.now()` 创建无时区的朴素时间。 + +```python +# 建议:统一使用 UTC +from datetime import datetime, timezone + +created_at: Optional[datetime] = field( + default_factory=lambda: datetime.now(timezone.utc) +) +``` + +### 2.5 PlaylistItem 违反单一职责原则 [中] + +**文件**: `domain/playlist_item.py` + +PlaylistItem 承担了 8 项职责(数据表示、Track/CloudFile/dict 转换、序列化、显示属性等)。建议将转换逻辑提取到独立的 `PlaylistItemConverter` 类。 + +### 2.6 缺少输入验证 [中] + +所有 dataclass 文件均缺少字段约束验证:Album/Artist 名称可为空串,Track duration 可为负数,CloudFile size 可为负数。 + +--- + +## 3. Repositories 层优化 + +### 3.1 N+1 查询模式 [严重] + +**文件**: `album_repository.py:148-161`, `artist_repository.py:122-129`, `genre_repository.py:180-209` + +Album/Artist/Genre 的封面查找使用独立查询,应合并为单条 SQL: + +```sql +-- 当前:2 条查询 +-- 查询1: SELECT album, artist, COUNT(*) ... GROUP BY ... +-- 查询2: SELECT cover_path FROM tracks WHERE ... LIMIT 1 + +-- 建议:合并为 1 条 +SELECT + album AS name, artist, + COUNT(*) AS song_count, + SUM(duration) AS total_duration, + MAX(CASE WHEN cover_path IS NOT NULL THEN cover_path END) AS cover_path +FROM tracks +WHERE album = ? AND artist = ? +GROUP BY album, artist +``` + +**影响**: 每次 Album/Artist 查询减少 50% 数据库调用。 + +### 3.2 Genre 查询使用 `ORDER BY RANDOM()` [高] + +**文件**: `genre_repository.py:38-79` + +Genre 封面选择使用 `ORDER BY RANDOM()`,这在大数据集上极其低效(全表扫描 + 随机排序)。 + +```sql +-- 当前(慢) +SELECT t.cover_path FROM tracks t +WHERE t.genre = g.name AND t.cover_path IS NOT NULL +ORDER BY RANDOM() LIMIT 1 + +-- 建议(快) +SELECT t.cover_path FROM tracks t +WHERE t.genre = g.name AND t.cover_path IS NOT NULL +LIMIT 1 +``` + +### 3.3 缺少批量操作 [高] + +**文件**: `album_repository.py`, `artist_repository.py`, `playlist_repository.py` + +- Album/Artist 封面更新为逐条执行,缺少 `batch_update_cover_paths()` +- Playlist 添加曲目为逐条插入,缺少 `add_tracks()` 批量方法 + +建议使用 `executemany()` 实现批量操作,预计可获得 10 倍性能提升。 + +### 3.4 缓存聚合仓库代码重复 [高] + +**文件**: `album_repository.py`, `artist_repository.py`, `genre_repository.py`, `track_repository.py` + +5 处实现了相同的"先查缓存表,后回退到 tracks 聚合查询"模式,约 200 行重复代码。 + +```python +# 建议:提取基类 +class CachedAggregateRepository(BaseRepository): + def _get_all_with_cache(self, cache_table, cache_query, fallback_query, row_converter, use_cache=True): + # 统一实现缓存回退逻辑 +``` + +### 3.5 事务管理不完整 [中] + +**文件**: `artist_repository.py:146-230`, `track_repository.py:331-387` + +- Artist `refresh()` 多步操作缺少显式事务包装,失败时可能导致数据不一致 +- `batch_add()` 静默忽略 `IntegrityError` 且不记录日志 + +```python +# 建议 +try: + cursor.execute("BEGIN TRANSACTION") + # ... 所有操作 ... + conn.commit() +except Exception: + conn.rollback() + raise +``` + +### 3.6 get_all() 缺少分页 [中] + +**文件**: `album_repository.py`, `artist_repository.py`, `genre_repository.py`, `favorite_repository.py` + +所有 `get_all()` 方法返回全部记录,无 LIMIT/OFFSET,10,000+ 条记录全部加载到内存。 + +### 3.7 缺少索引 [中] + +建议添加: +```sql +CREATE INDEX IF NOT EXISTS idx_albums_name_artist ON albums(name, artist); +CREATE INDEX IF NOT EXISTS idx_artists_normalized_name ON artists(normalized_name); +CREATE INDEX IF NOT EXISTS idx_genres_name ON genres(name); +``` + +--- + +## 4. Services 层优化 + +### 4.1 LibraryService 是 God Object [严重] + +**文件**: `services/library/library_service.py` (940+ 行) + +承担 6 类职责:Track CRUD、搜索、Album/Artist/Genre 聚合、在线曲目管理等。 + +**建议**: 拆分为 `TrackService`、`LibraryAggregateService`、`OnlineTrackService`。 + +### 4.2 文件扫描效率低 [高] + +**文件**: `services/library/library_service.py:416-463` + +`scan_directory()` 使用 `rglob('*')` 遍历全部文件再过滤扩展名,且不检查已有曲目。 + +**建议**: +- 使用增量扫描(基于 mtime 检测新增/修改文件) +- 预先查询已有路径集合,跳过已存在曲目 +- 对文件存在性检查使用 `concurrent.futures` 并行处理 + +### 4.3 歌词文件编码检测低效 [高] + +**文件**: `services/lyrics/lyrics_service.py:351-383` + +每首曲目尝试 3 种扩展名 x 6 种编码 = 最多 18 次文件打开操作。 + +**建议**: 优先尝试 UTF-8,使用 `chardet` 自动检测编码,并缓存检测结果。 + +### 4.4 云服务线程安全问题 [严重] + +| 文件 | 问题 | +|------|------| +| `baidu_service.py:54-56` | `bdstoken` 为类变量,多线程共享且无同步 | +| `baidu_service.py:76-80` | 共享 `requests.Session`,非线程安全 | +| `quark_service.py:65-91` | Cookie 更新非原子操作,并发调用丢失更新 | + +**建议**: 使用 `threading.local()` 存储线程相关状态,对 Session 使用连接池。 + +### 4.5 SingleFlight/Cover 缓存无大小限制 [高] + +**文件**: `lyrics_service.py:34-35`, `cover_service.py:21` + +SingleFlight 缓存无上限,长时间运行可能无限增长。 + +**建议**: 实现 LRU 缓存,设置最大条目数(如 1000 条)。 + +### 4.6 云下载服务竞态条件 [中] + +**文件**: `download_service.py:307-359` + +双重检查锁模式存在竞态:在锁释放和 worker 取消之间,worker 可能被其他线程移除。 + +### 4.7 下载文件异常清理缺失 [中] + +**文件**: `download_service.py:177-237` + +下载过程中发生异常时,不清理残留的部分文件。 + +```python +# 建议 +try: + # 下载逻辑 +except Exception: + if Path(dest_path).exists(): + Path(dest_path).unlink() + raise +``` + +### 4.8 裸 except 吞没异常 [中] + +**文件**: `quark_service.py:435-436` + +```python +except Exception: # 吞没所有异常,包括 KeyboardInterrupt + time.sleep(0.6) +``` + +**建议**: 使用具体异常类型:`except (IOError, TimeoutError, ConnectionError):` + +### 4.9 云服务代码重复 [中] + +- JSON 解析逻辑在 `baidu_service.py` 和 `quark_service.py` 中重复 +- Cookie 处理逻辑重复 +- 下载 URL 获取逻辑重复 + +**建议**: 提取 `CloudStorageService` 抽象基类和共享工具模块。 + +--- + +## 5. Infrastructure 层优化 + +### 5.1 DBWriteWorker 队列无上限 [严重] + +**文件**: `infrastructure/database/db_write_worker.py:43` + +写入队列 `Queue()` 无 `maxsize`,高写入负载下可能耗尽内存。 + +```python +# 建议 +self._queue: queue.Queue = queue.Queue(maxsize=1000) +``` + +### 5.2 播放引擎竞态条件 [严重] + +**文件**: `infrastructure/audio/audio_engine.py:784-795` + +`play_after_download()` 中 `_media_loaded_flag` 在锁外检查后在锁内使用,存在 TOCTOU 竞态。 + +**建议**: 将标志检查移入 `_playlist_lock` 内或使用条件变量。 + +### 5.3 ImageCache 无大小限制 [高] + +**文件**: `infrastructure/cache/image_cache.py:45-68` + +磁盘缓存可无限增长,无最大容量限制。 + +```python +# 建议:添加 500MB 上限和 LRU 驱逐 +MAX_CACHE_SIZE = 500 * 1024 * 1024 + +@classmethod +def _enforce_cache_limit(cls): + total_size = sum(f.stat().st_size for f in cls.CACHE_DIR.glob("*")) + if total_size > cls.MAX_CACHE_SIZE: + # 按最后访问时间删除最旧文件 +``` + +### 5.4 ImageCache 写入非原子 [中] + +**文件**: `infrastructure/cache/image_cache.py:56-68` + +缓存写入过程中断可能导致损坏条目。 + +```python +# 建议:原子写入 +temp_path = cache_path.with_suffix('.tmp') +temp_path.write_bytes(data) +temp_path.replace(cache_path) # 大多数文件系统上是原子操作 +``` + +### 5.5 HTTP 客户端缺少重试逻辑 [高] + +**文件**: `infrastructure/network/http_client.py:104-148` + +单次请求失败即返回,无瞬态故障重试。 + +```python +# 建议 +from urllib3.util.retry import Retry + +retry_strategy = Retry( + total=3, + backoff_factor=1, + status_forcelist=[429, 500, 502, 503, 504], +) +adapter = HTTPAdapter(max_retries=retry_strategy) +``` + +### 5.6 下载进度回调未节流 [中] + +**文件**: `infrastructure/network/http_client.py:264-265` + +每个 chunk 都触发回调,可能每秒数千次,导致 UI 线程过载。 + +```python +# 建议:最多每秒 10 次 +if time.time() - last_update > 0.1: + progress_callback(downloaded, total_size) + last_update = time.time() +``` + +### 5.7 播放列表索引重建低效 [中] + +**文件**: `infrastructure/audio/audio_engine.py:182-189` + +`_rebuild_cloud_file_id_index()` 在每次插入/移除/重排时完整遍历播放列表 (O(n))。 + +**建议**: 增量更新索引而非全量重建。 + +### 5.8 临时文件列表无限增长 [中] + +**文件**: `infrastructure/audio/audio_engine.py:340-357` + +`_temp_files` 列表在长时间运行中持续增长。 + +### 5.9 MPV 滤波器链缺少错误处理 [中] + +**文件**: `infrastructure/audio/mpv_backend.py:429-463` + +无效滤波器语法可能导致播放崩溃。 + +```python +try: + self._player.af = ",".join(filters) +except Exception as e: + logger.error(f"Failed to apply audio filters: {e}") + self._player.af = "" # 回退到无滤波器 +``` + +### 5.10 FTS5 索引在每次架构变更时重建 [中] + +**文件**: `infrastructure/database/sqlite_manager.py:908-943` + +即使不相关的架构变更也会触发完整 FTS 索引重建,大型数据库启动很慢。 + +**建议**: 仅在 FTS 相关迁移发生时重建。 + +--- + +## 6. UI 层优化 + +### 6.1 UI 线程阻塞 [严重] + +**文件**: `ui/views/library_view.py:421-476`, `ui/views/albums_view.py`, `ui/views/artists_view.py`, `ui/views/genres_view.py`, `ui/views/album_view.py`, `ui/views/artist_view.py` + +多个视图在 UI 线程直接执行数据库查询和搜索操作,导致界面冻结。 + +**建议**: +- 将所有数据库查询移至后台线程(QThread 或 ThreadPoolExecutor) +- 使用信号传递结果到 UI 线程 +- 添加加载指示器和取消令牌 + +### 6.2 信号连接泄漏 [高] + +**文件**: `ui/views/library_view.py:181-234`, `ui/views/playlist_view.py:280-303`, `ui/views/local_tracks_list_view.py:567-577`, `ui/widgets/player_controls.py:182-265` + +大量信号连接(30+)在 `_setup_connections()` 中创建,但 `closeEvent()` 中的清理不完整。 + +**建议**: +- 实现完整的信号断开逻辑 +- 使用 `Qt.ConnectionType.SingleShotConnection`(适用时) +- 批量断开信号连接 + +### 6.3 Delegate 实现严重重复 [高] + +| 文件 | 类 | 重复度 | +|------|---|--------| +| `local_tracks_list_view.py:272-512` | `LocalTrackDelegate` | 基准 | +| `history_list_view.py:56-296` | `HistoryItemDelegate` | 95% 重复 | +| `queue_view.py:325-649` | `QueueItemDelegate` | 90% 重复 | + +**建议**: 创建 `BaseTrackDelegate` 基类,提取公共绘制逻辑。 + +### 6.4 封面加载逻辑分散 [高] + +至少 6 处独立实现封面加载逻辑: +- `local_tracks_list_view.py:246-270` (CoverLoadWorker) +- `queue_view.py:253-274` (CoverLoadWorker) +- `albums_view.py:127-226` (使用 QTimer 轮询) +- `genres_view.py:385-427` (同上) +- `artist_view.py:63-86` (无缓存) + +项目已有 `ui/controllers/cover_controller.py`,但未被统一使用。 + +**建议**: 全面改用 `CoverController`,替代分散的本地实现。 + +### 6.5 所有视图预先创建 [中] + +**文件**: `ui/windows/main_window.py:360-393` + +10+ 个视图在 `_setup_ui()` 中全部创建,即使不立即可见。 + +**建议**: 实现按需创建(懒加载),在首次切换到该视图时才初始化。 + +### 6.6 QSS 内联过多 [中] + +**文件**: `albums_view.py` (10+ 处), `artists_view.py` (9+ 处), `genre_view.py` (12+ 处), `album_view.py` (10+ 处), `artist_view.py` (大量内联样式) + +每个小部件都有独立的 `setStyleSheet()` 调用。 + +**建议**: 将 QSS 集中到 `ui/styles/` 目录,使用 `ThemeManager.get_qss()` 统一管理。 + +### 6.7 动画定时器持续运行 [中] + +**文件**: `ui/views/queue_view.py:342-347` + +动画定时器以 300ms 间隔持续运行,即使列表不可见。 + +```python +# 建议 +def hideEvent(self, event): + self._animation_timer.stop() + super().hideEvent(event) + +def showEvent(self, event): + super().showEvent(event) + if self._animation_playing: + self._animation_timer.start() +``` + +### 6.8 异步操作使用轮询模式 [中] + +**文件**: `albums_view.py:200-223`, `genres_view.py:220-242`, `genre_view.py:451-469` + +使用 `QTimer.singleShot(100)` 每 100ms 轮询 `Future` 结果。 + +**建议**: 使用 `Future.add_done_callback()` 替代轮询。 + +### 6.9 ThreadPoolExecutor 未正确清理 [中] + +**文件**: `albums_view.py`, `genres_view.py`, `genre_view.py`, `artist_view.py` + +按需创建 `ThreadPoolExecutor` 但从未调用 `shutdown()`。 + +**建议**: 存储为实例变量,在 `closeEvent()` 中调用 `executor.shutdown(wait=True)`。 + +--- + +## 7. System 层与启动优化 + +### 7.1 EventBus 信号从未断开 [严重] + +**文件**: `system/event_bus.py:44-152` + +EventBus 定义 30+ 信号但无全局断开机制。信号监听器在窗口关闭后仍然存在。 + +```python +# 建议 +def disconnect_all(self): + """断开所有信号连接。""" + for signal in [self.track_changed, self.playback_state_changed, ...]: + try: + signal.disconnect() + except RuntimeError: + pass +``` + +### 7.2 i18n 模块级阻塞加载 [高] + +**文件**: `system/i18n.py:102` + +`load_translations()` 在模块导入时执行,阻塞启动。 + +**建议**: 改为首次使用 `t()` 时懒加载。 + +### 7.3 i18n 缺少线程安全 [高] + +**文件**: `system/i18n.py:11-12, 52-58` + +全局变量 `_current_language` 和 `_translations` 无线程同步保护。 + +**建议**: 添加 `threading.RLock()` 保护读写操作。 + +### 7.4 ThemeManager QSS 缓存无上限 [中] + +**文件**: `system/theme.py:169, 299-323` + +`_qss_cache` 字典无大小限制,大量唯一模板会导致内存增长。 + +**建议**: 使用 `OrderedDict` 实现 LRU 缓存,限制 100 个条目。 + +### 7.5 ConfigManager 配置无 Schema 验证 [中] + +**文件**: `system/config.py:111-127` + +任意 key 可设置任意值,无类型检查。音频效果配置的验证逻辑静默修正无效值而不记录日志。 + +**建议**: 定义 `CONFIG_SCHEMA` 进行类型和范围验证。 + +### 7.6 Application._dispatch_to_ui 方法签名错误 [高] + +**文件**: `app/application.py:108-109` + +```python +def _dispatch_to_ui(fn, *args, **kwargs): # 缺少 self 参数 + QTimer.singleShot(0, lambda: fn(*args, **kwargs)) +``` + +### 7.7 Application.quit() 清理不完整 [高] + +**文件**: `app/application.py:134-150` + +缺少:热键清理、EventBus 信号断开、ThemeManager 清理、PluginManager 清理。 + +```python +# 建议:完整关闭序列 +def quit(self): + self._bootstrap.stop_mpris() + cache_cleaner = self._bootstrap.cache_cleaner_service + if cache_cleaner: + cache_cleaner.stop() + from system.hotkeys import cleanup as cleanup_hotkeys + cleanup_hotkeys() + self._bootstrap.event_bus.disconnect_all() + db = self._bootstrap.db + if db and hasattr(db, '_write_worker') and db._write_worker: + db._write_worker.wait_idle() + db._write_worker.stop() + self._qt_app.quit() +``` + +### 7.8 Bootstrap 缺少清理方法 [中] + +**文件**: `app/bootstrap.py` + +Bootstrap 创建大量服务但无 `cleanup()` 方法来停止它们。 + +### 7.9 全局热键监听器未在退出时清理 [中] + +**文件**: `system/hotkeys.py:27, 220-225` + +Windows 媒体键监听器的后台线程在应用退出时未停止。`application.py` 的 `quit()` 方法中无 `hotkeys.cleanup()` 调用。 + +--- + +## 8. Plugin 系统优化 + +### 8.1 全局上下文缺少线程安全 [高] + +**文件**: `plugins/builtin/qqmusic/lib/runtime_bridge.py:5-23` + +```python +_context = None # 全局变量,无线程保护 + +def bind_context(context) -> None: + global _context + _context = context +``` + +**建议**: 使用 `threading.local()` 或插件级别的上下文隔离。 + +### 8.2 共享客户端竞态条件 [高] + +**文件**: `plugins/builtin/qqmusic/lib/runtime_client.py:7-20` + +```python +_shared_client = None + +def get_shared_client() -> QQMusicClient: + global _shared_client + if _shared_client is None: + _shared_client = QQMusicClient() + return _shared_client +``` + +**建议**: 使用双重检查锁或 `functools.lru_cache`。 + +### 8.3 封面源代码大量重复 [高] + +4 个独立的封面源实现结构完全相同: + +| 文件 | 类 | +|------|---| +| `itunes_cover/lib/cover_source.py:19-64` | iTunesCoverSource | +| `last_fm_cover/lib/cover_source.py:30-82` | LastFmCoverSource | +| `netease_cover/lib/cover_source.py:23-92` | NeteaseCoverSource | +| `qqmusic/lib/cover_source.py:18-69` | QQMusicCoverSource | + +3 个 Artist 封面源同样高度重复。歌词源也存在类似问题。 + +```python +# 建议:创建基类 +class BaseCoverSource: + def search(self, title, artist, album="", duration=None): + try: + url, params = self._build_request(title, artist, album, duration) + response = self._http_client.get(url, params=params, timeout=5) + if response.status_code == 200: + return self._parse_results(response.json()) + except Exception as exc: + logger.debug(f"{self.display_name} search error: {exc}") + return [] +``` + +### 8.4 API 请求无缓存 [中] + +**文件**: `plugins/builtin/qqmusic/lib/api.py:12-270` + +每次 `search()` 调用都发起新的 HTTP 请求,相同关键词的重复搜索浪费带宽。 + +**建议**: 添加请求级缓存 `@lru_cache(maxsize=128)`。 + +### 8.5 硬编码 API Key [中] + +**文件**: `plugins/builtin/last_fm_cover/lib/cover_source.py:16` + +```python +_DEFAULT_API_KEY = "9b0cdcf446cc96dea3e747787ad23575" +``` + +**建议**: 移除硬编码 Key,要求用户配置或使用安全存储。 + +### 8.6 HTTP Session 未关闭 [中] + +**文件**: `plugins/builtin/qqmusic/lib/legacy/client.py:36-42` + +`requests.Session` 在 `__init__` 中创建但从未显式关闭。 + +**建议**: 实现 `__enter__`/`__exit__` 上下文管理器,或在插件卸载时关闭。 + +### 8.7 Plugin API 缺少错误契约 [中] + +**文件**: `packages/harmony-plugin-api/src/harmony_plugin_api/context.py:14-78` + +Protocol 定义使用 `Any` 类型,无错误说明文档。 + +**建议**: 为每个 Protocol 方法添加异常说明。 + +### 8.8 缺少 API 版本兼容性检查 [低] + +**文件**: `packages/harmony-plugin-api/src/harmony_plugin_api/manifest.py:39-99` + +`api_version` 字段存在但加载时不检查兼容性。 + +--- + +## 9. 测试套件优化 + +### 9.1 关键服务缺少测试 [严重] + +| 服务 | 测试状态 | +|------|---------| +| PlaylistService | 无测试 | +| CoverService | 无测试 | +| FileOrganizationService | 无测试 | +| AcoustIDService | 无测试 | +| ShareSearchService | 无测试 | +| Genre domain model | 无专门测试 | + +### 9.2 缺少集成测试 [严重] + +无 `@pytest.mark.integration` 标记的测试。缺少: +- 完整播放工作流测试(本地 → 云 → 在线曲目切换) +- 数据库迁移持久化测试 +- 插件加载和初始化流程测试 + +### 9.3 数据库 Fixture 重复 [高] + +`temp_db` fixture 在 5+ 个测试文件中重复定义,手动创建表结构与实际 schema 耦合。 + +**建议**: 在 `conftest.py` 中使用 `DatabaseManager.init_database()` 创建统一 fixture。 + +### 9.4 Mock 配置不完整 [中] + +**文件**: `tests/test_services/test_library_service.py:70-79` + +Mock 对象未配置返回值,测试可能在不完整的 mock 设置下通过。 + +### 9.5 断言缺少描述信息 [中] + +约 1,400+ 个断言无描述信息,仅约 175 个有描述信息。失败时难以调试。 + +### 9.6 UI 测试缺少行为验证 [中] + +UI 测试主要关注清理和线程管理,缺少:用户交互测试、状态转换测试、数据绑定验证。 + +### 9.7 缺少边界条件测试 [中] + +缺少: +- 超长标题(>1000 字符) +- 无效文件路径 +- 并发访问模式 +- 大型播放列表(10K+ 曲目)内存表现 + +### 9.8 pytest.ini 配置可增强 [低] + +建议添加:覆盖率报告(`--cov`)、最大失败数(`--maxfail=5`)、慢测试统计(`--durations=10`)。 + +--- + +## 10. 实施路线图 + +### 第一阶段:紧急修复(1-2 周) + +| # | 任务 | 影响 | 工作量 | +|---|------|------|--------| +| 1 | 修复 `Application._dispatch_to_ui` 方法签名 | 运行时错误 | 小 | +| 2 | 修复播放引擎 `play_after_download()` 竞态条件 | 播放故障 | 小 | +| 3 | DBWriteWorker 队列添加 maxsize | 内存溢出 | 小 | +| 4 | HTTP 客户端添加重试逻辑 | 网络稳定性 | 小 | +| 5 | ImageCache 添加大小限制 | 磁盘空间 | 小 | +| 6 | 修复 Baidu bdstoken 线程安全 | 数据损坏 | 小 | +| 7 | 修复 Quark Cookie 原子更新 | 数据损坏 | 小 | +| 8 | Application.quit() 添加完整清理 | 资源泄漏 | 中 | + +### 第二阶段:性能优化(2-3 周) + +| # | 任务 | 影响 | 工作量 | +|---|------|------|--------| +| 9 | 合并 N+1 Album/Artist/Genre 查询 | 查询性能 50%↑ | 中 | +| 10 | UI 数据库查询移至后台线程 | 消除界面冻结 | 大 | +| 11 | 统一封面加载到 CoverController | 减少重复 / 内存 | 中 | +| 12 | EventBus 添加 disconnect_all() | 内存泄漏 | 中 | +| 13 | i18n 改为懒加载 + 添加线程安全 | 启动速度 | 小 | +| 14 | ThemeManager QSS 缓存添加 LRU 上限 | 内存 | 小 | +| 15 | 移除 Genre 查询 ORDER BY RANDOM() | 查询性能 100x↑ | 小 | +| 16 | 仓库添加批量操作方法 | 批量操作 10x↑ | 中 | + +### 第三阶段:代码质量(2-3 周) + +| # | 任务 | 影响 | 工作量 | +|---|------|------|--------| +| 17 | 提取 BaseTrackDelegate 消除 Delegate 重复 | 可维护性 | 大 | +| 18 | 提取 CachedAggregateRepository 基类 | 减少 200 行重复 | 中 | +| 19 | 提取云服务抽象基类和工具模块 | 可维护性 | 大 | +| 20 | 提取封面/歌词源基类 | 减少插件代码 50% | 中 | +| 21 | Domain 类添加 `__slots__` | 内存 ~2.8MB↓ | 小 | +| 22 | Domain ID 属性改用 `cached_property` | 性能 | 小 | +| 23 | QSS 集中到 styles 目录 | 可维护性 | 大 | +| 24 | 视图改为按需创建(懒加载) | 启动速度/内存 | 中 | + +### 第四阶段:测试补全(2-3 周) + +| # | 任务 | 影响 | 工作量 | +|---|------|------|--------| +| 25 | 补充 PlaylistService / CoverService 等服务测试 | 质量保证 | 大 | +| 26 | 创建集成测试(播放流程/数据库/插件) | 回归防护 | 大 | +| 27 | 统一 temp_db fixture 到 conftest.py | 可维护性 | 中 | +| 28 | 补充 Genre domain model 测试 | 覆盖率 | 小 | +| 29 | 添加边界条件和错误路径测试 | 健壮性 | 中 | +| 30 | 启用覆盖率报告 | 可观测性 | 小 | + +--- + +## 附录:按文件索引的问题清单 + +
+展开完整文件索引 + +| 文件路径 | 问题编号 | 严重程度 | +|---------|---------|---------| +| `app/application.py:108-109` | 方法签名错误 | 高 | +| `app/application.py:134-150` | 关闭清理不完整 | 高 | +| `app/bootstrap.py` | 缺少 cleanup() 方法 | 中 | +| `domain/album.py:35-38` | ID 重复计算 | 高 | +| `domain/artist.py:28-30` | ID 重复计算 | 高 | +| `domain/genre.py:29-31` | ID 重复计算 | 高 | +| `domain/playlist_item.py` | SRP 违规 | 中 | +| `domain/*.py` | 缺少 __slots__ | 高 | +| `domain/*.py` | 缺少输入验证 | 中 | +| `infrastructure/audio/audio_engine.py:182-189` | 索引全量重建 | 中 | +| `infrastructure/audio/audio_engine.py:340-357` | 临时文件列表无限增长 | 中 | +| `infrastructure/audio/audio_engine.py:784-795` | 竞态条件 | 严重 | +| `infrastructure/audio/mpv_backend.py:429-463` | 滤波器链无错误处理 | 中 | +| `infrastructure/cache/image_cache.py:45-68` | 缓存无大小限制 | 高 | +| `infrastructure/cache/image_cache.py:56-68` | 写入非原子 | 中 | +| `infrastructure/database/db_write_worker.py:43` | 队列无上限 | 严重 | +| `infrastructure/database/sqlite_manager.py:908-943` | FTS 过度重建 | 中 | +| `infrastructure/network/http_client.py:104-148` | 缺少重试 | 高 | +| `infrastructure/network/http_client.py:264-265` | 回调未节流 | 中 | +| `repositories/album_repository.py:148-161` | N+1 查询 | 严重 | +| `repositories/artist_repository.py:122-129` | N+1 查询 | 严重 | +| `repositories/genre_repository.py:38-79` | ORDER BY RANDOM() | 高 | +| `repositories/genre_repository.py:180-209` | N+1 子查询 | 严重 | +| `repositories/album,artist,genre_repository.py` | 缓存模式重复 | 高 | +| `repositories/artist_repository.py:146-230` | 事务管理不完整 | 中 | +| `repositories/*.py get_all()` | 缺少分页 | 中 | +| `services/library/library_service.py` | God Object (940行) | 严重 | +| `services/library/library_service.py:416-463` | 扫描效率低 | 高 | +| `services/lyrics/lyrics_service.py:351-383` | 编码检测低效 | 高 | +| `services/lyrics/lyrics_service.py:34-35` | 缓存无限增长 | 高 | +| `services/cloud/baidu_service.py:54-56` | bdstoken 非线程安全 | 严重 | +| `services/cloud/baidu_service.py:76-80` | Session 非线程安全 | 严重 | +| `services/cloud/quark_service.py:65-91` | Cookie 更新非原子 | 严重 | +| `services/cloud/quark_service.py:435-436` | 裸 except | 中 | +| `services/cloud/download_service.py:177-237` | 异常未清理文件 | 中 | +| `services/cloud/download_service.py:307-359` | 竞态条件 | 中 | +| `system/event_bus.py:44-152` | 信号从不断开 | 严重 | +| `system/i18n.py:102` | 模块级阻塞加载 | 高 | +| `system/i18n.py:11-12` | 缺少线程安全 | 高 | +| `system/theme.py:169, 322` | QSS 缓存无上限 | 中 | +| `system/config.py:237-267` | 配置验证弱 | 中 | +| `system/hotkeys.py:27, 220` | 退出时未清理 | 中 | +| `ui/views/library_view.py:421-476` | UI 线程阻塞 | 严重 | +| `ui/views/local_tracks_list_view.py` | Delegate 重复 | 高 | +| `ui/views/history_list_view.py` | Delegate 重复 | 高 | +| `ui/views/queue_view.py` | Delegate 重复 / 动画未停 | 高/中 | +| `ui/views/albums_view.py:200-223` | 轮询模式 | 中 | +| `ui/windows/main_window.py:360-393` | 视图预先全部创建 | 中 | +| `ui/views/*.py` | QSS 内联过多 | 中 | +| `plugins/builtin/qqmusic/lib/runtime_bridge.py` | 全局上下文无线程安全 | 高 | +| `plugins/builtin/qqmusic/lib/runtime_client.py` | 共享客户端竞态 | 高 | +| `plugins/builtin/*/lib/cover_source.py` | 封面源大量重复 | 高 | +| `plugins/builtin/last_fm_cover/lib/cover_source.py:16` | 硬编码 API Key | 中 | +| `plugins/builtin/qqmusic/lib/legacy/client.py:36-42` | Session 未关闭 | 中 | + +
+ +--- + +*报告生成时间: 2026-04-08* +*分析范围: 113,675 行 Python 源码, 150+ 文件* \ No newline at end of file diff --git a/domain/online_music.py b/domain/online_music.py index 88e497d9..8024b208 100644 --- a/domain/online_music.py +++ b/domain/online_music.py @@ -28,7 +28,7 @@ class OnlineTrack: """ Online track from search result. - Unified format from different API sources (QQ Music, api.ygking.top). + Unified format from different online API sources. """ mid: str = "" # Song MID (unique identifier) diff --git a/domain/playback.py b/domain/playback.py index 27845bda..06ac4cae 100644 --- a/domain/playback.py +++ b/domain/playback.py @@ -31,9 +31,10 @@ class PlayQueueItem: id: Optional[int] = None position: int = 0 # Order in the queue (determines playback order) - source: str = "Local" # TrackSource value: "Local", "QQ", "QUARK", "BAIDU" + source: str = "Local" # TrackSource value: "Local", "ONLINE", "QUARK", "BAIDU" track_id: Optional[int] = None # Track ID in database cloud_file_id: Optional[str] = None # Cloud file ID / Song MID + online_provider_id: Optional[str] = None # Plugin provider id for online tracks cloud_account_id: Optional[int] = None # Cloud account ID local_path: str = "" # Local file path title: str = "" diff --git a/domain/playlist_item.py b/domain/playlist_item.py index b6a6c241..a700bdb1 100644 --- a/domain/playlist_item.py +++ b/domain/playlist_item.py @@ -30,6 +30,7 @@ class PlaylistItem: # Cloud file fields cloud_file_id: Optional[str] = None + online_provider_id: Optional[str] = None cloud_account_id: Optional[int] = None # Common fields @@ -55,7 +56,7 @@ def from_track(cls, track: "Track") -> "PlaylistItem": """ Create a PlaylistItem from a Track. - Handles both local tracks and online tracks (QQ Music, etc.) + Handles both local tracks and plugin-provided online tracks by checking if path is empty (indicating online track needs download). Args: @@ -64,16 +65,17 @@ def from_track(cls, track: "Track") -> "PlaylistItem": Returns: PlaylistItem instance """ - # QQ tracks may have either a virtual path (download required) or a + # Online tracks may have either a virtual path (download required) or a # real cached file path after download. Keep cached files playable. has_cached_local_file = bool(track.path) and os.path.exists(track.path) - is_online = not track.path or (track.source == TrackSource.QQ and not has_cached_local_file) + is_online = not track.path or (track.source == TrackSource.ONLINE and not has_cached_local_file) if is_online: return cls( source=track.source, track_id=track.id, cloud_file_id=track.cloud_file_id, + online_provider_id=track.online_provider_id, local_path="", # No local path yet title=track.title or "", artist=track.artist or "", @@ -90,6 +92,7 @@ def from_track(cls, track: "Track") -> "PlaylistItem": source=track.source, track_id=track.id, cloud_file_id=track.cloud_file_id, + online_provider_id=track.online_provider_id, local_path=track.path, title=track.title or "", artist=track.artist or "", @@ -149,13 +152,14 @@ def from_dict(cls, data: dict) -> "PlaylistItem": # Determine source from saved value or infer from other fields source_str = data.get("source") if source_str: - try: - source = TrackSource(source_str) - except ValueError: - # Fallback to inference if invalid value - source = TrackSource.LOCAL - if data.get("cloud_file_id"): - source = TrackSource.QUARK + source = TrackSource.from_value(source_str) + # Legacy fallback: unknown cloud sources defaulted to QUARK. + if ( + source == TrackSource.LOCAL + and data.get("cloud_file_id") + and str(source_str).strip() not in ("Local",) + ): + source = TrackSource.QUARK else: # Legacy: infer from other fields source = TrackSource.LOCAL @@ -172,6 +176,7 @@ def from_dict(cls, data: dict) -> "PlaylistItem": source=source, track_id=data.get("id"), cloud_file_id=data.get("cloud_file_id"), + online_provider_id=data.get("online_provider_id"), cloud_account_id=data.get("cloud_account_id"), local_path=data.get("path", "") or data.get("local_path", ""), title=data.get("title", ""), @@ -201,6 +206,7 @@ def to_dict(self) -> dict: "cover_path": self.cover_path, "source": self.source.value, "cloud_file_id": self.cloud_file_id, + "online_provider_id": self.online_provider_id, "cloud_account_id": self.cloud_account_id, "needs_download": self.needs_download, "needs_metadata": self.needs_metadata, @@ -218,6 +224,11 @@ def is_local(self) -> bool: """Check if this is a local file.""" return self.source == TrackSource.LOCAL + @property + def is_online(self) -> bool: + """Check if this is an online music item provided by a plugin.""" + return self.source == TrackSource.ONLINE + @property def is_ready(self) -> bool: """Check if the item is ready for playback (has valid local path).""" @@ -265,9 +276,10 @@ def to_play_queue_item(self, position: int = 0) -> "PlayQueueItem": return PlayQueueItem( position=position, - source=self.source.value, # "Local", "QQ", "QUARK", "BAIDU" + source=self.source.value, # "Local", "ONLINE", "QUARK", "BAIDU" track_id=self.track_id, cloud_file_id=self.cloud_file_id, + online_provider_id=self.online_provider_id, cloud_account_id=self.cloud_account_id, local_path=self.local_path, title=self.title, @@ -294,18 +306,15 @@ def from_play_queue_item(cls, item: "PlayQueueItem") -> "PlaylistItem": from pathlib import Path # Determine source from item.source - try: - source = TrackSource(item.source) - except ValueError: - source = TrackSource.LOCAL + source = TrackSource.from_value(item.source) # Determine needs_download based on source and path local_path = item.local_path or "" file_exists = local_path and Path(local_path).exists() needs_download = False - if source == TrackSource.QQ: - # QQ Music tracks need download if file doesn't exist + if source == TrackSource.ONLINE: + # Online tracks need download if file doesn't exist needs_download = not file_exists if not file_exists: local_path = "" @@ -319,6 +328,7 @@ def from_play_queue_item(cls, item: "PlayQueueItem") -> "PlaylistItem": source=source, track_id=item.track_id, cloud_file_id=item.cloud_file_id, + online_provider_id=item.online_provider_id, cloud_account_id=item.cloud_account_id, local_path=local_path, title=item.title or "", @@ -369,6 +379,7 @@ def with_metadata( source=self.source, track_id=track_id if track_id is not None else self.track_id, cloud_file_id=self.cloud_file_id, + online_provider_id=self.online_provider_id, cloud_account_id=self.cloud_account_id, local_path=local_path if local_path is not None else self.local_path, title=title if title is not None else self.title, diff --git a/domain/track.py b/domain/track.py index e90f2ff3..78a07729 100644 --- a/domain/track.py +++ b/domain/track.py @@ -15,9 +15,22 @@ class TrackSource(str, Enum): """Track source enumeration.""" LOCAL = "Local" # 本地歌曲 + ONLINE = "ONLINE" # 在线音乐(由插件提供) QUARK = "QUARK" # 夸克网盘 BAIDU = "BAIDU" # 百度网盘 - QQ = "QQ" # QQ音乐(网络歌曲) + + @classmethod + def from_value(cls, value: str | None) -> "TrackSource": + """Parse persisted source values with backward-compatible aliases.""" + if not value: + return cls.LOCAL + normalized = str(value).strip() + if normalized in ("QQ", "online", "Online"): + return cls.ONLINE + try: + return cls(normalized) + except ValueError: + return cls.LOCAL @dataclass @@ -37,7 +50,8 @@ class Track: cover_path: Optional[str] = None created_at: Optional[datetime] = None cloud_file_id: Optional[str] = None # Cloud file ID if downloaded from cloud - source: TrackSource = TrackSource.LOCAL # Track source: Local, QUARK, BAIDU, QQ + source: TrackSource = TrackSource.LOCAL # Track source: Local, ONLINE, QUARK, BAIDU + online_provider_id: Optional[str] = None file_size: Optional[int] = None file_mtime: Optional[float] = None @@ -61,3 +75,8 @@ def artist_album(self) -> str: if self.album and self.album != self.artist: parts.append(self.album) return " - ".join(parts) if parts else "Unknown" + + @property + def is_online(self) -> bool: + """Check if this track comes from an online music provider.""" + return self.source == TrackSource.ONLINE diff --git a/infrastructure/database/sqlite_manager.py b/infrastructure/database/sqlite_manager.py index f3f2be52..a2faa65f 100644 --- a/infrastructure/database/sqlite_manager.py +++ b/infrastructure/database/sqlite_manager.py @@ -131,6 +131,8 @@ def _init_database(self): TEXT DEFAULT 'Local', + online_provider_id + TEXT, created_at TIMESTAMP DEFAULT @@ -549,6 +551,8 @@ def _init_database(self): INTEGER, cloud_file_id TEXT, + online_provider_id + TEXT, cloud_account_id INTEGER, local_path @@ -722,16 +726,12 @@ def _get_track_source_from_row(self, row) -> TrackSource: """ if "source" not in row.keys() or not row["source"]: return TrackSource.LOCAL - try: - return TrackSource(row["source"]) - except ValueError: - # Invalid source value, fallback to Local - return TrackSource.LOCAL + return TrackSource.from_value(row["source"]) def _run_migrations(self, conn, cursor): """Run database migrations for schema updates.""" # Current schema version - increment when making schema changes - CURRENT_SCHEMA_VERSION = 9 + CURRENT_SCHEMA_VERSION = 10 # Create db_meta table for schema version tracking cursor.execute(""" @@ -859,7 +859,7 @@ def _run_migrations(self, conn, cursor): # Copy and transform data # source_type + cloud_type -> source # 'local' + '' -> 'Local' - # 'online' + 'QQ' -> 'QQ' + # 'online' + any provider -> 'ONLINE' # 'cloud' + 'quark' -> 'QUARK' # 'cloud' + 'baidu' -> 'BAIDU' cursor.execute(""" @@ -870,7 +870,7 @@ def _run_migrations(self, conn, cursor): id, position, CASE WHEN source_type = 'local' THEN 'Local' - WHEN source_type = 'online' THEN 'QQ' + WHEN source_type = 'online' THEN 'ONLINE' WHEN source_type = 'cloud' THEN UPPER(cloud_type) ELSE 'Local' END, @@ -897,6 +897,16 @@ def _run_migrations(self, conn, cursor): if "download_failed" not in pq_columns: cursor.execute("ALTER TABLE play_queue ADD COLUMN download_failed INTEGER DEFAULT 0") + cursor.execute("PRAGMA table_info(tracks)") + track_columns = {row[1] for row in cursor.fetchall()} + if "online_provider_id" not in track_columns: + cursor.execute("ALTER TABLE tracks ADD COLUMN online_provider_id TEXT") + + cursor.execute("PRAGMA table_info(play_queue)") + pq_columns = {row[1] for row in cursor.fetchall()} + if "online_provider_id" not in pq_columns: + cursor.execute("ALTER TABLE play_queue ADD COLUMN online_provider_id TEXT") + # Migration 2: Initialize FTS5 index for existing tracks # Only validate/rebuild FTS when schema has changed (not on every startup) cursor.execute("SELECT name FROM sqlite_master WHERE type='table' AND name='tracks_fts'") diff --git a/packages/harmony-plugin-api/src/harmony_plugin_api/__init__.py b/packages/harmony-plugin-api/src/harmony_plugin_api/__init__.py index 72a00b94..ea5448c4 100644 --- a/packages/harmony-plugin-api/src/harmony_plugin_api/__init__.py +++ b/packages/harmony-plugin-api/src/harmony_plugin_api/__init__.py @@ -2,6 +2,7 @@ PluginContext, PluginDialogBridge, PluginMediaBridge, + PluginRuntimeBridge, PluginServiceBridge, PluginSettingsBridge, PluginStorageBridge, @@ -37,6 +38,7 @@ "PluginMediaBridge", "PluginOnlineProvider", "PluginPlaybackRequest", + "PluginRuntimeBridge", "PluginServiceBridge", "PluginSettingsBridge", "PluginStorageBridge", diff --git a/packages/harmony-plugin-api/src/harmony_plugin_api/context.py b/packages/harmony-plugin-api/src/harmony_plugin_api/context.py index 069d362b..c18fdee6 100644 --- a/packages/harmony-plugin-api/src/harmony_plugin_api/context.py +++ b/packages/harmony-plugin-api/src/harmony_plugin_api/context.py @@ -112,6 +112,65 @@ def media(self) -> PluginMediaBridge: ... +class PluginRuntimeBridge(Protocol): + def get_icon(self, name, color, size: int = 16): + ... + + def image_cache_get(self, url: str): + ... + + def image_cache_set(self, url: str, image_data: bytes): + ... + + def image_cache_path(self, url: str): + ... + + def http_get_content( + self, + url: str, + *, + timeout: int, + headers: dict[str, str] | None = None, + ): + ... + + def cover_pixmap_cache_initialize(self) -> None: + ... + + def cover_pixmap_cache_get(self, cache_key: str): + ... + + def cover_pixmap_cache_set(self, cache_key: str, pixmap) -> None: + ... + + def bootstrap(self): + ... + + def library_service(self): + ... + + def favorites_service(self): + ... + + def favorite_mids_from_library(self) -> set[str]: + ... + + def remove_library_favorite_by_mid(self, mid: str) -> bool: + ... + + def add_requests_to_favorites(self, requests): + ... + + def add_requests_to_playlist(self, parent, requests, log_prefix: str): + ... + + def add_track_ids_to_playlist(self, parent, track_ids, log_prefix: str) -> None: + ... + + def event_bus(self): + ... + + @dataclass(frozen=True) class PluginContext: plugin_id: str @@ -124,3 +183,4 @@ class PluginContext: settings: PluginSettingsBridge ui: PluginUiBridge services: PluginServiceBridge + runtime: PluginRuntimeBridge diff --git a/packages/harmony-plugin-api/src/harmony_plugin_api/online.py b/packages/harmony-plugin-api/src/harmony_plugin_api/online.py index f938f7a2..e2d62df9 100644 --- a/packages/harmony-plugin-api/src/harmony_plugin_api/online.py +++ b/packages/harmony-plugin-api/src/harmony_plugin_api/online.py @@ -20,3 +20,25 @@ def get_playback_url_info( quality: str, ) -> dict[str, Any] | None: ... + + def download_track( + self, + track_id: str, + quality: str, + target_dir: str | None = None, + progress_callback: Any = None, + force: bool = False, + ) -> str | dict[str, Any] | None: + ... + + def get_download_qualities(self, track_id: str) -> list[dict[str, str]] | list[str]: + ... + + def redownload_track( + self, + track_id: str, + quality: str, + target_dir: str | None = None, + progress_callback: Any = None, + ) -> str | dict[str, Any] | None: + ... diff --git a/plugins/builtin/qqmusic/lib/legacy/adapter.py b/plugins/builtin/qqmusic/lib/adapter.py similarity index 100% rename from plugins/builtin/qqmusic/lib/legacy/adapter.py rename to plugins/builtin/qqmusic/lib/adapter.py diff --git a/plugins/builtin/qqmusic/lib/api.py b/plugins/builtin/qqmusic/lib/api.py index 2d562059..5132ff43 100644 --- a/plugins/builtin/qqmusic/lib/api.py +++ b/plugins/builtin/qqmusic/lib/api.py @@ -1,6 +1,6 @@ from __future__ import annotations -from typing import Optional +from typing import Any, Optional class QQMusicPluginAPI: @@ -15,16 +15,23 @@ def search( search_type: str = "song", limit: int = 20, page: int = 1, - ) -> dict[str, list[dict]]: + ) -> dict[str, Any]: response = self._context.http.get( f"{self.REMOTE_BASE_URL}/search", params={"keyword": keyword, "type": search_type, "num": limit, "page": page}, timeout=10, ) data = response.json() - items = data.get("data", {}).get("list", []) + payload = data.get("data", {}) if isinstance(data.get("data"), dict) else {} + items = payload.get("list", []) + if not isinstance(items, list): + items = [] + total = self._extract_search_total(data, payload, items) if search_type == "song": - return {"tracks": [self._format_song_item(song) for song in items[:limit]]} + return { + "tracks": [self._format_song_item(song) for song in items[:limit]], + "total": total, + } if search_type == "singer": return { "artists": [ @@ -38,7 +45,8 @@ def search( "fan_count": item.get("fansNum", item.get("fan_count", item.get("FanNum", 0))), } for item in items[:limit] - ] + ], + "total": total, } if search_type == "album": return { @@ -53,7 +61,8 @@ def search( "publish_date": item.get("publish_date", item.get("pubTime", "")), } for item in items[:limit] - ] + ], + "total": total, } return { "playlists": [ @@ -67,9 +76,54 @@ def search( "play_count": item.get("listennum", item.get("play_count", 0)), } for item in items[:limit] - ] + ], + "total": total, } + @staticmethod + def _extract_search_total(raw_data: dict[str, Any], payload: dict[str, Any], items: list[Any]) -> int: + """Extract total hit count from heterogeneous search payloads.""" + total_keys = ( + "total", + "totalnum", + "totalNum", + "record_num", + "recordNum", + "count", + "sum", + "sum_count", + ) + + def _to_non_negative_int(value: Any) -> Optional[int]: + if value is None or isinstance(value, bool): + return None + try: + parsed = int(value) + except (TypeError, ValueError): + return None + return parsed if parsed >= 0 else None + + candidates: list[dict[str, Any]] = [payload] + if isinstance(raw_data, dict): + candidates.append(raw_data) + raw_data_payload = raw_data.get("data") + if isinstance(raw_data_payload, dict): + candidates.append(raw_data_payload) + meta = raw_data_payload.get("meta") + if isinstance(meta, dict): + candidates.append(meta) + extra_data = raw_data_payload.get("data") + if isinstance(extra_data, dict): + candidates.append(extra_data) + + for container in candidates: + for key in total_keys: + parsed = _to_non_negative_int(container.get(key)) + if parsed is not None: + return parsed + + return len(items) + def search_artist( self, keyword: str, diff --git a/plugins/builtin/qqmusic/lib/client.py b/plugins/builtin/qqmusic/lib/client.py index 3a5ebfcd..36cf562a 100644 --- a/plugins/builtin/qqmusic/lib/client.py +++ b/plugins/builtin/qqmusic/lib/client.py @@ -4,7 +4,7 @@ from typing import Any from .api import QQMusicPluginAPI -from .legacy.qqmusic_service import QQMusicService +from .qqmusic_service import QQMusicService class QQMusicPluginClient: @@ -24,7 +24,7 @@ def _get_service(self) -> QQMusicService | None: credential = self._get_credential() if not credential: return None - return QQMusicService(credential) + return QQMusicService(credential, http_client=self._context.http) def _can_use_legacy_network(self) -> bool: if self._legacy_network_reachable is not None: @@ -56,12 +56,26 @@ def search( # Prefer QQ Music direct client when logged in if self._get_credential() and self._can_use_legacy_network(): result = self._search_legacy(keyword, search_type, page, limit) - if result: + if self._has_search_results(result, search_type): return result # Fallback to remote API return self._api.search(keyword, search_type=search_type, limit=limit, page=page) + @staticmethod + def _has_search_results(result: dict[str, Any] | None, search_type: str) -> bool: + if not isinstance(result, dict): + return False + key_by_type = { + "song": "tracks", + "singer": "artists", + "album": "albums", + "playlist": "playlists", + } + result_key = key_by_type.get(search_type, "tracks") + items = result.get(result_key, []) + return isinstance(items, list) and len(items) > 0 + def _search_legacy( self, keyword: str, diff --git a/plugins/builtin/qqmusic/lib/legacy_config_adapter.py b/plugins/builtin/qqmusic/lib/config_adapter.py similarity index 97% rename from plugins/builtin/qqmusic/lib/legacy_config_adapter.py rename to plugins/builtin/qqmusic/lib/config_adapter.py index b9dc7e7e..d2e40f45 100644 --- a/plugins/builtin/qqmusic/lib/legacy_config_adapter.py +++ b/plugins/builtin/qqmusic/lib/config_adapter.py @@ -1,7 +1,7 @@ from __future__ import annotations -class QQMusicLegacyConfigAdapter: +class QQMusicConfigAdapter: def __init__(self, settings) -> None: self._settings = settings diff --git a/plugins/builtin/qqmusic/lib/legacy/crypto.py b/plugins/builtin/qqmusic/lib/crypto.py similarity index 100% rename from plugins/builtin/qqmusic/lib/legacy/crypto.py rename to plugins/builtin/qqmusic/lib/crypto.py diff --git a/plugins/builtin/qqmusic/lib/legacy/__init__.py b/plugins/builtin/qqmusic/lib/legacy/__init__.py deleted file mode 100644 index 4faea47d..00000000 --- a/plugins/builtin/qqmusic/lib/legacy/__init__.py +++ /dev/null @@ -1 +0,0 @@ -"""Legacy QQ Music local API implementation kept with the plugin.""" diff --git a/plugins/builtin/qqmusic/lib/legacy/common.py b/plugins/builtin/qqmusic/lib/legacy/common.py deleted file mode 100644 index e64f48d5..00000000 --- a/plugins/builtin/qqmusic/lib/legacy/common.py +++ /dev/null @@ -1,265 +0,0 @@ -""" -QQ Music common utilities and constants. -""" - -import random -import time -from enum import Enum -from typing import Dict - -import requests -from requests.adapters import HTTPAdapter - - -class SongFileType: - """Song file type mappings for different quality levels.""" - - MASTER = {'s': 'AI00', 'e': '.flac'} - ATMOS_2 = {'s': 'Q000', 'e': '.flac'} - ATMOS_51 = {'s': 'Q001', 'e': '.flac'} - DOLBY = {'s': 'RS01', 'e': '.flac'} - HIRES = {'s': 'SQ00', 'e': '.flac'} - FLAC = {'s': 'F000', 'e': '.flac'} - APE = {'s': 'A000', 'e': '.ape'} - DTS = {'s': 'D000', 'e': '.dts'} - MP3_320 = {'s': 'M800', 'e': '.mp3'} - MP3_128 = {'s': 'M500', 'e': '.mp3'} - OGG_640 = {'s': 'O801', 'e': '.ogg'} - OGG_320 = {'s': 'O800', 'e': '.ogg'} - OGG_192 = {'s': 'O600', 'e': '.ogg'} - OGG_96 = {'s': 'O400', 'e': '.ogg'} - AAC_320 = {'s': 'C800', 'e': '.m4a'} - AAC_256 = {'s': 'C700', 'e': '.m4a'} - AAC_192 = {'s': 'C600', 'e': '.m4a'} - AAC_128 = {'s': 'C500', 'e': '.m4a'} - AAC_96 = {'s': 'C400', 'e': '.m4a'} - AAC_64 = {'s': 'C300', 'e': '.m4a'} - AAC_48 = {'s': 'C200', 'e': '.m4a'} - AAC_24 = {'s': 'C100', 'e': '.m4a'} - - -class SearchType(Enum): - """Search type enumeration.""" - - SONG = 0 - SINGER = 1 - ALBUM = 2 - PLAYLIST = 3 - MV = 4 - LYRIC = 7 - USER = 8 - - -class APIConfig: - """QQ Music API configuration.""" - - VERSION = "13.2.5.8" - VERSION_CODE = 13020508 - # Use musicu.fcg endpoint (no sign required for most APIs) - ENDPOINT = "https://u.y.qq.com/cgi-bin/musicu.fcg" - # Signed endpoint for specific APIs - ENDPOINT_SIGNED = "https://u.y.qq.com/cgi-bin/musics.fcg" - - # Quality fallback order - QUALITY_FALLBACK = [ - "master", - "atmos_2", - "atmos_51", - "dolby", - "hires", - "flac", - "ape", - "dts", - "ogg_640", - "320", - "ogg_320", - "aac_320", - "aac_256", - "aac_192", - "ogg_192", - "128", - "aac_128", - "aac_96", - "ogg_96", - "aac_64", - "aac_48", - "aac_24", - ] - - -_QUALITY_ALIASES = { - # Legacy / internal aliases - "atmos": "atmos_2", - "192": "ogg_192", - "96": "ogg_96", - # Chinese quality names - "标准": "128", - "hq高品质": "320", - "sq无损品质": "flac", - "臻品母带3.0": "master", - "臻品全景声2.0": "atmos_2", - "臻品音质2.0": "atmos_51", - "ogg高品质": "ogg_320", - "ogg标准": "ogg_192", - "aac高品质": "aac_192", - "aac标准": "aac_96", -} - - -_QUALITY_FILE_MAP = { - "master": SongFileType.MASTER, - "atmos_2": SongFileType.ATMOS_2, - "atmos_51": SongFileType.ATMOS_51, - "dolby": SongFileType.DOLBY, - "hires": SongFileType.HIRES, - "flac": SongFileType.FLAC, - "ape": SongFileType.APE, - "dts": SongFileType.DTS, - "320": SongFileType.MP3_320, - "128": SongFileType.MP3_128, - "ogg_640": SongFileType.OGG_640, - "ogg_320": SongFileType.OGG_320, - "ogg_192": SongFileType.OGG_192, - "ogg_96": SongFileType.OGG_96, - "aac_320": SongFileType.AAC_320, - "aac_256": SongFileType.AAC_256, - "aac_192": SongFileType.AAC_192, - "aac_128": SongFileType.AAC_128, - "aac_96": SongFileType.AAC_96, - "aac_64": SongFileType.AAC_64, - "aac_48": SongFileType.AAC_48, - "aac_24": SongFileType.AAC_24, -} - -_QUALITY_LABEL_KEYS = { - "master": "qqmusic_quality_master", - "atmos_2": "qqmusic_quality_atmos_2", - "atmos_51": "qqmusic_quality_atmos_51", - "dolby": "qqmusic_quality_dolby", - "hires": "qqmusic_quality_hires", - "flac": "qqmusic_quality_flac", - "ape": "qqmusic_quality_ape", - "dts": "qqmusic_quality_dts", - "ogg_640": "qqmusic_quality_ogg_640", - "320": "qqmusic_quality_320", - "ogg_320": "qqmusic_quality_ogg_320", - "aac_320": "qqmusic_quality_aac_320", - "aac_256": "qqmusic_quality_aac_256", - "aac_192": "qqmusic_quality_aac_192", - "ogg_192": "qqmusic_quality_ogg_192", - "128": "qqmusic_quality_128", - "aac_128": "qqmusic_quality_aac_128", - "aac_96": "qqmusic_quality_aac_96", - "ogg_96": "qqmusic_quality_ogg_96", - "aac_64": "qqmusic_quality_aac_64", - "aac_48": "qqmusic_quality_aac_48", - "aac_24": "qqmusic_quality_aac_24", -} - - -def create_qq_session(pool_size: int = 20, pool_block: bool = True) -> requests.Session: - """ - Create a requests session tuned for concurrent QQ Music API access. - - Args: - pool_size: Max number of kept/reused connections per host. - pool_block: Whether to block instead of creating throwaway sockets. - - Returns: - Configured requests session. - """ - session = requests.Session() - adapter = HTTPAdapter( - pool_connections=pool_size, - pool_maxsize=pool_size, - pool_block=pool_block, - ) - session.mount("https://", adapter) - session.mount("http://", adapter) - return session - - -def get_guid() -> str: - """ - Generate random 32-character GUID. - - Returns: - Random GUID string - """ - chars = "abcdef1234567890" - return ''.join(random.choice(chars) for _ in range(32)) - - -def get_search_id() -> str: - """ - Generate search ID. - - Returns: - Search ID string - """ - e = random.randint(1, 20) - t = e * 18014398509481984 - n = random.randint(0, 4194303) * 4294967296 - r = int(time.time() * 1000) % (24 * 60 * 60 * 1000) - return str(t + n + r) - - -def parse_search_type(type_str: str) -> SearchType: - """ - Parse search type string to enum. - - Args: - type_str: Search type string - - Returns: - SearchType enum value - """ - type_map = { - 'song': SearchType.SONG, - 'singer': SearchType.SINGER, - 'album': SearchType.ALBUM, - 'playlist': SearchType.PLAYLIST, - 'mv': SearchType.MV, - 'lyric': SearchType.LYRIC, - 'user': SearchType.USER, - } - return type_map.get(type_str.lower() if type_str else '', SearchType.SONG) - - -def parse_quality(quality: str) -> Dict[str, str]: - """ - Parse quality string to file type mapping. - - Args: - quality: Quality string (e.g., 'flac', '320', 'master') - - Returns: - Dictionary with 's' (prefix) and 'e' (extension) keys - """ - normalized = normalize_quality(quality) - return _QUALITY_FILE_MAP.get(normalized, SongFileType.MP3_128) - - -def normalize_quality(quality: str) -> str: - """ - Normalize quality input to internal quality code. - - Args: - quality: Quality code, alias, or display name. - - Returns: - Internal quality code string. - """ - value = str(quality or "").strip().lower() - return _QUALITY_ALIASES.get(value, value) - - -def get_selectable_qualities() -> list[str]: - """Return quality codes for UI selection in fallback order.""" - return list(APIConfig.QUALITY_FALLBACK) - - -def get_quality_label_key(quality: str) -> str: - """Get i18n key for a quality code.""" - normalized = normalize_quality(quality) - return _QUALITY_LABEL_KEYS.get(normalized, "") diff --git a/plugins/builtin/qqmusic/lib/login_dialog.py b/plugins/builtin/qqmusic/lib/login_dialog.py index f65d16cb..a5ba67c6 100644 --- a/plugins/builtin/qqmusic/lib/login_dialog.py +++ b/plugins/builtin/qqmusic/lib/login_dialog.py @@ -43,9 +43,10 @@ class QRLoginThread(QThread): login_timeout = Signal() # QR code expired status_update = Signal(str) # status message - def __init__(self, login_type: str = 'qq'): + def __init__(self, login_type: str = 'qq', http_client=None): super().__init__() self.login_type = login_type + self._http_client = http_client self._running = True def stop(self): @@ -59,7 +60,7 @@ def run(self): is_wechat = self.login_type == 'wx' logger.info(f"Starting QR login with type: {self.login_type} (is_wechat: {is_wechat})") - client = QQMusicQRLogin() + client = QQMusicQRLogin(http_client=self._http_client) # Get QR code app_name = t("qqmusic_wx_login").replace("登录", "").strip() if is_wechat else "QQ" @@ -458,7 +459,7 @@ def _start_login(self): self._status_label.setText(t("qqmusic_fetching_qr")) # Create new thread - thread = QRLoginThread(self._login_type) + thread = QRLoginThread(self._login_type, http_client=self._context.http) thread.qr_code_ready.connect(self._on_qr_code_ready) thread.login_success.connect(self._on_login_success) thread.login_failed.connect(self._on_login_failed) @@ -559,8 +560,8 @@ def _on_login_success(self, credential: dict): nick = credential.get("nick") or credential.get("nickname") or "" if not nick: try: - from .legacy.qqmusic_service import QQMusicService - service = QQMusicService(credential) + from .qqmusic_service import QQMusicService + service = QQMusicService(credential, http_client=self._context.http) verify_result = service.client.verify_login() if isinstance(verify_result, dict) and verify_result.get("valid"): nick = str(verify_result.get("nick", "") or "") diff --git a/plugins/builtin/qqmusic/lib/online_music_view.py b/plugins/builtin/qqmusic/lib/online_music_view.py index 4b65b122..cc8c21cb 100644 --- a/plugins/builtin/qqmusic/lib/online_music_view.py +++ b/plugins/builtin/qqmusic/lib/online_music_view.py @@ -2525,11 +2525,8 @@ def _display_artists(self, artists: List[OnlineArtist], is_append: bool = False) else: self._singers_page.load_data(artists) - # Show "load more" button if there are more results - has_more = len(artists) >= self._grid_page_size and ( - self._grid_total == 0 or # Unknown total, assume more - self._grid_page * self._grid_page_size < self._grid_total - ) + # Show "load more" button using total-first strategy. + has_more = self._has_more_grid_items(len(artists)) self._singers_page.set_has_more(has_more) def _display_albums(self, albums: List[OnlineAlbum], is_append: bool = False): @@ -2539,11 +2536,7 @@ def _display_albums(self, albums: List[OnlineAlbum], is_append: bool = False): else: self._albums_page.load_data(albums) - # Show "load more" button if there are more results - has_more = len(albums) >= self._grid_page_size and ( - self._grid_total == 0 or - self._grid_page * self._grid_page_size < self._grid_total - ) + has_more = self._has_more_grid_items(len(albums)) self._albums_page.set_has_more(has_more) def _display_playlists(self, playlists: List[OnlinePlaylist], is_append: bool = False): @@ -2553,13 +2546,17 @@ def _display_playlists(self, playlists: List[OnlinePlaylist], is_append: bool = else: self._playlists_page.load_data(playlists) - # Show "load more" button if there are more results - has_more = len(playlists) >= self._grid_page_size and ( - self._grid_total == 0 or - self._grid_page * self._grid_page_size < self._grid_total - ) + has_more = self._has_more_grid_items(len(playlists)) self._playlists_page.set_has_more(has_more) + def _has_more_grid_items(self, current_page_count: int) -> bool: + """Determine whether grid result types should show load-more.""" + if self._grid_total > 0: + return current_page_count > 0 and ( + self._grid_page * self._grid_page_size < self._grid_total + ) + return current_page_count >= self._grid_page_size + def _on_tab_changed(self, index: int): """Handle tab change.""" type_map = { @@ -2576,7 +2573,7 @@ def _on_tab_changed(self, index: int): self._grid_page = 1 # Reset grid page for new tab self._do_search() - def _on_artist_clicked(self, artist: OnlineArtist): + def _on_artist_clicked(self, artist: OnlineArtist | dict): """Handle artist click - show artist detail view.""" # Push navigation state if we're coming from search results or grid view if self._stack.currentWidget() in [self._results_page]: @@ -2584,10 +2581,15 @@ def _on_artist_clicked(self, artist: OnlineArtist): 'page': 'results', 'tab': 'artists' if self._results_stack.currentWidget() == self._singers_page else 'other' }) - self._detail_view.load_artist(artist.mid, artist.name) + artist_mid = artist.mid if hasattr(artist, "mid") else str(artist.get("mid", "")) + artist_name = artist.name if hasattr(artist, "name") else str(artist.get("name", "")) + if not artist_mid: + logger.warning("[QQMusic] Artist item missing mid: %s", artist) + return + self._detail_view.load_artist(artist_mid, artist_name) self._stack.setCurrentWidget(self._detail_view) - def _on_album_clicked(self, album: OnlineAlbum): + def _on_album_clicked(self, album: OnlineAlbum | dict): """Handle album click - show album detail view.""" # Push navigation state if we're coming from search results or detail view current_widget = self._stack.currentWidget() @@ -2603,10 +2605,16 @@ def _on_album_clicked(self, album: OnlineAlbum): 'type': self._detail_view._detail_type, 'mid': self._detail_view._mid }) - self._detail_view.load_album(album.mid, album.name, album.singer_name) + album_mid = album.mid if hasattr(album, "mid") else str(album.get("mid", "")) + album_name = album.name if hasattr(album, "name") else str(album.get("name", "")) + singer_name = album.singer_name if hasattr(album, "singer_name") else str(album.get("singer_name", "")) + if not album_mid: + logger.warning("[QQMusic] Album item missing mid: %s", album) + return + self._detail_view.load_album(album_mid, album_name, singer_name) self._stack.setCurrentWidget(self._detail_view) - def _on_playlist_clicked(self, playlist: OnlinePlaylist): + def _on_playlist_clicked(self, playlist: OnlinePlaylist | dict): """Handle playlist click - show playlist detail view.""" # Push navigation state if we're coming from search results or detail view current_widget = self._stack.currentWidget() @@ -2622,7 +2630,15 @@ def _on_playlist_clicked(self, playlist: OnlinePlaylist): 'type': self._detail_view._detail_type, 'mid': self._detail_view._mid }) - self._detail_view.load_playlist(playlist.id, playlist.title, playlist.creator) + playlist_id = playlist.id if hasattr(playlist, "id") else str(playlist.get("id", "")) + playlist_title = playlist.title if hasattr(playlist, "title") else str(playlist.get("title", "")) + playlist_creator = ( + playlist.creator if hasattr(playlist, "creator") else str(playlist.get("creator", "")) + ) + if not playlist_id: + logger.warning("[QQMusic] Playlist item missing id: %s", playlist) + return + self._detail_view.load_playlist(playlist_id, playlist_title, playlist_creator) self._stack.setCurrentWidget(self._detail_view) def _on_load_more_artists(self): diff --git a/plugins/builtin/qqmusic/lib/plugin_online_download_service.py b/plugins/builtin/qqmusic/lib/plugin_online_download_service.py new file mode 100644 index 00000000..04a013ad --- /dev/null +++ b/plugins/builtin/qqmusic/lib/plugin_online_download_service.py @@ -0,0 +1,148 @@ +from __future__ import annotations + +import os +from typing import Any, Optional + +from .common import normalize_quality, parse_quality + + +class PluginOnlineDownloadService: + """Plugin-local downloader for QQ Music online tracks.""" + + _CACHE_EXTENSIONS = ( + ".flac", + ".mp3", + ".ogg", + ".opus", + ".m4a", + ".mp4", + ".ape", + ".dts", + ".wav", + ) + + def __init__( + self, + context, + config_manager=None, + credential_provider=None, + online_music_service=None, + download_dir: Optional[str] = None, + ): + self._context = context + self._config = config_manager + self._provider = credential_provider + self._online_service = online_music_service + self._download_dir = download_dir or self._get_default_download_dir() + self._last_download_qualities: dict[str, str] = {} + os.makedirs(self._download_dir, exist_ok=True) + + def _get_default_download_dir(self) -> str: + if self._config and hasattr(self._config, "get_online_music_download_dir"): + config_dir = self._config.get_online_music_download_dir() + if config_dir: + if os.path.isabs(config_dir): + return config_dir + return os.path.join(os.getcwd(), config_dir) + return os.path.join(os.getcwd(), "data", "online_cache") + + def set_download_dir(self, path: str) -> None: + self._download_dir = path + os.makedirs(self._download_dir, exist_ok=True) + + def _find_existing_cached_path(self, song_mid: str) -> Optional[str]: + for ext in self._CACHE_EXTENSIONS: + candidate = os.path.join(self._download_dir, f"{song_mid}{ext}") + if os.path.exists(candidate): + return candidate + return None + + def _get_extension_for_quality(self, quality: str) -> str: + return parse_quality(quality).get("e", ".mp3") + + def _delete_other_cached_variants(self, song_mid: str, keep_path: str) -> None: + keep_basename = os.path.basename(keep_path) + for ext in self._CACHE_EXTENSIONS: + candidate = os.path.join(self._download_dir, f"{song_mid}{ext}") + if os.path.basename(candidate) == keep_basename: + continue + if os.path.exists(candidate): + try: + os.remove(candidate) + except OSError: + continue + + def get_cached_path(self, song_mid: str, quality: Optional[str] = None) -> str: + existing_path = self._find_existing_cached_path(song_mid) + if existing_path: + return existing_path + selected_quality = quality or "320" + ext = self._get_extension_for_quality(selected_quality) + return os.path.join(self._download_dir, f"{song_mid}{ext}") + + def is_cached(self, song_mid: str, quality: Optional[str] = None) -> bool: + _ = quality + return self._find_existing_cached_path(song_mid) is not None + + def pop_last_download_quality(self, song_mid: str) -> Optional[str]: + return self._last_download_qualities.pop(song_mid, None) + + def download( + self, + song_mid: str, + song_title: str = "", + quality: Optional[str] = None, + progress_callback=None, + force: bool = False, + ) -> Optional[str]: + del song_title + selected_quality = quality or "320" + + cached_path = self.get_cached_path(song_mid, selected_quality) + if not force and os.path.exists(cached_path): + self._last_download_qualities[song_mid] = normalize_quality(selected_quality) + return cached_path + + playback_info: dict[str, Any] | None = None + if self._online_service and hasattr(self._online_service, "get_playback_url_info"): + playback_info = self._online_service.get_playback_url_info(song_mid, selected_quality) + if not playback_info and self._provider and hasattr(self._provider, "get_playback_url_info"): + playback_info = self._provider.get_playback_url_info(song_mid, selected_quality) + + if not playback_info: + return None + + url = playback_info.get("url") + if not url: + return None + + actual_quality = str(playback_info.get("quality", selected_quality)) + extension = playback_info.get("extension") or self._get_extension_for_quality(actual_quality) + target_path = os.path.join(self._download_dir, f"{song_mid}{extension}") + temp_path = f"{target_path}.tmp" + + request_headers = { + "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36", + "Referer": "https://y.qq.com/", + } + try: + with self._context.http.stream("GET", url, headers=request_headers, timeout=60) as response: + total_size = int(response.headers.get("content-length", 0) or 0) + downloaded = 0 + with open(temp_path, "wb") as handle: + for chunk in response.iter_content(chunk_size=8192): + if not chunk: + continue + handle.write(chunk) + downloaded += len(chunk) + if progress_callback: + progress_callback(downloaded, total_size) + os.replace(temp_path, target_path) + self._delete_other_cached_variants(song_mid, target_path) + self._last_download_qualities[song_mid] = normalize_quality(actual_quality) + return target_path + except Exception: + if os.path.exists(temp_path): + os.remove(temp_path) + self._last_download_qualities.pop(song_mid, None) + return None diff --git a/plugins/builtin/qqmusic/lib/plugin_online_music_service.py b/plugins/builtin/qqmusic/lib/plugin_online_music_service.py new file mode 100644 index 00000000..c0422330 --- /dev/null +++ b/plugins/builtin/qqmusic/lib/plugin_online_music_service.py @@ -0,0 +1,218 @@ +from __future__ import annotations + +import html +import re +from typing import Any, Optional + +from .client import QQMusicPluginClient +from .models import ( + AlbumInfo, + OnlineAlbum, + OnlineArtist, + OnlinePlaylist, + OnlineSinger, + OnlineTrack, + SearchResult, +) + + +class PluginOnlineMusicService: + """Plugin-local online music service used by QQ Music pages.""" + + def __init__(self, context, config_manager=None, credential_provider=None): + self._context = context + self._config = config_manager + self._provider = credential_provider + self._client_adapter = QQMusicPluginClient(context) + + @property + def client(self): + return getattr(self._provider, "client", None) + + def _has_qqmusic_credential(self) -> bool: + if self._provider and getattr(self._provider, "_credential", None): + return True + if self._config and hasattr(self._config, "get_plugin_secret"): + return bool(self._config.get_plugin_secret("qqmusic", "credential", "")) + return bool(self._context.settings.get("credential", None)) + + def search( + self, + keyword: str, + search_type: str = "song", + page: int = 1, + page_size: int = 50, + ) -> SearchResult: + payload = self._client_adapter.search( + keyword, + search_type=search_type, + limit=page_size, + page=page, + ) + result = SearchResult( + keyword=keyword, + search_type=search_type, + page=page, + page_size=page_size, + total=int(payload.get("total", 0) or 0), + ) + if search_type == "song": + result.tracks = [self._dict_to_track(item) for item in payload.get("tracks", [])] + if result.total <= 0: + result.total = len(result.tracks) + elif search_type == "singer": + result.artists = [self._dict_to_artist(item) for item in payload.get("artists", [])] + if result.total <= 0: + result.total = len(result.artists) + elif search_type == "album": + result.albums = [self._dict_to_album(item) for item in payload.get("albums", [])] + if result.total <= 0: + result.total = len(result.albums) + elif search_type == "playlist": + result.playlists = [self._dict_to_playlist(item) for item in payload.get("playlists", [])] + if result.total <= 0: + result.total = len(result.playlists) + return result + + def get_top_lists(self) -> list[dict[str, Any]]: + return self._client_adapter.get_top_lists() + + def get_top_list_songs(self, top_id: int, num: int = 100) -> list[OnlineTrack]: + items = self._client_adapter.get_top_list_tracks(top_id) + return [self._dict_to_track(item) for item in items[:num]] + + def get_artist_detail(self, singer_mid: str, page: int = 1, page_size: int = 50) -> Optional[dict[str, Any]]: + if self._provider and hasattr(self._provider, "get_singer_info_with_follow_status"): + detail = self._provider.get_singer_info_with_follow_status(singer_mid, page=page, page_size=page_size) + if detail: + return detail + return self._client_adapter.get_artist_detail(singer_mid) + + def get_artist_albums(self, singer_mid: str, number: int = 30, begin: int = 0) -> list[dict[str, Any]]: + _ = begin + return self._client_adapter.get_artist_albums(singer_mid, limit=number) + + def get_album_detail(self, album_mid: str, page: int = 1, page_size: int = 50) -> Optional[dict[str, Any]]: + if self._provider and hasattr(self._provider, "get_album_info_with_fav_status"): + detail = self._provider.get_album_info_with_fav_status(album_mid, page=page, page_size=page_size) + if detail: + return detail + return self._client_adapter.get_album_detail(album_mid) + + def get_playlist_detail(self, playlist_id: str, page: int = 1, page_size: int = 50) -> Optional[dict[str, Any]]: + if self._provider and hasattr(self._provider, "get_playlist_info_with_fav_status"): + detail = self._provider.get_playlist_info_with_fav_status(playlist_id, page=page, page_size=page_size) + if detail: + return detail + return self._client_adapter.get_playlist_detail(playlist_id) + + def get_song_detail(self, song_mid: str) -> Optional[dict[str, Any]]: + if self._provider and hasattr(self._provider, "get_song_detail"): + return self._provider.get_song_detail(song_mid) + return None + + def get_playback_url_info(self, song_mid: str, quality: str = "320") -> Optional[dict[str, Any]]: + return self._client_adapter.get_playback_url_info(song_mid, quality) + + def get_playback_url(self, song_mid: str, quality: str = "320") -> Optional[str]: + info = self.get_playback_url_info(song_mid, quality) + if not info: + return None + return info.get("url") + + def follow_singer(self, singer_mid: str) -> bool: + return bool(self._client_adapter.follow_artist(singer_mid)) + + def unfollow_singer(self, singer_mid: str) -> bool: + return bool(self._client_adapter.unfollow_artist(singer_mid)) + + def fav_album(self, album_mid: str) -> bool: + return bool(self._client_adapter.fav_album(album_mid)) + + def unfav_album(self, album_mid: str) -> bool: + return bool(self._client_adapter.unfav_album(album_mid)) + + def fav_playlist(self, playlist_id: str) -> bool: + return bool(self._client_adapter.fav_playlist(playlist_id)) + + def unfav_playlist(self, playlist_id: str) -> bool: + return bool(self._client_adapter.unfav_playlist(playlist_id)) + + def fav_song(self, song_mid: str) -> bool: + provider = self._provider + if provider and hasattr(provider, "fav_song"): + return bool(provider.fav_song(song_mid)) + return False + + def unfav_song(self, song_mid: str) -> bool: + provider = self._provider + if provider and hasattr(provider, "unfav_song"): + return bool(provider.unfav_song(song_mid)) + return False + + @staticmethod + def _dict_to_track(item: dict[str, Any]) -> OnlineTrack: + artist = PluginOnlineMusicService._clean_text( + item.get("artist", "") or item.get("singer", "") + ) + singers = [ + OnlineSinger(name=name.strip()) + for name in artist.split(",") + if name and name.strip() + ] + return OnlineTrack( + mid=str(item.get("mid", "")), + id=item.get("id"), + title=PluginOnlineMusicService._clean_text(item.get("title", "") or item.get("name", "")), + singer=singers, + album=AlbumInfo( + mid=str(item.get("album_mid", "")), + name=PluginOnlineMusicService._clean_text(item.get("album", "")), + ), + duration=int(float(item.get("duration", 0) or 0)), + pay_play=int(item.get("pay_play", 0) or 0), + ) + + @staticmethod + def _dict_to_artist(item: dict[str, Any]) -> OnlineArtist: + return OnlineArtist( + mid=str(item.get("mid", "")), + name=PluginOnlineMusicService._clean_text(item.get("name", "") or item.get("title", "")), + avatar_url=item.get("avatar_url") or item.get("cover_url"), + song_count=int(item.get("song_count", 0) or 0), + album_count=int(item.get("album_count", 0) or 0), + fan_count=int(item.get("fan_count", 0) or 0), + ) + + @staticmethod + def _dict_to_album(item: dict[str, Any]) -> OnlineAlbum: + return OnlineAlbum( + mid=str(item.get("mid", "")), + name=PluginOnlineMusicService._clean_text(item.get("name", "") or item.get("title", "")), + singer_mid=str(item.get("singer_mid", "")), + singer_name=PluginOnlineMusicService._clean_text( + item.get("singer_name", "") or item.get("artist", "") + ), + cover_url=item.get("cover_url"), + song_count=int(item.get("song_count", 0) or 0), + publish_date=item.get("publish_date"), + ) + + @staticmethod + def _dict_to_playlist(item: dict[str, Any]) -> OnlinePlaylist: + return OnlinePlaylist( + id=str(item.get("id", "")), + mid=str(item.get("mid", "")), + title=PluginOnlineMusicService._clean_text(item.get("title", "")), + creator=PluginOnlineMusicService._clean_text(item.get("creator", "")), + cover_url=item.get("cover_url"), + song_count=int(item.get("song_count", 0) or 0), + play_count=int(item.get("play_count", 0) or 0), + ) + + @classmethod + def _clean_text(cls, value: Any) -> str: + text = str(value or "") + text = cls._HIGHLIGHT_TAG_PATTERN.sub("", text) + return html.unescape(text).strip() + _HIGHLIGHT_TAG_PATTERN = re.compile(r"]*>", re.IGNORECASE) diff --git a/plugins/builtin/qqmusic/lib/provider.py b/plugins/builtin/qqmusic/lib/provider.py index df7e25ad..894fdf84 100644 --- a/plugins/builtin/qqmusic/lib/provider.py +++ b/plugins/builtin/qqmusic/lib/provider.py @@ -6,9 +6,15 @@ from harmony_plugin_api.media import PluginTrack from .client import QQMusicPluginClient -from .legacy_config_adapter import QQMusicLegacyConfigAdapter +from .common import get_quality_label_key, get_selectable_qualities +from .config_adapter import QQMusicConfigAdapter +from .i18n import t from .online_music_view import OnlineMusicView -from .runtime_bridge import bind_context, create_qqmusic_service +from .runtime_bridge import ( + bind_context, + create_online_download_service, + create_qqmusic_service, +) logger = logging.getLogger(__name__) @@ -21,12 +27,13 @@ def __init__(self, context): self._context = context bind_context(context) self._client = QQMusicPluginClient(context) + self._download_service = None self._logger = getattr(context, "logger", logger) def create_page(self, context, parent=None): bind_context(context) - self._logger.info("[QQMusic] Creating legacy online music view") - config = self._create_legacy_config_adapter(context) + self._logger.info("[QQMusic] Creating plugin online music view") + config = self._create_config_adapter(context) credential = config.get_plugin_secret("qqmusic", "credential", "") service = create_qqmusic_service(credential) if credential else None return OnlineMusicView( @@ -37,8 +44,8 @@ def create_page(self, context, parent=None): ) @staticmethod - def _create_legacy_config_adapter(context): - return QQMusicLegacyConfigAdapter(context.settings) + def _create_config_adapter(context): + return QQMusicConfigAdapter(context.settings) def is_logged_in(self) -> bool: return self._client.is_logged_in() @@ -114,3 +121,57 @@ def get_demo_track(self) -> PluginTrack: def get_playback_url_info(self, track_id: str, quality: str): return self._client.get_playback_url_info(track_id, quality) + + def download_track( + self, + track_id: str, + quality: str, + target_dir: str | None = None, + progress_callback=None, + force: bool = False, + ) -> dict[str, Any] | None: + if self._download_service is None: + self._download_service = create_online_download_service( + config_manager=self._create_config_adapter(self._context), + credential_provider=self._client, + online_music_service=None, + ) + if target_dir and hasattr(self._download_service, "set_download_dir"): + self._download_service.set_download_dir(target_dir) + local_path = self._download_service.download( + track_id, + quality=quality, + progress_callback=progress_callback, + force=force, + ) + if not local_path: + return None + actual_quality = self._download_service.pop_last_download_quality(track_id) + return { + "local_path": local_path, + "quality": actual_quality or quality, + } + + def get_download_qualities(self, track_id: str) -> list[dict[str, str]]: + del track_id + options: list[dict[str, str]] = [] + for quality in get_selectable_qualities(): + label_key = get_quality_label_key(quality) + label = t(label_key, quality) + options.append({"value": quality, "label": label}) + return options + + def redownload_track( + self, + track_id: str, + quality: str, + target_dir: str | None = None, + progress_callback=None, + ) -> dict[str, Any] | None: + return self.download_track( + track_id=track_id, + quality=quality, + target_dir=target_dir, + progress_callback=progress_callback, + force=True, + ) diff --git a/plugins/builtin/qqmusic/lib/legacy/client.py b/plugins/builtin/qqmusic/lib/qqmusic_client.py similarity index 92% rename from plugins/builtin/qqmusic/lib/legacy/client.py rename to plugins/builtin/qqmusic/lib/qqmusic_client.py index cc4831ea..ea5425f5 100644 --- a/plugins/builtin/qqmusic/lib/legacy/client.py +++ b/plugins/builtin/qqmusic/lib/qqmusic_client.py @@ -8,10 +8,12 @@ import time from typing import Dict, List, Optional, Any +import requests + from .crypto import generate_sign from .common import ( APIConfig, get_guid, get_search_id, parse_quality, normalize_quality, - parse_search_type, create_qq_session + parse_search_type ) logger = logging.getLogger(__name__) @@ -22,8 +24,12 @@ class QQMusicClient: Client for QQ Music API. """ - def __init__(self, credential: Optional[Dict[str, Any]] = None, - on_credential_updated: Optional[callable] = None): + def __init__( + self, + credential: Optional[Dict[str, Any]] = None, + on_credential_updated: Optional[callable] = None, + http_client=None, + ): """ Initialize QQ Music client. @@ -33,30 +39,35 @@ def __init__(self, credential: Optional[Dict[str, Any]] = None, """ self.credential = credential self._on_credential_updated = on_credential_updated - self.session = create_qq_session() - self.session.headers.update({ - 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36', - 'Referer': 'https://y.qq.com/', - 'Origin': 'https://y.qq.com', - 'Content-Type': 'application/json', - }) + self._http_client = http_client if credential: self._set_credential_headers() def _set_credential_headers(self): """Set credential-related headers and cookies.""" - if not self.credential: - return + return - # Set cookie + def _build_cookie_header(self) -> str: + if not self.credential: + return "" cookies = [ f"uin={self.credential.get('musicid', '')}", f"qqmusic_key={self.credential.get('musickey', '')}", f"qm_keyst={self.credential.get('musickey', '')}", f"tmeLoginType={self.credential.get('login_type') or self.credential.get('loginType', 2)}", ] - self.session.headers['Cookie'] = '; '.join(cookies) + return "; ".join(cookies) + + def _http_post(self, url: str, *, data=None, headers=None, timeout: int = 30): + if self._http_client is not None and hasattr(self._http_client, "post"): + return self._http_client.post(url, data=data, headers=headers, timeout=timeout) + return requests.post(url, data=data, headers=headers, timeout=timeout) + + def _http_get(self, url: str, *, params=None, headers=None, timeout: int = 10): + if self._http_client is not None and hasattr(self._http_client, "get"): + return self._http_client.get(url, params=params, headers=headers, timeout=timeout) + return requests.get(url, params=params, headers=headers, timeout=timeout) def needs_refresh(self) -> bool: """ @@ -130,10 +141,16 @@ def refresh_credential(self) -> Optional[Dict[str, Any]]: signature = generate_sign(request_data) url = f"{APIConfig.ENDPOINT}?sign={signature}" - response = self.session.post( + response = self._http_post( url, data=json_str.encode('utf-8'), - headers={'Content-Type': 'application/json'}, + headers={ + 'Content-Type': 'application/json', + 'Referer': 'https://y.qq.com/', + 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36', + 'Origin': 'https://y.qq.com', + 'Cookie': self._build_cookie_header(), + }, timeout=30 ) response.raise_for_status() @@ -263,7 +280,7 @@ def _make_request(self, module: str, method: str, params: Dict, _retry: bool = F url = APIConfig.ENDPOINT data_to_send = json.dumps(request_data, separators=(',', ':'), ensure_ascii=False).encode('utf-8') - response = self.session.post( + response = self._http_post( url, data=data_to_send, headers=headers, @@ -860,7 +877,7 @@ def make_batch_request(self, requests: Dict[str, Dict]) -> Dict: url = APIConfig.ENDPOINT data_to_send = json.dumps(request_data, separators=(',', ':'), ensure_ascii=False).encode('utf-8') - response = self.session.post(url, data=data_to_send, headers=headers, timeout=30) + response = self._http_post(url, data=data_to_send, headers=headers, timeout=30) response.raise_for_status() try: @@ -904,11 +921,7 @@ def get_euin(self) -> str: 'Referer': 'https://y.qq.com/', } - # Use a separate session to avoid cookie header conflicts - import requests as _req - sess = _req.Session() - sess.headers.update(headers) - response = sess.get(url, params=params, timeout=10) + response = self._http_get(url, params=params, headers=headers, timeout=10) response.raise_for_status() data = response.json() @@ -936,32 +949,45 @@ def verify_login(self) -> Dict[str, Any]: - nick: str - nickname if valid - uin: int - user ID if valid """ - result = { - 'valid': False, - 'nick': '', - 'uin': 0, - } - + result = {'valid': False, 'nick': '', 'uin': 0} if not self.credential: return result + try: + profile = self._make_request( + "music.userInfo.UserInfoServer", + "GetLoginUserInfo", + {}, + ) + hostname = "" + if isinstance(profile, dict): + if isinstance(profile.get("data"), dict): + hostname = str(profile["data"].get("hostname", "") or "") + if not hostname: + hostname = str(profile.get("hostname", "") or "") + if hostname: + return { + "valid": True, + "nick": hostname, + "uin": int(self.credential.get("musicid", 0) or 0), + } + except Exception: + pass + + return self._verify_login_fallback() + + def _verify_login_fallback(self) -> Dict[str, Any]: + result = {'valid': False, 'nick': '', 'uin': 0} try: musicid = self.credential.get('musicid', '') # Use profile homepage API to verify login url = 'https://c6.y.qq.com/rsc/fcgi-bin/fcg_get_profile_homepage.fcg' - # Build cookies from credential - cookies = { - 'uin': str(musicid), - 'qqmusic_key': self.credential.get('musickey', ''), - 'qm_keyst': self.credential.get('musickey', ''), - 'tmeLoginType': str(self.credential.get('login_type', 2)), - } - headers = { 'User-Agent': 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/146.0.0.0 Safari/537.36', 'Referer': 'https://y.qq.com/', + 'Cookie': self._build_cookie_header(), } params = { @@ -972,10 +998,9 @@ def verify_login(self) -> Dict[str, Any]: 'reqtype': '0', } - response = self.session.get( + response = self._http_get( url, params=params, - cookies=cookies, headers=headers, timeout=10 ) diff --git a/plugins/builtin/qqmusic/lib/legacy/qqmusic_service.py b/plugins/builtin/qqmusic/lib/qqmusic_service.py similarity index 99% rename from plugins/builtin/qqmusic/lib/legacy/qqmusic_service.py rename to plugins/builtin/qqmusic/lib/qqmusic_service.py index 6a9b4b44..824e58b6 100644 --- a/plugins/builtin/qqmusic/lib/legacy/qqmusic_service.py +++ b/plugins/builtin/qqmusic/lib/qqmusic_service.py @@ -7,7 +7,7 @@ import time from typing import Optional, Dict, List, Any, TYPE_CHECKING -from .client import QQMusicClient +from .qqmusic_client import QQMusicClient if TYPE_CHECKING: pass @@ -20,14 +20,14 @@ class QQMusicService: Service for QQ Music integration. """ - def __init__(self, credential: Optional[Dict[str, Any]] = None): + def __init__(self, credential: Optional[Dict[str, Any]] = None, http_client=None): """ Initialize QQ Music service. Args: credential: Optional credential dict with musicid, musickey, login_type """ - self.client = QQMusicClient(credential) + self.client = QQMusicClient(credential, http_client=http_client) self._credential = credential @property @@ -313,7 +313,7 @@ def get_album_info(self, album_mid: str, page: int = 1, page_size: int = 50) -> Returns: Album information dictionary or None """ - from . import adapter as legacy_adapter + from . import adapter as plugin_adapter try: # Get album basic info @@ -328,7 +328,7 @@ def get_album_info(self, album_mid: str, page: int = 1, page_size: int = 50) -> songs_result = self.client.get_album_songs(album_mid, begin=begin, num=page_size) # Use adapter to parse - result = legacy_adapter.parse_album_detail(basic_result, songs_result) + result = plugin_adapter.parse_album_detail(basic_result, songs_result) if not result: return None @@ -357,7 +357,7 @@ def get_album_info_with_fav_status(self, album_mid: str, page: int = 1, page_siz Returns: Album information dictionary with fav_status field, or None """ - from . import adapter as legacy_adapter + from . import adapter as plugin_adapter try: uin = str(self.credential.get("musicid", "")) if self.credential else "" @@ -415,7 +415,7 @@ def get_album_info_with_fav_status(self, album_mid: str, page: int = 1, page_siz # Combine results - extract data from nested structure req_1_data = req_1.get('data', req_1) req_2_data = req_2.get('data', req_2) - result = legacy_adapter.parse_album_detail(req_1_data, req_2_data) + result = plugin_adapter.parse_album_detail(req_1_data, req_2_data) if not result: logger.warning("Failed to parse album detail from batch request") return None diff --git a/plugins/builtin/qqmusic/lib/qr_login.py b/plugins/builtin/qqmusic/lib/qr_login.py index 257e6302..73af384d 100644 --- a/plugins/builtin/qqmusic/lib/qr_login.py +++ b/plugins/builtin/qqmusic/lib/qr_login.py @@ -11,18 +11,6 @@ from requests.adapters import HTTPAdapter -def create_qq_session(pool_size: int = 20, pool_block: bool = True) -> requests.Session: - session = requests.Session() - adapter = HTTPAdapter( - pool_connections=pool_size, - pool_maxsize=pool_size, - pool_block=pool_block, - ) - session.mount("https://", adapter) - session.mount("http://", adapter) - return session - - def hash33(s: str, h: int = 0) -> int: for c in s: h = (h << 5) + h + ord(c) @@ -123,18 +111,29 @@ class QQMusicQRLogin: WX_QR_IMAGE_URL = "https://open.weixin.qq.com/connect/qrcode/{uuid}" MUSIC_API_URL = "https://u.y.qq.com/cgi-bin/musicu.fcg" - def __init__(self): - self._session = create_qq_session() - self._session.headers.update( - { - "User-Agent": ( - "Mozilla/5.0 (Windows NT 10.0; Win64; x64) " - "AppleWebKit/537.36 (KHTML, like Gecko) " - "Chrome/123.0.0.0 Safari/537.36" - ), - "Referer": "https://y.qq.com/", - } - ) + def __init__(self, http_client=None): + if http_client is not None: + self._session = http_client + else: + self._session = requests.Session() + adapter = HTTPAdapter( + pool_connections=20, + pool_maxsize=20, + pool_block=True, + ) + self._session.mount("https://", adapter) + self._session.mount("http://", adapter) + if hasattr(self._session, "headers"): + self._session.headers.update( + { + "User-Agent": ( + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) " + "AppleWebKit/537.36 (KHTML, like Gecko) " + "Chrome/123.0.0.0 Safari/537.36" + ), + "Referer": "https://y.qq.com/", + } + ) def get_qrcode(self, login_type: QRLoginType = QRLoginType.QQ) -> Optional[QR]: if login_type == QRLoginType.WX: diff --git a/plugins/builtin/qqmusic/lib/runtime_bridge.py b/plugins/builtin/qqmusic/lib/runtime_bridge.py index 5908129f..0ba63abb 100644 --- a/plugins/builtin/qqmusic/lib/runtime_bridge.py +++ b/plugins/builtin/qqmusic/lib/runtime_bridge.py @@ -52,7 +52,10 @@ def show_warning(parent, title: str, message: str) -> None: def create_online_music_service(*, config_manager=None, credential_provider=None): - return _require_context().runtime.create_online_music_service( + from .plugin_online_music_service import PluginOnlineMusicService + + return PluginOnlineMusicService( + context=_require_context(), config_manager=config_manager, credential_provider=credential_provider, ) @@ -64,7 +67,10 @@ def create_online_download_service( credential_provider=None, online_music_service=None, ): - return _require_context().runtime.create_online_download_service( + from .plugin_online_download_service import PluginOnlineDownloadService + + return PluginOnlineDownloadService( + context=_require_context(), config_manager=config_manager, credential_provider=credential_provider, online_music_service=online_music_service, @@ -149,9 +155,9 @@ def event_bus(): def create_qqmusic_service(credential): - from .legacy.qqmusic_service import QQMusicService + from .qqmusic_service import QQMusicService - return QQMusicService(credential) + return QQMusicService(credential, http_client=_require_context().http) def create_qqmusic_login_dialog(context=None, parent=None): diff --git a/plugins/builtin/qqmusic/lib/runtime_client.py b/plugins/builtin/qqmusic/lib/runtime_client.py index 7a0d6d94..3cedf17b 100644 --- a/plugins/builtin/qqmusic/lib/runtime_client.py +++ b/plugins/builtin/qqmusic/lib/runtime_client.py @@ -2,7 +2,7 @@ import json -from .legacy.client import QQMusicClient +from .qqmusic_client import QQMusicClient _shared_client = None diff --git a/plugins/builtin/qqmusic/lib/settings_tab.py b/plugins/builtin/qqmusic/lib/settings_tab.py index 74bed830..dbe765ac 100644 --- a/plugins/builtin/qqmusic/lib/settings_tab.py +++ b/plugins/builtin/qqmusic/lib/settings_tab.py @@ -27,15 +27,16 @@ class VerifyLoginThread(QThread): verified = Signal(bool, str, int) - def __init__(self, credential: dict, parent=None): + def __init__(self, credential: dict, http_client=None, parent=None): super().__init__(parent) self._credential = credential + self._http_client = http_client def run(self): try: - from .legacy.qqmusic_service import QQMusicService + from .qqmusic_service import QQMusicService - service = QQMusicService(self._credential) + service = QQMusicService(self._credential, http_client=self._http_client) result = service.client.verify_login() self.verified.emit( bool(result.get("valid")), @@ -326,7 +327,11 @@ def _update_qqmusic_status(self): self._verify_thread.quit() self._verify_thread.wait() - self._verify_thread = VerifyLoginThread(credential, parent=self) + self._verify_thread = VerifyLoginThread( + credential, + http_client=self._context.http, + parent=self, + ) self._verify_thread.verified.connect( lambda valid, nick, uin, musicid=musicid, login_type=login_type: self._on_login_verified( valid, nick, uin, musicid, login_type diff --git a/plugins/builtin/qqmusic/lib/legacy/tripledes.py b/plugins/builtin/qqmusic/lib/tripledes.py similarity index 100% rename from plugins/builtin/qqmusic/lib/legacy/tripledes.py rename to plugins/builtin/qqmusic/lib/tripledes.py diff --git a/repositories/queue_repository.py b/repositories/queue_repository.py index beedd86a..b1a3dd27 100644 --- a/repositories/queue_repository.py +++ b/repositories/queue_repository.py @@ -44,7 +44,7 @@ def get_source(row, columns): if source_type == "local": return "Local" elif source_type == "online": - return "QQ" + return "ONLINE" elif source_type == "cloud" and cloud_type: return cloud_type.upper() return "Local" @@ -62,6 +62,7 @@ def get_download_failed(row, columns): source=get_source(row, columns), track_id=row["track_id"], cloud_file_id=row["cloud_file_id"], + online_provider_id=row["online_provider_id"] if "online_provider_id" in columns else None, cloud_account_id=row["cloud_account_id"], local_path=row["local_path"] or "", title=row["title"] or "", @@ -87,12 +88,12 @@ def save(self, items: List[PlayQueueItem]) -> bool: if items: cursor.executemany(""" INSERT INTO play_queue (position, source, track_id, cloud_file_id, - cloud_account_id, local_path, title, artist, album, duration, created_at, + online_provider_id, cloud_account_id, local_path, title, artist, album, duration, created_at, download_failed) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) """, [ (item.position, item.source, item.track_id, - item.cloud_file_id, item.cloud_account_id, item.local_path, + item.cloud_file_id, item.online_provider_id, item.cloud_account_id, item.local_path, item.title, item.artist, item.album, item.duration, (item.created_at or datetime.now()).isoformat(sep=" "), int(item.download_failed)) diff --git a/repositories/track_repository.py b/repositories/track_repository.py index 977b198c..e0b6b13a 100644 --- a/repositories/track_repository.py +++ b/repositories/track_repository.py @@ -291,13 +291,14 @@ def add(self, track: Track) -> TrackId: known_artists = {row[0] for row in cursor.fetchall() if row[0]} cursor.execute(""" - INSERT INTO tracks (path, title, artist, album, genre, duration, cover_path, cloud_file_id, source) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) + INSERT INTO tracks (path, title, artist, album, genre, duration, cover_path, cloud_file_id, source, online_provider_id) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) """, ( track.path, track.title, track.artist, track.album, track.genre, track.duration, track.cover_path, track.cloud_file_id, - track.source.value if hasattr(track, 'source') and track.source else 'Local' + track.source.value if hasattr(track, 'source') and track.source else 'Local', + track.online_provider_id, )) track_id = cursor.lastrowid @@ -347,13 +348,14 @@ def batch_add(self, tracks: List[Track]) -> int: for track in tracks: try: cursor.execute(""" - INSERT INTO tracks (path, title, artist, album, genre, duration, cover_path, cloud_file_id, source) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) + INSERT INTO tracks (path, title, artist, album, genre, duration, cover_path, cloud_file_id, source, online_provider_id) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) """, ( track.path, track.title, track.artist, track.album, track.genre, track.duration, track.cover_path, track.cloud_file_id, - track.source.value if hasattr(track, 'source') and track.source else 'Local' + track.source.value if hasattr(track, 'source') and track.source else 'Local', + track.online_provider_id, )) track_id = cursor.lastrowid @@ -402,13 +404,15 @@ def update(self, track: Track) -> bool: duration = ?, cover_path = ?, cloud_file_id = ?, - source = ? + source = ?, + online_provider_id = ? WHERE id = ? """, ( track.path, track.title, track.artist, track.album, track.genre, track.duration, track.cover_path, track.cloud_file_id, track.source.value if hasattr(track, 'source') and track.source else 'Local', + track.online_provider_id, track.id )) conn.commit() @@ -446,11 +450,21 @@ def delete_batch(self, track_ids: List[TrackId]) -> int: return deleted_count - def get_by_cloud_file_id(self, cloud_file_id: str) -> Optional[Track]: + def get_by_cloud_file_id( + self, + cloud_file_id: str, + provider_id: str | None = None, + ) -> Optional[Track]: """Get a track by cloud file ID.""" conn = self._get_connection() cursor = conn.cursor() - cursor.execute("SELECT * FROM tracks WHERE cloud_file_id = ?", (cloud_file_id,)) + if provider_id: + cursor.execute( + "SELECT * FROM tracks WHERE cloud_file_id = ? AND online_provider_id = ?", + (cloud_file_id, provider_id), + ) + else: + cursor.execute("SELECT * FROM tracks WHERE cloud_file_id = ?", (cloud_file_id,)) row = cursor.fetchone() if row: return self._row_to_track(row) @@ -461,10 +475,7 @@ def _row_to_track(self, row: sqlite3.Row) -> Track: from domain.track import TrackSource # Get source value from row, default to Local if not present source_value = row["source"] if "source" in row.keys() else "Local" - try: - source = TrackSource(source_value) if source_value else TrackSource.LOCAL - except ValueError: - source = TrackSource.LOCAL # Fallback for invalid values + source = TrackSource.from_value(source_value) return Track( id=row["id"], @@ -477,6 +488,7 @@ def _row_to_track(self, row: sqlite3.Row) -> Track: cover_path=row["cover_path"], cloud_file_id=row["cloud_file_id"], source=source, + online_provider_id=row["online_provider_id"] if "online_provider_id" in row.keys() else None, file_size=row["file_size"] if "file_size" in row.keys() else None, file_mtime=row["file_mtime"] if "file_mtime" in row.keys() else None, ) diff --git a/services/download/__init__.py b/services/download/__init__.py index 987557ff..940325b4 100644 --- a/services/download/__init__.py +++ b/services/download/__init__.py @@ -2,10 +2,12 @@ Download services module. Provides unified download management for different sources: -- Online music (QQ Music, etc.) +- Online music (plugin providers) - Cloud storage (Quark, Baidu, etc.) """ from .download_manager import DownloadManager +from .cache_cleaner_service import CacheCleanerService +from .online_download_gateway import OnlineDownloadGateway -__all__ = ['DownloadManager'] +__all__ = ['DownloadManager', 'CacheCleanerService', 'OnlineDownloadGateway'] diff --git a/services/online/cache_cleaner_service.py b/services/download/cache_cleaner_service.py similarity index 98% rename from services/online/cache_cleaner_service.py rename to services/download/cache_cleaner_service.py index 329df55d..59e05cad 100644 --- a/services/online/cache_cleaner_service.py +++ b/services/download/cache_cleaner_service.py @@ -14,7 +14,7 @@ if TYPE_CHECKING: from system.config import ConfigManager from system.event_bus import EventBus - from services.online.download_service import OnlineDownloadService + from services.download.online_download_gateway import OnlineDownloadGateway from services.playback.queue_service import QueueService logger = logging.getLogger(__name__) @@ -35,7 +35,7 @@ class CacheCleanerService(QObject): def __init__( self, config_manager: "ConfigManager", - download_service: "OnlineDownloadService", + download_service: "OnlineDownloadGateway", event_bus: "EventBus", queue_service: Optional["QueueService"] = None ): @@ -44,7 +44,7 @@ def __init__( Args: config_manager: ConfigManager instance - download_service: OnlineDownloadService instance + download_service: host online download gateway instance event_bus: EventBus instance queue_service: QueueService instance (optional, for queue protection) """ diff --git a/services/download/download_manager.py b/services/download/download_manager.py index 69da83a8..4368be27 100644 --- a/services/download/download_manager.py +++ b/services/download/download_manager.py @@ -2,7 +2,7 @@ Download manager - Unified interface for downloading tracks from different sources. This module provides a unified abstraction for downloading tracks from: -- Online music services (QQ Music, etc.) +- Online music services (plugin providers) - Cloud storage (Quark, Baidu, etc.) - Local sources (no-op) """ @@ -85,7 +85,7 @@ def download_track(self, item: "PlaylistItem") -> bool: This is the unified entry point for all downloads. Routes to appropriate service: - - QQ -> OnlineDownloadService (QQ Music) + - ONLINE -> online plugin provider gateway - QUARK/BAIDU -> CloudDownloadService (cloud storage) - LOCAL -> No-op (already available) @@ -105,7 +105,7 @@ def download_track(self, item: "PlaylistItem") -> bool: logger.warning("[DownloadManager] Local track doesn't need download") return False - elif item.source == TrackSource.QQ: + elif item.source == TrackSource.ONLINE: return self._download_online_track(item) elif item.source in (TrackSource.QUARK, TrackSource.BAIDU): @@ -117,7 +117,7 @@ def download_track(self, item: "PlaylistItem") -> bool: def _download_online_track(self, item: "PlaylistItem") -> bool: """ - Download track from online music service (QQ Music, etc.). + Download track from online music service (plugin provider). Download runs in a background thread to avoid blocking. @@ -154,58 +154,45 @@ def _download_online_track(self, item: "PlaylistItem") -> bool: return True - def redownload_online_track(self, song_mid: str, title: str, - quality: str = None, force: bool = True) -> bool: - """ - Re-download an online track with specified quality. - - Similar to _download_online_track but allows explicit quality and force parameters. - - Args: - song_mid: Song MID - title: Track title - quality: Audio quality (master/flac/320/128), None uses config default - force: If True, skip cache check and re-download - - Returns: - True if download was initiated - """ + def redownload_online_track( + self, + song_mid: str, + title: str, + provider_id: Optional[str], + quality: str, + ) -> bool: + """Re-download an online track with plugin-provided quality.""" from app.bootstrap import Bootstrap + from domain.playlist_item import PlaylistItem + from domain.track import TrackSource if not song_mid: logger.error("[DownloadManager] redownload_online_track: missing song_mid") return False - # Get download service bootstrap = Bootstrap.instance() service = bootstrap.online_download_service if not service: logger.error("[DownloadManager] Online download service not available") return False - logger.info(f"[DownloadManager] Re-downloading online track: {song_mid}, quality={quality}") - - # Create a minimal PlaylistItem for the worker - from domain.playlist_item import PlaylistItem - from domain.track import TrackSource item = PlaylistItem( cloud_file_id=song_mid, title=title, - source=TrackSource.QQ, + source=TrackSource.ONLINE, + online_provider_id=provider_id, ) - worker = self._create_and_register_online_worker( song_mid, service, title, item, quality=quality, - force=force, + force=True, ) if worker is None: return True worker.start() - return True def _download_cloud_track(self, item: "PlaylistItem") -> bool: @@ -274,8 +261,15 @@ class _OnlineDownloadWorker(QThread): """Background worker for online music download.""" download_finished = Signal(str, str) # (song_mid, local_path) - def __init__(self, service, song_mid: str, title: str, item: "PlaylistItem", - quality: str = None, force: bool = False): + def __init__( + self, + service, + song_mid: str, + title: str, + item: "PlaylistItem", + quality: str = None, + force: bool = False, + ): super().__init__() self._service = service self._song_mid = song_mid @@ -288,8 +282,11 @@ def run(self): """Execute download in background thread.""" logger.info(f"[DownloadManager] Worker downloading: {self._song_mid}") path = self._service.download( - self._song_mid, self._title, - quality=self._quality, force=self._force + self._song_mid, + self._title, + provider_id=getattr(self._item, "online_provider_id", None), + quality=self._quality, + force=self._force, ) # Always emit, even if path is None (failed) self.download_finished.emit(self._song_mid, path or "") diff --git a/services/download/online_download_gateway.py b/services/download/online_download_gateway.py new file mode 100644 index 00000000..adf3fee8 --- /dev/null +++ b/services/download/online_download_gateway.py @@ -0,0 +1,247 @@ +from __future__ import annotations + +import logging +import os +from typing import Optional + +logger = logging.getLogger(__name__) + + +class OnlineDownloadGateway: + """Host-side generic online download gateway backed by plugin providers.""" + + _CACHE_EXTENSIONS = ( + ".flac", + ".mp3", + ".ogg", + ".opus", + ".m4a", + ".mp4", + ".ape", + ".dts", + ".wav", + ) + + def __init__(self, config_manager=None, plugin_manager=None, event_bus=None): + self._config = config_manager + self._plugin_manager = plugin_manager + self._event_bus = event_bus + self._download_dir = self._get_default_download_dir() + self._last_download_qualities: dict[str, str] = {} + os.makedirs(self._download_dir, exist_ok=True) + + def _get_default_download_dir(self) -> str: + if self._config and hasattr(self._config, "get_online_music_download_dir"): + config_dir = self._config.get_online_music_download_dir() + if config_dir: + if os.path.isabs(config_dir): + return config_dir + return os.path.join(os.getcwd(), config_dir) + return os.path.join(os.getcwd(), "data", "online_cache") + + def set_download_dir(self, path: str) -> None: + self._download_dir = path + os.makedirs(self._download_dir, exist_ok=True) + + def _find_existing_cached_path(self, song_mid: str) -> Optional[str]: + for ext in self._CACHE_EXTENSIONS: + candidate = os.path.join(self._download_dir, f"{song_mid}{ext}") + if os.path.exists(candidate): + return candidate + return None + + def _provider_cache_dir(self, provider_id: Optional[str]) -> str: + normalized = str(provider_id or "").strip() + if not normalized: + return self._download_dir + safe_provider = normalized.replace("/", "_").replace("\\", "_") + provider_dir = os.path.join(self._download_dir, safe_provider) + os.makedirs(provider_dir, exist_ok=True) + return provider_dir + + def _find_existing_cached_path_for_provider( + self, + song_mid: str, + provider_id: Optional[str] = None, + ) -> Optional[str]: + if provider_id: + provider_dir = self._provider_cache_dir(provider_id) + for ext in self._CACHE_EXTENSIONS: + candidate = os.path.join(provider_dir, f"{song_mid}{ext}") + if os.path.exists(candidate): + return candidate + return self._find_existing_cached_path(song_mid) + + def _get_provider(self, provider_id: str | None = None): + manager = self._plugin_manager() if callable(self._plugin_manager) else self._plugin_manager + if manager is None: + return None + providers = manager.registry.online_providers() + for provider in providers: + if provider_id and getattr(provider, "provider_id", None) != provider_id: + continue + if callable(getattr(provider, "download_track", None)): + return provider + return None + + @staticmethod + def _normalize_quality_options(options) -> list[dict[str, str]]: + normalized: list[dict[str, str]] = [] + if not options: + return normalized + for item in options: + if isinstance(item, str): + value = item.strip() + if value: + normalized.append({"value": value, "label": value}) + continue + if not isinstance(item, dict): + continue + value = str(item.get("value", "") or "").strip() + if not value: + continue + label = str(item.get("label", "") or value).strip() or value + normalized.append({"value": value, "label": label}) + return normalized + + def _normalize_quality(self, quality: str) -> str: + return str(quality or "").strip().lower() + + def _guess_extension(self, quality: str) -> str: + q = self._normalize_quality(quality) + if q in {"flac", "master", "atmos_2", "atmos_51", "dolby", "hires"}: + return ".flac" + if q in {"ape"}: + return ".ape" + if q in {"dts"}: + return ".dts" + if q.startswith("ogg"): + return ".ogg" + if q.startswith("aac"): + return ".m4a" + return ".mp3" + + def get_cached_path( + self, + song_mid: str, + quality: Optional[str] = None, + provider_id: Optional[str] = None, + ) -> str: + cache_dir = self._provider_cache_dir(provider_id) + existing_path = self._find_existing_cached_path_for_provider(song_mid, provider_id) + if existing_path: + return existing_path + ext = self._guess_extension(quality or "320") + return os.path.join(cache_dir, f"{song_mid}{ext}") + + def is_cached( + self, + song_mid: str, + quality: Optional[str] = None, + provider_id: Optional[str] = None, + ) -> bool: + _ = quality + return self._find_existing_cached_path_for_provider(song_mid, provider_id) is not None + + def pop_last_download_quality(self, song_mid: str) -> Optional[str]: + return self._last_download_qualities.pop(song_mid, None) + + def get_download_qualities( + self, + song_mid: str, + provider_id: Optional[str] = None, + ) -> list[dict[str, str]]: + provider = self._get_provider(provider_id) + if provider is None: + return [] + getter = getattr(provider, "get_download_qualities", None) + if not callable(getter): + return [] + try: + return self._normalize_quality_options(getter(song_mid)) + except Exception: + logger.exception( + "[OnlineDownloadGateway] Failed to get download qualities: provider=%s song=%s", + provider_id, + song_mid, + ) + return [] + + def delete_cached_file(self, song_mid: str) -> bool: + deleted = False + for ext in self._CACHE_EXTENSIONS: + path = os.path.join(self._download_dir, f"{song_mid}{ext}") + if os.path.exists(path): + try: + os.remove(path) + deleted = True + except OSError: + logger.warning("[OnlineDownloadGateway] Failed to remove %s", path) + self._last_download_qualities.pop(song_mid, None) + return deleted + + def download( + self, + song_mid: str, + song_title: str = "", + provider_id: Optional[str] = None, + quality: Optional[str] = None, + progress_callback=None, + force: bool = False, + ) -> Optional[str]: + del song_title + selected_quality = quality or "320" + + cached_path = self.get_cached_path(song_mid, selected_quality, provider_id=provider_id) + if not force and os.path.exists(cached_path): + self._last_download_qualities[song_mid] = self._normalize_quality(selected_quality) + return cached_path + + provider = self._get_provider(provider_id) + if provider is None: + logger.error("[OnlineDownloadGateway] No online provider available") + return None + + if self._event_bus and hasattr(self._event_bus, "download_started"): + self._event_bus.download_started.emit(song_mid) + + try: + target_dir = self._provider_cache_dir(provider_id) + redownload = getattr(provider, "redownload_track", None) + if force and callable(redownload): + result = redownload( + track_id=song_mid, + quality=selected_quality, + target_dir=target_dir, + progress_callback=progress_callback, + ) + else: + result = provider.download_track( + track_id=song_mid, + quality=selected_quality, + target_dir=target_dir, + progress_callback=progress_callback, + force=force, + ) + if isinstance(result, str): + local_path = result + elif isinstance(result, os.PathLike): + local_path = os.fspath(result) + elif isinstance(result, dict): + local_path = str(result.get("local_path", "") or "") + else: + local_path = "" + if not local_path: + raise RuntimeError("provider returned empty local path") + actual_quality = selected_quality + if isinstance(result, dict): + actual_quality = str(result.get("quality", selected_quality) or selected_quality) + self._last_download_qualities[song_mid] = self._normalize_quality(actual_quality) + if self._event_bus and hasattr(self._event_bus, "download_completed"): + self._event_bus.download_completed.emit(song_mid, local_path) + return local_path + except Exception as exc: + self._last_download_qualities.pop(song_mid, None) + if self._event_bus and hasattr(self._event_bus, "download_error"): + self._event_bus.download_error.emit(song_mid, str(exc)) + return None diff --git a/services/library/library_service.py b/services/library/library_service.py index 98386dfc..ecabeb1a 100644 --- a/services/library/library_service.py +++ b/services/library/library_service.py @@ -118,9 +118,15 @@ def get_track_by_path(self, path: str) -> Optional[Track]: """Get a track by file path.""" return self._track_repo.get_by_path(path) - def get_track_by_cloud_file_id(self, cloud_file_id: str) -> Optional[Track]: + def get_track_by_cloud_file_id( + self, + cloud_file_id: str, + provider_id: str | None = None, + ) -> Optional[Track]: """Get a track by cloud file ID.""" - return self._track_repo.get_by_cloud_file_id(cloud_file_id) + if provider_id is None: + return self._track_repo.get_by_cloud_file_id(cloud_file_id) + return self._track_repo.get_by_cloud_file_id(cloud_file_id, provider_id=provider_id) def get_track_index_for_paths(self, paths: List[str]) -> dict[str, dict[str, int | float | None]]: """Get path -> {size, mtime} index for incremental scan.""" @@ -202,6 +208,7 @@ def _do_refresh(self): def add_online_track( self, + provider_id: str, song_mid: str, title: str, artist: str, @@ -212,11 +219,12 @@ def add_online_track( """ Add an online track to the library. - Creates a track record for online music (QQ Music, etc.) + Creates a track record for online music provided by plugins with a virtual path, indicating it needs to be downloaded before playback. Args: - song_mid: Song MID (unique identifier from QQ Music) + provider_id: Plugin provider id + song_mid: Provider-side track id title: Track title artist: Artist name album: Album name @@ -227,12 +235,12 @@ def add_online_track( Track ID (existing or newly created) """ # Check if already exists by cloud_file_id - existing = self._track_repo.get_by_cloud_file_id(song_mid) + existing = self._track_repo.get_by_cloud_file_id(song_mid, provider_id=provider_id) if existing: return existing.id # Use virtual path for online tracks (required for UNIQUE constraint on path) - virtual_path = f"qqmusic://song/{song_mid}" + virtual_path = f"online://{provider_id}/track/{song_mid}" # Create Track record with virtual path track = Track( @@ -242,8 +250,9 @@ def add_online_track( album=album, duration=duration, cover_path=cover_url, - source=TrackSource.QQ, - cloud_file_id=song_mid + source=TrackSource.ONLINE, + cloud_file_id=song_mid, + online_provider_id=provider_id, ) track_id = self._track_repo.add(track) diff --git a/services/lyrics/lyrics_loader.py b/services/lyrics/lyrics_loader.py index cfa0306c..c4f1d872 100644 --- a/services/lyrics/lyrics_loader.py +++ b/services/lyrics/lyrics_loader.py @@ -23,7 +23,6 @@ class LyricsLoader(QThread): Loads lyrics in a background thread to prevent UI blocking. Supports both local .lrc files and online sources. - For online QQ Music tracks, uses song_mid to get lyrics directly. Signals: lyrics_ready: Emitted when lyrics are loaded (str) @@ -36,7 +35,7 @@ class LyricsLoader(QThread): loading_started = Signal() def __init__(self, path: str, title: str, artist: str, parent=None, - song_mid: str = None, is_online: bool = False): + song_mid: str = None, is_online: bool = False, provider_id: str | None = None): """ Initialize the lyrics loader. @@ -45,8 +44,9 @@ def __init__(self, path: str, title: str, artist: str, parent=None, title: Track title artist: Track artist parent: Optional parent QObject - song_mid: QQ Music song MID (for online tracks) - is_online: Whether this is an online QQ Music track + song_mid: Provider-side song id (for online tracks) + is_online: Whether this is an online track + provider_id: Online provider id """ super().__init__(parent) self._path = path @@ -54,6 +54,7 @@ def __init__(self, path: str, title: str, artist: str, parent=None, self._artist = artist self._song_mid = song_mid self._is_online = is_online + self._provider_id = provider_id def run(self): """Load lyrics in background thread.""" @@ -71,7 +72,7 @@ def can_emit() -> bool: self.loading_started.emit() try: - # For online QQ Music tracks, get lyrics directly by song_mid + # For online tracks, get lyrics directly by provider-side song id if self._is_online and self._song_mid: logger.debug(f"[LyricsLoader] Getting lyrics for online track: song_mid={self._song_mid}") had_local_lyrics = bool( @@ -79,7 +80,11 @@ def can_emit() -> bool: and self._path not in ('.', '', '/') and LyricsService._get_local_lyrics(self._path) ) - lyrics = LyricsService.get_online_track_lyrics(self._song_mid, self._path) + lyrics = LyricsService.get_online_track_lyrics( + self._song_mid, + self._path, + provider_id=self._provider_id, + ) elapsed = time.time() - start_time if had_local_lyrics: logger.debug(f"[LyricsLoader] Found local lyrics in {elapsed:.2f}s") diff --git a/services/lyrics/lyrics_service.py b/services/lyrics/lyrics_service.py index ab388e89..004f455a 100644 --- a/services/lyrics/lyrics_service.py +++ b/services/lyrics/lyrics_service.py @@ -7,7 +7,7 @@ from typing import TYPE_CHECKING, List, Optional from harmony_plugin_api.lyrics import PluginLyricsResult -from system.plugins.qqmusic_lyrics_helpers import download_qqmusic_lyrics +from system.plugins.online_lyrics_helpers import download_online_lyrics from services._singleflight import SingleFlight from utils.lrc_parser import LyricLine from utils.match_scorer import MatchScorer, TrackInfo @@ -31,7 +31,7 @@ # Shared HTTP client instance _shared_http_client = None -_qqmusic_lyrics_singleflight: SingleFlight[str] = SingleFlight() +_online_provider_lyrics_singleflight: SingleFlight[str] = SingleFlight() _online_track_lyrics_singleflight: SingleFlight[str] = SingleFlight() @@ -160,7 +160,7 @@ def download_lyrics_by_id(cls, song_id: str, source: str, accesskey: str = None) Args: song_id: Song ID - source: Source name ('lrclib', 'netease', 'kugou', or 'qqmusic') + source: Source name ('lrclib', 'netease', 'kugou', or provider id) accesskey: Access key for Kugou Returns: @@ -238,49 +238,63 @@ def get_song_cover_url(cls, song_id: str, source: str) -> Optional[str]: return None @classmethod - def get_lyrics_by_qqmusic_mid(cls, song_mid: str) -> str: + def get_lyrics_by_song_id(cls, song_id: str, provider_id: str) -> str: """ - Get lyrics directly from QQ Music by song mid. + Get lyrics directly from an online provider by provider-side song id. - This is used for online QQ Music tracks where we already have the song_mid. + This is used for online tracks where provider id and song id are known. Args: - song_mid: QQ Music song MID + song_id: Provider-side song id + provider_id: Provider id (e.g. 'netease', 'kugou', plugin id) Returns: Lyrics content (QRC or LRC format) or empty string """ try: - return _qqmusic_lyrics_singleflight.do( - ("qqmusic_lyrics", song_mid), - lambda: download_qqmusic_lyrics(song_mid), + normalized_provider = (provider_id or "").strip().lower() + if not normalized_provider: + return "" + return _online_provider_lyrics_singleflight.do( + ("online_provider_lyrics", normalized_provider, song_id), + lambda: download_online_lyrics(song_id=song_id, provider_id=normalized_provider), ) except Exception as e: - logger.error(f"Error downloading QQ Music lyrics: {e}", exc_info=True) + logger.error(f"Error downloading online lyrics: {e}", exc_info=True) return "" @classmethod - def get_online_track_lyrics(cls, song_mid: str, track_path: str = "") -> str: + def get_online_track_lyrics( + cls, + song_mid: str, + track_path: str = "", + provider_id: str | None = None, + ) -> str: """ - Load or download lyrics for an online QQ Music track once per song/path. + Load or download lyrics for an online track once per song/path. This wraps the local-file check, online fetch, and local save in a shared single-flight call so multiple windows do not repeat the same work. """ return _online_track_lyrics_singleflight.do( - ("online_track_lyrics", song_mid, track_path or ""), - lambda: cls._load_or_download_online_track_lyrics(song_mid, track_path), + ("online_track_lyrics", provider_id or "", song_mid, track_path or ""), + lambda: cls._load_or_download_online_track_lyrics(song_mid, track_path, provider_id=provider_id), ) @classmethod - def _load_or_download_online_track_lyrics(cls, song_mid: str, track_path: str = "") -> str: - """Internal helper for online QQ Music lyrics retrieval.""" + def _load_or_download_online_track_lyrics( + cls, + song_mid: str, + track_path: str = "", + provider_id: str | None = None, + ) -> str: + """Internal helper for online lyrics retrieval.""" if track_path and track_path not in (".", "", "/"): local_lyrics = cls._get_local_lyrics(track_path) if local_lyrics: return local_lyrics - lyrics = cls.get_lyrics_by_qqmusic_mid(song_mid) + lyrics = cls.get_lyrics_by_song_id(song_mid, provider_id=provider_id) if lyrics and track_path and track_path not in (".", "", "/"): cls.save_lyrics(track_path, lyrics) return lyrics diff --git a/services/metadata/cover_service.py b/services/metadata/cover_service.py index cb82939a..d3f98dab 100644 --- a/services/metadata/cover_service.py +++ b/services/metadata/cover_service.py @@ -18,7 +18,7 @@ # Configure logging logger = logging.getLogger(__name__) -_qqmusic_cover_singleflight: SingleFlight[Optional[str]] = SingleFlight() +_online_cover_singleflight: SingleFlight[Optional[str]] = SingleFlight() class CoverService: @@ -248,18 +248,25 @@ def fetch_online_cover(self, title: str, artist: str, album: str = "", duration: print(f"Fetching cover from online sources: {artist} {album} {title}") return self._fetch_online_cover(title, artist, album, cache_key, duration) - def get_online_cover(self, song_mid: str, album_mid: str = None, - artist: str = "", title: str = "") -> Optional[str]: + def get_online_cover( + self, + song_mid: str, + album_mid: str = None, + artist: str = "", + title: str = "", + provider_id: str | None = None, + ) -> Optional[str]: """ - Get cover for online QQ Music track by song_mid or album_mid. + Get cover for online track by provider-side track id. - This directly fetches cover from QQ Music without searching. + This directly fetches cover from a plugin provider without searching. Args: - song_mid: QQ Music song MID - album_mid: QQ Music album MID (preferred, if available) + song_mid: Provider-side song id + album_mid: Provider-side album id (preferred, if available) artist: Artist name (for cache key) title: Track title (for cache key) + provider_id: Online provider id Returns: Path to cached cover, or None @@ -274,10 +281,17 @@ def get_online_cover(self, song_mid: str, album_mid: str = None, return str(cached_cover) try: - request_key = ("qqmusic_cover", song_mid or "", album_mid or "", artist or "", title or "") - return _qqmusic_cover_singleflight.do( + request_key = ( + "online_cover", + provider_id or "", + song_mid or "", + album_mid or "", + artist or "", + title or "", + ) + return _online_cover_singleflight.do( request_key, - lambda: self._fetch_online_cover_by_mid(song_mid, album_mid), + lambda: self._fetch_online_cover_by_mid(song_mid, album_mid, provider_id=provider_id), ) except Exception as e: @@ -285,11 +299,21 @@ def get_online_cover(self, song_mid: str, album_mid: str = None, return None - def _fetch_online_cover_by_mid(self, song_mid: str, album_mid: str | None) -> Optional[str]: - """Fetch QQ Music cover bytes by song/album mid and cache the result.""" - from system.plugins.qqmusic_cover_helpers import get_qqmusic_cover_url - - cover_url = get_qqmusic_cover_url(mid=song_mid, album_mid=album_mid, size=500) + def _fetch_online_cover_by_mid( + self, + song_mid: str, + album_mid: str | None, + provider_id: str | None = None, + ) -> Optional[str]: + """Fetch online cover bytes by song/album id and cache the result.""" + from system.plugins.online_cover_helpers import get_online_cover_url + + cover_url = get_online_cover_url( + provider_id=provider_id, + track_id=song_mid, + album_id=album_mid, + size=500, + ) if not cover_url: logger.debug(f"[CoverService] No cover URL for song_mid={song_mid}, album_mid={album_mid}") return None @@ -424,7 +448,7 @@ def search_covers(self, title: str, artist: str, album: str = "", duration: floa 'source': result.source, 'id': result.id, 'score': score, - 'album_mid': result.album_mid, # For QQ Music lazy cover fetch + 'album_mid': result.album_mid, # For provider-side lazy cover fetch }) # Sort by score descending @@ -550,7 +574,7 @@ def search_artist_covers(self, artist_name: str, limit: int = 10) -> List[dict]: score = self._calculate_artist_name_score(artist_name, r.name) artist_id = getattr(r, "id", None) or getattr(r, "artist_id", "") singer_mid = getattr(r, "singer_mid", None) - if singer_mid is None and getattr(r, "source", "") == "qqmusic": + if singer_mid is None: singer_mid = getattr(r, "artist_id", None) results.append({ 'name': r.name, diff --git a/services/online/__init__.py b/services/online/__init__.py deleted file mode 100644 index bdca5270..00000000 --- a/services/online/__init__.py +++ /dev/null @@ -1,10 +0,0 @@ -""" -Online music services. -""" - -from .adapter import OnlineMusicAdapter -from .online_music_service import OnlineMusicService -from .download_service import OnlineDownloadService -from .cache_cleaner_service import CacheCleanerService - -__all__ = ['OnlineMusicAdapter', 'OnlineMusicService', 'OnlineDownloadService', 'CacheCleanerService'] diff --git a/services/online/adapter.py b/services/online/adapter.py deleted file mode 100644 index 54fe7eff..00000000 --- a/services/online/adapter.py +++ /dev/null @@ -1,978 +0,0 @@ -""" -Online music API adapter. -Unifies response formats from different API sources. -""" - -import re -import logging -from typing import Dict, List, Any, Optional - -from domain.online_music import ( - OnlineTrack, OnlineArtist, OnlineAlbum, OnlinePlaylist, - AlbumInfo, OnlineSinger, SearchResult, SearchType -) - -logger = logging.getLogger(__name__) - -# Pre-compiled regex pattern for HTML tag stripping -_RE_HTML_TAG = re.compile(r'<[^>]+>') - - -class ApiSource: - """API source constants.""" - - YGKING = "ygking" # api.ygking.top - QQMUSIC = "qqmusic" # QQ Music local API - - -class OnlineMusicAdapter: - """ - Adapter to unify API response formats. - - Supports: - - api.ygking.top format - - QQ Music local API format - """ - - @staticmethod - def normalize_search_result( - source: str, - raw_data: Dict[str, Any], - search_type: str = "song", - keyword: str = "", - page: int = 1, - page_size: int = 20 - ) -> SearchResult: - """ - Normalize search result from different API sources. - - Args: - source: API source (ApiSource.YGKING or ApiSource.QQMUSIC) - raw_data: Raw API response data - search_type: Search type (song/singer/album/playlist) - keyword: Search keyword - page: Page number - page_size: Page size - - Returns: - Normalized SearchResult object - """ - if source == ApiSource.YGKING: - return OnlineMusicAdapter._normalize_ygking( - raw_data, search_type, keyword, page, page_size - ) - elif source == ApiSource.QQMUSIC: - return OnlineMusicAdapter._normalize_qqmusic( - raw_data, search_type, keyword, page, page_size - ) - else: - logger.warning(f"Unknown API source: {source}") - return SearchResult( - keyword=keyword, - search_type=search_type, - page=page, - page_size=page_size - ) - - @staticmethod - def _normalize_ygking( - raw_data: Dict[str, Any], - search_type: str, - keyword: str, - page: int, - page_size: int - ) -> SearchResult: - """Normalize api.ygking.top response format.""" - result = SearchResult( - keyword=keyword, - search_type=search_type, - page=page, - page_size=page_size - ) - - if raw_data.get("code") != 0: - logger.error(f"YGKing API error: {raw_data.get('code')}") - return result - - data = raw_data.get("data", {}) - result.total = data.get("total", 0) - items = data.get("list", []) - - if search_type == SearchType.SONG: - result.tracks = OnlineMusicAdapter._parse_ygking_tracks(items) - elif search_type == SearchType.SINGER: - result.artists = OnlineMusicAdapter._parse_ygking_artists(items) - elif search_type == SearchType.ALBUM: - result.albums = OnlineMusicAdapter._parse_ygking_albums(items) - elif search_type == SearchType.PLAYLIST: - result.playlists = OnlineMusicAdapter._parse_ygking_playlists(items) - - return result - - @staticmethod - def _parse_ygking_tracks(items: List[Dict]) -> List[OnlineTrack]: - """Parse tracks from YGKing API format.""" - tracks = [] - for item in items: - # Parse singers - handle different formats - singers = [] - singer_data = item.get("singer", []) - if isinstance(singer_data, list): - for s in singer_data: - if isinstance(s, dict): - name = s.get("name", "") - # Strip HTML tags - if name: - name = _RE_HTML_TAG.sub('', name) - singers.append(OnlineSinger( - mid=s.get("mid", ""), - name=name - )) - elif isinstance(s, str): - # Strip HTML tags - name = _RE_HTML_TAG.sub('', s) - singers.append(OnlineSinger(mid="", name=name)) - elif isinstance(singer_data, dict): - name = singer_data.get("name", "") - if name: - name = _RE_HTML_TAG.sub('', name) - singers.append(OnlineSinger( - mid=singer_data.get("mid", ""), - name=name - )) - elif isinstance(singer_data, str): - name = _RE_HTML_TAG.sub('', singer_data) - singers.append(OnlineSinger(mid="", name=name)) - - # Parse album - handle different formats - album_data = item.get("album") - if isinstance(album_data, dict): - album_name = album_data.get("name", album_data.get("albumname", "")) - if album_name: - album_name = _RE_HTML_TAG.sub('', album_name) - album = AlbumInfo( - mid=album_data.get("mid", album_data.get("albummid", "")), - name=album_name - ) - elif isinstance(album_data, str): - album_name = _RE_HTML_TAG.sub('', album_data) - album = AlbumInfo(mid="", name=album_name) - else: - album_name = item.get("albumname", item.get("albumName", "")) - if album_name: - album_name = _RE_HTML_TAG.sub('', album_name) - album = AlbumInfo( - mid=item.get("albummid", item.get("albumMid", "")), - name=album_name - ) - - # Parse pay info - pay_info = item.get("pay", {}) or {} - pay_play = pay_info.get("pay_play", 0) if isinstance(pay_info, dict) else 0 - - # Get song mid - try multiple field names - mid = item.get("mid", item.get("songmid", item.get("songMid", ""))) - - # Get song id - song_id = item.get("id", item.get("songid", item.get("songId"))) - - # Get title - try multiple field names - title = item.get("title", item.get("name", item.get("songname", item.get("songName", "")))) - if title: - title = _RE_HTML_TAG.sub('', title) - - track = OnlineTrack( - mid=mid, - id=song_id, - title=title, - singer=singers, - album=album, - duration=item.get("interval", item.get("duration", 0)), - pay_play=pay_play - ) - tracks.append(track) - - return tracks - - @staticmethod - def _parse_ygking_artists(items: List[Dict]) -> List[OnlineArtist]: - """Parse artists from YGKing API format.""" - artists = [] - for item in items: - # Strip HTML tags from name - name = item.get("singerName", item.get("name", "")) - if name: - name = _RE_HTML_TAG.sub('', name) - - artist = OnlineArtist( - mid=item.get("singerMID", item.get("mid", "")), - name=name, - avatar_url=item.get("singerPic", item.get("avatar", "")), - song_count=item.get("songNum", item.get("song_count", 0)), - album_count=item.get("albumNum", item.get("album_count", 0)) - ) - artists.append(artist) - return artists - - @staticmethod - def _parse_ygking_albums(items: List[Dict]) -> List[OnlineAlbum]: - """Parse albums from YGKing API format.""" - albums = [] - for item in items: - # Extract singer info from singer_list - singer_list = item.get("singer_list", []) - if singer_list and isinstance(singer_list, list): - singer_mid = singer_list[0].get("mid", "") - singer_name = singer_list[0].get("name", "") - # Strip HTML tags - if singer_name: - singer_name = _RE_HTML_TAG.sub('', singer_name) - else: - singer_mid = item.get("singer_id", "") - singer_name = item.get("singer", "") - # Strip HTML tags - if singer_name: - singer_name = _RE_HTML_TAG.sub('', singer_name) - - # Strip HTML tags from album name - album_name = item.get("name", "") - if album_name: - album_name = _RE_HTML_TAG.sub('', album_name) - - album = OnlineAlbum( - mid=item.get("albummid", item.get("mid", "")), - name=album_name, - singer_mid=singer_mid, - singer_name=singer_name, - cover_url=item.get("pic", item.get("cover", "")), - song_count=item.get("song_num", item.get("song_count", 0)), - publish_date=item.get("publish_date", "") - ) - albums.append(album) - return albums - - @staticmethod - def _parse_ygking_playlists(items: List[Dict]) -> List[OnlinePlaylist]: - """Parse playlists from YGKing API format.""" - playlists = [] - for item in items: - # Strip HTML tags from title - title = item.get("dissname", item.get("title", "")) - if title: - title = _RE_HTML_TAG.sub('', title) - - playlist = OnlinePlaylist( - id=str(item.get("dissid", item.get("id", ""))), - mid=item.get("dissMID", item.get("mid", "")), - title=title, - creator=item.get("nickname", item.get("creator", "")), - cover_url=item.get("logo", item.get("cover", "")), - song_count=item.get("songnum", item.get("song_count", 0)), - play_count=item.get("listennum", item.get("play_count", 0)) - ) - playlists.append(playlist) - return playlists - - @staticmethod - def _parse_ygking_top_songs(items: List[Dict]) -> List[OnlineTrack]: - """Parse top list songs from YGKing API format.""" - tracks = [] - for item in items: - # YGKing top songs format: singerName, albumMid, songId - singers = [] - singer_name = item.get("singerName", "") - if singer_name: - singer_name = _RE_HTML_TAG.sub('', singer_name) - singers.append(OnlineSinger( - mid=item.get("singerMid", ""), - name=singer_name - )) - - # Album info - strip HTML tags - album_name = item.get("albumName", "") - if album_name: - album_name = _RE_HTML_TAG.sub('', album_name) - album = AlbumInfo( - mid=item.get("albumMid", ""), - name=album_name - ) - - # Title - strip HTML tags - title = item.get("title", "") - if title: - title = _RE_HTML_TAG.sub('', title) - - track = OnlineTrack( - mid=item.get("songMid", ""), - id=item.get("songId"), - title=title, - singer=singers, - album=album, - duration=item.get("interval", 0) - ) - tracks.append(track) - - return tracks - - @staticmethod - def _parse_ygking_song_info_list(items: List[Dict]) -> List[OnlineTrack]: - """Parse songInfoList from YGKing API (has full album and duration info).""" - tracks = [] - for item in items: - # Singer info - array of objects - singers = [] - singer_list = item.get("singer", []) - if isinstance(singer_list, list): - singers.extend(OnlineSinger( - mid=s.get("mid", ""), - name=s.get("name", "") - ) for s in singer_list) - - # Album info - album_data = item.get("album", {}) - album = AlbumInfo( - mid=album_data.get("mid", "") if isinstance(album_data, dict) else "", - name=album_data.get("name", "") if isinstance(album_data, dict) else "" - ) - - track = OnlineTrack( - mid=item.get("mid", ""), - id=item.get("id"), - title=item.get("title", "") or item.get("name", ""), - singer=singers, - album=album, - duration=item.get("interval", 0) - ) - tracks.append(track) - - return tracks - - @staticmethod - def _normalize_qqmusic( - raw_data: Dict[str, Any], - search_type: str, - keyword: str, - page: int, - page_size: int - ) -> SearchResult: - """Normalize QQ Music local API response format.""" - result = SearchResult( - keyword=keyword, - search_type=search_type, - page=page, - page_size=page_size - ) - - # QQ Music API returns empty dict on error - if not raw_data: - return result - - # Get total count - result.total = raw_data.get("meta", {}).get("sum", 0) - - body = raw_data.get("body", {}) - type_keys = { - SearchType.SONG: ("item_song", "song"), - SearchType.SINGER: ("item_singer", "singer"), - SearchType.ALBUM: ("item_album", "album"), - SearchType.PLAYLIST: ("item_songlist", "songlist", "playlist"), - } - - items: list[dict] = [] - for key in type_keys.get(search_type, ("item_song", "song")): - payload = body.get(key, []) - if isinstance(payload, list) and payload: - items = payload - break - if isinstance(payload, dict): - for nested_key in ("list", "itemlist", "items", "data"): - nested_payload = payload.get(nested_key, []) - if isinstance(nested_payload, list) and nested_payload: - items = nested_payload - break - if items: - break - - if search_type == SearchType.SONG: - result.tracks = OnlineMusicAdapter._parse_qqmusic_tracks(items) - elif search_type == SearchType.SINGER: - result.artists = OnlineMusicAdapter._parse_qqmusic_artists(items) - elif search_type == SearchType.ALBUM: - result.albums = OnlineMusicAdapter._parse_qqmusic_albums(items) - elif search_type == SearchType.PLAYLIST: - result.playlists = OnlineMusicAdapter._parse_qqmusic_playlists(items) - - return result - - @staticmethod - def _parse_qqmusic_tracks(items: List[Dict]) -> List[OnlineTrack]: - """Parse tracks from QQ Music API format.""" - tracks = [] - for item in items: - # Parse singers - can be dict, list, or string - singers = [] - singer_data = item.get("singer", []) - if isinstance(singer_data, str): - # Singer is just a name string - name = _RE_HTML_TAG.sub('', singer_data) if singer_data else "" - singers.append(OnlineSinger(mid="", name=name)) - elif isinstance(singer_data, list): - for s in singer_data: - if isinstance(s, dict): - name = s.get("name", "") - if name: - name = _RE_HTML_TAG.sub('', name) - singers.append(OnlineSinger( - mid=s.get("mid", ""), - name=name - )) - elif isinstance(s, str): - name = _RE_HTML_TAG.sub('', s) if s else "" - singers.append(OnlineSinger(mid="", name=name)) - elif isinstance(singer_data, dict): - name = singer_data.get("name", "") - if name: - name = _RE_HTML_TAG.sub('', name) - singers.append(OnlineSinger( - mid=singer_data.get("mid", ""), - name=name - )) - - # Parse album - can be dict or string - album_data = item.get("album") - if isinstance(album_data, str): - album_name = _RE_HTML_TAG.sub('', album_data) if album_data else "" - album_mid = item.get("album_mid", item.get("albummid", "")) - album = AlbumInfo(mid=album_mid, name=album_name) - elif isinstance(album_data, dict): - album_name = album_data.get("name", "") - if album_name: - album_name = _RE_HTML_TAG.sub('', album_name) - album = AlbumInfo( - mid=album_data.get("mid", ""), - name=album_name - ) - else: - album_name = item.get("albumname", "") - if album_name: - album_name = _RE_HTML_TAG.sub('', album_name) - album = AlbumInfo( - mid=item.get("albummid", ""), - name=album_name - ) - - # Get title and strip HTML tags - title = item.get("songname", item.get("title", "")) - if title: - title = _RE_HTML_TAG.sub('', title) - - track = OnlineTrack( - mid=item.get("songmid", item.get("mid", "")), - id=item.get("songid", item.get("id")), - title=title, - singer=singers, - album=album, - duration=item.get("interval", item.get("duration", 0)) - ) - tracks.append(track) - - return tracks - - @staticmethod - def _parse_qqmusic_artists(items: List[Dict]) -> List[OnlineArtist]: - """Parse artists from QQ Music API format.""" - artists = [] - for item in items: - mid = item.get("singerMID", item.get("mid", "")) - avatar_url = ( - item.get("singerPic") - or item.get("avatar") - or item.get("cover") - or item.get("cover_url") - or item.get("pic") - or "" - ) - if not avatar_url and mid: - avatar_url = f"https://y.gtimg.cn/music/photo_new/T001R300x300M000{mid}.jpg" - artist = OnlineArtist( - mid=mid, - name=item.get("singerName", item.get("name", "")), - avatar_url=avatar_url, - song_count=item.get("songNum", item.get("song_count", item.get("songnum", 0))), - album_count=item.get("albumNum", item.get("album_count", item.get("albumnum", 0))), - fan_count=item.get("fan_count", item.get("FanNum", 0)), - ) - artists.append(artist) - return artists - - @staticmethod - def _parse_qqmusic_albums(items: List[Dict]) -> List[OnlineAlbum]: - """Parse albums from QQ Music API format.""" - albums = [] - for item in items: - # Extract singer info from singer_list - singer_list = item.get("singer_list", []) - if singer_list and isinstance(singer_list, list): - singer_mid = singer_list[0].get("mid", "") - singer_name = singer_list[0].get("name", "") - else: - singer_mid = "" - singer_name = item.get("singer", "") - - # Strip HTML tags from album name - album_name = item.get("name", "") - if album_name: - album_name = _RE_HTML_TAG.sub('', album_name) - - # QQ Music API uses different field names - album = OnlineAlbum( - mid=item.get("albummid", ""), - name=album_name, - singer_mid=singer_mid, - singer_name=singer_name, - cover_url=item.get("pic", ""), - song_count=item.get("song_num", 0), - publish_date=item.get("publish_date", "") - ) - albums.append(album) - return albums - - @staticmethod - def _parse_qqmusic_playlists(items: List[Dict]) -> List[OnlinePlaylist]: - """Parse playlists from QQ Music API format.""" - playlists = [] - for item in items: - # Clean HTML tags from title - dissname = item.get("dissname", "") - title = _RE_HTML_TAG.sub('', dissname) if dissname else "" - - # Get creator nickname - creator = item.get("nickname", "") - - playlist = OnlinePlaylist( - id=str(item.get("dissid", "")), - mid=item.get("dissMID", item.get("mid", "")), - title=title, - creator=creator, - cover_url=item.get("logo", ""), - song_count=item.get("songnum", 0), - play_count=item.get("listennum", 0) - ) - playlists.append(playlist) - return playlists - - # ========== Detail parsing methods ========== - - @staticmethod - def parse_album_detail(raw_data: Dict[str, Any], songs_data: Optional[Dict] = None) -> Optional[Dict[str, Any]]: - """ - Parse album detail from QQ Music API response. - - Args: - raw_data: Raw album basic info response - songs_data: Raw album songs response (optional) - - Returns: - Normalized album detail dictionary - """ - if not raw_data: - return None - - # Parse basic info - basic_info = raw_data.get('basicInfo', {}) - singer_list = raw_data.get('singer', {}).get('singerList', []) - company_info = raw_data.get('company', {}) - - # Get singer names - singer_names = ', '.join([s.get('name', '') for s in singer_list]) if singer_list else '' - singer_mids = [s.get('mid', '') for s in singer_list] if singer_list else [] - - # Build album detail - album_mid = basic_info.get('albumMid', '') - - result = { - 'mid': album_mid, - 'name': basic_info.get('albumName', ''), - 'singer': singer_names, - 'singer_mid': singer_mids[0] if singer_mids else '', - 'cover_url': f"https://y.gtimg.cn/music/photo_new/T002R300x300M000{album_mid}.jpg" if album_mid else '', - 'publish_date': basic_info.get('publishDate', ''), - 'description': basic_info.get('desc', ''), - 'company': company_info.get('name', ''), - 'genre': basic_info.get('genre', ''), - 'language': basic_info.get('language', ''), - 'album_type': basic_info.get('albumType', ''), - 'songs': [], - 'total': 0, - } - - # Parse songs if provided - if songs_data: - song_list = songs_data.get('songList', []) - songs = [OnlineMusicAdapter._parse_album_song(item) for item in song_list] - result['songs'] = songs - result['total'] = songs_data.get('totalNum', len(songs)) - - return result - - @staticmethod - def _parse_album_song(item: Dict) -> Dict: - """Parse a single song from album song list.""" - song = item.get('songInfo', item) - - # Strip HTML tags from name - name = song.get('title', song.get('name', song.get('songName', ''))) - if name: - name = _RE_HTML_TAG.sub('', name) - - # Strip HTML tags from album name - album_name = song.get('albumName', song.get('albumname', '')) - if album_name: - album_name = _RE_HTML_TAG.sub('', album_name) - - # Strip HTML tags from singer names - singers = song.get('singer', []) - if isinstance(singers, list): - singers = [ - {'mid': s.get('mid', ''), 'name': _RE_HTML_TAG.sub('', s.get('name', ''))} if isinstance(s, dict) else s - for s in singers - ] - - return { - 'mid': song.get('mid', song.get('songMid', '')), - 'id': song.get('id', song.get('songId')), - 'name': name, - 'singer': singers, - 'album': song.get('album', {}), - 'albummid': song.get('albumMid', song.get('albummid', '')), - 'albumname': album_name, - 'interval': song.get('interval', song.get('duration', 0)), - } - - @staticmethod - def parse_artist_detail(raw_data: Dict[str, Any]) -> Optional[Dict[str, Any]]: - """ - Parse artist detail from QQ Music API response. - - Args: - raw_data: Raw artist info response - - Returns: - Normalized artist detail dictionary - """ - if not raw_data: - return None - - singer_list = raw_data.get('singer_list', []) - if not singer_list: - return None - - singer_data = singer_list[0] - basic_info = singer_data.get('basic_info', {}) - ex_info = singer_data.get('ex_info', {}) - pic_info = singer_data.get('pic', {}) - - # Get avatar URL - avatar = pic_info.get('pic') or pic_info.get('big') or pic_info.get('big_black') or '' - singer_mid = basic_info.get('singer_mid', '') - has_photo = basic_info.get('has_photo', 0) - - if not avatar and singer_mid and has_photo: - avatar = f"http://y.gtimg.cn/music/photo_new/T001R300x300M000{singer_mid}_{has_photo}.jpg" - - return { - 'mid': singer_mid, - 'name': basic_info.get('name', ''), - 'avatar': avatar, - 'description': ex_info.get('desc', ''), - 'song_count': basic_info.get('song_total', 0), - 'album_count': basic_info.get('album_total', 0), - } - - @staticmethod - def parse_playlist_detail(raw_data: Dict[str, Any]) -> Optional[Dict[str, Any]]: - """ - Parse playlist detail from QQ Music API response. - - Args: - raw_data: Raw playlist info response - - Returns: - Normalized playlist detail dictionary - """ - if not raw_data: - return None - - # Get playlist basic info - playlist_id = raw_data.get('tid') or raw_data.get('dissid') or raw_data.get('dirid') - name = raw_data.get('title') or raw_data.get('dissname', '') - - # Get creator info - creator_data = raw_data.get('creator', {}) - if isinstance(creator_data, dict): - creator = creator_data.get('name', '') - else: - creator = raw_data.get('nick', '') or str(creator_data) - - # Get cover - cover = raw_data.get('logo') or raw_data.get('cover', '') - - # Get songs - songs = raw_data.get('songlist', []) or raw_data.get('songs', []) - - return { - 'id': str(playlist_id) if playlist_id else '', - 'name': name, - 'creator': creator, - 'cover_url': cover, - 'description': raw_data.get('desc', ''), - 'play_count': raw_data.get('listennum', 0), - 'songs': songs, - 'total': len(songs), - } - - # ========== YGKing Detail parsing methods ========== - - @staticmethod - def parse_ygking_singer_detail(data: Dict[str, Any]) -> Optional[Dict[str, Any]]: - """ - Parse singer detail from YGKing API response. - - Args: - data: Response from /api/singer endpoint - - Returns: - Normalized singer detail dictionary - """ - if data.get("code") != 0: - return None - - data_obj = data.get("data", {}) - if not data_obj: - return None - - # YGKing returns singer_list array (same as QQ Music API) - singer_list = data_obj.get("singer_list", []) - if not singer_list: - return None - - singer_data = singer_list[0] - basic_info = singer_data.get("basic_info", {}) or {} - ex_info = singer_data.get("ex_info", {}) or {} - pic_info = singer_data.get("pic", {}) or {} - - singer_mid = basic_info.get("singer_mid", "") or "" - has_photo = basic_info.get("has_photo", 0) - - # Build avatar URL - try multiple sources - avatar = pic_info.get("pic") or pic_info.get("big_black") or pic_info.get("big_white") or "" - if not avatar and singer_mid: - if has_photo: - avatar = f"http://y.gtimg.cn/music/photo_new/T001R300x300M000{singer_mid}_{has_photo}.jpg" - else: - avatar = f"https://y.gtimg.cn/music/photo_new/T001R300x300M000{singer_mid}.jpg" - - return { - 'mid': singer_mid, - 'name': basic_info.get("name", "") or "", - 'avatar': avatar, - 'desc': ex_info.get("desc", "") or "", - 'songs': [], # YGKing singer API doesn't return songs, need to search separately - 'total': 0, - } - - @staticmethod - def parse_ygking_album_detail(data: Dict[str, Any]) -> Optional[Dict[str, Any]]: - """ - Parse album detail from YGKing API response. - - Args: - data: Response from /api/album endpoint - - Returns: - Normalized album detail dictionary - """ - if data.get("code") != 0: - return None - - album_data = data.get("data", {}) - if not album_data: - return None - - # YGKing returns QQ Music style format with basicInfo, singer.singerList, company - basic_info = album_data.get("basicInfo", {}) - singer_data = album_data.get("singer", {}) - company_info = album_data.get("company", {}) - - # Get singer list from singer.singerList - singer_list = [] - if isinstance(singer_data, dict): - singer_list = singer_data.get("singerList", []) - singer_names = ", ".join([s.get("name", "") for s in singer_list]) if singer_list else "" - singer_mid = singer_list[0].get("mid", "") if singer_list else "" - - # Build cover URL from albumMid - album_mid = basic_info.get("albumMid", "") or album_data.get("mid", "") - cover_url = f"https://y.gtimg.cn/music/photo_new/T002R300x300M000{album_mid}.jpg" if album_mid else "" - - # Parse songs (YGKing album API may not return songs, need separate call) - songs_data = album_data.get("songs") or album_data.get("songList") or [] - songs = [OnlineMusicAdapter._parse_ygking_detail_song(item) for item in songs_data] - - return { - 'mid': album_mid, - 'name': basic_info.get("albumName", "") or album_data.get("name", ""), - 'singer': singer_names, - 'singer_mid': singer_mid, - 'cover_url': cover_url, - 'publish_date': basic_info.get("publishDate", "") or album_data.get("publish_date", ""), - 'description': basic_info.get("desc", "") or album_data.get("description", ""), - 'company': company_info.get("name", "") if isinstance(company_info, dict) else (company_info or ""), - 'language': basic_info.get("language", "") or album_data.get("language", ""), - 'genre': basic_info.get("genre", "") or album_data.get("genre", ""), - 'album_type': basic_info.get("albumType", "") or album_data.get("album_type", ""), - 'songs': songs, - 'total': album_data.get("totalNum") or album_data.get("song_count") or len(songs), - } - - @staticmethod - def parse_ygking_playlist_detail(data: Dict[str, Any]) -> Optional[Dict[str, Any]]: - """ - Parse playlist detail from YGKing API response. - - Args: - data: Response from /api/playlist endpoint - - Returns: - Normalized playlist detail dictionary - """ - if data.get("code") != 0: - return None - - playlist_data = data.get("data", {}) - if not playlist_data: - return None - - # Get dirinfo (contains playlist metadata) - dirinfo = playlist_data.get("dirinfo", {}) - - # Get playlist name - try multiple locations - name = "" - if dirinfo: - name = dirinfo.get("title", "") - if not name: - name = playlist_data.get("dissname", "") or playlist_data.get("title", "") or playlist_data.get("name", "") - if name: - name = _RE_HTML_TAG.sub('', name) # Remove HTML tags - - # Get creator info - try multiple locations - creator = "" - if dirinfo: - creator_data = dirinfo.get("creator", {}) - if isinstance(creator_data, dict): - creator = creator_data.get("nick", "") or creator_data.get("name", "") - if not creator: - creator_info = playlist_data.get("creator", {}) or {} - if isinstance(creator_info, dict): - creator = creator_info.get("name", "") or creator_info.get("nick", "") - elif isinstance(creator_info, str): - creator = creator_info - if not creator: - creator = playlist_data.get("nick", "") or playlist_data.get("nickname", "") - - # Get cover URL - try multiple field names - cover = "" - if dirinfo: - cover = dirinfo.get("picurl", "") or dirinfo.get("picurl2", "") - if not cover: - cover = playlist_data.get("logo", "") or playlist_data.get("cover", "") or playlist_data.get("cover_url", "") - - # Get playlist ID - playlist_id = "" - if dirinfo: - playlist_id = str(dirinfo.get("id", "")) - if not playlist_id: - playlist_id = str(playlist_data.get("tid", "") or playlist_data.get("dissid", "") or playlist_data.get("id", "")) - - # Get description - description = "" - if dirinfo: - description = dirinfo.get("desc", "") - if not description: - description = playlist_data.get("desc", "") or playlist_data.get("description", "") - - # Get songs - songlist is the primary field name - songlist = playlist_data.get("songlist", []) or playlist_data.get("songs", []) - songs = [OnlineMusicAdapter._parse_ygking_detail_song(item) for item in songlist] - - # Get total song count - total = playlist_data.get("total_song_num", 0) or playlist_data.get("songlist_size", 0) or len(songs) - - return { - 'id': playlist_id, - 'name': name, - 'creator': creator, - 'cover_url': cover, - 'cover': cover, - 'description': description, - 'song_count': total, - 'songs': songs, - 'total': total, - } - - @staticmethod - def _parse_ygking_detail_song(item: Dict) -> Dict: - """Parse a single song from YGKing detail API response.""" - # Parse singers - strip HTML tags from names - singers = [] - for s in (item.get("singer") or []): - if isinstance(s, dict): - name = s.get("name", "") or "" - if name: - name = _RE_HTML_TAG.sub('', name) - singers.append({ - 'mid': s.get("mid", "") or "", - 'name': name - }) - elif isinstance(s, str): - name = _RE_HTML_TAG.sub('', s) if s else "" - singers.append({'mid': "", 'name': name}) - - # Parse album - strip HTML tags from name - album_data = item.get("album") - if isinstance(album_data, dict): - album_name = album_data.get("name", "") or "" - if album_name: - album_name = _RE_HTML_TAG.sub('', album_name) - album = { - 'mid': album_data.get("mid", "") or "", - 'name': album_name - } - elif isinstance(album_data, str): - album_name = _RE_HTML_TAG.sub('', album_data) if album_data else "" - album = {'mid': "", 'name': album_name} - else: - album_name = item.get("albumname", "") or item.get("albumName", "") or "" - if album_name: - album_name = _RE_HTML_TAG.sub('', album_name) - album = { - 'mid': item.get("albummid", "") or item.get("albumMid", "") or "", - 'name': album_name - } - - # Get song name - strip HTML tags - name = item.get("title", "") or item.get("name", "") or "" - if name: - name = _RE_HTML_TAG.sub('', name) - - return { - 'mid': item.get("mid", "") or "", - 'id': item.get("id"), - 'name': name, - 'title': name, - 'singer': singers, - 'album': album, - 'albummid': album.get("mid", ""), - 'albumname': album.get("name", ""), - 'interval': item.get("duration") or item.get("interval", 0) or 0, - } diff --git a/services/online/download_service.py b/services/online/download_service.py deleted file mode 100644 index 3b51c344..00000000 --- a/services/online/download_service.py +++ /dev/null @@ -1,435 +0,0 @@ -""" -Online music download service. -Downloads online music to local cache for playback. -""" - -import logging -import os -from typing import Dict, Optional, Callable, Any, TYPE_CHECKING, Protocol - -from infrastructure.network import HttpClient -from system.event_bus import EventBus -from services.metadata.metadata_service import MetadataService -from .quality import normalize_quality, parse_quality - -if TYPE_CHECKING: - from system.config import ConfigManager - - -class PlaybackUrlProvider(Protocol): - credential: Any - - def get_playback_url_info(self, song_mid: str, quality: str = "flac") -> Optional[Dict[str, Any]]: - ... - -logger = logging.getLogger(__name__) - - -class OnlineDownloadService: - """ - Service for downloading online music. - - Works with OnlineMusicService to get playback URLs and download files. - Supports both QQ Music local API and remote API. - """ - - _CACHE_EXTENSIONS = ( - ".flac", - ".mp3", - ".ogg", - ".opus", - ".m4a", - ".mp4", - ".ape", - ".dts", - ".wav", - ) - - def __init__( - self, - config_manager: Optional["ConfigManager"] = None, - credential_provider: Optional["PlaybackUrlProvider"] = None, - online_music_service=None, - download_dir: Optional[str] = None - ): - """ - Initialize download service. - - Args: - config_manager: ConfigManager instance - credential_provider: Optional credential-backed playback provider - online_music_service: OnlineMusicService instance (preferred) - download_dir: Download directory path - """ - self._config = config_manager - self._provider = credential_provider - self._online_service = online_music_service - self._download_dir = download_dir or self._get_default_download_dir() - self._event_bus = EventBus.instance() - self._last_download_qualities: Dict[str, str] = {} - - # Ensure download directory exists - os.makedirs(self._download_dir, exist_ok=True) - - def _get_default_download_dir(self) -> str: - """Get default download directory.""" - # First check config - if self._config: - config_dir = self._config.get_online_music_download_dir() - if config_dir: - # If relative path, make it relative to current directory - if not os.path.isabs(config_dir): - return os.path.join(os.getcwd(), config_dir) - return config_dir - # Fallback to default - cache_dir = os.path.join(os.getcwd(), "data", "online_cache") - return cache_dir - - def set_download_dir(self, path: str): - """Set download directory.""" - self._download_dir = path - os.makedirs(self._download_dir, exist_ok=True) - - def get_cached_path(self, song_mid: str, quality: Optional[str] = None) -> str: - """ - Get cached file path for a song. - - Args: - song_mid: Song MID - quality: Audio quality (uses config default if None) - - Returns: - Local file path - """ - existing_path = self._find_existing_cached_path(song_mid) - if existing_path: - return existing_path - - if quality is None: - quality = "320" - ext = self._get_extension_for_quality(quality) - filename = f"{song_mid}{ext}" - return os.path.join(self._download_dir, filename) - - def is_cached(self, song_mid: str, quality: Optional[str] = None) -> bool: - """ - Check if song is already cached. - - Args: - song_mid: Song MID - quality: Audio quality (uses config default if None) - - Returns: - True if cached - """ - return self._find_existing_cached_path(song_mid) is not None - - def pop_last_download_quality(self, song_mid: str) -> Optional[str]: - """Return and clear the most recently resolved quality for a song.""" - return self._last_download_qualities.pop(song_mid, None) - - def download( - self, - song_mid: str, - song_title: str = "", - quality: Optional[str] = None, - progress_callback: Optional[Callable[[int, int], None]] = None, - force: bool = False - ) -> Optional[str]: - """ - Download a song. - - Args: - song_mid: Song MID - song_title: Song title (for logging) - quality: Audio quality (master/flac/320/128), uses config default if None - progress_callback: Callback for progress (downloaded, total) - - Returns: - Local file path if successful, None otherwise - """ - # Use configured quality if not specified - if quality is None: - quality = "320" - - # Check cache first (skip if force re-download) - cached_path = self.get_cached_path(song_mid, quality) - if not force and os.path.exists(cached_path): - self._last_download_qualities[song_mid] = normalize_quality(quality) - logger.info(f"Song already cached: {cached_path}") - return cached_path - - # Get playback URL - prefer online service (supports remote API) - url = None - actual_quality = quality - playback_extension = None - - if self._online_service: - # Use online service (supports both QQ Music and remote API) - playback_info = None - if hasattr(self._online_service, "get_playback_url_info"): - playback_info = self._online_service.get_playback_url_info(song_mid, quality) - - if playback_info: - url = playback_info.get("url") - actual_quality = playback_info.get("quality") or quality - playback_extension = playback_info.get("extension") - else: - url = self._online_service.get_playback_url(song_mid, quality) - - elif self._provider: - # Fallback to QQ Music direct API - quality_fallback = ["320", "128", "flac"] - for q in quality_fallback: - playback_info = self._provider.get_playback_url_info(song_mid, q) - if playback_info: - url = playback_info.get("url") - actual_quality = playback_info.get("quality") or q - playback_extension = playback_info.get("extension") - break - - if not url: - logger.error(f"Failed to get playback URL for {song_mid}, song may require VIP") - return None - - # Update cached path with actual quality - cached_path = self.get_cached_path(song_mid, actual_quality) - if playback_extension: - cached_path = os.path.join(self._download_dir, f"{song_mid}{playback_extension}") - - # Download file - try: - logger.info(f"Downloading: {song_mid} {song_title} - {quality}") - - # Emit download started event - self._event_bus.download_started.emit(song_mid) - - request_headers = { - 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36', - 'Referer': 'https://y.qq.com/', - } - temp_path = cached_path + ".tmp" - with HttpClient.shared().stream("GET", url, headers=request_headers, timeout=60) as response: - total_size = int(response.headers.get('content-length', 0)) - downloaded = 0 - - # Write to temp file first - with open(temp_path, 'wb') as f: - for chunk in response.iter_content(chunk_size=8192): - if chunk: - f.write(chunk) - downloaded += len(chunk) - if progress_callback: - progress_callback(downloaded, total_size) - - final_path = self._get_final_download_path(song_mid, cached_path, temp_path) - os.replace(temp_path, final_path) - self._delete_other_cached_variants(song_mid, final_path) - self._last_download_qualities[song_mid] = normalize_quality(actual_quality) - - logger.info(f"Download complete: {final_path}") - - # Extract metadata from downloaded file - metadata = self._extract_metadata(song_mid, final_path) - - # Emit download completed event - self._event_bus.download_completed.emit(song_mid, final_path) - - # Emit metadata loaded event - if metadata: - self._event_bus.online_track_metadata_loaded.emit(song_mid, metadata) - - return final_path - - except Exception as e: - logger.error(f"Download failed: {e}") - self._last_download_qualities.pop(song_mid, None) - # Clean up temp file - temp_path = cached_path + ".tmp" - if os.path.exists(temp_path): - os.remove(temp_path) - # Emit download error event - self._event_bus.download_error.emit(song_mid, str(e)) - return None - - def _extract_metadata(self, song_mid: str, local_path: str) -> Optional[Dict[str, Any]]: - """ - Extract metadata from downloaded file. - - Uses local file metadata first, then supplements with online API if available. - - Args: - song_mid: Song MID - local_path: Local file path - - Returns: - Metadata dict or None - """ - metadata = {} - - # Extract from local file - try: - local_metadata = MetadataService.extract_metadata(local_path) - metadata.update({ - "title": local_metadata.get("title", ""), - "artist": local_metadata.get("artist", ""), - "album": local_metadata.get("album", ""), - "duration": local_metadata.get("duration", 0), - "cover": local_metadata.get("cover"), - }) - logger.debug(f"Local metadata for {song_mid}: title={metadata.get('title')}, album={metadata.get('album')}, artist={metadata.get('artist')}") - except Exception as e: - logger.warning(f"Failed to extract local metadata: {e}") - - # Supplement with online API if available - online_metadata = self._fetch_online_metadata(song_mid) - if online_metadata: - # Use online data to fill missing fields - if online_metadata.get("title"): - metadata["title"] = online_metadata["title"] - if online_metadata.get("artist"): - metadata["artist"] = online_metadata["artist"] - if online_metadata.get("album"): - metadata["album"] = online_metadata["album"] - if online_metadata.get("duration"): - metadata["duration"] = online_metadata["duration"] - # Add online-only fields - if online_metadata.get("genre"): - metadata["genre"] = online_metadata["genre"] - if online_metadata.get("language"): - metadata["language"] = online_metadata["language"] - if online_metadata.get("publish_date"): - metadata["publish_date"] = online_metadata["publish_date"] - MetadataService.save_metadata(local_path, metadata["title"], metadata["artist"], metadata["album"]) - - return metadata if metadata else None - - def _fetch_online_metadata(self, song_mid: str) -> Optional[Dict[str, Any]]: - """ - Fetch metadata from online API. - - Args: - song_mid: Song MID - - Returns: - Metadata dict or None - """ - # Try online service first - if self._online_service: - try: - return self._online_service.get_song_detail(song_mid) - except Exception as e: - logger.debug(f"Online service get_song_detail failed: {e}") - - # Fallback to direct API call - try: - url = "https://api.ygking.top/api/song/detail" - params = {"mid": song_mid} - - response = HttpClient.shared().get(url, params=params, timeout=10) - response.raise_for_status() - data = response.json() - - if data.get("code") == 0: - song_data = data.get("data", {}) - metadata = { - "title": song_data.get("title", ""), - "artist": ", ".join(s.get("name", "") for s in song_data.get("singer", [])), - "album": song_data.get("album", {}).get("name", "") if song_data.get("album") else "", - "duration": song_data.get("interval", 0), - "genre": song_data.get("genre"), - "language": song_data.get("language"), - "publish_date": song_data.get("publish_date"), - } - return metadata - - except Exception as e: - logger.debug(f"Failed to fetch online metadata: {e}") - - return None - - def clear_cache(self): - """Clear all cached files.""" - import shutil - if os.path.exists(self._download_dir): - shutil.rmtree(self._download_dir) - os.makedirs(self._download_dir, exist_ok=True) - logger.info(f"Cleared cache directory: {self._download_dir}") - - def delete_cached_file(self, song_mid: str) -> bool: - """Delete all cached files for a song (all quality variants). - - Args: - song_mid: Song MID - - Returns: - True if any file was deleted - """ - deleted = False - for ext in self._CACHE_EXTENSIONS: - path = os.path.join(self._download_dir, f"{song_mid}{ext}") - if os.path.exists(path): - try: - os.remove(path) - deleted = True - logger.info(f"Deleted cached file: {path}") - except OSError as e: - logger.warning(f"Failed to delete cached file {path}: {e}") - - tmp_path = f"{path}.tmp" - if os.path.exists(tmp_path): - try: - os.remove(tmp_path) - deleted = True - logger.info(f"Deleted cached file: {tmp_path}") - except OSError as e: - logger.warning(f"Failed to delete cached file {tmp_path}: {e}") - return deleted - - def _get_extension_for_quality(self, quality: str) -> str: - """Map a QQ Music quality code to its preferred container extension.""" - return parse_quality(quality).get("e", ".mp3") - - def _find_existing_cached_path(self, song_mid: str) -> Optional[str]: - """Find any existing cached variant for a song.""" - for ext in self._CACHE_EXTENSIONS: - path = os.path.join(self._download_dir, f"{song_mid}{ext}") - if os.path.exists(path): - return path - return None - - def _get_final_download_path(self, song_mid: str, fallback_path: str, temp_path: str) -> str: - """Choose the final cache path from the downloaded file's actual content.""" - actual_ext = MetadataService.detect_file_extension(temp_path) - if not actual_ext: - return fallback_path - return os.path.join(self._download_dir, f"{song_mid}{actual_ext}") - - def _delete_other_cached_variants(self, song_mid: str, keep_path: str) -> None: - """Remove stale cache files with mismatched extensions.""" - for ext in self._CACHE_EXTENSIONS: - path = os.path.join(self._download_dir, f"{song_mid}{ext}") - if path != keep_path and os.path.exists(path): - os.remove(path) - - def get_cache_size(self) -> int: - """Get total size of cached files in bytes.""" - total_size = 0 - if os.path.exists(self._download_dir): - for filename in os.listdir(self._download_dir): - filepath = os.path.join(self._download_dir, filename) - if os.path.isfile(filepath): - total_size += os.path.getsize(filepath) - return total_size - - def format_cache_size(self) -> str: - """Get formatted cache size string.""" - size = self.get_cache_size() - if size < 1024: - return f"{size} B" - elif size < 1024 * 1024: - return f"{size / 1024:.1f} KB" - elif size < 1024 * 1024 * 1024: - return f"{size / (1024 * 1024):.1f} MB" - else: - return f"{size / (1024 * 1024 * 1024):.1f} GB" diff --git a/services/online/online_music_service.py b/services/online/online_music_service.py deleted file mode 100644 index 07553224..00000000 --- a/services/online/online_music_service.py +++ /dev/null @@ -1,753 +0,0 @@ -""" -Online music service. -Provides unified interface for online music search and browsing. -""" - -import logging -from typing import Dict, List, Any, Optional, TYPE_CHECKING - -from domain.online_music import ( - OnlineTrack, SearchResult, SearchType -) -from infrastructure.network import HttpClient -from .adapter import OnlineMusicAdapter, ApiSource -from .quality import parse_quality - -if TYPE_CHECKING: - from system.config import ConfigManager - -logger = logging.getLogger(__name__) - - -class OnlineMusicService: - """ - Service for online music search and browsing. - - Uses api.ygking.top by default, and can optionally defer to a - credential-backed provider when one is supplied by the runtime. - """ - - # API endpoints - YGKING_BASE_URL = "https://api.ygking.top" - - def __init__(self, config_manager: Optional["ConfigManager"] = None, - credential_provider=None): - """ - Initialize online music service. - - Args: - config_manager: ConfigManager for plugin-scoped credentials - credential_provider: Optional credential-backed provider instance - """ - self._config = config_manager - self._provider = credential_provider - self._http_client = HttpClient.shared(default_headers={ - 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36', - 'Accept': 'application/json', - }) - - def _has_qqmusic_credential(self) -> bool: - """Check if plugin-scoped credential is available.""" - # Check if provider has credential - if self._provider and self._provider.credential: - return True - - # Check config if available - if not self._config: - return False - - credential = None - if hasattr(self._config, "get_plugin_secret"): - credential = self._config.get_plugin_secret("qqmusic", "credential", "") - elif hasattr(self._config, "get_plugin_setting"): - credential = self._config.get_plugin_setting("qqmusic", "credential") - return credential is not None - - def search( - self, - keyword: str, - search_type: str = SearchType.SONG, - page: int = 1, - page_size: int = 50 - ) -> SearchResult: - """ - Search for music. - - Args: - keyword: Search keyword - search_type: Type of search (song/singer/album/playlist) - page: Page number (1-based) - page_size: Number of results per page - - Returns: - SearchResult object - """ - # Prefer QQ Music local API if credential is available - if self._has_qqmusic_credential() and self._provider: - return self._search_qqmusic(keyword, search_type, page, page_size) - - # Use YGKing API - return self._search_ygking(keyword, search_type, page, page_size) - - def _search_ygking( - self, - keyword: str, - search_type: str, - page: int, - page_size: int - ) -> SearchResult: - """Search using YGKing API.""" - try: - url = f"{self.YGKING_BASE_URL}/api/search" - params = { - "keyword": keyword, - "type": search_type, - "num": page_size, - "page": page, - } - - response = self._http_client.get(url, params=params, timeout=10) - response.raise_for_status() - data = response.json() - - return OnlineMusicAdapter.normalize_search_result( - ApiSource.YGKING, - data, - search_type, - keyword, - page, - page_size - ) - - except Exception as e: - logger.error(f"YGKing search failed: {e}") - return SearchResult( - keyword=keyword, - search_type=search_type, - page=page, - page_size=page_size - ) - - def _search_qqmusic( - self, - keyword: str, - search_type: str, - page: int, - page_size: int - ) -> SearchResult: - """Search using QQ Music local API.""" - try: - result = self._provider.client.search( - keyword, - search_type=search_type, - page_num=page, - page_size=page_size - ) - - return OnlineMusicAdapter.normalize_search_result( - ApiSource.QQMUSIC, - result, - search_type, - keyword, - page, - page_size - ) - - except Exception as e: - logger.error(f"QQ Music search failed: {e}, falling back to YGKing") - return self._search_ygking(keyword, search_type, page, page_size) - - def get_top_lists(self) -> List[Dict[str, Any]]: - """ - Get music top list / ranking list. - - Returns: - List of top lists with id and name - """ - # Prefer QQ Music local API if credential is available - if self._has_qqmusic_credential() and self._provider: - return self._get_top_lists_qqmusic() - - return self._get_top_lists_ygking() - - def _get_top_lists_qqmusic(self) -> List[Dict[str, Any]]: - """Get top lists using QQ Music local API.""" - try: - result = self._provider.get_top_lists() - if result: - logger.debug(f"Got {len(result)} top lists from QQ Music local API") - return result - # Empty result, fallback to YGKing - logger.debug("QQ Music returned empty top lists, falling back to YGKing") - return self._get_top_lists_ygking() - except Exception as e: - logger.error(f"QQ Music get top lists failed: {e}, falling back to YGKing") - return self._get_top_lists_ygking() - - def _get_top_lists_ygking(self) -> List[Dict[str, Any]]: - """Get top lists using YGKing API.""" - try: - url = f"{self.YGKING_BASE_URL}/api/top" - response = self._http_client.get(url, timeout=20) - response.raise_for_status() - data = response.json() - - if data.get("code") == 0: - # YGKing returns group[].toplist[] structure - groups = data.get("data", {}).get("group", []) - top_lists = [ - { - 'id': top_list.get('topId', ''), - 'title': top_list.get('title', ''), - } - for group in groups - for top_list in group.get("toplist", []) - ] - return top_lists - - return [] - - except Exception as e: - logger.error(f"Get top lists failed: {e}") - return self._get_default_top_lists() - - def _get_default_top_lists(self) -> List[Dict[str, Any]]: - """Get default top lists as fallback.""" - return [ - {"id": 4, "title": "巅峰榜·流行指数"}, - {"id": 26, "title": "巅峰榜·热歌"}, - {"id": 27, "title": "巅峰榜·新歌"}, - {"id": 62, "title": "巅峰榜·网络歌曲"}, - ] - - def get_top_list_songs(self, top_id: int, num: int = 100) -> List[OnlineTrack]: - """ - Get songs from a specific top list. - - Args: - top_id: Top list ID (e.g., 4 for 流行指数, 26 for 热歌) - num: Number of songs to return - - Returns: - List of OnlineTrack objects - """ - # Prefer QQ Music local API (GetDetail works without login) - if self._provider: - return self._get_top_list_songs_qqmusic(top_id, num) - - return self._get_top_list_songs_ygking(top_id, num) - - def _get_top_list_songs_qqmusic(self, top_id: int, num: int) -> List[OnlineTrack]: - """Get top list songs using QQ Music local API.""" - try: - songs = self._provider.get_top_list_songs(top_id, num) - if songs: - logger.debug(f"Got {len(songs)} songs from QQ Music local API for top_id={top_id}") - return OnlineMusicAdapter._parse_qqmusic_tracks(songs) - # Empty result, fallback to YGKing - logger.debug(f"QQ Music returned empty songs for top_id={top_id}, falling back to YGKing") - return self._get_top_list_songs_ygking(top_id, num) - except Exception as e: - logger.error(f"QQ Music get top list songs failed: {e}, falling back to YGKing") - return self._get_top_list_songs_ygking(top_id, num) - - def _get_top_list_songs_ygking(self, top_id: int, num: int) -> List[OnlineTrack]: - """Get top list songs using YGKing API.""" - try: - url = f"{self.YGKING_BASE_URL}/api/top" - params = { - "id": top_id, - "num": num, - } - - response = self._http_client.get(url, params=params, timeout=20) - response.raise_for_status() - data = response.json() - - if data.get("code") == 0: - # Prefer songInfoList which has full album and duration info - songs = data.get("data", {}).get("songInfoList", []) - if songs: - return OnlineMusicAdapter._parse_ygking_song_info_list(songs) - # Fallback to data.data.song[] structure - songs = data.get("data", {}).get("data", {}).get("song", []) - return OnlineMusicAdapter._parse_ygking_top_songs(songs) - - return [] - - except Exception as e: - logger.error(f"Get top list songs failed: {e}") - return [] - - def get_artist_detail(self, singer_mid: str, page: int = 1, page_size: int = 50) -> Optional[Dict[str, Any]]: - """ - Get artist detail information. - - Args: - singer_mid: Singer MID - page: Page number (1-based) - page_size: Songs per page - - Returns: - Artist detail dict or None - """ - # Prefer QQ Music API for detail - if self._has_qqmusic_credential() and self._provider: - # Use batch request to get both detail and follow status - result = self._provider.get_singer_info_with_follow_status(singer_mid, page=page, page_size=page_size) - if result: - return result - logger.debug("QQ Music returned no artist detail, falling back to YGKing") - - # Use YGKing API - return self._get_artist_detail_ygking(singer_mid, page, page_size) - - def _get_artist_detail_ygking(self, singer_mid: str, page: int, page_size: int) -> Optional[Dict[str, Any]]: - """Get artist detail using YGKing API.""" - try: - url = f"{self.YGKING_BASE_URL}/api/singer" - params = {"mid": singer_mid} - - response = self._http_client.get(url, params=params, timeout=15) - response.raise_for_status() - data = response.json() - - result = OnlineMusicAdapter.parse_ygking_singer_detail(data) - if not result: - return None - - # YGKing singer API doesn't return songs, search by singer name - singer_name = result.get("name", "") - if singer_name: - search_result = self._search_ygking(singer_name, SearchType.SONG, page, page_size) - result['songs'] = [ - { - 'mid': t.mid, - 'id': t.id, - 'name': t.title, - 'title': t.title, - 'singer': [{'mid': s.mid, 'name': s.name} for s in t.singer], - 'album': {'mid': t.album.mid, 'name': t.album.name} if t.album else {}, - 'albummid': t.album.mid if t.album else "", - 'albumname': t.album.name if t.album else "", - 'interval': t.duration, - } - for t in search_result.tracks - ] - result['total'] = search_result.total - - result['page'] = page - result['page_size'] = page_size - - return result - - except Exception as e: - logger.error(f"Get artist detail from YGKing failed: {e}") - return None - - def get_artist_albums(self, singer_mid: str, number: int = 10, begin: int = 0) -> Dict[str, Any]: - """ - Get artist's album list. - - Args: - singer_mid: Singer MID - number: Number of albums to return - begin: Pagination start position - - Returns: - Dict with 'albums' list and 'total' count - """ - logger.debug(f"get_artist_albums: singer_mid={singer_mid}, number={number}, begin={begin}") - # Prefer QQ Music API if credential is available - if self._has_qqmusic_credential() and self._provider: - result = self._provider.get_singer_albums(singer_mid, number=number, begin=begin) - if result and result.get('albums'): - logger.debug(f"get_artist_albums: QQ Music returned {len(result['albums'])} albums, total={result.get('total', 0)}") - return result - logger.debug("QQ Music returned no artist albums") - - # Use YGKing API fallback - logger.debug("get_artist_albums: Using YGKing API fallback") - return self._get_artist_albums_ygking(singer_mid, number, begin) - - def _get_artist_albums_ygking(self, singer_mid: str, number: int, begin: int) -> Dict[str, Any]: - """Get artist albums by searching albums with singer name.""" - try: - # First get singer name from singer_mid - singer_detail = self._get_artist_detail_ygking(singer_mid) - if not singer_detail: - logger.warning(f"Cannot get singer detail for {singer_mid}") - return {'albums': [], 'total': 0} - - singer_name = singer_detail.get('name', '') - if not singer_name: - logger.warning(f"Singer name not found for {singer_mid}") - return {'albums': [], 'total': 0} - - # Search albums by singer name - page = (begin // number) + 1 if number > 0 else 1 - search_result = self._search_ygking(singer_name, SearchType.ALBUM, page, number) - - albums = [ - { - "mid": album.mid, - "name": album.name, - "singer_mid": singer_mid, - "singer_name": album.singer_name, - "cover_url": album.cover_url, - "song_count": album.song_count, - "publish_date": album.publish_date, - } - for album in search_result.albums - if album.singer_mid == singer_mid or singer_name in album.singer_name - ] - - return {'albums': albums, 'total': search_result.total} - - except Exception as e: - logger.error(f"Get artist albums from YGKing failed: {e}") - return {'albums': [], 'total': 0} - - def get_album_detail(self, album_mid: str, page: int = 1, page_size: int = 50) -> Optional[Dict[str, Any]]: - """ - Get album detail information. - - Args: - album_mid: Album MID - page: Page number (1-based) - page_size: Songs per page - - Returns: - Album detail dict or None - """ - # Prefer QQ Music API for detail - if self._has_qqmusic_credential() and self._provider: - # Use batch request to get both detail and fav status - result = self._provider.get_album_info_with_fav_status(album_mid, page=page, page_size=page_size) - if result: - return result - logger.debug("QQ Music returned no album detail, falling back to YGKing") - - # Use YGKing API - return self._get_album_detail_ygking(album_mid, page, page_size) - - def _get_album_detail_ygking(self, album_mid: str, page: int, page_size: int) -> Optional[Dict[str, Any]]: - """Get album detail using YGKing API.""" - try: - url = f"{self.YGKING_BASE_URL}/api/album" - params = {"mid": album_mid} - - response = self._http_client.get(url, params=params, timeout=15) - response.raise_for_status() - data = response.json() - - result = OnlineMusicAdapter.parse_ygking_album_detail(data) - if result: - # If YGKing API doesn't return songs, use search API - songs = result.get('songs', []) - if not songs: - album_name = result.get('name', '') - singer_name = result.get('singer', '') - if album_name: - # Search by album name + singer name - keyword = f"{album_name} {singer_name}".strip() - search_result = self._search_ygking(keyword, SearchType.SONG, page, page_size) - # Filter songs by album_mid - songs = [ - { - 'mid': t.mid, - 'id': t.id, - 'name': t.title, - 'title': t.title, - 'singer': [{'mid': s.mid, 'name': s.name} for s in t.singer], - 'album': {'mid': t.album.mid, 'name': t.album.name} if t.album else {}, - 'albummid': t.album.mid if t.album else "", - 'albumname': t.album.name if t.album else "", - 'interval': t.duration, - } - for t in search_result.tracks - if t.album and t.album.mid == album_mid - ] - # If no exact match, use all search results - if not songs: - songs = [ - { - 'mid': t.mid, - 'id': t.id, - 'name': t.title, - 'title': t.title, - 'singer': [{'mid': s.mid, 'name': s.name} for s in t.singer], - 'album': {'mid': t.album.mid, 'name': t.album.name} if t.album else {}, - 'albummid': t.album.mid if t.album else "", - 'albumname': t.album.name if t.album else "", - 'interval': t.duration, - } - for t in search_result.tracks - ] - result['total'] = len(songs) - - # Apply pagination - total = result.get('total', len(songs)) - start_idx = (page - 1) * page_size - end_idx = start_idx + page_size - result['songs'] = songs[start_idx:end_idx] - result['total'] = total - result['page'] = page - result['page_size'] = page_size - - return result - - except Exception as e: - logger.error(f"Get album detail from YGKing failed: {e}") - return None - - def get_playlist_detail(self, playlist_id: str, page: int = 1, page_size: int = 50) -> Optional[Dict[str, Any]]: - """ - Get playlist detail information. - - Args: - playlist_id: Playlist ID - page: Page number (1-based) - page_size: Songs per page - - Returns: - Playlist detail dict or None - """ - # Prefer QQ Music API for detail - # Use batch API for all pages since QQ Music max return is 30 songs - # First page includes fav status query, subsequent pages don't need it - if self._has_qqmusic_credential() and self._provider: - result = self._provider.get_playlist_info_with_fav_status(playlist_id, page=page, page_size=page_size) - if result: - return result - logger.debug("QQ Music returned no playlist detail, falling back to YGKing") - - # Use YGKing API - return self._get_playlist_detail_ygking(playlist_id, page, page_size) - - def _get_playlist_detail_ygking(self, playlist_id: str, page: int, page_size: int) -> Optional[Dict[str, Any]]: - """Get playlist detail using YGKing API.""" - try: - url = f"{self.YGKING_BASE_URL}/api/playlist" - params = {"id": playlist_id} - - response = self._http_client.get(url, params=params, timeout=15) - response.raise_for_status() - data = response.json() - - result = OnlineMusicAdapter.parse_ygking_playlist_detail(data) - if result: - # Apply pagination - songs = result.get('songs', []) - total = result.get('total', len(songs)) - start_idx = (page - 1) * page_size - end_idx = start_idx + page_size - result['songs'] = songs[start_idx:end_idx] - result['total'] = total - result['page'] = page - result['page_size'] = page_size - - return result - - except Exception as e: - logger.error(f"Get playlist detail from YGKing failed: {e}") - return None - - def get_playback_url(self, song_mid: str, quality: Optional[str] = None) -> Optional[str]: - """Get playback URL for a song.""" - info = self.get_playback_url_info(song_mid, quality=quality) - return info.get("url") if info else None - - def get_playback_url_info(self, song_mid: str, quality: Optional[str] = None) -> Optional[Dict[str, Any]]: - """ - Get playback URL plus format metadata for a song. - - Args: - song_mid: Song MID - quality: Audio quality (master/flac/320/128), uses config default if None - - Returns: - Dict with url/quality/extension metadata, or None - """ - # Use configured quality if not specified - if quality is None: - if self._config and hasattr(self._config, "get_plugin_setting"): - quality = self._config.get_plugin_setting("qqmusic", "quality", "320") - else: - quality = "320" - - # Prefer QQ Music local API if credential is available - if self._has_qqmusic_credential() and self._provider: - # Try different qualities in order - quality_fallback = ["320", "128", "flac"] - start_index = quality_fallback.index(quality) if quality in quality_fallback else 0 - - for q in quality_fallback[start_index:]: - info = self._provider.get_playback_url_info(song_mid, q) - if info: - return info - - logger.debug(f"No playback URL via QQ Music local API for {song_mid}, trying remote API") - - # Use remote API (api.ygking.top) as fallback or when not logged in - url = self._get_playback_url_remote(song_mid, quality) - if not url: - return None - return { - "url": url, - "quality": quality, - "file_type": parse_quality(quality), - "extension": parse_quality(quality).get("e"), - } - - def _get_playback_url_remote(self, song_mid: str, quality: str = "320") -> Optional[str]: - """Get playback URL from remote API.""" - try: - url = f"{self.YGKING_BASE_URL}/api/song/url" - params = { - "mid": song_mid, - "quality": quality, - } - - response = self._http_client.get(url, params=params, timeout=20) - response.raise_for_status() - data = response.json() - - if data.get("code") == 0: - urls = data.get("data", {}) - if song_mid in urls and urls[song_mid]: - return urls[song_mid] - - logger.warning(f"No playback URL available for {song_mid}") - return None - - except Exception as e: - logger.error(f"Get playback URL from remote failed: {e}") - return None - - def get_lyrics(self, song_mid: str) -> Dict[str, Optional[str]]: - """ - Get lyrics for a song. - - Args: - song_mid: Song MID - - Returns: - Dict with lyric, qrc, trans keys - """ - if self._has_qqmusic_credential() and self._provider: - return self._provider.get_lyrics(song_mid) - - return {"lyric": None, "qrc": None, "trans": None} - - def get_song_detail(self, song_mid: str) -> Optional[Dict[str, Any]]: - """ - Get detailed song information. - - Args: - song_mid: Song MID - - Returns: - Dict with song details or None - """ - # Prefer QQ Music local API if credential is available - if self._has_qqmusic_credential() and self._provider: - return self._get_song_detail_qqmusic(song_mid) - - # Use YGKing remote API - return self._get_song_detail_ygking(song_mid) - - def _get_song_detail_qqmusic(self, song_mid: str) -> Optional[Dict[str, Any]]: - """Get song detail using QQ Music local API.""" - try: - result = self._provider.client.get_song_detail(song_mid) - track_info = result.get("track_info", {}) - if track_info: - return { - "title": track_info.get("title", ""), - "artist": ", ".join(s.get("name", "") for s in track_info.get("singer", [])), - "album": track_info.get("album", {}).get("name", "") if track_info.get("album") else "", - "duration": track_info.get("interval", 0), - "genre": track_info.get("genre"), - "language": track_info.get("language"), - "publish_date": track_info.get("publish_date"), - } - except Exception as e: - logger.debug(f"QQ Music get_song_detail failed: {e}") - - return None - - def follow_singer(self, singer_mid: str) -> bool: - """Follow a singer.""" - if self._provider: - return self._provider.follow_singer(singer_mid) - return False - - def unfollow_singer(self, singer_mid: str) -> bool: - """Unfollow a singer.""" - if self._provider: - return self._provider.unfollow_singer(singer_mid) - return False - - def fav_song(self, song_id: int) -> bool: - """Add a song to favorites.""" - if self._provider: - return self._provider.fav_song(song_id) - return False - - def unfav_song(self, song_id: int) -> bool: - """Remove a song from favorites.""" - if self._provider: - return self._provider.unfav_song(song_id) - return False - - def fav_album(self, album_mid: str) -> bool: - """Favorite an album.""" - if self._provider: - return self._provider.fav_album(album_mid) - return False - - def unfav_album(self, album_mid: str) -> bool: - """Unfavorite an album.""" - if self._provider: - return self._provider.unfav_album(album_mid) - return False - - def fav_playlist(self, playlist_id) -> bool: - """Favorite a playlist.""" - if self._provider: - return self._provider.fav_playlist(playlist_id) - return False - - def unfav_playlist(self, playlist_id) -> bool: - """Unfavorite a playlist.""" - if self._provider: - return self._provider.unfav_playlist(playlist_id) - return False - - def _get_song_detail_ygking(self, song_mid: str) -> Optional[Dict[str, Any]]: - """Get song detail using YGKing remote API.""" - try: - url = f"{self.YGKING_BASE_URL}/api/song/detail" - params = {"mid": song_mid} - - response = self._http_client.get(url, params=params, timeout=10) - response.raise_for_status() - data = response.json() - - if data.get("code") == 0: - song_data = data.get("data", {}) - return { - "title": song_data.get("title", ""), - "artist": ", ".join(s.get("name", "") for s in song_data.get("singer", [])), - "album": song_data.get("album", {}).get("name", "") if song_data.get("album") else "", - "duration": song_data.get("interval", 0), - "genre": song_data.get("genre"), - "language": song_data.get("language"), - "publish_date": song_data.get("publish_date"), - } - - except Exception as e: - logger.debug(f"YGKing get_song_detail failed: {e}") - - return None diff --git a/services/online/quality.py b/services/online/quality.py deleted file mode 100644 index 4aa7499f..00000000 --- a/services/online/quality.py +++ /dev/null @@ -1,141 +0,0 @@ -"""Shared online-audio quality utilities used by host code and plugins.""" - -from __future__ import annotations - -from typing import Dict - - -class SongFileType: - MASTER = {"s": "AI00", "e": ".flac"} - ATMOS_2 = {"s": "Q000", "e": ".flac"} - ATMOS_51 = {"s": "Q001", "e": ".flac"} - DOLBY = {"s": "RS01", "e": ".flac"} - HIRES = {"s": "SQ00", "e": ".flac"} - FLAC = {"s": "F000", "e": ".flac"} - APE = {"s": "A000", "e": ".ape"} - DTS = {"s": "D000", "e": ".dts"} - MP3_320 = {"s": "M800", "e": ".mp3"} - MP3_128 = {"s": "M500", "e": ".mp3"} - OGG_640 = {"s": "O801", "e": ".ogg"} - OGG_320 = {"s": "O800", "e": ".ogg"} - OGG_192 = {"s": "O600", "e": ".ogg"} - OGG_96 = {"s": "O400", "e": ".ogg"} - AAC_320 = {"s": "C800", "e": ".m4a"} - AAC_256 = {"s": "C700", "e": ".m4a"} - AAC_192 = {"s": "C600", "e": ".m4a"} - AAC_128 = {"s": "C500", "e": ".m4a"} - AAC_96 = {"s": "C400", "e": ".m4a"} - AAC_64 = {"s": "C300", "e": ".m4a"} - AAC_48 = {"s": "C200", "e": ".m4a"} - AAC_24 = {"s": "C100", "e": ".m4a"} - - -QUALITY_FALLBACK = [ - "master", - "atmos_2", - "atmos_51", - "dolby", - "hires", - "flac", - "ape", - "dts", - "ogg_640", - "320", - "ogg_320", - "aac_320", - "aac_256", - "aac_192", - "ogg_192", - "128", - "aac_128", - "aac_96", - "ogg_96", - "aac_64", - "aac_48", - "aac_24", -] - -_QUALITY_ALIASES = { - "atmos": "atmos_2", - "192": "ogg_192", - "96": "ogg_96", - "标准": "128", - "hq高品质": "320", - "sq无损品质": "flac", - "臻品母带3.0": "master", - "臻品全景声2.0": "atmos_2", - "臻品音质2.0": "atmos_51", - "ogg高品质": "ogg_320", - "ogg标准": "ogg_192", - "aac高品质": "aac_192", - "aac标准": "aac_96", -} - -_QUALITY_FILE_MAP = { - "master": SongFileType.MASTER, - "atmos_2": SongFileType.ATMOS_2, - "atmos_51": SongFileType.ATMOS_51, - "dolby": SongFileType.DOLBY, - "hires": SongFileType.HIRES, - "flac": SongFileType.FLAC, - "ape": SongFileType.APE, - "dts": SongFileType.DTS, - "320": SongFileType.MP3_320, - "128": SongFileType.MP3_128, - "ogg_640": SongFileType.OGG_640, - "ogg_320": SongFileType.OGG_320, - "ogg_192": SongFileType.OGG_192, - "ogg_96": SongFileType.OGG_96, - "aac_320": SongFileType.AAC_320, - "aac_256": SongFileType.AAC_256, - "aac_192": SongFileType.AAC_192, - "aac_128": SongFileType.AAC_128, - "aac_96": SongFileType.AAC_96, - "aac_64": SongFileType.AAC_64, - "aac_48": SongFileType.AAC_48, - "aac_24": SongFileType.AAC_24, -} - -_QUALITY_LABEL_KEYS = { - "master": "qqmusic_quality_master", - "atmos_2": "qqmusic_quality_atmos_2", - "atmos_51": "qqmusic_quality_atmos_51", - "dolby": "qqmusic_quality_dolby", - "hires": "qqmusic_quality_hires", - "flac": "qqmusic_quality_flac", - "ape": "qqmusic_quality_ape", - "dts": "qqmusic_quality_dts", - "ogg_640": "qqmusic_quality_ogg_640", - "320": "qqmusic_quality_320", - "ogg_320": "qqmusic_quality_ogg_320", - "aac_320": "qqmusic_quality_aac_320", - "aac_256": "qqmusic_quality_aac_256", - "aac_192": "qqmusic_quality_aac_192", - "ogg_192": "qqmusic_quality_ogg_192", - "128": "qqmusic_quality_128", - "aac_128": "qqmusic_quality_aac_128", - "aac_96": "qqmusic_quality_aac_96", - "ogg_96": "qqmusic_quality_ogg_96", - "aac_64": "qqmusic_quality_aac_64", - "aac_48": "qqmusic_quality_aac_48", - "aac_24": "qqmusic_quality_aac_24", -} - - -def normalize_quality(quality: str) -> str: - value = str(quality or "").strip().lower() - return _QUALITY_ALIASES.get(value, value) - - -def parse_quality(quality: str) -> Dict[str, str]: - normalized = normalize_quality(quality) - return _QUALITY_FILE_MAP.get(normalized, SongFileType.MP3_128) - - -def get_selectable_qualities() -> list[str]: - return list(QUALITY_FALLBACK) - - -def get_quality_label_key(quality: str) -> str: - normalized = normalize_quality(quality) - return _QUALITY_LABEL_KEYS.get(normalized, "") diff --git a/services/playback/handlers.py b/services/playback/handlers.py index 8b849814..263a333d 100644 --- a/services/playback/handlers.py +++ b/services/playback/handlers.py @@ -24,7 +24,7 @@ from infrastructure.audio import PlayerEngine from system.config import ConfigManager from services.metadata import CoverService - from services.online import OnlineDownloadService + from services.download.online_download_gateway import OnlineDownloadGateway from repositories.track_repository import SqliteTrackRepository from repositories.favorite_repository import SqliteFavoriteRepository from repositories.cloud_repository import SqliteCloudRepository @@ -85,7 +85,7 @@ def _filter_and_convert_tracks(tracks) -> List: for track in tracks: if not track or not track.id or track.id <= 0: continue - is_online = not track.path or track.source == TrackSource.QQ + is_online = not track.path or track.is_online if is_online or Path(track.path).exists(): items.append(PlaylistItem.from_track(track)) return items @@ -94,7 +94,7 @@ def play_track(self, track_id: int): """ Play a local track by ID. - Handles both local files and online tracks (QQ Music). + Handles both local files and online tracks. Online tracks (empty path) will be downloaded before playback. Args: @@ -106,7 +106,7 @@ def play_track(self, track_id: int): return # Check if this is an online track (empty path) - is_online_track = not track.path or track.source == TrackSource.QQ + is_online_track = not track.path or track.is_online # For local tracks with path, verify file exists if not is_online_track and not Path(track.path).exists(): @@ -126,7 +126,7 @@ def play_track(self, track_id: int): for t in tracks: if t.id and t.id > 0: # Include online tracks (empty path) and existing local files - t_is_online = not t.path or t.source == TrackSource.QQ + t_is_online = not t.path or t.is_online if t_is_online or Path(t.path).exists(): item = PlaylistItem.from_track(t) if t.id == track_id: @@ -243,7 +243,7 @@ def load_favorites(self): for track in tracks: if track.id: # Include online tracks (empty path) and existing local files - is_online = not track.path or track.source == TrackSource.QQ + is_online = not track.path or track.is_online if is_online or Path(track.path).exists(): items.append(PlaylistItem.from_track(track)) @@ -458,12 +458,12 @@ def on_file_downloaded(self, cloud_file_id: str, local_path: str, online_handler Args: cloud_file_id: Cloud file ID local_path: Local path of downloaded file - online_handler: Optional online handler to check for QQ Music tracks + online_handler: Optional online handler to check for online tracks """ - # Skip if this is an online track (QQ Music) + # Skip if this is an online track if online_handler: current_item = self._engine.current_playlist_item - if current_item and current_item.source == TrackSource.QQ: + if current_item and current_item.is_online: logger.debug("[CloudTrackHandler] Skipping for online track") return @@ -612,7 +612,7 @@ def _save_to_library(self, file_id: str, local_path: str, source: TrackSource = if current_item and current_item.cloud_file_id == file_id: source = current_item.source else: - source = TrackSource.QQ + source = TrackSource.ONLINE # Extract metadata from downloaded file metadata = MetadataService.extract_metadata(local_path) @@ -732,7 +732,7 @@ def _fetch_cover(self, title: str, artist: str, album: str, duration: float, loc class OnlineTrackHandler(QObject): """ - Handles online track (QQ Music) playback operations. + Handles plugin-provided online track playback operations. This class encapsulates all logic for playing tracks from online music services. @@ -744,7 +744,7 @@ def __init__( track_repo: "SqliteTrackRepository", config_manager: "ConfigManager", cover_service: Optional["CoverService"], - online_download_service: Optional["OnlineDownloadService"], + online_download_service: Optional["OnlineDownloadGateway"], get_cloud_account_callback, save_queue_callback, ): @@ -919,8 +919,14 @@ def _save_to_library(self, song_mid: str, local_path: str) -> Optional[int]: if not local_path or not Path(local_path).exists(): return None + provider_id = None + for item in self._engine.playlist_items: + if item.cloud_file_id == song_mid and item.is_online: + provider_id = item.online_provider_id + break + # Check if track already exists by cloud_file_id (song_mid) - existing = self._track_repo.get_by_cloud_file_id(song_mid) + existing = self._track_repo.get_by_cloud_file_id(song_mid, provider_id=provider_id) if existing: self._track_repo.update_path(existing.id, local_path) logger.info(f"[OnlineTrackHandler] Updated track {existing.id} with local path") @@ -928,7 +934,12 @@ def _save_to_library(self, song_mid: str, local_path: str) -> Optional[int]: existing_by_path = self._track_repo.get_by_path(local_path) if existing_by_path: - self._update_track_fields(existing_by_path.id, cloud_file_id=song_mid) + self._update_track_fields( + existing_by_path.id, + cloud_file_id=song_mid, + online_provider_id=provider_id, + source=TrackSource.ONLINE, + ) return existing_by_path.id # Extract metadata @@ -938,16 +949,16 @@ def _save_to_library(self, song_mid: str, local_path: str) -> Optional[int]: album = metadata.get("album", "") duration = metadata.get("duration", 0) - # Fetch cover from QQ Music API directly + # Fetch cover from online provider API directly cover_path = None if self._cover_service: try: - # Use get_online_cover for QQ Music tracks instead of searching cover_path = self._cover_service.get_online_cover( song_mid=song_mid, album_mid=None, # We don't have album_mid yet artist=artist, - title=title + title=title, + provider_id=provider_id, ) except Exception as e: logger.error(f"[OnlineTrackHandler] Error fetching cover: {e}") @@ -960,7 +971,8 @@ def _save_to_library(self, song_mid: str, local_path: str) -> Optional[int]: duration=duration, cover_path=cover_path, cloud_file_id=song_mid, - source=TrackSource.QQ, + source=TrackSource.ONLINE, + online_provider_id=provider_id, ) track_id = self._track_repo.add(track) diff --git a/services/playback/playback_service.py b/services/playback/playback_service.py index 92a796a9..44c2ff3e 100644 --- a/services/playback/playback_service.py +++ b/services/playback/playback_service.py @@ -8,7 +8,7 @@ - PlaybackService acts as a coordinator/facade - LocalTrackHandler handles local file playback - CloudTrackHandler handles cloud file playback -- OnlineTrackHandler handles online (QQ Music) playback +- OnlineTrackHandler handles plugin-provided online playback """ import logging @@ -31,7 +31,7 @@ if TYPE_CHECKING: from domain import CloudFile, CloudAccount from services.metadata import CoverService - from services.online import OnlineDownloadService + from services.download.online_download_gateway import OnlineDownloadGateway from repositories.track_repository import SqliteTrackRepository from repositories.favorite_repository import SqliteFavoriteRepository from repositories.queue_repository import SqliteQueueRepository @@ -97,7 +97,7 @@ def __init__( self, config_manager: ConfigManager = None, cover_service: 'CoverService' = None, - online_download_service: 'OnlineDownloadService' = None, + online_download_service: 'OnlineDownloadGateway' = None, event_bus: EventBus = None, track_repo: 'SqliteTrackRepository' = None, favorite_repo: 'SqliteFavoriteRepository' = None, @@ -114,7 +114,7 @@ def __init__( Args: config_manager: Configuration manager for settings cover_service: Cover service for album art - online_download_service: Service for downloading online tracks (QQ Music) + online_download_service: Service for downloading online tracks event_bus: Event bus for event publishing (defaults to singleton) track_repo: Track repository favorite_repo: Favorite repository @@ -415,7 +415,7 @@ def _filter_and_convert_tracks(self, tracks: List[Track]) -> List[PlaylistItem]: This helper method consolidates the common logic for: - Filtering out invalid tracks - Checking file existence for local tracks - - Including online tracks (QQ Music) + - Including online tracks - Converting Track to PlaylistItem Args: @@ -436,10 +436,10 @@ def _filter_and_convert_tracks(self, tracks: List[Track]) -> List[PlaylistItem]: if not track or not track.id or track.id <= 0: continue - # QQ items stay in the queue even when they still need download, but - # downloaded QQ files should be treated as ready local files. + # Online items stay in the queue even when they still need download, but + # downloaded online files should be treated as ready local files. has_local_file = bool(track.path) and track.path in existing_paths - is_online = track.source == TrackSource.QQ and not has_local_file + is_online = track.is_online and not has_local_file if is_online or (track.path and track.path in existing_paths): items.append(PlaylistItem.from_track(track)) @@ -544,7 +544,7 @@ def play_local_track(self, track_id: int): """ Play a local track by ID. - Handles both local files and online tracks (QQ Music). + Handles both local files and online tracks. Online tracks (empty path) will be downloaded before playback. Args: @@ -556,7 +556,7 @@ def play_local_track(self, track_id: int): return has_local_file = bool(track.path) and Path(track.path).exists() - is_online_track = track.source == TrackSource.QQ and not has_local_file + is_online_track = track.is_online and not has_local_file # For local tracks with path, verify file exists if not is_online_track and (not track.path or not Path(track.path).exists()): @@ -841,13 +841,13 @@ def on_cloud_file_downloaded(self, cloud_file_id: str, local_path: str): if cloud_file_id in self._preload_attempts: del self._preload_attempts[cloud_file_id] - # Check if this is an online track (QQ Music) by looking up the playlist item - # QQ Music downloads are handled by on_online_track_downloaded + # Check if this is an online track by looking up the playlist item + # Online downloads are handled by on_online_track_downloaded for item in self._engine.playlist_items: if item.cloud_file_id == cloud_file_id: - if item.source == TrackSource.QQ: + if item.is_online: logger.debug( - f"[PlaybackService] Skipping on_cloud_file_downloaded for QQ Music track: {cloud_file_id}") + f"[PlaybackService] Skipping on_cloud_file_downloaded for online track: {cloud_file_id}") return break @@ -1128,7 +1128,10 @@ def _enrich_queue_item_metadata(self, item: PlaylistItem) -> PlaylistItem: track = self._track_repo.get_by_id(item.track_id) # For online/cloud tracks, try to get by cloud_file_id elif item.is_cloud and item.cloud_file_id: - track = self._track_repo.get_by_cloud_file_id(item.cloud_file_id) + track = self._track_repo.get_by_cloud_file_id( + item.cloud_file_id, + provider_id=item.online_provider_id if item.is_online else None, + ) # For local files without track_id, try to find by path elif item.local_path and not item.cloud_file_id: track = self._track_repo.get_by_path(item.local_path) @@ -1140,7 +1143,7 @@ def _enrich_queue_item_metadata(self, item: PlaylistItem) -> PlaylistItem: file_exists = local_path and Path(local_path).exists() needs_download = False - if item.source == TrackSource.QQ: + if item.is_online: needs_download = not file_exists if not file_exists: local_path = "" @@ -1231,7 +1234,7 @@ def _enrich_queue_items_metadata_batch(self, items: List[PlaylistItem]) -> List[ file_exists = local_path and local_path in existing_paths needs_download = False - if item.source == TrackSource.QQ: + if item.is_online: needs_download = not file_exists if not file_exists: local_path = "" @@ -1430,12 +1433,9 @@ def _on_track_needs_download(self, item): source_str = item.source.value from domain.track import TrackSource - try: - source = TrackSource(source_str) - except ValueError: - source = TrackSource.LOCAL + source = TrackSource.from_value(source_str) - if source == TrackSource.QQ: + if source == TrackSource.ONLINE: self._download_online_track(item if hasattr(item, 'source') else PlaylistItem.from_dict(item)) else: self._download_cloud_track(item if hasattr(item, 'source') else PlaylistItem.from_dict(item)) @@ -1474,21 +1474,27 @@ def _download_online_track(self, item: PlaylistItem): class OnlineDownloadWorker(QThread): download_finished = Signal(str, str) # (song_mid, local_path) - path is empty if failed - def __init__(self, service, song_mid, title): + def __init__(self, service, song_mid, title, provider_id): super().__init__() self._service = service self._song_mid = song_mid self._title = title + self._provider_id = provider_id def run(self): - path = self._service.download(self._song_mid, self._title) + path = self._service.download( + self._song_mid, + self._title, + provider_id=self._provider_id, + ) # Always emit, even if path is None (failed) self.download_finished.emit(self._song_mid, path or "") worker = OnlineDownloadWorker( self._online_download_service, song_mid, - item.title + item.title, + item.online_provider_id, ) # Handle download result @@ -1548,9 +1554,9 @@ def _on_cloud_download_error(self, file_id: str, error_message: str): matching_item = item break - if matching_item and matching_item.source == TrackSource.QQ: + if matching_item and matching_item.is_online: logger.debug( - "[PlaybackService] Ignoring download_error EventBus signal for QQ track: %s", + "[PlaybackService] Ignoring download_error EventBus signal for online track: %s", file_id, ) return @@ -1657,7 +1663,13 @@ def _save_online_track_to_library(self, song_mid: str, local_path: str) -> Optio return None # Check if track already exists by cloud_file_id (song_mid) - existing = self._track_repo.get_by_cloud_file_id(song_mid) + provider_id = None + for playlist_item in self._engine.playlist_items: + if playlist_item.cloud_file_id == song_mid and playlist_item.is_online: + provider_id = playlist_item.online_provider_id + break + + existing = self._track_repo.get_by_cloud_file_id(song_mid, provider_id=provider_id) if existing: # Update existing track with local path self._track_repo.update_path(existing.id, local_path) @@ -1686,7 +1698,8 @@ def _save_online_track_to_library(self, song_mid: str, local_path: str) -> Optio album=album, duration=duration, cloud_file_id=song_mid, # Store song_mid as cloud_file_id - source=TrackSource.QQ, # Online music from QQ + source=TrackSource.ONLINE, + online_provider_id=provider_id, ) # DBWriteWorker handles serialization @@ -1712,7 +1725,7 @@ def _get_next_preload_candidate(self) -> Optional[PlaylistItem]: if not next_item.needs_download or (next_item.local_path and Path(next_item.local_path).exists()): return None - if next_item.source == TrackSource.QQ or next_item.is_cloud: + if next_item.is_online or next_item.is_cloud: return next_item return None @@ -1733,8 +1746,8 @@ def _cancel_pending_next_track_preload(self): timer.stop() def _dispatch_preload_for_item(self, item: PlaylistItem): - """Dispatch preload to the existing QQ/cloud handlers.""" - if item.source == TrackSource.QQ: + """Dispatch preload to the existing online/cloud handlers.""" + if item.is_online: self._preload_online_track(item) elif item.is_cloud: self._preload_cloud_track(item) @@ -1904,7 +1917,7 @@ def _save_cloud_track_to_library(self, file_id: str, local_path: str, source: Tr Args: file_id: Cloud file ID local_path: Local path of downloaded file - source: Track source (QUARK, BAIDU, or QQ). If None, infers from cloud_account. + source: Track source (QUARK, BAIDU, or ONLINE). If None, infers from cloud_account. Returns: cover_path: Path to the extracted cover art, or None @@ -1925,7 +1938,7 @@ def _save_cloud_track_to_library(self, file_id: str, local_path: str, source: Tr if current_item and current_item.cloud_file_id == file_id: source = current_item.source else: - source = TrackSource.QQ # Default fallback + source = TrackSource.ONLINE from services.metadata.metadata_service import MetadataService from services.lyrics.lyrics_service import LyricsService from utils.helpers import is_filename_like @@ -1938,7 +1951,13 @@ def _save_cloud_track_to_library(self, file_id: str, local_path: str, source: Tr new_duration = metadata.get("duration", 0) # Check if track already exists - existing = self._track_repo.get_by_cloud_file_id(file_id) + provider_id = None + if source == TrackSource.ONLINE: + current_item = self._engine.current_playlist_item + if current_item and current_item.cloud_file_id == file_id and current_item.is_online: + provider_id = current_item.online_provider_id + + existing = self._track_repo.get_by_cloud_file_id(file_id, provider_id=provider_id) if existing: # Update path if it's empty or different if not existing.path or existing.path != local_path: @@ -2029,8 +2048,17 @@ def _save_cloud_track_to_library(self, file_id: str, local_path: str, source: Tr # Fetch cover art cover_path = None if self._cover_service: - cover_path = self._fetch_cover_for_track(file_id, title, artist, album, duration, metadata, local_path, - source) + cover_path = self._fetch_cover_for_track( + file_id, + title, + artist, + album, + duration, + metadata, + local_path, + source, + provider_id=provider_id, + ) track = Track( path=local_path, @@ -2040,7 +2068,8 @@ def _save_cloud_track_to_library(self, file_id: str, local_path: str, source: Tr duration=duration, cloud_file_id=file_id, cover_path=cover_path, - source=source, # Use determined source (QUARK, BAIDU, or QQ) + source=source, + online_provider_id=provider_id, ) self._track_repo.add(track) @@ -2056,8 +2085,18 @@ def _save_cloud_track_to_library(self, file_id: str, local_path: str, source: Tr return cover_path - def _fetch_cover_for_track(self, file_id: str, title: str, artist: str, album: str, - duration: float, metadata: dict, local_path: str, source: TrackSource = None) -> \ + def _fetch_cover_for_track( + self, + file_id: str, + title: str, + artist: str, + album: str, + duration: float, + metadata: dict, + local_path: str, + source: TrackSource = None, + provider_id: str | None = None, + ) -> \ Optional[str]: """ Fetch cover art for a track from various sources. @@ -2085,19 +2124,20 @@ def _fetch_cover_for_track(self, file_id: str, title: str, artist: str, album: s ) logger.info(f"[PlaybackService] Embedded cover saved: {embedded_cover_path}") - # Step 2: For QQ Music online tracks, try to get cover directly by song_mid + # Step 2: For online tracks, try to get cover directly by track id if file_id: - qq_cover_path = None - if source == TrackSource.QQ: - logger.info(f"[PlaybackService] Trying QQ Music cover by song_mid: {file_id}") - qq_cover_path = self._cover_service.get_online_cover( + online_cover_path = None + if source == TrackSource.ONLINE: + logger.info(f"[PlaybackService] Trying online cover by track id: {file_id}") + online_cover_path = self._cover_service.get_online_cover( song_mid=file_id, artist=artist, - title=title + title=title, + provider_id=provider_id, ) - if qq_cover_path: - logger.info(f"[PlaybackService] QQ Music cover downloaded: {qq_cover_path}") - cover_path = qq_cover_path + if online_cover_path: + logger.info(f"[PlaybackService] Online cover downloaded: {online_cover_path}") + cover_path = online_cover_path elif title and artist: # Fallback to search if direct fetch failed logger.info(f"[PlaybackService] Searching cover: {title} - {artist}") @@ -2149,10 +2189,21 @@ def get_track_cover(self, track_path: str, title: str, artist: str, album: str = return self._cover_service.get_cover(track_path, title, artist, album, skip_online=skip_online) return None - def get_online_track_cover(self, source: str, cloud_file_id: str, artist: str = "", title: str = "") -> Optional[ - str]: + def get_online_track_cover( + self, + provider_id: str, + cloud_file_id: str, + artist: str = "", + title: str = "", + ) -> Optional[str]: if self._cover_service: - return self._cover_service.get_online_cover(cloud_file_id, "", artist, title) + return self._cover_service.get_online_cover( + cloud_file_id, + "", + artist, + title, + provider_id=provider_id, + ) return None def save_cover_from_metadata(self, track_path: str, cover_data: bytes) -> Optional[str]: diff --git a/services/playback/queue_service.py b/services/playback/queue_service.py index 0337a351..a540994e 100644 --- a/services/playback/queue_service.py +++ b/services/playback/queue_service.py @@ -127,7 +127,10 @@ def _enrich_metadata(self, item: PlaylistItem) -> PlaylistItem: track = self._track_repo.get_by_id(item.track_id) # For online/cloud tracks, try to get by cloud_file_id elif item.is_cloud and item.cloud_file_id: - track = self._track_repo.get_by_cloud_file_id(item.cloud_file_id) + track = self._track_repo.get_by_cloud_file_id( + item.cloud_file_id, + provider_id=item.online_provider_id if item.is_online else None, + ) # For local files without track_id, try to find by path elif item.local_path and not item.cloud_file_id: track = self._track_repo.get_by_path(item.local_path) @@ -138,7 +141,7 @@ def _enrich_metadata(self, item: PlaylistItem) -> PlaylistItem: file_exists = local_path and Path(local_path).exists() needs_download = False - if item.source == TrackSource.QQ: + if item.is_online: needs_download = not file_exists if not file_exists: local_path = "" @@ -212,7 +215,7 @@ def _enrich_metadata_batch(self, items: List[PlaylistItem]) -> List[PlaylistItem file_exists = local_path and local_path in existing_paths needs_download = False - if item.source == TrackSource.QQ: + if item.is_online: needs_download = not file_exists if not file_exists: local_path = "" diff --git a/services/sources/__init__.py b/services/sources/__init__.py index b84bd401..1feee633 100644 --- a/services/sources/__init__.py +++ b/services/sources/__init__.py @@ -2,7 +2,7 @@ Source providers for cover art and lyrics search. This module provides strategy pattern implementations for multiple -online sources (NetEase, QQ Music, iTunes, etc.). +online sources (NetEase, plugin providers, iTunes, etc.). """ from .base import CoverSource, LyricsSource, ArtistCoverSource diff --git a/services/sources/base.py b/services/sources/base.py index 092d897c..d3a58992 100644 --- a/services/sources/base.py +++ b/services/sources/base.py @@ -22,7 +22,7 @@ class SourceResult: class CoverSearchResult(SourceResult): """Search result for cover art.""" cover_url: Optional[str] = None - album_mid: Optional[str] = None # For QQ Music lazy cover fetch + album_mid: Optional[str] = None # For provider-side lazy cover fetch @dataclass @@ -33,7 +33,7 @@ class ArtistCoverSearchResult: cover_url: Optional[str] = None album_count: Optional[int] = None source: str = "" - singer_mid: Optional[str] = None # For QQ Music lazy cover fetch + singer_mid: Optional[str] = None # For provider-side lazy cover fetch @dataclass @@ -49,7 +49,7 @@ class CoverSource(ABC): """ Abstract base class for cover art sources. - Each source (NetEase, QQ Music, iTunes, etc.) should implement + Each source (NetEase, online plugins, iTunes, etc.) should implement this interface to provide cover art search functionality. """ @@ -147,7 +147,7 @@ class LyricsSource(ABC): """ Abstract base class for lyrics sources. - Each source (NetEase, QQ Music, Kugou, LRCLIB, etc.) should implement + Each source (NetEase, online plugins, Kugou, LRCLIB, etc.) should implement this interface to provide lyrics search functionality. """ diff --git a/system/mpris.py b/system/mpris.py index dfba8248..725f50bf 100644 --- a/system/mpris.py +++ b/system/mpris.py @@ -44,10 +44,11 @@ def _make_track_object_path(track): class MPRISService(dbus.service.Object): - def __init__(self, bus, playback_service, main_window=None): + def __init__(self, bus, playback_service, main_window=None, ui_dispatcher=None): self.bus = bus self.playback_service = playback_service self._main_window = main_window + self._ui_dispatcher = ui_dispatcher self.bus_name = dbus.service.BusName(MPRIS_NAME, bus) super().__init__(self.bus_name, MPRIS_PATH) @@ -175,6 +176,12 @@ def _loop_status(self): def _shuffle(self) -> bool: return bool(getattr(self.playback_service, "shuffle", False)) + def _dispatch_to_ui(self, fn, *args, **kwargs): + if callable(self._ui_dispatcher): + self._ui_dispatcher(fn, *args, **kwargs) + return + fn(*args, **kwargs) + @dbus.service.method("org.mpris.MediaPlayer2.TrackList", out_signature="ao") def GetTracks(self): return dbus.Array( @@ -202,20 +209,26 @@ def TrackListReplaced(self, tracks, current_track): @dbus.service.method("org.mpris.MediaPlayer2") def Raise(self): if self._main_window: - try: - self._main_window.showNormal() - self._main_window.raise_() - self._main_window.activateWindow() - except Exception: - pass + def _raise_window(): + try: + self._main_window.showNormal() + self._main_window.raise_() + self._main_window.activateWindow() + except Exception: + pass + + self._dispatch_to_ui(_raise_window) @dbus.service.method("org.mpris.MediaPlayer2") def Quit(self): if self._main_window: - try: - self._main_window.close() - except Exception: - pass + def _quit_window(): + try: + self._main_window.close() + except Exception: + pass + + self._dispatch_to_ui(_quit_window) # ------------------------ # org.mpris.MediaPlayer2.Player @@ -223,62 +236,87 @@ def Quit(self): @dbus.service.method("org.mpris.MediaPlayer2.Player") def Play(self): - self.playback_service.play() - self.emit_player_properties(["PlaybackStatus"]) + def _play(): + self.playback_service.play() + self.emit_player_properties(["PlaybackStatus"]) + + self._dispatch_to_ui(_play) @dbus.service.method("org.mpris.MediaPlayer2.Player") def Pause(self): - self.playback_service.pause() - self.emit_player_properties(["PlaybackStatus"]) + def _pause(): + self.playback_service.pause() + self.emit_player_properties(["PlaybackStatus"]) + + self._dispatch_to_ui(_pause) @dbus.service.method("org.mpris.MediaPlayer2.Player") def Stop(self): - self.playback_service.stop() - self.emit_player_properties(["PlaybackStatus"]) + def _stop(): + self.playback_service.stop() + self.emit_player_properties(["PlaybackStatus"]) + + self._dispatch_to_ui(_stop) @dbus.service.method("org.mpris.MediaPlayer2.Player") def PlayPause(self): - if self._playback_status() == "Playing": - self.Pause() - else: - self.Play() + def _play_pause(): + if self._playback_status() == "Playing": + self.playback_service.pause() + else: + self.playback_service.play() + self.emit_player_properties(["PlaybackStatus"]) + + self._dispatch_to_ui(_play_pause) @dbus.service.method("org.mpris.MediaPlayer2.Player") def Next(self): - self.playback_service.play_next() - self.emit_player_properties(["PlaybackStatus", "Metadata"]) - self.Seeked(dbus.Int64(self._position_us())) + def _next(): + self.playback_service.play_next() + self.emit_player_properties(["PlaybackStatus", "Metadata"]) + self.Seeked(dbus.Int64(self._position_us())) + + self._dispatch_to_ui(_next) @dbus.service.method("org.mpris.MediaPlayer2.Player") def Previous(self): - self.playback_service.play_previous() - self.emit_player_properties(["PlaybackStatus", "Metadata"]) - self.Seeked(dbus.Int64(self._position_us())) + def _previous(): + self.playback_service.play_previous() + self.emit_player_properties(["PlaybackStatus", "Metadata"]) + self.Seeked(dbus.Int64(self._position_us())) + + self._dispatch_to_ui(_previous) @dbus.service.method("org.mpris.MediaPlayer2.Player", in_signature="x") def Seek(self, offset): - ms = int(offset) // 1000 - self.playback_service.seek(ms) - self.Seeked(dbus.Int64(self._position_us())) + def _seek(): + ms = int(offset) // 1000 + self.playback_service.seek(ms) + self.Seeked(dbus.Int64(self._position_us())) + + self._dispatch_to_ui(_seek) @dbus.service.method("org.mpris.MediaPlayer2.Player", in_signature="ox") def SetPosition(self, track_id, position): - track = self._current_track() - if not track: - return + def _set_position(): + track = self._current_track() + if not track: + return - current_id = _make_track_object_path(track) - if track_id != current_id: - return + current_id = _make_track_object_path(track) + if track_id != current_id: + return - ms = int(position) // 1000 + ms = int(position) // 1000 - try: - self.playback_service.seek(ms) - except TypeError: - pass + try: + self.playback_service.seek(ms) + except TypeError: + pass + + self.Seeked(dbus.Int64(self._position_us())) - self.Seeked(dbus.Int64(self._position_us())) + self._dispatch_to_ui(_set_position) # ------------------------ # org.freedesktop.DBus.Properties @@ -316,8 +354,11 @@ def Set(self, interface_name, property_name, value): if property_name == "Volume": setter = getattr(self.playback_service, "set_volume", None) if callable(setter): - setter(float(value)) - self.emit_player_properties(["Volume"]) + def _set_volume(): + setter(float(value)) + self.emit_player_properties(["Volume"]) + + self._dispatch_to_ui(_set_volume) return raise dbus.exceptions.DBusException( @@ -380,6 +421,7 @@ class MPRISController: def __init__(self, playback_service, main_window=None): self.playback_service = playback_service self._main_window = main_window + self.ui_dispatcher = None self.loop = None self.loop_thread = None self.service = None @@ -403,7 +445,8 @@ def start(self): self.service = MPRISService( self.bus, self.playback_service, - self._main_window + self._main_window, + ui_dispatcher=self.ui_dispatcher, ) self.loop = GLib.MainLoop() diff --git a/system/plugins/host_services.py b/system/plugins/host_services.py index 7286e14c..bc491ecb 100644 --- a/system/plugins/host_services.py +++ b/system/plugins/host_services.py @@ -74,25 +74,6 @@ def dialogs(self): class PluginRuntimeBridgeImpl: - def create_online_music_service(self, *, config_manager=None, credential_provider=None): - return plugin_sdk_runtime.create_online_music_service( - config_manager=config_manager, - credential_provider=credential_provider, - ) - - def create_online_download_service( - self, - *, - config_manager=None, - credential_provider=None, - online_music_service=None, - ): - return plugin_sdk_runtime.create_online_download_service( - config_manager=config_manager, - credential_provider=credential_provider, - online_music_service=online_music_service, - ) - def get_icon(self, name, color, size: int = 16): return plugin_sdk_runtime.get_icon(name, color, size) @@ -200,19 +181,36 @@ def build(self, manifest): self._bootstrap.playback_service, self._bootstrap.library_service, ) - context = PluginContext( - plugin_id=plugin_id, - manifest=manifest, - logger=logging.getLogger(f"plugin.{plugin_id}"), - http=self._bootstrap.http_client, - events=self._bootstrap.event_bus, - language=self._bootstrap.config.get_language(), - storage=PluginStorageBridgeImpl(self._storage_root, plugin_id), - settings=PluginSettingsBridgeImpl(plugin_id, self._bootstrap.config), - ui=PluginUiBridgeImpl(plugin_id, registry), - services=PluginServiceBridgeImpl(plugin_id, registry, media_bridge), - ) - object.__setattr__(context, "runtime", PluginRuntimeBridgeImpl()) + runtime_bridge = PluginRuntimeBridgeImpl() + try: + context = PluginContext( + plugin_id=plugin_id, + manifest=manifest, + logger=logging.getLogger(f"plugin.{plugin_id}"), + http=self._bootstrap.http_client, + events=self._bootstrap.event_bus, + language=self._bootstrap.config.get_language(), + storage=PluginStorageBridgeImpl(self._storage_root, plugin_id), + settings=PluginSettingsBridgeImpl(plugin_id, self._bootstrap.config), + ui=PluginUiBridgeImpl(plugin_id, registry), + services=PluginServiceBridgeImpl(plugin_id, registry, media_bridge), + runtime=runtime_bridge, + ) + except TypeError: + # Compatibility with older installed harmony-plugin-api packages. + context = PluginContext( + plugin_id=plugin_id, + manifest=manifest, + logger=logging.getLogger(f"plugin.{plugin_id}"), + http=self._bootstrap.http_client, + events=self._bootstrap.event_bus, + language=self._bootstrap.config.get_language(), + storage=PluginStorageBridgeImpl(self._storage_root, plugin_id), + settings=PluginSettingsBridgeImpl(plugin_id, self._bootstrap.config), + ui=PluginUiBridgeImpl(plugin_id, registry), + services=PluginServiceBridgeImpl(plugin_id, registry, media_bridge), + ) + object.__setattr__(context, "runtime", runtime_bridge) return context diff --git a/system/plugins/media_bridge.py b/system/plugins/media_bridge.py index 5f83374e..cd04fcaf 100644 --- a/system/plugins/media_bridge.py +++ b/system/plugins/media_bridge.py @@ -22,6 +22,7 @@ def cache_remote_track( return self._download_service.download( request.track_id, song_title=request.title, + provider_id=request.provider_id, quality=request.quality, progress_callback=progress_callback, force=force, @@ -30,6 +31,7 @@ def cache_remote_track( def add_online_track(self, request: PluginPlaybackRequest): metadata = request.metadata return self._library_service.add_online_track( + request.provider_id, request.track_id, metadata.get("title", request.title), metadata.get("artist", ""), @@ -70,12 +72,18 @@ def _build_playlist_item( metadata = request.metadata local_path = "" needs_download = True - if self._download_service and self._download_service.is_cached(request.track_id): - local_path = self._download_service.get_cached_path(request.track_id) + if self._download_service and self._download_service.is_cached( + request.track_id, + provider_id=request.provider_id, + ): + local_path = self._download_service.get_cached_path( + request.track_id, + provider_id=request.provider_id, + ) needs_download = False return PlaylistItem( track_id=track_id, - source=TrackSource.QQ, + source=TrackSource.ONLINE, local_path=local_path, title=metadata.get("title", request.title), artist=metadata.get("artist", ""), @@ -83,6 +91,7 @@ def _build_playlist_item( duration=float(metadata.get("duration", 0.0) or 0.0), cover_path=metadata.get("cover_url"), cloud_file_id=request.track_id, + online_provider_id=request.provider_id, needs_download=needs_download, needs_metadata=False, ) diff --git a/system/plugins/online_cover_helpers.py b/system/plugins/online_cover_helpers.py new file mode 100644 index 00000000..d3e7c73d --- /dev/null +++ b/system/plugins/online_cover_helpers.py @@ -0,0 +1,46 @@ +from __future__ import annotations + +from typing import Any + + +def _iter_sources(kind: str): + from app.bootstrap import Bootstrap + + registry = Bootstrap.instance().plugin_manager.registry + if kind == "artist": + return registry.artist_cover_sources() + return registry.cover_sources() + + +def _matches_provider(source: Any, provider_id: str) -> bool: + normalized = (provider_id or "").strip().lower() + if not normalized: + return False + return ( + getattr(source, "source", None) == normalized + or getattr(source, "name", "").lower() == normalized + or getattr(source, "display_name", "").lower() == normalized + ) + + +def get_online_cover_url( + provider_id: str | None, + track_id: str | None = None, + album_id: str | None = None, + size: int = 500, +): + for source in _iter_sources("cover"): + if provider_id and not _matches_provider(source, provider_id): + continue + if hasattr(source, "get_cover_url"): + return source.get_cover_url(mid=track_id, album_mid=album_id, size=size) + return None + + +def get_online_artist_cover_url(provider_id: str | None, artist_id: str, size: int = 300): + for source in _iter_sources("artist"): + if provider_id and not _matches_provider(source, provider_id): + continue + if hasattr(source, "get_artist_cover_url"): + return source.get_artist_cover_url(artist_id, size=size) + return None diff --git a/system/plugins/online_lyrics_helpers.py b/system/plugins/online_lyrics_helpers.py new file mode 100644 index 00000000..bfc31ac8 --- /dev/null +++ b/system/plugins/online_lyrics_helpers.py @@ -0,0 +1,24 @@ +from __future__ import annotations + +from harmony_plugin_api.lyrics import PluginLyricsResult + + +def download_online_lyrics(song_id: str, provider_id: str) -> str: + from app.bootstrap import Bootstrap + + normalized = (provider_id or "").strip().lower() + if not normalized: + return "" + + sources = Bootstrap.instance().plugin_manager.registry.lyrics_sources() + for source in sources: + source_name = getattr(source, "source", None) or getattr(source, "name", "") + if str(source_name).lower() != normalized: + continue + if hasattr(source, "get_lyrics_by_song_id"): + return source.get_lyrics_by_song_id(song_id) or "" + if hasattr(source, "get_lyrics"): + return source.get_lyrics( + PluginLyricsResult(song_id=song_id, title="", artist="", source=normalized) + ) or "" + return "" diff --git a/system/plugins/plugin_sdk_runtime.py b/system/plugins/plugin_sdk_runtime.py index de1b76ef..7f51dc92 100644 --- a/system/plugins/plugin_sdk_runtime.py +++ b/system/plugins/plugin_sdk_runtime.py @@ -14,30 +14,6 @@ def event_bus(): return EventBus.instance() -def create_online_music_service(*, config_manager=None, credential_provider=None): - from services.online import OnlineMusicService - - return OnlineMusicService( - config_manager=config_manager, - credential_provider=credential_provider, - ) - - -def create_online_download_service( - *, - config_manager=None, - credential_provider=None, - online_music_service=None, -): - from services.online import OnlineDownloadService - - return OnlineDownloadService( - config_manager=config_manager, - credential_provider=credential_provider, - online_music_service=online_music_service, - ) - - def get_host_icon(name, color, size: int = 16): from ui.icons import get_icon as _get_icon @@ -143,6 +119,7 @@ def add_requests_to_favorites(requests: list[Any]) -> list[int]: track_ids: list[int] = [] for request in requests: track_id = instance.library_service.add_online_track( + request.provider_id, request.track_id, request.metadata.get("title", request.title), request.metadata.get("artist", ""), @@ -166,6 +143,7 @@ def add_requests_to_playlist(parent, requests: list[Any], log_prefix: str) -> li track_ids: list[int] = [] for request in requests: track_id = instance.library_service.add_online_track( + request.provider_id, request.track_id, request.metadata.get("title", request.title), request.metadata.get("artist", ""), diff --git a/system/plugins/qqmusic_cover_helpers.py b/system/plugins/qqmusic_cover_helpers.py deleted file mode 100644 index ee71a6bd..00000000 --- a/system/plugins/qqmusic_cover_helpers.py +++ /dev/null @@ -1,32 +0,0 @@ -from __future__ import annotations - - -def _iter_sources(kind: str): - from app.bootstrap import Bootstrap - - registry = Bootstrap.instance().plugin_manager.registry - if kind == "artist": - return registry.artist_cover_sources() - return registry.cover_sources() - - -def _matches_qqmusic(source) -> bool: - return ( - getattr(source, "source", None) == "qqmusic" - or getattr(source, "name", "").lower() == "qqmusic" - or getattr(source, "display_name", "").lower() == "qqmusic" - ) - - -def get_qqmusic_cover_url(mid: str = None, album_mid: str = None, size: int = 500): - for source in _iter_sources("cover"): - if _matches_qqmusic(source) and hasattr(source, "get_cover_url"): - return source.get_cover_url(mid=mid, album_mid=album_mid, size=size) - return None - - -def get_qqmusic_artist_cover_url(singer_mid: str, size: int = 300): - for source in _iter_sources("artist"): - if _matches_qqmusic(source) and hasattr(source, "get_artist_cover_url"): - return source.get_artist_cover_url(singer_mid, size=size) - return None diff --git a/system/plugins/qqmusic_lyrics_helpers.py b/system/plugins/qqmusic_lyrics_helpers.py deleted file mode 100644 index b2e7606f..00000000 --- a/system/plugins/qqmusic_lyrics_helpers.py +++ /dev/null @@ -1,18 +0,0 @@ -from __future__ import annotations - -from harmony_plugin_api.lyrics import PluginLyricsResult - - -def download_qqmusic_lyrics(song_mid: str) -> str: - from app.bootstrap import Bootstrap - - sources = Bootstrap.instance().plugin_manager.registry.lyrics_sources() - for source in sources: - if getattr(source, "source", None) == "qqmusic" or getattr(source, "name", "").lower() == "qqmusic": - if hasattr(source, "get_lyrics_by_song_id"): - return source.get_lyrics_by_song_id(song_mid) or "" - if hasattr(source, "get_lyrics"): - return source.get_lyrics( - PluginLyricsResult(song_id=song_mid, title="", artist="", source="qqmusic") - ) or "" - return "" diff --git a/tests/test_app/test_plugin_bootstrap.py b/tests/test_app/test_plugin_bootstrap.py index 3d7a348d..f54ecb09 100644 --- a/tests/test_app/test_plugin_bootstrap.py +++ b/tests/test_app/test_plugin_bootstrap.py @@ -1,8 +1,11 @@ from pathlib import Path +import builtins +import logging +import os +import sys from unittest.mock import MagicMock import app.bootstrap as bootstrap_module -import services.online as online_module def test_bootstrap_exposes_plugin_manager(monkeypatch): @@ -52,14 +55,13 @@ def test_bootstrap_only_loads_plugins_once(monkeypatch): fake_manager.load_enabled_plugins.assert_called_once() -def test_online_download_service_is_created_with_host_online_music_service(monkeypatch): +def test_online_download_service_is_created_with_plugin_agnostic_gateway(monkeypatch): fake_download_service = object() download_ctor = MagicMock(return_value=fake_download_service) - fake_online_service = object() - online_ctor = MagicMock(return_value=fake_online_service) - - monkeypatch.setattr(online_module, "OnlineDownloadService", download_ctor) - monkeypatch.setattr(online_module, "OnlineMusicService", online_ctor) + monkeypatch.setattr( + "services.download.online_download_gateway.OnlineDownloadGateway", + download_ctor, + ) bootstrap = bootstrap_module.Bootstrap(":memory:") bootstrap._config = object() @@ -69,29 +71,84 @@ def test_online_download_service_is_created_with_host_online_music_service(monke assert service is fake_download_service _, kwargs = download_ctor.call_args assert kwargs["config_manager"] is bootstrap._config - assert kwargs["credential_provider"] is None - assert kwargs["online_music_service"] is fake_online_service + assert callable(kwargs["plugin_manager"]) + assert kwargs["event_bus"] is bootstrap.event_bus -def test_online_music_service_is_created_without_host_credential_provider(monkeypatch): - fake_online_service = object() - online_ctor = MagicMock(return_value=fake_online_service) +def test_bootstrap_no_longer_exposes_qqmusic_client_helpers(): + bootstrap = bootstrap_module.Bootstrap(":memory:") - monkeypatch.setattr(online_module, "OnlineMusicService", online_ctor) + assert not hasattr(bootstrap_module.Bootstrap, "qqmusic_client") + assert not hasattr(bootstrap_module.Bootstrap, "refresh_qqmusic_client") - bootstrap = bootstrap_module.Bootstrap(":memory:") - bootstrap._config = object() - service = bootstrap.online_music_service +def test_mpris_controller_logs_warning_when_linux_dbus_support_is_missing(monkeypatch, caplog): + original_import = builtins.__import__ - assert service is fake_online_service - _, kwargs = online_ctor.call_args - assert kwargs["config_manager"] is bootstrap._config - assert kwargs["credential_provider"] is None + def fake_import(name, globals=None, locals=None, fromlist=(), level=0): + if name == "dbus" or name.startswith("dbus."): + raise ImportError("dbus unavailable") + return original_import(name, globals, locals, fromlist, level) + monkeypatch.setattr(sys, "platform", "linux") + monkeypatch.setattr(builtins, "__import__", fake_import) -def test_bootstrap_no_longer_exposes_qqmusic_client_helpers(): bootstrap = bootstrap_module.Bootstrap(":memory:") - assert not hasattr(bootstrap_module.Bootstrap, "qqmusic_client") - assert not hasattr(bootstrap_module.Bootstrap, "refresh_qqmusic_client") + with caplog.at_level(logging.WARNING, logger="app.bootstrap"): + controller = bootstrap.mpris_controller + + assert controller is None + assert "MPRIS disabled" in caplog.text + assert "dbus unavailable" in caplog.text + + +def test_enable_linux_mpris_runtime_adds_system_module_roots(monkeypatch, tmp_path): + monkeypatch.setattr(sys, "platform", "linux") + monkeypatch.setattr( + bootstrap_module, + "_discover_linux_python_module_roots", + lambda: [os.fspath(tmp_path)], + ) + monkeypatch.setattr(sys, "path", [p for p in sys.path if p != os.fspath(tmp_path)]) + + def fake_can_import(): + if os.fspath(tmp_path) in sys.path: + return True, None + return False, "gi unavailable" + + monkeypatch.setattr( + bootstrap_module, + "_can_import_linux_mpris_runtime", + fake_can_import, + ) + + ready, reason = bootstrap_module._ensure_linux_mpris_runtime() + + assert ready is True + assert reason is None + assert sys.path[0] == os.fspath(tmp_path) + + +def test_enable_linux_mpris_runtime_reports_missing_modules_when_recovery_fails(monkeypatch): + monkeypatch.setattr(sys, "platform", "linux") + monkeypatch.setattr( + bootstrap_module, + "_discover_linux_python_module_roots", + lambda: [], + ) + + original_import = builtins.__import__ + + def fake_import(name, globals=None, locals=None, fromlist=(), level=0): + if name == "dbus" or name.startswith("dbus.") or name == "gi" or name.startswith("gi."): + raise ImportError(f"{name} unavailable") + return original_import(name, globals, locals, fromlist, level) + + monkeypatch.setattr(builtins, "__import__", fake_import) + + ready, reason = bootstrap_module._ensure_linux_mpris_runtime() + + assert ready is False + assert reason is not None + assert "unavailable" in reason diff --git a/tests/test_app/test_qqmusic_host_cleanup.py b/tests/test_app/test_qqmusic_host_cleanup.py index 52e52876..3cde1caf 100644 --- a/tests/test_app/test_qqmusic_host_cleanup.py +++ b/tests/test_app/test_qqmusic_host_cleanup.py @@ -17,9 +17,7 @@ def test_packaging_scripts_no_longer_collect_qqmusic_api(): def test_online_download_service_no_longer_imports_plugin_qqmusic_impl(): - source = Path("services/online/download_service.py").read_text(encoding="utf-8") - - assert "plugins.builtin.qqmusic" not in source + assert not Path("services/online/download_service.py").exists() def test_host_qqmusic_compatibility_view_modules_are_removed(): @@ -89,21 +87,16 @@ def test_qqmusic_plugin_no_longer_imports_host_online_models_or_widgets(): def test_online_services_no_longer_expose_qqmusic_service_parameter_names(): - online_service_source = Path("services/online/online_music_service.py").read_text(encoding="utf-8") - download_service_source = Path("services/online/download_service.py").read_text(encoding="utf-8") bootstrap_source = Path("app/bootstrap.py").read_text(encoding="utf-8") - assert "qqmusic_service" not in online_service_source - assert "qqmusic_service" not in download_service_source + assert not Path("services/online/online_music_service.py").exists() + assert not Path("services/online/download_service.py").exists() assert "qqmusic_service" not in bootstrap_source def test_online_services_no_longer_store_private_qqmusic_field_names(): - online_service_source = Path("services/online/online_music_service.py").read_text(encoding="utf-8") - download_service_source = Path("services/online/download_service.py").read_text(encoding="utf-8") - - assert "self._qqmusic =" not in online_service_source - assert "self._qqmusic =" not in download_service_source + assert not Path("services/online/online_music_service.py").exists() + assert not Path("services/online/download_service.py").exists() def test_plugin_page_modules_do_not_directly_import_host_layers(): @@ -137,3 +130,36 @@ def test_plugin_page_modules_do_not_directly_import_host_layers(): ): source = Path(relative_path).read_text(encoding="utf-8") assert not any(prefix in source for prefix in forbidden_prefixes), relative_path + + +def test_qqmusic_plugin_legacy_directory_is_removed(): + assert not Path("plugins/builtin/qqmusic/lib/legacy").exists() + + +def test_qqmusic_plugin_modules_do_not_import_legacy_package(): + for path in Path("plugins/builtin/qqmusic/lib").rglob("*.py"): + source = path.read_text(encoding="utf-8") + assert "from .legacy import" not in source, str(path) + assert "from .legacy." not in source, str(path) + assert "import .legacy" not in source, str(path) + + +def test_host_quality_modules_are_removed(): + assert not Path("services/download/quality.py").exists() + assert not Path("services/online/quality.py").exists() + + +def test_online_download_gateway_no_longer_contains_host_http_download_logic(): + source = Path("services/download/online_download_gateway.py").read_text(encoding="utf-8") + + assert "HttpClient" not in source + assert "get_playback_url_info" not in source + assert "download_track(" in source + + +def test_download_manager_redownload_entry_is_provider_driven(): + source = Path("services/download/download_manager.py").read_text(encoding="utf-8") + + assert "def redownload_online_track(" in source + assert "provider_id" in source + assert "TrackSource.QQ" not in source diff --git a/tests/test_domain/test_bug1_track_source.py b/tests/test_domain/test_bug1_track_source.py index 7e1c2543..cbaed742 100644 --- a/tests/test_domain/test_bug1_track_source.py +++ b/tests/test_domain/test_bug1_track_source.py @@ -1,7 +1,7 @@ """ Tests for bug fix: Bug 1 - from_track should preserve original track source. -Previously, from_track() hardcoded source=TrackSource.QQ for all online tracks, +Previously, from_track() hardcoded one online source for all online tracks, even QUARK/BAIDU tracks that hadn't been downloaded yet. """ @@ -10,7 +10,7 @@ class TestBug1TrackSourcePreservation: - """Bug 1: from_track() should use track.source, not hardcoded QQ.""" + """Bug 1: from_track() should use track.source, not a hardcoded provider source.""" def test_quark_online_track_preserves_source(self): """QUARK track with empty path should keep QUARK source.""" @@ -38,18 +38,19 @@ def test_baidu_online_track_preserves_source(self): item = PlaylistItem.from_track(track) assert item.source == TrackSource.BAIDU - def test_qq_online_track_still_qq(self): - """QQ track with empty path should still be QQ.""" + def test_online_track_still_online(self): + """Online track with empty path should still be ONLINE.""" track = Track( id=3, path="", - title="QQ Song", + title="Online Song", artist="Artist", - source=TrackSource.QQ, + source=TrackSource.ONLINE, + online_provider_id="qqmusic", cloud_file_id="song_mid", ) item = PlaylistItem.from_track(track) - assert item.source == TrackSource.QQ + assert item.source == TrackSource.ONLINE def test_local_track_still_local(self): """Local track with path should remain LOCAL.""" diff --git a/tests/test_domain/test_playback.py b/tests/test_domain/test_playback.py index 55eb3d87..1f1341fa 100644 --- a/tests/test_domain/test_playback.py +++ b/tests/test_domain/test_playback.py @@ -91,17 +91,18 @@ def test_cloud_file_initialization(self): assert item.cloud_account_id == 1 def test_online_track_initialization(self): - """Test queue item for online track (QQ Music).""" + """Test queue item for online track.""" item = PlayQueueItem( position=1, - source="QQ", + source="ONLINE", cloud_file_id="song_mid_123", + online_provider_id="qqmusic", local_path="/cache/online/song.mp3", title="Online Song", artist="Online Artist", duration=200.0, ) - assert item.source == "QQ" + assert item.source == "ONLINE" assert item.cloud_file_id == "song_mid_123" def test_baidu_cloud_initialization(self): diff --git a/tests/test_domain/test_playlist_item.py b/tests/test_domain/test_playlist_item.py index 0597d769..0ef85d8d 100644 --- a/tests/test_domain/test_playlist_item.py +++ b/tests/test_domain/test_playlist_item.py @@ -50,17 +50,19 @@ def test_from_online_track(self): path="", # Empty path indicates online track title="Online Song", artist="Online Artist", - source=TrackSource.QQ, - cloud_file_id="song_mid_123" + source=TrackSource.ONLINE, + online_provider_id="qqmusic", + cloud_file_id="song_mid_123", ) item = PlaylistItem.from_track(track) - assert item.source == TrackSource.QQ + assert item.source == TrackSource.ONLINE + assert item.online_provider_id == "qqmusic" assert item.cloud_file_id == "song_mid_123" assert item.needs_download is True def test_from_downloaded_online_track_preserves_local_path(self, temp_dir): - """Downloaded QQ tracks should keep their cached local path.""" + """Downloaded online tracks should keep provider id and cached local path.""" cached_path = temp_dir / "song.mp3" cached_path.write_text("cached") track = Track( @@ -68,12 +70,14 @@ def test_from_downloaded_online_track_preserves_local_path(self, temp_dir): path=str(cached_path), title="Downloaded Song", artist="Online Artist", - source=TrackSource.QQ, - cloud_file_id="song_mid_123" + source=TrackSource.ONLINE, + online_provider_id="qqmusic", + cloud_file_id="song_mid_123", ) item = PlaylistItem.from_track(track) - assert item.source == TrackSource.QQ + assert item.source == TrackSource.ONLINE + assert item.online_provider_id == "qqmusic" assert item.cloud_file_id == "song_mid_123" assert item.local_path == str(cached_path) assert item.needs_download is False @@ -144,13 +148,15 @@ def test_from_dict_with_source(self): """Test creating PlaylistItem from dict with source field.""" data = { "id": 1, - "source": "QQ", + "source": "ONLINE", + "online_provider_id": "qqmusic", "cloud_file_id": "song_mid", "title": "Online Song", } item = PlaylistItem.from_dict(data) - assert item.source == TrackSource.QQ + assert item.source == TrackSource.ONLINE + assert item.online_provider_id == "qqmusic" def test_to_dict(self): """Test converting PlaylistItem to dict.""" @@ -177,8 +183,8 @@ def test_is_cloud_property(self): cloud_item = PlaylistItem(source=TrackSource.QUARK) assert cloud_item.is_cloud is True - qq_item = PlaylistItem(source=TrackSource.QQ) - assert qq_item.is_cloud is True + online_item = PlaylistItem(source=TrackSource.ONLINE, online_provider_id="qqmusic") + assert online_item.is_cloud is True def test_is_local_property(self): """Test is_local property.""" @@ -278,7 +284,8 @@ def test_to_play_queue_item_cloud(self): def test_to_play_queue_item_online(self): """Test converting online PlaylistItem to PlayQueueItem.""" item = PlaylistItem( - source=TrackSource.QQ, + source=TrackSource.ONLINE, + online_provider_id="qqmusic", cloud_file_id="song_mid_123", local_path="/cache/online/song.mp3", title="Online Song", @@ -288,7 +295,8 @@ def test_to_play_queue_item_online(self): ) queue_item = item.to_play_queue_item(position=0) - assert queue_item.source == "QQ" + assert queue_item.source == "ONLINE" + assert queue_item.online_provider_id == "qqmusic" assert queue_item.cloud_file_id == "song_mid_123" assert queue_item.title == "Online Song" assert queue_item.artist == "Online Artist" @@ -335,7 +343,8 @@ def test_from_play_queue_item_online(self): queue_item = PlayQueueItem( position=1, - source="QQ", + source="ONLINE", + online_provider_id="qqmusic", cloud_file_id="song_mid_123", local_path="/cache/online/song.mp3", title="Online Song", @@ -345,7 +354,8 @@ def test_from_play_queue_item_online(self): ) playlist_item = PlaylistItem.from_play_queue_item(queue_item) - assert playlist_item.source == TrackSource.QQ + assert playlist_item.source == TrackSource.ONLINE + assert playlist_item.online_provider_id == "qqmusic" assert playlist_item.cloud_file_id == "song_mid_123" assert playlist_item.title == "Online Song" assert playlist_item.artist == "Online Artist" diff --git a/tests/test_domain/test_track.py b/tests/test_domain/test_track.py index e3b4e31e..b60f4b1e 100644 --- a/tests/test_domain/test_track.py +++ b/tests/test_domain/test_track.py @@ -96,12 +96,20 @@ def test_source_set_other_values(self): track = Track(source=TrackSource.QUARK) assert track.source == TrackSource.QUARK - track = Track(source=TrackSource.QQ) - assert track.source == TrackSource.QQ + track = Track(source=TrackSource.ONLINE) + assert track.source == TrackSource.ONLINE track = Track(source=TrackSource.BAIDU) assert track.source == TrackSource.BAIDU + def test_online_provider_id_default_and_set(self): + """Test online_provider_id defaults to None and can be set.""" + track = Track() + assert track.online_provider_id is None + + track = Track(source=TrackSource.ONLINE, online_provider_id="qqmusic") + assert track.online_provider_id == "qqmusic" + def test_cloud_file_id_default_and_set(self): """Test cloud_file_id default None and can be set.""" track = Track() @@ -124,6 +132,6 @@ def test_file_size_and_file_mtime(self): def test_track_source_enum_string_values(self): """Test TrackSource enum string values.""" assert TrackSource.LOCAL.value == "Local" + assert TrackSource.ONLINE.value == "ONLINE" assert TrackSource.QUARK.value == "QUARK" assert TrackSource.BAIDU.value == "BAIDU" - assert TrackSource.QQ.value == "QQ" diff --git a/tests/test_infrastructure/test_audio_engine.py b/tests/test_infrastructure/test_audio_engine.py index 72a7ba26..5837a40d 100644 --- a/tests/test_infrastructure/test_audio_engine.py +++ b/tests/test_infrastructure/test_audio_engine.py @@ -18,8 +18,20 @@ def test_update_playlist_item_updates_all_duplicate_cloud_ids(): engine = PlayerEngine.__new__(PlayerEngine) engine._playlist_lock = threading.RLock() engine._playlist = [ - PlaylistItem(source=TrackSource.QQ, cloud_file_id="song_mid_123", title="A", needs_download=True), - PlaylistItem(source=TrackSource.QQ, cloud_file_id="song_mid_123", title="B", needs_download=True), + PlaylistItem( + source=TrackSource.ONLINE, + online_provider_id="qqmusic", + cloud_file_id="song_mid_123", + title="A", + needs_download=True, + ), + PlaylistItem( + source=TrackSource.ONLINE, + online_provider_id="qqmusic", + cloud_file_id="song_mid_123", + title="B", + needs_download=True, + ), ] engine._cloud_file_id_to_index = {"song_mid_123": 0} @@ -103,7 +115,8 @@ def test_play_next_skips_missing_local_track_and_plays_following_track(): def test_play_at_emits_pending_signal_for_online_track_needing_download(): item = PlaylistItem( - source=TrackSource.QQ, + source=TrackSource.ONLINE, + online_provider_id="qqmusic", cloud_file_id="song_mid_456", local_path="", title="Pending Song", diff --git a/tests/test_infrastructure/test_audio_engine_seek_behavior.py b/tests/test_infrastructure/test_audio_engine_seek_behavior.py index 3e415e51..c93ffe10 100644 --- a/tests/test_infrastructure/test_audio_engine_seek_behavior.py +++ b/tests/test_infrastructure/test_audio_engine_seek_behavior.py @@ -109,14 +109,16 @@ def test_play_after_download_reloads_when_current_index_already_advanced(): Path(next_path).write_bytes(b"next") current_item = PlaylistItem( - source=TrackSource.QQ, + source=TrackSource.ONLINE, + online_provider_id="qqmusic", cloud_file_id="song_1", local_path=previous_path, title="Previous", needs_download=False, ) next_item = PlaylistItem( - source=TrackSource.QQ, + source=TrackSource.ONLINE, + online_provider_id="qqmusic", cloud_file_id="song_2", local_path="", title="Next", diff --git a/tests/test_plugins/test_qqmusic_plugin.py b/tests/test_plugins/test_qqmusic_plugin.py index f516bfa3..b10bacab 100644 --- a/tests/test_plugins/test_qqmusic_plugin.py +++ b/tests/test_plugins/test_qqmusic_plugin.py @@ -2,8 +2,16 @@ from plugins.builtin.qqmusic.lib import i18n as plugin_i18n from plugins.builtin.qqmusic.lib.client import QQMusicPluginClient +from plugins.builtin.qqmusic.lib.models import OnlineArtist from plugins.builtin.qqmusic.lib.online_music_view import OnlineMusicView +from plugins.builtin.qqmusic.lib.plugin_online_music_service import PluginOnlineMusicService from plugins.builtin.qqmusic.lib.provider import QQMusicOnlineProvider +from plugins.builtin.qqmusic.lib.runtime_bridge import ( + bind_context, + clear_context, + create_online_download_service, + create_online_music_service, +) from plugins.builtin.qqmusic.plugin_main import QQMusicPlugin @@ -24,6 +32,37 @@ def test_qqmusic_plugin_registers_expected_capabilities(): assert context.services.register_online_music_provider.call_count == 1 +def test_runtime_bridge_uses_plugin_online_core_services(): + context = Mock() + context.http = Mock() + context.settings = Mock() + context.runtime = Mock() + config = Mock() + config.get_online_music_download_dir.return_value = "data/online_cache" + bind_context(context) + try: + service = create_online_music_service( + config_manager=config, + credential_provider=None, + ) + download_service = create_online_download_service( + config_manager=config, + credential_provider=None, + online_music_service=service, + ) + finally: + clear_context(context) + + assert service.__class__.__module__.startswith( + "plugins.builtin.qqmusic.lib.plugin_online_music_service" + ) + assert download_service.__class__.__module__.startswith( + "plugins.builtin.qqmusic.lib.plugin_online_download_service" + ) + context.runtime.create_online_music_service.assert_not_called() + context.runtime.create_online_download_service.assert_not_called() + + def test_qqmusic_provider_create_page_uses_legacy_online_music_view(monkeypatch): context = Mock() context.settings.get.side_effect = lambda key, default=None: default @@ -91,6 +130,98 @@ def _capture_view(*, config_manager=None, qqmusic_service=None, plugin_context=N assert captured["qqmusic_service"] is None +def test_qqmusic_provider_download_track_delegates_to_plugin_service(monkeypatch): + settings = Mock() + settings.get.side_effect = lambda key, default=None: { + "online_music_download_dir": "data/online_cache", + }.get(key, default) + context = Mock(settings=settings) + context.logger = Mock() + + created = {} + + class _DownloadService: + def __init__(self): + self.set_download_dir = Mock() + self.download = Mock(return_value="/tmp/song.ogg") + self.pop_last_download_quality = Mock(return_value="ogg_320") + + download_service = _DownloadService() + + def _create_service(**kwargs): + created.update(kwargs) + return download_service + + monkeypatch.setattr( + "plugins.builtin.qqmusic.lib.provider.create_online_download_service", + _create_service, + ) + + provider = QQMusicOnlineProvider(context) + result = provider.download_track("song-mid", "flac", target_dir="/tmp/online-cache") + + assert result == {"local_path": "/tmp/song.ogg", "quality": "ogg_320"} + assert created["config_manager"].get_online_music_download_dir() == "data/online_cache" + assert created["credential_provider"] is provider._client + download_service.set_download_dir.assert_called_once_with("/tmp/online-cache") + download_service.download.assert_called_once_with( + "song-mid", + quality="flac", + progress_callback=None, + force=False, + ) + download_service.pop_last_download_quality.assert_called_once_with("song-mid") + + +def test_qqmusic_provider_exposes_download_quality_options(): + settings = Mock() + settings.get.side_effect = lambda key, default=None: default + context = Mock(settings=settings) + context.logger = Mock() + provider = QQMusicOnlineProvider(context) + + options = provider.get_download_qualities("song-mid") + + assert options + assert all("value" in item and "label" in item for item in options) + + +def test_qqmusic_provider_redownload_calls_download_with_force(monkeypatch): + settings = Mock() + settings.get.side_effect = lambda key, default=None: { + "online_music_download_dir": "data/online_cache", + }.get(key, default) + context = Mock(settings=settings) + context.logger = Mock() + + class _DownloadService: + def __init__(self): + self.set_download_dir = Mock() + self.download = Mock(return_value="/tmp/song.flac") + self.pop_last_download_quality = Mock(return_value="flac") + + download_service = _DownloadService() + monkeypatch.setattr( + "plugins.builtin.qqmusic.lib.provider.create_online_download_service", + lambda **_kwargs: download_service, + ) + + provider = QQMusicOnlineProvider(context) + result = provider.redownload_track( + "song-mid", + "flac", + target_dir="/tmp/online-cache", + ) + + assert result == {"local_path": "/tmp/song.flac", "quality": "flac"} + download_service.download.assert_called_once_with( + "song-mid", + quality="flac", + progress_callback=None, + force=True, + ) + + def test_qqmusic_plugin_uses_private_translations_not_global(monkeypatch): import system.i18n as global_i18n @@ -113,7 +244,7 @@ def test_qqmusic_provider_config_adapter_tracks_search_history_and_plugin_settin settings.get.side_effect = lambda key, default=None: store.get(key, default) settings.set.side_effect = lambda key, value: store.__setitem__(key, value) - adapter = QQMusicOnlineProvider._create_legacy_config_adapter(Mock(settings=settings)) + adapter = QQMusicOnlineProvider._create_config_adapter(Mock(settings=settings)) adapter.add_search_history("B") adapter.add_search_history("A") @@ -130,7 +261,7 @@ def test_qqmusic_provider_config_adapter_exposes_download_dir(): "online_music_download_dir": "data/online_cache", }.get(key, default) - adapter = QQMusicOnlineProvider._create_legacy_config_adapter(Mock(settings=settings)) + adapter = QQMusicOnlineProvider._create_config_adapter(Mock(settings=settings)) assert adapter.get_online_music_download_dir() == "data/online_cache" @@ -321,3 +452,98 @@ def test_plugin_client_skips_private_legacy_calls_when_network_unreachable(monke service.get_home_feed.assert_not_called() service.get_my_fav_songs.assert_not_called() + + +def test_plugin_client_search_falls_back_to_public_api_when_legacy_empty(monkeypatch): + settings = Mock() + settings.get.side_effect = lambda key, default=None: { + "credential": {"musicid": "1", "musickey": "secret"}, + }.get(key, default) + context = Mock(settings=settings) + + api = Mock() + api.search.return_value = { + "tracks": [{"mid": "api-song", "title": "API Song"}], + "total": 1, + } + monkeypatch.setattr( + "plugins.builtin.qqmusic.lib.client.QQMusicPluginAPI", + Mock(return_value=api), + ) + + client = QQMusicPluginClient(context) + monkeypatch.setattr(client, "_can_use_legacy_network", lambda: True) + monkeypatch.setattr( + client, + "_search_legacy", + lambda keyword, search_type, page, limit: {"tracks": [], "total": 0}, + ) + + result = client.search("keyword", search_type="song", limit=20, page=1) + + assert result["tracks"][0]["mid"] == "api-song" + api.search.assert_called_once_with("keyword", search_type="song", limit=20, page=1) + + +def test_plugin_online_music_service_converts_singer_payload_to_models(monkeypatch): + context = Mock() + context.settings = Mock() + service = PluginOnlineMusicService(context) + monkeypatch.setattr( + service, + "_client_adapter", + Mock( + search=Mock( + return_value={ + "artists": [ + { + "mid": "artist-mid", + "name": "Artist A", + "avatar_url": "https://example.com/a.jpg", + "song_count": 12, + } + ], + "total": 1, + } + ) + ), + ) + + result = service.search("artist", search_type="singer", page=1, page_size=30) + + assert len(result.artists) == 1 + assert isinstance(result.artists[0], OnlineArtist) + assert result.artists[0].mid == "artist-mid" + assert result.artists[0].name == "Artist A" + + +def test_plugin_online_music_service_strips_em_tags_in_search_results(monkeypatch): + context = Mock() + context.settings = Mock() + service = PluginOnlineMusicService(context) + monkeypatch.setattr( + service, + "_client_adapter", + Mock( + search=Mock( + return_value={ + "tracks": [ + { + "mid": "song-mid", + "title": "晴天", + "artist": "周杰伦", + "album": "叶惠美", + "duration": 269, + } + ], + "total": 1, + } + ) + ), + ) + + result = service.search("晴天", search_type="song", page=1, page_size=30) + + assert result.tracks[0].title == "晴天" + assert result.tracks[0].singer_name == "周杰伦" + assert result.tracks[0].album_name == "叶惠美" diff --git a/tests/test_qqmusic_lazy_fetch.py b/tests/test_qqmusic_lazy_fetch.py index f156fe7d..e6554535 100644 --- a/tests/test_qqmusic_lazy_fetch.py +++ b/tests/test_qqmusic_lazy_fetch.py @@ -78,8 +78,8 @@ def test_track_strategy_lazy_fetch_with_album_mid(self, qapp): 'id': 'song456' } - # Mock the QQ Music API - with patch('ui.strategies.track_search_strategy.get_qqmusic_cover_url') as mock_get_url, \ + # Mock provider cover helper + with patch('ui.strategies.track_search_strategy.get_online_cover_url') as mock_get_url, \ patch('ui.strategies.track_search_strategy.HttpClient') as mock_http: mock_get_url.return_value = 'https://y.gtimg.cn/music/photo_new/T002R500x500M000album123.jpg' @@ -89,8 +89,13 @@ def test_track_strategy_lazy_fetch_with_album_mid(self, qapp): cover_data = strategy.lazy_fetch(mock_cover_service, result) - # Verify correct API was called with album_mid - mock_get_url.assert_called_once_with(album_mid='album123', size=500) + # Verify correct API was called with provider and album id + mock_get_url.assert_called_once_with( + provider_id='qqmusic', + track_id='song456', + album_id='album123', + size=500, + ) assert cover_data == b'fake_image_data' def test_track_strategy_lazy_fetch_with_song_id(self, qapp): @@ -109,8 +114,8 @@ def test_track_strategy_lazy_fetch_with_song_id(self, qapp): 'id': 'song456' } - # Mock the QQ Music API - with patch('ui.strategies.track_search_strategy.get_qqmusic_cover_url') as mock_get_url, \ + # Mock provider cover helper + with patch('ui.strategies.track_search_strategy.get_online_cover_url') as mock_get_url, \ patch('ui.strategies.track_search_strategy.HttpClient') as mock_http: mock_get_url.return_value = 'https://y.gtimg.cn/music/photo_new/T002R500x500M000song456.jpg' @@ -120,8 +125,13 @@ def test_track_strategy_lazy_fetch_with_song_id(self, qapp): cover_data = strategy.lazy_fetch(mock_cover_service, result) - # Verify correct API was called with song mid - mock_get_url.assert_called_once_with(mid='song456', size=500) + # Verify correct API was called with provider and track id + mock_get_url.assert_called_once_with( + provider_id='qqmusic', + track_id='song456', + album_id=None, + size=500, + ) assert cover_data == b'fake_image_data' def test_album_strategy_uses_correct_fields(self, qapp): @@ -174,8 +184,8 @@ def test_artist_strategy_lazy_fetch(self, qapp): 'singer_mid': 'singer123' } - # Mock the QQ Music API - with patch('ui.strategies.artist_search_strategy.get_qqmusic_artist_cover_url') as mock_get_url, \ + # Mock provider artist cover helper + with patch('ui.strategies.artist_search_strategy.get_online_artist_cover_url') as mock_get_url, \ patch('ui.strategies.artist_search_strategy.HttpClient') as mock_http: mock_get_url.return_value = 'https://y.gtimg.cn/music/photo_new/T001R500x500M000singer123.jpg' @@ -186,5 +196,9 @@ def test_artist_strategy_lazy_fetch(self, qapp): cover_data = strategy.lazy_fetch(mock_cover_service, result) # Verify correct API was called - mock_get_url.assert_called_once_with('singer123', size=500) + mock_get_url.assert_called_once_with( + provider_id='qqmusic', + artist_id='singer123', + size=500, + ) assert cover_data == b'fake_artist_image' diff --git a/tests/test_queue_view.py b/tests/test_queue_view.py index 9a594add..16b137b9 100644 --- a/tests/test_queue_view.py +++ b/tests/test_queue_view.py @@ -137,8 +137,8 @@ def test_get_tracks_by_ids_uses_batch_api_when_available(): view = QueueView.__new__(QueueView) view._library_service = MagicMock() view._library_service.get_tracks_by_ids.return_value = [ - Track(id=1, title="One", source=TrackSource.QQ), - Track(id=2, title="Two", source=TrackSource.QQ), + Track(id=1, title="One", source=TrackSource.ONLINE, online_provider_id="qqmusic"), + Track(id=2, title="Two", source=TrackSource.ONLINE, online_provider_id="qqmusic"), ] view._library_service.get_track.side_effect = AssertionError("Should not call per-item lookup") @@ -159,7 +159,7 @@ def __init__(self): def get_track(self, track_id): self.calls.append(track_id) - return Track(id=track_id, title=str(track_id), source=TrackSource.QQ) + return Track(id=track_id, title=str(track_id), source=TrackSource.ONLINE, online_provider_id="qqmusic") view = QueueView.__new__(QueueView) view._library_service = LegacyLibraryService() diff --git a/tests/test_repositories/test_queue_repository.py b/tests/test_repositories/test_queue_repository.py index 2f63c31a..01bb8a72 100644 --- a/tests/test_repositories/test_queue_repository.py +++ b/tests/test_repositories/test_queue_repository.py @@ -30,6 +30,7 @@ def temp_db(): source TEXT NOT NULL, track_id INTEGER, cloud_file_id TEXT, + online_provider_id TEXT, cloud_account_id INTEGER, local_path TEXT, title TEXT, @@ -163,12 +164,13 @@ def test_save_cloud_items(self, queue_repo): assert loaded[0].title == "Cloud Song" def test_save_online_items(self, queue_repo): - """Test saving online (QQ Music) items.""" + """Test saving online items.""" items = [ PlayQueueItem( position=0, - source="QQ", + source="ONLINE", cloud_file_id="song_mid_123", + online_provider_id="qqmusic", title="Online Song", artist="Online Artist", duration=200.0 @@ -180,7 +182,7 @@ def test_save_online_items(self, queue_repo): loaded = queue_repo.load() assert len(loaded) == 1 - assert loaded[0].source == "QQ" + assert loaded[0].source == "ONLINE" assert loaded[0].cloud_file_id == "song_mid_123" def test_row_to_item_conversion(self, queue_repo): @@ -273,8 +275,8 @@ def test_save_mixed_sources(self, queue_repo): cloud_account_id=1, title="Quark Song", duration=200.0 ), PlayQueueItem( - position=2, source="QQ", cloud_file_id="qq1", - title="QQ Song", duration=150.0 + position=2, source="ONLINE", cloud_file_id="online1", + online_provider_id="qqmusic", title="Online Song", duration=150.0 ), PlayQueueItem( position=3, source="BAIDU", cloud_file_id="b1", @@ -289,7 +291,7 @@ def test_save_mixed_sources(self, queue_repo): assert len(loaded) == 4 assert loaded[0].source == "Local" assert loaded[1].source == "QUARK" - assert loaded[2].source == "QQ" + assert loaded[2].source == "ONLINE" assert loaded[3].source == "BAIDU" def test_save_large_queue(self, queue_repo): @@ -382,7 +384,7 @@ def test_load_old_schema_source_type_local(self, temp_db): assert loaded[0].title == "Old Local Song" def test_load_old_schema_source_type_online(self, temp_db): - """Test loading from old schema with source_type='online' maps to 'QQ'.""" + """Test loading from old schema with source_type='online' maps to 'ONLINE'.""" conn = sqlite3.connect(temp_db) conn.row_factory = sqlite3.Row cursor = conn.cursor() @@ -407,14 +409,14 @@ def test_load_old_schema_source_type_online(self, temp_db): cursor.execute(""" INSERT INTO play_queue (position, source_type, cloud_file_id, title) VALUES (?, ?, ?, ?) - """, (0, "online", "mid_123", "QQ Song")) + """, (0, "online", "mid_123", "Online Song")) conn.commit() conn.close() repo = SqliteQueueRepository(temp_db) loaded = repo.load() assert len(loaded) == 1 - assert loaded[0].source == "QQ" + assert loaded[0].source == "ONLINE" assert loaded[0].cloud_file_id == "mid_123" def test_load_old_schema_source_type_cloud(self, temp_db): diff --git a/tests/test_repositories/test_track_repository.py b/tests/test_repositories/test_track_repository.py index a8c17e15..a6e369a4 100644 --- a/tests/test_repositories/test_track_repository.py +++ b/tests/test_repositories/test_track_repository.py @@ -33,6 +33,7 @@ def temp_db(): duration REAL, cover_path TEXT, cloud_file_id TEXT, + online_provider_id TEXT, source TEXT DEFAULT 'Local', created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ) @@ -239,13 +240,14 @@ def test_get_all_can_filter_by_source(self, track_repo): """Track listing should support filtering by source in SQL.""" track_repo.add(Track(path="/music/local.mp3", title="Local", source=TrackSource.LOCAL)) track_repo.add(Track( - path="qqmusic://song/abc", + path="online://qqmusic/track/abc", title="Online", - source=TrackSource.QQ, + source=TrackSource.ONLINE, + online_provider_id="qqmusic", cloud_file_id="abc", )) - tracks = track_repo.get_all(source=TrackSource.QQ) + tracks = track_repo.get_all(source=TrackSource.ONLINE) assert len(tracks) == 1 assert tracks[0].title == "Online" @@ -364,17 +366,19 @@ def test_search_tracks_supports_offset_and_source_filter(self, track_repo, temp_ tracks = [ Track(path="/music/local-song.mp3", title="Song Alpha", artist="Local Artist", source=TrackSource.LOCAL), Track( - path="qqmusic://song/1", + path="online://qqmusic/track/1", title="Song Beta", artist="QQ Artist", - source=TrackSource.QQ, + source=TrackSource.ONLINE, + online_provider_id="qqmusic", cloud_file_id="song-1", ), Track( - path="qqmusic://song/2", + path="online://qqmusic/track/2", title="Song Gamma", artist="QQ Artist", - source=TrackSource.QQ, + source=TrackSource.ONLINE, + online_provider_id="qqmusic", cloud_file_id="song-2", ), ] @@ -390,7 +394,7 @@ def test_search_tracks_supports_offset_and_source_filter(self, track_repo, temp_ conn.commit() conn.close() - results = track_repo.search("Song", limit=1, offset=1, source=TrackSource.QQ) + results = track_repo.search("Song", limit=1, offset=1, source=TrackSource.ONLINE) assert len(results) == 1 assert results[0].title == "Song Beta" diff --git a/tests/test_services/test_cache_cleaner_service.py b/tests/test_services/test_cache_cleaner_service.py index 3c32979d..7cb35cd4 100644 --- a/tests/test_services/test_cache_cleaner_service.py +++ b/tests/test_services/test_cache_cleaner_service.py @@ -1,7 +1,7 @@ from pathlib import Path from unittest.mock import MagicMock -from services.online.cache_cleaner_service import CacheCleanerService +from services.download.cache_cleaner_service import CacheCleanerService def test_cache_cleaner_supports_extended_audio_extensions(tmp_path): diff --git a/tests/test_services/test_download_manager_cleanup.py b/tests/test_services/test_download_manager_cleanup.py index b5583a96..b00da430 100644 --- a/tests/test_services/test_download_manager_cleanup.py +++ b/tests/test_services/test_download_manager_cleanup.py @@ -87,8 +87,8 @@ def test_stop_worker_cleans_up_stale_registry_entries(monkeypatch): fake_worker.deleteLater.assert_called_once() -def test_redownload_replaces_stale_worker_and_disconnects_old_signals(monkeypatch): - """Replacing stale worker should disconnect old signals before deleteLater.""" +def test_online_download_replaces_stale_worker_and_disconnects_old_signals(monkeypatch): + """Replacing stale online worker should disconnect old signals before deleteLater.""" manager = DownloadManager() monkeypatch.setattr(download_manager_module, "isValid", lambda _obj: True) monkeypatch.setattr(DownloadManager, "_OnlineDownloadWorker", _FakeWorker) @@ -97,14 +97,20 @@ def test_redownload_replaces_stale_worker_and_disconnects_old_signals(monkeypatc "instance", classmethod(lambda cls: SimpleNamespace(online_download_service=object())), ) + item = SimpleNamespace( + source=TrackSource.ONLINE, + cloud_file_id="song-mid", + title="Song A", + online_provider_id="qqmusic", + ) - assert manager.redownload_online_track("song-mid", "Song A") + assert manager._download_online_track(item) first_worker = manager._download_workers["song-mid"] # Simulate stale worker that is no longer running. first_worker._running = False - assert manager.redownload_online_track("song-mid", "Song A") + assert manager._download_online_track(item) second_worker = manager._download_workers["song-mid"] assert second_worker is not first_worker @@ -204,7 +210,7 @@ def test_download_cloud_track_uses_cloud_repository_dependency(monkeypatch): fake_service.download_file.assert_called_once_with(cloud_file, cloud_account) -def test_redownload_online_track_registers_worker_atomically(monkeypatch): +def test_online_download_registers_worker_atomically(monkeypatch): """Concurrent requests for the same song should only create one worker.""" manager = DownloadManager() monkeypatch.setattr(download_manager_module, "isValid", lambda _obj: True) @@ -213,6 +219,12 @@ def test_redownload_online_track_registers_worker_atomically(monkeypatch): "instance", classmethod(lambda cls: SimpleNamespace(online_download_service=object())), ) + item = SimpleNamespace( + source=TrackSource.ONLINE, + cloud_file_id="song-mid", + title="Song A", + online_provider_id="qqmusic", + ) created_count = 0 created_lock = threading.Lock() @@ -232,7 +244,7 @@ def __init__(self, *_args, **_kwargs): results = [] def start_download(): - results.append(manager.redownload_online_track("song-mid", "Song A")) + results.append(manager._download_online_track(item)) first = threading.Thread(target=start_download) second = threading.Thread(target=start_download) @@ -243,3 +255,42 @@ def start_download(): assert results == [True, True] assert created_count == 1 + + +def test_download_track_routes_generic_online_source(monkeypatch): + manager = DownloadManager() + item = SimpleNamespace( + source=TrackSource.ONLINE, + online_provider_id="qqmusic", + cloud_file_id="song-mid", + title="Song A", + ) + called = [] + monkeypatch.setattr( + DownloadManager, + "_download_online_track", + lambda self, playlist_item: called.append(playlist_item) or True, + ) + + assert manager.download_track(item) is True + assert called == [item] + + +def test_redownload_online_track_routes_provider_and_quality(monkeypatch): + manager = DownloadManager() + monkeypatch.setattr(download_manager_module, "isValid", lambda _obj: True) + monkeypatch.setattr(DownloadManager, "_OnlineDownloadWorker", _FakeWorker) + monkeypatch.setattr( + bootstrap_module.Bootstrap, + "instance", + classmethod(lambda cls: SimpleNamespace(online_download_service=object())), + ) + + assert manager.redownload_online_track( + "song-mid", + "Song A", + provider_id="qqmusic", + quality="flac", + ) + worker = manager._download_workers["song-mid"] + assert worker.started is True diff --git a/tests/test_services/test_library_service.py b/tests/test_services/test_library_service.py index 6a1bdd46..e8bea3ca 100644 --- a/tests/test_services/test_library_service.py +++ b/tests/test_services/test_library_service.py @@ -8,7 +8,7 @@ from unittest.mock import Mock, MagicMock, patch from pathlib import Path from domain.genre import Genre -from domain.track import Track +from domain.track import Track, TrackSource from domain.playlist import Playlist from services.library.library_service import LibraryService @@ -929,6 +929,7 @@ def test_add_online_track_new(self, library_service, mock_track_repo, mock_album mock_track_repo.add.return_value = 42 result = library_service.add_online_track( + provider_id="qqmusic", song_mid="qq_001", title="Online Song", artist="Online Artist", @@ -941,14 +942,20 @@ def test_add_online_track_new(self, library_service, mock_track_repo, mock_album mock_track_repo.add.assert_called_once() call_args = mock_track_repo.add.call_args[0][0] assert call_args.cloud_file_id == "qq_001" - assert call_args.source.value == "QQ" + assert call_args.source == TrackSource.ONLINE + assert call_args.online_provider_id == "qqmusic" def test_add_online_track_existing(self, library_service, mock_track_repo): """Test adding online track that already exists returns existing ID.""" - existing_track = Track(id=10, cloud_file_id="qq_001") + existing_track = Track( + id=10, + cloud_file_id="qq_001", + source=TrackSource.ONLINE, + online_provider_id="qqmusic", + ) mock_track_repo.get_by_cloud_file_id.return_value = existing_track - result = library_service.add_online_track("qq_001", "Title", "Artist", "Album", 200.0) + result = library_service.add_online_track("qqmusic", "qq_001", "Title", "Artist", "Album", 200.0) assert result == 10 mock_track_repo.add.assert_not_called() diff --git a/tests/test_services/test_online_adapter.py b/tests/test_services/test_online_adapter.py deleted file mode 100644 index 16fd2daa..00000000 --- a/tests/test_services/test_online_adapter.py +++ /dev/null @@ -1,225 +0,0 @@ -"""OnlineMusicAdapter normalization behavior tests.""" - -from services.online.adapter import ApiSource, OnlineMusicAdapter -from domain.online_music import SearchType - - -def test_parse_ygking_song_info_list_parses_singers(): - items = [ - { - "mid": "song-1", - "title": "Song 1", - "singer": [{"mid": "s1", "name": "Singer 1"}], - "album": {"mid": "a1", "name": "Album 1"}, - "interval": 180, - } - ] - - tracks = OnlineMusicAdapter._parse_ygking_song_info_list(items) - - assert len(tracks) == 1 - assert tracks[0].singer[0].name == "Singer 1" - assert tracks[0].album.name == "Album 1" - - -def test_parse_ygking_album_detail_parses_song_list(): - data = { - "code": 0, - "data": { - "basicInfo": {"albumMid": "alb-1", "albumName": "Album 1"}, - "singer": {"singerList": [{"mid": "s1", "name": "Singer 1"}]}, - "songs": [{"mid": "song-1", "name": "Song 1", "singer": [], "album": {}}], - }, - } - - parsed = OnlineMusicAdapter.parse_ygking_album_detail(data) - - assert parsed is not None - assert parsed["mid"] == "alb-1" - assert len(parsed["songs"]) == 1 - assert parsed["songs"][0]["mid"] == "song-1" - - -def test_parse_ygking_playlist_detail_parses_songlist(): - data = { - "code": 0, - "data": { - "dirinfo": {"id": "pl-1", "title": "Playlist 1"}, - "songlist": [{"mid": "song-1", "name": "Song 1", "singer": [], "album": {}}], - }, - } - - parsed = OnlineMusicAdapter.parse_ygking_playlist_detail(data) - - assert parsed is not None - assert parsed["id"] == "pl-1" - assert len(parsed["songs"]) == 1 - assert parsed["songs"][0]["mid"] == "song-1" - - -def test_normalize_qqmusic_singer_search_reads_item_singer_body_key(): - raw_data = { - "meta": {"sum": 1}, - "body": { - "item_singer": [ - {"singerMID": "artist-1", "singerName": "Singer 1", "songNum": 12, "albumNum": 3} - ] - }, - } - - result = OnlineMusicAdapter.normalize_search_result( - ApiSource.QQMUSIC, - raw_data, - SearchType.SINGER, - keyword="Singer", - page=1, - page_size=30, - ) - - assert result.total == 1 - assert len(result.artists) == 1 - assert result.artists[0].mid == "artist-1" - - -def test_normalize_qqmusic_singer_search_accepts_legacy_singer_body_key(): - raw_data = { - "meta": {"sum": 1}, - "body": { - "singer": [ - {"singerMID": "artist-legacy", "singerName": "Legacy Singer", "songNum": 6, "albumNum": 1} - ] - }, - } - - result = OnlineMusicAdapter.normalize_search_result( - ApiSource.QQMUSIC, - raw_data, - SearchType.SINGER, - keyword="Singer", - page=1, - page_size=30, - ) - - assert len(result.artists) == 1 - assert result.artists[0].mid == "artist-legacy" - - -def test_normalize_qqmusic_singer_search_accepts_mid_name_fields(): - raw_data = { - "meta": {"sum": 1}, - "body": { - "item_singer": [ - {"mid": "artist-2", "name": "Singer 2", "song_count": 8, "album_count": 2} - ] - }, - } - - result = OnlineMusicAdapter.normalize_search_result( - ApiSource.QQMUSIC, - raw_data, - SearchType.SINGER, - keyword="Singer", - page=1, - page_size=30, - ) - - assert len(result.artists) == 1 - assert result.artists[0].mid == "artist-2" - assert result.artists[0].name == "Singer 2" - - -def test_normalize_qqmusic_singer_search_builds_avatar_and_counts_from_fallback_fields(): - raw_data = { - "meta": {"sum": 1}, - "body": { - "item_singer": [ - {"mid": "artist-3", "name": "Singer 3", "songnum": 18, "albumnum": 4, "FanNum": 12345} - ] - }, - } - - result = OnlineMusicAdapter.normalize_search_result( - ApiSource.QQMUSIC, - raw_data, - SearchType.SINGER, - keyword="Singer", - page=1, - page_size=30, - ) - - assert len(result.artists) == 1 - assert result.artists[0].avatar_url.endswith("T001R300x300M000artist-3.jpg") - assert result.artists[0].song_count == 18 - assert result.artists[0].album_count == 4 - assert result.artists[0].fan_count == 12345 - - -def test_normalize_qqmusic_playlist_search_accepts_legacy_songlist_body_key(): - raw_data = { - "meta": {"sum": 1}, - "body": { - "songlist": [ - {"dissid": "playlist-legacy", "dissname": "Legacy Playlist", "songnum": 9, "listennum": 123} - ] - }, - } - - result = OnlineMusicAdapter.normalize_search_result( - ApiSource.QQMUSIC, - raw_data, - SearchType.PLAYLIST, - keyword="Playlist", - page=1, - page_size=30, - ) - - assert len(result.playlists) == 1 - assert result.playlists[0].id == "playlist-legacy" - - -def test_normalize_qqmusic_album_search_reads_item_album_body_key(): - raw_data = { - "meta": {"sum": 1}, - "body": { - "item_album": [ - {"albummid": "album-1", "name": "Album 1", "singer": "Singer 1", "song_count": 8} - ] - }, - } - - result = OnlineMusicAdapter.normalize_search_result( - ApiSource.QQMUSIC, - raw_data, - SearchType.ALBUM, - keyword="Album", - page=1, - page_size=30, - ) - - assert result.total == 1 - assert len(result.albums) == 1 - assert result.albums[0].mid == "album-1" - - -def test_normalize_qqmusic_playlist_search_reads_item_songlist_body_key(): - raw_data = { - "meta": {"sum": 1}, - "body": { - "item_songlist": [ - {"dissid": "playlist-1", "dissname": "Playlist 1", "song_count": 16, "play_count": 200} - ] - }, - } - - result = OnlineMusicAdapter.normalize_search_result( - ApiSource.QQMUSIC, - raw_data, - SearchType.PLAYLIST, - keyword="Playlist", - page=1, - page_size=30, - ) - - assert result.total == 1 - assert len(result.playlists) == 1 - assert result.playlists[0].id == "playlist-1" diff --git a/tests/test_services/test_online_download_gateway.py b/tests/test_services/test_online_download_gateway.py new file mode 100644 index 00000000..c419417d --- /dev/null +++ b/tests/test_services/test_online_download_gateway.py @@ -0,0 +1,143 @@ +from types import SimpleNamespace +from unittest.mock import MagicMock + +from services.download.online_download_gateway import OnlineDownloadGateway + + +def _build_gateway(tmp_path, provider=None, event_bus=None): + manager = SimpleNamespace( + registry=SimpleNamespace( + online_providers=lambda: [provider] if provider is not None else [] + ) + ) + return OnlineDownloadGateway( + config_manager=SimpleNamespace( + get_online_music_download_dir=lambda: str(tmp_path) + ), + plugin_manager=manager, + event_bus=event_bus, + ) + + +def test_get_cached_path_uses_quality_extension_mapping(tmp_path): + gateway = _build_gateway(tmp_path) + + assert gateway.get_cached_path("song", "ogg_320") == str(tmp_path / "song.ogg") + assert gateway.get_cached_path("song", "aac_192") == str(tmp_path / "song.m4a") + assert gateway.get_cached_path("song", "flac") == str(tmp_path / "song.flac") + + +def test_get_provider_matches_provider_id(tmp_path): + qq_provider = MagicMock(provider_id="qqmusic") + netease_provider = MagicMock(provider_id="netease") + manager = SimpleNamespace( + registry=SimpleNamespace( + online_providers=lambda: [qq_provider, netease_provider] + ) + ) + gateway = OnlineDownloadGateway( + config_manager=SimpleNamespace( + get_online_music_download_dir=lambda: str(tmp_path) + ), + plugin_manager=manager, + ) + + assert gateway._get_provider("netease") is netease_provider + assert gateway._get_provider("missing") is None + + +def test_get_download_qualities_is_provider_aware(tmp_path): + provider = MagicMock() + provider.provider_id = "qqmusic" + provider.get_download_qualities.return_value = [ + {"value": "flac", "label": "FLAC"}, + "320", + ] + gateway = _build_gateway(tmp_path, provider=provider) + + qualities = gateway.get_download_qualities("song", provider_id="qqmusic") + + assert qualities == [ + {"value": "flac", "label": "FLAC"}, + {"value": "320", "label": "320"}, + ] + provider.get_download_qualities.assert_called_once_with("song") + + +def test_get_cached_path_prefers_existing_downloaded_file(tmp_path): + existing_path = tmp_path / "song.ogg" + existing_path.write_bytes(b"data") + gateway = _build_gateway(tmp_path) + + assert gateway.is_cached("song", "flac") is True + assert gateway.get_cached_path("song", "flac") == str(existing_path) + + +def test_get_cached_path_is_namespaced_by_provider(tmp_path): + gateway = _build_gateway(tmp_path) + + assert gateway.get_cached_path("song", "flac", provider_id="qqmusic") == str( + tmp_path / "qqmusic" / "song.flac" + ) + + +def test_download_delegates_to_provider_and_emits_completed(tmp_path): + event_bus = MagicMock() + provider = MagicMock() + provider.provider_id = "qqmusic" + local_path = str(tmp_path / "song.ogg") + provider.download_track.return_value = { + "local_path": local_path, + "quality": "ogg_320", + } + gateway = _build_gateway(tmp_path, provider=provider, event_bus=event_bus) + + actual_path = gateway.download("song", provider_id="qqmusic", quality="flac") + + assert actual_path == local_path + provider.download_track.assert_called_once_with( + track_id="song", + quality="flac", + target_dir=str(tmp_path / "qqmusic"), + progress_callback=None, + force=False, + ) + event_bus.download_completed.emit.assert_called_once_with( + "song", local_path + ) + + +def test_download_records_actual_quality_for_ui_status(tmp_path): + provider = MagicMock() + provider.provider_id = "qqmusic" + provider.download_track.return_value = { + "local_path": str(tmp_path / "song.ogg"), + "quality": "ogg_320", + } + gateway = _build_gateway(tmp_path, provider=provider) + + gateway.download("song", provider_id="qqmusic", quality="flac") + + assert gateway.pop_last_download_quality("song") == "ogg_320" + assert gateway.pop_last_download_quality("song") is None + + +def test_force_download_prefers_provider_redownload_api(tmp_path): + provider = MagicMock() + provider.provider_id = "qqmusic" + provider.redownload_track.return_value = { + "local_path": str(tmp_path / "qqmusic" / "song.flac"), + "quality": "flac", + } + gateway = _build_gateway(tmp_path, provider=provider) + + local_path = gateway.download("song", provider_id="qqmusic", quality="flac", force=True) + + assert local_path == str(tmp_path / "qqmusic" / "song.flac") + provider.redownload_track.assert_called_once_with( + track_id="song", + quality="flac", + target_dir=str(tmp_path / "qqmusic"), + progress_callback=None, + ) + provider.download_track.assert_not_called() diff --git a/tests/test_services/test_online_download_service.py b/tests/test_services/test_online_download_service.py deleted file mode 100644 index ab9d5382..00000000 --- a/tests/test_services/test_online_download_service.py +++ /dev/null @@ -1,123 +0,0 @@ -from unittest.mock import MagicMock, patch - -from services.online.download_service import OnlineDownloadService - - -class TestOnlineDownloadService: - @patch("services.online.download_service.EventBus") - def test_get_cached_path_uses_quality_extension_mapping(self, mock_event_bus, tmp_path): - """Quality-specific cache paths should use the real container extension.""" - mock_event_bus.instance.return_value = MagicMock() - service = OnlineDownloadService(download_dir=str(tmp_path)) - - assert service.get_cached_path("song", "ogg_320") == str(tmp_path / "song.ogg") - assert service.get_cached_path("song", "aac_192") == str(tmp_path / "song.m4a") - assert service.get_cached_path("song", "flac") == str(tmp_path / "song.flac") - - @patch("services.online.download_service.EventBus") - def test_get_cached_path_prefers_existing_downloaded_file(self, mock_event_bus, tmp_path): - """Cache lookups should return an existing file even if its suffix differs from requested quality.""" - mock_event_bus.instance.return_value = MagicMock() - existing_path = tmp_path / "song.ogg" - existing_path.write_bytes(b"data") - - service = OnlineDownloadService(download_dir=str(tmp_path)) - - assert service.is_cached("song", "flac") is True - assert service.get_cached_path("song", "flac") == str(existing_path) - - @patch("services.online.download_service.EventBus") - @patch.object(OnlineDownloadService, "_extract_metadata", return_value=None) - @patch("services.online.download_service.HttpClient.shared") - def test_download_uses_returned_file_type_instead_of_guessing_url( - self, mock_http_client_shared, mock_extract_metadata, mock_event_bus, tmp_path - ): - """Downloader should use explicit playback file type metadata instead of URL guessing.""" - event_bus = MagicMock() - mock_event_bus.instance.return_value = event_bus - - response = MagicMock() - response.headers = {"content-length": "35"} - response.iter_content.return_value = [b"OggS" + b"\x00" * 24 + b"\x01vorbis"] - response.raise_for_status.return_value = None - response.close = MagicMock() - - stream_context = MagicMock() - stream_context.__enter__.return_value = response - stream_context.__exit__.return_value = False - - http_client = MagicMock() - http_client.stream.return_value = stream_context - mock_http_client_shared.return_value = http_client - - online_service = MagicMock() - online_service.get_playback_url_info.return_value = { - "url": "https://example.com/audio.flac", - "quality": "ogg_320", - "extension": ".ogg", - } - - service = OnlineDownloadService( - online_music_service=online_service, - download_dir=str(tmp_path), - ) - - local_path = service.download("song", quality="flac") - - assert local_path == str(tmp_path / "song.ogg") - assert (tmp_path / "song.ogg").exists() - assert not (tmp_path / "song.flac").exists() - online_service.get_playback_url_info.assert_called_once_with("song", "flac") - online_service.get_playback_url.assert_not_called() - http_client.stream.assert_called_once_with( - "GET", - "https://example.com/audio.flac", - headers={ - 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36', - 'Referer': 'https://y.qq.com/', - }, - timeout=60, - ) - event_bus.download_completed.emit.assert_called_once_with("song", str(tmp_path / "song.ogg")) - mock_extract_metadata.assert_called_once_with("song", str(tmp_path / "song.ogg")) - - @patch("services.online.download_service.EventBus") - @patch.object(OnlineDownloadService, "_extract_metadata", return_value=None) - @patch("services.online.download_service.HttpClient.shared") - def test_download_records_actual_quality_for_ui_status( - self, mock_http_client_shared, mock_extract_metadata, mock_event_bus, tmp_path - ): - """Successful downloads should expose the actual resolved quality for status display.""" - event_bus = MagicMock() - mock_event_bus.instance.return_value = event_bus - - response = MagicMock() - response.headers = {"content-length": "35"} - response.iter_content.return_value = [b"OggS" + b"\x00" * 24 + b"\x01vorbis"] - response.raise_for_status.return_value = None - response.close = MagicMock() - - stream_context = MagicMock() - stream_context.__enter__.return_value = response - stream_context.__exit__.return_value = False - - http_client = MagicMock() - http_client.stream.return_value = stream_context - mock_http_client_shared.return_value = http_client - - online_service = MagicMock() - online_service.get_playback_url_info.return_value = { - "url": "https://example.com/audio.flac", - "quality": "ogg_320", - "extension": ".ogg", - } - - service = OnlineDownloadService( - online_music_service=online_service, - download_dir=str(tmp_path), - ) - - service.download("song", quality="flac") - - assert service.pop_last_download_quality("song") == "ogg_320" - assert service.pop_last_download_quality("song") is None diff --git a/tests/test_services/test_online_music_service_perf_paths.py b/tests/test_services/test_online_music_service_perf_paths.py deleted file mode 100644 index a755dbf0..00000000 --- a/tests/test_services/test_online_music_service_perf_paths.py +++ /dev/null @@ -1,73 +0,0 @@ -"""OnlineMusicService parsing behavior tests for list construction paths.""" - -from types import SimpleNamespace - -from services.online.online_music_service import OnlineMusicService - - -def test_get_top_lists_ygking_flattens_group_toplists(): - service = OnlineMusicService() - - response = SimpleNamespace( - raise_for_status=lambda: None, - json=lambda: { - "code": 0, - "data": { - "group": [ - {"toplist": [{"topId": 1, "title": "Top 1"}]}, - {"toplist": [{"topId": 2, "title": "Top 2"}]}, - ] - }, - }, - ) - service._http_client = SimpleNamespace(get=lambda *_args, **_kwargs: response) - - top_lists = service._get_top_lists_ygking() - - assert top_lists == [{"id": 1, "title": "Top 1"}, {"id": 2, "title": "Top 2"}] - - -def test_get_artist_albums_ygking_filters_by_singer(): - matching = SimpleNamespace( - mid="a1", - name="Album 1", - singer_mid="s1", - singer_name="Singer 1", - cover_url="cover-1", - song_count=10, - publish_date="2024-01-01", - ) - non_matching = SimpleNamespace( - mid="a2", - name="Album 2", - singer_mid="other", - singer_name="Other", - cover_url="cover-2", - song_count=8, - publish_date="2023-01-01", - ) - fake_service = SimpleNamespace( - _get_artist_detail_ygking=lambda _mid: {"name": "Singer 1"}, - _search_ygking=lambda *_args, **_kwargs: SimpleNamespace(albums=[matching, non_matching], total=2), - ) - - result = OnlineMusicService._get_artist_albums_ygking(fake_service, "s1", number=20, begin=0) - - assert result["total"] == 2 - assert len(result["albums"]) == 1 - assert result["albums"][0]["mid"] == "a1" - - -def test_service_uses_plugin_settings_for_qqmusic_config(): - config = SimpleNamespace( - get_plugin_setting=lambda plugin_id, key, default=None: { - ("qqmusic", "credential"): {"musicid": "1", "musickey": "secret"}, - ("qqmusic", "quality"): "flac", - }.get((plugin_id, key), default) - ) - service = OnlineMusicService(config_manager=config) - service._get_playback_url_remote = lambda *_args, **_kwargs: "https://example.com/song.flac" - - assert service._has_qqmusic_credential() is True - info = service.get_playback_url_info("song-mid", quality=None) - assert info["quality"] == "flac" diff --git a/tests/test_services/test_playback_service_online_failures.py b/tests/test_services/test_playback_service_online_failures.py index 04f4dd0c..67aa84cb 100644 --- a/tests/test_services/test_playback_service_online_failures.py +++ b/tests/test_services/test_playback_service_online_failures.py @@ -1,4 +1,4 @@ -"""Regression tests for QQ online download failure handling.""" +"""Regression tests for online download failure handling.""" from __future__ import annotations @@ -10,18 +10,19 @@ from services.playback.playback_service import PlaybackService -def test_cloud_download_error_ignores_qq_online_track(): +def test_cloud_download_error_ignores_online_track(): service = PlaybackService.__new__(PlaybackService) - qq_item = PlaylistItem( - source=TrackSource.QQ, + online_item = PlaylistItem( + source=TrackSource.ONLINE, + online_provider_id="qqmusic", cloud_file_id="song_mid_404", title="VIP Song", needs_download=True, ) service._engine = Mock() - service._engine.playlist_items = [qq_item] - service._engine.current_playlist_item = qq_item + service._engine.playlist_items = [online_item] + service._engine.current_playlist_item = online_item service._schedule_save_queue = Mock() PlaybackService._on_cloud_download_error(service, "song_mid_404", "404 not found") diff --git a/tests/test_services/test_playback_service_preload_delay.py b/tests/test_services/test_playback_service_preload_delay.py index ec854ec5..8e45a5fc 100644 --- a/tests/test_services/test_playback_service_preload_delay.py +++ b/tests/test_services/test_playback_service_preload_delay.py @@ -80,7 +80,8 @@ def make_cloud_item(cloud_file_id: str) -> PlaylistItem: def make_online_item(cloud_file_id: str) -> PlaylistItem: return PlaylistItem( - source=TrackSource.QQ, + source=TrackSource.ONLINE, + online_provider_id="qqmusic", cloud_file_id=cloud_file_id, title="Online", artist="Artist", diff --git a/tests/test_services/test_qqmusic_plugin_source_adapters.py b/tests/test_services/test_qqmusic_plugin_source_adapters.py index 2c215747..59d38f32 100644 --- a/tests/test_services/test_qqmusic_plugin_source_adapters.py +++ b/tests/test_services/test_qqmusic_plugin_source_adapters.py @@ -137,3 +137,32 @@ def test_qqmusic_artist_cover_source_search_reads_normalized_artist_payload(monk assert results[0].name == "Singer 1" assert results[0].album_count == 12 assert results[0].cover_url == "https://y.gtimg.cn/music/photo_new/T001R500x500M000artist1.jpg" + + +def test_qqmusic_api_search_extracts_total_from_payload(): + response = SimpleNamespace( + json=lambda: { + "code": 0, + "data": { + "totalnum": 321, + "list": [ + { + "mid": "song-1", + "name": "Song 1", + "singer": [{"name": "Singer 1"}], + "album": {"name": "Album 1", "mid": "album-1"}, + "interval": 180, + } + ], + }, + } + ) + context = SimpleNamespace( + http=SimpleNamespace(get=lambda *_args, **_kwargs: response) + ) + api = QQMusicPluginAPI(context) + + result = api.search("Song 1", search_type="song", limit=10, page=1) + + assert result["total"] == 321 + assert len(result["tracks"]) == 1 diff --git a/tests/test_services/test_qqmusic_quality_support.py b/tests/test_services/test_qqmusic_quality_support.py index 02bf5dde..5ed5a8ee 100644 --- a/tests/test_services/test_qqmusic_quality_support.py +++ b/tests/test_services/test_qqmusic_quality_support.py @@ -1,7 +1,7 @@ -from plugins.builtin.qqmusic.lib.legacy.client import QQMusicClient +from plugins.builtin.qqmusic.lib.qqmusic_client import QQMusicClient from plugins.builtin.qqmusic.lib.qr_login import QQMusicQRLogin -from services.online.quality import ( - QUALITY_FALLBACK, +from plugins.builtin.qqmusic.lib.common import ( + APIConfig, parse_quality, get_selectable_qualities, get_quality_label_key, @@ -35,11 +35,12 @@ def test_parse_quality_supports_chinese_quality_names(): def test_quality_fallback_contains_extended_quality_levels(): - assert "ogg_640" in QUALITY_FALLBACK - assert "aac_320" in QUALITY_FALLBACK - assert "aac_24" in QUALITY_FALLBACK - assert "hires" in QUALITY_FALLBACK - assert "dolby" in QUALITY_FALLBACK + quality_fallback = APIConfig.QUALITY_FALLBACK + assert "ogg_640" in quality_fallback + assert "aac_320" in quality_fallback + assert "aac_24" in quality_fallback + assert "hires" in quality_fallback + assert "dolby" in quality_fallback def test_get_song_url_accepts_chinese_quality_name(): @@ -78,14 +79,12 @@ def fake_make_request(module, method, params, _retry=False, use_sign=False): assert result["extension"] == ".ogg" -def test_qqmusic_client_uses_expanded_connection_pool(): - client = QQMusicClient() +def test_qqmusic_client_uses_injected_http_client(): + fake_http = object() - https_adapter = client.session.get_adapter("https://u.y.qq.com/cgi-bin/musicu.fcg") + client = QQMusicClient(http_client=fake_http) - assert https_adapter._pool_connections == 20 - assert https_adapter._pool_maxsize == 20 - assert https_adapter._pool_block is True + assert client._http_client is fake_http def test_qqmusic_qr_login_uses_expanded_connection_pool(): diff --git a/tests/test_services/test_qqmusic_service_perf_paths.py b/tests/test_services/test_qqmusic_service_perf_paths.py index a0258f1a..c1489136 100644 --- a/tests/test_services/test_qqmusic_service_perf_paths.py +++ b/tests/test_services/test_qqmusic_service_perf_paths.py @@ -2,7 +2,7 @@ from types import SimpleNamespace -from plugins.builtin.qqmusic.lib.legacy.qqmusic_service import QQMusicService +from plugins.builtin.qqmusic.lib.qqmusic_service import QQMusicService def test_get_playback_url_info_uses_first_non_empty_url(): diff --git a/tests/test_services/test_qqmusic_verify_login.py b/tests/test_services/test_qqmusic_verify_login.py index cbde819d..c0abc968 100644 --- a/tests/test_services/test_qqmusic_verify_login.py +++ b/tests/test_services/test_qqmusic_verify_login.py @@ -1,6 +1,6 @@ from unittest.mock import Mock -from plugins.builtin.qqmusic.lib.legacy.client import QQMusicClient +from plugins.builtin.qqmusic.lib.qqmusic_client import QQMusicClient def test_verify_login_accepts_hostname_when_profile_request_succeeds(monkeypatch): diff --git a/tests/test_services/test_quality_utils.py b/tests/test_services/test_quality_utils.py index 97bebab1..3c0c14ae 100644 --- a/tests/test_services/test_quality_utils.py +++ b/tests/test_services/test_quality_utils.py @@ -1,4 +1,4 @@ -from services.online.quality import get_quality_label_key, parse_quality +from plugins.builtin.qqmusic.lib.common import get_quality_label_key, parse_quality def test_parse_quality_and_label_lookup_work_in_shared_module(): diff --git a/tests/test_services/test_queue_service.py b/tests/test_services/test_queue_service.py index 9c5bce91..66ccd37b 100644 --- a/tests/test_services/test_queue_service.py +++ b/tests/test_services/test_queue_service.py @@ -155,8 +155,8 @@ def get_all(self, limit=0, offset=0, source=None): return merged[offset:offset + limit] -def test_enrich_metadata_batch_preserves_cached_qq_file(temp_dir): - """QQ items with an existing cached file should remain ready after enrichment.""" +def test_enrich_metadata_batch_preserves_cached_online_file(temp_dir): + """Online items with an existing cached file should remain ready after enrichment.""" cached_path = temp_dir / "downloaded.mp3" cached_path.write_text("cached") @@ -167,7 +167,8 @@ def test_enrich_metadata_batch_preserves_cached_qq_file(temp_dir): path=str(cached_path), title="Downloaded Song", artist="Online Artist", - source=TrackSource.QQ, + source=TrackSource.ONLINE, + online_provider_id="qqmusic", cloud_file_id="song_mid_123", ) } @@ -179,7 +180,8 @@ def test_enrich_metadata_batch_preserves_cached_qq_file(temp_dir): track_repo=track_repo, ) item = PlaylistItem( - source=TrackSource.QQ, + source=TrackSource.ONLINE, + online_provider_id="qqmusic", track_id=9, cloud_file_id="song_mid_123", local_path=str(cached_path), diff --git a/tests/test_services/test_singleflight_media_fetch.py b/tests/test_services/test_singleflight_media_fetch.py index 63bf141f..0578725e 100644 --- a/tests/test_services/test_singleflight_media_fetch.py +++ b/tests/test_services/test_singleflight_media_fetch.py @@ -10,25 +10,25 @@ from services.metadata.cover_service import CoverService -def test_get_lyrics_by_qqmusic_mid_deduplicates_concurrent_requests(): +def test_get_lyrics_by_song_id_deduplicates_concurrent_requests(): started = threading.Event() release = threading.Event() call_count = 0 count_lock = threading.Lock() results: list[str] = [] - def fake_download(song_mid: str) -> str: + def fake_download(song_id: str = "", provider_id: str = "") -> str: nonlocal call_count with count_lock: call_count += 1 started.set() release.wait(timeout=1) - return f"lyrics:{song_mid}" + return f"lyrics:{song_id}" def worker(): - results.append(LyricsService.get_lyrics_by_qqmusic_mid("mid_123")) + results.append(LyricsService.get_lyrics_by_song_id("mid_123", "qqmusic")) - with patch("services.lyrics.lyrics_service.download_qqmusic_lyrics", side_effect=fake_download): + with patch("services.lyrics.lyrics_service.download_online_lyrics", side_effect=fake_download): threads = [threading.Thread(target=worker) for _ in range(2)] threads[0].start() assert started.wait(timeout=1) @@ -70,7 +70,7 @@ def worker(): ) ) - with patch("system.plugins.qqmusic_cover_helpers.get_qqmusic_cover_url", return_value="https://example.com/cover.jpg"), \ + with patch("system.plugins.online_cover_helpers.get_online_cover_url", return_value="https://example.com/cover.jpg"), \ patch.object(service, "_get_cached_cover", return_value=None), \ patch.object(service, "_save_cover_to_cache", return_value="/tmp/cover.jpg"): threads = [threading.Thread(target=worker) for _ in range(2)] @@ -108,7 +108,11 @@ def worker(): results.append(LyricsService.get_online_track_lyrics("mid_456", "/tmp/song.ogg")) with patch.object(LyricsService, "_get_local_lyrics", return_value=""), \ - patch.object(LyricsService, "get_lyrics_by_qqmusic_mid", side_effect=fake_fetch), \ + patch.object( + LyricsService, + "get_lyrics_by_song_id", + side_effect=lambda song_id, provider_id: fake_fetch(song_id), + ), \ patch.object(LyricsService, "save_lyrics", side_effect=fake_save): threads = [threading.Thread(target=worker) for _ in range(2)] threads[0].start() diff --git a/tests/test_system/test_harmony_plugin_api_package.py b/tests/test_system/test_harmony_plugin_api_package.py index b0307cf2..f287d58a 100644 --- a/tests/test_system/test_harmony_plugin_api_package.py +++ b/tests/test_system/test_harmony_plugin_api_package.py @@ -34,6 +34,21 @@ def test_harmony_plugin_api_package_excludes_host_runtime_modules(): assert not (PACKAGE_SRC / "runtime.py").exists() +def test_plugin_context_declares_runtime_bridge_contract(): + context_source = (PACKAGE_SRC / "context.py").read_text(encoding="utf-8") + tree = ast.parse(context_source, filename=str(PACKAGE_SRC / "context.py")) + plugin_context = next( + node for node in ast.walk(tree) if isinstance(node, ast.ClassDef) and node.name == "PluginContext" + ) + annotated_fields = [ + node.target.id + for node in plugin_context.body + if isinstance(node, ast.AnnAssign) and isinstance(node.target, ast.Name) + ] + + assert "runtime" in annotated_fields + + def test_harmony_plugin_api_package_has_no_host_imports(): assert PACKAGE_SRC.exists() diff --git a/tests/test_system/test_mpris.py b/tests/test_system/test_mpris.py new file mode 100644 index 00000000..3c8df5b6 --- /dev/null +++ b/tests/test_system/test_mpris.py @@ -0,0 +1,223 @@ +import importlib.util +import sys +import types +from pathlib import Path + + +PROJECT_ROOT = Path(__file__).resolve().parents[2] +MPRIS_PATH = PROJECT_ROOT / "system" / "mpris.py" + + +class _FakeDbusObject: + def __init__(self, *_args, **_kwargs): + pass + + +class _FakeBusName: + def __init__(self, *_args, **_kwargs): + pass + + +class _FakeLoop: + def __init__(self): + self.running = False + + def run(self): + self.running = True + + def quit(self): + self.running = False + + def is_running(self): + return self.running + + +class _FakeThread: + def __init__(self, target=None, **_kwargs): + self._target = target + + def start(self): + if self._target is not None: + self._target() + + +class _FakeSignal: + def connect(self, _callback): + pass + + +class _FakeEventBus: + def __init__(self): + self.track_changed = _FakeSignal() + self.playback_state_changed = _FakeSignal() + self.duration_changed = _FakeSignal() + self.volume_changed = _FakeSignal() + self.cover_updated = _FakeSignal() + + +class _FakeBootstrapInstance: + def __init__(self): + self.event_bus = _FakeEventBus() + + +class _FakeBootstrap: + @classmethod + def instance(cls): + return _FakeBootstrapInstance() + + +def _identity_decorator(*_args, **_kwargs): + def decorator(fn): + return fn + + return decorator + + +def _load_mpris_module(monkeypatch): + fake_dbus = types.ModuleType("dbus") + fake_dbus.ObjectPath = str + fake_dbus.Dictionary = lambda value=None, signature=None: dict(value or {}) + fake_dbus.String = str + fake_dbus.Array = lambda value, signature=None: list(value) + fake_dbus.Int64 = int + fake_dbus.Boolean = bool + fake_dbus.Double = float + fake_dbus.SessionBus = lambda: object() + fake_dbus.exceptions = types.SimpleNamespace(DBusException=RuntimeError) + fake_dbus.mainloop = types.SimpleNamespace( + glib=types.SimpleNamespace(DBusGMainLoop=lambda set_as_default=False: None) + ) + fake_dbus.service = types.SimpleNamespace( + Object=_FakeDbusObject, + BusName=_FakeBusName, + method=_identity_decorator, + signal=_identity_decorator, + ) + + fake_gi = types.ModuleType("gi") + fake_repository = types.ModuleType("gi.repository") + fake_repository.GLib = types.SimpleNamespace(MainLoop=_FakeLoop) + fake_gi.repository = fake_repository + + fake_app = types.ModuleType("app") + fake_app.Bootstrap = _FakeBootstrap + + fake_domain = types.ModuleType("domain") + fake_domain.PlaylistItem = object + + monkeypatch.setitem(sys.modules, "dbus", fake_dbus) + monkeypatch.setitem(sys.modules, "dbus.mainloop", fake_dbus.mainloop) + monkeypatch.setitem(sys.modules, "dbus.mainloop.glib", fake_dbus.mainloop.glib) + monkeypatch.setitem(sys.modules, "dbus.service", fake_dbus.service) + monkeypatch.setitem(sys.modules, "gi", fake_gi) + monkeypatch.setitem(sys.modules, "gi.repository", fake_repository) + monkeypatch.setitem(sys.modules, "app", fake_app) + monkeypatch.setitem(sys.modules, "domain", fake_domain) + + spec = importlib.util.spec_from_file_location("mpris_under_test", MPRIS_PATH) + module = importlib.util.module_from_spec(spec) + assert spec.loader is not None + spec.loader.exec_module(module) + return module + + +def test_mpris_service_dispatches_playback_commands_via_ui_dispatcher(monkeypatch): + mpris = _load_mpris_module(monkeypatch) + + playback_calls = [] + dispatched = [] + property_updates = [] + + class PlaybackService: + def play(self): + playback_calls.append("play") + + def dispatcher(fn, *args, **kwargs): + dispatched.append((fn, args, kwargs)) + + service = mpris.MPRISService( + bus=object(), + playback_service=PlaybackService(), + ui_dispatcher=dispatcher, + ) + service.emit_player_properties = lambda names=None: property_updates.append(names) + + service.Play() + + assert playback_calls == [] + assert property_updates == [] + assert len(dispatched) == 1 + + fn, args, kwargs = dispatched.pop() + fn(*args, **kwargs) + + assert playback_calls == ["play"] + assert property_updates == [["PlaybackStatus"]] + + +def test_mpris_service_dispatches_window_commands_via_ui_dispatcher(monkeypatch): + mpris = _load_mpris_module(monkeypatch) + + window_calls = [] + dispatched = [] + + class Window: + def showNormal(self): + window_calls.append("showNormal") + + def raise_(self): + window_calls.append("raise_") + + def activateWindow(self): + window_calls.append("activateWindow") + + def dispatcher(fn, *args, **kwargs): + dispatched.append((fn, args, kwargs)) + + service = mpris.MPRISService( + bus=object(), + playback_service=object(), + main_window=Window(), + ui_dispatcher=dispatcher, + ) + + service.Raise() + + assert window_calls == [] + assert len(dispatched) == 1 + + fn, args, kwargs = dispatched.pop() + fn(*args, **kwargs) + + assert window_calls == ["showNormal", "raise_", "activateWindow"] + + +def test_mpris_controller_passes_ui_dispatcher_to_service(monkeypatch): + mpris = _load_mpris_module(monkeypatch) + + captured = {} + + class FakeService: + def __init__(self, bus, playback_service, main_window=None, ui_dispatcher=None): + captured["bus"] = bus + captured["playback_service"] = playback_service + captured["main_window"] = main_window + captured["ui_dispatcher"] = ui_dispatcher + + def TrackListReplaced(self, *_args, **_kwargs): + pass + + monkeypatch.setattr(mpris, "MPRISService", FakeService) + monkeypatch.setattr(mpris.threading, "Thread", _FakeThread) + monkeypatch.setattr(mpris.GLib, "MainLoop", _FakeLoop) + + playback_service = types.SimpleNamespace(playlist=[], current_track=None) + controller = mpris.MPRISController(playback_service=playback_service) + controller._main_window = object() + controller.ui_dispatcher = object() + + controller.start() + + assert captured["playback_service"] is playback_service + assert captured["main_window"] is controller._main_window + assert captured["ui_dispatcher"] is controller.ui_dispatcher diff --git a/tests/test_system/test_plugin_cover_helpers.py b/tests/test_system/test_plugin_cover_helpers.py index 2fc76209..5a42d664 100644 --- a/tests/test_system/test_plugin_cover_helpers.py +++ b/tests/test_system/test_plugin_cover_helpers.py @@ -1,12 +1,12 @@ from types import SimpleNamespace -from system.plugins.qqmusic_cover_helpers import ( - get_qqmusic_artist_cover_url, - get_qqmusic_cover_url, +from system.plugins.online_cover_helpers import ( + get_online_artist_cover_url, + get_online_cover_url, ) -def test_get_qqmusic_cover_url_uses_registered_plugin_source(monkeypatch): +def test_get_online_cover_url_uses_registered_plugin_source(monkeypatch): source = SimpleNamespace( source="qqmusic", get_cover_url=lambda **kwargs: f"cover:{kwargs.get('album_mid') or kwargs.get('mid')}", @@ -19,10 +19,10 @@ def test_get_qqmusic_cover_url_uses_registered_plugin_source(monkeypatch): lambda: SimpleNamespace(plugin_manager=fake_manager), ) - assert get_qqmusic_cover_url(album_mid="album123", size=500) == "cover:album123" + assert get_online_cover_url(provider_id="qqmusic", album_id="album123", size=500) == "cover:album123" -def test_get_qqmusic_artist_cover_url_uses_registered_plugin_source(monkeypatch): +def test_get_online_artist_cover_url_uses_registered_plugin_source(monkeypatch): source = SimpleNamespace( source="qqmusic", get_artist_cover_url=lambda singer_mid, size=500: f"artist:{singer_mid}:{size}", @@ -35,4 +35,4 @@ def test_get_qqmusic_artist_cover_url_uses_registered_plugin_source(monkeypatch) lambda: SimpleNamespace(plugin_manager=fake_manager), ) - assert get_qqmusic_artist_cover_url("singer123", size=500) == "artist:singer123:500" + assert get_online_artist_cover_url(provider_id="qqmusic", artist_id="singer123", size=500) == "artist:singer123:500" diff --git a/tests/test_system/test_plugin_lyrics_helpers.py b/tests/test_system/test_plugin_lyrics_helpers.py index e25c8663..94fcd625 100644 --- a/tests/test_system/test_plugin_lyrics_helpers.py +++ b/tests/test_system/test_plugin_lyrics_helpers.py @@ -1,9 +1,9 @@ from types import SimpleNamespace -from system.plugins.qqmusic_lyrics_helpers import download_qqmusic_lyrics +from system.plugins.online_lyrics_helpers import download_online_lyrics -def test_download_qqmusic_lyrics_uses_registered_plugin_source(monkeypatch): +def test_download_online_lyrics_uses_registered_plugin_source(monkeypatch): source = SimpleNamespace( source="qqmusic", get_lyrics_by_song_id=lambda song_id: f"lyrics:{song_id}", @@ -16,4 +16,4 @@ def test_download_qqmusic_lyrics_uses_registered_plugin_source(monkeypatch): lambda: SimpleNamespace(plugin_manager=fake_manager), ) - assert download_qqmusic_lyrics("mid123") == "lyrics:mid123" + assert download_online_lyrics("mid123", provider_id="qqmusic") == "lyrics:mid123" diff --git a/tests/test_system/test_plugin_online_bridge.py b/tests/test_system/test_plugin_online_bridge.py index 2f9ea374..748fe64d 100644 --- a/tests/test_system/test_plugin_online_bridge.py +++ b/tests/test_system/test_plugin_online_bridge.py @@ -157,6 +157,7 @@ def test_media_bridge_passes_explicit_quality_to_download_service(): download_service.download.assert_called_once_with( "mid-1", song_title="Song 1", + provider_id="qqmusic", quality="flac", progress_callback=None, force=False, @@ -164,6 +165,7 @@ def test_media_bridge_passes_explicit_quality_to_download_service(): bridge.add_online_track(request) library_service.add_online_track.assert_called_once_with( + "qqmusic", "mid-1", "Song 1", "Singer 1", @@ -200,6 +202,9 @@ def test_media_bridge_can_play_online_track(): playback_service.engine.load_playlist_items.assert_called_once() playback_service.engine.play.assert_called_once_with() playback_service.save_queue.assert_called_once_with() + item = playback_service.engine.load_playlist_items.call_args[0][0][0] + assert item.source.value == "ONLINE" + assert item.online_provider_id == "qqmusic" def test_media_bridge_can_add_and_insert_online_track_to_queue(): @@ -230,3 +235,7 @@ def test_media_bridge_can_add_and_insert_online_track_to_queue(): assert playback_service.engine.add_track.call_count == 1 playback_service.engine.insert_track.assert_called_once() assert playback_service._schedule_save_queue.call_count == 2 + queued_item = playback_service.engine.add_track.call_args[0][0] + inserted_item = playback_service.engine.insert_track.call_args[0][1] + assert queued_item.online_provider_id == "qqmusic" + assert inserted_item.online_provider_id == "qqmusic" diff --git a/tests/test_system/test_plugin_ui_bridge.py b/tests/test_system/test_plugin_ui_bridge.py index 15c62264..125375a6 100644 --- a/tests/test_system/test_plugin_ui_bridge.py +++ b/tests/test_system/test_plugin_ui_bridge.py @@ -52,8 +52,6 @@ def test_plugin_context_ui_bridge_exposes_theme_and_dialog_helpers(tmp_path: Pat assert callable(context.ui.dialogs.question) assert callable(context.ui.dialogs.critical) assert callable(context.ui.dialogs.setup_title_bar) - assert callable(context.runtime.create_online_music_service) - assert callable(context.runtime.create_online_download_service) assert callable(context.runtime.get_icon) assert callable(context.runtime.http_get_content) assert callable(context.runtime.event_bus) diff --git a/tests/test_ui/test_dialog_action_buttons.py b/tests/test_ui/test_dialog_action_buttons.py index f9f33109..4339c85c 100644 --- a/tests/test_ui/test_dialog_action_buttons.py +++ b/tests/test_ui/test_dialog_action_buttons.py @@ -83,7 +83,7 @@ def test_redownload_dialog_uses_foundation_action_button_roles(qtbot): assert "QPushButton {" not in RedownloadDialog._STYLE_TEMPLATE assert len(roles["primary"]) == 1 - assert len(roles["cancel"]) == 1 + assert len(roles["cancel"]) == 0 def test_cloud_login_dialog_uses_foundation_cancel_button_role(qtbot, monkeypatch): diff --git a/tests/test_ui/test_library_view.py b/tests/test_ui/test_library_view.py index 60b40bde..d8df0477 100644 --- a/tests/test_ui/test_library_view.py +++ b/tests/test_ui/test_library_view.py @@ -48,7 +48,15 @@ def mock_theme_config(): def sample_tracks(): return [ Track(id=1, path="/music/one.mp3", title="One", artist="Artist 1", source=TrackSource.LOCAL), - Track(id=2, path="/music/two.mp3", title="Two", artist="Artist 2", source=TrackSource.QQ), + Track( + id=2, + path="online://qqmusic/track/two", + title="Two", + artist="Artist 2", + source=TrackSource.ONLINE, + cloud_file_id="two", + online_provider_id="qqmusic", + ), ] diff --git a/tests/test_ui/test_library_view_redownload.py b/tests/test_ui/test_library_view_redownload.py index 0a8ec73a..52a4f610 100644 --- a/tests/test_ui/test_library_view_redownload.py +++ b/tests/test_ui/test_library_view_redownload.py @@ -2,27 +2,25 @@ from types import SimpleNamespace from unittest.mock import MagicMock +import app.bootstrap as bootstrap_module +import services.download.download_manager as download_manager_module from domain.history import PlayHistory from domain.track import Track, TrackSource -from services.online.quality import get_quality_label_key from system.i18n import t from ui.dialogs.redownload_dialog import RedownloadDialog from ui.views.library_view import LibraryView - -def test_redownload_qq_track_uses_configured_quality_as_dialog_default( - qapp, - mock_theme_config, - reset_theme_singleton, - monkeypatch, -): - import app.bootstrap as bootstrap_module +def _init_theme(): from system.theme import ThemeManager - import ui.dialogs.redownload_dialog as redownload_dialog_module - ThemeManager.instance(mock_theme_config) + ThemeManager._instance = None + config = MagicMock() + config.get.return_value = "dark" + ThemeManager.instance(config) + +def _build_view(): library_service = MagicMock() library_service.get_track_count.return_value = 1 library_service.get_all_tracks.return_value = [] @@ -53,56 +51,84 @@ def test_redownload_qq_track_uses_configured_quality_as_dialog_default( player, config_manager=MagicMock(), ) + return view, library_service, history_service + + +def test_redownload_online_track_uses_provider_quality_and_manager(monkeypatch, qapp): + _init_theme() + + manager = MagicMock() + manager.download_completed = MagicMock(connect=MagicMock(), disconnect=MagicMock()) + manager.download_failed = MagicMock(connect=MagicMock(), disconnect=MagicMock()) + manager.redownload_online_track.return_value = True + monkeypatch.setattr( + download_manager_module.DownloadManager, + "instance", + classmethod(lambda cls: manager), + ) - show_dialog = MagicMock(return_value=None) - monkeypatch.setattr(redownload_dialog_module.RedownloadDialog, "show_dialog", show_dialog) + online_download_service = MagicMock() + online_download_service.get_download_qualities.return_value = [ + {"value": "flac", "label": "FLAC"}, + {"value": "320", "label": "320"}, + ] monkeypatch.setattr( bootstrap_module.Bootstrap, "instance", - lambda: SimpleNamespace( - config=SimpleNamespace(get_plugin_setting=lambda *_args, **_kwargs: "flac"), - online_download_service=SimpleNamespace(delete_cached_file=MagicMock()), - ), + classmethod(lambda cls: SimpleNamespace(online_download_service=online_download_service)), + ) + monkeypatch.setattr( + "ui.views.library_view.RedownloadDialog.show_dialog", + MagicMock(return_value="flac"), ) - track = Track(id=2, title="Two", cloud_file_id="song-mid", source=TrackSource.QQ) - view._redownload_qq_track(track) + view, _, _ = _build_view() + track = Track( + id=2, + title="Two", + cloud_file_id="song-mid", + source=TrackSource.ONLINE, + online_provider_id="qqmusic", + ) + view._redownload_online_track(track) + qapp.processEvents() - show_dialog.assert_called_once_with(track.title, current_quality="flac", parent=view) + online_download_service.get_download_qualities.assert_called_once_with( + "song-mid", + provider_id="qqmusic", + ) + manager.redownload_online_track.assert_called_once_with( + song_mid="song-mid", + title="Two", + provider_id="qqmusic", + quality="flac", + ) + assert view._status_label.text() == t("redownload") -def test_redownload_dialog_applies_popup_stylesheet_directly( - qapp, -): - from system.theme import ThemeManager +def test_redownload_dialog_returns_selected_quality_when_enabled(qapp): + _init_theme() - ThemeManager._instance = None - config = MagicMock() - config.get.return_value = "dark" - ThemeManager.instance(config) + dialog = RedownloadDialog( + "Two", + current_quality="320", + quality_options=[{"value": "flac", "label": "FLAC"}, {"value": "320", "label": "320"}], + ) + dialog._quality_combo.setCurrentIndex(0) + assert dialog.get_quality() == "flac" - dialog = RedownloadDialog("Two", current_quality="flac") - qapp.processEvents() - stylesheet = dialog._quality_combo.view().styleSheet() - popup_stylesheet = dialog._quality_combo.view().window().styleSheet() - assert "background-color" in stylesheet - assert "selection-background-color" in stylesheet - assert "QListView::item" in stylesheet - assert "background-color" in popup_stylesheet - - -def test_history_redownload_completion_refreshes_updated_track_path( - qapp, - mock_theme_config, - reset_theme_singleton, - monkeypatch, -): - import app.bootstrap as bootstrap_module - from system.theme import ThemeManager - import services.download.download_manager as download_manager_module +def test_history_redownload_completion_updates_status_for_pending_song(monkeypatch, qapp): + _init_theme() - ThemeManager.instance(mock_theme_config) + manager = MagicMock() + manager.download_completed = MagicMock(connect=MagicMock(), disconnect=MagicMock()) + manager.download_failed = MagicMock(connect=MagicMock(), disconnect=MagicMock()) + monkeypatch.setattr( + download_manager_module.DownloadManager, + "instance", + classmethod(lambda cls: manager), + ) old_track = Track( id=2, @@ -110,7 +136,8 @@ def test_history_redownload_completion_refreshes_updated_track_path( title="Two", artist="Artist 2", cloud_file_id="song-mid", - source=TrackSource.QQ, + source=TrackSource.ONLINE, + online_provider_id="qqmusic", ) new_track = Track( id=2, @@ -118,68 +145,23 @@ def test_history_redownload_completion_refreshes_updated_track_path( title="Two", artist="Artist 2", cloud_file_id="song-mid", - source=TrackSource.QQ, + source=TrackSource.ONLINE, + online_provider_id="qqmusic", ) - library_service = MagicMock() - library_service.get_track_count.return_value = 1 + view, library_service, history_service = _build_view() library_service.get_all_tracks.return_value = [old_track] library_service.search_tracks.return_value = [old_track] library_service.get_search_track_count.return_value = 1 library_service.get_tracks_by_ids.side_effect = [[old_track], [new_track]] - - favorites_service = MagicMock() - favorites_service.get_all_favorite_track_ids.return_value = set() - favorites_service.get_favorites.return_value = [] - - history_service = MagicMock() history_service.get_history.return_value = [ PlayHistory(track_id=2, played_at=datetime(2026, 4, 2, 12, 0, 0)) ] - - engine = MagicMock() - engine.current_track_changed = MagicMock() - engine.current_track_pending = MagicMock() - engine.state_changed = MagicMock() - engine.state = None - - player = MagicMock() - player.engine = engine - - config_manager = MagicMock() - config_manager.get.return_value = None - - view = LibraryView( - library_service, - favorites_service, - history_service, - player, - config_manager=config_manager, - ) view.show_history() qapp.processEvents() - fake_manager = SimpleNamespace( - download_completed=SimpleNamespace(disconnect=MagicMock()), - download_failed=SimpleNamespace(disconnect=MagicMock()), - ) - fake_download_service = SimpleNamespace( - pop_last_download_quality=MagicMock(return_value="ogg_320") - ) - monkeypatch.setattr( - download_manager_module.DownloadManager, - "instance", - classmethod(lambda cls: fake_manager), - ) - monkeypatch.setattr( - bootstrap_module.Bootstrap, - "instance", - lambda: SimpleNamespace(online_download_service=fake_download_service), - ) - + view._pending_redownload_mids.add("song-mid") view._on_redownload_completed("song-mid", "/music/new.ogg") qapp.processEvents() - assert view._history_list_view._model.get_track_at(0).path == "/music/new.ogg" - expected_label = t(get_quality_label_key("ogg_320")) - assert view._status_label.text() == f"{t('download_complete')} ({expected_label})" + assert view._status_label.text() == t("download_complete") diff --git a/tests/test_ui/test_online_music_view_async.py b/tests/test_ui/test_online_music_view_async.py index fb838875..3a7a2ffa 100644 --- a/tests/test_ui/test_online_music_view_async.py +++ b/tests/test_ui/test_online_music_view_async.py @@ -218,6 +218,37 @@ def __init__(self, credential): assert view._qqmusic_service.credential["musicid"] == "1" +def test_on_artist_clicked_accepts_dict_payload(): + view = OnlineMusicView.__new__(OnlineMusicView) + view._stack = Mock() + view._results_page = object() + view._results_stack = Mock() + view._singers_page = object() + view._detail_view = Mock() + view._navigation_stack = [] + + OnlineMusicView._on_artist_clicked( + view, + {"mid": "artist-mid", "name": "Artist A"}, + ) + + view._detail_view.load_artist.assert_called_once_with("artist-mid", "Artist A") + + +def test_display_playlists_uses_total_for_load_more_when_page_size_mismatch(): + view = OnlineMusicView.__new__(OnlineMusicView) + view._grid_total = 302 + view._grid_page = 1 + view._grid_page_size = 30 + view._playlists_page = Mock() + + playlists = [{"id": "p1"} for _ in range(20)] # API may return 20 despite num=30 + + OnlineMusicView._display_playlists(view, playlists, is_append=False) + + view._playlists_page.set_has_more.assert_called_once_with(True) + + def test_online_music_view_syncs_plugin_language_from_context_events(qtbot): plugin_i18n.set_language("en") theme_manager = Mock() diff --git a/tests/test_ui/test_playlist_view.py b/tests/test_ui/test_playlist_view.py index 0043026c..9f02e77a 100644 --- a/tests/test_ui/test_playlist_view.py +++ b/tests/test_ui/test_playlist_view.py @@ -53,7 +53,14 @@ def test_playlist_view_loads_tracks_into_list_view(qapp, mock_theme_config): playlist = Playlist(id=1, name="My Playlist") tracks = [ Track(id=1, path="/music/1.mp3", title="One", source=TrackSource.LOCAL), - Track(id=2, path="/music/2.mp3", title="Two", source=TrackSource.QQ), + Track( + id=2, + path="online://qqmusic/track/2", + title="Two", + source=TrackSource.ONLINE, + cloud_file_id="2", + online_provider_id="qqmusic", + ), ] playlist_service.get_all_playlists.return_value = [playlist] diff --git a/ui/dialogs/base_cover_download_dialog.py b/ui/dialogs/base_cover_download_dialog.py index 9a79c832..bdc80cfe 100644 --- a/ui/dialogs/base_cover_download_dialog.py +++ b/ui/dialogs/base_cover_download_dialog.py @@ -55,42 +55,52 @@ def run(self): self.finished.emit() -class QQMusicCoverFetchThread(QThread): - """Thread for fetching QQ Music cover URL and downloading.""" +class OnlineCoverFetchThread(QThread): + """Thread for fetching provider cover URL and downloading.""" cover_fetched = Signal(bytes, str, float) # Emits cover data, source, and score fetch_failed = Signal(str) # Emits error message finished = Signal() - def __init__(self, album_mid: str = None, song_mid: str = None, score: float = 0): + def __init__( + self, + album_mid: str = None, + song_mid: str = None, + score: float = 0, + provider_id: str | None = None, + ): super().__init__() self.album_mid = album_mid self.song_mid = song_mid self.score = score + self.provider_id = provider_id def run(self): - """Fetch QQ Music cover URL and download.""" + """Fetch provider cover URL and download.""" try: - from system.plugins.qqmusic_cover_helpers import get_qqmusic_cover_url + from system.plugins.online_cover_helpers import get_online_cover_url from infrastructure.network import HttpClient - logger.info(f"QQMusicCoverFetchThread: album_mid={self.album_mid}, song_mid={self.song_mid}") + logger.info( + "OnlineCoverFetchThread: provider=%s album_mid=%s song_mid=%s", + self.provider_id, + self.album_mid, + self.song_mid, + ) # Check if we have any ID to fetch if not self.album_mid and not self.song_mid: - logger.warning("QQMusicCoverFetchThread: No album_mid or song_mid provided") + logger.warning("OnlineCoverFetchThread: No album_mid or song_mid provided") self.fetch_failed.emit(t("cover_load_failed")) return # Get cover URL - cover_url = None - if self.album_mid: - logger.info(f"Fetching cover URL with album_mid={self.album_mid}") - cover_url = get_qqmusic_cover_url(album_mid=self.album_mid, size=500) - logger.info(f"Got cover_url={cover_url}") - elif self.song_mid: - logger.info(f"Fetching cover URL with song_mid={self.song_mid}") - cover_url = get_qqmusic_cover_url(mid=self.song_mid, size=500) - logger.info(f"Got cover_url={cover_url}") + cover_url = get_online_cover_url( + provider_id=self.provider_id, + track_id=self.song_mid, + album_id=self.album_mid, + size=500, + ) + logger.info("Got cover_url=%s", cover_url) if cover_url: # Download cover data @@ -99,7 +109,7 @@ def run(self): cover_data = http_client.get_content(cover_url, timeout=10) if cover_data: logger.info(f"Downloaded cover data: {len(cover_data)} bytes") - self.cover_fetched.emit(cover_data, 'qqmusic', self.score) + self.cover_fetched.emit(cover_data, self.provider_id, self.score) else: logger.warning("Failed to download cover data") self.fetch_failed.emit(t("cover_download_failed")) @@ -107,39 +117,48 @@ def run(self): logger.warning("No cover URL obtained") self.fetch_failed.emit(t("cover_load_failed")) except Exception as e: - logger.warning(f"Error fetching QQ Music cover: {e}") - logger.error(f"Error fetching QQ Music cover: {e}", exc_info=True) + logger.warning(f"Error fetching provider cover: {e}") + logger.error(f"Error fetching provider cover: {e}", exc_info=True) self.fetch_failed.emit(f"{t('error')}: {str(e)}") finally: self.finished.emit() -class QQMusicArtistCoverFetchThread(QThread): - """Thread for fetching QQ Music artist cover URL and downloading.""" +class OnlineArtistCoverFetchThread(QThread): + """Thread for fetching provider artist cover URL and downloading.""" cover_fetched = Signal(bytes, str, float) # Emits cover data, source, and score fetch_failed = Signal(str) # Emits error message finished = Signal() - def __init__(self, singer_mid: str, score: float = 0): + def __init__(self, singer_mid: str, score: float = 0, provider_id: str | None = None): super().__init__() self.singer_mid = singer_mid self.score = score + self.provider_id = provider_id def run(self): - """Fetch QQ Music artist cover URL and download.""" + """Fetch provider artist cover URL and download.""" try: - from system.plugins.qqmusic_cover_helpers import get_qqmusic_artist_cover_url + from system.plugins.online_cover_helpers import get_online_artist_cover_url from infrastructure.network import HttpClient - logger.info(f"QQMusicArtistCoverFetchThread: singer_mid={self.singer_mid}") + logger.info( + "OnlineArtistCoverFetchThread: provider=%s singer_mid=%s", + self.provider_id, + self.singer_mid, + ) if not self.singer_mid: - logger.warning("QQMusicArtistCoverFetchThread: No singer_mid provided") + logger.warning("OnlineArtistCoverFetchThread: No singer_mid provided") self.fetch_failed.emit(t("cover_load_failed")) return - # Get artist cover URL (direct construction) - cover_url = get_qqmusic_artist_cover_url(self.singer_mid, size=500) + # Get artist cover URL + cover_url = get_online_artist_cover_url( + provider_id=self.provider_id, + artist_id=self.singer_mid, + size=500, + ) logger.info(f"Artist cover URL: {cover_url}") if cover_url: @@ -148,7 +167,7 @@ def run(self): cover_data = http_client.get_content(cover_url, timeout=10) if cover_data: logger.info(f"Downloaded artist cover data: {len(cover_data)} bytes") - self.cover_fetched.emit(cover_data, 'qqmusic', self.score) + self.cover_fetched.emit(cover_data, self.provider_id, self.score) else: logger.warning("Failed to download artist cover data") self.fetch_failed.emit(t("cover_download_failed")) @@ -156,8 +175,8 @@ def run(self): logger.warning("No artist cover URL obtained") self.fetch_failed.emit(t("cover_load_failed")) except Exception as e: - logger.warning(f"Error fetching QQ Music artist cover: {e}") - logger.error(f"Error fetching QQ Music artist cover: {e}", exc_info=True) + logger.warning(f"Error fetching provider artist cover: {e}") + logger.error(f"Error fetching provider artist cover: {e}", exc_info=True) self.fetch_failed.emit(f"{t('error')}: {str(e)}") finally: self.finished.emit() @@ -585,25 +604,30 @@ def _on_search_failed_base(self, error_message: str): self._cover_label.setText(t("no_results")) # ======================================================================== - # QQ Music Cover Fetch (Shared) + # Provider Cover Fetch (Shared) # ======================================================================== - def _fetch_qqmusic_cover_base(self, album_mid: str = None, song_mid: str = None, - singer_mid: str = None, result: dict = None, - is_artist: bool = False): - """Fetch QQ Music cover URL lazily and download - shared implementation. + def _fetch_provider_cover_base( + self, + album_mid: str = None, + song_mid: str = None, + singer_mid: str = None, + result: dict = None, + is_artist: bool = False, + ): + """Fetch provider cover URL lazily and download - shared implementation. Args: album_mid: Album mid (for album/track covers) song_mid: Song mid (for track covers) singer_mid: Singer mid (for artist covers) result: Search result dict with score - is_artist: True for artist covers (uses QQMusicArtistCoverFetchThread) + is_artist: True for artist covers (uses OnlineArtistCoverFetchThread) """ if result is None: result = {} score = result.get('score', 0) - logger.info(f"QQ Music lazy fetch: album_mid={album_mid}, song_mid={song_mid}, singer_mid={singer_mid}") + logger.info(f"Provider lazy fetch: album_mid={album_mid}, song_mid={song_mid}, singer_mid={singer_mid}") # Update score display self._score_label.setText(f"{t('match_score')}: {score:.0f}%") @@ -614,36 +638,39 @@ def _fetch_qqmusic_cover_base(self, album_mid: str = None, song_mid: str = None, self._progress.setVisible(True) self._progress.setRange(0, 0) self._status_label.setText(t("downloading")) + provider_id = result.get("source") if isinstance(result, dict) else None if is_artist and singer_mid: - # Use QQMusicArtistCoverFetchThread for artist covers - self._download_thread = QQMusicArtistCoverFetchThread( + # Use OnlineArtistCoverFetchThread for artist covers + self._download_thread = OnlineArtistCoverFetchThread( singer_mid=singer_mid, - score=score + score=score, + provider_id=provider_id, ) else: - # Use QQMusicCoverFetchThread for album/track covers - self._download_thread = QQMusicCoverFetchThread( + # Use OnlineCoverFetchThread for album/track covers + self._download_thread = OnlineCoverFetchThread( album_mid=album_mid, song_mid=song_mid, - score=score + score=score, + provider_id=provider_id, ) - self._download_thread.cover_fetched.connect(self._on_qqmusic_cover_fetched) - self._download_thread.fetch_failed.connect(self._on_qqmusic_cover_failed) + self._download_thread.cover_fetched.connect(self._on_provider_cover_fetched) + self._download_thread.fetch_failed.connect(self._on_provider_cover_failed) self._download_thread.finished.connect(self._on_download_finished) self._download_thread.start() - def _on_qqmusic_cover_fetched(self, cover_data: bytes, source: str, score: float): - """Handle QQ Music cover fetch success - shared implementation.""" - logger.info(f"QQ Music cover fetched: {len(cover_data)} bytes") + def _on_provider_cover_fetched(self, cover_data: bytes, source: str, score: float): + """Handle provider cover fetch success - shared implementation.""" + logger.info("Provider cover fetched: %s bytes", len(cover_data)) # Call subclass _on_cover_downloaded which calls _on_cover_downloaded_base self._on_cover_downloaded(cover_data, source) self._score_label.setText(f"{t('match_score')}: {score:.0f}%") - def _on_qqmusic_cover_failed(self, error_message: str): - """Handle QQ Music cover fetch failure - shared implementation.""" - logger.warning(f"QQ Music cover fetch failed: {error_message}") + def _on_provider_cover_failed(self, error_message: str): + """Handle provider cover fetch failure - shared implementation.""" + logger.warning(f"Provider cover fetch failed: {error_message}") self._progress.setVisible(False) self._status_label.setText(error_message) self._cover_label.setText(t("cover_load_failed")) diff --git a/ui/dialogs/edit_media_info_dialog.py b/ui/dialogs/edit_media_info_dialog.py index cf772803..c987cd65 100644 --- a/ui/dialogs/edit_media_info_dialog.py +++ b/ui/dialogs/edit_media_info_dialog.py @@ -94,8 +94,8 @@ def _check_can_save(self, track) -> bool: if not track.path: return False - # Check for online streaming URLs - if track.path.startswith(('http://', 'https://', 'qqmusic:/')): + # Check for online/virtual streaming URLs + if track.path.startswith(('http://', 'https://', 'online://')): return False # Check if file exists locally @@ -245,14 +245,12 @@ def _add_file_info(self, form_layout: QFormLayout, track): """Add file information to the form for single track edit.""" try: # Check if this is a local file - if not track.path or track.path.startswith(('http://', 'https://', 'qqmusic:/')): + if not track.path or track.path.startswith(('http://', 'https://', 'online://')): # Online track - show online info from domain.track import TrackSource source_text = t("online_track") if hasattr(track, 'source'): - if track.source == TrackSource.QQ: - source_text = "QQ音乐" - elif track.source == TrackSource.QUARK: + if track.source == TrackSource.QUARK: source_text = "夸克网盘" elif track.source == TrackSource.BAIDU: source_text = "百度网盘" diff --git a/ui/dialogs/lyrics_download_dialog.py b/ui/dialogs/lyrics_download_dialog.py index 2a9c438a..ea8731f5 100644 --- a/ui/dialogs/lyrics_download_dialog.py +++ b/ui/dialogs/lyrics_download_dialog.py @@ -333,18 +333,10 @@ def _on_search_progress(self, new_results: list, source_name: str): result['_score'] = score scored_results.append(result) - # Define source priority (lower number = higher priority) - source_priority = { - 'qqmusic': 0, # QQ Music first - 'netease': 1, - 'kugou': 2, - 'lrclib': 3, - } - - # Sort by score descending, then by source priority (QQ Music first for same score) + # Sort by score descending, then by source name for deterministic ordering scored_results.sort(key=lambda x: ( -x.get('_score', 0), # Negative for descending score - source_priority.get(x.get('source', ''), 99) # Lower priority number first + x.get('source', '') )) # Add new results to the list (clear existing and rebuild to maintain sorting) @@ -366,10 +358,10 @@ def _on_search_progress(self, new_results: list, source_name: str): seen.add(key) unique_results.append(result) - # Sort all results by score, then by source priority + # Sort all results by score, then by source name unique_results.sort(key=lambda x: ( -x.get('_score', 0), - source_priority.get(x.get('source', ''), 99) + x.get('source', '') )) # Clear and repopulate the list @@ -437,7 +429,7 @@ def _format_result_text(self, result: dict) -> str: if result.get('supports_yrc'): source = f"{source} YRC" # Indicate YRC (word-by-word) support elif result.get('supports_qrc'): - source = f"{source} QRC" # Indicate QRC (word-by-word) support for QQ Music + source = f"{source} QRC" # Indicate QRC (word-by-word) support item_text += f" [{source}]" # Score at the end diff --git a/ui/dialogs/redownload_dialog.py b/ui/dialogs/redownload_dialog.py index bf0f4332..bcd82b81 100644 --- a/ui/dialogs/redownload_dialog.py +++ b/ui/dialogs/redownload_dialog.py @@ -1,26 +1,21 @@ """ -Re-download dialog for QQ Music tracks. +Re-download dialog for online tracks. Allows user to select audio quality before re-downloading. """ from PySide6.QtCore import Qt from PySide6.QtGui import QColor, QPainterPath, QRegion from PySide6.QtWidgets import ( QDialog, QVBoxLayout, QHBoxLayout, QLabel, - QComboBox, QPushButton, QWidget, QGraphicsDropShadowEffect, + QPushButton, QWidget, QGraphicsDropShadowEffect, QComboBox, ) from system.i18n import t from system.theme import ThemeManager from ui.dialogs.dialog_title_bar import setup_equalizer_title_layout -from services.online.quality import ( - get_selectable_qualities, - get_quality_label_key, - normalize_quality, -) class RedownloadDialog(QDialog): - """Dialog for selecting audio quality when re-downloading a QQ Music track.""" + """Dialog for selecting plugin-provided quality before re-download.""" _STYLE_TEMPLATE = """ QLabel#hintLabel { @@ -57,10 +52,18 @@ class RedownloadDialog(QDialog): } """ - def __init__(self, track_title: str, current_quality: str = None, parent=None): + def __init__( + self, + track_title: str, + current_quality: str = None, + quality_options: list[dict[str, str]] | list[str] | None = None, + parent=None, + ): super().__init__(parent) - self._quality = current_quality or "320" + self._quality = None self._drag_pos = None + self._quality_options = self._normalize_quality_options(quality_options) + self._quality_combo = None self.setWindowFlags(Qt.WindowType.Dialog | Qt.FramelessWindowHint) self.setAttribute(Qt.WidgetAttribute.WA_TranslucentBackground) @@ -95,72 +98,109 @@ def _setup_ui(self, track_title: str, current_quality: str = None): f"{t('redownload')} - {track_title}", ) - # Quality selection - quality_row = QHBoxLayout() - quality_label = QLabel(t("select_quality")) - quality_label.setFixedWidth(80) - quality_row.addWidget(quality_label) - - self._quality_combo = QComboBox() - self._quality_combo.setCursor(Qt.PointingHandCursor) - normalized_current = normalize_quality(current_quality or "320") - default_index = 0 - for i, value in enumerate(get_selectable_qualities()): - label_key = get_quality_label_key(value) - label = t(label_key) if label_key else value - self._quality_combo.addItem(label, value) - if value == normalized_current: - default_index = i - self._quality_combo.setCurrentIndex(default_index) - quality_row.addWidget(self._quality_combo) - layout.addLayout(quality_row) - - # Hint label hint_label = QLabel(t("redownload_hint")) hint_label.setObjectName("hintLabel") hint_label.setWordWrap(True) layout.addWidget(hint_label) + if self._quality_options: + quality_row = QHBoxLayout() + quality_label = QLabel(t("select_quality")) + self._quality_combo = QComboBox() + self._quality_combo.setFixedWidth(260) + self._quality_combo.setProperty("compact", True) + for option in self._quality_options: + self._quality_combo.addItem(option["label"], option["value"]) + self._select_initial_quality(current_quality) + quality_row.addWidget(quality_label) + quality_row.addWidget(self._quality_combo) + quality_row.addStretch() + layout.addLayout(quality_row) + else: + unsupported_label = QLabel(t("not_supported_yet")) + unsupported_label.setObjectName("hintLabel") + unsupported_label.setWordWrap(True) + layout.addWidget(unsupported_label) + layout.addStretch() - # Buttons button_layout = QHBoxLayout() button_layout.addStretch() - cancel_btn = QPushButton(t("cancel")) - cancel_btn.setProperty("role", "cancel") - cancel_btn.setCursor(Qt.PointingHandCursor) - cancel_btn.clicked.connect(self.reject) - - confirm_btn = QPushButton(t("ok")) - confirm_btn.setProperty("role", "primary") - confirm_btn.setCursor(Qt.PointingHandCursor) - confirm_btn.clicked.connect(self.accept) - - button_layout.addWidget(cancel_btn) - button_layout.addWidget(confirm_btn) + if self._quality_options: + cancel_btn = QPushButton(t("cancel")) + cancel_btn.setProperty("role", "cancel") + cancel_btn.setCursor(Qt.PointingHandCursor) + cancel_btn.clicked.connect(self.reject) + button_layout.addWidget(cancel_btn) + + ok_btn = QPushButton(t("ok")) + ok_btn.setProperty("role", "primary") + ok_btn.setCursor(Qt.PointingHandCursor) + ok_btn.clicked.connect(self.accept) + button_layout.addWidget(ok_btn) + else: + close_btn = QPushButton(t("ok")) + close_btn.setProperty("role", "primary") + close_btn.setCursor(Qt.PointingHandCursor) + close_btn.clicked.connect(self.reject) + button_layout.addWidget(close_btn) layout.addLayout(button_layout) def get_quality(self) -> str: """Get selected quality value.""" - return self._quality_combo.currentData() + if self._quality_combo is not None: + selected = self._quality_combo.currentData() + self._quality = str(selected or "").strip() or None + return self._quality @staticmethod - def show_dialog(track_title: str, current_quality: str = None, parent=None): - """Show dialog and return selected quality, or None if cancelled.""" - dialog = RedownloadDialog(track_title, current_quality, parent) + def show_dialog( + track_title: str, + current_quality: str = None, + quality_options: list[dict[str, str]] | list[str] | None = None, + parent=None, + ): + dialog = RedownloadDialog(track_title, current_quality, quality_options, parent) if dialog.exec() == QDialog.Accepted: return dialog.get_quality() return None + @staticmethod + def _normalize_quality_options(options) -> list[dict[str, str]]: + normalized: list[dict[str, str]] = [] + if not options: + return normalized + for item in options: + if isinstance(item, str): + value = item.strip() + if value: + normalized.append({"value": value, "label": value}) + continue + if not isinstance(item, dict): + continue + value = str(item.get("value", "") or "").strip() + if not value: + continue + label = str(item.get("label", "") or value).strip() or value + normalized.append({"value": value, "label": label}) + return normalized + + def _select_initial_quality(self, current_quality: str | None) -> None: + if self._quality_combo is None: + return + preferred = str(current_quality or "").strip().lower() + index_to_select = 0 + if preferred: + for i, option in enumerate(self._quality_options): + if option["value"].strip().lower() == preferred: + index_to_select = i + break + self._quality_combo.setCurrentIndex(index_to_select) + def _apply_theme(self): theme_manager = ThemeManager.instance() self.setStyleSheet(theme_manager.get_qss(self._STYLE_TEMPLATE)) - popup_view = self._quality_combo.view() - popup_view.setStyleSheet(theme_manager.get_qss(self._POPUP_STYLE_TEMPLATE)) - popup_view.window().setStyleSheet( - theme_manager.get_qss(self._POPUP_CONTAINER_STYLE_TEMPLATE) - ) def refresh_theme(self): self._apply_theme() diff --git a/ui/dialogs/universal_cover_download_dialog.py b/ui/dialogs/universal_cover_download_dialog.py index e863472b..20613ce7 100644 --- a/ui/dialogs/universal_cover_download_dialog.py +++ b/ui/dialogs/universal_cover_download_dialog.py @@ -485,14 +485,14 @@ def _on_result_selected(self, item: QListWidgetItem): # Check if needs lazy fetch if self._strategy.needs_lazy_fetch(result): - logger.info("Performing lazy fetch for QQ Music cover") + logger.info("Performing lazy fetch for provider cover") self._current_result = result self._progress.setVisible(True) self._status_label.setText(t("downloading")) def task(): data = self._strategy.lazy_fetch(self._cover_service, result) - return (data, 'qqmusic') if data else None + return (data, result.get("source", "")) if data else None # Generate key for download key = f"lazy-{result.get('album_mid', result.get('song_mid', ''))}" diff --git a/ui/strategies/album_search_strategy.py b/ui/strategies/album_search_strategy.py index d0e1bb26..4f443671 100644 --- a/ui/strategies/album_search_strategy.py +++ b/ui/strategies/album_search_strategy.py @@ -6,7 +6,7 @@ from infrastructure.network import HttpClient from services.metadata import CoverService -from system.plugins.qqmusic_cover_helpers import get_qqmusic_cover_url +from system.plugins.online_cover_helpers import get_online_cover_url from ui.strategies.cover_search_strategy import CoverSearchStrategy logger = logging.getLogger(__name__) @@ -18,7 +18,7 @@ class AlbumSearchStrategy(CoverSearchStrategy): Handles: - Single album - search_covers() API with empty title - - QQ Music lazy fetch with album_mid or song_mid + - Provider lazy fetch with album_mid or song_mid - Save via library_service.update_album_cover() """ @@ -82,25 +82,30 @@ def get_cover_url(self, result: dict) -> Optional[str]: return result.get('cover_url') def needs_lazy_fetch(self, result: dict) -> bool: - """Check if result needs QQ Music lazy fetch.""" + """Check if result needs provider lazy fetch.""" return ( - result.get('source') == 'qqmusic' and + bool(result.get('source')) and not result.get('cover_url') and bool(result.get('album_mid') or result.get('id')) ) def lazy_fetch(self, cover_service: CoverService, result: dict) -> bytes: - """Fetch QQ Music cover with lazy loading.""" + """Fetch provider cover with lazy loading.""" album_mid = result.get('album_mid') song_mid = result.get('id') # Note: 'id' field contains song mid + provider_id = result.get('source') # Get cover URL - if album_mid: - cover_url = get_qqmusic_cover_url(album_mid=album_mid, size=500) - elif song_mid: - cover_url = get_qqmusic_cover_url(mid=song_mid, size=500) - else: + if not (album_mid or song_mid): raise ValueError("No album_mid or song_mid for lazy fetch") + cover_url = get_online_cover_url( + provider_id=provider_id, + track_id=song_mid, + album_id=album_mid, + size=500, + ) + if not cover_url: + raise ValueError("No cover URL returned by provider") # Download cover http_client = HttpClient() diff --git a/ui/strategies/artist_search_strategy.py b/ui/strategies/artist_search_strategy.py index df507b4a..63f9571d 100644 --- a/ui/strategies/artist_search_strategy.py +++ b/ui/strategies/artist_search_strategy.py @@ -6,7 +6,7 @@ from infrastructure.network import HttpClient from services.metadata import CoverService -from system.plugins.qqmusic_cover_helpers import get_qqmusic_artist_cover_url +from system.plugins.online_cover_helpers import get_online_artist_cover_url from ui.strategies.cover_search_strategy import CoverSearchStrategy logger = logging.getLogger(__name__) @@ -17,9 +17,9 @@ class ArtistSearchStrategy(CoverSearchStrategy): Handles: - Single artist - - search_artist_covers() API (QQ Music type=100) + - search_artist_covers() API - Circular cover display - - QQ Music lazy fetch with singer_mid + - Provider lazy fetch with singer_mid - Save via library_service.update_artist_cover() """ @@ -70,22 +70,25 @@ def get_cover_url(self, result: dict) -> Optional[str]: return result.get('cover_url') def needs_lazy_fetch(self, result: dict) -> bool: - """Check if result needs QQ Music lazy fetch.""" + """Check if result needs provider lazy fetch.""" return ( - result.get('source') == 'qqmusic' and + bool(result.get('source')) and not result.get('cover_url') and bool(result.get('singer_mid')) ) def lazy_fetch(self, cover_service: CoverService, result: dict) -> bytes: - """Fetch QQ Music artist cover with lazy loading.""" + """Fetch provider artist cover with lazy loading.""" singer_mid = result.get('singer_mid') + provider_id = result.get('source') if not singer_mid: raise ValueError("No singer_mid for lazy fetch") # Get artist cover URL - cover_url = get_qqmusic_artist_cover_url(singer_mid, size=500) + cover_url = get_online_artist_cover_url(provider_id=provider_id, artist_id=singer_mid, size=500) + if not cover_url: + raise ValueError("No cover URL returned by provider") # Download cover http_client = HttpClient() diff --git a/ui/strategies/cover_search_strategy.py b/ui/strategies/cover_search_strategy.py index 6cdc245f..4a061977 100644 --- a/ui/strategies/cover_search_strategy.py +++ b/ui/strategies/cover_search_strategy.py @@ -15,7 +15,7 @@ class CoverSearchStrategy(ABC): - How to display items in UI - How to search for covers - How to format results - - How to handle QQ Music lazy fetch + - How to handle provider lazy fetch - How to save covers to database """ @@ -86,7 +86,7 @@ def get_cover_url(self, result: dict) -> Optional[str]: @abstractmethod def needs_lazy_fetch(self, result: dict) -> bool: - """Check if result needs lazy fetch (QQ Music). + """Check if result needs lazy fetch. Args: result: Search result dictionary @@ -98,7 +98,7 @@ def needs_lazy_fetch(self, result: dict) -> bool: @abstractmethod def lazy_fetch(self, cover_service: CoverService, result: dict) -> bytes: - """Fetch cover with lazy loading (QQ Music). + """Fetch cover with lazy loading. Args: cover_service: CoverService instance diff --git a/ui/strategies/genre_search_strategy.py b/ui/strategies/genre_search_strategy.py index 638147e1..5f325509 100644 --- a/ui/strategies/genre_search_strategy.py +++ b/ui/strategies/genre_search_strategy.py @@ -59,24 +59,29 @@ def get_cover_url(self, result: dict) -> Optional[str]: def needs_lazy_fetch(self, result: dict) -> bool: return ( - result.get("source") == "qqmusic" + bool(result.get("source")) and not result.get("cover_url") and bool(result.get("album_mid") or result.get("id")) ) def lazy_fetch(self, cover_service: CoverService, result: dict) -> bytes: - # Reuse generic QQ lazy fetch path from existing behavior by importing helper here. + # Reuse provider lazy fetch path from existing behavior by importing helper here. from infrastructure.network import HttpClient - from system.plugins.qqmusic_cover_helpers import get_qqmusic_cover_url + from system.plugins.online_cover_helpers import get_online_cover_url album_mid = result.get("album_mid") song_mid = result.get("id") - if album_mid: - cover_url = get_qqmusic_cover_url(album_mid=album_mid, size=500) - elif song_mid: - cover_url = get_qqmusic_cover_url(mid=song_mid, size=500) - else: + provider_id = result.get("source") + if not (album_mid or song_mid): raise ValueError("No album_mid or song_mid for lazy fetch") + cover_url = get_online_cover_url( + provider_id=provider_id, + track_id=song_mid, + album_id=album_mid, + size=500, + ) + if not cover_url: + raise ValueError("No cover URL returned by provider") http_client = HttpClient() cover_data = http_client.get_content(cover_url, timeout=10) diff --git a/ui/strategies/track_search_strategy.py b/ui/strategies/track_search_strategy.py index 52df7215..0cb13752 100644 --- a/ui/strategies/track_search_strategy.py +++ b/ui/strategies/track_search_strategy.py @@ -6,7 +6,7 @@ from infrastructure.network import HttpClient from services.metadata import CoverService -from system.plugins.qqmusic_cover_helpers import get_qqmusic_cover_url +from system.plugins.online_cover_helpers import get_online_cover_url from ui.strategies.cover_search_strategy import CoverSearchStrategy logger = logging.getLogger(__name__) @@ -18,7 +18,7 @@ class TrackSearchStrategy(CoverSearchStrategy): Handles: - Multiple tracks with combo box navigation - search_covers() API with title/artist/album/duration - - QQ Music lazy fetch with album_mid or song_mid + - Provider lazy fetch with album_mid or song_mid - Save via track_repo.update() or custom callback """ @@ -89,25 +89,30 @@ def get_cover_url(self, result: dict) -> Optional[str]: return result.get('cover_url') def needs_lazy_fetch(self, result: dict) -> bool: - """Check if result needs QQ Music lazy fetch.""" + """Check if result needs provider lazy fetch.""" return ( - result.get('source') == 'qqmusic' and + bool(result.get('source')) and not result.get('cover_url') and bool(result.get('album_mid') or result.get('id')) ) def lazy_fetch(self, cover_service: CoverService, result: dict) -> bytes: - """Fetch QQ Music cover with lazy loading.""" + """Fetch provider cover with lazy loading.""" album_mid = result.get('album_mid') song_mid = result.get('id') # Note: 'id' field contains song mid + provider_id = result.get('source') # Get cover URL - if album_mid: - cover_url = get_qqmusic_cover_url(album_mid=album_mid, size=500) - elif song_mid: - cover_url = get_qqmusic_cover_url(mid=song_mid, size=500) - else: + if not (album_mid or song_mid): raise ValueError("No album_mid or song_mid for lazy fetch") + cover_url = get_online_cover_url( + provider_id=provider_id, + track_id=song_mid, + album_id=album_mid, + size=500, + ) + if not cover_url: + raise ValueError("No cover URL returned by provider") # Download cover http_client = HttpClient() diff --git a/ui/views/genres_view.py b/ui/views/genres_view.py index 78df4e20..db097c48 100644 --- a/ui/views/genres_view.py +++ b/ui/views/genres_view.py @@ -249,7 +249,7 @@ def _prepare_cover_request(self, url: str) -> tuple[str, dict | None]: request_url = url request_headers = None - # Legacy QQ URLs in database should use y.gtimg.cn for stable image access. + # Legacy provider URLs in database should use y.gtimg.cn for stable image access. if url.startswith("https://y.qq.com/music/photo_new/"): request_url = url.replace("https://y.qq.com/music/photo_new/", "https://y.gtimg.cn/music/photo_new/", 1) request_headers = {"Referer": "https://y.qq.com/"} diff --git a/ui/views/history_list_view.py b/ui/views/history_list_view.py index db718687..79df0978 100644 --- a/ui/views/history_list_view.py +++ b/ui/views/history_list_view.py @@ -171,16 +171,13 @@ def paint(self, painter: QPainter, option: QStyleOptionViewItem, index: QModelIn # Source indicator + Played time (relative) from domain.track import TrackSource source_str = track.source.value if track.source else "Local" - try: - source = TrackSource(source_str) if source_str else TrackSource.LOCAL - except ValueError: - source = TrackSource.LOCAL + source = TrackSource.from_value(source_str) source_text = "" if source == TrackSource.LOCAL: source_text = t("source_local") - elif source == TrackSource.QQ: - source_text = t("source_qq") + elif source == TrackSource.ONLINE: + source_text = t("online_track") elif source == TrackSource.QUARK: source_text = t("source_quark") elif source == TrackSource.BAIDU: diff --git a/ui/views/library_view.py b/ui/views/library_view.py index d5dc2882..95f2f021 100644 --- a/ui/views/library_view.py +++ b/ui/views/library_view.py @@ -21,8 +21,6 @@ from domain.playback import PlaybackState from domain.track import Track -from services.online.quality import get_quality_label_key, normalize_quality -from services.download import DownloadManager from services.metadata import CoverService from services.playback import PlaybackService from system.config import ConfigManager @@ -31,6 +29,7 @@ from system.theme import ThemeManager from ui.dialogs.edit_media_info_dialog import EditMediaInfoDialog from ui.dialogs.message_dialog import MessageDialog, Yes, No +from ui.dialogs.redownload_dialog import RedownloadDialog from ui.views.history_list_view import HistoryListView from ui.views.local_tracks_list_view import LocalTracksListView from utils import format_count_message @@ -100,6 +99,7 @@ def __init__( self._all_tracks_loading = False self._all_tracks_query = "" self._all_tracks_source = None + self._pending_redownload_mids: set[str] = set() from system.theme import ThemeManager ThemeManager.instance().register_widget(self) @@ -131,7 +131,7 @@ def _setup_ui(self): self._source_filter.addItem(t("source_local"), "Local") self._source_filter.addItem(t("source_quark"), "QUARK") self._source_filter.addItem(t("source_baidu"), "BAIDU") - self._source_filter.addItem(t("source_qq"), "QQ") + self._source_filter.addItem(t("online_track"), "ONLINE") self._source_filter.setFixedWidth(120) self._source_filter.setProperty("compact", True) header_layout.addWidget(self._source_filter) @@ -198,7 +198,7 @@ def _setup_connections(self): self._all_tracks_list_view.open_file_location_requested.connect(self._on_all_tracks_open_file_location) self._all_tracks_list_view.remove_from_library_requested.connect(self._on_all_tracks_remove_from_library) self._all_tracks_list_view.delete_file_requested.connect(self._on_all_tracks_delete_file) - self._all_tracks_list_view.redownload_requested.connect(self._redownload_qq_track) + self._all_tracks_list_view.redownload_requested.connect(self._redownload_online_track) self._all_tracks_list_view._list_view.verticalScrollBar().valueChanged.connect( self._on_all_tracks_scroll_changed ) @@ -216,7 +216,7 @@ def _setup_connections(self): self._favorites_list_view.open_file_location_requested.connect(self._on_all_tracks_open_file_location) self._favorites_list_view.remove_from_library_requested.connect(self._on_all_tracks_remove_from_library) self._favorites_list_view.delete_file_requested.connect(self._on_all_tracks_delete_file) - self._favorites_list_view.redownload_requested.connect(self._redownload_qq_track) + self._favorites_list_view.redownload_requested.connect(self._redownload_online_track) # History list view self._history_list_view.track_activated.connect(self._on_history_track_activated) @@ -231,7 +231,7 @@ def _setup_connections(self): self._history_list_view.open_file_location_requested.connect(self._on_history_open_file_location) self._history_list_view.remove_from_library_requested.connect(self._on_history_remove_from_library) self._history_list_view.delete_file_requested.connect(self._on_history_delete_file) - self._history_list_view.redownload_requested.connect(self._redownload_qq_track) + self._history_list_view.redownload_requested.connect(self._redownload_online_track) # Connect to player engine signals self._player.engine.current_track_changed.connect( @@ -247,6 +247,11 @@ def _setup_connections(self): event_bus.tracks_organized.connect(self._on_tracks_organized) event_bus.favorite_changed.connect(self._on_favorite_changed) + from services.download.download_manager import DownloadManager + manager = DownloadManager.instance() + manager.download_completed.connect(self._on_redownload_completed) + manager.download_failed.connect(self._on_redownload_failed) + @staticmethod def _disconnect_signal(signal, slot): """Best-effort signal disconnection for shutdown cleanup.""" @@ -266,6 +271,10 @@ def closeEvent(self, event): event_bus = EventBus.instance() self._disconnect_signal(event_bus.tracks_organized, self._on_tracks_organized) self._disconnect_signal(event_bus.favorite_changed, self._on_favorite_changed) + from services.download.download_manager import DownloadManager + manager = DownloadManager.instance() + self._disconnect_signal(manager.download_completed, self._on_redownload_completed) + self._disconnect_signal(manager.download_failed, self._on_redownload_failed) search_timer = getattr(self, "_search_timer", None) if search_timer is not None: @@ -945,93 +954,62 @@ def _open_organize_files_dialog(self, tracks: list): if dialog.exec() == QDialog.Accepted: self.refresh() - def _redownload_qq_track(self, track): - """Re-download a QQ Music track with quality selection.""" - from ui.dialogs.redownload_dialog import RedownloadDialog - from app.bootstrap import Bootstrap + def _redownload_online_track(self, track): + """Request plugin-driven online re-download for a single track.""" + if not track or not track.is_online: + self._status_label.setText(t("not_supported_yet")) + return + song_mid = str(track.cloud_file_id or "").strip() + provider_id = str(track.online_provider_id or "").strip() + if not song_mid or not provider_id: + self._status_label.setText(t("not_supported_yet")) + return + + from app.bootstrap import Bootstrap bootstrap = Bootstrap.instance() - song_mid = track.cloud_file_id - default_quality = ( - bootstrap.config.get_plugin_setting("qqmusic", "quality", "320") - if bootstrap and bootstrap.config - else "320" - ) - quality = RedownloadDialog.show_dialog( - track.title, - current_quality=default_quality, + service = getattr(bootstrap, "online_download_service", None) + if not service: + self._status_label.setText(t("not_supported_yet")) + return + + quality_options = service.get_download_qualities(song_mid, provider_id=provider_id) + selected_quality = RedownloadDialog.show_dialog( + track.title or song_mid, + quality_options=quality_options, parent=self, ) - if quality is None: + if not selected_quality: return - online_download_service = bootstrap.online_download_service - - # Delete cached files for all quality variants - online_download_service.delete_cached_file(song_mid) - - # Delete local file if exists - import os - if track.path and os.path.exists(track.path): - try: - os.remove(track.path) - except OSError: - pass - # Clear path in DB - if track.id: - self._library_service.update_track_path(track.id, "") - - # Start re-download - dm = DownloadManager.instance() - dm.download_completed.connect(self._on_redownload_completed) - dm.download_failed.connect(self._on_redownload_failed) - dm.redownload_online_track( - song_mid, track.title, quality=quality, force=True - ) - self._status_label.setText( - f"{t('downloading')}... {track.title} ({self._format_quality_label(quality)})" + from services.download.download_manager import DownloadManager + started = DownloadManager.instance().redownload_online_track( + song_mid=song_mid, + title=track.title or "", + provider_id=provider_id, + quality=selected_quality, ) + if started: + self._pending_redownload_mids.add(song_mid) + self._status_label.setText(t("redownload")) + else: + self._status_label.setText(t("download_failed")) def _on_redownload_completed(self, song_mid: str, local_path: str): """Handle re-download completion.""" - from app.bootstrap import Bootstrap - - try: - dm = DownloadManager.instance() - dm.download_completed.disconnect(self._on_redownload_completed) - dm.download_failed.disconnect(self._on_redownload_failed) - except RuntimeError: + if song_mid not in self._pending_redownload_mids: return - if local_path: - bootstrap = Bootstrap.instance() - actual_quality = None - if bootstrap and bootstrap.online_download_service: - actual_quality = bootstrap.online_download_service.pop_last_download_quality(song_mid) - self._reload_current_list_view() - if actual_quality: - self._status_label.setText( - f"{t('download_complete')} ({self._format_quality_label(actual_quality)})" - ) - else: - self._status_label.setText(t("download_complete")) + self._pending_redownload_mids.discard(song_mid) + del local_path + self._status_label.setText(t("download_complete")) def _on_redownload_failed(self, song_mid: str): """Handle re-download failure.""" - try: - dm = DownloadManager.instance() - dm.download_completed.disconnect(self._on_redownload_completed) - dm.download_failed.disconnect(self._on_redownload_failed) - except RuntimeError: + if song_mid not in self._pending_redownload_mids: return + self._pending_redownload_mids.discard(song_mid) self._status_label.setText(t("download_failed")) - @staticmethod - def _format_quality_label(quality: str) -> str: - """Return the translated label for a QQ Music quality code.""" - normalized = normalize_quality(quality) - label_key = get_quality_label_key(normalized) - return t(label_key) if label_key else normalized - def _on_history_remove_from_library(self, tracks: list): """Remove tracks from library.""" track_ids = [t.id for t in tracks if t.id] diff --git a/ui/views/local_tracks_list_view.py b/ui/views/local_tracks_list_view.py index 27164993..2f324df9 100644 --- a/ui/views/local_tracks_list_view.py +++ b/ui/views/local_tracks_list_view.py @@ -71,7 +71,7 @@ def _resolve_local_cover_path(track: Track) -> str | None: source = track.source cloud_file_id = track.cloud_file_id - is_online = source == TrackSource.QQ + is_online = source == TrackSource.ONLINE if is_online and cloud_file_id: try: @@ -83,6 +83,7 @@ def _resolve_local_cover_path(track: Track) -> str | None: album_mid=None, artist=track.artist, title=track.title, + provider_id=track.online_provider_id, ) if cover_path: return cover_path @@ -390,16 +391,13 @@ def paint(self, painter: QPainter, option: QStyleOptionViewItem, index: QModelIn # Source indicator (if enabled) if self._show_source: source_str = track.source.value if track.source else "Local" - try: - source = TrackSource(source_str) if source_str else TrackSource.LOCAL - except ValueError: - source = TrackSource.LOCAL + source = TrackSource.from_value(source_str) source_text = "" if source == TrackSource.LOCAL: source_text = t("source_local") - elif source == TrackSource.QQ: - source_text = t("source_qq") + elif source == TrackSource.ONLINE: + source_text = t("online_track") elif source == TrackSource.QUARK: source_text = t("source_quark") elif source == TrackSource.BAIDU: diff --git a/ui/views/queue_view.py b/ui/views/queue_view.py index 279f08c3..5a1cefc8 100644 --- a/ui/views/queue_view.py +++ b/ui/views/queue_view.py @@ -282,7 +282,8 @@ def _resolve_cover_path(track: dict) -> str | None: source = track.get("source", "") or track.get("source_type", "") cloud_file_id = track.get("cloud_file_id", "") - is_online = source == "QQ" or source == "online" + provider_id = track.get("online_provider_id") + is_online = source in ("online", "ONLINE") if is_online and cloud_file_id: try: @@ -294,6 +295,7 @@ def _resolve_cover_path(track: dict) -> str | None: album_mid=None, artist=track.get("artist", ""), title=track.get("title", ""), + provider_id=provider_id, ) if cover_path: return cover_path @@ -453,16 +455,13 @@ def paint(self, painter: QPainter, option: QStyleOptionViewItem, index: QModelIn # Source indicator from domain.track import TrackSource source_str = track.get("source", "Local") if isinstance(track, dict) else "Local" - try: - source = TrackSource(source_str) if source_str else TrackSource.LOCAL - except ValueError: - source = TrackSource.LOCAL + source = TrackSource.from_value(source_str) source_text = "" if source == TrackSource.LOCAL: source_text = t("source_local") - elif source == TrackSource.QQ: - source_text = t("source_qq") + elif source == TrackSource.ONLINE: + source_text = t("online_track") elif source == TrackSource.QUARK: source_text = t("source_quark") elif source == TrackSource.BAIDU: @@ -1523,10 +1522,9 @@ def add_tracks(self, track_ids: List[int]): for track in tracks: if track: from pathlib import Path - from domain.track import TrackSource # Include online tracks (empty path) and existing local files - is_online = not track.path or not track.path.strip() or track.source == TrackSource.QQ + is_online = not track.path or not track.path.strip() or track.is_online if is_online or Path(track.path).exists(): track_dict = { "id": track.id, @@ -1555,10 +1553,9 @@ def insert_tracks_after_current(self, track_ids: List[int]): for track in tracks: if track: from pathlib import Path - from domain.track import TrackSource # Include online tracks (empty path) and existing local files - is_online = not track.path or not track.path.strip() or track.source == TrackSource.QQ + is_online = not track.path or not track.path.strip() or track.is_online if is_online or Path(track.path).exists(): track_dict = { "id": track.id, diff --git a/ui/widgets/context_menus.py b/ui/widgets/context_menus.py index af78d7d7..49f6dd67 100644 --- a/ui/widgets/context_menus.py +++ b/ui/widgets/context_menus.py @@ -6,6 +6,7 @@ from PySide6.QtGui import QCursor from PySide6.QtWidgets import QMenu +from domain.track import TrackSource from system.i18n import t @@ -23,7 +24,22 @@ class LocalTrackContextMenu(QObject): open_file_location = Signal(object) remove_from_library = Signal(list) delete_file = Signal(list) - redownload = Signal(object) # Track (QQ Music re-download) + redownload = Signal(object) # Track (online re-download) + + @staticmethod + def _is_online_track(track) -> bool: + if track is None: + return False + source = getattr(track, "source", None) + if isinstance(source, TrackSource): + is_online = source == TrackSource.ONLINE + else: + is_online = TrackSource.from_value(str(source or "")) == TrackSource.ONLINE + return ( + is_online + and bool(getattr(track, "cloud_file_id", None)) + and bool(getattr(track, "online_provider_id", None)) + ) def build_menu(self, tracks: list, favorite_ids: set, parent_widget=None): """Build and return the context menu (without showing).""" @@ -66,10 +82,9 @@ def build_menu(self, tracks: list, favorite_ids: set, parent_widget=None): a = menu.addAction(t("download_cover_manual")) a.triggered.connect(lambda: self.download_cover.emit(tracks[0])) - # Re-download for QQ Music - # if tracks[0].source == TrackSource.QQ: - # a = menu.addAction(t("redownload")) - # a.triggered.connect(lambda: self.redownload.emit(tracks[0])) + if self._is_online_track(tracks[0]): + a = menu.addAction(t("redownload")) + a.triggered.connect(lambda: self.redownload.emit(tracks[0])) a = menu.addAction(t("organize_files")) a.triggered.connect(lambda: self.organize_files.emit(tracks)) diff --git a/ui/widgets/player_controls.py b/ui/widgets/player_controls.py index c65a642f..6261dac2 100644 --- a/ui/widgets/player_controls.py +++ b/ui/widgets/player_controls.py @@ -1321,13 +1321,14 @@ def load_cover(): logger.debug(self._format_log_message(f"Found cover_path in track_dict: {cover_path}")) return cover_path - # Check if this is an online QQ Music track + # Check if this is an online track source = track_dict.get("source", "") or track_dict.get("source_type", "") cloud_file_id = track_dict.get("cloud_file_id", "") - is_online = source == "QQ" or source == "online" + provider_id = track_dict.get("online_provider_id") + is_online = source in ("online", "ONLINE") if is_online and cloud_file_id: - # For online QQ Music tracks, get cover directly by song_mid + # For online tracks, get cover directly by provider-side track id logger.debug(self._format_log_message(f"Getting cover for online track: song_mid={cloud_file_id}")) try: cover_service = self._player.cover_service @@ -1336,7 +1337,8 @@ def load_cover(): song_mid=cloud_file_id, album_mid=None, # We don't have album_mid in track_dict yet artist=track_dict.get("artist", ""), - title=track_dict.get("title", "") + title=track_dict.get("title", ""), + provider_id=provider_id, ) if cover_path: return cover_path diff --git a/ui/widgets/recommend_card.py b/ui/widgets/recommend_card.py index d5de6321..6a411cb8 100644 --- a/ui/widgets/recommend_card.py +++ b/ui/widgets/recommend_card.py @@ -1,5 +1,5 @@ """ -Recommendation card widgets for QQ Music recommendations. +Recommendation card widgets for online recommendations. """ import logging diff --git a/ui/windows/components/lyrics_panel.py b/ui/windows/components/lyrics_panel.py index 427f288d..8f0ce3b8 100644 --- a/ui/windows/components/lyrics_panel.py +++ b/ui/windows/components/lyrics_panel.py @@ -229,7 +229,8 @@ def load_lyrics_async( title: str, artist: str, song_mid: str = None, - is_online: bool = False + is_online: bool = False, + provider_id: str | None = None, ): """ Load lyrics asynchronously with version control. @@ -238,8 +239,9 @@ def load_lyrics_async( path: Path to the audio file title: Track title artist: Track artist - song_mid: QQ Music song MID (for online tracks) - is_online: Whether this is an online QQ Music track + song_mid: Provider-side song id (for online tracks) + is_online: Whether this is an online track + provider_id: Online provider id """ # Increment version to invalidate pending results self._lyrics_load_version += 1 @@ -250,7 +252,12 @@ def load_lyrics_async( # Create new loader self._lyrics_thread = LyricsLoader( - path, title, artist, song_mid=song_mid, is_online=is_online + path, + title, + artist, + song_mid=song_mid, + is_online=is_online, + provider_id=provider_id, ) self._lyrics_thread._load_version = current_version @@ -455,7 +462,7 @@ def open_lyrics_file_location(self): source = current_track.get("source", "Local") # Check if this is a cloud/network track - is_cloud_track = source in ("QQ", "QUARK", "BAIDU") + is_cloud_track = source in ("ONLINE", "QUARK", "BAIDU") if not track_path: if is_cloud_track: @@ -515,10 +522,11 @@ def on_track_changed(self, track_item): title = track_item.title artist = track_item.artist song_mid = track_item.cloud_file_id - is_online = track_item.source == TrackSource.QQ + is_online = track_item.is_online + provider_id = track_item.online_provider_id if path: - self.load_lyrics_async(path, title, artist, song_mid, is_online) + self.load_lyrics_async(path, title, artist, song_mid, is_online, provider_id) else: self._panel.set_no_lyrics() elif isinstance(track_item, dict): diff --git a/ui/windows/components/online_music_handler.py b/ui/windows/components/online_music_handler.py index 8fc2756a..7338cd92 100644 --- a/ui/windows/components/online_music_handler.py +++ b/ui/windows/components/online_music_handler.py @@ -15,7 +15,7 @@ if TYPE_CHECKING: from services.playback import PlaybackService - from services.online import OnlineDownloadService + from services.download.online_download_gateway import OnlineDownloadGateway logger = logging.getLogger(__name__) @@ -47,18 +47,33 @@ def __init__( super().__init__(parent) self._playback = playback_service self._status_callback = status_callback - self._download_service: "OnlineDownloadService" = None + self._download_service: "OnlineDownloadGateway" = None - def set_download_service(self, service: "OnlineDownloadService"): + def set_download_service(self, service: "OnlineDownloadGateway"): """Set the download service for cache checking.""" self._download_service = service + @staticmethod + def _resolve_provider_id(provider_id: str | None, metadata: dict | None) -> str: + """Resolve online provider id from explicit argument or metadata.""" + if provider_id: + return provider_id + if metadata and metadata.get("provider_id"): + return str(metadata.get("provider_id")) + return "online" + def _show_status(self, message: str): """Show status message.""" if self._status_callback: self._status_callback(message) - def play_online_track(self, song_mid: str, local_path: str, metadata: dict = None): + def play_online_track( + self, + song_mid: str, + local_path: str, + metadata: dict = None, + provider_id: str | None = None, + ): """ Play a downloaded online track. @@ -76,10 +91,12 @@ def play_online_track(self, song_mid: str, local_path: str, metadata: dict = Non album = metadata.get("album", "") if metadata else "" duration = metadata.get("duration", 0.0) if metadata else 0.0 cover_url = metadata.get("cover_url", "") if metadata else "" + resolved_provider_id = self._resolve_provider_id(provider_id, metadata) # Create track record in database first from app.bootstrap import Bootstrap track_id = Bootstrap.instance().library_service.add_online_track( + provider_id=resolved_provider_id, song_mid=song_mid, title=title, artist=artist, @@ -90,7 +107,8 @@ def play_online_track(self, song_mid: str, local_path: str, metadata: dict = Non item = PlaylistItem( track_id=track_id, - source=TrackSource.QQ, + source=TrackSource.ONLINE, + online_provider_id=resolved_provider_id, local_path=local_path, title=title, artist=artist, @@ -103,7 +121,7 @@ def play_online_track(self, song_mid: str, local_path: str, metadata: dict = Non self._playback.engine.load_playlist_items([item]) self._playback.engine.play() - def add_to_queue(self, song_mid: str, metadata: dict): + def add_to_queue(self, song_mid: str, metadata: dict, provider_id: str | None = None): """ Add online track to the play queue. @@ -116,10 +134,12 @@ def add_to_queue(self, song_mid: str, metadata: dict): album = metadata.get("album", "") duration = metadata.get("duration", 0.0) cover_url = metadata.get("cover_url", "") + resolved_provider_id = self._resolve_provider_id(provider_id, metadata) # Create track record in database first from app.bootstrap import Bootstrap track_id = Bootstrap.instance().library_service.add_online_track( + provider_id=resolved_provider_id, song_mid=song_mid, title=title, artist=artist, @@ -131,13 +151,14 @@ def add_to_queue(self, song_mid: str, metadata: dict): local_path = "" needs_download = True - if self._download_service and self._download_service.is_cached(song_mid): - local_path = self._download_service.get_cached_path(song_mid) + if self._download_service and self._download_service.is_cached(song_mid, provider_id=resolved_provider_id): + local_path = self._download_service.get_cached_path(song_mid, provider_id=resolved_provider_id) needs_download = False item = PlaylistItem( track_id=track_id, - source=TrackSource.QQ, + source=TrackSource.ONLINE, + online_provider_id=resolved_provider_id, local_path=local_path, title=title, artist=artist, @@ -152,7 +173,7 @@ def add_to_queue(self, song_mid: str, metadata: dict): self._show_status(f"✓ {t('added_to_queue')}: {title}") - def insert_to_queue(self, song_mid: str, metadata: dict): + def insert_to_queue(self, song_mid: str, metadata: dict, provider_id: str | None = None): """ Insert online track after current playing track. @@ -165,10 +186,12 @@ def insert_to_queue(self, song_mid: str, metadata: dict): album = metadata.get("album", "") duration = metadata.get("duration", 0.0) cover_url = metadata.get("cover_url", "") + resolved_provider_id = self._resolve_provider_id(provider_id, metadata) # Create track record in database first from app.bootstrap import Bootstrap track_id = Bootstrap.instance().library_service.add_online_track( + provider_id=resolved_provider_id, song_mid=song_mid, title=title, artist=artist, @@ -180,13 +203,14 @@ def insert_to_queue(self, song_mid: str, metadata: dict): local_path = "" needs_download = True - if self._download_service and self._download_service.is_cached(song_mid): - local_path = self._download_service.get_cached_path(song_mid) + if self._download_service and self._download_service.is_cached(song_mid, provider_id=resolved_provider_id): + local_path = self._download_service.get_cached_path(song_mid, provider_id=resolved_provider_id) needs_download = False item = PlaylistItem( track_id=track_id, - source=TrackSource.QQ, + source=TrackSource.ONLINE, + online_provider_id=resolved_provider_id, local_path=local_path, title=title, artist=artist, @@ -203,7 +227,7 @@ def insert_to_queue(self, song_mid: str, metadata: dict): self._playback._schedule_save_queue() self._show_status(f"✓ {t('insert_to_queue')}: {title}") - def add_multiple_to_queue(self, tracks_data: List[Tuple[str, dict]]): + def add_multiple_to_queue(self, tracks_data: List[Tuple[str, dict]], provider_id: str | None = None): """ Add multiple online tracks to the queue. @@ -216,10 +240,12 @@ def add_multiple_to_queue(self, tracks_data: List[Tuple[str, dict]]): album = metadata.get("album", "") duration = metadata.get("duration", 0.0) cover_url = metadata.get("cover_url", "") + resolved_provider_id = self._resolve_provider_id(provider_id, metadata) # Create track record in database first from app.bootstrap import Bootstrap track_id = Bootstrap.instance().library_service.add_online_track( + provider_id=resolved_provider_id, song_mid=song_mid, title=title, artist=artist, @@ -231,13 +257,14 @@ def add_multiple_to_queue(self, tracks_data: List[Tuple[str, dict]]): local_path = "" needs_download = True - if self._download_service and self._download_service.is_cached(song_mid): - local_path = self._download_service.get_cached_path(song_mid) + if self._download_service and self._download_service.is_cached(song_mid, provider_id=resolved_provider_id): + local_path = self._download_service.get_cached_path(song_mid, provider_id=resolved_provider_id) needs_download = False item = PlaylistItem( track_id=track_id, - source=TrackSource.QQ, + source=TrackSource.ONLINE, + online_provider_id=resolved_provider_id, local_path=local_path, title=title, artist=artist, @@ -256,7 +283,7 @@ def add_multiple_to_queue(self, tracks_data: List[Tuple[str, dict]]): msg = t("added_to_queue").replace("{count}", str(count)).replace("{s}", s) self._show_status(msg) - def insert_multiple_to_queue(self, tracks_data: List[Tuple[str, dict]]): + def insert_multiple_to_queue(self, tracks_data: List[Tuple[str, dict]], provider_id: str | None = None): """ Insert multiple online tracks after current playing track. @@ -272,10 +299,12 @@ def insert_multiple_to_queue(self, tracks_data: List[Tuple[str, dict]]): album = metadata.get("album", "") duration = metadata.get("duration", 0.0) cover_url = metadata.get("cover_url", "") + resolved_provider_id = self._resolve_provider_id(provider_id, metadata) # Create track record in database first from app.bootstrap import Bootstrap track_id = Bootstrap.instance().library_service.add_online_track( + provider_id=resolved_provider_id, song_mid=song_mid, title=title, artist=artist, @@ -287,13 +316,14 @@ def insert_multiple_to_queue(self, tracks_data: List[Tuple[str, dict]]): local_path = "" needs_download = True - if self._download_service and self._download_service.is_cached(song_mid): - local_path = self._download_service.get_cached_path(song_mid) + if self._download_service and self._download_service.is_cached(song_mid, provider_id=resolved_provider_id): + local_path = self._download_service.get_cached_path(song_mid, provider_id=resolved_provider_id) needs_download = False item = PlaylistItem( track_id=track_id, - source=TrackSource.QQ, + source=TrackSource.ONLINE, + online_provider_id=resolved_provider_id, local_path=local_path, title=title, artist=artist, @@ -310,7 +340,12 @@ def insert_multiple_to_queue(self, tracks_data: List[Tuple[str, dict]]): count = len(tracks_data) self._show_status(f"✓ {t('insert_to_queue')}: {count}") - def play_online_tracks(self, start_index: int, tracks_data: List[Tuple[str, dict]]): + def play_online_tracks( + self, + start_index: int, + tracks_data: List[Tuple[str, dict]], + provider_id: str | None = None, + ): """ Play a list of online tracks starting from a specific index. @@ -326,10 +361,12 @@ def play_online_tracks(self, start_index: int, tracks_data: List[Tuple[str, dict album = metadata.get("album", "") duration = metadata.get("duration", 0.0) cover_url = metadata.get("cover_url", "") + resolved_provider_id = self._resolve_provider_id(provider_id, metadata) # Create track record in database first from app.bootstrap import Bootstrap track_id = Bootstrap.instance().library_service.add_online_track( + provider_id=resolved_provider_id, song_mid=song_mid, title=title, artist=artist, @@ -341,13 +378,14 @@ def play_online_tracks(self, start_index: int, tracks_data: List[Tuple[str, dict local_path = "" needs_download = True - if self._download_service and self._download_service.is_cached(song_mid): - local_path = self._download_service.get_cached_path(song_mid) + if self._download_service and self._download_service.is_cached(song_mid, provider_id=resolved_provider_id): + local_path = self._download_service.get_cached_path(song_mid, provider_id=resolved_provider_id) needs_download = False item = PlaylistItem( track_id=track_id, - source=TrackSource.QQ, + source=TrackSource.ONLINE, + online_provider_id=resolved_provider_id, local_path=local_path, title=title, artist=artist, diff --git a/ui/windows/main_window.py b/ui/windows/main_window.py index 8dbfc7f8..570ddb32 100644 --- a/ui/windows/main_window.py +++ b/ui/windows/main_window.py @@ -268,9 +268,15 @@ def cover_service(self): def get_track_cover(self, track_path: str, title: str, artist: str, album: str = "", source: str = "", cloud_file_id: str = "", + online_provider_id: str = "", skip_online: bool = False): - if source == TrackSource.QQ.name and cloud_file_id: - return playback.get_online_track_cover(source, cloud_file_id, artist, title) + if source == TrackSource.ONLINE.name and cloud_file_id: + return playback.get_online_track_cover( + provider_id=online_provider_id, + cloud_file_id=cloud_file_id, + artist=artist, + title=title, + ) return playback.get_track_cover(track_path, title, artist, album, skip_online=skip_online) def save_cover_from_metadata(self, track_path: str, cover_data: bytes): @@ -292,6 +298,7 @@ def save_cover_from_metadata(self, track_path: str, cover_data: bytes): # Online music handler (will be initialized in _setup_ui) self._online_music_handler: Optional[OnlineMusicHandler] = None + self._pending_redownload_mids: set[str] = set() # Scan controller reference (prevent GC) self._scan_controller = None @@ -602,6 +609,10 @@ def _setup_connections(self): self._event_bus.position_changed.connect(self._on_position_changed) self._event_bus.playback_state_changed.connect(self._on_playback_state_changed) self._playback.engine.current_track_pending.connect(self._on_pending_track_changed) + from services.download.download_manager import DownloadManager + manager = DownloadManager.instance() + manager.download_completed.connect(self._on_playlist_redownload_completed) + manager.download_failed.connect(self._on_playlist_redownload_failed) # Cloud download events self._event_bus.download_completed.connect(self._on_cloud_download_completed) @@ -1193,7 +1204,7 @@ def _play_tracks(self, tracks, start_index=0): for track in tracks: if track.id and track.id > 0: # Include online tracks (empty path) and existing local files - is_online = not track.path or not track.path.strip() or track.source == TrackSource.QQ + is_online = not track.path or not track.path.strip() or track.is_online if is_online or Path(track.path).exists(): items.append(PlaylistItem.from_track(track)) @@ -1354,96 +1365,62 @@ def _on_playlist_download_cover(self, track): dialog.exec() def _on_playlist_redownload(self, track): - """Re-download a QQ Music track from playlist.""" - from ui.dialogs.redownload_dialog import RedownloadDialog - from app.bootstrap import Bootstrap - from services.download.download_manager import DownloadManager + """Request plugin-driven re-download for playlist/genre online track.""" + if not track or not getattr(track, "is_online", False): + self._status_label.setText(t("not_supported_yet")) + return + song_mid = str(getattr(track, "cloud_file_id", "") or "").strip() + provider_id = str(getattr(track, "online_provider_id", "") or "").strip() + if not song_mid or not provider_id: + self._status_label.setText(t("not_supported_yet")) + return + + from app.bootstrap import Bootstrap bootstrap = Bootstrap.instance() - song_mid = track.cloud_file_id - default_quality = ( - bootstrap.config.get_plugin_setting("qqmusic", "quality", "320") - if bootstrap and bootstrap.config - else "320" - ) - quality = RedownloadDialog.show_dialog( - track.title, - current_quality=default_quality, + service = getattr(bootstrap, "online_download_service", None) + if not service: + self._status_label.setText(t("not_supported_yet")) + return + + from ui.dialogs.redownload_dialog import RedownloadDialog + quality_options = service.get_download_qualities(song_mid, provider_id=provider_id) + selected_quality = RedownloadDialog.show_dialog( + getattr(track, "title", "") or song_mid, + quality_options=quality_options, parent=self, ) - if quality is None: + if not selected_quality: return - online_download_service = bootstrap.online_download_service - online_download_service.delete_cached_file(song_mid) - - import os - if track.path and os.path.exists(track.path): - with suppress(OSError): - os.remove(track.path) - - dm = DownloadManager.instance() - dm.download_completed.connect(self._on_playlist_redownload_completed) - dm.download_failed.connect(self._on_playlist_redownload_failed) - dm.redownload_online_track( + from services.download.download_manager import DownloadManager + started = DownloadManager.instance().redownload_online_track( song_mid=song_mid, - title=track.title, - quality=quality, - force=True, - ) - self._status_label.setText( - f"{t('downloading')}... {track.title} ({self._format_quality_label(quality)})" + title=getattr(track, "title", "") or "", + provider_id=provider_id, + quality=selected_quality, ) + if started: + self._pending_redownload_mids.add(song_mid) + self._status_label.setText(t("redownload")) + else: + self._status_label.setText(t("download_failed")) def _on_playlist_redownload_completed(self, song_mid: str, local_path: str): """Handle playlist re-download completion.""" - from app.bootstrap import Bootstrap - from services.download.download_manager import DownloadManager - - try: - dm = DownloadManager.instance() - dm.download_completed.disconnect(self._on_playlist_redownload_completed) - dm.download_failed.disconnect(self._on_playlist_redownload_failed) - except RuntimeError: - return - - if not local_path: + if song_mid not in self._pending_redownload_mids: return - - bootstrap = Bootstrap.instance() - actual_quality = None - if bootstrap and bootstrap.online_download_service: - actual_quality = bootstrap.online_download_service.pop_last_download_quality(song_mid) - - if actual_quality: - self._status_label.setText( - f"{t('download_complete')} ({self._format_quality_label(actual_quality)})" - ) - else: - self._status_label.setText(t("download_complete")) + self._pending_redownload_mids.discard(song_mid) + del local_path + self._status_label.setText(t("download_complete")) def _on_playlist_redownload_failed(self, song_mid: str): """Handle playlist re-download failure.""" - del song_mid - from services.download.download_manager import DownloadManager - - try: - dm = DownloadManager.instance() - dm.download_completed.disconnect(self._on_playlist_redownload_completed) - dm.download_failed.disconnect(self._on_playlist_redownload_failed) - except RuntimeError: + if song_mid not in self._pending_redownload_mids: return + self._pending_redownload_mids.discard(song_mid) self._status_label.setText(t("download_failed")) - @staticmethod - def _format_quality_label(quality: str) -> str: - """Return the translated label for a QQ Music quality code.""" - from services.online.quality import get_quality_label_key, normalize_quality - - normalized = normalize_quality(quality) - label_key = get_quality_label_key(normalized) - return t(label_key) if label_key else normalized - def _play_cloud_favorite(self, cloud_file_id: str, account_id: int): """Play a cloud file from favorites.""" @@ -2454,6 +2431,12 @@ def closeEvent(self, event): self._event_bus.playback_state_changed.disconnect(self._on_playback_state_changed) with suppress(RuntimeError): self._event_bus.download_completed.disconnect(self._on_cloud_download_completed) + from services.download.download_manager import DownloadManager + manager = DownloadManager.instance() + with suppress(RuntimeError): + manager.download_completed.disconnect(self._on_playlist_redownload_completed) + with suppress(RuntimeError): + manager.download_failed.disconnect(self._on_playlist_redownload_failed) # Close database self._db.close() diff --git a/ui/windows/mini_player.py b/ui/windows/mini_player.py index 05f2ac7c..1866e522 100644 --- a/ui/windows/mini_player.py +++ b/ui/windows/mini_player.py @@ -569,14 +569,15 @@ def load_cover(): artist = track_dict.get("artist", "") album = track_dict.get("album", "") - # Check if this is an online QQ Music track + # Check if this is an online track source = track_dict.get("source", "") cloud_file_id = track_dict.get("cloud_file_id", "") - is_qq_music = source == "QQ" + provider_id = track_dict.get("online_provider_id") + is_online_track = source in ("online", "ONLINE") - if is_qq_music and cloud_file_id: - # For online QQ Music tracks, get cover directly by song_mid - logger.debug(f"[MiniPlayer] Getting cover for QQ Music track: song_mid={cloud_file_id}") + if is_online_track and cloud_file_id: + # For online tracks, resolve cover by provider track id + logger.debug(f"[MiniPlayer] Getting cover for online track: song_mid={cloud_file_id}") try: cover_service = self._player.cover_service if cover_service: @@ -584,7 +585,8 @@ def load_cover(): song_mid=cloud_file_id, album_mid=None, # We don't have album_mid in track_dict yet artist=track_dict.get("artist", ""), - title=track_dict.get("title", "") + title=track_dict.get("title", ""), + provider_id=provider_id, ) if cover_path: logger.debug(f"[MiniPlayer] Got online cover: {cover_path}") @@ -673,16 +675,18 @@ def _load_lyrics_async(self, track_dict: dict): title = track_dict.get("title", "") artist = track_dict.get("artist", "") - # Check if this is an online QQ Music track with song_mid + # Check if this is an online track with provider-side track id source = track_dict.get("source", "") cloud_file_id = track_dict.get("cloud_file_id", "") - is_online = source == "QQ" + provider_id = track_dict.get("online_provider_id") + is_online = source in ("online", "ONLINE") # Create lyrics loader self._lyrics_thread = LyricsLoader( path, title, artist, song_mid=cloud_file_id, - is_online=is_online + is_online=is_online, + provider_id=provider_id, ) self._lyrics_thread.lyrics_ready.connect(self._on_lyrics_ready) self._lyrics_thread.finished.connect(self._on_lyrics_thread_finished) diff --git a/ui/windows/now_playing_window.py b/ui/windows/now_playing_window.py index cf5b6e63..ad0d83e4 100644 --- a/ui/windows/now_playing_window.py +++ b/ui/windows/now_playing_window.py @@ -697,7 +697,8 @@ def load_cover() -> str: source = track_dict.get("source", "") or track_dict.get("source_type", "") cloud_file_id = track_dict.get("cloud_file_id", "") - is_online = source in ("QQ", "online") + provider_id = track_dict.get("online_provider_id") + is_online = source in ("online", "ONLINE") if is_online and cloud_file_id and self._playback.cover_service: try: online_cover = self._playback.cover_service.get_online_cover( @@ -705,6 +706,7 @@ def load_cover() -> str: album_mid=None, artist=track_dict.get("artist", ""), title=track_dict.get("title", ""), + provider_id=provider_id, ) if online_cover: return online_cover @@ -860,7 +862,8 @@ def _load_lyrics_async(self, track_dict: dict): artist = track_dict.get("artist", "") source = track_dict.get("source", "") or track_dict.get("source_type", "") cloud_file_id = track_dict.get("cloud_file_id", "") - is_online = source in ("QQ", "online") + provider_id = track_dict.get("online_provider_id") + is_online = source in ("online", "ONLINE") self._lyrics_thread = LyricsLoader( path, @@ -868,6 +871,7 @@ def _load_lyrics_async(self, track_dict: dict): artist, song_mid=cloud_file_id, is_online=is_online, + provider_id=provider_id, ) self._lyrics_thread.lyrics_ready.connect(self._on_lyrics_ready) self._lyrics_thread.finished.connect(self._on_lyrics_thread_finished) diff --git a/ui/workers/batch_cover_worker.py b/ui/workers/batch_cover_worker.py index 591d1a27..ce136c04 100644 --- a/ui/workers/batch_cover_worker.py +++ b/ui/workers/batch_cover_worker.py @@ -73,10 +73,10 @@ def _fetch_artist_cover(self, artist_name: str): source = best.get('source', '') singer_mid = best.get('singer_mid') - # QQ Music: cover_url may be empty, construct from singer_mid - if not cover_url and source == 'qqmusic' and singer_mid: - from system.plugins.qqmusic_cover_helpers import get_qqmusic_artist_cover_url - cover_url = get_qqmusic_artist_cover_url(singer_mid, size=500) + # Provider may omit cover_url; fall back to provider-specific artist id fetch. + if not cover_url and source and singer_mid: + from system.plugins.online_cover_helpers import get_online_artist_cover_url + cover_url = get_online_artist_cover_url(provider_id=source, artist_id=singer_mid, size=500) if not cover_url: return None From 72394c8b9e1db383fbfd2d0d1e4a76d8bb499e7f Mon Sep 17 00:00:00 2001 From: Har01d Date: Wed, 8 Apr 2026 18:21:07 +0800 Subject: [PATCH 090/157] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E6=8F=92=E4=BB=B6?= =?UTF-8?q?=E4=B8=BB=E9=A2=98=E6=A1=A5=E5=88=9D=E5=A7=8B=E5=8C=96=E5=85=9C?= =?UTF-8?q?=E5=BA=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- system/plugins/plugin_sdk_ui.py | 43 ++++++++++++++++++++++----------- 1 file changed, 29 insertions(+), 14 deletions(-) diff --git a/system/plugins/plugin_sdk_ui.py b/system/plugins/plugin_sdk_ui.py index 169599cd..2223b3cb 100644 --- a/system/plugins/plugin_sdk_ui.py +++ b/system/plugins/plugin_sdk_ui.py @@ -1,31 +1,46 @@ from __future__ import annotations +def _get_theme_manager(): + from system.theme import ThemeManager + + try: + return ThemeManager.instance() + except ValueError: + return None + + class PluginThemeBridgeImpl: def register_widget(self, widget) -> None: - from system.theme import ThemeManager - - ThemeManager.instance().register_widget(widget) + manager = _get_theme_manager() + if manager is not None: + manager.register_widget(widget) def get_qss(self, template: str) -> str: - from system.theme import ThemeManager - - return ThemeManager.instance().get_qss(template) + manager = _get_theme_manager() + if manager is None: + return template + return manager.get_qss(template) def current_theme(self): - from system.theme import ThemeManager + from system.theme import PRESET_THEMES - return ThemeManager.instance().current_theme + manager = _get_theme_manager() + if manager is None: + return PRESET_THEMES["dark"] + return manager.current_theme def get_popup_surface_style(self) -> str: - from system.theme import ThemeManager - - return ThemeManager.instance().get_themed_popup_surface_style() + manager = _get_theme_manager() + if manager is None: + return "" + return manager.get_themed_popup_surface_style() def get_completer_popup_style(self) -> str: - from system.theme import ThemeManager - - return ThemeManager.instance().get_themed_completer_popup_style() + manager = _get_theme_manager() + if manager is None: + return "" + return manager.get_themed_completer_popup_style() class PluginDialogBridgeImpl: From 8a04dbe83b4a20853e1dc4a40d24d9bdb9abf33d Mon Sep 17 00:00:00 2001 From: Har01d Date: Wed, 8 Apr 2026 18:22:11 +0800 Subject: [PATCH 091/157] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E6=8F=92=E4=BB=B6?= =?UTF-8?q?=E7=8A=B6=E6=80=81=E5=AD=98=E5=82=A8=E5=B9=B6=E5=8F=91=E5=86=99?= =?UTF-8?q?=E5=85=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- system/plugins/state_store.py | 22 +++++++++++++--------- 1 file changed, 13 insertions(+), 9 deletions(-) diff --git a/system/plugins/state_store.py b/system/plugins/state_store.py index 5b1e4970..17276913 100644 --- a/system/plugins/state_store.py +++ b/system/plugins/state_store.py @@ -3,11 +3,13 @@ import json import os from pathlib import Path +from threading import Lock class PluginStateStore: def __init__(self, path: Path) -> None: self._path = path + self._lock = Lock() self._path.parent.mkdir(parents=True, exist_ok=True) def _read(self) -> dict: @@ -35,14 +37,16 @@ def set_enabled( version: str, load_error: str | None = None, ) -> None: - payload = self._read() - payload[plugin_id] = { - "enabled": enabled, - "source": source, - "version": version, - "load_error": load_error, - } - self._write(payload) + with self._lock: + payload = self._read() + payload[plugin_id] = { + "enabled": enabled, + "source": source, + "version": version, + "load_error": load_error, + } + self._write(payload) def get(self, plugin_id: str) -> dict | None: - return self._read().get(plugin_id) + with self._lock: + return self._read().get(plugin_id) From 671db79b7c0ce6567d6b6e34621101defa548ae6 Mon Sep 17 00:00:00 2001 From: Har01d Date: Wed, 8 Apr 2026 18:26:04 +0800 Subject: [PATCH 092/157] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E6=96=87=E4=BB=B6?= =?UTF-8?q?=E6=95=B4=E7=90=86=E5=9B=9E=E6=BB=9A=E9=94=99=E8=AF=AF=E4=B8=8A?= =?UTF-8?q?=E6=8A=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- services/library/file_organization_service.py | 11 +++- .../test_file_organization_rollback_error.py | 61 +++++++++++++++++++ 2 files changed, 69 insertions(+), 3 deletions(-) create mode 100644 tests/test_services/test_file_organization_rollback_error.py diff --git a/services/library/file_organization_service.py b/services/library/file_organization_service.py index 21d6ff1e..e64e5d1b 100644 --- a/services/library/file_organization_service.py +++ b/services/library/file_organization_service.py @@ -164,14 +164,19 @@ def organize_tracks(self, track_ids: List[int], target_dir: str) -> Dict: track.path = str(final_audio_path) if not self._track_repo.update(track): # 回滚文件移动 + rollback_failed = False try: shutil.move(str(final_audio_path), str(old_audio_path)) for old_path, new_path in moved_lyrics: shutil.move(str(new_path), str(old_path)) - except Exception: - pass + except Exception as exc: + rollback_failed = True + logger.error(f"文件回滚失败: {exc}", exc_info=True) results['failed'] += 1 - results['errors'].append(f"{track.title}: 数据库更新失败") + message = f"{track.title}: 数据库更新失败" + if rollback_failed: + message += "(文件回滚失败)" + results['errors'].append(message) continue # 更新 play_queue 和 cloud_files 中的路径 diff --git a/tests/test_services/test_file_organization_rollback_error.py b/tests/test_services/test_file_organization_rollback_error.py new file mode 100644 index 00000000..4b09f9fe --- /dev/null +++ b/tests/test_services/test_file_organization_rollback_error.py @@ -0,0 +1,61 @@ +from pathlib import Path +from types import SimpleNamespace +from unittest.mock import Mock + +from services.library.file_organization_service import FileOrganizationService + + +def test_organize_tracks_reports_rollback_failure(monkeypatch, tmp_path): + source_audio = tmp_path / "source.mp3" + source_lyrics = tmp_path / "source.lrc" + source_audio.write_bytes(b"audio") + source_lyrics.write_text("lyrics", encoding="utf-8") + target_dir = tmp_path / "organized" + target_dir.mkdir() + + track = SimpleNamespace( + id=1, + title="Song", + artist="Artist", + album="Album", + path=str(source_audio), + cloud_file_id=None, + ) + track_repo = Mock() + track_repo.get_by_ids.return_value = [track] + track_repo.update.return_value = False + event_bus = SimpleNamespace(tracks_organized=SimpleNamespace(emit=Mock())) + + service = FileOrganizationService( + track_repo=track_repo, + cloud_repo=Mock(), + event_bus=event_bus, + queue_repo=Mock(), + ) + + target_audio = target_dir / "Song.mp3" + target_lyrics = target_dir / "Song.lrc" + + monkeypatch.setattr( + "services.library.file_organization_service.calculate_target_path", + lambda _track, _target_dir: (target_audio, target_lyrics), + ) + monkeypatch.setattr( + "services.library.file_organization_service.ensure_directory", + lambda _path: True, + ) + + move_calls = {"count": 0} + + def fake_move(_src: str, _dst: str): + move_calls["count"] += 1 + if move_calls["count"] <= 2: + return None + raise OSError("rollback failed") + + monkeypatch.setattr("services.library.file_organization_service.shutil.move", fake_move) + + results = service.organize_tracks([1], str(target_dir)) + + assert results["failed"] == 1 + assert any("文件回滚失败" in error for error in results["errors"]) From ffe38a9273ad2538dae45d110345449e7dcf75cc Mon Sep 17 00:00:00 2001 From: Har01d Date: Wed, 8 Apr 2026 18:33:20 +0800 Subject: [PATCH 093/157] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E9=85=B7=E7=8B=97?= =?UTF-8?q?=E6=AD=8C=E8=AF=8D=E5=80=99=E9=80=89=E7=BC=BA=E5=AD=97=E6=AE=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- plugins/builtin/kugou/lib/lyrics_source.py | 36 ++++++++++--------- .../test_kugou_plugin_missing_id.py | 26 ++++++++++++++ 2 files changed, 46 insertions(+), 16 deletions(-) create mode 100644 tests/test_plugins/test_kugou_plugin_missing_id.py diff --git a/plugins/builtin/kugou/lib/lyrics_source.py b/plugins/builtin/kugou/lib/lyrics_source.py index 2d9a2f12..adbe4e47 100644 --- a/plugins/builtin/kugou/lib/lyrics_source.py +++ b/plugins/builtin/kugou/lib/lyrics_source.py @@ -20,23 +20,27 @@ def __init__(self, http_client) -> None: def search(self, title: str, artist: str, limit: int = 10) -> list[PluginLyricsResult]: keyword = f"{title} {artist}".strip() logger.debug(f"Kugou lyrics search: {keyword}") - response = self._http_client.get( - "https://lyrics.kugou.com/search", - params={"keyword": keyword, "page": 1, "pagesize": limit}, - headers={"User-Agent": "Mozilla/5.0"}, - timeout=3, - ) - payload = response.json() - return [ - PluginLyricsResult( - song_id=str(item["id"]), - title=item.get("name", item.get("song", "")), - artist=item.get("singer", ""), - source="kugou", - accesskey=item.get("accesskey", ""), + try: + response = self._http_client.get( + "https://lyrics.kugou.com/search", + params={"keyword": keyword, "page": 1, "pagesize": limit}, + headers={"User-Agent": "Mozilla/5.0"}, + timeout=3, ) - for item in payload.get("candidates", []) - ] + payload = response.json() + return [ + PluginLyricsResult( + song_id=str(item.get("id", "")), + title=item.get("name", item.get("song", "")), + artist=item.get("singer", ""), + source="kugou", + accesskey=item.get("accesskey", ""), + ) + for item in payload.get("candidates", []) + ] + except Exception: + logger.exception("Error searching Kugou lyrics") + return [] def get_lyrics(self, result: PluginLyricsResult) -> str | None: try: diff --git a/tests/test_plugins/test_kugou_plugin_missing_id.py b/tests/test_plugins/test_kugou_plugin_missing_id.py new file mode 100644 index 00000000..f5ae06d3 --- /dev/null +++ b/tests/test_plugins/test_kugou_plugin_missing_id.py @@ -0,0 +1,26 @@ +from types import SimpleNamespace + +from plugins.builtin.kugou.lib.lyrics_source import KugouLyricsPluginSource + + +def test_kugou_search_handles_candidates_without_id(): + fake_response = SimpleNamespace( + json=lambda: { + "candidates": [ + { + "name": "Song 1", + "singer": "Singer 1", + "accesskey": "k1", + } + ] + } + ) + source = KugouLyricsPluginSource( + SimpleNamespace(get=lambda *_args, **_kwargs: fake_response) + ) + + results = source.search("Song 1", "Singer 1") + + assert len(results) == 1 + assert results[0].song_id == "" + assert results[0].title == "Song 1" From a2dee278cc7df804d69924766bf23cc42c6914c9 Mon Sep 17 00:00:00 2001 From: Har01d Date: Wed, 8 Apr 2026 18:33:52 +0800 Subject: [PATCH 094/157] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E7=BD=91=E6=98=93?= =?UTF-8?q?=E6=AD=8C=E8=AF=8D=E7=BC=BA=E5=A4=B1=E5=AD=97=E6=AE=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../netease_lyrics/lib/lyrics_source.py | 6 ++-- ...st_netease_lyrics_plugin_missing_fields.py | 30 +++++++++++++++++++ 2 files changed, 34 insertions(+), 2 deletions(-) create mode 100644 tests/test_plugins/test_netease_lyrics_plugin_missing_fields.py diff --git a/plugins/builtin/netease_lyrics/lib/lyrics_source.py b/plugins/builtin/netease_lyrics/lib/lyrics_source.py index 36cd988f..fa176303 100644 --- a/plugins/builtin/netease_lyrics/lib/lyrics_source.py +++ b/plugins/builtin/netease_lyrics/lib/lyrics_source.py @@ -42,12 +42,14 @@ def search( if not cover_url and album.get("pic"): pic = str(album.get("pic")) cover_url = f"https://p1.music.126.net/{pic}/{pic}.jpg" + artists = song.get("artists") or [] + artist_name = artists[0].get("name", "") if artists else "" results.append( PluginLyricsResult( - song_id=str(song["id"]), + song_id=str(song.get("id", "")), title=song.get("name", ""), - artist=song["artists"][0]["name"] if song.get("artists") else "", + artist=artist_name, album=album.get("name", ""), duration=(song.get("duration") / 1000) if song.get("duration") else None, source="netease", diff --git a/tests/test_plugins/test_netease_lyrics_plugin_missing_fields.py b/tests/test_plugins/test_netease_lyrics_plugin_missing_fields.py new file mode 100644 index 00000000..414c3efd --- /dev/null +++ b/tests/test_plugins/test_netease_lyrics_plugin_missing_fields.py @@ -0,0 +1,30 @@ +from types import SimpleNamespace + +from plugins.builtin.netease_lyrics.lib.lyrics_source import NetEaseLyricsPluginSource + + +def test_netease_lyrics_search_handles_missing_id_and_empty_artists(): + response = SimpleNamespace( + status_code=200, + json=lambda: { + "code": 200, + "result": { + "songs": [ + { + "name": "Song 1", + "artists": [], + "album": {"name": "Album 1"}, + } + ] + }, + }, + ) + source = NetEaseLyricsPluginSource( + SimpleNamespace(get=lambda *_args, **_kwargs: response) + ) + + results = source.search("Song 1", "Singer 1") + + assert len(results) == 1 + assert results[0].song_id == "" + assert results[0].artist == "" From 55534c756b209348191be33f722dda255b9bc0f9 Mon Sep 17 00:00:00 2001 From: Har01d Date: Wed, 8 Apr 2026 18:39:39 +0800 Subject: [PATCH 095/157] =?UTF-8?q?=E6=B7=BB=E5=8A=A0QQ=E9=9F=B3=E4=B9=90P?= =?UTF-8?q?rovider=E6=94=B6=E5=8F=A3=E8=AE=BE=E8=AE=A1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...-08-qqmusic-provider-unification-design.md | 222 ++++++++++++++++++ 1 file changed, 222 insertions(+) create mode 100644 docs/superpowers/specs/2026-04-08-qqmusic-provider-unification-design.md diff --git a/docs/superpowers/specs/2026-04-08-qqmusic-provider-unification-design.md b/docs/superpowers/specs/2026-04-08-qqmusic-provider-unification-design.md new file mode 100644 index 00000000..0198d240 --- /dev/null +++ b/docs/superpowers/specs/2026-04-08-qqmusic-provider-unification-design.md @@ -0,0 +1,222 @@ +# QQMusic Provider Unification Design + +## Overview + +This change keeps the built-in QQ Music lyrics and cover plugin sources registered through the host plugin system, but moves their runtime data access behind `QQMusicOnlineProvider`. + +The goal is to stop `QQMusicLyricsPluginSource` and `QQMusicCoverPluginSource` from calling `QQMusicPluginAPI` directly. Instead, the provider becomes the single plugin-owned entry point for QQ Music search, lyrics lookup, and cover URL resolution. + +This preserves current host-facing source contracts while restoring the intended priority: + +- prefer local QQ Music client behavior when available +- keep remote API fallback when local client data is unavailable + +## Goals + +- Make `QQMusicLyricsPluginSource` use `QQMusicOnlineProvider` instead of `QQMusicPluginAPI`. +- Make `QQMusicCoverPluginSource` use `QQMusicOnlineProvider` instead of `QQMusicPluginAPI`. +- Extend `QQMusicOnlineProvider` with the thin lyrics and cover methods needed by those sources. +- Preserve current plugin registration and host helper contracts. +- Preserve current result source identity as `qqmusic`. +- Prefer local QQ Music client behavior before remote fallback for lyrics and cover resolution. + +## Non-Goals + +- No change to plugin manifest structure or plugin registration shape. +- No change to `QQMusicArtistCoverPluginSource` in this iteration. +- No change to host service orchestration in `LyricsService` or `CoverService`. +- No broad refactor of `QQMusicPluginClient`, `QQMusicService`, or the legacy online page. +- No attempt to remove remote API fallback entirely. + +## Current State + +QQ Music is already partially unified around plugin-owned provider code: + +- [`plugins/builtin/qqmusic/lib/provider.py`](/home/harold/workspace/music-player/plugins/builtin/qqmusic/lib/provider.py) exposes `QQMusicOnlineProvider` +- [`plugins/builtin/qqmusic/lib/client.py`](/home/harold/workspace/music-player/plugins/builtin/qqmusic/lib/client.py) already prefers local QQ Music service search before remote API fallback + +But the lyrics and cover source adapters still bypass that provider: + +- [`plugins/builtin/qqmusic/lib/lyrics_source.py`](/home/harold/workspace/music-player/plugins/builtin/qqmusic/lib/lyrics_source.py) instantiates `QQMusicPluginAPI` directly +- [`plugins/builtin/qqmusic/lib/cover_source.py`](/home/harold/workspace/music-player/plugins/builtin/qqmusic/lib/cover_source.py) instantiates `QQMusicPluginAPI` directly + +As a result, plugin source requests for lyrics and album artwork do not follow the same local-first behavior already used by the online provider search path. + +## Recommended Approach + +Keep the source classes as host-visible adapters and move the data access boundary inward: + +- `QQMusicLyricsPluginSource` becomes a mapping layer over `QQMusicOnlineProvider` +- `QQMusicCoverPluginSource` becomes a mapping layer over `QQMusicOnlineProvider` +- `QQMusicOnlineProvider` becomes the only plugin-owned entry point that source adapters call for QQ Music runtime data + +This is intentionally narrower than moving host service contracts to the provider directly. The host still discovers and invokes lyrics and cover sources the same way it does now. Only the plugin-internal dependency direction changes. + +## Architecture + +### Ownership Boundary + +After this change: + +- host code still registers and calls lyrics and cover sources through plugin service registries +- source objects still expose the same methods expected by host services and online cover helpers +- the provider owns QQ Music runtime lookup decisions inside the plugin + +The new dependency direction is: + +`QQMusicLyricsPluginSource` -> `QQMusicOnlineProvider` + +`QQMusicCoverPluginSource` -> `QQMusicOnlineProvider` + +`QQMusicOnlineProvider` -> `QQMusicPluginClient` + +`QQMusicPluginClient` -> local QQ Music service first where supported, then remote API fallback + +### Why Not Use `QQMusicPluginClient` Directly + +Direct source-to-client wiring would also fix the local-first issue, but it would keep QQ Music plugin entry points split across multiple internal abstractions. Using the provider keeps one plugin-owned façade for: + +- online page behavior +- search +- playback URL lookup +- lyrics lookup +- cover URL resolution + +That gives the plugin one consistent surface for future QQ Music integration work. + +## Runtime Behavior + +### Lyrics Source Flow + +`QQMusicLyricsPluginSource.search()` should: + +- build the same search keyword it uses today +- call `QQMusicOnlineProvider.search(..., search_type="song")` +- accept either normalized `{"tracks": [...]}` payloads or an empty result +- map returned track dictionaries into `PluginLyricsResult` + +Field mapping should stay compatible with current behavior: + +- `song_id` from `mid` +- `title` from `title` or `name` +- `artist` from `artist` or `singer` +- `album` from normalized `album` fields +- `duration` from `duration` or `interval` +- `source = "qqmusic"` +- `cover_url` from provider-level cover resolution + +`QQMusicLyricsPluginSource.get_lyrics()` should call a new provider method such as `get_lyrics(song_mid)` and return: + +- QRC content first when present +- plain lyric content second +- `None` on failure or when both are missing + +This keeps the source contract simple while preserving richer local lyric data when available. + +### Cover Source Flow + +`QQMusicCoverPluginSource.search()` should: + +- keep the current keyword construction +- call `QQMusicOnlineProvider.search(..., search_type="song")` +- map normalized track dictionaries into `PluginCoverResult` + +Field mapping should stay compatible with current behavior: + +- `item_id` from `mid` +- `title` from `title` or `name` +- `artist` from normalized artist fields +- `album` from normalized album fields +- `duration` from `duration` or `interval` +- `source = "qqmusic"` +- `extra_id` from `album_mid` +- `cover_url` may remain `None` in search results + +`QQMusicCoverPluginSource.get_cover_url()` should call a new provider method such as `get_cover_url(mid=None, album_mid=None, size=500)`. + +### Provider Lyrics Resolution + +`QQMusicOnlineProvider` should expose a thin lyrics method that delegates to plugin-owned internals in this order: + +1. Try local QQ Music service lyrics when a client/service path is available. +2. Prefer returned `qrc` content. +3. Fall back to returned `lyric` content. +4. If local lookup is unavailable or empty, fall back to existing remote API lyrics lookup. + +The provider should swallow plugin-internal request failures and return `None`, matching current source behavior. + +### Provider Cover Resolution + +`QQMusicOnlineProvider` should expose a thin cover method that resolves URLs in this order: + +1. If `album_mid` is already present, build the `y.gtimg.cn` album cover URL directly. +2. If only `mid` is present, try a local client-backed detail lookup to derive `album_mid`. +3. If local lookup cannot produce an `album_mid`, fall back to the existing remote API cover helper. +4. Return `None` if no path can produce a usable URL. + +This keeps cover lookup fast when the track mapping already includes album metadata while preserving current remote fallback. + +### Search Normalization + +This design assumes search normalization continues to live in the existing provider/client stack. The lyrics and cover sources should not reimplement low-level QQ Music response parsing beyond adapting normalized provider payloads into plugin API result objects. + +## File Changes + +### Update + +- [`plugins/builtin/qqmusic/lib/provider.py`](/home/harold/workspace/music-player/plugins/builtin/qqmusic/lib/provider.py) +- [`plugins/builtin/qqmusic/lib/lyrics_source.py`](/home/harold/workspace/music-player/plugins/builtin/qqmusic/lib/lyrics_source.py) +- [`plugins/builtin/qqmusic/lib/cover_source.py`](/home/harold/workspace/music-player/plugins/builtin/qqmusic/lib/cover_source.py) +- [`tests/test_services/test_qqmusic_plugin_source_adapters.py`](/home/harold/workspace/music-player/tests/test_services/test_qqmusic_plugin_source_adapters.py) + +### Likely Additional Test Updates + +- [`tests/test_services/test_lyrics_sources_perf_paths.py`](/home/harold/workspace/music-player/tests/test_services/test_lyrics_sources_perf_paths.py) +- [`tests/test_plugins/test_qqmusic_plugin.py`](/home/harold/workspace/music-player/tests/test_plugins/test_qqmusic_plugin.py) + +## Testing Strategy + +Use TDD and shift tests toward provider delegation. + +### Source Adapter Tests + +Add or update tests to verify: + +- lyrics source search delegates through provider-backed search data +- lyrics source lyric download delegates through provider-backed lyric lookup +- cover source search delegates through provider-backed search data +- cover source cover URL lookup delegates through provider-backed cover lookup + +These tests should stop asserting direct use of `QQMusicPluginAPI` from the source classes. + +### Provider Tests + +Add provider-focused tests to verify: + +- provider lyric lookup prefers local QQ Music service `qrc` +- provider lyric lookup falls back from `qrc` to plain lyric +- provider lyric lookup falls back to remote API when local path yields no lyric +- provider cover lookup uses direct album URL generation when `album_mid` exists +- provider cover lookup can derive cover URL from a song `mid` through local detail data +- provider cover lookup falls back to remote helper when local detail lookup does not help + +### Regression Scope + +Run QQ Music plugin tests that cover: + +- provider behavior +- source adapter behavior +- plugin registration +- cover helper integration + +## Risks + +- `QQMusicOnlineProvider` currently does not expose lyrics or cover methods, so the new API surface must stay narrow and avoid duplicating client logic. +- Local lyric/detail responses may not be shaped exactly like search responses, so provider methods must normalize only what source adapters need. +- Some tests currently patch `QQMusicPluginAPI` directly. Those tests will need to move up one abstraction level so they verify behavior rather than an old implementation detail. + +## Open Decisions + +The current recommendation is to leave `QQMusicArtistCoverPluginSource` unchanged for now. It already works against normalized artist search payloads, and changing it in the same step would widen the scope without being required by the reported issue. + +If later work wants full provider unification for artist cover search too, it can follow the same pattern in a separate change. From 069ab76861165a3b0726331cdb1ef9683101aec6 Mon Sep 17 00:00:00 2001 From: Har01d Date: Wed, 8 Apr 2026 18:42:48 +0800 Subject: [PATCH 096/157] =?UTF-8?q?=E4=BF=AE=E5=A4=8DQt=E5=90=8E=E7=AB=AF?= =?UTF-8?q?=E5=AF=B9=E8=B1=A1=E7=88=B6=E7=BA=A7=E7=BB=91=E5=AE=9A?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- infrastructure/audio/qt_backend.py | 4 +- tests/test_infrastructure/test_qt_backend.py | 6 ++- .../test_qt_backend_parenting.py | 52 +++++++++++++++++++ 3 files changed, 58 insertions(+), 4 deletions(-) create mode 100644 tests/test_infrastructure/test_qt_backend_parenting.py diff --git a/infrastructure/audio/qt_backend.py b/infrastructure/audio/qt_backend.py index 9616ee38..8d27b7f4 100644 --- a/infrastructure/audio/qt_backend.py +++ b/infrastructure/audio/qt_backend.py @@ -17,8 +17,8 @@ class QtAudioBackend(AudioBackend): def __init__(self, parent=None): super().__init__(parent) - self._player = QMediaPlayer() - self._audio_output = QAudioOutput() + self._player = QMediaPlayer(self) + self._audio_output = QAudioOutput(self) self._player.setAudioOutput(self._audio_output) self._source_path = "" diff --git a/tests/test_infrastructure/test_qt_backend.py b/tests/test_infrastructure/test_qt_backend.py index de95ac9b..7fc09cd2 100644 --- a/tests/test_infrastructure/test_qt_backend.py +++ b/tests/test_infrastructure/test_qt_backend.py @@ -27,7 +27,8 @@ def toLocalFile(self): class _FakeQAudioOutput: - def __init__(self): + def __init__(self, parent=None): + self.parent = parent self._volume = 0.0 def setVolume(self, value: float): @@ -47,7 +48,8 @@ class MediaStatus: LoadedMedia = 7 EndOfMedia = 6 - def __init__(self): + def __init__(self, parent=None): + self.parent = parent self.positionChanged = _FakeSignal() self.durationChanged = _FakeSignal() self.playbackStateChanged = _FakeSignal() diff --git a/tests/test_infrastructure/test_qt_backend_parenting.py b/tests/test_infrastructure/test_qt_backend_parenting.py new file mode 100644 index 00000000..357f12c3 --- /dev/null +++ b/tests/test_infrastructure/test_qt_backend_parenting.py @@ -0,0 +1,52 @@ +"""Regression tests for QtAudioBackend QObject parenting.""" + +from infrastructure.audio import qt_backend + + +class _FakeSignal: + def connect(self, _callback): + return None + + +class _FakeQAudioOutput: + def __init__(self, parent=None): + self.parent = parent + self._volume = 0.0 + + def setVolume(self, value: float): + self._volume = value + + def volume(self) -> float: + return self._volume + + +class _FakeQMediaPlayer: + class PlaybackState: + PlayingState = 1 + PausedState = 2 + StoppedState = 0 + + class MediaStatus: + LoadedMedia = 7 + EndOfMedia = 6 + + def __init__(self, parent=None): + self.parent = parent + self.positionChanged = _FakeSignal() + self.durationChanged = _FakeSignal() + self.playbackStateChanged = _FakeSignal() + self.mediaStatusChanged = _FakeSignal() + self.errorOccurred = _FakeSignal() + + def setAudioOutput(self, _audio_output): + return None + + +def test_qt_backend_parents_qt_multimedia_objects(monkeypatch): + monkeypatch.setattr(qt_backend, "QMediaPlayer", _FakeQMediaPlayer) + monkeypatch.setattr(qt_backend, "QAudioOutput", _FakeQAudioOutput) + + backend = qt_backend.QtAudioBackend() + + assert backend._player.parent is backend + assert backend._audio_output.parent is backend From 2f78eefaaa9a3956ead9496be993bc45ece74ab6 Mon Sep 17 00:00:00 2001 From: Har01d Date: Wed, 8 Apr 2026 18:43:04 +0800 Subject: [PATCH 097/157] =?UTF-8?q?=E4=BF=AE=E5=A4=8DHTTP=E5=85=B1?= =?UTF-8?q?=E4=BA=AB=E5=AE=A2=E6=88=B7=E7=AB=AF=E9=80=80=E5=87=BA=E6=B8=85?= =?UTF-8?q?=E7=90=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- infrastructure/network/http_client.py | 16 ++++++++++++++++ .../test_http_client_atexit.py | 17 +++++++++++++++++ 2 files changed, 33 insertions(+) create mode 100644 tests/test_infrastructure/test_http_client_atexit.py diff --git a/infrastructure/network/http_client.py b/infrastructure/network/http_client.py index 932fb65a..7f5d246d 100644 --- a/infrastructure/network/http_client.py +++ b/infrastructure/network/http_client.py @@ -2,6 +2,7 @@ HTTP client wrapper for network requests. """ +import atexit from contextlib import contextmanager import logging from pathlib import Path @@ -25,6 +26,7 @@ class HttpClient: } _shared_clients = {} _shared_lock = threading.Lock() + _atexit_registered = False def __init__( self, @@ -89,6 +91,9 @@ def shared( pool_block, ) with cls._shared_lock: + if not cls._atexit_registered: + atexit.register(cls.close_shared_clients) + cls._atexit_registered = True client = cls._shared_clients.get(key) if client is None: client = cls( @@ -101,6 +106,13 @@ def shared( cls._shared_clients[key] = client return client + @classmethod + def close_shared_clients(cls) -> None: + with cls._shared_lock: + for client in cls._shared_clients.values(): + client.close() + cls._shared_clients = {} + def request( self, method: str, @@ -116,6 +128,7 @@ def request( """Make an HTTP request using the configured shared session.""" method = method.upper() request_timeout = timeout or self.timeout + verify = request_kwargs.pop("verify", True) if method == "GET": return self._session.get( url, @@ -123,6 +136,7 @@ def request( headers=headers, timeout=request_timeout, stream=stream, + verify=verify, **request_kwargs, ) if method == "POST": @@ -133,6 +147,7 @@ def request( headers=headers, timeout=request_timeout, stream=stream, + verify=verify, **request_kwargs, ) return self._session.request( @@ -144,6 +159,7 @@ def request( headers=headers, timeout=request_timeout, stream=stream, + verify=verify, **request_kwargs, ) diff --git a/tests/test_infrastructure/test_http_client_atexit.py b/tests/test_infrastructure/test_http_client_atexit.py new file mode 100644 index 00000000..12c02039 --- /dev/null +++ b/tests/test_infrastructure/test_http_client_atexit.py @@ -0,0 +1,17 @@ +from infrastructure.network.http_client import HttpClient + + +def test_shared_client_registers_atexit_cleanup(monkeypatch): + registrations = [] + HttpClient._shared_clients = {} + HttpClient._atexit_registered = False + + monkeypatch.setattr( + "infrastructure.network.http_client.atexit.register", + lambda callback: registrations.append(callback), + ) + + client = HttpClient.shared(timeout=5) + + assert client is not None + assert len(registrations) == 1 From 315d2bc52b538feee395ae1347938ad6d11f3013 Mon Sep 17 00:00:00 2001 From: Har01d Date: Wed, 8 Apr 2026 18:43:26 +0800 Subject: [PATCH 098/157] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E9=85=8D=E7=BD=AE?= =?UTF-8?q?=E5=AF=86=E9=92=A5=E5=AD=98=E5=82=A8=E7=A9=BA=E5=80=BC=E5=85=9C?= =?UTF-8?q?=E5=BA=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- system/config.py | 2 ++ .../test_config_secret_store_none.py | 26 +++++++++++++++++++ 2 files changed, 28 insertions(+) create mode 100644 tests/test_system/test_config_secret_store_none.py diff --git a/system/config.py b/system/config.py index 0ee721ff..deae8222 100644 --- a/system/config.py +++ b/system/config.py @@ -140,6 +140,8 @@ def set(self, key: str, value: Any): def _get_secret(self, key: str, default: str = "") -> str: """Get a sensitive setting and transparently decrypt it.""" + if self._secret_store is None: + return self.get(key, default) return self._secret_store.decrypt(self.get(key, default)) def _set_secret(self, key: str, value: str): diff --git a/tests/test_system/test_config_secret_store_none.py b/tests/test_system/test_config_secret_store_none.py new file mode 100644 index 00000000..d7560bee --- /dev/null +++ b/tests/test_system/test_config_secret_store_none.py @@ -0,0 +1,26 @@ +from system.config import ConfigManager + + +class _FakeSettingsRepository: + def __init__(self): + self.values = {} + + def get(self, key, default=None): + return self.values.get(key, default) + + def set(self, key, value): + self.values[key] = value + return True + + def delete(self, key): + self.values.pop(key, None) + return True + + +def test_get_secret_falls_back_to_plain_value_when_secret_store_missing(): + repo = _FakeSettingsRepository() + repo.values["ai.api_key"] = "plain-secret" + config = ConfigManager(repo, secret_store=None) + config._secret_store = None + + assert config._get_secret("ai.api_key") == "plain-secret" From 0a4b6ae630fad49bcb0193fc7e12b091048e6436 Mon Sep 17 00:00:00 2001 From: Har01d Date: Wed, 8 Apr 2026 18:43:46 +0800 Subject: [PATCH 099/157] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E7=A9=BA=E6=B5=81?= =?UTF-8?q?=E6=B4=BEID=E5=86=B2=E7=AA=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- domain/genre.py | 4 +++- tests/test_domain/test_genre_id.py | 11 +++++++++++ 2 files changed, 14 insertions(+), 1 deletion(-) create mode 100644 tests/test_domain/test_genre_id.py diff --git a/domain/genre.py b/domain/genre.py index 11366585..5418ba6e 100644 --- a/domain/genre.py +++ b/domain/genre.py @@ -28,7 +28,9 @@ def display_name(self) -> str: @property def id(self) -> str: """Generate a unique ID for the genre based on name.""" - return self.name.lower() + if self.name: + return self.name.lower() + return f"unknown:{id(self)}" def __hash__(self): """Make Genre hashable for use in sets.""" diff --git a/tests/test_domain/test_genre_id.py b/tests/test_domain/test_genre_id.py new file mode 100644 index 00000000..a4a82672 --- /dev/null +++ b/tests/test_domain/test_genre_id.py @@ -0,0 +1,11 @@ +from domain.genre import Genre + + +def test_empty_genres_do_not_share_same_generated_id(): + first = Genre(name="") + second = Genre(name="") + + assert first.id != "" + assert second.id != "" + assert first.id != second.id + assert first != second From 6d2bb6a5ec8bf6ccf3f48f17e2f306f19cd8ea3f Mon Sep 17 00:00:00 2001 From: Har01d Date: Wed, 8 Apr 2026 18:43:58 +0800 Subject: [PATCH 100/157] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E6=92=AD=E6=94=BE?= =?UTF-8?q?=E9=A1=B9=E5=8F=8D=E5=BA=8F=E5=88=97=E5=8C=96=E7=B1=BB=E5=9E=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- domain/playlist_item.py | 4 ++-- tests/test_domain/test_playlist_item_types.py | 16 ++++++++++++++++ 2 files changed, 18 insertions(+), 2 deletions(-) create mode 100644 tests/test_domain/test_playlist_item_types.py diff --git a/domain/playlist_item.py b/domain/playlist_item.py index a700bdb1..dc509902 100644 --- a/domain/playlist_item.py +++ b/domain/playlist_item.py @@ -174,7 +174,7 @@ def from_dict(cls, data: dict) -> "PlaylistItem": return cls( source=source, - track_id=data.get("id"), + track_id=int(data["id"]) if data.get("id") is not None else None, cloud_file_id=data.get("cloud_file_id"), online_provider_id=data.get("online_provider_id"), cloud_account_id=data.get("cloud_account_id"), @@ -182,7 +182,7 @@ def from_dict(cls, data: dict) -> "PlaylistItem": title=data.get("title", ""), artist=data.get("artist", ""), album=data.get("album", ""), - duration=data.get("duration", 0.0), + duration=float(data.get("duration", 0.0)), cover_path=data.get("cover_path"), needs_download=data.get("needs_download", False), needs_metadata=needs_metadata, diff --git a/tests/test_domain/test_playlist_item_types.py b/tests/test_domain/test_playlist_item_types.py new file mode 100644 index 00000000..3efe93d7 --- /dev/null +++ b/tests/test_domain/test_playlist_item_types.py @@ -0,0 +1,16 @@ +from domain.playlist_item import PlaylistItem + + +def test_from_dict_coerces_track_id_and_duration_types(): + item = PlaylistItem.from_dict( + { + "id": "12", + "duration": "245.5", + "title": "Song", + } + ) + + assert item.track_id == 12 + assert isinstance(item.track_id, int) + assert item.duration == 245.5 + assert isinstance(item.duration, float) From 6134e30ef07e4b0951339b83838784128716550d Mon Sep 17 00:00:00 2001 From: Har01d Date: Wed, 8 Apr 2026 18:44:10 +0800 Subject: [PATCH 101/157] =?UTF-8?q?=E4=BF=AE=E5=A4=8DLRCLIB=E5=93=8D?= =?UTF-8?q?=E5=BA=94=E7=B1=BB=E5=9E=8B=E6=A0=A1=E9=AA=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- plugins/builtin/lrclib/lib/lrclib_source.py | 2 ++ tests/test_plugins/test_lrclib_plugin_payload_type.py | 10 ++++++++++ 2 files changed, 12 insertions(+) create mode 100644 tests/test_plugins/test_lrclib_plugin_payload_type.py diff --git a/plugins/builtin/lrclib/lib/lrclib_source.py b/plugins/builtin/lrclib/lib/lrclib_source.py index 55cc0c89..1e700476 100644 --- a/plugins/builtin/lrclib/lib/lrclib_source.py +++ b/plugins/builtin/lrclib/lib/lrclib_source.py @@ -28,6 +28,8 @@ def search( timeout=3, ) payload = response.json() if response.status_code == 200 else [] + if not isinstance(payload, list): + return [] return [ PluginLyricsResult( song_id=str(item.get("id", "")), diff --git a/tests/test_plugins/test_lrclib_plugin_payload_type.py b/tests/test_plugins/test_lrclib_plugin_payload_type.py new file mode 100644 index 00000000..2c32f33f --- /dev/null +++ b/tests/test_plugins/test_lrclib_plugin_payload_type.py @@ -0,0 +1,10 @@ +from types import SimpleNamespace + +from plugins.builtin.lrclib.lib.lrclib_source import LRCLIBPluginSource + + +def test_lrclib_search_ignores_non_list_payload(): + response = SimpleNamespace(status_code=200, json=lambda: {"id": 1}) + source = LRCLIBPluginSource(SimpleNamespace(get=lambda *_args, **_kwargs: response)) + + assert source.search("Song 1", "Singer 1") == [] From 20a6542aa422d4b8da4a663f045f9a4155e96c80 Mon Sep 17 00:00:00 2001 From: Har01d Date: Wed, 8 Apr 2026 18:44:56 +0800 Subject: [PATCH 102/157] =?UTF-8?q?=E6=B7=BB=E5=8A=A0QQ=E9=9F=B3=E4=B9=90P?= =?UTF-8?q?rovider=E6=AD=8C=E8=AF=8D=E5=85=A5=E5=8F=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...2026-04-08-qqmusic-provider-unification.md | 552 ++++++++++++++++++ plugins/builtin/qqmusic/lib/provider.py | 20 + ...st_netease_cover_plugin_missing_artists.py | 38 ++ tests/test_plugins/test_qqmusic_plugin.py | 61 ++ 4 files changed, 671 insertions(+) create mode 100644 docs/superpowers/plans/2026-04-08-qqmusic-provider-unification.md create mode 100644 tests/test_plugins/test_netease_cover_plugin_missing_artists.py diff --git a/docs/superpowers/plans/2026-04-08-qqmusic-provider-unification.md b/docs/superpowers/plans/2026-04-08-qqmusic-provider-unification.md new file mode 100644 index 00000000..9cc7ee9b --- /dev/null +++ b/docs/superpowers/plans/2026-04-08-qqmusic-provider-unification.md @@ -0,0 +1,552 @@ +# QQMusic Provider Unification Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Route QQ Music lyrics and album-cover plugin sources through `QQMusicOnlineProvider` so they prefer local QQ client data before remote API fallback without changing host-facing source contracts. + +**Architecture:** Extend `QQMusicOnlineProvider` with two thin methods, `get_lyrics()` and `get_cover_url()`, that reuse the existing plugin client/service stack and only fall back to `QQMusicPluginAPI` when the local path cannot produce data. Then convert `QQMusicLyricsPluginSource` and `QQMusicCoverPluginSource` into provider-backed mapping adapters, leaving plugin registration and helper integration unchanged. + +**Tech Stack:** Python 3, PySide6 plugin package, pytest, monkeypatch-based unit tests, `uv` + +--- + +## File Map + +- Modify: `plugins/builtin/qqmusic/lib/provider.py` + Responsibility: add thin provider-owned lyrics and cover lookup methods, with local-first fallback behavior. +- Modify: `plugins/builtin/qqmusic/lib/lyrics_source.py` + Responsibility: replace direct `QQMusicPluginAPI` usage with `QQMusicOnlineProvider` delegation while preserving `PluginLyricsResult` mapping. +- Modify: `plugins/builtin/qqmusic/lib/cover_source.py` + Responsibility: replace direct `QQMusicPluginAPI` usage with `QQMusicOnlineProvider` delegation while preserving `PluginCoverResult` mapping and host helper contract. +- Modify: `tests/test_plugins/test_qqmusic_plugin.py` + Responsibility: cover provider-level `get_lyrics()` and `get_cover_url()` behavior. +- Modify: `tests/test_services/test_qqmusic_plugin_source_adapters.py` + Responsibility: assert the lyrics and cover sources delegate through the provider instead of the raw API. +- Modify: `tests/test_services/test_lyrics_sources_perf_paths.py` + Responsibility: keep the lightweight transformed-list regression aligned with provider-backed source wiring. + +### Task 1: Add provider-level lyrics resolution + +**Files:** +- Modify: `tests/test_plugins/test_qqmusic_plugin.py` +- Modify: `plugins/builtin/qqmusic/lib/provider.py` + +- [ ] **Step 1: Write the failing provider lyrics tests** + +Add these tests near the existing provider/client tests in `tests/test_plugins/test_qqmusic_plugin.py`: + +```python +def test_qqmusic_provider_get_lyrics_prefers_qrc_from_local_service(monkeypatch): + settings = Mock() + settings.get.side_effect = lambda key, default=None: { + "credential": {"musicid": "1", "musickey": "secret"}, + }.get(key, default) + context = Mock(settings=settings) + context.logger = Mock() + + service = Mock() + service.get_lyrics.return_value = { + "qrc": "[0,100]word", + "lyric": "[00:00.00]plain", + } + api = Mock() + monkeypatch.setattr( + "plugins.builtin.qqmusic.lib.client.QQMusicService", + Mock(return_value=service), + ) + monkeypatch.setattr( + "plugins.builtin.qqmusic.lib.client.QQMusicPluginAPI", + Mock(return_value=api), + ) + + provider = QQMusicOnlineProvider(context) + monkeypatch.setattr(provider._client, "_can_use_legacy_network", lambda: True) + + assert provider.get_lyrics("song-mid") == "[0,100]word" + service.get_lyrics.assert_called_once_with("song-mid") + api.get_lyrics.assert_not_called() + + +def test_qqmusic_provider_get_lyrics_falls_back_to_public_api(monkeypatch): + settings = Mock() + settings.get.side_effect = lambda key, default=None: { + "credential": {"musicid": "1", "musickey": "secret"}, + }.get(key, default) + context = Mock(settings=settings) + context.logger = Mock() + + service = Mock() + service.get_lyrics.return_value = {"qrc": None, "lyric": None} + api = Mock() + api.get_lyrics.return_value = "[00:00.00]remote" + monkeypatch.setattr( + "plugins.builtin.qqmusic.lib.client.QQMusicService", + Mock(return_value=service), + ) + monkeypatch.setattr( + "plugins.builtin.qqmusic.lib.client.QQMusicPluginAPI", + Mock(return_value=api), + ) + + provider = QQMusicOnlineProvider(context) + monkeypatch.setattr(provider._client, "_can_use_legacy_network", lambda: True) + + assert provider.get_lyrics("song-mid") == "[00:00.00]remote" + api.get_lyrics.assert_called_once_with("song-mid") +``` + +- [ ] **Step 2: Run the provider lyrics tests to verify they fail** + +Run: `uv run pytest tests/test_plugins/test_qqmusic_plugin.py -k "provider_get_lyrics" -v` + +Expected: FAIL with `AttributeError: 'QQMusicOnlineProvider' object has no attribute 'get_lyrics'` + +- [ ] **Step 3: Write the minimal provider lyrics implementation** + +Update `plugins/builtin/qqmusic/lib/provider.py` by adding `QQMusicPluginAPI` import and this method inside `QQMusicOnlineProvider`: + +```python +from .api import QQMusicPluginAPI +``` + +```python + def get_lyrics(self, song_mid: str) -> str | None: + service = self._client._get_service() + if service is not None and self._client._can_use_legacy_network(): + try: + lyric_data = service.get_lyrics(song_mid) or {} + except Exception: + lyric_data = {} + qrc = lyric_data.get("qrc") + if qrc: + return qrc + lyric = lyric_data.get("lyric") + if lyric: + return lyric + + try: + return QQMusicPluginAPI(self._context).get_lyrics(song_mid) + except Exception: + return None +``` + +- [ ] **Step 4: Run the provider lyrics tests to verify they pass** + +Run: `uv run pytest tests/test_plugins/test_qqmusic_plugin.py -k "provider_get_lyrics" -v` + +Expected: PASS for both new tests + +- [ ] **Step 5: Commit the provider lyrics slice** + +Run: + +```bash +git add tests/test_plugins/test_qqmusic_plugin.py plugins/builtin/qqmusic/lib/provider.py +git commit -m "添加QQ音乐Provider歌词入口" +``` + +### Task 2: Add provider-level cover URL resolution + +**Files:** +- Modify: `tests/test_plugins/test_qqmusic_plugin.py` +- Modify: `plugins/builtin/qqmusic/lib/provider.py` + +- [ ] **Step 1: Write the failing provider cover tests** + +Add these tests to `tests/test_plugins/test_qqmusic_plugin.py` after the provider lyrics tests: + +```python +def test_qqmusic_provider_get_cover_url_prefers_album_mid(monkeypatch): + settings = Mock() + settings.get.side_effect = lambda key, default=None: default + context = Mock(settings=settings) + context.logger = Mock() + + provider = QQMusicOnlineProvider(context) + + assert provider.get_cover_url(album_mid="album-1", size=800) == ( + "https://y.gtimg.cn/music/photo_new/T002R800x800M000album-1.jpg" + ) + + +def test_qqmusic_provider_get_cover_url_uses_local_song_detail_before_public_api(monkeypatch): + settings = Mock() + settings.get.side_effect = lambda key, default=None: { + "credential": {"musicid": "1", "musickey": "secret"}, + }.get(key, default) + context = Mock(settings=settings) + context.logger = Mock() + + service = Mock() + service.client.get_song_detail.return_value = { + "track_info": {"album": {"mid": "album-from-detail"}} + } + api = Mock() + monkeypatch.setattr( + "plugins.builtin.qqmusic.lib.client.QQMusicService", + Mock(return_value=service), + ) + monkeypatch.setattr( + "plugins.builtin.qqmusic.lib.client.QQMusicPluginAPI", + Mock(return_value=api), + ) + + provider = QQMusicOnlineProvider(context) + monkeypatch.setattr(provider._client, "_can_use_legacy_network", lambda: True) + + assert provider.get_cover_url(mid="song-1", size=500) == ( + "https://y.gtimg.cn/music/photo_new/T002R500x500M000album-from-detail.jpg" + ) + api.get_cover_url.assert_not_called() + + +def test_qqmusic_provider_get_cover_url_falls_back_to_public_api(monkeypatch): + settings = Mock() + settings.get.side_effect = lambda key, default=None: { + "credential": {"musicid": "1", "musickey": "secret"}, + }.get(key, default) + context = Mock(settings=settings) + context.logger = Mock() + + service = Mock() + service.client.get_song_detail.return_value = {} + api = Mock() + api.get_cover_url.return_value = "https://remote/cover.jpg" + monkeypatch.setattr( + "plugins.builtin.qqmusic.lib.client.QQMusicService", + Mock(return_value=service), + ) + monkeypatch.setattr( + "plugins.builtin.qqmusic.lib.client.QQMusicPluginAPI", + Mock(return_value=api), + ) + + provider = QQMusicOnlineProvider(context) + monkeypatch.setattr(provider._client, "_can_use_legacy_network", lambda: True) + + assert provider.get_cover_url(mid="song-1", size=500) == "https://remote/cover.jpg" + api.get_cover_url.assert_called_once_with(mid="song-1", album_mid=None, size=500) +``` + +- [ ] **Step 2: Run the provider cover tests to verify they fail** + +Run: `uv run pytest tests/test_plugins/test_qqmusic_plugin.py -k "provider_get_cover_url" -v` + +Expected: FAIL with `AttributeError: 'QQMusicOnlineProvider' object has no attribute 'get_cover_url'` + +- [ ] **Step 3: Write the minimal provider cover implementation** + +Add these helpers and method to `plugins/builtin/qqmusic/lib/provider.py`: + +```python + @staticmethod + def _build_album_cover_url(album_mid: str, size: int) -> str | None: + if not album_mid: + return None + return f"https://y.gtimg.cn/music/photo_new/T002R{size}x{size}M000{album_mid}.jpg" + + @staticmethod + def _extract_album_mid_from_song_detail(detail: dict[str, Any] | None) -> str: + if not isinstance(detail, dict): + return "" + track = detail.get("track_info", detail.get("data", detail)) + if not isinstance(track, dict): + return "" + album = track.get("album", {}) + if isinstance(album, dict): + album_mid = album.get("mid") or album.get("albumMid") or album.get("albummid") + if album_mid: + return str(album_mid) + return str(track.get("album_mid") or track.get("albummid") or track.get("albumMid") or "") + + def get_cover_url( + self, + mid: str | None = None, + album_mid: str | None = None, + size: int = 500, + ) -> str | None: + cover_url = self._build_album_cover_url(album_mid or "", size) + if cover_url: + return cover_url + + service = self._client._get_service() + if service is not None and mid and self._client._can_use_legacy_network(): + try: + detail = service.client.get_song_detail(mid) + except Exception: + detail = {} + local_album_mid = self._extract_album_mid_from_song_detail(detail) + cover_url = self._build_album_cover_url(local_album_mid, size) + if cover_url: + return cover_url + + try: + return QQMusicPluginAPI(self._context).get_cover_url(mid=mid, album_mid=album_mid, size=size) + except Exception: + return None +``` + +- [ ] **Step 4: Run the provider cover tests to verify they pass** + +Run: `uv run pytest tests/test_plugins/test_qqmusic_plugin.py -k "provider_get_cover_url" -v` + +Expected: PASS for all three new tests + +- [ ] **Step 5: Commit the provider cover slice** + +Run: + +```bash +git add tests/test_plugins/test_qqmusic_plugin.py plugins/builtin/qqmusic/lib/provider.py +git commit -m "添加QQ音乐Provider封面入口" +``` + +### Task 3: Move lyrics and cover sources onto the provider + +**Files:** +- Modify: `tests/test_services/test_qqmusic_plugin_source_adapters.py` +- Modify: `tests/test_services/test_lyrics_sources_perf_paths.py` +- Modify: `plugins/builtin/qqmusic/lib/lyrics_source.py` +- Modify: `plugins/builtin/qqmusic/lib/cover_source.py` + +- [ ] **Step 1: Rewrite the source adapter tests to fail against provider delegation** + +Update `tests/test_services/test_qqmusic_plugin_source_adapters.py` so the lyrics and cover source tests patch `QQMusicOnlineProvider` methods instead of `QQMusicPluginAPI`. Use tests shaped like: + +```python +from plugins.builtin.qqmusic.lib.provider import QQMusicOnlineProvider +``` + +```python +def test_qqmusic_lyrics_source_search_reads_tracks_payload(monkeypatch): + captured = {} + + def fake_search(self, keyword, search_type="song", page=1, page_size=30): + captured.update( + keyword=keyword, + search_type=search_type, + page=page, + page_size=page_size, + ) + return { + "tracks": [ + { + "mid": "song-1", + "title": "Song 1", + "artist": "Singer 1", + "album": "Album 1", + "album_mid": "album-1", + "duration": 180, + } + ] + } + + monkeypatch.setattr(QQMusicOnlineProvider, "search", fake_search) + monkeypatch.setattr( + QQMusicOnlineProvider, + "get_cover_url", + lambda *_args, **_kwargs: "cover-1", + ) + + source = QQMusicLyricsPluginSource(SimpleNamespace()) + + results = source.search("Song 1", "Singer 1", limit=7) + + assert captured == { + "keyword": "Song 1 Singer 1", + "search_type": "song", + "page": 1, + "page_size": 7, + } + assert results[0].cover_url == "cover-1" + + +def test_qqmusic_lyrics_source_get_lyrics_uses_provider(monkeypatch): + monkeypatch.setattr( + QQMusicOnlineProvider, + "get_lyrics", + lambda self, song_mid: f"lyrics:{song_mid}", + ) + + source = QQMusicLyricsPluginSource(SimpleNamespace()) + + assert source.get_lyrics_by_song_id("song-1") == "lyrics:song-1" + + +def test_qqmusic_cover_source_get_cover_url_uses_provider(monkeypatch): + monkeypatch.setattr( + QQMusicOnlineProvider, + "get_cover_url", + lambda self, mid=None, album_mid=None, size=500: f"cover:{album_mid or mid}:{size}", + ) + + source = QQMusicCoverPluginSource(SimpleNamespace()) + + assert source.get_cover_url(mid="song-1", album_mid="album-1", size=700) == "cover:album-1:700" +``` + +Also update `tests/test_services/test_lyrics_sources_perf_paths.py` so it patches `QQMusicOnlineProvider.search` and `QQMusicOnlineProvider.get_cover_url`, not `QQMusicPluginAPI`. + +- [ ] **Step 2: Run the source adapter tests to verify they fail** + +Run: + +```bash +uv run pytest tests/test_services/test_qqmusic_plugin_source_adapters.py -v +uv run pytest tests/test_services/test_lyrics_sources_perf_paths.py -v +``` + +Expected: FAIL because the current source classes still instantiate and call `QQMusicPluginAPI` + +- [ ] **Step 3: Write the minimal source delegation implementation** + +Update `plugins/builtin/qqmusic/lib/lyrics_source.py` to use `QQMusicOnlineProvider`: + +```python +from .provider import QQMusicOnlineProvider +``` + +```python + def __init__(self, context): + self._context = context + self._provider = QQMusicOnlineProvider(context) + + def search(self, title: str, artist: str, limit: int = 10) -> list[PluginLyricsResult]: + try: + keyword = f"{title} {artist}" if artist else title + search_payload = self._provider.search( + keyword, + search_type="song", + page=1, + page_size=limit, + ) + search_results = search_payload.get("tracks", []) if isinstance(search_payload, dict) else search_payload + return [ + PluginLyricsResult( + song_id=item.get("mid", ""), + title=item.get("title", "") or item.get("name", ""), + artist=item.get("artist", "") or item.get("singer", ""), + album=item.get("album", ""), + duration=item.get("duration") or item.get("interval"), + source="qqmusic", + cover_url=self._provider.get_cover_url( + mid=item.get("mid", ""), + album_mid=item.get("album_mid", ""), + size=500, + ), + ) + for item in search_results + ] + except Exception: + return [] + + def get_lyrics(self, result: PluginLyricsResult) -> str | None: + try: + return self._provider.get_lyrics(result.song_id) + except Exception: + return None +``` + +Update `plugins/builtin/qqmusic/lib/cover_source.py` similarly: + +```python +from .provider import QQMusicOnlineProvider +``` + +```python + def __init__(self, context): + self._context = context + self._provider = QQMusicOnlineProvider(context) +``` + +```python + search_payload = self._provider.search( + keyword, + search_type="song", + page=1, + page_size=5, + ) +``` + +```python + def get_cover_url( + self, + mid: str = None, + album_mid: str = None, + size: int = 500, + ): + return self._provider.get_cover_url(mid=mid, album_mid=album_mid, size=size) +``` + +- [ ] **Step 4: Run the source adapter tests to verify they pass** + +Run: + +```bash +uv run pytest tests/test_services/test_qqmusic_plugin_source_adapters.py -v +uv run pytest tests/test_services/test_lyrics_sources_perf_paths.py -v +``` + +Expected: PASS + +- [ ] **Step 5: Commit the source delegation slice** + +Run: + +```bash +git add tests/test_services/test_qqmusic_plugin_source_adapters.py tests/test_services/test_lyrics_sources_perf_paths.py plugins/builtin/qqmusic/lib/lyrics_source.py plugins/builtin/qqmusic/lib/cover_source.py +git commit -m "统一QQ音乐歌词封面来源入口" +``` + +### Task 4: Run the focused QQ Music regression suite + +**Files:** +- Modify: none +- Verify: `tests/test_plugins/test_qqmusic_plugin.py` +- Verify: `tests/test_services/test_qqmusic_plugin_source_adapters.py` +- Verify: `tests/test_services/test_lyrics_sources_perf_paths.py` +- Verify: `tests/test_system/test_plugin_cover_helpers.py` + +- [ ] **Step 1: Run the focused regression commands** + +Run: + +```bash +uv run pytest tests/test_plugins/test_qqmusic_plugin.py -v +uv run pytest tests/test_services/test_qqmusic_plugin_source_adapters.py -v +uv run pytest tests/test_services/test_lyrics_sources_perf_paths.py -v +uv run pytest tests/test_system/test_plugin_cover_helpers.py -v +``` + +Expected: PASS on all four commands + +- [ ] **Step 2: Inspect the final diff before the last commit** + +Run: + +```bash +git diff -- plugins/builtin/qqmusic/lib/provider.py plugins/builtin/qqmusic/lib/lyrics_source.py plugins/builtin/qqmusic/lib/cover_source.py tests/test_plugins/test_qqmusic_plugin.py tests/test_services/test_qqmusic_plugin_source_adapters.py tests/test_services/test_lyrics_sources_perf_paths.py +``` + +Expected: diff only shows provider entry points plus source/test delegation updates required by this plan + +- [ ] **Step 3: Create the final integration commit** + +Run: + +```bash +git add plugins/builtin/qqmusic/lib/provider.py plugins/builtin/qqmusic/lib/lyrics_source.py plugins/builtin/qqmusic/lib/cover_source.py tests/test_plugins/test_qqmusic_plugin.py tests/test_services/test_qqmusic_plugin_source_adapters.py tests/test_services/test_lyrics_sources_perf_paths.py +git commit -m "优化QQ音乐Provider调用链" +``` + +- [ ] **Step 4: Record the verification commands in the handoff** + +Include this exact summary in the final handoff: + +```text +Verified with: +- uv run pytest tests/test_plugins/test_qqmusic_plugin.py -v +- uv run pytest tests/test_services/test_qqmusic_plugin_source_adapters.py -v +- uv run pytest tests/test_services/test_lyrics_sources_perf_paths.py -v +- uv run pytest tests/test_system/test_plugin_cover_helpers.py -v +``` diff --git a/plugins/builtin/qqmusic/lib/provider.py b/plugins/builtin/qqmusic/lib/provider.py index 894fdf84..c3204065 100644 --- a/plugins/builtin/qqmusic/lib/provider.py +++ b/plugins/builtin/qqmusic/lib/provider.py @@ -3,6 +3,7 @@ import logging from typing import Any +from .api import QQMusicPluginAPI from harmony_plugin_api.media import PluginTrack from .client import QQMusicPluginClient @@ -122,6 +123,25 @@ def get_demo_track(self) -> PluginTrack: def get_playback_url_info(self, track_id: str, quality: str): return self._client.get_playback_url_info(track_id, quality) + def get_lyrics(self, song_mid: str) -> str | None: + service = self._client._get_service() + if service is not None and self._client._can_use_legacy_network(): + try: + lyric_data = service.get_lyrics(song_mid) or {} + except Exception: + lyric_data = {} + qrc = lyric_data.get("qrc") + if qrc: + return qrc + lyric = lyric_data.get("lyric") + if lyric: + return lyric + + try: + return QQMusicPluginAPI(self._context).get_lyrics(song_mid) + except Exception: + return None + def download_track( self, track_id: str, diff --git a/tests/test_plugins/test_netease_cover_plugin_missing_artists.py b/tests/test_plugins/test_netease_cover_plugin_missing_artists.py new file mode 100644 index 00000000..3ce17da8 --- /dev/null +++ b/tests/test_plugins/test_netease_cover_plugin_missing_artists.py @@ -0,0 +1,38 @@ +from types import SimpleNamespace + +from plugins.builtin.netease_cover.lib.cover_source import NetEaseCoverPluginSource + + +def test_netease_cover_search_handles_empty_artists_list(): + responses = [ + SimpleNamespace(status_code=200, json=lambda: {"code": 200, "result": {"albums": []}}), + SimpleNamespace( + status_code=200, + json=lambda: { + "code": 200, + "result": { + "songs": [ + { + "id": 2, + "name": "Song 1", + "artists": [], + "duration": 180000, + "album": { + "name": "Album 1", + "picUrl": "https://example.com/song.jpg", + }, + } + ] + }, + }, + ), + ] + source = NetEaseCoverPluginSource( + SimpleNamespace(get=lambda *_args, **_kwargs: responses.pop(0)) + ) + + results = source.search("Song 1", "Singer 1", "Album 1") + + assert len(results) == 1 + assert results[0].item_id == "2" + assert results[0].artist == "" diff --git a/tests/test_plugins/test_qqmusic_plugin.py b/tests/test_plugins/test_qqmusic_plugin.py index b10bacab..2d4aaa9c 100644 --- a/tests/test_plugins/test_qqmusic_plugin.py +++ b/tests/test_plugins/test_qqmusic_plugin.py @@ -130,6 +130,67 @@ def _capture_view(*, config_manager=None, qqmusic_service=None, plugin_context=N assert captured["qqmusic_service"] is None +def test_qqmusic_provider_get_lyrics_prefers_qrc_from_local_service(monkeypatch): + settings = Mock() + settings.get.side_effect = lambda key, default=None: { + "credential": {"musicid": "1", "musickey": "secret"}, + }.get(key, default) + context = Mock(settings=settings) + context.logger = Mock() + + service = Mock() + service.get_lyrics.return_value = { + "qrc": "[0,100]word", + "lyric": "[00:00.00]plain", + } + api = Mock() + monkeypatch.setattr( + "plugins.builtin.qqmusic.lib.client.QQMusicService", + Mock(return_value=service), + ) + monkeypatch.setattr( + "plugins.builtin.qqmusic.lib.provider.QQMusicPluginAPI", + Mock(return_value=api), + raising=False, + ) + + provider = QQMusicOnlineProvider(context) + monkeypatch.setattr(provider._client, "_can_use_legacy_network", lambda: True) + + assert provider.get_lyrics("song-mid") == "[0,100]word" + service.get_lyrics.assert_called_once_with("song-mid") + api.get_lyrics.assert_not_called() + + +def test_qqmusic_provider_get_lyrics_falls_back_to_public_api(monkeypatch): + settings = Mock() + settings.get.side_effect = lambda key, default=None: { + "credential": {"musicid": "1", "musickey": "secret"}, + }.get(key, default) + context = Mock(settings=settings) + context.logger = Mock() + + service = Mock() + service.get_lyrics.return_value = {"qrc": None, "lyric": None} + api = Mock() + api.get_lyrics.return_value = "[00:00.00]remote" + monkeypatch.setattr( + "plugins.builtin.qqmusic.lib.client.QQMusicService", + Mock(return_value=service), + ) + monkeypatch.setattr( + "plugins.builtin.qqmusic.lib.provider.QQMusicPluginAPI", + Mock(return_value=api), + raising=False, + ) + + provider = QQMusicOnlineProvider(context) + monkeypatch.setattr(provider._client, "_can_use_legacy_network", lambda: True) + + assert provider.get_lyrics("song-mid") == "[00:00.00]remote" + api.get_lyrics.assert_called_once_with("song-mid") + + def test_qqmusic_provider_download_track_delegates_to_plugin_service(monkeypatch): settings = Mock() settings.get.side_effect = lambda key, default=None: { From 8224f90dd47c7e5f1669a53ea9ee40f90bfb66e2 Mon Sep 17 00:00:00 2001 From: Har01d Date: Wed, 8 Apr 2026 18:46:06 +0800 Subject: [PATCH 103/157] =?UTF-8?q?=E6=B7=BB=E5=8A=A0QQ=E9=9F=B3=E4=B9=90P?= =?UTF-8?q?rovider=E5=B0=81=E9=9D=A2=E5=85=A5=E5=8F=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- plugins/builtin/qqmusic/lib/provider.py | 46 +++++++++++++++ tests/test_plugins/test_qqmusic_plugin.py | 72 +++++++++++++++++++++++ 2 files changed, 118 insertions(+) diff --git a/plugins/builtin/qqmusic/lib/provider.py b/plugins/builtin/qqmusic/lib/provider.py index c3204065..7480c06b 100644 --- a/plugins/builtin/qqmusic/lib/provider.py +++ b/plugins/builtin/qqmusic/lib/provider.py @@ -142,6 +142,52 @@ def get_lyrics(self, song_mid: str) -> str | None: except Exception: return None + @staticmethod + def _build_album_cover_url(album_mid: str, size: int) -> str | None: + if not album_mid: + return None + return f"https://y.gtimg.cn/music/photo_new/T002R{size}x{size}M000{album_mid}.jpg" + + @staticmethod + def _extract_album_mid_from_song_detail(detail: dict[str, Any] | None) -> str: + if not isinstance(detail, dict): + return "" + track = detail.get("track_info", detail.get("data", detail)) + if not isinstance(track, dict): + return "" + album = track.get("album", {}) + if isinstance(album, dict): + album_mid = album.get("mid") or album.get("albumMid") or album.get("albummid") + if album_mid: + return str(album_mid) + return str(track.get("album_mid") or track.get("albummid") or track.get("albumMid") or "") + + def get_cover_url( + self, + mid: str | None = None, + album_mid: str | None = None, + size: int = 500, + ) -> str | None: + cover_url = self._build_album_cover_url(album_mid or "", size) + if cover_url: + return cover_url + + service = self._client._get_service() + if service is not None and mid and self._client._can_use_legacy_network(): + try: + detail = service.client.get_song_detail(mid) + except Exception: + detail = {} + local_album_mid = self._extract_album_mid_from_song_detail(detail) + cover_url = self._build_album_cover_url(local_album_mid, size) + if cover_url: + return cover_url + + try: + return QQMusicPluginAPI(self._context).get_cover_url(mid=mid, album_mid=album_mid, size=size) + except Exception: + return None + def download_track( self, track_id: str, diff --git a/tests/test_plugins/test_qqmusic_plugin.py b/tests/test_plugins/test_qqmusic_plugin.py index 2d4aaa9c..78d90a78 100644 --- a/tests/test_plugins/test_qqmusic_plugin.py +++ b/tests/test_plugins/test_qqmusic_plugin.py @@ -191,6 +191,78 @@ def test_qqmusic_provider_get_lyrics_falls_back_to_public_api(monkeypatch): api.get_lyrics.assert_called_once_with("song-mid") +def test_qqmusic_provider_get_cover_url_prefers_album_mid(): + settings = Mock() + settings.get.side_effect = lambda key, default=None: default + context = Mock(settings=settings) + context.logger = Mock() + + provider = QQMusicOnlineProvider(context) + + assert provider.get_cover_url(album_mid="album-1", size=800) == ( + "https://y.gtimg.cn/music/photo_new/T002R800x800M000album-1.jpg" + ) + + +def test_qqmusic_provider_get_cover_url_uses_local_song_detail_before_public_api(monkeypatch): + settings = Mock() + settings.get.side_effect = lambda key, default=None: { + "credential": {"musicid": "1", "musickey": "secret"}, + }.get(key, default) + context = Mock(settings=settings) + context.logger = Mock() + + service = Mock() + service.client.get_song_detail.return_value = { + "track_info": {"album": {"mid": "album-from-detail"}} + } + api = Mock() + monkeypatch.setattr( + "plugins.builtin.qqmusic.lib.client.QQMusicService", + Mock(return_value=service), + ) + monkeypatch.setattr( + "plugins.builtin.qqmusic.lib.provider.QQMusicPluginAPI", + Mock(return_value=api), + ) + + provider = QQMusicOnlineProvider(context) + monkeypatch.setattr(provider._client, "_can_use_legacy_network", lambda: True) + + assert provider.get_cover_url(mid="song-1", size=500) == ( + "https://y.gtimg.cn/music/photo_new/T002R500x500M000album-from-detail.jpg" + ) + api.get_cover_url.assert_not_called() + + +def test_qqmusic_provider_get_cover_url_falls_back_to_public_api(monkeypatch): + settings = Mock() + settings.get.side_effect = lambda key, default=None: { + "credential": {"musicid": "1", "musickey": "secret"}, + }.get(key, default) + context = Mock(settings=settings) + context.logger = Mock() + + service = Mock() + service.client.get_song_detail.return_value = {} + api = Mock() + api.get_cover_url.return_value = "https://remote/cover.jpg" + monkeypatch.setattr( + "plugins.builtin.qqmusic.lib.client.QQMusicService", + Mock(return_value=service), + ) + monkeypatch.setattr( + "plugins.builtin.qqmusic.lib.provider.QQMusicPluginAPI", + Mock(return_value=api), + ) + + provider = QQMusicOnlineProvider(context) + monkeypatch.setattr(provider._client, "_can_use_legacy_network", lambda: True) + + assert provider.get_cover_url(mid="song-1", size=500) == "https://remote/cover.jpg" + api.get_cover_url.assert_called_once_with(mid="song-1", album_mid=None, size=500) + + def test_qqmusic_provider_download_track_delegates_to_plugin_service(monkeypatch): settings = Mock() settings.get.side_effect = lambda key, default=None: { From 3be99b5e000bf8ab8bd6d8bce03329082fcaa7fd Mon Sep 17 00:00:00 2001 From: Har01d Date: Wed, 8 Apr 2026 18:46:57 +0800 Subject: [PATCH 104/157] =?UTF-8?q?=E6=9B=BF=E6=8D=A2=E6=AD=8C=E8=AF=8D?= =?UTF-8?q?=E9=9D=A2=E6=9D=BF=E8=BF=87=E6=97=B6=E8=8F=9C=E5=8D=95=E8=B0=83?= =?UTF-8?q?=E7=94=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tests/test_ui/test_lyrics_panel_menu_exec.py | 60 ++++++++++++++++++++ ui/windows/components/lyrics_panel.py | 2 +- 2 files changed, 61 insertions(+), 1 deletion(-) create mode 100644 tests/test_ui/test_lyrics_panel_menu_exec.py diff --git a/tests/test_ui/test_lyrics_panel_menu_exec.py b/tests/test_ui/test_lyrics_panel_menu_exec.py new file mode 100644 index 00000000..7f81c762 --- /dev/null +++ b/tests/test_ui/test_lyrics_panel_menu_exec.py @@ -0,0 +1,60 @@ +from types import SimpleNamespace + +from ui.windows.components.lyrics_panel import LyricsPanel + + +class _FakeSignal: + def connect(self, _callback): + return None + + +class _FakeAction: + def __init__(self): + self.triggered = _FakeSignal() + + +class _FakeMenu: + def __init__(self, _parent): + self.exec_called = False + self.exec_legacy_called = False + + def setStyleSheet(self, _style): + return None + + def addAction(self, _label): + return _FakeAction() + + def addSeparator(self): + return None + + def exec(self, _pos): + self.exec_called = True + + def exec_(self, _pos): + self.exec_legacy_called = True + raise AssertionError("exec_ should not be used") + + +def test_lyrics_panel_context_menu_uses_exec(monkeypatch): + fake_menu = _FakeMenu(None) + + monkeypatch.setattr("ui.windows.components.lyrics_panel.QMenu", lambda parent: fake_menu) + monkeypatch.setattr( + "system.theme.ThemeManager.instance", + lambda: SimpleNamespace(get_qss=lambda template: template), + ) + monkeypatch.setattr("ui.windows.components.lyrics_panel.t", lambda key: key) + + panel = SimpleNamespace( + _MENU_STYLE="style", + _lyrics_view=SimpleNamespace(mapToGlobal=lambda pos: pos), + download_requested=object(), + edit_requested=object(), + delete_requested=object(), + open_location_requested=object(), + refresh_requested=object(), + ) + + LyricsPanel._show_context_menu(panel, (10, 20)) + + assert fake_menu.exec_called is True diff --git a/ui/windows/components/lyrics_panel.py b/ui/windows/components/lyrics_panel.py index 8f0ce3b8..a0ec7194 100644 --- a/ui/windows/components/lyrics_panel.py +++ b/ui/windows/components/lyrics_panel.py @@ -136,7 +136,7 @@ def _show_context_menu(self, pos): refresh_action = menu.addAction(t("refresh")) refresh_action.triggered.connect(self.refresh_requested) - menu.exec_(self._lyrics_view.mapToGlobal(pos)) + menu.exec(self._lyrics_view.mapToGlobal(pos)) def set_lyrics(self, lyrics: str): """Set the lyrics content.""" From f115de6ca94998ca408f320abd01bab501abcffb Mon Sep 17 00:00:00 2001 From: Har01d Date: Wed, 8 Apr 2026 18:47:18 +0800 Subject: [PATCH 105/157] =?UTF-8?q?=E5=87=8F=E5=B0=91=E5=9C=A8=E7=BA=BF?= =?UTF-8?q?=E6=AD=8C=E6=9B=B2=E6=89=B9=E9=87=8F=E5=85=A5=E9=98=9F=E6=9F=A5?= =?UTF-8?q?=E6=89=BE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...st_online_music_handler_bootstrap_calls.py | 36 +++++++++++++++++++ ui/windows/components/online_music_handler.py | 16 +++++---- 2 files changed, 46 insertions(+), 6 deletions(-) create mode 100644 tests/test_ui/test_online_music_handler_bootstrap_calls.py diff --git a/tests/test_ui/test_online_music_handler_bootstrap_calls.py b/tests/test_ui/test_online_music_handler_bootstrap_calls.py new file mode 100644 index 00000000..1f4828eb --- /dev/null +++ b/tests/test_ui/test_online_music_handler_bootstrap_calls.py @@ -0,0 +1,36 @@ +from types import SimpleNamespace + +from ui.windows.components.online_music_handler import OnlineMusicHandler + + +def test_add_multiple_to_queue_resolves_bootstrap_once(monkeypatch): + call_count = 0 + library_service = SimpleNamespace(add_online_track=lambda **_kwargs: 1) + bootstrap = SimpleNamespace(library_service=library_service) + + def fake_instance(): + nonlocal call_count + call_count += 1 + return bootstrap + + monkeypatch.setattr("app.bootstrap.Bootstrap.instance", fake_instance) + + handler = OnlineMusicHandler.__new__(OnlineMusicHandler) + handler._download_service = None + handler._status_callback = None + handler._show_status = lambda _message: None + handler._resolve_provider_id = OnlineMusicHandler._resolve_provider_id + handler._playback = SimpleNamespace( + engine=SimpleNamespace(add_track=lambda _item: None), + save_queue=lambda: None, + ) + + OnlineMusicHandler.add_multiple_to_queue( + handler, + [ + ("mid-1", {"title": "Song 1"}), + ("mid-2", {"title": "Song 2"}), + ], + ) + + assert call_count == 1 diff --git a/ui/windows/components/online_music_handler.py b/ui/windows/components/online_music_handler.py index 7338cd92..ab5783f0 100644 --- a/ui/windows/components/online_music_handler.py +++ b/ui/windows/components/online_music_handler.py @@ -234,6 +234,9 @@ def add_multiple_to_queue(self, tracks_data: List[Tuple[str, dict]], provider_id Args: tracks_data: List of (song_mid, metadata) tuples """ + from app.bootstrap import Bootstrap + bootstrap = Bootstrap.instance() + for song_mid, metadata in tracks_data: title = metadata.get("title", "Online Track") artist = metadata.get("artist", "") @@ -243,8 +246,7 @@ def add_multiple_to_queue(self, tracks_data: List[Tuple[str, dict]], provider_id resolved_provider_id = self._resolve_provider_id(provider_id, metadata) # Create track record in database first - from app.bootstrap import Bootstrap - track_id = Bootstrap.instance().library_service.add_online_track( + track_id = bootstrap.library_service.add_online_track( provider_id=resolved_provider_id, song_mid=song_mid, title=title, @@ -292,6 +294,8 @@ def insert_multiple_to_queue(self, tracks_data: List[Tuple[str, dict]], provider """ current_index = self._playback.engine.current_index insert_index = current_index + 1 if current_index >= 0 else 0 + from app.bootstrap import Bootstrap + bootstrap = Bootstrap.instance() for i, (song_mid, metadata) in enumerate(tracks_data): title = metadata.get("title", "Online Track") @@ -302,8 +306,7 @@ def insert_multiple_to_queue(self, tracks_data: List[Tuple[str, dict]], provider resolved_provider_id = self._resolve_provider_id(provider_id, metadata) # Create track record in database first - from app.bootstrap import Bootstrap - track_id = Bootstrap.instance().library_service.add_online_track( + track_id = bootstrap.library_service.add_online_track( provider_id=resolved_provider_id, song_mid=song_mid, title=title, @@ -353,6 +356,8 @@ def play_online_tracks( start_index: Index to start playing from tracks_data: List of (song_mid, metadata) tuples """ + from app.bootstrap import Bootstrap + bootstrap = Bootstrap.instance() items = [] for song_mid, metadata in tracks_data: @@ -364,8 +369,7 @@ def play_online_tracks( resolved_provider_id = self._resolve_provider_id(provider_id, metadata) # Create track record in database first - from app.bootstrap import Bootstrap - track_id = Bootstrap.instance().library_service.add_online_track( + track_id = bootstrap.library_service.add_online_track( provider_id=resolved_provider_id, song_mid=song_mid, title=title, From f7a8c268fc3889e1aa511bcd6c6ef15b75a6452a Mon Sep 17 00:00:00 2001 From: Har01d Date: Wed, 8 Apr 2026 18:47:50 +0800 Subject: [PATCH 106/157] =?UTF-8?q?=E7=BB=9F=E4=B8=80QQ=E9=9F=B3=E4=B9=90?= =?UTF-8?q?=E6=AD=8C=E8=AF=8D=E5=B0=81=E9=9D=A2=E6=9D=A5=E6=BA=90=E5=85=A5?= =?UTF-8?q?=E5=8F=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- plugins/builtin/qqmusic/lib/cover_source.py | 11 ++--- plugins/builtin/qqmusic/lib/lyrics_source.py | 30 +++++-------- .../test_lyrics_sources_perf_paths.py | 28 ++++++------ .../test_qqmusic_plugin_source_adapters.py | 45 ++++++++++++++----- 4 files changed, 66 insertions(+), 48 deletions(-) diff --git a/plugins/builtin/qqmusic/lib/cover_source.py b/plugins/builtin/qqmusic/lib/cover_source.py index 96f74686..9dbe76ea 100644 --- a/plugins/builtin/qqmusic/lib/cover_source.py +++ b/plugins/builtin/qqmusic/lib/cover_source.py @@ -2,7 +2,7 @@ from harmony_plugin_api.cover import PluginCoverResult -from .api import QQMusicPluginAPI +from .provider import QQMusicOnlineProvider class QQMusicCoverPluginSource: @@ -13,7 +13,7 @@ class QQMusicCoverPluginSource: def __init__(self, context): self._context = context - self._api = QQMusicPluginAPI(context) + self._provider = QQMusicOnlineProvider(context) def search( self, @@ -24,10 +24,11 @@ def search( ) -> list[PluginCoverResult]: try: keyword = f"{artist} {title}" if artist else title - search_payload = self._api.search( + search_payload = self._provider.search( keyword, search_type="song", - limit=5, + page=1, + page_size=5, ) songs = ( search_payload.get("tracks", []) @@ -77,4 +78,4 @@ def get_cover_url( album_mid: str = None, size: int = 500, ): - return self._api.get_cover_url(mid=mid, album_mid=album_mid, size=size) + return self._provider.get_cover_url(mid=mid, album_mid=album_mid, size=size) diff --git a/plugins/builtin/qqmusic/lib/lyrics_source.py b/plugins/builtin/qqmusic/lib/lyrics_source.py index 43d17869..b2f76d5d 100644 --- a/plugins/builtin/qqmusic/lib/lyrics_source.py +++ b/plugins/builtin/qqmusic/lib/lyrics_source.py @@ -2,7 +2,7 @@ from harmony_plugin_api.lyrics import PluginLyricsResult -from .api import QQMusicPluginAPI +from .provider import QQMusicOnlineProvider class QQMusicLyricsPluginSource: @@ -12,15 +12,16 @@ class QQMusicLyricsPluginSource: def __init__(self, context): self._context = context - self._api = QQMusicPluginAPI(context) + self._provider = QQMusicOnlineProvider(context) def search(self, title: str, artist: str, limit: int = 10) -> list[PluginLyricsResult]: try: keyword = f"{title} {artist}" if artist else title - search_payload = self._api.search( + search_payload = self._provider.search( keyword, search_type="song", - limit=limit, + page=1, + page_size=limit, ) search_results = ( search_payload.get("tracks", []) @@ -31,24 +32,13 @@ def search(self, title: str, artist: str, limit: int = 10) -> list[PluginLyricsR PluginLyricsResult( song_id=item.get("mid", ""), title=item.get("title", "") or item.get("name", ""), - artist=item.get("singer", "") or item.get("artist", ""), - album=( - item.get("album", {}).get("name", "") - if isinstance(item.get("album"), dict) - else item.get("album", "") - ), + artist=item.get("artist", "") or item.get("singer", ""), + album=item.get("album", ""), duration=item.get("duration") or item.get("interval"), source="qqmusic", - cover_url=self._api.get_cover_url( + cover_url=self._provider.get_cover_url( mid=item.get("mid", ""), - album_mid=( - item.get("album_mid", "") - or ( - item.get("album", {}).get("mid", "") - if isinstance(item.get("album"), dict) - else "" - ) - ), + album_mid=item.get("album_mid", ""), size=500, ), ) @@ -59,7 +49,7 @@ def search(self, title: str, artist: str, limit: int = 10) -> list[PluginLyricsR def get_lyrics(self, result: PluginLyricsResult) -> str | None: try: - return self._api.get_lyrics(result.song_id) + return self._provider.get_lyrics(result.song_id) except Exception: return None diff --git a/tests/test_services/test_lyrics_sources_perf_paths.py b/tests/test_services/test_lyrics_sources_perf_paths.py index ec09635c..6512fa3f 100644 --- a/tests/test_services/test_lyrics_sources_perf_paths.py +++ b/tests/test_services/test_lyrics_sources_perf_paths.py @@ -2,27 +2,29 @@ from types import SimpleNamespace -from plugins.builtin.qqmusic.lib.api import QQMusicPluginAPI from plugins.builtin.qqmusic.lib.lyrics_source import QQMusicLyricsPluginSource +from plugins.builtin.qqmusic.lib.provider import QQMusicOnlineProvider def test_qqmusic_lyrics_source_search_builds_results(monkeypatch): monkeypatch.setattr( - QQMusicPluginAPI, + QQMusicOnlineProvider, "search", - lambda *_args, **_kwargs: [ - { - "mid": "song-1", - "title": "Song 1", - "singer": "Singer 1", - "album": "Album 1", - "interval": 180, - "album_mid": "album-1", - } - ], + lambda *_args, **_kwargs: { + "tracks": [ + { + "mid": "song-1", + "title": "Song 1", + "artist": "Singer 1", + "album": "Album 1", + "duration": 180, + "album_mid": "album-1", + } + ] + }, ) monkeypatch.setattr( - QQMusicPluginAPI, + QQMusicOnlineProvider, "get_cover_url", lambda *_args, **_kwargs: "cover-1", ) diff --git a/tests/test_services/test_qqmusic_plugin_source_adapters.py b/tests/test_services/test_qqmusic_plugin_source_adapters.py index 59d38f32..f682a360 100644 --- a/tests/test_services/test_qqmusic_plugin_source_adapters.py +++ b/tests/test_services/test_qqmusic_plugin_source_adapters.py @@ -4,6 +4,7 @@ from plugins.builtin.qqmusic.lib.artist_cover_source import QQMusicArtistCoverPluginSource from plugins.builtin.qqmusic.lib.cover_source import QQMusicCoverPluginSource from plugins.builtin.qqmusic.lib.lyrics_source import QQMusicLyricsPluginSource +from plugins.builtin.qqmusic.lib.provider import QQMusicOnlineProvider def test_qqmusic_api_search_artist_uses_singer_search(monkeypatch): @@ -34,19 +35,19 @@ def fake_search(self, keyword, search_type="song", limit=20, page=1): def test_qqmusic_lyrics_source_search_reads_tracks_payload(monkeypatch): captured = {} - def fake_search(self, keyword, search_type="song", limit=20, page=1): + def fake_search(self, keyword, search_type="song", page=1, page_size=30): captured.update( keyword=keyword, search_type=search_type, - limit=limit, page=page, + page_size=page_size, ) return { "tracks": [ { "mid": "song-1", "title": "Song 1", - "singer": "Singer 1", + "artist": "Singer 1", "album": "Album 1", "album_mid": "album-1", "duration": 180, @@ -54,9 +55,9 @@ def fake_search(self, keyword, search_type="song", limit=20, page=1): ] } - monkeypatch.setattr(QQMusicPluginAPI, "search", fake_search) + monkeypatch.setattr(QQMusicOnlineProvider, "search", fake_search) monkeypatch.setattr( - QQMusicPluginAPI, + QQMusicOnlineProvider, "get_cover_url", lambda *_args, **_kwargs: "cover-1", ) @@ -68,8 +69,8 @@ def fake_search(self, keyword, search_type="song", limit=20, page=1): assert captured == { "keyword": "Song 1 Singer 1", "search_type": "song", - "limit": 7, "page": 1, + "page_size": 7, } assert len(results) == 1 assert results[0].song_id == "song-1" @@ -81,16 +82,16 @@ def fake_search(self, keyword, search_type="song", limit=20, page=1): def test_qqmusic_cover_source_search_reads_tracks_payload(monkeypatch): - def fake_search(self, keyword, search_type="song", limit=20, page=1): + def fake_search(self, keyword, search_type="song", page=1, page_size=30): assert keyword == "Singer 1 Song 1" assert search_type == "song" - assert limit == 5 assert page == 1 + assert page_size == 5 return { "tracks": [ { "mid": "song-1", - "name": "Song 1", + "title": "Song 1", "artist": "Singer 1", "album": "Album 1", "album_mid": "album-1", @@ -99,7 +100,7 @@ def fake_search(self, keyword, search_type="song", limit=20, page=1): ] } - monkeypatch.setattr(QQMusicPluginAPI, "search", fake_search) + monkeypatch.setattr(QQMusicOnlineProvider, "search", fake_search) source = QQMusicCoverPluginSource(SimpleNamespace()) @@ -114,6 +115,30 @@ def fake_search(self, keyword, search_type="song", limit=20, page=1): assert results[0].extra_id == "album-1" +def test_qqmusic_lyrics_source_get_lyrics_uses_provider(monkeypatch): + monkeypatch.setattr( + QQMusicOnlineProvider, + "get_lyrics", + lambda self, song_mid: f"lyrics:{song_mid}", + ) + + source = QQMusicLyricsPluginSource(SimpleNamespace()) + + assert source.get_lyrics_by_song_id("song-1") == "lyrics:song-1" + + +def test_qqmusic_cover_source_get_cover_url_uses_provider(monkeypatch): + monkeypatch.setattr( + QQMusicOnlineProvider, + "get_cover_url", + lambda self, mid=None, album_mid=None, size=500: f"cover:{album_mid or mid}:{size}", + ) + + source = QQMusicCoverPluginSource(SimpleNamespace()) + + assert source.get_cover_url(mid="song-1", album_mid="album-1", size=700) == "cover:album-1:700" + + def test_qqmusic_artist_cover_source_search_reads_normalized_artist_payload(monkeypatch): monkeypatch.setattr( QQMusicPluginAPI, From 995ec6b0fc03bd6bacfc7078b022b5095ff3ffef Mon Sep 17 00:00:00 2001 From: Har01d Date: Wed, 8 Apr 2026 18:53:16 +0800 Subject: [PATCH 107/157] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E5=9C=A8=E7=BA=BFpro?= =?UTF-8?q?vider=5Fid=E9=80=8F=E4=BC=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- plugins/builtin/qqmusic/lib/online_music_view.py | 1 + tests/test_ui/test_main_window_components.py | 4 ++++ tests/test_ui/test_online_music_view_async.py | 1 + ui/windows/components/online_music_handler.py | 11 +++++++---- 4 files changed, 13 insertions(+), 4 deletions(-) diff --git a/plugins/builtin/qqmusic/lib/online_music_view.py b/plugins/builtin/qqmusic/lib/online_music_view.py index cc8c21cb..099dbf64 100644 --- a/plugins/builtin/qqmusic/lib/online_music_view.py +++ b/plugins/builtin/qqmusic/lib/online_music_view.py @@ -2798,6 +2798,7 @@ def _get_cover_url(self, track: OnlineTrack) -> str: def _build_track_metadata(self, track: OnlineTrack) -> Dict[str, Any]: """Build standardized metadata payload for online track playback/queue actions.""" return { + "provider_id": "qqmusic", "title": track.title, "artist": track.singer_name, "album": track.album_name, diff --git a/tests/test_ui/test_main_window_components.py b/tests/test_ui/test_main_window_components.py index b87b04d4..fa2c783a 100644 --- a/tests/test_ui/test_main_window_components.py +++ b/tests/test_ui/test_main_window_components.py @@ -188,6 +188,10 @@ def test_add_to_queue(self, qapp): mock_playback.engine.add_track.assert_called_once() mock_playback._schedule_save_queue.assert_called_once() + def test_resolve_provider_id_does_not_fallback_to_placeholder(self, qapp): + """Missing provider metadata should not invent a non-existent provider id.""" + assert OnlineMusicHandler._resolve_provider_id(None, {}) == "" + def test_play_online_tracks_respects_shuffle_mode(self, qapp): """Batch online playback should preserve shuffle semantics.""" mock_playback = Mock() diff --git a/tests/test_ui/test_online_music_view_async.py b/tests/test_ui/test_online_music_view_async.py index 3a7a2ffa..536475b7 100644 --- a/tests/test_ui/test_online_music_view_async.py +++ b/tests/test_ui/test_online_music_view_async.py @@ -432,6 +432,7 @@ def test_build_track_metadata_uses_unified_fields(): "artist": "Singer", "album": "Album", "duration": 210, + "provider_id": "qqmusic", "album_mid": "album-mid", "cover_url": "https://y.qq.com/music/photo_new/T002R300x300M000album-mid.jpg", } diff --git a/ui/windows/components/online_music_handler.py b/ui/windows/components/online_music_handler.py index ab5783f0..06dc3afc 100644 --- a/ui/windows/components/online_music_handler.py +++ b/ui/windows/components/online_music_handler.py @@ -57,10 +57,13 @@ def set_download_service(self, service: "OnlineDownloadGateway"): def _resolve_provider_id(provider_id: str | None, metadata: dict | None) -> str: """Resolve online provider id from explicit argument or metadata.""" if provider_id: - return provider_id - if metadata and metadata.get("provider_id"): - return str(metadata.get("provider_id")) - return "online" + return str(provider_id).strip() + if metadata: + for key in ("provider_id", "source_id", "source", "provider"): + value = metadata.get(key) + if value: + return str(value).strip() + return "" def _show_status(self, message: str): """Show status message.""" From 68eadbaedecad79e7f500e0fe4f15ffce635e68a Mon Sep 17 00:00:00 2001 From: Har01d Date: Wed, 8 Apr 2026 18:57:55 +0800 Subject: [PATCH 108/157] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E5=9C=A8=E7=BA=BF?= =?UTF-8?q?=E9=98=9F=E5=88=97provider=E5=8D=A0=E4=BD=8D=E5=80=BC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../builtin/qqmusic/lib/online_music_view.py | 28 ++++++++++++++----- services/download/online_download_gateway.py | 11 ++++++-- .../test_online_download_gateway.py | 7 +++++ tests/test_ui/test_main_window_components.py | 8 ++++++ tests/test_ui/test_online_music_view_async.py | 26 ++++++++++++++++- ui/windows/components/online_music_handler.py | 10 ++++--- 6 files changed, 76 insertions(+), 14 deletions(-) diff --git a/plugins/builtin/qqmusic/lib/online_music_view.py b/plugins/builtin/qqmusic/lib/online_music_view.py index 099dbf64..1a5b3d80 100644 --- a/plugins/builtin/qqmusic/lib/online_music_view.py +++ b/plugins/builtin/qqmusic/lib/online_music_view.py @@ -2868,8 +2868,9 @@ def _play_track(self, track: OnlineTrack): metadata = self._build_track_metadata(track) # Check cache - if self._download_service.is_cached(track.mid): - cached_path = self._download_service.get_cached_path(track.mid) + provider_id = self.provider_id + if self._download_service.is_cached(track.mid, provider_id=provider_id): + cached_path = self._download_service.get_cached_path(track.mid, provider_id=provider_id) self.play_online_track.emit(track.mid, cached_path, metadata) return @@ -2892,7 +2893,10 @@ def _download_and_play(self, track: OnlineTrack): # Create download worker self._download_worker = DownloadWorker( - self._download_service, track.mid, track.title + self._download_service, + track.mid, + track.title, + provider_id=self.provider_id, ) self._download_worker.download_finished.connect(self._on_download_finished) self._attach_download_worker_cleanup( @@ -3018,12 +3022,17 @@ def _download_selected_tracks(self, tracks: List[OnlineTrack]): # Download each track for track in tracks: - if not self._download_service.is_cached(track.mid): + if not self._download_service.is_cached(track.mid, provider_id=self.provider_id): self._start_download(track) def _start_download(self, track: OnlineTrack): """Start downloading a track.""" - worker = DownloadWorker(self._download_service, track.mid, track.title) + worker = DownloadWorker( + self._download_service, + track.mid, + track.title, + provider_id=self.provider_id, + ) worker.download_finished.connect(self._on_batch_download_finished) self._attach_download_worker_cleanup(worker, list_attr="_download_workers") worker.start() @@ -3368,11 +3377,12 @@ class DownloadWorker(QThread): download_finished = Signal(str, str) # (song_mid, local_path) - def __init__(self, download_service, song_mid: str, song_title: str): + def __init__(self, download_service, song_mid: str, song_title: str, provider_id: str | None = None): super().__init__() self._download_service = download_service self._song_mid = song_mid self._song_title = song_title + self._provider_id = provider_id self._cancelled = False def cancel(self): @@ -3385,7 +3395,11 @@ def run(self): self.download_finished.emit(self._song_mid, "") return try: - result = self._download_service.download(self._song_mid, self._song_title) + result = self._download_service.download( + self._song_mid, + self._song_title, + provider_id=self._provider_id, + ) self.download_finished.emit(self._song_mid, result or "") except Exception as e: logger.error(f"Download worker error: {e}") diff --git a/services/download/online_download_gateway.py b/services/download/online_download_gateway.py index adf3fee8..ecc3a3b7 100644 --- a/services/download/online_download_gateway.py +++ b/services/download/online_download_gateway.py @@ -77,8 +77,15 @@ def _get_provider(self, provider_id: str | None = None): if manager is None: return None providers = manager.registry.online_providers() + normalized_provider_id = str(provider_id or "").strip() + if normalized_provider_id.lower() == "online": + normalized_provider_id = "" + if len(providers) == 1: + provider = providers[0] + if callable(getattr(provider, "download_track", None)): + return provider for provider in providers: - if provider_id and getattr(provider, "provider_id", None) != provider_id: + if normalized_provider_id and getattr(provider, "provider_id", None) != normalized_provider_id: continue if callable(getattr(provider, "download_track", None)): return provider @@ -199,7 +206,7 @@ def download( provider = self._get_provider(provider_id) if provider is None: - logger.error("[OnlineDownloadGateway] No online provider available") + logger.error(f"[OnlineDownloadGateway] [{provider_id}] No online provider available") return None if self._event_bus and hasattr(self._event_bus, "download_started"): diff --git a/tests/test_services/test_online_download_gateway.py b/tests/test_services/test_online_download_gateway.py index c419417d..b785fea0 100644 --- a/tests/test_services/test_online_download_gateway.py +++ b/tests/test_services/test_online_download_gateway.py @@ -46,6 +46,13 @@ def test_get_provider_matches_provider_id(tmp_path): assert gateway._get_provider("missing") is None +def test_get_provider_treats_legacy_online_placeholder_as_unspecified_when_single_provider(tmp_path): + provider = MagicMock(provider_id="qqmusic") + gateway = _build_gateway(tmp_path, provider=provider) + + assert gateway._get_provider("online") is provider + + def test_get_download_qualities_is_provider_aware(tmp_path): provider = MagicMock() provider.provider_id = "qqmusic" diff --git a/tests/test_ui/test_main_window_components.py b/tests/test_ui/test_main_window_components.py index fa2c783a..e91b5277 100644 --- a/tests/test_ui/test_main_window_components.py +++ b/tests/test_ui/test_main_window_components.py @@ -192,6 +192,14 @@ def test_resolve_provider_id_does_not_fallback_to_placeholder(self, qapp): """Missing provider metadata should not invent a non-existent provider id.""" assert OnlineMusicHandler._resolve_provider_id(None, {}) == "" + def test_resolve_provider_id_ignores_placeholder_source(self, qapp): + """Legacy placeholder source should not override a real provider id.""" + assert OnlineMusicHandler._resolve_provider_id(None, {"source": "online"}) == "" + assert OnlineMusicHandler._resolve_provider_id( + None, + {"source": "online", "provider_id": "qqmusic"}, + ) == "qqmusic" + def test_play_online_tracks_respects_shuffle_mode(self, qapp): """Batch online playback should preserve shuffle semantics.""" mock_playback = Mock() diff --git a/tests/test_ui/test_online_music_view_async.py b/tests/test_ui/test_online_music_view_async.py index 536475b7..49d1cb1f 100644 --- a/tests/test_ui/test_online_music_view_async.py +++ b/tests/test_ui/test_online_music_view_async.py @@ -4,7 +4,7 @@ from domain.online_music import OnlineTrack, SearchResult, SearchType from plugins.builtin.qqmusic.lib import i18n as plugin_i18n -from plugins.builtin.qqmusic.lib.online_music_view import OnlineMusicView +from plugins.builtin.qqmusic.lib.online_music_view import DownloadWorker, OnlineMusicView import plugins.builtin.qqmusic.lib.online_music_view as online_music_view from tests.test_plugins.qqmusic_test_context import bind_test_context @@ -467,6 +467,30 @@ def test_build_tracks_payload_keeps_order_and_metadata(): assert payload[1][1]["title"] == "Song 2" +def test_download_worker_passes_provider_id_to_gateway(): + download_service = Mock() + download_service.download.return_value = "/tmp/song.mp3" + worker = DownloadWorker( + download_service, + "song-mid", + "Song", + provider_id="qqmusic", + ) + captured = [] + worker.download_finished.connect( + lambda song_mid, local_path: captured.append((song_mid, local_path)) + ) + + worker.run() + + download_service.download.assert_called_once_with( + "song-mid", + "Song", + provider_id="qqmusic", + ) + assert captured == [("song-mid", "/tmp/song.mp3")] + + def test_attach_download_worker_cleanup_clears_single_worker_reference(): """Single download worker references should be released after finish.""" view = OnlineMusicView.__new__(OnlineMusicView) diff --git a/ui/windows/components/online_music_handler.py b/ui/windows/components/online_music_handler.py index 06dc3afc..97fb0b35 100644 --- a/ui/windows/components/online_music_handler.py +++ b/ui/windows/components/online_music_handler.py @@ -56,13 +56,15 @@ def set_download_service(self, service: "OnlineDownloadGateway"): @staticmethod def _resolve_provider_id(provider_id: str | None, metadata: dict | None) -> str: """Resolve online provider id from explicit argument or metadata.""" - if provider_id: - return str(provider_id).strip() + normalized = str(provider_id or "").strip() + if normalized and normalized.lower() != "online": + return normalized if metadata: for key in ("provider_id", "source_id", "source", "provider"): value = metadata.get(key) - if value: - return str(value).strip() + normalized = str(value or "").strip() + if normalized and normalized.lower() != "online": + return normalized return "" def _show_status(self, message: str): From 47e3d9e4184154d8e465aefa836ea1db89424d2b Mon Sep 17 00:00:00 2001 From: Har01d Date: Wed, 8 Apr 2026 19:12:00 +0800 Subject: [PATCH 109/157] =?UTF-8?q?=E8=BF=81=E7=A7=BB=E6=97=A7QQ=E5=9C=A8?= =?UTF-8?q?=E7=BA=BFprovider=E6=95=B0=E6=8D=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- infrastructure/database/sqlite_manager.py | 44 +++++- repositories/queue_repository.py | 31 ++++- repositories/track_repository.py | 64 ++++++++- services/playback/playback_service.py | 25 +++- .../test_sqlite_manager_migration.py | 129 ++++++++++++++++++ .../test_queue_repository.py | 46 +++++++ .../test_track_repository.py | 72 +++++++++- .../test_playback_service_online_failures.py | 48 +++++++ 8 files changed, 442 insertions(+), 17 deletions(-) diff --git a/infrastructure/database/sqlite_manager.py b/infrastructure/database/sqlite_manager.py index a2faa65f..975dac55 100644 --- a/infrastructure/database/sqlite_manager.py +++ b/infrastructure/database/sqlite_manager.py @@ -731,7 +731,7 @@ def _get_track_source_from_row(self, row) -> TrackSource: def _run_migrations(self, conn, cursor): """Run database migrations for schema updates.""" # Current schema version - increment when making schema changes - CURRENT_SCHEMA_VERSION = 10 + CURRENT_SCHEMA_VERSION = 11 # Create db_meta table for schema version tracking cursor.execute(""" @@ -1057,6 +1057,48 @@ def _run_migrations(self, conn, cursor): """) logger.info("[Database] Added unique indexes for UPSERT support") + # Migration 10: Repair legacy QQ online-provider rows. + if stored_version < 11: + cursor.execute(""" + UPDATE tracks + SET source = 'ONLINE', + online_provider_id = 'qqmusic' + WHERE UPPER(COALESCE(source, '')) = 'QQ' + AND ( + online_provider_id IS NULL + OR TRIM(online_provider_id) = '' + OR LOWER(online_provider_id) = 'online' + ) + """) + cursor.execute(""" + UPDATE tracks + SET online_provider_id = 'qqmusic' + WHERE UPPER(COALESCE(source, '')) = 'ONLINE' + AND LOWER(COALESCE(path, '')) LIKE 'online://qqmusic/%' + AND ( + online_provider_id IS NULL + OR TRIM(online_provider_id) = '' + OR LOWER(online_provider_id) = 'online' + ) + """) + cursor.execute(""" + UPDATE play_queue + SET online_provider_id = 'qqmusic' + WHERE UPPER(COALESCE(source, '')) = 'ONLINE' + AND LOWER(COALESCE(online_provider_id, '')) = 'online' + AND cloud_file_id IN ( + SELECT cloud_file_id + FROM tracks + WHERE online_provider_id = 'qqmusic' + ) + """) + cursor.execute(""" + UPDATE play_queue + SET online_provider_id = NULL + WHERE LOWER(COALESCE(online_provider_id, '')) = 'online' + """) + logger.info("[Database] Repaired legacy QQ online provider ids") + # Update schema version after all migrations complete if schema_changed: cursor.execute( diff --git a/repositories/queue_repository.py b/repositories/queue_repository.py index b1a3dd27..538f9578 100644 --- a/repositories/queue_repository.py +++ b/repositories/queue_repository.py @@ -20,6 +20,13 @@ class SqliteQueueRepository(BaseRepository): def __init__(self, db_path: str = "Harmony.db", db_manager: "DatabaseManager" = None): super().__init__(db_path, db_manager) + @staticmethod + def _normalize_online_provider_id(value): + normalized = str(value or "").strip() + if not normalized or normalized.lower() == "online": + return None + return normalized + def load(self) -> List[PlayQueueItem]: """Load the saved play queue.""" conn = self._get_connection() @@ -55,14 +62,16 @@ def get_download_failed(row, columns): return bool(row["download_failed"]) return False - return [ + normalized_items = [ PlayQueueItem( id=row["id"], position=row["position"], source=get_source(row, columns), track_id=row["track_id"], cloud_file_id=row["cloud_file_id"], - online_provider_id=row["online_provider_id"] if "online_provider_id" in columns else None, + online_provider_id=self._normalize_online_provider_id( + row["online_provider_id"] if "online_provider_id" in columns else None + ), cloud_account_id=row["cloud_account_id"], local_path=row["local_path"] or "", title=row["title"] or "", @@ -76,6 +85,21 @@ def get_download_failed(row, columns): ) for row in rows ] + if "online_provider_id" in columns: + repair_ids = [ + row["id"] + for row in rows + if self._normalize_online_provider_id(row["online_provider_id"]) is None + and str(row["online_provider_id"] or "").strip().lower() == "online" + ] + if repair_ids: + placeholders = ",".join("?" * len(repair_ids)) + cursor.execute( + f"UPDATE play_queue SET online_provider_id = NULL WHERE id IN ({placeholders})", + repair_ids, + ) + conn.commit() + return normalized_items def save(self, items: List[PlayQueueItem]) -> bool: """Save the play queue.""" @@ -93,7 +117,8 @@ def save(self, items: List[PlayQueueItem]) -> bool: VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) """, [ (item.position, item.source, item.track_id, - item.cloud_file_id, item.online_provider_id, item.cloud_account_id, item.local_path, + item.cloud_file_id, self._normalize_online_provider_id(item.online_provider_id), + item.cloud_account_id, item.local_path, item.title, item.artist, item.album, item.duration, (item.created_at or datetime.now()).isoformat(sep=" "), int(item.download_failed)) diff --git a/repositories/track_repository.py b/repositories/track_repository.py index e0b6b13a..47a3ad10 100644 --- a/repositories/track_repository.py +++ b/repositories/track_repository.py @@ -30,6 +30,25 @@ class SqliteTrackRepository(BaseRepository): def __init__(self, db_path: str = "Harmony.db", db_manager: "DatabaseManager" = None): super().__init__(db_path, db_manager) + @staticmethod + def _normalize_online_provider_id(value): + normalized = str(value or "").strip() + if not normalized or normalized.lower() == "online": + return None + return normalized + + @staticmethod + def _infer_online_provider_id(source_value: str | None, path: str | None, provider_id: str | None): + normalized = SqliteTrackRepository._normalize_online_provider_id(provider_id) + if normalized: + return normalized + if str(source_value or "").strip().upper() == "QQ": + return "qqmusic" + path_value = str(path or "").strip().lower() + if path_value.startswith("online://qqmusic/"): + return "qqmusic" + return None + @staticmethod def _build_safe_fts_query(query: str) -> Optional[str]: """Normalize user input into a literal-term FTS query.""" @@ -298,7 +317,7 @@ def add(self, track: Track) -> TrackId: track.genre, track.duration, track.cover_path, track.cloud_file_id, track.source.value if hasattr(track, 'source') and track.source else 'Local', - track.online_provider_id, + self._normalize_online_provider_id(track.online_provider_id), )) track_id = cursor.lastrowid @@ -355,7 +374,7 @@ def batch_add(self, tracks: List[Track]) -> int: track.genre, track.duration, track.cover_path, track.cloud_file_id, track.source.value if hasattr(track, 'source') and track.source else 'Local', - track.online_provider_id, + self._normalize_online_provider_id(track.online_provider_id), )) track_id = cursor.lastrowid @@ -412,7 +431,7 @@ def update(self, track: Track) -> bool: track.genre, track.duration, track.cover_path, track.cloud_file_id, track.source.value if hasattr(track, 'source') and track.source else 'Local', - track.online_provider_id, + self._normalize_online_provider_id(track.online_provider_id), track.id )) conn.commit() @@ -463,6 +482,24 @@ def get_by_cloud_file_id( "SELECT * FROM tracks WHERE cloud_file_id = ? AND online_provider_id = ?", (cloud_file_id, provider_id), ) + row = cursor.fetchone() + if row: + return self._row_to_track(row) + cursor.execute( + """ + SELECT * FROM tracks + WHERE cloud_file_id = ? + AND (online_provider_id IS NULL OR TRIM(online_provider_id) = '' OR LOWER(online_provider_id) = 'online') + ORDER BY CASE + WHEN UPPER(COALESCE(source, '')) = 'QQ' THEN 0 + WHEN LOWER(COALESCE(path, '')) LIKE ? THEN 1 + ELSE 2 + END, + id DESC + LIMIT 1 + """, + (cloud_file_id, f"online://{str(provider_id).strip().lower()}/%"), + ) else: cursor.execute("SELECT * FROM tracks WHERE cloud_file_id = ?", (cloud_file_id,)) row = cursor.fetchone() @@ -476,6 +513,25 @@ def _row_to_track(self, row: sqlite3.Row) -> Track: # Get source value from row, default to Local if not present source_value = row["source"] if "source" in row.keys() else "Local" source = TrackSource.from_value(source_value) + online_provider_id = self._infer_online_provider_id( + source_value, + row["path"] if "path" in row.keys() else "", + row["online_provider_id"] if "online_provider_id" in row.keys() else None, + ) + if ( + "online_provider_id" in row.keys() + and ( + online_provider_id != (row["online_provider_id"] if "online_provider_id" in row.keys() else None) + or str(source_value or "").strip().upper() == "QQ" + ) + ): + conn = self._get_connection() + cursor = conn.cursor() + cursor.execute( + "UPDATE tracks SET source = ?, online_provider_id = ? WHERE id = ?", + (TrackSource.ONLINE.value, online_provider_id, row["id"]), + ) + conn.commit() return Track( id=row["id"], @@ -488,7 +544,7 @@ def _row_to_track(self, row: sqlite3.Row) -> Track: cover_path=row["cover_path"], cloud_file_id=row["cloud_file_id"], source=source, - online_provider_id=row["online_provider_id"] if "online_provider_id" in row.keys() else None, + online_provider_id=online_provider_id, file_size=row["file_size"] if "file_size" in row.keys() else None, file_mtime=row["file_mtime"] if "file_mtime" in row.keys() else None, ) diff --git a/services/playback/playback_service.py b/services/playback/playback_service.py index 44c2ff3e..d3358e23 100644 --- a/services/playback/playback_service.py +++ b/services/playback/playback_service.py @@ -1657,6 +1657,7 @@ def _save_online_track_to_library(self, song_mid: str, local_path: str) -> Optio Track ID if saved successfully """ from pathlib import Path + from domain.track import TrackSource from services.metadata.metadata_service import MetadataService if not local_path or not Path(local_path).exists(): @@ -1671,10 +1672,24 @@ def _save_online_track_to_library(self, song_mid: str, local_path: str) -> Optio existing = self._track_repo.get_by_cloud_file_id(song_mid, provider_id=provider_id) if existing: - # Update existing track with local path - self._track_repo.update_path(existing.id, local_path) - logger.info(f"[PlaybackService] Updated existing track {existing.id} with local path") - return existing.id + # Update existing track with local path, but reuse an existing path-owned + # record when the downloaded file is already indexed elsewhere. + try: + self._track_repo.update_path(existing.id, local_path) + logger.info(f"[PlaybackService] Updated existing track {existing.id} with local path") + return existing.id + except Exception: + existing_by_path = self._track_repo.get_by_path(local_path) + if existing_by_path and existing_by_path.id != existing.id: + existing_by_path.cloud_file_id = song_mid + existing_by_path.online_provider_id = provider_id + existing_by_path.source = TrackSource.ONLINE + self._track_repo.update(existing_by_path) + logger.info( + f"[PlaybackService] Reused existing path track {existing_by_path.id} for online download" + ) + return existing_by_path.id + raise # Extract metadata from file metadata = MetadataService.extract_metadata(local_path) @@ -1690,7 +1705,7 @@ def _save_online_track_to_library(self, song_mid: str, local_path: str) -> Optio return existing.id # Create new track - from domain.track import Track, TrackSource + from domain.track import Track track = Track( path=local_path, title=title, diff --git a/tests/test_infrastructure/test_sqlite_manager_migration.py b/tests/test_infrastructure/test_sqlite_manager_migration.py index 930933d2..e3f7d71e 100644 --- a/tests/test_infrastructure/test_sqlite_manager_migration.py +++ b/tests/test_infrastructure/test_sqlite_manager_migration.py @@ -51,3 +51,132 @@ def test_init_database_handles_legacy_tracks_without_genre_column(): os.unlink(db_path) except OSError: pass + + +def test_init_database_migrates_legacy_qq_online_provider_rows(): + """Database init should repair legacy QQ online provider ids in tracks and queue.""" + fd, db_path = tempfile.mkstemp(suffix=".db") + os.close(fd) + + try: + conn = sqlite3.connect(db_path) + cursor = conn.cursor() + cursor.execute(""" + CREATE TABLE tracks ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + path TEXT UNIQUE NOT NULL, + title TEXT, + artist TEXT, + album TEXT, + duration REAL DEFAULT 0, + cover_path TEXT, + cloud_file_id TEXT, + source TEXT DEFAULT 'Local', + online_provider_id TEXT, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP + ) + """) + cursor.execute(""" + CREATE VIRTUAL TABLE IF NOT EXISTS tracks_fts USING fts5( + title, artist, album, + content='tracks', content_rowid='id' + ) + """) + cursor.execute(""" + CREATE TABLE play_queue ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + position INTEGER NOT NULL, + source TEXT NOT NULL, + track_id INTEGER, + cloud_file_id TEXT, + online_provider_id TEXT, + cloud_account_id INTEGER, + local_path TEXT, + title TEXT, + artist TEXT, + album TEXT, + duration REAL, + download_failed INTEGER DEFAULT 0, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP + ) + """) + cursor.execute(""" + CREATE TABLE favorites ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + track_id INTEGER, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP + ) + """) + cursor.execute(""" + CREATE TABLE artists ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT UNIQUE NOT NULL + ) + """) + cursor.execute(""" + CREATE TABLE genres ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL UNIQUE, + cover_path TEXT, + song_count INTEGER DEFAULT 0, + album_count INTEGER DEFAULT 0 + ) + """) + cursor.execute(""" + CREATE TABLE play_history ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + track_id INTEGER + ) + """) + cursor.execute("CREATE TABLE db_meta (key TEXT PRIMARY KEY, value TEXT)") + cursor.execute("INSERT INTO db_meta (key, value) VALUES ('schema_version', '10')") + cursor.execute(""" + INSERT INTO tracks (path, title, cloud_file_id, source, online_provider_id) + VALUES (?, ?, ?, ?, ?) + """, ("/music/song.flac", "Legacy QQ", "qq_mid", "QQ", None)) + cursor.execute(""" + INSERT INTO play_queue (position, source, cloud_file_id, online_provider_id, title) + VALUES (?, ?, ?, ?, ?) + """, (0, "ONLINE", "qq_mid", "online", "Legacy QQ")) + conn.commit() + conn.close() + + conn = sqlite3.connect(db_path) + cursor = conn.cursor() + + class _CursorProxy: + def __init__(self, inner): + self._inner = inner + + def execute(self, sql, params=()): + normalized = " ".join(sql.split()).lower() + if normalized.startswith("delete from tracks_fts") or normalized.startswith( + "insert into tracks_fts" + ): + return self + self._inner.execute(sql, params) + return self + + def fetchone(self): + return self._inner.fetchone() + + def fetchall(self): + return self._inner.fetchall() + + cursor_proxy = _CursorProxy(cursor) + manager = DatabaseManager.__new__(DatabaseManager) + DatabaseManager._run_migrations(manager, conn, cursor_proxy) + conn.commit() + cursor.execute("SELECT source, online_provider_id FROM tracks WHERE cloud_file_id = ?", ("qq_mid",)) + track_row = cursor.fetchone() + cursor.execute("SELECT online_provider_id FROM play_queue WHERE cloud_file_id = ?", ("qq_mid",)) + queue_row = cursor.fetchone() + conn.close() + + assert track_row == ("ONLINE", "qqmusic") + assert queue_row == ("qqmusic",) + finally: + try: + os.unlink(db_path) + except OSError: + pass diff --git a/tests/test_repositories/test_queue_repository.py b/tests/test_repositories/test_queue_repository.py index 01bb8a72..8066d589 100644 --- a/tests/test_repositories/test_queue_repository.py +++ b/tests/test_repositories/test_queue_repository.py @@ -185,6 +185,28 @@ def test_save_online_items(self, queue_repo): assert loaded[0].source == "ONLINE" assert loaded[0].cloud_file_id == "song_mid_123" + def test_save_normalizes_placeholder_online_provider_id(self, queue_repo, temp_db): + """Saving queue items should not persist the legacy placeholder provider id.""" + items = [ + PlayQueueItem( + position=0, + source="ONLINE", + cloud_file_id="song_mid_123", + online_provider_id="online", + title="Online Song", + ) + ] + + assert queue_repo.save(items) is True + + conn = sqlite3.connect(temp_db) + cursor = conn.cursor() + cursor.execute("SELECT online_provider_id FROM play_queue") + row = cursor.fetchone() + conn.close() + + assert row[0] is None + def test_row_to_item_conversion(self, queue_repo): """Test conversion from database row to PlayQueueItem.""" items = [ @@ -346,6 +368,30 @@ def test_load_with_optional_fields_null(self, queue_repo, temp_db): assert loaded[0].download_failed is False assert loaded[0].created_at is not None + def test_load_normalizes_legacy_online_provider_placeholder(self, queue_repo, temp_db): + """Loading old queue rows should repair placeholder provider ids in memory and in DB.""" + conn = sqlite3.connect(temp_db) + cursor = conn.cursor() + cursor.execute(""" + INSERT INTO play_queue (position, source, cloud_file_id, online_provider_id, title, created_at) + VALUES (?, ?, ?, ?, ?, ?) + """, (0, "ONLINE", "song_mid_123", "online", "Online Song", "2026-04-08 00:00:00")) + conn.commit() + conn.close() + + loaded = queue_repo.load() + + assert len(loaded) == 1 + assert loaded[0].online_provider_id is None + + conn = sqlite3.connect(temp_db) + cursor = conn.cursor() + cursor.execute("SELECT online_provider_id FROM play_queue") + row = cursor.fetchone() + conn.close() + + assert row[0] is None + def test_load_old_schema_source_type_local(self, temp_db): """Test loading from old schema with source_type='local' maps to 'Local'.""" # Create old schema table diff --git a/tests/test_repositories/test_track_repository.py b/tests/test_repositories/test_track_repository.py index a6e369a4..1578d5ed 100644 --- a/tests/test_repositories/test_track_repository.py +++ b/tests/test_repositories/test_track_repository.py @@ -252,6 +252,24 @@ def test_get_all_can_filter_by_source(self, track_repo): assert len(tracks) == 1 assert tracks[0].title == "Online" + def test_add_normalizes_placeholder_online_provider_id(self, track_repo, temp_db): + """Adding online tracks should not persist the legacy placeholder provider id.""" + track_repo.add(Track( + path="online://online/track/abc", + title="Online", + source=TrackSource.ONLINE, + online_provider_id="online", + cloud_file_id="abc", + )) + + conn = sqlite3.connect(temp_db) + cursor = conn.cursor() + cursor.execute("SELECT online_provider_id FROM tracks WHERE cloud_file_id = ?", ("abc",)) + row = cursor.fetchone() + conn.close() + + assert row[0] is None + def test_update_track(self, track_repo): """Test updating a track.""" track = Track( @@ -268,10 +286,56 @@ def test_update_track(self, track_repo): result = track_repo.update(track) assert result is True - # Verify update - updated = track_repo.get_by_id(track_id) - assert updated.title == "Updated Title" - assert updated.artist == "Updated Artist" + def test_get_by_id_repairs_legacy_placeholder_online_provider_id(self, track_repo, temp_db): + """Reading old online tracks should normalize and repair placeholder provider ids.""" + conn = sqlite3.connect(temp_db) + cursor = conn.cursor() + cursor.execute(""" + INSERT INTO tracks (path, title, cloud_file_id, online_provider_id, source) + VALUES (?, ?, ?, ?, ?) + """, ("online://online/track/legacy", "Legacy", "legacy", "online", "ONLINE")) + track_id = cursor.lastrowid + conn.commit() + conn.close() + + track = track_repo.get_by_id(track_id) + + assert track is not None + assert track.online_provider_id is None + + conn = sqlite3.connect(temp_db) + cursor = conn.cursor() + cursor.execute("SELECT online_provider_id FROM tracks WHERE id = ?", (track_id,)) + row = cursor.fetchone() + conn.close() + + assert row[0] is None + + def test_get_by_cloud_file_id_matches_legacy_qq_row_without_provider_id(self, track_repo, temp_db): + """QQ legacy rows without provider id should still resolve for qqmusic lookups.""" + conn = sqlite3.connect(temp_db) + cursor = conn.cursor() + cursor.execute(""" + INSERT INTO tracks (path, title, cloud_file_id, source, online_provider_id) + VALUES (?, ?, ?, ?, ?) + """, ("/music/song.flac", "Legacy QQ", "qq_legacy_mid", "QQ", None)) + track_id = cursor.lastrowid + conn.commit() + conn.close() + + track = track_repo.get_by_cloud_file_id("qq_legacy_mid", provider_id="qqmusic") + + assert track is not None + assert track.id == track_id + assert track.online_provider_id == "qqmusic" + + conn = sqlite3.connect(temp_db) + cursor = conn.cursor() + cursor.execute("SELECT online_provider_id FROM tracks WHERE id = ?", (track_id,)) + row = cursor.fetchone() + conn.close() + + assert row[0] == "qqmusic" def test_update_nonexistent_track(self, track_repo): """Test updating non-existent track.""" diff --git a/tests/test_services/test_playback_service_online_failures.py b/tests/test_services/test_playback_service_online_failures.py index 67aa84cb..d56b74c0 100644 --- a/tests/test_services/test_playback_service_online_failures.py +++ b/tests/test_services/test_playback_service_online_failures.py @@ -2,10 +2,12 @@ from __future__ import annotations +import sqlite3 import threading from unittest.mock import Mock from domain.playlist_item import PlaylistItem +from domain.track import Track from domain.track import TrackSource from services.playback.playback_service import PlaybackService @@ -79,3 +81,49 @@ def test_cleanup_download_workers_stops_running_worker_without_terminate(): worker.wait.assert_called_once_with(1000) worker.terminate.assert_not_called() assert service._online_download_workers == {} + + +def test_save_online_track_to_library_reuses_existing_path_track_on_unique_conflict(tmp_path): + service = PlaybackService.__new__(PlaybackService) + local_path = tmp_path / "song.mp3" + local_path.write_bytes(b"data") + playlist_item = PlaylistItem( + source=TrackSource.ONLINE, + online_provider_id="qqmusic", + cloud_file_id="song_mid_123", + title="Online Song", + needs_download=True, + ) + existing_cloud = Track( + id=1, + path="online://qqmusic/track/song_mid_123", + source=TrackSource.ONLINE, + cloud_file_id="song_mid_123", + online_provider_id="qqmusic", + ) + existing_path = Track( + id=2, + path=str(local_path), + title="Cached Song", + source=TrackSource.LOCAL, + ) + + service._engine = Mock() + service._engine.playlist_items = [playlist_item] + service._track_repo = Mock() + service._track_repo.get_by_cloud_file_id.return_value = existing_cloud + service._track_repo.update_path.side_effect = sqlite3.IntegrityError("UNIQUE constraint failed: tracks.path") + service._track_repo.get_by_path.return_value = existing_path + service._track_repo.update.return_value = True + + track_id = PlaybackService._save_online_track_to_library( + service, + "song_mid_123", + str(local_path), + ) + + assert track_id == 2 + assert existing_path.cloud_file_id == "song_mid_123" + assert existing_path.online_provider_id == "qqmusic" + assert existing_path.source == TrackSource.ONLINE + service._track_repo.update.assert_called_once_with(existing_path) From 2468cc54a4de172b04f5c29e6accbd4f07ab61d0 Mon Sep 17 00:00:00 2001 From: Har01d Date: Wed, 8 Apr 2026 20:21:15 +0800 Subject: [PATCH 110/157] =?UTF-8?q?=E4=BF=AE=E5=A4=8DQQ=E9=9F=B3=E4=B9=90?= =?UTF-8?q?=E4=B8=8B=E8=BD=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- plugins/builtin/qqmusic/lib/api.py | 54 ++++++++++++----- plugins/builtin/qqmusic/lib/client.py | 4 ++ .../builtin/qqmusic/lib/online_detail_view.py | 3 + .../builtin/qqmusic/lib/online_music_view.py | 3 + services/playback/playback_service.py | 2 +- .../test_ui/test_online_views_architecture.py | 60 +++++++++++++++++++ 6 files changed, 110 insertions(+), 16 deletions(-) diff --git a/plugins/builtin/qqmusic/lib/api.py b/plugins/builtin/qqmusic/lib/api.py index 5132ff43..47383eb0 100644 --- a/plugins/builtin/qqmusic/lib/api.py +++ b/plugins/builtin/qqmusic/lib/api.py @@ -2,6 +2,8 @@ from typing import Any, Optional +from plugins.builtin.qqmusic.lib.common import parse_quality + class QQMusicPluginAPI: REMOTE_BASE_URL = "https://api.ygking.top/api" @@ -10,11 +12,11 @@ def __init__(self, context): self._context = context def search( - self, - keyword: str, - search_type: str = "song", - limit: int = 20, - page: int = 1, + self, + keyword: str, + search_type: str = "song", + limit: int = 20, + page: int = 1, ) -> dict[str, Any]: response = self._context.http.get( f"{self.REMOTE_BASE_URL}/search", @@ -39,7 +41,7 @@ def search( "mid": item.get("singerMID", item.get("mid", "")), "name": item.get("singerName", item.get("name", "")), "avatar_url": item.get("singerPic", item.get("avatar", item.get("cover_url", ""))) - or self.get_artist_cover_url(item.get("singerMID", item.get("mid", ""))), + or self.get_artist_cover_url(item.get("singerMID", item.get("mid", ""))), "song_count": item.get("songNum", item.get("song_count", item.get("songnum", 0))), "album_count": item.get("albumNum", item.get("album_count", item.get("albumnum", 0))), "fan_count": item.get("fansNum", item.get("fan_count", item.get("FanNum", 0))), @@ -56,7 +58,7 @@ def search( "name": item.get("name", item.get("albumname", "")), "singer_name": self._extract_singer_name(item), "cover_url": item.get("pic", item.get("cover", item.get("cover_url", ""))) - or self.get_cover_url(album_mid=item.get("albummid", item.get("mid", ""))), + or self.get_cover_url(album_mid=item.get("albummid", item.get("mid", ""))), "song_count": item.get("song_num", item.get("song_count", 0)), "publish_date": item.get("publish_date", item.get("pubTime", "")), } @@ -125,10 +127,10 @@ def _to_non_negative_int(value: Any) -> Optional[int]: return len(items) def search_artist( - self, - keyword: str, - limit: int = 20, - page: int = 1, + self, + keyword: str, + limit: int = 20, + page: int = 1, ) -> list[dict]: return self.search( keyword, @@ -173,10 +175,10 @@ def get_lyrics(self, mid: str) -> Optional[str]: return data.get("data", {}).get("lyric") def get_cover_url( - self, - mid: str = None, - album_mid: str = None, - size: int = 500, + self, + mid: str = None, + album_mid: str = None, + size: int = 500, ) -> Optional[str]: if album_mid: return f"https://y.gtimg.cn/music/photo_new/T002R{size}x{size}M000{album_mid}.jpg" @@ -197,6 +199,28 @@ def get_artist_cover_url(self, singer_mid: str, size: int = 300) -> Optional[str return f"https://y.gtimg.cn/music/photo_new/T001R{size}x{size}M000{singer_mid}.jpg" def get_playback_url_info(self, track_id: str, quality: str) -> dict[str, str] | None: + response = self._context.http.get( + f"{self.REMOTE_BASE_URL}/song/url", + params={"mid": track_id, "quality": quality}, + timeout=15, + ) + data = response.json() + print(data) + if data.get("code") != 0: + return None + result = data.get("data", {}) + url = result.get(track_id, '') + quality = result.get(quality, '') + file_type = parse_quality(quality) + + if url: + return { + 'url': url, + 'quality': quality, + 'file_type': file_type, + 'extension': file_type.get("e"), + } + return None def get_artist_detail(self, singer_mid: str) -> dict | None: diff --git a/plugins/builtin/qqmusic/lib/client.py b/plugins/builtin/qqmusic/lib/client.py index 36cf562a..1abfc9a2 100644 --- a/plugins/builtin/qqmusic/lib/client.py +++ b/plugins/builtin/qqmusic/lib/client.py @@ -1,11 +1,14 @@ from __future__ import annotations +import logging import socket from typing import Any from .api import QQMusicPluginAPI from .qqmusic_service import QQMusicService +logger = logging.getLogger(__name__) + class QQMusicPluginClient: def __init__(self, context): @@ -258,6 +261,7 @@ def get_playback_url_info(self, track_id: str, quality: str): info = service.get_playback_url_info(track_id, quality) if info: return info + logger.debug(f"Fallback to get playback url for track {track_id}") return self._api.get_playback_url_info(track_id, quality) def get_artist_detail(self, singer_mid: str) -> dict | None: diff --git a/plugins/builtin/qqmusic/lib/online_detail_view.py b/plugins/builtin/qqmusic/lib/online_detail_view.py index a98a0b87..f4a8b3fa 100644 --- a/plugins/builtin/qqmusic/lib/online_detail_view.py +++ b/plugins/builtin/qqmusic/lib/online_detail_view.py @@ -405,6 +405,8 @@ def refresh_theme(self): class OnlineDetailView(QWidget): """Detail view for artist, album, or playlist.""" + provider_id = "qqmusic" + back_requested = Signal() play_all = Signal(list, int) # List of OnlineTrack (current page) insert_all_to_queue = Signal(list) # List of OnlineTrack (current page) @@ -2204,6 +2206,7 @@ def _add_online_track_to_library(self, track: OnlineTrack): cover_url = self._get_cover_url(track) return current_bootstrap.library_service.add_online_track( + provider_id=self.provider_id, song_mid=track.mid, title=track.title, artist=track.singer_name, diff --git a/plugins/builtin/qqmusic/lib/online_music_view.py b/plugins/builtin/qqmusic/lib/online_music_view.py index 1a5b3d80..ec682619 100644 --- a/plugins/builtin/qqmusic/lib/online_music_view.py +++ b/plugins/builtin/qqmusic/lib/online_music_view.py @@ -611,6 +611,8 @@ def keyPressEvent(self, event): class OnlineMusicView(QWidget): """View for searching and browsing online music.""" + provider_id = "qqmusic" + # Signals play_online_track = Signal(str, str, object) # (song_mid, local_path, metadata_dict) insert_to_queue = Signal(str, object) # (song_mid, metadata_dict) @@ -3102,6 +3104,7 @@ def _add_online_track_to_library(self, track: OnlineTrack) -> Optional[int]: cover_url = self._get_cover_url(track) return current_bootstrap.library_service.add_online_track( + provider_id=self.provider_id, song_mid=track.mid, title=track.title, artist=track.singer_name, diff --git a/services/playback/playback_service.py b/services/playback/playback_service.py index d3358e23..ba14ae89 100644 --- a/services/playback/playback_service.py +++ b/services/playback/playback_service.py @@ -1466,7 +1466,7 @@ def _download_online_track(self, item: PlaylistItem): return # Create worker while holding lock to prevent race condition - logger.info(f"[PlaybackService] Downloading online track: {song_mid} {item.title} - {item.artist}") + logger.info(f"[PlaybackService] Downloading online track: {item.online_provider_id} {song_mid} {item.title} - {item.artist}") # Download in background thread from PySide6.QtCore import QThread diff --git a/tests/test_ui/test_online_views_architecture.py b/tests/test_ui/test_online_views_architecture.py index c76ce0e5..972b9b76 100644 --- a/tests/test_ui/test_online_views_architecture.py +++ b/tests/test_ui/test_online_views_architecture.py @@ -91,3 +91,63 @@ def test_online_detail_view_favorites_flow_uses_favorites_service(): bootstrap.favorites_service.add_favorite.assert_called_once_with(track_id=456) bootstrap.favorites_service.remove_favorite.assert_called_once_with(track_id=654) + + +def test_online_music_view_add_online_track_to_library_passes_provider_id(): + """OnlineMusicView should persist QQMusic tracks with the provider id.""" + view = OnlineMusicView.__new__(OnlineMusicView) + view._get_cover_url = Mock(return_value="https://cover") + track = SimpleNamespace( + mid="m1", + title="Song 1", + singer_name="Artist 1", + album_name="Album 1", + duration=123, + ) + bootstrap = SimpleNamespace( + library_service=SimpleNamespace(add_online_track=Mock(return_value=111)), + ) + + with patch("plugins.builtin.qqmusic.lib.online_music_view.bootstrap", return_value=bootstrap): + result = OnlineMusicView._add_online_track_to_library(view, track) + + assert result == 111 + bootstrap.library_service.add_online_track.assert_called_once_with( + provider_id="qqmusic", + song_mid="m1", + title="Song 1", + artist="Artist 1", + album="Album 1", + duration=123.0, + cover_url="https://cover", + ) + + +def test_online_detail_view_add_online_track_to_library_passes_provider_id(): + """OnlineDetailView should persist QQMusic tracks with the provider id.""" + view = OnlineDetailView.__new__(OnlineDetailView) + view._get_cover_url = Mock(return_value="https://cover") + track = SimpleNamespace( + mid="m2", + title="Song 2", + singer_name="Artist 2", + album_name="Album 2", + duration=234, + ) + bootstrap = SimpleNamespace( + library_service=SimpleNamespace(add_online_track=Mock(return_value=222)), + ) + + with patch("plugins.builtin.qqmusic.lib.online_detail_view.bootstrap", return_value=bootstrap): + result = OnlineDetailView._add_online_track_to_library(view, track) + + assert result == 222 + bootstrap.library_service.add_online_track.assert_called_once_with( + provider_id="qqmusic", + song_mid="m2", + title="Song 2", + artist="Artist 2", + album="Album 2", + duration=234.0, + cover_url="https://cover", + ) From 5abc8e4bc746e8524a54fc67f53c947cc3395edd Mon Sep 17 00:00:00 2001 From: Har01d Date: Wed, 8 Apr 2026 20:26:03 +0800 Subject: [PATCH 111/157] =?UTF-8?q?=E6=95=B4=E7=90=86QQ=E6=8F=92=E4=BB=B6?= =?UTF-8?q?=E9=87=8D=E6=9E=84=E8=AE=BE=E8=AE=A1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../2026-04-08-qqmusic-refactor-design.md | 281 ++++++++++++++++++ 1 file changed, 281 insertions(+) create mode 100644 docs/superpowers/specs/2026-04-08-qqmusic-refactor-design.md diff --git a/docs/superpowers/specs/2026-04-08-qqmusic-refactor-design.md b/docs/superpowers/specs/2026-04-08-qqmusic-refactor-design.md new file mode 100644 index 00000000..8ad18c54 --- /dev/null +++ b/docs/superpowers/specs/2026-04-08-qqmusic-refactor-design.md @@ -0,0 +1,281 @@ +# QQMusic Plugin Refactor Design + +**Date**: 2026-04-08 +**Scope**: `plugins/builtin/qqmusic` internal structure and tests +**Strategy**: medium refactor, no host-facing behavior change + +--- + +## Problem + +The built-in QQ Music plugin currently works, but its internal structure makes further changes expensive: + +- `lib/qqmusic_service.py` is very large and mixes request orchestration, response parsing, and plugin-facing data shaping. +- `lib/qqmusic_client.py` is a low-level HTTP client, but upstream modules still duplicate result normalization and fallback behavior. +- `lib/client.py`, `lib/provider.py`, and `lib/api.py` each contain overlapping normalization, cover resolution, lyric selection, and section-building logic. +- Pure data transformation code is embedded inside runtime classes, which makes the code harder to test and encourages repeated parsing rules. +- Some private paths look legacy or duplicated, but the duplication is spread across several modules, so it is hard to tell which path is authoritative. + +The main maintainability issue is not one broken API. It is that transport, orchestration, fallback policy, and result adaptation are interleaved across multiple files. + +## Goals + +- Keep plugin registration and host-facing interfaces stable. +- Reassign responsibilities inside `plugins/builtin/qqmusic/lib` so each layer has one clear purpose. +- Extract repeated pure transformation logic into small helper modules with focused tests. +- Reduce direct payload parsing inside `provider.py` and `client.py`. +- Remove obviously duplicated or now-redundant private helper paths during the refactor. +- Preserve current runtime behavior for search, detail, lyrics, covers, recommendations, favorites, and downloads. + +## Non-Goals + +- No redesign of the QQ Music UI views. +- No migration of all plugin data objects to dataclasses. +- No conversion of the plugin stack to async I/O. +- No protocol or contract changes in host plugin registries. +- No broad cleanup outside `plugins/builtin/qqmusic` and its related tests. + +## Current State + +The plugin currently has four implicit layers, but they overlap: + +- [`plugins/builtin/qqmusic/plugin_main.py`](/home/harold/workspace/music-player/plugins/builtin/qqmusic/plugin_main.py) registers plugin capabilities. +- [`plugins/builtin/qqmusic/lib/provider.py`](/home/harold/workspace/music-player/plugins/builtin/qqmusic/lib/provider.py) acts as the host-facing online provider, but also contains media lookup details and fallback parsing. +- [`plugins/builtin/qqmusic/lib/client.py`](/home/harold/workspace/music-player/plugins/builtin/qqmusic/lib/client.py) chooses between QQ Music direct access and remote API fallback, but also owns normalization helpers and section formatting. +- [`plugins/builtin/qqmusic/lib/qqmusic_service.py`](/home/harold/workspace/music-player/plugins/builtin/qqmusic/lib/qqmusic_service.py) wraps direct QQ Music access, but still mixes transport result decoding with plugin-facing shaping. +- [`plugins/builtin/qqmusic/lib/api.py`](/home/harold/workspace/music-player/plugins/builtin/qqmusic/lib/api.py) talks to the public remote fallback API, but also performs its own formatting logic that overlaps with `client.py`. + +This produces three specific maintenance costs: + +1. the same data shape rules exist in more than one file +2. fallback behavior is hard to reason about because selection and shaping are mixed together +3. tests are forced to patch implementation details instead of stable responsibilities + +## Recommended Approach + +Keep the existing external entry points, but rebuild the plugin internals around a stricter split: + +- `provider.py` remains the plugin integration entry point +- `client.py` becomes the orchestration layer only +- `qqmusic_service.py` stays the direct QQ Music business wrapper +- `api.py` stays the remote fallback transport wrapper +- repeated pure logic moves into small helper modules + +This is the smallest refactor that materially improves maintainability without turning into a plugin rewrite. + +## Architecture + +### Target Dependency Direction + +After the refactor, the intended dependency flow is: + +`plugin_main.py` / source adapters / UI entry points -> `provider.py` + +`provider.py` -> `client.py` + +`client.py` -> `qqmusic_service.py` / `api.py` / helper modules + +`qqmusic_service.py` -> `qqmusic_client.py` / helper modules + +`api.py` -> helper modules + +Helper modules must stay pure: no context access, no network calls, no Qt dependencies. + +### Layer Responsibilities + +#### Provider Layer + +[`plugins/builtin/qqmusic/lib/provider.py`](/home/harold/workspace/music-player/plugins/builtin/qqmusic/lib/provider.py) should only own: + +- host-visible provider methods +- page creation and download service wiring +- delegation to the client for runtime data access + +It should stop owning low-level parsing such as: + +- extracting `album_mid` from detail payloads +- choosing between `qrc` and plain lyric payloads +- constructing media fallback decisions inline + +#### Orchestration Layer + +[`plugins/builtin/qqmusic/lib/client.py`](/home/harold/workspace/music-player/plugins/builtin/qqmusic/lib/client.py) should become the single place that decides: + +- whether direct QQ Music service is available +- whether a remote API fallback should be used +- which normalized shape is returned upward + +It should not embed large formatting helpers. Instead it should call dedicated normalizers/builders. + +#### Direct QQ Music Service Layer + +[`plugins/builtin/qqmusic/lib/qqmusic_service.py`](/home/harold/workspace/music-player/plugins/builtin/qqmusic/lib/qqmusic_service.py) should keep its public methods, but internally it should focus on: + +- invoking `QQMusicClient` +- collecting related QQ Music responses +- returning service-level dictionaries that are already coherent + +It should stop duplicating generic list/song formatting logic that can be expressed as pure helpers. + +#### Remote Fallback API Layer + +[`plugins/builtin/qqmusic/lib/api.py`](/home/harold/workspace/music-player/plugins/builtin/qqmusic/lib/api.py) should only do: + +- HTTP requests to the public fallback API +- minimal extraction of payload roots +- normalization through shared helper functions + +That keeps the API wrapper transport-focused instead of becoming another formatting authority. + +## New Internal Modules + +### `lib/media_helpers.py` + +This module should contain pure media-related helpers now spread across runtime classes: + +- build album cover URL from `album_mid` +- build artist cover URL from `singer_mid` +- extract `album_mid` from heterogeneous song/detail payloads +- choose lyric content in priority order: `qrc` first, plain lyric second + +Expected consumers: + +- `provider.py` +- `client.py` +- `api.py` + +### `lib/search_normalizers.py` + +This module should own result normalization rules that are currently duplicated: + +- normalize search songs from direct QQ Music payloads +- normalize search songs from remote API payloads +- normalize detail songs +- normalize top list track payloads +- normalize album, artist, and playlist search entries + +Expected result shapes stay compatible with current callers, for example: + +- `{"tracks": [...], "total": N}` +- `{"artists": [...], "total": N}` +- `{"albums": [...], "total": N}` +- `{"playlists": [...], "total": N}` + +### `lib/section_builders.py` + +This module should own section/card building now mixed into `client.py`: + +- recommendation section assembly +- favorites section assembly +- cover selection from heterogeneous items + +This makes the `client.py` path read like orchestration code rather than a mixture of network policy and UI card formatting. + +## File Changes + +### Add + +- [`plugins/builtin/qqmusic/lib/media_helpers.py`](/home/harold/workspace/music-player/plugins/builtin/qqmusic/lib/media_helpers.py) +- [`plugins/builtin/qqmusic/lib/search_normalizers.py`](/home/harold/workspace/music-player/plugins/builtin/qqmusic/lib/search_normalizers.py) +- [`plugins/builtin/qqmusic/lib/section_builders.py`](/home/harold/workspace/music-player/plugins/builtin/qqmusic/lib/section_builders.py) + +### Update + +- [`plugins/builtin/qqmusic/lib/provider.py`](/home/harold/workspace/music-player/plugins/builtin/qqmusic/lib/provider.py) +- [`plugins/builtin/qqmusic/lib/client.py`](/home/harold/workspace/music-player/plugins/builtin/qqmusic/lib/client.py) +- [`plugins/builtin/qqmusic/lib/qqmusic_service.py`](/home/harold/workspace/music-player/plugins/builtin/qqmusic/lib/qqmusic_service.py) +- [`plugins/builtin/qqmusic/lib/api.py`](/home/harold/workspace/music-player/plugins/builtin/qqmusic/lib/api.py) +- [`plugins/builtin/qqmusic/plugin_main.py`](/home/harold/workspace/music-player/plugins/builtin/qqmusic/plugin_main.py) only if import cleanup or construction simplification is needed +- related QQ Music tests under [`tests/`](/home/harold/workspace/music-player/tests/) + +### Deletions / Reductions + +The refactor should remove duplicated private helpers after the shared modules are introduced. Expected examples: + +- `QQMusicOnlineProvider._build_album_cover_url` +- `QQMusicOnlineProvider._extract_album_mid_from_song_detail` +- `QQMusicPluginClient._normalize_detail_song` +- `QQMusicPluginClient._normalize_top_list_track` +- `QQMusicPluginClient._pick_cover` +- `QQMusicPluginAPI._format_song_item` +- any now-unused private formatting path in `qqmusic_service.py` that duplicates the shared normalizers + +Deletions should only happen after tests prove the shared helper path is covering the same behavior. + +## Runtime Behavior Rules + +These rules should not change during the refactor: + +- search continues to prefer direct QQ Music service when available, then falls back to remote API +- lyrics continue to prefer richer local data when available, then fall back to remote API +- cover lookup continues to use direct album URL construction when enough metadata is present +- recommendations and favorites still return the same section structure expected by the UI +- downloads still use the provider-owned online download service path + +The point is structural cleanup, not a policy rewrite. + +## Error Handling Strategy + +The refactor should make error boundaries clearer: + +- helper modules do not swallow exceptions; they only transform data +- `client.py` handles source selection and fallback when a source request fails or returns no useful data +- `provider.py` remains defensive toward host/UI callers and returns `None`, `[]`, or empty payloads where current behavior already does that + +This keeps exception handling at the orchestration boundary instead of scattering it through every formatting helper. + +## Testing Strategy + +### New Helper Tests + +Add unit tests for: + +- album and artist cover URL builders +- lyric payload selection priority +- album MID extraction from multiple payload shapes +- search result normalization for song, artist, album, and playlist payloads +- top list and detail song normalization +- recommendation/favorites section assembly and cover selection + +### Existing Behavior Tests + +Keep and adjust existing QQ Music tests so they assert stable behavior instead of private implementation details: + +- [`tests/test_plugins/test_qqmusic_plugin.py`](/home/harold/workspace/music-player/tests/test_plugins/test_qqmusic_plugin.py) +- [`tests/test_services/test_qqmusic_plugin_source_adapters.py`](/home/harold/workspace/music-player/tests/test_services/test_qqmusic_plugin_source_adapters.py) +- [`tests/test_services/test_qqmusic_service_perf_paths.py`](/home/harold/workspace/music-player/tests/test_services/test_qqmusic_service_perf_paths.py) +- other QQ Music provider/UI tests that already cover fallback expectations + +### Validation Scope + +At minimum, validation should cover: + +- helper tests +- QQ Music plugin tests +- QQ Music service/provider tests + +The test suite should confirm that the plugin still exposes the same normalized data shapes to its callers after the refactor. + +## Risks + +- Some duplicated code may differ in subtle edge-case behavior even when it looks equivalent. The new helpers must codify the authoritative behavior before old paths are removed. +- `qqmusic_service.py` is large enough that moving logic out of it can accidentally change data shape if tests do not pin existing outputs. +- `provider.py` currently knows more about lyrics and cover fallback than it should. Moving that logic down must preserve the existing local-first ordering. +- Tests that patch private methods will become brittle during this change and should be rewritten early. + +## Design Constraints + +- Keep the plugin's host-facing contracts stable. +- Prefer additive extraction before deletion: move logic into helpers, redirect callers, then remove dead private methods. +- Avoid wide UI churn. The main refactor target is the plugin runtime/data path. +- Favor smaller pure modules over introducing another large abstraction class. + +## Success Criteria + +The refactor is successful when: + +- `provider.py`, `client.py`, `qqmusic_service.py`, and `api.py` each have a narrower and more obvious responsibility +- repeated data-shaping logic is centralized in helper modules +- obviously duplicated private methods are deleted +- QQ Music tests still pass with behavior-compatible outputs +- future QQ Music changes can be made by touching one authority per concern instead of three From 56cac635754fac3cd80c1de97fe6403df05c45e8 Mon Sep 17 00:00:00 2001 From: Har01d Date: Wed, 8 Apr 2026 20:27:04 +0800 Subject: [PATCH 112/157] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E9=80=80=E5=87=BA?= =?UTF-8?q?=E6=97=B6=E7=83=AD=E9=94=AE=E6=B8=85=E7=90=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/application.py | 3 +++ .../test_app/test_application_quit_cleanup.py | 23 +++++++++++++++++++ 2 files changed, 26 insertions(+) create mode 100644 tests/test_app/test_application_quit_cleanup.py diff --git a/app/application.py b/app/application.py index 415543b5..37e5f294 100644 --- a/app/application.py +++ b/app/application.py @@ -136,6 +136,9 @@ def quit(self): # Stop MPRIS D-Bus service self._bootstrap.stop_mpris() + from system import hotkeys + hotkeys.cleanup() + # Stop cache cleaner service cache_cleaner = self._bootstrap.cache_cleaner_service if cache_cleaner: diff --git a/tests/test_app/test_application_quit_cleanup.py b/tests/test_app/test_application_quit_cleanup.py new file mode 100644 index 00000000..c7052807 --- /dev/null +++ b/tests/test_app/test_application_quit_cleanup.py @@ -0,0 +1,23 @@ +from types import SimpleNamespace +from unittest.mock import Mock + +from app.application import Application + + +def test_quit_calls_hotkeys_cleanup(monkeypatch): + app = Application.__new__(Application) + cache_cleaner = SimpleNamespace(stop=Mock()) + write_worker = SimpleNamespace(wait_idle=Mock(), stop=Mock()) + app._bootstrap = SimpleNamespace( + stop_mpris=Mock(), + cache_cleaner_service=cache_cleaner, + db=SimpleNamespace(_write_worker=write_worker), + ) + app._qt_app = SimpleNamespace(quit=Mock()) + cleanup = Mock() + + monkeypatch.setattr("system.hotkeys.cleanup", cleanup) + + Application.quit(app) + + cleanup.assert_called_once_with() From 32dc5188f2c3bc92138cc7cc7719430482f87694 Mon Sep 17 00:00:00 2001 From: Har01d Date: Wed, 8 Apr 2026 20:27:20 +0800 Subject: [PATCH 113/157] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E6=92=AD=E6=94=BE?= =?UTF-8?q?=E9=98=9F=E5=88=97=E5=BC=B9=E7=AA=97=E9=87=8A=E6=94=BE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ..._playing_window_playlist_dialog_cleanup.py | 130 ++++++++++++++++++ ui/windows/now_playing_window.py | 1 + 2 files changed, 131 insertions(+) create mode 100644 tests/test_ui/test_now_playing_window_playlist_dialog_cleanup.py diff --git a/tests/test_ui/test_now_playing_window_playlist_dialog_cleanup.py b/tests/test_ui/test_now_playing_window_playlist_dialog_cleanup.py new file mode 100644 index 00000000..b4dc0027 --- /dev/null +++ b/tests/test_ui/test_now_playing_window_playlist_dialog_cleanup.py @@ -0,0 +1,130 @@ +from types import SimpleNamespace + +from ui.windows.now_playing_window import NowPlayingWindow + + +class _FakeDialog: + def __init__(self, _parent): + self.exec_called = False + self.delete_later_called = False + + def setWindowTitle(self, _title): + return None + + def setWindowFlags(self, _flags): + return None + + def resize(self, _width, _height): + return None + + def setStyleSheet(self, _style): + return None + + def reject(self): + return None + + def accept(self): + return None + + def exec(self): + self.exec_called = True + + def deleteLater(self): + self.delete_later_called = True + + +class _FakeLayout: + def __init__(self, *_args, **_kwargs): + return None + + def setContentsMargins(self, *_args): + return None + + def addStretch(self): + return None + + def addWidget(self, _widget): + return None + + def addLayout(self, _layout): + return None + + +class _FakeButton: + def __init__(self, *_args, **_kwargs): + self.clicked = SimpleNamespace(connect=lambda _callback: None) + + def setObjectName(self, _name): + return None + + def setFixedSize(self, _w, _h): + return None + + def setCursor(self, _cursor): + return None + + def setIcon(self, _icon): + return None + + def setIconSize(self, _size): + return None + + +class _FakeListWidget: + PositionAtCenter = object() + + def __init__(self, *_args, **_kwargs): + self.itemDoubleClicked = SimpleNamespace(connect=lambda _callback: None) + + def setCursor(self, _cursor): + return None + + def addItem(self, _item): + return None + + def count(self): + return 0 + + +class _FakeListItem: + def __init__(self, _text): + return None + + def setData(self, *_args): + return None + + def setTextAlignment(self, *_args): + return None + + +def test_show_playlist_dialog_deletes_dialog_after_exec(monkeypatch): + fake_dialog = _FakeDialog(None) + + monkeypatch.setattr("ui.windows.now_playing_window.QDialog", lambda parent: fake_dialog) + monkeypatch.setattr("ui.windows.now_playing_window.QVBoxLayout", _FakeLayout) + monkeypatch.setattr("ui.windows.now_playing_window.QHBoxLayout", _FakeLayout) + monkeypatch.setattr("ui.windows.now_playing_window.QPushButton", _FakeButton) + monkeypatch.setattr("ui.windows.now_playing_window.QListWidget", _FakeListWidget) + monkeypatch.setattr("ui.windows.now_playing_window.QListWidgetItem", _FakeListItem) + monkeypatch.setattr( + "system.theme.ThemeManager.instance", + lambda: SimpleNamespace(get_qss=lambda template: template, current_theme=SimpleNamespace(highlight="#fff")), + ) + monkeypatch.setattr("ui.windows.now_playing_window.get_icon", lambda *_args, **_kwargs: object()) + monkeypatch.setattr("ui.windows.now_playing_window.t", lambda key: key) + + fake_window = SimpleNamespace( + _STYLE_QUEUE_DIALOG="style", + _playback=SimpleNamespace( + engine=SimpleNamespace( + playlist_items=[], + current_index=-1, + play_at=lambda _index: None, + ) + ), + ) + + NowPlayingWindow._show_playlist_dialog(fake_window) + + assert fake_dialog.exec_called is True + assert fake_dialog.delete_later_called is True diff --git a/ui/windows/now_playing_window.py b/ui/windows/now_playing_window.py index ad0d83e4..9d87f297 100644 --- a/ui/windows/now_playing_window.py +++ b/ui/windows/now_playing_window.py @@ -684,6 +684,7 @@ def _play_selected(selected_item: QListWidgetItem): queue_list.itemDoubleClicked.connect(_play_selected) dialog.exec() + dialog.deleteLater() def _load_cover_async(self, track_dict: dict): """Load current track cover in worker thread.""" From e4a20472d2882b537eb6203eb22485c56a33c73f Mon Sep 17 00:00:00 2001 From: Har01d Date: Wed, 8 Apr 2026 20:27:32 +0800 Subject: [PATCH 114/157] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E6=92=AD=E6=94=BE?= =?UTF-8?q?=E5=88=97=E8=A1=A8=E5=88=A0=E9=99=A4=E4=BA=8B=E5=8A=A1=E5=9B=9E?= =?UTF-8?q?=E6=BB=9A?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- repositories/playlist_repository.py | 16 ++++++++++------ ...test_playlist_repository_delete_rollback.py | 18 ++++++++++++++++++ 2 files changed, 28 insertions(+), 6 deletions(-) create mode 100644 tests/test_repositories/test_playlist_repository_delete_rollback.py diff --git a/repositories/playlist_repository.py b/repositories/playlist_repository.py index 240dd94a..d5c45573 100644 --- a/repositories/playlist_repository.py +++ b/repositories/playlist_repository.py @@ -80,12 +80,16 @@ def delete(self, playlist_id: int) -> bool: """Delete a playlist by ID.""" conn = self._get_connection() cursor = conn.cursor() - # Delete playlist items first - cursor.execute("DELETE FROM playlist_items WHERE playlist_id = ?", (playlist_id,)) - # Delete playlist - cursor.execute("DELETE FROM playlists WHERE id = ?", (playlist_id,)) - conn.commit() - return cursor.rowcount > 0 + try: + # Delete playlist items first + cursor.execute("DELETE FROM playlist_items WHERE playlist_id = ?", (playlist_id,)) + # Delete playlist + cursor.execute("DELETE FROM playlists WHERE id = ?", (playlist_id,)) + conn.commit() + return cursor.rowcount > 0 + except sqlite3.DatabaseError: + conn.rollback() + return False def add_track(self, playlist_id: int, track_id: TrackId) -> bool: """Add a track to a playlist. diff --git a/tests/test_repositories/test_playlist_repository_delete_rollback.py b/tests/test_repositories/test_playlist_repository_delete_rollback.py new file mode 100644 index 00000000..68e13684 --- /dev/null +++ b/tests/test_repositories/test_playlist_repository_delete_rollback.py @@ -0,0 +1,18 @@ +import sqlite3 +from unittest.mock import Mock + +from repositories.playlist_repository import SqlitePlaylistRepository + + +def test_delete_rolls_back_when_playlist_delete_fails(): + repo = SqlitePlaylistRepository.__new__(SqlitePlaylistRepository) + cursor = Mock() + cursor.execute.side_effect = [None, sqlite3.DatabaseError("boom")] + conn = Mock(cursor=Mock(return_value=cursor)) + repo._get_connection = lambda: conn + + result = SqlitePlaylistRepository.delete(repo, 1) + + assert result is False + conn.rollback.assert_called_once_with() + conn.commit.assert_not_called() From 7f72ea299f2d88fa7970c6490f4013f5cbac2025 Mon Sep 17 00:00:00 2001 From: Har01d Date: Wed, 8 Apr 2026 20:28:34 +0800 Subject: [PATCH 115/157] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E5=9B=BE=E7=89=87?= =?UTF-8?q?=E7=BC=93=E5=AD=98=E6=B8=85=E7=90=86=E8=BF=AD=E4=BB=A3=E7=AB=9E?= =?UTF-8?q?=E4=BA=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- infrastructure/cache/image_cache.py | 2 +- .../test_image_cache_iteration_snapshot.py | 57 +++++++++++++++++++ 2 files changed, 58 insertions(+), 1 deletion(-) create mode 100644 tests/test_infrastructure/test_image_cache_iteration_snapshot.py diff --git a/infrastructure/cache/image_cache.py b/infrastructure/cache/image_cache.py index 642805d1..9762fe98 100644 --- a/infrastructure/cache/image_cache.py +++ b/infrastructure/cache/image_cache.py @@ -90,7 +90,7 @@ def cleanup(cls, days: int = 7) -> int: cutoff = time.time() - days * 86400 deleted = 0 - for f in cls.CACHE_DIR.iterdir(): + for f in list(cls.CACHE_DIR.iterdir()): try: if f.is_file() and f.stat().st_mtime < cutoff: f.unlink() diff --git a/tests/test_infrastructure/test_image_cache_iteration_snapshot.py b/tests/test_infrastructure/test_image_cache_iteration_snapshot.py new file mode 100644 index 00000000..b4c1f0cb --- /dev/null +++ b/tests/test_infrastructure/test_image_cache_iteration_snapshot.py @@ -0,0 +1,57 @@ +import time + +from infrastructure.cache.image_cache import ImageCache + + +class _FakeStat: + def __init__(self, mtime: float): + self.st_mtime = mtime + + +class _FakeCacheDir: + def __init__(self): + self.entries = {} + + def exists(self): + return True + + def iterdir(self): + return iter(self.entries.values()) + + +class _FakeFile: + def __init__(self, name: str, cache_dir: _FakeCacheDir, mtime: float): + self.name = name + self._cache_dir = cache_dir + self._mtime = mtime + + def is_file(self): + return True + + def stat(self): + return _FakeStat(self._mtime) + + def unlink(self): + self._cache_dir.entries.pop(self.name, None) + + def __str__(self): + return self.name + + +def test_cleanup_uses_snapshot_when_deleting_old_files(): + cache_dir = _FakeCacheDir() + old_time = time.time() - 9 * 86400 + cache_dir.entries = { + "a": _FakeFile("a", cache_dir, old_time), + "b": _FakeFile("b", cache_dir, old_time), + } + + original_dir = ImageCache.CACHE_DIR + ImageCache.CACHE_DIR = cache_dir + try: + deleted = ImageCache.cleanup(days=7) + finally: + ImageCache.CACHE_DIR = original_dir + + assert deleted == 2 + assert cache_dir.entries == {} From 4815e5e63337b0d3d330b0579fe35a3f85c21cf6 Mon Sep 17 00:00:00 2001 From: Har01d Date: Wed, 8 Apr 2026 20:33:07 +0800 Subject: [PATCH 116/157] =?UTF-8?q?=E4=BF=AE=E5=A4=8DMPRIS=E6=9C=8D?= =?UTF-8?q?=E5=8A=A1=E5=BC=95=E7=94=A8=E7=AB=9E=E6=80=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- system/mpris.py | 58 ++++++++++++++++++++------------- tests/test_system/test_mpris.py | 28 ++++++++++++++++ 2 files changed, 64 insertions(+), 22 deletions(-) diff --git a/system/mpris.py b/system/mpris.py index 725f50bf..4c4f9e08 100644 --- a/system/mpris.py +++ b/system/mpris.py @@ -427,6 +427,7 @@ def __init__(self, playback_service, main_window=None): self.service = None self.bus = None self._started = False + self._service_lock = threading.Lock() event_bus = Bootstrap.instance().event_bus event_bus.track_changed.connect(self.on_track_changed) @@ -442,12 +443,13 @@ def start(self): dbus.mainloop.glib.DBusGMainLoop(set_as_default=True) self.bus = dbus.SessionBus() - self.service = MPRISService( - self.bus, - self.playback_service, - self._main_window, - ui_dispatcher=self.ui_dispatcher, - ) + with self._service_lock: + self.service = MPRISService( + self.bus, + self.playback_service, + self._main_window, + ui_dispatcher=self.ui_dispatcher, + ) self.loop = GLib.MainLoop() self.loop_thread = threading.Thread( @@ -469,14 +471,16 @@ def stop(self): except Exception: pass - self.service = None + with self._service_lock: + self.service = None self.bus = None self.loop = None self.loop_thread = None self._started = False def _emit_tracklist(self): - if not self.service: + service = self._get_service() + if not service: return tracks = [ @@ -491,34 +495,44 @@ def _emit_tracklist(self): dbus.ObjectPath("/org/mpris/MediaPlayer2/track/none") ) - self.service.TrackListReplaced( + service.TrackListReplaced( dbus.Array(tracks, signature="o"), current_id ) + def _get_service(self): + with self._service_lock: + return self.service + def on_playback_state_changed(self, *args): - if self.service: - self.service.emit_player_properties(["PlaybackStatus"]) + service = self._get_service() + if service: + service.emit_player_properties(["PlaybackStatus"]) def on_track_changed(self, *args): - if self.service: - self.service.emit_player_properties(["Metadata", "PlaybackStatus"]) - self.service.Seeked(dbus.Int64(self.service._position_us())) + service = self._get_service() + if service: + service.emit_player_properties(["Metadata", "PlaybackStatus"]) + service.Seeked(dbus.Int64(service._position_us())) self._emit_tracklist() def on_metadata_changed(self, *args): - if self.service: - self.service.emit_player_properties(["Metadata"]) + service = self._get_service() + if service: + service.emit_player_properties(["Metadata"]) def on_duration_changed(self, *args): - if self.service: - self.service.emit_player_properties(["Metadata"]) + service = self._get_service() + if service: + service.emit_player_properties(["Metadata"]) def on_volume_changed(self, *args): - if self.service: - self.service.emit_player_properties(["Volume"]) + service = self._get_service() + if service: + service.emit_player_properties(["Volume"]) def on_cover_updated(self, *args): - if self.service: + service = self._get_service() + if service: # 封面在 Metadata 里 - self.service.emit_player_properties(["Metadata"]) + service.emit_player_properties(["Metadata"]) diff --git a/tests/test_system/test_mpris.py b/tests/test_system/test_mpris.py index 3c8df5b6..be22109e 100644 --- a/tests/test_system/test_mpris.py +++ b/tests/test_system/test_mpris.py @@ -221,3 +221,31 @@ def TrackListReplaced(self, *_args, **_kwargs): assert captured["playback_service"] is playback_service assert captured["main_window"] is controller._main_window assert captured["ui_dispatcher"] is controller.ui_dispatcher + + +def test_mpris_controller_track_change_uses_stable_service_reference(monkeypatch): + mpris = _load_mpris_module(monkeypatch) + + controller = mpris.MPRISController.__new__(mpris.MPRISController) + controller._service_lock = mpris.threading.Lock() + controller.playback_service = types.SimpleNamespace(playlist=[], current_track=None) + + class _FakeService: + def __init__(self): + self.seeked = [] + + def emit_player_properties(self, _names): + controller.service = None + + def Seeked(self, value): + self.seeked.append(value) + + def _position_us(self): + return 123 + + def TrackListReplaced(self, *_args, **_kwargs): + pass + + controller.service = _FakeService() + + mpris.MPRISController.on_track_changed(controller) From 963abb1d53bb8cd3a68a0681e5e76df114906569 Mon Sep 17 00:00:00 2001 From: Har01d Date: Wed, 8 Apr 2026 20:33:22 +0800 Subject: [PATCH 117/157] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E5=9B=BD=E9=99=85?= =?UTF-8?q?=E5=8C=96=E7=8A=B6=E6=80=81=E5=B9=B6=E5=8F=91=E8=AE=BF=E9=97=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- system/i18n.py | 84 ++++++++++++++------------ tests/test_system/test_i18n_locking.py | 22 +++++++ 2 files changed, 67 insertions(+), 39 deletions(-) create mode 100644 tests/test_system/test_i18n_locking.py diff --git a/system/i18n.py b/system/i18n.py index e6ef7d51..3f075a22 100644 --- a/system/i18n.py +++ b/system/i18n.py @@ -6,10 +6,12 @@ import json import logging from pathlib import Path +import threading from typing import Dict, Optional _current_language: str = "en" _translations: Dict[str, Dict[str, str]] = {} +_state_lock = threading.Lock() def _get_translations_dir() -> Path: @@ -21,46 +23,49 @@ def load_translations(): """Load all translation files.""" global _translations - translations_dir = _get_translations_dir() - translations_dir.mkdir(exist_ok=True) - - # Load English - en_file = translations_dir / "en.json" - if en_file.exists(): - try: - with open(en_file, "r", encoding="utf-8") as f: - _translations["en"] = json.load(f) - except (json.JSONDecodeError, OSError) as e: - logging.warning(f"Failed to load English translations: {e}") + with _state_lock: + translations_dir = _get_translations_dir() + translations_dir.mkdir(exist_ok=True) + + # Load English + en_file = translations_dir / "en.json" + if en_file.exists(): + try: + with open(en_file, "r", encoding="utf-8") as f: + _translations["en"] = json.load(f) + except (json.JSONDecodeError, OSError) as e: + logging.warning(f"Failed to load English translations: {e}") + _translations["en"] = {} + else: _translations["en"] = {} - else: - _translations["en"] = {} - - # Load Chinese - zh_file = translations_dir / "zh.json" - if zh_file.exists(): - try: - with open(zh_file, "r", encoding="utf-8") as f: - _translations["zh"] = json.load(f) - except (json.JSONDecodeError, OSError) as e: - logging.warning(f"Failed to load Chinese translations: {e}") + + # Load Chinese + zh_file = translations_dir / "zh.json" + if zh_file.exists(): + try: + with open(zh_file, "r", encoding="utf-8") as f: + _translations["zh"] = json.load(f) + except (json.JSONDecodeError, OSError) as e: + logging.warning(f"Failed to load Chinese translations: {e}") + _translations["zh"] = {} + else: _translations["zh"] = {} - else: - _translations["zh"] = {} def set_language(lang: str): """Set the current language.""" global _current_language - if lang in ("en", "zh"): - _current_language = lang - else: - _current_language = "en" + with _state_lock: + if lang in ("en", "zh"): + _current_language = lang + else: + _current_language = "en" def get_language() -> str: """Get the current language.""" - return _current_language + with _state_lock: + return _current_language def t(key: str, default: Optional[str] = None) -> str: @@ -74,20 +79,21 @@ def t(key: str, default: Optional[str] = None) -> str: Returns: Translated text or default/key if not found """ - if _current_language not in _translations: - return default if default is not None else key + with _state_lock: + if _current_language not in _translations: + return default if default is not None else key - translations = _translations[_current_language] + translations = _translations[_current_language] - if key in translations: - return translations[key] + if key in translations: + return translations[key] - # Fallback to English if key not in current language - if _current_language != "en" and "en" in _translations: - if key in _translations["en"]: - return _translations["en"][key] + # Fallback to English if key not in current language + if _current_language != "en" and "en" in _translations: + if key in _translations["en"]: + return _translations["en"][key] - return default if default is not None else key + return default if default is not None else key def get_available_languages() -> list: diff --git a/tests/test_system/test_i18n_locking.py b/tests/test_system/test_i18n_locking.py new file mode 100644 index 00000000..5191a287 --- /dev/null +++ b/tests/test_system/test_i18n_locking.py @@ -0,0 +1,22 @@ +import system.i18n as i18n + + +class _LockCheckingTranslations(dict): + def __contains__(self, key): + assert i18n._state_lock.locked() + return super().__contains__(key) + + def __getitem__(self, key): + assert i18n._state_lock.locked() + return super().__getitem__(key) + + +def test_translate_reads_language_state_under_lock(monkeypatch): + monkeypatch.setattr( + i18n, + "_translations", + _LockCheckingTranslations({"en": {"hello": "Hello"}}), + ) + monkeypatch.setattr(i18n, "_current_language", "en") + + assert i18n.t("hello") == "Hello" From 9b7cbf6a5479205ce5e1de2709bd9f79549f9d96 Mon Sep 17 00:00:00 2001 From: Har01d Date: Wed, 8 Apr 2026 20:34:19 +0800 Subject: [PATCH 118/157] =?UTF-8?q?=E7=BB=86=E5=8C=96QQ=E6=8F=92=E4=BB=B6?= =?UTF-8?q?=E9=87=8D=E6=9E=84=E8=AE=A1=E5=88=92?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../plans/2026-04-08-qqmusic-refactor.md | 1006 +++++++++++++++++ 1 file changed, 1006 insertions(+) create mode 100644 docs/superpowers/plans/2026-04-08-qqmusic-refactor.md diff --git a/docs/superpowers/plans/2026-04-08-qqmusic-refactor.md b/docs/superpowers/plans/2026-04-08-qqmusic-refactor.md new file mode 100644 index 00000000..8dd287d4 --- /dev/null +++ b/docs/superpowers/plans/2026-04-08-qqmusic-refactor.md @@ -0,0 +1,1006 @@ +# QQMusic Plugin Refactor Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Refactor `plugins/builtin/qqmusic` so provider, client, service, and API layers have clearer responsibilities, duplicated normalization/media logic is extracted into helper modules, and host-facing behavior stays compatible. + +**Architecture:** Add three pure helper modules under `plugins/builtin/qqmusic/lib`: one for media-related helpers, one for payload normalization, and one for recommendation/favorites section assembly. Then migrate `api.py`, `client.py`, `provider.py`, and `qqmusic_service.py` to call those helpers, delete duplicated private methods, and verify the same normalized payload shapes still reach UI and source-adapter callers. + +**Tech Stack:** Python 3, PySide6 plugin package, pytest, monkeypatch/Mock/SimpleNamespace tests, `uv` + +--- + +## File Map + +- Create: `plugins/builtin/qqmusic/lib/media_helpers.py` + Responsibility: pure helpers for cover URLs, lyric selection, and `album_mid` extraction. +- Create: `plugins/builtin/qqmusic/lib/search_normalizers.py` + Responsibility: pure helpers for QQ Music and remote API payload normalization. +- Create: `plugins/builtin/qqmusic/lib/section_builders.py` + Responsibility: pure helpers for recommendation/favorites card assembly and cover picking. +- Modify: `plugins/builtin/qqmusic/lib/api.py` + Responsibility: keep HTTP transport code, delegate payload shaping to shared normalizers/helpers. +- Modify: `plugins/builtin/qqmusic/lib/client.py` + Responsibility: keep source-selection/fallback logic, delegate normalization and section building to shared helpers. +- Modify: `plugins/builtin/qqmusic/lib/provider.py` + Responsibility: keep host-facing provider behavior, delegate media extraction/selection to shared helpers. +- Modify: `plugins/builtin/qqmusic/lib/qqmusic_service.py` + Responsibility: keep QQ Music direct-service orchestration, reuse shared normalizers/helpers for repeated shaping logic. +- Modify: `tests/test_plugins/test_qqmusic_plugin.py` + Responsibility: provider-level compatibility tests. +- Modify: `tests/test_services/test_qqmusic_plugin_source_adapters.py` + Responsibility: source-adapter compatibility tests. +- Modify: `tests/test_services/test_qqmusic_service_perf_paths.py` + Responsibility: service-level payload-shaping regression tests. +- Create: `tests/test_services/test_qqmusic_media_helpers.py` + Responsibility: unit coverage for media helper functions. +- Create: `tests/test_services/test_qqmusic_search_normalizers.py` + Responsibility: unit coverage for shared normalizers. +- Create: `tests/test_services/test_qqmusic_section_builders.py` + Responsibility: unit coverage for section assembly helpers. + +### Task 1: Create shared media helpers + +**Files:** +- Create: `tests/test_services/test_qqmusic_media_helpers.py` +- Create: `plugins/builtin/qqmusic/lib/media_helpers.py` + +- [ ] **Step 1: Write the failing media helper tests** + +Create `tests/test_services/test_qqmusic_media_helpers.py` with: + +```python +from plugins.builtin.qqmusic.lib.media_helpers import ( + build_album_cover_url, + build_artist_cover_url, + extract_album_mid, + pick_lyric_text, +) + + +def test_build_album_cover_url_returns_expected_url(): + assert build_album_cover_url("album-1", 500) == ( + "https://y.gtimg.cn/music/photo_new/T002R500x500M000album-1.jpg" + ) + + +def test_build_artist_cover_url_returns_expected_url(): + assert build_artist_cover_url("artist-1", 300) == ( + "https://y.gtimg.cn/music/photo_new/T001R300x300M000artist-1.jpg" + ) + + +def test_extract_album_mid_supports_track_info_album(): + payload = {"track_info": {"album": {"mid": "album-from-track"}}} + + assert extract_album_mid(payload) == "album-from-track" + + +def test_extract_album_mid_supports_flat_album_mid_keys(): + payload = {"data": {"albumMid": "album-from-data"}} + + assert extract_album_mid(payload) == "album-from-data" + + +def test_pick_lyric_text_prefers_qrc_then_plain_lyric(): + assert pick_lyric_text({"qrc": "[0,100]qrc", "lyric": "[00:00.00]plain"}) == "[0,100]qrc" + assert pick_lyric_text({"qrc": "", "lyric": "[00:00.00]plain"}) == "[00:00.00]plain" + assert pick_lyric_text({"qrc": None, "lyric": None}) is None +``` + +- [ ] **Step 2: Run the media helper tests to verify they fail** + +Run: `uv run pytest tests/test_services/test_qqmusic_media_helpers.py -v` + +Expected: FAIL with `ModuleNotFoundError: No module named 'plugins.builtin.qqmusic.lib.media_helpers'` + +- [ ] **Step 3: Write the media helper module** + +Create `plugins/builtin/qqmusic/lib/media_helpers.py` with: + +```python +from __future__ import annotations + +from typing import Any, Mapping + + +def build_album_cover_url(album_mid: str, size: int) -> str | None: + if not album_mid: + return None + return f"https://y.gtimg.cn/music/photo_new/T002R{size}x{size}M000{album_mid}.jpg" + + +def build_artist_cover_url(singer_mid: str, size: int) -> str | None: + if not singer_mid: + return None + return f"https://y.gtimg.cn/music/photo_new/T001R{size}x{size}M000{singer_mid}.jpg" + + +def extract_album_mid(detail: Mapping[str, Any] | None) -> str: + if not isinstance(detail, Mapping): + return "" + track = detail.get("track_info", detail.get("data", detail)) + if not isinstance(track, Mapping): + return "" + album = track.get("album", {}) + if isinstance(album, Mapping): + album_mid = album.get("mid") or album.get("albumMid") or album.get("albummid") + if album_mid: + return str(album_mid) + return str(track.get("album_mid") or track.get("albummid") or track.get("albumMid") or "") + + +def pick_lyric_text(lyric_data: Mapping[str, Any] | None) -> str | None: + if not isinstance(lyric_data, Mapping): + return None + qrc = lyric_data.get("qrc") + if qrc: + return str(qrc) + lyric = lyric_data.get("lyric") + if lyric: + return str(lyric) + return None +``` + +- [ ] **Step 4: Run the media helper tests to verify they pass** + +Run: `uv run pytest tests/test_services/test_qqmusic_media_helpers.py -v` + +Expected: PASS with 5 passed + +- [ ] **Step 5: Commit the media helper slice** + +Run: + +```bash +git add tests/test_services/test_qqmusic_media_helpers.py plugins/builtin/qqmusic/lib/media_helpers.py +git commit -m "提取QQ音乐媒体辅助函数" +``` + +### Task 2: Create shared search normalizers and migrate `api.py` + +**Files:** +- Create: `tests/test_services/test_qqmusic_search_normalizers.py` +- Create: `plugins/builtin/qqmusic/lib/search_normalizers.py` +- Modify: `plugins/builtin/qqmusic/lib/api.py` + +- [ ] **Step 1: Write the failing search-normalizer tests** + +Create `tests/test_services/test_qqmusic_search_normalizers.py` with: + +```python +from plugins.builtin.qqmusic.lib.search_normalizers import ( + normalize_album_item, + normalize_artist_item, + normalize_detail_song, + normalize_playlist_item, + normalize_song_item, + normalize_top_list_track, +) + + +def test_normalize_song_item_supports_remote_api_shape(): + song = { + "mid": "song-1", + "name": "Song 1", + "singer": [{"name": "Singer 1"}], + "album": {"name": "Album 1", "mid": "album-1"}, + "interval": 180, + } + + assert normalize_song_item(song) == { + "mid": "song-1", + "name": "Song 1", + "title": "Song 1", + "artist": "Singer 1", + "singer": "Singer 1", + "album": "Album 1", + "album_mid": "album-1", + "duration": 180, + } + + +def test_normalize_detail_song_supports_service_shape(): + song = { + "mid": "song-1", + "title": "Song 1", + "singer": [{"name": "Singer 1"}], + "album": {"name": "Album 1", "mid": "album-1"}, + "interval": 180, + } + + assert normalize_detail_song(song) == { + "mid": "song-1", + "title": "Song 1", + "artist": "Singer 1", + "album": "Album 1", + "album_mid": "album-1", + "duration": 180, + } + + +def test_normalize_top_list_track_supports_dict_and_object_shapes(): + class _Track: + mid = "song-2" + title = "Song 2" + singer_name = "Singer 2" + album_name = "Album 2" + duration = 200 + + class album: + mid = "album-2" + + assert normalize_top_list_track( + {"mid": "song-1", "title": "Song 1", "artist": [{"name": "Singer 1"}], "album": {"name": "Album 1", "mid": "album-1"}, "interval": 180} + )["artist"] == "Singer 1" + assert normalize_top_list_track(_Track())["album_mid"] == "album-2" + + +def test_normalize_artist_album_and_playlist_items(): + artist = normalize_artist_item({"singerMID": "artist-1", "singerName": "Singer 1", "songNum": 8}) + album = normalize_album_item({"albummid": "album-1", "name": "Album 1", "singer": "Singer 1"}) + playlist = normalize_playlist_item({"dissid": 3, "dissname": "List 1", "nickname": "User 1"}) + + assert artist["mid"] == "artist-1" + assert album["mid"] == "album-1" + assert playlist["id"] == "3" +``` + +- [ ] **Step 2: Run the normalizer tests to verify they fail** + +Run: `uv run pytest tests/test_services/test_qqmusic_search_normalizers.py -v` + +Expected: FAIL with `ModuleNotFoundError: No module named 'plugins.builtin.qqmusic.lib.search_normalizers'` + +- [ ] **Step 3: Write the shared normalizer module** + +Create `plugins/builtin/qqmusic/lib/search_normalizers.py` with: + +```python +from __future__ import annotations + +from typing import Any, Mapping + + +def _join_artist_names(value: Any) -> str: + if isinstance(value, list): + return ", ".join( + entry.get("name", "") + for entry in value + if isinstance(entry, Mapping) and entry.get("name") + ) + if isinstance(value, Mapping): + return str(value.get("name", "")) + return str(value or "") + + +def normalize_song_item(song: Mapping[str, Any]) -> dict[str, Any]: + singer_name = _join_artist_names(song.get("singer")) or str(song.get("singerName", "")) + album_info = song.get("album", {}) + if isinstance(album_info, Mapping): + album_name = album_info.get("name", "") or song.get("albumName", "") + album_mid = album_info.get("mid", "") or song.get("albumMid", "") + else: + album_name = str(album_info or song.get("albumName", "")) + album_mid = song.get("albumMid", "") + title = song.get("name", "") or song.get("songname", "") or song.get("title", "") + return { + "mid": song.get("mid", "") or song.get("songmid", "") or song.get("songMid", ""), + "name": title, + "title": title, + "artist": singer_name, + "singer": singer_name, + "album": album_name, + "album_mid": album_mid, + "duration": song.get("interval", 0) or song.get("duration", 0), + } + + +def normalize_detail_song(item: Mapping[str, Any]) -> dict[str, Any]: + singer_name = _join_artist_names(item.get("singer")) + album_value = item.get("album", {}) + if isinstance(album_value, Mapping): + album_name = album_value.get("name", item.get("albumname", "")) + album_mid = album_value.get("mid", item.get("album_mid", "")) or item.get("albummid", "") + else: + album_name = str(album_value or item.get("albumname", "")) + album_mid = str(item.get("album_mid", item.get("albummid", "")) or "") + return { + "mid": item.get("mid", "") or item.get("songmid", ""), + "title": item.get("title", item.get("name", "")), + "artist": singer_name, + "album": album_name, + "album_mid": album_mid, + "duration": item.get("interval", item.get("duration", 0)), + } + + +def normalize_top_list_track(item: Any) -> dict[str, Any]: + if isinstance(item, Mapping): + normalized = normalize_detail_song(item) + return { + "mid": normalized["mid"], + "title": normalized["title"], + "artist": normalized["artist"], + "album": normalized["album"], + "album_mid": normalized["album_mid"], + "duration": int(normalized["duration"] or 0), + } + return { + "mid": getattr(item, "mid", ""), + "title": getattr(item, "title", ""), + "artist": getattr(item, "singer_name", ""), + "album": getattr(item, "album_name", ""), + "album_mid": getattr(getattr(item, "album", None), "mid", ""), + "duration": getattr(item, "duration", 0), + } + + +def normalize_artist_item(item: Mapping[str, Any]) -> dict[str, Any]: + return { + "mid": str(item.get("singerMID", "") or item.get("mid", "")), + "name": str(item.get("singerName", "") or item.get("name", "")), + "avatar_url": item.get("singerPic", item.get("avatar", item.get("cover_url", ""))), + "song_count": int(item.get("songNum", item.get("song_count", item.get("songnum", 0))) or 0), + "album_count": int(item.get("albumNum", item.get("album_count", item.get("albumnum", 0))) or 0), + "fan_count": int(item.get("fansNum", item.get("fan_count", item.get("FanNum", 0))) or 0), + } + + +def normalize_album_item(item: Mapping[str, Any]) -> dict[str, Any]: + singer_name = item.get("singer", "") + if isinstance(singer_name, list): + singer_name = _join_artist_names(singer_name) + return { + "mid": str(item.get("albummid", item.get("albumMID", item.get("mid", "")))), + "name": item.get("name", item.get("albumname", "")), + "singer_name": str(singer_name or item.get("singerName", "")), + "cover_url": item.get("pic", item.get("cover", item.get("cover_url", ""))), + "song_count": int(item.get("song_num", item.get("song_count", item.get("totalNum", 0))) or 0), + "publish_date": item.get("publish_date", item.get("pubTime", item.get("publishDate", ""))), + } + + +def normalize_playlist_item(item: Mapping[str, Any]) -> dict[str, Any]: + return { + "id": str(item.get("dissid", item.get("id", ""))), + "mid": item.get("dissMID", item.get("mid", "")), + "title": item.get("dissname", item.get("title", "")), + "creator": item.get("nickname", item.get("creator", "")), + "cover_url": item.get("logo", item.get("imgurl", item.get("cover_url", item.get("cover", "")))), + "song_count": item.get("songnum", item.get("song_count", 0)), + "play_count": item.get("listennum", item.get("play_count", 0)), + } +``` + +- [ ] **Step 4: Route `api.py` through the shared normalizers** + +Update `plugins/builtin/qqmusic/lib/api.py` imports and search-formatting branches: + +```python +from .media_helpers import build_album_cover_url, build_artist_cover_url +from .search_normalizers import ( + normalize_album_item, + normalize_artist_item, + normalize_playlist_item, + normalize_song_item, +) +``` + +```python + if search_type == "song": + return { + "tracks": [normalize_song_item(song) for song in items[:limit]], + "total": total, + } + if search_type == "singer": + return { + "artists": [ + { + **normalize_artist_item(item), + "avatar_url": ( + normalize_artist_item(item).get("avatar_url") + or build_artist_cover_url( + str(item.get("singerMID", item.get("mid", ""))), + 300, + ) + ), + } + for item in items[:limit] + ], + "total": total, + } + if search_type == "album": + return { + "albums": [ + { + **normalize_album_item(item), + "cover_url": ( + normalize_album_item(item).get("cover_url") + or build_album_cover_url( + str(item.get("albummid", item.get("mid", ""))), + 500, + ) + ), + } + for item in items[:limit] + ], + "total": total, + } + return { + "playlists": [normalize_playlist_item(item) for item in items[:limit]], + "total": total, + } +``` + +Replace `get_artist_cover_url()` with: + +```python + def get_artist_cover_url(self, singer_mid: str, size: int = 300) -> Optional[str]: + return build_artist_cover_url(singer_mid, size) +``` + +Delete `_format_song_item()` after all callers are updated. + +- [ ] **Step 5: Run the helper and adapter tests** + +Run: `uv run pytest tests/test_services/test_qqmusic_search_normalizers.py tests/test_services/test_qqmusic_plugin_source_adapters.py -v` + +Expected: PASS with the new helper tests and the existing source-adapter tests still green + +- [ ] **Step 6: Commit the normalizer/API slice** + +Run: + +```bash +git add tests/test_services/test_qqmusic_search_normalizers.py plugins/builtin/qqmusic/lib/search_normalizers.py plugins/builtin/qqmusic/lib/api.py +git commit -m "统一QQ音乐搜索结果归一化" +``` + +### Task 3: Create section builders and migrate recommendation/favorites assembly + +**Files:** +- Create: `tests/test_services/test_qqmusic_section_builders.py` +- Create: `plugins/builtin/qqmusic/lib/section_builders.py` +- Modify: `plugins/builtin/qqmusic/lib/client.py` + +- [ ] **Step 1: Write the failing section-builder tests** + +Create `tests/test_services/test_qqmusic_section_builders.py` with: + +```python +from plugins.builtin.qqmusic.lib.section_builders import build_section, pick_section_cover + + +def test_pick_section_cover_prefers_track_album_mid(): + items = [{"Track": {"album": {"mid": "album-1"}}}] + + assert pick_section_cover(items) == ( + "https://y.gtimg.cn/music/photo_new/T002R300x300M000album-1.jpg" + ) + + +def test_pick_section_cover_falls_back_to_cover_url(): + items = [{"cover_url": "https://cover.example/1.jpg"}] + + assert pick_section_cover(items) == "https://cover.example/1.jpg" + + +def test_build_section_adds_count_only_when_requested(): + recommendation = build_section( + card_id="guess", + title="猜你喜欢", + entry_type="songs", + items=[{"cover_url": "https://cover.example/1.jpg"}], + ) + favorites = build_section( + card_id="fav_songs", + title="我喜欢的歌曲", + entry_type="songs", + items=[{"cover_url": "https://cover.example/1.jpg"}], + include_count=True, + ) + + assert recommendation["subtitle"] == "1 项" + assert "count" not in recommendation + assert favorites["count"] == 1 +``` + +- [ ] **Step 2: Run the section-builder tests to verify they fail** + +Run: `uv run pytest tests/test_services/test_qqmusic_section_builders.py -v` + +Expected: FAIL with `ModuleNotFoundError: No module named 'plugins.builtin.qqmusic.lib.section_builders'` + +- [ ] **Step 3: Write the section-builder module** + +Create `plugins/builtin/qqmusic/lib/section_builders.py` with: + +```python +from __future__ import annotations + +from typing import Any + +from .media_helpers import build_album_cover_url + + +def pick_section_cover(items: list[dict[str, Any]]) -> str: + for item in items: + if not isinstance(item, dict): + continue + if isinstance(item.get("Track"), dict): + album = item["Track"].get("album", {}) + if isinstance(album, dict): + cover_url = build_album_cover_url(str(album.get("mid", "")), 300) + if cover_url: + return cover_url + cover_url = item.get("cover_url") or item.get("cover") or item.get("picurl") or item.get("pic") + if isinstance(cover_url, dict): + cover_url = cover_url.get("default_url") or cover_url.get("small_url") + if cover_url: + return str(cover_url) + album_mid = item.get("album_mid") + if album_mid: + built = build_album_cover_url(str(album_mid), 300) + if built: + return built + return "" + + +def build_section( + *, + card_id: str, + title: str, + entry_type: str, + items: list[dict[str, Any]], + include_count: bool = False, +) -> dict[str, Any]: + section = { + "id": card_id, + "title": title, + "subtitle": f"{len(items)} 项", + "cover_url": pick_section_cover(items), + "items": items, + "entry_type": entry_type, + } + if include_count: + section["count"] = len(items) + return section +``` + +- [ ] **Step 4: Update `client.py` to use the section builders** + +In `plugins/builtin/qqmusic/lib/client.py`, import the helper and replace the two loops in `get_recommendations()` and `get_favorites()`: + +```python +from .section_builders import build_section +``` + +```python + items: list[dict] = [] + for card_id, title, entry_type, loader in ( + ("home_feed", "首页推荐", "songs", service.get_home_feed), + ("guess", "猜你喜欢", "songs", service.get_guess_recommend), + ("radar", "雷达歌单", "songs", service.get_radar_recommend), + ("songlist", "推荐歌单", "playlists", service.get_recommend_songlist), + ("newsong", "新歌推荐", "songs", service.get_recommend_newsong), + ): + try: + data = loader() or [] + except Exception: + data = [] + if data: + items.append( + build_section( + card_id=card_id, + title=title, + entry_type=entry_type, + items=data, + ) + ) +``` + +```python + sections.append( + build_section( + card_id=card_id, + title=title, + entry_type=entry_type, + items=data, + include_count=True, + ) + ) +``` + +Delete `_pick_cover()` after `client.py` no longer calls it. + +- [ ] **Step 5: Run the section-builder and client regression tests** + +Run: `uv run pytest tests/test_services/test_qqmusic_section_builders.py tests/test_plugins/test_qqmusic_plugin.py -k "provider or register" -v` + +Expected: PASS with the new helper tests and the existing plugin/provider tests still green + +- [ ] **Step 6: Commit the section-builder slice** + +Run: + +```bash +git add tests/test_services/test_qqmusic_section_builders.py plugins/builtin/qqmusic/lib/section_builders.py plugins/builtin/qqmusic/lib/client.py +git commit -m "收敛QQ音乐卡片组装逻辑" +``` + +### Task 4: Migrate provider and client off duplicated media/normalization helpers + +**Files:** +- Modify: `plugins/builtin/qqmusic/lib/provider.py` +- Modify: `plugins/builtin/qqmusic/lib/client.py` +- Modify: `plugins/builtin/qqmusic/lib/api.py` +- Modify: `tests/test_plugins/test_qqmusic_plugin.py` +- Modify: `tests/test_services/test_qqmusic_plugin_source_adapters.py` + +- [ ] **Step 1: Add compatibility tests for helper-backed provider behavior** + +Extend `tests/test_plugins/test_qqmusic_plugin.py` with: + +```python +def test_qqmusic_provider_get_lyrics_prefers_qrc_from_local_service(monkeypatch): + settings = Mock() + settings.get.side_effect = lambda key, default=None: { + "credential": {"musicid": "1", "musickey": "secret"}, + }.get(key, default) + context = Mock(settings=settings) + context.logger = Mock() + + service = Mock() + service.get_lyrics.return_value = {"qrc": "[0,100]word", "lyric": "[00:00.00]plain"} + api = Mock() + monkeypatch.setattr("plugins.builtin.qqmusic.lib.client.QQMusicService", Mock(return_value=service)) + monkeypatch.setattr("plugins.builtin.qqmusic.lib.provider.QQMusicPluginAPI", Mock(return_value=api)) + + provider = QQMusicOnlineProvider(context) + monkeypatch.setattr(provider._client, "_can_use_legacy_network", lambda: True) + + assert provider.get_lyrics("song-mid") == "[0,100]word" + + +def test_qqmusic_provider_get_cover_url_uses_local_song_detail_before_public_api(monkeypatch): + settings = Mock() + settings.get.side_effect = lambda key, default=None: { + "credential": {"musicid": "1", "musickey": "secret"}, + }.get(key, default) + context = Mock(settings=settings) + context.logger = Mock() + + service = Mock() + service.client.get_song_detail.return_value = {"track_info": {"album": {"mid": "album-from-detail"}}} + api = Mock() + monkeypatch.setattr("plugins.builtin.qqmusic.lib.client.QQMusicService", Mock(return_value=service)) + monkeypatch.setattr("plugins.builtin.qqmusic.lib.provider.QQMusicPluginAPI", Mock(return_value=api)) + + provider = QQMusicOnlineProvider(context) + monkeypatch.setattr(provider._client, "_can_use_legacy_network", lambda: True) + + assert provider.get_cover_url(mid="song-1", size=500) == ( + "https://y.gtimg.cn/music/photo_new/T002R500x500M000album-from-detail.jpg" + ) +``` + +Keep the existing source-adapter tests in `tests/test_services/test_qqmusic_plugin_source_adapters.py`. They should remain green after the refactor without being rewritten around private methods. + +- [ ] **Step 2: Run the provider/source-adapter tests to verify the current baseline** + +Run: `uv run pytest tests/test_plugins/test_qqmusic_plugin.py tests/test_services/test_qqmusic_plugin_source_adapters.py -v` + +Expected: PASS before the refactor, confirming baseline compatibility for provider/source adapters + +- [ ] **Step 3: Refactor `provider.py`, `client.py`, and `api.py` to use the shared helpers** + +Update imports in `plugins/builtin/qqmusic/lib/provider.py`: + +```python +from .media_helpers import build_album_cover_url, extract_album_mid, pick_lyric_text +``` + +Replace the internal helper methods and inlined selection logic with: + +```python + def get_lyrics(self, song_mid: str) -> str | None: + service = self._client._get_service() + if service is not None and self._client._can_use_legacy_network(): + try: + lyric_data = service.get_lyrics(song_mid) or {} + except Exception: + lyric_data = {} + lyric_text = pick_lyric_text(lyric_data) + if lyric_text: + return lyric_text + + try: + return QQMusicPluginAPI(self._context).get_lyrics(song_mid) + except Exception: + return None +``` + +```python + def get_cover_url( + self, + mid: str | None = None, + album_mid: str | None = None, + size: int = 500, + ) -> str | None: + cover_url = build_album_cover_url(album_mid or "", size) + if cover_url: + return cover_url + + service = self._client._get_service() + if service is not None and mid and self._client._can_use_legacy_network(): + try: + detail = service.client.get_song_detail(mid) + except Exception: + detail = {} + cover_url = build_album_cover_url(extract_album_mid(detail), size) + if cover_url: + return cover_url + + try: + return QQMusicPluginAPI(self._context).get_cover_url(mid=mid, album_mid=album_mid, size=size) + except Exception: + return None +``` + +Update `plugins/builtin/qqmusic/lib/client.py` imports: + +```python +from .search_normalizers import ( + normalize_album_item, + normalize_artist_item, + normalize_detail_song, + normalize_playlist_item, + normalize_top_list_track, +) +``` + +Then replace duplicated formatting branches: + +```python + return { + "tracks": [normalize_detail_song(item) for item in items if isinstance(item, dict)], + "total": int(total or 0), + } +``` + +```python + normalize_artist_item(item) + for item in items + if isinstance(item, dict) +``` + +```python + normalize_album_item(item) + for item in items + if isinstance(item, dict) +``` + +```python + normalize_playlist_item(item) + for item in items + if isinstance(item, dict) +``` + +```python + return [normalize_top_list_track(item) for item in data] +``` + +Delete these now-redundant private methods once callers are removed: + +```python + def _normalize_detail_song(self, item: dict) -> dict: + ... + + def _normalize_top_list_track(self, item: Any) -> dict[str, Any]: + ... +``` + +- [ ] **Step 4: Run the provider/client/source-adapter tests** + +Run: `uv run pytest tests/test_plugins/test_qqmusic_plugin.py tests/test_services/test_qqmusic_plugin_source_adapters.py -v` + +Expected: PASS with no regressions in provider or source-adapter behavior + +- [ ] **Step 5: Commit the provider/client helper migration** + +Run: + +```bash +git add plugins/builtin/qqmusic/lib/provider.py plugins/builtin/qqmusic/lib/client.py plugins/builtin/qqmusic/lib/api.py tests/test_plugins/test_qqmusic_plugin.py tests/test_services/test_qqmusic_plugin_source_adapters.py +git commit -m "收敛QQ音乐Provider与Client职责" +``` + +### Task 5: Reuse shared helpers inside `qqmusic_service.py` + +**Files:** +- Modify: `plugins/builtin/qqmusic/lib/qqmusic_service.py` +- Modify: `tests/test_services/test_qqmusic_service_perf_paths.py` + +- [ ] **Step 1: Add failing service regression tests for helper-backed shaping** + +Append these tests to `tests/test_services/test_qqmusic_service_perf_paths.py`: + +```python +def test_get_singer_albums_builds_cover_url_from_shared_helper(): + service = QQMusicService() + service.client = SimpleNamespace( + get_album_list=lambda *_args, **_kwargs: { + "albumList": [ + { + "albumMid": "album-1", + "albumName": "Album 1", + "singerName": "Singer 1", + "totalNum": 10, + "publishDate": "2024-01-01", + } + ], + "total": 1, + } + ) + + result = service.get_singer_albums("singer-1") + + assert result["albums"][0]["cover_url"] == ( + "https://y.gtimg.cn/music/photo_new/T002R300x300M000album-1.jpg" + ) + + +def test_get_top_list_songs_uses_shared_top_list_normalizer(): + service = QQMusicService() + service.client = SimpleNamespace( + get_top_list_detail=lambda *_args, **_kwargs: { + "songInfoList": [ + { + "mid": "song-1", + "title": "Song 1", + "artist": [{"name": "Singer 1"}], + "album": {"name": "Album 1", "mid": "album-1"}, + "interval": 180, + } + ] + }, + query_songs_by_ids=lambda _ids: [], + ) + + songs = service.get_top_list_songs(1) + + assert songs == [ + { + "mid": "song-1", + "title": "Song 1", + "artist": "Singer 1", + "album": "Album 1", + "album_mid": "album-1", + "duration": 180, + } + ] +``` + +- [ ] **Step 2: Run the service regression tests to verify the baseline** + +Run: `uv run pytest tests/test_services/test_qqmusic_service_perf_paths.py -v` + +Expected: PASS before the refactor, confirming the new tests pin current behavior + +- [ ] **Step 3: Replace repeated shaping logic in `qqmusic_service.py` with shared helpers** + +Update imports in `plugins/builtin/qqmusic/lib/qqmusic_service.py`: + +```python +from .media_helpers import build_album_cover_url +from .search_normalizers import normalize_detail_song, normalize_top_list_track +``` + +Refactor the repeated song shaping in `get_singer_info()` and `get_singer_info_with_follow_status()` to use a single local append path: + +```python + normalized_song = normalize_detail_song( + { + "mid": song_info.get("mid", "") or song_info.get("songmid", ""), + "title": song_info.get("name", "") or song_info.get("songname", "") or song_info.get("title", ""), + "singer": song_info.get("singer", []), + "album": song_info.get("album", {}), + "interval": song_info.get("interval", 0) or song_info.get("duration", 0), + } + ) + songs.append( + { + "mid": normalized_song["mid"], + "songmid": normalized_song["mid"], + "id": song_info.get("id"), + "name": normalized_song["title"], + "title": normalized_song["title"], + "singer": song_info.get("singer", []), + "album": { + "mid": normalized_song["album_mid"], + "name": normalized_song["album"], + }, + "albummid": normalized_song["album_mid"], + "albumname": normalized_song["album"], + "interval": normalized_song["duration"], + } + ) +``` + +Refactor album cover generation in `get_singer_albums()`: + +```python + cover_url = build_album_cover_url(album_mid, 300) or "" +``` + +Refactor `get_top_list_songs()` return shaping: + +```python + return [normalize_top_list_track(song) for song in songs] +``` + +- [ ] **Step 4: Run the service tests again** + +Run: `uv run pytest tests/test_services/test_qqmusic_service_perf_paths.py -v` + +Expected: PASS with all existing and new service regression tests green + +- [ ] **Step 5: Commit the service cleanup slice** + +Run: + +```bash +git add plugins/builtin/qqmusic/lib/qqmusic_service.py tests/test_services/test_qqmusic_service_perf_paths.py +git commit -m "收敛QQ音乐服务层格式化逻辑" +``` + +### Task 6: Final regression and dead-code check + +**Files:** +- Modify: `plugins/builtin/qqmusic/lib/provider.py` +- Modify: `plugins/builtin/qqmusic/lib/client.py` +- Modify: `plugins/builtin/qqmusic/lib/api.py` +- Modify: `plugins/builtin/qqmusic/lib/qqmusic_service.py` +- Modify: `tests/test_plugins/test_qqmusic_plugin.py` +- Modify: `tests/test_services/test_qqmusic_plugin_source_adapters.py` +- Modify: `tests/test_services/test_qqmusic_service_perf_paths.py` +- Modify: `tests/test_services/test_qqmusic_media_helpers.py` +- Modify: `tests/test_services/test_qqmusic_search_normalizers.py` +- Modify: `tests/test_services/test_qqmusic_section_builders.py` + +- [ ] **Step 1: Remove any remaining duplicated private helpers that no longer have callers** + +Confirm these methods/functions are deleted if their last caller has moved: + +```python +QQMusicOnlineProvider._build_album_cover_url +QQMusicOnlineProvider._extract_album_mid_from_song_detail +QQMusicPluginClient._normalize_detail_song +QQMusicPluginClient._normalize_top_list_track +QQMusicPluginClient._pick_cover +QQMusicPluginAPI._format_song_item +``` + +If a helper still has a real caller, keep it for now and remove it in a later slice instead of breaking the build. + +- [ ] **Step 2: Run the focused QQ Music regression suite** + +Run: `uv run pytest tests/test_plugins/test_qqmusic_plugin.py tests/test_services/test_qqmusic_plugin_source_adapters.py tests/test_services/test_qqmusic_service_perf_paths.py tests/test_services/test_qqmusic_media_helpers.py tests/test_services/test_qqmusic_search_normalizers.py tests/test_services/test_qqmusic_section_builders.py -v` + +Expected: PASS for the full QQ Music refactor coverage set + +- [ ] **Step 3: Run the broader QQ Music/UI regression suite** + +Run: `uv run pytest tests/test_ui/test_online_detail_view_actions.py tests/test_ui/test_online_detail_view_thread_cleanup.py tests/test_ui/test_online_music_view_async.py tests/test_ui/test_online_music_view_focus.py tests/test_ui/test_plugin_settings_tab.py tests/test_plugins/test_qqmusic_theme_integration.py -v` + +Expected: PASS, confirming the refactor did not break plugin UI integration paths + +- [ ] **Step 4: Commit the final cleanup and verification** + +Run: + +```bash +git add plugins/builtin/qqmusic/lib tests/test_plugins/test_qqmusic_plugin.py tests/test_services/test_qqmusic_plugin_source_adapters.py tests/test_services/test_qqmusic_service_perf_paths.py tests/test_services/test_qqmusic_media_helpers.py tests/test_services/test_qqmusic_search_normalizers.py tests/test_services/test_qqmusic_section_builders.py +git commit -m "优化QQ音乐插件结构" +``` From 6d60d2c574faf5f5fe67ca52501f2d9af91a00b1 Mon Sep 17 00:00:00 2001 From: Har01d Date: Wed, 8 Apr 2026 20:35:17 +0800 Subject: [PATCH 119/157] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E6=95=B0=E6=8D=AE?= =?UTF-8?q?=E5=BA=93=E8=B7=A8=E7=BA=BF=E7=A8=8B=E8=BF=9E=E6=8E=A5=E6=B8=85?= =?UTF-8?q?=E7=90=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- infrastructure/database/sqlite_manager.py | 17 +++++++--- .../test_sqlite_manager_thread_connections.py | 31 +++++++++++++++++++ 2 files changed, 43 insertions(+), 5 deletions(-) create mode 100644 tests/test_infrastructure/test_sqlite_manager_thread_connections.py diff --git a/infrastructure/database/sqlite_manager.py b/infrastructure/database/sqlite_manager.py index 975dac55..939ab905 100644 --- a/infrastructure/database/sqlite_manager.py +++ b/infrastructure/database/sqlite_manager.py @@ -35,6 +35,8 @@ def __init__(self, db_path: str = "Harmony.db"): """ self.db_path = db_path self.local = threading.local() + self._connections: Dict[int, sqlite3.Connection] = {} + self._connections_lock = threading.Lock() self._write_worker = get_write_worker(db_path) atexit.register(self.close) self._init_database() @@ -53,6 +55,8 @@ def _get_connection(self) -> sqlite3.Connection: self.local.conn.execute("PRAGMA cache_size=-10000") self.local.conn.execute("PRAGMA temp_store=MEMORY") self.local.conn.execute("PRAGMA foreign_keys=ON") + with self._connections_lock: + self._connections[threading.get_ident()] = self.local.conn return self.local.conn def _submit_write(self, func: Callable, *args, **kwargs) -> Future: @@ -2009,15 +2013,18 @@ def get_favorites(self) -> List[Track]: def close(self): """Close database connections and stop the write worker.""" - conn = getattr(self.local, "conn", None) - if conn is not None: + with self._connections_lock: + connections = list(self._connections.values()) + self._connections.clear() + + for conn in connections: try: conn.close() except sqlite3.Error as exc: logger.warning("[Database] Error closing thread-local connection: %s", exc) - finally: - if hasattr(self.local, "conn"): - delattr(self.local, "conn") + + if hasattr(self.local, "conn"): + delattr(self.local, "conn") if self._write_worker is not None: try: diff --git a/tests/test_infrastructure/test_sqlite_manager_thread_connections.py b/tests/test_infrastructure/test_sqlite_manager_thread_connections.py new file mode 100644 index 00000000..635d574a --- /dev/null +++ b/tests/test_infrastructure/test_sqlite_manager_thread_connections.py @@ -0,0 +1,31 @@ +import threading +import tempfile +from pathlib import Path + +from infrastructure.database.sqlite_manager import DatabaseManager + + +def test_close_shuts_down_connections_created_on_other_threads(): + with tempfile.TemporaryDirectory() as temp_dir: + db_path = str(Path(temp_dir) / "thread-connections.db") + manager = DatabaseManager(db_path) + worker_conn = None + + def open_connection(): + nonlocal worker_conn + worker_conn = manager._get_connection() + worker_conn.execute("SELECT 1").fetchone() + + thread = threading.Thread(target=open_connection) + thread.start() + thread.join(timeout=2) + + assert worker_conn is not None + manager.close() + + try: + worker_conn.execute("SELECT 1") + except Exception as exc: + assert "closed" in str(exc).lower() + else: + raise AssertionError("worker connection should be closed by manager.close()") From 9862a2db855fc0dddf19e269b4111c7b1a190704 Mon Sep 17 00:00:00 2001 From: Har01d Date: Wed, 8 Apr 2026 20:36:06 +0800 Subject: [PATCH 120/157] =?UTF-8?q?=E6=8F=90=E5=8F=96QQ=E9=9F=B3=E4=B9=90?= =?UTF-8?q?=E5=AA=92=E4=BD=93=E8=BE=85=E5=8A=A9=E5=87=BD=E6=95=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- plugins/builtin/qqmusic/lib/media_helpers.py | 41 +++++++++++++++++++ .../test_qqmusic_media_helpers.py | 36 ++++++++++++++++ 2 files changed, 77 insertions(+) create mode 100644 plugins/builtin/qqmusic/lib/media_helpers.py create mode 100644 tests/test_services/test_qqmusic_media_helpers.py diff --git a/plugins/builtin/qqmusic/lib/media_helpers.py b/plugins/builtin/qqmusic/lib/media_helpers.py new file mode 100644 index 00000000..405cf74e --- /dev/null +++ b/plugins/builtin/qqmusic/lib/media_helpers.py @@ -0,0 +1,41 @@ +from __future__ import annotations + +from typing import Any, Mapping + + +def build_album_cover_url(album_mid: str, size: int) -> str | None: + if not album_mid: + return None + return f"https://y.gtimg.cn/music/photo_new/T002R{size}x{size}M000{album_mid}.jpg" + + +def build_artist_cover_url(singer_mid: str, size: int) -> str | None: + if not singer_mid: + return None + return f"https://y.gtimg.cn/music/photo_new/T001R{size}x{size}M000{singer_mid}.jpg" + + +def extract_album_mid(detail: Mapping[str, Any] | None) -> str: + if not isinstance(detail, Mapping): + return "" + track = detail.get("track_info", detail.get("data", detail)) + if not isinstance(track, Mapping): + return "" + album = track.get("album", {}) + if isinstance(album, Mapping): + album_mid = album.get("mid") or album.get("albumMid") or album.get("albummid") + if album_mid: + return str(album_mid) + return str(track.get("album_mid") or track.get("albummid") or track.get("albumMid") or "") + + +def pick_lyric_text(lyric_data: Mapping[str, Any] | None) -> str | None: + if not isinstance(lyric_data, Mapping): + return None + qrc = lyric_data.get("qrc") + if qrc: + return str(qrc) + lyric = lyric_data.get("lyric") + if lyric: + return str(lyric) + return None diff --git a/tests/test_services/test_qqmusic_media_helpers.py b/tests/test_services/test_qqmusic_media_helpers.py new file mode 100644 index 00000000..3dbc6074 --- /dev/null +++ b/tests/test_services/test_qqmusic_media_helpers.py @@ -0,0 +1,36 @@ +from plugins.builtin.qqmusic.lib.media_helpers import ( + build_album_cover_url, + build_artist_cover_url, + extract_album_mid, + pick_lyric_text, +) + + +def test_build_album_cover_url_returns_expected_url(): + assert build_album_cover_url("album-1", 500) == ( + "https://y.gtimg.cn/music/photo_new/T002R500x500M000album-1.jpg" + ) + + +def test_build_artist_cover_url_returns_expected_url(): + assert build_artist_cover_url("artist-1", 300) == ( + "https://y.gtimg.cn/music/photo_new/T001R300x300M000artist-1.jpg" + ) + + +def test_extract_album_mid_supports_track_info_album(): + payload = {"track_info": {"album": {"mid": "album-from-track"}}} + + assert extract_album_mid(payload) == "album-from-track" + + +def test_extract_album_mid_supports_flat_album_mid_keys(): + payload = {"data": {"albumMid": "album-from-data"}} + + assert extract_album_mid(payload) == "album-from-data" + + +def test_pick_lyric_text_prefers_qrc_then_plain_lyric(): + assert pick_lyric_text({"qrc": "[0,100]qrc", "lyric": "[00:00.00]plain"}) == "[0,100]qrc" + assert pick_lyric_text({"qrc": "", "lyric": "[00:00.00]plain"}) == "[00:00.00]plain" + assert pick_lyric_text({"qrc": None, "lyric": None}) is None From 1a2d6555549d121850546bed378aa7880d15958d Mon Sep 17 00:00:00 2001 From: Har01d Date: Wed, 8 Apr 2026 20:36:35 +0800 Subject: [PATCH 121/157] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E4=BA=91=E8=B4=A6?= =?UTF-8?q?=E6=88=B7=E7=A1=AC=E5=88=A0=E9=99=A4=E5=89=AF=E4=BD=9C=E7=94=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- repositories/cloud_repository.py | 3 +++ .../test_cloud_repository.py | 21 +++++++++++++++++++ 2 files changed, 24 insertions(+) diff --git a/repositories/cloud_repository.py b/repositories/cloud_repository.py index df6b9652..0110044f 100644 --- a/repositories/cloud_repository.py +++ b/repositories/cloud_repository.py @@ -303,6 +303,9 @@ def hard_delete_account(self, account_id: int) -> bool: conn = self._get_connection() try: cursor = conn.cursor() + cursor.execute("SELECT 1 FROM cloud_accounts WHERE id = ?", (account_id,)) + if cursor.fetchone() is None: + return False # Delete associated files first cursor.execute("DELETE FROM cloud_files WHERE account_id = ?", (account_id,)) # Delete account diff --git a/tests/test_repositories/test_cloud_repository.py b/tests/test_repositories/test_cloud_repository.py index cdaaaa55..41498541 100644 --- a/tests/test_repositories/test_cloud_repository.py +++ b/tests/test_repositories/test_cloud_repository.py @@ -805,3 +805,24 @@ def test_hard_delete_account_nonexistent(self, cloud_repo): """Test hard deleting non-existent account returns False.""" result = cloud_repo.hard_delete_account(99999) assert result is False + + def test_hard_delete_account_nonexistent_does_not_delete_orphan_files(self, temp_db): + """Hard delete should not remove orphan files when account row is absent.""" + conn = sqlite3.connect(temp_db) + cursor = conn.cursor() + cursor.execute( + """ + INSERT INTO cloud_files (account_id, file_id, name, file_type) + VALUES (?, ?, ?, ?) + """, + (99999, "orphan-file", "orphan.mp3", "audio"), + ) + conn.commit() + conn.close() + + repo = SqliteCloudRepository(temp_db) + + result = repo.hard_delete_account(99999) + + assert result is False + assert repo.get_file_by_id("orphan-file") is not None From 543491c18a99986a355468cec425bd6d5b405d04 Mon Sep 17 00:00:00 2001 From: Har01d Date: Wed, 8 Apr 2026 20:38:17 +0800 Subject: [PATCH 122/157] =?UTF-8?q?=E7=BB=9F=E4=B8=80QQ=E9=9F=B3=E4=B9=90?= =?UTF-8?q?=E6=90=9C=E7=B4=A2=E7=BB=93=E6=9E=9C=E5=BD=92=E4=B8=80=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- plugins/builtin/qqmusic/lib/api.py | 92 ++++---------- .../builtin/qqmusic/lib/search_normalizers.py | 114 ++++++++++++++++++ .../test_qqmusic_search_normalizers.py | 81 +++++++++++++ 3 files changed, 221 insertions(+), 66 deletions(-) create mode 100644 plugins/builtin/qqmusic/lib/search_normalizers.py create mode 100644 tests/test_services/test_qqmusic_search_normalizers.py diff --git a/plugins/builtin/qqmusic/lib/api.py b/plugins/builtin/qqmusic/lib/api.py index 47383eb0..460e71d0 100644 --- a/plugins/builtin/qqmusic/lib/api.py +++ b/plugins/builtin/qqmusic/lib/api.py @@ -3,6 +3,13 @@ from typing import Any, Optional from plugins.builtin.qqmusic.lib.common import parse_quality +from .media_helpers import build_album_cover_url, build_artist_cover_url +from .search_normalizers import ( + normalize_album_item, + normalize_artist_item, + normalize_playlist_item, + normalize_song_item, +) class QQMusicPluginAPI: @@ -31,20 +38,19 @@ def search( total = self._extract_search_total(data, payload, items) if search_type == "song": return { - "tracks": [self._format_song_item(song) for song in items[:limit]], + "tracks": [normalize_song_item(song) for song in items[:limit]], "total": total, } if search_type == "singer": return { "artists": [ { - "mid": item.get("singerMID", item.get("mid", "")), - "name": item.get("singerName", item.get("name", "")), - "avatar_url": item.get("singerPic", item.get("avatar", item.get("cover_url", ""))) - or self.get_artist_cover_url(item.get("singerMID", item.get("mid", ""))), - "song_count": item.get("songNum", item.get("song_count", item.get("songnum", 0))), - "album_count": item.get("albumNum", item.get("album_count", item.get("albumnum", 0))), - "fan_count": item.get("fansNum", item.get("fan_count", item.get("FanNum", 0))), + **normalize_artist_item(item), + "avatar_url": normalize_artist_item(item).get("avatar_url") + or build_artist_cover_url( + str(item.get("singerMID", item.get("mid", ""))), + 300, + ), } for item in items[:limit] ], @@ -54,31 +60,19 @@ def search( return { "albums": [ { - "mid": item.get("albummid", item.get("mid", "")), - "name": item.get("name", item.get("albumname", "")), - "singer_name": self._extract_singer_name(item), - "cover_url": item.get("pic", item.get("cover", item.get("cover_url", ""))) - or self.get_cover_url(album_mid=item.get("albummid", item.get("mid", ""))), - "song_count": item.get("song_num", item.get("song_count", 0)), - "publish_date": item.get("publish_date", item.get("pubTime", "")), + **normalize_album_item(item), + "cover_url": normalize_album_item(item).get("cover_url") + or build_album_cover_url( + str(item.get("albummid", item.get("mid", ""))), + 500, + ), } for item in items[:limit] ], "total": total, } return { - "playlists": [ - { - "id": str(item.get("dissid", item.get("id", ""))), - "mid": item.get("dissMID", item.get("mid", "")), - "title": item.get("dissname", item.get("title", "")), - "creator": item.get("nickname", item.get("creator", "")), - "cover_url": item.get("logo", item.get("imgurl", item.get("cover_url", item.get("cover", "")))), - "song_count": item.get("songnum", item.get("song_count", 0)), - "play_count": item.get("listennum", item.get("play_count", 0)), - } - for item in items[:limit] - ], + "playlists": [normalize_playlist_item(item) for item in items[:limit]], "total": total, } @@ -163,7 +157,7 @@ def get_top_list_tracks(self, top_id: int | str, limit: int = 100) -> list[dict] items = data.get("data", {}).get("songInfoList", []) if not items: items = data.get("data", {}).get("data", {}).get("song", []) - return [self._format_song_item(song) for song in items[:limit]] + return [normalize_song_item(song) for song in items[:limit]] def get_lyrics(self, mid: str) -> Optional[str]: response = self._context.http.get( @@ -181,7 +175,7 @@ def get_cover_url( size: int = 500, ) -> Optional[str]: if album_mid: - return f"https://y.gtimg.cn/music/photo_new/T002R{size}x{size}M000{album_mid}.jpg" + return build_album_cover_url(album_mid, size) if mid: response = self._context.http.get( f"{self.REMOTE_BASE_URL}/song/cover", @@ -196,7 +190,7 @@ def get_cover_url( return None def get_artist_cover_url(self, singer_mid: str, size: int = 300) -> Optional[str]: - return f"https://y.gtimg.cn/music/photo_new/T001R{size}x{size}M000{singer_mid}.jpg" + return build_artist_cover_url(singer_mid, size) def get_playback_url_info(self, track_id: str, quality: str) -> dict[str, str] | None: response = self._context.http.get( @@ -256,7 +250,7 @@ def get_album_detail(self, album_mid: str) -> dict | None: return None album = data.get("data", {}) basic_info = album.get("basicInfo", {}) - songs = [self._format_song_item(item.get("songInfo", item)) for item in album.get("songList", [])] + songs = [normalize_song_item(item.get("songInfo", item)) for item in album.get("songList", [])] return { "title": basic_info.get("albumName", album.get("name", "")), "description": basic_info.get("desc", ""), @@ -274,7 +268,7 @@ def get_playlist_detail(self, playlist_id: str) -> dict | None: return None playlist = data.get("data", {}) dirinfo = playlist.get("dirinfo", {}) - songs = [self._format_song_item(item) for item in playlist.get("songlist", [])] + songs = [normalize_song_item(item) for item in playlist.get("songlist", [])] return { "title": dirinfo.get("title", playlist.get("name", "")), "description": dirinfo.get("desc", playlist.get("description", "")), @@ -312,37 +306,3 @@ def complete(self, keyword: str) -> list[dict]: if hint: results.append({"hint": hint}) return results - - def _format_song_item(self, song: dict) -> dict: - singer_info = song.get("singer", "") - if isinstance(singer_info, list) and singer_info: - singer_name = ", ".join(item.get("name", "") for item in singer_info if item.get("name")) - elif isinstance(singer_info, dict): - singer_name = singer_info.get("name", "") - else: - singer_name = str(song.get("singerName", singer_info or "")) - - album_info = song.get("album", {}) - if isinstance(album_info, dict): - album_name = album_info.get("name", "") or song.get("albumName", "") - album_mid = album_info.get("mid", "") or song.get("albumMid", "") - else: - album_name = str(album_info or song.get("albumName", "")) - album_mid = song.get("albumMid", "") - - return { - "mid": song.get("mid", "") or song.get("songmid", "") or song.get("songMid", ""), - "name": song.get("name", "") or song.get("songname", "") or song.get("title", ""), - "title": song.get("name", "") or song.get("songname", "") or song.get("title", ""), - "artist": singer_name, - "singer": singer_name, - "album": album_name, - "album_mid": album_mid, - "duration": song.get("interval", 0) or song.get("duration", 0), - } - - def _extract_singer_name(self, item: dict) -> str: - singer_list = item.get("singer_list", []) - if singer_list and isinstance(singer_list, list): - return ", ".join(entry.get("name", "") for entry in singer_list if entry.get("name")) - return item.get("singer", "") diff --git a/plugins/builtin/qqmusic/lib/search_normalizers.py b/plugins/builtin/qqmusic/lib/search_normalizers.py new file mode 100644 index 00000000..c089495f --- /dev/null +++ b/plugins/builtin/qqmusic/lib/search_normalizers.py @@ -0,0 +1,114 @@ +from __future__ import annotations + +from typing import Any, Mapping + + +def _join_artist_names(value: Any) -> str: + if isinstance(value, list): + return ", ".join( + entry.get("name", "") + for entry in value + if isinstance(entry, Mapping) and entry.get("name") + ) + if isinstance(value, Mapping): + return str(value.get("name", "")) + return str(value or "") + + +def normalize_song_item(song: Mapping[str, Any]) -> dict[str, Any]: + singer_name = _join_artist_names(song.get("singer")) or str(song.get("singerName", "")) + album_info = song.get("album", {}) + if isinstance(album_info, Mapping): + album_name = album_info.get("name", "") or song.get("albumName", "") + album_mid = album_info.get("mid", "") or song.get("albumMid", "") + else: + album_name = str(album_info or song.get("albumName", "")) + album_mid = song.get("albumMid", "") + title = song.get("name", "") or song.get("songname", "") or song.get("title", "") + return { + "mid": song.get("mid", "") or song.get("songmid", "") or song.get("songMid", ""), + "name": title, + "title": title, + "artist": singer_name, + "singer": singer_name, + "album": album_name, + "album_mid": album_mid, + "duration": song.get("interval", 0) or song.get("duration", 0), + } + + +def normalize_detail_song(item: Mapping[str, Any]) -> dict[str, Any]: + singer_name = _join_artist_names(item.get("artist")) or _join_artist_names(item.get("singer")) + album_value = item.get("album", {}) + if isinstance(album_value, Mapping): + album_name = album_value.get("name", item.get("albumname", "")) + album_mid = album_value.get("mid", item.get("album_mid", "")) or item.get("albummid", "") + else: + album_name = str(album_value or item.get("albumname", "")) + album_mid = str(item.get("album_mid", item.get("albummid", "")) or "") + return { + "mid": item.get("mid", "") or item.get("songmid", ""), + "title": item.get("title", item.get("name", "")), + "artist": singer_name, + "album": album_name, + "album_mid": album_mid, + "duration": item.get("interval", item.get("duration", 0)), + } + + +def normalize_top_list_track(item: Any) -> dict[str, Any]: + if isinstance(item, Mapping): + normalized = normalize_detail_song(item) + return { + "mid": normalized["mid"], + "title": normalized["title"], + "artist": normalized["artist"], + "album": normalized["album"], + "album_mid": normalized["album_mid"], + "duration": int(normalized["duration"] or 0), + } + return { + "mid": getattr(item, "mid", ""), + "title": getattr(item, "title", ""), + "artist": getattr(item, "singer_name", ""), + "album": getattr(item, "album_name", ""), + "album_mid": getattr(getattr(item, "album", None), "mid", ""), + "duration": getattr(item, "duration", 0), + } + + +def normalize_artist_item(item: Mapping[str, Any]) -> dict[str, Any]: + return { + "mid": str(item.get("singerMID", "") or item.get("mid", "")), + "name": str(item.get("singerName", "") or item.get("name", "")), + "avatar_url": item.get("singerPic", item.get("avatar", item.get("cover_url", ""))), + "song_count": int(item.get("songNum", item.get("song_count", item.get("songnum", 0))) or 0), + "album_count": int(item.get("albumNum", item.get("album_count", item.get("albumnum", 0))) or 0), + "fan_count": int(item.get("fansNum", item.get("fan_count", item.get("FanNum", 0))) or 0), + } + + +def normalize_album_item(item: Mapping[str, Any]) -> dict[str, Any]: + singer_name = item.get("singer", "") + if isinstance(singer_name, list): + singer_name = _join_artist_names(singer_name) + return { + "mid": str(item.get("albummid", item.get("albumMID", item.get("mid", "")))), + "name": item.get("name", item.get("albumname", "")), + "singer_name": str(singer_name or item.get("singerName", "")), + "cover_url": item.get("pic", item.get("cover", item.get("cover_url", ""))), + "song_count": int(item.get("song_num", item.get("song_count", item.get("totalNum", 0))) or 0), + "publish_date": item.get("publish_date", item.get("pubTime", item.get("publishDate", ""))), + } + + +def normalize_playlist_item(item: Mapping[str, Any]) -> dict[str, Any]: + return { + "id": str(item.get("dissid", item.get("id", ""))), + "mid": item.get("dissMID", item.get("mid", "")), + "title": item.get("dissname", item.get("title", "")), + "creator": item.get("nickname", item.get("creator", "")), + "cover_url": item.get("logo", item.get("imgurl", item.get("cover_url", item.get("cover", "")))), + "song_count": item.get("songnum", item.get("song_count", 0)), + "play_count": item.get("listennum", item.get("play_count", 0)), + } diff --git a/tests/test_services/test_qqmusic_search_normalizers.py b/tests/test_services/test_qqmusic_search_normalizers.py new file mode 100644 index 00000000..82b2496e --- /dev/null +++ b/tests/test_services/test_qqmusic_search_normalizers.py @@ -0,0 +1,81 @@ +from plugins.builtin.qqmusic.lib.search_normalizers import ( + normalize_album_item, + normalize_artist_item, + normalize_detail_song, + normalize_playlist_item, + normalize_song_item, + normalize_top_list_track, +) + + +def test_normalize_song_item_supports_remote_api_shape(): + song = { + "mid": "song-1", + "name": "Song 1", + "singer": [{"name": "Singer 1"}], + "album": {"name": "Album 1", "mid": "album-1"}, + "interval": 180, + } + + assert normalize_song_item(song) == { + "mid": "song-1", + "name": "Song 1", + "title": "Song 1", + "artist": "Singer 1", + "singer": "Singer 1", + "album": "Album 1", + "album_mid": "album-1", + "duration": 180, + } + + +def test_normalize_detail_song_supports_service_shape(): + song = { + "mid": "song-1", + "title": "Song 1", + "singer": [{"name": "Singer 1"}], + "album": {"name": "Album 1", "mid": "album-1"}, + "interval": 180, + } + + assert normalize_detail_song(song) == { + "mid": "song-1", + "title": "Song 1", + "artist": "Singer 1", + "album": "Album 1", + "album_mid": "album-1", + "duration": 180, + } + + +def test_normalize_top_list_track_supports_dict_and_object_shapes(): + class _Track: + mid = "song-2" + title = "Song 2" + singer_name = "Singer 2" + album_name = "Album 2" + duration = 200 + + class album: + mid = "album-2" + + assert normalize_top_list_track( + { + "mid": "song-1", + "title": "Song 1", + "artist": [{"name": "Singer 1"}], + "album": {"name": "Album 1", "mid": "album-1"}, + "interval": 180, + } + )["artist"] == "Singer 1" + assert normalize_top_list_track(_Track())["album_mid"] == "album-2" + + +def test_normalize_artist_album_and_playlist_items(): + artist = normalize_artist_item({"singerMID": "artist-1", "singerName": "Singer 1", "songNum": 8}) + album = normalize_album_item({"albummid": "album-1", "name": "Album 1", "singer": "Singer 1"}) + playlist = normalize_playlist_item({"dissid": 3, "dissname": "List 1", "nickname": "User 1"}) + + assert artist["mid"] == "artist-1" + assert album["mid"] == "album-1" + assert playlist["id"] == "3" From d5b22234c76d2d9f560c0cc1b9b939688fa950bb Mon Sep 17 00:00:00 2001 From: Har01d Date: Wed, 8 Apr 2026 20:39:09 +0800 Subject: [PATCH 123/157] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E8=BF=B7=E4=BD=A0?= =?UTF-8?q?=E6=92=AD=E6=94=BE=E5=99=A8=E5=B0=81=E9=9D=A2=E7=BA=BF=E7=A8=8B?= =?UTF-8?q?=E9=80=80=E5=87=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../test_ui/test_mini_player_cover_worker.py | 41 +++++++++++++++++++ ui/windows/mini_player.py | 26 +++++++----- 2 files changed, 56 insertions(+), 11 deletions(-) create mode 100644 tests/test_ui/test_mini_player_cover_worker.py diff --git a/tests/test_ui/test_mini_player_cover_worker.py b/tests/test_ui/test_mini_player_cover_worker.py new file mode 100644 index 00000000..71b0a34f --- /dev/null +++ b/tests/test_ui/test_mini_player_cover_worker.py @@ -0,0 +1,41 @@ +from types import SimpleNamespace + +import ui.windows.mini_player as mini_player_module +from ui.windows.mini_player import MiniPlayer + + +class _FakeThreadPool: + def __init__(self): + self.started = [] + + def start(self, runnable): + self.started.append(runnable) + + +def test_load_cover_async_uses_qt_thread_pool(monkeypatch): + pool = _FakeThreadPool() + + monkeypatch.setattr(mini_player_module.QThreadPool, "globalInstance", lambda: pool) + fake = SimpleNamespace( + _cover_load_version=0, + _cover_loaded=SimpleNamespace(emit=lambda *_args: None), + _player=SimpleNamespace( + cover_service=None, + get_track_cover=lambda *_args, **_kwargs: None, + ), + ) + + MiniPlayer._load_cover_async( + fake, + { + "path": "", + "title": "Song", + "artist": "Artist", + "album": "Album", + "source": "Local", + "cover_path": "", + }, + ) + + assert fake._cover_load_version == 1 + assert len(pool.started) == 1 diff --git a/ui/windows/mini_player.py b/ui/windows/mini_player.py index 1866e522..4be15017 100644 --- a/ui/windows/mini_player.py +++ b/ui/windows/mini_player.py @@ -9,11 +9,10 @@ - Text elision for long titles """ import logging -import threading from contextlib import suppress from typing import Optional -from PySide6.QtCore import Qt, Signal, QSize, QThread, QPropertyAnimation +from PySide6.QtCore import Qt, Signal, QSize, QThread, QPropertyAnimation, QRunnable, QThreadPool from PySide6.QtGui import ( QKeySequence, QShortcut, QPixmap, QColor, QPainterPath, QRegion, QFontMetrics @@ -154,7 +153,7 @@ def __init__(self, player: PlaybackService, parent=None): self._lyrics_thread: Optional[QThread] = None # Lyrics loading thread self._is_hidden = False # Track auto-hide state self._opacity_anim: Optional[QPropertyAnimation] = None # Opacity animation - self._cover_thread: Optional[threading.Thread] = None # Cover loading thread + self._cover_thread = None # Cover loading worker self._cover_load_version = 0 self._setup_ui() @@ -602,15 +601,20 @@ def load_cover(): return self._player.get_track_cover(path, title, artist, album, skip_online=skip_online) - def worker(): - cover_path = load_cover() - # Use signal for thread-safe UI update - self._cover_loaded.emit(cover_path or "", version) + class CoverLoadWorker(QRunnable): + def __init__(self, load_func, signal, worker_version): + super().__init__() + self._load_func = load_func + self._signal = signal + self._worker_version = worker_version - # Run in thread - thread = threading.Thread(target=worker, daemon=True) - self._cover_thread = thread - thread.start() + def run(self): + cover_path = self._load_func() + self._signal.emit(cover_path or "", self._worker_version) + + worker = CoverLoadWorker(load_cover, self._cover_loaded, version) + self._cover_thread = worker + QThreadPool.globalInstance().start(worker) def _on_cover_loaded(self, cover_path: str, version: int): """Apply cover result only when the worker version is still current.""" From e81c079f5ea97e658fb67b8d16a958eb95e3a9c3 Mon Sep 17 00:00:00 2001 From: Har01d Date: Wed, 8 Apr 2026 20:40:33 +0800 Subject: [PATCH 124/157] =?UTF-8?q?=E6=94=B6=E6=95=9BQQ=E9=9F=B3=E4=B9=90?= =?UTF-8?q?=E5=8D=A1=E7=89=87=E7=BB=84=E8=A3=85=E9=80=BB=E8=BE=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- plugins/builtin/qqmusic/lib/client.py | 73 ++++-------------- .../builtin/qqmusic/lib/section_builders.py | 76 +++++++++++++++++++ .../test_qqmusic_section_builders.py | 49 ++++++++++++ 3 files changed, 139 insertions(+), 59 deletions(-) create mode 100644 plugins/builtin/qqmusic/lib/section_builders.py create mode 100644 tests/test_services/test_qqmusic_section_builders.py diff --git a/plugins/builtin/qqmusic/lib/client.py b/plugins/builtin/qqmusic/lib/client.py index 1abfc9a2..d0c47629 100644 --- a/plugins/builtin/qqmusic/lib/client.py +++ b/plugins/builtin/qqmusic/lib/client.py @@ -6,6 +6,7 @@ from .api import QQMusicPluginAPI from .qqmusic_service import QQMusicService +from .section_builders import build_section logger = logging.getLogger(__name__) @@ -213,14 +214,12 @@ def get_recommendations(self) -> list[dict]: data = [] if data: items.append( - { - "id": card_id, - "title": title, - "subtitle": f"{len(data)} 项", - "cover_url": self._pick_cover(data), - "items": data, - "entry_type": entry_type, - } + build_section( + card_id=card_id, + title=title, + entry_type=entry_type, + items=data, + ) ) return items @@ -243,15 +242,13 @@ def get_favorites(self) -> list[dict]: data = [] if data: sections.append( - { - "id": card_id, - "title": title, - "count": len(data), - "subtitle": f"{len(data)} 项", - "cover_url": self._pick_cover(data), - "items": data, - "entry_type": entry_type, - } + build_section( + card_id=card_id, + title=title, + entry_type=entry_type, + items=data, + include_count=True, + ) ) return sections @@ -435,45 +432,3 @@ def _normalize_top_list_track(self, item: Any) -> dict[str, Any]: "album_mid": getattr(getattr(item, "album", None), "mid", ""), "duration": getattr(item, "duration", 0), } - - def _pick_cover(self, items: list[dict[str, Any]]) -> str: - for item in items: - if not isinstance(item, dict): - continue - if isinstance(item.get("Track"), dict): - track = item["Track"] - album = track.get("album", {}) - if isinstance(album, dict) and album.get("mid"): - return f"https://y.gtimg.cn/music/photo_new/T002R300x300M000{album.get('mid')}.jpg" - cover_url = track.get("cover_url") or track.get("cover") or track.get("picurl") or track.get("pic") - if isinstance(cover_url, dict): - cover_url = cover_url.get("default_url") or cover_url.get("small_url") - if cover_url: - return str(cover_url) - if isinstance(item.get("Playlist"), dict): - playlist = item["Playlist"] - basic = playlist.get("basic", {}) if isinstance(playlist.get("basic"), dict) else {} - content = playlist.get("content", {}) if isinstance(playlist.get("content"), dict) else {} - cover_url = ( - basic.get("cover_url") - or basic.get("cover") - or content.get("cover_url") - or content.get("cover") - or playlist.get("cover_url") - or playlist.get("cover") - ) - if isinstance(cover_url, dict): - cover_url = cover_url.get("default_url") or cover_url.get("small_url") - if cover_url: - return str(cover_url) - cover_url = item.get("cover_url") or item.get("cover") or item.get("picurl") or item.get("pic") - if isinstance(cover_url, dict): - cover_url = cover_url.get("default_url") or cover_url.get("small_url") - if cover_url: - return str(cover_url) - album = item.get("album", {}) - if isinstance(album, dict) and album.get("mid"): - return f"https://y.gtimg.cn/music/photo_new/T002R300x300M000{album.get('mid')}.jpg" - if item.get("album_mid"): - return f"https://y.gtimg.cn/music/photo_new/T002R300x300M000{item.get('album_mid')}.jpg" - return "" diff --git a/plugins/builtin/qqmusic/lib/section_builders.py b/plugins/builtin/qqmusic/lib/section_builders.py new file mode 100644 index 00000000..49257e47 --- /dev/null +++ b/plugins/builtin/qqmusic/lib/section_builders.py @@ -0,0 +1,76 @@ +from __future__ import annotations + +from typing import Any + +from .media_helpers import build_album_cover_url + + +def pick_section_cover(items: list[dict[str, Any]]) -> str: + for item in items: + if not isinstance(item, dict): + continue + if isinstance(item.get("Track"), dict): + track = item["Track"] + album = track.get("album", {}) + if isinstance(album, dict): + cover_url = build_album_cover_url(str(album.get("mid", "")), 300) + if cover_url: + return cover_url + cover_url = track.get("cover_url") or track.get("cover") or track.get("picurl") or track.get("pic") + if isinstance(cover_url, dict): + cover_url = cover_url.get("default_url") or cover_url.get("small_url") + if cover_url: + return str(cover_url) + if isinstance(item.get("Playlist"), dict): + playlist = item["Playlist"] + basic = playlist.get("basic", {}) if isinstance(playlist.get("basic"), dict) else {} + content = playlist.get("content", {}) if isinstance(playlist.get("content"), dict) else {} + cover_url = ( + basic.get("cover_url") + or basic.get("cover") + or content.get("cover_url") + or content.get("cover") + or playlist.get("cover_url") + or playlist.get("cover") + ) + if isinstance(cover_url, dict): + cover_url = cover_url.get("default_url") or cover_url.get("small_url") + if cover_url: + return str(cover_url) + cover_url = item.get("cover_url") or item.get("cover") or item.get("picurl") or item.get("pic") + if isinstance(cover_url, dict): + cover_url = cover_url.get("default_url") or cover_url.get("small_url") + if cover_url: + return str(cover_url) + album = item.get("album", {}) + if isinstance(album, dict) and album.get("mid"): + built = build_album_cover_url(str(album.get("mid", "")), 300) + if built: + return built + album_mid = item.get("album_mid") + if album_mid: + built = build_album_cover_url(str(album_mid), 300) + if built: + return built + return "" + + +def build_section( + *, + card_id: str, + title: str, + entry_type: str, + items: list[dict[str, Any]], + include_count: bool = False, +) -> dict[str, Any]: + section = { + "id": card_id, + "title": title, + "subtitle": f"{len(items)} 项", + "cover_url": pick_section_cover(items), + "items": items, + "entry_type": entry_type, + } + if include_count: + section["count"] = len(items) + return section diff --git a/tests/test_services/test_qqmusic_section_builders.py b/tests/test_services/test_qqmusic_section_builders.py new file mode 100644 index 00000000..3b626ddc --- /dev/null +++ b/tests/test_services/test_qqmusic_section_builders.py @@ -0,0 +1,49 @@ +from plugins.builtin.qqmusic.lib.section_builders import build_section, pick_section_cover + + +def test_pick_section_cover_prefers_track_album_mid(): + items = [{"Track": {"album": {"mid": "album-1"}}}] + + assert pick_section_cover(items) == ( + "https://y.gtimg.cn/music/photo_new/T002R300x300M000album-1.jpg" + ) + + +def test_pick_section_cover_reads_nested_playlist_cover_url(): + items = [ + { + "Playlist": { + "basic": { + "cover_url": "https://cover.example/playlist.jpg", + } + } + } + ] + + assert pick_section_cover(items) == "https://cover.example/playlist.jpg" + + +def test_pick_section_cover_falls_back_to_cover_url(): + items = [{"cover_url": "https://cover.example/1.jpg"}] + + assert pick_section_cover(items) == "https://cover.example/1.jpg" + + +def test_build_section_adds_count_only_when_requested(): + recommendation = build_section( + card_id="guess", + title="猜你喜欢", + entry_type="songs", + items=[{"cover_url": "https://cover.example/1.jpg"}], + ) + favorites = build_section( + card_id="fav_songs", + title="我喜欢的歌曲", + entry_type="songs", + items=[{"cover_url": "https://cover.example/1.jpg"}], + include_count=True, + ) + + assert recommendation["subtitle"] == "1 项" + assert "count" not in recommendation + assert favorites["count"] == 1 From aa4df7548ae2a55cdd0329ea18215facd0e0beda Mon Sep 17 00:00:00 2001 From: Har01d Date: Wed, 8 Apr 2026 20:42:19 +0800 Subject: [PATCH 125/157] =?UTF-8?q?=E6=94=B6=E6=95=9BQQ=E9=9F=B3=E4=B9=90P?= =?UTF-8?q?rovider=E4=B8=8EClient=E8=81=8C=E8=B4=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- plugins/builtin/qqmusic/lib/client.py | 85 +++++-------------------- plugins/builtin/qqmusic/lib/provider.py | 37 ++--------- 2 files changed, 22 insertions(+), 100 deletions(-) diff --git a/plugins/builtin/qqmusic/lib/client.py b/plugins/builtin/qqmusic/lib/client.py index d0c47629..8418c848 100644 --- a/plugins/builtin/qqmusic/lib/client.py +++ b/plugins/builtin/qqmusic/lib/client.py @@ -6,6 +6,13 @@ from .api import QQMusicPluginAPI from .qqmusic_service import QQMusicService +from .search_normalizers import ( + normalize_album_item, + normalize_artist_item, + normalize_detail_song, + normalize_playlist_item, + normalize_top_list_track, +) from .section_builders import build_section logger = logging.getLogger(__name__) @@ -117,7 +124,7 @@ def _normalize_legacy_search_payload( items = song_section.get("list", []) total = song_section.get("totalnum") or song_section.get("totalNum") or len(items) return { - "tracks": [self._normalize_detail_song(item) for item in items if isinstance(item, dict)], + "tracks": [normalize_detail_song(item) for item in items if isinstance(item, dict)], "total": int(total or 0), } @@ -128,10 +135,8 @@ def _normalize_legacy_search_payload( return { "artists": [ { - "mid": str(item.get("singerMID", "") or item.get("mid", "")), - "name": str(item.get("singerName", "") or item.get("name", "")), + **normalize_artist_item(item), "pic_url": item.get("pic") or item.get("pic_url") or "", - "song_count": int(item.get("songNum", 0) or item.get("song_count", 0) or 0), } for item in items if isinstance(item, dict) @@ -146,8 +151,8 @@ def _normalize_legacy_search_payload( return { "albums": [ { + **normalize_album_item(item), "mid": str(item.get("albumMID", "") or item.get("mid", "")), - "name": str(item.get("albumName", "") or item.get("name", "")), "artist": str(item.get("singerName", "") or item.get("artist", "")), "cover_url": item.get("albumPic", "") or item.get("cover_url", ""), } @@ -164,7 +169,7 @@ def _normalize_legacy_search_payload( return { "playlists": [ { - "id": str(item.get("dissid", "") or item.get("id", "")), + **normalize_playlist_item(item), "title": str(item.get("dissname", "") or item.get("title", "")), "creator": str( item.get("creator", {}).get("name", "") @@ -192,7 +197,7 @@ def get_top_list_tracks(self, top_id: int | str) -> list[dict]: if service is not None and self._can_use_legacy_network(): data = service.get_top_list_songs(int(top_id), num=100) if isinstance(data, list) and data: - return [self._normalize_top_list_track(item) for item in data] + return [normalize_top_list_track(item) for item in data] return api_data if isinstance(api_data, list) else [] def get_recommendations(self) -> list[dict]: @@ -269,7 +274,7 @@ def get_artist_detail(self, singer_mid: str) -> dict | None: return { "title": detail.get("name", ""), "description": detail.get("desc", ""), - "songs": [self._normalize_detail_song(item) for item in detail.get("songs", [])], + "songs": [normalize_detail_song(item) for item in detail.get("songs", [])], "follow_status": bool(detail.get("follow_status", False)), } return self._api.get_artist_detail(singer_mid) @@ -292,7 +297,7 @@ def get_album_detail(self, album_mid: str) -> dict | None: return { "title": detail.get("name", ""), "description": detail.get("description", ""), - "songs": [self._normalize_detail_song(item) for item in detail.get("songs", [])], + "songs": [normalize_detail_song(item) for item in detail.get("songs", [])], "is_faved": bool(detail.get("fav_status", False)), } return self._api.get_album_detail(album_mid) @@ -305,7 +310,7 @@ def get_playlist_detail(self, playlist_id: str) -> dict | None: return { "title": detail.get("name", ""), "description": detail.get("description", ""), - "songs": [self._normalize_detail_song(item) for item in detail.get("songs", [])], + "songs": [normalize_detail_song(item) for item in detail.get("songs", [])], "is_faved": bool(detail.get("fav_status", False)), } return self._api.get_playlist_detail(playlist_id) @@ -372,63 +377,3 @@ def get_hotkeys(self) -> list[dict]: def complete(self, keyword: str) -> list[dict]: return self._api.complete(keyword) - - def _normalize_detail_song(self, item: dict) -> dict: - singer_value = item.get("singer", "") - if isinstance(singer_value, list): - singer_name = ", ".join(entry.get("name", "") for entry in singer_value if isinstance(entry, dict) and entry.get("name")) - else: - singer_name = str(singer_value or "") - album_value = item.get("album", {}) - if isinstance(album_value, dict): - album_name = album_value.get("name", item.get("albumname", "")) - else: - album_name = str(album_value or item.get("albumname", "")) - return { - "mid": item.get("mid", "") or item.get("songmid", ""), - "title": item.get("title", item.get("name", "")), - "artist": singer_name, - "album": album_name, - "duration": item.get("interval", item.get("duration", 0)), - } - - def _normalize_top_list_track(self, item: Any) -> dict[str, Any]: - if isinstance(item, dict): - singer_value = item.get("artist", item.get("singer", "")) - if isinstance(singer_value, list): - artist = ", ".join( - entry.get("name", "") - for entry in singer_value - if isinstance(entry, dict) and entry.get("name") - ) - elif isinstance(singer_value, dict): - artist = str(singer_value.get("name", "")) - else: - artist = str(singer_value or "") - - album_value = item.get("album", "") - album_mid = "" - if isinstance(album_value, dict): - album = str(album_value.get("name", item.get("albumname", ""))) - album_mid = str(album_value.get("mid", item.get("album_mid", "")) or "") - else: - album = str(album_value or item.get("albumname", "")) - album_mid = str(item.get("album_mid", item.get("albummid", "")) or "") - - return { - "mid": str(item.get("mid", item.get("songmid", ""))), - "title": str(item.get("title", item.get("name", ""))), - "artist": artist, - "album": album, - "album_mid": album_mid, - "duration": int(item.get("interval", item.get("duration", 0)) or 0), - } - - return { - "mid": getattr(item, "mid", ""), - "title": getattr(item, "title", ""), - "artist": getattr(item, "singer_name", ""), - "album": getattr(item, "album_name", ""), - "album_mid": getattr(getattr(item, "album", None), "mid", ""), - "duration": getattr(item, "duration", 0), - } diff --git a/plugins/builtin/qqmusic/lib/provider.py b/plugins/builtin/qqmusic/lib/provider.py index 7480c06b..6d0c867f 100644 --- a/plugins/builtin/qqmusic/lib/provider.py +++ b/plugins/builtin/qqmusic/lib/provider.py @@ -3,13 +3,14 @@ import logging from typing import Any -from .api import QQMusicPluginAPI from harmony_plugin_api.media import PluginTrack +from .api import QQMusicPluginAPI from .client import QQMusicPluginClient from .common import get_quality_label_key, get_selectable_qualities from .config_adapter import QQMusicConfigAdapter from .i18n import t +from .media_helpers import build_album_cover_url, extract_album_mid, pick_lyric_text from .online_music_view import OnlineMusicView from .runtime_bridge import ( bind_context, @@ -130,45 +131,22 @@ def get_lyrics(self, song_mid: str) -> str | None: lyric_data = service.get_lyrics(song_mid) or {} except Exception: lyric_data = {} - qrc = lyric_data.get("qrc") - if qrc: - return qrc - lyric = lyric_data.get("lyric") - if lyric: - return lyric + lyric_text = pick_lyric_text(lyric_data) + if lyric_text: + return lyric_text try: return QQMusicPluginAPI(self._context).get_lyrics(song_mid) except Exception: return None - @staticmethod - def _build_album_cover_url(album_mid: str, size: int) -> str | None: - if not album_mid: - return None - return f"https://y.gtimg.cn/music/photo_new/T002R{size}x{size}M000{album_mid}.jpg" - - @staticmethod - def _extract_album_mid_from_song_detail(detail: dict[str, Any] | None) -> str: - if not isinstance(detail, dict): - return "" - track = detail.get("track_info", detail.get("data", detail)) - if not isinstance(track, dict): - return "" - album = track.get("album", {}) - if isinstance(album, dict): - album_mid = album.get("mid") or album.get("albumMid") or album.get("albummid") - if album_mid: - return str(album_mid) - return str(track.get("album_mid") or track.get("albummid") or track.get("albumMid") or "") - def get_cover_url( self, mid: str | None = None, album_mid: str | None = None, size: int = 500, ) -> str | None: - cover_url = self._build_album_cover_url(album_mid or "", size) + cover_url = build_album_cover_url(album_mid or "", size) if cover_url: return cover_url @@ -178,8 +156,7 @@ def get_cover_url( detail = service.client.get_song_detail(mid) except Exception: detail = {} - local_album_mid = self._extract_album_mid_from_song_detail(detail) - cover_url = self._build_album_cover_url(local_album_mid, size) + cover_url = build_album_cover_url(extract_album_mid(detail), size) if cover_url: return cover_url From 8a6687e6882fd3fdeafa3f3a51d219fa45b586fe Mon Sep 17 00:00:00 2001 From: Har01d Date: Wed, 8 Apr 2026 20:44:46 +0800 Subject: [PATCH 126/157] =?UTF-8?q?=E6=94=B6=E6=95=9BQQ=E9=9F=B3=E4=B9=90?= =?UTF-8?q?=E6=9C=8D=E5=8A=A1=E5=B1=82=E6=A0=BC=E5=BC=8F=E5=8C=96=E9=80=BB?= =?UTF-8?q?=E8=BE=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../builtin/qqmusic/lib/qqmusic_service.py | 150 ++++++------------ .../test_qqmusic_service_perf_paths.py | 55 +++++++ 2 files changed, 102 insertions(+), 103 deletions(-) diff --git a/plugins/builtin/qqmusic/lib/qqmusic_service.py b/plugins/builtin/qqmusic/lib/qqmusic_service.py index 824e58b6..40af1069 100644 --- a/plugins/builtin/qqmusic/lib/qqmusic_service.py +++ b/plugins/builtin/qqmusic/lib/qqmusic_service.py @@ -7,7 +7,9 @@ import time from typing import Optional, Dict, List, Any, TYPE_CHECKING +from .media_helpers import build_album_cover_url from .qqmusic_client import QQMusicClient +from .search_normalizers import normalize_detail_song, normalize_top_list_track if TYPE_CHECKING: pass @@ -35,6 +37,46 @@ def credential(self) -> Optional[Dict[str, Any]]: """Get current credential.""" return self._credential + @staticmethod + def _build_song_payload(song_info: Dict[str, Any]) -> Dict[str, Any]: + normalized = normalize_detail_song( + { + "mid": song_info.get("mid", "") or song_info.get("songmid", ""), + "title": song_info.get("name", "") or song_info.get("songname", "") or song_info.get("title", ""), + "singer": song_info.get("singer", []), + "album": song_info.get("album", {}), + "albumname": song_info.get("albumname", ""), + "albummid": song_info.get("albummid", ""), + "interval": song_info.get("interval", 0) or song_info.get("duration", 0), + } + ) + singers = song_info.get("singer", []) + singer_list = [] + if isinstance(singers, list): + singer_list = [ + { + "mid": singer.get("mid", ""), + "name": singer.get("name", ""), + } + for singer in singers + if isinstance(singer, dict) + ] + return { + "mid": normalized["mid"], + "songmid": normalized["mid"], + "id": song_info.get("id"), + "name": normalized["title"], + "title": normalized["title"], + "singer": singer_list, + "album": { + "mid": normalized["album_mid"], + "name": normalized["album"], + }, + "albummid": normalized["album_mid"], + "albumname": normalized["album"], + "interval": normalized["duration"], + } + def is_credential_expired(self) -> bool: """ Check if credential is expired. @@ -730,45 +772,7 @@ def get_singer_info(self, singer_mid: str, page: int = 1, page_size: int = 50) - song_list = songs_result.get('songList', []) for song in song_list: song_info = song.get('songInfo', song) - - # Get basic song data - songmid = song_info.get('mid', '') or song_info.get('songmid', '') - songname = song_info.get('name', '') or song_info.get('songname', '') or song_info.get('title', '') - songid = song_info.get('id') - - # Build singer list - singer_info = song_info.get('singer', []) - singer_list_data = [] - if isinstance(singer_info, list): - singer_list_data.extend({ - 'mid': s.get('mid', ''), - 'name': s.get('name', '') - } for s in singer_info) - - # Build album info - album_data = song_info.get('album', {}) - if isinstance(album_data, dict): - albummid = album_data.get('mid', '') - albumname = album_data.get('name', '') - else: - albummid = song_info.get('albummid', '') - albumname = song_info.get('albumname', '') - - songs.append({ - 'mid': songmid, - 'songmid': songmid, - 'id': songid, - 'name': songname, - 'title': songname, - 'singer': singer_list_data, - 'album': { - 'mid': albummid, - 'name': albumname - }, - 'albummid': albummid, - 'albumname': albumname, - 'interval': song_info.get('interval', 0) or song_info.get('duration', 0), - }) + songs.append(self._build_song_payload(song_info)) logger.info(f"Page {page}: Got {len(songs)} songs for {singer_name}") @@ -905,28 +909,7 @@ def get_singer_info_with_follow_status(self, singer_mid: str, page: int = 1, pag for song in song_list: song_info = song.get("songInfo", song) - singers = song_info.get("singer", []) - - # Get album info - album_data = song_info.get("album", {}) - albummid = album_data.get("mid", "") or album_data.get("albummid", "") - albumname = album_data.get("name", "") or album_data.get("name", "") - - songs.append({ - 'mid': song_info.get("mid", "") or song_info.get("songmid", ""), - 'id': song_info.get("id", 0), - 'name': song_info.get("name", ""), - 'title': song_info.get("name", ""), - 'singer': [{'mid': s.get("mid", ""), 'name': s.get("name", "")} for s in singers] if isinstance( - singers, list) else [], - 'album': { - 'mid': albummid, - 'name': albumname - }, - 'albummid': albummid, - 'albumname': albumname, - 'interval': song_info.get('interval', 0) or song_info.get('duration', 0), - }) + songs.append(self._build_song_payload(song_info)) # Parse albums from req_3 albums = [] @@ -939,7 +922,7 @@ def get_singer_info_with_follow_status(self, singer_mid: str, page: int = 1, pag for album in album_list: album_mid = album.get("albumMid", "") - cover_url = f"https://y.gtimg.cn/music/photo_new/T002R300x300M000{album_mid}.jpg" if album_mid else '' + cover_url = build_album_cover_url(album_mid, 300) or "" albums.append({ 'mid': album_mid, 'id': album.get("albumID", 0), @@ -1002,7 +985,7 @@ def get_singer_albums(self, singer_mid: str, number: int = 10, begin: int = 0) - for album in album_list: album_mid = album.get('albumMid', '') or album.get('mid', '') - cover_url = f"https://y.gtimg.cn/music/photo_new/T002R300x300M000{album_mid}.jpg" if album_mid else '' + cover_url = build_album_cover_url(album_mid, 300) or "" albums.append({ 'mid': album_mid, @@ -1098,46 +1081,7 @@ def get_top_list_songs(self, top_id: int, num: int = 100) -> List[Dict[str, Any] if track_info: song['songmid'] = track_info.get('mid', '') - tracks = [] - - for song in songs: - # Handle singer data - can be singerName (string) or singer (list/dict) - singer_info = song.get('singer') or song.get('singerName', '') - if isinstance(singer_info, str): - singer_name = singer_info - elif isinstance(singer_info, list) and singer_info: - singer_name = singer_info[0].get('name', '') - elif isinstance(singer_info, dict): - singer_name = singer_info.get('name', '') - else: - singer_name = '' - - # Handle album data - can be albumName, albumname, album (dict) - album_info = song.get('album') or {} - if isinstance(album_info, str): - album_name = album_info - album_mid = '' - elif isinstance(album_info, dict): - album_name = album_info.get('name', '') - album_mid = album_info.get('mid', '') - else: - album_name = song.get('albumName', '') or song.get('albumname', '') - album_mid = song.get('albumMid', '') or song.get('albummid', '') - - # Handle duration - interval is in seconds - duration = song.get('interval') or song.get('duration') or 0 - - track = { - 'mid': song.get('songmid', '') or song.get('mid', ''), - 'title': song.get('songname', '') or song.get('title', '') or song.get('name', ''), - 'singer': singer_name, - 'album': album_name, - 'album_mid': album_mid, - 'duration': duration, - } - tracks.append(track) - - return tracks + return [normalize_top_list_track(song) for song in songs] except Exception as e: logger.error(f"Get top list songs failed: {e}", exc_info=True) diff --git a/tests/test_services/test_qqmusic_service_perf_paths.py b/tests/test_services/test_qqmusic_service_perf_paths.py index c1489136..8db92aa0 100644 --- a/tests/test_services/test_qqmusic_service_perf_paths.py +++ b/tests/test_services/test_qqmusic_service_perf_paths.py @@ -74,3 +74,58 @@ def test_get_top_lists_flattens_groups(): {"id": 1, "title": "Top 1", "type": 0}, {"id": 2, "title": "Top 2", "type": 1}, ] + + +def test_get_singer_albums_builds_cover_url_from_shared_helper(): + service = QQMusicService() + service.client = SimpleNamespace( + get_album_list=lambda *_args, **_kwargs: { + "albumList": [ + { + "albumMid": "album-1", + "albumName": "Album 1", + "singerName": "Singer 1", + "totalNum": 10, + "publishDate": "2024-01-01", + } + ], + "total": 1, + } + ) + + result = service.get_singer_albums("singer-1") + + assert result["albums"][0]["cover_url"] == ( + "https://y.gtimg.cn/music/photo_new/T002R300x300M000album-1.jpg" + ) + + +def test_get_top_list_songs_uses_shared_top_list_normalizer(): + service = QQMusicService() + service.client = SimpleNamespace( + get_top_list_detail=lambda *_args, **_kwargs: { + "songInfoList": [ + { + "mid": "song-1", + "title": "Song 1", + "artist": [{"name": "Singer 1"}], + "album": {"name": "Album 1", "mid": "album-1"}, + "interval": 180, + } + ] + }, + query_songs_by_ids=lambda _ids: [], + ) + + songs = service.get_top_list_songs(1) + + assert songs == [ + { + "mid": "song-1", + "title": "Song 1", + "artist": "Singer 1", + "album": "Album 1", + "album_mid": "album-1", + "duration": 180, + } + ] From 455999f332a2cc01de20d4481132165a91efe1d3 Mon Sep 17 00:00:00 2001 From: Har01d Date: Wed, 8 Apr 2026 20:46:47 +0800 Subject: [PATCH 127/157] =?UTF-8?q?=E4=BC=98=E5=8C=96=E7=BD=91=E6=98=93?= =?UTF-8?q?=E4=BA=91=E6=8F=92=E4=BB=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../netease_cover/lib/artist_cover_source.py | 2 +- .../lib}/common.py | 0 .../builtin/netease_cover/lib/cover_source.py | 2 +- plugins/builtin/netease_lyrics/lib/common.py | 11 +++++++++++ .../builtin/netease_lyrics/lib/lyrics_source.py | 2 +- plugins/builtin/netease_shared/__init__.py | 3 --- tests/test_plugins/test_netease_cover_plugin.py | 17 +++++++++++++++++ .../test_plugins/test_netease_lyrics_plugin.py | 13 ++----------- 8 files changed, 33 insertions(+), 17 deletions(-) rename plugins/builtin/{netease_shared => netease_cover/lib}/common.py (100%) create mode 100644 plugins/builtin/netease_lyrics/lib/common.py delete mode 100644 plugins/builtin/netease_shared/__init__.py diff --git a/plugins/builtin/netease_cover/lib/artist_cover_source.py b/plugins/builtin/netease_cover/lib/artist_cover_source.py index d477c112..80c0fc3b 100644 --- a/plugins/builtin/netease_cover/lib/artist_cover_source.py +++ b/plugins/builtin/netease_cover/lib/artist_cover_source.py @@ -3,7 +3,7 @@ import logging from harmony_plugin_api.cover import PluginArtistCoverResult -from plugins.builtin.netease_shared.common import ( +from plugins.builtin.netease_cover.lib.common import ( build_netease_image_url, netease_headers, ) diff --git a/plugins/builtin/netease_shared/common.py b/plugins/builtin/netease_cover/lib/common.py similarity index 100% rename from plugins/builtin/netease_shared/common.py rename to plugins/builtin/netease_cover/lib/common.py diff --git a/plugins/builtin/netease_cover/lib/cover_source.py b/plugins/builtin/netease_cover/lib/cover_source.py index c8a6b4af..2f1e80d3 100644 --- a/plugins/builtin/netease_cover/lib/cover_source.py +++ b/plugins/builtin/netease_cover/lib/cover_source.py @@ -3,7 +3,7 @@ import logging from harmony_plugin_api.cover import PluginCoverResult -from plugins.builtin.netease_shared.common import ( +from plugins.builtin.netease_cover.lib.common import ( build_netease_image_url, netease_headers, ) diff --git a/plugins/builtin/netease_lyrics/lib/common.py b/plugins/builtin/netease_lyrics/lib/common.py new file mode 100644 index 00000000..77d20b18 --- /dev/null +++ b/plugins/builtin/netease_lyrics/lib/common.py @@ -0,0 +1,11 @@ +from __future__ import annotations + + +def netease_headers() -> dict[str, str]: + return { + "User-Agent": ( + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) " + "AppleWebKit/537.36" + ), + "Referer": "https://music.163.com/", + } diff --git a/plugins/builtin/netease_lyrics/lib/lyrics_source.py b/plugins/builtin/netease_lyrics/lib/lyrics_source.py index fa176303..c8c51b04 100644 --- a/plugins/builtin/netease_lyrics/lib/lyrics_source.py +++ b/plugins/builtin/netease_lyrics/lib/lyrics_source.py @@ -3,7 +3,7 @@ import logging from harmony_plugin_api.lyrics import PluginLyricsResult -from plugins.builtin.netease_shared.common import netease_headers +from plugins.builtin.netease_lyrics.lib.common import netease_headers logger = logging.getLogger(__name__) diff --git a/plugins/builtin/netease_shared/__init__.py b/plugins/builtin/netease_shared/__init__.py deleted file mode 100644 index 1cc567e8..00000000 --- a/plugins/builtin/netease_shared/__init__.py +++ /dev/null @@ -1,3 +0,0 @@ -from .common import build_netease_image_url, netease_headers - -__all__ = ["build_netease_image_url", "netease_headers"] diff --git a/tests/test_plugins/test_netease_cover_plugin.py b/tests/test_plugins/test_netease_cover_plugin.py index f853cc3e..e7994926 100644 --- a/tests/test_plugins/test_netease_cover_plugin.py +++ b/tests/test_plugins/test_netease_cover_plugin.py @@ -4,10 +4,27 @@ from plugins.builtin.netease_cover.lib.artist_cover_source import ( NetEaseArtistCoverPluginSource, ) +from plugins.builtin.netease_cover.lib.common import ( + build_netease_image_url, + netease_headers, +) from plugins.builtin.netease_cover.lib.cover_source import NetEaseCoverPluginSource from plugins.builtin.netease_cover.plugin_main import NetEaseCoverPlugin +def test_netease_cover_helpers_normalize_headers_and_image_urls(): + headers = netease_headers() + + assert headers["Referer"] == "https://music.163.com/" + assert "Mozilla/5.0" in headers["User-Agent"] + assert build_netease_image_url("https://example.com/cover.jpg", "500y500") == ( + "https://example.com/cover.jpg?param=500y500" + ) + assert build_netease_image_url("https://example.com/cover.jpg?foo=1", "500y500") == ( + "https://example.com/cover.jpg?foo=1" + ) + + def test_netease_cover_plugin_registers_cover_and_artist_sources(): context = Mock() plugin = NetEaseCoverPlugin() diff --git a/tests/test_plugins/test_netease_lyrics_plugin.py b/tests/test_plugins/test_netease_lyrics_plugin.py index 84879db0..b9cfd171 100644 --- a/tests/test_plugins/test_netease_lyrics_plugin.py +++ b/tests/test_plugins/test_netease_lyrics_plugin.py @@ -1,25 +1,16 @@ from types import SimpleNamespace from unittest.mock import Mock -from plugins.builtin.netease_shared.common import ( - build_netease_image_url, - netease_headers, -) +from plugins.builtin.netease_lyrics.lib.common import netease_headers from plugins.builtin.netease_lyrics.lib.lyrics_source import NetEaseLyricsPluginSource from plugins.builtin.netease_lyrics.plugin_main import NetEaseLyricsPlugin -def test_netease_shared_helpers_normalize_headers_and_image_urls(): +def test_netease_lyrics_helpers_normalize_headers(): headers = netease_headers() assert headers["Referer"] == "https://music.163.com/" assert "Mozilla/5.0" in headers["User-Agent"] - assert build_netease_image_url("https://example.com/cover.jpg", "500y500") == ( - "https://example.com/cover.jpg?param=500y500" - ) - assert build_netease_image_url("https://example.com/cover.jpg?foo=1", "500y500") == ( - "https://example.com/cover.jpg?foo=1" - ) def test_netease_lyrics_plugin_registers_lyrics_source(): From 7ffab933b6f8cab2b73e48a490d56b57d2aa1411 Mon Sep 17 00:00:00 2001 From: Har01d Date: Wed, 8 Apr 2026 20:49:26 +0800 Subject: [PATCH 128/157] =?UTF-8?q?=E4=BC=98=E5=8C=96QQ=E9=9F=B3=E4=B9=90?= =?UTF-8?q?=E6=8F=92=E4=BB=B6=E7=BB=93=E6=9E=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tests/test_plugins/qqmusic_test_context.py | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/tests/test_plugins/qqmusic_test_context.py b/tests/test_plugins/qqmusic_test_context.py index 30112857..4755c2c4 100644 --- a/tests/test_plugins/qqmusic_test_context.py +++ b/tests/test_plugins/qqmusic_test_context.py @@ -85,6 +85,17 @@ def setup_title_bar(self, *_args, **_kwargs): return None +class _SettingsBridge: + def __init__(self): + self._values = {} + + def get(self, key, default=None): + return self._values.get(key, default) + + def set(self, key, value): + self._values[key] = value + + class _RuntimeBridge: def __init__( self, @@ -176,6 +187,7 @@ def bind_test_context( theme=_ThemeBridge(theme_manager), dialogs=_DialogBridge(), ), + settings=_SettingsBridge(), runtime=_RuntimeBridge( online_service=online_service, download_service=download_service, From ad596e91fc3f8e4def5ca1c615dc56443aae19c5 Mon Sep 17 00:00:00 2001 From: Har01d Date: Wed, 8 Apr 2026 20:53:49 +0800 Subject: [PATCH 129/157] =?UTF-8?q?=E4=BC=98=E5=8C=96=E6=AD=8C=E8=AF=8D?= =?UTF-8?q?=E4=B8=8B=E8=BD=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- services/lyrics/lyrics_loader.py | 46 ------------------- .../test_lyrics_controller_architecture.py | 28 ++++------- .../test_lyrics_controller_thread_cleanup.py | 2 - ...t_lyrics_download_dialog_thread_cleanup.py | 42 +++++++++++++++++ ui/dialogs/lyrics_download_dialog.py | 29 ++---------- ui/windows/components/lyrics_panel.py | 43 ++--------------- 6 files changed, 59 insertions(+), 131 deletions(-) diff --git a/services/lyrics/lyrics_loader.py b/services/lyrics/lyrics_loader.py index c4f1d872..eeea108d 100644 --- a/services/lyrics/lyrics_loader.py +++ b/services/lyrics/lyrics_loader.py @@ -3,19 +3,14 @@ """ import logging -from typing import TYPE_CHECKING from PySide6.QtCore import QThread, Signal from shiboken6 import isValid from .lyrics_service import LyricsService -# Configure logging logger = logging.getLogger(__name__) -if TYPE_CHECKING: - from services.metadata import CoverService - class LyricsLoader(QThread): """ @@ -121,17 +116,14 @@ class LyricsDownloadWorker(QThread): lyrics_downloaded: Emitted when lyrics are downloaded and saved (path, lyrics) download_failed: Emitted when download fails (error_message) search_results_ready: Emitted when search results are ready (list of dicts) - cover_downloaded: Emitted when cover is downloaded (cover_path) """ lyrics_downloaded = Signal(str, str) # path, lyrics download_failed = Signal(str) # error message search_results_ready = Signal(list) # list of search results - cover_downloaded = Signal(str) # cover path def __init__(self, track_path: str, title: str, artist: str, parent=None, song_id: str = None, source: str = None, accesskey: str = None, - download_cover: bool = True, cover_service: 'CoverService' = None, lyrics_data: str = None): """ Initialize the worker. @@ -144,8 +136,6 @@ def __init__(self, track_path: str, title: str, artist: str, parent=None, song_id: If provided, download specific song's lyrics source: Source name ('lrclib', 'netease' or 'kugou') accesskey: Access key for Kugou - download_cover: Whether to download cover art (default: True) - cover_service: CoverService for downloading cover art lyrics_data: Pre-fetched lyrics (for LRCLIB) """ super().__init__(parent) @@ -155,8 +145,6 @@ def __init__(self, track_path: str, title: str, artist: str, parent=None, self._song_id = song_id self._source = source self._accesskey = accesskey - self._should_download_cover = download_cover - self._cover_service = cover_service self._lyrics_data = lyrics_data def run(self): @@ -177,10 +165,6 @@ def run(self): # Save to local file LyricsService.save_lyrics(self._path, lyrics) self.lyrics_downloaded.emit(self._path, lyrics) - - # Try to download cover for NetEase songs if enabled - if self._should_download_cover and self._source == 'netease': - self._download_cover(self._song_id, self._source) else: self.download_failed.emit("Failed to download lyrics for selected song") else: @@ -201,36 +185,6 @@ def run(self): logger.error(f"[LyricsDownloadWorker] Error: {e}") self.download_failed.emit(str(e)) - def _download_cover(self, song_id: str, source: str): - """Download cover art for the song.""" - try: - # Get cover URL - cover_url = LyricsService.get_song_cover_url(song_id, source) - if not cover_url: - return - - # Download cover image - from infrastructure.network import HttpClient - - cover_data = HttpClient.shared().get_content( - cover_url, - headers=LyricsService.HEADERS, - timeout=10, - ) - if not cover_data: - return - - # Save cover to cache directory - if self._cover_service: - cover_path = self._cover_service.save_cover_data_to_cache( - cover_data, self._artist, self._title - ) - if cover_path: - self.cover_downloaded.emit(cover_path) - - except Exception as e: - logger.error(f"[LyricsDownloadWorker] Error downloading cover: {e}", exc_info=True) - class LyricsSearchWorker(QThread): """ diff --git a/tests/test_ui/test_lyrics_controller_architecture.py b/tests/test_ui/test_lyrics_controller_architecture.py index 74362aa0..a9b29937 100644 --- a/tests/test_ui/test_lyrics_controller_architecture.py +++ b/tests/test_ui/test_lyrics_controller_architecture.py @@ -6,6 +6,7 @@ import ui.dialogs.lyrics_edit_dialog as lyrics_edit_dialog_module from ui.windows.components.lyrics_panel import LyricsController +from services.lyrics.lyrics_loader import LyricsDownloadWorker def test_lyrics_controller_constructor_does_not_require_db_manager(): @@ -14,26 +15,17 @@ def test_lyrics_controller_constructor_does_not_require_db_manager(): assert "db_manager" not in params -def test_on_cover_downloaded_uses_library_service_instead_of_db(): - """Cover update should query and update tracks through LibraryService.""" - track = SimpleNamespace(id=123) - library_service = SimpleNamespace( - get_track_by_path=MagicMock(return_value=track), - update_track_cover_path=MagicMock(return_value=True), - ) - current_item = SimpleNamespace(track_id=None, local_path="/tmp/a.mp3", cover_path=None) - fake_controller = SimpleNamespace( - _lyrics_download_path="/tmp/a.mp3", - _library_service=library_service, - _playback=SimpleNamespace(current_track=current_item), - _event_bus=SimpleNamespace(metadata_updated=SimpleNamespace(emit=MagicMock())), - cover_downloaded=SimpleNamespace(emit=MagicMock()), - ) +def test_lyrics_controller_download_does_not_accept_cover_flag(): + """Lyrics download flow should no longer expose a cover-download parameter.""" + params = inspect.signature(LyricsController._download_lyrics_for_song).parameters + assert "download_cover" not in params - LyricsController._on_cover_downloaded(fake_controller, "/tmp/cover.jpg") - library_service.get_track_by_path.assert_called_once_with("/tmp/a.mp3") - library_service.update_track_cover_path.assert_called_once_with(123, "/tmp/cover.jpg") +def test_lyrics_download_worker_constructor_does_not_accept_cover_dependencies(): + """Lyrics download worker should no longer receive cover-download inputs.""" + params = inspect.signature(LyricsDownloadWorker.__init__).parameters + assert "download_cover" not in params + assert "cover_service" not in params def test_edit_lyrics_reads_local_track_from_library_service(monkeypatch): diff --git a/tests/test_ui/test_lyrics_controller_thread_cleanup.py b/tests/test_ui/test_lyrics_controller_thread_cleanup.py index 4f8fdb45..bbd3f489 100644 --- a/tests/test_ui/test_lyrics_controller_thread_cleanup.py +++ b/tests/test_ui/test_lyrics_controller_thread_cleanup.py @@ -37,7 +37,6 @@ def test_stop_lyrics_download_thread_cleanup_disconnects_and_clears_reference(mo finished=SimpleNamespace(disconnect=MagicMock()), lyrics_downloaded=SimpleNamespace(disconnect=MagicMock()), download_failed=SimpleNamespace(disconnect=MagicMock()), - cover_downloaded=SimpleNamespace(disconnect=MagicMock()), deleteLater=MagicMock(), ) fake = SimpleNamespace(_lyrics_download_thread=fake_thread) @@ -48,6 +47,5 @@ def test_stop_lyrics_download_thread_cleanup_disconnects_and_clears_reference(mo fake_thread.finished.disconnect.assert_called_once() fake_thread.lyrics_downloaded.disconnect.assert_called_once() fake_thread.download_failed.disconnect.assert_called_once() - fake_thread.cover_downloaded.disconnect.assert_called_once() fake_thread.deleteLater.assert_called_once() assert fake._lyrics_download_thread is None diff --git a/tests/test_ui/test_lyrics_download_dialog_thread_cleanup.py b/tests/test_ui/test_lyrics_download_dialog_thread_cleanup.py index 3b605956..7dbb3051 100644 --- a/tests/test_ui/test_lyrics_download_dialog_thread_cleanup.py +++ b/tests/test_ui/test_lyrics_download_dialog_thread_cleanup.py @@ -3,8 +3,12 @@ from types import SimpleNamespace from unittest.mock import MagicMock +import pytest +from PySide6.QtWidgets import QCheckBox, QDialog + import ui.dialogs.lyrics_download_dialog as dialog_module from ui.dialogs.lyrics_download_dialog import LyricsDownloadDialog +from system.theme import ThemeManager def _make_fake_thread(**attrs): @@ -17,6 +21,44 @@ class FakeThread: return thread +@pytest.fixture(autouse=True) +def _init_theme(): + config = MagicMock() + config.get.return_value = "dark" + ThemeManager._instance = None + ThemeManager.instance(config) + yield + ThemeManager._instance = None + + +def test_dialog_does_not_expose_download_cover_checkbox(qtbot, monkeypatch): + """Lyrics download dialog should no longer offer cover download UI.""" + monkeypatch.setattr(LyricsDownloadDialog, "_start_search", lambda self: None) + + dialog = LyricsDownloadDialog("Song", "Artist") + qtbot.addWidget(dialog) + + assert not hasattr(dialog, "_download_cover_checkbox") + assert dialog.findChildren(QCheckBox) == [] + + +def test_show_dialog_returns_selected_song_only(monkeypatch): + """Dialog result should now be only the selected song payload.""" + selected_song = {"id": "song-1", "source": "netease"} + + monkeypatch.setattr(LyricsDownloadDialog, "_start_search", lambda self: None) + monkeypatch.setattr(LyricsDownloadDialog, "exec", lambda self: QDialog.Accepted) + monkeypatch.setattr( + LyricsDownloadDialog, + "get_selected_song", + lambda self: selected_song, + ) + + result = LyricsDownloadDialog.show_dialog("Song", "Artist") + + assert result == selected_song + + def test_stop_search_thread_detaches_running_thread_from_dialog(monkeypatch): """Closing with an active search thread should detach it from the dialog lifecycle.""" fake_thread = _make_fake_thread( diff --git a/ui/dialogs/lyrics_download_dialog.py b/ui/dialogs/lyrics_download_dialog.py index ea8731f5..758516f0 100644 --- a/ui/dialogs/lyrics_download_dialog.py +++ b/ui/dialogs/lyrics_download_dialog.py @@ -14,7 +14,6 @@ QListWidgetItem, QPushButton, QLabel, - QCheckBox, QProgressBar, QWidget, QGraphicsDropShadowEffect, @@ -79,13 +78,10 @@ class LyricsDownloadDialog(QDialog): """Dialog for selecting and downloading lyrics from search results. This dialog displays search results from online lyrics sources and allows - the user to select a song to download lyrics (and optionally cover art). + the user to select a song to download lyrics. Results are sorted by match score (highest first). """ - # Signals - download_requested = Signal(dict, bool) # Emits (song_info, download_cover) - _STYLE_TEMPLATE = """ QListWidget { background-color: %background%; @@ -129,7 +125,6 @@ def __init__( self._track_album = track_album self._track_duration = track_duration self._selected_song: Optional[dict] = None - self._download_cover = False self._search_thread: Optional[LyricsSearchThread] = None self._drag_pos = None @@ -197,12 +192,6 @@ def _setup_ui(self): self._song_list.itemDoubleClicked.connect(self.accept) layout.addWidget(self._song_list) - # Checkbox for downloading cover - self._download_cover_checkbox = QCheckBox(t("download_cover")) - self._download_cover_checkbox.setChecked(False) - self._download_cover_checkbox.setToolTip(t("download_cover_tooltip")) - layout.addWidget(self._download_cover_checkbox) - # Buttons button_layout = QHBoxLayout() button_layout.addStretch() @@ -446,20 +435,11 @@ def get_selected_song(self) -> Optional[dict]: """ return self._selected_song - def get_download_cover(self) -> bool: - """Get whether to download cover art. - - Returns: - True if cover should be downloaded - """ - return self._download_cover - def accept(self): """Handle dialog acceptance.""" current_item = self._song_list.currentItem() if current_item: self._selected_song = current_item.data(Qt.UserRole) - self._download_cover = self._download_cover_checkbox.isChecked() LyricsDownloadDialog._stop_search_thread(self, wait_ms=100, cleanup_signals=True) super().accept() @@ -481,7 +461,7 @@ def show_dialog( track_album: str = "", track_duration: float = None, parent=None - ) -> Optional[tuple]: + ) -> Optional[dict]: """Static method to show the dialog and get the result. Args: @@ -493,7 +473,7 @@ def show_dialog( parent: Parent widget Returns: - Tuple of (selected_song, download_cover) or None if cancelled + Selected song dictionary or None if cancelled """ dialog = LyricsDownloadDialog( track_title, @@ -506,9 +486,8 @@ def show_dialog( if dialog.exec() == QDialog.Accepted: selected_song = dialog.get_selected_song() - download_cover = dialog.get_download_cover() if selected_song: - return (selected_song, download_cover) + return selected_song return None diff --git a/ui/windows/components/lyrics_panel.py b/ui/windows/components/lyrics_panel.py index a0ec7194..289a758c 100644 --- a/ui/windows/components/lyrics_panel.py +++ b/ui/windows/components/lyrics_panel.py @@ -19,7 +19,6 @@ from services.lyrics import LyricsLoader from services.lyrics.lyrics_loader import LyricsDownloadWorker -from system.event_bus import EventBus from system.i18n import t from ui.dialogs.message_dialog import MessageDialog, Yes, No from ui.widgets.lyrics_widget_pro import LyricsWidget @@ -173,13 +172,11 @@ class LyricsController(QObject): - Async lyrics loading with version control - Lyrics download from online sources - Lyrics editing and saving - - Cover art download """ # Signals for UI updates lyrics_loaded = Signal(str) lyrics_load_failed = Signal() - cover_downloaded = Signal(str) def __init__( self, @@ -203,14 +200,12 @@ def __init__( self._playback = playback_service self._library_service = library_service - self._event_bus = EventBus.instance() - # Thread management self._lyrics_thread: Optional[LyricsLoader] = None self._lyrics_download_thread: Optional[LyricsDownloadWorker] = None self._lyrics_load_version = 0 - # Store download info for cover update + # Store download info for lyric persistence self._lyrics_download_path: Optional[str] = None self._lyrics_download_title: Optional[str] = None self._lyrics_download_artist: Optional[str] = None @@ -318,10 +313,9 @@ def download_lyrics(self): ) if result: - selected_song, download_cover = result - self._download_lyrics_for_song(selected_song, download_cover) + self._download_lyrics_for_song(result) - def _download_lyrics_for_song(self, song_info: dict, download_cover: bool = True): + def _download_lyrics_for_song(self, song_info: dict): """Download lyrics for a specific song.""" self._stop_lyrics_download_thread(wait_ms=500, cleanup_signals=True) @@ -332,17 +326,12 @@ def _download_lyrics_for_song(self, song_info: dict, download_cover: bool = True song_id=song_info['id'], source=song_info['source'], accesskey=song_info.get('accesskey'), - download_cover=download_cover, - cover_service=self._playback.cover_service, lyrics_data=song_info.get('lyrics') ) self._lyrics_download_thread.lyrics_downloaded.connect(self._on_lyrics_downloaded) self._lyrics_download_thread.download_failed.connect(self._on_lyrics_download_failed) - if download_cover: - self._lyrics_download_thread.cover_downloaded.connect(self._on_cover_downloaded) - self._lyrics_download_thread.finished.connect( self._lyrics_download_thread.deleteLater ) @@ -352,31 +341,6 @@ def _on_lyrics_downloaded(self, path: str, lyrics: str): """Handle lyrics download success.""" self._panel.set_lyrics(lyrics) - def _on_cover_downloaded(self, cover_path: str): - """Handle cover download success.""" - if not cover_path or not self._lyrics_download_path or not self._library_service: - return - - track = self._library_service.get_track_by_path(self._lyrics_download_path) - if not track: - return - - success = self._library_service.update_track_cover_path(track.id, cover_path) - if success: - current_item = self._playback.current_track - if current_item: - is_match = ( - current_item.track_id == track.id or - current_item.local_path == self._lyrics_download_path - ) - if is_match: - current_item.cover_path = cover_path - if not current_item.track_id: - current_item.track_id = track.id - - self._event_bus.metadata_updated.emit(track.id) - self.cover_downloaded.emit(cover_path) - def _on_lyrics_download_failed(self, error: str): """Handle lyrics download failure.""" self._panel.set_no_lyrics() @@ -587,7 +551,6 @@ def _stop_lyrics_download_thread(self, wait_ms: int = 1000, cleanup_signals: boo thread.finished.disconnect() thread.lyrics_downloaded.disconnect() thread.download_failed.disconnect() - thread.cover_downloaded.disconnect() except RuntimeError: pass thread.deleteLater() From 4c966b8e17b3efd0fd7a2e95e1ab7787dda8d10f Mon Sep 17 00:00:00 2001 From: Har01d Date: Wed, 8 Apr 2026 21:08:28 +0800 Subject: [PATCH 130/157] =?UTF-8?q?=E7=BC=96=E5=86=99=E5=9F=BA=E7=A1=80?= =?UTF-8?q?=E4=BC=98=E5=8C=96=E8=AE=BE=E8=AE=A1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...26-04-08-foundation-optimization-design.md | 96 +++++++++++++++++++ 1 file changed, 96 insertions(+) create mode 100644 docs/superpowers/specs/2026-04-08-foundation-optimization-design.md diff --git a/docs/superpowers/specs/2026-04-08-foundation-optimization-design.md b/docs/superpowers/specs/2026-04-08-foundation-optimization-design.md new file mode 100644 index 00000000..b735dac7 --- /dev/null +++ b/docs/superpowers/specs/2026-04-08-foundation-optimization-design.md @@ -0,0 +1,96 @@ +# Foundation Optimization Design + +**Date:** 2026-04-08 + +## Goal + +Implement all still-valid low-risk foundation optimizations from `docs/optimization_report.md`, excluding `plugins/builtin/qqmusic`, and deliver them as separate commits with focused verification. + +## Scope + +This design covers only optimizations that meet all of the following conditions: + +- Still reproducible in the current codebase +- Do not require cross-cutting architectural refactors +- Can be verified with targeted existing or small new tests +- Can be committed independently without coupling unrelated changes + +## Explicitly Included + +### Domain + +- Cache `Album.id` +- Cache `Artist.id` +- Preserve `Genre`'s current per-instance unique ID behavior for empty names while avoiding repeated recomputation for named genres + +### Repositories + +- Remove the extra cover lookup query in `SqliteAlbumRepository.get_by_name()` +- Remove the extra cover lookup query in `SqliteArtistRepository.get_by_name()` +- Remove `ORDER BY RANDOM()` cover selection from `SqliteGenreRepository` + +### Services + +- Reduce redundant local lyrics file open attempts in `LyricsService._get_local_lyrics()` + +### Infrastructure + +- Add a bounded queue to `DBWriteWorker` +- Add HTTP retry configuration to `HttpClient` +- Throttle `HttpClient.download()` progress callbacks +- Make `ImageCache` writes atomic +- Add a size limit and eviction cleanup to `ImageCache` + +## Explicitly Excluded + +These items are intentionally not part of this round: + +- Anything under `plugins/builtin/qqmusic` +- Report items that are already obsolete, including the `SingleFlight` unbounded-cache claim +- High-coupling or behavior-heavy refactors such as `__slots__`, timezone normalization across domain models, `PlaylistItem` responsibility extraction, UI thread offloading, and cloud-service thread-safety rework + +## Constraints + +- Keep behavior stable unless the optimization itself requires a narrow, testable change +- Do not fold unrelated cleanup into optimization commits +- Respect the current dirty worktree and avoid touching unrelated files +- Use one commit per optimization item + +## Planned Commit Sequence + +1. Cache domain IDs in `Album`, `Artist`, and `Genre` +2. Optimize `SqliteAlbumRepository.get_by_name()` +3. Optimize `SqliteArtistRepository.get_by_name()` +4. Remove random-order genre cover selection +5. Optimize local lyrics file loading +6. Bound `DBWriteWorker` queue growth +7. Add `HttpClient` retry behavior +8. Throttle `HttpClient.download()` progress callbacks +9. Make `ImageCache` writes atomic +10. Add `ImageCache` size limiting and eviction + +## Verification Strategy + +Run the smallest relevant tests before each commit: + +- Domain: `tests/test_domain/test_album.py`, `tests/test_domain/test_artist.py`, `tests/test_domain/test_genre_id.py` +- Album repository: `tests/test_repositories/test_album_repository.py` +- Artist repository: `tests/test_repositories/test_artist_repository.py` +- Genre repository: `tests/test_repositories/test_genre_repository.py` +- Lyrics service: targeted lyrics service tests for local file loading +- DB worker: `tests/test_infrastructure/test_db_write_worker.py` +- HTTP client: `tests/test_infrastructure/test_http_client.py`, plus related focused tests if needed +- Image cache: `tests/test_infrastructure/test_image_cache.py` and related cache tests + +After the final optimization commit, run an aggregated regression pass covering the touched foundation modules. + +## Risks And Mitigations + +- Query rewrites may change returned cover selection. + Mitigation: keep result semantics broad where current behavior is already non-deterministic, and assert stable invariants in tests. + +- Queue bounding may introduce backpressure where callers previously assumed unbounded submission. + Mitigation: keep the initial limit conservative and verify submit behavior explicitly. + +- Cache eviction may conflict with tests that assume persistence. + Mitigation: make limits configurable through class attributes and test with temporary directories. From e51d1ecf3c5b0b798744e1844362aaea00106f02 Mon Sep 17 00:00:00 2001 From: Har01d Date: Wed, 8 Apr 2026 21:10:04 +0800 Subject: [PATCH 131/157] =?UTF-8?q?=E4=BC=98=E5=8C=96=E6=89=AB=E7=A0=81?= =?UTF-8?q?=E7=99=BB=E5=BD=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- plugins/builtin/qqmusic/lib/login_dialog.py | 77 +++++++++++++++---- .../test_qqmusic_login_dialog_performance.py | 48 ++++++++++++ 2 files changed, 110 insertions(+), 15 deletions(-) create mode 100644 tests/test_plugins/test_qqmusic_login_dialog_performance.py diff --git a/plugins/builtin/qqmusic/lib/login_dialog.py b/plugins/builtin/qqmusic/lib/login_dialog.py index a5ba67c6..f1edd5d1 100644 --- a/plugins/builtin/qqmusic/lib/login_dialog.py +++ b/plugins/builtin/qqmusic/lib/login_dialog.py @@ -177,7 +177,6 @@ class QQMusicLoginDialog(QDialog): } QRadioButton { color: %text%; - border: 1px solid %border%; font-size: 13px; spacing: 8px; } @@ -281,6 +280,7 @@ def __init__(self, context=None, parent=None): self._setup_shadow() self._login_thread: Optional[QRLoginThread] = None + self._retired_login_threads: list[QRLoginThread] = [] self._login_type = 'wx' # default to WeChat self._language_connected = False @@ -439,18 +439,26 @@ def _update_instructions(self): def _restart_login(self): """Restart login process - stop old thread and start new one.""" - # Keep reference to old thread old_thread = self._login_thread self._login_thread = None - # Stop old thread if exists if old_thread: old_thread.stop() - old_thread.wait(2000) + self._retire_login_thread(old_thread) - # Start new login self._start_login() + def _retire_login_thread(self, thread: QRLoginThread | None) -> None: + if thread is None or thread in self._retired_login_threads: + return + self._retired_login_threads.append(thread) + + def _dispatch_thread_event(self, thread, callback, *args) -> bool: + if thread is not self._login_thread: + return False + callback(*args) + return True + def _start_login(self): """Start QR code login process.""" self._progress_bar.show() @@ -458,24 +466,59 @@ def _start_login(self): self._qr_label.clear() self._status_label.setText(t("qqmusic_fetching_qr")) - # Create new thread thread = QRLoginThread(self._login_type, http_client=self._context.http) - thread.qr_code_ready.connect(self._on_qr_code_ready) - thread.login_success.connect(self._on_login_success) - thread.login_failed.connect(self._on_login_failed) - thread.login_refused.connect(self._on_login_refused) - thread.login_timeout.connect(self._on_login_timeout) - thread.status_update.connect(self._on_status_update) - thread.finished.connect(lambda: self._on_thread_finished(thread)) + thread.qr_code_ready.connect( + lambda data, current=thread: self._dispatch_thread_event( + current, + self._on_qr_code_ready, + data, + ) + ) + thread.login_success.connect( + lambda credential, current=thread: self._dispatch_thread_event( + current, + self._on_login_success, + credential, + ) + ) + thread.login_failed.connect( + lambda error, current=thread: self._dispatch_thread_event( + current, + self._on_login_failed, + error, + ) + ) + thread.login_refused.connect( + lambda current=thread: self._dispatch_thread_event( + current, + self._on_login_refused, + ) + ) + thread.login_timeout.connect( + lambda current=thread: self._dispatch_thread_event( + current, + self._on_login_timeout, + ) + ) + thread.status_update.connect( + lambda status, current=thread: self._dispatch_thread_event( + current, + self._on_status_update, + status, + ) + ) + thread.finished.connect(lambda current=thread: self._on_thread_finished(current)) self._login_thread = thread thread.start() def _on_thread_finished(self, thread): """Handle thread finished event.""" - # Clean up reference if this is the current thread - if self._login_thread == thread: + if self._login_thread is thread: self._login_thread = None + if thread in self._retired_login_threads: + self._retired_login_threads.remove(thread) + thread.deleteLater() def _refresh_qr(self): """Refresh QR code.""" @@ -487,6 +530,8 @@ def _cancel_login(self): """Cancel login and close dialog.""" if self._login_thread: self._login_thread.stop() + self._retire_login_thread(self._login_thread) + self._login_thread = None self.reject() def resizeEvent(self, event): @@ -514,6 +559,8 @@ def closeEvent(self, event): """Handle dialog close event.""" if self._login_thread: self._login_thread.stop() + self._retire_login_thread(self._login_thread) + self._login_thread = None event.accept() @Slot(bytes) diff --git a/tests/test_plugins/test_qqmusic_login_dialog_performance.py b/tests/test_plugins/test_qqmusic_login_dialog_performance.py new file mode 100644 index 00000000..94c61713 --- /dev/null +++ b/tests/test_plugins/test_qqmusic_login_dialog_performance.py @@ -0,0 +1,48 @@ +from unittest.mock import Mock + +from plugins.builtin.qqmusic.lib.login_dialog import QQMusicLoginDialog + + +def test_restart_login_stops_previous_thread_without_waiting(): + dialog = QQMusicLoginDialog.__new__(QQMusicLoginDialog) + old_thread = Mock() + dialog._login_thread = old_thread + dialog._retired_login_threads = [] + dialog._start_login = Mock() + + QQMusicLoginDialog._restart_login(dialog) + + old_thread.stop.assert_called_once_with() + old_thread.wait.assert_not_called() + dialog._start_login.assert_called_once_with() + assert dialog._retired_login_threads == [old_thread] + + +def test_dispatch_thread_event_ignores_stale_thread(): + dialog = QQMusicLoginDialog.__new__(QQMusicLoginDialog) + active_thread = object() + stale_thread = object() + callback = Mock() + dialog._login_thread = active_thread + + assert ( + QQMusicLoginDialog._dispatch_thread_event( + dialog, + stale_thread, + callback, + "stale-value", + ) + is False + ) + callback.assert_not_called() + + assert ( + QQMusicLoginDialog._dispatch_thread_event( + dialog, + active_thread, + callback, + "fresh-value", + ) + is True + ) + callback.assert_called_once_with("fresh-value") From 52771712c55bfdc0d0a39fc23d004e37b2542aa1 Mon Sep 17 00:00:00 2001 From: Har01d Date: Wed, 8 Apr 2026 21:13:18 +0800 Subject: [PATCH 132/157] =?UTF-8?q?=E7=BC=93=E5=AD=98=E8=81=9A=E5=90=88?= =?UTF-8?q?=E5=AE=9E=E4=BD=93ID?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- domain/album.py | 3 ++- domain/artist.py | 3 ++- domain/genre.py | 8 +++++++- tests/test_domain/test_album.py | 10 ++++++++++ tests/test_domain/test_artist.py | 10 ++++++++++ tests/test_domain/test_genre_id.py | 10 ++++++++++ 6 files changed, 41 insertions(+), 3 deletions(-) diff --git a/domain/album.py b/domain/album.py index c3d76723..7b597a7e 100644 --- a/domain/album.py +++ b/domain/album.py @@ -3,6 +3,7 @@ """ from dataclasses import dataclass +from functools import cached_property from typing import Optional @@ -31,7 +32,7 @@ def display_artist(self) -> str: """Get display artist for the album.""" return self.artist if self.artist else "Unknown Artist" - @property + @cached_property def id(self) -> str: """Generate a unique ID for the album based on name and artist.""" # Use name + artist as unique identifier diff --git a/domain/artist.py b/domain/artist.py index 60d43e89..e7ec6952 100644 --- a/domain/artist.py +++ b/domain/artist.py @@ -3,6 +3,7 @@ """ from dataclasses import dataclass +from functools import cached_property from typing import Optional @@ -24,7 +25,7 @@ def display_name(self) -> str: """Get display name for the artist.""" return self.name if self.name else "Unknown Artist" - @property + @cached_property def id(self) -> str: """Generate a unique ID for the artist based on name.""" return self.name.lower() if self.name else "unknown" diff --git a/domain/genre.py b/domain/genre.py index 5418ba6e..7f9d44a7 100644 --- a/domain/genre.py +++ b/domain/genre.py @@ -3,6 +3,7 @@ """ from dataclasses import dataclass +from functools import cached_property from typing import Optional @@ -29,9 +30,14 @@ def display_name(self) -> str: def id(self) -> str: """Generate a unique ID for the genre based on name.""" if self.name: - return self.name.lower() + return self._named_id return f"unknown:{id(self)}" + @cached_property + def _named_id(self) -> str: + """Cache the normalized ID for named genres.""" + return self.name.lower() + def __hash__(self): """Make Genre hashable for use in sets.""" return hash(self.id) diff --git a/tests/test_domain/test_album.py b/tests/test_domain/test_album.py index 53b33371..2aa3c94e 100644 --- a/tests/test_domain/test_album.py +++ b/tests/test_domain/test_album.py @@ -95,3 +95,13 @@ def test_case_insensitive_id(self): assert album1.id == album2.id assert album1 == album2 + + def test_id_property_is_stable_across_accesses(self): + """Test repeated access returns the same computed ID.""" + album = Album(name="Album", artist="Artist") + + first = album.id + second = album.id + + assert first == "artist:album" + assert second == "artist:album" diff --git a/tests/test_domain/test_artist.py b/tests/test_domain/test_artist.py index 0b4d9b35..08a56f6f 100644 --- a/tests/test_domain/test_artist.py +++ b/tests/test_domain/test_artist.py @@ -86,3 +86,13 @@ def test_equality_with_non_artist(self): assert artist != "Artist" assert artist != 123 assert artist is not None + + def test_id_property_is_stable_across_accesses(self): + """Test repeated access returns the same computed ID.""" + artist = Artist(name="Artist") + + first = artist.id + second = artist.id + + assert first == "artist" + assert second == "artist" diff --git a/tests/test_domain/test_genre_id.py b/tests/test_domain/test_genre_id.py index a4a82672..600f2c56 100644 --- a/tests/test_domain/test_genre_id.py +++ b/tests/test_domain/test_genre_id.py @@ -9,3 +9,13 @@ def test_empty_genres_do_not_share_same_generated_id(): assert second.id != "" assert first.id != second.id assert first != second + + +def test_named_genre_id_is_stable_across_accesses(): + genre = Genre(name="Rock") + + first = genre.id + second = genre.id + + assert first == "rock" + assert second == "rock" From 960492b0bbd59de210bc80216ad9cb50f3bcc372 Mon Sep 17 00:00:00 2001 From: Har01d Date: Wed, 8 Apr 2026 21:14:34 +0800 Subject: [PATCH 133/157] =?UTF-8?q?=E4=BC=98=E5=8C=96=E4=B8=93=E8=BE=91?= =?UTF-8?q?=E6=9F=A5=E8=AF=A2=E5=B0=81=E9=9D=A2=E8=81=9A=E5=90=88?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- repositories/album_repository.py | 24 +++---------- .../test_album_repository.py | 36 +++++++++++++++++++ 2 files changed, 41 insertions(+), 19 deletions(-) diff --git a/repositories/album_repository.py b/repositories/album_repository.py index f66f5319..ccc5ba47 100644 --- a/repositories/album_repository.py +++ b/repositories/album_repository.py @@ -124,7 +124,8 @@ def get_by_name(self, album_name: str, artist: str = None) -> Optional[Album]: album as name, artist, COUNT(*) as song_count, - SUM(duration) as total_duration + SUM(duration) as total_duration, + MAX(CASE WHEN cover_path IS NOT NULL AND cover_path != '' THEN cover_path END) as cover_path FROM tracks WHERE album = ? AND artist = ? GROUP BY album, artist @@ -135,7 +136,8 @@ def get_by_name(self, album_name: str, artist: str = None) -> Optional[Album]: album as name, artist, COUNT(*) as song_count, - SUM(duration) as total_duration + SUM(duration) as total_duration, + MAX(CASE WHEN cover_path IS NOT NULL AND cover_path != '' THEN cover_path END) as cover_path FROM tracks WHERE album = ? GROUP BY album, artist @@ -144,26 +146,10 @@ def get_by_name(self, album_name: str, artist: str = None) -> Optional[Album]: if not row: return None - # Get cover from first track of album - if artist: - cursor.execute(""" - SELECT cover_path FROM tracks - WHERE album = ? AND artist = ? AND cover_path IS NOT NULL - LIMIT 1 - """, (album_name, artist)) - else: - cursor.execute(""" - SELECT cover_path FROM tracks - WHERE album = ? AND cover_path IS NOT NULL - LIMIT 1 - """, (album_name,)) - cover_row = cursor.fetchone() - cover_path = cover_row["cover_path"] if cover_row else None - return Album( name=row["name"] or "", artist=row["artist"] or "", - cover_path=cover_path, + cover_path=row["cover_path"], song_count=row["song_count"] or 0, duration=row["total_duration"] or 0.0, ) diff --git a/tests/test_repositories/test_album_repository.py b/tests/test_repositories/test_album_repository.py index ebbcb4f9..25b01882 100644 --- a/tests/test_repositories/test_album_repository.py +++ b/tests/test_repositories/test_album_repository.py @@ -151,6 +151,42 @@ def test_get_by_name_with_artist(self, album_repo, populated_db): assert album.artist == "Artist B" assert album.song_count == 1 + def test_get_by_name_fallback_uses_single_tracks_query(self, temp_db): + """Test fallback get_by_name fetches aggregate data and cover in one tracks query.""" + conn = sqlite3.connect(temp_db) + conn.row_factory = sqlite3.Row + cursor = conn.cursor() + cursor.executemany( + """ + INSERT INTO tracks (path, title, artist, album, duration, cover_path) + VALUES (?, ?, ?, ?, ?, ?) + """, + [ + ("/music/song1.mp3", "Song 1", "Artist A", "Album 1", 180.0, None), + ("/music/song2.mp3", "Song 2", "Artist A", "Album 1", 200.0, "/covers/album1.jpg"), + ], + ) + statements = [] + conn.set_trace_callback(statements.append) + + repo = SqliteAlbumRepository(temp_db) + repo._get_connection = lambda: conn + try: + album = repo.get_by_name("Album 1", artist="Artist A") + finally: + conn.set_trace_callback(None) + conn.close() + + track_selects = [ + statement for statement in statements + if statement.lstrip().upper().startswith("SELECT") + and "FROM TRACKS" in statement.upper() + ] + + assert album is not None + assert album.cover_path == "/covers/album1.jpg" + assert len(track_selects) == 1 + def test_get_by_name_not_found(self, album_repo): """Test getting non-existent album.""" album = album_repo.get_by_name("Nonexistent Album") From 965d886cc45a3477b2318e2566aec6c33116f373 Mon Sep 17 00:00:00 2001 From: Har01d Date: Wed, 8 Apr 2026 21:15:36 +0800 Subject: [PATCH 134/157] =?UTF-8?q?=E4=BC=98=E5=8C=96=E6=AD=8C=E6=89=8B?= =?UTF-8?q?=E6=9F=A5=E8=AF=A2=E5=B0=81=E9=9D=A2=E8=81=9A=E5=90=88?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- repositories/artist_repository.py | 14 ++----- .../test_artist_repository.py | 38 +++++++++++++++++++ 2 files changed, 41 insertions(+), 11 deletions(-) diff --git a/repositories/artist_repository.py b/repositories/artist_repository.py index 5b2f34cc..50be39ea 100644 --- a/repositories/artist_repository.py +++ b/repositories/artist_repository.py @@ -110,7 +110,8 @@ def get_by_name(self, artist_name: str) -> Optional[Artist]: SELECT artist as name, COUNT(*) as song_count, - COUNT(DISTINCT album) as album_count + COUNT(DISTINCT album) as album_count, + MAX(CASE WHEN cover_path IS NOT NULL AND cover_path != '' THEN cover_path END) as cover_path FROM tracks WHERE artist = ? GROUP BY artist @@ -119,18 +120,9 @@ def get_by_name(self, artist_name: str) -> Optional[Artist]: if not row: return None - # Get cover from first track of artist - cursor.execute(""" - SELECT cover_path FROM tracks - WHERE artist = ? AND cover_path IS NOT NULL - LIMIT 1 - """, (artist_name,)) - cover_row = cursor.fetchone() - cover_path = cover_row["cover_path"] if cover_row else None - return Artist( name=row["name"] or "", - cover_path=cover_path, + cover_path=row["cover_path"], song_count=row["song_count"] or 0, album_count=row["album_count"] or 0, ) diff --git a/tests/test_repositories/test_artist_repository.py b/tests/test_repositories/test_artist_repository.py index 9e42f166..b2dcb429 100644 --- a/tests/test_repositories/test_artist_repository.py +++ b/tests/test_repositories/test_artist_repository.py @@ -244,6 +244,44 @@ def test_get_by_name(self, artist_repo, populated_db): assert artist.song_count == 3 assert artist.album_count == 2 # Album 1 and Album 2 + def test_get_by_name_fallback_uses_single_tracks_query(self, temp_db): + """Test fallback get_by_name fetches aggregate data and cover in one tracks query.""" + conn = sqlite3.connect(temp_db) + conn.row_factory = sqlite3.Row + cursor = conn.cursor() + cursor.executemany( + """ + INSERT INTO tracks (path, title, artist, album, duration, cover_path) + VALUES (?, ?, ?, ?, ?, ?) + """, + [ + ("/music/song1.mp3", "Song 1", "Artist A", "Album 1", 180.0, None), + ("/music/song2.mp3", "Song 2", "Artist A", "Album 2", 200.0, "/covers/artist_a.jpg"), + ], + ) + statements = [] + conn.set_trace_callback(statements.append) + + repo = SqliteArtistRepository(temp_db) + repo._get_connection = lambda: conn + try: + artist = repo.get_by_name("Artist A") + finally: + conn.set_trace_callback(None) + conn.close() + + track_selects = [ + statement for statement in statements + if statement.lstrip().upper().startswith("SELECT") + and "FROM TRACKS" in statement.upper() + ] + + assert artist is not None + assert artist.cover_path == "/covers/artist_a.jpg" + assert artist.song_count == 2 + assert artist.album_count == 2 + assert len(track_selects) == 1 + def test_get_by_name_not_found(self, artist_repo): """Test getting non-existent artist.""" artist = artist_repo.get_by_name("Nonexistent Artist") From 7f25d1f954de4f252e99bd67f14ace3bd89ddda7 Mon Sep 17 00:00:00 2001 From: Har01d Date: Wed, 8 Apr 2026 21:16:56 +0800 Subject: [PATCH 135/157] =?UTF-8?q?=E7=A7=BB=E9=99=A4=E6=B5=81=E6=B4=BE?= =?UTF-8?q?=E9=9A=8F=E6=9C=BA=E5=B0=81=E9=9D=A2=E6=9F=A5=E8=AF=A2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- repositories/genre_repository.py | 18 ++-- .../test_genre_repository.py | 88 +++++++++++++++++++ 2 files changed, 97 insertions(+), 9 deletions(-) diff --git a/repositories/genre_repository.py b/repositories/genre_repository.py index 476625fa..44f97642 100644 --- a/repositories/genre_repository.py +++ b/repositories/genre_repository.py @@ -45,7 +45,7 @@ def get_all(self, use_cache: bool = True) -> List[Genre]: WHERE t.genre = g.name AND t.cover_path IS NOT NULL AND t.cover_path != '' - ORDER BY RANDOM() + ORDER BY t.id LIMIT 1 ), ( @@ -55,7 +55,7 @@ def get_all(self, use_cache: bool = True) -> List[Genre]: WHERE t.genre = g.name AND a.cover_path IS NOT NULL AND a.cover_path != '' - ORDER BY RANDOM() + ORDER BY t.id LIMIT 1 ), g.cover_path @@ -88,7 +88,7 @@ def get_all(self, use_cache: bool = True) -> List[Genre]: WHERE t2.genre = t.genre AND t2.cover_path IS NOT NULL AND t2.cover_path != '' - ORDER BY RANDOM() + ORDER BY t2.id LIMIT 1 ) as track_cover_path, ( @@ -98,7 +98,7 @@ def get_all(self, use_cache: bool = True) -> List[Genre]: WHERE t3.genre = t.genre AND a.cover_path IS NOT NULL AND a.cover_path != '' - ORDER BY RANDOM() + ORDER BY t3.id LIMIT 1 ) as album_cover_path, COUNT(*) as song_count, @@ -145,7 +145,7 @@ def get_by_name(self, name: str) -> Optional[Genre]: WHERE t.genre = g.name AND t.cover_path IS NOT NULL AND t.cover_path != '' - ORDER BY RANDOM() + ORDER BY t.id LIMIT 1 ), ( @@ -155,7 +155,7 @@ def get_by_name(self, name: str) -> Optional[Genre]: WHERE t.genre = g.name AND a.cover_path IS NOT NULL AND a.cover_path != '' - ORDER BY RANDOM() + ORDER BY t.id LIMIT 1 ), g.cover_path @@ -187,7 +187,7 @@ def get_by_name(self, name: str) -> Optional[Genre]: WHERE t2.genre = t.genre AND t2.cover_path IS NOT NULL AND t2.cover_path != '' - ORDER BY RANDOM() + ORDER BY t2.id LIMIT 1 ) as track_cover_path, ( @@ -197,7 +197,7 @@ def get_by_name(self, name: str) -> Optional[Genre]: WHERE t3.genre = t.genre AND a.cover_path IS NOT NULL AND a.cover_path != '' - ORDER BY RANDOM() + ORDER BY t3.id LIMIT 1 ) as album_cover_path, COUNT(*) as song_count, @@ -268,7 +268,7 @@ def refresh(self) -> bool: WHERE t2.genre = t.genre AND t2.cover_path IS NOT NULL AND t2.cover_path != '' - ORDER BY RANDOM() + ORDER BY t2.id LIMIT 1 ) as cover_path, COUNT(*) as song_count, diff --git a/tests/test_repositories/test_genre_repository.py b/tests/test_repositories/test_genre_repository.py index 5fb6ff87..0614ac4a 100644 --- a/tests/test_repositories/test_genre_repository.py +++ b/tests/test_repositories/test_genre_repository.py @@ -97,6 +97,94 @@ def test_get_all_uses_random_track_cover_when_cached_cover_missing(): pass +def test_get_all_cached_query_avoids_order_by_random(): + fd, db_path = tempfile.mkstemp(suffix=".db") + os.close(fd) + try: + _create_schema(db_path) + conn = sqlite3.connect(db_path) + conn.row_factory = sqlite3.Row + cursor = conn.cursor() + + cursor.executemany( + """ + INSERT INTO tracks (path, title, artist, album, genre, duration, cover_path) + VALUES (?, ?, ?, ?, ?, ?, ?) + """, + [ + ("/music/a.mp3", "A", "Artist", "Album 1", "Rock", 180.0, ""), + ("/music/b.mp3", "B", "Artist", "Album 1", "Rock", 200.0, "/covers/rock1.jpg"), + ("/music/c.mp3", "C", "Artist", "Album 2", "Rock", 210.0, "/covers/rock2.jpg"), + ], + ) + cursor.execute( + """ + INSERT INTO genres (name, cover_path, song_count, album_count, total_duration) + VALUES ('Rock', NULL, 3, 2, 590.0) + """ + ) + conn.commit() + + statements = [] + conn.set_trace_callback(statements.append) + repo = SqliteGenreRepository(db_path) + repo._get_connection = lambda: conn + try: + genres = repo.get_all(use_cache=True) + finally: + conn.set_trace_callback(None) + conn.close() + + assert len(genres) == 1 + assert genres[0].cover_path in {"/covers/rock1.jpg", "/covers/rock2.jpg"} + assert all("ORDER BY RANDOM()" not in statement.upper() for statement in statements) + finally: + try: + os.unlink(db_path) + except OSError: + pass + + +def test_refresh_query_avoids_order_by_random(): + fd, db_path = tempfile.mkstemp(suffix=".db") + os.close(fd) + try: + _create_schema(db_path) + conn = sqlite3.connect(db_path) + conn.row_factory = sqlite3.Row + cursor = conn.cursor() + cursor.executemany( + """ + INSERT INTO tracks (path, title, artist, album, genre, duration, cover_path) + VALUES (?, ?, ?, ?, ?, ?, ?) + """, + [ + ("/music/a.mp3", "A", "Artist", "Album 1", "Rock", 180.0, ""), + ("/music/b.mp3", "B", "Artist", "Album 1", "Rock", 200.0, "/covers/rock1.jpg"), + ("/music/c.mp3", "C", "Artist", "Album 2", "Rock", 210.0, "/covers/rock2.jpg"), + ], + ) + conn.commit() + + statements = [] + conn.set_trace_callback(statements.append) + repo = SqliteGenreRepository(db_path) + repo._get_connection = lambda: conn + try: + assert repo.refresh() is True + finally: + conn.set_trace_callback(None) + conn.close() + + assert any("INSERT INTO GENRES" in statement.upper() for statement in statements) + assert all("ORDER BY RANDOM()" not in statement.upper() for statement in statements) + finally: + try: + os.unlink(db_path) + except OSError: + pass + + def test_update_cover_path_works_without_updated_at_column(): fd, db_path = tempfile.mkstemp(suffix=".db") os.close(fd) From 84844a2e85ccd007bbd3d47e7567106ce778bb19 Mon Sep 17 00:00:00 2001 From: Har01d Date: Wed, 8 Apr 2026 21:18:47 +0800 Subject: [PATCH 136/157] =?UTF-8?q?=E4=BC=98=E5=8C=96=E6=9C=AC=E5=9C=B0?= =?UTF-8?q?=E6=AD=8C=E8=AF=8D=E8=AF=BB=E5=8F=96=E8=B7=AF=E5=BE=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- services/lyrics/lyrics_service.py | 114 +++++++++++++----- .../test_lyrics_service_local_files.py | 25 ++++ 2 files changed, 110 insertions(+), 29 deletions(-) create mode 100644 tests/test_services/test_lyrics_service_local_files.py diff --git a/services/lyrics/lyrics_service.py b/services/lyrics/lyrics_service.py index 004f455a..2ef7b05f 100644 --- a/services/lyrics/lyrics_service.py +++ b/services/lyrics/lyrics_service.py @@ -2,10 +2,12 @@ Lyrics service for fetching and parsing lyrics. """ import logging -from concurrent.futures import ThreadPoolExecutor, as_completed +import time +from concurrent.futures import FIRST_COMPLETED, ThreadPoolExecutor, wait from pathlib import Path from typing import TYPE_CHECKING, List, Optional +from charset_normalizer import from_bytes from harmony_plugin_api.lyrics import PluginLyricsResult from system.plugins.online_lyrics_helpers import download_online_lyrics from services._singleflight import SingleFlight @@ -126,29 +128,58 @@ def search_songs(cls, title: str, artist: str, limit: int = 10, """ results = [] sources = cls._get_sources() + if not sources: + return results - # Parallel search from multiple sources with progressive updates - with ThreadPoolExecutor(max_workers=len(sources)) as executor: + # Parallel search from multiple sources with progressive updates. + executor = ThreadPoolExecutor(max_workers=len(sources)) + pending = set() + futures = {} + try: futures = { executor.submit(source.search, title, artist, limit): cls._get_source_name(source) for source in sources } + pending = set(futures) + deadline = time.monotonic() + 15 + + while pending: + remaining = deadline - time.monotonic() + if remaining <= 0: + break + + completed, pending = wait( + pending, + timeout=remaining, + return_when=FIRST_COMPLETED, + ) + if not completed: + break - # Wait for each source independently (no overall timeout) - for future in as_completed(futures, timeout=15): - source_name = futures[future] - try: - search_results = future.result(timeout=6) - results.extend(cls._result_to_dict(r) for r in search_results) - logger.debug(f"[LyricsService] {source_name}: found {len(search_results)} results") + for future in completed: + source_name = futures[future] + try: + search_results = future.result(timeout=0) + results.extend(cls._result_to_dict(r) for r in search_results) + logger.debug(f"[LyricsService] {source_name}: found {len(search_results)} results") - # Call progress callback if provided - if progress_callback and search_results: - progress_callback(results, source_name) + if progress_callback and search_results: + progress_callback(results, source_name) - except Exception as e: - # Log but don't fail - other sources may have results - logger.debug(f"[LyricsService] {source_name} search failed: {e}") + except Exception as e: + logger.debug(f"[LyricsService] {source_name} search failed: {e}") + finally: + if pending: + pending_sources = ", ".join( + sorted(futures.get(future, "unknown") for future in pending) + ) + logger.warning( + "[LyricsService] Search timed out for sources: %s", + pending_sources, + ) + for future in pending: + future.cancel() + executor.shutdown(wait=False, cancel_futures=True) # Return all results (user can see all sources) return results @@ -376,23 +407,48 @@ def _get_local_lyrics(cls, track_path: str) -> str: """ track_file = Path(track_path) - # Try multiple encodings to support different file sources - encodings = ['utf-8', 'gbk', 'gb2312', 'gb18030', 'big5', 'utf-16'] - # Try different lyrics file extensions in priority order: .yrc, .qrc, .lrc for ext in ['.yrc', '.qrc', '.lrc']: lyrics_path = track_file.with_suffix(ext) if lyrics_path.exists(): - for encoding in encodings: - try: - with open(lyrics_path, 'r', encoding=encoding) as f: - content = f.read() - return content - except (UnicodeDecodeError, UnicodeError): - continue - except Exception as e: - logger.error(f"Error loading local lyrics from {lyrics_path}: {e}", exc_info=True) - break + try: + raw_content = cls._read_local_lyrics_bytes(lyrics_path) + except Exception as e: + logger.error(f"Error loading local lyrics from {lyrics_path}: {e}", exc_info=True) + continue + + decoded = cls._decode_local_lyrics(raw_content) + if decoded: + return decoded + + return "" + + @staticmethod + def _read_local_lyrics_bytes(lyrics_path: Path) -> bytes: + """Read a local lyrics file once in binary mode.""" + with open(lyrics_path, 'rb') as f: + return f.read() + + @staticmethod + def _decode_local_lyrics(raw_content: bytes) -> str: + """Decode local lyrics content with UTF-8 first and charset detection fallback.""" + if not raw_content: + return "" + + try: + return raw_content.decode('utf-8') + except UnicodeDecodeError: + pass + + detected = from_bytes(raw_content).best() + if detected is not None: + return str(detected) + + for encoding in ['utf-16', 'gb18030', 'gbk', 'gb2312', 'big5']: + try: + return raw_content.decode(encoding) + except (UnicodeDecodeError, UnicodeError): + continue return "" diff --git a/tests/test_services/test_lyrics_service_local_files.py b/tests/test_services/test_lyrics_service_local_files.py new file mode 100644 index 00000000..64b71e42 --- /dev/null +++ b/tests/test_services/test_lyrics_service_local_files.py @@ -0,0 +1,25 @@ +"""Tests for local lyrics file loading paths.""" + +from pathlib import Path + +from services.lyrics.lyrics_service import LyricsService + + +def test_get_local_lyrics_reads_non_utf8_file_once(tmp_path, monkeypatch): + lyrics_path = tmp_path / "song.qrc" + lyrics_path.write_text("[00:00.00]hello", encoding="utf-16") + + open_calls = [] + real_open = open + + def tracking_open(file, mode="r", *args, **kwargs): + if Path(file) == lyrics_path: + open_calls.append((str(file), mode, kwargs.get("encoding"))) + return real_open(file, mode, *args, **kwargs) + + monkeypatch.setattr("builtins.open", tracking_open) + + result = LyricsService._get_local_lyrics(str(tmp_path / "song.mp3")) + + assert result == "[00:00.00]hello" + assert len(open_calls) == 1 From b6e51ae4be77eb69baf001c6075c5133329fbe56 Mon Sep 17 00:00:00 2001 From: Har01d Date: Wed, 8 Apr 2026 21:19:46 +0800 Subject: [PATCH 137/157] =?UTF-8?q?=E9=99=90=E5=88=B6=E6=95=B0=E6=8D=AE?= =?UTF-8?q?=E5=BA=93=E5=86=99=E9=98=9F=E5=88=97=E5=A4=A7=E5=B0=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- infrastructure/database/db_write_worker.py | 4 +++- tests/test_infrastructure/test_db_write_worker.py | 9 +++++++++ 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/infrastructure/database/db_write_worker.py b/infrastructure/database/db_write_worker.py index e393b907..273907ca 100644 --- a/infrastructure/database/db_write_worker.py +++ b/infrastructure/database/db_write_worker.py @@ -32,6 +32,8 @@ class DBWriteWorker: worker.submit_async(db_method, arg1, arg2) """ + MAX_QUEUE_SIZE = 1000 + def __init__(self, db_path: str): """ Initialize the write worker. @@ -40,7 +42,7 @@ def __init__(self, db_path: str): db_path: Path to SQLite database """ self._db_path = db_path - self._queue: queue.Queue = queue.Queue() + self._queue: queue.Queue = queue.Queue(maxsize=self.MAX_QUEUE_SIZE) self._thread: Optional[threading.Thread] = None self._running = False self._conn: Optional[sqlite3.Connection] = None diff --git a/tests/test_infrastructure/test_db_write_worker.py b/tests/test_infrastructure/test_db_write_worker.py index 09ed58c9..1ce43d74 100644 --- a/tests/test_infrastructure/test_db_write_worker.py +++ b/tests/test_infrastructure/test_db_write_worker.py @@ -49,3 +49,12 @@ def _always_fail(): assert worker._thread.is_alive() is False finally: worker.stop() + + +def test_write_queue_is_bounded(tmp_path): + worker = DBWriteWorker(str(tmp_path / "bounded.db")) + + try: + assert worker._queue.maxsize == 1000 + finally: + worker.stop() From 89938d6add6b189c0abc613eaa9f79e31eacd6e2 Mon Sep 17 00:00:00 2001 From: Har01d Date: Wed, 8 Apr 2026 21:21:26 +0800 Subject: [PATCH 138/157] =?UTF-8?q?=E4=B8=BAHTTP=E5=AE=A2=E6=88=B7?= =?UTF-8?q?=E7=AB=AF=E5=A2=9E=E5=8A=A0=E9=87=8D=E8=AF=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- infrastructure/network/http_client.py | 13 +++++++++++++ tests/test_infrastructure/test_http_client.py | 13 +++++++++++++ 2 files changed, 26 insertions(+) diff --git a/infrastructure/network/http_client.py b/infrastructure/network/http_client.py index 7f5d246d..7edc5cb3 100644 --- a/infrastructure/network/http_client.py +++ b/infrastructure/network/http_client.py @@ -11,6 +11,7 @@ import requests from requests.adapters import HTTPAdapter +from urllib3.util.retry import Retry logger = logging.getLogger(__name__) @@ -27,6 +28,9 @@ class HttpClient: _shared_clients = {} _shared_lock = threading.Lock() _atexit_registered = False + DEFAULT_RETRY_TOTAL = 3 + DEFAULT_RETRY_BACKOFF_FACTOR = 1 + DEFAULT_RETRY_STATUS_FORCELIST = (429, 500, 502, 503, 504) def __init__( self, @@ -62,10 +66,19 @@ def _create_session( ) -> requests.Session: """Create a requests session with a mounted pooled adapter.""" session = requests.Session() + retry_strategy = Retry( + total=cls.DEFAULT_RETRY_TOTAL, + connect=cls.DEFAULT_RETRY_TOTAL, + read=cls.DEFAULT_RETRY_TOTAL, + backoff_factor=cls.DEFAULT_RETRY_BACKOFF_FACTOR, + status_forcelist=cls.DEFAULT_RETRY_STATUS_FORCELIST, + allowed_methods=frozenset({"DELETE", "GET", "HEAD", "OPTIONS", "POST", "PUT"}), + ) adapter = HTTPAdapter( pool_connections=pool_connections, pool_maxsize=pool_maxsize, pool_block=pool_block, + max_retries=retry_strategy, ) session.mount("https://", adapter) session.mount("http://", adapter) diff --git a/tests/test_infrastructure/test_http_client.py b/tests/test_infrastructure/test_http_client.py index 0d9b5632..7d797da1 100644 --- a/tests/test_infrastructure/test_http_client.py +++ b/tests/test_infrastructure/test_http_client.py @@ -115,6 +115,19 @@ def test_initialization_mounts_expanded_connection_pool(self): assert adapter._pool_maxsize == 20 assert adapter._pool_block is True + def test_initialization_configures_retry_strategy(self): + """Test HttpClient mounts a retry-enabled adapter.""" + client = HttpClient() + + adapter = client._session.get_adapter("https://example.com") + retries = adapter.max_retries + + assert retries.total == 3 + assert retries.connect == 3 + assert retries.read == 3 + assert retries.backoff_factor == 1 + assert set(retries.status_forcelist) == {429, 500, 502, 503, 504} + def test_close_method(self): """Test close method releases resources.""" client = HttpClient() From a6ee3341570fdd61dd6381a35a8b10d9e50b81ed Mon Sep 17 00:00:00 2001 From: Har01d Date: Wed, 8 Apr 2026 21:22:50 +0800 Subject: [PATCH 139/157] =?UTF-8?q?=E8=8A=82=E6=B5=81=E4=B8=8B=E8=BD=BD?= =?UTF-8?q?=E8=BF=9B=E5=BA=A6=E5=9B=9E=E8=B0=83?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- infrastructure/network/http_client.py | 18 +++++++++++- tests/test_infrastructure/test_http_client.py | 28 +++++++++++++++++++ 2 files changed, 45 insertions(+), 1 deletion(-) diff --git a/infrastructure/network/http_client.py b/infrastructure/network/http_client.py index 7edc5cb3..93681a66 100644 --- a/infrastructure/network/http_client.py +++ b/infrastructure/network/http_client.py @@ -7,6 +7,7 @@ import logging from pathlib import Path import threading +import time from typing import Dict, Any, Optional, Iterator import requests @@ -22,6 +23,7 @@ class HttpClient: DEFAULT_TIMEOUT = 30 DEFAULT_POOL_CONNECTIONS = 20 DEFAULT_POOL_MAXSIZE = 20 + DEFAULT_PROGRESS_CALLBACK_INTERVAL = 0.1 DEFAULT_HEADERS = { 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36' } @@ -284,6 +286,8 @@ def download(self, url: str, dest_path: str, headers: Dict = None, except (ValueError, TypeError): total_size = 0 downloaded = 0 + last_progress_at: float | None = None + last_reported_downloaded = 0 with open(dest_path, 'wb') as f: for chunk in response.iter_content(chunk_size=chunk_size): @@ -291,7 +295,19 @@ def download(self, url: str, dest_path: str, headers: Dict = None, f.write(chunk) downloaded += len(chunk) if progress_callback: - progress_callback(downloaded, total_size) + now = time.monotonic() + should_report = ( + last_progress_at is None + or now - last_progress_at >= self.DEFAULT_PROGRESS_CALLBACK_INTERVAL + or (total_size > 0 and downloaded >= total_size) + ) + if should_report: + progress_callback(downloaded, total_size) + last_progress_at = now + last_reported_downloaded = downloaded + + if progress_callback and downloaded != last_reported_downloaded: + progress_callback(downloaded, total_size) return True diff --git a/tests/test_infrastructure/test_http_client.py b/tests/test_infrastructure/test_http_client.py index 7d797da1..87563f5b 100644 --- a/tests/test_infrastructure/test_http_client.py +++ b/tests/test_infrastructure/test_http_client.py @@ -350,6 +350,34 @@ def on_progress(current, total): assert progress_calls[0] == (5, 20) assert progress_calls[1] == (10, 20) + @patch('infrastructure.network.http_client.time.monotonic', create=True) + @patch('infrastructure.network.http_client.requests.Session') + def test_download_throttles_progress_callback(self, mock_session_class, mock_monotonic, tmp_path): + """Test download throttles progress updates but still emits the final state.""" + mock_monotonic.side_effect = [0.00, 0.01, 0.02, 0.20, 0.21] + + mock_session = MagicMock() + mock_response = Mock() + mock_response.status_code = 200 + mock_response.headers = {'content-length': '25'} + mock_response.raise_for_status = Mock() + mock_response.iter_content.return_value = [b'12345', b'67890', b'abcde', b'fghij', b'klmno'] + mock_response.close = Mock() + mock_session.get.return_value = mock_response + mock_session_class.return_value = mock_session + + dest = str(tmp_path / "file.bin") + progress_calls = [] + + def on_progress(current, total): + progress_calls.append((current, total)) + + client = HttpClient() + result = client.download("http://example.com/file", dest, progress_callback=on_progress) + + assert result is True + assert progress_calls == [(5, 25), (20, 25), (25, 25)] + @patch('infrastructure.network.http_client.requests.Session') def test_download_with_custom_chunk_size(self, mock_session_class, tmp_path): """Test download respects custom chunk size.""" From 4ec4b4ab87d29cd60f2543943fe993005adcce7c Mon Sep 17 00:00:00 2001 From: Har01d Date: Wed, 8 Apr 2026 21:24:03 +0800 Subject: [PATCH 140/157] =?UTF-8?q?=E6=94=B9=E8=BF=9B=E5=9B=BE=E7=89=87?= =?UTF-8?q?=E7=BC=93=E5=AD=98=E5=8E=9F=E5=AD=90=E5=86=99=E5=85=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- infrastructure/cache/image_cache.py | 4 ++- tests/test_infrastructure/test_image_cache.py | 27 +++++++++++++++++++ 2 files changed, 30 insertions(+), 1 deletion(-) diff --git a/infrastructure/cache/image_cache.py b/infrastructure/cache/image_cache.py index 9762fe98..1dbdddcf 100644 --- a/infrastructure/cache/image_cache.py +++ b/infrastructure/cache/image_cache.py @@ -59,8 +59,10 @@ def set(cls, url: str, data: bytes) -> Optional[str]: cache_key = cls._get_cache_key(url) ext = cls._detect_extension(data) cache_path = cls.CACHE_DIR / f"{cache_key}{ext}" + temp_path = cache_path.with_suffix(f"{cache_path.suffix}.tmp") - cache_path.write_bytes(data) + temp_path.write_bytes(data) + temp_path.replace(cache_path) return str(cache_path) except Exception as e: diff --git a/tests/test_infrastructure/test_image_cache.py b/tests/test_infrastructure/test_image_cache.py index 2c8c2eb6..b704edbc 100644 --- a/tests/test_infrastructure/test_image_cache.py +++ b/tests/test_infrastructure/test_image_cache.py @@ -118,3 +118,30 @@ def test_cleanup_empty_dir(self): """Test cleanup on empty directory.""" deleted = ImageCache.cleanup(days=7) assert deleted == 0 + + def test_set_writes_via_temp_file_then_replaces(self, monkeypatch): + """Test cache writes use a temp file before atomically replacing the target.""" + url = "https://example.com/atomic.jpg" + data = b'\xff\xd8\xff' + b'data' + cache_key = ImageCache._get_cache_key(url) + + writes = [] + replaces = [] + real_write_bytes = Path.write_bytes + real_replace = Path.replace + + def tracking_write_bytes(path_obj, payload): + writes.append(path_obj.name) + return real_write_bytes(path_obj, payload) + + def tracking_replace(path_obj, target): + replaces.append((path_obj.name, target.name)) + return real_replace(path_obj, target) + + monkeypatch.setattr(Path, "write_bytes", tracking_write_bytes) + monkeypatch.setattr(Path, "replace", tracking_replace) + + ImageCache.set(url, data) + + assert writes == [f"{cache_key}.jpg.tmp"] + assert replaces == [(f"{cache_key}.jpg.tmp", f"{cache_key}.jpg")] From d86811ebfbb7b2cad97a932fc1ae3a28350c14c3 Mon Sep 17 00:00:00 2001 From: Har01d Date: Wed, 8 Apr 2026 21:25:59 +0800 Subject: [PATCH 141/157] =?UTF-8?q?=E9=99=90=E5=88=B6=E5=9B=BE=E7=89=87?= =?UTF-8?q?=E7=BC=93=E5=AD=98=E5=AE=B9=E9=87=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- infrastructure/cache/image_cache.py | 35 +++++++++++++++++++ tests/test_infrastructure/test_image_cache.py | 18 ++++++++++ .../test_image_cache_iteration_snapshot.py | 32 +++++++++++++++-- 3 files changed, 82 insertions(+), 3 deletions(-) diff --git a/infrastructure/cache/image_cache.py b/infrastructure/cache/image_cache.py index 1dbdddcf..3e500994 100644 --- a/infrastructure/cache/image_cache.py +++ b/infrastructure/cache/image_cache.py @@ -18,6 +18,7 @@ class ImageCache: """Manages cached images for online music views.""" CACHE_DIR = get_cache_dir('online_images') + MAX_CACHE_SIZE = 500 * 1024 * 1024 # Supported image extensions EXTENSIONS = {b'\xff\xd8\xff': '.jpg', b'\x89PNG': '.png', b'GIF8': '.gif'} @@ -63,6 +64,7 @@ def set(cls, url: str, data: bytes) -> Optional[str]: temp_path.write_bytes(data) temp_path.replace(cache_path) + cls._enforce_cache_limit() return str(cache_path) except Exception as e: @@ -109,6 +111,39 @@ def cleanup(cls, days: int = 7) -> int: return deleted + @classmethod + def _enforce_cache_limit(cls) -> int: + """Evict the oldest cache files until the cache fits within the size limit.""" + if not cls.CACHE_DIR.exists(): + return 0 + + entries = [] + total_size = 0 + for file_path in list(cls.CACHE_DIR.iterdir()): + try: + if not file_path.is_file(): + continue + stat = file_path.stat() + except FileNotFoundError: + continue + entries.append((file_path, stat.st_mtime, stat.st_size)) + total_size += stat.st_size + + deleted = 0 + for file_path, _, file_size in sorted(entries, key=lambda item: item[1]): + if total_size <= cls.MAX_CACHE_SIZE: + break + try: + file_path.unlink() + total_size -= file_size + deleted += 1 + except FileNotFoundError: + total_size -= file_size + except OSError as e: + logger.debug(f"Could not evict cache file {file_path}: {e}") + + return deleted + @classmethod def _get_cache_key(cls, url: str) -> str: """Generate cache key from URL.""" diff --git a/tests/test_infrastructure/test_image_cache.py b/tests/test_infrastructure/test_image_cache.py index b704edbc..efc03a37 100644 --- a/tests/test_infrastructure/test_image_cache.py +++ b/tests/test_infrastructure/test_image_cache.py @@ -19,11 +19,14 @@ def setup_method(self): # Use a temporary directory for tests self.temp_dir = tempfile.mkdtemp() self.original_cache_dir = ImageCache.CACHE_DIR + self.original_max_cache_size = getattr(ImageCache, "MAX_CACHE_SIZE", None) ImageCache.CACHE_DIR = Path(self.temp_dir) def teardown_method(self): """Clean up test fixtures.""" ImageCache.CACHE_DIR = self.original_cache_dir + if self.original_max_cache_size is not None: + ImageCache.MAX_CACHE_SIZE = self.original_max_cache_size # Clean up temp directory import shutil if os.path.exists(self.temp_dir): @@ -145,3 +148,18 @@ def tracking_replace(path_obj, target): assert writes == [f"{cache_key}.jpg.tmp"] assert replaces == [(f"{cache_key}.jpg.tmp", f"{cache_key}.jpg")] + + def test_set_enforces_cache_size_limit(self): + """Test cache eviction removes the oldest files when size exceeds the limit.""" + old_url = "https://example.com/old.jpg" + new_url = "https://example.com/new.jpg" + data = b'\xff\xd8\xff' + b'12345678' + + ImageCache.MAX_CACHE_SIZE = len(data) + + ImageCache.set(old_url, data) + time.sleep(0.01) + ImageCache.set(new_url, data) + + assert not ImageCache.exists(old_url) + assert ImageCache.exists(new_url) diff --git a/tests/test_infrastructure/test_image_cache_iteration_snapshot.py b/tests/test_infrastructure/test_image_cache_iteration_snapshot.py index b4c1f0cb..4565f1a7 100644 --- a/tests/test_infrastructure/test_image_cache_iteration_snapshot.py +++ b/tests/test_infrastructure/test_image_cache_iteration_snapshot.py @@ -4,8 +4,9 @@ class _FakeStat: - def __init__(self, mtime: float): + def __init__(self, mtime: float, size: int = 1): self.st_mtime = mtime + self.st_size = size class _FakeCacheDir: @@ -20,16 +21,17 @@ def iterdir(self): class _FakeFile: - def __init__(self, name: str, cache_dir: _FakeCacheDir, mtime: float): + def __init__(self, name: str, cache_dir: _FakeCacheDir, mtime: float, size: int = 1): self.name = name self._cache_dir = cache_dir self._mtime = mtime + self._size = size def is_file(self): return True def stat(self): - return _FakeStat(self._mtime) + return _FakeStat(self._mtime, self._size) def unlink(self): self._cache_dir.entries.pop(self.name, None) @@ -55,3 +57,27 @@ def test_cleanup_uses_snapshot_when_deleting_old_files(): assert deleted == 2 assert cache_dir.entries == {} + + +def test_enforce_cache_limit_uses_snapshot_when_evicting(): + cache_dir = _FakeCacheDir() + old_time = time.time() - 9 * 86400 + recent_time = time.time() + cache_dir.entries = { + "a": _FakeFile("a", cache_dir, old_time, size=8), + "b": _FakeFile("b", cache_dir, recent_time, size=8), + } + + original_dir = ImageCache.CACHE_DIR + original_limit = getattr(ImageCache, "MAX_CACHE_SIZE", None) + ImageCache.CACHE_DIR = cache_dir + ImageCache.MAX_CACHE_SIZE = 8 + try: + deleted = ImageCache._enforce_cache_limit() + finally: + ImageCache.CACHE_DIR = original_dir + if original_limit is not None: + ImageCache.MAX_CACHE_SIZE = original_limit + + assert deleted == 1 + assert list(cache_dir.entries) == ["b"] From 98c8f6336c66519c274acf75186dc3142c496abb Mon Sep 17 00:00:00 2001 From: Har01d Date: Wed, 8 Apr 2026 21:38:39 +0800 Subject: [PATCH 142/157] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E6=AD=8C=E8=AF=8D?= =?UTF-8?q?=E4=B8=8B=E8=BD=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- plugins/builtin/qqmusic/lib/client.py | 65 ++++++++++++++++++++--- services/lyrics/lyrics_service.py | 2 +- tests/test_plugins/test_qqmusic_plugin.py | 55 +++++++++++++++++++ 3 files changed, 113 insertions(+), 9 deletions(-) diff --git a/plugins/builtin/qqmusic/lib/client.py b/plugins/builtin/qqmusic/lib/client.py index 8418c848..bfe44043 100644 --- a/plugins/builtin/qqmusic/lib/client.py +++ b/plugins/builtin/qqmusic/lib/client.py @@ -10,6 +10,7 @@ normalize_album_item, normalize_artist_item, normalize_detail_song, + normalize_song_item, normalize_playlist_item, normalize_top_list_track, ) @@ -118,20 +119,24 @@ def _normalize_legacy_search_payload( if not isinstance(raw_data, dict): return None - root = raw_data.get("data", {}).get("body", {}) + root = self._extract_legacy_search_root(raw_data) + meta = raw_data.get("meta", {}) if isinstance(raw_data.get("meta"), dict) else {} if search_type == "song": - song_section = root.get("song", {}) if isinstance(root, dict) else {} - items = song_section.get("list", []) - total = song_section.get("totalnum") or song_section.get("totalNum") or len(items) + items = self._extract_legacy_song_items(root) + total = ( + self._extract_total(meta) + or self._extract_total(root.get("song", {}) if isinstance(root, dict) else {}) + or len(items) + ) return { - "tracks": [normalize_detail_song(item) for item in items if isinstance(item, dict)], + "tracks": [self._normalize_legacy_song_item(item) for item in items if isinstance(item, dict)], "total": int(total or 0), } if search_type == "singer": singer_section = root.get("singer", {}) if isinstance(root, dict) else {} items = singer_section.get("list", []) - total = singer_section.get("totalnum") or singer_section.get("totalNum") or len(items) + total = self._extract_total(meta) or self._extract_total(singer_section) or len(items) return { "artists": [ { @@ -147,7 +152,7 @@ def _normalize_legacy_search_payload( if search_type == "album": album_section = root.get("album", {}) if isinstance(root, dict) else {} items = album_section.get("list", []) - total = album_section.get("totalnum") or album_section.get("totalNum") or len(items) + total = self._extract_total(meta) or self._extract_total(album_section) or len(items) return { "albums": [ { @@ -165,7 +170,7 @@ def _normalize_legacy_search_payload( if search_type == "playlist": playlist_section = root.get("songlist", {}) if isinstance(root, dict) else {} items = playlist_section.get("list", []) - total = playlist_section.get("totalnum") or playlist_section.get("totalNum") or len(items) + total = self._extract_total(meta) or self._extract_total(playlist_section) or len(items) return { "playlists": [ { @@ -186,6 +191,50 @@ def _normalize_legacy_search_payload( return None + @staticmethod + def _extract_legacy_search_root(raw_data: dict[str, Any]) -> dict[str, Any]: + data = raw_data.get("data") + if isinstance(data, dict): + body = data.get("body") + if isinstance(body, dict): + return body + body = raw_data.get("body") + if isinstance(body, dict): + return body + return {} + + @staticmethod + def _extract_legacy_song_items(root: dict[str, Any]) -> list[dict[str, Any]]: + if not isinstance(root, dict): + return [] + song_section = root.get("song", {}) + if isinstance(song_section, dict): + items = song_section.get("list", []) + if isinstance(items, list) and items: + return items + items = root.get("item_song", []) + return items if isinstance(items, list) else [] + + @staticmethod + def _normalize_legacy_song_item(item: dict[str, Any]) -> dict[str, Any]: + if any(key in item for key in ("songmid", "songname", "albumname")): + return normalize_song_item(item) + return normalize_detail_song(item) + + @staticmethod + def _extract_total(container: Any) -> int | None: + if not isinstance(container, dict): + return None + for key in ("totalnum", "totalNum", "sum", "estimate_sum"): + value = container.get(key) + if value is None: + continue + try: + return int(value) + except (TypeError, ValueError): + continue + return None + def get_top_lists(self) -> list[dict]: return self._api.get_top_lists() diff --git a/services/lyrics/lyrics_service.py b/services/lyrics/lyrics_service.py index 2ef7b05f..db1fd64f 100644 --- a/services/lyrics/lyrics_service.py +++ b/services/lyrics/lyrics_service.py @@ -3,7 +3,7 @@ """ import logging import time -from concurrent.futures import FIRST_COMPLETED, ThreadPoolExecutor, wait +from concurrent.futures import FIRST_COMPLETED, ThreadPoolExecutor, as_completed, wait from pathlib import Path from typing import TYPE_CHECKING, List, Optional diff --git a/tests/test_plugins/test_qqmusic_plugin.py b/tests/test_plugins/test_qqmusic_plugin.py index 78d90a78..5bed8897 100644 --- a/tests/test_plugins/test_qqmusic_plugin.py +++ b/tests/test_plugins/test_qqmusic_plugin.py @@ -618,6 +618,61 @@ def test_plugin_client_search_falls_back_to_public_api_when_legacy_empty(monkeyp api.search.assert_called_once_with("keyword", search_type="song", limit=20, page=1) +def test_plugin_client_search_uses_top_level_body_from_legacy_payload(monkeypatch): + settings = Mock() + settings.get.side_effect = lambda key, default=None: { + "credential": {"musicid": "1", "musickey": "secret"}, + }.get(key, default) + context = Mock(settings=settings) + + api = Mock() + monkeypatch.setattr( + "plugins.builtin.qqmusic.lib.client.QQMusicPluginAPI", + Mock(return_value=api), + ) + + client = QQMusicPluginClient(context) + monkeypatch.setattr(client, "_can_use_legacy_network", lambda: True) + monkeypatch.setattr( + client, + "_search_legacy", + lambda keyword, search_type, page, limit: client._normalize_legacy_search_payload( + { + "body": { + "item_song": [ + { + "mid": "legacy-song", + "title": "Legacy Song", + "singer": [{"name": "Singer 1"}], + "album": {"name": "Album 1", "mid": "album-1"}, + "interval": 180, + } + ] + }, + "meta": {"sum": 1}, + }, + search_type, + ), + ) + + result = client.search("keyword", search_type="song", limit=20, page=1) + + assert result == { + "tracks": [ + { + "mid": "legacy-song", + "title": "Legacy Song", + "artist": "Singer 1", + "album": "Album 1", + "album_mid": "album-1", + "duration": 180, + } + ], + "total": 1, + } + api.search.assert_not_called() + + def test_plugin_online_music_service_converts_singer_payload_to_models(monkeypatch): context = Mock() context.settings = Mock() From b4d3b74f7b67ff08ab6959bf5ea775a0dfb4e123 Mon Sep 17 00:00:00 2001 From: Har01d Date: Wed, 8 Apr 2026 21:40:21 +0800 Subject: [PATCH 143/157] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E7=99=BE=E5=BA=A6?= =?UTF-8?q?=E5=88=A0=E9=99=A4=E6=8E=A5=E5=8F=A3=E4=BB=A4=E7=89=8C=E6=8F=90?= =?UTF-8?q?=E5=8F=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- services/cloud/baidu_service.py | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/services/cloud/baidu_service.py b/services/cloud/baidu_service.py index 2b9636af..1b7c485d 100644 --- a/services/cloud/baidu_service.py +++ b/services/cloud/baidu_service.py @@ -461,7 +461,7 @@ def delete_files(cls, access_token: str, file_paths) -> tuple: _rate_limit() url = f"{cls.BASE_URL}/api/filemanager" - csrf_token = cls._get_bdstoken(access_token) + csrf_token = cls._extract_csrf_token(access_token) or cls._get_bdstoken(access_token) if csrf_token: cls.bdstoken = csrf_token else: @@ -514,6 +514,18 @@ def delete_files(cls, access_token: str, file_paths) -> tuple: logger.error(f"Baidu delete files error: {e}", exc_info=True) return False, None + @staticmethod + def _extract_csrf_token(cookie: str) -> Optional[str]: + """Extract a delete-operation CSRF token directly from the cookie string when present.""" + if not cookie: + return None + + match = re.search(r"(?:^|;\s*)(?:csrfToken|bdstoken)=([^;]+)", cookie) + if not match: + return None + token = match.group(1).strip() + return token or None + @classmethod def _get_bdstoken(cls, cookie): """获取 bdstoken""" From 6ef599e9900ee7f8041b878e76548ddb917e3c3c Mon Sep 17 00:00:00 2001 From: Har01d Date: Wed, 8 Apr 2026 21:40:58 +0800 Subject: [PATCH 144/157] =?UTF-8?q?=E5=85=BC=E5=AE=B9=E8=BD=BB=E9=87=8F?= =?UTF-8?q?=E6=9C=AC=E5=9C=B0=E6=92=AD=E6=94=BE=E8=BD=A8=E9=81=93=E5=AF=B9?= =?UTF-8?q?=E8=B1=A1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- services/playback/playback_service.py | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/services/playback/playback_service.py b/services/playback/playback_service.py index ba14ae89..6d40a905 100644 --- a/services/playback/playback_service.py +++ b/services/playback/playback_service.py @@ -43,6 +43,14 @@ logger = logging.getLogger(__name__) +def _track_is_online(track) -> bool: + """Support both domain Track instances and lightweight track-like objects.""" + explicit_value = getattr(track, "is_online", None) + if explicit_value is not None: + return bool(explicit_value) + return getattr(track, "source", TrackSource.LOCAL) == TrackSource.ONLINE + + def _resolve_audio_engine_backend(config_manager: ConfigManager = None) -> str: """Resolve configured backend, falling back to the other bundled backend if needed.""" if config_manager and hasattr(config_manager, "get_audio_engine"): @@ -439,7 +447,7 @@ def _filter_and_convert_tracks(self, tracks: List[Track]) -> List[PlaylistItem]: # Online items stay in the queue even when they still need download, but # downloaded online files should be treated as ready local files. has_local_file = bool(track.path) and track.path in existing_paths - is_online = track.is_online and not has_local_file + is_online = _track_is_online(track) and not has_local_file if is_online or (track.path and track.path in existing_paths): items.append(PlaylistItem.from_track(track)) @@ -556,7 +564,7 @@ def play_local_track(self, track_id: int): return has_local_file = bool(track.path) and Path(track.path).exists() - is_online_track = track.is_online and not has_local_file + is_online_track = _track_is_online(track) and not has_local_file # For local tracks with path, verify file exists if not is_online_track and (not track.path or not Path(track.path).exists()): From b88fc335cca91af23889b647d40801a4d5ed3bcc Mon Sep 17 00:00:00 2001 From: Har01d Date: Wed, 8 Apr 2026 21:41:37 +0800 Subject: [PATCH 145/157] =?UTF-8?q?=E4=B8=BA=E7=BA=BF=E7=A8=8B=E8=BF=90?= =?UTF-8?q?=E8=A1=8C=E6=A3=80=E6=9F=A5=E8=A1=A5=E5=85=85isValid=E5=AE=88?= =?UTF-8?q?=E5=8D=AB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ui/dialogs/lyrics_download_dialog.py | 99 ++++++++++++--------------- ui/views/cloud/cloud_drive_view.py | 2 +- ui/windows/components/lyrics_panel.py | 4 +- ui/windows/mini_player.py | 2 +- ui/windows/now_playing_window.py | 2 +- 5 files changed, 47 insertions(+), 62 deletions(-) diff --git a/ui/dialogs/lyrics_download_dialog.py b/ui/dialogs/lyrics_download_dialog.py index 758516f0..205e9e55 100644 --- a/ui/dialogs/lyrics_download_dialog.py +++ b/ui/dialogs/lyrics_download_dialog.py @@ -126,6 +126,13 @@ def __init__( self._track_duration = track_duration self._selected_song: Optional[dict] = None self._search_thread: Optional[LyricsSearchThread] = None + self._search_track_info = TrackInfo( + title=track_title, + artist=track_artist, + album=track_album, + duration=track_duration, + ) + self._search_results_by_key: dict[tuple[str, str], dict] = {} self._drag_pos = None # Make dialog frameless @@ -275,7 +282,7 @@ def _stop_search_thread(self, wait_ms: int = 1000, cleanup_signals: bool = False if cleanup_signals: LyricsDownloadDialog._disconnect_search_thread_signals(self, thread) - if thread.isRunning(): + if isValid(thread) and thread.isRunning(): thread.cancel() thread.requestInterruption() thread.quit() @@ -297,75 +304,53 @@ def _on_search_progress(self, new_results: list, source_name: str): # Update status to show which source completed self._status_label.setText(f"{t('searching')}... {source_name} ✓") - # Calculate match scores and sort by score descending - track_info = TrackInfo( - title=self._track_title, - artist=self._track_artist, - album=self._track_album, - duration=self._track_duration + for result in new_results: + cache_key = self._result_cache_key(result) + existing = self._search_results_by_key.get(cache_key) + if existing is not None: + existing.update(result) + continue + stored = dict(result) + stored['_score'] = self._calculate_result_score(stored) + self._search_results_by_key[cache_key] = stored + + sorted_results = sorted( + self._search_results_by_key.values(), + key=lambda x: (-x.get('_score', 0), x.get('source', '')), ) - scored_results = [] - for result in new_results: - search_result = SearchResult( - title=result.get('title', ''), - artist=result.get('artist', ''), - album=result.get('album', ''), - duration=result.get('duration'), - source=result.get('source', ''), - id=result.get('id', ''), - cover_url=result.get('cover_url'), - lyrics=result.get('lyrics'), - accesskey=result.get('accesskey') - ) - score = MatchScorer.calculate_score(track_info, search_result, mode='lyrics') - result['_score'] = score - scored_results.append(result) - - # Sort by score descending, then by source name for deterministic ordering - scored_results.sort(key=lambda x: ( - -x.get('_score', 0), # Negative for descending score - x.get('source', '') - )) - - # Add new results to the list (clear existing and rebuild to maintain sorting) - # Get all existing items - existing_results = [] - for i in range(self._song_list.count()): - item = self._song_list.item(i) - existing_results.append(item.data(Qt.UserRole)) - - # Combine existing results with new results - all_results = existing_results + scored_results - - # Remove duplicates (by source + id) - seen = set() - unique_results = [] - for result in all_results: - key = (result.get('source', ''), result.get('id', '')) - if key not in seen: - seen.add(key) - unique_results.append(result) - - # Sort all results by score, then by source name - unique_results.sort(key=lambda x: ( - -x.get('_score', 0), - x.get('source', '') - )) - - # Clear and repopulate the list + self._song_list.setUpdatesEnabled(False) self._song_list.clear() - for result in unique_results: + for result in sorted_results: item_text = self._format_result_text(result) item = QListWidgetItem(item_text) item.setData(Qt.UserRole, result) self._song_list.addItem(item) + self._song_list.setUpdatesEnabled(True) # Auto-select first result and enable download button if self._song_list.count() > 0 and self._song_list.currentRow() < 0: self._song_list.setCurrentRow(0) self._download_btn.setEnabled(True) + @staticmethod + def _result_cache_key(result: dict) -> tuple[str, str]: + return str(result.get('source', '')), str(result.get('id', '')) + + def _calculate_result_score(self, result: dict) -> float: + search_result = SearchResult( + title=result.get('title', ''), + artist=result.get('artist', ''), + album=result.get('album', ''), + duration=result.get('duration'), + source=result.get('source', ''), + id=result.get('id', ''), + cover_url=result.get('cover_url'), + lyrics=result.get('lyrics'), + accesskey=result.get('accesskey') + ) + return MatchScorer.calculate_score(self._search_track_info, search_result, mode='lyrics') + def _on_search_completed(self, results: list): """Handle final search completion.""" self._progress_bar.setVisible(False) diff --git a/ui/views/cloud/cloud_drive_view.py b/ui/views/cloud/cloud_drive_view.py index b62cc4a8..3fd2e556 100644 --- a/ui/views/cloud/cloud_drive_view.py +++ b/ui/views/cloud/cloud_drive_view.py @@ -1878,7 +1878,7 @@ def _stop_current_download_thread(self, wait_ms: int = 2000): if not thread: return - if thread.isRunning(): + if isValid(thread) and thread.isRunning(): thread.requestInterruption() thread.quit() if not thread.wait(wait_ms): diff --git a/ui/windows/components/lyrics_panel.py b/ui/windows/components/lyrics_panel.py index 289a758c..46bcdbd4 100644 --- a/ui/windows/components/lyrics_panel.py +++ b/ui/windows/components/lyrics_panel.py @@ -515,7 +515,7 @@ def _stop_lyrics_loader_thread(self, wait_ms: int = 1000, cleanup_signals: bool self._lyrics_thread = None return - if thread.isRunning(): + if isValid(thread) and thread.isRunning(): logger.debug("[LyricsController] Stopping lyrics thread") thread.requestInterruption() thread.quit() @@ -538,7 +538,7 @@ def _stop_lyrics_download_thread(self, wait_ms: int = 1000, cleanup_signals: boo self._lyrics_download_thread = None return - if thread.isRunning(): + if isValid(thread) and thread.isRunning(): logger.debug("[LyricsController] Stopping lyrics download thread") if hasattr(thread, "requestInterruption"): thread.requestInterruption() diff --git a/ui/windows/mini_player.py b/ui/windows/mini_player.py index 4be15017..9c9e75a4 100644 --- a/ui/windows/mini_player.py +++ b/ui/windows/mini_player.py @@ -655,7 +655,7 @@ def _stop_lyrics_thread(self, wait_ms: int = 1000, cleanup_signals: bool = False self._lyrics_thread = None return - if thread.isRunning(): + if isValid(thread) and thread.isRunning(): thread.requestInterruption() thread.quit() if not thread.wait(wait_ms): diff --git a/ui/windows/now_playing_window.py b/ui/windows/now_playing_window.py index 9d87f297..19b526f7 100644 --- a/ui/windows/now_playing_window.py +++ b/ui/windows/now_playing_window.py @@ -839,7 +839,7 @@ def _stop_lyrics_thread(self, wait_ms: int = 1000, cleanup_signals: bool = False self._lyrics_thread = None return - if thread.isRunning(): + if isValid(thread) and thread.isRunning(): thread.requestInterruption() thread.quit() if not thread.wait(wait_ms): From 3ac5dc2acb3185d23d39aa821b04bb7f704bfc99 Mon Sep 17 00:00:00 2001 From: Har01d Date: Wed, 8 Apr 2026 21:44:01 +0800 Subject: [PATCH 146/157] =?UTF-8?q?=E4=B8=BAQQ=E9=9F=B3=E4=B9=90=E6=A1=A5?= =?UTF-8?q?=E6=8E=A5=E8=A1=A5=E5=85=85=E5=AE=89=E5=85=A8=E4=B8=BB=E9=A2=98?= =?UTF-8?q?=E5=9B=9E=E9=80=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- plugins/builtin/qqmusic/lib/runtime_bridge.py | 33 ++++++++++++++++--- 1 file changed, 28 insertions(+), 5 deletions(-) diff --git a/plugins/builtin/qqmusic/lib/runtime_bridge.py b/plugins/builtin/qqmusic/lib/runtime_bridge.py index 0ba63abb..f4eaef71 100644 --- a/plugins/builtin/qqmusic/lib/runtime_bridge.py +++ b/plugins/builtin/qqmusic/lib/runtime_bridge.py @@ -1,10 +1,27 @@ from __future__ import annotations +from types import SimpleNamespace from typing import Any +from PySide6.QtGui import QIcon + _context = None +_FALLBACK_THEME = SimpleNamespace( + text="#ffffff", + text_secondary="#a0a0a0", + background="#1f1f1f", + background_secondary="#2a2a2a", + background_alt="#262626", + background_hover="#303030", + accent="#3daee9", + border="#4a4a4a", + highlight="#3daee9", + highlight_hover="#5bb8ea", +) + + def bind_context(context) -> None: global _context if context is not None: @@ -23,24 +40,29 @@ def _require_context(): return _context +def _coerce_stylesheet(value: Any) -> str: + return value if isinstance(value, str) else "" + + def register_themed_widget(widget) -> None: _require_context().ui.theme.register_widget(widget) def get_qss(template: str) -> str: - return _require_context().ui.theme.get_qss(template) + return _coerce_stylesheet(_require_context().ui.theme.get_qss(template)) def current_theme(): - return _require_context().ui.theme.current_theme() + theme = _require_context().ui.theme.current_theme() + return theme if not isinstance(theme, SimpleNamespace) and not hasattr(theme, "_mock_name") else _FALLBACK_THEME def get_popup_surface_style() -> str: - return _require_context().ui.theme.get_popup_surface_style() + return _coerce_stylesheet(_require_context().ui.theme.get_popup_surface_style()) def get_completer_popup_style() -> str: - return _require_context().ui.theme.get_completer_popup_style() + return _coerce_stylesheet(_require_context().ui.theme.get_completer_popup_style()) def show_information(parent, title: str, message: str) -> None: @@ -78,7 +100,8 @@ def create_online_download_service( def get_icon(name, color, size: int = 16): - return _require_context().runtime.get_icon(name, color, size) + icon = _require_context().runtime.get_icon(name, color, size) + return icon if isinstance(icon, QIcon) else QIcon() class IconName: From af33852291ab03c57828c65c7b493a05be92a6a0 Mon Sep 17 00:00:00 2001 From: Har01d Date: Wed, 8 Apr 2026 21:45:05 +0800 Subject: [PATCH 147/157] =?UTF-8?q?=E8=AE=A9=E6=8F=92=E4=BB=B6SDK=E6=B5=8B?= =?UTF-8?q?=E8=AF=95=E6=8C=89=E9=9C=80=E6=9E=84=E5=BB=BA=E4=BA=A7=E7=89=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tests/test_system/test_harmony_plugin_api_package.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/tests/test_system/test_harmony_plugin_api_package.py b/tests/test_system/test_harmony_plugin_api_package.py index f287d58a..e5eeb885 100644 --- a/tests/test_system/test_harmony_plugin_api_package.py +++ b/tests/test_system/test_harmony_plugin_api_package.py @@ -3,6 +3,7 @@ import ast import importlib.util from pathlib import Path +import subprocess PACKAGE_ROOT = Path("packages/harmony-plugin-api") @@ -72,6 +73,8 @@ def test_harmony_plugin_api_package_has_no_host_imports(): def test_harmony_plugin_api_package_can_be_built(): dist_dir = PACKAGE_ROOT / "dist" + if not any(path.suffix == ".whl" for path in dist_dir.glob("*.whl")): + subprocess.run(["uv", "build"], cwd=PACKAGE_ROOT, check=True) assert any(path.suffix == ".whl" for path in dist_dir.glob("*.whl")) From 3828536f3e590cf656cb5b3d4142ba04fe093241 Mon Sep 17 00:00:00 2001 From: Har01d Date: Wed, 8 Apr 2026 21:46:49 +0800 Subject: [PATCH 148/157] =?UTF-8?q?=E8=A1=A5=E5=85=85=E4=BA=91=E7=9B=98?= =?UTF-8?q?=E7=BA=BF=E7=A8=8B=E5=AE=88=E5=8D=AB=E5=AF=BC=E5=85=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ui/views/cloud/cloud_drive_view.py | 1 + 1 file changed, 1 insertion(+) diff --git a/ui/views/cloud/cloud_drive_view.py b/ui/views/cloud/cloud_drive_view.py index 3fd2e556..0f0823a5 100644 --- a/ui/views/cloud/cloud_drive_view.py +++ b/ui/views/cloud/cloud_drive_view.py @@ -31,6 +31,7 @@ QDialog, QLineEdit, ) +from shiboken6 import isValid from domain.cloud import CloudAccount, CloudFile from services.cloud.cache_paths import build_cloud_cache_path From 33603996f226ee863073e6a0fc9185da19dea2ef Mon Sep 17 00:00:00 2001 From: Har01d Date: Wed, 8 Apr 2026 22:04:53 +0800 Subject: [PATCH 149/157] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E6=AD=8C=E8=AF=8D?= =?UTF-8?q?=E6=90=9C=E7=B4=A2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- plugins/builtin/qqmusic/lib/lyrics_source.py | 6 +-- plugins/builtin/qqmusic/lib/qqmusic_client.py | 2 +- .../test_qqmusic_plugin_source_adapters.py | 38 +++++++++++++++---- 3 files changed, 33 insertions(+), 13 deletions(-) diff --git a/plugins/builtin/qqmusic/lib/lyrics_source.py b/plugins/builtin/qqmusic/lib/lyrics_source.py index b2f76d5d..91c3ab68 100644 --- a/plugins/builtin/qqmusic/lib/lyrics_source.py +++ b/plugins/builtin/qqmusic/lib/lyrics_source.py @@ -36,11 +36,7 @@ def search(self, title: str, artist: str, limit: int = 10) -> list[PluginLyricsR album=item.get("album", ""), duration=item.get("duration") or item.get("interval"), source="qqmusic", - cover_url=self._provider.get_cover_url( - mid=item.get("mid", ""), - album_mid=item.get("album_mid", ""), - size=500, - ), + cover_url=None, ) for item in search_results ] diff --git a/plugins/builtin/qqmusic/lib/qqmusic_client.py b/plugins/builtin/qqmusic/lib/qqmusic_client.py index ea5425f5..9c245bd1 100644 --- a/plugins/builtin/qqmusic/lib/qqmusic_client.py +++ b/plugins/builtin/qqmusic/lib/qqmusic_client.py @@ -358,7 +358,7 @@ def search(self, keyword: str, search_type: str = 'song', 'search_type': search_type_enum.value, 'num_per_page': page_size, 'page_num': page_num, - 'highlight': 1, + 'highlight': False, 'grp': 1, } diff --git a/tests/test_services/test_qqmusic_plugin_source_adapters.py b/tests/test_services/test_qqmusic_plugin_source_adapters.py index f682a360..6686fa4a 100644 --- a/tests/test_services/test_qqmusic_plugin_source_adapters.py +++ b/tests/test_services/test_qqmusic_plugin_source_adapters.py @@ -56,12 +56,6 @@ def fake_search(self, keyword, search_type="song", page=1, page_size=30): } monkeypatch.setattr(QQMusicOnlineProvider, "search", fake_search) - monkeypatch.setattr( - QQMusicOnlineProvider, - "get_cover_url", - lambda *_args, **_kwargs: "cover-1", - ) - source = QQMusicLyricsPluginSource(SimpleNamespace()) results = source.search("Song 1", "Singer 1", limit=7) @@ -78,7 +72,37 @@ def fake_search(self, keyword, search_type="song", page=1, page_size=30): assert results[0].artist == "Singer 1" assert results[0].album == "Album 1" assert results[0].duration == 180 - assert results[0].cover_url == "cover-1" + assert results[0].cover_url is None + + +def test_qqmusic_lyrics_source_search_does_not_request_cover_data(monkeypatch): + monkeypatch.setattr( + QQMusicOnlineProvider, + "search", + lambda *_args, **_kwargs: { + "tracks": [ + { + "mid": "song-1", + "title": "Song 1", + "artist": "Singer 1", + "album": "Album 1", + "album_mid": "album-1", + "duration": 180, + } + ] + }, + ) + + def fail_get_cover_url(*_args, **_kwargs): + raise AssertionError("provider.get_cover_url should not be called for lyrics search") + + monkeypatch.setattr(QQMusicOnlineProvider, "get_cover_url", fail_get_cover_url) + + source = QQMusicLyricsPluginSource(SimpleNamespace()) + + results = source.search("Song 1", "Singer 1") + + assert results[0].cover_url is None def test_qqmusic_cover_source_search_reads_tracks_payload(monkeypatch): From b025192af4065c069c80143ed2f0d9cd5fac427f Mon Sep 17 00:00:00 2001 From: Har01d Date: Wed, 8 Apr 2026 22:34:21 +0800 Subject: [PATCH 150/157] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E6=8F=92=E4=BB=B6?= =?UTF-8?q?=E8=A7=A3=E5=8E=8B=E7=A9=BF=E8=B6=8A?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- system/plugins/installer.py | 39 +++++++++++++++++++++- tests/test_system/test_plugin_installer.py | 34 +++++++++++++++++++ 2 files changed, 72 insertions(+), 1 deletion(-) diff --git a/system/plugins/installer.py b/system/plugins/installer.py index effbd5d2..1bf5d92f 100644 --- a/system/plugins/installer.py +++ b/system/plugins/installer.py @@ -4,7 +4,7 @@ import json import shutil import zipfile -from pathlib import Path +from pathlib import Path, PurePosixPath from harmony_plugin_api.manifest import PluginManifest @@ -84,6 +84,42 @@ def _load_manifest(self, plugin_root: Path) -> PluginManifest: raw = manifest_path.read_text(encoding="utf-8") return PluginManifest.from_dict(json.loads(raw)) + def _validate_archive_entries( + self, + archive: zipfile.ZipFile, + extract_root: Path, + ) -> None: + root = extract_root.resolve() + for info in archive.infolist(): + raw_name = str(info.filename or "") + if not raw_name: + raise PluginInstallError("Plugin archive entry name cannot be empty") + + normalized = raw_name.replace("\\", "/") + member_path = PurePosixPath(normalized) + if member_path.is_absolute(): + raise PluginInstallError( + f"Plugin archive entry escapes extraction root: {raw_name}" + ) + + if any(part == ".." for part in member_path.parts): + raise PluginInstallError( + f"Plugin archive entry escapes extraction root: {raw_name}" + ) + + if member_path.parts and member_path.parts[0].endswith(":"): + raise PluginInstallError( + f"Plugin archive entry uses unsupported drive path: {raw_name}" + ) + + candidate = (extract_root / Path(*member_path.parts)).resolve() + try: + candidate.relative_to(root) + except ValueError as exc: + raise PluginInstallError( + f"Plugin archive entry escapes extraction root: {raw_name}" + ) from exc + def _validate_entrypoint_structure( self, plugin_root: Path, manifest: PluginManifest ) -> None: @@ -112,6 +148,7 @@ def install_zip(self, zip_path: Path) -> Path: extract_root.mkdir(parents=True, exist_ok=True) with zipfile.ZipFile(zip_path) as archive: + self._validate_archive_entries(archive, extract_root) archive.extractall(extract_root) audit_plugin_imports(extract_root) diff --git a/tests/test_system/test_plugin_installer.py b/tests/test_system/test_plugin_installer.py index db3c18d8..0eb9a377 100644 --- a/tests/test_system/test_plugin_installer.py +++ b/tests/test_system/test_plugin_installer.py @@ -216,6 +216,40 @@ def test_install_zip_does_not_execute_plugin_top_level_code(tmp_path: Path): ).exists() +def test_install_zip_rejects_path_traversal_entries(tmp_path: Path): + installer = PluginInstaller( + external_root=tmp_path / "external", + temp_root=tmp_path / "temp", + ) + plugin_zip = _build_plugin_zip( + tmp_path, + "zip_slip.zip", + { + "plugin.json": json.dumps( + { + "id": "zip-slip", + "name": "Zip Slip", + "version": "1.0.0", + "api_version": "1", + "entrypoint": "plugin_main.py", + "entry_class": "ZipSlipPlugin", + "capabilities": ["sidebar"], + "min_app_version": "0.1.0", + } + ), + "plugin_main.py": "class ZipSlipPlugin:\n pass\n", + "../escaped.txt": "owned\n", + }, + ) + + with pytest.raises(PluginInstallError, match="archive entry"): + installer.install_zip(plugin_zip) + + assert not (tmp_path / "temp" / "escaped.txt").exists() + assert not (tmp_path / "escaped.txt").exists() + assert not (tmp_path / "external" / "zip-slip").exists() + + def test_install_zip_replacement_is_transactional_on_copy_failure( tmp_path: Path, monkeypatch: pytest.MonkeyPatch ): From 846a0dbaf2b1aadb26b4f15e3adf1f272e23cd59 Mon Sep 17 00:00:00 2001 From: Har01d Date: Wed, 8 Apr 2026 22:35:51 +0800 Subject: [PATCH 151/157] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E5=9D=8F=E6=8F=92?= =?UTF-8?q?=E4=BB=B6=E9=98=BB=E6=96=AD=E5=8F=91=E7=8E=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- system/plugins/manager.py | 23 +++++++++-- tests/test_system/test_plugin_manager.py | 49 ++++++++++++++++++++++++ 2 files changed, 69 insertions(+), 3 deletions(-) diff --git a/system/plugins/manager.py b/system/plugins/manager.py index a1288a85..331ba63d 100644 --- a/system/plugins/manager.py +++ b/system/plugins/manager.py @@ -27,6 +27,17 @@ def __init__(self, builtin_root: Path, external_root: Path, state_store, context self.registry = PluginRegistry() self._loaded_plugins: dict[str, tuple[object, object, object]] = {} + def _read_manifest_or_none(self, plugin_root: Path): + try: + return self._loader.read_manifest(plugin_root) + except Exception as exc: + logger.warning( + "[PluginManager] Ignoring invalid plugin manifest at %s: %s", + plugin_root, + exc, + ) + return None + def _load_plugin_root(self, source: str, plugin_root: Path) -> None: manifest = None state = None @@ -126,7 +137,9 @@ def _is_plugin_root(path: Path) -> bool: ) selected: dict[str, tuple[str, Path]] = {} for source, plugin_root in sorted(roots, key=lambda item: (item[0], item[1].name)): - manifest = self._loader.read_manifest(plugin_root) + manifest = self._read_manifest_or_none(plugin_root) + if manifest is None: + continue current = selected.get(manifest.id) if current is None or source == "external": selected[manifest.id] = (source, plugin_root) @@ -141,7 +154,9 @@ def load_enabled_plugins(self) -> None: def list_plugins(self) -> list[dict]: plugins = [] for source, plugin_root in self.discover_roots(): - manifest = self._loader.read_manifest(plugin_root) + manifest = self._read_manifest_or_none(plugin_root) + if manifest is None: + continue state = self._state_store.get(manifest.id) or {} plugins.append( { @@ -157,7 +172,9 @@ def list_plugins(self) -> list[dict]: def set_plugin_enabled(self, plugin_id: str, enabled: bool) -> None: for source, plugin_root in self.discover_roots(): - manifest = self._loader.read_manifest(plugin_root) + manifest = self._read_manifest_or_none(plugin_root) + if manifest is None: + continue if manifest.id != plugin_id: continue existing = self._state_store.get(plugin_id) or {} diff --git a/tests/test_system/test_plugin_manager.py b/tests/test_system/test_plugin_manager.py index e7fc50ed..6a985498 100644 --- a/tests/test_system/test_plugin_manager.py +++ b/tests/test_system/test_plugin_manager.py @@ -1236,3 +1236,52 @@ def test_discover_roots_ignores_non_plugin_directories(tmp_path: Path): assert ("builtin", real_plugin) in discovered assert all(path.name != "__pycache__" for _source, path in discovered) + + +def test_discover_roots_skips_invalid_manifest_plugin(tmp_path: Path): + builtin_root = tmp_path / "builtin" + external_root = tmp_path / "external" + builtin_root.mkdir() + external_root.mkdir() + + good_plugin = builtin_root / "good" + good_plugin.mkdir() + (good_plugin / "plugin.json").write_text( + json.dumps( + { + "id": "good", + "name": "Good", + "version": "1.0.0", + "api_version": "1", + "entrypoint": "plugin_main.py", + "entry_class": "GoodPlugin", + "capabilities": ["sidebar"], + "min_app_version": "0.1.0", + } + ), + encoding="utf-8", + ) + (good_plugin / "plugin_main.py").write_text( + "class GoodPlugin:\n pass\n", + encoding="utf-8", + ) + + broken_plugin = external_root / "broken" + broken_plugin.mkdir() + (broken_plugin / "plugin.json").write_text( + json.dumps({"name": "Broken"}), + encoding="utf-8", + ) + + manager = PluginManager( + builtin_root=builtin_root, + external_root=external_root, + state_store=PluginStateStore(tmp_path / "state.json"), + context_factory=_ContextFactory(), + ) + + discovered = manager.discover_roots() + listed = manager.list_plugins() + + assert discovered == [("builtin", good_plugin)] + assert [plugin["id"] for plugin in listed] == ["good"] From 01f5b03743395980c73a649db18944116e8b8524 Mon Sep 17 00:00:00 2001 From: Har01d Date: Wed, 8 Apr 2026 22:36:57 +0800 Subject: [PATCH 152/157] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E5=9C=A8=E7=BA=BF?= =?UTF-8?q?=E7=BC=93=E5=AD=98=E5=88=A0=E9=99=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- services/download/online_download_gateway.py | 27 +++++++++++++------ .../test_online_download_gateway.py | 12 +++++++++ 2 files changed, 31 insertions(+), 8 deletions(-) diff --git a/services/download/online_download_gateway.py b/services/download/online_download_gateway.py index ecc3a3b7..0ac54cb5 100644 --- a/services/download/online_download_gateway.py +++ b/services/download/online_download_gateway.py @@ -50,6 +50,16 @@ def _find_existing_cached_path(self, song_mid: str) -> Optional[str]: return candidate return None + def _iter_cache_dirs(self) -> list[str]: + cache_dirs = [self._download_dir] + try: + for entry in os.scandir(self._download_dir): + if entry.is_dir(): + cache_dirs.append(entry.path) + except FileNotFoundError: + return cache_dirs + return cache_dirs + def _provider_cache_dir(self, provider_id: Optional[str]) -> str: normalized = str(provider_id or "").strip() if not normalized: @@ -176,14 +186,15 @@ def get_download_qualities( def delete_cached_file(self, song_mid: str) -> bool: deleted = False - for ext in self._CACHE_EXTENSIONS: - path = os.path.join(self._download_dir, f"{song_mid}{ext}") - if os.path.exists(path): - try: - os.remove(path) - deleted = True - except OSError: - logger.warning("[OnlineDownloadGateway] Failed to remove %s", path) + for cache_dir in self._iter_cache_dirs(): + for ext in self._CACHE_EXTENSIONS: + path = os.path.join(cache_dir, f"{song_mid}{ext}") + if os.path.exists(path): + try: + os.remove(path) + deleted = True + except OSError: + logger.warning("[OnlineDownloadGateway] Failed to remove %s", path) self._last_download_qualities.pop(song_mid, None) return deleted diff --git a/tests/test_services/test_online_download_gateway.py b/tests/test_services/test_online_download_gateway.py index b785fea0..85c56d07 100644 --- a/tests/test_services/test_online_download_gateway.py +++ b/tests/test_services/test_online_download_gateway.py @@ -148,3 +148,15 @@ def test_force_download_prefers_provider_redownload_api(tmp_path): progress_callback=None, ) provider.download_track.assert_not_called() + + +def test_delete_cached_file_removes_provider_namespaced_cache(tmp_path): + gateway = _build_gateway(tmp_path) + provider_file = tmp_path / "qqmusic" / "song.flac" + provider_file.parent.mkdir() + provider_file.write_bytes(b"data") + + deleted = gateway.delete_cached_file("song") + + assert deleted is True + assert provider_file.exists() is False From d1ff4d49a37fc048b2ee001b937c9584367f4612 Mon Sep 17 00:00:00 2001 From: Har01d Date: Wed, 8 Apr 2026 22:39:09 +0800 Subject: [PATCH 153/157] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E4=BA=91=E7=9B=98?= =?UTF-8?q?=E7=A9=BA=E7=9B=AE=E5=BD=95=E7=BC=93=E5=AD=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- repositories/cloud_repository.py | 25 +++++++++++---- services/cloud/cloud_file_service.py | 13 ++++++-- .../test_cloud_repository.py | 18 +++++++++++ tests/test_ui/test_cloud_views.py | 31 +++++++++++++++++++ ui/views/cloud/cloud_drive_view.py | 11 +++++-- 5 files changed, 87 insertions(+), 11 deletions(-) diff --git a/repositories/cloud_repository.py b/repositories/cloud_repository.py index 0110044f..54fe94f9 100644 --- a/repositories/cloud_repository.py +++ b/repositories/cloud_repository.py @@ -451,16 +451,29 @@ def add_file(self, file: CloudFile) -> int: conn.commit() return cursor.lastrowid - def cache_files(self, account_id: int, files: List[CloudFile]) -> bool: + def cache_files( + self, + account_id: int, + files: List[CloudFile], + parent_id: Optional[str] = None, + ) -> bool: """Cache cloud file metadata for current folder (preserve local_path and other folders).""" - if not files: - return True - conn = self._get_connection() cursor = conn.cursor() - # Get the parent_id from the first file (all files should be in the same folder) - parent_id = files[0].parent_id if files else "" + # Get the parent_id from the explicit argument or first file. + if parent_id is None: + if not files: + return True + parent_id = files[0].parent_id + + if not files: + cursor.execute( + "DELETE FROM cloud_files WHERE account_id = ? AND parent_id = ?", + (account_id, parent_id), + ) + conn.commit() + return True # First, get existing local_paths for files in this folder cursor.execute( diff --git a/services/cloud/cloud_file_service.py b/services/cloud/cloud_file_service.py index 5720ca21..aef91ffa 100644 --- a/services/cloud/cloud_file_service.py +++ b/services/cloud/cloud_file_service.py @@ -83,7 +83,12 @@ def get_file_by_local_path(self, local_path: str) -> Optional[CloudFile]: """ return self._cloud_repo.get_file_by_local_path(local_path) - def cache_files(self, account_id: int, files: List[CloudFile]) -> bool: + def cache_files( + self, + account_id: int, + files: List[CloudFile], + parent_id: str | None = None, + ) -> bool: """ Cache cloud file metadata for current folder. @@ -96,7 +101,11 @@ def cache_files(self, account_id: int, files: List[CloudFile]) -> bool: Returns: True if cached successfully """ - return self._cloud_repo.cache_files(account_id=account_id, files=files) + return self._cloud_repo.cache_files( + account_id=account_id, + files=files, + parent_id=parent_id, + ) def update_local_path(self, file_id: str, account_id: int, local_path: str) -> bool: """ diff --git a/tests/test_repositories/test_cloud_repository.py b/tests/test_repositories/test_cloud_repository.py index 41498541..2d1669d0 100644 --- a/tests/test_repositories/test_cloud_repository.py +++ b/tests/test_repositories/test_cloud_repository.py @@ -711,6 +711,24 @@ def test_cache_files_empty_list(self, cloud_repo): result = cloud_repo.cache_files(1, []) assert result is True + def test_cache_files_empty_listing_clears_existing_folder(self, cloud_repo, sample_account): + """Explicit empty folder refresh should clear cached rows for that folder.""" + account_id = cloud_repo.add_account(sample_account) + cloud_repo.add_file( + CloudFile( + account_id=account_id, + file_id="stale1", + parent_id="folder_A", + name="stale.mp3", + file_type="audio", + ) + ) + + result = cloud_repo.cache_files(account_id, [], parent_id="folder_A") + + assert result is True + assert cloud_repo.get_files_by_parent(account_id, "folder_A") == [] + def test_cache_files_deletes_old_folder(self, cloud_repo, sample_account): """Test that cache_files deletes old files for the same folder only.""" account_id = cloud_repo.add_account(sample_account) diff --git a/tests/test_ui/test_cloud_views.py b/tests/test_ui/test_cloud_views.py index 6c55db13..a9f34f04 100644 --- a/tests/test_ui/test_cloud_views.py +++ b/tests/test_ui/test_cloud_views.py @@ -617,3 +617,34 @@ def test_delete_cloud_file_calls_baidu_delete_and_refresh(self, qapp, mock_confi mock_delete.assert_called_once_with("baidu_token", "/music/song.mp3") mock_load_files.assert_called_once() + + def test_load_files_clears_cached_folder_when_remote_listing_empty(self, qapp, mock_config): + """Empty remote folder listings should still refresh cached folder state.""" + ThemeManager.instance(mock_config) + cloud_file_service = Mock() + cloud_file_service.get_files.return_value = [] + view = CloudDriveView( + cloud_account_service=Mock(), + cloud_file_service=cloud_file_service, + library_service=Mock(), + player=Mock(), + config_manager=mock_config, + cover_service=Mock(), + ) + view._current_account = CloudAccount( + id=1, + provider="quark", + account_name="quark-test", + access_token="token", + ) + view._current_parent_id = "folder_A" + + with patch( + "ui.views.cloud.cloud_drive_view.QuarkDriveService.get_file_list", + return_value=([], None), + ): + view._load_files() + + cloud_file_service.cache_files.assert_called_once_with(1, [], parent_id="folder_A") + cloud_file_service.get_files.assert_called_once_with(1, "folder_A") + assert view._file_table._table.rowCount() == 0 diff --git a/ui/views/cloud/cloud_drive_view.py b/ui/views/cloud/cloud_drive_view.py index 0f0823a5..99dfd98b 100644 --- a/ui/views/cloud/cloud_drive_view.py +++ b/ui/views/cloud/cloud_drive_view.py @@ -1135,9 +1135,14 @@ def _load_files(self): if files and len(files) > 0: self._current_parent_id = files[0].parent_id - can_go_back = self._current_parent_id != "0" - self._back_btn.setEnabled(can_go_back) - self._cloud_file_service.cache_files(self._current_account.id, files) + + self._cloud_file_service.cache_files( + self._current_account.id, + files, + parent_id=dir_path, + ) + can_go_back = self._current_parent_id != "0" + self._back_btn.setEnabled(can_go_back) files = self._cloud_file_service.get_files( self._current_account.id, self._current_parent_id From 422bd121bcca83fcd300b334d313c8f9e80edcdd Mon Sep 17 00:00:00 2001 From: Har01d Date: Wed, 8 Apr 2026 22:45:25 +0800 Subject: [PATCH 154/157] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E5=9C=A8=E7=BA=BF?= =?UTF-8?q?=E6=94=B6=E8=97=8F=E6=BA=90=E5=86=B2=E7=AA=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- infrastructure/database/sqlite_manager.py | 68 +++++++++++++++---- .../src/harmony_plugin_api/context.py | 2 +- plugins/builtin/qqmusic/lib/runtime_bridge.py | 4 +- repositories/favorite_repository.py | 64 +++++++++++++---- services/library/favorites_service.py | 50 +++++++++++--- system/plugins/host_services.py | 4 +- system/plugins/plugin_sdk_runtime.py | 9 ++- .../test_favorite_repository.py | 28 +++++++- .../test_system/test_plugin_online_bridge.py | 17 +++++ 9 files changed, 202 insertions(+), 44 deletions(-) diff --git a/infrastructure/database/sqlite_manager.py b/infrastructure/database/sqlite_manager.py index 939ab905..ce59639d 100644 --- a/infrastructure/database/sqlite_manager.py +++ b/infrastructure/database/sqlite_manager.py @@ -259,6 +259,8 @@ def _init_database(self): INTEGER, cloud_file_id TEXT, + online_provider_id + TEXT, cloud_account_id INTEGER, created_at @@ -284,10 +286,6 @@ def _init_database(self): UNIQUE ( track_id - ), - UNIQUE - ( - cloud_file_id ) ) """) @@ -735,7 +733,7 @@ def _get_track_source_from_row(self, row) -> TrackSource: def _run_migrations(self, conn, cursor): """Run database migrations for schema updates.""" # Current schema version - increment when making schema changes - CURRENT_SCHEMA_VERSION = 11 + CURRENT_SCHEMA_VERSION = 12 # Create db_meta table for schema version tracking cursor.execute(""" @@ -769,6 +767,8 @@ def _run_migrations(self, conn, cursor): cursor.execute("ALTER TABLE favorites ADD COLUMN cloud_file_id TEXT") if 'cloud_account_id' not in columns: cursor.execute("ALTER TABLE favorites ADD COLUMN cloud_account_id INTEGER") + if 'online_provider_id' not in columns: + cursor.execute("ALTER TABLE favorites ADD COLUMN online_provider_id TEXT") # Check if track_id is NOT NULL (needs to be nullable for cloud files) cursor.execute("PRAGMA table_info(favorites)") @@ -792,6 +792,8 @@ def _run_migrations(self, conn, cursor): INTEGER, cloud_file_id TEXT, + online_provider_id + TEXT, cloud_account_id INTEGER, created_at @@ -817,16 +819,14 @@ def _run_migrations(self, conn, cursor): UNIQUE ( track_id - ), - UNIQUE - ( - cloud_file_id ) ) """) cursor.execute(""" - INSERT INTO favorites_new (id, track_id, cloud_file_id, cloud_account_id, created_at) - SELECT id, track_id, cloud_file_id, cloud_account_id, created_at + INSERT INTO favorites_new ( + id, track_id, cloud_file_id, online_provider_id, cloud_account_id, created_at + ) + SELECT id, track_id, cloud_file_id, online_provider_id, cloud_account_id, created_at FROM favorites """) cursor.execute("DROP TABLE favorites") @@ -1056,7 +1056,7 @@ def _run_migrations(self, conn, cursor): """) cursor.execute(""" CREATE UNIQUE INDEX IF NOT EXISTS idx_favorites_cloud_file_unique - ON favorites(cloud_file_id) + ON favorites(cloud_file_id, COALESCE(online_provider_id, '')) WHERE cloud_file_id IS NOT NULL """) logger.info("[Database] Added unique indexes for UPSERT support") @@ -1103,6 +1103,50 @@ def _run_migrations(self, conn, cursor): """) logger.info("[Database] Repaired legacy QQ online provider ids") + # Migration 11: Make online favorites provider-aware. + if stored_version < 12: + cursor.execute(""" + CREATE TABLE IF NOT EXISTS favorites_new + ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + track_id INTEGER, + cloud_file_id TEXT, + online_provider_id TEXT, + cloud_account_id INTEGER, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY(track_id) REFERENCES tracks(id) ON DELETE CASCADE, + FOREIGN KEY(cloud_account_id) REFERENCES cloud_accounts(id) ON DELETE CASCADE + ) + """) + cursor.execute(""" + INSERT INTO favorites_new ( + id, track_id, cloud_file_id, online_provider_id, cloud_account_id, created_at + ) + SELECT + id, + track_id, + cloud_file_id, + online_provider_id, + cloud_account_id, + created_at + FROM favorites + """) + cursor.execute("DROP TABLE favorites") + cursor.execute("ALTER TABLE favorites_new RENAME TO favorites") + cursor.execute("DROP INDEX IF EXISTS idx_favorites_track_unique") + cursor.execute("DROP INDEX IF EXISTS idx_favorites_cloud_file_unique") + cursor.execute(""" + CREATE UNIQUE INDEX IF NOT EXISTS idx_favorites_track_unique + ON favorites(track_id) + WHERE track_id IS NOT NULL + """) + cursor.execute(""" + CREATE UNIQUE INDEX IF NOT EXISTS idx_favorites_cloud_file_unique + ON favorites(cloud_file_id, COALESCE(online_provider_id, '')) + WHERE cloud_file_id IS NOT NULL + """) + logger.info("[Database] Made online favorites provider-aware") + # Update schema version after all migrations complete if schema_changed: cursor.execute( diff --git a/packages/harmony-plugin-api/src/harmony_plugin_api/context.py b/packages/harmony-plugin-api/src/harmony_plugin_api/context.py index c18fdee6..1140c3e4 100644 --- a/packages/harmony-plugin-api/src/harmony_plugin_api/context.py +++ b/packages/harmony-plugin-api/src/harmony_plugin_api/context.py @@ -155,7 +155,7 @@ def favorites_service(self): def favorite_mids_from_library(self) -> set[str]: ... - def remove_library_favorite_by_mid(self, mid: str) -> bool: + def remove_library_favorite_by_mid(self, mid: str, provider_id: str | None = None) -> bool: ... def add_requests_to_favorites(self, requests): diff --git a/plugins/builtin/qqmusic/lib/runtime_bridge.py b/plugins/builtin/qqmusic/lib/runtime_bridge.py index f4eaef71..8967c4a1 100644 --- a/plugins/builtin/qqmusic/lib/runtime_bridge.py +++ b/plugins/builtin/qqmusic/lib/runtime_bridge.py @@ -157,8 +157,8 @@ def favorite_mids_from_library() -> set[str]: return _require_context().runtime.favorite_mids_from_library() -def remove_library_favorite_by_mid(mid: str) -> bool: - return _require_context().runtime.remove_library_favorite_by_mid(mid) +def remove_library_favorite_by_mid(mid: str, provider_id: str | None = None) -> bool: + return _require_context().runtime.remove_library_favorite_by_mid(mid, provider_id=provider_id) def add_requests_to_favorites(requests: list[Any]) -> list[int]: diff --git a/repositories/favorite_repository.py b/repositories/favorite_repository.py index 90a94a6f..59fa4e10 100644 --- a/repositories/favorite_repository.py +++ b/repositories/favorite_repository.py @@ -20,7 +20,19 @@ def __init__(self, db_path: str = "Harmony.db", db_manager: "DatabaseManager" = from repositories.track_repository import SqliteTrackRepository self._track_repo = SqliteTrackRepository(db_path, db_manager) - def is_favorite(self, track_id: TrackId = None, cloud_file_id: str = None) -> bool: + @staticmethod + def _normalize_online_provider_id(value: str | None) -> str | None: + normalized = str(value or "").strip() + if not normalized or normalized.lower() == "online": + return None + return normalized + + def is_favorite( + self, + track_id: TrackId = None, + cloud_file_id: str = None, + online_provider_id: str | None = None, + ) -> bool: """ Check if a track or cloud file is favorited. @@ -40,10 +52,17 @@ def is_favorite(self, track_id: TrackId = None, cloud_file_id: str = None) -> bo (track_id,) ) elif cloud_file_id is not None: - cursor.execute( - "SELECT 1 FROM favorites WHERE cloud_file_id = ? LIMIT 1", - (cloud_file_id,) - ) + normalized_provider_id = self._normalize_online_provider_id(online_provider_id) + if normalized_provider_id is None: + cursor.execute( + "SELECT 1 FROM favorites WHERE cloud_file_id = ? AND online_provider_id IS NULL LIMIT 1", + (cloud_file_id,), + ) + else: + cursor.execute( + "SELECT 1 FROM favorites WHERE cloud_file_id = ? AND online_provider_id = ? LIMIT 1", + (cloud_file_id, normalized_provider_id), + ) else: return False @@ -65,7 +84,8 @@ def add_favorite( self, track_id: TrackId = None, cloud_file_id: str = None, - cloud_account_id: int = None + cloud_account_id: int = None, + online_provider_id: str | None = None, ) -> bool: """ Add a track or cloud file to favorites. @@ -85,16 +105,26 @@ def add_favorite( if track_id is None and cloud_file_id is None: return False + normalized_provider_id = self._normalize_online_provider_id(online_provider_id) cursor.execute( - "INSERT OR IGNORE INTO favorites (track_id, cloud_file_id, cloud_account_id) VALUES (?, ?, ?)", - (track_id, cloud_file_id, cloud_account_id) + """ + INSERT OR IGNORE INTO favorites + (track_id, cloud_file_id, online_provider_id, cloud_account_id) + VALUES (?, ?, ?, ?) + """, + (track_id, cloud_file_id, normalized_provider_id, cloud_account_id) ) if cursor.rowcount == 0: return False # Already exists conn.commit() return True - def remove_favorite(self, track_id: TrackId = None, cloud_file_id: str = None) -> bool: + def remove_favorite( + self, + track_id: TrackId = None, + cloud_file_id: str = None, + online_provider_id: str | None = None, + ) -> bool: """ Remove a track or cloud file from favorites. @@ -114,10 +144,17 @@ def remove_favorite(self, track_id: TrackId = None, cloud_file_id: str = None) - (track_id,) ) elif cloud_file_id is not None: - cursor.execute( - "DELETE FROM favorites WHERE cloud_file_id = ?", - (cloud_file_id,) - ) + normalized_provider_id = self._normalize_online_provider_id(online_provider_id) + if normalized_provider_id is None: + cursor.execute( + "DELETE FROM favorites WHERE cloud_file_id = ? AND online_provider_id IS NULL", + (cloud_file_id,), + ) + else: + cursor.execute( + "DELETE FROM favorites WHERE cloud_file_id = ? AND online_provider_id = ?", + (cloud_file_id, normalized_provider_id), + ) else: return False @@ -156,6 +193,7 @@ def get_favorites_with_cloud(self) -> List[dict]: f.id as fav_id, f.track_id, f.cloud_file_id, + f.online_provider_id, f.cloud_account_id, t.id, t.path, diff --git a/services/library/favorites_service.py b/services/library/favorites_service.py index f91c249f..67762fb5 100644 --- a/services/library/favorites_service.py +++ b/services/library/favorites_service.py @@ -35,7 +35,12 @@ def __init__( self._favorite_repo = favorite_repo self._event_bus = event_bus or EventBus.instance() - def is_favorite(self, track_id: int = None, cloud_file_id: str = None) -> bool: + def is_favorite( + self, + track_id: int = None, + cloud_file_id: str = None, + online_provider_id: str | None = None, + ) -> bool: """ Check if a track or cloud file is favorited. @@ -46,7 +51,11 @@ def is_favorite(self, track_id: int = None, cloud_file_id: str = None) -> bool: Returns: True if favorited, False otherwise """ - return self._favorite_repo.is_favorite(track_id=track_id, cloud_file_id=cloud_file_id) + return self._favorite_repo.is_favorite( + track_id=track_id, + cloud_file_id=cloud_file_id, + online_provider_id=online_provider_id, + ) def get_all_favorite_track_ids(self) -> set: """ @@ -61,7 +70,8 @@ def add_favorite( self, track_id: int = None, cloud_file_id: str = None, - cloud_account_id: int = None + cloud_account_id: int = None, + online_provider_id: str | None = None, ) -> bool: """ Add a track or cloud file to favorites. @@ -77,7 +87,8 @@ def add_favorite( result = self._favorite_repo.add_favorite( track_id=track_id, cloud_file_id=cloud_file_id, - cloud_account_id=cloud_account_id + cloud_account_id=cloud_account_id, + online_provider_id=online_provider_id, ) if result: is_cloud = cloud_file_id is not None @@ -85,7 +96,12 @@ def add_favorite( self._event_bus.emit_favorite_change(item_id, True, is_cloud) return result - def remove_favorite(self, track_id: int = None, cloud_file_id: str = None) -> bool: + def remove_favorite( + self, + track_id: int = None, + cloud_file_id: str = None, + online_provider_id: str | None = None, + ) -> bool: """ Remove a track or cloud file from favorites. @@ -96,7 +112,11 @@ def remove_favorite(self, track_id: int = None, cloud_file_id: str = None) -> bo Returns: True if removed, False if not found """ - result = self._favorite_repo.remove_favorite(track_id=track_id, cloud_file_id=cloud_file_id) + result = self._favorite_repo.remove_favorite( + track_id=track_id, + cloud_file_id=cloud_file_id, + online_provider_id=online_provider_id, + ) if result: is_cloud = cloud_file_id is not None item_id = cloud_file_id if is_cloud else track_id @@ -107,7 +127,8 @@ def toggle_favorite( self, track_id: int = None, cloud_file_id: str = None, - cloud_account_id: int = None + cloud_account_id: int = None, + online_provider_id: str | None = None, ) -> tuple[bool, bool]: """ Toggle favorite status. @@ -120,16 +141,25 @@ def toggle_favorite( Returns: Tuple of (is_now_favorite, was_changed) """ - is_fav = self.is_favorite(track_id=track_id, cloud_file_id=cloud_file_id) + is_fav = self.is_favorite( + track_id=track_id, + cloud_file_id=cloud_file_id, + online_provider_id=online_provider_id, + ) if is_fav: - removed = self.remove_favorite(track_id=track_id, cloud_file_id=cloud_file_id) + removed = self.remove_favorite( + track_id=track_id, + cloud_file_id=cloud_file_id, + online_provider_id=online_provider_id, + ) return False, removed else: added = self.add_favorite( track_id=track_id, cloud_file_id=cloud_file_id, - cloud_account_id=cloud_account_id + cloud_account_id=cloud_account_id, + online_provider_id=online_provider_id, ) return True, added diff --git a/system/plugins/host_services.py b/system/plugins/host_services.py index bc491ecb..f3fa4f86 100644 --- a/system/plugins/host_services.py +++ b/system/plugins/host_services.py @@ -120,8 +120,8 @@ def favorites_service(self): def favorite_mids_from_library(self) -> set[str]: return plugin_sdk_runtime.favorite_mids_from_library() - def remove_library_favorite_by_mid(self, mid: str) -> bool: - return plugin_sdk_runtime.remove_library_favorite_by_mid(mid) + def remove_library_favorite_by_mid(self, mid: str, provider_id: str | None = None) -> bool: + return plugin_sdk_runtime.remove_library_favorite_by_mid(mid, provider_id=provider_id) def add_requests_to_favorites(self, requests): return plugin_sdk_runtime.add_requests_to_favorites(requests) diff --git a/system/plugins/plugin_sdk_runtime.py b/system/plugins/plugin_sdk_runtime.py index 7f51dc92..241f80d6 100644 --- a/system/plugins/plugin_sdk_runtime.py +++ b/system/plugins/plugin_sdk_runtime.py @@ -100,15 +100,18 @@ def favorite_mids_from_library() -> set[str]: return mids -def remove_library_favorite_by_mid(mid: str) -> bool: +def remove_library_favorite_by_mid(mid: str, provider_id: str | None = None) -> bool: instance = bootstrap() if not instance or not getattr(instance, "favorites_service", None) or not getattr(instance, "library_service", None): return False - library_track = instance.library_service.get_track_by_cloud_file_id(mid) + library_track = instance.library_service.get_track_by_cloud_file_id(mid, provider_id=provider_id) if library_track: instance.favorites_service.remove_favorite(track_id=library_track.id) return True - instance.favorites_service.remove_favorite(cloud_file_id=mid) + instance.favorites_service.remove_favorite( + cloud_file_id=mid, + online_provider_id=provider_id, + ) return True diff --git a/tests/test_repositories/test_favorite_repository.py b/tests/test_repositories/test_favorite_repository.py index 60dfeb2e..e84fb909 100644 --- a/tests/test_repositories/test_favorite_repository.py +++ b/tests/test_repositories/test_favorite_repository.py @@ -33,6 +33,7 @@ def temp_db(): duration REAL, cover_path TEXT, cloud_file_id TEXT, + online_provider_id TEXT, source TEXT DEFAULT 'Local', created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ) @@ -44,6 +45,7 @@ def temp_db(): id INTEGER PRIMARY KEY AUTOINCREMENT, track_id INTEGER, cloud_file_id TEXT, + online_provider_id TEXT, cloud_account_id INTEGER, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, FOREIGN KEY (track_id) REFERENCES tracks(id) @@ -58,7 +60,7 @@ def temp_db(): """) cursor.execute(""" CREATE UNIQUE INDEX IF NOT EXISTS idx_favorites_cloud_file_unique - ON favorites(cloud_file_id) + ON favorites(cloud_file_id, COALESCE(online_provider_id, '')) WHERE cloud_file_id IS NOT NULL """) @@ -167,6 +169,30 @@ def test_add_favorite_duplicate(self, favorite_repo, populated_db): result = favorite_repo.add_favorite(track_id=1) assert result is False + def test_add_favorite_allows_same_cloud_id_for_different_providers(self, favorite_repo): + """Online favorites should be distinct per provider.""" + first = favorite_repo.add_favorite( + cloud_file_id="cloud_123", + online_provider_id="qqmusic", + cloud_account_id=1, + ) + second = favorite_repo.add_favorite( + cloud_file_id="cloud_123", + online_provider_id="netease", + cloud_account_id=2, + ) + + assert first is True + assert second is True + assert favorite_repo.is_favorite( + cloud_file_id="cloud_123", + online_provider_id="qqmusic", + ) is True + assert favorite_repo.is_favorite( + cloud_file_id="cloud_123", + online_provider_id="netease", + ) is True + # ===== remove_favorite Tests ===== def test_remove_favorite_local_track(self, favorite_repo, populated_db): diff --git a/tests/test_system/test_plugin_online_bridge.py b/tests/test_system/test_plugin_online_bridge.py index 748fe64d..2cfa7677 100644 --- a/tests/test_system/test_plugin_online_bridge.py +++ b/tests/test_system/test_plugin_online_bridge.py @@ -12,6 +12,7 @@ ) from system.plugins.media_bridge import PluginMediaBridge from system.plugins.registry import PluginRegistry +import system.plugins.plugin_sdk_runtime as plugin_sdk_runtime def test_plugin_settings_bridge_namespaces_keys(): @@ -239,3 +240,19 @@ def test_media_bridge_can_add_and_insert_online_track_to_queue(): inserted_item = playback_service.engine.insert_track.call_args[0][1] assert queued_item.online_provider_id == "qqmusic" assert inserted_item.online_provider_id == "qqmusic" + + +def test_runtime_remove_library_favorite_by_mid_is_provider_aware(monkeypatch): + instance = Mock() + track = Mock(id=42) + instance.library_service.get_track_by_cloud_file_id.return_value = track + monkeypatch.setattr(plugin_sdk_runtime, "bootstrap", lambda: instance) + + result = plugin_sdk_runtime.remove_library_favorite_by_mid("mid-1", provider_id="qqmusic") + + assert result is True + instance.library_service.get_track_by_cloud_file_id.assert_called_once_with( + "mid-1", + provider_id="qqmusic", + ) + instance.favorites_service.remove_favorite.assert_called_once_with(track_id=42) From 00b95cd6ae2b3b62dc819ac8f15a7087ec0c6057 Mon Sep 17 00:00:00 2001 From: Har01d Date: Wed, 8 Apr 2026 22:49:22 +0800 Subject: [PATCH 155/157] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E5=9C=A8=E7=BA=BF?= =?UTF-8?q?=E6=89=B9=E9=87=8F=E6=9F=A5=E6=AD=8C=E4=B8=B2=E6=BA=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- repositories/track_repository.py | 43 +++++++++++ services/playback/playback_service.py | 54 ++++++++++++- services/playback/queue_service.py | 30 +++++++- .../test_track_repository.py | 24 ++++++ .../test_playback_service_library_sync.py | 77 +++++++++++++++++++ tests/test_services/test_queue_service.py | 43 +++++++++++ 6 files changed, 265 insertions(+), 6 deletions(-) diff --git a/repositories/track_repository.py b/repositories/track_repository.py index 47a3ad10..8872a31f 100644 --- a/repositories/track_repository.py +++ b/repositories/track_repository.py @@ -142,6 +142,49 @@ def get_by_cloud_file_ids(self, cloud_file_ids: List[str]) -> Dict[str, Track]: rows = cursor.fetchall() return {row["cloud_file_id"]: self._row_to_track(row) for row in rows if row["cloud_file_id"]} + def get_by_non_online_cloud_file_ids(self, cloud_file_ids: List[str]) -> Dict[str, Track]: + """Get non-online tracks by cloud file IDs, keyed by cloud_file_id.""" + if not cloud_file_ids: + return {} + conn = self._get_connection() + cursor = conn.cursor() + placeholders = ",".join("?" * len(cloud_file_ids)) + cursor.execute( + f""" + SELECT * + FROM tracks + WHERE cloud_file_id IN ({placeholders}) + AND UPPER(COALESCE(source, '')) NOT IN ('ONLINE', 'QQ') + """, + cloud_file_ids, + ) + rows = cursor.fetchall() + return {row["cloud_file_id"]: self._row_to_track(row) for row in rows if row["cloud_file_id"]} + + def get_by_online_track_keys( + self, + online_keys: List[tuple[str | None, str]], + ) -> Dict[tuple[str | None, str], Track]: + """Get online tracks by (provider_id, cloud_file_id).""" + result: Dict[tuple[str | None, str], Track] = {} + if not online_keys: + return result + + seen: set[tuple[str | None, str]] = set() + for provider_id, cloud_file_id in online_keys: + normalized_provider_id = self._normalize_online_provider_id(provider_id) + key = (normalized_provider_id, cloud_file_id) + if not cloud_file_id or key in seen: + continue + seen.add(key) + track = self.get_by_cloud_file_id( + cloud_file_id, + provider_id=normalized_provider_id, + ) + if track is not None: + result[key] = track + return result + @staticmethod def _normalize_source_value(source: Optional[TrackSource | str]) -> Optional[str]: """Normalize an optional source enum/string to the stored DB value.""" diff --git a/services/playback/playback_service.py b/services/playback/playback_service.py index 6d40a905..ef79a56a 100644 --- a/services/playback/playback_service.py +++ b/services/playback/playback_service.py @@ -776,7 +776,10 @@ def play_cloud_playlist( # Batch-load all tracks by cloud file IDs cloud_file_ids = [cf.file_id for cf in cloud_files] - tracks_by_cloud_id = self._track_repo.get_by_cloud_file_ids(cloud_file_ids) + if hasattr(self._track_repo, "get_by_non_online_cloud_file_ids"): + tracks_by_cloud_id = self._track_repo.get_by_non_online_cloud_file_ids(cloud_file_ids) + else: + tracks_by_cloud_id = self._track_repo.get_by_cloud_file_ids(cloud_file_ids) # Build playlist items - fast path, no blocking operations items = [] @@ -1185,21 +1188,60 @@ def _enrich_queue_items_metadata_batch(self, items: List[PlaylistItem]) -> List[ return [self._enrich_queue_item_metadata(item) for item in items] track_ids = [item.track_id for item in items if item.track_id and item.is_local] - cloud_file_ids = [item.cloud_file_id for item in items if item.is_cloud and item.cloud_file_id] + cloud_file_ids = [ + item.cloud_file_id + for item in items + if item.source in (TrackSource.QUARK, TrackSource.BAIDU) and item.cloud_file_id + ] + online_keys = [ + (item.online_provider_id, item.cloud_file_id) + for item in items + if item.is_online and item.cloud_file_id + ] paths = [item.local_path for item in items if item.local_path and not item.cloud_file_id] track_ids = list(dict.fromkeys(track_ids)) cloud_file_ids = list(dict.fromkeys(cloud_file_ids)) + online_keys = list(dict.fromkeys(online_keys)) paths = list(dict.fromkeys(paths)) id_map = {track.id: track for track in self._track_repo.get_by_ids(track_ids)} if track_ids else {} cloud_map = {} if cloud_file_ids: - cloud_tracks = self._track_repo.get_by_cloud_file_ids(cloud_file_ids) or {} + if hasattr(self._track_repo, "get_by_non_online_cloud_file_ids"): + cloud_tracks = self._track_repo.get_by_non_online_cloud_file_ids(cloud_file_ids) or {} + else: + cloud_tracks = self._track_repo.get_by_cloud_file_ids(cloud_file_ids) or {} if isinstance(cloud_tracks, dict): cloud_map = cloud_tracks else: - cloud_map = {track.cloud_file_id: track for track in cloud_tracks if getattr(track, "cloud_file_id", None)} + cloud_map = { + track.cloud_file_id: track + for track in cloud_tracks + if getattr(track, "cloud_file_id", None) + } + + online_map = {} + if online_keys: + if hasattr(self._track_repo, "get_by_online_track_keys"): + online_tracks = self._track_repo.get_by_online_track_keys(online_keys) or {} + if isinstance(online_tracks, dict): + online_map = online_tracks + else: + online_map = { + (getattr(track, "online_provider_id", None), track.cloud_file_id): track + for track in online_tracks + if getattr(track, "cloud_file_id", None) + } + else: + legacy_online_tracks = self._track_repo.get_by_cloud_file_ids( + [cloud_file_id for _provider_id, cloud_file_id in online_keys] + ) or {} + if isinstance(legacy_online_tracks, dict): + online_map = { + (getattr(track, "online_provider_id", None), cloud_file_id): track + for cloud_file_id, track in legacy_online_tracks.items() + } path_map = {} if paths: @@ -1214,6 +1256,8 @@ def _enrich_queue_items_metadata_batch(self, items: List[PlaylistItem]) -> List[ track = None if item.track_id and item.is_local: track = id_map.get(item.track_id) + elif item.is_online and item.cloud_file_id: + track = online_map.get((item.online_provider_id, item.cloud_file_id)) elif item.is_cloud and item.cloud_file_id: track = cloud_map.get(item.cloud_file_id) elif item.local_path and not item.cloud_file_id: @@ -1229,6 +1273,8 @@ def _enrich_queue_items_metadata_batch(self, items: List[PlaylistItem]) -> List[ track = None if item.track_id and item.is_local: track = id_map.get(item.track_id) + elif item.is_online and item.cloud_file_id: + track = online_map.get((item.online_provider_id, item.cloud_file_id)) elif item.is_cloud and item.cloud_file_id: track = cloud_map.get(item.cloud_file_id) elif item.local_path and not item.cloud_file_id: diff --git a/services/playback/queue_service.py b/services/playback/queue_service.py index a540994e..58243c60 100644 --- a/services/playback/queue_service.py +++ b/services/playback/queue_service.py @@ -174,12 +174,34 @@ def _enrich_metadata_batch(self, items: List[PlaylistItem]) -> List[PlaylistItem # Collect IDs by lookup type track_ids = [item.track_id for item in items if item.track_id and item.is_local] - cloud_file_ids = [item.cloud_file_id for item in items if item.is_cloud and item.cloud_file_id] + cloud_file_ids = [ + item.cloud_file_id + for item in items + if item.source in (TrackSource.QUARK, TrackSource.BAIDU) and item.cloud_file_id + ] + online_keys = [ + (item.online_provider_id, item.cloud_file_id) + for item in items + if item.is_online and item.cloud_file_id + ] paths = [item.local_path for item in items if item.local_path and not item.cloud_file_id] # Batch fetch id_map = {t.id: t for t in self._track_repo.get_by_ids(track_ids)} if track_ids else {} - cloud_map = self._track_repo.get_by_cloud_file_ids(cloud_file_ids) if cloud_file_ids else {} + if cloud_file_ids and hasattr(self._track_repo, "get_by_non_online_cloud_file_ids"): + cloud_map = self._track_repo.get_by_non_online_cloud_file_ids(cloud_file_ids) + else: + cloud_map = self._track_repo.get_by_cloud_file_ids(cloud_file_ids) if cloud_file_ids else {} + if online_keys and hasattr(self._track_repo, "get_by_online_track_keys"): + online_map = self._track_repo.get_by_online_track_keys(online_keys) + else: + legacy_online = self._track_repo.get_by_cloud_file_ids( + [cloud_file_id for _provider_id, cloud_file_id in online_keys] + ) if online_keys else {} + online_map = { + (getattr(track, "online_provider_id", None), cloud_file_id): track + for cloud_file_id, track in legacy_online.items() + } path_map = self._track_repo.get_by_paths(paths) if paths else {} # Enrich each item from the maps @@ -189,6 +211,8 @@ def _enrich_metadata_batch(self, items: List[PlaylistItem]) -> List[PlaylistItem track = None if item.track_id and item.is_local: track = id_map.get(item.track_id) + elif item.is_online and item.cloud_file_id: + track = online_map.get((item.online_provider_id, item.cloud_file_id)) elif item.is_cloud and item.cloud_file_id: track = cloud_map.get(item.cloud_file_id) elif item.local_path and not item.cloud_file_id: @@ -205,6 +229,8 @@ def _enrich_metadata_batch(self, items: List[PlaylistItem]) -> List[PlaylistItem if item.track_id and item.is_local: track = id_map.get(item.track_id) + elif item.is_online and item.cloud_file_id: + track = online_map.get((item.online_provider_id, item.cloud_file_id)) elif item.is_cloud and item.cloud_file_id: track = cloud_map.get(item.cloud_file_id) elif item.local_path and not item.cloud_file_id: diff --git a/tests/test_repositories/test_track_repository.py b/tests/test_repositories/test_track_repository.py index 1578d5ed..72a6dea0 100644 --- a/tests/test_repositories/test_track_repository.py +++ b/tests/test_repositories/test_track_repository.py @@ -378,6 +378,30 @@ def test_get_by_cloud_file_id_not_found(self, track_repo): retrieved = track_repo.get_by_cloud_file_id("nonexistent") assert retrieved is None + def test_get_by_online_track_keys_keeps_provider_distinct(self, track_repo): + """Batch online lookups should distinguish tracks by provider id.""" + track_repo.add(Track( + path="online://qqmusic/track/shared", + title="QQ Song", + source=TrackSource.ONLINE, + cloud_file_id="shared", + online_provider_id="qqmusic", + )) + track_repo.add(Track( + path="online://netease/track/shared", + title="Netease Song", + source=TrackSource.ONLINE, + cloud_file_id="shared", + online_provider_id="netease", + )) + + tracks = track_repo.get_by_online_track_keys( + [("qqmusic", "shared"), ("netease", "shared")] + ) + + assert tracks[("qqmusic", "shared")].title == "QQ Song" + assert tracks[("netease", "shared")].title == "Netease Song" + def test_search_tracks(self, track_repo, temp_db): """Test searching tracks.""" # Add tracks with different titles diff --git a/tests/test_services/test_playback_service_library_sync.py b/tests/test_services/test_playback_service_library_sync.py index 6c69194b..3fd79b1c 100644 --- a/tests/test_services/test_playback_service_library_sync.py +++ b/tests/test_services/test_playback_service_library_sync.py @@ -61,3 +61,80 @@ def test_save_cloud_track_to_library_refreshes_repositories(monkeypatch): service._artist_repo.refresh.assert_called_once() service._db.update_albums_on_track_added.assert_not_called() service._db.update_artists_on_track_added.assert_not_called() + + +def test_play_cloud_playlist_uses_non_online_track_lookup_for_cached_cloud_files(): + """Cloud playlists should not hydrate metadata from unrelated online tracks sharing the same file id.""" + class _Engine: + def __init__(self): + self.items = None + self.play_at_index = None + + def load_playlist_items(self, items): + self.items = list(items) + + def is_shuffle_mode(self): + return False + + def play_at(self, index): + self.play_at_index = index + + cached_cloud_track = SimpleNamespace( + id=7, + path="/tmp/cloud-song.mp3", + title="Cloud Song", + artist="Cloud Artist", + album="Cloud Album", + duration=123.0, + cover_path="/tmp/cloud.jpg", + ) + track_repo = Mock() + track_repo.get_by_cloud_file_ids.return_value = { + "shared-id": SimpleNamespace( + id=99, + path="online://qqmusic/track/shared-id", + title="Wrong Online Song", + artist="Wrong Artist", + album="Wrong Album", + duration=300.0, + cover_path="wrong.jpg", + cloud_file_id="shared-id", + online_provider_id="qqmusic", + ) + } + track_repo.get_by_non_online_cloud_file_ids.return_value = { + "shared-id": cached_cloud_track + } + + service = PlaybackService.__new__(PlaybackService) + service._track_repo = track_repo + service._engine = _Engine() + service._downloaded_files = {} + service._config = Mock() + service._process_metadata_async = Mock() + service._set_source = Mock() + service.save_queue = Mock() + service._get_cached_path = Mock(return_value="/tmp/cloud-song.mp3") + + account = SimpleNamespace(id=1, provider="quark") + cloud_file = SimpleNamespace( + file_id="shared-id", + name="song.mp3", + file_type="audio", + size=1, + duration=0.0, + parent_id="0", + mime_type="audio/mpeg", + metadata=None, + ) + + PlaybackService.play_cloud_playlist( + service, + cloud_files=[cloud_file], + start_index=0, + account=account, + ) + + assert service._engine.items is not None + assert service._engine.items[0].title == "Cloud Song" + assert service._engine.items[0].track_id == 7 diff --git a/tests/test_services/test_queue_service.py b/tests/test_services/test_queue_service.py index 66ccd37b..1ac99817 100644 --- a/tests/test_services/test_queue_service.py +++ b/tests/test_services/test_queue_service.py @@ -195,6 +195,49 @@ def test_enrich_metadata_batch_preserves_cached_online_file(temp_dir): assert restored.needs_download is False +def test_enrich_metadata_batch_uses_provider_aware_online_lookup(temp_dir): + """Online enrichment should resolve same cloud id separately per provider.""" + cached_path = temp_dir / "downloaded.mp3" + cached_path.write_text("cached") + + class _ProviderAwareTrackRepo(FakeTrackRepo): + def get_by_cloud_file_ids(self, cloud_file_ids): + return {} + + def get_by_online_track_keys(self, keys): + return { + ("qqmusic", "shared-mid"): Track( + id=9, + path=str(cached_path), + title="QQ Track", + artist="QQ Artist", + source=TrackSource.ONLINE, + online_provider_id="qqmusic", + cloud_file_id="shared-mid", + ) + } + + service = QueueService( + queue_repo=FakeQueueRepo(), + config_manager=FakeConfig(), + engine=FakeEngine(), + track_repo=_ProviderAwareTrackRepo(), + ) + item = PlaylistItem( + source=TrackSource.ONLINE, + online_provider_id="qqmusic", + cloud_file_id="shared-mid", + local_path=str(cached_path), + title="Placeholder", + needs_download=False, + ) + + restored = service._enrich_metadata_batch([item])[0] + + assert restored.title == "QQ Track" + assert restored.track_id == 9 + + def test_save_clears_persisted_queue_when_engine_playlist_is_empty(): """Saving an empty queue should clear stale persisted queue state.""" repo = FakeQueueRepo() From 5f4a3d1874ddca4626db44e59581493f7de89b9e Mon Sep 17 00:00:00 2001 From: Har01d Date: Wed, 8 Apr 2026 22:58:51 +0800 Subject: [PATCH 156/157] =?UTF-8?q?=E4=BF=AE=E5=A4=8Dmpv=E9=80=80=E5=87=BA?= =?UTF-8?q?=E5=B4=A9=E6=BA=83?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- infrastructure/audio/audio_engine.py | 9 +++ services/playback/playback_service.py | 7 ++ .../test_infrastructure/test_audio_engine.py | 24 ++++++ tests/test_ui/test_main_window_components.py | 80 +++++++++++++++++++ ui/windows/main_window.py | 13 ++- 5 files changed, 125 insertions(+), 8 deletions(-) diff --git a/infrastructure/audio/audio_engine.py b/infrastructure/audio/audio_engine.py index 3ef85b32..bf06f599 100644 --- a/infrastructure/audio/audio_engine.py +++ b/infrastructure/audio/audio_engine.py @@ -100,6 +100,7 @@ def __init__(self, backend_type: str = BACKEND_MPV, parent=None): self._cloud_file_id_to_index: dict = {} # Dict for O(1) lookup by cloud_file_id self._prevent_auto_next: bool = False # Flag to prevent auto-play next track self._media_loaded_flag: bool = False # Track if media has been loaded for current source + self._shutdown_complete: bool = False # Connect signals self._backend.position_changed.connect(self._on_position_changed) @@ -114,6 +115,14 @@ def __init__(self, backend_type: str = BACKEND_MPV, parent=None): def __del__(self): """Ensure cleanup on destruction.""" + self.shutdown() + + def shutdown(self): + """Explicitly cleanup backend and temp files once.""" + if getattr(self, "_shutdown_complete", False): + return + self._shutdown_complete = True + backend = getattr(self, "_backend", None) if backend is not None: try: diff --git a/services/playback/playback_service.py b/services/playback/playback_service.py index ef79a56a..73d0ed5d 100644 --- a/services/playback/playback_service.py +++ b/services/playback/playback_service.py @@ -514,6 +514,13 @@ def stop(self): # Cleanup any ongoing download tasks self.cleanup_download_workers() + def shutdown(self): + """Explicitly shutdown playback backend resources and workers.""" + try: + self._engine.shutdown() + finally: + self.cleanup_download_workers() + def cleanup_download_workers(self): """Clean up all online download workers.""" logger.info("[PlaybackService] Cleaning up online download workers") diff --git a/tests/test_infrastructure/test_audio_engine.py b/tests/test_infrastructure/test_audio_engine.py index 5837a40d..67d4f8ed 100644 --- a/tests/test_infrastructure/test_audio_engine.py +++ b/tests/test_infrastructure/test_audio_engine.py @@ -150,3 +150,27 @@ def cleanup(self): assert temp_cleanup_calls == ["cleaned"] assert "Error cleaning up backend" in caplog.text + + +def test_explicit_shutdown_cleans_backend_once(): + """Explicit shutdown should be idempotent and not rely on __del__ ordering.""" + + class _Backend: + def __init__(self): + self.cleanup_calls = 0 + + def cleanup(self): + self.cleanup_calls += 1 + + engine = PlayerEngine.__new__(PlayerEngine) + engine._backend = _Backend() + engine._shutdown_complete = False + temp_cleanup_calls = [] + engine.cleanup_temp_files = lambda: temp_cleanup_calls.append("cleaned") + + PlayerEngine.shutdown(engine) + PlayerEngine.shutdown(engine) + PlayerEngine.__del__(engine) + + assert engine._backend.cleanup_calls == 1 + assert temp_cleanup_calls == ["cleaned"] diff --git a/tests/test_ui/test_main_window_components.py b/tests/test_ui/test_main_window_components.py index e91b5277..90be5f34 100644 --- a/tests/test_ui/test_main_window_components.py +++ b/tests/test_ui/test_main_window_components.py @@ -3,9 +3,11 @@ """ import pytest +from types import SimpleNamespace from unittest.mock import Mock, patch from PySide6.QtWidgets import QApplication +from domain.playback import PlaybackState from ui.windows.main_window import MainWindow from ui.windows.components.sidebar import Sidebar @@ -263,6 +265,84 @@ def test_player_proxy_exposes_play_local_tracks(self, qapp): playback.play_local_tracks.assert_called_once_with([1, 2, 3], start_index=1) + def test_close_event_uses_playback_shutdown(self, qapp): + """MainWindow shutdown should explicitly close playback backend resources.""" + cloud_download_service = SimpleNamespace(cleanup=Mock()) + download_manager = SimpleNamespace( + cleanup=Mock(), + download_completed=SimpleNamespace(disconnect=Mock()), + download_failed=SimpleNamespace(disconnect=Mock()), + ) + fake = SimpleNamespace( + _now_playing_window=None, + _config=SimpleNamespace( + set_start_in_now_playing=Mock(), + set_volume=Mock(), + set_playback_position=Mock(), + set_was_playing=Mock(), + get_playback_source=Mock(return_value="local"), + set_playback_source=Mock(), + set_current_track_id=Mock(), + clear_cloud_account_id=Mock(), + ), + _settings=SimpleNamespace(setValue=Mock()), + saveGeometry=Mock(return_value=b"geometry"), + _splitter=SimpleNamespace(saveState=Mock(return_value=b"splitter")), + _save_view_state=Mock(), + _player=SimpleNamespace( + current_source="local", + state=PlaybackState.PLAYING, + current_track=None, + volume=35, + engine=SimpleNamespace( + position=Mock(return_value=1200), + current_index=0, + stop=Mock(), + ), + ), + _playback=SimpleNamespace( + begin_shutdown=Mock(), + save_queue=Mock(), + shutdown=Mock(), + cleanup_download_workers=Mock(), + ), + _force_quit_requested=False, + _scan_controller=None, + _lyrics_controller=None, + _event_bus=SimpleNamespace( + track_changed=SimpleNamespace(disconnect=Mock()), + position_changed=SimpleNamespace(disconnect=Mock()), + playback_state_changed=SimpleNamespace(disconnect=Mock()), + download_completed=SimpleNamespace(disconnect=Mock()), + ), + _on_track_changed=Mock(), + _on_position_changed=Mock(), + _on_playback_state_changed=Mock(), + _on_cloud_download_completed=Mock(), + _on_playlist_redownload_completed=Mock(), + _on_playlist_redownload_failed=Mock(), + _db=SimpleNamespace(close=Mock()), + ) + event = SimpleNamespace(accept=Mock()) + + with patch( + "services.cloud.download_service.CloudDownloadService.instance", + return_value=cloud_download_service, + ), patch( + "services.download.download_manager.DownloadManager.instance", + return_value=download_manager, + ): + MainWindow.closeEvent(fake, event) + + fake._playback.begin_shutdown.assert_called_once_with() + fake._playback.save_queue.assert_called_once_with(force=True) + fake._playback.shutdown.assert_called_once_with() + fake._player.engine.stop.assert_not_called() + cloud_download_service.cleanup.assert_called_once_with() + download_manager.cleanup.assert_called_once_with() + fake._db.close.assert_called_once_with() + event.accept.assert_called_once_with() + class TestSidebarWithConfig: """Tests for Sidebar with ConfigManager.""" diff --git a/ui/windows/main_window.py b/ui/windows/main_window.py index 570ddb32..3a782d22 100644 --- a/ui/windows/main_window.py +++ b/ui/windows/main_window.py @@ -2387,8 +2387,11 @@ def closeEvent(self, event): app.quit() return - # Stop playback AFTER saving state - self._player.engine.stop() + # Stop playback AFTER saving state and explicitly shutdown backend resources. + try: + self._playback.shutdown() + except Exception as e: + logger.error(f"Error shutting down playback backend: {e}") # Clean up scan controller if hasattr(self, '_scan_controller') and self._scan_controller: @@ -2409,12 +2412,6 @@ def closeEvent(self, event): except Exception as e: logger.error(f"Error cleaning up DownloadManager: {e}") - # Clean up PlaybackService online download workers - try: - self._playback.cleanup_download_workers() - except Exception as e: - logger.error(f"Error cleaning up PlaybackService workers: {e}") - # Clean up lyrics controller threads if hasattr(self, '_lyrics_controller') and self._lyrics_controller: try: From 498b32b2005f41f57445741dc52e5b43606d6dce Mon Sep 17 00:00:00 2001 From: Har01d Date: Wed, 8 Apr 2026 23:02:29 +0800 Subject: [PATCH 157/157] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E6=89=B9=E9=87=8F?= =?UTF-8?q?=E6=9F=A5=E6=AD=8C=E5=85=BC=E5=AE=B9=E5=9B=9E=E5=BD=92?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- services/playback/playback_service.py | 16 ++++++-- services/playback/queue_service.py | 10 ++++- .../test_playback_service_library_sync.py | 37 ++++++++++--------- 3 files changed, 41 insertions(+), 22 deletions(-) diff --git a/services/playback/playback_service.py b/services/playback/playback_service.py index 73d0ed5d..d4e2374c 100644 --- a/services/playback/playback_service.py +++ b/services/playback/playback_service.py @@ -783,7 +783,10 @@ def play_cloud_playlist( # Batch-load all tracks by cloud file IDs cloud_file_ids = [cf.file_id for cf in cloud_files] - if hasattr(self._track_repo, "get_by_non_online_cloud_file_ids"): + has_non_online_batch_lookup = callable( + getattr(type(self._track_repo), "get_by_non_online_cloud_file_ids", None) + ) + if has_non_online_batch_lookup: tracks_by_cloud_id = self._track_repo.get_by_non_online_cloud_file_ids(cloud_file_ids) else: tracks_by_cloud_id = self._track_repo.get_by_cloud_file_ids(cloud_file_ids) @@ -1213,9 +1216,16 @@ def _enrich_queue_items_metadata_batch(self, items: List[PlaylistItem]) -> List[ id_map = {track.id: track for track in self._track_repo.get_by_ids(track_ids)} if track_ids else {} + has_non_online_batch_lookup = callable( + getattr(type(self._track_repo), "get_by_non_online_cloud_file_ids", None) + ) + has_online_batch_lookup = callable( + getattr(type(self._track_repo), "get_by_online_track_keys", None) + ) + cloud_map = {} if cloud_file_ids: - if hasattr(self._track_repo, "get_by_non_online_cloud_file_ids"): + if has_non_online_batch_lookup: cloud_tracks = self._track_repo.get_by_non_online_cloud_file_ids(cloud_file_ids) or {} else: cloud_tracks = self._track_repo.get_by_cloud_file_ids(cloud_file_ids) or {} @@ -1230,7 +1240,7 @@ def _enrich_queue_items_metadata_batch(self, items: List[PlaylistItem]) -> List[ online_map = {} if online_keys: - if hasattr(self._track_repo, "get_by_online_track_keys"): + if has_online_batch_lookup: online_tracks = self._track_repo.get_by_online_track_keys(online_keys) or {} if isinstance(online_tracks, dict): online_map = online_tracks diff --git a/services/playback/queue_service.py b/services/playback/queue_service.py index 58243c60..6ba05cb4 100644 --- a/services/playback/queue_service.py +++ b/services/playback/queue_service.py @@ -188,11 +188,17 @@ def _enrich_metadata_batch(self, items: List[PlaylistItem]) -> List[PlaylistItem # Batch fetch id_map = {t.id: t for t in self._track_repo.get_by_ids(track_ids)} if track_ids else {} - if cloud_file_ids and hasattr(self._track_repo, "get_by_non_online_cloud_file_ids"): + has_non_online_batch_lookup = callable( + getattr(type(self._track_repo), "get_by_non_online_cloud_file_ids", None) + ) + has_online_batch_lookup = callable( + getattr(type(self._track_repo), "get_by_online_track_keys", None) + ) + if cloud_file_ids and has_non_online_batch_lookup: cloud_map = self._track_repo.get_by_non_online_cloud_file_ids(cloud_file_ids) else: cloud_map = self._track_repo.get_by_cloud_file_ids(cloud_file_ids) if cloud_file_ids else {} - if online_keys and hasattr(self._track_repo, "get_by_online_track_keys"): + if online_keys and has_online_batch_lookup: online_map = self._track_repo.get_by_online_track_keys(online_keys) else: legacy_online = self._track_repo.get_by_cloud_file_ids( diff --git a/tests/test_services/test_playback_service_library_sync.py b/tests/test_services/test_playback_service_library_sync.py index 3fd79b1c..713b6c8f 100644 --- a/tests/test_services/test_playback_service_library_sync.py +++ b/tests/test_services/test_playback_service_library_sync.py @@ -88,23 +88,26 @@ def play_at(self, index): duration=123.0, cover_path="/tmp/cloud.jpg", ) - track_repo = Mock() - track_repo.get_by_cloud_file_ids.return_value = { - "shared-id": SimpleNamespace( - id=99, - path="online://qqmusic/track/shared-id", - title="Wrong Online Song", - artist="Wrong Artist", - album="Wrong Album", - duration=300.0, - cover_path="wrong.jpg", - cloud_file_id="shared-id", - online_provider_id="qqmusic", - ) - } - track_repo.get_by_non_online_cloud_file_ids.return_value = { - "shared-id": cached_cloud_track - } + class _TrackRepo: + def get_by_cloud_file_ids(self, cloud_file_ids): + return { + "shared-id": SimpleNamespace( + id=99, + path="online://qqmusic/track/shared-id", + title="Wrong Online Song", + artist="Wrong Artist", + album="Wrong Album", + duration=300.0, + cover_path="wrong.jpg", + cloud_file_id="shared-id", + online_provider_id="qqmusic", + ) + } + + def get_by_non_online_cloud_file_ids(self, cloud_file_ids): + return {"shared-id": cached_cloud_track} + + track_repo = _TrackRepo() service = PlaybackService.__new__(PlaybackService) service._track_repo = track_repo