Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@
- 支持多来源分组、多线路播放列表、自动连播和播放恢复
- 播放列表根据可用字段支持原始顺序、名称、大小、评分和时间排序,并在排序后保持当前播放项
- 支持主字幕、次字幕、外挂字幕、音轨选择、DASH 清晰度切换
- 支持从外部字幕站搜索并加载字幕:内置 `SubDL`、`SubHD`、`字幕库`、`射手网(伪)`、`SubSource`、`OpenSubtitles` 六个来源,按发布文件名解析出片名/季集/画质/片源/编码后并发搜索,并按匹配度排序(简英双语优先)
- YouTube / `yt-dlp` 播放支持默认画质上限、清晰度切换、多音轨切换、外部字幕加载、详情字段回填和短时解析缓存
- 支持弹幕搜索、弹幕来源切换、弹幕渲染设置和缓存
- 支持媒体刮削,可手动搜索并补充影片元数据(海报、简介、评分、演员等),也可自动增强
Expand Down Expand Up @@ -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`
Expand Down Expand Up @@ -160,6 +162,7 @@ uv run atv-player
| `W` | 切换宽屏 |
| `D` | 打开弹幕源 |
| `S` | 打开刮削 |
| `C` | 搜索外部字幕 |
| `Ctrl+D` | 打开弹幕设置 |
| `I` | 显示视频信息 |
| `Ctrl+P` | 返回主窗口 |
Expand Down Expand Up @@ -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` 表中
Expand All @@ -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 浏览器、默认画质、默认字幕、默认音轨、元数据语言、地区、分类配置源和分类缓存)

Expand Down
108 changes: 98 additions & 10 deletions docs/help.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 音轨与清晰度

音轨:
Expand Down Expand Up @@ -966,15 +1031,25 @@ 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`
- **直连规则**:一行一条,匹配的域名不走代理。支持主机名和 CIDR,例如 `localhost` 或 `10.0.0.0/8`
- **代理规则**:留空则代理所有域名;填写后仅匹配域名走代理,例如 `.google.com`
- **覆盖范围**:API 请求、元数据、解析源、弹幕、海报、插件下载、HLS 上游请求、yt-dlp

### 11.6 缓存管理
### 11.7 缓存管理

缓存管理页用于查看和清理本地缓存:

Expand All @@ -998,7 +1073,7 @@ YouTube 标签页包含默认画质、2K+ 编码、默认字幕、默认音轨

清空缓存不会删除登录令牌、插件配置、直播源、收藏、追更和播放历史;这些数据保存在数据目录的数据库中。

### 11.7 日志
### 11.8 日志

- **启用日志记录**:关闭后不再写入新日志,但仍可查看历史日志
- 支持按来源、级别、分类和关键字筛选
Expand Down Expand Up @@ -1213,6 +1288,7 @@ YouTube 标签页包含默认画质、2K+ 编码、默认字幕、默认音轨
| `W` | 切换宽屏 |
| `D` | 打开弹幕源 |
| `S` | 打开刮削 |
| `C` | 搜索外部字幕 |
| `Ctrl+D` | 打开弹幕设置 |
| `I` | 显示视频信息 |
| `Ctrl+P` | 返回主窗口 |
Expand Down Expand Up @@ -1280,7 +1356,19 @@ YouTube 标签页包含默认画质、2K+ 编码、默认字幕、默认音轨
3. 指定单个弹幕提供方重搜。
4. 重新选择候选结果并加载。

### 18.6 直播源没有内容
### 18.6 搜不到字幕或字幕不匹配

先看状态栏的提示,它会区分“这个站失败了”和“没有搜到”:

1. 提示“未配置 Token 已跳过”时,去“高级设置” → “字幕”填上对应的 Key,可用来源会明显变多。
2. 提示“触发了验证码”时,该抓取站暂时不可用,换其他站或稍后再试。
3. 片名解析不准时,直接在对话框顶部改片名再点“搜索字幕”。
4. 结果太杂时,用语言下拉筛选,或指定只搜某一个字幕站。
5. 字幕时间轴对不上,多半是发布版本不同:优先选匹配度高、且发布名里画质和压制组
与当前视频一致的那一条。
6. 只提供 `rar` 压缩包的结果无法使用,换一条即可。

### 18.7 直播源没有内容

优先检查:

Expand All @@ -1289,15 +1377,15 @@ YouTube 标签页包含默认画质、2K+ 编码、默认字幕、默认音轨
- 本地文件路径是否仍然存在
- M3U 或 TXT 格式是否符合示例

### 18.7 EPG 不显示
### 18.8 EPG 不显示

优先检查:

- 是否已填写至少一个有效 EPG URL
- 是否点击过“立即更新”
- 当前频道名称是否能和节目单中的频道名匹配

### 18.8 插件标签不正常或插件报错
### 18.9 插件标签不正常或插件报错

建议操作顺序:

Expand All @@ -1308,7 +1396,7 @@ YouTube 标签页包含默认画质、2K+ 编码、默认字幕、默认音轨

如果是远程插件,先确认插件源码本身可被信任,再继续排查兼容性问题。

### 18.9 刮削搜不到结果或匹配不准
### 18.10 刮削搜不到结果或匹配不准

建议操作顺序:

Expand All @@ -1323,7 +1411,7 @@ YouTube 标签页包含默认画质、2K+ 编码、默认字幕、默认音轨
- TMDB 来源需要填写有效的 TMDB API Key
- 豆瓣来源需要填写豆瓣 Cookie

### 18.10 网络连接问题
### 18.11 网络连接问题

如果部分内容加载失败或超时:

Expand All @@ -1332,15 +1420,15 @@ YouTube 标签页包含默认画质、2K+ 编码、默认字幕、默认音轨
3. 如果使用直连规则,确认目标域名未被误设为直连。
4. 对于 YouTube 等需要特定网络环境的站点,优先确认代理模式和网络可达性。

### 18.11 YouTube 播放受限制
### 18.12 YouTube 播放受限制

如果 YouTube 视频提示需要登录或受地区限制:

1. 在"高级设置" → "YouTube"中选择浏览器提取 Cookie。
2. 确保所选浏览器已登录 YouTube 账号。
3. 如果仍有问题,尝试更换浏览器选项。

### 18.12 Linux 上 YouTube 播放缓冲很浅或频繁卡顿
### 18.13 Linux 上 YouTube 播放缓冲很浅或频繁卡顿

如果同一个 `YouTube` 视频在 Windows 上播放流畅,但在 Linux 上缓存始终只有几 MB 到十几 MB、经常反复缓冲,建议按下面顺序排查:

Expand Down
27 changes: 25 additions & 2 deletions scripts/build_mpv.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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" ]]
}
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
}

Expand Down
61 changes: 45 additions & 16 deletions src/atv_player/api.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
from __future__ import annotations

import hashlib
import logging
import platform
from collections.abc import Callable
Expand Down Expand Up @@ -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
Expand All @@ -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

Expand Down Expand Up @@ -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)),
Expand All @@ -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")
Expand Down
Loading
Loading