diff --git a/README.md b/README.md index af609f2d..e536cea2 100644 --- a/README.md +++ b/README.md @@ -37,6 +37,7 @@ - 支持多来源分组、多线路播放列表、自动连播和播放恢复 - 播放列表根据可用字段支持原始顺序、名称、大小、评分和时间排序,并在排序后保持当前播放项 - 支持主字幕、次字幕、外挂字幕、音轨选择、DASH 清晰度切换 +- 支持从外部字幕站搜索并加载字幕:内置 `SubDL`、`SubHD`、`字幕库`、`射手网(伪)`、`SubSource`、`OpenSubtitles` 六个来源,按发布文件名解析出片名/季集/画质/片源/编码后并发搜索,并按匹配度排序(简英双语优先) - YouTube / `yt-dlp` 播放支持默认画质上限、清晰度切换、多音轨切换、外部字幕加载、详情字段回填和短时解析缓存 - 支持弹幕搜索、弹幕来源切换、弹幕渲染设置和缓存 - 支持媒体刮削,可手动搜索并补充影片元数据(海报、简介、评分、演员等),也可自动增强 @@ -101,6 +102,7 @@ scripts/build_mpv.sh --master - 默认使用 `mpv-build` 的 release 轨道构建 `mpv/libmpv` - 默认执行 `sudo ./install` +- 默认启用 FFmpeg 的 `libxml2`,确保 DASH/MPD 解复用可用;缺少开发包时会自动安装 `libxml2-dev` - 如果缺少 Lua 开发包,脚本会在 `apt-get` 可用时自动执行 `sudo apt-get install -y liblua5.2-dev` - 如果缺少硬件解码相关开发包,脚本会在 `apt-get` 可用时自动执行 `sudo apt-get install -y libva-dev libvdpau-dev` - 如果缺少 NVIDIA codec headers,脚本会在 `apt-get` 可用时自动执行 `sudo apt-get install -y libffmpeg-nvenc-dev` @@ -160,6 +162,7 @@ uv run atv-player | `W` | 切换宽屏 | | `D` | 打开弹幕源 | | `S` | 打开刮削 | +| `C` | 搜索外部字幕 | | `Ctrl+D` | 打开弹幕设置 | | `I` | 显示视频信息 | | `Ctrl+P` | 返回主窗口 | @@ -194,6 +197,7 @@ uv run atv-player - 插件缓存:`~/.cache/atv-player/plugins` - 海报缓存:`~/.cache/atv-player/posters` - 弹幕搜索缓存:`~/.local/share/atv-player/danmaku-search-cache.json` +- 外部字幕缓存:`~/.cache/atv-player/subtitles` - 弹幕系列偏好:`~/.local/share/atv-player/danmaku-series-preferences.json` - 元数据缓存:`~/.cache/atv-player/metadata` - 元数据手动绑定:保存在 `~/.local/share/atv-player/app.db` 的 `metadata_bindings` 表中 @@ -208,6 +212,7 @@ uv run atv-player - 插件配置、缓存路径和加载日志 - 弹幕偏好(启用、行数、显示模式、颜色、位置、速率、字号) - 元数据增强配置(启用状态、TMDB API Key、TMDB 代理地址、Bangumi Token、豆瓣 Cookie、剧集标题增强;代理已隐藏 Key 时可不填 TMDB API Key) +- 字幕站配置(启用的字幕站、SubDL API Key、射手网 Token、OpenSubtitles API Key) - 元数据手动绑定记录 - YouTube 偏好(Cookie 浏览器、默认画质、默认字幕、默认音轨、元数据语言、地区、分类配置源和分类缓存) diff --git a/docs/help.md b/docs/help.md index ecc1f020..1cb2041e 100644 --- a/docs/help.md +++ b/docs/help.md @@ -518,6 +518,71 @@ Emby 和 Jellyfin 页更接近媒体库浏览体验: - 位置支持预设和 5% 微调 - 大小支持预设和 5% 微调 +#### 8.6.1 从外部字幕站搜索字幕 + +片源没有内嵌中文字幕时,可以直接在播放器里搜字幕:按 `C`,或在画面上右键选择“搜索字幕”。 + +内置六个来源: + +| 字幕站 | 是否需要配置 | 说明 | +|--------|--------------|------| +| `SubDL` | 需要免费 API Key | 官方 API,免费额度每天 2000 次请求 | +| `射手网(伪)` | 需要免费 Token | 官方 API,中文主力;配额 20 次/分钟,与 IP 共享 | +| `SubSource` | 需要免费 API Key | 官方 API(subsource.net),支持中文与英文;注册后在个人资料页生成 Key | +| `OpenSubtitles` | 需要免费 API Key | 官方 API,外语片覆盖好;免费账号每天限 5 次下载 | +| `SubHD` | 不需要 | 网页抓取,匿名可搜可下载(下载走多步校验,偶尔会被风控) | +| `字幕库` | 不需要 | 该站启用云锁验证码,通常无法使用 | + +> SubDL、射手网、SubSource、OpenSubtitles 是稳定的官方 API 来源,**推荐优先配置使用**。 +> SubHD 是免配置的网页抓取站,匿名可搜可下载,但页面结构随时可能变。 +> 字幕库启用云锁验证码,通常无法使用,仅保留入口。 + +Token 在“高级设置” → “字幕”里填写。**未填写的站点会被自动跳过,不影响其余站点**,因此不做任何配置也能直接使用 `SubHD`。 + +搜索流程: + +1. 打开对话框时会自动按当前播放项搜索一次。 +2. 片名、季集会从播放项标题和原始文件名里解析出来。例如 + `The.Last.of.Us.S02E06.2160p.WEB-DL.H.265-GROUP.mkv` 会解析成片名 `The Last of Us`、 + 第 2 季第 6 集,以及 `2160p` / `WEB-DL` / `H.265` / `GROUP`。 +3. 只有片名和季集用于搜索;画质、片源、编码、压制组用于给结果打分。 +4. 结果按匹配度排序,**简体中文与英语的双语字幕优先级最高**。 +5. 选中一条后点“下载并加载”,或点“设为次字幕”。 + +#### 用 TMDB / IMDb ID 搜索(推荐) + +中文片名在英文站(SubDL、OpenSubtitles)常常搜不到——比如"方舟一号"在 SubDL 搜不到, +但用 TMDB ID 能精确命中。对话框里有可选的 **TMDB ID** 和 **IMDb ID** 输入框: + +- 填了之后 SubDL、OpenSubtitles 会**优先按 ID 搜索**,命中率远高于片名,也不会搜到同名无关作品。 +- 如果这部片子之前刮削过且绑定到 TMDB,打开对话框时会**自动填好 TMDB ID**。 +- 整季搜索(只有季、没有具体集)也会正确按剧集类型搜,不会被当成电影。 + +获取 ID:在 [TMDB](https://www.themoviedb.org/) 或 [IMDb](https://www.imdb.com/) 搜到作品后, +网址里的数字就是(如 `themoviedb.org/tv/105923` 的 `105923`,`imdb.com/title/tt1234567` 的 `tt1234567`)。 + +搜不到字幕时的排查顺序:先填 TMDB/IMDb ID 重搜 → 再把片名改成英文原名 → 最后再试中文站。 + + +界面上还可以: + +- 手动修改片名后重新搜索 +- 按语言筛选结果 +- 指定只搜某一个字幕站 +- 双击结果直接下载并加载 + +下载的字幕会自动解包(支持 `zip`、`gzip`)、自动识别编码(`UTF-8` / `GBK` / `BIG5`), +存到 `~/.cache/atv-player/subtitles`,然后作为外挂字幕加载。加载后它也会出现在 +字幕下拉框和右键的“主字幕”菜单里,可以随时切回来。 + +说明: + +- 标准库无法解开 `rar` 压缩包,遇到只提供 `rar` 的结果会提示换一条。 + `SubDL` 和 `射手网` 多数情况会直接返回已解包的字幕直链,不受影响。 +- `字幕库` 触发验证码时会明确提示“触发了验证码”,而不是当成“没有搜到”。 +- 状态栏会分别说明哪些站点失败、哪些站点因未配置 Token 被跳过。 +- 字幕服务由 assrt.net 提供(使用射手网来源时按其要求署名)。 + ### 8.7 音轨与清晰度 音轨: @@ -966,7 +1031,17 @@ YouTube 标签页包含默认画质、2K+ 编码、默认字幕、默认音轨 - **刮削源**:可启用 / 禁用 Bangumi、B站、爱奇艺、腾讯、优酷、搜狐、豆瓣、豆瓣官方、TMDB 等来源 - **弹幕源**:可启用 / 禁用腾讯、优酷、B站、爱奇艺、芒果、搜狐等来源 -### 11.5 网络代理 +### 11.5 字幕 + +- **字幕站**:可启用 / 禁用 SubDL、SubHD、字幕库、射手网(伪)、SubSource、OpenSubtitles +- **SubDL API Key**:在 `subdl.com` 账号面板免费获取;留空则不使用该站 +- **射手网 Token**:在 `assrt.net` 用户面板获取;留空则不使用该站 +- **SubSource API Key**:在 `subsource.net` 注册后于个人资料页生成;留空则不使用该站 +- **OpenSubtitles API Key**:在 `opensubtitles.com` 申请;免费账号每天限 5 次下载 + +SubHD 无需任何配置即可使用(字幕库已不可用)。用法见 [8.6.1](#861-从外部字幕站搜索字幕)。 + +### 11.6 网络代理 - **代理模式**:直连(默认)、系统代理、HTTP、HTTPS、SOCKS5 - **代理地址**:例如 `socks5://user:pass@127.0.0.1:1080` @@ -974,7 +1049,7 @@ YouTube 标签页包含默认画质、2K+ 编码、默认字幕、默认音轨 - **代理规则**:留空则代理所有域名;填写后仅匹配域名走代理,例如 `.google.com` - **覆盖范围**:API 请求、元数据、解析源、弹幕、海报、插件下载、HLS 上游请求、yt-dlp -### 11.6 缓存管理 +### 11.7 缓存管理 缓存管理页用于查看和清理本地缓存: @@ -998,7 +1073,7 @@ YouTube 标签页包含默认画质、2K+ 编码、默认字幕、默认音轨 清空缓存不会删除登录令牌、插件配置、直播源、收藏、追更和播放历史;这些数据保存在数据目录的数据库中。 -### 11.7 日志 +### 11.8 日志 - **启用日志记录**:关闭后不再写入新日志,但仍可查看历史日志 - 支持按来源、级别、分类和关键字筛选 @@ -1213,6 +1288,7 @@ YouTube 标签页包含默认画质、2K+ 编码、默认字幕、默认音轨 | `W` | 切换宽屏 | | `D` | 打开弹幕源 | | `S` | 打开刮削 | +| `C` | 搜索外部字幕 | | `Ctrl+D` | 打开弹幕设置 | | `I` | 显示视频信息 | | `Ctrl+P` | 返回主窗口 | @@ -1280,7 +1356,19 @@ YouTube 标签页包含默认画质、2K+ 编码、默认字幕、默认音轨 3. 指定单个弹幕提供方重搜。 4. 重新选择候选结果并加载。 -### 18.6 直播源没有内容 +### 18.6 搜不到字幕或字幕不匹配 + +先看状态栏的提示,它会区分“这个站失败了”和“没有搜到”: + +1. 提示“未配置 Token 已跳过”时,去“高级设置” → “字幕”填上对应的 Key,可用来源会明显变多。 +2. 提示“触发了验证码”时,该抓取站暂时不可用,换其他站或稍后再试。 +3. 片名解析不准时,直接在对话框顶部改片名再点“搜索字幕”。 +4. 结果太杂时,用语言下拉筛选,或指定只搜某一个字幕站。 +5. 字幕时间轴对不上,多半是发布版本不同:优先选匹配度高、且发布名里画质和压制组 + 与当前视频一致的那一条。 +6. 只提供 `rar` 压缩包的结果无法使用,换一条即可。 + +### 18.7 直播源没有内容 优先检查: @@ -1289,7 +1377,7 @@ YouTube 标签页包含默认画质、2K+ 编码、默认字幕、默认音轨 - 本地文件路径是否仍然存在 - M3U 或 TXT 格式是否符合示例 -### 18.7 EPG 不显示 +### 18.8 EPG 不显示 优先检查: @@ -1297,7 +1385,7 @@ YouTube 标签页包含默认画质、2K+ 编码、默认字幕、默认音轨 - 是否点击过“立即更新” - 当前频道名称是否能和节目单中的频道名匹配 -### 18.8 插件标签不正常或插件报错 +### 18.9 插件标签不正常或插件报错 建议操作顺序: @@ -1308,7 +1396,7 @@ YouTube 标签页包含默认画质、2K+ 编码、默认字幕、默认音轨 如果是远程插件,先确认插件源码本身可被信任,再继续排查兼容性问题。 -### 18.9 刮削搜不到结果或匹配不准 +### 18.10 刮削搜不到结果或匹配不准 建议操作顺序: @@ -1323,7 +1411,7 @@ YouTube 标签页包含默认画质、2K+ 编码、默认字幕、默认音轨 - TMDB 来源需要填写有效的 TMDB API Key - 豆瓣来源需要填写豆瓣 Cookie -### 18.10 网络连接问题 +### 18.11 网络连接问题 如果部分内容加载失败或超时: @@ -1332,7 +1420,7 @@ YouTube 标签页包含默认画质、2K+ 编码、默认字幕、默认音轨 3. 如果使用直连规则,确认目标域名未被误设为直连。 4. 对于 YouTube 等需要特定网络环境的站点,优先确认代理模式和网络可达性。 -### 18.11 YouTube 播放受限制 +### 18.12 YouTube 播放受限制 如果 YouTube 视频提示需要登录或受地区限制: @@ -1340,7 +1428,7 @@ YouTube 标签页包含默认画质、2K+ 编码、默认字幕、默认音轨 2. 确保所选浏览器已登录 YouTube 账号。 3. 如果仍有问题,尝试更换浏览器选项。 -### 18.12 Linux 上 YouTube 播放缓冲很浅或频繁卡顿 +### 18.13 Linux 上 YouTube 播放缓冲很浅或频繁卡顿 如果同一个 `YouTube` 视频在 Windows 上播放流畅,但在 Linux 上缓存始终只有几 MB 到十几 MB、经常反复缓冲,建议按下面顺序排查: diff --git a/scripts/build_mpv.sh b/scripts/build_mpv.sh index 30395af8..f0613f7e 100755 --- a/scripts/build_mpv.sh +++ b/scripts/build_mpv.sh @@ -93,6 +93,28 @@ install_lua_dev_package() { run sudo apt-get install -y liblua5.2-dev } +install_libxml2_dev_package() { + if ! command -v apt-get >/dev/null 2>&1; then + die "Missing required libxml2 development package for FFmpeg DASH support. Install libxml2-dev, then rebuild." + fi + log "Installing missing libxml2 development package: libxml2-dev" + run sudo apt-get install -y libxml2-dev +} + +require_libxml2_dev_package() { + if has_pkg_config_dep "libxml-2.0"; then + return 0 + fi + install_libxml2_dev_package + if [[ "${DRY_RUN}" == "1" ]]; then + return 0 + fi + if has_pkg_config_dep "libxml-2.0"; then + return 0 + fi + die "Missing required libxml2 development package after install. Verify libxml2-dev is available to pkg-config, then rebuild." +} + has_active_x11_session() { [[ "${XDG_SESSION_TYPE:-}" == "x11" ]] } @@ -234,6 +256,7 @@ check_dependencies() { require_cmd meson require_cmd ninja require_cmd pkg-config + require_libxml2_dev_package require_lua_dev_package require_hwdec_support_dependencies require_nvcodec_support_dependencies @@ -268,9 +291,9 @@ ensure_repo_layout() { } write_option_files() { - : > "${WORKDIR}/ffmpeg_options" + printf '%s\n' "--enable-libxml2" > "${WORKDIR}/ffmpeg_options" if [[ "${DISABLE_X86ASM}" == "1" ]]; then - printf '%s\n' "--disable-x86asm" > "${WORKDIR}/ffmpeg_options" + printf '%s\n' "--disable-x86asm" >> "${WORKDIR}/ffmpeg_options" fi } diff --git a/src/atv_player/api.py b/src/atv_player/api.py index f98a37c9..db2f8d25 100644 --- a/src/atv_player/api.py +++ b/src/atv_player/api.py @@ -1,5 +1,6 @@ from __future__ import annotations +import hashlib import logging import platform from collections.abc import Callable @@ -31,7 +32,13 @@ def __init__( transport: httpx.BaseTransport | None = None, proxy_decider: ProxyDecider | None = None, client_factory: Callable[..., httpx.Client] = httpx.Client, + username: str = "", ) -> None: + self._base_url = base_url + # 令牌随每次登录轮换,而用户名稳定。身份按用户名派生,使同步游标/快照跨会话保留, + # 避免每次登录全量重拉重推;用户名为空时退化回令牌派生(旧行为)。 + self._username = username or "" + self._playback_sync_identity = self._build_playback_sync_identity(token) headers = {"Authorization": token} if token else {} headers.setdefault("User-Agent", platform.platform() + " ATV-Player") self._vod_token = vod_token @@ -45,11 +52,21 @@ def __init__( self._client = client_factory(**client_kwargs) def set_token(self, token: str) -> None: + self._playback_sync_identity = self._build_playback_sync_identity(token) if token: self._client.headers["Authorization"] = token else: self._client.headers.pop("Authorization", None) + @property + def playback_sync_identity(self) -> str: + return self._playback_sync_identity + + def _build_playback_sync_identity(self, token: str) -> str: + stable = self._username or token + value = f"{self._base_url}\n{stable}".encode() + return hashlib.sha256(value).hexdigest()[:32] + def set_vod_token(self, vod_token: str) -> None: self._vod_token = vod_token @@ -419,6 +436,7 @@ def get_history(self, key: str) -> HistoryRecord | None: episode=int(data.get("episode", 0)), episode_url=str(data.get("episodeUrl") or ""), position=int(data.get("position", 0)), + duration=int(data.get("duration", 0)), opening=int(data.get("opening", 0)), ending=int(data.get("ending", 0)), speed=float(data.get("speed", 1.0)), @@ -427,27 +445,38 @@ def get_history(self, key: str) -> HistoryRecord | None: source_group_index=int(data.get("sourceGroupIndex", 0)), source_index=int(data.get("sourceIndex", 0)), source_subgroup_index=int(data.get("sourceSubgroupIndex", 0)), + source_subgroup_name=str(data.get("sourceSubgroupName") or ""), drive_dir_id=str(data.get("driveDirId") or ""), ) - def list_history(self, page: int, size: int) -> dict[str, Any]: - return self._request( + def push_playback_events(self, records: list[dict[str, Any]]) -> None: + # 多端播放记录同步:PUSH 本地 Tier-B 记录。Authorization(session)由客户端自动携带, + # 服务端 resolveUid 经 session 路径解析为 uid。 + if not records: + return + self._request("POST", "/api/playback/events", json=records) + + def pull_playback_records( + self, + since: int, + limit: int = 100, + *, + source_kinds: str = "", + site_keys: str = "", + ) -> dict[str, Any]: + headers = {"X-PlaySync-Since": str(since), "X-PlaySync-Limit": str(limit)} + if source_kinds: + headers["X-PlaySync-Source-Kind"] = source_kinds + if site_keys: + headers["X-PlaySync-Site-Key"] = site_keys + if since <= 0: + headers["X-PlaySync-Latest"] = "true" + data = self._request( "GET", - "/api/history", - params={"sort": "createTime,desc", "page": page - 1, "size": size}, + "/api/playback/changes", + headers=headers, ) - - def save_history(self, payload: dict[str, Any]) -> None: - self._request("POST", "/api/history", params={"log": "false"}, json=payload) - - def delete_history(self, history_id: int) -> None: - self._request("DELETE", f"/api/history/{history_id}") - - def delete_histories(self, history_ids: list[int]) -> None: - self._request("POST", "/api/history/-/delete", json=history_ids) - - def clear_history(self) -> None: - self._request("DELETE", f"/history/{self._vod_token}") + return data or {} def fetch_vod_token(self) -> str: data = self._request("GET", "/api/token") diff --git a/src/atv_player/app.py b/src/atv_player/app.py index 0111cf4a..c31e7551 100644 --- a/src/atv_player/app.py +++ b/src/atv_player/app.py @@ -28,6 +28,7 @@ from atv_player.danmaku.generic import GenericDanmakuController from atv_player.danmaku.preferences import DanmakuSeriesPreferenceStore from atv_player.danmaku.service import create_default_danmaku_service +from atv_player.subtitles.service import create_default_subtitle_service from atv_player.custom_live_service import CustomLiveService from atv_player.controllers.browse_controller import BrowseController from atv_player.controllers.favorites_controller import FavoritesController @@ -52,6 +53,7 @@ from atv_player.danmaku.utils import ( infer_playlist_episode_number, is_likely_variety_title, + is_variety_collection, ) from atv_player.diagnostics import resolve_app_version from atv_player.episode_titles import ( @@ -69,13 +71,16 @@ from atv_player.following_metadata import FollowingMetadataGateway from atv_player.following_repository import FollowingRepository from atv_player.following_update_service import FollowingUpdateService +from atv_player.playback_sync_service import PlaybackHistorySyncService from atv_player.heat import HeatController, HeatService from atv_player.metadata import ( METADATA_EPISODE_TITLE_SOURCE_PRIORITY, + EpisodeTitleOverrideRepository, MetadataBindingRepository, MetadataCache, MetadataContext, MetadataHydrator, + apply_episode_title_overrides, build_provider_episode_playlist, resolve_episode_title_source_priority, ) @@ -455,6 +460,7 @@ def __init__( self._app_log_service = app_log_service self.login_window: LoginWindow | None = None self.main_window: MainWindow | None = None + self._playback_sync_service: PlaybackHistorySyncService | None = None self._api_client: ApiClient | None = None initial_config = self.repo.load_config() set_proxy_decider_loader(self._build_proxy_decider) @@ -482,6 +488,12 @@ def __init__( config_loader=self.repo.load_config, ) self._danmaku_preference_store = DanmakuSeriesPreferenceStore() + self._subtitle_search_service = create_default_subtitle_service( + get=self._proxy_http_get(), + post=self._proxy_http_post(), + config_loader=self.repo.load_config, + disabled_provider_ids_loader=lambda: self.repo.load_config().disabled_subtitle_provider_ids, + ) if hasattr(repo, "database_path"): self._live_source_repository = LiveSourceRepository(repo.database_path) self._live_epg_repository = LiveEpgRepository(repo.database_path) @@ -498,6 +510,7 @@ def __init__( self._playback_history_repository, danmaku_preference_store=self._danmaku_preference_store, ) + self._plugin_manager.backfill_source_metadata() setattr(self._plugin_manager, "_playback_parser_service", self._playback_parser_service) setattr(self._plugin_manager, "_yt_dlp_service", self._yt_dlp_service) setattr(self._plugin_manager, "_danmaku_service", self._danmaku_service) @@ -526,6 +539,11 @@ def __init__( if hasattr(repo, "database_path") else None ) + self._episode_title_override_repository = ( + EpisodeTitleOverrideRepository(repo.database_path) + if hasattr(repo, "database_path") + else None + ) def _apply_runtime_config(self, config: AppConfig) -> None: proxy_server = getattr(self._m3u8_ad_filter, "_proxy_server", None) @@ -670,6 +688,7 @@ def _create_api_client(self, config: AppConfig) -> ApiClient: token=config.token, vod_token=config.vod_token, proxy_decider=self._build_proxy_decider(), + username=config.username, ) except TypeError as exc: if "proxy_decider" not in str(exc): @@ -1224,18 +1243,12 @@ def _episode_title_cache_item_identity(item: PlayItem) -> tuple[str, str, str, s ) def _is_variety_playlist(session_vod: VodItem, playlist: list[PlayItem]) -> bool: - metadata_text = " ".join( - str(value or "").strip().casefold() - for value in ( - session_vod.type_name, - session_vod.category_name, - session_vod.vod_tag, - session_vod.vod_content, - ) - if str(value or "").strip() - ) - variety_markers = ("综艺", "真人秀", "脱口秀", "variety") - if any(marker in metadata_text for marker in variety_markers): + if is_variety_collection( + session_vod.type_name, + session_vod.category_name, + session_vod.vod_tag, + session_vod.vod_content, + ): return True variety_items = sum( is_likely_variety_title(item.original_title or item.title or item.path) @@ -1685,7 +1698,10 @@ def enhance(session) -> list | None: requested_seasons.add(pair[0]) if not requested_seasons: requested_seasons.add(default_season) - if search_results: + # TMDB's flat season/episode model cannot represent variety shows + # (期上/中/下 + 加更 + 陪看 + 纯享), and its direct title assignment would + # shadow the official-source match below. Skip it for variety playlists. + if search_results and not preserve_playlist_order: preferred_title = playlist_search_title or search_title matched = _select_tmdb_search_match( search_results, @@ -1870,12 +1886,32 @@ def enhance(session) -> list | None: str(session_vod.vod_year or "").strip(), ) return playlist if playlist_has_title_variants(playlist) else None - return enhance + + override_repo = self._episode_title_override_repository + + def enhance_with_overrides(session) -> list | None: + # Manual per-episode overrides win over every auto-derived source + # (manual is rank 0 in the source priority). Applied to the returned + # playlist so the player-window merge carries the manual titles through. + updated = enhance(session) + if updated is None or override_repo is None: + return updated + overrides = override_repo.load_for_session( + source_kind=source_kind, + source_key=str(getattr(session, "source_key", "") or ""), + vod_id=str(getattr(getattr(session, "vod", None), "vod_id", "") or ""), + ) + if overrides: + apply_episode_title_overrides(updated, overrides) + return updated + + return enhance_with_overrides return factory def _show_login(self, error_message: str = "") -> LoginWindow: logger.info("Show login window has_error=%s", bool(error_message)) + self._stop_playback_sync_service() self._close_api_client() login_controller = LoginController( self.repo, @@ -1955,6 +1991,11 @@ def _load_startup_spider_plugins( def _show_main(self): self._close_api_client() self._api_client = self._build_api_client() + identity = str( + getattr(self._api_client, "playback_sync_identity", "") or "" + ).strip() + if self._playback_history_repository is not None and identity: + self._playback_history_repository.set_active_account(identity) metadata_hydrator_factory = self._build_metadata_hydrator_factory(self._api_client) metadata_scrape_service_factory = self._build_metadata_scrape_service_factory(self._api_client) danmaku_controller_factory = self._build_danmaku_controller_factory() @@ -2137,7 +2178,23 @@ def youtube_category_config_loader(config=config): source_name="飞牛影视", ), ) - browse_controller = BrowseController(self._api_client) + browse_controller = BrowseController( + self._api_client, + playback_history_loader=None + if self._playback_history_repository is None + else lambda source_key, vod_id: self._playback_history_repository.get_history( + "browse", vod_id, source_key + ), + playback_history_saver=None + if self._playback_history_repository is None + else lambda source_key, vod_id, payload: self._playback_history_repository.save_history( + "browse", + vod_id, + payload, + source_key=source_key, + source_name="AList", + ), + ) pansou_controller = PansouController(browse_controller) if bool(capabilities.get("pansou")) else None history_controller = HistoryController(self._api_client, self._playback_history_repository) favorites_controller = FavoritesController( @@ -2183,6 +2240,17 @@ def youtube_category_config_loader(config=config): HeatService(), installation_id=app_identity.installation_id, ) + self._stop_playback_sync_service() + if self._playback_history_repository is not None: + self._playback_sync_service = PlaybackHistorySyncService( + self._api_client, + self._playback_history_repository, + installation_id=app_identity.installation_id, + to_sync_source_key=self._to_playback_sync_source_key, + to_local_source_key=self._to_local_playback_source_key, + playback_source_keys_loader=self._playback_sync_source_keys, + parent=self, + ) player_controller = PlayerController(self._api_client) self._start_live_background_refresh(live_source_manager, live_epg_service) logger.info( @@ -2257,14 +2325,18 @@ def youtube_category_config_loader(config=config): youtube_category_text_loader=getattr(self._api_client, "get_text", None), metadata_hydrator_factory=metadata_hydrator_factory, metadata_scrape_service_factory=metadata_scrape_service_factory, + subtitle_search_service=self._subtitle_search_service, danmaku_controller_factory=danmaku_controller_factory, episode_title_enhancer_factory=episode_title_enhancer_factory, metadata_binding_repository=self._metadata_binding_repository, + episode_title_override_repository=self._episode_title_override_repository, danmaku_preference_store=self._danmaku_preference_store, ) self.main_window.logout_requested.connect(self._handle_logout_requested) if following_update_service is not None: following_update_service.start() + if self._playback_sync_service is not None: + self._playback_sync_service.start() if self.login_window is not None: self.login_window.close() self.login_window = None @@ -2283,6 +2355,42 @@ def youtube_category_config_loader(config=config): return restored return self.main_window + def _stop_playback_sync_service(self) -> None: + if self._playback_sync_service is None: + return + # 关闭/登出/重建前 flush 最后一次 PUSH,避免 <30s tick 窗口内的进度丢失。 + self._playback_sync_service.flush() + self._playback_sync_service.deleteLater() + self._playback_sync_service = None + + def _to_playback_sync_source_key(self, source_kind: str, source_key: str) -> str | None: + if source_kind != "spider_plugin": + return source_key + if self._plugin_repository is None: + return None + try: + plugin = self._plugin_repository.get_plugin(int(source_key)) + except (AssertionError, TypeError, ValueError): + return None + return plugin.manifest_id.strip() or None + + def _to_local_playback_source_key(self, source_kind: str, source_key: str) -> str | None: + if source_kind != "spider_plugin": + return source_key + if self._plugin_repository is None: + return None + plugin = self._plugin_repository.find_plugin_by_manifest_id(source_key) + return str(plugin.id) if plugin is not None else None + + def _playback_sync_source_keys(self) -> list[str]: + if self._plugin_repository is None: + return [] + return [ + plugin.manifest_id.strip() + for plugin in self._plugin_repository.list_plugins() + if plugin.enabled and plugin.manifest_id.strip() + ] + def _start_live_background_refresh(self, live_source_manager, live_epg_service) -> None: def refresh_epg() -> None: try: @@ -2367,6 +2475,7 @@ def _handle_logout_requested(self) -> None: widget.show() def close(self) -> None: + self._stop_playback_sync_service() close_filter = getattr(self._m3u8_ad_filter, "close", None) if callable(close_filter): close_filter() diff --git a/src/atv_player/controllers/browse_controller.py b/src/atv_player/controllers/browse_controller.py index 26a71872..e4e69453 100644 --- a/src/atv_player/controllers/browse_controller.py +++ b/src/atv_player/controllers/browse_controller.py @@ -2,6 +2,7 @@ import math import re +from collections.abc import Callable from atv_player.models import OpenPlayerRequest, PlayItem, PlaybackSource, PlaybackSourceGroup, VodItem from atv_player.playlist_sorting import parse_size_bytes @@ -66,6 +67,7 @@ def map_drive_video_to_play_item( media_title=media_title, play_source=play_source, vod_id=str(payload.get("path") or payload.get("url") or ""), + play_id=str(payload.get("playId") or payload.get("play_id") or ""), ) @@ -193,8 +195,28 @@ def filter_search_results(results: list[VodItem], drive_type: str) -> list[VodIt class BrowseController: - def __init__(self, api_client) -> None: + def __init__( + self, + api_client, + playback_history_loader: Callable[[str, str], object | None] | None = None, + playback_history_saver: Callable[[str, str, dict[str, object]], None] | None = None, + ) -> None: self._api_client = api_client + self._playback_history_loader = playback_history_loader + self._playback_history_saver = playback_history_saver + + def _history_callbacks(self, vod_id: str, source_key: str = "csp_AList"): + loader = None + saver = None + if self._playback_history_loader is not None: + loader = lambda vod_id=vod_id, source_key=source_key: self._playback_history_loader( + source_key, vod_id + ) + if self._playback_history_saver is not None: + saver = lambda payload, vod_id=vod_id, source_key=source_key: self._playback_history_saver( + source_key, vod_id, payload + ) + return loader, saver def _merge_vod_metadata(self, resolved_vod: VodItem | None, fallback_vod: VodItem) -> VodItem: if resolved_vod is None: @@ -297,18 +319,24 @@ def rename_file(self, item: VodItem, name: str) -> None: def delete_file(self, item: VodItem) -> None: self._api_client.delete_video(_video_id_from_vod_id(item.vod_id)) - def build_request_from_detail(self, vod_id: str) -> OpenPlayerRequest: + def build_request_from_detail(self, vod_id: str, source_key: str = "csp_AList") -> OpenPlayerRequest: payload = self._api_client.get_detail(vod_id) detail = _map_vod_item(payload["list"][0]) if not detail.items: raise ValueError(f"没有可播放的项目: {detail.vod_name}") + source_vod_id = str(detail.vod_id or vod_id) + history_loader, history_saver = self._history_callbacks(source_vod_id, source_key) return OpenPlayerRequest( vod=detail, playlist=detail.items, clicked_index=0, source_kind="browse", + source_key=source_key, source_mode="detail", source_vod_id=vod_id, + use_local_history=False, + playback_history_loader=history_loader, + playback_history_saver=history_saver, ) def build_request_from_folder_item( @@ -325,15 +353,20 @@ def build_request_from_folder_item( clicked_playlist_item.url = self._first_play_url(resolved_vod) if not clicked_playlist_item.url: raise ValueError(f"没有可用的播放地址: {clicked_item.vod_name}") + history_loader, history_saver = self._history_callbacks(clicked_item.vod_id) return OpenPlayerRequest( vod=resolved_vod, playlist=playlist, clicked_index=clicked_index, source_kind="browse", + source_key="csp_AList", source_mode="folder", source_path=clicked_item.path.rsplit("/", 1)[0] or "/", source_vod_id=clicked_item.vod_id, source_clicked_vod_id=clicked_item.vod_id, detail_resolver=self.resolve_folder_play_item, resolved_vod_by_id={resolved_vod.vod_id: resolved_vod}, + use_local_history=False, + playback_history_loader=history_loader, + playback_history_saver=history_saver, ) diff --git a/src/atv_player/controllers/history_controller.py b/src/atv_player/controllers/history_controller.py index 0334091d..c089be82 100644 --- a/src/atv_player/controllers/history_controller.py +++ b/src/atv_player/controllers/history_controller.py @@ -4,33 +4,19 @@ class HistoryController: - def __init__(self, api_client, playback_history_repository=None) -> None: + """播放历史列表控制器。 + + 后端已下线 ``/api/history``(GET/POST/DELETE 全部 404)。播放记录改由多端同步服务 + 维护:PUSH 本地记录到 ``/api/playback/events``,PULL ``/api/playback/changes`` 回灌 + 本地 ``media_playback_history``。因此历史列表只从本地仓库读取——它就是同步后的 + 本地视图,不再发任何网络请求,避免后端缺端点时整页加载失败。 + """ + + def __init__(self, api_client=None, playback_history_repository=None) -> None: + # api_client 保留以兼容旧调用方签名,但历史读取/删除不再依赖它。 self._api_client = api_client self._playback_history_repository = playback_history_repository - def _load_remote_records(self) -> list[HistoryRecord]: - payload = self._api_client.list_history(page=1, size=10000) - return [ - HistoryRecord( - id=item.get("id", 0), - key=item.get("key", ""), - vod_name=item.get("vodName", ""), - vod_pic=item.get("vodPic", ""), - vod_remarks=item.get("vodRemarks", ""), - episode=item.get("episode", 0), - episode_url=item.get("episodeUrl", ""), - position=item.get("position", 0), - opening=item.get("opening", 0), - ending=item.get("ending", 0), - speed=item.get("speed", 1.0), - create_time=item.get("createTime", 0), - source_subgroup_index=item.get("sourceSubgroupIndex", 0), - drive_dir_id=item.get("driveDirId", ""), - source_kind="remote", - ) - for item in payload.get("content", []) - ] - def load_page( self, page: int, @@ -41,7 +27,7 @@ def load_page( time_range: str = "", continue_watching: bool = False, ) -> tuple[list[HistoryRecord], int]: - records = self._load_remote_records() + records: list[HistoryRecord] = [] if self._playback_history_repository is not None: records.extend(self._playback_history_repository.list_histories()) records.sort(key=lambda item: item.create_time, reverse=True) @@ -64,9 +50,6 @@ def load_page( return records[start:end], total def delete_one(self, record: HistoryRecord) -> None: - if record.source_kind == "remote": - self._api_client.delete_history(record.id) - return if self._playback_history_repository is None: return self._playback_history_repository.delete_history( @@ -74,20 +57,24 @@ def delete_one(self, record: HistoryRecord) -> None: record.key, record.source_key, ) + self._record_pending_deletion(record) def delete_many(self, records: list[HistoryRecord]) -> None: - remote_ids = [record.id for record in records if record.source_kind == "remote"] - if remote_ids: - self._api_client.delete_histories(remote_ids) if self._playback_history_repository is None: return for record in records: - if record.source_kind != "remote": - self._playback_history_repository.delete_history( - record.source_kind, - record.key, - record.source_key, - ) + self._playback_history_repository.delete_history( + record.source_kind, + record.key, + record.source_key, + ) + self._record_pending_deletion(record) + + def _record_pending_deletion(self, record: HistoryRecord) -> None: + # 记下显式删除,供 PlaybackHistorySyncService 下次 PUSH 转成 tombstone 多端同步。 + recorder = getattr(self._playback_history_repository, "record_pending_deletion", None) + if callable(recorder): + recorder(record.source_kind, record.source_key, record.key, record.create_time) def clear_page(self, records: list[HistoryRecord]) -> None: self.delete_many(records) diff --git a/src/atv_player/controllers/player_controller.py b/src/atv_player/controllers/player_controller.py index 1948d87e..db6f93d5 100644 --- a/src/atv_player/controllers/player_controller.py +++ b/src/atv_player/controllers/player_controller.py @@ -49,7 +49,9 @@ class PlayerSession: detail_field_runner: Callable[[PlayItem, PlaybackDetailFieldAction], None] | None = None metadata_hydrator: Callable[[object], VodItem | None] | None = None metadata_scrape_service: object | None = None + subtitle_search_service: object | None = None metadata_binding_repository: object | None = None + episode_title_override_repository: object | None = None metadata_hydrated: bool = False episode_title_enhancer: Callable[[object], list[PlayItem] | None] | None = None episode_titles_hydrated: bool = False @@ -174,7 +176,20 @@ def _should_skip_history_report_for_youtube_placeholder( normalized_vod_id = str(current_item.vod_id or "").strip().lower() return normalized_vod_id.startswith(("yt:channel:", "yt:playlist:")) - def _history_episode_url(self, current_item: PlayItem) -> str: + def _history_episode_url( + self, + current_item: PlayItem, + session: PlayerSession | None = None, + ) -> str: + play_id = str(current_item.play_id or "").strip() + if play_id: + subgroup_index = ( + self._history_source_subgroup_index(session) + if session is not None + else 0 + ) + episode_index = max(0, int(current_item.index)) + return f"{play_id}@{subgroup_index}@{episode_index}" url = str(current_item.url or "").strip() original_url = str(current_item.original_url or "").strip() if original_url and self._is_youtube_play_item(current_item): @@ -395,7 +410,9 @@ def create_session( detail_field_runner: Callable[[PlayItem, PlaybackDetailFieldAction], None] | None = None, metadata_hydrator: Callable[[object], VodItem | None] | None = None, metadata_scrape_service: object | None = None, + subtitle_search_service: object | None = None, metadata_binding_repository: object | None = None, + episode_title_override_repository: object | None = None, episode_title_enhancer: Callable[[object], list[PlayItem] | None] | None = None, danmaku_controller: object | None = None, playback_progress_reporter: Callable[[PlayItem, int, bool], None] | None = None, @@ -482,7 +499,9 @@ def create_session( detail_field_runner=detail_field_runner, metadata_hydrator=metadata_hydrator, metadata_scrape_service=metadata_scrape_service, + subtitle_search_service=subtitle_search_service, metadata_binding_repository=metadata_binding_repository, + episode_title_override_repository=episode_title_override_repository, episode_title_enhancer=episode_title_enhancer, danmaku_controller=danmaku_controller, playback_progress_reporter=playback_progress_reporter, @@ -556,8 +575,9 @@ def report_progress( "vodPic": session.vod.vod_pic, "vodRemarks": playlist_item_display_title(current_item, "episode"), "episode": current_index, - "episodeUrl": self._history_episode_url(current_item), + "episodeUrl": self._history_episode_url(current_item, session), "position": position_ms, + "duration": duration_seconds * 1000, "opening": opening_seconds * 1000, "ending": ending_seconds * 1000, "speed": speed, @@ -565,6 +585,7 @@ def report_progress( "sourceGroupIndex": session.source_group_index, "sourceIndex": session.source_index, "sourceSubgroupIndex": self._history_source_subgroup_index(session), + "sourceSubgroupName": self._history_source_subgroup_name(session), "driveDirId": self._history_drive_dir_id(session), "createTime": int(time() * 1000), } @@ -576,9 +597,8 @@ def report_progress( and (duration_seconds - position_seconds) < 150 ): self._schedule_next_episode_danmaku_prefetch(session, current_index) - if not session.use_local_history: - return - self._api_client.save_history(payload) + # 后端已下线 /api/history(POST 持久化)。本地 playback_history_saver 已写入 + # media_playback_history,多端上报改由 PlaybackHistorySyncService PUSH 负责。 @staticmethod def _history_drive_subgroup(session: PlayerSession) -> PlaybackSourceGroup | None: @@ -592,13 +612,19 @@ def _history_drive_subgroup(session: PlayerSession) -> PlaybackSourceGroup | Non return None return source.subgroups[source.subgroup_index] - def _history_source_subgroup_index(self, session: PlayerSession) -> int: + def _history_source_subgroup_index(self, session: PlayerSession | None) -> int: + if session is None: + return 0 subgroup = self._history_drive_subgroup(session) if subgroup is None: return 0 group = session.source_groups[session.source_group_index] return group.sources[session.source_index].subgroup_index + def _history_source_subgroup_name(self, session: PlayerSession) -> str: + subgroup = self._history_drive_subgroup(session) + return subgroup.label if subgroup is not None else "" + def _history_drive_dir_id(self, session: PlayerSession) -> str: subgroup = self._history_drive_subgroup(session) return subgroup.drive_dir_id if subgroup is not None else "" diff --git a/src/atv_player/danmaku/generic.py b/src/atv_player/danmaku/generic.py index 5b64c598..e0d10db2 100644 --- a/src/atv_player/danmaku/generic.py +++ b/src/atv_player/danmaku/generic.py @@ -24,8 +24,11 @@ save_item_danmaku_offset, ) from atv_player.danmaku.utils import ( + extract_variety_episode_label, has_explicit_episode_marker, + has_variety_issue_marker, infer_playlist_episode_number, + is_variety_collection, ) from atv_player.models import PlayItem @@ -59,12 +62,15 @@ def _looks_like_title_only_item(item: PlayItem) -> bool: def _default_episode_label(item: PlayItem, playlist: list[PlayItem] | None = None) -> str: if _looks_like_title_only_item(item): return "" - if not str(item.title or "").strip(): + title = str(item.title or "").strip() + if not title: return "" + if has_variety_issue_marker(title) or is_variety_collection(item.type_name, item.category_name): + return extract_variety_episode_label(title) episode_number = infer_playlist_episode_number(item, playlist) if episode_number is None: return "" - if str(item.title or "").strip().isdigit(): + if title.isdigit(): return str(episode_number) return f"{episode_number}集" if has_explicit_episode_marker(item.title) or len(playlist or []) != 1 else "" diff --git a/src/atv_player/danmaku/providers/tencent.py b/src/atv_player/danmaku/providers/tencent.py index 20188552..1291fbda 100644 --- a/src/atv_player/danmaku/providers/tencent.py +++ b/src/atv_player/danmaku/providers/tencent.py @@ -49,6 +49,16 @@ class TencentDanmakuProvider: "反应", "reaction", ) + # Episode-level preview/noise detection: same vocabulary as + # ``_NON_MAIN_CONTENT_KEYWORDS`` but WITHOUT structural punctuation + # (``:`` ``#`` ``"``). Variety episode titles on QQ are routinely + # formatted as ``2026-08-10 第2期上:``; the fullwidth colon is a + # structural separator there, not a non-main-content marker, so filtering on + # it drops every real episode. Show-level filtering (``_is_main_content_title``) + # still uses the full keyword set. + _EPISODE_PREVIEW_KEYWORDS = tuple( + keyword for keyword in _NON_MAIN_CONTENT_KEYWORDS if keyword not in {":", "#", '"'} + ) _UA_PC = ( "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 " "(KHTML, like Gecko) Chrome/142.0.0.0 Safari/537.36" @@ -304,7 +314,10 @@ def _is_preview_episode_candidate(self, episode: dict) -> bool: if any(keyword in marker_text for keyword in ("预告", '"text":"预"', "预告片")): return True title = self._clean_text(str(episode.get("title") or "")) - return not self._is_main_content_title(title) + if not title or "" in title or "" in title: + return True + lowered = title.casefold() + return any(keyword.casefold() in lowered for keyword in self._EPISODE_PREVIEW_KEYWORDS) def _extract_detail_episode_items(self, page_url: str, html_text: str, query_name: str) -> list[DanmakuSearchItem]: found: list[dict[str, str]] = [] diff --git a/src/atv_player/danmaku/service.py b/src/atv_player/danmaku/service.py index 4c9a5cc7..74de7519 100644 --- a/src/atv_player/danmaku/service.py +++ b/src/atv_player/danmaku/service.py @@ -32,6 +32,7 @@ extract_cover_id, extract_episode_number, extract_variety_issue_key, + extract_variety_part, has_explicit_episode_marker, is_likely_variety_title, match_provider, @@ -294,14 +295,18 @@ def _is_likely_variety_search(query_name: str, items: list[DanmakuSearchItem]) - def _variety_issue_key_for_item(item: DanmakuSearchItem) -> str | None: metadata_year = item.resolve_context.get("variety_year") if metadata_year not in ("", None, 0): - return str(metadata_year) + base = str(metadata_year) + part = extract_variety_part(item.name) + return f"{base}{part}" if part else base return extract_variety_issue_key(item.name) def _variety_issue_key_for_option(option: DanmakuSourceOption) -> str | None: metadata_year = option.resolve_context.get("variety_year") if metadata_year not in ("", None, 0): - return str(metadata_year) + base = str(metadata_year) + part = extract_variety_part(option.name) + return f"{base}{part}" if part else base return extract_variety_issue_key(option.name) diff --git a/src/atv_player/danmaku/utils.py b/src/atv_player/danmaku/utils.py index f1018e8b..a9beafe4 100644 --- a/src/atv_player/danmaku/utils.py +++ b/src/atv_player/danmaku/utils.py @@ -56,13 +56,16 @@ # prefix), e.g. "第十三集 <剧情>" / "13集 ..." / "EP13 ...". Platforms such as # Tencent name episodes this way, so the show name is absent and name-similarity # against the query cannot judge relevance — such candidates must be kept and -# matched by episode number instead. +# matched by episode number instead. Variety episodes often lead with their +# air date, e.g. "2026-08-10 第2期上:", so a full date is also +# accepted as a leading marker. _LEADING_EPISODE_MARKER = re.compile( r"^\s*(?:第\s*[0-9零一二两三四五六七八九十百]+\s*[集话期部回]" r"|0*[0-9]+\s*[集话期]" r"|S\d+\s*E\s*0*[0-9]+" r"|EP\s*0*[0-9]+" r"|E\s*0*[0-9]+" + r"|(?:19|20)\d{2}[\s._/-]?(?:0[1-9]|1[0-2])[\s._/-]?(?:0[1-9]|[12]\d|3[01])\b" r"|[0-9]+\s*$)", re.IGNORECASE, ) @@ -420,14 +423,59 @@ def _extract_variety_issue_number(name: str) -> int | None: return None +_VARIETY_PART_PATTERN = re.compile( + r"期\s*(加更(?:[上下中终完])?|[上下中终完])(?![一-鿿])", +) + + +def extract_variety_part(name: str) -> str | None: + """Variety sub-part token following 第N期, e.g. 上/中/下/加更上. + + Lets ``extract_variety_issue_key`` distinguish same-date/same-issue halves + (第2期上 vs 第2期中 vs 第2期下) so danmaku matching picks the exact part. + The negative CJK lookahead avoids false positives on words like 完整版/完结. + Returns None when no part marker is present. + """ + match = _VARIETY_PART_PATTERN.search(normalize_name(name)) + if match is None: + return None + return match.group(1) + + def extract_variety_issue_key(name: str) -> str | None: date_key = _extract_variety_date_key(name) + base: str | None = date_key + if base is None: + issue_number = _extract_variety_issue_number(name) + if issue_number is not None: + base = str(issue_number) + if base is None: + return None + part = extract_variety_part(name) + return f"{base}{part}" if part else base + + +def extract_variety_episode_label(title: str) -> str: + """Build a danmaku search episode label for a variety filename/title. + + Reconstructs a compact ``[date] 第N期[part]`` suffix (e.g. ``20260810 第2期上``) + from a filename like ``2026.08.10-第2期上.mp4`` so the composed danmaku query + carries the variety signal. The date is included when present so both the + query and provider candidates resolve to a date-based ``extract_variety_issue_key`` + and match. Returns "" when no date/issue/part can be recovered. + """ + date_key = _extract_variety_date_key(title) + issue = _extract_variety_issue_number(title) + part = extract_variety_part(title) + segments: list[str] = [] if date_key is not None: - return date_key - issue_number = _extract_variety_issue_number(name) - if issue_number is not None: - return str(issue_number) - return None + segments.append(date_key) + if issue is not None: + segments.append(f"第{issue}期") + label = " ".join(segments) + if part: + label = f"{label}{part}" if label else part + return label def is_likely_variety_title(name: str) -> bool: @@ -442,6 +490,46 @@ def is_likely_variety_title(name: str) -> bool: return any(token in lowered for token in _VARIETY_HINT_TOKENS) +_VARIETY_COLLECTION_MARKERS = ("综艺", "真人秀", "脱口秀", "variety") + + +def is_variety_collection( + type_name: str = "", + category_name: str = "", + vod_tag: str = "", + vod_content: str = "", +) -> bool: + """Whether collection metadata marks this title as variety/reality/show. + + Complements filename-based :func:`is_likely_variety_title`: metadata genres + (e.g. ``真人秀`` after hydration) are a more reliable variety signal when the + per-episode filename is generic. Any marker in type/category/tag/content wins. + """ + metadata_text = " ".join( + str(value or "").strip().casefold() + for value in (type_name, category_name, vod_tag, vod_content) + if str(value or "").strip() + ) + return any(marker in metadata_text for marker in _VARIETY_COLLECTION_MARKERS) + + +def has_variety_issue_marker(name: str) -> bool: + """Strong variety marker in a title: 第N期/N期 or a variety hint token. + + Stricter than :func:`is_likely_variety_title`: a bare date does NOT qualify, + because ordinary episode filenames often carry air dates (e.g. + ``04-第4话…-2026-03-03``). Used to decide whether to rewrite an episode label + as a variety issue label — date-only titles stay on the regular episode path. + """ + value = normalize_name(name) + if re.search(r"第\s*[0-9零一二两三四五六七八九十百]+\s*期", value, re.IGNORECASE) is not None: + return True + if re.search(r"(? str: value = normalize_name(name) split_patterns = ( @@ -480,9 +568,35 @@ def match_provider(reg_src: str) -> str | None: return "iqiyi" if "mgtv.com" in host: return "mgtv" + if "sohu.com" in host: + return "sohu" + if "miguvideo.com" in host: + return "migu" return None +def extract_official_link_url(detail_fields: object) -> str: + """First ``官方链接`` action URL that maps to a known danmaku provider. + + Metadata hydration records official platform links as a ``官方链接`` + ``PlaybackDetailField`` whose value parts carry ``link`` actions with concrete + URLs (e.g. ``https://v.qq.com/x/cover/…``). Returns the first such URL whose + host :func:`match_provider` recognizes, else ``""``. Duck-typed over the + detail-field objects to avoid coupling this helper to the models dataclasses. + """ + for field in getattr(detail_fields, "__iter__", lambda: [])() or []: + if str(getattr(field, "label", "") or "").strip() != "官方链接": + continue + for part in getattr(field, "value_parts", []) or []: + action = getattr(part, "action", None) + if action is None or str(getattr(action, "type", "") or "") != "link": + continue + url = str(getattr(action, "value", "") or "").strip() + if url and match_provider(url) is not None: + return url + return "" + + def extract_cover_id(url: str) -> str: """Tencent (v.qq.com) cover id from an episode/cover URL, else empty. diff --git a/src/atv_player/local_playback_history.py b/src/atv_player/local_playback_history.py index 26f63cb3..3edd122d 100644 --- a/src/atv_player/local_playback_history.py +++ b/src/atv_player/local_playback_history.py @@ -10,6 +10,7 @@ class LocalPlaybackHistoryRepository: def __init__(self, db_path: Path) -> None: self._db_path = Path(db_path) + self._account_namespace = "" self._db_path.parent.mkdir(parents=True, exist_ok=True) self._init_db() @@ -21,6 +22,7 @@ def _init_db(self) -> None: conn.execute( """ CREATE TABLE IF NOT EXISTS media_playback_history ( + account_namespace TEXT NOT NULL DEFAULT '', source_kind TEXT NOT NULL, source_key TEXT NOT NULL DEFAULT '', source_name TEXT NOT NULL DEFAULT '', @@ -31,6 +33,7 @@ def _init_db(self) -> None: episode INTEGER NOT NULL DEFAULT 0, episode_url TEXT NOT NULL DEFAULT '', position INTEGER NOT NULL DEFAULT 0, + duration INTEGER NOT NULL DEFAULT 0, opening INTEGER NOT NULL DEFAULT 0, ending INTEGER NOT NULL DEFAULT 0, speed REAL NOT NULL DEFAULT 1.0, @@ -38,9 +41,10 @@ def _init_db(self) -> None: source_group_index INTEGER NOT NULL DEFAULT 0, source_index INTEGER NOT NULL DEFAULT 0, source_subgroup_index INTEGER NOT NULL DEFAULT 0, + source_subgroup_name TEXT NOT NULL DEFAULT '', drive_dir_id TEXT NOT NULL DEFAULT '', updated_at INTEGER NOT NULL DEFAULT 0, - PRIMARY KEY (source_kind, source_key, vod_id) + PRIMARY KEY (account_namespace, source_kind, source_key, vod_id) ) """ ) @@ -50,17 +54,127 @@ def _init_db(self) -> None: } if "source_group_index" not in columns: conn.execute("ALTER TABLE media_playback_history ADD COLUMN source_group_index INTEGER NOT NULL DEFAULT 0") + if "duration" not in columns: + conn.execute("ALTER TABLE media_playback_history ADD COLUMN duration INTEGER NOT NULL DEFAULT 0") if "source_index" not in columns: conn.execute("ALTER TABLE media_playback_history ADD COLUMN source_index INTEGER NOT NULL DEFAULT 0") if "source_subgroup_index" not in columns: conn.execute( "ALTER TABLE media_playback_history ADD COLUMN source_subgroup_index INTEGER NOT NULL DEFAULT 0" ) + if "source_subgroup_name" not in columns: + conn.execute( + "ALTER TABLE media_playback_history ADD COLUMN source_subgroup_name TEXT NOT NULL DEFAULT ''" + ) if "drive_dir_id" not in columns: conn.execute("ALTER TABLE media_playback_history ADD COLUMN drive_dir_id TEXT NOT NULL DEFAULT ''") + conn.execute( + """ + CREATE TABLE IF NOT EXISTS playback_sync_state ( + namespace TEXT NOT NULL, + state_key TEXT NOT NULL, + state_value TEXT NOT NULL, + PRIMARY KEY (namespace, state_key) + ) + """ + ) self._migrate_spider_plugin_history(conn) + if "account_namespace" not in columns: + self._migrate_account_namespace(conn) + conn.execute( + """ + CREATE TABLE IF NOT EXISTS playback_sync_snapshot ( + namespace TEXT NOT NULL, + source_kind TEXT NOT NULL, + source_key TEXT NOT NULL, + vod_id TEXT NOT NULL, + updated_at INTEGER NOT NULL, + PRIMARY KEY (namespace, source_kind, source_key, vod_id) + ) + """ + ) + conn.execute( + """ + CREATE TABLE IF NOT EXISTS playback_sync_pending_deletions ( + account_namespace TEXT NOT NULL DEFAULT '', + source_kind TEXT NOT NULL, + source_key TEXT NOT NULL DEFAULT '', + vod_id TEXT NOT NULL, + deleted_at INTEGER NOT NULL DEFAULT 0, + PRIMARY KEY (account_namespace, source_kind, source_key, vod_id) + ) + """ + ) + + def _migrate_account_namespace(self, conn: sqlite3.Connection) -> None: + conn.execute( + """ + CREATE TABLE media_playback_history_account ( + account_namespace TEXT NOT NULL DEFAULT '', + source_kind TEXT NOT NULL, + source_key TEXT NOT NULL DEFAULT '', + source_name TEXT NOT NULL DEFAULT '', + vod_id TEXT NOT NULL, + vod_name TEXT NOT NULL DEFAULT '', + vod_pic TEXT NOT NULL DEFAULT '', + vod_remarks TEXT NOT NULL DEFAULT '', + episode INTEGER NOT NULL DEFAULT 0, + episode_url TEXT NOT NULL DEFAULT '', + position INTEGER NOT NULL DEFAULT 0, + duration INTEGER NOT NULL DEFAULT 0, + opening INTEGER NOT NULL DEFAULT 0, + ending INTEGER NOT NULL DEFAULT 0, + speed REAL NOT NULL DEFAULT 1.0, + playlist_index INTEGER NOT NULL DEFAULT 0, + source_group_index INTEGER NOT NULL DEFAULT 0, + source_index INTEGER NOT NULL DEFAULT 0, + source_subgroup_index INTEGER NOT NULL DEFAULT 0, + source_subgroup_name TEXT NOT NULL DEFAULT '', + drive_dir_id TEXT NOT NULL DEFAULT '', + updated_at INTEGER NOT NULL DEFAULT 0, + PRIMARY KEY (account_namespace, source_kind, source_key, vod_id) + ) + """ + ) + conn.execute( + """ + INSERT INTO media_playback_history_account ( + source_kind, source_key, source_name, vod_id, vod_name, vod_pic, vod_remarks, + episode, episode_url, position, duration, opening, ending, speed, playlist_index, + source_group_index, source_index, source_subgroup_index, source_subgroup_name, + drive_dir_id, updated_at + ) + SELECT source_kind, source_key, source_name, vod_id, vod_name, vod_pic, vod_remarks, + episode, episode_url, position, duration, opening, ending, speed, playlist_index, + source_group_index, source_index, source_subgroup_index, source_subgroup_name, + drive_dir_id, updated_at + FROM media_playback_history + """ + ) + conn.execute("DROP TABLE media_playback_history") + conn.execute("ALTER TABLE media_playback_history_account RENAME TO media_playback_history") + + def set_active_account(self, namespace: str) -> None: + value = str(namespace or "").strip() + if not value: + raise ValueError("playback history account namespace is required") + with self._connect() as conn: + # 旧版本没有账户维度;首次登录时把遗留记录归属给当前账户。 + conn.execute( + "UPDATE media_playback_history SET account_namespace = ? WHERE account_namespace = ''", + (value,), + ) + self._account_namespace = value def _migrate_spider_plugin_history(self, conn: sqlite3.Connection) -> None: + marker = conn.execute( + """ + SELECT 1 FROM playback_sync_state + WHERE namespace = '__migration__' AND state_key = 'spider_plugin_history' + """ + ).fetchone() + if marker is not None: + return tables = {row[0] for row in conn.execute("SELECT name FROM sqlite_master WHERE type = 'table'").fetchall()} if "spider_plugin_playback_history" not in tables or "spider_plugins" not in tables: return @@ -81,9 +195,9 @@ def _migrate_spider_plugin_history(self, conn: sqlite3.Connection) -> None: source_kind, source_key, source_name, vod_id, vod_name, vod_pic, vod_remarks, episode, episode_url, position, opening, ending, speed, playlist_index, source_group_index, source_index, source_subgroup_index, - drive_dir_id, updated_at + source_subgroup_name, drive_dir_id, updated_at ) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) """, ( "spider_plugin", @@ -104,32 +218,41 @@ def _migrate_spider_plugin_history(self, conn: sqlite3.Connection) -> None: 0, 0, "", + "", int(row[12]), ), ) + conn.execute( + """ + INSERT OR REPLACE INTO playback_sync_state(namespace, state_key, state_value) + VALUES ('__migration__', 'spider_plugin_history', '1') + """ + ) def get_history(self, source_kind: str, vod_id: str, source_key: str = "") -> HistoryRecord | None: with self._connect() as conn: row = conn.execute( """ SELECT source_kind, source_key, source_name, vod_id, vod_name, vod_pic, vod_remarks, - episode, episode_url, position, opening, ending, speed, playlist_index, - source_group_index, source_index, source_subgroup_index, drive_dir_id, updated_at + episode, episode_url, position, duration, opening, ending, speed, playlist_index, + source_group_index, source_index, source_subgroup_index, source_subgroup_name, + drive_dir_id, updated_at FROM media_playback_history - WHERE source_kind = ? AND source_key = ? AND vod_id = ? + WHERE account_namespace = ? AND source_kind = ? AND source_key = ? AND vod_id = ? """, - (source_kind, source_key, vod_id), + (self._account_namespace, source_kind, source_key, vod_id), ).fetchone() if row is None and source_kind == "spider_plugin" and source_key: row = conn.execute( """ SELECT source_kind, source_key, source_name, vod_id, vod_name, vod_pic, vod_remarks, - episode, episode_url, position, opening, ending, speed, playlist_index, - source_group_index, source_index, source_subgroup_index, drive_dir_id, updated_at + episode, episode_url, position, duration, opening, ending, speed, playlist_index, + source_group_index, source_index, source_subgroup_index, source_subgroup_name, + drive_dir_id, updated_at FROM media_playback_history - WHERE source_kind = ? AND source_key = '' AND vod_id = ? + WHERE account_namespace = ? AND source_kind = ? AND source_key = '' AND vod_id = ? """, - (source_kind, vod_id), + (self._account_namespace, source_kind, vod_id), ).fetchone() if row is None: return None @@ -142,15 +265,17 @@ def get_history(self, source_kind: str, vod_id: str, source_key: str = "") -> Hi episode=int(row[7]), episode_url=row[8], position=int(row[9]), - opening=int(row[10]), - ending=int(row[11]), - speed=float(row[12]), - playlist_index=int(row[13]), - source_group_index=int(row[14]), - source_index=int(row[15]), - source_subgroup_index=int(row[16]), - drive_dir_id=str(row[17]), - create_time=int(row[18]), + duration=int(row[10]), + opening=int(row[11]), + ending=int(row[12]), + speed=float(row[13]), + playlist_index=int(row[14]), + source_group_index=int(row[15]), + source_index=int(row[16]), + source_subgroup_index=int(row[17]), + source_subgroup_name=str(row[18]), + drive_dir_id=str(row[19]), + create_time=int(row[20]), source_kind=str(row[0]), source_key=str(row[1]), source_name=str(row[2]), @@ -171,12 +296,13 @@ def save_history( conn.execute( """ INSERT INTO media_playback_history ( - source_kind, source_key, source_name, vod_id, vod_name, vod_pic, vod_remarks, - episode, episode_url, position, opening, ending, speed, playlist_index, - source_group_index, source_index, source_subgroup_index, drive_dir_id, updated_at + account_namespace, source_kind, source_key, source_name, vod_id, vod_name, vod_pic, vod_remarks, + episode, episode_url, position, duration, opening, ending, speed, playlist_index, + source_group_index, source_index, source_subgroup_index, source_subgroup_name, + drive_dir_id, updated_at ) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) - ON CONFLICT(source_kind, source_key, vod_id) DO UPDATE SET + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(account_namespace, source_kind, source_key, vod_id) DO UPDATE SET source_name = excluded.source_name, vod_name = excluded.vod_name, vod_pic = excluded.vod_pic, @@ -184,6 +310,7 @@ def save_history( episode = excluded.episode, episode_url = excluded.episode_url, position = excluded.position, + duration = excluded.duration, opening = excluded.opening, ending = excluded.ending, speed = excluded.speed, @@ -191,10 +318,12 @@ def save_history( source_group_index = excluded.source_group_index, source_index = excluded.source_index, source_subgroup_index = excluded.source_subgroup_index, + source_subgroup_name = excluded.source_subgroup_name, drive_dir_id = excluded.drive_dir_id, updated_at = excluded.updated_at """, ( + self._account_namespace, source_kind, source_key, source_name, @@ -205,6 +334,7 @@ def save_history( int(payload.get("episode", 0)), str(payload.get("episodeUrl", "")), int(payload.get("position", 0)), + int(payload.get("duration", 0)), int(payload.get("opening", 0)), int(payload.get("ending", 0)), float(payload.get("speed", 1.0)), @@ -212,6 +342,7 @@ def save_history( int(payload.get("sourceGroupIndex", 0)), int(payload.get("sourceIndex", 0)), int(payload.get("sourceSubgroupIndex", 0)), + str(payload.get("sourceSubgroupName", "")), str(payload.get("driveDirId", "")), int(payload.get("createTime", 0)), ), @@ -222,10 +353,13 @@ def list_histories(self) -> list[HistoryRecord]: rows = conn.execute( """ SELECT source_kind, source_key, source_name, vod_id, vod_name, vod_pic, vod_remarks, - episode, episode_url, position, opening, ending, speed, playlist_index, - source_group_index, source_index, source_subgroup_index, drive_dir_id, updated_at + episode, episode_url, position, duration, opening, ending, speed, playlist_index, + source_group_index, source_index, source_subgroup_index, source_subgroup_name, + drive_dir_id, updated_at FROM media_playback_history - """ + WHERE account_namespace = ? + """, + (self._account_namespace,), ).fetchall() return [ HistoryRecord( @@ -237,15 +371,17 @@ def list_histories(self) -> list[HistoryRecord]: episode=int(row[7]), episode_url=row[8], position=int(row[9]), - opening=int(row[10]), - ending=int(row[11]), - speed=float(row[12]), - playlist_index=int(row[13]), - source_group_index=int(row[14]), - source_index=int(row[15]), - source_subgroup_index=int(row[16]), - drive_dir_id=str(row[17]), - create_time=int(row[18]), + duration=int(row[10]), + opening=int(row[11]), + ending=int(row[12]), + speed=float(row[13]), + playlist_index=int(row[14]), + source_group_index=int(row[15]), + source_index=int(row[16]), + source_subgroup_index=int(row[17]), + source_subgroup_name=str(row[18]), + drive_dir_id=str(row[19]), + create_time=int(row[20]), source_kind=str(row[0]), source_key=str(row[1]), source_name=str(row[2]), @@ -258,6 +394,146 @@ def list_histories(self) -> list[HistoryRecord]: def delete_history(self, source_kind: str, vod_id: str, source_key: str = "") -> None: with self._connect() as conn: conn.execute( - "DELETE FROM media_playback_history WHERE source_kind = ? AND source_key = ? AND vod_id = ?", - (source_kind, source_key, vod_id), + "DELETE FROM media_playback_history WHERE account_namespace = ? AND source_kind = ? AND source_key = ? AND vod_id = ?", + (self._account_namespace, source_kind, source_key, vod_id), + ) + + def delete_site_history(self, source_kind: str, source_key: str, deleted_at: int) -> list[tuple[str, str, str]]: + clause = "account_namespace = ? AND source_kind = ? AND source_key = ?" + args: list[object] = [self._account_namespace, source_kind, source_key] + if deleted_at > 0: + clause += " AND updated_at <= ?" + args.append(deleted_at) + with self._connect() as conn: + rows = conn.execute( + f"SELECT source_kind, source_key, vod_id FROM media_playback_history WHERE {clause}", + args, + ).fetchall() + conn.execute(f"DELETE FROM media_playback_history WHERE {clause}", args) + return [(str(row[0]), str(row[1]), str(row[2])) for row in rows] + + def delete_all_histories(self, deleted_at: int) -> list[tuple[str, str, str]]: + clause = "account_namespace = ?" + args: list[object] = [self._account_namespace] + if deleted_at > 0: + clause += " AND updated_at <= ?" + args.append(deleted_at) + with self._connect() as conn: + rows = conn.execute( + f"SELECT source_kind, source_key, vod_id FROM media_playback_history WHERE {clause}", + args, + ).fetchall() + conn.execute(f"DELETE FROM media_playback_history WHERE {clause}", args) + return [(str(row[0]), str(row[1]), str(row[2])) for row in rows] + + def get_sync_cursor(self, namespace: str) -> int: + with self._connect() as conn: + row = conn.execute( + "SELECT state_value FROM playback_sync_state WHERE namespace = ? AND state_key = 'cursor'", + (namespace,), + ).fetchone() + try: + return int(row[0]) if row is not None else 0 + except (TypeError, ValueError): + return 0 + + def set_sync_cursor(self, namespace: str, cursor: int) -> None: + with self._connect() as conn: + conn.execute( + """ + INSERT INTO playback_sync_state(namespace, state_key, state_value) VALUES (?, 'cursor', ?) + ON CONFLICT(namespace, state_key) DO UPDATE SET state_value = excluded.state_value + """, + (namespace, str(cursor)), + ) + + def load_sync_snapshot(self, namespace: str) -> dict[tuple[str, str, str], int]: + with self._connect() as conn: + rows = conn.execute( + """ + SELECT source_kind, source_key, vod_id, updated_at + FROM playback_sync_snapshot WHERE namespace = ? + """, + (namespace,), + ).fetchall() + return {(str(row[0]), str(row[1]), str(row[2])): int(row[3]) for row in rows} + + def replace_sync_snapshot(self, namespace: str, versions: dict[tuple[str, str, str], int]) -> None: + with self._connect() as conn: + conn.execute("DELETE FROM playback_sync_snapshot WHERE namespace = ?", (namespace,)) + conn.executemany( + """ + INSERT INTO playback_sync_snapshot(namespace, source_kind, source_key, vod_id, updated_at) + VALUES (?, ?, ?, ?, ?) + """, + [(namespace, *identity, updated_at) for identity, updated_at in versions.items()], + ) + + def set_sync_snapshot_version( + self, + namespace: str, + identity: tuple[str, str, str], + updated_at: int, + ) -> None: + with self._connect() as conn: + conn.execute( + """ + INSERT INTO playback_sync_snapshot(namespace, source_kind, source_key, vod_id, updated_at) + VALUES (?, ?, ?, ?, ?) + ON CONFLICT(namespace, source_kind, source_key, vod_id) + DO UPDATE SET updated_at = excluded.updated_at + """, + (namespace, *identity, updated_at), + ) + + def remove_sync_snapshot(self, namespace: str, identity: tuple[str, str, str]) -> None: + with self._connect() as conn: + conn.execute( + """ + DELETE FROM playback_sync_snapshot + WHERE namespace = ? AND source_kind = ? AND source_key = ? AND vod_id = ? + """, + (namespace, *identity), + ) + + def record_pending_deletion( + self, source_kind: str, source_key: str, vod_id: str, deleted_at: int + ) -> None: + """记录一条用户显式删除,待下次同步 PUSH 转成服务端 tombstone(替代旧的 list 差集推断)。""" + with self._connect() as conn: + conn.execute( + """ + INSERT INTO playback_sync_pending_deletions + (account_namespace, source_kind, source_key, vod_id, deleted_at) + VALUES (?, ?, ?, ?, ?) + ON CONFLICT(account_namespace, source_kind, source_key, vod_id) + DO UPDATE SET deleted_at = excluded.deleted_at + """, + (self._account_namespace, source_kind, source_key, vod_id, int(deleted_at)), + ) + + def list_pending_deletions(self) -> list[tuple[str, str, str, int]]: + with self._connect() as conn: + rows = conn.execute( + """ + SELECT source_kind, source_key, vod_id, deleted_at + FROM playback_sync_pending_deletions WHERE account_namespace = ? + """, + (self._account_namespace,), + ).fetchall() + return [(str(row[0]), str(row[1]), str(row[2]), int(row[3])) for row in rows] + + def clear_pending_deletions(self, items: list[tuple[str, str, str]]) -> None: + if not items: + return + with self._connect() as conn: + conn.executemany( + """ + DELETE FROM playback_sync_pending_deletions + WHERE account_namespace = ? AND source_kind = ? AND source_key = ? AND vod_id = ? + """, + [ + (self._account_namespace, source_kind, source_key, vod_id) + for source_kind, source_key, vod_id in items + ], ) diff --git a/src/atv_player/metadata/__init__.py b/src/atv_player/metadata/__init__.py index 45fa20ea..603ecb43 100644 --- a/src/atv_player/metadata/__init__.py +++ b/src/atv_player/metadata/__init__.py @@ -1,5 +1,10 @@ from atv_player.metadata.bindings import MetadataBinding, MetadataBindingRepository from atv_player.metadata.cache import MetadataCache +from atv_player.metadata.episode_title_overrides import ( + EpisodeTitleOverride, + EpisodeTitleOverrideRepository, + apply_episode_title_overrides, +) from atv_player.metadata.episode_title_resolver import ( METADATA_EPISODE_TITLE_SOURCE_PRIORITY, build_provider_episode_playlist, @@ -14,6 +19,9 @@ "MetadataBinding", "MetadataBindingRepository", "MetadataCache", + "EpisodeTitleOverride", + "EpisodeTitleOverrideRepository", + "apply_episode_title_overrides", "METADATA_EPISODE_TITLE_SOURCE_PRIORITY", "MetadataHydrator", "MetadataContext", diff --git a/src/atv_player/metadata/episode_title_overrides.py b/src/atv_player/metadata/episode_title_overrides.py new file mode 100644 index 00000000..e144e044 --- /dev/null +++ b/src/atv_player/metadata/episode_title_overrides.py @@ -0,0 +1,179 @@ +from __future__ import annotations + +import logging +import re +from dataclasses import dataclass +from pathlib import Path +from time import time + +from atv_player.episode_titles import seed_original_titles +from atv_player.models import PlayItem +from atv_player.sqlite_utils import managed_connection + +logger = logging.getLogger(__name__) + +MANUAL_EPISODE_TITLE_SOURCE = "manual" + + +def episode_override_item_key(item: PlayItem) -> str: + """Stable per-file identity within a vod/source, used to key manual overrides. + + Durable ids win (play_id/url/original_url); path basename is the fallback so + local-drive / alist sources without a play id still key consistently. The same + item must resolve to the same key at save time and at load time, so the lookup + order here is the single source of truth. + """ + for attr in ("play_id", "url", "original_url"): + value = str(getattr(item, attr, "") or "").strip() + if value: + return value + path = str(getattr(item, "path", "") or "").strip().rstrip("/\\") + if path: + return re.split(r"[\\/]", path)[-1] + return str(getattr(item, "title", "") or "").strip() + + +@dataclass(slots=True) +class EpisodeTitleOverride: + source_kind: str + source_key: str + vod_id: str + item_key: str + display_title: str + updated_at: int = 0 + + +class EpisodeTitleOverrideRepository: + """Per-episode manual title overrides. + + Overrides carry the implicit ``manual`` source (rank 0 in the episode-title + source priority), so a saved override always beats any auto-derived title. + Keyed globally by (source_kind, source_key, vod_id, item_key); deliberately + not namespaced per account (a title correction is objective, not personal). + """ + + def __init__(self, db_path: Path) -> None: + self._db_path = Path(db_path) + self._db_path.parent.mkdir(parents=True, exist_ok=True) + self._init_db() + + def _connect(self): + return managed_connection(self._db_path) + + def _init_db(self) -> None: + with self._connect() as conn: + conn.execute( + """ + CREATE TABLE IF NOT EXISTS episode_title_overrides ( + source_kind TEXT NOT NULL DEFAULT '', + source_key TEXT NOT NULL DEFAULT '', + vod_id TEXT NOT NULL, + item_key TEXT NOT NULL, + display_title TEXT NOT NULL, + updated_at INTEGER NOT NULL, + PRIMARY KEY (source_kind, source_key, vod_id, item_key) + ) + """ + ) + + def load_for_session( + self, + *, + source_kind: str, + source_key: str, + vod_id: str, + ) -> dict[str, str]: + if not str(vod_id or "").strip(): + return {} + with self._connect() as conn: + rows = conn.execute( + """ + SELECT item_key, display_title FROM episode_title_overrides + WHERE source_kind = ? AND source_key = ? AND vod_id = ? + """, + (str(source_kind or ""), str(source_key or ""), str(vod_id)), + ).fetchall() + return {str(item_key): str(title) for item_key, title in rows if item_key} + + def upsert( + self, + *, + source_kind: str, + source_key: str, + vod_id: str, + item_key: str, + display_title: str, + ) -> None: + item_key = str(item_key or "").strip() + display_title = str(display_title or "").strip() + if not item_key or not display_title or not str(vod_id or "").strip(): + return + with self._connect() as conn: + conn.execute( + """ + INSERT INTO episode_title_overrides ( + source_kind, source_key, vod_id, item_key, display_title, updated_at + ) + VALUES (?, ?, ?, ?, ?, ?) + ON CONFLICT(source_kind, source_key, vod_id, item_key) DO UPDATE SET + display_title = excluded.display_title, + updated_at = excluded.updated_at + """, + ( + str(source_kind or ""), + str(source_key or ""), + str(vod_id), + item_key, + display_title, + int(time()), + ), + ) + + def delete( + self, + *, + source_kind: str, + source_key: str, + vod_id: str, + item_key: str, + ) -> None: + with self._connect() as conn: + conn.execute( + """ + DELETE FROM episode_title_overrides + WHERE source_kind = ? AND source_key = ? AND vod_id = ? AND item_key = ? + """, + ( + str(source_kind or ""), + str(source_key or ""), + str(vod_id), + str(item_key or ""), + ), + ) + + +def apply_episode_title_overrides( + playlist: list[PlayItem], + overrides: dict[str, str], +) -> bool: + """Stamp manual overrides onto matching playlist items in place. + + ``manual`` is the highest-priority source, so this is safe to run after every + auto-derived rewrite: it only overwrites titles for items that have a saved + override. Returns True if any item changed. + """ + if not overrides: + return False + seed_original_titles(playlist) + changed = False + for item in playlist: + key = episode_override_item_key(item) + title = overrides.get(key) + if not title: + continue + if item.episode_display_title == title and item.episode_title_source == MANUAL_EPISODE_TITLE_SOURCE: + continue + item.episode_display_title = title + item.episode_title_source = MANUAL_EPISODE_TITLE_SOURCE + changed = True + return changed diff --git a/src/atv_player/metadata/episode_title_resolver.py b/src/atv_player/metadata/episode_title_resolver.py index 5769dd80..74a6674a 100644 --- a/src/atv_player/metadata/episode_title_resolver.py +++ b/src/atv_player/metadata/episode_title_resolver.py @@ -3,7 +3,13 @@ from dataclasses import replace import re -from atv_player.danmaku.utils import infer_playlist_episode_number +from atv_player.danmaku.utils import ( + _extract_variety_date_key, + extract_variety_part, + infer_playlist_episode_number, + is_likely_variety_title, + is_variety_collection, +) from atv_player.episode_titles import ( apply_episode_title_index_map, episode_version_slots_by_index, @@ -16,12 +22,17 @@ from atv_player.metadata.providers.tmdb import infer_tmdb_media_type from atv_player.models import PlayItem, VodItem -METADATA_EPISODE_TITLE_SOURCE_PRIORITY = ["plugin", "bangumi", "bilibili", "tmdb", "tencent", "iqiyi"] -_IQIYI_PRIORITIZED_EPISODE_TITLE_SOURCE_PRIORITY = ["plugin", "bangumi", "bilibili", "iqiyi", "tmdb", "tencent"] +METADATA_EPISODE_TITLE_SOURCE_PRIORITY = ["manual", "plugin", "bangumi", "bilibili", "tmdb", "tencent", "iqiyi"] +_IQIYI_PRIORITIZED_EPISODE_TITLE_SOURCE_PRIORITY = ["manual", "plugin", "bangumi", "bilibili", "iqiyi", "tmdb", "tencent"] +# Variety shows: the official native source (Tencent/iQiyi) carries the real, +# date-ordered episode list that TMDB's flat season/episode model cannot represent, +# so it must outrank TMDB. +_VARIETY_EPISODE_TITLE_SOURCE_PRIORITY = ["manual", "plugin", "tencent", "iqiyi", "bilibili", "bangumi", "tmdb"] _MOVIE_MARKERS = ("电影", "影片", "movie") _ANIME_MARKERS = ("动漫", "动画", "番剧", "anime", "animation", "国创") _LIVE_ACTION_MARKERS = ("电视剧", "剧集", "连续剧", "剧版", "真人版", "真人", "短剧") _EPISODE_SORT_SENTINEL = 10**9 +_VARIETY_PUBLISH_DATE_RE = re.compile(r"(\d{4})\D(\d{1,2})\D(\d{1,2})") def is_high_confidence_iqiyi_episode_candidate( @@ -59,6 +70,8 @@ def resolve_episode_title_source_priority( *, preferred_provider: str = "", ) -> list[str]: + if _playlist_is_variety(vod, playlist): + return list(_VARIETY_EPISODE_TITLE_SOURCE_PRIORITY) for candidate in candidates: if is_high_confidence_iqiyi_episode_candidate( vod, @@ -215,6 +228,15 @@ def _titles_by_index_for_provider( provider: str, raw: dict[str, object], ) -> dict[int, str]: + if _playlist_is_variety(vod, playlist): + # Variety episodes are keyed by air date + 期段, not sequential episode + # numbers. The collapsed-number path (tmdb/bangumi/bilibili/iqiyi) maps + # 期上/中/下/加更 to a single episode and would scramble 纯享/陪看/花絮 + # onto unrelated episodes (e.g. 第1期上纯享 -> iqiyi ep1). Only the + # official date-keyed matcher may emit; everything else stays unmapped. + if provider == "tencent" and _is_tencent_variety_candidate(vod, playlist, raw): + return _titles_by_index_for_tencent_variety(vod, playlist, raw) + return {} if provider == "bangumi": return _titles_by_index_for_bangumi(vod, playlist, raw) if provider == "tencent": @@ -228,6 +250,110 @@ def _titles_by_index_for_provider( return {} +def _playlist_is_variety(vod: VodItem, playlist: list[PlayItem]) -> bool: + if is_variety_collection( + getattr(vod, "type_name", ""), + getattr(vod, "category_name", ""), + getattr(vod, "vod_tag", ""), + getattr(vod, "vod_content", ""), + ): + return True + if not playlist: + return False + variety_items = sum( + is_likely_variety_title(item.original_title or item.title or "") for item in playlist + ) + return variety_items >= 2 and variety_items * 2 >= len(playlist) + + +def _variety_date_key_from_publish_date(publish_date: object) -> str: + match = _VARIETY_PUBLISH_DATE_RE.search(str(publish_date or "")) + if match is None: + return "" + year, month, day = match.groups() + return f"{year}{int(month):02d}{int(day):02d}" + + +def _is_tencent_variety_candidate( + vod: VodItem, + playlist: list[PlayItem], + raw: dict[str, object], +) -> bool: + """A Tencent candidate whose hydrated cover list can be matched by air date.""" + if not _playlist_is_variety(vod, playlist): + return False + episodes = raw.get("episodes") + if not isinstance(episodes, list): + return False + return any( + isinstance(episode, dict) and str(episode.get("publish_date") or "").strip() + for episode in episodes + ) + + +def _titles_by_index_for_tencent_variety( + vod: VodItem, + playlist: list[PlayItem], + raw: dict[str, object], +) -> dict[int, str]: + """Match variety files to the official Tencent episode list by (air date, part). + + Air date is the strongest signal (filenames carry a YYYYMMDD prefix); the + 期上/中/下/加更 part disambiguates same-date halves. Same-date files/episodes + without a parseable part (e.g. 先导片上/下) are paired in broadcast order. + Output is formatted ``MM-DD {官方标题}`` (no meaningless 第N集 prefix). + """ + official_by_date: dict[str, list[dict[str, object]]] = {} + for episode in raw.get("episodes") or []: + if not isinstance(episode, dict): + continue + title = str(episode.get("title") or "").strip() + date_key = _variety_date_key_from_publish_date(episode.get("publish_date")) + if not title or not date_key: + continue + official_by_date.setdefault(date_key, []).append( + { + "title": title, + "part": extract_variety_part(title), + "mmdd": f"{int(date_key[4:6]):02d}-{int(date_key[6:8]):02d}", + } + ) + if not official_by_date: + return {} + + files_by_date: dict[str, list[tuple[int, str | None]]] = {} + for index, item in enumerate(playlist): + name = str(item.original_title or item.title or item.path or "") + date_key = _extract_variety_date_key(name) + if not date_key: + continue + files_by_date.setdefault(date_key, []).append((index, extract_variety_part(name))) + + titles_by_index: dict[int, str] = {} + for date_key, files in files_by_date.items(): + officials = official_by_date.get(date_key) + if not officials: + continue + used: set[int] = set() + # 1) exact (date, part) match — handles 第N期上/中/下/加更. + for index, file_part in files: + if file_part is None: + continue + for position, official in enumerate(officials): + if position in used: + continue + if official["part"] is not None and official["part"] == file_part: + titles_by_index[index] = f'{official["mmdd"]} {official["title"]}' + used.add(position) + break + # 2) order fallback for same-date items without a part (e.g. 先导片上/下). + remaining_officials = [official for position, official in enumerate(officials) if position not in used] + remaining_indices = [index for index, _part in files if index not in titles_by_index] + for index, official in zip(remaining_indices, remaining_officials): + titles_by_index[index] = f'{official["mmdd"]} {official["title"]}' + return titles_by_index + + def _titles_by_index_for_tencent(vod: VodItem, playlist: list[PlayItem], raw: dict[str, object]) -> dict[int, str]: episode_rows: list[str] = [] for site_key in ("episode_sites", "play_sites"): diff --git a/src/atv_player/metadata/providers/tencent.py b/src/atv_player/metadata/providers/tencent.py index fb21f709..0393690e 100644 --- a/src/atv_player/metadata/providers/tencent.py +++ b/src/atv_player/metadata/providers/tencent.py @@ -1,12 +1,17 @@ from __future__ import annotations +import logging +import re import uuid +from dataclasses import replace import httpx from atv_player.metadata.matching import score_match from atv_player.metadata.models import MetadataMatch, MetadataQuery, MetadataRecord +logger = logging.getLogger(__name__) + class TencentMetadataProvider: name = "tencent" @@ -22,6 +27,18 @@ class TencentMetadataProvider: "trpc-trans-info": '{"trpc-env":""}', "user-agent": "Mozilla/5.0", } + # Official per-cover episode list (variety shows live here, keyed by publish date). + _EPISODE_LIST_URL = ( + "https://pbaccess.video.qq.com/trpc.universal_backend_service.page_server_rpc.PageServer/GetPageData" + ) + _EPISODE_LIST_PARAMS = {"video_appid": "3000010", "vplatform": "2", "vversion_name": "8.2.96"} + _EPISODE_LIST_HEADERS = { + "content-type": "application/json", + "origin": "https://v.qq.com", + "referer": "https://v.qq.com/", + "user-agent": "Mozilla/5.0", + } + _COVER_ID_RE = re.compile(r"/cover/([A-Za-z0-9]+)") _FEATURE_LIST = [ "DEFAULT_FEFEATURE", "PC_SHORT_VIDEOS_WATERFALL", @@ -92,6 +109,115 @@ def _apply_native_site_penalty(self, match: MetadataMatch) -> float: return max(0.0, float(match.score or 0.0) - self._NON_NATIVE_SITE_PENALTY) return float(match.score or 0.0) + def _hydrate_episode_candidate(self, candidate: MetadataMatch) -> MetadataMatch: + """Fetch the cover's full episode list (titles + publish dates). + + Search results only carry a few preview episodes; the official per-cover + list is what variety-show rewriting needs to align files by air date. + Enriches ``candidate.raw`` with ``episodes=[{"title","publish_date"}, ...]``. + """ + provider_id = str(getattr(candidate, "provider_id", "") or "") + cover_id = self._cover_id_from_provider_id(provider_id) + if not cover_id: + logger.info( + "Tencent cover episode list skipped: no cover id in provider_id=%s", + provider_id, + extra={"log_category": "metadata", "log_source": "app"}, + ) + return candidate + episodes = self._fetch_cover_episodes(cover_id) + if not episodes: + logger.info( + "Tencent cover episode list empty for cover_id=%s", + cover_id, + extra={"log_category": "metadata", "log_source": "app"}, + ) + return candidate + logger.info( + "Tencent cover episode list fetched cover_id=%s count=%s", + cover_id, + len(episodes), + extra={"log_category": "metadata", "log_source": "app"}, + ) + raw = dict(getattr(candidate, "raw", {}) or {}) + raw["episodes"] = episodes + raw["episode_list_source"] = "tencent_cover" + return replace(candidate, raw=raw) + + def _cover_id_from_provider_id(self, provider_id: str) -> str: + match = self._COVER_ID_RE.search(str(provider_id or "")) + return match.group(1) if match else "" + + def _fetch_cover_episodes(self, cover_id: str) -> list[dict]: + if not cover_id: + return [] + payload = { + "page_params": { + "req_from": "web_vsite", + "page_id": "vsite_episode_list", + "page_type": "detail_operation", + "id_type": "1", + "page_size": "100", + "cid": cover_id, + "req_from_platform_id": "2", + "is_skp_style": "false", + }, + "has_cache": 1, + } + try: + response = self._post( + self._EPISODE_LIST_URL, + params=dict(self._EPISODE_LIST_PARAMS), + headers=dict(self._EPISODE_LIST_HEADERS), + json=payload, + follow_redirects=True, + timeout=10.0, + ) + data = response.json() + except Exception as exc: + logger.info( + "Tencent cover episode list fetch failed cover_id=%s error=%s", + cover_id, + exc, + extra={"log_category": "metadata", "log_source": "app"}, + ) + return [] + return self._parse_cover_episodes(data) + + def _parse_cover_episodes(self, payload: object) -> list[dict]: + data = payload.get("data") if isinstance(payload, dict) else None + if not isinstance(data, dict): + return [] + module_list_datas = data.get("module_list_datas") + if not isinstance(module_list_datas, list): + return [] + episodes: list[dict] = [] + seen: set[tuple[str, str]] = set() + for module in module_list_datas: + if not isinstance(module, dict): + continue + for module_data in module.get("module_datas") or []: + item_data_lists = module_data.get("item_data_lists") if isinstance(module_data, dict) else None + if not isinstance(item_data_lists, dict): + continue + for item in item_data_lists.get("item_datas") or []: + params = item.get("item_params") if isinstance(item, dict) else None + if not isinstance(params, dict): + continue + title = str( + params.get("union_title") or params.get("play_title") or params.get("title") or "" + ).strip() + publish_date = str(params.get("publish_date") or "").strip() + # Skip section tabs (第1季/纯享/陪看/花絮…) which carry no publish date. + if not title or not publish_date: + continue + key = (title, publish_date[:10]) + if key in seen: + continue + seen.add(key) + episodes.append({"title": title, "publish_date": publish_date}) + return episodes + def _build_search_payload(self, title: str) -> dict[str, object]: return { "version": "26022601", diff --git a/src/atv_player/models.py b/src/atv_player/models.py index b853be05..e1ff4ce8 100644 --- a/src/atv_player/models.py +++ b/src/atv_player/models.py @@ -65,6 +65,11 @@ class AppConfig: danmaku_convert_top_bottom_to_scroll: bool = False dandan_base_url: str = "" bangumi_data_danmaku_enabled: bool = False + subtitle_subdl_api_key: str = "" + subtitle_assrt_token: str = "" + subtitle_opensubtitles_api_key: str = "" + subtitle_subsource_api_key: str = "" + disabled_subtitle_provider_ids: list[str] = field(default_factory=list) disabled_metadata_provider_ids: list[str] = field(default_factory=list) last_path: str = "/" last_active_window: str = "main" @@ -200,6 +205,7 @@ class PlayItem: time: str = "" duration_seconds: int = 0 vod_id: str = "" + play_id: str = "" detail_actions: list[PlaybackDetailAction] = field(default_factory=list) detail_fields: list[PlaybackDetailField] = field(default_factory=list) headers: dict[str, str] = field(default_factory=dict) @@ -232,6 +238,7 @@ class PlayItem: danmaku_error: str = "" type_name: str = "" category_name: str = "" + metadata_provider_url: str = "" @dataclass(slots=True) @@ -401,10 +408,12 @@ class HistoryRecord: ending: int speed: float create_time: int + duration: int = 0 playlist_index: int = 0 source_group_index: int = 0 source_index: int = 0 source_subgroup_index: int = 0 + source_subgroup_name: str = "" drive_dir_id: str = "" source_kind: str = "remote" source_plugin_id: int = 0 @@ -553,6 +562,7 @@ class OpenPlayerRequest: metadata_hydrator: Callable[[object], VodItem | None] | None = None metadata_scrape_service: object | None = None metadata_binding_repository: object | None = None + episode_title_override_repository: object | None = None episode_title_enhancer: Callable[[object], list[PlayItem] | None] | None = None danmaku_controller: object | None = None playback_progress_reporter: Callable[[PlayItem, int, bool], None] | None = None diff --git a/src/atv_player/playback_sync_service.py b/src/atv_player/playback_sync_service.py new file mode 100644 index 00000000..9843f650 --- /dev/null +++ b/src/atv_player/playback_sync_service.py @@ -0,0 +1,536 @@ +# ruff: noqa: E501 +from __future__ import annotations + +import hashlib +import logging +import threading +from collections.abc import Callable +from time import monotonic +from typing import Any + +from PySide6.QtCore import QObject, QTimer + +logger = logging.getLogger(__name__) + +# 多端播放记录同步:周期性 PUSH 本地 Tier-B 记录 + PULL 服务端变更。 +# 本服务覆盖本地播放历史;TvBox 站点 key 与 atv-player source_kind 在边界处互转。 +# 鉴权复用 ApiClient 的 session 令牌(Authorization),服务端 resolveUid 解析为 uid。 +INITIAL_DELAY_MS = 30_000 +PERIOD_MS = 30_000 +PULL_PERIOD_MS = 5 * 60_000 +SYNC_SOURCE_KINDS = frozenset( + { + "browse", + "telegram", + "telegram_channel", + "bilibili", + "youtube", + "emby", + "jellyfin", + "feiniu", + "direct_parse", + "spider_plugin", + } +) +SYNC_NAMESPACE_VERSION = "v7-plugin-source-name" +SYNC_LIMIT = 100 +TVBOX_SITE_TO_ATV_KIND = { + "csp_TgDouBan": "telegram", + "csp_TgChannel": "telegram_channel", + "csp_TgSearch": "telegram", + "csp_TgWeb": "telegram", + "csp_FishPanSou": "telegram", + "csp_FishPanSouGroup": "telegram", + "csp_AList": "browse", + "csp_XiaoYa": "browse", + "csp_BiliBili": "bilibili", + "csp_FeiNiu": "feiniu", + "csp_Emby": "emby", + "csp_Jellyfin": "jellyfin", +} +ATV_KIND_TO_TVBOX_SITE = { + "telegram": "csp_TgDouBan", + "telegram_channel": "csp_TgChannel", + "bilibili": "csp_BiliBili", + "feiniu": "csp_FeiNiu", + "emby": "csp_Emby", + "jellyfin": "csp_Jellyfin", +} +TELEGRAM_SITE_KEYS = frozenset( + {"csp_TgDouBan", "csp_TgSearch", "csp_TgWeb", "csp_FishPanSou", "csp_FishPanSouGroup"} +) +BROWSE_SITE_KEYS = frozenset({"csp_AList", "csp_XiaoYa"}) +SYNC_PULL_SOURCE_KINDS = tuple(sorted(SYNC_SOURCE_KINDS | {"site"})) +SYNC_PULL_SITE_KEYS = tuple(sorted(TVBOX_SITE_TO_ATV_KIND)) +SourceKeyResolver = Callable[[str, str], str | None] +SourceKeysLoader = Callable[[], list[str]] + + +class PlaybackHistorySyncService(QObject): + def __init__( + self, + api_client, + repository, + *, + installation_id: str = "", + to_sync_source_key: SourceKeyResolver | None = None, + to_local_source_key: SourceKeyResolver | None = None, + playback_source_keys_loader: SourceKeysLoader | None = None, + parent=None, + ) -> None: + super().__init__(parent) + self._api = api_client + self._repo = repository + self._client_key = installation_id + identity = str(getattr(api_client, "playback_sync_identity", "") or "default") + self._namespace = f"{identity}:{SYNC_NAMESPACE_VERSION}" + self._sync_key_resolver = to_sync_source_key or self._default_source_key_resolver + self._local_key_resolver = to_local_source_key or self._default_source_key_resolver + self._playback_source_keys_loader = playback_source_keys_loader or (lambda: []) + self._unmapped_plugins: set[tuple[str, str]] = set() + self._timer = QTimer(self) + self._timer.timeout.connect(self.sync) + load_snapshot = getattr(self._repo, "load_sync_snapshot", lambda _namespace: {}) + load_cursor = getattr(self._repo, "get_sync_cursor", lambda _namespace: 0) + self._pushed_versions: dict[tuple[str, str, str], int] = load_snapshot(self._namespace) + self._pull_source_keys = self._current_pull_source_keys() + self._cursor_namespace = self._build_cursor_namespace(self._pull_source_keys) + self._pull_cursor = load_cursor(self._cursor_namespace) + self._sync_lock = threading.Lock() + self._sync_in_progress = False + self._started = False + self._worker = None + self._last_pull_at = 0.0 + + def start(self) -> None: + self._started = True + QTimer.singleShot(INITIAL_DELAY_MS, self.sync) + self._timer.start(PERIOD_MS) + logger.info( + "playback sync started: initial_delay_ms=%d push_period_ms=%d pull_period_ms=%d cursor=%s snapshot=%d", + INITIAL_DELAY_MS, + PERIOD_MS, + PULL_PERIOD_MS, + self._pull_cursor, + len(self._pushed_versions), + ) + + def stop(self) -> None: + self._started = False + self._timer.stop() + + def flush(self) -> None: + """关闭/登出前同步执行最后一次 PUSH,把未到 tick 的进度上报到服务端。 + + Qt 进程退出会强杀守护线程,故最终 PUSH 必须在调用线程内联执行而非再交给 worker; + 先 join 正在运行的 worker 以避免并发双推。PULL 留待下次启动续上。 + """ + self._started = False + self._timer.stop() + worker = self._worker + if worker is not None and worker.is_alive(): + worker.join(timeout=10.0) + try: + self._push() + except Exception as exc: # noqa: BLE001 - 关闭路径不能抛 + logger.warning("playback sync final flush failed: %s", exc) + + def sync(self) -> None: + """Schedule one sync without blocking the Qt event loop. + + The repository opens a SQLite connection per operation, and ApiClient's + HTTP client is safe to use from this worker thread. Keeping the whole + sync sequence in one worker also keeps its cursors and version snapshots + single-threaded. + """ + if not self._started: + return + with self._sync_lock: + if self._sync_in_progress: + return + self._sync_in_progress = True + self._worker = threading.Thread( + target=self._run_sync, + name="playback-history-sync", + daemon=True, + ) + self._worker.start() + + def _run_sync(self) -> None: + try: + push_succeeded = True + try: + self._push() + except Exception as exc: # noqa: BLE001 - 后台同步不能让异常冒泡到 Qt + push_succeeded = False + logger.warning("playback sync push failed: %s", exc) + pull_due = self._last_pull_at <= 0 or ( + monotonic() - self._last_pull_at + ) * 1000 >= PULL_PERIOD_MS + if self._started and push_succeeded and pull_due: + try: + self._pull() + self._last_pull_at = monotonic() + except Exception as exc: # noqa: BLE001 + logger.warning("playback sync pull failed: %s", exc) + finally: + with self._sync_lock: + self._sync_in_progress = False + + # ── PUSH:本地 Tier-B → 服务端 ────────────────────────────────────────── + + def _push(self) -> None: + all_records = sorted( + [ + record + for record in self._repo.list_histories() + if record.source_kind in SYNC_SOURCE_KINDS + ], + key=lambda record: int(record.create_time or 0), + reverse=True, + ) + records = all_records[:SYNC_LIMIT] + current_versions: dict[tuple[str, str, str], int] = {} + changed: list[tuple[tuple[str, str, str], int, dict[str, Any]]] = [] + for record in records: + record_key = self._resolved_sync_identity( + record.source_kind, record.source_key, record.key, self._sync_key_resolver + ) + if record_key is None: + self._warn_unmapped_plugin("push", record.source_key) + continue + payload = self._to_payload(record, record_key[0], record_key[1]) + updated_at = int(payload.get("updatedAt", 0) or 0) + current_versions[record_key] = updated_at + if updated_at > self._pushed_versions.get(record_key, -1): + changed.append((record_key, updated_at, payload)) + # 删除只走显式通道:用户删除时写入 pending 队列,这里转成 tombstone 上报。 + # 不再用 list 差集推断删除——那会在账户/命名空间切换时把"不可见"的全量记录 + # 误判为已删除并上报,配合服务端墓碑回灌清空整库(2026-08-09 实测 159→0)。 + tombstones, consumed_deletes = self._build_pending_tombstones() + logger.info( + "playback sync scan: local=%d latest=%d snapshot=%d updates=%d deletes=%d", + len(all_records), + len(records), + len(self._pushed_versions), + len(changed), + len(tombstones), + ) + if not changed and not tombstones: + if current_versions != self._pushed_versions: + self._repo.replace_sync_snapshot(self._namespace, current_versions) + self._pushed_versions = current_versions + return + self._api.push_playback_events([payload for _, _, payload in changed] + tombstones) + logger.info( + "playback sync push succeeded: updates=%d deletes=%d", + len(changed), + len(tombstones), + ) + # 仅在上报成功后清掉已消费的 pending 删除;失败则留待下个 tick 重试。 + if consumed_deletes: + self._repo.clear_pending_deletions(consumed_deletes) + self._repo.replace_sync_snapshot(self._namespace, current_versions) + self._pushed_versions = current_versions + + def _build_pending_tombstones(self) -> tuple[list[dict[str, Any]], list[tuple[str, str, str]]]: + """把本地 pending 删除队列转成服务端 tombstone。 + + 返回 ``(tombstones, consumed)``:``tombstones`` 用于上报;``consumed`` 是上报成功后 + 需要从 pending 表清掉的原始 (source_kind, source_key, vod_id)。无法解析同步身份的 + 记录(如缺稳定 manifest id 的 spider_plugin)无法上报,直接丢弃并告警。 + """ + tombstones: list[dict[str, Any]] = [] + consumed: list[tuple[str, str, str]] = [] + undeliverable: list[tuple[str, str, str]] = [] + for source_kind, source_key, vod_id, deleted_at in self._repo.list_pending_deletions(): + sync_identity = self._resolved_sync_identity( + source_kind, source_key, vod_id, self._sync_key_resolver + ) + if sync_identity is None: + self._warn_unmapped_plugin("delete", source_key) + undeliverable.append((source_kind, source_key, vod_id)) + continue + tombstones.append( + { + "event": "playback.deleted", + "scope": "item", + "sourceKind": sync_identity[0], + "sourceKey": sync_identity[1], + "vodId": sync_identity[2], + "deletedAt": max(1, int(deleted_at or 1)), + } + ) + consumed.append((source_kind, source_key, vod_id)) + if undeliverable: + self._repo.clear_pending_deletions(undeliverable) + return tombstones, consumed + + @staticmethod + def _record_key(source_kind: str, source_key: str, vod_id: str) -> tuple[str, str, str]: + return source_kind, source_key, vod_id + + def _to_payload(self, record, sync_source_kind: str, sync_source_key: str) -> dict[str, Any]: + return { + "sourceKind": sync_source_kind, + "sourceKey": sync_source_key, + "sourceName": record.source_name, + "vodId": record.key, + "vodName": record.vod_name, + "vodPic": record.vod_pic, + "episodeName": record.vod_remarks, + "episode": record.episode, + "episodeUrl": record.episode_url, + "positionMs": record.position, + "durationMs": record.duration, + "openingMs": record.opening, + "endingMs": record.ending, + "updatedAt": record.create_time, + "speed": record.speed, + "clientKey": self._client_key, + "playlistIndex": record.playlist_index, + "sourceGroupIndex": record.source_group_index, + "sourceIndex": record.source_index, + "sourceSubgroupIndex": record.source_subgroup_index, + "sourceSubgroupName": record.source_subgroup_name, + "driveDirId": record.drive_dir_id, + } + + # ── PULL:服务端 → 本地 Tier-B(LWW by updated_at) ────────────────────── + + def _pull(self) -> None: + pull_source_keys = self._current_pull_source_keys() + cursor_namespace = self._build_cursor_namespace(pull_source_keys) + if cursor_namespace != self._cursor_namespace: + self._pull_source_keys = pull_source_keys + self._cursor_namespace = cursor_namespace + self._pull_cursor = self._repo.get_sync_cursor(cursor_namespace) + page = self._api.pull_playback_records( + self._pull_cursor, + source_kinds=",".join(SYNC_PULL_SOURCE_KINDS), + site_keys=",".join(pull_source_keys), + ) + deleted = page.get("deleted") or [] + items = page.get("items") or [] + logger.info( + "playback sync pull: since=%s items=%d deleted=%d next=%s", + self._pull_cursor, + len(items), + len(deleted), + page.get("nextSince"), + ) + + # Process tombstones first: an item in the same page may be a newer + # re-created record and should therefore be allowed to save afterwards. + for tombstone in deleted: + if not isinstance(tombstone, dict): + continue + scope = str(tombstone.get("scope") or "item").strip().lower() + deleted_at = int( + tombstone.get("deletedAt") + or tombstone.get("deleted_at") + or tombstone.get("timestamp") + or 0 + ) + if deleted_at <= 0: + # 服务端协议保证 deletedAt 非空(NOT NULL);缺失时保守跳过, + # 绝不退化为无条件全量删除(scope=all 会清空整库)。 + logger.warning("playback sync: skip tombstone without deletedAt scope=%s", scope) + continue + removed: list[tuple[str, str, str]] = [] + if scope == "all": + removed = self._repo.delete_all_histories(deleted_at) + removed.extend( + identity + for identity, updated_at in self._pushed_versions.items() + if deleted_at <= 0 or updated_at <= deleted_at + ) + elif scope == "site": + sync_source_kind = str(tombstone.get("sourceKind") or tombstone.get("source_kind") or "site") + sync_source_key = str(tombstone.get("sourceKey") or tombstone.get("source_key") or "") + source_kind, raw_local_source_key = self._local_source(sync_source_kind, sync_source_key) + local_source_key = self._resolve_source_key( + source_kind, raw_local_source_key, self._local_key_resolver + ) + if source_kind in SYNC_SOURCE_KINDS and local_source_key is not None: + removed = self._repo.delete_site_history(source_kind, local_source_key, deleted_at) + removed = [ + self._record_key(sync_source_kind, sync_source_key, identity[2]) + for identity in removed + ] + removed.extend( + identity + for identity, updated_at in self._pushed_versions.items() + if identity[0] == sync_source_kind + and identity[1] == sync_source_key + and (deleted_at <= 0 or updated_at <= deleted_at) + ) + else: + sync_identity = self._item_identity(tombstone) + if sync_identity is None: + continue + local_identity = self._resolved_local_identity(*sync_identity, self._local_key_resolver) + if local_identity is None or local_identity[0] not in SYNC_SOURCE_KINDS: + self._warn_unmapped_plugin("pull delete", sync_identity[1]) + continue + source_kind, source_key, vod_id = local_identity + existing = self._repo.get_history(source_kind, vod_id, source_key) + if existing is not None and deleted_at > 0 and existing.create_time > deleted_at: + continue + self._repo.delete_history(source_kind, vod_id, source_key) + removed = [sync_identity] + for identity in set(removed): + self._repo.remove_sync_snapshot(self._namespace, identity) + self._pushed_versions.pop(identity, None) + + for item in items: + sync_identity = self._item_identity(item) + if sync_identity is None: + continue + local_identity = self._resolved_local_identity(*sync_identity, self._local_key_resolver) + if local_identity is None or local_identity[0] not in SYNC_SOURCE_KINDS: + self._warn_unmapped_plugin("pull", sync_identity[1]) + continue + source_kind, source_key, vod_id = local_identity + updated_at = int(item.get("updatedAt") or item.get("updated_at") or item.get("timestamp") or 0) + source_name = str(item.get("sourceName") or item.get("source_name") or "") + existing = self._repo.get_history(source_kind, vod_id, source_key) + if existing is not None: + if existing.create_time > updated_at: + continue # 本地进度更新,跳过远端旧版本 + if existing.create_time == updated_at and ( + not source_name or existing.source_name == source_name + ): + continue # 同版本且来源名无需修复 + payload = { + "vodName": item.get("vodName") or item.get("vod_name") or "", + "vodPic": item.get("vodPic") or item.get("vod_pic") or "", + "vodRemarks": item.get("episodeName") or item.get("vodRemarks") or "", + "episode": int(item.get("episode") or 0), + "episodeUrl": item.get("episodeUrl") or item.get("episode_url") or "", + "position": int(item.get("positionMs") or item.get("position") or 0), + "duration": int(item.get("durationMs") or item.get("duration") or 0), + # 服务端协议不包含片头片尾;远端进度更新不能清空本地标记。 + "opening": existing.opening if existing is not None else 0, + "ending": existing.ending if existing is not None else 0, + "speed": float(item.get("speed") or 1.0), + "createTime": updated_at, + "playlistIndex": int(item.get("playlistIndex") or item.get("playlist_index") or 0), + "sourceGroupIndex": int(item.get("sourceGroupIndex") or item.get("source_group_index") or 0), + "sourceIndex": int(item.get("sourceIndex") or item.get("source_index") or 0), + "sourceSubgroupIndex": int( + item.get("sourceSubgroupIndex") or item.get("source_subgroup_index") or 0 + ), + "sourceSubgroupName": item.get("sourceSubgroupName") + or item.get("source_subgroup_name") + or "", + "driveDirId": item.get("driveDirId") or item.get("drive_dir_id") or "", + } + self._repo.save_history(source_kind, vod_id, payload, source_key=source_key, source_name=source_name) + self._repo.set_sync_snapshot_version(self._namespace, sync_identity, updated_at) + self._pushed_versions[sync_identity] = updated_at + next_since = page.get("nextSince") + if next_since is not None: + try: + self._pull_cursor = int(next_since) + self._repo.set_sync_cursor(self._cursor_namespace, self._pull_cursor) + except (TypeError, ValueError): + pass + + def _current_pull_source_keys(self) -> tuple[str, ...]: + plugin_keys = { + str(value).strip() + for value in self._playback_source_keys_loader() + if str(value).strip() + } + return tuple(sorted(set(SYNC_PULL_SITE_KEYS) | plugin_keys)) + + def _build_cursor_namespace(self, source_keys: tuple[str, ...]) -> str: + digest = hashlib.sha256("\n".join(source_keys).encode()).hexdigest()[:16] + return f"{self._namespace}:pull:{digest}" + + @staticmethod + def _item_identity(item: Any) -> tuple[str, str, str] | None: + if not isinstance(item, dict): + return None + source_kind = str(item.get("sourceKind") or item.get("source_kind") or "") + source_key = str(item.get("sourceKey") or item.get("source_key") or "") + vod_id = str(item.get("vodId") or item.get("vod_id") or "") + if not vod_id: + return None + return PlaybackHistorySyncService._record_key(source_kind, source_key, vod_id) + + @staticmethod + def _default_source_key_resolver(source_kind: str, source_key: str) -> str | None: + if source_kind == "spider_plugin": + return None + return source_key + + @staticmethod + def _resolve_source_key( + source_kind: str, source_key: str, resolver: SourceKeyResolver + ) -> str | None: + resolved = resolver(source_kind, source_key) + if resolved is None: + return None + value = str(resolved) + if source_kind == "spider_plugin" and not value: + return None + return value + + @staticmethod + def _sync_source(source_kind: str, source_key: str) -> tuple[str, str]: + if source_kind == "browse": + return "site", source_key if source_key in BROWSE_SITE_KEYS else "csp_AList" + if source_kind == "telegram": + return "site", source_key if source_key in TELEGRAM_SITE_KEYS else "csp_TgDouBan" + site_key = ATV_KIND_TO_TVBOX_SITE.get(source_kind) + return ("site", site_key) if site_key else (source_kind, source_key) + + @staticmethod + def _local_source(source_kind: str, source_key: str) -> tuple[str, str]: + if source_kind != "site": + return source_kind, source_key + local_kind = TVBOX_SITE_TO_ATV_KIND.get(source_key) + if local_kind is None: + return source_kind, source_key + return local_kind, source_key if local_kind in {"browse", "telegram"} else "" + + @classmethod + def _resolved_sync_identity( + cls, + source_kind: str, + source_key: str, + vod_id: str, + resolver: SourceKeyResolver, + ) -> tuple[str, str, str] | None: + sync_source_kind, raw_sync_source_key = cls._sync_source(source_kind, source_key) + resolved = cls._resolve_source_key(sync_source_kind, raw_sync_source_key, resolver) + if resolved is None: + return None + return cls._record_key(sync_source_kind, resolved, vod_id) + + @classmethod + def _resolved_local_identity( + cls, + source_kind: str, + source_key: str, + vod_id: str, + resolver: SourceKeyResolver, + ) -> tuple[str, str, str] | None: + local_source_kind, raw_local_source_key = cls._local_source(source_kind, source_key) + resolved = cls._resolve_source_key(local_source_kind, raw_local_source_key, resolver) + if resolved is None: + return None + return cls._record_key(local_source_kind, resolved, vod_id) + + def _warn_unmapped_plugin(self, direction: str, source_key: str) -> None: + marker = direction, source_key + if marker in self._unmapped_plugins: + return + self._unmapped_plugins.add(marker) + logger.warning( + "playback sync skips spider plugin without stable manifest id: direction=%s source_key=%s", + direction, + source_key, + ) diff --git a/src/atv_player/plugins/__init__.py b/src/atv_player/plugins/__init__.py index 7381bf4f..f32dcc50 100644 --- a/src/atv_player/plugins/__init__.py +++ b/src/atv_player/plugins/__init__.py @@ -1,11 +1,11 @@ from __future__ import annotations -from collections.abc import Callable -from dataclasses import dataclass +import ast import re import time -from pathlib import Path -from pathlib import PurePosixPath +from collections.abc import Callable +from dataclasses import dataclass +from pathlib import Path, PurePosixPath from urllib.parse import quote, unquote, urljoin, urlparse import httpx @@ -114,6 +114,24 @@ def _parse_plugin_source_metadata(source_text: str) -> tuple[str, int]: version_match = re.match(r"^//\s*@version\s*:\s*(\d+)\s*$", line) if version_match is not None: plugin_version = max(1, int(version_match.group(1))) + try: + module = ast.parse(source_text) + for node in module.body: + if not isinstance(node, (ast.Assign, ast.AnnAssign)): + continue + targets = node.targets if isinstance(node, ast.Assign) else [node.target] + name = next( + (target.id for target in targets if isinstance(target, ast.Name)), "" + ) + if name not in {"PLUGIN_ID", "PLUGIN_VERSION"}: + continue + value = ast.literal_eval(node.value) + if name == "PLUGIN_ID" and isinstance(value, str) and value.strip(): + plugin_id = value.strip() + elif name == "PLUGIN_VERSION" and isinstance(value, int): + plugin_version = max(1, value) + except (SyntaxError, ValueError): + pass return plugin_id, plugin_version @@ -176,6 +194,47 @@ def __init__( def list_plugins(self) -> list[SpiderPluginConfig]: return self._repository.list_plugins() + def backfill_source_metadata(self) -> int: + """Backfill stable ids for plugins installed before PLUGIN_ID existed.""" + updated = 0 + for plugin in self._repository.list_plugins(): + if plugin.manifest_id: + continue + source_path = Path( + plugin.source_value + if plugin.source_type == "local" + else plugin.cached_file_path + ) + if source_path.suffix.lower() not in {".py", ".txt"} or not source_path.is_file(): + continue + try: + manifest_id, _ = _parse_plugin_source_metadata( + source_path.read_text(encoding="utf-8") + ) + except (OSError, UnicodeDecodeError): + continue + if not manifest_id: + continue + existing = self._repository.find_plugin_by_manifest_id(manifest_id) + if existing is not None and existing.id != plugin.id: + continue + self._repository.update_plugin( + plugin.id, + display_name=plugin.display_name, + enabled=plugin.enabled, + cached_file_path=plugin.cached_file_path, + last_loaded_at=plugin.last_loaded_at, + last_error=plugin.last_error, + config_text=plugin.config_text, + plugin_version=plugin.plugin_version, + category_overrides_json=plugin.category_overrides_json, + source_type=plugin.source_type, + source_value=plugin.source_value, + manifest_id=manifest_id, + ) + updated += 1 + return updated + def _upsert_single_plugin( self, source_type: str, @@ -213,8 +272,10 @@ def _upsert_single_plugin( def add_local_plugin(self, path: str) -> None: manifest_id = "" plugin_version = 1 - if Path(path).suffix.lower() == ".txt": - manifest_id, plugin_version = _parse_plugin_source_metadata(Path(path).read_text(encoding="utf-8")) + if Path(path).suffix.lower() in {".py", ".txt"}: + manifest_id, plugin_version = _parse_plugin_source_metadata( + Path(path).read_text(encoding="utf-8") + ) plugin = self._upsert_single_plugin( "local", path, @@ -228,9 +289,11 @@ def add_remote_plugin(self, url: str) -> None: name = _default_plugin_name("remote", url) manifest_id = "" plugin_version = 1 - if Path(urlparse(url).path).suffix.lower() == ".txt": + if Path(urlparse(url).path).suffix.lower() in {".py", ".txt"}: try: - manifest_id, plugin_version = _parse_plugin_source_metadata(self._fetch_text(url)) + manifest_id, plugin_version = _parse_plugin_source_metadata( + self._fetch_text(url) + ) except Exception: manifest_id = "" plugin_version = 1 @@ -289,6 +352,7 @@ def refresh_plugin(self, plugin_id: int) -> None: config_text=plugin.config_text, plugin_version=plugin.plugin_version, category_overrides_json=plugin.category_overrides_json, + manifest_id=loaded.config.manifest_id, ) def delete_plugin(self, plugin_id: int) -> None: @@ -653,6 +717,7 @@ def iter_enabled_plugins( ) self._repository.append_log(plugin.id, "error", str(exc)) continue + self._persist_loaded_manifest_id(plugin, loaded) yield self._build_plugin_definition( plugin, loaded, @@ -705,6 +770,7 @@ def load_plugins( ) self._repository.append_log(plugin.id, "error", str(exc)) continue + self._persist_loaded_manifest_id(plugin, loaded) definitions.append( self._build_plugin_definition( plugin, @@ -715,6 +781,27 @@ def load_plugins( ) return definitions + def _persist_loaded_manifest_id( + self, plugin: SpiderPluginConfig, loaded: LoadedSpiderPlugin + ) -> None: + manifest_id = loaded.config.manifest_id.strip() + if not manifest_id or manifest_id == plugin.manifest_id: + return + self._repository.update_plugin( + plugin.id, + display_name=plugin.display_name, + enabled=plugin.enabled, + cached_file_path=loaded.config.cached_file_path, + last_loaded_at=plugin.last_loaded_at, + last_error=plugin.last_error, + config_text=plugin.config_text, + plugin_version=plugin.plugin_version, + category_overrides_json=plugin.category_overrides_json, + source_type=plugin.source_type, + source_value=plugin.source_value, + manifest_id=manifest_id, + ) + __all__ = [ "LoadedSpiderPlugin", diff --git a/src/atv_player/plugins/controller.py b/src/atv_player/plugins/controller.py index 77cbccdb..0be46df2 100644 --- a/src/atv_player/plugins/controller.py +++ b/src/atv_player/plugins/controller.py @@ -33,8 +33,13 @@ from atv_player.danmaku.utils import ( episode_title_matches, extract_episode_number, + extract_official_link_url, + extract_variety_episode_label, has_explicit_episode_marker, + has_variety_issue_marker, infer_playlist_episode_number, + is_variety_collection, + match_provider, normalize_name, strip_episode_suffix, ) @@ -239,6 +244,8 @@ def _should_omit_default_episode_label(item: PlayItem, playlist: list[PlayItem] def _extract_episode_label(item: PlayItem, playlist: list[PlayItem] | None = None) -> str: + if has_variety_issue_marker(item.title) or is_variety_collection(item.type_name, item.category_name): + return extract_variety_episode_label(item.title) if _looks_like_calendar_episode_title(item.title): return _strip_trailing_title_size_suffix(item.title) if _should_omit_default_episode_label(item, playlist): @@ -1293,6 +1300,7 @@ def _build_drive_replacement_playlist( play_source=play_source, type_name=detail.type_name or item.type_name, category_name=resolved_category_name or item.category_name, + metadata_provider_url=extract_official_link_url(detail.detail_fields), ) for index, item in enumerate(detail.items) if item.url @@ -1311,6 +1319,7 @@ def _build_drive_replacement_playlist( play_source=play_source, type_name=detail.type_name or item.type_name, category_name=resolved_category_name or item.category_name, + metadata_provider_url=extract_official_link_url(detail.detail_fields), ) for index, item in enumerate(playlist) if item.url and not _looks_like_drive_share_link(item.url) @@ -1547,6 +1556,30 @@ def _save_danmaku_search_title_preference(self, item: PlayItem) -> None: ) ) + def _metadata_danmaku_preference( + self, + item: PlayItem, + series_key: str, + ) -> DanmakuSeriesPreference | None: + """Synthesize a danmaku preference from metadata's official-link URL. + + When the user has no saved source choice for this series, metadata + hydration may still tell us the official platform (e.g. a ``v.qq.com`` + cover URL). Pin that provider so the search hits it directly instead of + blind keyword search across all providers. Returns None if metadata + offers no recognizable platform URL. + """ + metadata_url = str(item.metadata_provider_url or "").strip() + provider_key = match_provider(metadata_url) if metadata_url else None + if provider_key is None: + return None + return DanmakuSeriesPreference( + series_key=series_key, + provider=provider_key, + page_url=metadata_url, + title="", + ) + def _prepare_danmaku_lookup( self, item: PlayItem, @@ -1555,6 +1588,8 @@ def _prepare_danmaku_lookup( ) -> tuple[DanmakuSeriesPreference | None, str, str] | None: series_key = build_danmaku_series_key(item.media_title or item.title) preference = self._danmaku_preference_store.load(series_key) if self._danmaku_preference_store is not None else None + if preference is None: + preference = self._metadata_danmaku_preference(item, series_key) search_title = self._resolve_danmaku_search_title(item, preference) search_episode = self._resolve_danmaku_search_episode(item, playlist) search_name = _compose_danmaku_search_query(search_title, search_episode) @@ -2160,6 +2195,37 @@ def refresh_danmaku_sources( candidate_count = sum(len(group.options) for group in item.danmaku_candidates) self._log_danmaku_event("弹幕搜索成功", detail=f"找到 {candidate_count} 个候选") + def auto_resolve_danmaku( + self, + item: PlayItem, + playlist: list[PlayItem] | None = None, + *, + media_duration_seconds: int = 0, + ) -> bool: + """Find and download the default danmaku source for one playlist item. + + Mirrors ``GenericDanmakuController.auto_resolve_danmaku`` so the player + window's generic auto-load fallback (``_maybe_restore_cached_danmaku_for_current_item``) + can search and download danmaku for spider-plugin sources, not just restore + from a cached source. Without this, opening a plugin item from playback + history — where the loader-based ``_maybe_resolve_danmaku`` does not always + run for the resumed episode — left danmaku unloaded until a manual search. + """ + if item.danmaku_xml: + return True + query_override = item.danmaku_search_query if item.danmaku_search_query_overridden else None + self.refresh_danmaku_sources( + item, + query_override=query_override, + playlist=playlist, + media_duration_seconds=media_duration_seconds, + ) + selected_url = str(item.selected_danmaku_url or "").strip() + if not selected_url: + return False + self.switch_danmaku_source(item, selected_url) + return bool(item.danmaku_xml) + def switch_danmaku_source(self, item: PlayItem, page_url: str) -> str: selected_option = None selected_provider_label = "" diff --git a/src/atv_player/plugins/loader.py b/src/atv_player/plugins/loader.py index 5e394cff..2a958f1b 100644 --- a/src/atv_player/plugins/loader.py +++ b/src/atv_player/plugins/loader.py @@ -84,6 +84,7 @@ def load( source_path = self._resolve_source_path(config, force_refresh=force_refresh) module_name = f"spider_plugin_{config.id}_{source_path.stem}" source_language = self._detect_source_language(source_path) + manifest_id = config.manifest_id if source_language == "js": spider = NodeSpider( plugin_path=source_path, @@ -112,6 +113,9 @@ def load( spider_cls = getattr(module, "Spider", None) if spider_cls is None: raise ValueError("缺少 Spider 类") + manifest_id = ( + str(getattr(module, "PLUGIN_ID", "") or "").strip() or manifest_id + ) spider = spider_cls() initialized = False initialize_lock = threading.Lock() @@ -149,6 +153,7 @@ def initialize_spider() -> None: config_text=config.config_text, plugin_version=config.plugin_version, category_overrides_json=config.category_overrides_json, + manifest_id=manifest_id, ) logger.info( "Loaded spider plugin id=%s name=%s source_type=%s search_enabled=%s", diff --git a/src/atv_player/source_preferences.py b/src/atv_player/source_preferences.py index e98c22fd..31ea3f25 100644 --- a/src/atv_player/source_preferences.py +++ b/src/atv_player/source_preferences.py @@ -35,7 +35,17 @@ class SourcePreference: SourcePreference("tmdb", "TMDB"), ) +SUBTITLE_SOURCE_PREFERENCES = ( + SourcePreference("subdl", "SubDL"), + SourcePreference("subhd", "SubHD"), + SourcePreference("zimuku", "字幕库"), + SourcePreference("assrt", "射手网(伪)"), + SourcePreference("subsource", "SubSource"), + SourcePreference("opensubtitles", "OpenSubtitles"), +) + VALID_DANMAKU_PROVIDER_IDS = {item.id for item in DANMAKU_SOURCE_PREFERENCES} +VALID_SUBTITLE_PROVIDER_IDS = {item.id for item in SUBTITLE_SOURCE_PREFERENCES} VALID_METADATA_PROVIDER_IDS = {item.id for item in METADATA_SOURCE_PREFERENCES} | { "plugin", "douban", diff --git a/src/atv_player/storage.py b/src/atv_player/storage.py index 85a1608c..9bae30e2 100644 --- a/src/atv_player/storage.py +++ b/src/atv_player/storage.py @@ -8,7 +8,11 @@ import uuid from atv_player.models import AppConfig, AppIdentity -from atv_player.source_preferences import VALID_DANMAKU_PROVIDER_IDS, VALID_METADATA_PROVIDER_IDS +from atv_player.source_preferences import ( + VALID_DANMAKU_PROVIDER_IDS, + VALID_METADATA_PROVIDER_IDS, + VALID_SUBTITLE_PROVIDER_IDS, +) from atv_player.sqlite_utils import managed_connection _VALID_DANMAKU_RENDER_MODES = {"static", "scroll_only", "mixed"} @@ -467,6 +471,11 @@ def _init_db(self) -> None: danmaku_convert_top_bottom_to_scroll INTEGER NOT NULL DEFAULT 0, dandan_base_url TEXT NOT NULL DEFAULT '', bangumi_data_danmaku_enabled INTEGER NOT NULL DEFAULT 0, + subtitle_subdl_api_key TEXT NOT NULL DEFAULT '', + subtitle_assrt_token TEXT NOT NULL DEFAULT '', + subtitle_opensubtitles_api_key TEXT NOT NULL DEFAULT '', + subtitle_subsource_api_key TEXT NOT NULL DEFAULT '', + disabled_subtitle_provider_ids TEXT NOT NULL DEFAULT '[]', disabled_metadata_provider_ids TEXT NOT NULL DEFAULT '[]', metadata_douban_cookie TEXT NOT NULL DEFAULT '', metadata_tmdb_api_key TEXT NOT NULL DEFAULT '', @@ -593,6 +602,26 @@ def _init_db(self) -> None: conn.execute( "ALTER TABLE app_config ADD COLUMN bangumi_data_danmaku_enabled INTEGER NOT NULL DEFAULT 0" ) + if "subtitle_subdl_api_key" not in columns: + conn.execute( + "ALTER TABLE app_config ADD COLUMN subtitle_subdl_api_key TEXT NOT NULL DEFAULT ''" + ) + if "subtitle_assrt_token" not in columns: + conn.execute( + "ALTER TABLE app_config ADD COLUMN subtitle_assrt_token TEXT NOT NULL DEFAULT ''" + ) + if "subtitle_opensubtitles_api_key" not in columns: + conn.execute( + "ALTER TABLE app_config ADD COLUMN subtitle_opensubtitles_api_key TEXT NOT NULL DEFAULT ''" + ) + if "subtitle_subsource_api_key" not in columns: + conn.execute( + "ALTER TABLE app_config ADD COLUMN subtitle_subsource_api_key TEXT NOT NULL DEFAULT ''" + ) + if "disabled_subtitle_provider_ids" not in columns: + conn.execute( + "ALTER TABLE app_config ADD COLUMN disabled_subtitle_provider_ids TEXT NOT NULL DEFAULT '[]'" + ) if "disabled_metadata_provider_ids" not in columns: conn.execute( "ALTER TABLE app_config ADD COLUMN disabled_metadata_provider_ids TEXT NOT NULL DEFAULT '[]'" @@ -1146,7 +1175,12 @@ def load_config(self) -> AppConfig: following_episode_grid_columns, home_mode, dandan_base_url, - bangumi_data_danmaku_enabled + bangumi_data_danmaku_enabled, + subtitle_subdl_api_key, + subtitle_assrt_token, + subtitle_opensubtitles_api_key, + subtitle_subsource_api_key, + disabled_subtitle_provider_ids FROM app_config WHERE id = 1 """ @@ -1243,6 +1277,11 @@ def load_config(self) -> AppConfig: home_mode, dandan_base_url, bangumi_data_danmaku_enabled, + subtitle_subdl_api_key, + subtitle_assrt_token, + subtitle_opensubtitles_api_key, + subtitle_subsource_api_key, + disabled_subtitle_provider_ids, ) = row return AppConfig( base_url=base_url, @@ -1366,6 +1405,18 @@ def load_config(self) -> AppConfig: home_mode=_normalize_home_mode(home_mode), dandan_base_url=str(dandan_base_url or "").strip(), bangumi_data_danmaku_enabled=bool(bangumi_data_danmaku_enabled), + subtitle_subdl_api_key=str(subtitle_subdl_api_key or "").strip(), + subtitle_assrt_token=str(subtitle_assrt_token or "").strip(), + subtitle_opensubtitles_api_key=str( + subtitle_opensubtitles_api_key or "" + ).strip(), + subtitle_subsource_api_key=str( + subtitle_subsource_api_key or "" + ).strip(), + disabled_subtitle_provider_ids=_normalize_disabled_provider_ids( + disabled_subtitle_provider_ids, + VALID_SUBTITLE_PROVIDER_IDS, + ), ) def save_config(self, config: AppConfig) -> None: @@ -1463,7 +1514,12 @@ def save_config(self, config: AppConfig) -> None: following_episode_grid_columns = ?, home_mode = ?, dandan_base_url = ?, - bangumi_data_danmaku_enabled = ? + bangumi_data_danmaku_enabled = ?, + subtitle_subdl_api_key = ?, + subtitle_assrt_token = ?, + subtitle_opensubtitles_api_key = ?, + subtitle_subsource_api_key = ?, + disabled_subtitle_provider_ids = ? WHERE id = 1 """, ( @@ -1581,6 +1637,17 @@ def save_config(self, config: AppConfig) -> None: _normalize_home_mode(config.home_mode), str(config.dandan_base_url or "").strip(), int(config.bangumi_data_danmaku_enabled), + str(config.subtitle_subdl_api_key or "").strip(), + str(config.subtitle_assrt_token or "").strip(), + str(config.subtitle_opensubtitles_api_key or "").strip(), + str(config.subtitle_subsource_api_key or "").strip(), + json.dumps( + _normalize_disabled_provider_ids( + config.disabled_subtitle_provider_ids, + VALID_SUBTITLE_PROVIDER_IDS, + ), + ensure_ascii=False, + ), ), ) diff --git a/src/atv_player/subtitles/__init__.py b/src/atv_player/subtitles/__init__.py new file mode 100644 index 00000000..a1a83572 --- /dev/null +++ b/src/atv_player/subtitles/__init__.py @@ -0,0 +1,67 @@ +from atv_player.subtitles.cache import save_subtitle_file, subtitle_cache_dir +from atv_player.subtitles.errors import ( + SubtitleArchiveError, + SubtitleArchiveUnsupportedError, + SubtitleBlockedError, + SubtitleEmptyResultError, + SubtitleError, + SubtitleProviderError, + SubtitleQuotaExceededError, + SubtitleTokenMissingError, +) +from atv_player.subtitles.languages import ( + language_label, + language_rank, + normalize_language, +) +from atv_player.subtitles.matcher import ( + DEFAULT_MATCH_WEIGHTS, + MatchWeights, + apply_scores, + score_subtitle, +) +from atv_player.subtitles.models import ( + SubtitleContent, + SubtitleProviderGroup, + SubtitleQuery, + SubtitleSearchItem, + SubtitleSearchResult, +) +from atv_player.subtitles.release_parser import ReleaseInfo, parse_release_name +from atv_player.subtitles.service import ( + DEFAULT_PROVIDER_ORDER, + SubtitleSearchService, + build_subtitle_query, + create_default_subtitle_service, +) + +__all__ = [ + "DEFAULT_MATCH_WEIGHTS", + "DEFAULT_PROVIDER_ORDER", + "MatchWeights", + "ReleaseInfo", + "SubtitleArchiveError", + "SubtitleArchiveUnsupportedError", + "SubtitleBlockedError", + "SubtitleContent", + "SubtitleEmptyResultError", + "SubtitleError", + "SubtitleProviderError", + "SubtitleProviderGroup", + "SubtitleQuery", + "SubtitleQuotaExceededError", + "SubtitleSearchItem", + "SubtitleSearchResult", + "SubtitleSearchService", + "SubtitleTokenMissingError", + "apply_scores", + "build_subtitle_query", + "create_default_subtitle_service", + "language_label", + "language_rank", + "normalize_language", + "parse_release_name", + "save_subtitle_file", + "score_subtitle", + "subtitle_cache_dir", +] diff --git a/src/atv_player/subtitles/archive.py b/src/atv_player/subtitles/archive.py new file mode 100644 index 00000000..ecb38dba --- /dev/null +++ b/src/atv_player/subtitles/archive.py @@ -0,0 +1,184 @@ +"""字幕下载内容的解包与编码处理。 + +字幕站返回的可能是裸字幕文本,也可能是 zip/gzip 压缩包。标准库解不开 rar/7z, +遇到这两种只能明确报错(SubDL 的 ``unpack=1`` 与 ASSRT 的 ``filelist`` 都能返回 +已解包的直链,是绕开该限制的主要手段)。 +""" + +from __future__ import annotations + +import gzip +import io +import logging +import zipfile + +from atv_player.subtitles.errors import ( + SubtitleArchiveError, + SubtitleArchiveUnsupportedError, +) +from atv_player.subtitles.languages import language_rank, normalize_language +from atv_player.subtitles.models import SubtitleContent + +logger = logging.getLogger(__name__) + +SUBTITLE_SUFFIXES = (".ass", ".ssa", ".srt", ".vtt", ".sub") + +# 越小越优先。ass/ssa 保留样式,srt 兼容性最好,sub 多为 VobSub 二进制,垫底。 +_FORMAT_RANKS = {".ass": 0, ".ssa": 1, ".srt": 2, ".vtt": 3, ".sub": 4} + +_ARCHIVE_MAGIC = ( + (b"PK\x03\x04", "zip"), + (b"PK\x05\x06", "zip"), + (b"\x1f\x8b", "gzip"), + (b"Rar!\x1a\x07", "rar"), + (b"7z\xbc\xaf\x27\x1c", "7z"), +) + +_ENCODINGS = ("utf-8-sig", "utf-8", "gb18030", "big5", "cp950", "shift_jis") + + +def detect_archive(data: bytes) -> str: + for magic, kind in _ARCHIVE_MAGIC: + if data.startswith(magic): + return kind + return "" + + +def _cjk_score(text: str) -> int: + """粗略衡量解码结果的合理程度。 + + gb18030 几乎能"成功"解码任意字节,所以不能只看是否抛异常。这里给常见 + 中日韩字符和 ASCII 加分,给私用区/替换符等乱码特征减分。 + """ + score = 0 + for char in text: + code = ord(char) + if 0x4E00 <= code <= 0x9FFF or 0x3000 <= code <= 0x303F: + score += 2 + elif char.isascii() and (char.isprintable() or char in "\r\n\t"): + score += 1 + elif code == 0xFFFD or 0xE000 <= code <= 0xF8FF: + score -= 8 + elif not char.isprintable() and char not in "\r\n\t": + score -= 4 + return score + + +def decode_subtitle_bytes(data: bytes) -> str: + """按常见字幕编码嗅探解码,挑合理度最高的结果。""" + if not data: + return "" + best_text = "" + best_score = None + for encoding in _ENCODINGS: + try: + text = data.decode(encoding) + except (UnicodeDecodeError, LookupError): + continue + # UTF-8 严格解码成功基本可以确定就是它,不必再比。 + if encoding in ("utf-8-sig", "utf-8"): + return text + score = _cjk_score(text) + if best_score is None or score > best_score: + best_score = score + best_text = text + if best_score is not None: + return best_text + return data.decode("utf-8", errors="replace") + + +def _suffix_of(name: str) -> str: + lowered = name.casefold() + for suffix in SUBTITLE_SUFFIXES: + if lowered.endswith(suffix): + return suffix + return "" + + +def _member_sort_key(name: str) -> tuple[int, int, int]: + suffix = _suffix_of(name) + language = normalize_language(name) + return ( + _FORMAT_RANKS.get(suffix, len(_FORMAT_RANKS)), + language_rank(language), + len(name), + ) + + +def _is_junk_member(name: str) -> bool: + normalized = name.replace("\\", "/") + if normalized.startswith("__MACOSX/") or "/__MACOSX/" in normalized: + return True + return normalized.rsplit("/", 1)[-1].startswith("._") + + +def _extract_from_zip(data: bytes) -> SubtitleContent: + with zipfile.ZipFile(io.BytesIO(data)) as archive: + candidates = [ + info + for info in archive.infolist() + if not info.is_dir() + and _suffix_of(info.filename) + and not _is_junk_member(info.filename) + ] + if not candidates: + raise SubtitleArchiveError("压缩包内没有可用的字幕文件") + candidates.sort(key=lambda info: _member_sort_key(info.filename)) + chosen = candidates[0] + payload = archive.read(chosen) + name = chosen.filename.replace("\\", "/").rsplit("/", 1)[-1] + return SubtitleContent( + text=decode_subtitle_bytes(payload), + suffix=_suffix_of(name) or ".srt", + name=name, + ) + + +def extract_subtitle(data: bytes, *, name_hint: str = "") -> SubtitleContent: + """把下载到的字节解成字幕正文。 + + ``name_hint`` 是站点给出的文件名,用于在内容本身没有格式特征时定后缀。 + """ + if not data: + raise SubtitleArchiveError("字幕内容为空") + kind = detect_archive(data) + if kind == "zip": + return _extract_from_zip(data) + if kind == "gzip": + try: + payload = gzip.decompress(data) + except OSError as exc: + raise SubtitleArchiveError(f"gzip 解压失败: {exc}") from exc + return extract_subtitle(payload, name_hint=name_hint) + if kind in ("rar", "7z"): + raise SubtitleArchiveUnsupportedError( + f"暂不支持 {kind} 压缩包,请换一条字幕" + ) + text = decode_subtitle_bytes(data) + return SubtitleContent( + text=text, + suffix=_suffix_of(name_hint) or sniff_suffix(text), + name=name_hint, + ) + + +def sniff_suffix(text: str) -> str: + head = text.lstrip()[:400].casefold() + if head.startswith("webvtt"): + return ".vtt" + if "[script info]" in head or "[v4+ styles]" in head: + return ".ass" + return ".srt" + + +def subtitle_name_sort_key(name: str) -> tuple[int, int, int]: + """按格式与语言给字幕文件名排序(越小越优先)。 + + 供 provider 在站点已返回"包内文件列表"时挑最合适的一条复用 + (SubDL 的 ``unpack_files`` 与 ASSRT 的 ``filelist``)。 + """ + return _member_sort_key(name) + + +def suffix_of(name: str) -> str: + return _suffix_of(name) diff --git a/src/atv_player/subtitles/cache.py b/src/atv_player/subtitles/cache.py new file mode 100644 index 00000000..d1e16091 --- /dev/null +++ b/src/atv_player/subtitles/cache.py @@ -0,0 +1,42 @@ +"""下载后的字幕落盘。 + +写到应用缓存目录,再把绝对路径交给播放器现有的外挂字幕通道 +(``_fetch_external_subtitle_text`` 本来就支持绝对本地路径)。 +""" + +from __future__ import annotations + +import hashlib +import re +from pathlib import Path + +from atv_player.paths import app_cache_dir +from atv_player.subtitles.models import SubtitleContent + +_UNSAFE_CHARS = re.compile(r'[\\/:*?"<>|\s]+') +_MAX_STEM_LENGTH = 60 + + +def subtitle_cache_dir() -> Path: + path = app_cache_dir() / "subtitles" + path.mkdir(parents=True, exist_ok=True) + return path + + +def _safe_stem(text: str) -> str: + stem = _UNSAFE_CHARS.sub("_", str(text or "").strip()).strip("_") + return stem[:_MAX_STEM_LENGTH] or "subtitle" + + +def save_subtitle_file(content: SubtitleContent, *, title: str = "") -> Path: + """把字幕正文写入缓存目录,返回绝对路径。 + + 文件名带内容哈希,同一条字幕重复下载会命中同一个文件,不会堆积。 + """ + digest = hashlib.sha256(content.text.encode("utf-8", "replace")).hexdigest()[:12] + stem = _safe_stem(title or content.name) + suffix = content.suffix if content.suffix.startswith(".") else f".{content.suffix}" + path = subtitle_cache_dir() / f"{stem}-{digest}{suffix or '.srt'}" + if not path.exists(): + path.write_text(content.text, encoding="utf-8") + return path diff --git a/src/atv_player/subtitles/errors.py b/src/atv_player/subtitles/errors.py new file mode 100644 index 00000000..922cf293 --- /dev/null +++ b/src/atv_player/subtitles/errors.py @@ -0,0 +1,30 @@ +class SubtitleError(Exception): + pass + + +class SubtitleProviderError(SubtitleError): + """字幕站返回了错误,或页面结构与预期不符。""" + + +class SubtitleTokenMissingError(SubtitleProviderError): + """该站需要 token / API Key,但用户尚未配置。""" + + +class SubtitleBlockedError(SubtitleProviderError): + """被验证码或风控拦截(抓取站常见)。""" + + +class SubtitleQuotaExceededError(SubtitleProviderError): + """超出站点配额(如 OpenSubtitles 免费层每日下载上限)。""" + + +class SubtitleEmptyResultError(SubtitleError): + pass + + +class SubtitleArchiveError(SubtitleError): + pass + + +class SubtitleArchiveUnsupportedError(SubtitleArchiveError): + """压缩格式标准库无法解开(主要是 rar)。""" diff --git a/src/atv_player/subtitles/languages.py b/src/atv_player/subtitles/languages.py new file mode 100644 index 00000000..1b94ad39 --- /dev/null +++ b/src/atv_player/subtitles/languages.py @@ -0,0 +1,91 @@ +"""字幕语言归一与优先级。 + +排序口径与 player/mpv_widget.py 的 ``_chinese_subtitle_preference`` 保持一致 +(那边是"越大越优先",这里统一成"越小越优先"以便直接用于 sort key): + + 简英双语 > 繁英/中英双语 > 简体 > 通用中文 > 繁体 > 英文 > 其他 +""" + +from __future__ import annotations + +CHS_ENG = "chs_eng" +CHT_ENG = "cht_eng" +ZH_ENG = "zh_eng" +CHS = "chs" +ZH = "zh" +CHT = "cht" +ENG = "eng" +OTHER = "other" + +_LANGUAGE_LABELS = { + CHS_ENG: "简英双语", + CHT_ENG: "繁英双语", + ZH_ENG: "中英双语", + CHS: "简体中文", + ZH: "中文", + CHT: "繁体中文", + ENG: "English", + OTHER: "其他", +} + +# 越小越优先。简英双语最高。 +_LANGUAGE_RANKS = { + CHS_ENG: 0, + CHT_ENG: 1, + ZH_ENG: 1, + CHS: 2, + ZH: 3, + CHT: 4, + ENG: 5, + OTHER: 6, +} + +_ENGLISH_TOKENS = ("english", "eng", "英文", "英语", "英", "en") +_SIMPLIFIED_TOKENS = ( + "简英", "简中", "简体", "简", "chs", "hans", "simplified", "gb", "sc", + "zh-cn", "zh_cn", +) +_TRADITIONAL_TOKENS = ( + "繁英", "繁中", "繁體", "繁体", "繁", "cht", "hant", "big5", "traditional", + "tranditional", "tc", "zh-tw", "zh_tw", "zh-hk", +) +_CHINESE_TOKENS = ("中文", "中字", "双语", "chinese", "chi", "zho", "zh") + + +def normalize_language(*hints: str) -> str: + """把站点给的语言描述/文件名归一成语言代码。 + + ``hints`` 按可信度从高到低传入(如 lang 字段、字幕名、文件名)。 + """ + text = " ".join(str(hint or "") for hint in hints).casefold() + if not text.strip(): + return OTHER + has_english = any(token in text for token in _ENGLISH_TOKENS) + has_simplified = any(token in text for token in _SIMPLIFIED_TOKENS) + has_traditional = any(token in text for token in _TRADITIONAL_TOKENS) + has_chinese = has_simplified or has_traditional or any( + token in text for token in _CHINESE_TOKENS + ) + if has_english and has_chinese: + if has_simplified: + return CHS_ENG + if has_traditional: + return CHT_ENG + return ZH_ENG + if has_simplified: + return CHS + if has_traditional: + return CHT + if has_chinese: + return ZH + if has_english: + return ENG + return OTHER + + +def language_label(code: str) -> str: + return _LANGUAGE_LABELS.get(code, _LANGUAGE_LABELS[OTHER]) + + +def language_rank(code: str) -> int: + return _LANGUAGE_RANKS.get(code, _LANGUAGE_RANKS[OTHER]) diff --git a/src/atv_player/subtitles/matcher.py b/src/atv_player/subtitles/matcher.py new file mode 100644 index 00000000..6520f4cf --- /dev/null +++ b/src/atv_player/subtitles/matcher.py @@ -0,0 +1,168 @@ +"""字幕匹配打分。 + +不要直接取搜索结果的第一条:字幕站的相关度排序很粗糙,同名不同季、不同发布 +版本混在一起。这里按多个维度打分,权重集中定义在 ``MatchWeights``,便于调整, +不散落在各处。 + +分数换算成百分比时,分母只统计"本次查询实际能判定的维度"——比如查询里没有年份 +就不把年份权重计入满分,否则电影/剧集的百分比不可比。 +""" + +from __future__ import annotations + +import re +from dataclasses import dataclass + +from atv_player.danmaku.utils import normalize_name, similarity_score +from atv_player.subtitles.languages import language_rank +from atv_player.subtitles.models import SubtitleQuery, SubtitleSearchItem +from atv_player.subtitles.release_parser import equivalent_tokens + +_NON_WORD = re.compile(r"[\W_]+", re.UNICODE) +# language_rank 的最大值(other),用于把语言排名换算成得分 +_MAX_LANGUAGE_RANK = language_rank("other") + + +@dataclass(frozen=True, slots=True) +class MatchWeights: + media_id: int = 50 + title: int = 30 + year: int = 10 + season: int = 30 + episode: int = 30 + language: int = 30 + release_group: int = 20 + resolution: int = 10 + source: int = 20 + codec: int = 10 + hearing_impaired: int = 5 + forced: int = 5 + + +DEFAULT_MATCH_WEIGHTS = MatchWeights() + + +def _compact(text: str) -> str: + return _NON_WORD.sub("", normalize_name(text or "")).casefold() + + +def _haystack(item: SubtitleSearchItem) -> str: + return " ".join( + part for part in (item.name, item.release_name, item.release_site) if part + ).casefold() + + +def _token_present(haystack: str, token: str) -> bool: + if not token: + return False + normalized = token.casefold() + if normalized in haystack: + return True + # H.265 / H265 / h 265 视为同一个词 + return normalized.replace(".", "").replace("-", "") in haystack.replace( + ".", "" + ).replace("-", "") + + +def score_subtitle( + item: SubtitleSearchItem, + query: SubtitleQuery, + *, + weights: MatchWeights = DEFAULT_MATCH_WEIGHTS, +) -> tuple[int, int]: + """返回 ``(得分, 匹配百分比)``。""" + haystack = _haystack(item) + score = 0.0 + max_score = 0.0 + + if query.has_media_id: + max_score += weights.media_id + media_ids = [value for value in (query.imdb_id, query.tmdb_id) if value] + if any(_token_present(haystack, value) for value in media_ids): + score += weights.media_id + + if query.title: + max_score += weights.title + query_title = _compact(query.title) + item_title = _compact(item.name) + if query_title and query_title == item_title: + score += weights.title + elif query_title and query_title in item_title: + score += weights.title * 0.8 + else: + similarity = similarity_score( + normalize_name(query.title), normalize_name(item.name) + ) + score += weights.title * max(0.0, min(1.0, similarity)) + + if query.year: + max_score += weights.year + if str(query.year) in haystack: + score += weights.year + + if query.season is not None: + max_score += weights.season + if item.season == query.season: + score += weights.season + elif item.season is None and _token_present(haystack, f"s{query.season:02d}"): + score += weights.season + + if query.episode is not None: + max_score += weights.episode + if item.episode == query.episode: + score += weights.episode + elif item.episode is None and _token_present(haystack, f"e{query.episode:02d}"): + score += weights.episode + + # 语言总是参与打分:越靠前的语言(简英双语最高)得分越高 + max_score += weights.language + rank = language_rank(item.language) + score += weights.language * max( + 0.0, (_MAX_LANGUAGE_RANK - rank) / _MAX_LANGUAGE_RANK + ) + + for token, weight in ( + (query.release_group, weights.release_group), + (query.resolution, weights.resolution), + (query.source, weights.source), + (query.codec, weights.codec), + ): + if token: + max_score += weight + candidates = equivalent_tokens(token) or (token,) + if any(_token_present(haystack, alias) for alias in candidates): + score += weight + + # 听障/强制字幕只作为小幅加分,不计入满分,避免稀释百分比 + if item.hearing_impaired: + score += weights.hearing_impaired + if item.forced: + score += weights.forced + + total = int(round(score)) + percent = int(round(100 * score / max_score)) if max_score > 0 else 0 + return total, max(0, min(100, percent)) + + +def apply_scores( + items: list[SubtitleSearchItem], + query: SubtitleQuery, + *, + weights: MatchWeights = DEFAULT_MATCH_WEIGHTS, +) -> list[SubtitleSearchItem]: + """给每条候选打分并按分数从高到低排序。""" + from dataclasses import replace + + scored = [] + for item in items: + score, percent = score_subtitle(item, query, weights=weights) + scored.append(replace(item, score=score, match_percent=percent)) + scored.sort( + key=lambda row: ( + -row.score, + language_rank(row.language), + -row.download_count, + -row.vote_score, + ) + ) + return scored diff --git a/src/atv_player/subtitles/models.py b/src/atv_player/subtitles/models.py new file mode 100644 index 00000000..8928d8cd --- /dev/null +++ b/src/atv_player/subtitles/models.py @@ -0,0 +1,98 @@ +from __future__ import annotations + +from dataclasses import dataclass, field + + +@dataclass(frozen=True, slots=True) +class SubtitleQuery: + """一次字幕搜索请求。 + + ``title`` / ``season`` / ``episode`` 等字段通常由 release_parser 从视频文件名 + 解析而来;``imdb_id`` / ``tmdb_id`` 若能从已有元数据拿到则优先使用,命中率 + 远高于按片名搜索。 + """ + + title: str = "" + episode: int | None = None + season: int | None = None + year: int = 0 + imdb_id: str = "" + tmdb_id: str = "" + file_name: str = "" + # 以下由发布名解析得到,用于匹配打分 + resolution: str = "" + source: str = "" + codec: str = "" + release_group: str = "" + + @property + def has_media_id(self) -> bool: + return bool(self.imdb_id or self.tmdb_id) + + @property + def is_episode(self) -> bool: + return self.episode is not None + + +@dataclass(frozen=True, slots=True) +class SubtitleSearchItem: + provider: str + provider_label: str + subtitle_id: str + name: str + language: str = "other" + language_label: str = "" + format: str = "" + release_site: str = "" + release_name: str = "" + download_count: int = 0 + vote_score: float = 0.0 + season: int | None = None + episode: int | None = None + hearing_impaired: bool = False + forced: bool = False + url: str = "" + # 匹配打分结果,由 matcher 填充 + score: int = 0 + match_percent: int = 0 + # 下载该条字幕所需的额外上下文(各 provider 自定义,如详情页地址) + context: dict[str, str] = field(default_factory=dict) + + +@dataclass(frozen=True, slots=True) +class SubtitleProviderGroup: + provider: str + provider_label: str + items: list[SubtitleSearchItem] + # 站点要求的署名等提示文案(如 ASSRT 的"字幕服务由 assrt.net 提供") + notice: str = "" + + +@dataclass(frozen=True, slots=True) +class SubtitleSearchResult: + groups: list[SubtitleProviderGroup] = field(default_factory=list) + # provider id -> 失败原因,用于在界面上区分"没搜到"和"这个站挂了" + errors: dict[str, str] = field(default_factory=dict) + # 未配置 token 而被跳过的站点 id + skipped: list[str] = field(default_factory=list) + + @property + def total(self) -> int: + return sum(len(group.items) for group in self.groups) + + def best_item(self) -> SubtitleSearchItem | None: + best: SubtitleSearchItem | None = None + for group in self.groups: + for item in group.items: + if best is None or item.score > best.score: + best = item + return best + + +@dataclass(frozen=True, slots=True) +class SubtitleContent: + """下载并解包后的字幕正文。""" + + text: str + suffix: str + name: str = "" diff --git a/src/atv_player/subtitles/providers/__init__.py b/src/atv_player/subtitles/providers/__init__.py new file mode 100644 index 00000000..20d1b5ec --- /dev/null +++ b/src/atv_player/subtitles/providers/__init__.py @@ -0,0 +1,17 @@ +from atv_player.subtitles.providers.assrt import AssrtSubtitleProvider +from atv_player.subtitles.providers.base import SubtitleProvider +from atv_player.subtitles.providers.opensubtitles import OpenSubtitlesProvider +from atv_player.subtitles.providers.subdl import SubDLSubtitleProvider +from atv_player.subtitles.providers.subhd import SubHDSubtitleProvider +from atv_player.subtitles.providers.subsource import SubsourceSubtitleProvider +from atv_player.subtitles.providers.zimuku import ZimukuSubtitleProvider + +__all__ = [ + "AssrtSubtitleProvider", + "OpenSubtitlesProvider", + "SubDLSubtitleProvider", + "SubHDSubtitleProvider", + "SubtitleProvider", + "SubsourceSubtitleProvider", + "ZimukuSubtitleProvider", +] diff --git a/src/atv_player/subtitles/providers/_common.py b/src/atv_player/subtitles/providers/_common.py new file mode 100644 index 00000000..2e7a36d1 --- /dev/null +++ b/src/atv_player/subtitles/providers/_common.py @@ -0,0 +1,172 @@ +"""字幕站 provider 共用的 HTTP 与错误收敛逻辑。 + +各 provider 通过注入的 ``get`` / ``post`` 发请求(应用侧传入的是代理感知的可调用 +对象,见 app.py 的 ``_proxy_http_get()``),单测里换成假函数即可离线跑。 +""" + +from __future__ import annotations + +from collections.abc import Callable +from typing import Any + +from atv_player.subtitles.errors import ( + SubtitleBlockedError, + SubtitleProviderError, + SubtitleQuotaExceededError, +) + +BROWSER_UA = ( + "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) " + "Chrome/126.0.0.0 Safari/537.36" +) +DEFAULT_TIMEOUT = 15.0 +DOWNLOAD_TIMEOUT = 30.0 + +_BLOCK_MARKERS = ( + "网站防火墙", + "访问认证", + "请输入验证码", + "cf-browser-verification", + "checking your browser", + "just a moment", + "attention required", +) + + +def merge_headers(extra: dict[str, str] | None = None) -> dict[str, str]: + headers = { + "User-Agent": BROWSER_UA, + "Accept-Language": "zh-CN,zh;q=0.9,en;q=0.8", + } + if extra: + headers.update(extra) + return headers + + +def clean_params(params: dict[str, Any] | None) -> dict[str, Any]: + if not params: + return {} + return {key: value for key, value in params.items() if value not in (None, "", [])} + + +def _status_code(response: object) -> int: + try: + return int(getattr(response, "status_code", 0) or 0) + except (TypeError, ValueError): + return 0 + + +def _raise_for_status(response: object, site: str) -> None: + code = _status_code(response) + if code == 429: + raise SubtitleQuotaExceededError(f"{site}请求过于频繁,请稍后再试") + if code in (401, 403): + raise SubtitleBlockedError( + f"{site}拒绝了请求({code}),可能需要重新配置或被风控" + ) + if code and not 200 <= code < 400: + raise SubtitleProviderError(f"{site}返回异常状态码 {code}") + + +def raise_for_status(response: object, site: str) -> None: + """公开版状态码检查,配合 ``ignore_status`` 使用的调用方手动触发。""" + _raise_for_status(response, site) + + +def guard_blocked(text: str, site: str) -> None: + """抓取站命中验证码/风控时给出明确文案,而不是当成"没搜到"。""" + lowered = text[:4000].casefold() + if any(marker in lowered for marker in _BLOCK_MARKERS): + raise SubtitleBlockedError(f"{site}触发了验证码,暂时无法搜索") + + +def http_get( + get: Callable[..., Any], + url: str, + *, + params: dict[str, Any] | None = None, + headers: dict[str, str] | None = None, + cookies: dict[str, str] | None = None, + timeout: float = DEFAULT_TIMEOUT, + site: str = "", + ignore_status: bool = False, +) -> Any: + """发 GET 请求并做统一的错误收敛。 + + ``ignore_status=True`` 时不检查 HTTP 状态码,给"错误信息在响应体里" + 的站点(如射手网把错误码漏进状态码)用,调用方自行解析后再调 + :func:`raise_for_status` 兜底。 + """ + kwargs: dict[str, Any] = { + "params": clean_params(params), + "headers": merge_headers(headers), + "timeout": timeout, + "follow_redirects": True, + } + if cookies: + kwargs["cookies"] = cookies + try: + response = get(url, **kwargs) + except (SubtitleProviderError, SubtitleQuotaExceededError, SubtitleBlockedError): + raise + except Exception as exc: + raise SubtitleProviderError(f"{site}请求失败: {exc}") from exc + if not ignore_status: + _raise_for_status(response, site) + return response + + +def http_post( + post: Callable[..., Any], + url: str, + *, + data: dict[str, Any] | None = None, + json_body: dict[str, Any] | None = None, + headers: dict[str, str] | None = None, + cookies: dict[str, str] | None = None, + timeout: float = DEFAULT_TIMEOUT, + site: str = "", +) -> Any: + kwargs: dict[str, Any] = { + "headers": merge_headers(headers), + "timeout": timeout, + "follow_redirects": True, + } + if cookies: + kwargs["cookies"] = cookies + if data is not None: + kwargs["data"] = data + if json_body is not None: + kwargs["json"] = json_body + try: + response = post(url, **kwargs) + except (SubtitleProviderError, SubtitleQuotaExceededError, SubtitleBlockedError): + raise + except Exception as exc: + raise SubtitleProviderError(f"{site}请求失败: {exc}") from exc + _raise_for_status(response, site) + return response + + +def response_json(response: object, site: str) -> dict[str, Any]: + try: + payload = response.json() # type: ignore[attr-defined] + except Exception as exc: + raise SubtitleProviderError(f"{site}返回的不是合法 JSON") from exc + if not isinstance(payload, dict): + raise SubtitleProviderError(f"{site}返回了非预期的数据结构") + return payload + + +def response_text(response: object) -> str: + text = getattr(response, "text", "") + return text if isinstance(text, str) else "" + + +def response_bytes(response: object) -> bytes: + content = getattr(response, "content", b"") + if isinstance(content, bytes): + return content + if isinstance(content, str): + return content.encode("utf-8", errors="replace") + return b"" diff --git a/src/atv_player/subtitles/providers/assrt.py b/src/atv_player/subtitles/providers/assrt.py new file mode 100644 index 00000000..6cf273b2 --- /dev/null +++ b/src/atv_player/subtitles/providers/assrt.py @@ -0,0 +1,239 @@ +"""ASSRT(射手网(伪))字幕站。 + +需要免费 token(用户面板获取),配额 20 次/分钟且 token 与 IP 共享。 +``sub/detail`` 返回的 ``filelist`` 是站点侧已解包的直链,优先用它,避免解压。 + +文档: https://2.assrt.net/api/doc +""" + +from __future__ import annotations + +from collections.abc import Callable +from typing import Any + +import httpx + +from atv_player.subtitles.archive import extract_subtitle, subtitle_name_sort_key +from atv_player.subtitles.errors import ( + SubtitleProviderError, + SubtitleQuotaExceededError, + SubtitleTokenMissingError, +) +from atv_player.subtitles.languages import language_label, normalize_language +from atv_player.subtitles.models import ( + SubtitleContent, + SubtitleQuery, + SubtitleSearchItem, +) +from atv_player.subtitles.providers._common import ( + DOWNLOAD_TIMEOUT, + http_get, + raise_for_status, + response_bytes, + response_json, +) + +# langlist 的键 -> 语言提示词,用于归一 +_LANG_FLAGS = { + "langchs": "简体", + "langcht": "繁体", + "langeng": "英文", + "langdou": "双语", + "langjpn": "日文", + "langkor": "韩文", +} + +_QUOTA_ERROR_CODE = 30900 +_TOKEN_ERROR_CODE = 20001 +_NOT_FOUND_ERROR_CODE = 20900 + + +class AssrtSubtitleProvider: + provider_id = "assrt" + label = "射手网(伪)" + requires_token = True + notice = "字幕服务由 assrt.net 提供" + + _BASE_URL = "https://api.assrt.net/v1" + _MIN_KEYWORD_LENGTH = 3 + _PAGE_SIZE = 15 + + def __init__( + self, + get: Callable[..., Any] = httpx.get, + token_loader: Callable[[], str] | None = None, + ) -> None: + self._get = get + self._token_loader = token_loader + + def _token(self) -> str: + if self._token_loader is None: + return "" + try: + return str(self._token_loader() or "").strip() + except Exception: + return "" + + def available(self) -> bool: + return bool(self._token()) + + def search(self, query: SubtitleQuery) -> list[SubtitleSearchItem]: + token = self._token() + if not token: + raise SubtitleTokenMissingError("射手网需要先配置 Token") + keyword = self._build_keyword(query) + if len(keyword) < self._MIN_KEYWORD_LENGTH: + return [] + # 对照 bazarr:is_file=1 让站点把查询串当文件名解析, + # "剧名 S01E01" 这种格式命中率比纯片名高很多 + params: dict[str, Any] = { + "token": token, + "q": keyword, + "is_file": 1, + "cnt": self._PAGE_SIZE, + "pos": 0, + } + payload = self._request("sub/search", params) + subs = self._subs_of(payload) + return [ + self._build_item(entry) + for entry in subs + if isinstance(entry, dict) and entry.get("id") is not None + ] + + @staticmethod + def _build_keyword(query: SubtitleQuery) -> str: + """对照 bazarr 的 query 构造:剧集拼 S01E01,电影拼年份。""" + parts: list[str] = [] + title = (query.title or query.file_name).strip() + if title: + parts.append(title) + if query.season is not None and query.episode is not None: + parts.append(f"S{query.season:02d}E{query.episode:02d}") + elif query.episode is not None: + parts.append(f"E{query.episode:02d}") + elif query.year: + parts.append(str(query.year)) + return " ".join(parts).strip() + + def _request(self, endpoint: str, params: dict[str, Any]) -> dict[str, Any]: + # 射手网会把 JSON 错误码漏进 HTTP 状态码(文档:ClientFail/ServerFail + # 分别映射 4xx/5xx,实测 20001→400;用户遇到过 492 这类非标准码), + # 真正的错误信息在响应体里,所以先解析 body 再兜底状态码 + response = http_get( + self._get, + f"{self._BASE_URL}/{endpoint}", + params=params, + headers={"Accept": "application/json"}, + site="射手网", + ignore_status=True, + ) + try: + payload = response_json(response, "射手网") + except SubtitleProviderError as exc: + # body 不是 JSON:若状态码异常先按状态码报错,否则报解析失败 + raise_for_status(response, "射手网") + raise SubtitleProviderError("射手网返回了无法解析的响应") from exc + status = payload.get("status") + if status not in (0, None): + if status == _QUOTA_ERROR_CODE: + raise SubtitleQuotaExceededError( + "射手网配额超限(20 次/分钟,Token 与 IP 共享),稍后再试" + ) + if status == _TOKEN_ERROR_CODE: + raise SubtitleProviderError( + "射手网 Token 无效,请到 assrt.net 用户面板核对后重新填写" + ) + if status == _NOT_FOUND_ERROR_CODE: + raise SubtitleProviderError("射手网上该字幕已下架,请换一条") + message = str(payload.get("errmsg") or payload.get("error") or "").strip() + raise SubtitleProviderError(f"射手网错误 {status}: {message or '请求失败'}") + return payload + + @staticmethod + def _subs_of(payload: dict[str, Any]) -> list[Any]: + sub = payload.get("sub") + if not isinstance(sub, dict): + return [] + subs = sub.get("subs") + return subs if isinstance(subs, list) else [] + + @staticmethod + def _language_hint(entry: dict[str, Any]) -> str: + lang = entry.get("lang") + if not isinstance(lang, dict): + return "" + parts = [str(lang.get("desc") or "")] + langlist = lang.get("langlist") + if isinstance(langlist, dict): + parts.extend(hint for key, hint in _LANG_FLAGS.items() if langlist.get(key)) + return " ".join(part for part in parts if part) + + def _build_item(self, entry: dict[str, Any]) -> SubtitleSearchItem: + name = str(entry.get("native_name") or "").strip() + video_name = str(entry.get("videoname") or "").strip() + display = name or video_name or "射手网字幕" + language = normalize_language(self._language_hint(entry), display, video_name) + return SubtitleSearchItem( + provider=self.provider_id, + provider_label=self.label, + subtitle_id=str(entry.get("id")), + name=display, + language=language, + language_label=language_label(language), + format=str(entry.get("subtype") or "").strip(), + release_site=str(entry.get("release_site") or "").strip(), + vote_score=self._as_float(entry.get("vote_score")), + context={"videoname": video_name}, + ) + + @staticmethod + def _as_float(value: object) -> float: + try: + return float(str(value).strip()) + except (TypeError, ValueError): + return 0.0 + + def download(self, item: SubtitleSearchItem) -> SubtitleContent: + token = self._token() + if not token: + raise SubtitleTokenMissingError("射手网需要先配置 Token") + payload = self._request( + "sub/detail", + {"token": token, "id": item.subtitle_id}, + ) + subs = self._subs_of(payload) + detail = next((row for row in subs if isinstance(row, dict)), None) + if detail is None: + raise SubtitleProviderError("射手网未返回字幕详情") + url, name_hint = self._pick_download_target(detail) + if not url: + raise SubtitleProviderError("射手网未返回可用的下载地址") + response = http_get( + self._get, + url, + timeout=DOWNLOAD_TIMEOUT, + site="射手网", + ) + return extract_subtitle(response_bytes(response), name_hint=name_hint) + + @staticmethod + def _pick_download_target(detail: dict[str, Any]) -> tuple[str, str]: + """优先取 filelist 里的直链,退回整包地址。""" + filelist = detail.get("filelist") + if isinstance(filelist, list): + rows = [ + row + for row in filelist + if isinstance(row, dict) and str(row.get("url") or "").strip() + ] + rows.sort(key=lambda row: subtitle_name_sort_key(str(row.get("f") or ""))) + if rows: + return ( + str(rows[0].get("url") or "").strip(), + str(rows[0].get("f") or "").strip(), + ) + return ( + str(detail.get("url") or "").strip(), + str(detail.get("filename") or "").strip(), + ) diff --git a/src/atv_player/subtitles/providers/base.py b/src/atv_player/subtitles/providers/base.py new file mode 100644 index 00000000..b6b943e5 --- /dev/null +++ b/src/atv_player/subtitles/providers/base.py @@ -0,0 +1,32 @@ +from __future__ import annotations + +from typing import Protocol + +from atv_player.subtitles.models import ( + SubtitleContent, + SubtitleQuery, + SubtitleSearchItem, +) + + +class SubtitleProvider(Protocol): + """字幕站接口。 + + 实现约定: + + - ``search`` 只负责取回候选并做最基本的归一,排序交给 SubtitleSearchService。 + - ``search`` / ``download`` 失败时抛 SubtitleProviderError 的子类, + 由 service 收敛成"该站失败",不影响其他站。 + - ``available`` 为 False 时 service 会静默跳过(通常是需要 token 但没配)。 + """ + + provider_id: str + label: str + requires_token: bool + notice: str + + def available(self) -> bool: ... + + def search(self, query: SubtitleQuery) -> list[SubtitleSearchItem]: ... + + def download(self, item: SubtitleSearchItem) -> SubtitleContent: ... diff --git a/src/atv_player/subtitles/providers/opensubtitles.py b/src/atv_player/subtitles/providers/opensubtitles.py new file mode 100644 index 00000000..0b9d2380 --- /dev/null +++ b/src/atv_player/subtitles/providers/opensubtitles.py @@ -0,0 +1,200 @@ +"""OpenSubtitles.com 字幕站。 + +需要免费 API Key,免费层每天 5 次下载,超额后 ``/download`` 会返回 406。 +搜索不消耗下载配额,所以列表能正常出,点下载才可能撞上限。 + +文档: https://opensubtitles.stoplight.io/docs/opensubtitles-api +""" + +from __future__ import annotations + +from collections.abc import Callable +from typing import Any + +import httpx + +from atv_player.subtitles.archive import extract_subtitle +from atv_player.subtitles.errors import ( + SubtitleQuotaExceededError, + SubtitleTokenMissingError, +) +from atv_player.subtitles.languages import language_label, normalize_language +from atv_player.subtitles.models import ( + SubtitleContent, + SubtitleQuery, + SubtitleSearchItem, +) +from atv_player.subtitles.providers._common import ( + DOWNLOAD_TIMEOUT, + http_get, + http_post, + response_bytes, + response_json, +) + +# OpenSubtitles 要求带上能标识应用的 User-Agent +_USER_AGENT = "atv-player v0.1.0" +_DEFAULT_LANGUAGES = "zh-CN,zh-TW,en" + + +class OpenSubtitlesProvider: + provider_id = "opensubtitles" + label = "OpenSubtitles" + requires_token = True + notice = "免费账号每天限 5 次下载" + + _BASE_URL = "https://api.opensubtitles.com/api/v1" + + def __init__( + self, + get: Callable[..., Any] = httpx.get, + post: Callable[..., Any] = httpx.post, + api_key_loader: Callable[[], str] | None = None, + languages: str = "", + ) -> None: + self._get = get + self._post = post + self._api_key_loader = api_key_loader + self._languages = str(languages or _DEFAULT_LANGUAGES).strip() + + def _api_key(self) -> str: + if self._api_key_loader is None: + return "" + try: + return str(self._api_key_loader() or "").strip() + except Exception: + return "" + + def available(self) -> bool: + return bool(self._api_key()) + + def _headers(self, api_key: str) -> dict[str, str]: + return { + "Api-Key": api_key, + "User-Agent": _USER_AGENT, + "Accept": "application/json", + } + + def search(self, query: SubtitleQuery) -> list[SubtitleSearchItem]: + api_key = self._api_key() + if not api_key: + raise SubtitleTokenMissingError("OpenSubtitles 需要先配置 API Key") + keyword = query.title.strip() + if not keyword and not query.has_media_id: + return [] + params: dict[str, Any] = {"languages": self._languages} + # 有权威 id 时优先按 id 搜 + if query.imdb_id: + params["imdb_id"] = query.imdb_id.removeprefix("tt") + elif query.tmdb_id: + params["tmdb_id"] = query.tmdb_id + else: + params["query"] = keyword + if query.episode is not None: + params["episode_number"] = query.episode + if query.season is not None: + params["season_number"] = query.season + if query.year: + params["year"] = query.year + response = http_get( + self._get, + f"{self._BASE_URL}/subtitles", + params=params, + headers=self._headers(api_key), + site="OpenSubtitles", + ) + payload = response_json(response, "OpenSubtitles") + rows = payload.get("data") + if not isinstance(rows, list): + return [] + items: list[SubtitleSearchItem] = [] + for row in rows: + if not isinstance(row, dict): + continue + item = self._build_item(row) + if item is not None: + items.append(item) + return items + + def _build_item(self, row: dict[str, Any]) -> SubtitleSearchItem | None: + attributes = row.get("attributes") + if not isinstance(attributes, dict): + return None + files = attributes.get("files") + file_id = "" + file_name = "" + if isinstance(files, list): + for entry in files: + if isinstance(entry, dict) and entry.get("file_id") is not None: + file_id = str(entry.get("file_id")) + file_name = str(entry.get("file_name") or "").strip() + break + if not file_id: + return None + release = str(attributes.get("release") or "").strip() + raw_language = str(attributes.get("language") or "").strip() + display = release or file_name or "OpenSubtitles 字幕" + language = normalize_language(raw_language, display, file_name) + return SubtitleSearchItem( + provider=self.provider_id, + provider_label=self.label, + subtitle_id=file_id, + name=display, + language=language, + language_label=language_label(language), + format=str(attributes.get("format") or "").strip(), + release_site=str(attributes.get("uploader_name") or "").strip(), + release_name=release, + hearing_impaired=bool(attributes.get("hearing_impaired")), + forced=bool(attributes.get("foreign_parts_only")), + download_count=self._as_int(attributes.get("download_count")), + vote_score=self._as_float(attributes.get("ratings")), + context={"file_name": file_name}, + ) + + @staticmethod + def _as_int(value: object) -> int: + try: + return int(str(value).strip()) + except (TypeError, ValueError): + return 0 + + @staticmethod + def _as_float(value: object) -> float: + try: + return float(str(value).strip()) + except (TypeError, ValueError): + return 0.0 + + def download(self, item: SubtitleSearchItem) -> SubtitleContent: + api_key = self._api_key() + if not api_key: + raise SubtitleTokenMissingError("OpenSubtitles 需要先配置 API Key") + headers = self._headers(api_key) + headers["Content-Type"] = "application/json" + response = http_post( + self._post, + f"{self._BASE_URL}/download", + json_body={"file_id": self._as_int(item.subtitle_id)}, + headers=headers, + site="OpenSubtitles", + ) + payload = response_json(response, "OpenSubtitles") + link = str(payload.get("link") or "").strip() + if not link: + message = str(payload.get("message") or "").strip() + raise SubtitleQuotaExceededError( + f"OpenSubtitles 无法下载: {message or '可能已超出每日配额'}" + ) + file_response = http_get( + self._get, + link, + timeout=DOWNLOAD_TIMEOUT, + site="OpenSubtitles", + ) + name_hint = ( + str(payload.get("file_name") or "").strip() + or item.context.get("file_name", "") + or item.name + ) + return extract_subtitle(response_bytes(file_response), name_hint=name_hint) diff --git a/src/atv_player/subtitles/providers/subdl.py b/src/atv_player/subtitles/providers/subdl.py new file mode 100644 index 00000000..7cd8c56b --- /dev/null +++ b/src/atv_player/subtitles/providers/subdl.py @@ -0,0 +1,256 @@ +"""SubDL 字幕站。 + +官方 REST API,免费 API Key(每天 2000 次请求)。``unpack=1`` 会额外返回压缩包内 +逐集的字幕直链,可直接下载单个 .srt,省去解压这一步。 + +文档: https://subdl.com/api-doc +""" + +from __future__ import annotations + +from collections.abc import Callable +from typing import Any + +import httpx + +from atv_player.subtitles.archive import extract_subtitle, subtitle_name_sort_key +from atv_player.subtitles.errors import ( + SubtitleProviderError, + SubtitleTokenMissingError, +) +from atv_player.subtitles.languages import language_label, normalize_language +from atv_player.subtitles.models import ( + SubtitleContent, + SubtitleQuery, + SubtitleSearchItem, +) +from atv_player.subtitles.providers._common import ( + DOWNLOAD_TIMEOUT, + http_get, + response_bytes, + response_json, +) + + +class SubDLSubtitleProvider: + provider_id = "subdl" + label = "SubDL" + requires_token = True + notice = "" + + _SEARCH_URL = "https://api.subdl.com/api/v1/subtitles" + _DOWNLOAD_BASE = "https://dl.subdl.com" + _DEFAULT_LANGUAGES = "ZH,EN" + _PAGE_SIZE = 30 + + def __init__( + self, + get: Callable[..., Any] = httpx.get, + api_key_loader: Callable[[], str] | None = None, + languages: str = "", + ) -> None: + self._get = get + self._api_key_loader = api_key_loader + self._languages = str(languages or self._DEFAULT_LANGUAGES).strip() + + def _api_key(self) -> str: + if self._api_key_loader is None: + return "" + try: + return str(self._api_key_loader() or "").strip() + except Exception: + return "" + + def available(self) -> bool: + return bool(self._api_key()) + + def search(self, query: SubtitleQuery) -> list[SubtitleSearchItem]: + api_key = self._api_key() + if not api_key: + raise SubtitleTokenMissingError("SubDL 需要先配置 API Key") + keyword = (query.file_name or query.title).strip() + if not keyword: + return [] + params: dict[str, Any] = { + "api_key": api_key, + "subs_per_page": self._PAGE_SIZE, + "unpack": 1, + "hi": 1, + "comment": 1, + "releases": 1, + # 对照 bazarr:bazarr=1 过滤掉不兼容的图片字幕/txt 字幕, + # 只保留文本字幕(v1 端点的官方过滤标志 + "bazarr": 1, + } + # 有权威 id 时优先按 id 搜,命中率远高于按片名 + if query.imdb_id: + params["imdb_id"] = query.imdb_id.removeprefix("tt") + elif query.tmdb_id: + params["tmdb_id"] = query.tmdb_id + elif query.title: + params["film_name"] = query.title + else: + params["file_name"] = query.file_name + # 只要有季或集就按剧集搜:整季搜索(只有 season 没有 episode)也很常见, + # 不能因为 episode 为空就误判成电影 + if query.season is not None or query.episode is not None: + params["type"] = "tv" + if query.season is not None: + params["season_number"] = query.season + if query.episode is not None: + params["episode_number"] = query.episode + elif query.title or query.has_media_id: + params["type"] = "movie" + if query.year: + params["year"] = query.year + + payload = self._request(dict(params, languages=self._languages)) + subtitles = payload.get("subtitles") + if not subtitles: + # 语言代码可能不被接受或过滤过窄,放开语言再试一次。 + payload = self._request(params) + subtitles = payload.get("subtitles") + if not isinstance(subtitles, list): + return [] + items: list[SubtitleSearchItem] = [] + for entry in subtitles: + if isinstance(entry, dict): + items.extend(self._build_items(entry, query)) + return items + + def _request(self, params: dict[str, Any]) -> dict[str, Any]: + response = http_get( + self._get, + self._SEARCH_URL, + params=params, + headers={"Accept": "application/json"}, + site="SubDL", + ) + payload = response_json(response, "SubDL") + if payload.get("status") is False: + message = str(payload.get("error") or "").strip() + raise SubtitleProviderError(f"SubDL: {message or '搜索失败'}") + return payload + + def _build_items( + self, + entry: dict[str, Any], + query: SubtitleQuery, + ) -> list[SubtitleSearchItem]: + release = str(entry.get("release_name") or "").strip() + base_name = str(entry.get("name") or "").strip() + unpack_files = entry.get("unpack_files") + if isinstance(unpack_files, list) and unpack_files: + return self._build_unpacked_items(unpack_files, release, query) + url = self._absolute_url(str(entry.get("url") or "").strip()) + if not url: + return [] + display = release or base_name or "SubDL 字幕" + language = normalize_language( + str(entry.get("language") or ""), + display, + base_name, + ) + return [ + SubtitleSearchItem( + provider=self.provider_id, + provider_label=self.label, + subtitle_id=url, + name=display, + language=language, + language_label=language_label(language), + format=self._format_of(base_name), + release_site=str(entry.get("author") or "").strip(), + release_name=release or base_name, + season=self._as_int(entry.get("season")), + episode=self._as_int(entry.get("episode")), + hearing_impaired=bool(entry.get("hi")), + url=url, + ) + ] + + def _build_unpacked_items( + self, + unpack_files: list[Any], + release: str, + query: SubtitleQuery, + ) -> list[SubtitleSearchItem]: + rows = [row for row in unpack_files if isinstance(row, dict)] + if query.episode is not None: + matched = [ + row + for row in rows + if self._as_int(row.get("episode")) == query.episode + ] + # 整季包里没有对应集数时不强行降级,交给其他结果。 + rows = matched or [] + rows.sort(key=lambda row: subtitle_name_sort_key(str(row.get("name") or ""))) + items: list[SubtitleSearchItem] = [] + for row in rows: + url = self._absolute_url(str(row.get("url") or "").strip()) + if not url: + continue + file_name = str(row.get("name") or "").strip() + display = str(row.get("release_name") or "").strip() or file_name or release + language = normalize_language( + str(row.get("language") or ""), + file_name, + display, + ) + items.append( + SubtitleSearchItem( + provider=self.provider_id, + provider_label=self.label, + subtitle_id=url, + name=display or "SubDL 字幕", + language=language, + language_label=language_label(language), + format=str(row.get("format") or "").strip() + or self._format_of(file_name), + release_name=display or release, + season=self._as_int(row.get("season")), + episode=self._as_int(row.get("episode")), + hearing_impaired=bool(row.get("hi")), + url=url, + context={"file_name": file_name}, + ) + ) + return items + + def _absolute_url(self, url: str) -> str: + if not url: + return "" + if url.startswith("http://") or url.startswith("https://"): + return url + return f"{self._DOWNLOAD_BASE}/{url.lstrip('/')}" + + @staticmethod + def _as_int(value: object) -> int | None: + try: + return int(str(value).strip()) + except (TypeError, ValueError): + return None + + @staticmethod + def _format_of(name: str) -> str: + lowered = name.casefold() + for suffix in (".ass", ".ssa", ".srt", ".vtt"): + if lowered.endswith(suffix): + return suffix.lstrip(".") + return "" + + def download(self, item: SubtitleSearchItem) -> SubtitleContent: + url = item.url or item.subtitle_id + if not url: + raise SubtitleProviderError("SubDL 字幕缺少下载地址") + # 免费 Key 不能给下载链接带 api_key(那会走付费配额),走匿名下载即可。 + response = http_get( + self._get, + url, + timeout=DOWNLOAD_TIMEOUT, + site="SubDL", + ) + return extract_subtitle( + response_bytes(response), + name_hint=item.context.get("file_name", "") or item.name, + ) diff --git a/src/atv_player/subtitles/providers/subhd.py b/src/atv_player/subtitles/providers/subhd.py new file mode 100644 index 00000000..7a8c1d8f --- /dev/null +++ b/src/atv_player/subtitles/providers/subhd.py @@ -0,0 +1,247 @@ +"""SubHD 字幕站(抓取,无需 token)。 + +站点没有公开 API,只能解析页面。HTML 结构随时可能变,所以: + +- 选择器写多个回退分支,任一命中即可; +- 全部落空时抛 SubtitleProviderError,由 service 收敛成"该站失败", + 不会影响其他站点的结果。 + +下载走 2026-08 改版后的多步校验链路(详情页 → prepare-download → +/down/ 中转页 → /api/sub/down → 直链),全程匿名但依赖 cookie,见 ``download``。 +""" + +from __future__ import annotations + +import re +from collections.abc import Callable +from typing import Any +from urllib.parse import quote, urljoin + +from lxml import etree + +from atv_player.subtitles.archive import extract_subtitle +from atv_player.subtitles.errors import SubtitleProviderError +from atv_player.subtitles.languages import language_label, normalize_language +from atv_player.subtitles.models import ( + SubtitleContent, + SubtitleQuery, + SubtitleSearchItem, +) +from atv_player.subtitles.providers._common import ( + DOWNLOAD_TIMEOUT, + guard_blocked, + http_get, + http_post, + response_bytes, + response_json, + response_text, +) +from atv_player.subtitles.release_parser import parse_release_name + +_DETAIL_HREF = re.compile(r"^/a/(\w+)") +_WHITESPACE = re.compile(r"\s+") + + +class SubHDSubtitleProvider: + provider_id = "subhd" + label = "SubHD" + requires_token = False + notice = "" + + _BASE_URL = "https://www.subhd.tv" + _MAX_ITEMS = 30 + + def __init__( + self, + get: Callable[..., Any], + post: Callable[..., Any] = lambda *a, **k: None, + base_url: str = "", + ) -> None: + self._get = get + self._post = post + self._base_url = (base_url or self._BASE_URL).rstrip("/") + + def available(self) -> bool: + return True + + def search(self, query: SubtitleQuery) -> list[SubtitleSearchItem]: + keyword = (query.title or query.file_name).strip() + if not keyword: + return [] + url = f"{self._base_url}/search/{quote(keyword)}" + response = http_get(self._get, url, site="SubHD") + text = response_text(response) + guard_blocked(text, "SubHD") + return self._parse_search(text, query) + + def _parse_search( + self, text: str, query: SubtitleQuery | None = None + ) -> list[SubtitleSearchItem]: + tree = etree.HTML(text) + if tree is None: + raise SubtitleProviderError("SubHD 返回的页面无法解析") + # 每个结果卡片里同一个 sid 有多个锚:短中文标题 + 带 SxxEyy 的发布名。 + # 只取第一个锚会让所有条目坍缩成同一个标题,季集信息全丢, + # 用户就总是下到排最前面的那一集——这里保留最长(信息最全)的锚文本 + candidates: dict[str, dict[str, str]] = {} + for anchor in tree.xpath('//a[starts-with(@href, "/a/")]'): + href = str(anchor.get("href") or "") + matched = _DETAIL_HREF.match(href) + if matched is None: + continue + subtitle_id = matched.group(1) + name = self._text_of(anchor) + if not name: + continue + current = candidates.get(subtitle_id) + if current is None: + candidates[subtitle_id] = { + "name": name, + "href": href, + "context": self._container_text(anchor), + } + continue + if len(name) > len(current["name"]): + # 中文标题有展示价值,发布名有匹配价值,两个都要 + if current["name"] not in name: + name = f"{current['name']} {name}" + current["name"] = name + items: list[SubtitleSearchItem] = [] + for subtitle_id, info in candidates.items(): + season, episode = self._season_episode(info["name"]) + # 能明确判定季集且与查询不符的直接丢弃;判定不出的(整季包)保留 + if query is not None and query.season is not None: + if season is not None and season != query.season: + continue + if query.episode is not None and episode is not None: + if episode != query.episode: + continue + language = normalize_language(info["context"], info["name"]) + items.append( + SubtitleSearchItem( + provider=self.provider_id, + provider_label=self.label, + subtitle_id=subtitle_id, + name=info["name"], + language=language, + language_label=language_label(language), + season=season, + episode=episode, + context={"detail_url": urljoin(self._base_url, info["href"])}, + ) + ) + if len(items) >= self._MAX_ITEMS: + break + return items + + @staticmethod + def _season_episode(name: str) -> tuple[int | None, int | None]: + parsed = parse_release_name(name) + return parsed.season, parsed.episode + + @staticmethod + def _text_of(node: Any) -> str: + return _WHITESPACE.sub(" ", "".join(node.itertext())).strip() + + def _container_text(self, anchor: Any) -> str: + # 语言标签通常在结果卡片里,取最近的容器文本作为归一依据 + for expression in ( + 'ancestor::div[contains(@class, "box")][1]', + "ancestor::div[2]", + "..", + ): + found = anchor.xpath(expression) + if found: + return self._text_of(found[0])[:400] + return "" + + def download(self, item: SubtitleSearchItem) -> SubtitleContent: + """匿名下载(2026-08 改版后的链路,对照站点 subhd.js)。 + + /api/sub/prepare-download 会下发带 Path 的校验 cookie(down_* 只对 + /api/sub/down 生效),后续每一步都要带上,否则 /down/ 直接 403 + "下载页面已失效"。注入的 get/post 是无状态的,所以在这里自己攒 cookie。 + """ + subtitle_id = item.subtitle_id + detail_url = item.context.get("detail_url") or ( + f"{self._base_url}/a/{subtitle_id}" + ) + cookies: dict[str, str] = {} + + # 1) 访问详情页,像浏览器一样建立会话 + response = http_get(self._get, detail_url, site="SubHD") + self._merge_cookies(cookies, response) + + # 2) 预备下载:校验会话并换 /down/ 入口 + response = http_post( + self._post, + f"{self._base_url}/api/sub/prepare-download", + json_body={"sid": subtitle_id}, + headers={ + "Referer": detail_url, + "X-Requested-With": "XMLHttpRequest", + }, + cookies=cookies, + site="SubHD", + ) + self._merge_cookies(cookies, response) + payload = response_json(response, "SubHD") + if payload.get("success") is not True: + raise SubtitleProviderError( + f"SubHD: {payload.get('msg') or '准备下载失败'}" + ) + down_url = urljoin( + self._base_url, str(payload.get("url") or f"/down/{subtitle_id}") + ) + + # 3) 打开下载中转页(刷新校验 cookie;cookie 缺失时这里 403) + response = http_get( + self._get, + down_url, + headers={"Referer": detail_url}, + cookies=cookies, + site="SubHD", + ) + self._merge_cookies(cookies, response) + + # 4) 真正的下载校验,返回直链 + response = http_post( + self._post, + f"{self._base_url}/api/sub/down", + json_body={"sid": subtitle_id}, + headers={ + "Referer": down_url, + "X-Requested-With": "XMLHttpRequest", + }, + cookies=cookies, + site="SubHD", + ) + self._merge_cookies(cookies, response) + payload = response_json(response, "SubHD") + if payload.get("success") is not True or payload.get("pass") is not True: + raise SubtitleProviderError( + f"SubHD: {payload.get('msg') or '下载校验未通过'}" + ) + file_url = str(payload.get("url") or "").strip() + if not file_url: + raise SubtitleProviderError("SubHD 未返回字幕文件地址") + + # 5) 直链在 dl.subhd.me,无需 cookie(页面 JS 也是 credentials: omit) + response = http_get( + self._get, + file_url, + timeout=DOWNLOAD_TIMEOUT, + site="SubHD", + ) + return extract_subtitle(response_bytes(response), name_hint=item.name) + + @staticmethod + def _merge_cookies(target: dict[str, str], response: Any) -> None: + jar = getattr(response, "cookies", None) + if not jar: + return + try: + for key, value in dict(jar).items(): + target[str(key)] = str(value) + except Exception: + return diff --git a/src/atv_player/subtitles/providers/subsource.py b/src/atv_player/subtitles/providers/subsource.py new file mode 100644 index 00000000..9100d7d0 --- /dev/null +++ b/src/atv_player/subtitles/providers/subsource.py @@ -0,0 +1,326 @@ +"""SubSource 字幕站(subsource.net)。 + +官方 REST API,免费 API Key(注册后在个人资料页生成)。中文字幕统一挂在 +``Chinese BG code`` 这个语言名下(对照 bazarr converters/subsource.py 的注释: +站内所有中文字幕都用这个名称上传),因此搜索时按语言名逐个请求。 +""" + +from __future__ import annotations + +from collections.abc import Callable +from typing import Any + +import httpx + +from atv_player.subtitles.archive import extract_subtitle +from atv_player.subtitles.errors import ( + SubtitleProviderError, + SubtitleTokenMissingError, +) +from atv_player.subtitles.languages import language_label, normalize_language +from atv_player.subtitles.models import ( + SubtitleContent, + SubtitleQuery, + SubtitleSearchItem, +) +from atv_player.subtitles.providers._common import ( + DOWNLOAD_TIMEOUT, + http_get, + response_bytes, + response_json, +) +from atv_player.subtitles.release_parser import parse_release_name + + +class SubsourceSubtitleProvider: + provider_id = "subsource" + label = "SubSource" + requires_token = True + notice = "" + + _API_BASE = "https://api.subsource.net/api/v1" + _PAGE_BASE = "https://subsource.net" + # 中文字幕全部以 "Chinese BG code" 提交;英文是兜底语言 + _LANGUAGES = ("chinese bg code", "english") + _PAGE_SIZE = 100 + + def __init__( + self, + get: Callable[..., Any] = httpx.get, + api_key_loader: Callable[[], str] | None = None, + ) -> None: + self._get = get + self._api_key_loader = api_key_loader + + def _api_key(self) -> str: + if self._api_key_loader is None: + return "" + try: + return str(self._api_key_loader() or "").strip() + except Exception: + return "" + + def available(self) -> bool: + return bool(self._api_key()) + + def search(self, query: SubtitleQuery) -> list[SubtitleSearchItem]: + api_key = self._api_key() + if not api_key: + raise SubtitleTokenMissingError("SubSource 需要先配置 API Key") + title = (query.title or "").strip() + if not title and not query.imdb_id: + return [] + + movie_id = self._find_movie_id(api_key, query, title) + if not movie_id: + return [] + + items: list[SubtitleSearchItem] = [] + seen: set[str] = set() + for language_name in self._LANGUAGES: + for item in self._query_subtitles(api_key, movie_id, language_name, query): + if item.subtitle_id in seen: + continue + seen.add(item.subtitle_id) + items.append(item) + return items + + def _find_movie_id( + self, + api_key: str, + query: SubtitleQuery, + title: str, + ) -> str: + """先按 IMDb id 搜片,没结果再按片名搜(对照 bazarr 的回退顺序)。""" + results: list[dict[str, Any]] = [] + if query.imdb_id: + results = self._search_movies( + api_key, + {"searchType": "imdb", "imdb": query.imdb_id.removeprefix("tt")}, + query, + ) + if not results and title: + results = self._search_movies( + api_key, {"searchType": "text", "q": title.lower()}, query + ) + + query_title = title.casefold().strip() + for result in results: + if "title" not in result or "releaseYear" not in result: + continue + site_titles = {str(result["title"]).casefold()} + alternate = str(result.get("alternateTitle") or "").casefold() + if alternate: + site_titles.add(alternate) + # 与 bazarr 一致用子串匹配:查询片名是站点片名的子串即算命中 + if not any(query_title in site_title for site_title in site_titles): + continue + release_year = self._as_int(result.get("releaseYear")) + if query.year and release_year and release_year != query.year: + continue + movie_id = str(result.get("movieId") or "").strip() + if movie_id: + return movie_id + return "" + + def _search_movies( + self, + api_key: str, + extra_params: dict[str, Any], + query: SubtitleQuery, + ) -> list[dict[str, Any]]: + params: dict[str, Any] = dict(extra_params) + if query.season is not None: + params["season"] = query.season + payload = self._request_json(f"{self._API_BASE}/movies/search", api_key, params) + data = payload.get("data") + if not isinstance(data, list): + return [] + return [row for row in data if isinstance(row, dict)] + + def _query_subtitles( + self, + api_key: str, + movie_id: str, + language_name: str, + query: SubtitleQuery, + ) -> list[SubtitleSearchItem]: + params: dict[str, Any] = { + "language": language_name, + "limit": self._PAGE_SIZE, + "movieId": movie_id, + } + if query.season is not None: + params["seasonNumber"] = query.season + if query.episode is not None: + params["episodeNumber"] = query.episode + payload = self._request_json(f"{self._API_BASE}/subtitles", api_key, params) + if payload.get("success") is False: + return [] + data = payload.get("data") + if not isinstance(data, list): + return [] + + items: list[SubtitleSearchItem] = [] + for entry in data: + if not isinstance(entry, dict): + continue + item = self._build_item(entry, language_name, query) + if item is not None: + items.append(item) + return items + + def _build_item( + self, + entry: dict[str, Any], + language_name: str, + query: SubtitleQuery, + ) -> SubtitleSearchItem | None: + subtitle_id = str(entry.get("subtitleId") or "").strip() + if not subtitle_id: + return None + releases = [ + str(name or "").strip() + for name in ( + entry.get("releaseInfo") + if isinstance(entry.get("releaseInfo"), list) + else [entry.get("releaseInfo")] + ) + if str(name or "").strip() + ] + release_name = ", ".join(releases) + display = releases[0] if releases else "SubSource 字幕" + season, episode = self._season_episode(releases) + if query.season is not None and season is not None and season != query.season: + return None + # 无集数的是整季包,保留给 matcher 打分;有集数但与查询不一致的丢弃 + if ( + query.episode is not None + and episode is not None + and episode != query.episode + ): + return None + language = normalize_language( + str(entry.get("language") or language_name), + release_name, + display, + ) + return SubtitleSearchItem( + provider=self.provider_id, + provider_label=self.label, + subtitle_id=subtitle_id, + name=display, + language=language, + language_label=language_label(language), + format="", + release_site=self._uploader_name(entry), + release_name=release_name or display, + season=season, + episode=episode, + hearing_impaired=self._is_hearing_impaired(entry), + forced=self._is_forced(entry), + url=f"{self._PAGE_BASE}{entry.get('link') or ''}", + ) + + def _request_json( + self, + url: str, + api_key: str, + params: dict[str, Any], + ) -> dict[str, Any]: + response = http_get( + self._get, + url, + params=dict(params, api_key=api_key), + headers={"Accept": "application/json"}, + site="SubSource", + ) + return response_json(response, "SubSource") + + def download(self, item: SubtitleSearchItem) -> SubtitleContent: + if not item.subtitle_id: + raise SubtitleProviderError("SubSource 字幕缺少下载 id") + response = http_get( + self._get, + f"{self._API_BASE}/subtitles/{item.subtitle_id}/download", + params={"api_key": self._api_key()}, + timeout=DOWNLOAD_TIMEOUT, + site="SubSource", + ) + return extract_subtitle(response_bytes(response), name_hint=item.name) + + @staticmethod + def _season_episode(releases: list[str]) -> tuple[int | None, int | None]: + """从发布名里取季集(bazarr 用 guessit,这里复用 release_parser)。""" + season: int | None = None + episode: int | None = None + for release in releases: + parsed = parse_release_name(release) + if season is None: + season = parsed.season + if episode is None: + episode = parsed.episode + if season is not None and episode is not None: + break + return season, episode + + @staticmethod + def _is_hearing_impaired(entry: dict[str, Any]) -> bool: + """对照 bazarr:优先看 hearingImpaired 标志,再看备注里的关键词。""" + if entry.get("hearingImpaired"): + return True + commentary = str(entry.get("commentary") or "").lower() + if any( + tag in commentary + for tag in ( + "hi remove", + "non hi", + "nonhi", + "non-hi", + "non-sdh", + "non sdh", + "nonsdh", + "sdh remove", + ) + ): + return False + return any( + tag in commentary + for tag in ( + "_hi_", + " hi ", + ".hi.", + "hi ", + " hi", + "sdh", + "_cc_", + " cc ", + ".cc.", + "closed caption", + ) + ) + + @staticmethod + def _is_forced(entry: dict[str, Any]) -> bool: + if entry.get("foreignParts"): + return True + commentary = str(entry.get("commentary") or "").lower() + return "forced" in commentary or "foreign" in commentary + + @staticmethod + def _uploader_name(entry: dict[str, Any]) -> str: + contributors = entry.get("contributors") + if not isinstance(contributors, list): + return "" + uploader_id = entry.get("uploaderId") + for contributor in contributors: + if isinstance(contributor, dict) and contributor.get("id") == uploader_id: + return str(contributor.get("displayname") or "") + return "" + + @staticmethod + def _as_int(value: object) -> int | None: + try: + return int(str(value).strip()) + except (TypeError, ValueError): + return None diff --git a/src/atv_player/subtitles/providers/zimuku.py b/src/atv_player/subtitles/providers/zimuku.py new file mode 100644 index 00000000..88f37e3a --- /dev/null +++ b/src/atv_player/subtitles/providers/zimuku.py @@ -0,0 +1,174 @@ +"""字幕库 Zimuku(抓取,无需 token)。 + +注意:该站带有验证码防火墙,命中时会返回"网站访问认证"页面。这里通过 +``guard_blocked`` 明确抛出 SubtitleBlockedError,让界面显示"触发验证码", +而不是让用户以为是"没搜到字幕"。 + +站点域名与 HTML 结构历史上变动频繁,选择器写成多分支回退。 +""" + +from __future__ import annotations + +import re +from collections.abc import Callable +from typing import Any +from urllib.parse import urljoin + +import httpx +from lxml import etree + +from atv_player.subtitles.archive import extract_subtitle +from atv_player.subtitles.errors import SubtitleProviderError +from atv_player.subtitles.languages import language_label, normalize_language +from atv_player.subtitles.models import ( + SubtitleContent, + SubtitleQuery, + SubtitleSearchItem, +) +from atv_player.subtitles.providers._common import ( + DOWNLOAD_TIMEOUT, + guard_blocked, + http_get, + response_bytes, + response_text, +) + +_DETAIL_HREF = re.compile(r"/(?:detail|subs)/(\w+)\.html") +_WHITESPACE = re.compile(r"\s+") + + +class ZimukuSubtitleProvider: + provider_id = "zimuku" + label = "字幕库" + requires_token = False + notice = "该站启用云锁验证码,通常无法使用" + + _BASE_URL = "https://srtku.com" + _MAX_ITEMS = 30 + + def __init__( + self, + get: Callable[..., Any] = httpx.get, + base_url: str = "", + ) -> None: + self._get = get + self._base_url = (base_url or self._BASE_URL).rstrip("/") + + def available(self) -> bool: + return True + + def search(self, query: SubtitleQuery) -> list[SubtitleSearchItem]: + keyword = (query.title or query.file_name).strip() + if not keyword: + return [] + response = http_get( + self._get, + f"{self._base_url}/search", + params={"q": keyword}, + site="字幕库", + ) + text = response_text(response) + guard_blocked(text, "字幕库") + return self._parse_search(text) + + def _parse_search(self, text: str) -> list[SubtitleSearchItem]: + tree = etree.HTML(text) + if tree is None: + raise SubtitleProviderError("字幕库返回的页面无法解析") + items: list[SubtitleSearchItem] = [] + seen: set[str] = set() + for anchor in tree.xpath("//a[@href]"): + href = str(anchor.get("href") or "") + matched = _DETAIL_HREF.search(href) + if matched is None: + continue + subtitle_id = matched.group(1) + if subtitle_id in seen: + continue + name = self._text_of(anchor) + if not name: + continue + seen.add(subtitle_id) + context_text = self._container_text(anchor) + language = normalize_language(context_text, name) + items.append( + SubtitleSearchItem( + provider=self.provider_id, + provider_label=self.label, + subtitle_id=subtitle_id, + name=name, + language=language, + language_label=language_label(language), + context={"detail_url": urljoin(f"{self._base_url}/", href)}, + ) + ) + if len(items) >= self._MAX_ITEMS: + break + return items + + @staticmethod + def _text_of(node: Any) -> str: + return _WHITESPACE.sub(" ", "".join(node.itertext())).strip() + + def _container_text(self, anchor: Any) -> str: + for expression in ( + 'ancestor::div[contains(@class, "item")][1]', + "ancestor::tr[1]", + "ancestor::div[2]", + "..", + ): + found = anchor.xpath(expression) + if found: + return self._text_of(found[0])[:400] + return "" + + def download(self, item: SubtitleSearchItem) -> SubtitleContent: + detail_url = item.context.get("detail_url") or ( + f"{self._base_url}/detail/{item.subtitle_id}.html" + ) + download_page = self._resolve_download_page(detail_url) + file_url = self._resolve_file_url(download_page, referer=detail_url) + response = http_get( + self._get, + file_url, + headers={"Referer": download_page}, + timeout=DOWNLOAD_TIMEOUT, + site="字幕库", + ) + return extract_subtitle(response_bytes(response), name_hint=item.name) + + def _resolve_download_page(self, detail_url: str) -> str: + response = http_get(self._get, detail_url, site="字幕库") + text = response_text(response) + guard_blocked(text, "字幕库") + tree = etree.HTML(text) + if tree is not None: + for href in tree.xpath('//a[contains(@href, "/dld/")]/@href'): + return urljoin(f"{self._base_url}/", str(href)) + # 详情页与下载页 id 一致时可以直接推出地址 + matched = _DETAIL_HREF.search(detail_url) + if matched is not None: + return f"{self._base_url}/dld/{matched.group(1)}.html" + raise SubtitleProviderError("字幕库未找到下载页地址") + + def _resolve_file_url(self, download_page: str, *, referer: str) -> str: + response = http_get( + self._get, + download_page, + headers={"Referer": referer}, + site="字幕库", + ) + text = response_text(response) + guard_blocked(text, "字幕库") + tree = etree.HTML(text) + if tree is None: + raise SubtitleProviderError("字幕库下载页无法解析") + for expression in ( + '//a[@id="down1"]/@href', + '//a[contains(@href, "/download/")]/@href', + '//a[contains(@rel, "nofollow") and contains(@href, "zip")]/@href', + ): + found = tree.xpath(expression) + if found: + return urljoin(f"{self._base_url}/", str(found[0])) + raise SubtitleProviderError("字幕库未找到实际下载链接") diff --git a/src/atv_player/subtitles/release_parser.py b/src/atv_player/subtitles/release_parser.py new file mode 100644 index 00000000..d7c1788e --- /dev/null +++ b/src/atv_player/subtitles/release_parser.py @@ -0,0 +1,205 @@ +"""发布文件名解析。 + +字幕站对 ``The.Last.of.Us.S02E06.2160p.WEB-DL.H.265-GROUP.mkv`` 这类整串文件名 +命中率很差,必须先拆成片名/季集/画质等结构化字段再去搜。 + +解析结果同时用于匹配打分(见 matcher.py):候选字幕的发布名里若也含有相同的 +画质/片源/编码/压制组,说明和当前视频是同一个发布版本,时间轴更可能对得上。 +""" + +from __future__ import annotations + +import re +from dataclasses import dataclass + +_VIDEO_SUFFIXES = ( + ".mkv", ".mp4", ".avi", ".ts", ".m2ts", ".mov", ".wmv", ".flv", + ".rmvb", ".iso", ".m4v", ".webm", +) + +_RESOLUTIONS = { + "2160p": "2160p", "4k": "2160p", "uhd": "2160p", + "1080p": "1080p", "1080i": "1080p", "fhd": "1080p", + "720p": "720p", "hd": "720p", + "480p": "480p", "576p": "576p", +} + +_SOURCES = { + "web-dl": "WEB-DL", "webdl": "WEB-DL", "webrip": "WEBRip", "web": "WEB-DL", + "bluray": "BluRay", "blu-ray": "BluRay", "bdrip": "BDRip", "brrip": "BDRip", + "remux": "REMUX", "hdtv": "HDTV", "dvdrip": "DVDRip", "dvd": "DVDRip", + "hdrip": "HDRip", "uhdtv": "HDTV", +} + +_CODECS = { + "x264": "H.264", "h264": "H.264", "h-264": "H.264", "avc": "H.264", + "x265": "H.265", "h265": "H.265", "h-265": "H.265", "hevc": "H.265", + "av1": "AV1", "vp9": "VP9", "xvid": "Xvid", "divx": "DivX", +} + +_SEASON_EPISODE = re.compile(r"\bs(\d{1,2})[\s._-]?e(\d{1,3})\b", re.IGNORECASE) +_SEASON_ONLY = re.compile(r"\bs(?:eason)?[\s._-]?(\d{1,2})\b", re.IGNORECASE) +_EPISODE_ONLY = re.compile(r"\be(?:p|pisode)?[\s._-]?(\d{1,3})\b", re.IGNORECASE) +_CN_SEASON = re.compile(r"第\s*(\d{1,2})\s*季") +_CN_EPISODE = re.compile(r"第\s*(\d{1,3})\s*[集话話期]") +_YEAR = re.compile(r"\b((?:19|20)\d{2})\b") +_RELEASE_GROUP = re.compile(r"-([A-Za-z0-9_@]+)$") +_SEPARATORS = re.compile(r"[._]+") +_MULTI_SPACE = re.compile(r"\s{2,}") + +# 出现这些词说明片名已经结束,后面都是技术参数 +_TITLE_STOP_TOKENS = frozenset( + { + *_RESOLUTIONS, + *_SOURCES, + *_CODECS, + "ddp5", "ddp", "dts", "aac", "ac3", "truehd", "atmos", "flac", "opus", + "dv", "hdr", "hdr10", "sdr", "10bit", "8bit", "repack", "proper", + "extended", "uncut", "limited", "internal", "complete", "multi", + } +) + + +@dataclass(frozen=True, slots=True) +class ReleaseInfo: + title: str = "" + season: int | None = None + episode: int | None = None + year: int = 0 + resolution: str = "" + source: str = "" + codec: str = "" + release_group: str = "" + raw: str = "" + + +def _strip_container_suffix(name: str) -> str: + lowered = name.casefold() + for suffix in _VIDEO_SUFFIXES: + if lowered.endswith(suffix): + return name[: -len(suffix)] + return name + + +def _clean_title(text: str) -> str: + text = _SEPARATORS.sub(" ", text) + text = text.replace("-", " ").strip(" -[](){}") + return _MULTI_SPACE.sub(" ", text).strip() + + +def parse_release_name(name: str) -> ReleaseInfo: + """把视频文件名拆成结构化字段。 + + 尽量宽容:解析不出来的字段留空,调用方按"有就用、没有就降级"处理。 + """ + raw = str(name or "").strip() + if not raw: + return ReleaseInfo() + stem = _strip_container_suffix(raw) + # 压制组在末尾的 -GROUP,要在替换分隔符之前取 + release_group = "" + group_match = _RELEASE_GROUP.search(stem) + if group_match is not None and not group_match.group(1).isdigit(): + release_group = group_match.group(1) + + season: int | None = None + episode: int | None = None + matched = _SEASON_EPISODE.search(stem) + if matched is not None: + season = int(matched.group(1)) + episode = int(matched.group(2)) + else: + cn_season = _CN_SEASON.search(stem) + if cn_season is not None: + season = int(cn_season.group(1)) + cn_episode = _CN_EPISODE.search(stem) + if cn_episode is not None: + episode = int(cn_episode.group(1)) + if episode is None: + bare = _EPISODE_ONLY.search(stem) + if bare is not None: + episode = int(bare.group(1)) + if season is None: + season_only = _SEASON_ONLY.search(stem) + if season_only is not None: + season = int(season_only.group(1)) + + normalized = _SEPARATORS.sub(" ", stem) + tokens = [token for token in re.split(r"[\s]+", normalized) if token] + lowered_tokens = [token.casefold().strip("-[](){}") for token in tokens] + + resolution = "" + source = "" + codec = "" + for token in lowered_tokens: + if not resolution and token in _RESOLUTIONS: + resolution = _RESOLUTIONS[token] + if not source and token in _SOURCES: + source = _SOURCES[token] + if not codec and token in _CODECS: + codec = _CODECS[token] + if not source: + joined = normalized.casefold() + for key, value in _SOURCES.items(): + if key in joined: + source = value + break + if not codec: + # "H.265" 会被分隔符规则拆成 "H 265" + compact = normalized.casefold().replace(" ", "") + for key, value in _CODECS.items(): + if key in compact: + codec = value + break + + year = 0 + title_tokens: list[str] = [] + for index, token in enumerate(tokens): + lowered = lowered_tokens[index] + if _SEASON_EPISODE.fullmatch(lowered) or _SEASON_EPISODE.match(lowered): + break + if lowered in _TITLE_STOP_TOKENS: + break + year_match = _YEAR.fullmatch(lowered) + if year_match is not None: + year = int(year_match.group(1)) + break + if _CN_SEASON.search(token) or _CN_EPISODE.search(token): + break + title_tokens.append(token) + if not year: + year_match = _YEAR.search(normalized) + if year_match is not None: + year = int(year_match.group(1)) + + title = _clean_title(" ".join(title_tokens)) + if not title: + title = _clean_title(normalized) + return ReleaseInfo( + title=title, + season=season, + episode=episode, + year=year, + resolution=resolution, + source=source, + codec=codec, + release_group=release_group, + raw=raw, + ) + + +def equivalent_tokens(canonical: str) -> tuple[str, ...]: + """返回某个规范值的所有等价写法(含自身)。 + + 发布名里 ``x265`` / ``HEVC`` / ``H.265`` 指的是同一种编码,匹配打分时必须 + 一视同仁,否则同一个发布版本会因为写法不同而被判成不匹配。 + """ + target = str(canonical or "").strip().casefold() + if not target: + return () + found = {target} + for mapping in (_RESOLUTIONS, _SOURCES, _CODECS): + for alias, value in mapping.items(): + if value.casefold() == target: + found.add(alias) + return tuple(sorted(found)) diff --git a/src/atv_player/subtitles/service.py b/src/atv_player/subtitles/service.py new file mode 100644 index 00000000..a0984274 --- /dev/null +++ b/src/atv_player/subtitles/service.py @@ -0,0 +1,313 @@ +"""字幕搜索编排。 + +职责:并发扇出到各字幕站、归一排序、把失败按站点收敛,保证单站挂掉不影响整体。 +排序口径:简英双语 > 繁英/中英 > 简体 > 中文 > 繁体 > 英文(见 languages.py), +集数匹配与站点偏好作为次级排序键。 +""" + +from __future__ import annotations + +import logging +import re +from collections.abc import Callable + +import httpx + +from atv_player.danmaku.providers._concurrency import iter_bounded_settled +from atv_player.danmaku.utils import ( + extract_episode_number, + normalize_name, + strip_episode_suffix, +) +from atv_player.subtitles.errors import ( + SubtitleProviderError, +) +from atv_player.subtitles.matcher import apply_scores +from atv_player.subtitles.models import ( + SubtitleContent, + SubtitleProviderGroup, + SubtitleQuery, + SubtitleSearchItem, + SubtitleSearchResult, +) +from atv_player.subtitles.providers.assrt import AssrtSubtitleProvider +from atv_player.subtitles.providers.base import SubtitleProvider +from atv_player.subtitles.providers.opensubtitles import OpenSubtitlesProvider +from atv_player.subtitles.providers.subdl import SubDLSubtitleProvider +from atv_player.subtitles.providers.subhd import SubHDSubtitleProvider +from atv_player.subtitles.providers.subsource import SubsourceSubtitleProvider +from atv_player.subtitles.providers.zimuku import ZimukuSubtitleProvider +from atv_player.subtitles.release_parser import parse_release_name + +logger = logging.getLogger(__name__) + +# 站点默认顺序:官方 API 且稳定的排前面,抓取站次之,备用站垫后 +DEFAULT_PROVIDER_ORDER = [ + "subdl", + "subhd", + "zimuku", + "assrt", + "subsource", + "opensubtitles", +] + +_SEASON_EPISODE = re.compile(r"[sS](\d{1,2})[eE](\d{1,3})") +_BARE_EPISODE = re.compile(r"(?:^|[^a-zA-Z0-9])[eE][pP]?(\d{1,3})(?:$|[^0-9])") + + +def episode_of(text: str) -> int | None: + """从字幕名里取集数,兼容 SxxExx / Exx 与中文"第 x 集"。""" + if not text: + return None + matched = _SEASON_EPISODE.search(text) + if matched is not None: + return int(matched.group(2)) + matched = _BARE_EPISODE.search(text) + if matched is not None: + return int(matched.group(1)) + return extract_episode_number(normalize_name(text)) + + +def build_subtitle_query( + *, + title: str = "", + file_name: str = "", + episode: int | None = None, + season: int | None = None, + year: int = 0, + imdb_id: str = "", + tmdb_id: str = "", +) -> SubtitleQuery: + """把播放项信息整理成一次字幕查询。 + + 优先级:已有的 IMDb/TMDB id > 显式传入的片名与季集 > 从文件名解析出的字段。 + 文件名解析出的画质/片源/编码不参与搜索关键词,只用于匹配打分(见 matcher)。 + """ + parsed = parse_release_name(file_name) if file_name else None + resolved_title = str(title or "").strip() + if not resolved_title and parsed is not None: + resolved_title = parsed.title + # 片名里若带着"第 N 集"之类的后缀,搜索时要去掉,否则站点几乎搜不到 + search_title = ( + strip_episode_suffix(normalize_name(resolved_title)) or resolved_title + ) + resolved_episode = episode + if resolved_episode is None: + if parsed is not None and parsed.episode is not None: + resolved_episode = parsed.episode + elif resolved_title: + resolved_episode = extract_episode_number(normalize_name(resolved_title)) + resolved_season = season + if resolved_season is None and parsed is not None: + resolved_season = parsed.season + return SubtitleQuery( + title=search_title.strip(), + episode=resolved_episode, + season=resolved_season, + year=year or (parsed.year if parsed is not None else 0), + imdb_id=str(imdb_id or "").strip(), + tmdb_id=str(tmdb_id or "").strip(), + file_name=str(file_name or "").strip(), + resolution=parsed.resolution if parsed is not None else "", + source=parsed.source if parsed is not None else "", + codec=parsed.codec if parsed is not None else "", + release_group=parsed.release_group if parsed is not None else "", + ) + + +class SubtitleSearchService: + def __init__( + self, + providers: dict[str, SubtitleProvider], + provider_order: list[str] | None = None, + disabled_provider_ids_loader: Callable[[], list[str]] | None = None, + max_concurrency: int = 4, + ) -> None: + self._providers = dict(providers) + order = list(provider_order or DEFAULT_PROVIDER_ORDER) + self._provider_order = [key for key in order if key in self._providers] + self._provider_order.extend( + key for key in self._providers if key not in self._provider_order + ) + self._provider_rank = { + key: index for index, key in enumerate(self._provider_order) + } + self._disabled_provider_ids_loader = disabled_provider_ids_loader + self._max_concurrency = max(1, max_concurrency) + + def _disabled_provider_ids(self) -> set[str]: + if self._disabled_provider_ids_loader is None: + return set() + try: + return { + str(item or "").strip() + for item in self._disabled_provider_ids_loader() + } + except Exception: + logger.exception("Failed to load disabled subtitle provider ids") + return set() + + @property + def provider_order(self) -> list[str]: + disabled = self._disabled_provider_ids() + return [key for key in self._provider_order if key not in disabled] + + def provider_label(self, provider_id: str) -> str: + provider = self._providers.get(provider_id) + return getattr(provider, "label", provider_id) if provider else provider_id + + def search( + self, + query: SubtitleQuery, + *, + provider_filter: str = "", + ) -> SubtitleSearchResult: + keys = self.provider_order + if provider_filter: + keys = [key for key in keys if key == provider_filter] + pending: list[str] = [] + skipped: list[str] = [] + for key in keys: + provider = self._providers[key] + try: + usable = provider.available() + except Exception: + logger.exception( + "Subtitle provider availability check failed key=%s", key + ) + usable = False + if usable: + pending.append(key) + else: + skipped.append(key) + + collected: dict[str, list[SubtitleSearchItem]] = {} + errors: dict[str, str] = {} + for batch in iter_bounded_settled( + pending, + lambda key: self._search_one(key, query), + max_workers=self._max_concurrency, + ): + for settled in batch: + if settled.error is not None: + # _search_one 已经吞掉了 provider 自身的异常,走到这里说明是 + # 调度层面的意外失败,无法归属到具体站点,只记日志 + logger.warning("Subtitle search worker failed: %s", settled.error) + continue + provider_id, items, error = settled.value + if error is not None: + errors[provider_id] = str(error) or error.__class__.__name__ + continue + collected[provider_id] = items + + groups = self._build_groups(collected, query) + return SubtitleSearchResult(groups=groups, errors=errors, skipped=skipped) + + def _search_one( + self, + provider_id: str, + query: SubtitleQuery, + ) -> tuple[str, list[SubtitleSearchItem], Exception | None]: + """在 worker 内部就把异常和 provider id 绑定,避免并发返回后无法归属。""" + try: + items = list(self._providers[provider_id].search(query) or []) + return provider_id, items, None + except Exception as exc: + logger.warning( + "Subtitle provider search failed key=%s error=%s", provider_id, exc + ) + return provider_id, [], exc + + def _build_groups( + self, + collected: dict[str, list[SubtitleSearchItem]], + query: SubtitleQuery, + ) -> list[SubtitleProviderGroup]: + groups: list[SubtitleProviderGroup] = [] + for provider_id, items in collected.items(): + deduped = self._dedupe(items) + if not deduped: + continue + deduped = apply_scores(deduped, query) + provider = self._providers.get(provider_id) + groups.append( + SubtitleProviderGroup( + provider=provider_id, + provider_label=getattr(provider, "label", provider_id), + items=deduped, + notice=getattr(provider, "notice", ""), + ) + ) + groups.sort(key=lambda group: self._group_sort_key(group)) + return groups + + @staticmethod + def _dedupe(items: list[SubtitleSearchItem]) -> list[SubtitleSearchItem]: + seen: set[tuple[str, str]] = set() + result: list[SubtitleSearchItem] = [] + for item in items: + key = (item.provider, item.subtitle_id) + if key in seen: + continue + seen.add(key) + result.append(item) + return result + + def _group_sort_key(self, group: SubtitleProviderGroup) -> tuple[int, int]: + # 组内已按分数排好,用最高分代表该站,站点默认顺序仅作同分时的兜底 + best_score = max((item.score for item in group.items), default=0) + return ( + -best_score, + self._provider_rank.get(group.provider, len(self._provider_rank)), + ) + + def download(self, item: SubtitleSearchItem) -> SubtitleContent: + provider = self._providers.get(item.provider) + if provider is None: + raise SubtitleProviderError(f"未知字幕来源: {item.provider}") + content = provider.download(item) + if not content.text.strip(): + raise SubtitleProviderError("下载到的字幕内容为空") + return content + + +def create_default_subtitle_service( + get=httpx.get, + post=httpx.post, + config_loader=None, + disabled_provider_ids_loader: Callable[[], list[str]] | None = None, +) -> SubtitleSearchService: + def _config_value(attribute: str) -> str: + if config_loader is None: + return "" + try: + return str(getattr(config_loader(), attribute, "") or "").strip() + except Exception: + return "" + + providers: dict[str, SubtitleProvider] = { + "subdl": SubDLSubtitleProvider( + get=get, + api_key_loader=lambda: _config_value("subtitle_subdl_api_key"), + ), + "subhd": SubHDSubtitleProvider(get=get, post=post), + "zimuku": ZimukuSubtitleProvider(get=get), + "assrt": AssrtSubtitleProvider( + get=get, + token_loader=lambda: _config_value("subtitle_assrt_token"), + ), + "subsource": SubsourceSubtitleProvider( + get=get, + api_key_loader=lambda: _config_value("subtitle_subsource_api_key"), + ), + "opensubtitles": OpenSubtitlesProvider( + get=get, + post=post, + api_key_loader=lambda: _config_value("subtitle_opensubtitles_api_key"), + ), + } + return SubtitleSearchService( + providers, + provider_order=list(DEFAULT_PROVIDER_ORDER), + disabled_provider_ids_loader=disabled_provider_ids_loader, + ) diff --git a/src/atv_player/ui/advanced_settings_dialog.py b/src/atv_player/ui/advanced_settings_dialog.py index da0600fc..53134dbe 100644 --- a/src/atv_player/ui/advanced_settings_dialog.py +++ b/src/atv_player/ui/advanced_settings_dialog.py @@ -40,7 +40,11 @@ from atv_player.danmaku.providers.dandan import probe_dandan_server from atv_player.models import AppConfig from atv_player.network_proxy import ProxyConfig, ProxyDecider, ProxyRuleError -from atv_player.source_preferences import DANMAKU_SOURCE_PREFERENCES, METADATA_SOURCE_PREFERENCES +from atv_player.source_preferences import ( + DANMAKU_SOURCE_PREFERENCES, + METADATA_SOURCE_PREFERENCES, + SUBTITLE_SOURCE_PREFERENCES, +) from atv_player.ui.log_console import LogConsoleWidget from atv_player.ui.theme import ( FlatComboBox, @@ -124,6 +128,7 @@ def __init__( self.appearance_tab = QWidget() self.metadata_tab = QWidget() self.danmaku_tab = QWidget() + self.subtitle_tab = QWidget() self.ai_tab = QWidget() self.network_proxy_tab = QWidget() self.playback_tab = QWidget() @@ -153,6 +158,29 @@ def __init__( self.episode_title_enhancement_checkbox = QCheckBox("启用剧集标题增强") self.metadata_source_checkboxes: dict[str, QCheckBox] = {} self.danmaku_source_checkboxes: dict[str, QCheckBox] = {} + self.subtitle_source_checkboxes: dict[str, QCheckBox] = {} + self.subtitle_source_group = QGroupBox("字幕站") + self.subtitle_token_group = QGroupBox("字幕站账号") + self.subtitle_subdl_api_key_edit = QLineEdit() + self.subtitle_subdl_api_key_edit.setPlaceholderText( + "在 subdl.com 账号面板免费获取;留空则不使用 SubDL" + ) + self.subtitle_subdl_api_key_edit.setEchoMode(QLineEdit.EchoMode.Password) + self.subtitle_assrt_token_edit = QLineEdit() + self.subtitle_assrt_token_edit.setPlaceholderText( + "在 assrt.net 用户面板获取;留空则不使用射手网" + ) + self.subtitle_assrt_token_edit.setEchoMode(QLineEdit.EchoMode.Password) + self.subtitle_opensubtitles_api_key_edit = QLineEdit() + self.subtitle_opensubtitles_api_key_edit.setPlaceholderText( + "在 opensubtitles.com 申请;免费账号每天限 5 次下载" + ) + self.subtitle_opensubtitles_api_key_edit.setEchoMode(QLineEdit.EchoMode.Password) + self.subtitle_subsource_api_key_edit = QLineEdit() + self.subtitle_subsource_api_key_edit.setPlaceholderText( + "在 subsource.net 注册后于个人资料页生成;留空则不使用 SubSource" + ) + self.subtitle_subsource_api_key_edit.setEchoMode(QLineEdit.EchoMode.Password) self.dandan_base_url_edit = QLineEdit() self.dandan_base_url_edit.setPlaceholderText( "http://host:9321 或 http://host:9321/87654321;留空=关闭此源" @@ -368,6 +396,17 @@ def __init__( checkbox = QCheckBox(source.label) checkbox.setChecked(source.id not in disabled_danmaku_sources) self.danmaku_source_checkboxes[source.id] = checkbox + disabled_subtitle_sources = set(config.disabled_subtitle_provider_ids) + for source in SUBTITLE_SOURCE_PREFERENCES: + checkbox = QCheckBox(source.label) + checkbox.setChecked(source.id not in disabled_subtitle_sources) + self.subtitle_source_checkboxes[source.id] = checkbox + self.subtitle_subdl_api_key_edit.setText(config.subtitle_subdl_api_key) + self.subtitle_assrt_token_edit.setText(config.subtitle_assrt_token) + self.subtitle_subsource_api_key_edit.setText(config.subtitle_subsource_api_key) + self.subtitle_opensubtitles_api_key_edit.setText( + config.subtitle_opensubtitles_api_key + ) self.danmaku_blocked_words_edit.setPlainText("\n".join(config.danmaku_blocked_words)) self.danmaku_duplicate_window_spinbox.setValue(config.danmaku_duplicate_window_minutes) self.danmaku_convert_top_bottom_checkbox.setChecked(config.danmaku_convert_top_bottom_to_scroll) @@ -496,6 +535,30 @@ def __init__( danmaku_tab_layout.addWidget(self.danmaku_cleaning_group) danmaku_tab_layout.addStretch(1) + self.subtitle_source_group.setLayout( + _build_source_checkbox_layout(list(self.subtitle_source_checkboxes.values())) + ) + subtitle_token_layout = QFormLayout() + subtitle_token_layout.addRow("SubDL API Key", self.subtitle_subdl_api_key_edit) + subtitle_token_layout.addRow("射手网 Token", self.subtitle_assrt_token_edit) + subtitle_token_layout.addRow( + "OpenSubtitles API Key", self.subtitle_opensubtitles_api_key_edit + ) + subtitle_token_layout.addRow( + "SubSource API Key", self.subtitle_subsource_api_key_edit + ) + subtitle_hint = QLabel( + "SubHD 与字幕库无需配置即可使用;填写上面的 Token 后会额外启用对应站点。" + "播放器中按 C 打开字幕搜索。" + ) + subtitle_hint.setWordWrap(True) + subtitle_token_layout.addRow(subtitle_hint) + self.subtitle_token_group.setLayout(subtitle_token_layout) + subtitle_tab_layout = QVBoxLayout(self.subtitle_tab) + subtitle_tab_layout.addWidget(self.subtitle_source_group) + subtitle_tab_layout.addWidget(self.subtitle_token_group) + subtitle_tab_layout.addStretch(1) + ai_layout = QFormLayout() ai_layout.addRow(self.ai_enabled_checkbox) ai_layout.addRow(self.ai_metadata_enrichment_checkbox) @@ -596,6 +659,7 @@ def __init__( self.settings_tabs.addTab(self.youtube_tab, "YouTube") self.settings_tabs.addTab(self.metadata_tab, "元数据") self.settings_tabs.addTab(self.danmaku_tab, "弹幕") + self.settings_tabs.addTab(self.subtitle_tab, "字幕") self.settings_tabs.addTab(self.ai_tab, "AI") self.settings_tabs.addTab(self.network_proxy_tab, "网络代理") self.settings_tabs.addTab(self.cache_tab, "缓存管理") @@ -1372,6 +1436,21 @@ def _save(self) -> None: if not checkbox.isChecked() ] self._config.dandan_base_url = self.dandan_base_url_edit.text().strip() + self._config.disabled_subtitle_provider_ids = [ + provider_id + for provider_id, checkbox in self.subtitle_source_checkboxes.items() + if not checkbox.isChecked() + ] + self._config.subtitle_subdl_api_key = ( + self.subtitle_subdl_api_key_edit.text().strip() + ) + self._config.subtitle_assrt_token = self.subtitle_assrt_token_edit.text().strip() + self._config.subtitle_opensubtitles_api_key = ( + self.subtitle_opensubtitles_api_key_edit.text().strip() + ) + self._config.subtitle_subsource_api_key = ( + self.subtitle_subsource_api_key_edit.text().strip() + ) self._config.bangumi_data_danmaku_enabled = self.bangumi_data_danmaku_checkbox.isChecked() self._config.disabled_metadata_provider_ids = [ provider_id diff --git a/src/atv_player/ui/help_dialog.py b/src/atv_player/ui/help_dialog.py index d7820f8f..a18afbce 100644 --- a/src/atv_player/ui/help_dialog.py +++ b/src/atv_player/ui/help_dialog.py @@ -48,6 +48,7 @@ class ShortcutEntry: ShortcutEntry("W", "切换宽屏"), ShortcutEntry("D", "打开弹幕源"), ShortcutEntry("S", "打开刮削"), + ShortcutEntry("C", "搜索外部字幕"), ShortcutEntry("Ctrl+D", "打开弹幕设置"), ShortcutEntry("I", "显示视频信息"), ShortcutEntry("Ctrl+P", "返回主窗口"), diff --git a/src/atv_player/ui/history_page.py b/src/atv_player/ui/history_page.py index be0a803b..150fde87 100644 --- a/src/atv_player/ui/history_page.py +++ b/src/atv_player/ui/history_page.py @@ -99,6 +99,7 @@ def __init__(self, controller) -> None: for label, value in ( ("全部来源", ""), ("远程", "remote"), + ("AList", "browse"), ("电报影视", "telegram"), ("电报频道", "telegram_channel"), ("插件", "spider_plugin"), @@ -407,6 +408,8 @@ def _on_continue_watching_toggled(self, checked: bool) -> None: def _source_label(self, record: HistoryRecord) -> str: if record.source_kind == "telegram": return record.source_name or "电报影视" + if record.source_kind == "browse": + return record.source_name or "AList" if record.source_kind == "telegram_channel": return record.source_name or "电报频道" if record.source_kind == "spider_plugin": diff --git a/src/atv_player/ui/main_window.py b/src/atv_player/ui/main_window.py index fc4008a7..360d4632 100644 --- a/src/atv_player/ui/main_window.py +++ b/src/atv_player/ui/main_window.py @@ -1486,9 +1486,11 @@ def __init__( media_detail_controller=None, metadata_hydrator_factory=None, metadata_scrape_service_factory=None, + subtitle_search_service=None, danmaku_controller_factory=None, episode_title_enhancer_factory=None, metadata_binding_repository=None, + episode_title_override_repository=None, danmaku_preference_store=None, ) -> None: super().__init__(title="alist-tvbox Desktop Player", resizable=True) @@ -1505,9 +1507,11 @@ def __init__( self._media_detail_controller = media_detail_controller self._metadata_hydrator_factory = metadata_hydrator_factory self._metadata_scrape_service_factory = metadata_scrape_service_factory + self._subtitle_search_service = subtitle_search_service self._danmaku_controller_factory = danmaku_controller_factory self._episode_title_enhancer_factory = episode_title_enhancer_factory self._metadata_binding_repository = metadata_binding_repository + self._episode_title_override_repository = episode_title_override_repository self._danmaku_preference_store = danmaku_preference_store self.config = config self._plugin_definitions = list(spider_plugins or []) @@ -6383,7 +6387,11 @@ def build_request(): if record.source_kind == "feiniu": self._start_open_request(lambda: self._apply_request_playback_history_title(self.feiniu_controller.build_request(record.key))) return - self._start_open_request(lambda: self.browse_controller.build_request_from_detail(record.key)) + self._start_open_request( + lambda: self.browse_controller.build_request_from_detail( + record.key, source_key=record.source_key or "csp_AList" + ) + ) def open_favorite_detail(self, record: FavoriteRecord) -> None: if record.source_kind == "direct_parse": @@ -6423,7 +6431,11 @@ def open_favorite_detail(self, record: FavoriteRecord) -> None: if record.source_kind == "feiniu": self._start_open_request(lambda: self.feiniu_controller.build_request(record.vod_id)) return - self._start_open_request(lambda: self.browse_controller.build_request_from_detail(record.vod_id)) + self._start_open_request( + lambda: self.browse_controller.build_request_from_detail( + record.vod_id, source_key=record.source_key or "csp_AList" + ) + ) def _open_favorite_placeholder(self, record: FavoriteRecord) -> None: source_kind = "plugin" if record.source_kind == "spider_plugin" else record.source_kind or "browse" @@ -6618,7 +6630,9 @@ def _create_player_session(self, request): detail_field_runner=request.detail_field_runner, metadata_hydrator=request.metadata_hydrator, metadata_scrape_service=request.metadata_scrape_service, + subtitle_search_service=self._subtitle_search_service, metadata_binding_repository=request.metadata_binding_repository, + episode_title_override_repository=request.episode_title_override_repository, episode_title_enhancer=request.episode_title_enhancer, danmaku_controller=request.danmaku_controller, playback_progress_reporter=request.playback_progress_reporter, @@ -6699,6 +6713,8 @@ def _prepare_request_for_open(self, request: OpenPlayerRequest) -> OpenPlayerReq ) if request.metadata_binding_repository is None: request.metadata_binding_repository = self._metadata_binding_repository + if request.episode_title_override_repository is None: + request.episode_title_override_repository = self._episode_title_override_repository if request.detail_field_runner is not None: return request if request.source_kind == "plugin" and request.source_key: diff --git a/src/atv_player/ui/player_window.py b/src/atv_player/ui/player_window.py index 12bc3f23..25261efa 100644 --- a/src/atv_player/ui/player_window.py +++ b/src/atv_player/ui/player_window.py @@ -40,7 +40,7 @@ QShortcut, QWindow, ) -from PySide6.QtWidgets import QApplication, QMenu, QStyle, QStyleOptionSlider, QToolTip +from PySide6.QtWidgets import QApplication, QInputDialog, QMenu, QStyle, QStyleOptionSlider, QToolTip from PySide6.QtWidgets import ( QComboBox, QCheckBox, @@ -62,6 +62,8 @@ QSplitter, QStackedLayout, QTabBar, + QTableWidget, + QTableWidgetItem, QTextBrowser, QTextEdit, QToolButton, @@ -71,7 +73,9 @@ from atv_player.danmaku.cache import load_or_create_danmaku_ass_cache from atv_player.danmaku.generic import normalize_danmaku_episode_url -from atv_player.danmaku.utils import infer_playlist_episode_number +from atv_player.danmaku.utils import extract_official_link_url, infer_playlist_episode_number +from atv_player.subtitles.cache import save_subtitle_file +from atv_player.subtitles.service import build_subtitle_query from atv_player.heat import has_required_heat_external_id, heat_identity_from_vod from atv_player.metadata.bindings import bilibili_season_binding_title from atv_player.metadata.cache import MetadataCache @@ -84,6 +88,10 @@ from atv_player.metadata.models import MetadataContext, MetadataQuery from atv_player.metadata.query import normalize_metadata_query_inputs from atv_player.metadata.scrape import normalize_metadata_scrape_title +from atv_player.metadata.episode_title_overrides import ( + apply_episode_title_overrides, + episode_override_item_key, +) from atv_player.metadata.providers.tmdb import infer_tmdb_media_type from atv_player.controllers.browse_controller import clean_drive_directory_title, map_drive_video_to_play_item from atv_player.playlist_sorting import format_size_bytes, parse_size_bytes @@ -121,6 +129,7 @@ from atv_player.ui.icon_cache import load_icon, tint_icon from atv_player.ui.poster_loader import load_remote_poster_image, normalize_poster_url, poster_cache_path from atv_player.ui.qt_compat import qbytearray_to_bytes, to_qbytearray +from atv_player.ui.table_utils import configure_table_columns from atv_player.ui.theme import ( FlatComboBox, build_combobox_qss, @@ -610,6 +619,27 @@ class _HeatSummarySignals(QObject): loaded = Signal(int, object) +class _SubtitleSearchSignals(QObject): + search_succeeded = Signal(int, object) + download_succeeded = Signal(int, object, object) + failed = Signal(int, str) + + +# 结果表列序:来源 / 名称 / 语言 / 格式 / 匹配度 +_SUBTITLE_SEARCH_COLUMNS = ("来源", "字幕", "语言", "格式", "匹配度") +_SUBTITLE_SEARCH_NAME_COLUMN = 1 + +# 语言筛选下拉项。默认不限制语言,仅靠匹配打分把简英双语排在最前, +# 用户想只看某种语言时再手动收窄。 +_SUBTITLE_LANGUAGE_FILTERS = ( + ("", "全部(简英双语优先)"), + ("chs_eng", "简英双语"), + ("chs", "简体中文"), + ("cht", "繁体中文"), + ("eng", "English"), +) + + @dataclass(slots=True) class SubtitlePreference: mode: str = "auto" @@ -845,6 +875,7 @@ def __init__( self._playback_loader_request_id = 0 self._metadata_request_id = 0 self._metadata_scrape_request_id = 0 + self._subtitle_search_request_id = 0 self._episode_title_request_id = 0 self._playback_prepare_request_id = 0 self._detail_action_request_id = 0 @@ -866,6 +897,20 @@ def __init__( self._danmaku_source_dialog: QDialog | None = None self._danmaku_settings_dialog: QDialog | None = None self._metadata_scrape_dialog: QDialog | None = None + self._subtitle_search_dialog: QDialog | None = None + self._subtitle_search_context_label: QLabel | None = None + self._subtitle_search_title_edit: QLineEdit | None = None + self._subtitle_search_language_combo: QComboBox | None = None + self._subtitle_search_provider_combo: QComboBox | None = None + self._subtitle_search_tmdb_id_edit: QLineEdit | None = None + self._subtitle_search_imdb_id_edit: QLineEdit | None = None + self._subtitle_search_table: QTableWidget | None = None + self._subtitle_search_status_label: QLabel | None = None + self._subtitle_search_button: QPushButton | None = None + self._subtitle_search_apply_button: QPushButton | None = None + self._subtitle_search_secondary_button: QPushButton | None = None + self._subtitle_search_items: list[object] = [] + self._subtitle_search_result: object | None = None self._danmaku_source_title_edit: QLineEdit | None = None self._danmaku_source_episode_edit: QLineEdit | None = None self._danmaku_source_url_edit: QLineEdit | None = None @@ -906,6 +951,8 @@ def __init__( self._metadata_hydration_override_category = "" self._restart_episode_title_after_next_metadata_hydration = False self._force_episode_title_restart_on_metadata_request_id = 0 + self._danmaku_research_pending = False + self._danmaku_last_searched_query = "" self._danmaku_render_mode_combo: QComboBox | None = None self._danmaku_color_mode_combo: QComboBox | None = None self._danmaku_uniform_color_edit: QLineEdit | None = None @@ -962,6 +1009,14 @@ def __init__( self._connect_async_signal(self._metadata_hydration_signals.succeeded, self._handle_metadata_hydration_succeeded) self._connect_async_signal(self._metadata_hydration_signals.failed, self._handle_metadata_hydration_failed) self._metadata_scrape_signals = _MetadataScrapeSignals() + self._subtitle_search_signals = _SubtitleSearchSignals() + self._subtitle_search_signals.search_succeeded.connect( + self._handle_subtitle_search_succeeded + ) + self._subtitle_search_signals.download_succeeded.connect( + self._handle_subtitle_download_succeeded + ) + self._subtitle_search_signals.failed.connect(self._handle_subtitle_search_failed) self._connect_async_signal( self._metadata_scrape_signals.search_succeeded, self._handle_metadata_scrape_search_succeeded, @@ -3792,7 +3847,15 @@ def restore_subgroup(subgroup_index: int) -> bool: if not subgroup.sources: return False playlist = subgroup.sources[0].playlist + target_play_id = target_url + parts = target_play_id.split("@") + if len(parts) >= 4: + target_play_id = "@".join(parts[:2]) for index, item in enumerate(playlist): + if item.play_id and item.play_id == target_play_id: + return self._select_nested_drive_history_item( + parent_source, subgroup_index, playlist, index + ) if item.url.strip() == target_url: return self._select_nested_drive_history_item( parent_source, subgroup_index, playlist, index @@ -3803,15 +3866,20 @@ def restore_subgroup(subgroup_index: int) -> bool: ) return False - if history.drive_dir_id: + if history.source_subgroup_name: for subgroup_index, subgroup in enumerate(parent_source.subgroups): - if subgroup.drive_dir_id == history.drive_dir_id: - return restore_subgroup(subgroup_index) - # Directory IDs can disappear when a share is regenerated. New records - # also keep the selected index as a best-effort fallback. + if ( + subgroup.label == history.source_subgroup_name + and restore_subgroup(subgroup_index) + ): + return True if restore_subgroup(history.source_subgroup_index): return True + if history.drive_dir_id: + for subgroup_index, subgroup in enumerate(parent_source.subgroups): + if subgroup.drive_dir_id == history.drive_dir_id: + return restore_subgroup(subgroup_index) # Records created before directory IDs were persisted fall back to URL # matching, which also preserves compatibility with existing histories. if not target_name: @@ -5461,6 +5529,13 @@ def _handle_metadata_hydration_succeeded(self, request_id: int, updated_vod: Vod metadata_log = _build_metadata_update_log(previous_vod, updated_vod) self.session.vod = updated_vod self._sync_playlist_media_title_from_metadata(previous_vod, updated_vod) + research_item = self._current_play_item() + if ( + research_item is not None + and not research_item.danmaku_xml + and not research_item.danmaku_pending + ): + self._danmaku_research_pending = True self._reset_metadata_poster_index() self._render_poster() self._render_metadata() @@ -5472,10 +5547,60 @@ def _handle_metadata_hydration_succeeded(self, request_id: int, updated_vod: Vod if force_restart_episode_titles: self.session.episode_titles_hydrated = False self._start_episode_title_enhancement() + else: + self._maybe_research_danmaku_after_metadata() + + def _maybe_research_danmaku_after_metadata(self) -> None: + """Re-search danmaku once after metadata hydrates the current item. + + Metadata can improve the query (corrected title, variety genre -> variety + issue label) and supply an official platform URL (provider pin). If the + first search (often issued before metadata arrived) left the current item + without danmaku, re-search now. The guard key combines query and provider + URL so a pin-only change still re-searches, while an unchanged signal does + not loop. + """ + if not self._danmaku_research_pending: + return + self._danmaku_research_pending = False + if self.session is None: + return + controller = getattr(self.session, "danmaku_controller", None) + current_item = self._current_play_item() + if controller is None or current_item is None: + return + if current_item.danmaku_xml or current_item.danmaku_pending: + return + query = str(current_item.danmaku_search_query or "").strip() + if not query: + return + research_key = f"{query}\x1f{str(current_item.metadata_provider_url or '').strip()}" + if research_key == self._danmaku_last_searched_query: + return + self._danmaku_last_searched_query = research_key + auto_resolve = getattr(controller, "auto_resolve_danmaku", None) + if not callable(auto_resolve): + return + self._start_danmaku_source_task( + current_item, + error_prefix="弹幕自动下载失败", + task=lambda: auto_resolve( + current_item, + playlist=self.session.playlist, + media_duration_seconds=self._current_media_duration_seconds(), + ), + configure_danmaku_on_success=True, + debug_label="元数据后重搜", + ) def _sync_playlist_media_title_from_metadata(self, previous_vod: VodItem, updated_vod: VodItem) -> None: if self.session is None: return + metadata_provider_url = extract_official_link_url(updated_vod.detail_fields) + if metadata_provider_url: + for item in self.session.playlist: + if not str(item.metadata_provider_url or "").strip(): + item.metadata_provider_url = metadata_provider_url corrected_title = str(updated_vod.vod_name or "").strip() if not corrected_title: return @@ -5607,6 +5732,7 @@ def _handle_episode_title_enhancement_succeeded(self, request_id: int, updated_p self._playlist_sort_state.remember(merged_playlist) self.session.playlist = merged_playlist self._playlist_sort_state.apply(self.session.playlist) + self._apply_episode_title_overrides_to_session() self.current_index = find_playlist_item_index( self.session.playlist, current_item, @@ -5626,6 +5752,7 @@ def _handle_episode_title_enhancement_succeeded(self, request_id: int, updated_p self._render_playlist_items() self._refresh_window_title() self._log_episode_title_mapping() + self._maybe_research_danmaku_after_metadata() def _log_episode_title_mapping(self) -> None: if self.session is None: @@ -8255,6 +8382,441 @@ def _close_video_context_menu(self) -> bool: self._video_context_menu = None return False + # ---- 外部字幕站搜索 ---- + + def _subtitle_search_service(self): + if self.session is None: + return None + return getattr(self.session, "subtitle_search_service", None) + + def _open_subtitle_search_dialog(self) -> None: + if self._subtitle_search_service() is None: + self._append_log("字幕搜索不可用") + return + dialog = self._ensure_subtitle_search_dialog() + self._refresh_subtitle_search_context() + dialog.show() + dialog.raise_() + dialog.activateWindow() + # 首次打开自动按当前播放项搜一次,省掉一次手动点击 + if not self._subtitle_search_items: + self._start_subtitle_search() + + def _ensure_subtitle_search_dialog(self) -> QDialog: + if self._subtitle_search_dialog is not None: + return self._subtitle_search_dialog + dialog = _PlayerToolDialog(title="外部字幕", parent=self, size=(820, 520)) + host = dialog.content_widget() + layout = dialog.content_layout() + + self._subtitle_search_context_label = QLabel("", host) + self._subtitle_search_context_label.setWordWrap(True) + layout.addWidget(self._subtitle_search_context_label) + + search_row = QGridLayout() + search_row.setHorizontalSpacing(6) + search_row.setVerticalSpacing(6) + search_row.addWidget(QLabel("片名", host), 0, 0) + self._subtitle_search_title_edit = QLineEdit(host) + self._subtitle_search_title_edit.returnPressed.connect( + self._start_subtitle_search + ) + search_row.addWidget(self._subtitle_search_title_edit, 1, 0) + search_row.addWidget(QLabel("语言", host), 0, 1) + self._subtitle_search_language_combo = FlatComboBox(host) + for code, label in _SUBTITLE_LANGUAGE_FILTERS: + self._subtitle_search_language_combo.addItem(label, code) + self._subtitle_search_language_combo.currentIndexChanged.connect( + lambda _index: self._populate_subtitle_search_table() + ) + search_row.addWidget(self._subtitle_search_language_combo, 1, 1) + search_row.addWidget(QLabel("字幕站", host), 0, 2) + self._subtitle_search_provider_combo = FlatComboBox(host) + self._subtitle_search_provider_combo.addItem("全部", "") + service = self._subtitle_search_service() + if service is not None: + for provider_id in service.provider_order: + self._subtitle_search_provider_combo.addItem( + service.provider_label(provider_id), provider_id + ) + search_row.addWidget(self._subtitle_search_provider_combo, 1, 2) + self._subtitle_search_button = QPushButton("搜索字幕", host) + self._subtitle_search_button.clicked.connect(self._start_subtitle_search) + search_row.addWidget(self._subtitle_search_button, 1, 3) + search_row.setColumnStretch(0, 3) + search_row.setColumnStretch(1, 1) + search_row.setColumnStretch(2, 1) + layout.addLayout(search_row) + + # 媒体 ID 行:填了之后 SubDL / OpenSubtitles 会优先按 ID 搜,命中率远高于片名, + # 尤其是中文片名在英文站搜不到时(如"方舟一号"在 SubDL 搜不到,但用 TMDB ID 能搜到) + id_row = QHBoxLayout() + id_row.setSpacing(6) + id_row.addWidget(QLabel("TMDB ID", host)) + self._subtitle_search_tmdb_id_edit = QLineEdit(host) + self._subtitle_search_tmdb_id_edit.setPlaceholderText("可选,如 105923") + self._subtitle_search_tmdb_id_edit.setFixedWidth(160) + id_row.addWidget(self._subtitle_search_tmdb_id_edit) + id_row.addWidget(QLabel("IMDb ID", host)) + self._subtitle_search_imdb_id_edit = QLineEdit(host) + self._subtitle_search_imdb_id_edit.setPlaceholderText("可选,如 tt1234567") + self._subtitle_search_imdb_id_edit.setFixedWidth(180) + id_row.addWidget(self._subtitle_search_imdb_id_edit) + id_row.addStretch(1) + layout.addLayout(id_row) + + table = QTableWidget(0, len(_SUBTITLE_SEARCH_COLUMNS), host) + table.setHorizontalHeaderLabels(list(_SUBTITLE_SEARCH_COLUMNS)) + table.verticalHeader().setVisible(False) + table.setSelectionBehavior(QTableWidget.SelectionBehavior.SelectRows) + table.setSelectionMode(QTableWidget.SelectionMode.SingleSelection) + table.setEditTriggers(QTableWidget.EditTrigger.NoEditTriggers) + table.itemDoubleClicked.connect(lambda _item: self._download_selected_subtitle()) + configure_table_columns(table, _SUBTITLE_SEARCH_NAME_COLUMN) + self._subtitle_search_table = table + layout.addWidget(table, 1) + + self._subtitle_search_status_label = QLabel("", host) + self._subtitle_search_status_label.setWordWrap(True) + layout.addWidget(self._subtitle_search_status_label) + + actions = QHBoxLayout() + actions.addStretch(1) + self._subtitle_search_secondary_button = QPushButton("设为次字幕", host) + self._subtitle_search_secondary_button.clicked.connect( + lambda: self._download_selected_subtitle(secondary=True) + ) + actions.addWidget(self._subtitle_search_secondary_button) + self._subtitle_search_apply_button = QPushButton("下载并加载", host) + self._subtitle_search_apply_button.clicked.connect( + self._download_selected_subtitle + ) + actions.addWidget(self._subtitle_search_apply_button) + close_button = QPushButton("关闭", host) + close_button.clicked.connect(dialog.close) + actions.addWidget(close_button) + layout.addLayout(actions) + + self._subtitle_search_dialog = dialog + self._apply_theme() + return dialog + + def _subtitle_search_query_context(self) -> tuple[str, str, int | None]: + """返回 (片名, 发布文件名, 集数)。""" + item = self._current_play_item() + vod_name = str(self.session.vod.vod_name or "").strip() if self.session else "" + item_title = str(getattr(item, "title", "") or "").strip() if item else "" + # original_title 通常是原始文件名,最适合拿去解析画质/压制组 + file_name = str(getattr(item, "original_title", "") or "").strip() if item else "" + if not file_name and item is not None: + path = str(getattr(item, "path", "") or "").strip() + file_name = path.replace("\\", "/").rsplit("/", 1)[-1] + title = vod_name or item_title + episode = None + if item is not None: + playlist = self.session.playlist if self.session is not None else None + episode = infer_playlist_episode_number(item, playlist) + return title, file_name, episode + + def _build_subtitle_search_query(self): + title, file_name, episode = self._subtitle_search_query_context() + if self._subtitle_search_title_edit is not None: + typed = self._subtitle_search_title_edit.text().strip() + if typed: + title = typed + imdb_id = self._subtitle_edit_value(self._subtitle_search_imdb_id_edit) + tmdb_id = self._subtitle_edit_value(self._subtitle_search_tmdb_id_edit) + vod = self.session.vod if self.session is not None else None + try: + year = int(str(getattr(vod, "vod_year", "") or "").strip()[:4]) + except (TypeError, ValueError): + year = 0 + return build_subtitle_query( + title=title, + file_name=file_name, + episode=episode, + year=year, + imdb_id=imdb_id, + tmdb_id=tmdb_id, + ) + + @staticmethod + def _subtitle_edit_value(edit: QLineEdit | None) -> str: + if edit is None: + return "" + return edit.text().strip() + + def _resolve_subtitle_search_media_ids(self) -> tuple[str, str]: + """从已刮削绑定的元数据里取 TMDB / IMDb id,用于自动填充。 + + 刮削来源通常是 TMDB(provider == "tmdb")或豆瓣(provider == "douban")。 + IMDb id 一般拿不到,这里主要补 TMDB id。 + """ + if self.session is None: + return "", "" + bindings = getattr(self.session, "metadata_binding_repository", None) + vod = self.session.vod + title = str(getattr(vod, "vod_name", "") or "").strip() + if bindings is None or not title or not hasattr(bindings, "load_by_title"): + return "", "" + try: + binding = bindings.load_by_title(title) + except Exception: + return "", "" + if binding is None: + return "", "" + provider = str(getattr(binding, "provider", "") or "").strip().lower() + provider_id = str(getattr(binding, "provider_id", "") or "").strip() + if provider == "tmdb" and provider_id: + return provider_id, "" + return "", "" + + def _refresh_subtitle_search_context(self) -> None: + query = self._build_subtitle_search_query() + title_edit = self._subtitle_search_title_edit + if title_edit is not None and not title_edit.text().strip(): + title_edit.setText(query.title) + # 自动填充刮削绑定到的 TMDB id(用户没手填时才覆盖) + tmdb_id, _imdb_id = self._resolve_subtitle_search_media_ids() + if tmdb_id and self._subtitle_search_tmdb_id_edit is not None: + if not self._subtitle_search_tmdb_id_edit.text().strip(): + self._subtitle_search_tmdb_id_edit.setText(tmdb_id) + if self._subtitle_search_context_label is not None: + parts = [f"影片:{query.title or '未知'}"] + if query.season is not None and query.episode is not None: + parts.append(f"S{query.season:02d}E{query.episode:02d}") + elif query.episode is not None: + parts.append(f"第 {query.episode} 集") + extras = [ + value + for value in ( + query.resolution, + query.source, + query.codec, + query.release_group, + ) + if value + ] + if extras: + parts.append(" / ".join(extras)) + self._subtitle_search_context_label.setText(" ".join(parts)) + + def _set_subtitle_search_busy(self, busy: bool) -> None: + for button in ( + self._subtitle_search_button, + self._subtitle_search_apply_button, + self._subtitle_search_secondary_button, + ): + if button is not None: + button.setEnabled(not busy) + + def _start_subtitle_search(self) -> None: + service = self._subtitle_search_service() + if service is None: + return + query = self._build_subtitle_search_query() + if not query.title and not query.file_name: + self._set_subtitle_search_status("没有可用于搜索的片名") + return + provider_filter = "" + if self._subtitle_search_provider_combo is not None: + provider_filter = str( + self._subtitle_search_provider_combo.currentData() or "" + ) + self._subtitle_search_request_id += 1 + request_id = self._subtitle_search_request_id + self._set_subtitle_search_status("正在搜索字幕…") + self._set_subtitle_search_busy(True) + + def run() -> None: + try: + result = service.search(query, provider_filter=provider_filter) + except Exception as exc: + if self._is_window_alive(): + self._subtitle_search_signals.failed.emit( + request_id, f"字幕搜索失败: {exc}" + ) + return + if self._is_window_alive(): + self._subtitle_search_signals.search_succeeded.emit(request_id, result) + + threading.Thread(target=run, daemon=True).start() + + def _handle_subtitle_search_succeeded(self, request_id: int, result: object) -> None: + if request_id != self._subtitle_search_request_id: + return + self._set_subtitle_search_busy(False) + self._subtitle_search_result = result + self._subtitle_search_items = [ + item for group in getattr(result, "groups", []) for item in group.items + ] + self._populate_subtitle_search_table() + + def _handle_subtitle_search_failed(self, request_id: int, message: str) -> None: + if request_id != self._subtitle_search_request_id: + return + self._set_subtitle_search_busy(False) + self._set_subtitle_search_status(message) + self._append_log(message) + + def _subtitle_search_language_filter(self) -> str: + if self._subtitle_search_language_combo is None: + return "" + return str(self._subtitle_search_language_combo.currentData() or "") + + def _populate_subtitle_search_table(self) -> None: + table = self._subtitle_search_table + if table is None: + return + language_filter = self._subtitle_search_language_filter() + rows = [ + item + for item in self._subtitle_search_items + if not language_filter or item.language == language_filter + ] + table.setRowCount(len(rows)) + for row, item in enumerate(rows): + values = ( + item.provider_label, + item.name, + item.language_label or item.language, + (item.format or "").upper(), + f"{item.match_percent}%", + ) + for column, text in enumerate(values): + cell = QTableWidgetItem(str(text)) + if column == 0: + cell.setData(Qt.ItemDataRole.UserRole, item) + table.setItem(row, column, cell) + if rows: + table.selectRow(0) + configure_table_columns(table, _SUBTITLE_SEARCH_NAME_COLUMN) + self._set_subtitle_search_status(self._describe_subtitle_search_result(len(rows))) + + def _describe_subtitle_search_result(self, shown: int) -> str: + result = self._subtitle_search_result + service = self._subtitle_search_service() + errors = getattr(result, "errors", {}) or {} if result else {} + skipped = getattr(result, "skipped", []) or [] if result else [] + # 没有任何结果时,给出可操作的根因提示,而不是干巴巴的"共 0 条" + if shown == 0: + if skipped: + labels = "、".join( + service.provider_label(key) if service else key for key in skipped + ) + return ( + f"没有找到字幕。以下站点未配置 Token 已跳过:{labels}。" + "免 Token 站中字幕库当前已不可用(SubHD 仍可用)。" + "推荐在「高级设置 → 字幕」配置射手网(ASSRT)的免费 Token " + "(assrt.net 注册即得),它的中文字幕最全;" + "也可配置 SubSource(subsource.net)的免费 API Key。" + ) + if errors: + details = ";".join( + f"{service.provider_label(key) if service else key}: {message}" + for key, message in errors.items() + ) + return f"没有找到字幕,所有站点均失败({details})。可尝试改用英文片名或填写 TMDB/IMDb ID 后重搜。" + return "没有找到字幕。可尝试改用英文片名,或填写 TMDB/IMDb ID 后重搜。" + parts = [f"共 {shown} 条"] + if result is None: + return parts[0] + notices = [ + group.notice + for group in getattr(result, "groups", []) + if getattr(group, "notice", "") + ] + if errors: + details = ";".join( + f"{service.provider_label(key) if service else key}: {message}" + for key, message in errors.items() + ) + parts.append(f"部分站点失败({details})") + if skipped: + labels = "、".join( + service.provider_label(key) if service else key for key in skipped + ) + parts.append(f"未配置 Token 已跳过:{labels}") + parts.extend(notices) + return " | ".join(parts) + + def _set_subtitle_search_status(self, text: str) -> None: + if self._subtitle_search_status_label is not None: + self._subtitle_search_status_label.setText(text) + + def _selected_subtitle_search_item(self): + table = self._subtitle_search_table + if table is None: + return None + row = table.currentRow() + if row < 0: + return None + cell = table.item(row, 0) + return cell.data(Qt.ItemDataRole.UserRole) if cell is not None else None + + def _download_selected_subtitle(self, *, secondary: bool = False) -> None: + service = self._subtitle_search_service() + item = self._selected_subtitle_search_item() + if service is None or item is None: + self._set_subtitle_search_status("请先选择一条字幕") + return + self._subtitle_search_request_id += 1 + request_id = self._subtitle_search_request_id + self._set_subtitle_search_status(f"正在下载:{item.name}") + self._set_subtitle_search_busy(True) + title = self._build_subtitle_search_query().title + + def run() -> None: + try: + content = service.download(item) + path = save_subtitle_file(content, title=title or item.name) + except Exception as exc: + if self._is_window_alive(): + self._subtitle_search_signals.failed.emit( + request_id, f"字幕下载失败: {exc}" + ) + return + if self._is_window_alive(): + self._subtitle_search_signals.download_succeeded.emit( + request_id, item, (str(path), secondary) + ) + + threading.Thread(target=run, daemon=True).start() + + def _handle_subtitle_download_succeeded( + self, + request_id: int, + item: object, + payload: object, + ) -> None: + if request_id != self._subtitle_search_request_id: + return + self._set_subtitle_search_busy(False) + path, secondary = payload + current_item = self._current_play_item() + if current_item is None: + self._set_subtitle_search_status("当前没有播放项,无法加载字幕") + return + label = f"{item.provider_label} {item.language_label or item.language}".strip() + option = ExternalSubtitleOption( + name=f"{label} · {item.name}".strip(" ·"), + lang=item.language, + url=path, + format=(item.format or "").lstrip("."), + source="subtitle-site", + ) + existing = self._find_current_item_external_subtitle(path) + if existing is None: + current_item.external_subtitles = [*current_item.external_subtitles, option] + # 复用既有的外挂字幕通道:下拉框与右键菜单会自动出现这一条 + self._refresh_subtitle_state() + if secondary: + self._set_secondary_subtitle_from_menu("external", path) + else: + self._set_primary_subtitle_from_menu("external", path) + self._set_subtitle_search_status(f"已加载:{option.name}") + self._append_log(f"已加载外部字幕: {option.name}") + def _build_video_context_menu(self) -> QMenu: menu = QMenu(self) menu.addMenu(self._build_primary_subtitle_menu(menu)) @@ -8271,6 +8833,7 @@ def _build_video_context_menu(self) -> QMenu: menu.addMenu(self._build_video_quality_menu(menu)) menu.addMenu(self._build_danmaku_menu(menu)) menu.addAction("刮削", self._open_metadata_scrape_dialog) + menu.addAction("搜索字幕", self._open_subtitle_search_dialog) menu.addAction("弹幕源", self._open_danmaku_source_dialog) menu.addAction("弹幕设置", self._open_danmaku_settings_dialog) menu.addAction("视频信息", self._toggle_video_info_from_menu) @@ -8294,6 +8857,114 @@ def _current_play_item(self) -> PlayItem | None: return None return self.session.playlist[self.current_index] + def _show_playlist_context_menu(self, pos) -> None: + if self.session is None or not self.session.playlist: + return + item_at = self.playlist.itemAt(pos) + if item_at is None: + return + row = self.playlist.row(item_at) + if not 0 <= row < len(self.session.playlist): + return + play_item = self.session.playlist[row] + menu = QMenu(self) + menu.addAction("编辑分集标题", lambda: self._edit_playlist_item_title(row)) + if self._playlist_item_has_override(play_item): + menu.addAction("恢复标题", lambda: self._reset_playlist_item_title(row)) + menu.exec(self.playlist.mapToGlobal(pos)) + + def _episode_title_override_identity(self) -> tuple[str, str, str] | None: + if self.session is None: + return None + source_kind = str(getattr(self.session, "source_kind", "") or "") + source_key = str(getattr(self.session, "source_key", "") or "") + vod_id = str(getattr(self.session.vod, "vod_id", "") or "") + if not vod_id: + return None + return source_kind, source_key, vod_id + + def _session_episode_title_overrides(self) -> dict[str, str]: + identity = self._episode_title_override_identity() + repo = getattr(self.session, "episode_title_override_repository", None) if self.session else None + if identity is None or repo is None: + return {} + source_kind, source_key, vod_id = identity + return repo.load_for_session(source_kind=source_kind, source_key=source_key, vod_id=vod_id) + + def _playlist_item_has_override(self, play_item: PlayItem) -> bool: + return episode_override_item_key(play_item) in self._session_episode_title_overrides() + + def _apply_episode_title_overrides_to_session(self) -> None: + """Stamp manual overrides onto the current session playlist (idempotent). + + Used after paths that set episode titles without going through the app + enhancer (e.g. manual metadata scrape), so a saved override always wins. + """ + if self.session is None: + return + overrides = self._session_episode_title_overrides() + if overrides: + apply_episode_title_overrides(self.session.playlist, overrides) + + def _edit_playlist_item_title(self, row: int) -> None: + if self.session is None or not 0 <= row < len(self.session.playlist): + return + repo = getattr(self.session, "episode_title_override_repository", None) + identity = self._episode_title_override_identity() + play_item = self.session.playlist[row] + if identity is None or repo is None: + self._append_log("当前来源不支持分集标题手动修正") + return + current_display = playlist_item_display_title(play_item, "episode").strip() + original = play_item.original_title.strip() or play_item.title.strip() + edited, ok = QInputDialog.getText( + self, + "编辑分集标题", + f"原始文件名:\n{original}" if original else "编辑分集标题", + text=current_display or original, + ) + if not ok: + return + edited = edited.strip() + if not edited or edited == current_display: + return + source_kind, source_key, vod_id = identity + repo.upsert( + source_kind=source_kind, + source_key=source_key, + vod_id=vod_id, + item_key=episode_override_item_key(play_item), + display_title=edited, + ) + play_item.episode_display_title = edited + play_item.episode_title_source = "manual" + self.playlist_title_mode = "episode" + self._render_playlist_items() + + def _reset_playlist_item_title(self, row: int) -> None: + if self.session is None or not 0 <= row < len(self.session.playlist): + return + repo = getattr(self.session, "episode_title_override_repository", None) + identity = self._episode_title_override_identity() + play_item = self.session.playlist[row] + if identity is None or repo is None: + return + source_kind, source_key, vod_id = identity + repo.delete( + source_kind=source_kind, + source_key=source_key, + vod_id=vod_id, + item_key=episode_override_item_key(play_item), + ) + if self.session.episode_title_enhancer is not None: + # Re-derive titles; the deleted item falls back to its auto/original title. + self.session.episode_titles_hydrated = False + self._start_episode_title_enhancement() + else: + play_item.episode_display_title = "" + play_item.episode_title_source = "" + self._render_playlist_items() + def _refresh_danmaku_source_entry_points(self) -> None: self.danmaku_source_button.setEnabled(True) self.danmaku_settings_button.setEnabled(True) @@ -10335,6 +11006,7 @@ def _register_shortcuts(self) -> None: (QKeySequence("W"), self.wide_button.click), (QKeySequence("D"), self._open_danmaku_source_dialog), (QKeySequence("S"), self._open_metadata_scrape_dialog), + (QKeySequence("C"), self._open_subtitle_search_dialog), (QKeySequence("Ctrl+D"), self._open_danmaku_settings_dialog), (QKeySequence("I"), self._toggle_video_info_from_menu), (QKeySequence("M"), self._toggle_mute), diff --git a/tests/test_api_client.py b/tests/test_api_client.py index 35075731..0d8e0414 100644 --- a/tests/test_api_client.py +++ b/tests/test_api_client.py @@ -82,6 +82,29 @@ def handler(request: httpx.Request) -> httpx.Response: assert seen_headers["authorization"] == "token-123" +def test_pull_playback_records_sends_source_filters() -> None: + seen_headers: dict[str, str] = {} + + def handler(request: httpx.Request) -> httpx.Response: + seen_headers.update(request.headers) + return httpx.Response(200, json={"items": [], "deleted": [], "nextSince": "1"}) + + client = ApiClient( + base_url="http://127.0.0.1:4567", + token="token-123", + transport=httpx.MockTransport(handler), + ) + + client.pull_playback_records( + 1, + source_kinds="site,spider_plugin", + site_keys="csp_AList,csp_TgWeb", + ) + + assert seen_headers["x-playsync-source-kind"] == "site,spider_plugin" + assert seen_headers["x-playsync-site-key"] == "csp_AList,csp_TgWeb" + + def test_api_client_uses_vod_token_for_vod_requests() -> None: seen_path = {"value": ""} @@ -192,6 +215,7 @@ def handler(request: httpx.Request) -> httpx.Response: "speed": 1.25, "createTime": 123456, "sourceSubgroupIndex": 2, + "sourceSubgroupName": "第三季", "driveDirId": "season-3", }, ) @@ -208,6 +232,7 @@ def handler(request: httpx.Request) -> httpx.Response: assert history.key == "movie-1" assert history.speed == 1.25 assert history.source_subgroup_index == 2 + assert history.source_subgroup_name == "第三季" assert history.drive_dir_id == "season-3" @@ -404,9 +429,8 @@ def handler(request: httpx.Request) -> httpx.Response: assert client.get_video_cover() == "" -def test_api_client_treats_successful_empty_delete_response_as_none() -> None: +def test_api_client_returns_none_for_successful_empty_response() -> None: def handler(request: httpx.Request) -> httpx.Response: - assert request.method == "DELETE" return httpx.Response(204, content=b"") client = ApiClient( @@ -416,7 +440,7 @@ def handler(request: httpx.Request) -> httpx.Response: transport=httpx.MockTransport(handler), ) - assert client.delete_history(9) is None + assert client.logout() is None def test_api_client_returns_plain_text_for_successful_text_response() -> None: diff --git a/tests/test_browse_controller.py b/tests/test_browse_controller.py index 48a99b5c..387a9cb4 100644 --- a/tests/test_browse_controller.py +++ b/tests/test_browse_controller.py @@ -1,6 +1,11 @@ from datetime import datetime -from atv_player.controllers.browse_controller import BrowseController, build_vod_list_path, filter_search_results +from atv_player.controllers.browse_controller import ( + BrowseController, + build_vod_list_path, + filter_search_results, + map_drive_video_to_play_item, +) from atv_player.models import VodItem from atv_player.share_types import infer_share_type @@ -69,6 +74,21 @@ def test_filter_search_results_by_drive_type() -> None: assert [item.vod_id for item in filtered] == ["1"] +def test_map_drive_video_preserves_backend_play_url_short_id() -> None: + item = map_drive_video_to_play_item( + { + "name": "S01E126.mp4", + "url": "http://atb/p/token/1@185535", + "path": "/凡人修仙传/S01E126.mp4", + "playId": "1@185535", + }, + index=1, + ) + + assert item.play_id == "1@185535" + assert item.vod_id == "/凡人修仙传/S01E126.mp4" + + def test_infer_share_type_uses_share_link_hostname() -> None: assert infer_share_type("https://pan.quark.cn/s/demo") == "5" assert infer_share_type("https://pan.baidu.com/s/demo") == "10" @@ -198,6 +218,44 @@ def test_build_request_from_detail_maps_playlist_items() -> None: assert request.clicked_index == 0 +def test_browse_request_uses_alist_sync_history_callbacks() -> None: + loaded: list[str] = [] + saved: list[tuple[str, dict]] = [] + controller = BrowseController( + FakeApiClient(), + playback_history_loader=lambda _source_key, vod_id: loaded.append(vod_id), + playback_history_saver=lambda _source_key, vod_id, payload: saved.append((vod_id, payload)), + ) + + request = controller.build_request_from_detail("detail-1") + request.playback_history_loader() + request.playback_history_saver({"position": 1000}) + + assert request.use_local_history is False + assert loaded == ["detail-1"] + assert saved == [("detail-1", {"position": 1000})] + + +def test_browse_history_keeps_concrete_tvbox_source_key() -> None: + loaded: list[tuple[str, str]] = [] + saved: list[tuple[str, str, dict]] = [] + controller = BrowseController( + FakeApiClient(), + playback_history_loader=lambda source_key, vod_id: loaded.append((source_key, vod_id)), + playback_history_saver=lambda source_key, vod_id, payload: saved.append( + (source_key, vod_id, payload) + ), + ) + + request = controller.build_request_from_detail("detail-1", source_key="csp_TgWeb") + request.playback_history_loader() + request.playback_history_saver({"position": 1000}) + + assert request.source_key == "csp_TgWeb" + assert loaded == [("csp_TgWeb", "detail-1")] + assert saved == [("csp_TgWeb", "detail-1", {"position": 1000})] + + def test_build_request_from_detail_preserves_original_filename_separately_from_rewritten_title() -> None: api = FakeApiClient() api.detail_payload = { diff --git a/tests/test_build_mpv_script.py b/tests/test_build_mpv_script.py index 7c3df374..afc38ef2 100644 --- a/tests/test_build_mpv_script.py +++ b/tests/test_build_mpv_script.py @@ -101,6 +101,36 @@ def test_build_mpv_script_auto_installs_liblua52_dev_when_missing(tmp_path: Path assert "+ sudo apt-get install -y liblua5.2-dev" in result.stdout +def test_build_mpv_script_auto_installs_libxml2_dev_when_missing(tmp_path: Path) -> None: + workdir = tmp_path / "mpv-build" + _write_fake_mpv_build_repo(workdir) + fake_bin = tmp_path / "fake-bin" + fake_bin.mkdir() + for tool in ("git", "meson", "ninja", "sudo", "nasm", "apt-get", "dirname"): + _write_fake_tool(fake_bin, tool) + (fake_bin / "pkg-config").write_text( + "#!/bin/sh\n" + "case \"$*\" in\n" + " *libxml-2.0*) exit 1 ;;\n" + " *) exit 0 ;;\n" + "esac\n", + encoding="utf-8", + ) + os.chmod(fake_bin / "pkg-config", 0o755) + + result = _run_script( + tmp_path, + "--workdir", + str(workdir), + "--dry-run", + "--no-install", + env={**os.environ, "PATH": f"{fake_bin}:{os.environ['PATH']}"}, + ) + + assert result.returncode == 0 + assert "+ sudo apt-get install -y libxml2-dev" in result.stdout + + def test_build_mpv_script_fails_when_lua_dev_runtime_missing_and_apt_get_unavailable(tmp_path: Path) -> None: workdir = tmp_path / "mpv-build" _write_fake_mpv_build_repo(workdir) @@ -340,7 +370,9 @@ def test_build_mpv_script_writes_disable_x86asm_option_and_uses_release_track_by ) assert result.returncode == 0 - assert (workdir / "ffmpeg_options").read_text(encoding="utf-8") == "--disable-x86asm\n" + assert (workdir / "ffmpeg_options").read_text(encoding="utf-8") == ( + "--enable-libxml2\n--disable-x86asm\n" + ) assert "+ ./use-mpv-release" in result.stdout assert "+ ./use-ffmpeg-release" in result.stdout assert "+ ./rebuild -j8" in result.stdout diff --git a/tests/test_danmaku_service.py b/tests/test_danmaku_service.py index 267b3682..aecdc16c 100644 --- a/tests/test_danmaku_service.py +++ b/tests/test_danmaku_service.py @@ -1107,6 +1107,42 @@ def test_search_danmu_variety_without_matching_issue_falls_back_to_title_similar ] +def test_search_danmu_prefers_variety_candidate_matching_date_and_part() -> None: + # Variety episodes split a single issue into 上/中/下 halves that may share + # the same air date; the date+part key must select the exact half. Candidate + # names are date-led (no show name), so this also covers the date-led + # should_filter_name exemption. (Regression: 《心动的信号 第9季》0 候选.) + tencent = FakeProvider( + "tencent", + [ + DanmakuSearchItem( + provider="tencent", name="2026-08-10 第2期中:首次约会 你侬我侬", url="https://tencent/2zhong" + ), + DanmakuSearchItem( + provider="tencent", name="2026-08-10 第2期上:偶像剧现场 情歌对唱", url="https://tencent/2shang" + ), + DanmakuSearchItem( + provider="tencent", name="2026-08-11 第2期下:勇敢者约会", url="https://tencent/2xia" + ), + DanmakuSearchItem( + provider="tencent", name="2026-08-03 第1期上:又争又抢", url="https://tencent/1shang" + ), + ], + [], + ) + service = DanmakuService({"tencent": tencent}, provider_order=["tencent"]) + + results = service.search_danmu("心动的信号 第9季 20260810 第2期上") + + assert results[0].url == "https://tencent/2shang" + assert {item.url for item in results} == { + "https://tencent/2shang", + "https://tencent/2zhong", + "https://tencent/2xia", + "https://tencent/1shang", + } + + def test_search_danmu_filters_to_candidates_with_matching_episode_number() -> None: tencent = FakeProvider( "tencent", diff --git a/tests/test_danmaku_tencent_provider.py b/tests/test_danmaku_tencent_provider.py index 19939467..7c33edfd 100644 --- a/tests/test_danmaku_tencent_provider.py +++ b/tests/test_danmaku_tencent_provider.py @@ -344,6 +344,21 @@ def fake_get( ] +def test_tencent_preview_filter_keeps_colon_titled_variety_episodes() -> None: + provider = TencentDanmakuProvider() + # QQ names variety episodes " 第N期"; the fullwidth + # colon is a structural separator there, not a non-main-content marker, so + # such episodes must NOT be dropped as previews. (Regression: 《心动的信号 + # 第9季》综艺搜出 0 候选 — all real episodes misclassified as previews.) + real_episode = {"title": "2026-08-10 第2期上:偶像剧现场 情歌对唱甜蜜升温"} + assert provider._is_preview_episode_candidate(real_episode) is False + # Actual non-main / preview content is still filtered. + assert provider._is_preview_episode_candidate({"title": "第2期 彩蛋:花絮"}) is True + assert provider._is_preview_episode_candidate( + {"title": "2", "rawTags": '{"tag_2":{"text":"预"}}'} + ) is True + + def test_tencent_provider_search_expands_episode_list_from_candidate_detail_page() -> None: def fake_get( url: str, diff --git a/tests/test_danmaku_utils.py b/tests/test_danmaku_utils.py index 49571173..5f9a86d6 100644 --- a/tests/test_danmaku_utils.py +++ b/tests/test_danmaku_utils.py @@ -3,15 +3,25 @@ build_xml, episode_matches_request, extract_episode_number, + extract_official_link_url, + extract_variety_episode_label, extract_variety_issue_key, + extract_variety_part, + has_variety_issue_marker, infer_playlist_episode_number, is_likely_variety_title, + is_variety_collection, match_provider, normalize_name, should_filter_name, strip_variety_issue_suffix, ) -from atv_player.models import PlayItem +from atv_player.models import ( + PlaybackDetailField, + PlaybackDetailFieldAction, + PlaybackDetailValuePart, + PlayItem, +) def test_normalize_name_strips_noise_tokens() -> None: @@ -50,6 +60,14 @@ def test_should_filter_name_keeps_marker_led_episode_subtitle() -> None: assert should_filter_name(target, "第十三集 朱字当众揭穿我藏在凤椅下,皇后一句谁先取她的命") is False +def test_should_filter_name_keeps_date_led_variety_episode_subtitle() -> None: + # Tencent names variety episodes by air date + 第N期 + subtitle, with no show + # name (e.g. "2026-08-10 第2期上:"); such candidates must not be + # dropped by name-similarity (regression: 《心动的信号 第9季》综艺 0 候选). + target = normalize_name("心动的信号 第9季 20260810 第2期上") + assert should_filter_name(target, "2026-08-10 第2期上:偶像剧现场 情歌对唱甜蜜升温") is False + + def test_episode_matches_request_rejects_different_show_with_same_episode_number() -> None: # A candidate carrying a different show-name prefix must still be rejected even # when its episode number equals the request (guard against over-relaxation). @@ -86,6 +104,106 @@ def test_extract_variety_issue_key_supports_calendar_issue_titles() -> None: assert extract_variety_issue_key("你好星期六 20250104期") == "20250104" +def test_extract_variety_part_finds_half_marker_after_issue() -> None: + assert extract_variety_part("2026-08-10 第2期上:偶像剧现场") == "上" + assert extract_variety_part("哈哈哈哈哈第6季 第1期下 邓超陈赫斗舞") == "下" + assert extract_variety_part("第1期加更上 小屋荡秋千") == "加更上" + assert extract_variety_part("第1期加更") == "加更" + + +def test_extract_variety_part_returns_none_without_marker() -> None: + assert extract_variety_part("你好星期六 20250104期") is None + assert extract_variety_part("歌手2026 第12期") is None + # 完整版 must NOT be read as part 完 (followed by CJK 整). + assert extract_variety_part("《哈哈哈哈哈第六季》 第七期 完整版") is None + + +def test_extract_variety_issue_key_appends_part_for_disambiguation() -> None: + assert extract_variety_issue_key("2026-08-10 第2期上:偶像剧现场") == "20260810上" + assert extract_variety_issue_key("2026-08-10 第2期中:首次约会") == "20260810中" + assert extract_variety_issue_key("2026-08-11 第2期下:勇敢者约会") == "20260811下" + # No part -> bare key (back-compat with calendar issue titles). + assert extract_variety_issue_key("你好星期六 20250104期") == "20250104" + + +def test_extract_variety_episode_label_builds_from_filename() -> None: + assert extract_variety_episode_label("2026.08.10-第2期上.mp4") == "20260810 第2期上" + assert extract_variety_episode_label("第2期上.mp4") == "第2期上" + assert extract_variety_episode_label("第1期加更上.mp4") == "第1期加更上" + assert extract_variety_episode_label("纯享版.mp4") == "" + + +def test_is_variety_collection_reads_metadata_genres() -> None: + assert is_variety_collection("真人秀") is True + assert is_variety_collection("综艺") is True + assert is_variety_collection("", "脱口秀") is True + assert is_variety_collection("Variety") is True + assert is_variety_collection("电视剧") is False + assert is_variety_collection("", "") is False + # tag/content are also consulted (parity with app-level playlist detection). + assert is_variety_collection("", "", "", "国内综艺first") is True + + +def test_has_variety_issue_marker_ignores_bare_dates() -> None: + # 第N期/N期 and hint tokens qualify... + assert has_variety_issue_marker("2026-08-10 第2期上") is True + assert has_variety_issue_marker("20250104期") is True + assert has_variety_issue_marker("第1期加更") is True + # ...but a bare air date must NOT, or ordinary episode files with dates + # (e.g. anime "04-第4话…-2026-03-03") would be misread as variety issues. + assert has_variety_issue_marker("04-第4话 啥!啥!-1080P-2026-03-03") is False + assert has_variety_issue_marker("2026.08.10.mp4") is False + + +def test_match_provider_maps_sohu_and_migu() -> None: + assert match_provider("https://tv.sohu.com/v/abc.html") == "sohu" + assert match_provider("https://www.miguvideo.com/p/detail/abc") == "migu" + + +def test_extract_official_link_url_returns_first_known_platform_link() -> None: + fields = [ + PlaybackDetailField(label="别名", value="Heart Signal"), + PlaybackDetailField( + label="官方链接", + value_parts=[ + PlaybackDetailValuePart( + label="腾讯视频", + action=PlaybackDetailFieldAction( + type="link", value="https://v.qq.com/x/cover/mzc002003kpyd2m/w4102gzejm3.html" + ), + ), + PlaybackDetailValuePart( + label="爱奇艺", + action=PlaybackDetailFieldAction(type="link", value="https://www.iqiyi.com/v_demo.html"), + ), + ], + ), + ] + + assert extract_official_link_url(fields) == "https://v.qq.com/x/cover/mzc002003kpyd2m/w4102gzejm3.html" + + +def test_extract_official_link_url_skips_unknown_and_missing_links() -> None: + assert extract_official_link_url([]) == "" + assert extract_official_link_url(None) == "" + # A 官方链接 pointing at an unrecognized host yields nothing to pin. + unknown = [ + PlaybackDetailField( + label="官方链接", + value_parts=[ + PlaybackDetailValuePart( + label="其他", + action=PlaybackDetailFieldAction(type="link", value="https://example.com/watch/1"), + ) + ], + ) + ] + assert extract_official_link_url(unknown) == "" + # Plain-text 官方链接 (no action) yields nothing. + text_only = [PlaybackDetailField(label="官方链接", value="腾讯视频")] + assert extract_official_link_url(text_only) == "" + + def test_is_likely_variety_title_distinguishes_issue_from_episode_titles() -> None: assert is_likely_variety_title("歌手2026 第12期") is True assert is_likely_variety_title("剑来 第12集") is False diff --git a/tests/test_history_controller.py b/tests/test_history_controller.py index 7c73e339..c9b5d6e1 100644 --- a/tests/test_history_controller.py +++ b/tests/test_history_controller.py @@ -2,413 +2,150 @@ from atv_player.models import HistoryRecord -class FakeApiClient: - def __init__(self) -> None: - self.deleted_one: list[int] = [] - self.deleted_many: list[list[int]] = [] - self.cleared = False - - def list_history(self, page: int, size: int) -> dict: - return { - "content": [ - { - "id": 9, - "key": "movie-1", - "vodName": "Movie", - "vodPic": "pic", - "vodRemarks": "Episode 2", - "episode": 1, - "episodeUrl": "2.m3u8", - "position": 90000, - "opening": 0, - "ending": 0, - "speed": 1.0, - "createTime": 123456, - } - ], - "totalElements": 1, - } - - def delete_history(self, history_id: int) -> None: - self.deleted_one.append(history_id) - - def delete_histories(self, history_ids: list[int]) -> None: - self.deleted_many.append(history_ids) - - def clear_history(self) -> None: - self.cleared = True - - class FakeRepository: def __init__(self, histories: list[HistoryRecord] | None = None) -> None: self.histories = list(histories or []) self.deleted: list[tuple[str, str, str]] = [] + self.pending_deletions: list[tuple[str, str, str, int]] = [] def list_histories(self) -> list[HistoryRecord]: return list(self.histories) def delete_history(self, source_kind: str, vod_id: str, source_key: str = "") -> None: - self.deleted.append((source_kind, source_key, vod_id)) - - -def test_history_controller_maps_backend_payload() -> None: - controller = HistoryController(FakeApiClient()) - - records, total = controller.load_page(page=1, size=20) - - assert total == 1 - assert records[0].id == 9 - assert records[0].vod_name == "Movie" - assert records[0].episode == 1 - assert records[0].source_kind == "remote" - assert records[0].source_plugin_id == 0 - assert records[0].source_plugin_name == "" - - -def test_history_controller_deletes_one_or_many() -> None: - api = FakeApiClient() - controller = HistoryController(api) - record_one = HistoryRecord( - id=9, - key="movie-1", - vod_name="Movie", + self.deleted.append((source_kind, vod_id, source_key)) + + def record_pending_deletion( + self, source_kind: str, source_key: str, vod_id: str, deleted_at: int + ) -> None: + self.pending_deletions.append((source_kind, source_key, vod_id, deleted_at)) + + +def _record( + *, + key: str, + name: str, + create_time: int, + source_kind: str = "telegram", + source_key: str = "", + position: int = 0, + episode: int = 0, +) -> HistoryRecord: + return HistoryRecord( + id=0, + key=key, + vod_name=name, vod_pic="pic", - vod_remarks="Episode 2", - episode=1, - episode_url="2.m3u8", - position=90000, - opening=0, - ending=0, - speed=1.0, - create_time=123456, - source_kind="remote", - ) - record_two = HistoryRecord( - id=10, - key="movie-2", - vod_name="Movie 2", - vod_pic="pic-2", - vod_remarks="Episode 1", - episode=0, - episode_url="1.m3u8", - position=3000, + vod_remarks=f"第{episode + 1}集", + episode=episode, + episode_url=f"{key}.m3u8", + position=position, opening=0, ending=0, speed=1.0, - create_time=123457, - source_kind="remote", + create_time=create_time, + source_kind=source_kind, + source_key=source_key, + source_name="", ) - controller.delete_one(record_one) - controller.delete_many([record_one, record_two]) - controller.clear_page([record_one, record_two]) - - assert api.deleted_one == [9] - assert api.deleted_many == [[9, 10], [9, 10]] - - -def test_history_controller_tolerates_missing_optional_fields() -> None: - class MissingFieldApiClient(FakeApiClient): - def list_history(self, page: int, size: int) -> dict: - return { - "content": [ - { - "id": 10, - "key": "movie-2", - "vodName": "Movie 2", - "vodRemarks": "Episode 1", - "episode": 0, - "position": 3000, - "createTime": 999, - } - ], - "totalElements": 1, - } - - controller = HistoryController(MissingFieldApiClient()) + +def test_load_page_returns_local_records_in_descending_time_order() -> None: + repository = FakeRepository( + histories=[ + _record(key="a", name="A", create_time=100), + _record(key="b", name="B", create_time=300), + _record(key="c", name="C", create_time=200), + ] + ) + controller = HistoryController(None, repository) records, total = controller.load_page(page=1, size=20) - assert total == 1 - assert records[0].id == 10 - assert records[0].vod_pic == "" - assert records[0].episode_url == "" - assert records[0].speed == 1.0 + assert total == 3 + assert [record.key for record in records] == ["b", "c", "a"] -def test_history_controller_merges_remote_and_plugin_histories_in_descending_time_order() -> None: - api = FakeApiClient() +def test_load_page_paginates_local_records() -> None: repository = FakeRepository( - histories=[ - HistoryRecord( - id=0, - key="plugin-1", - vod_name="Plugin Movie", - vod_pic="plugin-pic", - vod_remarks="第2集", - episode=1, - episode_url="plugin-2.m3u8", - position=45000, - opening=0, - ending=0, - speed=1.0, - create_time=200000, - source_kind="spider_plugin", - source_plugin_id=7, - source_plugin_name="红果短剧", - ) - ] + histories=[_record(key=str(n), name=str(n), create_time=n) for n in range(5)] ) - controller = HistoryController(api, repository) + controller = HistoryController(None, repository) - records, total = controller.load_page(page=1, size=20) + records, total = controller.load_page(page=2, size=2) - assert total == 2 - assert [record.key for record in records] == ["plugin-1", "movie-1"] - assert [record.source_kind for record in records] == ["spider_plugin", "remote"] + assert total == 5 + # create_time 4,3,2,1,0 desc → page 2 (offset 2) → 2,1 + assert [record.key for record in records] == ["2", "1"] -def test_history_controller_merges_remote_and_emby_jellyfin_feiniu_local_histories() -> None: - api = FakeApiClient() +def test_load_page_filters_by_keyword_source_kind_and_continue_watching() -> None: repository = FakeRepository( histories=[ - HistoryRecord( - id=0, - key="plugin-1", - vod_name="Plugin Movie", - vod_pic="plugin-pic", - vod_remarks="第2集", - episode=1, - episode_url="plugin-2.m3u8", - position=45000, - opening=0, - ending=0, - speed=1.0, - create_time=200000, - source_kind="spider_plugin", - source_plugin_id=7, - source_plugin_name="红果短剧", - source_key="7", - source_name="红果短剧", - ), - HistoryRecord( - id=0, - key="emby-1", - vod_name="Emby Movie", - vod_pic="emby-pic", - vod_remarks="Episode 3", - episode=2, - episode_url="emby-3.m3u8", - position=60000, - opening=0, - ending=0, - speed=1.25, - create_time=300000, - source_kind="emby", - source_name="Emby", - ), - HistoryRecord( - id=0, - key="jellyfin-1", - vod_name="Jellyfin Movie", - vod_pic="jf-pic", - vod_remarks="Episode 1", - episode=0, - episode_url="jf-1.m3u8", - position=15000, - opening=0, - ending=0, - speed=1.0, - create_time=250000, - source_kind="jellyfin", - source_name="Jellyfin", - ), - HistoryRecord( - id=0, - key="feiniu-1", - vod_name="Feiniu Movie", - vod_pic="fn-pic", - vod_remarks="Episode 2", - episode=1, - episode_url="fn-2.m3u8", - position=45000, - opening=0, - ending=0, - speed=1.0, - create_time=275000, - source_kind="feiniu", - source_name="飞牛影视", - ), + _record(key="tg-1", name="心动信号", create_time=300, source_kind="telegram", position=0), + _record(key="tg-2", name="心动信号 续", create_time=200, source_kind="telegram", position=5000), + _record(key="bili-1", name="心动信号", create_time=100, source_kind="bilibili", position=3000), ] ) - controller = HistoryController(api, repository) + controller = HistoryController(None, repository) + + # keyword + source_kind + records, _ = controller.load_page(page=1, size=20, keyword="续", source_kind="telegram") + assert [record.key for record in records] == ["tg-2"] + + # continue_watching keeps only entries with progress + watching, _ = controller.load_page(page=1, size=20, continue_watching=True) + assert {record.key for record in watching} == {"tg-2", "bili-1"} + + +def test_load_page_returns_empty_without_repository() -> None: + controller = HistoryController(None) records, total = controller.load_page(page=1, size=20) - assert total == 5 - assert [record.key for record in records] == ["emby-1", "feiniu-1", "jellyfin-1", "plugin-1", "movie-1"] - assert [record.source_kind for record in records] == ["emby", "feiniu", "jellyfin", "spider_plugin", "remote"] + assert records == [] + assert total == 0 -def test_history_controller_deletes_one_or_many_by_source() -> None: - api = FakeApiClient() +def test_delete_one_delegates_to_repository() -> None: repository = FakeRepository() - controller = HistoryController(api, repository) - remote = HistoryRecord( - id=9, - key="movie-1", - vod_name="Movie", - vod_pic="pic", - vod_remarks="Episode 2", - episode=1, - episode_url="2.m3u8", - position=90000, - opening=0, - ending=0, - speed=1.0, - create_time=123456, - source_kind="remote", - ) - plugin = HistoryRecord( - id=0, - key="detail-1", - vod_name="Plugin Movie", - vod_pic="poster", - vod_remarks="第1集", - episode=0, - episode_url="1.m3u8", - position=15000, - opening=0, - ending=0, - speed=1.0, - create_time=123457, - source_kind="spider_plugin", - source_plugin_id=3, - source_plugin_name="红果短剧", - source_key="3", - source_name="红果短剧", - ) - emby = HistoryRecord( - id=0, - key="emby-1", - vod_name="Emby Movie", - vod_pic="poster", - vod_remarks="Episode 1", - episode=0, - episode_url="1.m3u8", - position=3000, - opening=0, - ending=0, - speed=1.0, - create_time=123458, - source_kind="emby", - source_name="Emby", - ) - feiniu = HistoryRecord( - id=0, - key="feiniu-1", - vod_name="Feiniu Movie", - vod_pic="poster", - vod_remarks="Episode 2", - episode=1, - episode_url="2.m3u8", - position=3500, - opening=0, - ending=0, - speed=1.0, - create_time=123459, - source_kind="feiniu", - source_name="飞牛影视", - ) + controller = HistoryController(None, repository) + record = _record(key="detail-1", name="x", create_time=1, source_kind="spider_plugin", source_key="7") - controller.delete_one(remote) - controller.delete_many([remote, plugin, emby, feiniu]) + controller.delete_one(record) - assert api.deleted_one == [9] - assert api.deleted_many == [[9]] - assert repository.deleted == [ - ("spider_plugin", "3", "detail-1"), - ("emby", "", "emby-1"), - ("feiniu", "", "feiniu-1"), - ] + assert repository.deleted == [("spider_plugin", "detail-1", "7")] + assert repository.pending_deletions == [("spider_plugin", "7", "detail-1", 1)] -def test_history_controller_clear_page_deletes_current_records_by_source() -> None: - api = FakeApiClient() +def test_delete_many_and_clear_page_delegate_to_repository() -> None: repository = FakeRepository() - controller = HistoryController(api, repository) - remote = HistoryRecord( - id=11, - key="movie-2", - vod_name="Movie 2", - vod_pic="", - vod_remarks="Episode 1", - episode=0, - episode_url="1.m3u8", - position=3000, - opening=0, - ending=0, - speed=1.0, - create_time=999, - source_kind="remote", - ) - plugin = HistoryRecord( - id=0, - key="detail-2", - vod_name="Plugin Movie", - vod_pic="", - vod_remarks="第3集", - episode=2, - episode_url="3.m3u8", - position=6000, - opening=0, - ending=0, - speed=1.0, - create_time=1000, - source_kind="spider_plugin", - source_plugin_id=4, - source_plugin_name="插件二", - source_key="4", - source_name="插件二", - ) - jellyfin = HistoryRecord( - id=0, - key="jf-1", - vod_name="Jellyfin Movie", - vod_pic="", - vod_remarks="Episode 4", - episode=3, - episode_url="4.m3u8", - position=12000, - opening=0, - ending=0, - speed=1.0, - create_time=1001, - source_kind="jellyfin", - source_name="Jellyfin", - ) - feiniu = HistoryRecord( - id=0, - key="fn-1", - vod_name="Feiniu Movie", - vod_pic="", - vod_remarks="Episode 3", - episode=2, - episode_url="3.m3u8", - position=13000, - opening=0, - ending=0, - speed=1.0, - create_time=1002, - source_kind="feiniu", - source_name="飞牛影视", - ) + controller = HistoryController(None, repository) + records = [ + _record(key="emby-1", name="x", create_time=1, source_kind="emby"), + _record(key="fn-1", name="y", create_time=2, source_kind="feiniu", source_key="fn"), + ] - controller.clear_page([remote, plugin, jellyfin, feiniu]) + controller.delete_many(records) + controller.clear_page(records) - assert api.deleted_many == [[11]] assert repository.deleted == [ - ("spider_plugin", "4", "detail-2"), - ("jellyfin", "", "jf-1"), - ("feiniu", "", "fn-1"), + ("emby", "emby-1", ""), + ("feiniu", "fn-1", "fn"), + ("emby", "emby-1", ""), + ("feiniu", "fn-1", "fn"), ] + assert repository.pending_deletions == [ + ("emby", "", "emby-1", 1), + ("feiniu", "fn", "fn-1", 2), + ("emby", "", "emby-1", 1), + ("feiniu", "fn", "fn-1", 2), + ] + + +def test_delete_is_noop_without_repository() -> None: + controller = HistoryController(None) + + controller.delete_one(_record(key="a", name="a", create_time=1)) + controller.delete_many([_record(key="a", name="a", create_time=1)]) + controller.clear_page([_record(key="a", name="a", create_time=1)]) + # 无仓库时不抛异常即可。 diff --git a/tests/test_main_window_ui.py b/tests/test_main_window_ui.py index b61a9ee2..6333b775 100644 --- a/tests/test_main_window_ui.py +++ b/tests/test_main_window_ui.py @@ -1296,11 +1296,12 @@ def load_page(self, *, page: int, size: int, keyword: str): def test_main_window_opens_browse_favorite_record(qtbot, monkeypatch) -> None: opened: list[OpenPlayerRequest] = [] browse_controller = SimpleNamespace( - build_request_from_detail=lambda vod_id: OpenPlayerRequest( + build_request_from_detail=lambda vod_id, source_key="csp_AList": OpenPlayerRequest( vod=VodItem(vod_id=vod_id, vod_name="详情页"), playlist=[PlayItem(title="第1集", url="https://media.example/1.m3u8")], clicked_index=0, source_kind="browse", + source_key=source_key, source_mode="detail", source_vod_id=vod_id, ) @@ -1317,7 +1318,7 @@ def test_main_window_opens_browse_favorite_record(qtbot, monkeypatch) -> None: monkeypatch.setattr(window, "_start_open_request", lambda builder: opened.append(builder()) or 1) record = FavoriteRecord( source_kind="browse", - source_key="", + source_key="csp_TgWeb", source_name="文件浏览", vod_id="detail-1", vod_name_snapshot="庆余年", @@ -1332,6 +1333,7 @@ def test_main_window_opens_browse_favorite_record(qtbot, monkeypatch) -> None: window.open_favorite_detail(record) assert opened[0].source_kind == "browse" + assert opened[0].source_key == "csp_TgWeb" def test_main_window_opens_live_favorite_record(qtbot, monkeypatch) -> None: diff --git a/tests/test_metadata_episode_title_overrides.py b/tests/test_metadata_episode_title_overrides.py new file mode 100644 index 00000000..2f679ced --- /dev/null +++ b/tests/test_metadata_episode_title_overrides.py @@ -0,0 +1,99 @@ +from pathlib import Path + +from atv_player.metadata.episode_title_overrides import ( + EpisodeTitleOverrideRepository, + apply_episode_title_overrides, + episode_override_item_key, +) +from atv_player.models import PlayItem + + +def test_item_key_prefers_play_id_then_url_then_path_basename() -> None: + assert episode_override_item_key(PlayItem(title="a", url="", play_id="pid1")) == "pid1" + assert episode_override_item_key(PlayItem(title="a", url="http://m/1.mp4", play_id="")) == "http://m/1.mp4" + # url wins over original_url/path when present + assert ( + episode_override_item_key( + PlayItem(title="a", url="http://m/1.mp4", original_url="x", path="/d/1.mp4") + ) + == "http://m/1.mp4" + ) + # path basename fallback when no ids + assert episode_override_item_key(PlayItem(title="a", url="", path="/some/dir/EP01.mp4")) == "EP01.mp4" + # title last resort + assert episode_override_item_key(PlayItem(title="裸标题", url="")) == "裸标题" + + +def test_override_repository_round_trip_upsert_load_delete(tmp_path: Path) -> None: + repo = EpisodeTitleOverrideRepository(tmp_path / "app.db") + + repo.upsert( + source_kind="browse", + source_key="", + vod_id="1$/media/信号$1", + item_key="http://m/08-03.mp4", + display_title="08-03 第1期上:心动", + ) + + overrides = repo.load_for_session( + source_kind="browse", source_key="", vod_id="1$/media/信号$1" + ) + assert overrides == {"http://m/08-03.mp4": "08-03 第1期上:心动"} + + # upsert overwrites + repo.upsert( + source_kind="browse", + source_key="", + vod_id="1$/media/信号$1", + item_key="http://m/08-03.mp4", + display_title="08-03 改过的标题", + ) + assert repo.load_for_session( + source_kind="browse", source_key="", vod_id="1$/media/信号$1" + ) == {"http://m/08-03.mp4": "08-03 改过的标题"} + + repo.delete( + source_kind="browse", source_key="", vod_id="1$/media/信号$1", item_key="http://m/08-03.mp4" + ) + assert repo.load_for_session(source_kind="browse", source_key="", vod_id="1$/media/信号$1") == {} + + +def test_override_repository_isolates_by_vod_and_source(tmp_path: Path) -> None: + repo = EpisodeTitleOverrideRepository(tmp_path / "app.db") + repo.upsert(source_kind="browse", source_key="", vod_id="v1", item_key="k1", display_title="t1") + repo.upsert(source_kind="browse", source_key="", vod_id="v2", item_key="k1", display_title="t2") + + assert repo.load_for_session(source_kind="browse", source_key="", vod_id="v1") == {"k1": "t1"} + assert repo.load_for_session(source_kind="browse", source_key="", vod_id="v2") == {"k1": "t2"} + # empty vod_id never loads + assert repo.load_for_session(source_kind="browse", source_key="", vod_id="") == {} + + +def test_override_repository_ignores_empty_inputs(tmp_path: Path) -> None: + repo = EpisodeTitleOverrideRepository(tmp_path / "app.db") + repo.upsert(source_kind="browse", source_key="", vod_id="", item_key="k", display_title="t") + repo.upsert(source_kind="browse", source_key="", vod_id="v", item_key="", display_title="t") + repo.upsert(source_kind="browse", source_key="", vod_id="v", item_key="k", display_title="") + assert repo.load_for_session(source_kind="browse", source_key="", vod_id="v") == {} + + +def test_apply_overrides_stamps_manual_source_and_wins_over_auto(tmp_path: Path) -> None: + playlist = [ + PlayItem(title="20260803.第1期上.mp4", url="http://m/1.mp4", episode_display_title="第1集 自动", episode_title_source="tmdb"), + PlayItem(title="20260803.第1期中.mp4", url="http://m/2.mp4"), + ] + overrides = {"http://m/1.mp4": "08-03 第1期上:又争又抢"} + + changed = apply_episode_title_overrides(playlist, overrides) + + assert changed is True + assert playlist[0].episode_display_title == "08-03 第1期上:又争又抢" + assert playlist[0].episode_title_source == "manual" + assert playlist[1].episode_display_title == "" # untouched + assert playlist[1].episode_title_source == "" + + +def test_apply_overrides_seeds_original_title(tmp_path: Path) -> None: + item = PlayItem(title="file.mp4", url="http://m/1.mp4") + apply_episode_title_overrides([item], {"http://m/1.mp4": "改写标题"}) + assert item.original_title == "file.mp4" diff --git a/tests/test_metadata_episode_title_resolver.py b/tests/test_metadata_episode_title_resolver.py index 68a1ce12..e9bf173f 100644 --- a/tests/test_metadata_episode_title_resolver.py +++ b/tests/test_metadata_episode_title_resolver.py @@ -1,5 +1,7 @@ from atv_player.metadata.episode_title_resolver import ( METADATA_EPISODE_TITLE_SOURCE_PRIORITY, + _titles_by_index_for_tencent_variety, + _VARIETY_EPISODE_TITLE_SOURCE_PRIORITY, build_provider_episode_playlist, is_high_confidence_iqiyi_episode_candidate, resolve_episode_title_source_priority, @@ -549,6 +551,7 @@ def test_resolve_episode_title_source_priority_moves_iqiyi_ahead_of_tmdb_only_fo tmdb = MetadataMatch(provider="tmdb", provider_id="tv:42:season:1", title="临江仙", year="2025") assert resolve_episode_title_source_priority(vod, playlist, [iqiyi, tmdb]) == [ + "manual", "plugin", "bangumi", "bilibili", @@ -557,3 +560,102 @@ def test_resolve_episode_title_source_priority_moves_iqiyi_ahead_of_tmdb_only_fo "tencent", ] assert resolve_episode_title_source_priority(vod, playlist, [tmdb]) == METADATA_EPISODE_TITLE_SOURCE_PRIORITY + + +def _variety_show() -> tuple[VodItem, list[PlayItem]]: + vod = VodItem(vod_id="v1", vod_name="心动的信号 第9季", vod_year="2026", type_name="综艺") + playlist = [ + PlayItem(title="20260731.先导篇上 X.mp4", original_title="20260731.先导篇上 X.mp4", url="http://m/1.mp4"), + PlayItem(title="20260731.先导篇下 X.mp4", original_title="20260731.先导篇下 X.mp4", url="http://m/2.mp4"), + PlayItem(title="20260803.第1期上 X.mp4", original_title="20260803.第1期上 X.mp4", url="http://m/3.mp4"), + PlayItem(title="20260803.第1期中 X.mp4", original_title="20260803.第1期中 X.mp4", url="http://m/4.mp4"), + PlayItem(title="20260806.第1期加更上 X.mp4", original_title="20260806.第1期加更上 X.mp4", url="http://m/5.mp4"), + PlayItem(title="20260810.第2期上.mp4", original_title="20260810.第2期上.mp4", url="http://m/6.mp4"), + ] + return vod, playlist + + +def _variety_raw() -> dict: + return { + "episode_list_source": "tencent_cover", + "episodes": [ + {"title": "先导片上:丘比特集结", "publish_date": "2026-07-31 00:00:00"}, + {"title": "先导片下:男女初见面", "publish_date": "2026-07-31 00:00:00"}, + {"title": "第1期上:又争又抢", "publish_date": "2026-08-03 00:00:00"}, + {"title": "第1期中:你忙着抢行李", "publish_date": "2026-08-03 00:00:00"}, + {"title": "第1期加更上:小屋恋爱", "publish_date": "2026-08-06 00:00:00"}, + {"title": "第2期上:偶像剧现场", "publish_date": "2026-08-10 00:00:00"}, + ], + } + + +def test_resolve_episode_title_source_priority_returns_variety_priority_for_variety_playlist() -> None: + vod, playlist = _variety_show() + assert resolve_episode_title_source_priority(vod, playlist, []) == _VARIETY_EPISODE_TITLE_SOURCE_PRIORITY + + +def test_variety_matcher_aligns_files_to_official_episodes_by_date_and_part() -> None: + vod, playlist = _variety_show() + titles_by_index = _titles_by_index_for_tencent_variety(vod, playlist, _variety_raw()) + + # Part-keyed matches. + assert titles_by_index[2] == "08-03 第1期上:又争又抢" + assert titles_by_index[3] == "08-03 第1期中:你忙着抢行李" + assert titles_by_index[4] == "08-06 第1期加更上:小屋恋爱" + assert titles_by_index[5] == "08-10 第2期上:偶像剧现场" + # Order fallback for same-date items without a parseable part (先导片上/下). + assert titles_by_index[0] == "07-31 先导片上:丘比特集结" + assert titles_by_index[1] == "07-31 先导片下:男女初见面" + # No MM-DD-less prefix leak; no 第N集 prefix. + assert all(v[:5].endswith(("07-31", "08-03", "08-06", "08-10")) for v in titles_by_index.values()) + + +def test_variety_matcher_returns_empty_when_no_publish_dates() -> None: + vod, playlist = _variety_show() + # Cover episodes without publish dates (the section-tab case) must not match. + raw = {"episodes": [{"title": "第1季"}, {"title": "纯享"}]} + assert _titles_by_index_for_tencent_variety(vod, playlist, raw) == {} + + +def test_build_provider_episode_playlist_variety_uses_official_titles() -> None: + vod, playlist = _variety_show() + match = MetadataMatch( + provider="tencent", + provider_id="https://v.qq.com/x/cover/mzc002003kpyd2m.html", + title="心动的信号 第九季", + year="2026", + raw=_variety_raw(), + ) + updated = build_provider_episode_playlist( + vod, + playlist, + match, + source_priority=_VARIETY_EPISODE_TITLE_SOURCE_PRIORITY, + ) + assert updated is not None + assert updated[2].episode_display_title == "08-03 第1期上:又争又抢" + assert updated[2].episode_title_source == "tencent" + + +def test_build_provider_episode_playlist_variety_rejects_collapsed_number_providers() -> None: + # iQiyi/TMDB/bangumi map 第N期上/中/下 to a single episode number and would + # scramble 纯享/陪看 onto unrelated episodes. For a variety playlist they must + # produce nothing so files without an official date+part match stay unmapped + # (original filename) instead of getting garbage. + vod, playlist = _variety_show() + iqiyi = MetadataMatch( + provider="iqiyi", + provider_id="iqiyi:1", + title="心动的信号 第九季", + year="2026", + raw={"videos": [{"itemNumber": 1, "itemTitle": "第2期加更上:崔凯怡"}]}, + ) + assert ( + build_provider_episode_playlist( + vod, + playlist, + iqiyi, + source_priority=_VARIETY_EPISODE_TITLE_SOURCE_PRIORITY, + ) + is None + ) diff --git a/tests/test_metadata_tencent_provider.py b/tests/test_metadata_tencent_provider.py index b415387e..b6484f8e 100644 --- a/tests/test_metadata_tencent_provider.py +++ b/tests/test_metadata_tencent_provider.py @@ -571,3 +571,87 @@ def fake_post(url: str, **kwargs): ("仙剑奇侠传三", "https://v.qq.com/x/cover/drama/ep1.html"), ] assert matches[0].score > matches[1].score + + +def _cover_episode_payload() -> dict: + # Shape mirrors the real GetPageData vsite_episode_list response. + return { + "data": { + "module_list_datas": [ + { + "module_datas": [ + { + "item_data_lists": { + "item_datas": [ + { + "item_params": { + "union_title": "先导片上:丘比特集结", + "publish_date": "2026-07-31 00:00:00", + } + }, + { + "item_params": { + "union_title": "第1期上:又争又抢", + "publish_date": "2026-08-03 00:00:00", + } + }, + # Section tabs carry no publish date -> must be skipped. + {"item_params": {"union_title": "纯享"}}, + {"item_params": {}}, + ] + } + } + ] + } + ] + } + } + + +def test_tencent_parse_cover_episodes_keeps_dated_episodes_and_drops_section_tabs() -> None: + provider = TencentMetadataProvider() + episodes = provider._parse_cover_episodes(_cover_episode_payload()) + + assert episodes == [ + {"title": "先导片上:丘比特集结", "publish_date": "2026-07-31 00:00:00"}, + {"title": "第1期上:又争又抢", "publish_date": "2026-08-03 00:00:00"}, + ] + + +def test_tencent_hydrate_episode_candidate_fetches_cover_list_into_raw() -> None: + requested = {} + + def fake_post(url: str, **kwargs): + requested["url"] = url + requested["json"] = kwargs.get("json") + return JsonResponse(_cover_episode_payload()) + + provider = TencentMetadataProvider(post=fake_post) + candidate = MetadataMatch( + provider="tencent", + provider_id="https://v.qq.com/x/cover/mzc002003kpyd2m.html", + title="心动的信号 第九季", + year="2026", + raw={}, + ) + + hydrated = provider._hydrate_episode_candidate(candidate) + + assert requested["url"] == ( + "https://pbaccess.video.qq.com/trpc.universal_backend_service.page_server_rpc.PageServer/GetPageData" + ) + assert requested["json"]["page_params"]["cid"] == "mzc002003kpyd2m" + assert hydrated.raw["episode_list_source"] == "tencent_cover" + assert hydrated.raw["episodes"][0] == { + "title": "先导片上:丘比特集结", + "publish_date": "2026-07-31 00:00:00", + } + + +def test_tencent_hydrate_episode_candidate_returns_candidate_unchanged_without_cover_id() -> None: + provider = TencentMetadataProvider() + candidate = MetadataMatch(provider="tencent", provider_id="not-a-cover-url", title="x", year="2026", raw={}) + + hydrated = provider._hydrate_episode_candidate(candidate) + assert hydrated is candidate + assert hydrated.raw == {} diff --git a/tests/test_playback_sync_service.py b/tests/test_playback_sync_service.py new file mode 100644 index 00000000..8a6e4d42 --- /dev/null +++ b/tests/test_playback_sync_service.py @@ -0,0 +1,739 @@ +from __future__ import annotations + +import threading + +from atv_player.models import HistoryRecord +from atv_player.playback_sync_service import ( + INITIAL_DELAY_MS, + PERIOD_MS, + PULL_PERIOD_MS, + PlaybackHistorySyncService, +) + + +def _record( + *, + key: str, + source_key: str = "", + source_kind: str = "emby", + source_name: str = "", + updated_at: int, +) -> HistoryRecord: + return HistoryRecord( + id=0, + key=key, + vod_name=key, + vod_pic="", + vod_remarks="", + episode=1, + episode_url="url", + position=100, + opening=0, + ending=0, + speed=1.0, + create_time=updated_at, + source_kind=source_kind, + source_key=source_key, + source_name=source_name, + ) + + +class FakeRepository: + def __init__(self, records: list[HistoryRecord]) -> None: + self.records = { + (record.source_kind, record.source_key, record.key): record + for record in records + } + self.deleted: list[tuple[str, str, str]] = [] + self.saved: list[tuple[str, str, str]] = [] + self.saved_source_names: list[str] = [] + self.saved_payloads: list[dict] = [] + self.cursors: dict[str, int] = {} + self.snapshots: dict[str, dict[tuple[str, str, str], int]] = {} + self.pending_deletions: dict[tuple[str, str, str], int] = {} + + def list_histories(self) -> list[HistoryRecord]: + return list(self.records.values()) + + def get_history(self, source_kind: str, vod_id: str, source_key: str = ""): + return self.records.get((source_kind, source_key, vod_id)) + + def save_history( + self, + source_kind: str, + vod_id: str, + payload: dict, + *, + source_key: str = "", + source_name: str = "", + ) -> None: + self.saved.append((source_kind, source_key, vod_id)) + self.saved_source_names.append(source_name) + self.saved_payloads.append(dict(payload)) + + def delete_history( + self, source_kind: str, vod_id: str, source_key: str = "" + ) -> None: + self.deleted.append((source_kind, source_key, vod_id)) + self.records.pop((source_kind, source_key, vod_id), None) + + def delete_site_history( + self, source_kind: str, source_key: str, deleted_at: int + ) -> list[tuple[str, str, str]]: + removed = [ + identity + for identity, record in self.records.items() + if identity[:2] == (source_kind, source_key) + and (deleted_at <= 0 or record.create_time <= deleted_at) + ] + for identity in removed: + self.records.pop(identity) + self.deleted.append(identity) + return removed + + def delete_all_histories(self, deleted_at: int) -> list[tuple[str, str, str]]: + removed = [ + identity + for identity, record in self.records.items() + if deleted_at <= 0 or record.create_time <= deleted_at + ] + for identity in removed: + self.records.pop(identity) + self.deleted.append(identity) + return removed + + def get_sync_cursor(self, namespace: str) -> int: + return self.cursors.get(namespace, 0) + + def set_sync_cursor(self, namespace: str, cursor: int) -> None: + self.cursors[namespace] = cursor + + def load_sync_snapshot(self, namespace: str) -> dict[tuple[str, str, str], int]: + return dict(self.snapshots.get(namespace, {})) + + def replace_sync_snapshot( + self, namespace: str, versions: dict[tuple[str, str, str], int] + ) -> None: + self.snapshots[namespace] = dict(versions) + + def set_sync_snapshot_version( + self, + namespace: str, + identity: tuple[str, str, str], + updated_at: int, + ) -> None: + self.snapshots.setdefault(namespace, {})[identity] = updated_at + + def remove_sync_snapshot( + self, namespace: str, identity: tuple[str, str, str] + ) -> None: + self.snapshots.setdefault(namespace, {}).pop(identity, None) + + def record_pending_deletion( + self, source_kind: str, source_key: str, vod_id: str, deleted_at: int + ) -> None: + self.pending_deletions[(source_kind, source_key, vod_id)] = int(deleted_at) + + def list_pending_deletions(self) -> list[tuple[str, str, str, int]]: + return [ + (source_kind, source_key, vod_id, deleted_at) + for (source_kind, source_key, vod_id), deleted_at in self.pending_deletions.items() + ] + + def clear_pending_deletions(self, items: list[tuple[str, str, str]]) -> None: + for identity in items: + self.pending_deletions.pop(identity, None) + + +class FakeApi: + def __init__(self, page: dict | None = None) -> None: + self.page = page or {} + self.pushed: list[list[dict]] = [] + self.pull_source_kinds = "" + self.pull_site_keys = "" + self.pull_since: list[int] = [] + self.playback_sync_identity = "test-user" + + def push_playback_events(self, records: list[dict]) -> None: + self.pushed.append(records) + + def pull_playback_records( + self, since: int, *, source_kinds: str = "", site_keys: str = "" + ) -> dict: + self.pull_since.append(since) + self.pull_source_kinds = source_kinds + self.pull_site_keys = site_keys + return self.page + + +def test_playback_sync_runs_every_30_seconds() -> None: + assert INITIAL_DELAY_MS == 30_000 + assert PERIOD_MS == 30_000 + assert PULL_PERIOD_MS == 5 * 60_000 + + +def test_push_versions_are_tracked_per_record() -> None: + first = _record(key="first", updated_at=100) + second = _record(key="second", updated_at=10) + api = FakeApi() + service = PlaybackHistorySyncService(api, FakeRepository([first, second])) + + service._pushed_versions[("site", "csp_Emby", "first")] = 200 + service._push() + + pushed_ids = [[payload["vodId"] for payload in batch] for batch in api.pushed] + assert pushed_ids == [["second"]] + + +def test_push_only_uploads_latest_100_records() -> None: + records = [_record(key=f"vod-{index}", updated_at=index) for index in range(1, 102)] + api = FakeApi() + service = PlaybackHistorySyncService(api, FakeRepository(records)) + + service._push() + + pushed_ids = {payload["vodId"] for payload in api.pushed[0]} + assert len(pushed_ids) == 100 + assert "vod-101" in pushed_ids + assert "vod-1" not in pushed_ids + + +def test_record_aging_out_of_latest_100_is_not_pushed_as_deletion() -> None: + records = [_record(key=f"vod-{index}", updated_at=index) for index in range(1, 101)] + repository = FakeRepository(records) + api = FakeApi() + service = PlaybackHistorySyncService(api, repository) + service._push() + + repository.records[("emby", "", "vod-101")] = _record(key="vod-101", updated_at=101) + service._push() + + assert [event["vodId"] for event in api.pushed[-1]] == ["vod-101"] + assert all(event.get("event") != "playback.deleted" for event in api.pushed[-1]) + assert ("site", "csp_Emby", "vod-1") not in service._pushed_versions + + +def test_selection_context_round_trips_through_sync_payloads() -> None: + record = _record( + key="173", + source_kind="spider_plugin", + source_key="99", + source_name="木偶", + updated_at=100, + ) + record.episode_url = "1@185535@6@1" + record.playlist_index = 0 + record.source_group_index = 2 + record.source_index = 0 + record.source_subgroup_index = 6 + record.source_subgroup_name = "07外海风云" + record.drive_dir_id = "local-only-dir" + record.duration = 7_200_000 + stable_id = "02544b320a6d45de997bc0bd3975d0c060b8" + api = FakeApi() + service = PlaybackHistorySyncService( + api, + FakeRepository([record]), + to_sync_source_key=lambda kind, key: stable_id if kind == "spider_plugin" else key, + ) + + service._push() + + payload = api.pushed[0][0] + assert payload["vodId"] == "173" + assert payload["episodeUrl"] == "1@185535@6@1" + assert payload["durationMs"] == 7_200_000 + assert payload["playlistIndex"] == 0 + assert payload["sourceSubgroupIndex"] == 6 + assert payload["sourceSubgroupName"] == "07外海风云" + + repository = FakeRepository([]) + service = PlaybackHistorySyncService( + FakeApi({"items": [payload], "nextSince": 1}), + repository, + to_local_source_key=lambda kind, key: "99" if kind == "spider_plugin" else key, + ) + service._pull() + + assert repository.saved_payloads[0]["episodeUrl"] == "1@185535@6@1" + assert repository.saved_payloads[0]["duration"] == 7_200_000 + assert repository.saved_payloads[0]["sourceSubgroupIndex"] == 6 + assert repository.saved_payloads[0]["sourceSubgroupName"] == "07外海风云" + + +def test_atv_source_alias_pushes_as_tvbox_site_identity() -> None: + record = _record(key="v1", source_kind="telegram", updated_at=100) + api = FakeApi() + service = PlaybackHistorySyncService(api, FakeRepository([record])) + + service._push() + + payload = api.pushed[0][0] + assert payload["sourceKind"] == "site" + assert payload["sourceKey"] == "csp_TgDouBan" + + +def test_tvbox_site_aliases_round_trip_to_atv_sources() -> None: + expected = { + "csp_TgChannel": "telegram_channel", + "csp_TgDouBan": "telegram", + "csp_TgSearch": "telegram", + "csp_TgWeb": "telegram", + "csp_FishPanSou": "telegram", + "csp_FishPanSouGroup": "telegram", + "csp_AList": "browse", + "csp_XiaoYa": "browse", + "csp_BiliBili": "bilibili", + "csp_FeiNiu": "feiniu", + "csp_Jellyfin": "jellyfin", + } + + for site_key, source_kind in expected.items(): + local_kind, local_key = PlaybackHistorySyncService._local_source("site", site_key) + assert local_kind == source_kind + assert PlaybackHistorySyncService._sync_source(local_kind, local_key) == ( + "site", + site_key, + ) + + +def test_emby_pushes_and_pulls_with_tvbox_site_identity() -> None: + record = _record(key="emby-1", source_kind="emby", updated_at=100) + api = FakeApi() + service = PlaybackHistorySyncService(api, FakeRepository([record])) + + service._push() + + assert api.pushed[0][0]["sourceKind"] == "site" + assert api.pushed[0][0]["sourceKey"] == "csp_Emby" + assert "csp_Emby" in service._current_pull_source_keys() + + +def test_tvbox_alist_site_pulls_as_atv_browse_source() -> None: + repository = FakeRepository([]) + api = FakeApi( + { + "items": [ + { + "sourceKind": "site", + "sourceKey": "csp_AList", + "sourceName": "AList", + "vodId": "1$185535$1", + "updatedAt": 100, + } + ], + "nextSince": 1, + } + ) + service = PlaybackHistorySyncService(api, repository) + + service._pull() + + assert repository.saved == [("browse", "csp_AList", "1$185535$1")] + assert "site" in api.pull_source_kinds.split(",") + assert "csp_AList" in api.pull_site_keys.split(",") + + +def test_tvbox_xiaoya_site_pulls_as_atv_browse_source() -> None: + repository = FakeRepository([]) + api = FakeApi( + { + "items": [ + { + "sourceKind": "site", + "sourceKey": "csp_XiaoYa", + "sourceName": "小雅", + "vodId": "xiaoya-vod-1", + "updatedAt": 100, + } + ], + "nextSince": 1, + } + ) + service = PlaybackHistorySyncService(api, repository) + + service._pull() + + assert repository.saved == [("browse", "csp_XiaoYa", "xiaoya-vod-1")] + assert "csp_XiaoYa" in api.pull_site_keys.split(",") + + +def test_pull_preserves_local_opening_and_ending_markers() -> None: + record = _record(key="v1", source_kind="emby", source_key="server", updated_at=100) + record.opening = 12_000 + record.ending = 34_000 + repository = FakeRepository([record]) + api = FakeApi( + { + "items": [ + { + "sourceKind": "emby", + "sourceKey": "server", + "vodId": "v1", + "updatedAt": 200, + } + ] + } + ) + + PlaybackHistorySyncService(api, repository)._pull() + + assert repository.saved_payloads[0]["opening"] == 12_000 + assert repository.saved_payloads[0]["ending"] == 34_000 + + +def test_pull_applies_tombstones_before_advancing_cursor() -> None: + repository = FakeRepository( + [_record(key="removed", source_key="server", updated_at=10)] + ) + api = FakeApi( + { + "deleted": [ + { + "sourceKind": "emby", + "sourceKey": "server", + "vodId": "removed", + "deletedAt": 100, + } + ], + "items": [], + "nextSince": 42, + } + ) + service = PlaybackHistorySyncService(api, repository) + + service._pull() + + assert repository.deleted == [("emby", "server", "removed")] + assert service._pull_cursor == 42 + + +def test_tombstone_without_deleted_at_is_skipped_not_mass_deleted() -> None: + kept = _record(key="kept", source_key="server", updated_at=10) + repository = FakeRepository([kept]) + api = FakeApi( + { + # scope=all 但缺失 deletedAt:曾导致无条件清空整库。现在必须保守跳过。 + "deleted": [{"scope": "all"}], + "items": [], + "nextSince": 42, + } + ) + service = PlaybackHistorySyncService(api, repository) + + service._pull() + + assert repository.get_history("emby", "kept", "server") is not None + assert repository.deleted == [] + assert service._pull_cursor == 42 + + +def test_stale_tombstone_keeps_newer_local_history() -> None: + repository = FakeRepository( + [_record(key="keep", source_key="server", updated_at=200)] + ) + api = FakeApi( + { + "deleted": [ + { + "sourceKind": "emby", + "sourceKey": "server", + "vodId": "keep", + "deletedAt": 100, + } + ], + "nextSince": 42, + } + ) + service = PlaybackHistorySyncService(api, repository) + + service._pull() + + assert repository.get_history("emby", "keep", "server") is not None + assert repository.deleted == [] + + +def test_scope_tombstones_delete_only_not_newer_rows() -> None: + old = _record(key="old", source_key="server", updated_at=100) + new = _record(key="new", source_key="server", updated_at=300) + repository = FakeRepository([old, new]) + api = FakeApi( + { + "deleted": [ + { + "scope": "site", + "sourceKind": "emby", + "sourceKey": "server", + "deletedAt": 200, + } + ], + "nextSince": 10, + } + ) + service = PlaybackHistorySyncService(api, repository) + + service._pull() + + assert repository.get_history("emby", "old", "server") is None + assert repository.get_history("emby", "new", "server") is not None + + +def test_pull_ignores_sources_not_owned_by_local_history_repository() -> None: + repository = FakeRepository([]) + api = FakeApi( + { + "items": [ + { + "sourceKind": "site", + "sourceKey": "tvbox-site", + "vodId": "remote-only", + "updatedAt": 100, + } + ], + "nextSince": 10, + } + ) + service = PlaybackHistorySyncService(api, repository) + + service._pull() + + assert repository.saved == [] + assert service._pull_cursor == 10 + + +def test_local_deletion_is_pushed_as_tombstone() -> None: + record = _record(key="gone", source_key="server", updated_at=100) + repository = FakeRepository([record]) + api = FakeApi() + service = PlaybackHistorySyncService(api, repository) + service._push() + # 用户显式删除:本地删除 + 记入 pending 队列(由 HistoryController 触发)。 + repository.delete_history("emby", "gone", "server") + repository.record_pending_deletion("emby", "server", "gone", 100) + + service._push() + + event = api.pushed[-1][0] + assert event["event"] == "playback.deleted" + assert event["vodId"] == "gone" + assert event["deletedAt"] == 100 + + +def test_record_vanishing_from_list_without_explicit_delete_is_not_tombstoned() -> None: + # 回归保护:账户/命名空间切换会让记录从 list_histories() 消失,但只要不是显式删除, + # 就绝不能上报 tombstone——否则会清空整库(2026-08-09 的 159→0 事故)。 + record = _record(key="gone", source_key="server", updated_at=100) + repository = FakeRepository([record]) + api = FakeApi() + service = PlaybackHistorySyncService(api, repository) + service._push() + + # 模拟命名空间切换:记录仍在库里,但下一次扫描看不到它。 + repository.records.clear() + + service._push() + + # 没有显式删除就不应触发任何上报(尤其不能上报 tombstone)。 + assert len(api.pushed) == 1 + assert ("site", "csp_Emby", "gone") not in service._pushed_versions + + +def test_spider_plugin_push_uses_manifest_id_not_local_database_id() -> None: + stable_id = "02544b320a6d45de997bc0bd3975d0c060b8" + record = _record( + key="https://115cdn.com/s/example", + source_kind="spider_plugin", + source_key="99", + source_name="木偶(已重命名)", + updated_at=100, + ) + api = FakeApi() + service = PlaybackHistorySyncService( + api, + FakeRepository([record]), + to_sync_source_key=lambda kind, key: stable_id if kind == "spider_plugin" and key == "99" else key, + ) + + service._push() + + assert api.pushed[0][0]["sourceKey"] == stable_id + assert api.pushed[0][0]["sourceName"] == "木偶(已重命名)" + assert ("spider_plugin", stable_id, record.key) in service._pushed_versions + + +def test_spider_plugin_delete_keeps_stable_manifest_id_in_tombstone() -> None: + stable_id = "02544b320a6d45de997bc0bd3975d0c060b8" + record = _record( + key="vod-1", source_kind="spider_plugin", source_key="99", updated_at=100 + ) + repository = FakeRepository([record]) + api = FakeApi() + service = PlaybackHistorySyncService( + api, + repository, + to_sync_source_key=lambda kind, key: stable_id if kind == "spider_plugin" else key, + ) + service._push() + repository.delete_history("spider_plugin", "vod-1", "99") + repository.record_pending_deletion("spider_plugin", "99", "vod-1", 100) + + service._push() + + event = api.pushed[-1][0] + assert event["event"] == "playback.deleted" + assert event["sourceKey"] == stable_id + + +def test_spider_plugin_without_manifest_id_is_not_uploaded() -> None: + record = _record( + key="vod-1", source_kind="spider_plugin", source_key="99", updated_at=100 + ) + api = FakeApi() + service = PlaybackHistorySyncService(api, FakeRepository([record])) + + service._push() + + assert api.pushed == [] + + +def test_spider_plugin_pull_maps_manifest_id_to_local_database_id() -> None: + stable_id = "02544b320a6d45de997bc0bd3975d0c060b8" + repository = FakeRepository([]) + api = FakeApi( + { + "items": [ + { + "sourceKind": "spider_plugin", + "sourceKey": stable_id, + "sourceName": "木偶[盘]", + "vodId": "vod-1", + "updatedAt": 100, + } + ], + "nextSince": 10, + } + ) + service = PlaybackHistorySyncService( + api, + repository, + to_local_source_key=lambda kind, key: "99" if kind == "spider_plugin" and key == stable_id else key, + ) + + service._pull() + + assert repository.saved == [("spider_plugin", "99", "vod-1")] + assert repository.saved_source_names == ["木偶[盘]"] + assert ("spider_plugin", stable_id, "vod-1") in service._pushed_versions + + +def test_spider_plugin_pull_repairs_source_name_at_same_version() -> None: + stable_id = "ff03a81ea2c940d4838e71fb21cf6651157d" + existing = _record( + key="vod-1", + source_kind="spider_plugin", + source_key="3", + source_name="插件", + updated_at=100, + ) + repository = FakeRepository([existing]) + api = FakeApi( + { + "items": [ + { + "sourceKind": "spider_plugin", + "sourceKey": stable_id, + "sourceName": "短剧优选", + "vodId": "vod-1", + "updatedAt": 100, + } + ], + "nextSince": 10, + } + ) + service = PlaybackHistorySyncService( + api, + repository, + to_local_source_key=lambda kind, key: "3" + if kind == "spider_plugin" and key == stable_id + else key, + ) + + service._pull() + + assert repository.saved == [("spider_plugin", "3", "vod-1")] + assert repository.saved_source_names == ["短剧优选"] + + +def test_installing_plugin_uses_fresh_filtered_pull_cursor() -> None: + stable_id = "02544b320a6d45de997bc0bd3975d0c060b8" + plugin_keys: list[str] = [] + repository = FakeRepository([]) + api = FakeApi({"nextSince": 10}) + service = PlaybackHistorySyncService( + api, + repository, + playback_source_keys_loader=lambda: plugin_keys, + to_local_source_key=lambda kind, key: "99" if kind == "spider_plugin" else key, + ) + + service._pull() + plugin_keys.append(stable_id) + api.page = { + "items": [{ + "sourceKind": "spider_plugin", + "sourceKey": stable_id, + "vodId": "vod-1", + "updatedAt": 100, + }], + "nextSince": 20, + } + service._pull() + + assert api.pull_since == [0, 0] + assert stable_id in api.pull_site_keys.split(",") + assert repository.saved == [("spider_plugin", "99", "vod-1")] + + +def test_pull_is_skipped_when_push_fails() -> None: + pulled = False + + class FailingApi(FakeApi): + def push_playback_events(self, records: list[dict]) -> None: + del records + raise RuntimeError("offline") + + def pull_playback_records( + self, since: int, *, source_kinds: str = "", site_keys: str = "" + ) -> dict: + nonlocal pulled + del since, source_kinds, site_keys + pulled = True + return {} + + service = PlaybackHistorySyncService( + FailingApi(), FakeRepository([_record(key="one", updated_at=1)]) + ) + service._started = True + + service._run_sync() + + assert pulled is False + + +def test_sync_runs_outside_calling_thread() -> None: + worker_called = threading.Event() + + class BlockingApi(FakeApi): + def push_playback_events(self, records: list[dict]) -> None: + assert threading.current_thread() is not threading.main_thread() + worker_called.set() + super().push_playback_events(records) + + service = PlaybackHistorySyncService( + BlockingApi(), + FakeRepository([_record(key="one", updated_at=1)]), + ) + service._started = True + service.sync() + + assert worker_called.wait(timeout=1) diff --git a/tests/test_player_controller.py b/tests/test_player_controller.py index 1be29216..eebaa127 100644 --- a/tests/test_player_controller.py +++ b/tests/test_player_controller.py @@ -80,6 +80,8 @@ def test_player_controller_builds_history_payload() -> None: playlist = [PlayItem(title="Episode 1", url="1.m3u8"), PlayItem(title="Episode 2", url="2.m3u8")] session = controller.create_session(vod, playlist, clicked_index=1) + saved: list[dict] = [] + session.playback_history_saver = lambda payload: saved.append(payload) controller.report_progress( session, current_index=1, @@ -90,7 +92,7 @@ def test_player_controller_builds_history_payload() -> None: paused=False, ) - payload = api.saved_payloads[0] + payload = saved[0] assert payload["key"] == "movie-1" assert payload["vodName"] == "Movie" assert payload["episode"] == 1 @@ -119,6 +121,8 @@ def test_player_controller_preserves_ytdlp_collection_title_in_history_payload() session.playlist[0].media_title = "Resolved YouTube Video" session.playlist[0].selected_playback_quality_id = "ytdlp_1080" + saved: list[dict] = [] + session.playback_history_saver = lambda payload: saved.append(payload) controller.report_progress( session, current_index=0, @@ -129,7 +133,7 @@ def test_player_controller_preserves_ytdlp_collection_title_in_history_payload() paused=False, ) - payload = api.saved_payloads[0] + payload = saved[0] assert payload["vodName"] == "OpenAI" assert payload["vodRemarks"] == "Resolved YouTube Video" @@ -153,6 +157,8 @@ def test_player_controller_uses_ytdlp_channel_name_when_initial_title_is_channel session.playlist[0].media_title = "Resolved YouTube Video" session.playlist[0].selected_playback_quality_id = "ytdlp_1080" + saved: list[dict] = [] + session.playback_history_saver = lambda payload: saved.append(payload) controller.report_progress( session, current_index=0, @@ -163,7 +169,7 @@ def test_player_controller_uses_ytdlp_channel_name_when_initial_title_is_channel paused=False, ) - payload = api.saved_payloads[0] + payload = saved[0] assert payload["vodName"] == "OpenAI" assert payload["vodRemarks"] == "Resolved YouTube Video" @@ -180,6 +186,8 @@ def test_player_controller_uses_resolved_ytdlp_title_when_initial_title_is_url_p session.playlist[0].media_title = "Resolved YouTube Video" session.playlist[0].selected_playback_quality_id = "ytdlp_1080" + saved: list[dict] = [] + session.playback_history_saver = lambda payload: saved.append(payload) controller.report_progress( session, current_index=0, @@ -190,7 +198,7 @@ def test_player_controller_uses_resolved_ytdlp_title_when_initial_title_is_url_p paused=False, ) - payload = api.saved_payloads[0] + payload = saved[0] assert payload["vodName"] == "Resolved YouTube Video" assert payload["vodRemarks"] == "Resolved YouTube Video" @@ -207,6 +215,8 @@ def test_player_controller_uses_resolved_ytdlp_title_when_initial_title_is_bare_ session.playlist[0].media_title = "Resolved YouTube Video" session.playlist[0].selected_playback_quality_id = "ytdlp_1080" + saved: list[dict] = [] + session.playback_history_saver = lambda payload: saved.append(payload) controller.report_progress( session, current_index=0, @@ -217,7 +227,7 @@ def test_player_controller_uses_resolved_ytdlp_title_when_initial_title_is_bare_ paused=False, ) - payload = api.saved_payloads[0] + payload = saved[0] assert payload["vodName"] == "Resolved YouTube Video" assert payload["vodRemarks"] == "Resolved YouTube Video" @@ -287,6 +297,8 @@ def load_item(session, item: PlayItem) -> None: assert session.playback_loader is not None session.playback_loader(session.playlist[0]) + saved: list[dict] = [] + session.playback_history_saver = lambda payload: saved.append(payload) controller.report_progress( session, current_index=0, @@ -300,7 +312,7 @@ def load_item(session, item: PlayItem) -> None: assert session.video_cover_override == "https://img.example/video-cover.jpg" assert session.vod.vod_pic == "poster-detail" - assert api.saved_payloads[0]["vodPic"] == "poster-detail" + assert saved[0]["vodPic"] == "poster-detail" def test_player_controller_create_session_preserves_detail_resolver_and_seed_cache() -> None: @@ -597,9 +609,11 @@ def test_player_controller_saves_original_youtube_url_instead_of_video_only_reso use_local_history=True, ) + saved: list[dict] = [] + session.playback_history_saver = lambda payload: saved.append(payload) controller.report_progress(session, 0, 12, 1.0, 0, 0, paused=False) - assert api.saved_payloads[0]["episodeUrl"] == "https://www.youtube.com/watch?v=abc123xyz89" + assert saved[0]["episodeUrl"] == "https://www.youtube.com/watch?v=abc123xyz89" def test_player_controller_prefers_plugin_local_history_loader() -> None: @@ -755,10 +769,12 @@ def test_player_controller_reports_progress_to_plugin_local_saver_without_backen opening_seconds=5, ending_seconds=10, paused=False, + duration_seconds=120, ) assert len(saved_payloads) == 1 assert saved_payloads[0]["key"] == "plugin-1" + assert saved_payloads[0]["duration"] == 120000 assert api.saved_payloads == [] @@ -1088,7 +1104,14 @@ def test_player_controller_reports_nested_drive_directory_history_identity() -> api = FakeApiClient() controller = PlayerController(api) first = [PlayItem(title="01.mp4", url="https://drive/season-1/01.mp4")] - second = [PlayItem(title="02.mp4", url="https://drive/season-2/02.mp4")] + second = [ + PlayItem( + title="02.mp4", + url="https://drive/season-2/02.mp4", + play_id="1@185535", + index=1, + ) + ] source = PlaybackSource( label="百度资源", playlist=first, @@ -1124,7 +1147,9 @@ def test_player_controller_reports_nested_drive_directory_history_identity() -> ) assert saved_payloads[0]["sourceSubgroupIndex"] == 1 + assert saved_payloads[0]["sourceSubgroupName"] == "第二季" assert saved_payloads[0]["driveDirId"] == "season-2" + assert saved_payloads[0]["episodeUrl"] == "1@185535@1@1" def test_player_controller_reports_progress_via_session_hook_without_saving_history() -> None: diff --git a/tests/test_player_window_ui.py b/tests/test_player_window_ui.py index f2935afd..1e815b27 100644 --- a/tests/test_player_window_ui.py +++ b/tests/test_player_window_ui.py @@ -6669,6 +6669,92 @@ def test_player_window_restores_nested_drive_subdirectory_from_history_url(qtbot assert load_calls == ["dir-2"] +def test_player_window_restores_cross_device_drive_history_by_route_and_play_id(qtbot) -> None: + drive_entry = [PlayItem(title="百度", url="", vod_id="https://pan.baidu.com/s/demo")] + session = PlayerSession( + vod=VodItem(vod_id="173", vod_name="凡人修仙传"), + playlist=drive_entry, + playlists=[drive_entry], + source_groups=[ + PlaybackSourceGroup( + label="百度", + sources=[PlaybackSource(label="百度资源", playlist=drive_entry)], + ) + ], + start_index=0, + start_position_seconds=45, + speed=1.0, + resume_history=HistoryRecord( + id=0, + key="173", + vod_name="凡人修仙传", + vod_pic="", + vod_remarks="S01E126", + episode=1, + episode_url="1@185535@6@1", + position=45000, + opening=0, + ending=0, + speed=1.0, + create_time=0, + source_subgroup_index=6, + source_subgroup_name="07外海风云", + drive_dir_id="base64-from-another-device", + ), + ) + first_route = [ + PlayItem( + title="S01E01.mp4", + url="http://atb/p/token/1@185401", + play_id="1@185401", + ) + ] + groups = [ + PlaybackSourceGroup( + label=f"{index + 1:02d}线路", + sources=[ + PlaybackSource( + label=f"{index + 1:02d}线路", + playlist=first_route if index == 0 else [], + ) + ], + drive_dir_id=f"local-dir-{index}", + ) + for index in range(10) + ] + load_result = PlaybackLoadResult( + replacement_playlist=first_route, + source_groups=groups, + playlists=[first_route] + [[] for _ in groups[1:]], + drive_resource_id="resource-1", + drive_files_loader=lambda resource_id, dir_id: [ + { + "name": "S01E125.mp4", + "url": "http://atb/p/token/1@185534", + "playId": "1@185534", + }, + { + "name": "S01E126.mp4", + "url": "http://atb/p/token/1@185535", + "playId": "1@185535", + }, + ] if dir_id == "local-dir-6" else [], + ) + groups[6].label = "07外海风云" + groups[6].sources[0].label = "07外海风云" + window = PlayerWindow(FakePlayerController()) + qtbot.addWidget(window) + window.video = RecordingVideo() + window.open_session(session) + + window._apply_playback_loader_result(load_result) + + assert window.session is not None + assert window.session.source_groups[0].sources[0].subgroup_index == 6 + assert window.current_index == 1 + assert window.session.playlist[1].play_id == "1@185535" + + def test_player_window_auto_advance_loads_next_drive_subdirectory(qtbot) -> None: first_dir = [PlayItem(title="01.mp4", url="http://baidu/1.mp4", play_source="第一季")] second_dir: list[PlayItem] = [] @@ -12657,6 +12743,7 @@ def position_seconds(self) -> int: "画面调节", "弹幕配置", "刮削", + "搜索字幕", "弹幕源", "弹幕设置", "视频信息", @@ -13538,6 +13625,7 @@ def position_seconds(self) -> int: "画面调节", "弹幕配置", "刮削", + "搜索字幕", "弹幕源", "弹幕设置", "视频信息", diff --git a/tests/test_spider_plugin_controller.py b/tests/test_spider_plugin_controller.py index d19ab171..461ed502 100644 --- a/tests/test_spider_plugin_controller.py +++ b/tests/test_spider_plugin_controller.py @@ -2181,6 +2181,63 @@ def resolve_danmu(self, page_url: str) -> str: assert len(item.danmaku_candidates) == 1 +def test_controller_auto_resolve_danmaku_searches_and_downloads_default_source() -> None: + """auto_resolve_danmaku is the player window's generic auto-load fallback for + plugin sources (the path taken when opening from playback history, where the + loader-based _maybe_resolve_danmaku does not run for the resumed episode). It + must search sources and download the default, mirroring the generic controller.""" + + class FakeDanmakuService: + def search_danmu_sources( + self, + name: str, + reg_src: str = "", + preferred_provider: str = "", + preferred_page_url: str = "", + media_duration_seconds: int = 0, + ): + return DanmakuSourceSearchResult( + groups=[ + DanmakuSourceGroup( + provider="bilibili", + provider_label="B站", + options=[ + DanmakuSourceOption( + provider="bilibili", + name="凡人修仙传 第127话", + url="https://www.bilibili.com/bangumi/127", + ) + ], + ) + ], + default_option_url="https://www.bilibili.com/bangumi/127", + default_provider="bilibili", + ) + + def resolve_danmu(self, page_url: str) -> str: + return 'ok' + + controller = SpiderPluginController( + PluginLevelDanmakuSpider(), + plugin_name="网盘资源", + search_enabled=True, + danmaku_service=FakeDanmakuService(), + ) + item = PlayItem( + title="第127集 外海风云3", + url="https://stream.example/play/127.m3u8", + media_title="凡人修仙传", + vod_id="/play/127", + ) + + resolved = controller.auto_resolve_danmaku(item) + + assert resolved is True + assert item.selected_danmaku_url == "https://www.bilibili.com/bangumi/127" + assert item.danmaku_xml != "" + assert item.danmaku_search_query == "凡人修仙传 127集" + + def test_controller_research_danmaku_uses_temporary_query_only_for_current_item() -> None: calls: list[str] = [] @@ -7078,3 +7135,68 @@ def test_plugin_metadata_provider_maps_custom_metadata_payload() -> None: assert record.overview == "插件简介" assert record.rating == "9.3" assert record.imdb_id == "tt1234567" + + +def test_extract_episode_label_uses_variety_issue_for_plugin_variety_item() -> None: + # Plugin sources compose the danmaku query from _extract_episode_label; a + # variety filename must yield a date+issue+part label (not "N集"), otherwise + # the service's variety branch has no issue key to match on. + item = PlayItem( + title="2026.08.10-第2期上.mp4", + url="http://host/1", + media_title="心动的信号 第9季", + type_name="真人秀", + ) + + assert controller_module._extract_episode_label(item, [item]) == "20260810 第2期上" + + +def test_extract_episode_label_uses_metadata_type_when_filename_is_generic() -> None: + # Filename carries no 第N期 marker; the 综艺 genre from metadata is what + # routes it onto the variety path. + item = PlayItem(title="2026.08.10.mp4", url="http://host/1", media_title="节目", type_name="综艺") + + assert controller_module._extract_episode_label(item, [item]) == "20260810" + + +def test_extract_episode_label_keeps_episode_numbering_for_dated_drama_files() -> None: + # A bare air date in an ordinary episode filename must NOT trigger the + # variety path (regression guard for dated anime/drama filenames). + item = PlayItem( + title="04-第4话 啥!啥!这是啥啊!-1080P 高码率-HEVC-2026-03-03", + url="http://host/1", + media_title="盗妖行", + ) + + assert controller_module._extract_episode_label(item, [item]) == "4集" + + +def test_metadata_danmaku_preference_pins_provider_from_official_link() -> None: + controller = SpiderPluginController(DanmakuEnabledFakeSpider(), plugin_name="p", search_enabled=True) + item = PlayItem( + title="第1集", + url="http://host/1", + media_title="心动的信号 第9季", + metadata_provider_url="https://v.qq.com/x/cover/mzc002003kpyd2m/w4102gzejm3.html", + ) + + preference = controller._metadata_danmaku_preference(item, "series-key") + + assert preference is not None + assert preference.provider == "tencent" + assert preference.page_url == "https://v.qq.com/x/cover/mzc002003kpyd2m/w4102gzejm3.html" + assert preference.search_title == "" + + +def test_metadata_danmaku_preference_returns_none_without_usable_url() -> None: + controller = SpiderPluginController(DanmakuEnabledFakeSpider(), plugin_name="p", search_enabled=True) + bare = PlayItem(title="第1集", url="http://host/1", media_title="剧") + unknown = PlayItem( + title="第1集", + url="http://host/1", + media_title="剧", + metadata_provider_url="https://example.com/watch/1", + ) + + assert controller._metadata_danmaku_preference(bare, "k") is None + assert controller._metadata_danmaku_preference(unknown, "k") is None diff --git a/tests/test_spider_plugin_loader.py b/tests/test_spider_plugin_loader.py index cbe58efd..02b10d8f 100644 --- a/tests/test_spider_plugin_loader.py +++ b/tests/test_spider_plugin_loader.py @@ -48,6 +48,20 @@ def test_loader_loads_local_plugin_and_installs_base_spider_alias(tmp_path: Path assert loaded.search_enabled is False +def test_loader_exposes_stable_id_from_plain_python_metadata(tmp_path: Path) -> None: + plugin_path = tmp_path / "木偶.py" + plugin_path.write_text( + 'PLUGIN_ID = "02544b320a6d45de997bc0bd3975d0c060b8"\n' + PLUGIN_SOURCE, + encoding="utf-8", + ) + loader = SpiderPluginLoader(cache_dir=tmp_path / "cache") + config = SpiderPluginConfig(id=99, source_type="local", source_value=str(plugin_path)) + + loaded = loader.load(config) + + assert loaded.config.manifest_id == "02544b320a6d45de997bc0bd3975d0c060b8" + + def test_loader_can_defer_plugin_init_until_explicit_initialization(tmp_path: Path) -> None: plugin_path = tmp_path / "懒加载插件.py" plugin_path.write_text( diff --git a/tests/test_spider_plugin_manager.py b/tests/test_spider_plugin_manager.py index 972303d7..14fe66a1 100644 --- a/tests/test_spider_plugin_manager.py +++ b/tests/test_spider_plugin_manager.py @@ -41,6 +41,7 @@ def load( last_loaded_at=config.last_loaded_at, last_error=config.last_error, config_text=config.config_text, + manifest_id=config.manifest_id, ), spider=object(), plugin_name="", @@ -352,6 +353,38 @@ def test_manager_add_local_txt_plugin_overwrites_existing_plugin_with_same_id(tm assert plugins[0].plugin_version == 2 +def test_manager_reads_stable_id_from_plain_python_plugin_metadata(tmp_path: Path) -> None: + plugin_path = tmp_path / "木偶.py" + plugin_path.write_text( + 'PLUGIN_ID = "02544b320a6d45de997bc0bd3975d0c060b8"\nclass Spider: pass\n', + encoding="utf-8", + ) + repository = SpiderPluginRepository(tmp_path / "app.db") + manager = SpiderPluginManager(repository, FakeLoader()) + + manager.add_local_plugin(str(plugin_path)) + + plugin = repository.list_plugins()[0] + assert plugin.manifest_id == "02544b320a6d45de997bc0bd3975d0c060b8" + + +def test_manager_backfills_stable_id_for_existing_plain_python_plugin(tmp_path: Path) -> None: + stable_id = "02544b320a6d45de997bc0bd3975d0c060b8" + plugin_path = tmp_path / "木偶.py" + plugin_path.write_text( + f'PLUGIN_ID = "{stable_id}"\nclass Spider: pass\n', encoding="utf-8" + ) + repository = SpiderPluginRepository(tmp_path / "app.db") + old_plugin = repository.add_plugin("local", str(plugin_path), "木偶") + manager = SpiderPluginManager(repository, FakeLoader()) + + assert manager.backfill_source_metadata() == 1 + + plugin = repository.get_plugin(old_plugin.id) + assert plugin.id == old_plugin.id + assert plugin.manifest_id == stable_id + + def test_manager_import_github_repository_imports_manifest_plugins_and_disables_invalid_entries(tmp_path: Path) -> None: responses = { "https://api.github.com/repos/har01d5/tvbox": httpx.Response( diff --git a/tests/test_storage.py b/tests/test_storage.py index 31f19936..9f57358d 100644 --- a/tests/test_storage.py +++ b/tests/test_storage.py @@ -287,6 +287,7 @@ def test_local_playback_history_round_trip_persists_grouped_source_indexes(tmp_p "episode": 1, "episodeUrl": "https://b2/2.m3u8", "position": 90000, + "duration": 120000, "opening": 5000, "ending": 10000, "speed": 1.25, @@ -294,6 +295,7 @@ def test_local_playback_history_round_trip_persists_grouped_source_indexes(tmp_p "sourceGroupIndex": 1, "sourceIndex": 1, "sourceSubgroupIndex": 2, + "sourceSubgroupName": "第三季", "driveDirId": "season-3", "createTime": 42, }, @@ -304,10 +306,12 @@ def test_local_playback_history_round_trip_persists_grouped_source_indexes(tmp_p history = repo.get_history("spider_plugin", "detail-1", source_key="7") assert history is not None + assert history.duration == 120000 assert history.playlist_index == 3 assert history.source_group_index == 1 assert history.source_index == 1 assert history.source_subgroup_index == 2 + assert history.source_subgroup_name == "第三季" assert history.drive_dir_id == "season-3" @@ -426,6 +430,67 @@ def test_local_playback_history_repository_reads_legacy_spider_plugin_rows_witho assert history.key == "detail-1" assert history.source_key == "" assert history.episode == 1 + assert history.duration == 0 + + +def test_local_playback_sync_state_survives_repository_restart(tmp_path: Path) -> None: + from atv_player.local_playback_history import LocalPlaybackHistoryRepository + + db_path = tmp_path / "app.db" + identity = ("emby", "server", "movie-1") + repo = LocalPlaybackHistoryRepository(db_path) + repo.set_sync_cursor("account-a", 42) + repo.replace_sync_snapshot("account-a", {identity: 100}) + + reloaded = LocalPlaybackHistoryRepository(db_path) + + assert reloaded.get_sync_cursor("account-a") == 42 + assert reloaded.load_sync_snapshot("account-a") == {identity: 100} + assert reloaded.get_sync_cursor("account-b") == 0 + assert reloaded.load_sync_snapshot("account-b") == {} + + +def test_local_playback_history_is_partitioned_by_account(tmp_path: Path) -> None: + from atv_player.local_playback_history import LocalPlaybackHistoryRepository + + repo = LocalPlaybackHistoryRepository(tmp_path / "app.db") + repo.set_active_account("account-a") + repo.save_history("emby", "movie", {"vodName": "A", "position": 100, "createTime": 1}) + + repo.set_active_account("account-b") + assert repo.get_history("emby", "movie") is None + repo.save_history("emby", "movie", {"vodName": "B", "position": 200, "createTime": 2}) + + repo.set_active_account("account-a") + assert repo.get_history("emby", "movie").position == 100 + repo.set_active_account("account-b") + assert repo.get_history("emby", "movie").position == 200 + + +def test_local_playback_scope_delete_preserves_newer_rows(tmp_path: Path) -> None: + from atv_player.local_playback_history import LocalPlaybackHistoryRepository + + repo = LocalPlaybackHistoryRepository(tmp_path / "app.db") + for vod_id, updated_at in (("old", 100), ("new", 300)): + repo.save_history( + "emby", + vod_id, + {"vodName": vod_id, "createTime": updated_at}, + source_key="server", + ) + repo.save_history( + "jellyfin", + "other", + {"vodName": "other", "createTime": 100}, + source_key="server", + ) + + removed = repo.delete_site_history("emby", "server", 200) + + assert removed == [("emby", "server", "old")] + assert repo.get_history("emby", "old", "server") is None + assert repo.get_history("emby", "new", "server") is not None + assert repo.get_history("jellyfin", "other", "server") is not None def test_settings_repository_round_trip(tmp_path: Path) -> None: @@ -2755,3 +2820,28 @@ def test_settings_repository_normalizes_ai_values(tmp_path: Path) -> None: assert saved.ai_api_key == "sk-test" assert saved.ai_chat_model == "gpt-4o-mini" assert saved.ai_request_timeout_seconds == 120 + + +def test_local_playback_history_pending_deletions_round_trip(tmp_path: Path) -> None: + from atv_player.local_playback_history import LocalPlaybackHistoryRepository + + repo = LocalPlaybackHistoryRepository(tmp_path / "app.db") + + assert repo.list_pending_deletions() == [] + + repo.record_pending_deletion("emby", "server", "vod-1", 100) + repo.record_pending_deletion("telegram", "", "vod-2", 200) + # 相同 identity 再次记录应覆盖时间戳。 + repo.record_pending_deletion("emby", "server", "vod-1", 150) + + pending = sorted(repo.list_pending_deletions()) + assert pending == [ + ("emby", "server", "vod-1", 150), + ("telegram", "", "vod-2", 200), + ] + + repo.clear_pending_deletions([("emby", "server", "vod-1")]) + assert repo.list_pending_deletions() == [("telegram", "", "vod-2", 200)] + + repo.clear_pending_deletions([("telegram", "", "vod-2")]) + assert repo.list_pending_deletions() == [] diff --git a/tests/test_subtitle_archive.py b/tests/test_subtitle_archive.py new file mode 100644 index 00000000..bb3a37be --- /dev/null +++ b/tests/test_subtitle_archive.py @@ -0,0 +1,103 @@ +import gzip +import io +import zipfile + +import pytest + +from atv_player.subtitles.archive import ( + decode_subtitle_bytes, + detect_archive, + extract_subtitle, + sniff_suffix, +) +from atv_player.subtitles.errors import ( + SubtitleArchiveError, + SubtitleArchiveUnsupportedError, +) + +SRT_TEXT = "1\n00:00:01,000 --> 00:00:02,000\n你好世界\n" + + +def _zip_bytes(members: dict[str, str]) -> bytes: + buffer = io.BytesIO() + with zipfile.ZipFile(buffer, "w") as archive: + for name, text in members.items(): + archive.writestr(name, text) + return buffer.getvalue() + + +def test_detect_archive_recognizes_formats() -> None: + assert detect_archive(_zip_bytes({"a.srt": SRT_TEXT})) == "zip" + assert detect_archive(gzip.compress(SRT_TEXT.encode())) == "gzip" + assert detect_archive(b"Rar!\x1a\x07\x00rest") == "rar" + assert detect_archive(b"plain text") == "" + + +def test_decode_handles_utf8_gbk_and_big5() -> None: + assert decode_subtitle_bytes("你好世界".encode()) == "你好世界" + assert decode_subtitle_bytes("你好世界".encode("gb18030")) == "你好世界" + assert decode_subtitle_bytes("繁體中文字幕".encode("big5")) == "繁體中文字幕" + + +def test_decode_empty_returns_empty_string() -> None: + assert decode_subtitle_bytes(b"") == "" + + +def test_extract_plain_text_sniffs_suffix() -> None: + content = extract_subtitle(SRT_TEXT.encode()) + assert content.suffix == ".srt" + assert "你好世界" in content.text + + vtt = extract_subtitle(b"WEBVTT\n\n00:01.000 --> 00:02.000\nhi\n") + assert vtt.suffix == ".vtt" + + ass = extract_subtitle(b"[Script Info]\nTitle: x\n") + assert ass.suffix == ".ass" + + +def test_extract_from_zip_prefers_ass_then_simplified() -> None: + data = _zip_bytes( + { + "movie.eng.srt": "english", + "movie.chs.srt": "简体", + "movie.chs.ass": "[Script Info]\n简体样式", + } + ) + content = extract_subtitle(data) + assert content.name == "movie.chs.ass" + assert content.suffix == ".ass" + + +def test_extract_from_zip_prefers_simplified_over_traditional() -> None: + data = _zip_bytes({"movie.cht.srt": "繁體", "movie.chs.srt": "简体"}) + assert extract_subtitle(data).name == "movie.chs.srt" + + +def test_extract_from_zip_skips_macos_junk_entries() -> None: + data = _zip_bytes({"__MACOSX/._movie.srt": "junk", "movie.srt": SRT_TEXT}) + assert extract_subtitle(data).name == "movie.srt" + + +def test_extract_from_zip_without_subtitle_member_raises() -> None: + with pytest.raises(SubtitleArchiveError): + extract_subtitle(_zip_bytes({"readme.txt": "nothing here"})) + + +def test_extract_gzip_unwraps_then_sniffs() -> None: + content = extract_subtitle(gzip.compress(SRT_TEXT.encode())) + assert content.suffix == ".srt" + assert "你好世界" in content.text + + +def test_rar_archive_reports_unsupported() -> None: + with pytest.raises(SubtitleArchiveUnsupportedError): + extract_subtitle(b"Rar!\x1a\x07\x00payload") + + +def test_empty_payload_raises() -> None: + with pytest.raises(SubtitleArchiveError): + extract_subtitle(b"") + + +def test_sniff_suffix_defaults_to_srt() -> None: + assert sniff_suffix("1\n00:00:01,000 --> 00:00:02,000\nhi") == ".srt" diff --git a/tests/test_subtitle_assrt_provider.py b/tests/test_subtitle_assrt_provider.py new file mode 100644 index 00000000..2c899e1f --- /dev/null +++ b/tests/test_subtitle_assrt_provider.py @@ -0,0 +1,211 @@ +import httpx +import pytest + +from atv_player.subtitles.errors import ( + SubtitleProviderError, + SubtitleQuotaExceededError, + SubtitleTokenMissingError, +) +from atv_player.subtitles.languages import CHS_ENG +from atv_player.subtitles.models import SubtitleQuery, SubtitleSearchItem +from atv_player.subtitles.providers.assrt import AssrtSubtitleProvider + +SEARCH = "https://api.assrt.net/v1/sub/search" +DETAIL = "https://api.assrt.net/v1/sub/detail" + + +def _provider(get, token: str = "tok") -> AssrtSubtitleProvider: + return AssrtSubtitleProvider(get=get, token_loader=lambda: token) + + +def test_unavailable_without_token() -> None: + provider = _provider(lambda *a, **k: None, token="") + assert provider.available() is False + with pytest.raises(SubtitleTokenMissingError): + provider.search(SubtitleQuery(title="流浪地球")) + + +def test_short_keyword_is_skipped() -> None: + def fake_get(url: str, **kwargs): + raise AssertionError("should not hit the network") + + assert _provider(fake_get).search(SubtitleQuery(title="ab")) == [] + + +def test_search_parses_language_from_langlist() -> None: + def fake_get(url: str, **kwargs): + assert url == SEARCH + assert kwargs["params"]["token"] == "tok" + assert kwargs["params"]["q"] == "流浪地球" + return httpx.Response( + 200, + json={ + "status": 0, + "sub": { + "subs": [ + { + "id": 602333, + "native_name": "流浪地球", + "videoname": "The.Wandering.Earth.2019", + "subtype": "Subrip(srt)", + "release_site": "个人", + "vote_score": 80, + "lang": { + "desc": "简 英 双语", + "langlist": {"langchs": True, "langeng": True}, + }, + } + ] + }, + }, + ) + + items = _provider(fake_get).search(SubtitleQuery(title="流浪地球")) + + assert len(items) == 1 + assert items[0].subtitle_id == "602333" + assert items[0].language == CHS_ENG + assert items[0].vote_score == 80.0 + + +def test_episode_query_appends_season_episode_and_uses_is_file() -> None: + """剧集查询应拼成 '剧名 S03E06' 并带 is_file=1,对照 bazarr。""" + seen: dict = {} + + def fake_get(url: str, **kwargs): + seen.update(kwargs["params"]) + return httpx.Response(200, json={"status": 0, "sub": {"subs": []}}) + + _provider(fake_get).search(SubtitleQuery(title="方舟号", season=3, episode=6)) + + assert seen["q"] == "方舟号 S03E06" + assert seen["is_file"] == 1 + + +def test_movie_query_appends_year_when_no_episode() -> None: + seen: dict = {} + + def fake_get(url: str, **kwargs): + seen.update(kwargs["params"]) + return httpx.Response(200, json={"status": 0, "sub": {"subs": []}}) + + _provider(fake_get).search(SubtitleQuery(title="流浪地球", year=2019)) + + assert seen["q"] == "流浪地球 2019" + assert seen["is_file"] == 1 + + +def test_quota_error_maps_to_quota_exception() -> None: + def fake_get(url: str, **kwargs): + return httpx.Response(200, json={"status": 30900, "errmsg": "limit"}) + + with pytest.raises(SubtitleQuotaExceededError): + _provider(fake_get).search(SubtitleQuery(title="流浪地球")) + + +def test_other_error_status_raises_provider_error() -> None: + def fake_get(url: str, **kwargs): + return httpx.Response(200, json={"status": 20001, "errmsg": "invalid token"}) + + with pytest.raises(SubtitleProviderError, match="Token 无效"): + _provider(fake_get).search(SubtitleQuery(title="流浪地球")) + + +def test_error_body_is_parsed_even_when_http_status_leaks_error_code() -> None: + """射手网会把错误码漏进 HTTP 状态码(如 492),真实错误在 body 里。""" + + def fake_get(url: str, **kwargs): + return httpx.Response( + 492, json={"status": 30900, "errmsg": "you are exceeding request limits"} + ) + + with pytest.raises(SubtitleQuotaExceededError, match="配额超限"): + _provider(fake_get).search(SubtitleQuery(title="流浪地球")) + + +def test_non_json_error_response_falls_back_to_status_error() -> None: + def fake_get(url: str, **kwargs): + return httpx.Response(492, text="gateway") + + with pytest.raises(SubtitleProviderError, match="492"): + _provider(fake_get).search(SubtitleQuery(title="流浪地球")) + + +def test_subtitle_not_found_error_has_friendly_message() -> None: + def fake_get(url: str, **kwargs): + return httpx.Response( + 200, json={"status": 20900, "errmsg": "subtitle not found"} + ) + + with pytest.raises(SubtitleProviderError, match="已下架"): + _provider(fake_get).search(SubtitleQuery(title="流浪地球")) + + +def _item() -> SubtitleSearchItem: + return SubtitleSearchItem( + provider="assrt", + provider_label="射手网(伪)", + subtitle_id="602333", + name="流浪地球", + ) + + +def test_download_prefers_unpacked_filelist_entry() -> None: + def fake_get(url: str, **kwargs): + if url == DETAIL: + return httpx.Response( + 200, + json={ + "status": 0, + "sub": { + "subs": [ + { + "filename": "pack.rar", + "url": "http://file0.assrt.net/download/602333/pack.rar", + "filelist": [ + {"f": "movie.eng.srt", "url": "http://f/eng.srt"}, + {"f": "movie.chs.ass", "url": "http://f/chs.ass"}, + ], + } + ] + }, + }, + ) + assert url == "http://f/chs.ass" + return httpx.Response(200, content="[Script Info]\n简体".encode()) + + content = _provider(fake_get).download(_item()) + + assert content.suffix == ".ass" + assert "简体" in content.text + + +def test_download_falls_back_to_archive_url_without_filelist() -> None: + def fake_get(url: str, **kwargs): + if url == DETAIL: + return httpx.Response( + 200, + json={ + "status": 0, + "sub": { + "subs": [ + {"filename": "movie.srt", "url": "http://file0/movie.srt"} + ] + }, + }, + ) + assert url == "http://file0/movie.srt" + return httpx.Response( + 200, content="1\n00:00:01,000 --> 00:00:02,000\n你好\n".encode() + ) + + content = _provider(fake_get).download(_item()) + assert "你好" in content.text + + +def test_download_without_detail_rows_raises() -> None: + def fake_get(url: str, **kwargs): + return httpx.Response(200, json={"status": 0, "sub": {"subs": []}}) + + with pytest.raises(SubtitleProviderError): + _provider(fake_get).download(_item()) diff --git a/tests/test_subtitle_languages.py b/tests/test_subtitle_languages.py new file mode 100644 index 00000000..81e50047 --- /dev/null +++ b/tests/test_subtitle_languages.py @@ -0,0 +1,51 @@ +from atv_player.subtitles.languages import ( + CHS, + CHS_ENG, + CHT, + CHT_ENG, + ENG, + OTHER, + ZH, + language_label, + language_rank, + normalize_language, +) + + +def test_simplified_english_bilingual_has_highest_priority() -> None: + ranks = [ + language_rank(CHS_ENG), + language_rank(CHT_ENG), + language_rank(CHS), + language_rank(ZH), + language_rank(CHT), + language_rank(ENG), + language_rank(OTHER), + ] + assert ranks == sorted(ranks) + assert language_rank(CHS_ENG) == min(ranks) + + +def test_normalize_recognizes_bilingual_variants() -> None: + assert normalize_language("简英双语") == CHS_ENG + assert normalize_language("chs&eng") == CHS_ENG + assert normalize_language("繁英双语") == CHT_ENG + assert normalize_language("cht", "English") == CHT_ENG + + +def test_normalize_recognizes_single_language() -> None: + assert normalize_language("简体") == CHS + assert normalize_language("chs") == CHS + assert normalize_language("繁體中文") == CHT + assert normalize_language("big5") == CHT + assert normalize_language("English") == ENG + assert normalize_language("") == OTHER + + +def test_normalize_uses_later_hints_when_first_is_empty() -> None: + assert normalize_language("", "Movie.2020.chs.srt") == CHS + + +def test_language_label_falls_back_for_unknown_code() -> None: + assert language_label(CHS_ENG) == "简英双语" + assert language_label("no-such-code") == "其他" diff --git a/tests/test_subtitle_matcher.py b/tests/test_subtitle_matcher.py new file mode 100644 index 00000000..cd473152 --- /dev/null +++ b/tests/test_subtitle_matcher.py @@ -0,0 +1,104 @@ +from atv_player.subtitles.languages import CHS, CHS_ENG, CHT, ENG +from atv_player.subtitles.matcher import ( + DEFAULT_MATCH_WEIGHTS, + apply_scores, + score_subtitle, +) +from atv_player.subtitles.models import SubtitleQuery, SubtitleSearchItem + + +def _item(**kwargs) -> SubtitleSearchItem: + base = { + "provider": "subdl", + "provider_label": "SubDL", + "subtitle_id": "1", + "name": "Show", + } + base.update(kwargs) + return SubtitleSearchItem(**base) + + +def test_media_id_match_dominates_score() -> None: + query = SubtitleQuery(title="Show", imdb_id="tt1234567") + with_id = _item(name="Show tt1234567 1080p") + without_id = _item(name="Show 1080p") + + assert score_subtitle(with_id, query)[0] > score_subtitle(without_id, query)[0] + + +def test_season_and_episode_match_add_expected_weight() -> None: + query = SubtitleQuery(title="Show", season=2, episode=6) + matched = _item(season=2, episode=6) + mismatched = _item(season=1, episode=1) + + matched_score = score_subtitle(matched, query)[0] + mismatched_score = score_subtitle(mismatched, query)[0] + expected_gap = DEFAULT_MATCH_WEIGHTS.season + DEFAULT_MATCH_WEIGHTS.episode + assert matched_score - mismatched_score == expected_gap + + +def test_episode_can_be_detected_from_release_text() -> None: + query = SubtitleQuery(title="Show", season=2, episode=6) + from_text = _item(name="Show.S02E06.1080p") + structured = _item(name="Show.S02E06.1080p", season=2, episode=6) + assert from_text.episode is None + # 条目本身没有结构化集数,但发布名里有 S02E06,应与结构化字段等价 + assert score_subtitle(from_text, query)[0] == score_subtitle(structured, query)[0] + + +def test_bilingual_simplified_scores_highest_among_languages() -> None: + query = SubtitleQuery(title="Show") + scores = { + code: score_subtitle(_item(language=code), query)[0] + for code in (CHS_ENG, CHS, CHT, ENG) + } + assert scores[CHS_ENG] == max(scores.values()) + assert scores[CHS_ENG] > scores[CHS] > scores[CHT] > scores[ENG] + + +def test_release_attributes_contribute_to_score() -> None: + query = SubtitleQuery( + title="Show", + resolution="2160p", + source="WEB-DL", + codec="H.265", + release_group="GROUP", + ) + same_release = _item(name="Show.2160p.WEB-DL.H.265-GROUP") + other_release = _item(name="Show.720p.HDTV.x264-OTHER") + + assert score_subtitle(same_release, query)[0] > score_subtitle( + other_release, query + )[0] + + +def test_codec_token_matches_across_punctuation_variants() -> None: + query = SubtitleQuery(title="Show", codec="H.265") + assert score_subtitle(_item(name="Show x265"), query)[0] == score_subtitle( + _item(name="Show H265"), query + )[0] + + +def test_percentage_is_bounded_and_reflects_quality() -> None: + query = SubtitleQuery(title="Show", season=1, episode=1) + perfect = _item(name="Show", language="chs_eng", season=1, episode=1) + poor = _item(name="Totally Different", language="other", season=9, episode=9) + + _, perfect_percent = score_subtitle(perfect, query) + _, poor_percent = score_subtitle(poor, query) + assert perfect_percent == 100 + assert 0 <= poor_percent < perfect_percent + + +def test_apply_scores_sorts_descending_and_fills_fields() -> None: + query = SubtitleQuery(title="Show", episode=6) + items = [ + _item(subtitle_id="a", name="Show E01", language=ENG, episode=1), + _item(subtitle_id="b", name="Show E06", language=CHS_ENG, episode=6), + ] + + scored = apply_scores(items, query) + + assert [row.subtitle_id for row in scored] == ["b", "a"] + assert scored[0].score > 0 + assert scored[0].match_percent > scored[1].match_percent diff --git a/tests/test_subtitle_opensubtitles_provider.py b/tests/test_subtitle_opensubtitles_provider.py new file mode 100644 index 00000000..5520789b --- /dev/null +++ b/tests/test_subtitle_opensubtitles_provider.py @@ -0,0 +1,111 @@ +import httpx +import pytest + +from atv_player.subtitles.errors import ( + SubtitleQuotaExceededError, + SubtitleTokenMissingError, +) +from atv_player.subtitles.languages import CHS +from atv_player.subtitles.models import SubtitleQuery, SubtitleSearchItem +from atv_player.subtitles.providers.opensubtitles import OpenSubtitlesProvider + +SEARCH = "https://api.opensubtitles.com/api/v1/subtitles" +DOWNLOAD = "https://api.opensubtitles.com/api/v1/download" + + +def _provider(get=None, post=None, api_key: str = "os-key") -> OpenSubtitlesProvider: + return OpenSubtitlesProvider( + get=get or (lambda *a, **k: None), + post=post or (lambda *a, **k: None), + api_key_loader=lambda: api_key, + ) + + +def test_unavailable_without_api_key() -> None: + provider = _provider(api_key="") + assert provider.available() is False + with pytest.raises(SubtitleTokenMissingError): + provider.search(SubtitleQuery(title="Interstellar")) + + +def test_search_sends_auth_headers_and_parses_files() -> None: + seen: dict = {} + + def fake_get(url: str, **kwargs): + assert url == SEARCH + seen.update(kwargs["headers"]) + return httpx.Response( + 200, + json={ + "data": [ + { + "attributes": { + "language": "zh-CN", + "release": "Interstellar.2014.1080p", + "download_count": 1234, + "ratings": 8.5, + "files": [{"file_id": 987, "file_name": "inter.srt"}], + } + }, + {"attributes": {"language": "en", "files": []}}, + ] + }, + ) + + items = _provider(get=fake_get).search(SubtitleQuery(title="Interstellar")) + + assert seen["Api-Key"] == "os-key" + assert "atv-player" in seen["User-Agent"] + # 没有可下载文件的条目会被跳过 + assert len(items) == 1 + assert items[0].subtitle_id == "987" + assert items[0].language == CHS + assert items[0].download_count == 1234 + + +def test_search_passes_episode_and_season() -> None: + def fake_get(url: str, **kwargs): + assert kwargs["params"]["episode_number"] == 3 + assert kwargs["params"]["season_number"] == 2 + return httpx.Response(200, json={"data": []}) + + _provider(get=fake_get).search(SubtitleQuery(title="Show", episode=3, season=2)) + + +def _item() -> SubtitleSearchItem: + return SubtitleSearchItem( + provider="opensubtitles", + provider_label="OpenSubtitles", + subtitle_id="987", + name="Interstellar", + ) + + +def test_download_posts_file_id_then_follows_link() -> None: + def fake_post(url: str, **kwargs): + assert url == DOWNLOAD + assert kwargs["json"] == {"file_id": 987} + return httpx.Response( + 200, + json={ + "link": "https://dl.opensubtitles.com/x.srt", + "file_name": "x.srt", + }, + ) + + def fake_get(url: str, **kwargs): + assert url == "https://dl.opensubtitles.com/x.srt" + return httpx.Response(200, content=b"1\n00:00:01,000 --> 00:00:02,000\nhi\n") + + content = _provider(get=fake_get, post=fake_post).download(_item()) + + assert content.suffix == ".srt" + assert "hi" in content.text + + +def test_download_without_link_reports_quota() -> None: + def fake_post(url: str, **kwargs): + return httpx.Response(200, json={"message": "download limit reached"}) + + with pytest.raises(SubtitleQuotaExceededError, match="download limit reached"): + _provider(post=fake_post).download(_item()) diff --git a/tests/test_subtitle_release_parser.py b/tests/test_subtitle_release_parser.py new file mode 100644 index 00000000..ef5850bb --- /dev/null +++ b/tests/test_subtitle_release_parser.py @@ -0,0 +1,59 @@ +from atv_player.subtitles.release_parser import parse_release_name + + +def test_parses_full_scene_release_name() -> None: + info = parse_release_name( + "The.Last.of.Us.S02E06.2160p.WEB-DL.DDP5.1.DV.HDR.H.265-GROUP.mkv" + ) + assert info.title == "The Last of Us" + assert info.season == 2 + assert info.episode == 6 + assert info.resolution == "2160p" + assert info.source == "WEB-DL" + assert info.codec == "H.265" + assert info.release_group == "GROUP" + + +def test_parses_movie_with_year() -> None: + info = parse_release_name("Inception.2010.1080p.BluRay.x264-AMIABLE.mkv") + assert info.title == "Inception" + assert info.year == 2010 + assert info.resolution == "1080p" + assert info.source == "BluRay" + assert info.codec == "H.264" + assert info.release_group == "AMIABLE" + assert info.season is None + assert info.episode is None + + +def test_parses_chinese_season_and_episode() -> None: + info = parse_release_name("庆余年 第2季 第06集 1080p.mp4") + assert info.season == 2 + assert info.episode == 6 + assert "庆余年" in info.title + + +def test_parses_separated_season_episode_form() -> None: + info = parse_release_name("Show.Name.S01.E12.720p.HDTV.x265.mkv") + assert info.season == 1 + assert info.episode == 12 + assert info.codec == "H.265" + assert info.source == "HDTV" + + +def test_strips_container_suffix_and_keeps_plain_title() -> None: + info = parse_release_name("流浪地球.mkv") + assert info.title == "流浪地球" + assert info.resolution == "" + + +def test_numeric_trailing_segment_is_not_a_release_group() -> None: + info = parse_release_name("Movie.Name.2019.1080p-1080") + assert info.release_group == "" + + +def test_empty_input_returns_blank_info() -> None: + info = parse_release_name("") + assert info.title == "" + assert info.season is None + assert info.raw == "" diff --git a/tests/test_subtitle_search_ui.py b/tests/test_subtitle_search_ui.py new file mode 100644 index 00000000..c6e8d0d8 --- /dev/null +++ b/tests/test_subtitle_search_ui.py @@ -0,0 +1,414 @@ +from pathlib import Path + +from atv_player.controllers.player_controller import PlayerSession +from atv_player.models import PlayItem, VodItem +from atv_player.subtitles.models import ( + SubtitleContent, + SubtitleProviderGroup, + SubtitleSearchItem, + SubtitleSearchResult, +) +from atv_player.ui.player_window import PlayerWindow + + +class FakePlayerController: + def report_progress(self, *args, **kwargs) -> None: + return None + + def resolve_play_item_detail(self, session, play_item): + return None + + def stop_playback(self, session, current_index: int) -> None: + return None + + +class FakeVideo: + """最小视频桩,避免测试里真的拉起 mpv 去加载网络地址。""" + + def __init__(self) -> None: + self.load_calls: list[tuple[str, bool, int]] = [] + + def load( + self, + url: str, + pause: bool = False, + start_seconds: int = 0, + headers: dict[str, str] | None = None, + poster_image_path: str | None = None, + ) -> None: + del headers, poster_image_path + self.load_calls.append((url, pause, start_seconds)) + + def set_speed(self, speed: float) -> None: + return None + + def set_volume(self, value: int) -> None: + return None + + def position_seconds(self) -> int: + return 0 + + +class FakeSubtitleService: + def __init__(self, result: SubtitleSearchResult) -> None: + self._result = result + self.searched_queries: list[object] = [] + self.downloaded: list[SubtitleSearchItem] = [] + + @property + def provider_order(self) -> list[str]: + return ["subdl", "subhd"] + + def provider_label(self, provider_id: str) -> str: + return {"subdl": "SubDL", "subhd": "SubHD"}.get(provider_id, provider_id) + + def search(self, query, *, provider_filter: str = "") -> SubtitleSearchResult: + self.searched_queries.append(query) + return self._result + + def download(self, item: SubtitleSearchItem) -> SubtitleContent: + self.downloaded.append(item) + return SubtitleContent( + text="1\n00:00:01,000 --> 00:00:02,000\n你好\n", + suffix=".srt", + name="hello.srt", + ) + + +def _item( + subtitle_id: str, *, language: str, percent: int, name: str +) -> SubtitleSearchItem: + labels = {"chs_eng": "简英双语", "eng": "English"} + return SubtitleSearchItem( + provider="subdl", + provider_label="SubDL", + subtitle_id=subtitle_id, + name=name, + language=language, + language_label=labels.get(language, language), + format="srt", + match_percent=percent, + score=percent, + ) + + +def _result() -> SubtitleSearchResult: + return SubtitleSearchResult( + groups=[ + SubtitleProviderGroup( + provider="subdl", + provider_label="SubDL", + items=[ + _item( + "1", + language="chs_eng", + percent=96, + name="Show.S01E02.chs&eng", + ), + _item("2", language="eng", percent=70, name="Show.S01E02.eng"), + ], + ) + ], + errors={"zimuku": "触发了验证码"}, + skipped=["assrt"], + ) + + +def _session(service: FakeSubtitleService | None) -> PlayerSession: + return PlayerSession( + vod=VodItem(vod_id="s1", vod_name="Show"), + playlist=[ + PlayItem( + title="第2集", + original_title="Show.S01E02.1080p.WEB-DL.x265-GRP.mkv", + url="http://example.com/2.mkv", + ) + ], + start_index=0, + start_position_seconds=0, + speed=1.0, + subtitle_search_service=service, + ) + + +def _make_window(qtbot, service: FakeSubtitleService | None) -> PlayerWindow: + window = PlayerWindow(FakePlayerController()) + qtbot.addWidget(window) + window.video = FakeVideo() + window.open_session(_session(service)) + return window + + +def _open_dialog(qtbot) -> tuple[PlayerWindow, FakeSubtitleService]: + service = FakeSubtitleService(_result()) + window = _make_window(qtbot, service) + window._open_subtitle_search_dialog() + qtbot.waitUntil(lambda: bool(window._subtitle_search_items), timeout=3000) + return window, service + + +def test_dialog_autosearches_with_parsed_release_context(qtbot) -> None: + _window, service = _open_dialog(qtbot) + + assert len(service.searched_queries) == 1 + query = service.searched_queries[0] + assert query.title == "Show" + assert query.episode == 2 + # 画质/编码/压制组来自文件名解析,只参与打分,不进搜索关键词 + assert query.resolution == "1080p" + assert query.codec == "H.265" + assert query.release_group == "GRP" + + +def test_manually_entered_media_ids_are_sent_to_providers(qtbot) -> None: + """中文片名在英文站搜不到时,用户填 TMDB/IMDb id 后应按 id 搜。""" + _window, service = _open_dialog(qtbot) + window = _make_window(qtbot, service) + window._open_subtitle_search_dialog() + qtbot.waitUntil(lambda: bool(window._subtitle_search_items), timeout=3000) + + window._subtitle_search_tmdb_id_edit.setText("105923") + window._subtitle_search_imdb_id_edit.setText("tt1234567") + service.searched_queries.clear() + window._start_subtitle_search() + qtbot.waitUntil(lambda: len(service.searched_queries) >= 1, timeout=3000) + + query = service.searched_queries[-1] + assert query.tmdb_id == "105923" + assert query.imdb_id == "tt1234567" + + +def test_tmdb_id_auto_filled_from_scrape_binding(qtbot) -> None: + """刮削绑定到 TMDB 后,打开对话框应自动带上 TMDB id。""" + service = FakeSubtitleService(_result()) + + class FakeBindings: + def load_by_title(self, title): + from atv_player.metadata.bindings import MetadataBinding + + return MetadataBinding( + normalized_title="show", + normalized_year="", + provider="tmdb", + provider_id="105923", + ) + + window = PlayerWindow(FakePlayerController()) + qtbot.addWidget(window) + window.video = FakeVideo() + session = _session(service) + session.metadata_binding_repository = FakeBindings() + window.open_session(session) + window._open_subtitle_search_dialog() + qtbot.waitUntil(lambda: bool(window._subtitle_search_items), timeout=3000) + + assert window._subtitle_search_tmdb_id_edit.text() == "105923" + assert service.searched_queries[0].tmdb_id == "105923" + + +def test_no_token_configured_guides_user_to_set_up_assrt(qtbot) -> None: + """没配任何 Token、免 Token 站又都不可用时,应明确提示去配 ASSRT Token。""" + empty = SubtitleSearchResult( + groups=[], skipped=["subdl", "assrt", "opensubtitles"] + ) + service = FakeSubtitleService(empty) + window = _make_window(qtbot, service) + window._open_subtitle_search_dialog() + qtbot.waitUntil( + lambda: window._subtitle_search_result is not None, timeout=3000 + ) + + status = window._subtitle_search_status_label.text() + assert "射手网" in status or "ASSRT" in status + assert "Token" in status + + + +def test_table_lists_results_with_match_percentage(qtbot) -> None: + window, _service = _open_dialog(qtbot) + table = window._subtitle_search_table + + assert table.rowCount() == 2 + assert table.item(0, 0).text() == "SubDL" + assert table.item(0, 2).text() == "简英双语" + assert table.item(0, 4).text() == "96%" + + +def test_status_reports_failed_and_skipped_sites(qtbot) -> None: + window, _service = _open_dialog(qtbot) + status = window._subtitle_search_status_label.text() + + assert "共 2 条" in status + assert "触发了验证码" in status + assert "未配置 Token 已跳过" in status + + +def test_language_filter_narrows_rows(qtbot) -> None: + window, _service = _open_dialog(qtbot) + combo = window._subtitle_search_language_combo + combo.setCurrentIndex(combo.findData("eng")) + + table = window._subtitle_search_table + assert table.rowCount() == 1 + assert table.item(0, 2).text() == "English" + + +def test_download_attaches_external_subtitle_and_selects_it( + qtbot, tmp_path, monkeypatch +) -> None: + import atv_player.ui.player_window as player_window_module + + saved = tmp_path / "hello.srt" + + def fake_save(content, *, title=""): + saved.write_text(content.text, encoding="utf-8") + return saved + + monkeypatch.setattr(player_window_module, "save_subtitle_file", fake_save) + + window, service = _open_dialog(qtbot) + applied: list[tuple[str, str]] = [] + # 只验证挂载逻辑本身,mpv 侧的刷新在别处已有覆盖 + window._refresh_subtitle_state = lambda *args, **kwargs: None + window._set_primary_subtitle_from_menu = lambda mode, url: applied.append( + (mode, url) + ) + + window._subtitle_search_table.selectRow(0) + window._download_selected_subtitle() + qtbot.waitUntil(lambda: bool(service.downloaded), timeout=3000) + qtbot.waitUntil(lambda: bool(applied), timeout=3000) + + subtitles = window._current_play_item().external_subtitles + assert len(subtitles) == 1 + assert subtitles[0].source == "subtitle-site" + assert subtitles[0].url == str(saved) + assert "简英双语" in subtitles[0].name + # 走的是既有的 external 通道,而不是另起一套加载逻辑 + assert applied == [("external", str(saved))] + assert saved.read_text(encoding="utf-8").startswith("1\n") + + +def test_download_can_target_secondary_slot(qtbot, tmp_path, monkeypatch) -> None: + import atv_player.ui.player_window as player_window_module + + saved = tmp_path / "second.srt" + monkeypatch.setattr( + player_window_module, + "save_subtitle_file", + lambda content, *, title="": (saved.write_text(content.text), saved)[1], + ) + + window, service = _open_dialog(qtbot) + applied: list[tuple[str, str]] = [] + window._refresh_subtitle_state = lambda *args, **kwargs: None + window._set_secondary_subtitle_from_menu = lambda mode, url: applied.append( + (mode, url) + ) + + window._subtitle_search_table.selectRow(0) + window._download_selected_subtitle(secondary=True) + qtbot.waitUntil(lambda: bool(applied), timeout=3000) + + assert applied == [("external", str(saved))] + + +class SubtitleCapableVideo(FakeVideo): + """带字幕能力的视频桩,用于验证下载的字幕真的进了字幕下拉框。""" + + def __init__(self) -> None: + super().__init__() + self.external_loads: list[tuple[str, bool]] = [] + self.applied_modes: list[tuple[str, object]] = [] + self._next_track_id = 100 + + def subtitle_tracks(self) -> list: + return [] + + def audio_tracks(self) -> list: + return [] + + def apply_subtitle_mode(self, mode: str, track_id: object = None) -> None: + self.applied_modes.append((mode, track_id)) + + def apply_secondary_subtitle_mode(self, mode: str, track_id: object = None) -> None: + self.applied_modes.append((f"secondary:{mode}", track_id)) + + def current_subtitle_track_id(self) -> int | None: + return None + + def load_external_subtitle( + self, path: str, *, select_for_secondary: bool = False + ) -> int: + self.external_loads.append((path, select_for_secondary)) + self._next_track_id += 1 + return self._next_track_id + + +def test_downloaded_subtitle_enters_subtitle_combo_and_gets_selected( + qtbot, tmp_path, monkeypatch +) -> None: + """端到端验证复用的是既有外挂字幕通道,而不是另起一套加载逻辑。""" + import atv_player.ui.player_window as player_window_module + + saved = tmp_path / "combo.srt" + monkeypatch.setattr( + player_window_module, + "save_subtitle_file", + lambda content, *, title="": ( + saved.write_text(content.text, encoding="utf-8"), + saved, + )[1], + ) + + service = FakeSubtitleService(_result()) + window = PlayerWindow(FakePlayerController()) + qtbot.addWidget(window) + video = SubtitleCapableVideo() + window.video = video + window.open_session(_session(service)) + window._open_subtitle_search_dialog() + qtbot.waitUntil(lambda: bool(window._subtitle_search_items), timeout=3000) + + window._subtitle_search_table.selectRow(0) + window._download_selected_subtitle() + qtbot.waitUntil(lambda: bool(service.downloaded), timeout=3000) + qtbot.waitUntil( + lambda: bool(window._current_play_item().external_subtitles), timeout=3000 + ) + + # 下拉框里应该出现这条外挂字幕(说明走通了 _refresh_subtitle_state 的重建) + entries = [ + window.subtitle_combo.itemData(index) + for index in range(window.subtitle_combo.count()) + ] + external_urls = [ + getattr(data[2], "url", None) + for data in entries + if isinstance(data, tuple) and len(data) == 3 and data[0] == "external" + ] + assert str(saved) in external_urls + # 并且确实被送进了 mpv 的外挂字幕通道。注意既有实现会把内容另写一份临时文件 + # 再交给 mpv,所以这里比对的是内容而不是路径。 + qtbot.waitUntil(lambda: bool(video.external_loads), timeout=3000) + loaded_path = Path(video.external_loads[0][0]) + assert loaded_path.exists() + assert "你好" in loaded_path.read_text(encoding="utf-8") + assert video.external_loads[0][1] is False + + +def test_context_menu_and_shortcut_expose_subtitle_search(qtbot) -> None: + window = PlayerWindow(FakePlayerController()) + qtbot.addWidget(window) + menu = window._build_video_context_menu() + + assert any(action.text() == "搜索字幕" for action in menu.actions()) + sequences = {shortcut.key().toString() for shortcut in window._shortcut_bindings} + assert "C" in sequences + + +def test_dialog_is_not_built_without_service(qtbot) -> None: + window = _make_window(qtbot, None) + window._open_subtitle_search_dialog() + + assert window._subtitle_search_dialog is None diff --git a/tests/test_subtitle_service.py b/tests/test_subtitle_service.py new file mode 100644 index 00000000..c609b9ce --- /dev/null +++ b/tests/test_subtitle_service.py @@ -0,0 +1,209 @@ +import pytest + +from atv_player.subtitles.errors import ( + SubtitleBlockedError, + SubtitleProviderError, +) +from atv_player.subtitles.languages import CHS, CHS_ENG, ENG +from atv_player.subtitles.models import ( + SubtitleContent, + SubtitleQuery, + SubtitleSearchItem, +) +from atv_player.subtitles.service import ( + SubtitleSearchService, + build_subtitle_query, + episode_of, +) + + +class FakeProvider: + def __init__( + self, + provider_id: str, + *, + label: str = "", + items: list[SubtitleSearchItem] | None = None, + error: Exception | None = None, + available: bool = True, + requires_token: bool = False, + notice: str = "", + ) -> None: + self.provider_id = provider_id + self.label = label or provider_id + self.requires_token = requires_token + self.notice = notice + self._items = items or [] + self._error = error + self._available = available + self.searched = 0 + + def available(self) -> bool: + return self._available + + def search(self, query: SubtitleQuery) -> list[SubtitleSearchItem]: + self.searched += 1 + if self._error is not None: + raise self._error + return list(self._items) + + def download(self, item: SubtitleSearchItem) -> SubtitleContent: + return SubtitleContent( + text="1\n00:00:01,000 --> 00:00:02,000\nhi\n", suffix=".srt" + ) + + +def _item(provider: str, subtitle_id: str, **kwargs) -> SubtitleSearchItem: + base = { + "provider": provider, + "provider_label": provider, + "subtitle_id": subtitle_id, + "name": "Show", + } + base.update(kwargs) + return SubtitleSearchItem(**base) + + +def _service(providers: dict[str, FakeProvider], **kwargs) -> SubtitleSearchService: + return SubtitleSearchService(providers, provider_order=list(providers), **kwargs) + + +def test_providers_without_token_are_skipped_not_errored() -> None: + providers = { + "free": FakeProvider("free", items=[_item("free", "1")]), + "paid": FakeProvider("paid", available=False, requires_token=True), + } + result = _service(providers).search(SubtitleQuery(title="Show")) + + assert result.skipped == ["paid"] + assert result.errors == {} + assert providers["paid"].searched == 0 + assert result.total == 1 + + +def test_one_failing_provider_does_not_break_others() -> None: + providers = { + "good": FakeProvider("good", items=[_item("good", "1")]), + "bad": FakeProvider("bad", error=SubtitleBlockedError("触发了验证码")), + } + result = _service(providers).search(SubtitleQuery(title="Show")) + + assert result.total == 1 + assert "触发了验证码" in result.errors["bad"] + + +def test_results_are_grouped_and_deduped_per_provider() -> None: + providers = { + "a": FakeProvider( + "a", + items=[_item("a", "1"), _item("a", "1"), _item("a", "2")], + ) + } + result = _service(providers).search(SubtitleQuery(title="Show")) + + assert len(result.groups) == 1 + assert [row.subtitle_id for row in result.groups[0].items] == ["1", "2"] + + +def test_bilingual_simplified_ranks_first_across_providers() -> None: + providers = { + "a": FakeProvider("a", items=[_item("a", "1", language=ENG)]), + "b": FakeProvider("b", items=[_item("b", "2", language=CHS_ENG)]), + "c": FakeProvider("c", items=[_item("c", "3", language=CHS)]), + } + result = _service(providers).search(SubtitleQuery(title="Show")) + + assert result.groups[0].provider == "b" + best = result.best_item() + assert best is not None + assert best.language == CHS_ENG + + +def test_provider_filter_limits_search() -> None: + providers = { + "a": FakeProvider("a", items=[_item("a", "1")]), + "b": FakeProvider("b", items=[_item("b", "2")]), + } + result = _service(providers).search( + SubtitleQuery(title="Show"), provider_filter="b" + ) + + assert providers["a"].searched == 0 + assert [group.provider for group in result.groups] == ["b"] + + +def test_disabled_providers_are_excluded_from_order() -> None: + providers = { + "a": FakeProvider("a", items=[_item("a", "1")]), + "b": FakeProvider("b", items=[_item("b", "2")]), + } + service = _service(providers, disabled_provider_ids_loader=lambda: ["b"]) + + assert service.provider_order == ["a"] + result = service.search(SubtitleQuery(title="Show")) + assert providers["b"].searched == 0 + assert [group.provider for group in result.groups] == ["a"] + + +def test_notice_is_carried_into_group() -> None: + providers = { + "assrt": FakeProvider( + "assrt", items=[_item("assrt", "1")], notice="字幕服务由 assrt.net 提供" + ) + } + result = _service(providers).search(SubtitleQuery(title="Show")) + assert result.groups[0].notice == "字幕服务由 assrt.net 提供" + + +def test_download_rejects_unknown_provider() -> None: + service = _service({"a": FakeProvider("a")}) + with pytest.raises(SubtitleProviderError): + service.download(_item("nope", "1")) + + +def test_download_rejects_blank_subtitle_text() -> None: + class BlankProvider(FakeProvider): + def download(self, item): + return SubtitleContent(text=" \n", suffix=".srt") + + service = _service({"a": BlankProvider("a")}) + with pytest.raises(SubtitleProviderError, match="为空"): + service.download(_item("a", "1")) + + +def test_build_query_prefers_explicit_fields_over_filename() -> None: + query = build_subtitle_query( + title="庆余年", + episode=3, + file_name="Joy.of.Life.S02E06.1080p.WEB-DL.x265-GRP.mkv", + imdb_id="tt9999999", + ) + assert query.title == "庆余年" + assert query.episode == 3 + assert query.season == 2 + assert query.imdb_id == "tt9999999" + # 画质等只用于打分,不进搜索关键词 + assert query.resolution == "1080p" + assert query.codec == "H.265" + + +def test_build_query_falls_back_to_filename_parsing() -> None: + query = build_subtitle_query( + file_name="The.Last.of.Us.S02E06.2160p.WEB-DL.H.265-GROUP.mkv" + ) + assert query.title == "The Last of Us" + assert query.season == 2 + assert query.episode == 6 + + +def test_build_query_strips_episode_suffix_from_title() -> None: + query = build_subtitle_query(title="某剧 第12集") + assert "第12集" not in query.title + assert query.episode == 12 + + +def test_episode_of_supports_common_forms() -> None: + assert episode_of("Show.S01E05.1080p") == 5 + assert episode_of("Show EP07") == 7 + assert episode_of("某剧 第12集") == 12 + assert episode_of("") is None diff --git a/tests/test_subtitle_subdl_provider.py b/tests/test_subtitle_subdl_provider.py new file mode 100644 index 00000000..b69822ef --- /dev/null +++ b/tests/test_subtitle_subdl_provider.py @@ -0,0 +1,204 @@ +import httpx +import pytest + +from atv_player.subtitles.errors import ( + SubtitleProviderError, + SubtitleTokenMissingError, +) +from atv_player.subtitles.languages import CHS_ENG, ZH +from atv_player.subtitles.models import SubtitleQuery +from atv_player.subtitles.providers.subdl import SubDLSubtitleProvider + +API = "https://api.subdl.com/api/v1/subtitles" + + +def _provider(get, api_key: str = "key-1") -> SubDLSubtitleProvider: + return SubDLSubtitleProvider(get=get, api_key_loader=lambda: api_key) + + +def test_unavailable_without_api_key() -> None: + provider = _provider(lambda *a, **k: None, api_key="") + assert provider.available() is False + with pytest.raises(SubtitleTokenMissingError): + provider.search(SubtitleQuery(title="流浪地球")) + + +def test_search_sends_expected_params_and_parses_rows() -> None: + seen: list[dict] = [] + + def fake_get(url: str, **kwargs): + assert url == API + seen.append(kwargs["params"]) + return httpx.Response( + 200, + json={ + "status": True, + "subtitles": [ + { + "release_name": "The.Wandering.Earth.2019.1080p", + "name": "wandering.zip", + "url": "/subtitle/111-222.zip", + "language": "ZH", + } + ], + }, + ) + + items = _provider(fake_get).search(SubtitleQuery(title="流浪地球", year=2019)) + + assert seen[0]["film_name"] == "流浪地球" + assert seen[0]["year"] == 2019 + assert seen[0]["unpack"] == 1 + assert seen[0]["api_key"] == "key-1" + assert len(items) == 1 + assert items[0].provider == "subdl" + # 站点只给了笼统的 "ZH",发布名里也没有简繁线索,保持通用中文不臆断 + assert items[0].language == ZH + assert items[0].url == "https://dl.subdl.com/subtitle/111-222.zip" + + +def test_search_marks_tv_and_filters_unpacked_files_by_episode() -> None: + def fake_get(url: str, **kwargs): + assert kwargs["params"]["type"] == "tv" + assert kwargs["params"]["episode_number"] == 2 + assert kwargs["params"]["season_number"] == 1 + return httpx.Response( + 200, + json={ + "status": True, + "subtitles": [ + { + "release_name": "Show.S01", + "url": "/subtitle/1-2.zip", + "unpack_files": [ + { + "name": "Show.S01E01.chs&eng.srt", + "episode": 1, + "language": "ZH", + "format": "srt", + "url": "/subtitle/1/f1", + }, + { + "name": "Show.S01E02.chs&eng.srt", + "episode": 2, + "language": "ZH", + "format": "srt", + "url": "/subtitle/1/f2", + }, + ], + } + ], + }, + ) + + query = SubtitleQuery(title="Show", episode=2, season=1) + items = _provider(fake_get).search(query) + + assert len(items) == 1 + assert items[0].url == "https://dl.subdl.com/subtitle/1/f2" + assert items[0].language == CHS_ENG + assert items[0].format == "srt" + + +def test_search_marks_tv_when_only_season_present() -> None: + """整季搜索(有 season 无 episode)必须按剧集搜,不能误判成电影。""" + seen: dict = {} + + def fake_get(url: str, **kwargs): + seen.update(kwargs["params"]) + return httpx.Response(200, json={"status": True, "subtitles": []}) + + _provider(fake_get).search(SubtitleQuery(title="The Ark", season=3)) + + assert seen["type"] == "tv" + assert seen["season_number"] == 3 + assert "episode_number" not in seen + + +def test_search_prefers_imdb_id_and_strips_tt_prefix() -> None: + seen: dict = {} + + def fake_get(url: str, **kwargs): + seen.update(kwargs["params"]) + return httpx.Response(200, json={"status": True, "subtitles": []}) + + _provider(fake_get).search(SubtitleQuery(title="ignored", imdb_id="tt1234567")) + + assert seen["imdb_id"] == "1234567" + assert "film_name" not in seen + + +def test_search_uses_tmdb_id_when_no_imdb() -> None: + seen: dict = {} + + def fake_get(url: str, **kwargs): + seen.update(kwargs["params"]) + return httpx.Response(200, json={"status": True, "subtitles": []}) + + _provider(fake_get).search(SubtitleQuery(title="ignored", tmdb_id="105923")) + + assert seen["tmdb_id"] == "105923" + assert seen["type"] == "movie" + + + +def test_search_retries_without_language_filter_when_empty() -> None: + calls: list[dict] = [] + + def fake_get(url: str, **kwargs): + params = kwargs["params"] + calls.append(params) + if "languages" in params: + return httpx.Response(200, json={"status": True, "subtitles": []}) + return httpx.Response( + 200, + json={ + "status": True, + "subtitles": [{"release_name": "any", "url": "/subtitle/9.zip"}], + }, + ) + + items = _provider(fake_get).search(SubtitleQuery(title="冷门片")) + + assert len(calls) == 2 + assert "languages" in calls[0] + assert "languages" not in calls[1] + assert len(items) == 1 + + +def test_error_status_raises_with_message() -> None: + def fake_get(url: str, **kwargs): + return httpx.Response(200, json={"status": False, "error": "invalid api key"}) + + with pytest.raises(SubtitleProviderError, match="invalid api key"): + _provider(fake_get).search(SubtitleQuery(title="流浪地球")) + + +def test_download_fetches_absolute_url_and_decodes() -> None: + def fake_get(url: str, **kwargs): + if url == API: + return httpx.Response( + 200, + json={ + "status": True, + "subtitles": [ + { + "release_name": "x", + "name": "x.srt", + "url": "/subtitle/5.srt", + "language": "ZH", + } + ], + }, + ) + assert url == "https://dl.subdl.com/subtitle/5.srt" + return httpx.Response( + 200, content="1\n00:00:01,000 --> 00:00:02,000\n你好\n".encode() + ) + + provider = _provider(fake_get) + item = provider.search(SubtitleQuery(title="x"))[0] + content = provider.download(item) + + assert "你好" in content.text + assert content.suffix == ".srt" diff --git a/tests/test_subtitle_subhd_provider.py b/tests/test_subtitle_subhd_provider.py new file mode 100644 index 00000000..a1355b4d --- /dev/null +++ b/tests/test_subtitle_subhd_provider.py @@ -0,0 +1,247 @@ +import httpx +import pytest + +from atv_player.subtitles.errors import SubtitleBlockedError, SubtitleProviderError +from atv_player.subtitles.languages import CHS_ENG +from atv_player.subtitles.models import SubtitleQuery, SubtitleSearchItem +from atv_player.subtitles.providers.subhd import SubHDSubtitleProvider + +SEARCH_HTML = """ + +
+ 流浪地球 简英双语 + 简体&英文 +
+
+ 流浪地球 繁体 + 繁體 +
+ + +""" + +BLOCKED_HTML = "网站防火墙
请输入验证码后继续访问" + + +def _provider(get=None, post=None) -> SubHDSubtitleProvider: + return SubHDSubtitleProvider( + get=get or (lambda *a, **k: None), + post=post or (lambda *a, **k: None), + ) + + +def test_provider_is_always_available_without_token() -> None: + assert _provider().available() is True + + +def test_search_parses_entries_and_dedupes() -> None: + def fake_get(url: str, **kwargs): + assert url == "https://www.subhd.tv/search/%E6%B5%81%E6%B5%AA%E5%9C%B0%E7%90%83" + return httpx.Response(200, text=SEARCH_HTML) + + items = _provider(get=fake_get).search(SubtitleQuery(title="流浪地球")) + + assert [item.subtitle_id for item in items] == ["1001", "1002"] + assert items[0].language == CHS_ENG + assert items[0].context["detail_url"] == "https://www.subhd.tv/a/1001" + + +def test_blocked_page_raises_blocked_error() -> None: + def fake_get(url: str, **kwargs): + return httpx.Response(200, text=BLOCKED_HTML) + + with pytest.raises(SubtitleBlockedError): + _provider(get=fake_get).search(SubtitleQuery(title="流浪地球")) + + +def test_search_keeps_release_name_anchor_and_episode_fields() -> None: + """卡片里同一个 sid 有两个锚:中文标题 + 带 SxxEyy 的发布名。 + + 只取第一个锚会让所有条目坍缩成同一个标题,季集信息全丢 + (用户总是下到排最前的那一集)。 + """ + html = """ + + + + + """ + + def fake_get(url: str, **kwargs): + return httpx.Response(200, text=html) + + items = _provider(get=fake_get).search(SubtitleQuery(title="方舟一号 第三季")) + + # provider 按页面顺序返回,排序由 matcher 完成 + assert [item.episode for item in items] == [2, 6] + assert items[1].name == "方舟一号 第三季 The.Ark.S03E06.1080p.AMZN.WEB-DL-GROUP" + assert items[1].season == 3 + + from atv_player.subtitles.matcher import apply_scores + + ranked = apply_scores( + items, SubtitleQuery(title="方舟一号 第三季", season=3, episode=6) + ) + assert ranked[0].episode == 6 + + +def test_search_drops_items_with_mismatched_episode() -> None: + html = """ + + + + + + """ + + def fake_get(url: str, **kwargs): + return httpx.Response(200, text=html) + + items = _provider(get=fake_get).search( + SubtitleQuery(title="方舟一号 第三季", season=3, episode=6) + ) + + # 能判定集数且不符的丢弃;判定不出的整季包保留 + assert [item.subtitle_id for item in items] == ["2002", "2003"] + assert items[0].episode == 6 + assert items[1].episode is None + + +def _item() -> SubtitleSearchItem: + return SubtitleSearchItem( + provider="subhd", + provider_label="SubHD", + subtitle_id="1001", + name="流浪地球", + context={"detail_url": "https://www.subhd.tv/a/1001"}, + ) + + +def _resp(url: str, status: int, **kwargs) -> httpx.Response: + """手动构造的 Response 要挂上 request,否则 .cookies 会抛 RuntimeError。""" + response = httpx.Response(status, **kwargs) + response.request = httpx.Request("GET", url) + return response + + +def test_download_walks_prepare_and_down_chain_with_cookies() -> None: + """改版后的下载链路:prepare-download → /down/ → api/sub/down → 直链。 + + prepare 步骤下发的校验 cookie 必须带到后续每一步,否则 /down/ 403。 + """ + calls: list[tuple[str, dict]] = [] + + def fake_get(url: str, **kwargs): + # 记录副本:download 会在响应后原地合并新 cookie + calls.append( + ("GET", {"url": url, "cookies": dict(kwargs.get("cookies") or {})}) + ) + if url == "https://www.subhd.tv/a/1001": + return _resp(url, 200, text="") + if url == "https://www.subhd.tv/down/1001": + return _resp( + url, + 200, + headers={"Set-Cookie": "down_1001=step3token; Path=/api/sub/down"}, + text="", + ) + if url == "https://dl.subhd.me/2026/x.srt": + return _resp( + url, 200, content="1\n00:00:01,000 --> 00:00:02,000\n你好\n".encode() + ) + raise AssertionError(f"意外的请求: {url}") + + def fake_post(url: str, **kwargs): + calls.append( + ("POST", {"url": url, "cookies": dict(kwargs.get("cookies") or {})}) + ) + if url == "https://www.subhd.tv/api/sub/prepare-download": + return _resp( + url, + 200, + headers={"Set-Cookie": "tk_1001=sessiontoken; Path=/"}, + json={"success": True, "url": "/down/1001"}, + ) + if url == "https://www.subhd.tv/api/sub/down": + return _resp( + url, + 200, + json={ + "success": True, + "pass": True, + "msg": "验证通过", + "url": "https://dl.subhd.me/2026/x.srt", + }, + ) + raise AssertionError(f"意外的请求: {url}") + + content = _provider(get=fake_get, post=fake_post).download(_item()) + + assert [step for step, _ in calls] == [ + "GET", # 详情页 + "POST", # prepare-download + "GET", # /down/ 中转页 + "POST", # api/sub/down + "GET", # 直链 + ] + # prepare 下发的 cookie 要带回后续请求 + assert calls[2][1]["cookies"] == {"tk_1001": "sessiontoken"} + assert calls[3][1]["cookies"] == { + "tk_1001": "sessiontoken", + "down_1001": "step3token", + } + # 直链不带 cookie(http_get 只在非空时才传 cookies) + assert calls[4][1]["cookies"] == {} + assert "你好" in content.text + assert content.suffix == ".srt" + + +def test_download_surfaces_site_error_message() -> None: + def fake_get(url: str, **kwargs): + return _resp(url, 200, text="") + + def fake_post(url: str, **kwargs): + if url.endswith("/api/sub/prepare-download"): + return _resp( + url, 200, json={"success": False, "msg": "下载过于频繁,请稍后再试"} + ) + raise AssertionError(f"意外的请求: {url}") + + with pytest.raises(SubtitleProviderError, match="下载过于频繁"): + _provider(get=fake_get, post=fake_post).download(_item()) + + +def test_download_raises_when_down_check_rejected() -> None: + def fake_get(url: str, **kwargs): + return _resp(url, 200, text="") + + def fake_post(url: str, **kwargs): + if url.endswith("/api/sub/prepare-download"): + return _resp( + url, + 200, + headers={"Set-Cookie": "tk_1001=t; Path=/"}, + json={"success": True, "url": "/down/1001"}, + ) + if url.endswith("/api/sub/down"): + return _resp(url, 200, json={"success": False, "msg": "验证失败"}) + raise AssertionError(f"意外的请求: {url}") + + with pytest.raises(SubtitleProviderError, match="验证失败"): + _provider(get=fake_get, post=fake_post).download(_item()) diff --git a/tests/test_subtitle_subsource_provider.py b/tests/test_subtitle_subsource_provider.py new file mode 100644 index 00000000..c880ec36 --- /dev/null +++ b/tests/test_subtitle_subsource_provider.py @@ -0,0 +1,235 @@ +import httpx +import pytest + +from atv_player.subtitles.errors import ( + SubtitleProviderError, + SubtitleTokenMissingError, +) +from atv_player.subtitles.languages import ENG, ZH +from atv_player.subtitles.models import SubtitleQuery +from atv_player.subtitles.providers.subsource import SubsourceSubtitleProvider + +MOVIES_API = "https://api.subsource.net/api/v1/movies/search" +SUBTITLES_API = "https://api.subsource.net/api/v1/subtitles" + + +def _provider(get, api_key: str = "key-1") -> SubsourceSubtitleProvider: + return SubsourceSubtitleProvider(get=get, api_key_loader=lambda: api_key) + + +def _movies_response(*rows): + return httpx.Response(200, json={"data": list(rows)}) + + +def _movie(movie_id="77", title="The Ark", year=2023, alternate=""): + return { + "movieId": movie_id, + "title": title, + "alternateTitle": alternate, + "releaseYear": year, + } + + +def _subtitles_response(*rows): + return httpx.Response(200, json={"data": list(rows)}) + + +def test_unavailable_without_api_key() -> None: + provider = _provider(lambda *a, **k: None, api_key="") + assert provider.available() is False + with pytest.raises(SubtitleTokenMissingError): + provider.search(SubtitleQuery(title="The Ark")) + + +def test_search_by_title_matches_movie_and_parses_items() -> None: + seen: list[tuple[str, dict]] = [] + + def fake_get(url: str, **kwargs): + seen.append((url, kwargs["params"])) + if url == MOVIES_API: + return _movies_response(_movie()) + assert url == SUBTITLES_API + if kwargs["params"]["language"] == "chinese bg code": + return _subtitles_response( + { + "subtitleId": 501, + "link": "/subtitle/501", + "releaseInfo": ["The.Ark.S03E06.1080p.WEB-DL-GROUP"], + "language": "Chinese BG code", + "contributors": [{"id": 9, "displayname": "someone"}], + "uploaderId": 9, + } + ) + return _subtitles_response() + + items = _provider(fake_get).search( + SubtitleQuery(title="The Ark", season=3, episode=6) + ) + + assert seen[0][1]["searchType"] == "text" + assert seen[0][1]["q"] == "the ark" + assert seen[0][1]["season"] == 3 + assert seen[1][1]["movieId"] == "77" + assert seen[1][1]["language"] == "chinese bg code" + assert seen[1][1]["seasonNumber"] == 3 + assert seen[1][1]["episodeNumber"] == 6 + assert seen[2][1]["language"] == "english" + assert len(items) == 1 + assert items[0].provider == "subsource" + # 站内中文统一叫 "Chinese BG code",归一成通用中文 + assert items[0].language == ZH + assert items[0].season == 3 + assert items[0].episode == 6 + assert items[0].url == "https://subsource.net/subtitle/501" + assert items[0].release_site == "someone" + + +def test_search_prefers_imdb_and_falls_back_to_text() -> None: + calls: list[dict] = [] + + def fake_get(url: str, **kwargs): + params = kwargs["params"] + calls.append(params) + if url == MOVIES_API: + if params["searchType"] == "imdb": + return _movies_response() + return _movies_response(_movie()) + return _subtitles_response() + + items = _provider(fake_get).search( + SubtitleQuery(title="The Ark", imdb_id="tt2199999") + ) + + assert calls[0]["searchType"] == "imdb" + assert calls[0]["imdb"] == "2199999" + assert calls[1]["searchType"] == "text" + assert items == [] + + +def test_search_skips_mismatched_title_or_year() -> None: + def fake_get(url: str, **kwargs): + if url == MOVIES_API: + return _movies_response( + _movie(title="Totally Different Show"), + _movie(movie_id="88", year=1999), + ) + raise AssertionError("不应走到字幕查询") + + items = _provider(fake_get).search(SubtitleQuery(title="The Ark", year=2023)) + + assert items == [] + + +def test_search_keeps_season_pack_and_drops_other_episode() -> None: + def fake_get(url: str, **kwargs): + if url == MOVIES_API: + return _movies_response(_movie()) + if kwargs["params"]["language"] != "chinese bg code": + return _subtitles_response() + return _subtitles_response( + # 集数不符 → 丢弃 + { + "subtitleId": 1, + "releaseInfo": ["The.Ark.S03E05.1080p-GROUP"], + "language": "Chinese BG code", + }, + # 整季包(无集数)→ 保留 + { + "subtitleId": 2, + "releaseInfo": ["The.Ark.S03.1080p-GROUP"], + "language": "Chinese BG code", + }, + ) + + items = _provider(fake_get).search( + SubtitleQuery(title="The Ark", season=3, episode=6) + ) + + assert [item.subtitle_id for item in items] == ["2"] + assert items[0].episode is None + + +def test_search_marks_forced_and_hearing_impaired() -> None: + def fake_get(url: str, **kwargs): + if url == MOVIES_API: + return _movies_response(_movie(title="Movie", year=2020)) + return ( + _subtitles_response( + { + "subtitleId": 1, + "releaseInfo": ["Movie.2020.1080p"], + "language": "English", + "foreignParts": True, + }, + { + "subtitleId": 2, + "releaseInfo": ["Movie.2020.1080p"], + "language": "English", + "commentary": "SDH included", + }, + { + "subtitleId": 3, + "releaseInfo": ["Movie.2020.1080p"], + "language": "English", + }, + ) + if kwargs["params"]["language"] == "english" + else _subtitles_response() + ) + + items = _provider(fake_get).search(SubtitleQuery(title="Movie", year=2020)) + + assert [item.language for item in items] == [ENG, ENG, ENG] + forced = {item.subtitle_id: item for item in items} + assert forced["1"].forced is True + assert forced["2"].hearing_impaired is True + assert forced["3"].forced is False + assert forced["3"].hearing_impaired is False + + +def test_success_false_returns_empty() -> None: + def fake_get(url: str, **kwargs): + if url == MOVIES_API: + return _movies_response(_movie()) + return httpx.Response(200, json={"success": False}) + + items = _provider(fake_get).search(SubtitleQuery(title="The Ark")) + + assert items == [] + + +def test_http_error_raises_provider_error() -> None: + def fake_get(url: str, **kwargs): + return httpx.Response(401, json={"error": "API key required"}) + + with pytest.raises(SubtitleProviderError): + _provider(fake_get).search(SubtitleQuery(title="The Ark")) + + +def test_download_uses_subtitle_id_and_decodes() -> None: + def fake_get(url: str, **kwargs): + if url == MOVIES_API: + return _movies_response(_movie()) + if url == SUBTITLES_API: + if kwargs["params"]["language"] != "chinese bg code": + return _subtitles_response() + return _subtitles_response( + { + "subtitleId": 501, + "link": "/subtitle/501", + "releaseInfo": ["The.Ark.S03E06.1080p-GROUP"], + "language": "Chinese BG code", + } + ) + assert url == ("https://api.subsource.net/api/v1/subtitles/501/download") + assert kwargs["params"]["api_key"] == "key-1" + return httpx.Response( + 200, content="1\n00:00:01,000 --> 00:00:02,000\n你好\n".encode() + ) + + provider = _provider(fake_get) + item = provider.search(SubtitleQuery(title="The Ark"))[0] + content = provider.download(item) + + assert "你好" in content.text + assert content.suffix == ".srt" diff --git a/tests/test_subtitle_zimuku_provider.py b/tests/test_subtitle_zimuku_provider.py new file mode 100644 index 00000000..56a4f846 --- /dev/null +++ b/tests/test_subtitle_zimuku_provider.py @@ -0,0 +1,120 @@ +import httpx +import pytest + +from atv_player.subtitles.errors import SubtitleBlockedError, SubtitleProviderError +from atv_player.subtitles.languages import CHS +from atv_player.subtitles.models import SubtitleQuery, SubtitleSearchItem +from atv_player.subtitles.providers.zimuku import ZimukuSubtitleProvider + +SEARCH_HTML = """ + + +
+

流浪地球 繁体

+ 繁體 +
+ + +""" + +# 实测 zimuku.org 命中风控时返回的页面特征 +BLOCKED_HTML = """ +

网站防火墙

+

网站访问认证页面

请输入验证码后继续访问:

+""" + +DETAIL_HTML = '下载字幕' +DOWNLOAD_HTML = ( + '立即下载' +) + + +def _provider(get) -> ZimukuSubtitleProvider: + return ZimukuSubtitleProvider(get=get) + + +def test_provider_is_available_without_token() -> None: + assert _provider(lambda *a, **k: None).available() is True + + +def test_search_parses_detail_links_and_dedupes() -> None: + def fake_get(url: str, **kwargs): + assert url == "https://srtku.com/search" + assert kwargs["params"] == {"q": "流浪地球"} + return httpx.Response(200, text=SEARCH_HTML) + + items = _provider(fake_get).search(SubtitleQuery(title="流浪地球")) + + assert [item.subtitle_id for item in items] == ["12345", "67890"] + assert items[0].language == CHS + assert items[0].context["detail_url"] == "https://srtku.com/detail/12345.html" + + +def test_captcha_page_raises_blocked_error_not_empty_result() -> None: + def fake_get(url: str, **kwargs): + return httpx.Response(200, text=BLOCKED_HTML) + + # 必须明确报"被拦截",否则界面会误报成"没有搜到字幕" + with pytest.raises(SubtitleBlockedError, match="验证码"): + _provider(fake_get).search(SubtitleQuery(title="流浪地球")) + + +def test_blank_keyword_skips_network() -> None: + def fake_get(url: str, **kwargs): + raise AssertionError("不应发起请求") + + assert _provider(fake_get).search(SubtitleQuery(title=" ")) == [] + + +def _item() -> SubtitleSearchItem: + return SubtitleSearchItem( + provider="zimuku", + provider_label="字幕库", + subtitle_id="12345", + name="流浪地球", + context={"detail_url": "https://srtku.com/detail/12345.html"}, + ) + + +def test_download_walks_detail_then_download_page() -> None: + visited: list[str] = [] + + def fake_get(url: str, **kwargs): + visited.append(url) + if url.endswith("/detail/12345.html"): + return httpx.Response(200, text=DETAIL_HTML) + if url.endswith("/dld/12345.html"): + return httpx.Response(200, text=DOWNLOAD_HTML) + return httpx.Response( + 200, content="1\n00:00:01,000 --> 00:00:02,000\n你好\n".encode("gb18030") + ) + + content = _provider(fake_get).download(_item()) + + assert visited == [ + "https://srtku.com/detail/12345.html", + "https://srtku.com/dld/12345.html", + "https://srtku.com/download/abc.zip", + ] + assert "你好" in content.text + + +def test_download_blocked_at_detail_page_is_reported() -> None: + def fake_get(url: str, **kwargs): + return httpx.Response(200, text=BLOCKED_HTML) + + with pytest.raises(SubtitleBlockedError): + _provider(fake_get).download(_item()) + + +def test_download_without_final_link_raises() -> None: + def fake_get(url: str, **kwargs): + if url.endswith("/detail/12345.html"): + return httpx.Response(200, text=DETAIL_HTML) + return httpx.Response(200, text="改版了") + + with pytest.raises(SubtitleProviderError, match="下载链接"): + _provider(fake_get).download(_item())