From 52451ae4b516f68c5f4d47071a6f111bfc99b76c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=90=B4=E7=92=A8?= <1579649885@qq.com> Date: Wed, 30 Sep 2026 10:16:50 +0800 Subject: [PATCH] chore: setup project and scripts --- .dockerignore | 8 + .gitignore | 3 + Dockerfile | 22 ++ README.md | 487 ++++++++++++++++++++++++++++++++++ compose.yaml | 32 +++ go.mod | 5 + go.sum | 2 + main.go | 8 + scripts/build.bat | 16 ++ scripts/build.sh | 22 ++ scripts/check-cover-retry.cjs | 144 ++++++++++ scripts/docker-build.sh | 8 + scripts/docker-up.sh | 8 + scripts/启动剧库.bat | 20 ++ scripts/启动剧库.command | 18 ++ scripts/启动剧库.sh | 13 + 16 files changed, 816 insertions(+) create mode 100644 .dockerignore create mode 100644 .gitignore create mode 100644 Dockerfile create mode 100644 README.md create mode 100644 compose.yaml create mode 100644 go.mod create mode 100644 go.sum create mode 100644 main.go create mode 100644 scripts/build.bat create mode 100644 scripts/build.sh create mode 100644 scripts/check-cover-retry.cjs create mode 100644 scripts/docker-build.sh create mode 100644 scripts/docker-up.sh create mode 100644 scripts/启动剧库.bat create mode 100644 scripts/启动剧库.command create mode 100644 scripts/启动剧库.sh diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..dd8b27c --- /dev/null +++ b/.dockerignore @@ -0,0 +1,8 @@ +** +!go.mod +!go.sum +!main.go +!internal/ +!internal/** +**/*_test.go +**/*.test.cjs diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..c2ca648 --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +.DS_Store +/bin/ +/dist/ diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..b26c68c --- /dev/null +++ b/Dockerfile @@ -0,0 +1,22 @@ +ARG GO_IMAGE=golang:1.26-bookworm +ARG RUNTIME_IMAGE=debian:bookworm-slim +FROM ${GO_IMAGE} AS build +ENV GOPROXY=https://goproxy.cn,direct GOSUMDB=off +WORKDIR /src +COPY go.mod go.sum ./ +RUN go mod download +COPY main.go ./ +COPY internal ./internal +RUN CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" -o /out/juku . + +FROM ${RUNTIME_IMAGE} +ENV TZ=Asia/Shanghai +RUN apt-get update && apt-get install -y --no-install-recommends ca-certificates ffmpeg tzdata tini curl && rm -rf /var/lib/apt/lists/* && groupadd --gid 1000 juku && useradd --uid 1000 --gid juku --no-create-home juku && mkdir -p /data /downloads && chown juku:juku /data /downloads +COPY --from=build /out/juku /usr/local/bin/juku +USER 1000:1000 +WORKDIR /data +VOLUME ["/data", "/downloads"] +EXPOSE 8999 +HEALTHCHECK --interval=30s --timeout=5s --start-period=20s --retries=3 CMD curl --noproxy '*' --fail --silent --output /dev/null http://127.0.0.1:8999/ || exit 1 +ENTRYPOINT ["/usr/bin/tini", "--", "/usr/local/bin/juku"] +CMD ["-open=false", "-listen", "0.0.0.0:8999", "-data-dir", "/data", "-out", "/downloads", "-ffmpeg", "/usr/bin/ffmpeg"] diff --git a/README.md b/README.md new file mode 100644 index 0000000..e727254 --- /dev/null +++ b/README.md @@ -0,0 +1,487 @@ +# 果果剧库 + +红果专用版,“全部站源”也只包含红果。支持在线点播、观看记录、追剧清单、下载和 Emby 自动同步 / 导出。编译后的程序无需 Go、Node.js 或 Python;网页资源已经嵌入程序;兼容内容可免 FFmpeg 播放,需要解密、封装、转码或下载时自动准备 FFmpeg。 + +[启动](#启动与升级) · [账号](#账号与管理员) · [找剧](#剧库与追剧) · [播放](#在线播放) · [下载](#下载与合并) · [Docker](#docker-部署) · [反向代理](#反向代理) · [Emby](#emby-接入) · [故障排查](#常见问题) · [近期更新](#近期新增功能优化与修复) + +## 启动与升级 + +| 系统 | `dist/` 中的程序 | +| --- | --- | +| Windows x64 | 双击 `juku_windows_amd64.exe` | +| macOS Apple Silicon | `./juku_darwin_arm64` | +| macOS Intel | `./juku_darwin_amd64` | +| Linux x64 | `./juku_linux_amd64 -open=false` | + +macOS / Linux 在程序所在目录执行对应命令;若提示没有执行权限,先执行 `chmod +x 程序文件名`。也可使用 `scripts/` 内对应系统的“启动剧库”脚本。 + +源码运行需要 Go 1.24.1+,在项目根目录执行: + +```bash +go env -w GOPROXY=https://goproxy.cn,direct +go env -w GOSUMDB=off +go run . +``` + +启动及编译脚本会自动将上述设置保存到当前用户的 Go 配置;启动成品时若未安装 Go,会跳过配置。Docker 编译阶段也内置相同设置。 + +默认监听 `0.0.0.0:8999`,本机访问 `http://127.0.0.1:8999`,其他设备访问 `http://运行程序电脑的局域网IP:8999`。防火墙需允许 TCP 8999;`0.0.0.0` 是监听地址。服务器启动可加 `-open=false`,仅本机使用可加 `-listen 127.0.0.1:8999`。 + +第一次启动自动创建配置及管理员,随机初始密码显示在启动控制台。默认允许匿名使用。管理员、登录和注册设置见下一节。 + +程序应放在可写目录。独立程序默认在自身目录保存数据;源码运行或项目内的 `dist/` 程序默认使用项目根目录。显式指定 `-data-dir` 或 `-config` 时,相对路径以启动命令的工作目录为准。 + +升级时退出旧程序、替换对应程序并重启,再刷新浏览器;源码修改后也需要重启 `go run .`。保留原 `data/` 和下载目录。Docker 升级需重新构建镜像并沿用原数据卷。 + +## 账号与管理员 + +首次默认创建管理员 `admin`,密码随机生成并只在首次启动时输出;首次登录必须改密,完成前不能进入剧库或操作业务接口。普通重启沿用原账号和密码。 + +可以自行指定管理员账号和密码,启动参数同样适用于可执行文件: + +```bash +go run . -admin-user owner -admin-password 'replace-with-your-own-password' +``` + +只有自行指定了密码才免除首次强制改密;仅修改管理员名称仍会生成随机密码。用户名为 2–32 个中文、字母、数字、下划线或短横线,大小写不敏感;密码为 10–128 个字符。环境变量 `JUKU_ADMIN_USER`、`JUKU_ADMIN_PASSWORD` 也可设置,显式启动参数优先。 + +登录管理员后,从顶部“更多 → 用户管理”打开独立管理页面,也可从账号弹窗进入。用户管理集中设置访问策略、创建用户、搜索用户和保存权限;账号弹窗保留登录、退出和个人改密。 + +| 设置或操作 | 默认与行为 | +| --- | --- | +| 必须登录才能使用 | 默认关闭。开启后未登录访问跳转 `/login`,业务接口也要求登录 | +| 允许注册账号 | 默认开启。关闭后隐藏注册入口并拒绝注册请求,只能由管理员建号 | +| 创建普通用户 | 设置用户名、初始密码、可用站源和是否仅在线观看,新用户即可登录 | +| 可用站源 | 仅提供红果,管理员和普通账号均不能访问其他站源 | +| 仅在线观看 | 默认关闭。开启后隐藏下载页、批量下载、卡片与榜单下载、下载画质和 Emby 导出,并由服务端禁止相关接口 | +| 服务设置 | 代理、下载目录、并发、连接检测与 FFmpeg 管理仅管理员可修改 | + +访问开关保存后立即生效,重启保留。忘记管理员密码时,停止服务,用 `-admin-password` 指定新密码后重启;修改管理员名称仍保留原账号记录,修改凭据会撤销旧登录。显式密码参数或环境变量会在每次启动时重新应用,网页改密后应同步更新或移除旧值。 + +账号权限保存后,服务端对后续请求立即校验,当前登录无需退出;页面在下一次请求检测到权限变化时自动重载。站源限制同时覆盖剧库、搜索、榜单、推荐、播放、个人记录、任务和导出。仅在线观看账号可正常收藏和续播,原下载合集的观看记录会改走在线播放;已开始的共享下载仍由管理员或有下载权限的账号管理。 + +匿名访客和自行注册的新账号默认可用全部允许的站源及下载功能。需要严格按账号分配权限时,在“用户管理”开启“必须登录”,关闭“允许用户自行注册”,再由管理员建号。 + +观看记录、续播位置、收藏、手动已看和新集已读状态按账号保存,同一账号换设备登录后继续使用。匿名记录按浏览器 Cookie 隔离,不按 IP 区分;同一浏览器的多个标签页共用记录,其他浏览器、配置或设备分别保存。清除 Cookie 或换访问主机名会建立新的匿名身份;需要跨设备续播时使用账号。 + +注册时可选择接入当前浏览器的匿名记录;登录已有账号后也可手动接回,账号已有的同剧记录优先。退出登录会建立新的空匿名身份,账号记录保留,其他设备不受退出影响。改密会撤销该账号其他设备的登录。禁用 Cookie 时会提示开启,不会退回共享历史。 + +剧库、下载任务和视频文件由服务共享,展示和操作遵循当前账号权限。每个账号或匿名身份最多保存 500 部观看记录、1000 条追剧标记。 + +升级前的共享记录原样保留。需要接回时,查看 `data/viewer-legacy-code`,登录希望保留记录的账号,在“更多 → 代理 / 设置 → 个人记录”输入恢复码。旧记录只归属一个身份,原文件保留,恢复失败可重试。 + +## 剧库与追剧 + +主导航提供“剧库、追剧、下载”;仅在线观看账号隐藏“下载”。桌面使用顶栏,手机使用底部导航。“用户管理”和“代理 / 设置”统一收在顶部“更多”菜单,其中用户管理仅管理员可见。海报直接播放,剧名打开详情,卡片可直接收藏、下载;“批量管理”用于勾选和批量下载。主题可选浅色、深色或跟随系统。 + +“更新剧库”同时负责查新、继续加载历史目录和分批补齐旧资料,保留已有条目及分页位置;当前缓存数量不代表源站总量。新剧的附加文字资料异步补充,旧缓存由手动更新分批处理,缺失日期、热度或集数保持未知。 + +“更多 → 站源状态”中,每个可用站源都有独立的“更新”按钮,只检查该入口的目录和资料,不改变当前列表筛选。面板会显示更新进度、数量和错误;执行中的剧库任务会暂时禁用更新按钮,避免重复提交,完成后恢复。已有内容仍可使用,失败后可再次手动更新。 + +搜索框即时筛选本地剧库。在“红果”或“全部站源”下,输入时会显示红果官网的搜索联想并高亮匹配文字;可点击候选词,或用上下方向键选择后回车搜索,Esc 收起列表。联想采用 300 毫秒防抖,中文输入法选字期间不请求、不触发联网搜索;短期缓存、取消旧请求和过期响应检查可减少重复请求。联想暂不可用时仍可点击联网搜索或按回车显式搜索红果。清空搜索框后可查看最近搜索,账号的站源权限同样适用于联想。 + +正式联网搜索先显示红果名称检索及官网搜索取得的结果,再按同系列已验证季号的缺口分批补查,边查边显示;按剧集 ID 去重,保留不同季和同名不同 ID 的剧集。请求 50 条时上游仍可能只返回 20 个候选,因此不以单批不足 50 条作为结束条件。联想框最多显示 10 个词的限制不用于正式搜索。标题匹配忽略标点、空白、大小写和全角差异,也支持“普通弓箭手 第五季”这样的分词筛选。默认按相关度展示,标题命中优先,同系列按自然季号排序;手动选择热度、日期等排序时仍按所选方式显示。分类筛选隐藏联网结果时会提示,并可一键取消分类筛选。某一路检索失败会保留另一路的有效结果并允许重试。官网当前没有验证可用的搜索分页,搜索总数不代表已取得全部结果,宽泛关键词可换完整剧名继续查找。最新补季及分批返回行为见[红果搜索补季记录](docs/hongguo-search-seasons-2026-09-21.md),此前记录见[红果搜索完整性记录](docs/hongguo-search-completeness-2026-09-18.md)。 + +可按名称、上线日期、热度、播放量排序;季号使用自然顺序,未知值置后。榜单保留源站顺序和名次,兼容红果网页新旧数据格式与分页。红果推荐支持真人剧、漫剧、AI 剧,点击“继续推荐”翻页;它使用公开分类推荐,不提供红果官方账号登录或个性化推荐同步。 + +“追剧”包含想看、在看、已看。收藏和手动已看独立于真实播放进度;取消收藏不会删除历史。新集提示比较更新后的剧库集数与原基准,不会自动定时更新全库。“历史记录”支持搜索、单条删除和清空。追剧列表显示剧库中的海报,小屏也保留;缺失或加载失败时回退为剧名首字,封面地址修复后同步更新。 + +果果剧库仅接受红果条目、任务和播放入口,保留专用来源校验及四种红果真实榜单。运行数据与多站源“果果剧库”分别保存。 + +点击剧名打开详情,或点击封面、播放按钮观看时,都会在后台更新当前剧集的资料,即使海报正常、排序字段已经齐全也会检查。可用的剧名、简介、集数、状态、分类、标签、热度、日期和海报地址会合并保存,并同步更新剧库、详情、追剧和榜单;站源没有提供的字段保留原值或未知。重复点击共享请求,成功或上游返回失败后 5 分钟内复用检查结果,客户端连接失败可在 30 秒后重试;资料失败不阻塞打开详情或播放。普通读取和页面重载不会主动扫描历史缓存。 + +海报缺失可通过“更新剧库”补齐历史地址;加载失败后,再次播放或打开详情会立即重试当前剧集的失败海报,剧库、详情和追剧入口均适用,不受资料检查的五分钟冷却限制。重试只更新本地请求标记,保留源站签名地址和剧库记录。补齐资料只读取源站文字和地址字段。封面校验地址是否来自当前账号可访问的剧库记录,来源请求头按站源生成,无需手填图床域名名单。已登记来源的图床被系统 DNS 解析为 Fake-IP 或其他非公网地址时,使用备用 DNS 重解析一次,只连接校验通过的公网 IP,并保留原域名的 Host、TLS 校验及代理配置;重定向到内网和字面内网 IP 仍拒绝。 + +本次通过纯文字夹具和拦截图片的浏览器场景验证,详见[海报点击重试说明](docs/cover-retry-2026-09-20.md)。更新源码后须重启 `go run .`;使用 `dist/` 时须重启新程序并刷新网页。 + +封面地址更新后,旧页面的海报请求也会按剧集 ID 和当前权限读取最新地址,避免把地址变化误判成 403。403 拒绝记录为 `cover.denied`,502 记录为 `cover.failed`,包含图床域名和可用的剧集 ID,详见[封面旧链接修复说明](docs/cover-source-2026-09-21.md)。 + +## 在线播放 + +剧库播放只观看,不创建下载任务。播放器支持每页 30 集、输入跳集、画质、倍速、暂停、自动下一集和全屏。实际播放后约每 10 秒保存进度,暂停、切集、关闭及进入后台时补记;再次打开按分集 ID 和位置续播,手动选集从该集开头播放。 + +| 操作 | 行为 | +| --- | --- | +| 空格 | 播放 / 暂停 | +| ← / → 短按 | 后退 / 快进 5 秒 | +| ↑ / ↓ | 调节音量;系统不允许网页调音量时使用设备音量键 | +| 播放时长按 → 或视频画面 | 临时 3 倍速,松开、失焦或暂停后恢复原速 | +| F / Esc | 网页播放器全屏 / 逐层返回 | +| 手机向上 / 向下滑动视频画面 | 切到下一集 / 上一集,到首尾集时提示边界 | + +手机播放器按视频实际宽高识别横竖屏,保留完整画面和原比例。横向视频在竖屏时提供“全屏观看”,顶部旋转按钮可随时手动切换;横屏采用红果 App 参考布局:顶部返回、剧名和更多,底部播放 / 暂停、下一集、时间、进度、倍速、画质与选集,选集和设置从右侧展开。选集打开时缩小左侧画面,保持视频可见。横屏播放时操作栏约 3 秒后收起,点画面显示或隐藏;返回按钮先退出全屏,再回到竖屏或返回剧库。切换方向不重开视频或会话,保留进度、暂停状态与倍速。 + +手机横竖屏视频都可点击底部全屏按钮,也可使用“更多 → 全屏播放”。支持网页容器全屏时保留网页控件、选集和弹幕;iPhone 等只支持视频全屏的浏览器使用系统播放器,退出后回到当前网页播放位置。视频未准备好时暂禁用全屏,失败显示重试提示;进入和退出全屏不重新请求片源或重开播放会话。iOS 原生全屏由系统管理方向与控件,网页弹幕和选集需要退出全屏后操作。 + +自动旋转默认开启,可在播放器“更多”中关闭。网页跟随屏幕方向变化;浏览器支持方向感应时,也能在屏幕未旋转时跟随手机姿态。需要授权的浏览器通过“更多 → 启用方向感应”开启;感应受 HTTPS、浏览器和设备权限限制,不可用时仍可手动旋转。竖屏点击画面播放 / 暂停;横竖屏都支持上下滑动切集和长按临时加速,横向滑动、多指、拖动进度或滚动选集不会误切集。桌面原生控件保留自身操作。 + +播放会话每 20 秒保活,请求超过 8 秒会结束等待并在 5 秒后重试;有效取流和观看进度上报也会延长会话。无活动 10 分钟后释放后台资源;切回页面、恢复网络或浏览器返回时立即检查,需要时自动重新连接同一剧、同一集和刚才的位置,并保留暂停、倍速、画质。短时间内最多自动恢复两次,持续失败会保留手动重试入口;登录失效或权限不足需要先处理账号状态。关闭播放器立即释放播放资源。 + +在线播放默认选当前可解析的最高兼容画质,选项来自本集实际返回的片源;桌面只有一档时隐藏选择器,手机显示实际可用选项。切换时保留进度、暂停状态和倍速。输出保留源分辨率,不把低画质片源放大。 + +“优先原始 MP4 / HLS 播放”暂默认关闭,使用原有兼容播放链路;已保存的设置保持不变,之前开启过的用户可在“播放与资源限制”中关闭并保存。开启后,兼容的 MP4 直接交给视频元素,HLS 在 iOS 等支持的浏览器中走原生播放,其他支持 MSE 的浏览器使用程序内置的 hls.js,无需外部脚本 CDN。新链路的暂停、续播和跳转不因位置非零而自动启动视频转码;HLS 保留原始时长、初始化片段、密钥、字节范围及分片格式。 + +需要解密或换封装时优先复制视频码流;仅音频不兼容时只转为 AAC,视频转码作为最后回退。该类封装以整集连续处理生成真实分片索引,完成后提供完整时间轴和任意跳转,准备上限为 90 秒,首播可能需要等待。分片时长按实际关键帧生成,不再假定每片固定两秒。初始化文件与分片统一写入独立缓存目录;若 FFmpeg 已结束但输出索引不完整,自动释放缓存并尝试兼容播放。源站拒绝、网络失败、取消和资源额度限制仍按原错误处理。 + +桌面和手机显示当前播放位置之后实际可连续播放的缓存秒数,手机进度条同时标出已缓存范围。兼容 MSE 播放以约 90 秒为前向目标,倍速时最多提高到 180 秒,结合约 48 MiB 的压缩数据预算动态收缩;暂停也继续缓冲。出现浏览器缓存配额不足时,先释放已播放部分,再重试尚未写入的数据。原始 HLS 使用 hls.js 时保留有界退避重试,并使用 60–180 秒的前向目标;原生 MP4 / HLS 的浏览器缓存长度由浏览器控制。目标不会强制等待填满后才起播,实际长度仍受码率、分片大小、网络和浏览器内存限制。 + +管理员在“更多 → 代理 / 设置 → 播放与资源限制”调整以下设置,保存后重启保留: + +| 设置 | 默认值与用途 | +| --- | --- | +| 优先原始 MP4 / HLS 播放 | 暂默认关闭,使用原有兼容播放链路;可手动开启 | +| 允许尝试客户端直连的站源 | 全部关闭;按站源开启后,检查编码、源站鉴权、协议与跨域条件,通过才返回 302,客户端失败退回原样中转 | +| 播放会话 / 上游媒体连接上限 | 4 / 8;网页会话与 Emby 当前请求共用额度,已结束的探测不占播放名额 | +| 解密 / 换封装、音频转码、视频转码并发 | 2 / 1 / 1;分别限制,可设为 0 禁用对应处理 | +| 下一集预备并发 | 1;设为 0 禁用后台预备 | +| 封装缓存 | 512 MB;关闭播放后释放,可按磁盘容量调整 | + +客户端直连需同时开启“优先原始 MP4 / HLS 播放”和相应站源的直连选项;只勾站源不生效。它是允许尝试,不能保证每集直连:需要服务端解密或转换、源站要求额外鉴权、HTTPS 页面访问 HTTP 视频、MP4 不支持 Range、HLS 跨域或探测失败时会使用服务器中转。当前 HLS 探测最多检查 256 个地址、8 份清单,总上限 8 秒,源站不支持 HEAD 或检查超限也可能保守回退。成功时播放计划的 `delivery` 为 `redirect`,网关返回 302;浏览器连接失败会自动回退。 + +免转码减少 CPU 开销,原样中转仍消耗 VPS 视频流量;只有客户端成功直接取源站视频才能省下这部分流量。302 会向客户端提供源站临时地址,账号权限变更后已发出的源站地址可能在到期前继续有效,因此默认关闭。 + +“预缓存下一集”默认开启并记住开关。原始播放链路在当前集已有安全缓存余量、预计剩余观看时间不超过 60 秒时,仅提前解析下一集地址;兼容链路在当前集缓存完整、剩余观看时间不超过 45 秒时准备一集有界媒体缓存。当前播放优先使用资源,必要时取消尚未接管的后台预备;切到已预备的分集后恢复前台处理能力。原生 HLS 兼容链路提前准备约 48 秒的前向分片窗口,并限制缓存字节数。预备不记观看历史、不创建下载任务,关闭播放器时释放,可按需要关闭。 + +播放与下载统一使用 HTTP 重定向后的清单地址解析相对链接,保留主清单、独立音轨、密钥和初始化文件的正确路径。缓冲优化参考及验证范围见 [2026-09-18 反馈修复记录](docs/feedback-fixes-2026-09-18.md)。 + +红果文字弹幕默认开启,在播放设置中可关闭;按原始时间分段加载,暂停、倍速、跳转和切集保持同步,失败不影响播放。普通评论尚未接入,原生视频全屏可能无法显示网页弹幕。 + +从下载合集播放时,已有完整文件优先本地读取;未完成的分集只在播放到它时继续对应下载,同时在线缓冲。关闭播放器后已触发的下载继续,可在下载页暂停。默认会话上限为 4,管理员可调整;Emby 仅在解析、取流请求进行时占位,请求结束立即释放,同账号同一集的并发请求共用名额。解析结果保留 10 分钟便于复用,转码缓存按上限回收;再次读取已回收缓存的有效分片链接会自动重新准备。 + +| 播放入口 | 视频从哪里读取 | +| --- | --- | +| 下载页的“播放全集” | 读取已登记的本地合并文件,原分集已删除也可播放,保留完整文件的续播位置 | +| 下载页的合集 / 分集“播放” | 下载任务已完成且 MP4 文件仍存在时,直接读取服务所在电脑、VPS 或容器上的文件,不向源站取视频 | +| 下载合集中的未完成或缺失分集 | 地址可用时在线缓冲并继续对应下载;缺少有效地址时提示更新合集 | +| 剧库封面 / 播放按钮 | 默认在线播放;若该剧上次从下载合集观看且仍有权限,会尝试回到合集续播。不会仅因磁盘已有下载就自动切为本地播放 | + +本地文件仍由服务端通过网页播放接口传给浏览器,兼容 MP4 原样传输,仅在格式或编码需要时封装或转码;手机从 VPS 收流仍会使用 VPS 出站带宽。播放器“更多”中的说明可确认当前集显示“本集播放本地已完成文件”还是在线缓冲。 + + +### Artplayer 接入评估 + +已核对 [Artplayer 官方包 5.4.0](https://www.npmjs.com/package/artplayer/v/5.4.0) 与类型定义,许可为 MIT。它提供 `proxy` 复用现有视频元素、`customType` 接入自定义媒体管道、清晰度菜单、事件和插件接口;可随程序内嵌资源发布。 + +| 接入范围 | 工作量与处理方式 | +| --- | --- | +| 基础播放与控件 | 较低:复用当前视频元素和播放接口,接入暂停、倍速、音量和全屏 | +| 完整保留现有行为 | 中等:对接账号隔离续播、服务端画质切换、MSE / iOS HLS、会话恢复、预缓存、弹幕、手机旋转及上下滑动,避免两套控件重复处理事件 | +| 推进建议 | 先提供可选播放器,验证桌面与 iOS,再决定默认界面;后台取流、权限和媒体处理继续复用现有实现 | + +本节是可行性评估。当前发布版仍使用现有播放器;服务器转码负载、上游访问限制和原生全屏弹幕能力仍由对应链路决定。 + +## 下载与合并 + +仅对有下载权限的账号和允许匿名使用的访客显示;管理员可在“用户管理”给普通账号启用“仅在线观看”。 + +详情、批量栏、榜单和下载页的“新任务画质”共用当前浏览器的下载偏好,与在线播放画质独立。 + +| 下载画质 | 选择方式 | +| --- | --- | +| 最高可用(默认) | 每集选择当前可解析的最高兼容档位 | +| 指定档位 | 优先该档;缺档时选择不高于目标的最高档,若全部更高则保留其中最低档 | + +下载使用 `-c copy` 保留源音视频编码和分辨率,不重新编码成 720p,也不生成源站没有的 1080p。竖屏 720p 通常为 720×1280,1080p 为 1080×1920。画质选择随任务保存,暂停、重试、重启和补新集继续沿用;修改默认值只影响后续新任务,不重下已有视频。 + +下载页支持状态筛选、合集或分集批量暂停、继续、取消和重试。“更新本剧”只补新增或本地缺失分集。“清理任务”删除任务记录,保留已下载文件;运行中的任务先停止。暂停后继续会重下当前未完成分集,不是跨重启的字节级断点续传。 + +同名不同 ID 使用不同目录。下载先写 `.part.mp4`,校验成功后生成 `001.mp4` 等最终文件,未知时长或大小不虚报进度。下载完成与源站“剧情完结”是不同状态。 + +合并要求分集连续且文件存在,输出如 `剧名1-17.mp4`。自动选择数量最多的兼容视频格式与音轨格式,数量相同时优先总时长更长的一组。兼容内容直接复制;时间基准或封装差异只无损换封装;通常只处理不兼容的少数音轨。多数为 HE-AAC / HE-AACv2 且音轨配置混杂时,将整部剧的音轨统一为 AAC-LC,避免使用不支持的 HE-AAC 编码目标;同质 HE-AAC 继续复制。视频仍独立判断,只有编码或尺寸不兼容的少数分集需要转视频,保持画面比例并补边。缺音轨会补静音,已有声音不会因多数分集无声而被丢弃。下载仍保留原码流,不提前强制转码。 + +页面提交后在后台逐部合并,刷新、关页或请求断开不会中断任务,可在下载合集查看进度或“取消合并”;重复提交复用当前任务,失败保留已达到的进度。服务重启后未完成任务明确标为中断,可重新提交。原有 curl 请求仍可等待最终结果,JSON 增加 `"async": true` 则立即返回 HTTP 202。开始处理前检查所有分集的转换参数;合并后核对时长并完整解码校验,只解码、不重新编码,校验也需要 CPU 和时间。所需编码器不可用或校验失败时保留原分集。“合并成功后删除分集文件”默认关闭,仅主动启用且校验成功才删除。 + +合并成功后,下载页显示“播放全集(起止集数)”,直接读取本地合并文件,支持拖动进度和续播;只合并部分分集时按实际范围显示。原分集删除或任务记录清理后,只要合并文件与合并记录仍在即可播放,文件丢失会明确报错。兼容 MP4 直接传给浏览器,无需开启原始在线播放优化;其他编码按浏览器能力选择兼容链路。全集播放仍遵循账号的站源与下载权限。 + +默认保存到运行程序电脑上的 `短剧下载/`。管理员可在下载页“更改目录”,保存后重启生效;已有合集的任务、补新集和合并继续使用原路径,不自动移动视频。也可用 `-out "./我的下载"` 或 `JUKU_OUTPUT_DIR`;持续携带 `-out` 会覆盖页面保存值。本机访问可调用系统文件夹选择器,远程或无桌面环境手动填写服务器路径。 + +“更多 → 代理 / 设置 → 按站源分类保存”默认关闭,保存后立即应用于新剧。开启后,新下载保存为 `下载根目录/红果/剧名/001.mp4`,Emby 自动同步保存为 `Emby输出目录/红果/剧名 [ID摘要]/Season 01/S01E001.strm`,NFO 和本地海报放在同一剧集目录。来源取自已有剧集来源字段;同名的不同剧集 ID 使用独立目录,避免目录冲突。旧下载和已有 Emby 目录不搬迁,开关切换、补集、改名、清理任务或重启后仍按已登记归属复用原位置。配置项为 `download.groupBySource`,不要删除下载目录中的 `.drama-id` 或 Emby 目录中的 `.juku-emby.json`。 + +## 代理与 FFmpeg + +管理员在“更多 → 代理 / 设置”操作,桌面和手机使用同一入口。代理与并发保存后立即生效,已开始的连接不强制中断。 + +| 设置 | 默认 | 范围或说明 | +| --- | --- | --- | +| 下载并发 | 2 | 1–32 | +| 剧库请求并发 | 2 | 1–16,同一来源的元数据请求串行 | +| 同站请求间隔 | 500 毫秒 | 100–60000 毫秒,不限制媒体分片 | +| 代理 | 自动 | 环境变量优先,再查 macOS / Windows 系统静态代理;没有则直连 | + +可选择直连或手动 HTTP、HTTPS、SOCKS5 / SOCKS5h 代理;Linux 自动模式读取环境变量,不解析 PAC。站点请求、媒体、密钥和 FFmpeg 内部转发使用应用的代理配置。修改系统代理后重新保存自动模式可刷新检测。 + +FFmpeg 自动查找顺序为 PATH、项目 `bin/`、程序目录和已验证的自动版本。不兼容的自动候选会跳过;显式 `-ffmpeg`、`JUKU_FFMPEG` 或 `download.ffmpeg` 路径有误时提示更正。可将 Windows 的 `ffmpeg.exe` 或 macOS / Linux 的 `ffmpeg` 放入 `bin/`,后者需可执行权限。 + +所有候选都检查 H.264/AAC 编码器并执行短合成转码,通过后才显示就绪。未找到可用版本时,后台下载固定发布 `eugeneware/ffmpeg-static b6.1.1`,校验 SHA-256,保存至 `bin/<系统>-<架构>/verified-b6.1.1/`。支持 macOS Intel / Apple Silicon、Windows x64、Linux x64;下载约 18–29 MiB,离线环境需预先提供完整 FFmpeg。 + +自动准备失败后可调整路径或代理,点击“重新检查 FFmpeg”。设置与日志显示最终使用路径。Windows 修改 PATH 后需新开终端重启。分发 FFmpeg 时保留构建包自带许可和必要源码信息,发布来源为 。 + +## Docker 部署 + +需要 Docker Engine/Desktop 和 Compose v2,在项目根目录执行: + +```bash +docker compose up -d --build +docker compose logs --tail=100 app +``` + +也可运行 `./scripts/docker-up.sh`。默认访问 `http://宿主机IP:8999`,日志可查看首次随机管理员密码。镜像本机构建,默认名称 `guoguo-juku:local`;内置 FFmpeg、证书与时区,以 UID/GID 1000 运行。 + +可在 Compose 的 `.env` 设置: + +```dotenv +JUKU_PORT=8999 +JUKU_ADMIN_USER=admin +JUKU_ADMIN_PASSWORD=replace-with-your-own-password +``` + +省略密码变量则首次随机生成。显式密码每次启动生效,网页改密后同步更新或移除旧变量。`JUKU_BIND_ADDRESS=127.0.0.1` 可仅开放宿主本机;`JUKU_IMAGE` 更改镜像名。 + +配置、账号、记录、缓存和日志放在 `/data`,视频放在 `/downloads`;Compose 命名卷带 `guoguo-juku` 项目前缀。`docker compose down` 保留卷,加 `-v` 会删除卷。升级再次执行 `docker compose up -d --build`,保留原卷。 + +NAS 或宿主目录映射可改为 `/srv/guoguo-juku/data:/data`、`/srv/guoguo-juku/downloads:/downloads`,提前创建目录并让 UID/GID 1000 可写,也可配置实际运行用户。网页中填写容器路径 `/downloads`,而非宿主路径;启动参数固定此挂载点。 + +容器内的 `127.0.0.1` 指向容器本身。使用宿主代理可设置 `JUKU_PROXY_URL=http://host.docker.internal:7897`,宿主代理需允许容器访问;也可填写容器可达的局域网代理。拉取基础镜像的代理在 Docker 自身配置,独立于应用代理。 + +1Panel 导入 Compose 时,构建目录必须指向完整源码根目录,包含 `Dockerfile`、`go.mod`、`main.go`、`internal/`;多站源版还需要 `go.sum`。只粘贴 Compose 内容时,将 `build.context` 改为服务器上的源码绝对路径。新版显式使用 `pull_policy: build`,本地镜像名不会再被误当成 Docker Hub 的已发布镜像拉取。 + +如果日志出现 `failed to resolve source metadata`,且 `docker.1panel.live/.../manifests/...` 返回 `403 Forbidden`,失败发生在拉取 Go / Debian 基础镜像时,尚未复制或编译源码。到 1Panel 的 Docker 镜像加速设置移除失效地址,改用当前网络可访问的镜像服务或 Docker Hub;更改 Docker 配置后按面板提示使其生效。先确认下面两条成功,再重新构建,增加源码文件或设置 `GOPROXY` 无法修复该 403: + +```bash +docker pull golang:1.26-bookworm +docker pull debian:bookworm-slim +docker compose up -d --build +``` + +也可在 Compose 环境变量 / `.env` 中指定 `JUKU_GO_IMAGE` 和 `JUKU_RUNTIME_IMAGE`,填入自有或已验证可访问仓库中的完整 Go / Debian 镜像名。默认分别为 `golang:1.26-bookworm`、`debian:bookworm-slim`;`scripts/docker-build.sh` 同样支持这两个环境变量。这里只切换基础镜像地址,不改变 Go 依赖代理。 + +`./scripts/docker-build.sh` 可单独构建,并接受 `--platform linux/arm64` 等 Docker 参数。构建上下文排除运行数据、视频、便携 FFmpeg 和 `dist/`。本次环境没有 Docker 引擎,未在真实容器运行新版;已有本地代理、FFmpeg 和合成媒体回归。 + +## 反向代理 + +建议使用独立域名的根路径,账号登录通过 HTTPS。Nginx 位置配置示例,`proxy_pass` 换成实际后端: + +```nginx +location / { + proxy_pass http://127.0.0.1:8999; + proxy_http_version 1.1; + proxy_set_header Host $http_host; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header Connection ""; + proxy_buffering off; + proxy_cache off; + proxy_read_timeout 300s; + proxy_send_timeout 300s; +} +``` + +透传 Cookie、Set-Cookie、Host 和查询参数,不为登录、账号、个人记录及媒体接口设置公共缓存。保留 `X-Forwarded-Proto`,HTTPS 时账号 Cookie 才能正确附加 Secure。接口还返回 `X-Accel-Buffering: no`,提示及时转发媒体。 + +网页资源和 API 使用 `/assets/`、`/api/` 根路径,不能直接挂在任意子目录下。无需向公网映射 FFmpeg 使用的内部随机端口。403 查看来源与 Host,401 查看登录与 Cookie,410 查看会话是否过期,502/504 同时查看网关和应用日志。 + +## Emby 接入 + +支持自动同步到已有的 Emby 电视剧媒体库,**不需要先下载视频,也不必逐部下载 ZIP**。同步生成 STRM、NFO 和可用的本地海报;播放时有完整的本地分集就优先读取,否则重新解析在线播放地址,不把会过期的源站媒体地址写入文件。 + +1. 管理员登录后打开“更多 → Emby 同步”,开启自动同步。 +2. 填写媒体库输出目录和 **Emby 服务器能访问的剧库地址**,例如 `http://192.168.1.10:8999`。目录是程序所在电脑或容器的路径。 +3. 选择“我的追剧”和 / 或“下载完成的合集”,保存后自动加入这些剧集,后续按间隔检查并补入新集。无需手动操作每一部剧。 +4. 在 Emby 中创建或选择“电视剧”媒体库,将这个输出目录加入媒体库,并启用 NFO 元数据读取。程序会保存本地海报;展开“通知 Emby 扫描(可选)”填写 Emby 地址与 API Key 后,还可自动通知扫描并更新 Emby 中的海报。不填写时由 Emby 自行监控或扫描文件。 + +| 设置 | 行为 | +| --- | --- | +| 自动同步 | 默认关闭;启用后由服务器持续执行,关闭网页不影响任务,重启后继续使用已保存设置 | +| 输出目录 | 默认是当前下载目录下的 `Emby` 子目录;Docker 默认 `/downloads/Emby` | +| 我的追剧 | 使用保存设置的管理员账号的“在看”和“想看”;不会收集其他用户的观看记录 | +| 下载完成的合集 | 自动纳入有下载完成记录或成功合并记录的合集,并同步可获取的分集及合并版;此操作不会发起视频下载 | +| 检查间隔 | 默认 60 分钟,可设 5–1440 分钟;新增收藏、下载或合并完成会安排检查;每批最多 50 部,剩余内容一分钟后继续,总范围最多 1000 部 | +| Emby 服务器 / API Key | 可选,本地 STRM、NFO、海报均可独立生成。填入后通知扫描,并向 Emby 提交签名海报 URL,需要 Emby 管理权限;密钥仅保存在服务端,网页不回显 | +| 立即同步 | 重新检查已选范围;已有分集编号保持稳定,内容未变的文件不重写,也不重复通知扫描 | + +Docker 中需要让两个容器看到同一份导出文件。例如剧库的 `/downloads` 映射到宿主机 `/srv/juku/downloads`,Emby 挂载宿主机 `/srv/juku/downloads/Emby` 到自己的 `/media/short-dramas`,再将后者加入电视剧媒体库。容器内路径可以不同,共享的宿主目录必须相同;命名卷也可以共享。填写剧库地址时不要使用 Emby 容器无法访问的 `localhost`。两个程序都需要相应的目录读写 / 读取权限。 + +海报同步无需按剧名到 TMDB 匹配。自动同步将海报按原格式保存为剧集目录下的 `poster.jpg`、`poster.png`、`poster.webp` 或 `poster.gif`,按内容摘要确认归属,保留自定义海报。每批最多处理 20 部,本地保存失败后 5 分钟重试。封面地址缺失或取图失败不阻塞 STRM / NFO,可更新剧集资料后再同步。 + +配置 Emby API 后,程序使用自己的剧集标识和导出目录识别 Emby 条目,支持站源子目录及两端不同的 Docker 挂载路径;存在重复目录时停止该剧海报同步并提示。首次扫描尚未完成时先显示“海报等待扫描”,随后自动补齐;远端同步失败按 1、2、5、10、15 分钟退避重试,重启后继续。 + +已有的手动海报或其他刮削器海报会保留;仅已确认由本程序同步的海报随源站封面地址更新,地址未变不会反复下载。用户在 Emby 中更换海报后,程序会保留该修改;需要改用剧库海报时,在 Emby 删除当前海报,再点“立即同步”。面板分别显示分集和海报状态。 + +自动同步和手动 ZIP 的 NFO 都包含签名海报 URL;自动同步另行保存本地图片,手动 ZIP 仍只包含文字文件。Emby 自身不读取 NFO 中的海报 URL,仅解压 ZIP 时需自行补图或启用自动同步。签名海报链接不依赖网页登录 Cookie,会检查导出账号当前的站源及下载权限,并复用网页海报的代理、来源头和格式处理;通过链接取图时需保持本程序运行。 + +合并成功后,自动同步和手动导出会增加 `Season 00/S00E001.strm` 及 NFO,在 Emby 中显示“合并版(第 N–M 集)”特别篇,普通分集保留。链接直接读取已登记的本地合并文件,支持 Range / HEAD 和拖动,不依赖源站、FFmpeg 或原始播放开关。只剩合并文件和合并记录时仍可同步;源站分集查询失败也可导出合并版。开启“按站源分类保存”后,新剧的分集、特别篇、NFO 与海报共用来源目录,已有剧继续原目录。 + +自动同步只维护自己生成并登记的剧集目录;同名的手动目录、其他文件以及暂时从站源消失的已有分集均保留。移出所有同步范围或关闭功能会停止后续更新,不自动删除已导入的文件。单部剧更新失败不覆盖旧文件,面板会列出失败原因;其他剧继续处理。它不自动下载新视频,也不回写 Emby 的用户或观看进度。 Emby 播放同样遵循原始播放开关,开启后优先原始媒体与本地文件;若 Emby 服务端跟随 302 后再转发给终端,视频流量仍由 Emby 所在机器承担。播放期间保持本程序运行,需要媒体处理时由程序调用 FFmpeg。 + +手动导出仍可使用:打开剧集详情,点击“导出 Emby”,将 ZIP 解压到电视剧媒体库并扫描;这种方式需要有新集时重新导出。手动导出和自动同步都遵循账号权限。登录账号导出的链接绑定当前账号,自动同步链接绑定启用功能的管理员;链接不依赖网页登录 Cookie,每次播放和读取分片检查账号当前站源与下载权限。普通账号改为仅在线观看或收回相应站源后,对应链接失效。匿名导出和旧版 STRM 使用独立签名,开启必须登录不会撤销这类旧链接。 + +签名密钥为 `data/emby-key`,同步设置与进度为 `data/emby-sync.json`,备份时一并保留。剧库访问地址变更后,保存自动同步设置即可重写已选范围的链接,手动 ZIP 需要重新导出。需要撤销全部旧链接时,移走密钥文件并重启,再重新同步或导出。分享 STRM 即分享对应分集的访问权限。 + +## 配置、数据与备份 + +```text +data/ +├── juku.json 代理、并发、下载目录等配置 +├── playback-settings.json 播放优化、直连站源与资源限制 +├── accounts.json 账号、站源与下载权限、登录状态、访问策略 +├── viewer-key 浏览器身份签名密钥 +├── viewers/ 各账号或匿名身份的观看记录和追剧清单 +├── library.json 剧库缓存与红果分页位置 +├── ui-state.json 下载、画质偏好、暂停和合并状态 +├── emby-key 首次导出后生成的签名密钥 +└── logs/app.log 自动轮转的诊断日志 +短剧下载/ 视频目录,可自定义 +``` + +备份整个 `data/` 和需要保留的视频,尤其不要漏掉 `accounts.json`、`viewer-key`、`viewers/` 与 `emby-key`。密码采用加盐派生存储,不保存明文。两项目各用自己的数据目录,不能因同步源码而混用账号、记录或配置。 + +配置优先级为:内置默认值 < `data/juku.json` < `JUKU_*` 环境变量 < 显式启动参数。常用配置示例: + +```json +{ + "version": 1, + "download": {"directory": "短剧下载", "concurrency": 2, "groupBySource": false}, + "network": {"proxy": "auto", "requestConcurrency": 2, "requestIntervalMs": 500} +} +``` + +可选站源覆盖为 `sources.hongguo.baseURL`,专用版不接受其他站源配置。`download.ffmpeg` 可覆盖 FFmpeg 路径。通常使用页面设置即可,无需手改协议配置。 + +首次升级可导入旧配置及缓存,已有新数据不覆盖,原文件保留;损坏配置明确报错,不自动清空。分享程序时仅提供程序和本说明,避免包含自己的数据、配置、账号或视频。 + +## 命令行与编译 + +常用启动参数: + +| 参数 | 用途 | +| --- | --- | +| `-listen 地址`、`-open=false` | 调整监听地址、不自动打开浏览器 | +| `-admin-user`、`-admin-password` | 创建或更新管理员凭据 | +| `-data-dir 目录`、`-out 目录` | 数据目录、下载目录 | +| `-ffmpeg 路径`、`-c 数量` | FFmpeg、下载并发 | +| `-pages 数量` | 每频道扫描页数,默认 50;红果为 App 每批页数 | +| `-config 文件` | 首次导入旧配置,日常无需指定 | +| `-help` | 查看完整参数 | + +默认启动网页,命令行模式需加 `-ui=false`: + +```bash +go run . -ui=false -mode list +go run . -ui=false -mode list -refresh +go run . -ui=false -mode list -more +go run . -ui=false -mode search -keyword 关键词 +go run . -ui=false -mode id -id 'hongguo:实际剧ID' +``` + +`-refresh` 更新缓存,`-more` 从红果分页位置继续并补一批资料,两者不能同时使用。`search` 先筛选本地剧库并下载匹配条目;网页端联网搜索合并红果官网搜索与名称检索。`-mode all` 会下载当前剧库全部条目,先确认磁盘容量与范围。 + +```bash +go build -o dist/juku . +bash scripts/build.sh +``` + +构建脚本生成 macOS Intel / Apple Silicon、Linux amd64、Windows amd64 四个平台程序;Windows 的 `scripts\build.bat` 只构建 Windows x64。前端无需额外构建步骤,修改页面后必须重新编译程序。 + +## 常见问题 + +| 问题 | 当前处理与排查方法 | +| --- | --- | +| Docker 有封面和集数,却不能播放 | 已修复 FFmpeg 继承代理变量后,把程序内部 `127.0.0.1` 媒体请求错误送往外部代理的问题;应用访问源站仍使用所设代理。需要重建镜像。列表成功不代表视频 CDN 可达,其他原因结合日志判断 | +| 红果提示榜单格式或分页变化 | 已兼容新旧页面数据载体,修复真人剧、AI 剧及翻页;升级后重新加载榜单,不必清空剧库 | +| VPS 播放卡、CPU 高 | 原始 MP4/HLS 播放暂默认关闭;手动开启后兼容内容免转码,解密或换封装保留视频,仅必要时转码。查看 `playback.plan` / `playback.mode` 的处理方式和 CPU,在播放资源设置限制并发;CPU 低但缓冲需检查线路、代理和带宽,免转码中转仍使用 VPS 流量 | +| 准备播放失败:媒体分片未完整生成 | 已统一缓存目录中的相对输出路径;FFmpeg 结束后分片仍不完整时自动尝试兼容链路,日志记录 `playback.fallback`。这类错误本身不代表需要代理;仍失败时结合 `playback.failed` 中的缺失文件、源站响应、站源和集数排查 | +| macOS 缓存很短、下一集开始卡顿 | 已增加兼容 MSE 的前向缓存、暂停缓冲与配额恢复;切集接管预缓存后恢复前台能力,当前集优先取得资源,原生 HLS 提前准备分片。升级后重启服务并刷新页面,查看实际缓存秒数。已验证 macOS 14.4 的 Chrome / WebKit,macOS 12.6 尚需实机验证;原生媒体的浏览器缓存长度由浏览器决定 | +| 关闭原始播放后,HLS 下一集提示分片 404 | 已统一兼容播放、下载与原始播放的重定向地址处理,相对分片、音轨、密钥和初始化文件按最终清单地址解析 | +| 合并提示无法转为 AAC HE-AACv2 | 混合 AAC 配置时统一音轨为 AAC-LC,兼容视频保持复制;同质 HE-AAC 无需转换。失败保留原分集和已达到的进度 | +| 下载只有 720p | 选择“最高可用”或指定 1080p,查看 `download.media` 中实际和可用画质。下载保留原码流;源站只提供兼容 720p 时不会生成真正的 1080p。此前在线播放的 720p 缩小上限也已移除 | +| Windows 报 `Unrecognized option 'preset'` | 查看实际选中 FFmpeg 路径。自动模式会跳过不兼容版本,显式路径需改为具备 libx264/AAC 的完整版本,或设为 `ffmpeg` 恢复自动查找;修改 PATH 后重开终端 | +| 中途 `unexpected EOF` | 已知长度和范围时自动有限续读,默认每个响应最多两次;资源变化、不支持 Range 或持续失败仍报错。点击“重试播放”,结合发生时间和集数查看日志 | +| 播放会话已过期 | 已补上心跳超时重试、前台 / 网络恢复检查和按原位置自动续接,并延长闲置回收时间;持续失败检查登录、权限和反代连接,更新程序后重新打开页面 | +| 登录后又跳回登录页 / 记录看似消失 | 保持同一访问地址,允许 Cookie;检查反代是否透传 Cookie 和 Set-Cookie。匿名身份清除 Cookie 后不会自动认领旧记录,同一账号登录可恢复账号记录 | +| 无法访问局域网服务 | 检查监听地址、宿主端口、防火墙和无线客户端隔离;远程设备不要访问自己的 `127.0.0.1` | +| Docker / mihomo TUN 下封面报“指向本机、内网或保留网络” | Fake-IP 或私网解析可能触发地址校验。两版客户端均默认接入备用 DNS,异常结果会换用另一服务,成功后固定连接公网 IP 并保留代理;Docker 的内网 DNS 服务器地址本身不触发检查。更新程序或保留数据卷重建镜像;仍失败时查看 `cover.failed` 中的 IP 和备用 DNS 错误,详见[封面 DNS 修复说明](docs/cover-dns-2026-09-19.md) | +| Emby 播放 STRM 提示没有兼容的流 / servererror | 这是 Emby 的通用提示。旧版存在扫描后占满播放名额的缺陷,升级后同集复用,探测结束释放占位。保留 `/data` 卷重建镜像,再在 Emby 重试播放或刷新媒体信息;仍失败时,确认 STRM 地址能从 Emby 服务器 / 容器访问,并查看同一时间的 `emby.playback_failed` 与 FFmpeg 日志。429 表示当前并发达到设置上限,403 检查签名密钥与账号权限,502 检查源站取流和媒体处理;不要仅凭该提示判断编码不兼容 | +| Emby 剧集有了但没有海报 | 自动同步会保存本地 poster 图片,无需 API Key;查看输出目录和逐剧海报状态,取图失败会重试。API 配置可通知扫描并同步远端海报;只解压 ZIP 无法靠 NFO 海报 URL 自动补图 | +| Emby 缺少合并全集 | 在原同步范围内点击“立即同步”,或重新导出 ZIP;合并版位于 `Season 00/S00E001.strm` 特别篇。新合并完成会自动安排补入,保留合并文件及其记录 | +| Emby 没有自动出现新剧 | 先在“更多 → Emby 同步”启用并选好范围;检查输出目录是否与 Emby 共享、扫描是否开启,面板显示同步与通知错误。仅手动导出 ZIP 不会自动更新 | +| 下载目录不能写入 | 填写服务所在电脑或容器的路径并检查运行用户权限;修改目录后重启,旧任务仍使用原路径 | + +诊断日志默认 `data/logs/app.log`,单份 5 MiB,另保留两份备份;Docker 内为 `/data/logs/app.log`。日志隐藏凭证和 URL 签名,排查时记录剧名、集数、时间、浏览器及相关文字日志即可。 + +```bash +tail -n 100 data/logs/app.log +docker compose logs --tail=100 app +docker compose exec app tail -n 100 /data/logs/app.log +docker stats --no-stream +``` + +Windows PowerShell 可用 `Get-Content .\data\logs\app.log -Tail 100`。`ffmpeg.failed` 是准备或能力失败,`media.failed` 是视频连接或读取失败,`playback.failed` 是播放过程失败;`playback.plan` 记录原始播放、换封装、只转音频及传输方式,`playback.fallback` 记录输出不完整后的兼容回退,`playback.mode` 记录旧兼容链路的封装或转码,`download.media` 记录请求档位、可用档位与实际画质。旧版未保存的日志无法补回。 + +## 近期新增功能、优化与修复 + +2026-09-23 已同步 `../短剧库APP` 差异核对中的红果通用修复,详见[APP 差异核对与 TODO](docs/app-parity-2026-09-23.md)。果果剧库仍只允许红果来源,“全部站源”也只包含红果;本轮同步红果榜单持久缓存、实时分类与分类游标、状态筛选、更新停止 / 阶段提示及通用媒体修复,未加入黄果、黄豆、剧果、野果、帝果入口,未构建或替换 `dist/`。 + +2026-09-23 稍早同步修正两个 Web 版的 iOS 竖屏全屏入口:补入视频原生全屏、退出事件与状态同步,手机使用独立容器承载网页全屏,不再对 dialog 请求全屏。该次仅修改源码和本 README,未构建或替换 `dist/` 程序;iPhone / iPad 真机、系统全屏退出、不同片源及其他浏览器行为均待集中验证。 + +截至 2026-09-19,下列已实现内容已归入正式目录,并更新 `dist/` 程序。两版同步范围和验证见[双项目同步记录](docs/dual-project-sync-2026-09-18.md),本次补齐的默认 DNS 回退见[封面 DNS 修复说明](docs/cover-dns-2026-09-19.md)。 + +| 类型 | 功能或问题 | 当前行为 | +| --- | --- | --- | +| 优化 | 缓冲短与连续切集卡顿 | MSE 暂停继续缓冲、按时间与字节预算控制、缓存配额恢复;预缓存接管后提升优先级,原生 HLS 提前准备分片,显示真实缓存秒数 | +| 修复 | HE-AAC / HE-AACv2 混合合并失败 | 必要时统一音轨为 AAC-LC,视频独立保留;提前检查转换参数,失败进度不归零 | +| 新增 | Emby 合并版 STRM | 合并版作为 Season 00 特别篇自动补入,直接播放本地合并文件,支持 Range / HEAD | +| 修复 | 分片缺失与重定向地址 | FFmpeg 输出固定到隔离目录,索引不完整自动兼容回退;各播放及下载链路按最终清单 URL 解析相对资源 | +| 修复 | Docker / Fake-IP 导致封面被拒 | 两版默认接入备用 DNS,补齐果果版客户端初始化遗漏;异常 DNS 答复切换服务,只连接校验后的公网 IP,保留 Host、TLS 与代理 | +| 新增 | 按站源分类保存 | 默认关闭,新下载与 Emby 文件按来源建目录,更新旧剧沿用原目录,不自动移动已有文件 | +| 新增 | 原始 MP4 / HLS 播放 | 兼容片源跳过 FFmpeg,支持原生 HLS 和内置 hls.js;续播、暂停、跳转与画质切换保留状态 | +| 修复 | 合并不再整剧转视频 | 优先复制或换封装,视频和音轨分别判断,合并后完整解码校验;混合 HE-AAC 配置按上文统一音轨 | +| 修复 | 网页合并中途取消 | 后台执行、重复任务复用,刷新或断线不再取消 FFmpeg;支持取消和重启中断提示 | +| 新增 | 本地全集播放 | 合并文件可在下载页直接播放、拖动和续播,删除原分集或清理任务记录后仍可使用 | +| 修复 | 直连开关依赖提示 | 原始播放关闭时禁用直连选项并明确提示已保存的选择暂不生效 | +| 新增 | 条件式 302 直连 | 管理员按站源开启,默认关闭;检查通过后由客户端取源站,失败退回免转码中转 | +| 优化 | 解密、换封装与只转音频 | 连续处理整集,保留兼容视频;真实分片索引支持完整时间轴,视频转码作为最后回退 | +| 新增 | 播放资源限制 | 分开限制会话、媒体连接、换封装、音频、视频与预备任务,并限制封装缓存 | +| 修复 | HLS 分片格式 | 正确保留 MP4 初始化片段和 fMP4 / TS 分片扩展名及媒体类型,避免浏览器误判并触发额外封装 | +| 修复 | 红果榜单新格式 | 同时解析新旧页面数据载体,校验榜单与页码,恢复真人剧、AI 剧及翻页,保留真实名次 | +| 修复 | 封面域名限制 | 移除手填图床域名白名单,以剧库记录及站源权限校验;重定向和实际连接均限制为公网地址,保留代理配置 | +| 新增 | 站源单独更新 | 站源状态逐行提供更新按钮,按入口独立查新、续载和补资料;状态实时刷新,沿用账号权限及限流退避 | +| 修复 | Emby 表单勾选框 | 勾选框与第一行文字对齐,补充说明缩进到文字列,适配窄屏换行 | +| 新增 | Emby 海报自动同步 | 自动保存本地海报;API 可选同步远端海报及通知扫描,支持失败退避、重启恢复和自定义海报保护 | +| 修复 | Emby 探测占满播放名额 | 探测与传输结束立即释放占位,同账号同集复用;闲置转码缓存回收后可按原分片链接恢复,保留实际并发与转码限额 | +| 优化 | 1Panel / Compose 源码构建 | 明确使用本地构建,基础镜像地址可配置,区分镜像加速器 403 与缺少构建文件 | +| 新增 | Emby 自动同步 | 管理员可按追剧 / 已下载合集定时生成 STRM、NFO 并补集;无需下载视频,支持通知 Emby 扫描,重启继续,已有文件不重复写入 | +| 新增 | 管理员启动参数 | 默认 admin / 随机密码,首次强制改密;自设密码免首次强制改密,支持环境变量和重置 | +| 新增 | 登录与注册策略 | 管理员控制必须登录、允许注册及手动建号;默认匿名开放、注册开放 | +| 新增 | 独立用户管理 | 单独页面管理访问策略、创建和搜索账号,通过“更多”菜单进入 | +| 优化 | 顶部按钮 | “用户管理”和“代理 / 设置”统一移入“更多”,桌面、手机一致 | +| 新增 | 点开剧集补齐资料 | 剧名、封面和播放入口异步更新当前剧集,重复请求合并,保存缓存并更新页面 | +| 新增 | 账号权限 | 保持红果专用范围;支持仅在线观看,下载入口和接口同时受限,修改后当前登录生效 | +| 新增 | 移动端沉浸播放 | 铺满视口、进度拖动、上下滑动切集、长按加速,竖屏底部面板与横屏右侧面板 | +| 新增 | 横屏与方向感应 | 按实际尺寸识别视频,“全屏观看”与手动旋转、自动跟随方向;参考红果 App 的横屏控制条和抽屉,切换不中断播放 | +| 修复 | 播放会话保活与恢复 | 心跳超时后重试,有效取流及进度上报保活;回前台 / 网络恢复后自动接续,保留分集、进度、暂停、倍速和画质 | +| 修复 | 追剧海报 | 追剧列表使用真实封面,失败回退首字,窄屏保留海报并同步地址修复 | +| 新增 | Emby 账号权限 | 新登录账号导出的链接绑定当前账号,站源及下载权限变更可撤销播放 | +| 新增 | 账号与匿名隔离 | 账号跨设备续播,匿名按 Cookie 独立保存,支持接回旧记录和匿名记录 | +| 修复 | Docker 播放代理 | FFmpeg 内部回环请求不再继承外部代理,应用代理继续生效 | +| 优化 | VPS 播放负载 | 原始播放减少 CPU,必要封装保留码流,独立限制处理任务;直连成功时进一步减少剧库服务器流量 | +| 新增 | 下载画质 | 默认最高可用,支持指定档位、缺档回退、任务持久化与实际画质日志 | +| 修复 | iPhone / iPad 与高画质 | 优先原生 HLS,MSE 失败有回退,保留完整时间轴和所选源分辨率 | +| 修复 | FFmpeg 发现与 preset 错误 | PATH 候选逐个能力检测,显式路径报错清楚,自动版本校验后安装 | +| 新增 | 播放快捷操作 | 键盘播放、跳转、音量、全屏,鼠标或触摸长按临时 3 倍速 | +| 优化 | 下一集预备 | 新播放链路仅预解析地址,避免占用视频流量和转码 CPU;切集复用,关闭播放器释放 | +| 修复 | 中途断流与反向代理 | 媒体范围校验后有限续读,修正同源请求判断,媒体禁缓存并及时转发 | +| 优化 | 大剧库与下载页面 | 仅挂载可见卡片,筛选仍覆盖全部结果;隐藏下载页减少渲染与轮询 | +| 新增 | 追剧与继续观看 | 想看 / 在看 / 已看、收藏撤销、手动已看、按真实分集进度续播 | +| 新增 | 红果分类推荐与弹幕 | 真人 / 漫剧 / AI 推荐分页,真实文字弹幕异步加载并同步播放 | +| 修复 | 目录、搜索、榜单和资料 | 续载分页、自然季号排序、真实字段保留,旧资料手动分批补齐 | +| 修复 | 红果搜索漏季 | 名称检索补充官网首批遗漏结果,标题相关度优先,分类隐藏提示;实测“无限叠加攻击”可找到五季 | +| 修复 | 海报地址 | 榜单入库保留地址,支持历史条目补齐与卡片 / 详情失败重试 | +| 新增 | Emby | STRM / NFO 导出、稳定签名 HLS,支持本地文件与在线解析 | +| 新增 | 自动诊断日志 | 记录播放、下载、FFmpeg 与媒体恢复,支持轮转及凭证脱敏 | +| 同步 | 红果专用版 | 通用功能同步,保留果果剧库名称、8999 端口及红果来源校验 | +| 整理 | 发布与说明 | 临时实现合回正式目录,两版同步构建;使用说明统一为本 README | + +两版完整 Go 回归及相关并发检查通过,JavaScript 回归覆盖排序、资料补齐、播放器加载与取消、会话保活、弹幕等已有功能。账号权限、下载、Emby 自动同步与海报同步的模拟接口回归继续保留。四个平台的程序已交叉编译,macOS 程序在隔离数据目录验证启动、重启、内嵌资源和设置持久化。 + +本轮播放验证使用本机合成视频,覆盖 Chromium 原生 HLS、Chromium 内置 hls.js 路径和 WebKit:原始 MP4、TS / fMP4 HLS、画质切换、连续换封装、只转音频、302 直连及失败回退、会话恢复、本地文件和缓存释放。三秒关键帧间隔的样本生成实际 3 / 3 / 3 / 1 秒分片;CENC 解密与复制视频的逐帧校验通过。红果四种榜单的两页真实文字数据均成功解析,每页 20 条,名次连续。 + +封面使用模拟响应和本机合成图片验证地址登记、账号权限、新图床域名、签名参数、重定向、公网 DNS 校验、HTTP / HTTPS 代理与缓存。未请求、分析或处理源站图片;浏览器导航前拦截图片和外部请求。未在用户实际 Docker / VPS、Windows、iPhone 真机或 Emby 客户端运行新版,WebKit 与模拟接口检查不代表这些环境的实测结果。 diff --git a/compose.yaml b/compose.yaml new file mode 100644 index 0000000..8db8153 --- /dev/null +++ b/compose.yaml @@ -0,0 +1,32 @@ +name: guoguo-juku +services: + app: + image: ${JUKU_IMAGE:-guoguo-juku:local} + pull_policy: build + build: + context: . + args: + GO_IMAGE: ${JUKU_GO_IMAGE:-golang:1.26-bookworm} + RUNTIME_IMAGE: ${JUKU_RUNTIME_IMAGE:-debian:bookworm-slim} + ports: + - "${JUKU_BIND_ADDRESS:-0.0.0.0}:${JUKU_PORT:-8999}:8999" + environment: + TZ: ${TZ:-Asia/Shanghai} + JUKU_PROXY_URL: ${JUKU_PROXY_URL:-} + JUKU_ADMIN_USER: ${JUKU_ADMIN_USER:-} + JUKU_ADMIN_PASSWORD: ${JUKU_ADMIN_PASSWORD:-} + extra_hosts: + - "host.docker.internal:host-gateway" + volumes: + - data:/data + - downloads:/downloads + restart: unless-stopped + stop_grace_period: 30s + logging: + driver: json-file + options: + max-size: "10m" + max-file: "3" +volumes: + data: + downloads: diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..c46d875 --- /dev/null +++ b/go.mod @@ -0,0 +1,5 @@ +module juku + +go 1.24.1 + +require golang.org/x/text v0.32.0 diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..946b4a8 --- /dev/null +++ b/go.sum @@ -0,0 +1,2 @@ +golang.org/x/text v0.32.0 h1:ZD01bjUt1FQ9WJ0ClOL5vxgxOI/sVCNgX1YtKwcY0mU= +golang.org/x/text v0.32.0/go.mod h1:o/rUWzghvpD5TXrTIBuJU77MTaN0ljMWE47kxGJQ7jY= diff --git a/main.go b/main.go new file mode 100644 index 0000000..7a9d98e --- /dev/null +++ b/main.go @@ -0,0 +1,8 @@ +package main + +import "juku/internal/app" + +func main() { + // 短剧·库.zip + app.Run() +} diff --git a/scripts/build.bat b/scripts/build.bat new file mode 100644 index 0000000..19073d0 --- /dev/null +++ b/scripts/build.bat @@ -0,0 +1,16 @@ +@echo off +setlocal +set "GOTOOLCHAIN=local" +chcp 65001 >nul +cd /d "%~dp0.." +go env -w GOPROXY=https://goproxy.cn,direct +if errorlevel 1 exit /b 1 +go env -w GOSUMDB=off +if errorlevel 1 exit /b 1 +if not exist dist mkdir dist +set CGO_ENABLED=0 +set GOOS=windows +set GOARCH=amd64 +go build -trimpath -ldflags="-s -w" -o dist\juku_windows_amd64.exe . +if errorlevel 1 exit /b 1 +echo 已生成 dist\juku_windows_amd64.exe diff --git a/scripts/build.sh b/scripts/build.sh new file mode 100644 index 0000000..383adf2 --- /dev/null +++ b/scripts/build.sh @@ -0,0 +1,22 @@ +#!/usr/bin/env bash +set -euo pipefail +export GOTOOLCHAIN=local + +cd "$(dirname "${BASH_SOURCE[0]}")/.." +go env -w GOPROXY=https://goproxy.cn,direct +go env -w GOSUMDB=off +mkdir -p dist + +echo "Building macOS arm64..." +CGO_ENABLED=0 GOOS=darwin GOARCH=arm64 go build -trimpath -ldflags="-s -w" -o dist/juku_darwin_arm64 . + +echo "Building macOS amd64..." +CGO_ENABLED=0 GOOS=darwin GOARCH=amd64 go build -trimpath -ldflags="-s -w" -o dist/juku_darwin_amd64 . + +echo "Building Linux amd64..." +CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -trimpath -ldflags="-s -w" -o dist/juku_linux_amd64 . + +echo "Building Windows amd64..." +CGO_ENABLED=0 GOOS=windows GOARCH=amd64 go build -trimpath -ldflags="-s -w" -o dist/juku_windows_amd64.exe . + +echo "Done. Files are in dist/." diff --git a/scripts/check-cover-retry.cjs b/scripts/check-cover-retry.cjs new file mode 100644 index 0000000..fa3541d --- /dev/null +++ b/scripts/check-cover-retry.cjs @@ -0,0 +1,144 @@ +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); +const {chromium, webkit} = require(process.env.PLAYWRIGHT_MODULE || 'playwright'); + +const project = path.resolve(process.argv[2] || path.join(__dirname, '..')); +const id = process.argv[3] || 'hongguo:7000000000000000001'; +const source = id.split(':')[0]; +const group = process.argv[4] || 'hongguo'; +const controlID = source + ':7000000000000000002'; +const origin = 'http://127.0.0.1:27831'; +const remote = 'https://covers.example.org/text-fixture?auth=a%2Fb%2B%3D&expires=1900000000'; +const cover = '/api/ui/image?url=' + encodeURIComponent(remote) + '&dramaId=' + encodeURIComponent(id); +const controlCover = '/api/ui/image?url=' + encodeURIComponent('https://covers.example.org/control-text') + '&dramaId=' + encodeURIComponent(controlID); +const ui = path.join(project, 'internal/webui'); +let dramas = [ + {id, source, title: '海报重试文字测试', cover, coverUrl: cover, totalEpisode: 3}, + {id: controlID, source, title: '无关条目文字测试', cover: controlCover, coverUrl: controlCover, totalEpisode: 3}, +]; + +async function until(predicate, message) { + const end = Date.now() + 5000; + while (!predicate()) { + if (Date.now() >= end) throw new Error(message); + await new Promise(resolve => setTimeout(resolve, 20)); + } +} + +(async () => { + const engine = process.env.JUKU_BROWSER_ENGINE === 'webkit' ? webkit : chromium; + const browser = await engine.launch({headless: true, ...(process.env.JUKU_BROWSER_EXECUTABLE ? {executablePath: process.env.JUKU_BROWSER_EXECUTABLE} : {})}); + const context = await browser.newContext({serviceWorkers: 'block', viewport: process.env.JUKU_MOBILE ? {width: 390, height: 844} : {width: 1280, height: 900}}); + context.setDefaultTimeout(7000); + const imageAttempts = [], unexpected = [], pageErrors = []; + let refreshes = 0, repairs = 0, releaseRefresh; + const count = target => imageAttempts.filter(address => new URL(address).searchParams.get('url') === target).length; + try { + await context.route('**/*', async route => { + const request = route.request(), address = new URL(request.url()); + if (request.resourceType() === 'image' || address.pathname === '/api/ui/image') { + if (address.origin === origin && address.pathname === '/api/ui/image') imageAttempts.push(address.href); + else unexpected.push('image:' + address.origin + address.pathname); + return route.abort(); + } + if (address.origin !== origin) { + unexpected.push('external:' + address.origin + address.pathname); + return route.abort(); + } + if (address.pathname === '/') return route.fulfill({contentType: 'text/html', body: fs.readFileSync(path.join(ui, 'index.html'), 'utf8')}); + if (/^\/assets\/[a-zA-Z0-9_.-]+\.(?:js|css|txt)$/.test(address.pathname)) { + const name = path.basename(address.pathname); + let body = fs.readFileSync(path.join(ui, name), 'utf8'); + if (name === 'main.js') body = body.replace('const app = {api, post};', 'const app = {api, post}; window.__coverTestApp = app;'); + return route.fulfill({contentType: name.endsWith('.js') ? 'text/javascript' : name.endsWith('.css') ? 'text/css' : 'text/plain', body}); + } + let result; + switch (address.pathname) { + case '/api/ui/viewer': result = {id: 'a'.repeat(64), ready: true, sources: [group], account: {username: 'fixture', admin: false}}; break; + case '/api/ui/dramas': result = {data: dramas, revision: 1, loadedAt: '2026-09-20T00:00:00Z', sources: {}}; break; + case '/api/ui/tasks': result = {data: [], merges: {}}; break; + case '/api/ui/playback/history': result = {data: []}; break; + case '/api/ui/following': result = {data: dramas.map(drama => ({dramaId: drama.id, source, title: drama.title, saved: true, knownEpisodes: 3, updatedAt: '2026-09-20T00:00:00Z'}))}; break; + case '/api/ui/dramas/refresh': { + refreshes++; + assert.equal(request.postDataJSON().dramaId, id); + await new Promise(resolve => {releaseRefresh = resolve;}); + dramas[0] = {...dramas[0], title: '资料已更新文字测试', sortMetadata: {version: 2, checkedAt: '2026-09-20T01:00:00Z'}}; + result = {dramaId: id, drama: dramas[0], retryAfter: 300}; + break; + } + case '/api/ui/cover/repair': repairs++; result = {dramaId: id, cover, retryAfter: 300}; break; + default: unexpected.push('local:' + address.pathname); return route.abort(); + } + return route.fulfill({contentType: 'application/json', body: JSON.stringify(result)}); + }); + const page = await context.newPage(); + page.on('pageerror', error => pageErrors.push(error.message)); + await page.goto(origin); + await page.waitForFunction(() => document.documentElement.dataset.ready === 'true'); + await page.evaluate(() => { + window.__plays = []; + window.dramaPlayer.open = (id, title) => window.__plays.push({id, title}); + window.dramaPlayer.openHistory = window.dramaPlayer.open; + }); + const card = page.locator(`.card[data-drama-id="${id}"]`); + await card.locator('.cover.placeholder').waitFor(); + assert.equal(refreshes, 0, 'ordinary library reads must not refresh metadata'); + const unrelated = count('https://covers.example.org/control-text'); + let before = count(remote); + await card.locator('.poster').click(); + await until(() => refreshes === 1 && count(remote) > before, 'first click did not retry the failed cover before metadata returned'); + assert.equal(await page.evaluate(() => window.__plays.length), 1, 'playback waited for metadata'); + await card.locator('.cover.placeholder').waitFor(); + before = count(remote); + await card.locator('.poster').click(); + await until(() => count(remote) > before, 'pending metadata suppressed the second cover retry'); + assert.equal(refreshes, 1); + releaseRefresh(); + releaseRefresh = null; + await page.waitForFunction(id => window.__coverTestApp.library.get(id)?.title === '资料已更新文字测试', id); + await card.locator('.cover.placeholder').waitFor(); + before = count(remote); + await card.locator('.poster').click(); + await until(() => count(remote) > before, 'metadata cooldown suppressed the cover retry'); + assert.equal(refreshes, 1); + assert.equal(count('https://covers.example.org/control-text'), unrelated, 'clicking one drama retried an unrelated card'); + + await card.locator('.card-title').click(); + await page.waitForFunction(() => document.querySelector('.detail-cover-status')?.textContent === '海报加载失败'); + await page.locator('#detailPanel [data-close]').click(); + before = count(remote); + await card.locator('.card-title').click(); + await until(() => count(remote) > before, 'reopening details did not retry its poster'); + await page.waitForFunction(() => document.querySelector('.detail-cover-status')?.textContent === '海报加载失败'); + before = count(remote); + await page.locator('#detailPanel .primary-action').click(); + await until(() => count(remote) > before, 'playing from details did not retry its poster'); + await page.locator('#detailPanel [data-close]').click(); + + await page.locator('[data-page="following"]:visible').click(); + await page.locator('[data-follow-tab="saved"]').click(); + const following = page.locator(`.following-row[data-drama-id="${id}"]`); + await following.waitFor(); + await page.waitForFunction(id => !document.querySelector(`.following-row[data-drama-id="${id}"] .following-cover img`), id); + before = count(remote); + await following.locator(`[data-focus-key="following-play-${id}"]`).click(); + await until(() => count(remote) > before, 'playing from the following list did not retry its poster'); + assert.equal(refreshes, 1); + assert.equal(repairs, 0, 'cover retries created redundant metadata repairs'); + assert.equal(await page.evaluate(id => window.__coverTestApp.library.get(id).cover, id), cover, 'a transient retry URL replaced the canonical cover'); + for (const address of imageAttempts.filter(address => new URL(address).searchParams.has('_retry'))) { + const target = new URL(address); + assert.equal(target.searchParams.get('url'), remote, 'source signature changed'); + assert.equal(target.searchParams.get('dramaId'), id, 'drama identity changed'); + } + assert.deepEqual(unexpected, []); + assert.deepEqual(pageErrors, []); + console.log(JSON.stringify({project, source, engine: process.env.JUKU_BROWSER_ENGINE || 'chromium', mobile: Boolean(process.env.JUKU_MOBILE), refreshes, repairs, blockedImageAttempts: imageAttempts.length, upstreamRequests: 0, screenshots: 0, result: 'passed'})); + } finally { + releaseRefresh?.(); + await context.close(); + await browser.close(); + } +})().catch(error => {console.error(error); process.exitCode = 1;}); diff --git a/scripts/docker-build.sh b/scripts/docker-build.sh new file mode 100644 index 0000000..74f09f8 --- /dev/null +++ b/scripts/docker-build.sh @@ -0,0 +1,8 @@ +#!/usr/bin/env bash +set -euo pipefail +project_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +if ! command -v docker >/dev/null 2>&1; then + echo "请先安装 Docker。" >&2 + exit 1 +fi +exec docker build --pull -t "${JUKU_IMAGE:-guoguo-juku:local}" --build-arg "GO_IMAGE=${JUKU_GO_IMAGE:-golang:1.26-bookworm}" --build-arg "RUNTIME_IMAGE=${JUKU_RUNTIME_IMAGE:-debian:bookworm-slim}" "$@" "$project_dir" diff --git a/scripts/docker-up.sh b/scripts/docker-up.sh new file mode 100644 index 0000000..fa2981b --- /dev/null +++ b/scripts/docker-up.sh @@ -0,0 +1,8 @@ +#!/usr/bin/env bash +set -euo pipefail +project_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +if ! command -v docker >/dev/null 2>&1 || ! docker compose version >/dev/null 2>&1; then + echo "请先安装 Docker 及 Compose v2。" >&2 + exit 1 +fi +exec docker compose --project-directory "$project_dir" -f "$project_dir/compose.yaml" up -d --build "$@" diff --git a/scripts/启动剧库.bat b/scripts/启动剧库.bat new file mode 100644 index 0000000..a4db170 --- /dev/null +++ b/scripts/启动剧库.bat @@ -0,0 +1,20 @@ +@echo off +setlocal +set "GOTOOLCHAIN=local" +chcp 65001 >nul +title juku 剧库 +cd /d "%~dp0.." +where go >nul 2>nul +if not errorlevel 1 ( + go env -w GOPROXY=https://goproxy.cn,direct + if errorlevel 1 exit /b 1 + go env -w GOSUMDB=off + if errorlevel 1 exit /b 1 +) +if not exist "dist\juku_windows_amd64.exe" ( + echo 找不到下载器,请先编译或获取包含 dist 的完整版本。 + pause + exit /b 1 +) +"dist\juku_windows_amd64.exe" %* +if errorlevel 1 pause diff --git a/scripts/启动剧库.command b/scripts/启动剧库.command new file mode 100644 index 0000000..e5ac14e --- /dev/null +++ b/scripts/启动剧库.command @@ -0,0 +1,18 @@ +#!/usr/bin/env bash +set -euo pipefail +export GOTOOLCHAIN=local +cd "$(dirname "$0")/.." +if command -v go >/dev/null 2>&1; then + go env -w GOPROXY=https://goproxy.cn,direct + go env -w GOSUMDB=off +fi +case "$(uname -m)" in + arm64) binary="./dist/juku_darwin_arm64" ;; + x86_64) binary="./dist/juku_darwin_amd64" ;; + *) echo "不支持的 macOS 架构,请使用 go run ."; exit 1 ;; +esac +if [ ! -x "$binary" ]; then + echo "找不到可执行程序,请先在项目目录运行 ./scripts/build.sh。" + exit 1 +fi +exec "$binary" "$@" diff --git a/scripts/启动剧库.sh b/scripts/启动剧库.sh new file mode 100644 index 0000000..b14e59a --- /dev/null +++ b/scripts/启动剧库.sh @@ -0,0 +1,13 @@ +#!/usr/bin/env bash +set -euo pipefail +export GOTOOLCHAIN=local +cd "$(dirname "$0")/.." +if command -v go >/dev/null 2>&1; then + go env -w GOPROXY=https://goproxy.cn,direct + go env -w GOSUMDB=off +fi +if [ ! -x "./dist/juku_linux_amd64" ]; then + echo "找不到 Linux x64 程序,请先运行 ./scripts/build.sh。" + exit 1 +fi +exec ./dist/juku_linux_amd64 "$@"