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/app/application.py b/app/application.py
index 202452a8..37e5f294 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:
@@ -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/app/bootstrap.py b/app/bootstrap.py
index 57128ed0..6676f490 100644
--- a/app/bootstrap.py
+++ b/app/bootstrap.py
@@ -2,8 +2,12 @@
Bootstrap - Dependency injection container.
"""
+import importlib
import logging
+import subprocess
+import sys
import threading
+from pathlib import Path
from typing import TYPE_CHECKING, Optional
from infrastructure import HttpClient
@@ -28,11 +32,13 @@
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
- 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
@@ -40,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.
@@ -85,16 +166,15 @@ 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
-
- # QQ Music client
- self._qqmusic_client: Optional["QQMusicClient"] = 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
@classmethod
def instance(cls, db_path: str = "Harmony.db") -> "Bootstrap":
@@ -341,75 +421,43 @@ def file_org_service(self) -> FileOrganizationService:
)
return self._file_org_service
- # ===== 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
+ 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"),
+ state_store=PluginStateStore(Path("data/plugins/state.json")),
+ context_factory=BootstrapPluginContextFactory(
+ self,
+ storage_root=Path("data/plugins/storage"),
+ ),
+ )
+ 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_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
- 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
- )
- 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,
- qqmusic_service=None,
- online_music_service=self.online_music_service
+ plugin_manager=lambda: self._plugin_manager,
+ event_bus=self.event_bus,
)
return self._online_download_service
@@ -417,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,
@@ -441,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/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/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/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/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-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/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/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-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/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/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/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.
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 "验证统一基础主题样式改造"
+```
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/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音乐插件结构"
+```
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.
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 页面功能”:
+
+- 搜索四类结果具备旧页同等级的主要视图结构
+- 详情页支持批量播放/入队主路径
+- 首页收藏/推荐入口恢复为卡片化且可正确分流
+- 榜单支持双视图和主要批量动作
+- 搜索体验至少具备热词/历史/补全的旧页主路径
+- 整体实现保持插件边界,不回退插件系统设计
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/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/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.
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-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.
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
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..d5a214c5
--- /dev/null
+++ b/docs/superpowers/specs/2026-04-07-unified-widget-theme-styles-design.md
@@ -0,0 +1,236 @@
+# Unified Foundation Theme Styles Design
+
+## Overview
+
+This change consolidates the styling of common Qt foundation widgets and project-wide shared wrapper components under the host theme system.
+
+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.
+
+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 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 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 highly bespoke business widgets whose visuals are their primary value.
+- 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.
+
+## 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`, `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
+
+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 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
+- 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 foundation styles.
+
+### 1. Expand the global theme stylesheet
+
+`ui/styles.qss` becomes the base stylesheet source for foundation widget classes and theme-owned variants.
+
+It should define:
+
+- 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:
+
+- `QLineEdit[variant="search"]`
+- `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 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.
+
+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 shared title bar 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-foundation business widgets or purely content-driven decoration.
+
+### Not Allowed
+
+- 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 foundation 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 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:
+
+- 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
+- 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.
+
+## 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 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 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
+
+- 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 foundation-layer theme cleanup. It is larger than a one-file tweak, but still bounded:
+
+- theme system
+- 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, 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/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.
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.
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
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 11366585..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
@@ -28,6 +29,13 @@ def display_name(self) -> str:
@property
def id(self) -> str:
"""Generate a unique ID for the genre based on name."""
+ if self.name:
+ 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):
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..dc509902 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
@@ -170,14 +174,15 @@ 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"),
local_path=data.get("path", "") or data.get("local_path", ""),
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,
@@ -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/audio/audio_engine.py b/infrastructure/audio/audio_engine.py
index 3cef8488..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:
@@ -652,7 +661,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()
@@ -746,27 +756,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)
+
+ 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
- # 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 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
@@ -776,7 +796,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
@@ -794,7 +814,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."""
@@ -836,12 +856,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):
@@ -1195,6 +1218,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/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/infrastructure/cache/image_cache.py b/infrastructure/cache/image_cache.py
index 642805d1..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'}
@@ -59,8 +60,11 @@ 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)
+ cls._enforce_cache_limit()
return str(cache_path)
except Exception as e:
@@ -90,7 +94,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()
@@ -107,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/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/infrastructure/database/sqlite_manager.py b/infrastructure/database/sqlite_manager.py
index f3f2be52..ce59639d 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:
@@ -131,6 +135,8 @@ def _init_database(self):
TEXT
DEFAULT
'Local',
+ online_provider_id
+ TEXT,
created_at
TIMESTAMP
DEFAULT
@@ -253,6 +259,8 @@ def _init_database(self):
INTEGER,
cloud_file_id
TEXT,
+ online_provider_id
+ TEXT,
cloud_account_id
INTEGER,
created_at
@@ -278,10 +286,6 @@ def _init_database(self):
UNIQUE
(
track_id
- ),
- UNIQUE
- (
- cloud_file_id
)
)
""")
@@ -549,6 +553,8 @@ def _init_database(self):
INTEGER,
cloud_file_id
TEXT,
+ online_provider_id
+ TEXT,
cloud_account_id
INTEGER,
local_path
@@ -722,16 +728,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 = 12
# Create db_meta table for schema version tracking
cursor.execute("""
@@ -765,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)")
@@ -788,6 +792,8 @@ def _run_migrations(self, conn, cursor):
INTEGER,
cloud_file_id
TEXT,
+ online_provider_id
+ TEXT,
cloud_account_id
INTEGER,
created_at
@@ -813,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")
@@ -859,7 +863,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 +874,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 +901,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'")
@@ -1042,11 +1056,97 @@ 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")
+ # 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")
+
+ # 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(
@@ -1957,15 +2057,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/infrastructure/network/http_client.py b/infrastructure/network/http_client.py
index 932fb65a..93681a66 100644
--- a/infrastructure/network/http_client.py
+++ b/infrastructure/network/http_client.py
@@ -2,14 +2,17 @@
HTTP client wrapper for network requests.
"""
+import atexit
from contextlib import contextmanager
import logging
from pathlib import Path
import threading
+import time
from typing import Dict, Any, Optional, Iterator
import requests
from requests.adapters import HTTPAdapter
+from urllib3.util.retry import Retry
logger = logging.getLogger(__name__)
@@ -20,11 +23,16 @@ 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'
}
_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,
@@ -60,10 +68,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)
@@ -89,6 +106,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 +121,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 +143,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 +151,7 @@ def request(
headers=headers,
timeout=request_timeout,
stream=stream,
+ verify=verify,
**request_kwargs,
)
if method == "POST":
@@ -133,6 +162,7 @@ def request(
headers=headers,
timeout=request_timeout,
stream=stream,
+ verify=verify,
**request_kwargs,
)
return self._session.request(
@@ -144,6 +174,7 @@ def request(
headers=headers,
timeout=request_timeout,
stream=stream,
+ verify=verify,
**request_kwargs,
)
@@ -255,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):
@@ -262,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/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/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/packages/harmony-plugin-api/src/harmony_plugin_api/__init__.py b/packages/harmony-plugin-api/src/harmony_plugin_api/__init__.py
new file mode 100644
index 00000000..ea5448c4
--- /dev/null
+++ b/packages/harmony-plugin-api/src/harmony_plugin_api/__init__.py
@@ -0,0 +1,50 @@
+from .context import (
+ PluginContext,
+ PluginDialogBridge,
+ PluginMediaBridge,
+ PluginRuntimeBridge,
+ 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
+
+__all__ = [
+ "Capability",
+ "HarmonyPlugin",
+ "PluginArtistCoverResult",
+ "PluginArtistCoverSource",
+ "PluginContext",
+ "PluginCoverResult",
+ "PluginCoverSource",
+ "PluginDialogBridge",
+ "PluginLyricsResult",
+ "PluginLyricsSource",
+ "PluginManifest",
+ "PluginManifestError",
+ "PluginMediaBridge",
+ "PluginOnlineProvider",
+ "PluginPlaybackRequest",
+ "PluginRuntimeBridge",
+ "PluginServiceBridge",
+ "PluginSettingsBridge",
+ "PluginStorageBridge",
+ "PluginThemeBridge",
+ "PluginTrack",
+ "PluginUiBridge",
+ "SettingsTabSpec",
+ "SidebarEntrySpec",
+]
diff --git a/packages/harmony-plugin-api/src/harmony_plugin_api/context.py b/packages/harmony-plugin-api/src/harmony_plugin_api/context.py
new file mode 100644
index 00000000..1140c3e4
--- /dev/null
+++ b/packages/harmony-plugin-api/src/harmony_plugin_api/context.py
@@ -0,0 +1,186 @@
+from __future__ import annotations
+
+from dataclasses import dataclass
+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):
+ 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 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:
+ ...
+
+ def register_settings_tab(self, spec: SettingsTabSpec) -> None:
+ ...
+
+ @property
+ def theme(self) -> PluginThemeBridge:
+ ...
+
+ @property
+ def dialogs(self) -> PluginDialogBridge:
+ ...
+
+
+class PluginMediaBridge(Protocol):
+ 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:
+ ...
+
+
+class PluginServiceBridge(Protocol):
+ def register_lyrics_source(self, source: PluginLyricsSource) -> None:
+ ...
+
+ def register_cover_source(self, source: PluginCoverSource) -> None:
+ ...
+
+ def register_artist_cover_source(self, source: PluginArtistCoverSource) -> None:
+ ...
+
+ def register_online_music_provider(self, provider: PluginOnlineProvider) -> None:
+ ...
+
+ @property
+ 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, provider_id: str | None = None) -> 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
+ manifest: PluginManifest
+ logger: Any
+ http: Any
+ events: Any
+ language: str
+ storage: PluginStorageBridge
+ settings: PluginSettingsBridge
+ ui: PluginUiBridge
+ services: PluginServiceBridge
+ runtime: PluginRuntimeBridge
diff --git a/packages/harmony-plugin-api/src/harmony_plugin_api/cover.py b/packages/harmony-plugin-api/src/harmony_plugin_api/cover.py
new file mode 100644
index 00000000..ede4be30
--- /dev/null
+++ b/packages/harmony-plugin-api/src/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/packages/harmony-plugin-api/src/harmony_plugin_api/lyrics.py b/packages/harmony-plugin-api/src/harmony_plugin_api/lyrics.py
new file mode 100644
index 00000000..9fe8742a
--- /dev/null
+++ b/packages/harmony-plugin-api/src/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/packages/harmony-plugin-api/src/harmony_plugin_api/manifest.py b/packages/harmony-plugin-api/src/harmony_plugin_api/manifest.py
new file mode 100644
index 00000000..9ee9f39a
--- /dev/null
+++ b/packages/harmony-plugin-api/src/harmony_plugin_api/manifest.py
@@ -0,0 +1,99 @@
+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
+
+
+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
+
+
+@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_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=_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=_require_str(data, "min_app_version"),
+ max_app_version=max_app_version,
+ )
diff --git a/packages/harmony-plugin-api/src/harmony_plugin_api/media.py b/packages/harmony-plugin-api/src/harmony_plugin_api/media.py
new file mode 100644
index 00000000..bb88bac4
--- /dev/null
+++ b/packages/harmony-plugin-api/src/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/packages/harmony-plugin-api/src/harmony_plugin_api/online.py b/packages/harmony-plugin-api/src/harmony_plugin_api/online.py
new file mode 100644
index 00000000..e2d62df9
--- /dev/null
+++ b/packages/harmony-plugin-api/src/harmony_plugin_api/online.py
@@ -0,0 +1,44 @@
+from __future__ import annotations
+
+from typing import Any, Protocol
+
+from .media import PluginPlaybackRequest, PluginTrack
+
+__all__ = ["PluginOnlineProvider", "PluginPlaybackRequest", "PluginTrack"]
+
+
+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:
+ ...
+
+ 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/packages/harmony-plugin-api/src/harmony_plugin_api/plugin.py b/packages/harmony-plugin-api/src/harmony_plugin_api/plugin.py
new file mode 100644
index 00000000..315e4531
--- /dev/null
+++ b/packages/harmony-plugin-api/src/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/packages/harmony-plugin-api/src/harmony_plugin_api/registry_types.py b/packages/harmony-plugin-api/src/harmony_plugin_api/registry_types.py
new file mode 100644
index 00000000..25b0608d
--- /dev/null
+++ b/packages/harmony-plugin-api/src/harmony_plugin_api/registry_types.py
@@ -0,0 +1,26 @@
+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]
+ icon_path: str | None = None
+ title_provider: Callable[[], str] | None = None
+
+
+@dataclass(frozen=True)
+class SettingsTabSpec:
+ plugin_id: str
+ tab_id: str
+ title: str
+ order: int
+ widget_factory: Callable[[Any, Any], Any]
+ title_provider: Callable[[], str] | None = None
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/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..d4c476d7
--- /dev/null
+++ b/plugins/builtin/itunes_cover/lib/cover_source.py
@@ -0,0 +1,85 @@
+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,
+ }
+ 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:
+ 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/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..adbe4e47
--- /dev/null
+++ b/plugins/builtin/kugou/lib/lyrics_source.py
@@ -0,0 +1,68 @@
+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()
+ logger.debug(f"Kugou lyrics search: {keyword}")
+ 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,
+ )
+ 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:
+ 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/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..05bbd6ab
--- /dev/null
+++ b/plugins/builtin/last_fm_cover/lib/cover_source.py
@@ -0,0 +1,82 @@
+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] = []
+ logger.debug(f"Last.fm cover search: {title} {artist} {album} {duration}")
+
+ 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/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..1e700476
--- /dev/null
+++ b/plugins/builtin/lrclib/lib/lrclib_source.py
@@ -0,0 +1,48 @@
+from __future__ import annotations
+
+import logging
+
+from harmony_plugin_api.lyrics import PluginLyricsResult
+
+logger = logging.getLogger(__name__)
+
+
+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]:
+ 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},
+ headers={"User-Agent": "Mozilla/5.0"},
+ 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", "")),
+ 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/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..80c0fc3b
--- /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_cover.lib.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/common.py b/plugins/builtin/netease_cover/lib/common.py
new file mode 100644
index 00000000..53fa9d60
--- /dev/null
+++ b/plugins/builtin/netease_cover/lib/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/netease_cover/lib/cover_source.py b/plugins/builtin/netease_cover/lib/cover_source.py
new file mode 100644
index 00000000..2f1e80d3
--- /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_cover.lib.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/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
new file mode 100644
index 00000000..c8c51b04
--- /dev/null
+++ b/plugins/builtin/netease_lyrics/lib/lyrics_source.py
@@ -0,0 +1,94 @@
+from __future__ import annotations
+
+import logging
+
+from harmony_plugin_api.lyrics import PluginLyricsResult
+from plugins.builtin.netease_lyrics.lib.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"
+ artists = song.get("artists") or []
+ artist_name = artists[0].get("name", "") if artists else ""
+
+ results.append(
+ PluginLyricsResult(
+ song_id=str(song.get("id", "")),
+ title=song.get("name", ""),
+ artist=artist_name,
+ 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/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/adapter.py b/plugins/builtin/qqmusic/lib/adapter.py
new file mode 100644
index 00000000..88180558
--- /dev/null
+++ b/plugins/builtin/qqmusic/lib/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/plugins/builtin/qqmusic/lib/api.py b/plugins/builtin/qqmusic/lib/api.py
new file mode 100644
index 00000000..460e71d0
--- /dev/null
+++ b/plugins/builtin/qqmusic/lib/api.py
@@ -0,0 +1,308 @@
+from __future__ import annotations
+
+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:
+ REMOTE_BASE_URL = "https://api.ygking.top/api"
+
+ def __init__(self, context):
+ self._context = context
+
+ def search(
+ 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",
+ params={"keyword": keyword, "type": search_type, "num": limit, "page": page},
+ timeout=10,
+ )
+ data = response.json()
+ 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": [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,
+ }
+
+ @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,
+ 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()
+ 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 get_top_list_tracks(self, top_id: int | str, limit: int = 100) -> list[dict]:
+ response = self._context.http.get(
+ f"{self.REMOTE_BASE_URL}/top",
+ params={"id": top_id, "num": limit},
+ timeout=20,
+ )
+ data = response.json()
+ if data.get("code") != 0:
+ return []
+ items = data.get("data", {}).get("songInfoList", [])
+ if not items:
+ items = data.get("data", {}).get("data", {}).get("song", [])
+ return [normalize_song_item(song) for song in items[:limit]]
+
+ 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 build_album_cover_url(album_mid, size)
+ 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 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(
+ 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:
+ 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 = [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", ""),
+ "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 = [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", "")),
+ "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
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..71bb1dbe
--- /dev/null
+++ b/plugins/builtin/qqmusic/lib/artist_cover_source.py
@@ -0,0 +1,64 @@
+from __future__ import annotations
+
+import re
+
+from harmony_plugin_api.cover import PluginArtistCoverResult
+
+from .api import QQMusicPluginAPI
+
+
+class QQMusicArtistCoverPluginSource:
+ source = "qqmusic"
+ source_id = "qqmusic-artist-cover"
+ display_name = "QQMusic Artist"
+ name = "QQMusic"
+
+ 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)
+ 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:
+ artists = self._api.search_artist(artist_name, limit)
+ results = []
+ for artist in artists:
+ 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 self._api.get_artist_cover_url(singer_mid)
+ ),
+ album_count=album_count,
+ )
+ )
+ return results
+ except Exception:
+ return []
+
+ 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/client.py b/plugins/builtin/qqmusic/lib/client.py
new file mode 100644
index 00000000..bfe44043
--- /dev/null
+++ b/plugins/builtin/qqmusic/lib/client.py
@@ -0,0 +1,428 @@
+from __future__ import annotations
+
+import logging
+import socket
+from typing import Any
+
+from .api import QQMusicPluginAPI
+from .qqmusic_service import QQMusicService
+from .search_normalizers import (
+ normalize_album_item,
+ normalize_artist_item,
+ normalize_detail_song,
+ normalize_song_item,
+ normalize_playlist_item,
+ normalize_top_list_track,
+)
+from .section_builders import build_section
+
+logger = logging.getLogger(__name__)
+
+
+class QQMusicPluginClient:
+ def __init__(self, context):
+ self._context = context
+ self._api = QQMusicPluginAPI(context)
+ 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, http_client=self._context.http)
+
+ 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._context.settings.set("credential", credential)
+
+ def clear_credential(self) -> None:
+ self._context.settings.set("credential", None)
+
+ 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 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,
+ 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 = self._extract_legacy_search_root(raw_data)
+ meta = raw_data.get("meta", {}) if isinstance(raw_data.get("meta"), dict) else {}
+ if search_type == "song":
+ 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": [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 = self._extract_total(meta) or self._extract_total(singer_section) or len(items)
+ return {
+ "artists": [
+ {
+ **normalize_artist_item(item),
+ "pic_url": item.get("pic") or item.get("pic_url") or "",
+ }
+ 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 = self._extract_total(meta) or self._extract_total(album_section) or len(items)
+ return {
+ "albums": [
+ {
+ **normalize_album_item(item),
+ "mid": str(item.get("albumMID", "") or item.get("mid", "")),
+ "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 = self._extract_total(meta) or self._extract_total(playlist_section) or len(items)
+ return {
+ "playlists": [
+ {
+ **normalize_playlist_item(item),
+ "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
+
+ @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()
+
+ 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 [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(
+ build_section(
+ card_id=card_id,
+ title=title,
+ entry_type=entry_type,
+ items=data,
+ )
+ )
+ 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(
+ build_section(
+ card_id=card_id,
+ title=title,
+ entry_type=entry_type,
+ items=data,
+ include_count=True,
+ )
+ )
+ 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
+ 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:
+ 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": [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": [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": [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)
diff --git a/services/cloud/qqmusic/common.py b/plugins/builtin/qqmusic/lib/common.py
similarity index 89%
rename from services/cloud/qqmusic/common.py
rename to plugins/builtin/qqmusic/lib/common.py
index e64f48d5..8f5184fb 100644
--- a/services/cloud/qqmusic/common.py
+++ b/plugins/builtin/qqmusic/lib/common.py
@@ -7,9 +7,6 @@
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."""
@@ -157,28 +154,6 @@ class APIConfig:
}
-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.
diff --git a/plugins/builtin/qqmusic/lib/config_adapter.py b/plugins/builtin/qqmusic/lib/config_adapter.py
new file mode 100644
index 00000000..d2e40f45
--- /dev/null
+++ b/plugins/builtin/qqmusic/lib/config_adapter.py
@@ -0,0 +1,46 @@
+from __future__ import annotations
+
+
+class QQMusicConfigAdapter:
+ 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/context_menus.py b/plugins/builtin/qqmusic/lib/context_menus.py
new file mode 100644
index 00000000..198f681c
--- /dev/null
+++ b/plugins/builtin/qqmusic/lib/context_menus.py
@@ -0,0 +1,65 @@
+"""
+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
+
+
+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)
+
+ 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..74d6837c
--- /dev/null
+++ b/plugins/builtin/qqmusic/lib/cover_hover_popup.py
@@ -0,0 +1,102 @@
+"""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, register_themed_widget
+
+
+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.setProperty("popupSurface", True)
+
+ 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)
+ register_themed_widget(self)
+
+ 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/cover_source.py b/plugins/builtin/qqmusic/lib/cover_source.py
new file mode 100644
index 00000000..9dbe76ea
--- /dev/null
+++ b/plugins/builtin/qqmusic/lib/cover_source.py
@@ -0,0 +1,81 @@
+from __future__ import annotations
+
+from harmony_plugin_api.cover import PluginCoverResult
+
+from .provider import QQMusicOnlineProvider
+
+
+class QQMusicCoverPluginSource:
+ source = "qqmusic"
+ source_id = "qqmusic-cover"
+ display_name = "QQMusic"
+ name = "QQMusic"
+
+ def __init__(self, context):
+ self._context = context
+ self._provider = QQMusicOnlineProvider(context)
+
+ def search(
+ self,
+ title: str,
+ artist: str,
+ album: str = "",
+ duration: float | None = None,
+ ) -> list[PluginCoverResult]:
+ try:
+ keyword = f"{artist} {title}" if artist else title
+ search_payload = self._provider.search(
+ keyword,
+ search_type="song",
+ page=1,
+ page_size=5,
+ )
+ songs = (
+ search_payload.get("tracks", [])
+ if isinstance(search_payload, dict)
+ else search_payload
+ )
+ results = []
+ for song in songs:
+ 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_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", "") or song.get("title", ""),
+ artist=artist_name,
+ album=album_name,
+ duration=song.get("duration") or song.get("interval"),
+ source="qqmusic",
+ cover_url=None,
+ extra_id=album_mid,
+ )
+ )
+ return results
+ except Exception:
+ return []
+
+ def is_available(self) -> bool:
+ return True
+
+ 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)
diff --git a/services/cloud/qqmusic/crypto.py b/plugins/builtin/qqmusic/lib/crypto.py
similarity index 100%
rename from services/cloud/qqmusic/crypto.py
rename to plugins/builtin/qqmusic/lib/crypto.py
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..157ef81a
--- /dev/null
+++ b/plugins/builtin/qqmusic/lib/dialog_title_bar.py
@@ -0,0 +1,98 @@
+"""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, register_themed_widget
+
+
+@dataclass
+class DialogTitleBarController:
+ """Controller for dialog title bar widgets."""
+
+ dialog: QDialog
+ title_bar: QWidget
+ title_label: QLabel
+ close_btn: QPushButton
+
+ def refresh_theme(self):
+ """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(
+ 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()
+ register_themed_widget(title_bar)
+
+ _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/ui/dialogs/qqmusic_qr_login_dialog.py b/plugins/builtin/qqmusic/lib/login_dialog.py
similarity index 71%
rename from ui/dialogs/qqmusic_qr_login_dialog.py
rename to plugins/builtin/qqmusic/lib/login_dialog.py
index 475f9a66..f1edd5d1 100644
--- a/ui/dialogs/qqmusic_qr_login_dialog.py
+++ b/plugins/builtin/qqmusic/lib/login_dialog.py
@@ -2,6 +2,8 @@
QQ Music QR code login dialog.
Uses local implementation without qqmusic_api dependency.
"""
+from __future__ import annotations
+
import logging
import time
from io import BytesIO
@@ -15,13 +17,17 @@
QGraphicsDropShadowEffect,
)
-from services.cloud.qqmusic.qr_login import (
- QQMusicQRLogin, QRLoginType, QRCodeLoginEvents
+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 (
+ bind_context,
+ current_theme,
+ get_qss,
+ show_information,
+ register_themed_widget,
+ show_warning,
)
-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__)
@@ -37,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):
@@ -53,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"
@@ -147,7 +154,7 @@ def wait_for_stop(self, timeout_ms: int = 2000):
return self.wait(timeout_ms)
-class QQMusicQRLoginDialog(QDialog):
+class QQMusicLoginDialog(QDialog):
"""Dialog for QQ Music QR code login."""
# Signal emitted when credentials are successfully obtained
@@ -187,24 +194,24 @@ class QQMusicQRLoginDialog(QDialog):
QRadioButton::indicator:hover {
border: 2px solid %highlight%;
}
- QPushButton {
+ QPushButton#qqmusicRefreshBtn {
background-color: %border%;
color: %text%;
+ font-size: 13px;
border: 1px solid %background_hover%;
border-radius: 4px;
- padding: 8px 20px;
- font-size: 13px;
+ padding: 8px 16px;
}
- QPushButton:hover {
+ QPushButton#qqmusicRefreshBtn:hover {
background-color: %background_hover%;
border: 1px solid %highlight%;
}
- QPushButton:pressed {
+ QPushButton#qqmusicRefreshBtn:pressed {
background-color: %background_alt%;
}
- QPushButton:disabled {
+ QPushButton#qqmusicRefreshBtn:disabled {
background-color: %background_alt%;
- color: %border%;
+ color: %text_secondary%;
}
QProgressBar {
border: none;
@@ -216,10 +223,49 @@ class QQMusicQRLoginDialog(QDialog):
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, parent=None):
+ 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"))
@@ -229,18 +275,19 @@ def __init__(self, parent=None):
self.setWindowFlags(Qt.WindowType.Dialog | Qt.FramelessWindowHint)
self.setAttribute(Qt.WidgetAttribute.WA_TranslucentBackground)
+ self.setProperty("shell", True)
self._setup_shadow()
- from app.bootstrap import Bootstrap
- self.config = Bootstrap.instance().config
-
self._login_thread: Optional[QRLoginThread] = None
+ self._retired_login_threads: list[QRLoginThread] = []
self._login_type = 'wx' # default to WeChat
+ self._language_connected = False
self._setup_ui()
+ self._connect_language_events()
self._start_login()
- ThemeManager.instance().register_widget(self)
+ register_themed_widget(self)
def _setup_shadow(self):
"""Setup drop shadow effect."""
@@ -252,7 +299,7 @@ def _setup_shadow(self):
def _setup_ui(self):
"""Setup the UI."""
- self.setStyleSheet(ThemeManager.instance().get_qss(self._STYLE_TEMPLATE))
+ self.setStyleSheet(get_qss(self._STYLE_TEMPLATE))
# Outer layout with 0 margins
outer = QVBoxLayout(self)
@@ -264,17 +311,17 @@ def _setup_ui(self):
outer.addWidget(container)
container_layout = QVBoxLayout(container)
- layout, self._title_bar_controller = setup_equalizer_title_layout(
+ layout, self._title_bar_controller = setup_dialog_title_layout(
self,
container_layout,
t("qqmusic_login_title"),
- content_spacing=15,
+ content_spacing=2,
)
# Login type selection
type_layout = QHBoxLayout()
type_label = QLabel(t("qqmusic_login_method"))
- theme = ThemeManager.instance().current_theme
+ 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"))
@@ -335,18 +382,42 @@ def _setup_ui(self):
button_layout = QHBoxLayout()
self._refresh_button = QPushButton(t("qqmusic_refresh_qr"))
+ 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.setProperty("role", "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 _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():
@@ -368,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()
@@ -387,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)
- 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 = QRLoginThread(self._login_type, http_client=self._context.http)
+ 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."""
@@ -416,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):
@@ -443,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)
@@ -483,24 +601,24 @@ def _on_login_success(self, credential: dict):
try:
# Save credentials (full credential dict)
- self.config.set_qqmusic_credential(credential)
+ self._context.settings.set("credential", credential)
# 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'):
- 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}")
-
- # Refresh QQ Music client to use new credentials
- from app.bootstrap import Bootstrap
- Bootstrap.instance().refresh_qqmusic_client()
-
- MessageDialog.information(
+ nick = credential.get("nick") or credential.get("nickname") or ""
+ if not nick:
+ try:
+ 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 "")
+ 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")
@@ -511,7 +629,7 @@ def _on_login_success(self, credential: dict):
except Exception as e:
logger.error(f"Failed to save credentials: {e}")
- MessageDialog.warning(
+ show_warning(
self,
t("error"),
f"{t('error')}:\n{str(e)}"
@@ -522,14 +640,14 @@ 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)
+ 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"))
- MessageDialog.information(self, t("cancel"), t("qqmusic_you_cancelled"))
+ show_information(self, t("cancel"), t("qqmusic_you_cancelled"))
@Slot()
def _on_login_timeout(self):
@@ -537,7 +655,7 @@ def _on_login_timeout(self):
self._progress_bar.hide()
self._status_label.setText(t("qqmusic_qr_expired"))
self._refresh_button.setEnabled(True)
- MessageDialog.information(
+ show_information(
self,
t("qqmusic_qr_expired"),
t("qqmusic_qr_timeout_refresh")
@@ -550,9 +668,9 @@ def _on_status_update(self, status: str):
def refresh_theme(self):
"""Refresh theme when changed."""
- self.setStyleSheet(ThemeManager.instance().get_qss(self._STYLE_TEMPLATE))
+ self.setStyleSheet(get_qss(self._STYLE_TEMPLATE))
self._title_bar_controller.refresh_theme()
- theme = ThemeManager.instance().current_theme
+ theme = current_theme()
if self._status_label:
self._status_label.setStyleSheet(
f"font-size: 14px; color: {theme.highlight}; padding: 8px; font-weight: bold;")
diff --git a/plugins/builtin/qqmusic/lib/lyrics_source.py b/plugins/builtin/qqmusic/lib/lyrics_source.py
new file mode 100644
index 00000000..91c3ab68
--- /dev/null
+++ b/plugins/builtin/qqmusic/lib/lyrics_source.py
@@ -0,0 +1,58 @@
+from __future__ import annotations
+
+from harmony_plugin_api.lyrics import PluginLyricsResult
+
+from .provider import QQMusicOnlineProvider
+
+
+class QQMusicLyricsPluginSource:
+ source_id = "qqmusic"
+ display_name = "QQMusic"
+ name = "QQMusic"
+
+ 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=None,
+ )
+ 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
+
+ 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/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/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/ui/views/online_detail_view.py b/plugins/builtin/qqmusic/lib/online_detail_view.py
similarity index 93%
rename from ui/views/online_detail_view.py
rename to plugins/builtin/qqmusic/lib/online_detail_view.py
index 97a9a478..f4a8b3fa 100644
--- a/ui/views/online_detail_view.py
+++ b/plugins/builtin/qqmusic/lib/online_detail_view.py
@@ -24,12 +24,22 @@
)
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
+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__)
@@ -39,7 +49,7 @@ class DetailWorker(QThread):
detail_loaded = Signal(str, object, int) # (type, data, request_id)
- def __init__(self, service: OnlineMusicService, detail_type: str, mid: str,
+ 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
@@ -70,7 +80,7 @@ class AlbumListWorker(QThread):
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,
+ def __init__(self, service: Any, singer_mid: str, number: int = 10, begin: int = 0,
request_id: int = 0):
super().__init__()
self._service = service
@@ -102,18 +112,17 @@ def __init__(self, url: str, size: int):
def run(self):
try:
- from infrastructure.cache import ImageCache
import requests
# Check disk cache first
- image_data = ImageCache.get(self._url)
+ 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
- ImageCache.set(self._url, image_data)
+ image_cache_set(self._url, image_data)
pixmap = QPixmap()
if pixmap.loadFromData(image_data):
@@ -135,7 +144,7 @@ class AllTracksWorker(QThread):
all_tracks_loaded = Signal(list) # List of OnlineTrack
- def __init__(self, service: OnlineMusicService, detail_type: str, mid: str,
+ def __init__(self, service: Any, detail_type: str, mid: str,
total_songs: int, page_size: int = 30):
super().__init__()
self._service = service
@@ -247,13 +256,10 @@ def __init__(self, album_data: Dict[str, Any], parent=None):
QTimer.singleShot(10, self._load_cover)
# Register with theme system
- from system.theme import ThemeManager
- ThemeManager.instance().register_widget(self)
+ register_themed_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)
@@ -267,7 +273,7 @@ def _setup_ui(self):
self._cover_container.setFixedSize(self.COVER_SIZE, self.COVER_SIZE)
# Pre-computed stylesheets for hover (H-08 optimization)
- theme = ThemeManager.instance().current_theme
+ 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}; }}"
@@ -287,7 +293,7 @@ def _setup_ui(self):
# 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("""
+ self._name_label.setStyleSheet(get_qss("""
QLabel {
color: %text%;
font-size: 12px;
@@ -326,15 +332,14 @@ def _on_cover_loaded(self, pixmap: QPixmap):
def _set_default_cover(self):
"""Set default cover when no cover is available."""
- from system.theme import ThemeManager
- tm = ThemeManager.instance()
+ theme = current_theme()
pixmap = QPixmap(self.COVER_SIZE, self.COVER_SIZE)
- pixmap.fill(QColor(tm.current_theme.border))
+ pixmap.fill(QColor(theme.border))
painter = QPainter(pixmap)
painter.setRenderHint(QPainter.Antialiasing)
- painter.setPen(QColor(tm.current_theme.text_secondary))
+ painter.setPen(QColor(theme.text_secondary))
font = QFont()
font.setPixelSize(48)
painter.setFont(font)
@@ -370,9 +375,7 @@ def get_album(self) -> OnlineAlbum:
def refresh_theme(self):
"""Refresh all styles using current theme tokens."""
- from system.theme import ThemeManager
-
- theme = ThemeManager.instance().current_theme
+ theme = current_theme()
radius = self.BORDER_RADIUS
# Update pre-computed stylesheets
@@ -386,7 +389,7 @@ def refresh_theme(self):
self._cover_container.setStyleSheet(self._style_normal)
# Update text labels
- self._name_label.setStyleSheet(ThemeManager.instance().get_qss("""
+ self._name_label.setStyleSheet(get_qss("""
QLabel {
color: %text%;
font-size: 12px;
@@ -402,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)
@@ -548,7 +553,6 @@ class OnlineDetailView(QWidget):
border-bottom: 2px solid %highlight%;
font-weight: bold;
font-size: 12px;
- letter-spacing: 0.5px;
}
QTableWidget#detailSongsTable QTableCornerButton::section {
background-color: %background_hover%;
@@ -609,16 +613,16 @@ def __init__(
super().__init__(parent)
self._config = config_manager
- self._service = OnlineMusicService(
+ self._service = create_online_music_service(
config_manager=config_manager,
- qqmusic_service=qqmusic_service
+ credential_provider=qqmusic_service
)
- self._download_service = OnlineDownloadService(
+ self._download_service = create_online_download_service(
config_manager=config_manager,
- qqmusic_service=qqmusic_service,
+ credential_provider=qqmusic_service,
online_music_service=self._service
)
- self._event_bus = EventBus.instance()
+ self._event_bus = event_bus()
self._detail_type = "" # "artist", "album", "playlist"
self._mid = ""
@@ -645,8 +649,7 @@ def __init__(
self._setup_ui()
# Register with theme system
- from system.theme import ThemeManager
- ThemeManager.instance().register_widget(self)
+ register_themed_widget(self)
self.refresh_theme()
def _setup_ui(self):
@@ -733,7 +736,7 @@ def _create_info_section(self) -> QWidget:
# Follow button (for artist detail)
self._follow_btn = QPushButton(t("follow"))
self._follow_btn.setFixedHeight(28)
- self._follow_btn.setFixedWidth(80)
+ self._follow_btn.setFixedWidth(160)
self._follow_btn.setCursor(Qt.PointingHandCursor)
self._follow_btn.hide()
self._follow_btn.clicked.connect(self._on_follow_clicked)
@@ -744,7 +747,7 @@ def _create_info_section(self) -> QWidget:
# 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.setFixedWidth(160)
self._fav_btn.setCursor(Qt.PointingHandCursor)
self._fav_btn.hide()
self._fav_btn.clicked.connect(self._on_fav_clicked)
@@ -925,7 +928,7 @@ def _create_songs_section(self) -> QWidget:
section_layout.addWidget(self._songs_table, 1)
# Online tracks list view (for album)
- from ui.views.online_tracks_list_view import OnlineTracksListView
+ 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)
@@ -968,20 +971,18 @@ def _create_songs_table(self) -> QTableWidget:
def refresh_theme(self):
"""Refresh all styles using current theme tokens."""
- from system.theme import ThemeManager
- tm = ThemeManager.instance()
-
+ qss = get_qss
# Main button styles
- self.setStyleSheet(tm.get_qss(self._STYLE_BUTTONS))
+ self.setStyleSheet(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))
+ 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()
@@ -989,27 +990,27 @@ def refresh_theme(self):
self._update_fav_btn_style()
# Page label
- self._page_label.setStyleSheet(tm.get_qss(self._STYLE_PAGE_LABEL))
+ self._page_label.setStyleSheet(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))
+ 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(tm.get_qss(self._STYLE_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(tm.get_qss(self._STYLE_SONGS_TITLE))
+ self._songs_title_label.setStyleSheet(qss(self._STYLE_SONGS_TITLE))
# Songs table
- self._songs_table.setStyleSheet(tm.get_qss(self._STYLE_SONGS_TABLE))
+ self._songs_table.setStyleSheet(qss(self._STYLE_SONGS_TABLE))
# Refresh all album cards
for card in self._album_cards:
@@ -1094,7 +1095,7 @@ def load_playlist(self, playlist_id: str, title: str = "", creator: str = ""):
# Set placeholder info
self._type_label.setText(t("playlists"))
- self._name_label.setText(title)
+ self._name_label.setText(t(title))
self._secondary_label.setText(creator)
self._extra_label.setText("")
self._stats_label.setText("")
@@ -1126,7 +1127,7 @@ def load_songs_directly(self, songs: List[Dict], title: str = "", cover_url: str
# Set info
self._type_label.setText(t("playlists"))
- self._name_label.setText(title)
+ self._name_label.setText(t(title))
self._secondary_label.setText("")
self._extra_label.setText("")
self._stats_label.setText("")
@@ -1228,7 +1229,7 @@ def _on_desc_clicked(self, event):
"""Show full description in a dialog."""
if not self._full_description:
return
- MessageDialog.information(
+ show_information(
self.window(),
self._name_label.text() or t("view_details"),
self._full_description,
@@ -1334,7 +1335,6 @@ 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):
@@ -1348,12 +1348,12 @@ def __init__(self, url, request_id=0):
def run(self):
try:
# Check disk cache first
- image_data = ImageCache.get(self.url)
+ 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
- ImageCache.set(self.url, image_data)
+ image_cache_set(self.url, image_data)
pixmap = QPixmap()
if pixmap.loadFromData(image_data):
self.loaded.emit(pixmap, self._request_id)
@@ -1398,7 +1398,6 @@ 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)
@@ -1410,7 +1409,7 @@ def _show_cover_dialog_async(self, url: str):
# 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.setStyleSheet(f"background: {current_theme().background_alt};")
image_label.setText(t("loading"))
image_label.setMinimumSize(200, 200)
@@ -1429,15 +1428,14 @@ def __init__(self, url):
def run(self):
try:
- from infrastructure.cache import ImageCache
import requests
# Check disk cache first
- image_data = ImageCache.get(self.url)
+ 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
- ImageCache.set(self.url, image_data)
+ image_cache_set(self.url, image_data)
pixmap = QPixmap()
if pixmap.loadFromData(image_data):
self.loaded.emit(pixmap)
@@ -1584,7 +1582,7 @@ def _display_playlist_detail(self, data: Dict):
def _parse_songs(self, songs: List[Dict]) -> List[OnlineTrack]:
"""Parse songs from API response."""
- from domain.online_music import OnlineSinger, AlbumInfo
+ from .models import OnlineSinger, AlbumInfo
tracks = []
for song in songs:
@@ -1779,11 +1777,9 @@ def set_followed(self, is_followed: bool):
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("""
+ self._follow_btn.setStyleSheet(get_qss("""
QPushButton {
background: transparent;
color: %text_secondary%;
@@ -1798,7 +1794,7 @@ def _update_follow_btn_style(self):
"""))
else:
self._follow_btn.setText(t("follow"))
- self._follow_btn.setStyleSheet(tm.get_qss("""
+ self._follow_btn.setStyleSheet(get_qss("""
QPushButton {
background: %highlight%;
color: %background%;
@@ -1827,11 +1823,9 @@ def _on_follow_clicked(self):
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("""
+ self._fav_btn.setStyleSheet(get_qss("""
QPushButton {
background: transparent;
color: %text_secondary%;
@@ -1846,7 +1840,7 @@ def _update_fav_btn_style(self):
"""))
else:
self._fav_btn.setText(t("add_to_qq_favorites"))
- self._fav_btn.setStyleSheet(tm.get_qss("""
+ self._fav_btn.setStyleSheet(get_qss("""
QPushButton {
background: %highlight%;
color: %background%;
@@ -2005,8 +1999,7 @@ def _show_track_context_menu(self, pos):
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))
+ menu.setStyleSheet(get_qss(self._STYLE_MENU))
play_action = menu.addAction(t("play"))
insert_action = menu.addAction(t("insert_to_queue"))
@@ -2154,10 +2147,10 @@ def _add_track_to_favorites(self, track: OnlineTrack):
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
+ current_bootstrap = bootstrap()
+ if current_bootstrap is None:
+ return
+ favorites_service = current_bootstrap.favorites_service
added_count = 0
for track in tracks:
@@ -2168,7 +2161,7 @@ def _add_tracks_to_favorites(self, tracks: list):
if added_count > 0:
logger.info(f"[OnlineDetailView] Added {added_count} tracks to favorites")
- MessageDialog.information(
+ show_information(
self,
t("success"),
t("added_x_tracks_to_favorites").format(count=added_count)
@@ -2176,15 +2169,15 @@ def _add_tracks_to_favorites(self, tracks: list):
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)
+ 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:
- bootstrap.favorites_service.remove_favorite(track_id=library_track.id)
+ current_bootstrap.favorites_service.remove_favorite(track_id=library_track.id)
return
- bootstrap.favorites_service.remove_favorite(cloud_file_id=track.mid)
+ current_bootstrap.favorites_service.remove_favorite(cloud_file_id=track.mid)
def _add_track_to_playlist(self, track: OnlineTrack):
"""Add track to playlist."""
@@ -2192,11 +2185,6 @@ def _add_track_to_playlist(self, track: OnlineTrack):
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:
@@ -2207,24 +2195,18 @@ def _add_tracks_to_playlist(self, tracks: list):
if not track_ids:
return
- add_tracks_to_playlist(
- self,
- bootstrap.library_service,
- track_ids,
- "[OnlineDetailView]"
- )
+ 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."""
- from app.bootstrap import Bootstrap
-
- bootstrap = Bootstrap.instance()
- if not bootstrap.library_service:
+ current_bootstrap = bootstrap()
+ if current_bootstrap is None or not current_bootstrap.library_service:
return None
cover_url = self._get_cover_url(track)
- return bootstrap.library_service.add_online_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,
@@ -2248,6 +2230,8 @@ def refresh_ui(self):
# 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'):
@@ -2294,7 +2278,7 @@ class DownloadWorker(QThread):
download_finished = Signal(str, str) # (song_mid, local_path)
- def __init__(self, download_service: OnlineDownloadService, song_mid: str, song_title: str):
+ def __init__(self, download_service: Any, song_mid: str, song_title: str):
super().__init__()
self._download_service = download_service
self._song_mid = song_mid
diff --git a/ui/views/online_grid_view.py b/plugins/builtin/qqmusic/lib/online_grid_view.py
similarity index 88%
rename from ui/views/online_grid_view.py
rename to plugins/builtin/qqmusic/lib/online_grid_view.py
index 69322161..fc6071f2 100644
--- a/ui/views/online_grid_view.py
+++ b/plugins/builtin/qqmusic/lib/online_grid_view.py
@@ -5,7 +5,7 @@
import logging
from collections import OrderedDict
-from typing import List, Optional, Union
+from typing import List, Optional, Any
from PySide6.QtCore import (
Qt, Signal,
@@ -23,21 +23,25 @@
QPushButton,
)
-from domain.online_music import OnlineArtist, OnlineAlbum, OnlinePlaylist
-from system.i18n import t
+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__)
-# 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] = []
+ self._items: List[Any] = []
def rowCount(self, parent=QModelIndex()):
return len(self._items)
@@ -49,23 +53,23 @@ def data(self, index, role=Qt.DisplayRole):
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
+ 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[OnlineItem]):
+ def set_items(self, items: List[Any]):
self.beginResetModel()
self._items = items
self.endResetModel()
- def get_item(self, row: int) -> Optional[OnlineItem]:
+ def get_item(self, row: int) -> Optional[Any]:
if 0 <= row < len(self._items):
return self._items[row]
return None
@@ -111,9 +115,7 @@ def __init__(self, data_type: str, parent=None):
def _create_default_cover(self) -> QPixmap:
"""Create default cover pixmap."""
- from system.theme import ThemeManager
-
- theme = ThemeManager.instance().current_theme
+ theme = current_theme()
pixmap = QPixmap(self.COVER_SIZE, self.COVER_SIZE)
pixmap.fill(Qt.transparent)
@@ -146,15 +148,15 @@ def _create_default_cover(self) -> QPixmap:
painter.end()
return pixmap
- def _load_cover(self, item: OnlineItem) -> QPixmap:
+ def _load_cover(self, item) -> QPixmap:
"""Load cover from URL with caching."""
cover_url = None
- if isinstance(item, OnlineArtist):
+ if hasattr(item, 'avatar_url'):
+ # OnlineArtist
cover_url = item.avatar_url
- elif isinstance(item, OnlineAlbum):
- cover_url = item.cover_url
- elif isinstance(item, OnlinePlaylist):
+ elif hasattr(item, 'cover_url'):
+ # OnlineAlbum or OnlinePlaylist
cover_url = item.cover_url
if not cover_url:
@@ -174,21 +176,17 @@ def _load_cover(self, item: OnlineItem) -> QPixmap:
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)
+ cached_data = image_cache_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={
+ return http_get_content(url, timeout=5, headers={
'Referer': 'https://y.qq.com/'
})
except Exception as e:
@@ -208,7 +206,7 @@ def check_download():
image_data = future.result()
if image_data:
# Save to disk cache
- ImageCache.set(url, image_data)
+ image_cache_set(url, image_data)
self._load_cached_cover(url, image_data)
self._pending_downloads.discard(url)
else:
@@ -283,8 +281,7 @@ def paint(self, painter, option, index):
logger.warning(f"[OnlineItemDelegate] paint called but item is None, index={index.row()}")
return
- from system.theme import ThemeManager
- theme = ThemeManager.instance().current_theme
+ theme = current_theme()
rect = option.rect
is_hovered = option.state & QStyle.State_MouseOver
@@ -342,16 +339,20 @@ def paint(self, painter, option, index):
font.setBold(True)
painter.setFont(font)
- # Get name and alignment based on type
- if isinstance(item, OnlineArtist):
+ # 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 isinstance(item, OnlineAlbum):
- name = item.name
- name_align = Qt.AlignLeft | Qt.TextWordWrap
- elif isinstance(item, OnlinePlaylist):
+ 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
@@ -370,11 +371,11 @@ def paint(self, painter, option, index):
font.setPixelSize(11)
painter.setFont(font)
- if isinstance(item, OnlineArtist):
- from system.i18n import t
+ 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 item.fan_count:
+ 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:
@@ -382,11 +383,8 @@ def paint(self, painter, option, index):
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
+ elif hasattr(item, 'title'):
+ # OnlinePlaylist
play_str = self._format_play_count(item.play_count) if item.play_count else ""
parts = []
if item.song_count:
@@ -395,6 +393,10 @@ def paint(self, painter, option, index):
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
@@ -437,8 +439,8 @@ class OnlineGridView(QWidget):
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
+ item_clicked = Signal(object)
+ load_more_requested = Signal()
_STYLE_MAIN = """
background-color: %background%;
@@ -500,16 +502,15 @@ def __init__(self, data_type: str, parent=None):
"""
super().__init__(parent)
self._data_type = data_type
- self._items: List[OnlineItem] = []
+ self._items: List[Any] = []
self._data_loaded = False
- self._pending_data: Optional[List[OnlineItem]] = None
+ self._pending_data: Optional[List[Any]] = None
self._setup_ui()
self._connect_signals()
# Register with theme system
- from system.theme import ThemeManager
- ThemeManager.instance().register_widget(self)
+ register_themed_widget(self)
def showEvent(self, event):
"""Load data when view is first shown (lazy loading)."""
@@ -519,8 +520,7 @@ def showEvent(self, event):
def _setup_ui(self):
"""Set up the grid view UI."""
- from system.theme import ThemeManager
- theme = ThemeManager.instance().current_theme
+ theme = current_theme()
self.setStyleSheet(f"background-color: {theme.background};")
self.setMouseTracking(True)
@@ -602,13 +602,14 @@ def _on_load_more_clicked(self):
"""Handle load more button click."""
self.load_more_requested.emit()
- def load_data(self, items: List[OnlineItem]):
+ 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():
@@ -619,7 +620,7 @@ def load_data(self, items: List[OnlineItem]):
from PySide6.QtCore import QTimer
QTimer.singleShot(50, lambda: self._do_load(items))
- def _do_load(self, items: List[OnlineItem]):
+ def _do_load(self, items: List[Any]):
"""Actually load data into the view."""
self._items = items
self._data_loaded = True
@@ -627,7 +628,7 @@ def _do_load(self, items: List[OnlineItem]):
self._loading.hide()
self._list_view.show()
- def append_data(self, items: List[OnlineItem]):
+ def append_data(self, items: List[Any]):
"""
Append more items to existing data (for load more functionality).
@@ -684,27 +685,26 @@ def refresh_ui(self):
def refresh_theme(self):
"""Refresh all styles using current theme tokens."""
- from system.theme import ThemeManager
- tm = ThemeManager.instance()
+ theme = current_theme()
# Main widget
- self.setStyleSheet(tm.get_qss(self._STYLE_MAIN))
+ self.setStyleSheet(get_qss(self._STYLE_MAIN))
# List view
- self._list_view.setStyleSheet(tm.get_qss(self._STYLE_LIST_VIEW))
+ self._list_view.setStyleSheet(get_qss(self._STYLE_LIST_VIEW))
# Load more button
- self._load_more_btn.setStyleSheet(tm.get_qss(self._STYLE_LOAD_MORE_BTN))
+ 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(tm.get_qss(self._STYLE_PROGRESS_BAR))
+ progress.setStyleSheet(get_qss(self._STYLE_PROGRESS_BAR))
# Loading label
self._loading_label.setStyleSheet(
- f"color: {tm.current_theme.text_secondary}; font-size: 14px;"
+ f"color: {theme.text_secondary}; font-size: 14px;"
)
# Refresh delegate's default cover
diff --git a/ui/views/online_music_view.py b/plugins/builtin/qqmusic/lib/online_music_view.py
similarity index 90%
rename from ui/views/online_music_view.py
rename to plugins/builtin/qqmusic/lib/online_music_view.py
index 55d099b7..ec682619 100644
--- a/ui/views/online_music_view.py
+++ b/plugins/builtin/qqmusic/lib/online_music_view.py
@@ -1,5 +1,5 @@
"""
-Online music view for searching and browsing online music.
+Legacy online music view kept only as a compatibility layer during plugin migration.
"""
import logging
@@ -29,21 +29,40 @@
)
from shiboken6 import isValid
-from domain.online_music import (
+from .i18n import t
+from .i18n import get_language, set_language
+from .models import (
OnlineTrack, OnlineArtist, OnlineAlbum, OnlinePlaylist,
- SearchResult, SearchType
+ 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
+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,
+ bind_context,
+ bootstrap,
+ create_online_download_service,
+ create_online_music_service,
+ create_qqmusic_login_dialog,
+ create_qqmusic_service,
+ current_theme,
+ get_completer_popup_style,
+ 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):
@@ -79,8 +98,7 @@ def __init__(self, parent=None):
def _apply_theme(self):
"""Apply themed styles to popup."""
- from system.theme import ThemeManager
- self.popup().setStyleSheet(ThemeManager.instance().get_qss(self._STYLE_POPUP))
+ self.popup().setStyleSheet(get_completer_popup_style())
def refresh_theme(self):
"""Refresh popup styles."""
@@ -95,7 +113,7 @@ class SearchWorker(QThread):
search_completed = Signal(object) # SearchResult
search_failed = Signal(str)
- def __init__(self, service: OnlineMusicService, keyword: str,
+ def __init__(self, service: Any, keyword: str,
search_type: str, page: int = 1, page_size: int = 50):
super().__init__()
self._service = service
@@ -123,7 +141,7 @@ class TopListWorker(QThread):
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):
+ def __init__(self, service: Any, top_id: Optional[int] = None):
super().__init__()
self._service = service
self._top_id = top_id
@@ -350,8 +368,7 @@ def __init__(self, parent=None):
self._setup_ui()
# Register with theme system
- from system.theme import ThemeManager
- ThemeManager.instance().register_widget(self)
+ register_themed_widget(self)
def _setup_ui(self):
"""Setup UI components."""
@@ -371,11 +388,8 @@ def _setup_ui(self):
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))
+ self._container.setStyleSheet(get_qss(self._STYLE_CONTAINER))
def set_hotkeys(self, hotkeys: List[Dict[str, Any]]):
"""Set hotkey list."""
@@ -383,8 +397,7 @@ def set_hotkeys(self, hotkeys: List[Dict[str, Any]]):
# Title
title = QLabel(f"🔥 {t('hot_search')}")
- from system.theme import ThemeManager
- title.setStyleSheet(ThemeManager.instance().get_qss(self._STYLE_TITLE))
+ title.setStyleSheet(get_qss(self._STYLE_TITLE))
self._container_layout.addWidget(title)
# Hotkey items
@@ -406,14 +419,13 @@ def set_search_history(self, history: List[str]):
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.setStyleSheet(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.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)
@@ -434,9 +446,6 @@ 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
@@ -444,13 +453,13 @@ def set_combined(self, history: List[str], hotkeys: List[Dict[str, Any]]):
title_layout.setContentsMargins(12, 10, 12, 6)
title = QLabel(f"📝 {t('search_history')}")
- title.setStyleSheet(tm.get_qss(self._STYLE_TITLE_NO_PADDING))
+ 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(tm.get_qss(self._STYLE_CLEAR_BTN))
+ 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)
@@ -468,14 +477,14 @@ def set_combined(self, history: List[str], hotkeys: List[Dict[str, Any]]):
if history and hotkeys:
separator = QFrame()
separator.setFrameShape(QFrame.HLine)
- separator.setStyleSheet(tm.get_qss(self._STYLE_SEPARATOR))
+ 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(tm.get_qss(self._STYLE_TITLE))
+ 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
@@ -489,9 +498,6 @@ def set_combined(self, history: List[str], hotkeys: List[Dict[str, Any]]):
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)
@@ -499,19 +505,19 @@ def _add_history_item(self, keyword: str):
# Keyword label
label = QLabel(keyword)
- label.setStyleSheet(tm.get_qss(self._STYLE_HISTORY_LABEL))
+ 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(tm.get_qss(self._STYLE_DELETE_BTN))
+ 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(tm.get_qss(self._STYLE_HISTORY_ITEM))
+ item_widget.setStyleSheet(get_qss(self._STYLE_HISTORY_ITEM))
item_widget.setCursor(Qt.PointingHandCursor)
item_widget.mousePressEvent = lambda e: self._on_item_clicked(keyword)
@@ -519,9 +525,8 @@ def _add_history_item(self, keyword: str):
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.setStyleSheet(get_qss(self._STYLE_HOTKEY_ITEM))
label.setCursor(Qt.PointingHandCursor)
label.mousePressEvent = lambda e: self._on_item_clicked(query)
@@ -606,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)
@@ -616,55 +623,7 @@ 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%;
- 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_TABS = ""
_STYLE_RANKINGS_TITLE = "color: %highlight%; font-size: 16px; font-weight: bold;"
_STYLE_FAV_BACK_BTN = """
QPushButton {
@@ -732,7 +691,6 @@ class OnlineMusicView(QWidget):
border-bottom: 2px solid %highlight%;
font-weight: bold;
font-size: 12px;
- letter-spacing: 0.5px;
}
QTableWidget#songsTable QTableCornerButton::section {
background-color: %background_hover%;
@@ -824,21 +782,25 @@ 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
+ bind_context(plugin_context)
+ self._language_connected = False
# Create services
- self._service = OnlineMusicService(
+ self._service = create_online_music_service(
config_manager=config_manager,
- qqmusic_service=qqmusic_service
+ credential_provider=qqmusic_service
)
- self._download_service = OnlineDownloadService(
+ self._download_service = create_online_download_service(
config_manager=config_manager,
- qqmusic_service=qqmusic_service,
+ credential_provider=qqmusic_service,
online_music_service=self._service
)
@@ -884,7 +846,7 @@ def __init__(
self._grid_page_size = 30 # Page size for grid views
# Event bus
- self._event_bus = EventBus.instance()
+ self._event_bus = event_bus()
# Setup completion timer
self._completion_timer = QTimer()
@@ -896,8 +858,9 @@ def __init__(
self._register_focus_clear_filter()
# Register with theme system
- from system.theme import ThemeManager
- ThemeManager.instance().register_widget(self)
+ register_themed_widget(self)
+ self._connect_language_events()
+ self._sync_language_from_context()
self.refresh_theme()
def _setup_ui(self):
@@ -916,13 +879,13 @@ def _setup_ui(self):
# 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 = 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=t("recommendations"), parent=self)
+ self._recommend_section = RecommendSection(title="recommendations", parent=self)
self._recommend_section.recommendation_clicked.connect(self._on_recommendation_clicked)
layout.addWidget(self._recommend_section)
@@ -1035,7 +998,7 @@ def _create_header(self) -> QWidget:
layout.setContentsMargins(0, 0, 0, 0)
# Title
- self._online_music_title = QLabel(t("online_music"))
+ self._online_music_title = QLabel(t("qqmusic_page_title"))
layout.addWidget(self._online_music_title)
layout.addStretch()
@@ -1054,6 +1017,31 @@ def _create_header(self) -> QWidget:
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()
@@ -1063,6 +1051,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)
@@ -1110,21 +1099,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
@@ -1327,36 +1301,36 @@ def _create_pagination(self) -> QWidget:
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))
+ self.setStyleSheet(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))
+ 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(tm.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(tm.get_qss(self._STYLE_TABS))
+ self._tabs.setStyleSheet(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))
+ 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(tm.get_qss(self._STYLE_FAV_BACK_BTN))
- self._results_info.setStyleSheet(tm.get_qss(self._STYLE_RESULTS_INFO))
+ 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(tm.get_qss(self._STYLE_SONGS_TABLE))
- self._results_table.setStyleSheet(tm.get_qss(self._STYLE_SONGS_TABLE))
+ 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(tm.get_qss(self._STYLE_PAGE_LABEL))
+ self._page_label.setStyleSheet(get_qss(self._STYLE_PAGE_LABEL))
# Refresh completer popup
if hasattr(self, '_completer') and self._completer:
@@ -1369,22 +1343,28 @@ 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
- 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:
- self._qqmusic_service = QQMusicService(qqmusic_credential)
+ 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._qqmusic = self._qqmusic_service
+ self._service._provider = self._qqmusic_service
# Update download service reference too
- self._download_service._qqmusic = self._qqmusic_service
+ 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._qqmusic = self._qqmusic_service
- self._detail_view._download_service._qqmusic = self._qqmusic_service
- logger.info(f"QQ Music service refreshed, musicid={qqmusic_credential.get('musicid')}, "
- f"has_refresh_key={bool(qqmusic_credential.get('refresh_key'))}")
+ 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}")
@@ -1397,10 +1377,13 @@ 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", "")
+ else:
+ nick = ""
if nick:
- self._login_status_label.setText(t("qqmusic_logged_in_as").format(nick=nick))
+ self._login_status_label.setText(f"{t('qqmusic_logged_in_as')} {nick}")
else:
self._login_status_label.setText(t("qqmusic_logged_in"))
@@ -1417,12 +1400,35 @@ def _update_login_status(self):
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:
- 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", "")
self._update_login_status()
MessageDialog.information(self, t("logout"), t("logout_success"))
else:
@@ -1431,16 +1437,24 @@ def _on_login_clicked(self):
def _show_login_dialog(self):
"""Show QQ Music login dialog."""
- from ui.dialogs.qqmusic_qr_login_dialog import QQMusicQRLoginDialog
-
- dialog = QQMusicQRLoginDialog(self)
- # Connect to credentials signal to refresh immediately on success
+ 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
@@ -1457,11 +1471,11 @@ def _load_recommendations(self):
# 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")),
+ ("home_feed", "home_recommend"),
+ ("guess", "guess_you_like"),
+ ("radar", "radar_recommend"),
+ ("newsong", "new_songs"),
+ ("songlist", "recommend_playlists"),
]
for recommend_type, title in recommend_types:
@@ -1489,11 +1503,11 @@ def _display_recommendations(self):
# 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")),
+ ("home_feed", "home_recommend"),
+ ("guess", "guess_you_like"),
+ ("radar", "radar_recommend"),
+ ("newsong", "new_songs"),
+ ("songlist", "recommend_playlists"),
]
for recommend_type, title in recommend_config:
@@ -1657,7 +1671,7 @@ def _display_favorites_cards(self):
fav_songs = self._fav_data.get("fav_songs", [])
cards.append({
"id": "fav_songs",
- "title": t("fav_songs"),
+ "title": "fav_songs",
"subtitle": f"{len(fav_songs)} {t('songs')}",
"cover_url": self._get_random_cover(fav_songs),
"card_type": "fav_songs",
@@ -1667,7 +1681,7 @@ def _display_favorites_cards(self):
created_pl = self._fav_data.get("created_playlists", [])
cards.append({
"id": "created_playlists",
- "title": t("created_playlists"),
+ "title": "created_playlists",
"subtitle": f"{len(created_pl)} {t('playlists')}",
"cover_url": self._get_random_cover(created_pl),
"card_type": "created_playlists",
@@ -1677,7 +1691,7 @@ def _display_favorites_cards(self):
fav_pl = self._fav_data.get("fav_playlists", [])
cards.append({
"id": "fav_playlists",
- "title": t("fav_playlists"),
+ "title": "fav_playlists",
"subtitle": f"{len(fav_pl)} {t('playlists')}",
"cover_url": self._get_random_cover(fav_pl),
"card_type": "fav_playlists",
@@ -1687,7 +1701,7 @@ def _display_favorites_cards(self):
fav_albums = self._fav_data.get("fav_albums", [])
cards.append({
"id": "fav_albums",
- "title": t("fav_albums"),
+ "title": "fav_albums",
"subtitle": f"{len(fav_albums)} {t('albums')}",
"cover_url": self._get_random_cover(fav_albums),
"card_type": "fav_albums",
@@ -1697,7 +1711,7 @@ def _display_favorites_cards(self):
followed_singers = self._fav_data.get("followed_singers", [])
cards.append({
"id": "followed_singers",
- "title": t("followed_singers"),
+ "title": "followed_singers",
"subtitle": f"{len(followed_singers)} {t('singers')}",
"cover_url": self._get_random_cover(followed_singers),
"card_type": "followed_singers",
@@ -1877,7 +1891,7 @@ def _show_fav_songs_in_table(self, tracks: list):
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
+ from .models import OnlinePlaylist
# Clear previous data
self._playlists_page.clear()
@@ -1893,7 +1907,7 @@ def _show_playlist_list_in_detail(self, title: str, playlists: list):
) for pl in playlists]
self._playlists_page.load_data(online_playlists)
- self._results_info.setText(title)
+ self._results_info.setText(t(title))
self._tabs.hide()
self._is_top_list_view = False
self._results_stack.setCurrentWidget(self._playlists_page)
@@ -1908,7 +1922,7 @@ def _show_playlist_list_in_detail(self, title: str, playlists: list):
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
+ from .models import OnlineAlbum
# Clear previous data
self._albums_page.clear()
@@ -1942,7 +1956,7 @@ def _show_album_list_in_detail(self, title: str, albums: list):
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
+ from .models import OnlineArtist
# Clear previous data
self._singers_page.clear()
@@ -2513,11 +2527,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):
@@ -2527,11 +2538,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):
@@ -2541,13 +2548,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 = {
@@ -2564,7 +2575,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]:
@@ -2572,10 +2583,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()
@@ -2591,10 +2607,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()
@@ -2610,7 +2632,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):
@@ -2770,6 +2800,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,
@@ -2839,8 +2870,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
@@ -2863,7 +2895,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(
@@ -2954,8 +2989,7 @@ def _show_track_context_menu(self, pos):
return
menu = QMenu(self)
- from system.theme import ThemeManager
- menu.setStyleSheet(ThemeManager.instance().get_qss(self._STYLE_MENU))
+ menu.setStyleSheet(get_qss(self._STYLE_MENU))
play_action = menu.addAction(t("play"))
play_action.triggered.connect(lambda: self._play_selected_tracks(tracks))
@@ -2990,12 +3024,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()
@@ -3017,10 +3056,10 @@ def _add_selected_to_favorites(self, tracks: List[OnlineTrack]):
return
added_count = 0
- from app.bootstrap import Bootstrap
-
- bootstrap = Bootstrap.instance()
- favorites_service = bootstrap.favorites_service
+ 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)
@@ -3044,11 +3083,6 @@ def _add_selected_to_playlist(self, tracks: List[OnlineTrack]):
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:
@@ -3059,24 +3093,18 @@ def _add_selected_to_playlist(self, tracks: List[OnlineTrack]):
if not track_ids:
return
- add_tracks_to_playlist(
- self,
- bootstrap.library_service,
- track_ids,
- "[OnlineMusicView]"
- )
+ 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."""
- from app.bootstrap import Bootstrap
-
- bootstrap = Bootstrap.instance()
- if not bootstrap.library_service:
+ current_bootstrap = bootstrap()
+ if current_bootstrap is None or not current_bootstrap.library_service:
return None
cover_url = self._get_cover_url(track)
- return bootstrap.library_service.add_online_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,
@@ -3224,7 +3252,7 @@ def _toggle_ranking_view_mode(self):
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
+ theme = current_theme()
if view_mode == "list":
icon = get_icon(IconName.GRID, theme.text_secondary)
@@ -3244,9 +3272,10 @@ 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
+ 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)
@@ -3254,7 +3283,7 @@ def _on_ranking_favorite_toggled(self, track, is_favorite: bool):
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)
+ 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)
@@ -3272,7 +3301,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("online_music"))
+ self._online_music_title.setText(t("qqmusic_page_title"))
if hasattr(self, '_rankings_title'):
self._rankings_title.setText(t("rankings"))
@@ -3285,7 +3314,7 @@ def refresh_ui(self):
self._search_btn.setText(t("search"))
# Update login button
- self._update_login_status()
+ self._refresh_login_status()
# Update type tabs
if hasattr(self, '_tabs'):
@@ -3337,6 +3366,10 @@ def refresh_ui(self):
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()
@@ -3347,11 +3380,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):
@@ -3364,7 +3398,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/ui/views/online_tracks_list_view.py b/plugins/builtin/qqmusic/lib/online_tracks_list_view.py
similarity index 92%
rename from ui/views/online_tracks_list_view.py
rename to plugins/builtin/qqmusic/lib/online_tracks_list_view.py
index e834ac20..8b366e3d 100644
--- a/ui/views/online_tracks_list_view.py
+++ b/plugins/builtin/qqmusic/lib/online_tracks_list_view.py
@@ -10,33 +10,51 @@
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
+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 online cover for QQ music track."""
- if not track:
+ """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:
- 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,
- )
+ 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
@@ -157,7 +175,7 @@ def __init__(self, parent=None):
self._cover_loaded_signal.connect(self._on_cover_loaded)
self._requested_covers: set = set()
self._failed_covers: set = set()
- CoverPixmapCache.initialize()
+ cover_pixmap_cache_initialize()
self._cover_size = 64
self._rank_width = 50
self._padding = 10
@@ -172,8 +190,7 @@ def paint(self, painter: QPainter, option: QStyleOptionViewItem, index: QModelIn
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
+ theme = current_theme()
track = index.data(OnlineTracksModel.TrackRole)
rank = index.data(OnlineTracksModel.RankRole)
@@ -294,7 +311,7 @@ def _paint_cover(self, painter: QPainter, rect: QRect, track: OnlineTrack, row:
cache_key = self._get_cover_cache_key(track)
# Try cache
- cached = CoverPixmapCache.get(cache_key)
+ cached = cover_pixmap_cache_get(cache_key)
if cached and not cached.isNull():
painter.drawPixmap(rect, cached)
else:
@@ -327,8 +344,9 @@ def _paint_cover(self, painter: QPainter, rect: QRect, track: OnlineTrack, row:
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):
+ 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)
@@ -343,7 +361,7 @@ def _on_cover_loaded(self, cache_key: str, cover_path: str, qimage):
def _get_cover_cache_key(self, track: OnlineTrack) -> str:
"""Generate cache key for an online track."""
- return f"{TrackSource.QQ.name}:{track.mid}"
+ return f"QQ:{track.mid}"
def cover_rect_for_item(self, item_rect: QRect) -> QRect:
"""Return the clickable cover rectangle for an item."""
@@ -412,13 +430,13 @@ def _setup_connections(self):
self._list_view.clicked.connect(self._on_item_clicked)
# Event bus
- bus = EventBus.instance()
+ 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):
- EventBus.instance().favorite_changed.disconnect(self._on_favorite_changed)
+ event_bus().favorite_changed.disconnect(self._on_favorite_changed)
self._hover_timer.stop()
self._cover_popup.hide()
super().closeEvent(event)
@@ -568,7 +586,7 @@ def _on_cover_ready(self, cache_key: str, cover_path: str, qimage):
Qt.AspectRatioMode.KeepAspectRatioByExpanding,
Qt.TransformationMode.SmoothTransformation
)
- CoverPixmapCache.set(cache_key, pixmap)
+ cover_pixmap_cache_set(cache_key, pixmap)
if track_row is not None:
self._model.notify_cover_loaded(track_row)
@@ -585,8 +603,7 @@ def _find_row_by_cover_key(self, cache_key: str):
return None
def _apply_viewport_bg(self):
- from system.theme import ThemeManager
- theme = ThemeManager.instance().current_theme
+ theme = current_theme()
self._list_view.setStyleSheet(
f"QListView {{ background-color: {theme.background_alt}; border: none; outline: none; }}"
)
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"?em[^>]*>", re.IGNORECASE)
diff --git a/plugins/builtin/qqmusic/lib/provider.py b/plugins/builtin/qqmusic/lib/provider.py
new file mode 100644
index 00000000..6d0c867f
--- /dev/null
+++ b/plugins/builtin/qqmusic/lib/provider.py
@@ -0,0 +1,220 @@
+from __future__ import annotations
+
+import logging
+from typing import Any
+
+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,
+ create_online_download_service,
+ create_qqmusic_service,
+)
+
+logger = logging.getLogger(__name__)
+
+
+class QQMusicOnlineProvider:
+ provider_id = "qqmusic"
+ display_name = "QQ 音乐"
+
+ 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 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(
+ config_manager=config,
+ qqmusic_service=service,
+ plugin_context=context,
+ parent=parent,
+ )
+
+ @staticmethod
+ def _create_config_adapter(context):
+ return QQMusicConfigAdapter(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.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(
+ 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 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 = {}
+ 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
+
+ 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
+
+ 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/services/cloud/qqmusic/client.py b/plugins/builtin/qqmusic/lib/qqmusic_client.py
similarity index 91%
rename from services/cloud/qqmusic/client.py
rename to plugins/builtin/qqmusic/lib/qqmusic_client.py
index 025f0a5d..9c245bd1 100644
--- a/services/cloud/qqmusic/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,
@@ -341,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,
}
@@ -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:
@@ -892,11 +909,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 +921,12 @@ 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
- )
+ response = self._http_get(url, params=params, headers=headers, 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
@@ -945,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 = {
@@ -981,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/services/cloud/qqmusic/qqmusic_service.py b/plugins/builtin/qqmusic/lib/qqmusic_service.py
similarity index 91%
rename from services/cloud/qqmusic/qqmusic_service.py
rename to plugins/builtin/qqmusic/lib/qqmusic_service.py
index fae7a4c5..40af1069 100644
--- a/services/cloud/qqmusic/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 .client import QQMusicClient
+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
@@ -20,14 +22,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
@@ -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.
@@ -313,7 +355,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 plugin_adapter
try:
# Get album basic info
@@ -328,7 +370,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 = plugin_adapter.parse_album_detail(basic_result, songs_result)
if not result:
return None
@@ -357,7 +399,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 plugin_adapter
try:
uin = str(self.credential.get("musicid", "")) if self.credential else ""
@@ -415,7 +457,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 = 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
@@ -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,42 +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
- elif isinstance(album_info, dict):
- album_name = album_info.get('name', '')
- else:
- album_name = song.get('albumName', '') or song.get('albumname', '')
-
- # 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,
- '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/services/cloud/qqmusic/qr_login.py b/plugins/builtin/qqmusic/lib/qr_login.py
similarity index 65%
rename from services/cloud/qqmusic/qr_login.py
rename to plugins/builtin/qqmusic/lib/qr_login.py
index 27fe67bb..73af384d 100644
--- a/services/cloud/qqmusic/qr_login.py
+++ b/plugins/builtin/qqmusic/lib/qr_login.py
@@ -1,9 +1,5 @@
-"""
-QQ Music QR code login implementation.
-Local implementation without external dependencies.
-Based on qqmusic_api library implementation.
-"""
-import logging
+from __future__ import annotations
+
import random
import re
import time
@@ -12,35 +8,21 @@
from typing import Any, Dict, Optional
import requests
-
-from .common import create_qq_session
-
-logger = logging.getLogger(__name__)
+from requests.adapters import HTTPAdapter
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)
@@ -50,7 +32,6 @@ class QRCodeLoginEvents(Enum):
@classmethod
def get_by_value(cls, value: int):
- """Get enum member by value."""
for member in cls:
if value in member.value:
return member
@@ -59,13 +40,6 @@ def get_by_value(cls, value: int):
@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
@@ -73,22 +47,6 @@ class QR:
@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 = ""
@@ -104,33 +62,28 @@ class Credential:
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
+ self.login_type = 1 if self.musickey.startswith("W_X") else 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
+ "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."""
+ def from_cookies_dict(cls, cookies: Dict[str, Any]) -> "Credential":
_musicid = int(cookies.pop("musicid", 0) or 0)
return cls(
openid=cookies.pop("openid", ""),
@@ -149,48 +102,45 @@ def from_cookies_dict(cls, cookies: Dict[str, Any]) -> 'Credential':
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 __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]:
- """
- 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,
@@ -206,22 +156,16 @@ def _get_qq_qr(self) -> Optional[QR]:
"pt_3rd_aid": "100497308",
},
headers={"Referer": "https://xui.ptlogin2.qq.com/"},
- timeout=10
+ 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}")
+ except Exception:
return None
def _get_wx_qr(self) -> Optional[QR]:
- """Get WeChat login QR code."""
try:
response = self._session.get(
self.WX_QR_URL,
@@ -233,47 +177,28 @@ def _get_wx_qr(self) -> Optional[QR]:
"state": "STATE",
"href": "https://y.qq.com/mediastyle/music_v17/src/css/popup_wechat.css#wechat_redirect",
},
- timeout=10
+ 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
+ timeout=10,
)
-
return QR(qr_response.content, QRLoginType.WX, uuid)
-
- except Exception as e:
- logger.error(f"Error getting WeChat QR code: {e}")
+ except Exception:
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,
@@ -297,88 +222,67 @@ def _check_qq_qr(self, qrcode: QR) -> tuple[QRCodeLoginEvents, Optional[Credenti
},
headers={
"Referer": "https://xui.ptlogin2.qq.com/",
- "Cookie": f"qrsig={qrsig}"
+ "Cookie": f"qrsig={qrsig}",
},
- timeout=10
+ 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:
+ if not data:
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
+ 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]]:
- """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
+ timeout=10,
)
except requests.Timeout:
return QRCodeLoginEvents.SCAN, None
- except requests.RequestException as e:
- logger.warning(f"WeChat QR code check failed: {e}")
+ except requests.RequestException:
return QRCodeLoginEvents.SCAN, None
- match = re.search(r"window\.wx_errcode=(\d+);window\.wx_code=\'([^\']*)\'", response.text)
+ 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}")
+ except Exception:
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={
@@ -403,29 +307,22 @@ def _authorize_qq_qr(self, uin: str, sigx: str) -> Credential:
},
headers={"Referer": "https://xui.ptlogin2.qq.com/"},
allow_redirects=True,
- timeout=10
+ 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'):
+ 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')
+ 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)
+ 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={
@@ -444,20 +341,16 @@ def _authorize_qq_qr(self, uin: str, sigx: str) -> Credential:
"ui": str(random.randint(100000, 999999)),
},
allow_redirects=False,
- timeout=10
+ 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
+ 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:
- """Login via QQ Connect."""
request_data = {
"comm": {
"ct": "11",
@@ -474,26 +367,17 @@ def _qq_connect_login(self, code: str) -> Credential:
"module": "QQConnectLogin.LoginServer",
"method": "QQLogin",
"param": {"code": code},
- }
+ },
}
-
- response = self._session.post(
- self.MUSIC_API_URL,
- json=request_data,
- timeout=30
- )
+ 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",
@@ -513,20 +397,12 @@ def _authorize_wx_qr(self, code: str) -> Credential:
"code": code,
"strAppid": "wx48db31d50e334801",
},
- }
+ },
}
-
- response = self._session.post(
- self.MUSIC_API_URL,
- json=request_data,
- timeout=30
- )
+ 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/runtime_bridge.py b/plugins/builtin/qqmusic/lib/runtime_bridge.py
new file mode 100644
index 00000000..8967c4a1
--- /dev/null
+++ b/plugins/builtin/qqmusic/lib/runtime_bridge.py
@@ -0,0 +1,203 @@
+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:
+ _context = context
+
+
+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 _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 _coerce_stylesheet(_require_context().ui.theme.get_qss(template))
+
+
+def 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 _coerce_stylesheet(_require_context().ui.theme.get_popup_surface_style())
+
+
+def get_completer_popup_style() -> str:
+ return _coerce_stylesheet(_require_context().ui.theme.get_completer_popup_style())
+
+
+def show_information(parent, title: str, message: str) -> None:
+ _require_context().ui.dialogs.information(parent, title, message)
+
+
+def show_warning(parent, title: str, message: str) -> None:
+ _require_context().ui.dialogs.warning(parent, title, message)
+
+
+def create_online_music_service(*, config_manager=None, credential_provider=None):
+ from .plugin_online_music_service import PluginOnlineMusicService
+
+ return PluginOnlineMusicService(
+ context=_require_context(),
+ config_manager=config_manager,
+ credential_provider=credential_provider,
+ )
+
+
+def create_online_download_service(
+ *,
+ config_manager=None,
+ credential_provider=None,
+ online_music_service=None,
+):
+ 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,
+ )
+
+
+def get_icon(name, color, size: int = 16):
+ icon = _require_context().runtime.get_icon(name, color, size)
+ return icon if isinstance(icon, QIcon) else QIcon()
+
+
+class IconName:
+ GRID = "grid.svg"
+ LIST = "list.svg"
+
+
+def image_cache_get(url: str):
+ return _require_context().runtime.image_cache_get(url)
+
+
+def image_cache_set(url: str, image_data: bytes):
+ return _require_context().runtime.image_cache_set(url, image_data)
+
+
+def image_cache_path(url: str):
+ return _require_context().runtime.image_cache_path(url)
+
+
+def http_get_content(url: str, *, timeout: int, headers: dict[str, str] | None = None):
+ return _require_context().runtime.http_get_content(
+ url,
+ timeout=timeout,
+ headers=headers,
+ )
+
+
+def cover_pixmap_cache_initialize() -> None:
+ _require_context().runtime.cover_pixmap_cache_initialize()
+
+
+def cover_pixmap_cache_get(cache_key: str):
+ return _require_context().runtime.cover_pixmap_cache_get(cache_key)
+
+
+def cover_pixmap_cache_set(cache_key: str, pixmap) -> None:
+ _require_context().runtime.cover_pixmap_cache_set(cache_key, pixmap)
+
+
+def bootstrap():
+ return _require_context().runtime.bootstrap()
+
+
+def library_service():
+ return _require_context().runtime.library_service()
+
+
+def favorites_service():
+ return _require_context().runtime.favorites_service()
+
+
+def favorite_mids_from_library() -> set[str]:
+ return _require_context().runtime.favorite_mids_from_library()
+
+
+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]:
+ return _require_context().runtime.add_requests_to_favorites(requests)
+
+
+def add_requests_to_playlist(parent, requests: list[Any], log_prefix: str) -> list[int]:
+ 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:
+ _require_context().runtime.add_track_ids_to_playlist(parent, track_ids, log_prefix)
+
+
+def event_bus():
+ return _require_context().runtime.event_bus()
+
+
+def create_qqmusic_service(credential):
+ from .qqmusic_service import QQMusicService
+
+ return QQMusicService(credential, http_client=_require_context().http)
+
+
+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)
+
+
+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/runtime_client.py b/plugins/builtin/qqmusic/lib/runtime_client.py
new file mode 100644
index 00000000..3cedf17b
--- /dev/null
+++ b/plugins/builtin/qqmusic/lib/runtime_client.py
@@ -0,0 +1,40 @@
+from __future__ import annotations
+
+import json
+
+from .qqmusic_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/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/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/plugins/builtin/qqmusic/lib/settings_tab.py b/plugins/builtin/qqmusic/lib/settings_tab.py
new file mode 100644
index 00000000..dbe765ac
--- /dev/null
+++ b/plugins/builtin/qqmusic/lib/settings_tab.py
@@ -0,0 +1,426 @@
+from __future__ import annotations
+
+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 bind_context, 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, http_client=None, parent=None):
+ super().__init__(parent)
+ self._credential = credential
+ self._http_client = http_client
+
+ def run(self):
+ try:
+ from .qqmusic_service import QQMusicService
+
+ service = QQMusicService(self._credential, http_client=self._http_client)
+ 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%;
+ }
+ """
+ _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
+ bind_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)
+
+ 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(self._on_quality_changed)
+ 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)
+
+ qqmusic_layout.addLayout(qqmusic_button_layout)
+
+ # Update status after buttons are created
+ self._update_qqmusic_status()
+
+ 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:
+ 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(
+ "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,
+ 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
+ )
+ )
+ 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 _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._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()
+
+ 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._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))
+ 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/services/cloud/qqmusic/tripledes.py b/plugins/builtin/qqmusic/lib/tripledes.py
similarity index 100%
rename from services/cloud/qqmusic/tripledes.py
rename to plugins/builtin/qqmusic/lib/tripledes.py
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..4c819b68
--- /dev/null
+++ b/plugins/builtin/qqmusic/plugin_main.py
@@ -0,0 +1,82 @@
+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.runtime_bridge import bind_context, clear_context
+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:
+ bind_context(context)
+ 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=_localized_title(),
+ order=80,
+ 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=_localized_title(),
+ order=80,
+ widget_factory=lambda _context, parent: QQMusicSettingsTab(context, parent),
+ title_provider=_localized_title,
+ )
+ )
+ 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))
+ 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:
+ clear_context(context)
+ 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..b722085a
--- /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/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/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/repositories/cloud_repository.py b/repositories/cloud_repository.py
index df6b9652..54fe94f9 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
@@ -448,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/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/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/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/repositories/queue_repository.py b/repositories/queue_repository.py
index beedd86a..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()
@@ -44,7 +51,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"
@@ -55,13 +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=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 "",
@@ -75,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."""
@@ -87,12 +112,13 @@ 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, 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 977b198c..8872a31f 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."""
@@ -123,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."""
@@ -291,13 +353,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',
+ self._normalize_online_provider_id(track.online_provider_id),
))
track_id = cursor.lastrowid
@@ -347,13 +410,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',
+ self._normalize_online_provider_id(track.online_provider_id),
))
track_id = cursor.lastrowid
@@ -402,13 +466,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',
+ self._normalize_online_provider_id(track.online_provider_id),
track.id
))
conn.commit()
@@ -446,11 +512,39 @@ 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),
+ )
+ 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()
if row:
return self._row_to_track(row)
@@ -461,10 +555,26 @@ 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)
+ 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"],
@@ -477,6 +587,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=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/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/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/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"""
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/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/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..0ac54cb5
--- /dev/null
+++ b/services/download/online_download_gateway.py
@@ -0,0 +1,265 @@
+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 _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:
+ 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()
+ 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 normalized_provider_id and getattr(provider, "provider_id", None) != normalized_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 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
+
+ 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(f"[OnlineDownloadGateway] [{provider_id}] 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/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/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/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..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):
"""
@@ -23,7 +18,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 +30,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 +39,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 +49,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 +67,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 +75,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")
@@ -116,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.
@@ -139,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)
@@ -150,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):
@@ -172,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:
@@ -196,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/services/lyrics/lyrics_service.py b/services/lyrics/lyrics_service.py
index b93431fa..db1fd64f 100644
--- a/services/lyrics/lyrics_service.py
+++ b/services/lyrics/lyrics_service.py
@@ -2,14 +2,17 @@
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, as_completed, 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
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__)
@@ -30,7 +33,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()
@@ -54,22 +57,37 @@ class LyricsService:
# Enable online lyrics
ENABLE_ONLINE = True # Changed to True for better UX
+ @classmethod
+ def _get_builtin_sources(cls) -> List["LyricsSource"]:
+ """Get built-in host lyrics sources."""
+ return []
+
@classmethod
def _get_sources(cls) -> List["LyricsSource"]:
- """Get 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(),
- ]
+ """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:
@@ -110,41 +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): source.name
+ 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)
- # 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)
- 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)
+ 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")
- except Exception as e:
- # Log but don't fail - other sources may have results
- logger.debug(f"[LyricsService] {source_name} search failed: {e}")
+ if progress_callback and search_results:
+ progress_callback(results, source_name)
+
+ 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
@@ -156,7 +191,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:
@@ -164,21 +199,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:
@@ -226,49 +269,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
@@ -350,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/services/lyrics/qqmusic_lyrics.py b/services/lyrics/qqmusic_lyrics.py
deleted file mode 100644
index ae896b03..00000000
--- a/services/lyrics/qqmusic_lyrics.py
+++ /dev/null
@@ -1,605 +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()
-
-
-def _get_client() -> 'QQMusicClient':
- """Get QQMusicClient from Bootstrap."""
- from app.bootstrap import Bootstrap
- return Bootstrap.instance().qqmusic_client
-
-
-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 services.cloud.qqmusic.client import QQMusicClient as QQMusicClientLocal
- from app.bootstrap import Bootstrap
-
- config = Bootstrap.instance().config
- credential = config.get_qqmusic_credential()
-
- 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: config.set_qqmusic_credential(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 = config.get_qqmusic_credential()
- 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:
- config.set_qqmusic_credential(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/services/metadata/cover_service.py b/services/metadata/cover_service.py
index 916ef5c0..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:
@@ -43,35 +43,29 @@ def __init__(
self.http_client = http_client
self._sources = sources
+ def _get_builtin_sources(self) -> List["CoverSource"]:
+ """Get built-in host cover sources."""
+ return []
+
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
+
+ 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."""
+ return []
def _get_artist_sources(self) -> List["ArtistCoverSource"]:
- """Get artist cover sources."""
- from services.sources import (
- NetEaseArtistCoverSource,
- QQMusicArtistCoverSource,
- ITunesArtistCoverSource,
- )
- return [
- NetEaseArtistCoverSource(self.http_client),
- QQMusicArtistCoverSource(),
- ITunesArtistCoverSource(self.http_client),
- ]
+ """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]:
@@ -221,6 +215,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).
@@ -241,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
@@ -267,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:
@@ -278,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 services.lyrics.qqmusic_lyrics 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
@@ -330,16 +361,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}")
@@ -396,16 +421,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)
@@ -429,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
@@ -553,13 +572,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:
+ 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/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/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 b6674bdb..00000000
--- a/services/online/adapter.py
+++ /dev/null
@@ -1,954 +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)
-
- # Type keys for different search types
- type_keys = {
- SearchType.SONG: "item_song",
- SearchType.SINGER: "singer",
- SearchType.ALBUM: "item_album",
- SearchType.PLAYLIST: "item_songlist",
- }
-
- result_key = type_keys.get(search_type, "item_song")
- body = raw_data.get("body", {})
- items = body.get(result_key, [])
-
- 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 = AlbumInfo(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:
- 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)
- )
- 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 560b1a57..00000000
--- a/services/online/download_service.py
+++ /dev/null
@@ -1,429 +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
-
-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
-
-if TYPE_CHECKING:
- from system.config import ConfigManager
- from services.cloud.qqmusic.qqmusic_service import QQMusicService
-
-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,
- qqmusic_service: Optional["QQMusicService"] = None,
- online_music_service=None,
- download_dir: Optional[str] = None
- ):
- """
- Initialize download service.
-
- Args:
- config_manager: ConfigManager instance
- qqmusic_service: QQMusicService instance
- online_music_service: OnlineMusicService instance (preferred)
- download_dir: Download directory path
- """
- self._config = config_manager
- self._qqmusic = qqmusic_service
- 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 = self._config.get_qqmusic_quality() if self._config else "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 = self._config.get_qqmusic_quality() if self._config else "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._qqmusic:
- # 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)
- 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 268e7341..00000000
--- a/services/online/online_music_service.py
+++ /dev/null
@@ -1,747 +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 services.cloud.qqmusic.common import parse_quality
-from .adapter import OnlineMusicAdapter, ApiSource
-
-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, falls back to QQ Music local API
- if credential is available.
- """
-
- # API endpoints
- YGKING_BASE_URL = "https://api.ygking.top"
-
- def __init__(self, config_manager: Optional["ConfigManager"] = None,
- qqmusic_service=None):
- """
- Initialize online music service.
-
- Args:
- config_manager: ConfigManager for QQ Music credential
- qqmusic_service: Optional QQMusicService instance
- """
- self._config = config_manager
- self._qqmusic = qqmusic_service
- 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:
- return True
-
- # Check config if available
- if not self._config:
- return False
-
- # Use get_qqmusic_credential() method which handles both formats
- credential = self._config.get_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._qqmusic:
- 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._qqmusic.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._qqmusic:
- 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._qqmusic.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._qqmusic:
- 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._qqmusic.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._qqmusic:
- # 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)
- 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._qqmusic:
- result = self._qqmusic.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._qqmusic:
- # 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)
- 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._qqmusic:
- result = self._qqmusic.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:
- quality = self._config.get_qqmusic_quality() if self._config else "320"
-
- # Prefer QQ Music local API if credential is available
- if self._has_qqmusic_credential() and self._qqmusic:
- # 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)
- 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._qqmusic:
- return self._qqmusic.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._qqmusic:
- 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._qqmusic.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._qqmusic:
- return self._qqmusic.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)
- 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)
- 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)
- return False
-
- def fav_album(self, album_mid: str) -> bool:
- """Favorite an album."""
- if self._qqmusic:
- return self._qqmusic.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)
- return False
-
- def fav_playlist(self, playlist_id) -> bool:
- """Favorite a playlist."""
- if self._qqmusic:
- return self._qqmusic.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)
- 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/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..d4e2374c 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
@@ -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"):
@@ -97,7 +105,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 +122,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 +423,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 +444,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(track) and not has_local_file
if is_online or (track.path and track.path in existing_paths):
items.append(PlaylistItem.from_track(track))
@@ -506,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")
@@ -544,7 +559,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 +571,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(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()):
@@ -768,7 +783,13 @@ 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)
+ 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)
# Build playlist items - fast path, no blocking operations
items = []
@@ -841,13 +862,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 +1149,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 +1164,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 = ""
@@ -1174,21 +1198,67 @@ 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 {}
+ 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:
- cloud_tracks = self._track_repo.get_by_cloud_file_ids(cloud_file_ids) or {}
+ 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 {}
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 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
+ 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:
@@ -1203,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:
@@ -1218,6 +1290,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:
@@ -1231,7 +1305,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 +1504,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))
@@ -1466,7 +1537,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
@@ -1474,21 +1545,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 +1625,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
@@ -1651,18 +1728,39 @@ 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():
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)
- 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)
@@ -1678,7 +1776,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,
@@ -1686,7 +1784,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 +1811,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 +1832,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 +2003,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 +2024,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 +2037,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 +2134,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 +2154,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 +2171,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 +2210,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 +2275,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..6ba05cb4 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 = ""
@@ -171,12 +174,40 @@ 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 {}
+ 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 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(
+ [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
@@ -186,6 +217,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:
@@ -202,6 +235,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:
@@ -212,7 +247,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/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/services/sources/__init__.py b/services/sources/__init__.py
index 0d2fc11b..1feee633 100644
--- a/services/sources/__init__.py
+++ b/services/sources/__init__.py
@@ -2,28 +2,15 @@
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
from .cover_sources import (
- NetEaseCoverSource,
- QQMusicCoverSource,
- ITunesCoverSource,
- LastFmCoverSource,
MusicBrainzCoverSource,
SpotifyCoverSource,
)
-from .lyrics_sources import (
- NetEaseLyricsSource,
- QQMusicLyricsSource,
- KugouLyricsSource,
- LRCLIBLyricsSource,
-)
from .artist_cover_sources import (
- NetEaseArtistCoverSource,
- QQMusicArtistCoverSource,
- ITunesArtistCoverSource,
SpotifyArtistCoverSource,
)
@@ -33,20 +20,8 @@
"LyricsSource",
"ArtistCoverSource",
# Cover sources
- "NetEaseCoverSource",
- "QQMusicCoverSource",
- "ITunesCoverSource",
- "LastFmCoverSource",
"MusicBrainzCoverSource",
"SpotifyCoverSource",
- # Lyrics sources
- "NetEaseLyricsSource",
- "QQMusicLyricsSource",
- "KugouLyricsSource",
- "LRCLIBLyricsSource",
# 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..4204f6c2 100644
--- a/services/sources/artist_cover_sources.py
+++ b/services/sources/artist_cover_sources.py
@@ -12,196 +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 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."""
-
- @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/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/services/sources/cover_sources.py b/services/sources/cover_sources.py
index b63982a8..21ee47eb 100644
--- a/services/sources/cover_sources.py
+++ b/services/sources/cover_sources.py
@@ -11,335 +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 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."""
-
- @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."""
-
- @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/services/sources/lyrics_sources.py b/services/sources/lyrics_sources.py
deleted file mode 100644
index c575b238..00000000
--- a/services/sources/lyrics_sources.py
+++ /dev/null
@@ -1,382 +0,0 @@
-"""
-Lyrics source implementations.
-"""
-
-import base64
-import logging
-import zlib
-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
-
-
-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."""
-
- @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."""
-
- 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/config.py b/system/config.py
index f66040d6..deae8222 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
@@ -148,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):
@@ -409,6 +403,22 @@ 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)
+
+ 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:
@@ -706,119 +716,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/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/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/system/mpris.py b/system/mpris.py
index dfba8248..4c4f9e08 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,11 +421,13 @@ 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
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)
@@ -400,11 +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
- )
+ 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(
@@ -426,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 = [
@@ -448,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/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/host_services.py b/system/plugins/host_services.py
new file mode 100644
index 00000000..f3fa4f86
--- /dev/null
+++ b/system/plugins/host_services.py
@@ -0,0 +1,227 @@
+from __future__ import annotations
+
+import json
+import logging
+from pathlib import Path
+
+from . import plugin_sdk_runtime
+from .plugin_sdk_ui import PluginDialogBridgeImpl, PluginThemeBridgeImpl
+
+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 _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)
+
+
+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
+ self._theme = PluginThemeBridgeImpl()
+ self._dialogs = PluginDialogBridgeImpl()
+
+ 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)
+
+ @property
+ def theme(self):
+ return self._theme
+
+ @property
+ def dialogs(self):
+ return self._dialogs
+
+
+class PluginRuntimeBridgeImpl:
+ 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, 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)
+
+ 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
+ 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
+ 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,
+ self._bootstrap.library_service,
+ )
+ 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
+
+
+from .media_bridge import PluginMediaBridge
+
+__all__ = [
+ "BootstrapPluginContextFactory",
+ "PluginMediaBridge",
+ "PluginRuntimeBridgeImpl",
+ "PluginServiceBridgeImpl",
+ "PluginSettingsBridgeImpl",
+ "PluginStorageBridgeImpl",
+ "PluginUiBridgeImpl",
+]
diff --git a/system/plugins/installer.py b/system/plugins/installer.py
new file mode 100644
index 00000000..1bf5d92f
--- /dev/null
+++ b/system/plugins/installer.py
@@ -0,0 +1,190 @@
+from __future__ import annotations
+
+import ast
+import json
+import shutil
+import zipfile
+from pathlib import Path, PurePosixPath
+
+from harmony_plugin_api.manifest import PluginManifest
+
+from .errors import PluginInstallError
+
+_FORBIDDEN_ROOT_IMPORTS = {
+ "app",
+ "domain",
+ "services",
+ "repositories",
+ "infrastructure",
+ "system",
+ "ui",
+}
+
+
+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):
+ names = _import_roots_from_node(node)
+ if not names:
+ 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 _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")
+ 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:
+ entrypoint_path = plugin_root / manifest.entrypoint
+ if not entrypoint_path.exists():
+ raise PluginInstallError(
+ f"Entrypoint file does not exist: {entrypoint_path}"
+ )
+
+ 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:
+ 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:
+ self._validate_archive_entries(archive, extract_root)
+ archive.extractall(extract_root)
+
+ 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)
+ final_root = self._external_root / manifest.id
+ 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
+ except Exception as exc:
+ raise PluginInstallError(f"Failed to install plugin from {zip_path}: {exc}") from exc
diff --git a/system/plugins/loader.py b/system/plugins/loader.py
new file mode 100644
index 00000000..87a37652
--- /dev/null
+++ b/system/plugins/loader.py
@@ -0,0 +1,154 @@
+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
+from .installer import audit_plugin_imports
+
+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(
+ 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"))
+ )
+
+ def _load_entry_module(
+ self,
+ plugin_root: Path,
+ manifest: PluginManifest,
+ ):
+ 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}")
+
+ 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)}"
+ spec = importlib.util.spec_from_file_location(
+ 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)
+ sys.modules[module_name] = module
+ try:
+ 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(
+ 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)
+ logger.debug(
+ "[PluginLoader] Instantiating plugin class %s for %s",
+ manifest.entry_class,
+ manifest.id,
+ )
+ 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
new file mode 100644
index 00000000..331ba63d
--- /dev/null
+++ b/system/plugins/manager.py
@@ -0,0 +1,206 @@
+from __future__ import annotations
+
+import logging
+import time
+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
+
+logger = logging.getLogger(__name__)
+
+
+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._installer = PluginInstaller(
+ external_root=external_root,
+ temp_root=external_root.parent / "tmp",
+ )
+ 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
+ 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()
+
+ roots = []
+ if self._builtin_root.exists():
+ roots.extend(
+ ("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 _is_plugin_root(path)
+ and not path.name.endswith(".staging")
+ and not path.name.endswith(".backup")
+ )
+ selected: dict[str, tuple[str, Path]] = {}
+ for source, plugin_root in sorted(roots, key=lambda item: (item[0], item[1].name)):
+ 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)
+ return sorted(selected.values(), key=lambda item: (item[0], item[1].name))
+
+ 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:
+ self._load_plugin_root(source, plugin_root)
+
+ def list_plugins(self) -> list[dict]:
+ plugins = []
+ for source, plugin_root in self.discover_roots():
+ manifest = self._read_manifest_or_none(plugin_root)
+ if manifest is None:
+ continue
+ 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 set_plugin_enabled(self, plugin_id: str, enabled: bool) -> None:
+ for source, plugin_root in self.discover_roots():
+ 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 {}
+ 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"),
+ )
+ if enabled:
+ self._load_plugin_root(source, plugin_root)
+ else:
+ self._unload_plugin(plugin_id)
+ 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))
+
+ 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/system/plugins/media_bridge.py b/system/plugins/media_bridge.py
new file mode 100644
index 00000000..cd04fcaf
--- /dev/null
+++ b/system/plugins/media_bridge.py
@@ -0,0 +1,97 @@
+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/queue 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,
+ provider_id=request.provider_id,
+ 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.provider_id,
+ 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"),
+ )
+
+ 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,
+ 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.ONLINE,
+ 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,
+ 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
new file mode 100644
index 00000000..241f80d6
--- /dev/null
+++ b/system/plugins/plugin_sdk_runtime.py
@@ -0,0 +1,171 @@
+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 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, 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, 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,
+ online_provider_id=provider_id,
+ )
+ 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.provider_id,
+ 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.provider_id,
+ 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..2223b3cb
--- /dev/null
+++ b/system/plugins/plugin_sdk_ui.py
@@ -0,0 +1,122 @@
+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:
+ manager = _get_theme_manager()
+ if manager is not None:
+ manager.register_widget(widget)
+
+ def get_qss(self, template: str) -> str:
+ manager = _get_theme_manager()
+ if manager is None:
+ return template
+ return manager.get_qss(template)
+
+ def current_theme(self):
+ from system.theme import PRESET_THEMES
+
+ manager = _get_theme_manager()
+ if manager is None:
+ return PRESET_THEMES["dark"]
+ return manager.current_theme
+
+ def get_popup_surface_style(self) -> str:
+ manager = _get_theme_manager()
+ if manager is None:
+ return ""
+ return manager.get_themed_popup_surface_style()
+
+ def get_completer_popup_style(self) -> str:
+ manager = _get_theme_manager()
+ if manager is None:
+ return ""
+ return manager.get_themed_completer_popup_style()
+
+
+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 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)
+
+
+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/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..17276913
--- /dev/null
+++ b/system/plugins/state_store.py
@@ -0,0 +1,52 @@
+from __future__ import annotations
+
+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:
+ if not self._path.exists():
+ return {}
+ 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:
+ 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,
+ plugin_id: str,
+ enabled: bool,
+ source: str,
+ version: str,
+ load_error: str | None = None,
+ ) -> None:
+ 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:
+ with self._lock:
+ return self._read().get(plugin_id)
diff --git a/system/theme.py b/system/theme.py
index 236054b3..3c1a6347 100644
--- a/system/theme.py
+++ b/system/theme.py
@@ -323,53 +323,51 @@ def get_qss(self, template: str) -> str:
return result
@staticmethod
- def get_combobox_style() -> str:
- """
- Get unified QComboBox style template.
-
- Returns:
- QSS string with theme tokens for QComboBox styling
- """
+ def get_completer_popup_style() -> str:
+ """Get themed QListView popup style for completers."""
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 {
+ QListView {
background-color: %background_alt%;
border: 1px solid %border%;
+ border-radius: 8px;
color: %text%;
selection-background-color: %highlight%;
selection-color: %background%;
outline: none;
}
- QComboBox QAbstractItemView::item {
- padding: 6px 10px;
- min-height: 20px;
+ QListView::item {
+ padding: 8px 12px;
+ border-bottom: 1px solid %border%;
}
- QComboBox QAbstractItemView::item:hover {
+ QListView::item:selected {
background-color: %highlight%;
color: %background%;
}
- QComboBox QAbstractItemView::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_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"]
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()
diff --git a/tests/test_app/test_plugin_bootstrap.py b/tests/test_app/test_plugin_bootstrap.py
new file mode 100644
index 00000000..f54ecb09
--- /dev/null
+++ b/tests/test_app/test_plugin_bootstrap.py
@@ -0,0 +1,154 @@
+from pathlib import Path
+import builtins
+import logging
+import os
+import sys
+from unittest.mock import MagicMock
+
+import app.bootstrap as bootstrap_module
+
+
+def test_bootstrap_exposes_plugin_manager(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()
+
+ 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")
+ 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_with_plugin_agnostic_gateway(monkeypatch):
+ fake_download_service = object()
+ download_ctor = MagicMock(return_value=fake_download_service)
+ monkeypatch.setattr(
+ "services.download.online_download_gateway.OnlineDownloadGateway",
+ 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 callable(kwargs["plugin_manager"])
+ assert kwargs["event_bus"] is bootstrap.event_bus
+
+
+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")
+
+
+def test_mpris_controller_logs_warning_when_linux_dbus_support_is_missing(monkeypatch, caplog):
+ original_import = builtins.__import__
+
+ 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)
+
+ bootstrap = bootstrap_module.Bootstrap(":memory:")
+
+ 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
new file mode 100644
index 00000000..3cde1caf
--- /dev/null
+++ b/tests/test_app/test_qqmusic_host_cleanup.py
@@ -0,0 +1,165 @@
+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():
+ assert not Path("services/online/download_service.py").exists()
+
+
+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",
+ ):
+ assert not Path(relative_path).exists(), relative_path
+
+
+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_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
+ 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" 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/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/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():
+ bootstrap_source = Path("app/bootstrap.py").read_text(encoding="utf-8")
+
+ 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():
+ 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():
+ 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/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
+
+
+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_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_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_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_genre_id.py b/tests/test_domain/test_genre_id.py
new file mode 100644
index 00000000..600f2c56
--- /dev/null
+++ b/tests/test_domain/test_genre_id.py
@@ -0,0 +1,21 @@
+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
+
+
+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"
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_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)
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..67d4f8ed 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",
@@ -137,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_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
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
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
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_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()
diff --git a/tests/test_infrastructure/test_http_client.py b/tests/test_infrastructure/test_http_client.py
index 0d9b5632..87563f5b 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()
@@ -337,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."""
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
diff --git a/tests/test_infrastructure/test_image_cache.py b/tests/test_infrastructure/test_image_cache.py
index 2c8c2eb6..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):
@@ -118,3 +121,45 @@ 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")]
+
+ 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
new file mode 100644
index 00000000..4565f1a7
--- /dev/null
+++ b/tests/test_infrastructure/test_image_cache_iteration_snapshot.py
@@ -0,0 +1,83 @@
+import time
+
+from infrastructure.cache.image_cache import ImageCache
+
+
+class _FakeStat:
+ def __init__(self, mtime: float, size: int = 1):
+ self.st_mtime = mtime
+ self.st_size = size
+
+
+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, 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, self._size)
+
+ 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 == {}
+
+
+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"]
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
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_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()")
diff --git a/tests/test_plugins/qqmusic_test_context.py b/tests/test_plugins/qqmusic_test_context.py
new file mode 100644
index 00000000..4755c2c4
--- /dev/null
+++ b/tests/test_plugins/qqmusic_test_context.py
@@ -0,0 +1,201 @@
+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 _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,
+ *,
+ 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(),
+ ),
+ settings=_SettingsBridge(),
+ 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_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_plugins/test_kugou_plugin.py b/tests/test_plugins/test_kugou_plugin.py
new file mode 100644
index 00000000..1e7ef4b5
--- /dev/null
+++ b/tests/test_plugins/test_kugou_plugin.py
@@ -0,0 +1,61 @@
+import base64
+import zlib
+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"
+
+
+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_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"
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_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_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") == []
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..e7994926
--- /dev/null
+++ b/tests/test_plugins/test_netease_cover_plugin.py
@@ -0,0 +1,162 @@
+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.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()
+
+ 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_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_netease_lyrics_plugin.py b/tests/test_plugins/test_netease_lyrics_plugin.py
new file mode 100644
index 00000000..b9cfd171
--- /dev/null
+++ b/tests/test_plugins/test_netease_lyrics_plugin.py
@@ -0,0 +1,99 @@
+from types import SimpleNamespace
+from unittest.mock import Mock
+
+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_lyrics_helpers_normalize_headers():
+ headers = netease_headers()
+
+ assert headers["Referer"] == "https://music.163.com/"
+ assert "Mozilla/5.0" in headers["User-Agent"]
+
+
+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_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 == ""
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")
diff --git a/tests/test_plugins/test_qqmusic_plugin.py b/tests/test_plugins/test_qqmusic_plugin.py
new file mode 100644
index 00000000..5bed8897
--- /dev/null
+++ b/tests/test_plugins/test_qqmusic_plugin.py
@@ -0,0 +1,737 @@
+from unittest.mock import Mock
+
+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
+
+
+def test_qqmusic_plugin_registers_expected_capabilities():
+ context = Mock()
+ plugin = QQMusicPlugin()
+
+ 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
+ assert context.services.register_artist_cover_source.call_count == 1
+ 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
+ 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_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_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: {
+ "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
+
+ 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_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_config_adapter(Mock(settings=settings))
+
+ assert adapter.get_online_music_download_dir() == "data/online_cache"
+
+
+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_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_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()
+ 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_plugins/test_qqmusic_theme_integration.py b/tests/test_plugins/test_qqmusic_theme_integration.py
new file mode 100644
index 00000000..1dc75bf0
--- /dev/null
+++ b/tests/test_plugins/test_qqmusic_theme_integration.py
@@ -0,0 +1,80 @@
+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
+from tests.test_plugins.qqmusic_test_context import bind_test_context
+
+
+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))
+ bind_test_context()
+ 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() == ""
+ assert dialog._cancel_button.property("role") == "cancel"
+
+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)
+ context = bind_test_context()
+ _stub_online_services(monkeypatch)
+
+ view = OnlineMusicView(config_manager=settings, qqmusic_service=None, plugin_context=context)
+ qtbot.addWidget(view)
+
+ 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)
+ context = bind_test_context()
+ _stub_online_services(monkeypatch)
+
+ view = OnlineMusicView(config_manager=settings, qqmusic_service=None, plugin_context=context)
+ qtbot.addWidget(view)
+
+ assert view._tabs.cursor().shape() == view._search_btn.cursor().shape()
+ assert view._tabs.styleSheet() == ""
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_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_queue_view.py b/tests/test_queue_view.py
index 45c001ac..16b137b9 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
@@ -126,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")
@@ -148,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_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")
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")
diff --git a/tests/test_repositories/test_cloud_repository.py b/tests/test_repositories/test_cloud_repository.py
index cdaaaa55..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)
@@ -805,3 +823,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
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_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)
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()
diff --git a/tests/test_repositories/test_queue_repository.py b/tests/test_repositories/test_queue_repository.py
index 2f63c31a..8066d589 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,9 +182,31 @@ 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_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 = [
@@ -273,8 +297,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 +313,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):
@@ -344,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
@@ -382,7 +430,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 +455,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..72a6dea0 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,17 +240,36 @@ 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"
+ 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(
@@ -266,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."""
@@ -312,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
@@ -364,17 +454,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 +482,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_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_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_cover_service_perf_paths.py b/tests/test_services/test_cover_service_perf_paths.py
index 5fcfc550..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,8 @@
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
@@ -43,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",
@@ -58,7 +102,8 @@ 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",
@@ -70,3 +115,59 @@ 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_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",
+ 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]["album_mid"] == "album-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_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_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"])
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_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
diff --git a/tests/test_services/test_lyrics_sources_perf_paths.py b/tests/test_services/test_lyrics_sources_perf_paths.py
index d84c3e1c..6512fa3f 100644
--- a/tests/test_services/test_lyrics_sources_perf_paths.py
+++ b/tests/test_services/test_lyrics_sources_perf_paths.py
@@ -2,40 +2,36 @@
from types import SimpleNamespace
-from services.sources.lyrics_sources import QQMusicLyricsSource, KugouLyricsSource
+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(
- "services.lyrics.qqmusic_lyrics.search_from_qqmusic",
- lambda *_args, **_kwargs: [
- {
- "id": "song-1",
- "title": "Song 1",
- "artist": "Singer 1",
- "album": "Album 1",
- "duration": 180,
- "cover_url": "cover-1",
- }
- ],
+ QQMusicOnlineProvider,
+ "search",
+ lambda *_args, **_kwargs: {
+ "tracks": [
+ {
+ "mid": "song-1",
+ "title": "Song 1",
+ "artist": "Singer 1",
+ "album": "Album 1",
+ "duration": 180,
+ "album_mid": "album-1",
+ }
+ ]
+ },
)
- source = QQMusicLyricsSource()
-
- results = source.search("Song 1", "Singer 1")
-
- assert len(results) == 1
- assert results[0].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"}]}
+ monkeypatch.setattr(
+ QQMusicOnlineProvider,
+ "get_cover_url",
+ lambda *_args, **_kwargs: "cover-1",
)
- source = KugouLyricsSource(SimpleNamespace(get=lambda *_args, **_kwargs: fake_response))
+ source = QQMusicLyricsPluginSource(SimpleNamespace())
results = source.search("Song 1", "Singer 1")
assert len(results) == 1
- assert results[0].id == "1"
- assert results[0].accesskey == "k1"
+ assert results[0].song_id == "song-1"
+ assert results[0].title == "Song 1"
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
diff --git a/tests/test_services/test_online_adapter.py b/tests/test_services/test_online_adapter.py
deleted file mode 100644
index bb435f74..00000000
--- a/tests/test_services/test_online_adapter.py
+++ /dev/null
@@ -1,56 +0,0 @@
-"""OnlineMusicAdapter normalization behavior tests."""
-
-from services.online.adapter import OnlineMusicAdapter
-
-
-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"
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..85c56d07
--- /dev/null
+++ b/tests/test_services/test_online_download_gateway.py
@@ -0,0 +1,162 @@
+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_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"
+ 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()
+
+
+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
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 fb365d70..00000000
--- a/tests/test_services/test_online_music_service_perf_paths.py
+++ /dev/null
@@ -1,58 +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"
diff --git a/tests/test_services/test_playback_service_library_sync.py b/tests/test_services/test_playback_service_library_sync.py
index 6c69194b..713b6c8f 100644
--- a/tests/test_services/test_playback_service_library_sync.py
+++ b/tests/test_services/test_playback_service_library_sync.py
@@ -61,3 +61,83 @@ 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",
+ )
+ 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
+ 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_playback_service_online_failures.py b/tests/test_services/test_playback_service_online_failures.py
index 04f4dd0c..d56b74c0 100644
--- a/tests/test_services/test_playback_service_online_failures.py
+++ b/tests/test_services/test_playback_service_online_failures.py
@@ -1,27 +1,30 @@
-"""Regression tests for QQ online download failure handling."""
+"""Regression tests for online download failure handling."""
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
-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")
@@ -78,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)
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_plugin_cover_registry.py b/tests/test_services/test_plugin_cover_registry.py
new file mode 100644
index 00000000..5e8362c2
--- /dev/null
+++ b/tests/test_services/test_plugin_cover_registry.py
@@ -0,0 +1,48 @@
+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]
+
+
+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
+ assert "QQMusic" not in names
+ assert "QQMusic" not in artist_names
+ assert "iTunes" not in names
+ assert "iTunes" not in artist_names
+ assert "Last.fm" not in names
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..3d8296a9
--- /dev/null
+++ b/tests/test_services/test_plugin_lyrics_registry.py
@@ -0,0 +1,48 @@
+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)
+
+
+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
+ assert "Kugou" not in names
+ assert "NetEase" not in names
+ assert names == set()
diff --git a/tests/test_services/test_qqmusic_lyrics_perf_paths.py b/tests/test_services/test_qqmusic_lyrics_perf_paths.py
index 764726e8..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 == [
{
@@ -22,3 +33,41 @@ 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(runtime_client, "QQMusicClient", _FakeClient)
+ monkeypatch.setattr(runtime_client, "_shared_client", None, raising=False)
+
+ client = runtime_client.get_shared_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_secret(self, plugin_id, key, default=""):
+ return self.values.get((plugin_id, key), default)
+
+ def set_plugin_secret(self, plugin_id, key, value):
+ self.saved.append((plugin_id, key, value))
+
+ config = _Config()
+
+ assert runtime_client.get_credential_from_config(config)["musickey"] == "secret"
+
+ payload = {"musicid": "2", "musickey": "new"}
+ runtime_client.save_credential_to_config(config, payload)
+
+ assert config.saved == [("qqmusic", "credential", '{"musicid": "2", "musickey": "new"}')]
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
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..6686fa4a
--- /dev/null
+++ b/tests/test_services/test_qqmusic_plugin_source_adapters.py
@@ -0,0 +1,217 @@
+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
+from plugins.builtin.qqmusic.lib.provider import QQMusicOnlineProvider
+
+
+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", 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)
+ 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 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 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):
+ def fake_search(self, keyword, search_type="song", page=1, page_size=30):
+ assert keyword == "Singer 1 Song 1"
+ assert search_type == "song"
+ assert page == 1
+ assert page_size == 5
+ 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)
+
+ 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_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,
+ "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"
+
+
+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 b7a0e31b..5ed5a8ee 100644
--- a/tests/test_services/test_qqmusic_quality_support.py
+++ b/tests/test_services/test_qqmusic_quality_support.py
@@ -1,6 +1,6 @@
-from services.cloud.qqmusic.client import QQMusicClient
-from services.cloud.qqmusic.qr_login import QQMusicQRLogin
-from services.cloud.qqmusic.common import (
+from plugins.builtin.qqmusic.lib.qqmusic_client import QQMusicClient
+from plugins.builtin.qqmusic.lib.qr_login import QQMusicQRLogin
+from plugins.builtin.qqmusic.lib.common import (
APIConfig,
parse_quality,
get_selectable_qualities,
@@ -35,11 +35,12 @@ 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
+ 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_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"
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
diff --git a/tests/test_services/test_qqmusic_service_perf_paths.py b/tests/test_services/test_qqmusic_service_perf_paths.py
index b6badc70..8db92aa0 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.qqmusic_service import QQMusicService
def test_get_playback_url_info_uses_first_non_empty_url():
@@ -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,
+ }
+ ]
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..c0abc968
--- /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.qqmusic_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_services/test_quality_utils.py b/tests/test_services/test_quality_utils.py
new file mode 100644
index 00000000..3c0c14ae
--- /dev/null
+++ b/tests/test_services/test_quality_utils.py
@@ -0,0 +1,6 @@
+from plugins.builtin.qqmusic.lib.common 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/tests/test_services/test_queue_service.py b/tests/test_services/test_queue_service.py
index 9c5bce91..1ac99817 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),
@@ -193,6 +195,49 @@ def test_enrich_metadata_batch_preserves_cached_qq_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()
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
diff --git a/tests/test_services/test_singleflight_media_fetch.py b/tests/test_services/test_singleflight_media_fetch.py
index 64d0bf87..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("services.lyrics.qqmusic_lyrics.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_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/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"
diff --git a/tests/test_system/test_config_security.py b/tests/test_system/test_config_security.py
index 07404602..8f734140 100644
--- a/tests/test_system/test_config_security.py
+++ b/tests/test_system/test_config_security.py
@@ -42,35 +42,34 @@ 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."""
+def test_plugin_settings_are_namespaced(tmp_path):
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)
+ config.set_plugin_setting("qqmusic", "quality", "flac")
- 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"
+ assert repo.values["plugins.qqmusic.quality"] == "flac"
+ assert config.get_plugin_setting("qqmusic", "quality") == "flac"
-def test_qqmusic_credential_keeps_legacy_plaintext_compatible(tmp_path):
- """Legacy plaintext settings should still be readable during migration."""
+def test_plugin_secret_is_encrypted_at_rest(tmp_path):
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()
+ 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"}'
+
+
+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 credential["musicid"] == "legacy-id"
- assert credential["musickey"] == "legacy-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/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..e5eeb885
--- /dev/null
+++ b/tests/test_system/test_harmony_plugin_api_package.py
@@ -0,0 +1,86 @@
+from __future__ import annotations
+
+import ast
+import importlib.util
+from pathlib import Path
+import subprocess
+
+
+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_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()
+
+ 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"
+ 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"))
+
+
+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_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"
diff --git a/tests/test_system/test_mpris.py b/tests/test_system/test_mpris.py
new file mode 100644
index 00000000..be22109e
--- /dev/null
+++ b/tests/test_system/test_mpris.py
@@ -0,0 +1,251 @@
+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
+
+
+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)
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..5a42d664
--- /dev/null
+++ b/tests/test_system/test_plugin_cover_helpers.py
@@ -0,0 +1,38 @@
+from types import SimpleNamespace
+
+from system.plugins.online_cover_helpers import (
+ get_online_artist_cover_url,
+ get_online_cover_url,
+)
+
+
+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')}",
+ )
+ fake_manager = SimpleNamespace(
+ registry=SimpleNamespace(cover_sources=lambda: [source])
+ )
+ monkeypatch.setattr(
+ "app.bootstrap.Bootstrap.instance",
+ lambda: SimpleNamespace(plugin_manager=fake_manager),
+ )
+
+ assert get_online_cover_url(provider_id="qqmusic", album_id="album123", size=500) == "cover:album123"
+
+
+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}",
+ )
+ fake_manager = SimpleNamespace(
+ registry=SimpleNamespace(artist_cover_sources=lambda: [source])
+ )
+ monkeypatch.setattr(
+ "app.bootstrap.Bootstrap.instance",
+ lambda: SimpleNamespace(plugin_manager=fake_manager),
+ )
+
+ 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_import_guard.py b/tests/test_system/test_plugin_import_guard.py
new file mode 100644
index 00000000..9fd11000
--- /dev/null
+++ b/tests/test_system/test_plugin_import_guard.py
@@ -0,0 +1,118 @@
+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):
+ 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)
+
+
+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_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()
+ (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
+
+
+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_installer.py b/tests/test_system/test_plugin_installer.py
new file mode 100644
index 00000000..0eb9a377
--- /dev/null
+++ b/tests/test_system/test_plugin_installer.py
@@ -0,0 +1,334 @@
+from pathlib import Path
+import json
+import shutil
+import zipfile
+
+import pytest
+
+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):
+ plugin_root = tmp_path / "plugin"
+ plugin_root.mkdir()
+ (plugin_root / "plugin_main.py").write_text(
+ "from services.library.library_service import LibraryService\n",
+ encoding="utf-8",
+ )
+
+ with pytest.raises(PluginInstallError):
+ 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:
+ 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()
+
+
+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"
+
+
+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()
+
+
+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
+):
+ 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"
+
+
+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)
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..94fcd625
--- /dev/null
+++ b/tests/test_system/test_plugin_lyrics_helpers.py
@@ -0,0 +1,19 @@
+from types import SimpleNamespace
+
+from system.plugins.online_lyrics_helpers import download_online_lyrics
+
+
+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}",
+ )
+ fake_manager = SimpleNamespace(
+ registry=SimpleNamespace(lyrics_sources=lambda: [source])
+ )
+ monkeypatch.setattr(
+ "app.bootstrap.Bootstrap.instance",
+ lambda: SimpleNamespace(plugin_manager=fake_manager),
+ )
+
+ assert download_online_lyrics("mid123", provider_id="qqmusic") == "lyrics:mid123"
diff --git a/tests/test_system/test_plugin_manager.py b/tests/test_system/test_plugin_manager.py
new file mode 100644
index 00000000..6a985498
--- /dev/null
+++ b/tests/test_system/test_plugin_manager.py
@@ -0,0 +1,1287 @@
+import json
+from pathlib import Path
+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
+
+
+class _ContextFactory:
+ 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()
+
+
+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")
+
+ 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
+
+
+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
+
+
+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_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"
+ (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"]
+
+
+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 True
+ 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 True
+ 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
+
+
+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"
+ ]
+
+
+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() == []
+
+
+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"
+ ]
+
+
+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
+
+
+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()
+ (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)
+
+
+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"]
diff --git a/tests/test_system/test_plugin_manifest.py b/tests/test_system/test_plugin_manifest.py
new file mode 100644
index 00000000..53dfc4b5
--- /dev/null
+++ b/tests/test_system/test_plugin_manifest.py
@@ -0,0 +1,178 @@
+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 PluginOnlineProvider, PluginTrack
+from harmony_plugin_api.registry_types import SidebarEntrySpec
+from harmony_plugin_api.registry_types import SettingsTabSpec
+
+
+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",
+ }
+ )
+
+
+@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)
+
+
+@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 = []
+
+ 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=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 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
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..2cfa7677
--- /dev/null
+++ b/tests/test_system/test_plugin_online_bridge.py
@@ -0,0 +1,258 @@
+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 (
+ BootstrapPluginContextFactory,
+ PluginServiceBridgeImpl,
+ PluginSettingsBridgeImpl,
+ PluginStorageBridgeImpl,
+ PluginUiBridgeImpl,
+)
+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():
+ 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_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")
+
+ 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()
+ playback_service.engine = 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",
+ provider_id="qqmusic",
+ quality="flac",
+ progress_callback=None,
+ force=False,
+ )
+
+ bridge.add_online_track(request)
+ library_service.add_online_track.assert_called_once_with(
+ "qqmusic",
+ "mid-1",
+ "Song 1",
+ "Singer 1",
+ "Album 1",
+ 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()
+ 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():
+ 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
+ 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"
+
+
+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)
diff --git a/tests/test_system/test_plugin_packaging.py b/tests/test_system/test_plugin_packaging.py
new file mode 100644
index 00000000..43646877
--- /dev/null
+++ b/tests/test_system/test_plugin_packaging.py
@@ -0,0 +1,34 @@
+import zipfile
+from pathlib import Path
+
+from system.plugins.installer import PluginInstaller
+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
+
+
+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()
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() == []
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.py b/tests/test_system/test_plugin_ui_bridge.py
new file mode 100644
index 00000000..125375a6
--- /dev/null
+++ b/tests/test_system/test_plugin_ui_bridge.py
@@ -0,0 +1,130 @@
+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.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)
+ assert callable(context.ui.dialogs.critical)
+ assert callable(context.ui.dialogs.setup_title_bar)
+ 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):
+ 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"
+ 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_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}!!!") == ""
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..df9b99aa
--- /dev/null
+++ b/tests/test_system/test_theme_foundation_styles.py
@@ -0,0 +1,63 @@
+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
+
+
+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_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_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/tests/test_ui/test_dialog_action_buttons.py b/tests/test_ui/test_dialog_action_buttons.py
new file mode 100644
index 00000000..4339c85c
--- /dev/null
+++ b/tests/test_ui/test_dialog_action_buttons.py
@@ -0,0 +1,261 @@
+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.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
+
+
+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"]) == 0
+
+
+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
+
+
+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
new file mode 100644
index 00000000..5b35884e
--- /dev/null
+++ b/tests/test_ui/test_dialog_title_bar.py
@@ -0,0 +1,83 @@
+from unittest.mock import Mock
+
+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
+
+
+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() == ""
+
+
+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
new file mode 100644
index 00000000..4c28b6f7
--- /dev/null
+++ b/tests/test_ui/test_foundation_theme_cleanup.py
@@ -0,0 +1,65 @@
+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
+
+
+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() == ""
+
+
+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..d8df0477 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
@@ -47,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",
+ ),
]
@@ -254,3 +263,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_library_view_redownload.py b/tests/test_ui/test_library_view_redownload.py
index 81f1f707..52a4f610 100644
--- a/tests/test_ui/test_library_view_redownload.py
+++ b/tests/test_ui/test_library_view_redownload.py
@@ -2,26 +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.cloud.qqmusic.common 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 = []
@@ -52,35 +51,84 @@ def test_redownload_qq_track_uses_configured_quality_as_dialog_default(
player,
config_manager=MagicMock(),
)
+ return view, library_service, history_service
+
- show_dialog = MagicMock(return_value=None)
- monkeypatch.setattr(redownload_dialog_module.RedownloadDialog, "show_dialog", show_dialog)
+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),
+ )
+
+ 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_qqmusic_quality=lambda: "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_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_redownload_dialog_returns_selected_quality_when_enabled(qapp):
+ _init_theme()
+
+ 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"
+
+
+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,
@@ -88,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,
@@ -96,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_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
new file mode 100644
index 00000000..7dbb3051
--- /dev/null
+++ b/tests/test_ui/test_lyrics_download_dialog_thread_cleanup.py
@@ -0,0 +1,137 @@
+"""LyricsDownloadDialog thread cleanup behavior tests."""
+
+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):
+ class FakeThread:
+ pass
+
+ thread = FakeThread()
+ for name, value in attrs.items():
+ setattr(thread, name, value)
+ 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(
+ 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/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/tests/test_ui/test_main_window_components.py b/tests/test_ui/test_main_window_components.py
index 39a15cca..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
@@ -53,14 +55,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
@@ -187,6 +190,18 @@ 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_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()
@@ -250,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/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/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/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_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/tests/test_ui/test_online_music_view_async.py b/tests/test_ui/test_online_music_view_async.py
index b6e04357..49d1cb1f 100644
--- a/tests/test_ui/test_online_music_view_async.py
+++ b/tests/test_ui/test_online_music_view_async.py
@@ -3,8 +3,10 @@
from unittest.mock import Mock, patch
from domain.online_music import OnlineTrack, SearchResult, SearchType
-from ui.views.online_music_view import OnlineMusicView
-import ui.views.online_music_view as online_music_view
+from plugins.builtin.qqmusic.lib import i18n as plugin_i18n
+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
def _make_view_for_search_callbacks():
@@ -136,6 +138,164 @@ 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("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)
+ view._config.set_plugin_setting.assert_any_call("qqmusic", "nick", "")
+
+
+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.online_music_view.create_qqmusic_login_dialog",
+ dialog_ctor,
+ )
+
+ OnlineMusicView._show_login_dialog(view)
+
+ dialog_ctor.assert_called_once_with(None, view)
+ assert dialog.credentials_obtained.connected == view._on_credentials_obtained
+ dialog.exec.assert_called_once_with()
+
+
+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(
+ "plugins.builtin.qqmusic.lib.online_music_view.create_qqmusic_service",
+ lambda credential: _FakeQQMusicService(credential),
+ )
+
+ OnlineMusicView._refresh_qqmusic_service(view)
+
+ view._config.get_plugin_secret.assert_called_once_with("qqmusic", "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()
+ 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)
+
+ 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"
+
+ context.events.language_changed.emit("en")
+
+ assert plugin_i18n.get_language() == "en"
+
+
class _FakeSignal:
def __init__(self):
self.connected = None
@@ -200,6 +360,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
@@ -220,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",
}
@@ -254,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/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 17cbf1d2..612a07f5 100644
--- a/tests/test_ui/test_online_tracks_list_view.py
+++ b/tests/test_ui/test_online_tracks_list_view.py
@@ -8,7 +8,9 @@
from PySide6.QtWidgets import QApplication
from domain.online_music import OnlineTrack
-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():
@@ -27,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):
@@ -35,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()
@@ -73,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()
@@ -89,3 +97,23 @@ 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()
+ 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_online_views_architecture.py b/tests/test_ui/test_online_views_architecture.py
index 2d99bc46..972b9b76 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,11 +83,71 @@ 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)
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",
+ )
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/tests/test_ui/test_plugin_settings_tab.py b/tests/test_ui/test_plugin_settings_tab.py
new file mode 100644
index 00000000..4f62fe7d
--- /dev/null
+++ b/tests/test_ui/test_plugin_settings_tab.py
@@ -0,0 +1,748 @@
+from unittest.mock import Mock
+
+from PySide6.QtCore import QRect, Qt
+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, t
+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
+
+
+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 _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 _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
+ 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):
+ _init_theme_manager()
+ 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)
+
+ 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_shows_load_errors_in_custom_rows(qtbot):
+ _init_theme_manager()
+ 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):
+ _init_theme_manager()
+ 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")
+ _init_theme_manager()
+ 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)
+
+ 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")
+ _init_theme_manager()
+ 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_uses_global_panel_table_variant(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)
+ assert table.property("variant") == "panel"
+ 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"
+ 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):
+ 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
+
+
+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"
+
+ 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
+
+
+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"
+ 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)
+
+ 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_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
+
+ 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()
+
+ 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
+ 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_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"
+ 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)
+
+ 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_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_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: {
+ "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 {" not in QQMusicLoginDialog._STYLE_TEMPLATE
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..d7565dcb
--- /dev/null
+++ b/tests/test_ui/test_plugin_sidebar_integration.py
@@ -0,0 +1,433 @@
+from unittest.mock import Mock, patch
+
+import pytest
+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
+
+
+@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", 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):
+ 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
+
+
+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 f9017027..6f153afa 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",
@@ -314,79 +315,15 @@
"error": "Error",
"ai_tab": "AI Enhancement",
"acoustid_tab": "AcoustID",
- "qqmusic_tab": "QQ Music",
- "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)",
+ "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",
+ "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",
@@ -548,6 +485,7 @@
"search_history": "Search History",
"clear_all": "Clear All",
"songs": "Songs",
+ "plays": "plays",
"singers": "Singers",
"fans": "Fans",
"ten_thousand": "",
@@ -561,20 +499,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 0fdbd51d..5fa39da8 100644
--- a/translations/zh.json
+++ b/translations/zh.json
@@ -54,6 +54,7 @@
"previous": "上一首",
"quit": "退出",
"title": "标题",
+ "version": "版本",
"source": "来源",
"source_local": "本地文件",
"source_quark": "夸克网盘",
@@ -314,79 +315,15 @@
"error": "错误",
"ai_tab": "AI 增强",
"acoustid_tab": "AcoustID",
- "qqmusic_tab": "QQ音乐",
- "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)",
+ "plugins_tab": "插件",
+ "plugins_install_zip": "安装 Zip",
+ "plugins_install_url": "在线安装",
+ "plugins_install_warning": "只安装来自受信任来源的插件。插件会在应用内执行受信任的 Python 代码。",
+ "plugins_load_error": "加载错误",
+ "plugins_enabled": "已启用",
+ "plugins_disabled": "已禁用",
+ "plugins_source_builtin": "内置",
+ "plugins_source_external": "外部",
"redownload": "⬇ 重新下载",
"redownload_hint": "提示:高音质可能因版权限制无法下载,将自动降级到可用音质",
"select_quality": "选择音质",
@@ -548,6 +485,7 @@
"search_history": "搜索历史",
"clear_all": "清空",
"songs": "歌曲",
+ "plays": "次播放",
"singers": "歌手",
"fans": "粉丝",
"ten_thousand": "万",
@@ -561,20 +499,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/__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',
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 e8c94cb0..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 services.lyrics.qqmusic_lyrics 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 services.lyrics.qqmusic_lyrics 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()
@@ -174,16 +193,7 @@ 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 {
+ QPushButton#coverSearchBtn {
background-color: %border%;
color: %text%;
border: 1px solid %background_hover%;
@@ -191,13 +201,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%;
@@ -341,40 +352,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)
@@ -387,6 +365,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)
@@ -467,12 +446,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)
@@ -623,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}%")
@@ -652,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/base_rename_dialog.py b/ui/dialogs/base_rename_dialog.py
index db0f5f07..6a52fbbb 100644
--- a/ui/dialogs/base_rename_dialog.py
+++ b/ui/dialogs/base_rename_dialog.py
@@ -40,69 +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;
- }
- 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):
super().__init__(parent)
self._worker = None
@@ -116,6 +53,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)
@@ -137,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)
@@ -214,6 +151,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)
@@ -363,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 a0a46276..bdd8e6d3 100644
--- a/ui/dialogs/cloud_login_dialog.py
+++ b/ui/dialogs/cloud_login_dialog.py
@@ -27,39 +27,31 @@ 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 {
+ 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 +132,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 +165,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 +238,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/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/dialogs/edit_media_info_dialog.py b/ui/dialogs/edit_media_info_dialog.py
index a75ad890..c987cd65 100644
--- a/ui/dialogs/edit_media_info_dialog.py
+++ b/ui/dialogs/edit_media_info_dialog.py
@@ -36,80 +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;
- }
- 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 = """
QProgressBar {
border: 2px solid %border%;
@@ -141,6 +67,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()
@@ -167,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
@@ -187,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)
@@ -297,6 +223,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"))
@@ -318,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 = "百度网盘"
@@ -573,7 +498,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 40ca1d04..03f07903 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)
@@ -119,12 +69,13 @@ 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)
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 +83,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..205e9e55 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,
@@ -29,6 +28,8 @@
logger = logging.getLogger(__name__)
+_ACTIVE_LYRICS_SEARCH_THREADS: set[QThread] = set()
+
class LyricsSearchThread(QThread):
"""Thread for searching lyrics with progressive updates."""
@@ -77,29 +78,11 @@ 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 = """
- 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%;
@@ -114,55 +97,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__(
@@ -191,13 +125,20 @@ 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._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
self.setWindowFlags(Qt.WindowType.Dialog | Qt.FramelessWindowHint)
self.setAttribute(Qt.WidgetAttribute.WA_TranslucentBackground)
+ self.setProperty("shell", True)
self._setup_shadow()
self._setup_ui()
@@ -258,12 +199,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()
@@ -274,6 +209,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)
@@ -284,103 +220,137 @@ 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 isValid(thread) and 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."""
# 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
- )
-
- 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)
-
- # Define source priority (lower number = higher priority)
- source_priority = {
- 'qqmusic': 0, # QQ Music first
- 'netease': 1,
- 'kugou': 2,
- 'lrclib': 3,
- }
+ 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', '')),
+ )
- # Sort by score descending, then by source priority (QQ Music first for same score)
- 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
- ))
-
- # 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 priority
- unique_results.sort(key=lambda x: (
- -x.get('_score', 0),
- source_priority.get(x.get('source', ''), 99)
- ))
-
- # 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)
@@ -396,6 +366,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.
@@ -427,7 +403,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
@@ -444,33 +420,22 @@ 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()
+ 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
@@ -481,7 +446,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 +458,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 +471,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/dialogs/lyrics_edit_dialog.py b/ui/dialogs/lyrics_edit_dialog.py
index 5640c2a9..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%;
@@ -55,24 +40,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 +139,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/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
new file mode 100644
index 00000000..2e1a2ae7
--- /dev/null
+++ b/ui/dialogs/plugin_management_tab.py
@@ -0,0 +1,194 @@
+from __future__ import annotations
+
+from PySide6.QtCore import Qt
+from PySide6.QtWidgets import (
+ QFileDialog,
+ QHeaderView,
+ QHBoxLayout,
+ QLabel,
+ QLineEdit,
+ 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
+ def __init__(self, plugin_manager, parent=None):
+ super().__init__(parent)
+ self._plugin_manager = plugin_manager
+ self._table = QTableWidget(self)
+ self._url_input = QLineEdit(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)
+
+ self._table.setObjectName("pluginManagementTable")
+ self._table.setProperty("variant", "panel")
+ 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)
+
+ 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)
+ 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 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()
+
+ 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:
+ return
+
+ def _resolve_theme_manager(self):
+ try:
+ return ThemeManager.instance()
+ except ValueError:
+ return None
+
+ 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/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 2c69d4ea..bcd82b81 100644
--- a/ui/dialogs/redownload_dialog.py
+++ b/ui/dialogs/redownload_dialog.py
@@ -1,83 +1,76 @@
"""
-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.cloud.qqmusic.common 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 = """
- QWidget#dialogContainer {
+ QLabel#hintLabel {
+ color: %text_secondary%;
+ font-size: 12px;
+ }
+ """
+ _POPUP_STYLE_TEMPLATE = """
+ QListView {
background-color: %background_alt%;
- color: %text%;
border: 1px solid %border%;
- border-radius: 12px;
- }
- QLabel#dialogTitle {
color: %text%;
- font-size: 15px;
- font-weight: bold;
+ selection-background-color: %highlight%;
+ selection-color: %background%;
+ outline: none;
}
- QLabel {
- color: %text%;
- font-size: 13px;
+ QListView::item {
+ padding: 6px 10px;
+ min-height: 20px;
}
- QLabel#hintLabel {
- color: %text_secondary%;
- font-size: 12px;
- }
- QPushButton {
+ QListView::item:hover {
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%;
+ QListView::item:selected {
+ background-color: %highlight%;
+ color: %background%;
}
- QPushButton[role="cancel"]:hover {
- background-color: %background_hover%;
+ """
+ _POPUP_CONTAINER_STYLE_TEMPLATE = """
+ QFrame {
+ background-color: %background_alt%;
+ border: 1px solid %border%;
}
- """ + ThemeManager.get_combobox_style() + """
"""
- 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)
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):
@@ -105,65 +98,112 @@ 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.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))
+
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 45bed481..9e8e52a0 100644
--- a/ui/dialogs/settings_dialog.py
+++ b/ui/dialogs/settings_dialog.py
@@ -1,12 +1,11 @@
-"""
-General Settings Dialog for configuring AI, AcoustID, and QQ Music.
-"""
-import importlib.util
+"""General Settings Dialog for configuring host and plugin settings."""
+import importlib
import logging
import os
from typing import Optional
-from PySide6.QtCore import Qt, QThread, Signal
+from app.bootstrap import Bootstrap
+from PySide6.QtCore import Qt
from PySide6.QtGui import QColor, QPainterPath, QRegion
from PySide6.QtWidgets import (
QDialog, QVBoxLayout, QHBoxLayout, QLabel, QLineEdit,
@@ -20,12 +19,8 @@
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,
- get_quality_label_key,
- normalize_quality,
-)
# Configure logging
logger = logging.getLogger(__name__)
@@ -41,119 +36,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."""
-
- _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;
- }
- 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%;
- border: 1px solid %border%;
- border-radius: 4px;
- padding: 8px 16px;
- font-size: 13px;
- }
- QPushButton:hover {
- background-color: %selection%;
- }
- 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() + """
- """
+ """Dialog for configuring host and plugin settings."""
def __init__(self, config_manager, parent=None):
"""
@@ -165,13 +49,14 @@ 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
+ self._plugin_settings_tabs = []
# 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()
@@ -191,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)
@@ -213,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()
@@ -322,82 +207,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)
@@ -850,12 +659,23 @@ 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"))
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():
+ plugin_tab = spec.widget_factory(bootstrap.plugin_manager, self)
+ self._plugin_settings_tabs.append(plugin_tab)
+ tab_widget.addTab(
+ plugin_tab,
+ spec.title_provider() if callable(getattr(spec, "title_provider", None)) else spec.title,
+ )
layout.addWidget(tab_widget)
@@ -864,11 +684,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)
@@ -947,13 +769,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()):
@@ -965,10 +780,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()):
@@ -1029,6 +840,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)
@@ -1039,21 +853,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)
@@ -1102,6 +906,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()
@@ -1175,110 +995,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:
@@ -1711,9 +1427,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()
@@ -1907,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/dialogs/sleep_timer_dialog.py b/ui/dialogs/sleep_timer_dialog.py
index 6ceeb4a3..e21664cf 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)
@@ -270,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%; }
@@ -292,10 +291,6 @@ def _apply_styles(self):
QSpinBox::up-button, QSpinBox::down-button {
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/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/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/strategies/album_search_strategy.py b/ui/strategies/album_search_strategy.py
index 40f78b84..4f443671 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.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 903fd23d..63f9571d 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.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 89164700..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 services.lyrics.qqmusic_lyrics 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 4c0a8fc7..0cb13752 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.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/styles.qss b/ui/styles.qss
index c40e1b8b..fa74d6ef 100644
--- a/ui/styles.qss
+++ b/ui/styles.qss
@@ -14,6 +14,26 @@ QMainWindow {
color: %text%;
}
+QDialog[shell="true"] {
+ background-color: %background_alt%;
+ color: %text%;
+ border: 1px solid %border%;
+ border-radius: 12px;
+}
+
+QWidget#dialogContainer,
+QWidget#settingsContainer {
+ background-color: %background_alt%;
+ color: %text%;
+ border: 1px solid %border%;
+ border-radius: 12px;
+}
+
+QWidget#dialogContainer QLabel {
+ color: %text%;
+ font-size: 13px;
+}
+
/* Scrollbar */
QScrollBar:vertical {
background: transparent;
@@ -90,29 +110,153 @@ 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: %background%;
+ border: 1px solid %highlight%;
+ border-radius: 6px;
+ padding: 8px 20px;
+ min-width: 80px;
+ font-size: 13px;
+ font-weight: bold;
+}
+
+QPushButton[role="primary"]:hover {
+ background-color: %highlight_hover%;
+}
+
+QPushButton[role="primary"]:pressed {
+ background-color: %highlight_hover%;
+}
+
+QPushButton[role="cancel"] {
+ 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: %border%;
+}
+
+QPushButton[role="cancel"]:pressed {
+ background-color: %border%;
+}
+
+QWidget#titleBar,
+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 {
+ 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%;
+}
+
+QPushButton#closeBtn:hover {
+ background-color: #e81123;
+ color: white;
+}
+
/* 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: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 */
QLabel {
color: %text%;
@@ -123,6 +267,160 @@ QLabel[secondary="true"] {
color: %text_secondary%;
}
+QLabel#titleLabel,
+QLabel#dialogTitle {
+ color: %text%;
+ font-size: 15px;
+ font-weight: bold;
+}
+
+QLabel#trackLabel {
+ color: %text_secondary%;
+ font-size: 13px;
+}
+
+QLabel#dialogLabel {
+ color: %text_secondary%;
+ font-size: 13px;
+}
+
+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;
+}
+
+QComboBox::drop-down {
+ border: none;
+ 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%;
+ 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%;
+}
+
+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%;
+ border-top: none;
+ background-color: %background_alt%;
+}
+
+QTabBar::tab {
+ background-color: transparent;
+ color: %text_secondary%;
+ padding: 8px 16px;
+ border: none;
+ border-bottom: 2px solid transparent;
+}
+
+QTabBar::tab:selected {
+ color: %highlight%;
+ border-bottom-color: %highlight%;
+}
+
+QTabBar::tab:hover:!selected {
+ color: %highlight%;
+}
+
/* Lists */
QListWidget {
background-color: transparent;
@@ -176,6 +474,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/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..99dfd98b 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
@@ -231,40 +232,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 +408,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)
@@ -1167,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
@@ -1911,7 +1884,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):
@@ -2327,7 +2300,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..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/"}
@@ -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/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 0d7660d7..95f2f021 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,
@@ -20,8 +21,6 @@
from domain.playback import PlaybackState
from domain.track import Track
-from services.cloud.qqmusic.common 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
@@ -30,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
@@ -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
@@ -131,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)
@@ -162,8 +131,9 @@ 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)
# Add spacing between filter and search box
@@ -174,6 +144,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)
@@ -223,10 +194,11 @@ 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)
- 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
)
@@ -240,10 +212,11 @@ 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)
- 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)
@@ -254,10 +227,11 @@ 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)
- 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(
@@ -273,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."""
@@ -292,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:
@@ -830,6 +813,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)
@@ -936,89 +923,93 @@ 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 _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 _on_history_organize_files(self, tracks: list):
+ """Open the organize-files dialog from the history list."""
+ self._open_organize_files_dialog(tracks)
- bootstrap = Bootstrap.instance()
- song_mid = track.cloud_file_id
- default_quality = bootstrap.config.get_qqmusic_quality() if bootstrap and bootstrap.config else "320"
- quality = RedownloadDialog.show_dialog(
- track.title,
- current_quality=default_quality,
- parent=self,
+ 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 quality is None:
+ if dialog.exec() == QDialog.Accepted:
+ self.refresh()
+
+ 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
- online_download_service = bootstrap.online_download_service
+ 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
- # Delete cached files for all quality variants
- online_download_service.delete_cached_file(song_mid)
+ from app.bootstrap import Bootstrap
+ bootstrap = Bootstrap.instance()
+ service = getattr(bootstrap, "online_download_service", None)
+ if not service:
+ self._status_label.setText(t("not_supported_yet"))
+ return
- # 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
+ 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,
)
- self._status_label.setText(
- f"{t('downloading')}... {track.title} ({self._format_quality_label(quality)})"
+ if not selected_quality:
+ return
+
+ 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 ade4b51b..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:
@@ -523,6 +521,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 +705,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/views/queue_view.py b/ui/views/queue_view.py
index c3319dc2..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:
@@ -961,6 +960,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)
@@ -1522,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,
@@ -1554,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 66e135bd..49f6dd67 100644
--- a/ui/widgets/context_menus.py
+++ b/ui/widgets/context_menus.py
@@ -10,25 +10,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."""
@@ -39,20 +20,33 @@ 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)
- 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)."""
- 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
@@ -88,11 +82,13 @@ 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:
+ 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))
+
if len(tracks) == 1 and tracks[0].path:
a = menu.addAction(t("open_file_location"))
a.triggered.connect(lambda: self.open_file_location.emit(tracks[0]))
@@ -115,67 +111,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/equalizer_widget.py b/ui/widgets/equalizer_widget.py
index 6aa91375..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):
@@ -547,36 +541,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 +622,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):
diff --git a/ui/widgets/player_controls.py b/ui/widgets/player_controls.py
index d302b98f..6261dac2 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)
@@ -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 8dcc5c8b..6a411cb8 100644
--- a/ui/widgets/recommend_card.py
+++ b/ui/widgets/recommend_card.py
@@ -1,9 +1,9 @@
"""
-Recommendation card widgets for QQ Music recommendations.
+Recommendation card widgets for online recommendations.
"""
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/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 ===
diff --git a/ui/widgets/toggle_switch.py b/ui/widgets/toggle_switch.py
new file mode 100644
index 00000000..7ed1f196
--- /dev/null
+++ b/ui/widgets/toggle_switch.py
@@ -0,0 +1,151 @@
+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
+
+from system.theme import ThemeManager
+
+
+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(ThemeManager.instance().current_theme.highlight)
+ 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())
diff --git a/ui/windows/components/lyrics_panel.py b/ui/windows/components/lyrics_panel.py
index 427f288d..46bcdbd4 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
@@ -136,7 +135,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."""
@@ -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
@@ -229,7 +224,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 +234,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 +247,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
@@ -311,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)
@@ -325,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
)
@@ -345,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()
@@ -455,7 +426,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 +486,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):
@@ -543,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()
@@ -566,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()
@@ -579,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()
diff --git a/ui/windows/components/online_music_handler.py b/ui/windows/components/online_music_handler.py
index 8fc2756a..97fb0b35 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,38 @@ 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."""
+ 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)
+ normalized = str(value or "").strip()
+ if normalized and normalized.lower() != "online":
+ return normalized
+ return ""
+
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 +96,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 +112,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 +126,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 +139,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 +156,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 +178,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 +191,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 +208,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,23 +232,27 @@ 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.
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", "")
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(
+ track_id = bootstrap.library_service.add_online_track(
+ provider_id=resolved_provider_id,
song_mid=song_mid,
title=title,
artist=artist,
@@ -231,13 +264,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 +290,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.
@@ -265,6 +299,8 @@ def insert_multiple_to_queue(self, tracks_data: List[Tuple[str, dict]]):
"""
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")
@@ -272,10 +308,11 @@ 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(
+ track_id = bootstrap.library_service.add_online_track(
+ provider_id=resolved_provider_id,
song_mid=song_mid,
title=title,
artist=artist,
@@ -287,13 +324,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 +348,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.
@@ -318,6 +361,8 @@ def play_online_tracks(self, start_index: int, tracks_data: List[Tuple[str, dict
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:
@@ -326,10 +371,11 @@ 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(
+ track_id = bootstrap.library_service.add_online_track(
+ provider_id=resolved_provider_id,
song_mid=song_mid,
title=title,
artist=artist,
@@ -341,13 +387,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/components/sidebar.py b/ui/windows/components/sidebar.py
index 3893ec77..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
@@ -34,16 +35,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 +130,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")),
@@ -186,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)
@@ -193,6 +194,32 @@ 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,
+ icon_path: str | None = None,
+ title_provider=None,
+ ) -> None:
+ """Add a plugin-provided navigation button before the footer actions."""
+ 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))
+
def _on_nav_clicked(self, page_index: int):
"""Handle navigation button click."""
# Uncheck other buttons
@@ -236,7 +263,6 @@ def refresh_texts(self):
t("artists"),
t("genres"),
t("cloud_drive"),
- t("online_music"),
t("playlists"),
t("queue"),
t("favorites"),
@@ -246,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 5fe51149..3a782d22 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,
@@ -47,7 +49,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
@@ -267,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):
@@ -291,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
@@ -391,17 +399,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)
- # 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:
- qqmusic_service = QQMusicService(qqmusic_credential)
- except Exception as e:
- logger.error(f"QQMusicService init error: {e}")
- self._online_music_view = OnlineMusicView(self._config, qqmusic_service)
-
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
@@ -410,9 +407,9 @@ 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)
self._splitter.addWidget(self._stacked_widget)
@@ -460,6 +457,121 @@ 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 = {}
+ 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()
+ 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_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."""
self._nav_stack.clear()
@@ -497,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)
@@ -516,22 +632,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)
@@ -654,6 +759,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
@@ -900,7 +1006,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):
@@ -920,7 +1026,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)
@@ -1098,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))
@@ -1172,6 +1278,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()
@@ -1204,7 +1312,18 @@ 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
+ 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())
@@ -1246,92 +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_qqmusic_quality() 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.cloud.qqmusic.common 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."""
@@ -2018,9 +2107,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:
@@ -2032,7 +2124,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":
@@ -2295,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:
@@ -2317,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:
@@ -2339,6 +2428,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..9c9e75a4 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()
@@ -569,14 +568,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 +584,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}")
@@ -600,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."""
@@ -649,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):
@@ -673,16 +679,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..19b526f7 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."""
@@ -697,7 +698,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 +707,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
@@ -836,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):
@@ -860,7 +863,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 +872,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 9d98aba5..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 services.lyrics.qqmusic_lyrics 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
diff --git a/ui/workers/cover_workers.py b/ui/workers/cover_workers.py
deleted file mode 100644
index cf448219..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 services.lyrics.qqmusic_lyrics 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 services.lyrics.qqmusic_lyrics 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)
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" },