diff --git a/Dockerfile b/Dockerfile index 121cf4b..7cf3e35 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,4 +1,7 @@ -FROM golang:1.26-bookworm AS build +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 @@ -6,7 +9,7 @@ COPY main.go ./ COPY internal ./internal RUN CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" -o /out/juku . -FROM debian:bookworm-slim +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 diff --git a/README.md b/README.md index 7cf7c83..e740cf6 100644 --- a/README.md +++ b/README.md @@ -18,9 +18,13 @@ macOS / Linux 在程序所在目录执行对应命令;若提示没有执行权 源码运行需要 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:8998`,本机访问 `http://127.0.0.1:8998`,其他设备访问 `http://运行程序电脑的局域网IP:8998`。防火墙需允许 TCP 8998;`0.0.0.0` 是监听地址。服务器启动可加 `-open=false`,仅本机使用可加 `-listen 127.0.0.1:8998`。 第一次启动自动创建配置及管理员,随机初始密码显示在启动控制台。默认允许匿名使用。管理员、登录和注册设置见下一节。 @@ -109,7 +113,7 @@ go run . -admin-user owner -admin-password 'replace-with-your-own-password' 在线播放默认选当前可解析的最高兼容画质,选项来自本集实际返回的片源;桌面只有一档时隐藏选择器,手机显示实际可用选项。切换时保留进度、暂停状态和倍速。输出保留源分辨率,不把低画质片源放大。 -默认优先播放源站的原始 MP4 / HLS。兼容的 MP4 直接交给视频元素,HLS 在 iOS 等支持的浏览器中走原生播放,其他支持 MSE 的浏览器使用程序内置的 hls.js,无需外部脚本 CDN。暂停、续播和跳转不因位置非零而自动启动视频转码;HLS 保留原始时长、初始化片段、密钥、字节范围及分片格式。 +“优先原始 MP4 / HLS 播放”暂默认关闭,使用原有兼容播放链路;已保存的设置保持不变,之前开启过的用户可在“播放与资源限制”中关闭并保存。开启后,兼容的 MP4 直接交给视频元素,HLS 在 iOS 等支持的浏览器中走原生播放,其他支持 MSE 的浏览器使用程序内置的 hls.js,无需外部脚本 CDN。新链路的暂停、续播和跳转不因位置非零而自动启动视频转码;HLS 保留原始时长、初始化片段、密钥、字节范围及分片格式。 需要解密或换封装时优先复制视频码流;仅音频不兼容时只转为 AAC,视频转码作为最后回退。该类封装以整集连续处理生成真实分片索引,完成后提供完整时间轴和任意跳转,准备上限为 90 秒,首播可能需要等待。分片时长按实际关键帧生成,不再假定每片固定两秒。源站拒绝或网络失败只有限重试,不通过视频转码解决连接问题。 @@ -117,9 +121,9 @@ go run . -admin-user owner -admin-password 'replace-with-your-own-password' | 设置 | 默认值与用途 | | --- | --- | -| 优先原始 MP4 / HLS 播放 | 开启;关闭可回到原有兼容播放链路 | +| 优先原始 MP4 / HLS 播放 | 暂默认关闭,使用原有兼容播放链路;可手动开启 | | 允许尝试客户端直连的站源 | 全部关闭;按站源开启后,检查编码、源站鉴权、协议与跨域条件,通过才返回 302,客户端失败退回原样中转 | -| 播放会话 / 上游媒体连接上限 | 4 / 8;网页与 Emby 共用额度 | +| 播放会话 / 上游媒体连接上限 | 4 / 8;网页会话与 Emby 当前请求共用额度,已结束的探测不占播放名额 | | 解密 / 换封装、音频转码、视频转码并发 | 2 / 1 / 1;分别限制,可设为 0 禁用对应处理 | | 下一集预备并发 | 1;设为 0 禁用后台预备 | | 封装缓存 | 512 MB;关闭播放后释放,可按磁盘容量调整 | @@ -130,7 +134,7 @@ go run . -admin-user owner -admin-password 'replace-with-your-own-password' 红果文字弹幕默认开启,在播放设置中可关闭;按原始时间分段加载,暂停、倍速、跳转和切集保持同步,失败不影响播放。普通评论尚未接入,原生视频全屏可能无法显示网页弹幕。 -从下载合集播放时,已有完整文件优先本地读取;未完成的分集只在播放到它时继续对应下载,同时在线缓冲。关闭播放器后已触发的下载继续,可在下载页暂停。网页和 Emby 默认合计最多四个活跃播放会话,管理员可调整。 +从下载合集播放时,已有完整文件优先本地读取;未完成的分集只在播放到它时继续对应下载,同时在线缓冲。关闭播放器后已触发的下载继续,可在下载页暂停。默认会话上限为 4,管理员可调整;Emby 仅在解析、取流请求进行时占位,请求结束立即释放,同账号同一集的并发请求共用名额。解析结果保留 10 分钟便于复用,转码缓存按上限回收;再次读取已回收缓存的有效分片链接会自动重新准备。 | 播放入口 | 视频从哪里读取 | | --- | --- | @@ -220,6 +224,18 @@ NAS 或宿主目录映射可改为 `/srv/juku/data:/data`、`/srv/juku/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 和合成媒体回归。 ## 反向代理 @@ -271,7 +287,7 @@ Docker 中需要让两个容器看到同一份导出文件。例如剧库的 `/d 自动同步和手动 ZIP 的 NFO 都包含签名海报 URL,导出目录不生成图片文件。Emby 自身不读取 NFO 里的海报 URL,因此自动补图需要上述 Emby 地址和 API Key;仅解压手动 ZIP 不会自动补图。海报 URL 不依赖网页登录 Cookie,会检查导出账号当前的站源及下载权限,并复用网页海报的代理、来源头和格式兼容处理。Emby 获取海报时需保持本程序运行;获取后由 Emby 缓存,是否将图片保存到媒体目录由 Emby 自身设置决定。 -自动同步只维护自己生成并登记的剧集目录;同名的手动目录、其他文件以及暂时从站源消失的已有分集均保留。移出所有同步范围或关闭功能会停止后续更新,不自动删除已导入的文件。单部剧更新失败不覆盖旧文件,面板会列出失败原因;其他剧继续处理。它不自动下载新视频,也不回写 Emby 的用户或观看进度。 Emby 播放同样优先原始媒体与本地文件;若 Emby 服务端跟随 302 后再转发给终端,视频流量仍由 Emby 所在机器承担。播放期间保持本程序运行,需要媒体处理时由程序调用 FFmpeg。 +自动同步只维护自己生成并登记的剧集目录;同名的手动目录、其他文件以及暂时从站源消失的已有分集均保留。移出所有同步范围或关闭功能会停止后续更新,不自动删除已导入的文件。单部剧更新失败不覆盖旧文件,面板会列出失败原因;其他剧继续处理。它不自动下载新视频,也不回写 Emby 的用户或观看进度。 Emby 播放同样遵循原始播放开关,开启后优先原始媒体与本地文件;若 Emby 服务端跟随 302 后再转发给终端,视频流量仍由 Emby 所在机器承担。播放期间保持本程序运行,需要媒体处理时由程序调用 FFmpeg。 手动导出仍可使用:打开剧集详情,点击“导出 Emby”,将 ZIP 解压到电视剧媒体库并扫描;这种方式需要有新集时重新导出。手动导出和自动同步都遵循账号权限。登录账号导出的链接绑定当前账号,自动同步链接绑定启用功能的管理员;链接不依赖网页登录 Cookie,每次播放和读取分片检查账号当前站源与下载权限。普通账号改为仅在线观看或收回相应站源后,对应链接失效。匿名导出和旧版 STRM 使用独立签名,开启必须登录不会撤销这类旧链接。 @@ -359,7 +375,8 @@ bash scripts/build.sh | --- | --- | | Docker 有封面和集数,却不能播放 | 已修复 FFmpeg 继承代理变量后,把程序内部 `127.0.0.1` 媒体请求错误送往外部代理的问题;应用访问源站仍使用所设代理。需要重建镜像。列表成功不代表视频 CDN 可达,其他原因结合日志判断 | | 红果提示榜单格式或分页变化 | 已兼容新旧页面数据载体,修复真人剧、AI 剧及翻页;升级后重新加载榜单,不必清空剧库 | -| VPS 播放卡、CPU 高 | 默认原始 MP4/HLS 免转码,解密或换封装保留视频,仅必要时转码。查看 `playback.plan` 的处理方式和 CPU,在播放资源设置限制并发;CPU 低但缓冲需检查线路、代理和带宽,免转码中转仍使用 VPS 流量 | +| VPS 播放卡、CPU 高 | 原始 MP4/HLS 播放暂默认关闭;手动开启后兼容内容免转码,解密或换封装保留视频,仅必要时转码。查看 `playback.plan` / `playback.mode` 的处理方式和 CPU,在播放资源设置限制并发;CPU 低但缓冲需检查线路、代理和带宽,免转码中转仍使用 VPS 流量 | +| 准备播放失败:媒体分片未完整生成 | 先关闭“优先原始 MP4 / HLS 播放”并保存,再重新播放。已修正 Windows 输出路径分隔符导致初始化分片写错目录的问题;仍失败时,日志会指出具体文件缺失、为空或访问失败,请提供站源、剧名/集数、系统与相关日志 | | 下载只有 720p | 选择“最高可用”或指定 1080p,查看 `download.media` 中实际和可用画质。下载保留原码流;源站只提供兼容 720p 时不会生成真正的 1080p。此前在线播放的 720p 缩小上限也已移除 | | Windows 报 `Unrecognized option 'preset'` | 查看实际选中 FFmpeg 路径。自动模式会跳过不兼容版本,显式路径需改为具备 libx264/AAC 的完整版本,或设为 `ffmpeg` 恢复自动查找;修改 PATH 后重开终端 | | 中途 `unexpected EOF` | 已知长度和范围时自动有限续读,默认每个响应最多两次;资源变化、不支持 Range 或持续失败仍报错。点击“重试播放”,结合发生时间和集数查看日志 | @@ -368,6 +385,7 @@ bash scripts/build.sh | 无法访问局域网服务 | 检查监听地址、宿主端口、防火墙和无线客户端隔离;远程设备不要访问自己的 `127.0.0.1` | | 个别用户黄果海报返回 400,其他站源正常 | 已修复图床 `pic.tuafjz.cn` 被旧硬编码域名名单误拒绝的问题,并取消该名单;新域名使用剧库来源校验、正确来源头和格式处理。升级程序或重建镜像后刷新网页;旧缓存缺地址时点开剧集或更新剧库补齐,无需删除账号、观看记录。502 则查看 `cover.failed` 的上游错误 | | 黄果视频返回 Cloudflare 403 | 使用已接入 Chrome 150 指纹客户端的新版程序或重建镜像,等待提示的退避结束再更新。查看 `network.browser_request` 的状态和 Ray ID,不把 403 直接当作 IP 封禁 | +| Emby 播放 STRM 提示没有兼容的流 / servererror | 这是 Emby 的通用提示。旧版存在扫描后占满播放名额的缺陷,升级后同集复用,探测结束释放占位。保留 `/data` 卷重建镜像,再在 Emby 重试播放或刷新媒体信息;仍失败时,确认 STRM 地址能从 Emby 服务器 / 容器访问,并查看同一时间的 `emby.playback_failed` 与 FFmpeg 日志。429 表示当前并发达到设置上限,403 检查签名密钥与账号权限,502 检查源站取流和媒体处理;不要仅凭该提示判断编码不兼容 | | Emby 剧集有了但没有海报 | 在“扫描与海报同步”填写服务器地址和有管理权限的 API Key;查看逐剧海报状态,等待扫描或失败重试。目录必须加入电视剧媒体库,不能重复挂载;只解压 ZIP 无法靠 NFO 海报 URL 自动补图 | | Emby 没有自动出现新剧 | 先在“更多 → Emby 同步”启用并选好范围;检查输出目录是否与 Emby 共享、扫描是否开启,面板显示同步与通知错误。仅手动导出 ZIP 不会自动更新 | | 下载目录不能写入 | 填写服务所在电脑或容器的路径并检查运行用户权限;修改目录后重启,旧任务仍使用原路径 | @@ -401,6 +419,8 @@ Windows PowerShell 可用 `Get-Content .\data\logs\app.log -Tail 100`。`ffmpeg. | 修复 | 黄果海报来源与解密 | 修正 `pic.tuafjz.cn` 的 CDN 分类,按剧集站源发送来源头;加密封面不再依赖某个固定域名才能解密,签名查询参数完整保留 | | 修复 | 黄果视频 Cloudflare 403 | 预先使用 Chrome 150 的 TLS / HTTP/2 / 请求头指纹;保留代理、限流、退避与证书验证,网络检测采用同一链路 | | 新增 | Emby 海报自动同步 | 通过签名 URL 让 Emby 获取和缓存海报;支持扫描等待、失败退避、重启恢复,保留自定义海报,导出目录无需额外图片文件 | +| 修复 | Emby 探测占满播放名额 | 探测与传输结束立即释放占位,同账号同集复用;闲置转码缓存回收后可按原分片链接恢复,保留实际并发与转码限额 | +| 优化 | 1Panel / Compose 源码构建 | 明确使用本地构建,基础镜像地址可配置,区分镜像加速器 403 与缺少构建文件 | | 新增 | Emby 自动同步 | 管理员可按追剧 / 已下载合集定时生成 STRM、NFO 并补集;无需下载视频,支持通知 Emby 扫描,重启继续,已有文件不重复写入 | | 新增 | 管理员启动参数 | 默认 admin / 随机密码,首次强制改密;自设密码免首次强制改密,支持环境变量和重置 | | 新增 | 登录与注册策略 | 管理员控制必须登录、允许注册及手动建号;默认匿名开放、注册开放 | diff --git a/_checkzip.py b/_checkzip.py deleted file mode 100644 index 576b486..0000000 --- a/_checkzip.py +++ /dev/null @@ -1,19 +0,0 @@ -import zipfile -from pathlib import Path - -zp = Path(r"C:\Users\Yvan\AppData\Local\Temp\短剧库-update.zip") -with zipfile.ZipFile(zp) as zf: - for info in zf.infolist(): - name = info.filename - if not info.flag_bits & 0x800: - try: - name = name.encode("cp437").decode("utf-8") - except Exception: - pass - if name.endswith("internal/app/playback_stream.go"): - body = zf.read(info).decode("utf-8", "replace") - print("ultrafast in zip:", "ultrafast" in body) - print("veryfast in zip:", "veryfast" in body) - start = body.find("func playbackEncodingArgs") - print(body[start:start + 420]) - break diff --git a/_diff_report.txt b/_diff_report.txt deleted file mode 100644 index c79492d..0000000 --- a/_diff_report.txt +++ /dev/null @@ -1,47 +0,0 @@ -下载最新压缩包:https://www.252035.xyz/短剧库.zip - - 65536/815309 字节 - 131072/815309 字节 - 196608/815309 字节 - 262144/815309 字节 - 327680/815309 字节 - 393216/815309 字节 - 458752/815309 字节 - 524288/815309 字节 - 589824/815309 字节 - 655360/815309 字节 - 720896/815309 字节 - 786432/815309 字节 - 815309/815309 字节 - 已保存到 C:\Users\Yvan\AppData\Local\Temp\短剧库-update.zip -检测到压缩包顶层目录:短剧库/(比对时已忽略) - -对比完成:压缩包 245 个文件,本地 246 个文件 - 新增 2 个 - + internal/app/emby_playback.go - + internal/app/emby_playback_test.go - 修改 18 个 - ~ .gitignore - ~ Dockerfile - ~ README.md - ~ compose.yaml - ~ internal/app/playback_generated.go - ~ internal/app/playback_plan_test.go - ~ internal/app/playback_resources.go - ~ internal/app/playback_stream.go - ~ internal/app/ui_emby.go - ~ internal/app/ui_emby_media.go - ~ internal/app/ui_playback.go - ~ internal/webui/index.html - ~ scripts/build.bat - ~ scripts/build.sh - ~ scripts/docker-build.sh - ~ scripts/启动剧库.bat - ~ scripts/启动剧库.command - ~ scripts/启动剧库.sh - 删除 2 个 - - _diff_report.txt - - internal/app/init.mp4 - 未变化 225 个 - -当前为预览模式。确认无误后追加 --apply 执行更新,然后 git add -A && git commit。 diff --git a/compose.yaml b/compose.yaml index 1767018..3b89cf0 100644 --- a/compose.yaml +++ b/compose.yaml @@ -2,8 +2,12 @@ name: juku services: app: image: ${JUKU_IMAGE:-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:-8998}:8998" environment: diff --git a/internal/app/emby_playback.go b/internal/app/emby_playback.go new file mode 100644 index 0000000..8cd75ce --- /dev/null +++ b/internal/app/emby_playback.go @@ -0,0 +1,247 @@ +package app + +import ( + "context" + "errors" + "math" + "net/http" + "net/url" + "time" +) + +func (app *UIApp) activePlaybackSessionsLocked() int { + count := 0 + for _, session := range app.playbacks { + if !session.emby || session.mediaWaiters > 0 { + count++ + } + } + return count +} + +func (app *UIApp) trimEmbyPlaybacksLocked(reserve int) []context.CancelFunc { + limit := app.mediaResources().snapshot().MaxSessions - reserve + var cancellations []context.CancelFunc + for { + count := 0 + var oldest *playbackSession + for _, session := range app.playbacks { + if !session.emby || session.native == nil && (session.media == nil || session.media.generated == nil) { + continue + } + count++ + if session.mediaWaiters == 0 && session.state != "opening" && (oldest == nil || session.expires.Before(oldest.expires)) { + oldest = session + } + } + if count <= limit || oldest == nil { + return cancellations + } + if oldest.cancel != nil { + cancellations = append(cancellations, oldest.cancel) + } + if oldest.media != nil { + oldest.embyProcessing = oldest.media.plan.Processing + } + oldest.cancel, oldest.native, oldest.media = nil, nil, nil + oldest.mediaReady, oldest.mediaError, oldest.state = nil, nil, "ready" + } +} + +func (app *UIApp) releaseEmbyPlayback(session *playbackSession) { + app.playbackMu.Lock() + session.mediaWaiters-- + var cancellations []context.CancelFunc + if app.playbacks[session.id] == session && session.mediaWaiters == 0 { + if session.state == "opening" || session.mediaError != nil { + delete(app.playbacks, session.id) + session.timer.Stop() + if session.cancel != nil { + cancellations = append(cancellations, session.cancel) + } + } else { + app.touchPlaybackLocked(session) + } + } + cancellations = append(cancellations, app.trimEmbyPlaybacksLocked(0)...) + app.playbackMu.Unlock() + for _, cancel := range cancellations { + cancel() + } +} + +func (app *UIApp) writeEmbyPlaybackError(writer http.ResponseWriter, request *http.Request, status int, message string) { + if status == http.StatusTooManyRequests { + writer.Header().Set("Retry-After", "2") + } + id := request.URL.Query().Get("id") + app.downloader.recordDiagnostic(diagnosticEvent{Event: "emby.playback_failed", HTTPStatus: status, + Source: sourceFromDramaID(id), DramaID: id, Message: message}) + writeJSON(writer, status, map[string]string{"error": message}) +} + +func (app *UIApp) acquireEmbyPlayback(writer http.ResponseWriter, request *http.Request, id, chapter, sessionID string, legacy bool) *playbackSession { + owner := request.URL.Query().Get("account") + app.playbackMu.Lock() + var session *playbackSession + if sessionID != "" { + session = app.playbacks[sessionID] + if session == nil || !session.emby || session.viewer != nil || session.accountID != owner || session.dramaID != id || len(session.tasks) != 1 || session.tasks[0].Chapter.ID != chapter { + app.playbackMu.Unlock() + app.writeEmbyPlaybackError(writer, request, http.StatusGone, "播放已过期,请重新播放") + return nil + } + } else { + for _, candidate := range app.playbacks { + if candidate.emby && candidate.embyLegacy == legacy && candidate.accountID == owner && candidate.dramaID == id && len(candidate.tasks) == 1 && candidate.tasks[0].Chapter.ID == chapter && candidate.mediaError == nil { + session = candidate + break + } + } + } + if (session == nil || session.mediaWaiters == 0) && app.activePlaybackSessionsLocked() >= app.mediaResources().snapshot().MaxSessions { + app.playbackMu.Unlock() + app.writeEmbyPlaybackError(writer, request, http.StatusTooManyRequests, "同时播放数量已达上限,请稍后再试") + return nil + } + if session == nil { + sessionID = randomHex(24) + if sessionID == "" { + app.playbackMu.Unlock() + app.writeEmbyPlaybackError(writer, request, http.StatusInternalServerError, "无法创建播放会话") + return nil + } + session = &playbackSession{id: sessionID, accountID: owner, dramaID: id, emby: true, embyLegacy: legacy, run: 1, + tasks: []Task{{DramaID: id, Chapter: Chapter{ID: chapter}}}} + if app.playbacks == nil { + app.playbacks = make(map[string]*playbackSession) + } + app.playbacks[sessionID] = session + session.timer = time.AfterFunc(playbackIdleTimeout, func() { app.expirePlayback(sessionID) }) + } + session.mediaWaiters++ + app.touchPlaybackLocked(session) + var cancellations []context.CancelFunc + if session.mediaWaiters == 1 { + failed := sessionID == "" && session.media != nil && (session.media.failed.Load() || session.media.ctx.Err() != nil || session.media.proxy != nil && session.media.proxy.Err() != nil) + if session.native != nil { + state, err := session.native.state() + failed = failed || err != nil || state == "stopped" + } + if failed { + if session.cancel != nil { + cancellations = append(cancellations, session.cancel) + } + session.cancel, session.native, session.media, session.mediaReady, session.mediaError = nil, nil, nil, nil, nil + } + } + var parent context.Context + if session.mediaReady == nil { + cancellations = append(cancellations, app.trimEmbyPlaybacksLocked(1)...) + parent, session.cancel = context.WithCancel(context.Background()) + session.mediaReady, session.state = make(chan struct{}), "opening" + } + ready := session.mediaReady + app.playbackMu.Unlock() + for _, cancel := range cancellations { + cancel() + } + if parent != nil { + query := url.Values{"id": {id}, "chapter": {chapter}, "key": {request.URL.Query().Get("key")}} + if owner != "" { + query.Set("account", owner) + } + go app.prepareEmbyPlayback(parent, session, ready, query.Encode(), request.Header.Get("Origin")) + } + select { + case <-ready: + case <-request.Context().Done(): + app.releaseEmbyPlayback(session) + return nil + } + app.playbackMu.Lock() + err := session.mediaError + if err == nil && (app.playbacks[session.id] != session || session.media == nil && session.native == nil) { + err = errors.New("播放会话已结束") + } + app.playbackMu.Unlock() + if err != nil { + app.releaseEmbyPlayback(session) + app.writeEmbyPlaybackError(writer, request, http.StatusBadGateway, "准备 Emby 播放失败:"+app.redactError(err)) + return nil + } + return session +} + +func (app *UIApp) prepareEmbyPlayback(parent context.Context, session *playbackSession, ready chan struct{}, query, origin string) { + ctx, cancel := context.WithTimeout(parent, 90*time.Second) + defer cancel() + task, downloadID, err := app.embyTask(ctx, session.dramaID, session.tasks[0].Chapter.ID) + var media *playbackMediaSession + var native *playbackNative + var duration float64 + if err == nil && !session.embyLegacy { + media, err = app.resolveMediaSession(parent, ctx, task, downloadID, 0) + if err == nil { + base := "/api/emby/media/" + session.id + "/" + mode := "auto" + if session.embyProcessing == "remux" { + mode = "compatible" + } else if session.embyProcessing == "audio" { + mode = "audio" + } + err = app.planMedia(ctx, media, playbackClientCapabilities{MP4: true, NativeHLS: true}, mode, sourceFromDramaID(task.DramaID), origin, base, query) + if err == nil { + duration = media.plan.Duration + if media.plan.Player == "legacy" { + media.Close() + media = nil + } + } + } + } + if err == nil && media == nil { + native = newPlaybackNative(app, parent, task, downloadID, 0, 0, false) + native.start() + _, err = native.segment(ctx, 0) + if err == nil { + _, duration, _ = native.metadata() + if duration <= 0 || duration > 24*60*60 || math.IsNaN(duration) || math.IsInf(duration, 0) { + err = errors.New("未取得有效播放时长") + } + } + } + app.playbackMu.Lock() + if err == nil && (app.playbacks[session.id] != session || session.mediaReady != ready || ctx.Err() != nil) { + err = context.Canceled + } + if err == nil { + previous := session.cancel + session.cancel = func() { + previous() + if media != nil { + media.Close() + } + if native != nil { + native.Close() + } + } + session.tasks, session.media, session.native, session.duration, session.state = []Task{task}, media, native, duration, "streaming" + if media != nil { + media.plan.Run = session.run + } + app.touchPlaybackLocked(session) + } else { + session.mediaError, session.state = err, "failed" + } + close(ready) + app.playbackMu.Unlock() + if err != nil { + if media != nil { + media.Close() + } + if native != nil { + native.Close() + } + } +} diff --git a/internal/app/emby_playback_test.go b/internal/app/emby_playback_test.go new file mode 100644 index 0000000..8cd6fdf --- /dev/null +++ b/internal/app/emby_playback_test.go @@ -0,0 +1,338 @@ +package app + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "net/url" + "os/exec" + "strings" + "sync" + "testing" + "time" +) + +func embyScanFixture(t *testing.T, optimized bool) (*UIApp, func(int) string) { + t.Helper() + app, fixture := nativePlaybackFixture(t, "4", "160x90") + app.closePlayback(fixture.id) + app.mediaResources().settings.Enabled = optimized + app.mediaResources().settings.MaxSessions = 1 + for index := 1; index <= 8; index++ { + task := fixture.tasks[0] + task.Index, task.Total = index, 8 + task.Chapter.ID = fmt.Sprintf("%s:%d", task.DramaID, index) + task.Chapter.CurrentEpisode = rawEpisode(index) + id := fmt.Sprintf("scan-%d", index) + app.tasks[id] = &UITask{ID: id, DramaID: task.DramaID, Status: uiStatusSuccess, Path: task.OutPath, Source: task} + app.taskOrder = append(app.taskOrder, id) + } + key, err := app.embySigningKey(true) + if err != nil { + t.Fatal(err) + } + return app, func(index int) string { + task := app.tasks[fmt.Sprintf("scan-%d", index)].Source + query := url.Values{"id": {task.DramaID}, "chapter": {task.Chapter.ID}, "key": {embyToken(key, task.DramaID, task.Chapter.ID)}} + return "/api/emby/stream.m3u8?" + query.Encode() + } +} + +func embyRequest(app *UIApp, method, address string) *httptest.ResponseRecorder { + writer := httptest.NewRecorder() + app.routes().ServeHTTP(writer, httptest.NewRequest(method, "http://localhost"+address, nil)) + return writer +} + +func embyFirstSegment(t *testing.T, playlist string) string { + t.Helper() + for _, line := range strings.Split(playlist, "\n") { + if strings.HasPrefix(line, "segment.ts?") { + return "/api/emby/" + line + } + } + t.Fatal("missing signed HLS segment") + return "" +} + +func TestEmbySerialProbesReleaseSlotsAndReusePlayback(t *testing.T) { + probe, err := exec.LookPath("ffprobe") + if err != nil { + t.Skip("ffprobe unavailable") + } + for _, optimized := range []bool{false, true} { + t.Run(fmt.Sprintf("original=%t", optimized), func(t *testing.T) { + app, address := embyScanFixture(t, optimized) + server := httptest.NewServer(app.routes()) + defer server.Close() + first := embyRequest(app, http.MethodGet, address(1)) + firstBody, firstLocation := first.Body.String(), first.Header().Get("Location") + for index := 1; index <= 8; index++ { + if head := embyRequest(app, http.MethodHead, address(index)); head.Code != 200 || head.Body.Len() != 0 { + t.Fatal("HEAD failed", head.Code) + } + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + output, err := exec.CommandContext(ctx, probe, "-v", "error", "-show_entries", "format=duration", "-of", "default=nw=1:nk=1", server.URL+address(index)).CombinedOutput() + cancel() + if err != nil || !strings.Contains(string(output), "4.000000") { + t.Fatalf("serial probe %d failed: %v %s", index, err, output) + } + app.playbackMu.Lock() + active := app.activePlaybackSessionsLocked() + cached := 0 + for _, session := range app.playbacks { + if session.native != nil { + cached++ + } + } + app.playbackMu.Unlock() + if active != 0 || cached > 1 { + t.Fatal("finished probe retained a slot or unbounded media cache", active, cached) + } + } + if !optimized { + part := embyRequest(app, http.MethodGet, embyFirstSegment(t, firstBody)) + if part.Code != 200 || part.Body.Len() < 188 || part.Body.Bytes()[0] != 0x47 { + t.Fatal("evicting an idle cache broke an existing HLS URL", part.Code, part.Body.String()) + } + } + repeated := embyRequest(app, http.MethodGet, address(1)) + if repeated.Code != first.Code || repeated.Body.String() != firstBody || repeated.Header().Get("Location") != firstLocation { + t.Fatal("repeated episode did not reuse its prepared session", repeated.Code) + } + web := historyJSONRequest(t, app, app.handlePlaybackOpen, "/api/ui/playback/open", map[string]string{"taskId": "scan-1"}) + if web.Code != 200 { + t.Fatal("completed Emby scans blocked a new browser playback", web.Code, web.Body.String()) + } + }) + } +} + +type embyBlockingWriter struct { + *httptest.ResponseRecorder + entered chan struct{} + resume chan struct{} + once sync.Once +} + +func (writer *embyBlockingWriter) Write(body []byte) (int, error) { + writer.once.Do(func() { + close(writer.entered) + <-writer.resume + }) + return writer.ResponseRecorder.Write(body) +} + +func TestEmbyActiveRequestsShareLimitAndReleaseOnCompletion(t *testing.T) { + for _, optimized := range []bool{false, true} { + t.Run(fmt.Sprintf("original=%t", optimized), func(t *testing.T) { + app, address := embyScanFixture(t, optimized) + first := embyRequest(app, http.MethodGet, address(1)) + asset := first.Header().Get("Location") + if !optimized { + asset = embyFirstSegment(t, first.Body.String()) + } + writer := &embyBlockingWriter{ResponseRecorder: httptest.NewRecorder(), entered: make(chan struct{}), resume: make(chan struct{})} + done := make(chan struct{}) + go func() { + defer close(done) + app.routes().ServeHTTP(writer, httptest.NewRequest(http.MethodGet, "http://localhost"+asset, nil)) + }() + var resume sync.Once + defer resume.Do(func() { close(writer.resume); <-done }) + select { + case <-writer.entered: + case <-time.After(10 * time.Second): + t.Fatal("media request never reached the response writer") + } + if second := embyRequest(app, http.MethodGet, address(2)); second.Code != 429 || second.Header().Get("Retry-After") == "" { + t.Fatal("active requests bypassed the configured limit", second.Code) + } + if same := embyRequest(app, http.MethodGet, address(1)); same.Code != first.Code { + t.Fatal("concurrent requests for the same episode were counted twice", same.Code) + } + web := historyJSONRequest(t, app, app.handlePlaybackOpen, "/api/ui/playback/open", map[string]string{"taskId": "scan-1"}) + if web.Code != 429 { + t.Fatal("browser playback bypassed an active Emby request", web.Code) + } + resume.Do(func() { close(writer.resume); <-done }) + if writer.Code != 200 { + t.Fatal("the original media request was interrupted", writer.Code) + } + if second := embyRequest(app, http.MethodGet, address(2)); second.Code != first.Code { + t.Fatal("completed request did not release its slot", second.Code) + } + web = historyJSONRequest(t, app, app.handlePlaybackOpen, "/api/ui/playback/open", map[string]string{"taskId": "scan-1"}) + if web.Code != 200 { + t.Fatal("released Emby slot was not available to the browser", web.Code, web.Body.String()) + } + var opened struct{ Session string } + if err := json.Unmarshal(web.Body.Bytes(), &opened); err != nil { + t.Fatal(err) + } + app.closePlayback(opened.Session) + }) + } +} + +func TestEmbyCanceledProbeReleasesPendingPreparation(t *testing.T) { + started, stopped := make(chan struct{}), make(chan struct{}) + var startOnce, stopOnce sync.Once + d := rankingTestDownloader(t, func(request *http.Request) (*http.Response, error) { + startOnce.Do(func() { close(started) }) + <-request.Context().Done() + stopOnce.Do(func() { close(stopped) }) + return nil, request.Context().Err() + }) + app := &UIApp{downloader: d, cfg: d.cfg} + t.Cleanup(app.closePlaybacks) + key, err := app.embySigningKey(true) + if err != nil { + t.Fatal(err) + } + id, chapter := "hongguo:7000000000000000001", "hongguo:7000000000000000001:1" + query := url.Values{"id": {id}, "chapter": {chapter}, "key": {embyToken(key, id, chapter)}} + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + done := make(chan struct{}) + go func() { + defer close(done) + request := httptest.NewRequest(http.MethodGet, "http://localhost/api/emby/stream.m3u8?"+query.Encode(), nil).WithContext(ctx) + app.handleEmbyStream(httptest.NewRecorder(), request) + }() + select { + case <-started: + case <-time.After(5 * time.Second): + t.Fatal("preparation did not start") + } + other, cancelOther := context.WithCancel(context.Background()) + defer cancelOther() + otherDone := make(chan struct{}) + go func() { + defer close(otherDone) + request := httptest.NewRequest(http.MethodGet, "http://localhost/api/emby/stream.m3u8?"+query.Encode(), nil).WithContext(other) + app.handleEmbyStream(httptest.NewRecorder(), request) + }() + deadline := time.Now().Add(5 * time.Second) + for { + app.playbackMu.Lock() + waiters := 0 + for _, session := range app.playbacks { + waiters += session.mediaWaiters + } + app.playbackMu.Unlock() + if waiters == 2 { + break + } + if time.Now().After(deadline) { + t.Fatal("second request did not join the preparation") + } + time.Sleep(time.Millisecond) + } + cancel() + select { + case <-done: + case <-time.After(5 * time.Second): + t.Fatal("first canceled request did not finish") + } + select { + case <-stopped: + t.Fatal("one canceled client stopped another client's preparation") + default: + } + cancelOther() + for _, signal := range []chan struct{}{otherDone, stopped} { + select { + case <-signal: + case <-time.After(5 * time.Second): + t.Fatal("canceling the probe left its preparation running") + } + } + app.playbackMu.Lock() + defer app.playbackMu.Unlock() + if len(app.playbacks) != 0 { + t.Fatal("canceled probe retained a playback session") + } +} + +func TestEmbyEvictedRemuxRestoresExistingSegmentURLs(t *testing.T) { + app, address := embyScanFixture(t, true) + first := embyRequest(app, http.MethodGet, address(1)) + if first.Code != http.StatusFound { + t.Fatal(first.Code, first.Body.String()) + } + parsed, _ := url.Parse(first.Header().Get("Location")) + base := strings.TrimSuffix(parsed.Path, "file.mp4") + sessionID := strings.Split(base, "/")[4] + app.playbackMu.Lock() + session := app.playbacks[sessionID] + media := session.media + app.playbackMu.Unlock() + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + if err := app.generatePlayback(ctx, media, "remux", base, parsed.RawQuery); err != nil { + t.Fatal(err) + } + segment := media.generated.assetURL(media.generated.segments[0].Name) + before := embyRequest(app, http.MethodGet, segment) + if before.Code != 200 { + t.Fatal("prepared remux was unreadable", before.Code) + } + if next := embyRequest(app, http.MethodGet, address(2)); next.Code != http.StatusFound { + t.Fatal("could not prepare another episode", next.Code) + } + if media.ctx.Err() == nil { + t.Fatal("idle remux cache was not released") + } + after := embyRequest(app, http.MethodGet, segment) + if after.Code != 200 || !bytes.Equal(before.Body.Bytes(), after.Body.Bytes()) { + t.Fatal("reclaiming cached files changed or invalidated an existing segment", after.Code) + } +} + +func TestEmbyPlaybackCacheRemainsAccountScoped(t *testing.T) { + for _, optimized := range []bool{false, true} { + t.Run(fmt.Sprintf("original=%t", optimized), func(t *testing.T) { + app, address := embyScanFixture(t, optimized) + store := app.browserViewers().accountStore() + store.mu.Lock() + for _, owner := range []string{"owner-a", "owner-b"} { + store.state.Accounts[owner] = accountRecord{ID: owner, Sources: []string{sourceHongguo}} + } + store.mu.Unlock() + key, err := app.embySigningKey(false) + if err != nil { + t.Fatal(err) + } + sign := func(path, owner string) string { + parsed, _ := url.Parse(path) + query := parsed.Query() + query.Set("account", owner) + query.Set("key", embyToken(key, query.Get("id"), query.Get("chapter"), owner)) + parsed.RawQuery = query.Encode() + return parsed.String() + } + first := embyRequest(app, http.MethodGet, sign(address(1), "owner-a")) + asset := first.Header().Get("Location") + if !optimized { + asset = embyFirstSegment(t, first.Body.String()) + } + if forged := embyRequest(app, http.MethodGet, sign(asset, "owner-b")); forged.Code != http.StatusGone { + t.Fatal("valid credentials for another account reused a private cache", forged.Code) + } + second := embyRequest(app, http.MethodGet, sign(address(1), "owner-b")) + if second.Code != first.Code { + t.Fatal("second account could not prepare its own session", second.Code) + } + app.playbackMu.Lock() + count := len(app.playbacks) + app.playbackMu.Unlock() + if count != 2 { + t.Fatal("accounts shared a playback session", count) + } + }) + } +} diff --git a/internal/app/init.mp4 b/internal/app/init.mp4 deleted file mode 100644 index 48987f2..0000000 Binary files a/internal/app/init.mp4 and /dev/null differ diff --git a/internal/app/playback_generated.go b/internal/app/playback_generated.go index 48b5936..470271e 100644 --- a/internal/app/playback_generated.go +++ b/internal/app/playback_generated.go @@ -45,7 +45,7 @@ func playbackGeneratedArgs(media providerMedia, input, directory, processing str } return append(args, "-avoid_negative_ts", "make_zero", "-f", "hls", "-hls_time", "2", "-hls_list_size", "0", "-hls_playlist_type", "vod", "-hls_segment_type", "fmp4", "-hls_fmp4_init_filename", "init.mp4", - "-hls_flags", "temp_file", "-hls_segment_filename", filepath.Join(directory, "%06d.m4s"), filepath.Join(directory, "index.m3u8")) + "-hls_flags", "temp_file", "-hls_segment_filename", filepath.ToSlash(filepath.Join(directory, "%06d.m4s")), filepath.ToSlash(filepath.Join(directory, "index.m3u8"))) } func (app *UIApp) generatePlayback(ctx context.Context, media *playbackMediaSession, processing, base, query string) error { @@ -209,8 +209,14 @@ func (generated *playbackGenerated) index(playlist string) error { } for name := range generated.files { info, err := os.Stat(filepath.Join(generated.directory, name)) - if err != nil || !info.Mode().IsRegular() || info.Size() <= 0 { - return errors.New("媒体分片未完整生成") + if err != nil { + return fmt.Errorf("媒体分片未完整生成(%s):%w", name, err) + } + if !info.Mode().IsRegular() { + return fmt.Errorf("媒体分片未完整生成(%s 不是普通文件)", name) + } + if info.Size() <= 0 { + return fmt.Errorf("媒体分片未完整生成(%s 为空文件)", name) } } generated.playlist = strings.Join(lines, "\n") diff --git a/internal/app/playback_plan_test.go b/internal/app/playback_plan_test.go index a021019..ec37b13 100644 --- a/internal/app/playback_plan_test.go +++ b/internal/app/playback_plan_test.go @@ -21,6 +21,7 @@ import ( func plannedFixture(t *testing.T, app *UIApp, episode, version int, mode string) playbackMediaPlan { t.Helper() + app.mediaResources().settings.Enabled = true body, _ := json.Marshal(map[string]any{"session": "fixture", "episode": episode, "version": version, "start": 1.25, "mode": mode, "client": playbackClientCapabilities{MP4: true, NativeHLS: true, HlsJS: true, Video: []string{"h264"}, Audio: []string{"aac"}}}) request := httptest.NewRequest(http.MethodPost, "http://localhost/api/ui/playback/plan", bytes.NewReader(body)) @@ -165,6 +166,28 @@ func playbackDecodedFrames(t *testing.T, ffmpeg, address string) []string { return frames } +func TestPlaybackGeneratedInitializationPathMatchesPlaylist(t *testing.T) { + directory := filepath.Join(t.TempDir(), "media cache") + args := playbackGeneratedArgs(providerMedia{}, "input.mp4", directory, "remux") + playlist := args[len(args)-1] + var init, segment string + for i := 0; i+1 < len(args); i++ { + switch args[i] { + case "-hls_fmp4_init_filename": + init = args[i+1] + case "-hls_segment_filename": + segment = args[i+1] + } + } + if init != "init.mp4" || strings.ContainsAny(playlist+segment, "\\") || !strings.HasSuffix(segment, "/%06d.m4s") { + t.Fatalf("FFmpeg output paths are not portable: %q", args) + } + resolvedInit := playlist[:strings.LastIndex(playlist, "/")+1] + init + if resolvedInit != filepath.ToSlash(filepath.Join(directory, "init.mp4")) { + t.Fatalf("FFmpeg would write its initialization segment outside the media directory: %q", resolvedInit) + } +} + func TestPlaybackGeneratedPreservesLongGOPAndAudioOnlyVideo(t *testing.T) { app, fixture := nativePlaybackFixture(t, "10", "320x180") ffmpeg := app.downloader.cfg.FFmpeg @@ -296,14 +319,16 @@ func TestPlaybackOriginalHLSURIHeadersAndByteRanges(t *testing.T) { func TestPlaybackResourceBudgetsCancellationAndPersistence(t *testing.T) { app, admin := administratorFixture(t) settings := defaultPlaybackSettings() + settings.Enabled = true settings.MaxVideoTranscodes, settings.MaxMediaRequests = 0, 1 response := admin.request(t, http.MethodPost, "/api/ui/admin/playback", settings) if response.Code != 200 { t.Fatal(response.Code, response.Body.String()) } restarted := &UIApp{cfg: app.cfg} - if restarted.mediaResources().snapshot().MaxVideoTranscodes != 0 { - t.Fatal("disabled video processing did not persist") + saved := restarted.mediaResources().snapshot() + if !saved.Enabled || saved.MaxVideoTranscodes != 0 { + t.Fatal("explicit playback settings did not persist") } resources := app.mediaResources() if _, err := resources.acquire(context.Background(), "video", false); err == nil { @@ -338,6 +363,7 @@ func TestPlaybackResourceBudgetsCancellationAndPersistence(t *testing.T) { func TestEmbyOriginalMP4SignedRangeWithoutCookies(t *testing.T) { app, fixture := nativePlaybackFixture(t, "4", "320x180") + app.mediaResources().settings.Enabled = true key, err := app.embySigningKey(true) if err != nil { t.Fatal(err) diff --git a/internal/app/playback_resources.go b/internal/app/playback_resources.go index ecb5e41..3bf2f35 100644 --- a/internal/app/playback_resources.go +++ b/internal/app/playback_resources.go @@ -35,7 +35,7 @@ type playbackResources struct { } func defaultPlaybackSettings() playbackSettings { - return playbackSettings{Enabled: true, DirectSources: []string{}, MaxSessions: 4, MaxMediaRequests: 8, + return playbackSettings{Enabled: false, DirectSources: []string{}, MaxSessions: 4, MaxMediaRequests: 8, MaxRemuxJobs: 2, MaxAudioTranscodes: 1, MaxVideoTranscodes: 1, MaxPrefetchJobs: 1, CacheMB: 512} } diff --git a/internal/app/playback_stream.go b/internal/app/playback_stream.go index 34e0a9d..5fcb2bc 100644 --- a/internal/app/playback_stream.go +++ b/internal/app/playback_stream.go @@ -122,8 +122,8 @@ func playbackInputArgs(media providerMedia, input string, offset float64) []stri func playbackEncodingArgs(media providerMedia, input string, offset float64) []string { return append(playbackInputArgs(media, input, offset), "-vf", "fps=30,scale=trunc(iw/2)*2:trunc(ih/2)*2,setsar=1", - "-c:v", "libx264", "-preset", "ultrafast", "-tune", "zerolatency", "-profile:v", "baseline", - "-pix_fmt", "yuv420p", "-crf", "22", "-maxrate", "12000k", "-bufsize", "24000k", "-threads", "4", + "-c:v", "libx264", "-preset", "veryfast", "-tune", "zerolatency", "-profile:v", "baseline", + "-pix_fmt", "yuv420p", "-crf", "23", "-maxrate", "12000k", "-bufsize", "24000k", "-threads", "2", "-g", "30", "-keyint_min", "30", "-sc_threshold", "0", "-c:a", "aac", "-b:a", "128k", "-ar", "48000", "-ac", "2") } diff --git a/internal/app/ui_emby.go b/internal/app/ui_emby.go index ed69378..c480abf 100644 --- a/internal/app/ui_emby.go +++ b/internal/app/ui_emby.go @@ -242,58 +242,16 @@ func (app *UIApp) handleEmbyLegacyStream(writer http.ResponseWriter, request *ht writer.Header().Set("Content-Type", "application/vnd.apple.mpegurl") return } - sessionID := randomHex(24) - if sessionID == "" { - writeJSON(writer, http.StatusInternalServerError, map[string]string{"error": "无法创建播放会话"}) - return - } - ctx, cancel := context.WithTimeout(request.Context(), 90*time.Second) - defer cancel() - app.playbackMu.Lock() - if len(app.playbacks) >= app.mediaResources().snapshot().MaxSessions { - app.playbackMu.Unlock() - writeJSON(writer, http.StatusTooManyRequests, map[string]string{"error": "同时播放数量已达上限,请稍后再试"}) - return - } - if app.playbacks == nil { - app.playbacks = make(map[string]*playbackSession) - } - session := &playbackSession{accountID: request.URL.Query().Get("account"), dramaID: id, id: sessionID, run: 1, state: "opening", expires: time.Now().Add(playbackIdleTimeout), cancel: cancel} - session.timer = time.AfterFunc(playbackIdleTimeout, func() { app.expirePlayback(sessionID) }) - app.playbacks[sessionID] = session - app.playbackMu.Unlock() - ready := false - defer func() { - if !ready { - app.closePlayback(sessionID) - } - }() - task, downloadID, err := app.embyTask(ctx, id, chapter) - if err != nil { - writeJSON(writer, http.StatusBadGateway, map[string]string{"error": "解析 Emby 分集失败:" + app.redactError(err)}) - return - } - cache := newPlaybackNative(app, context.Background(), task, downloadID, 0, 0, false) - app.playbackMu.Lock() - if app.playbacks[sessionID] != session || ctx.Err() != nil { - app.playbackMu.Unlock() - cache.Close() - return - } - session.tasks = []Task{task} - session.native = cache - session.cancel = cache.Close - app.playbackMu.Unlock() - cache.start() - if _, err = cache.segment(ctx, 0); err != nil { - writeJSON(writer, http.StatusBadGateway, map[string]string{"error": "准备 Emby 播放失败:" + app.redactError(err)}) - return - } - _, duration, _ := cache.metadata() - if duration <= 0 || duration > 24*60*60 || math.IsNaN(duration) || math.IsInf(duration, 0) { - writeJSON(writer, http.StatusBadGateway, map[string]string{"error": "未取得有效播放时长"}) + session := app.acquireEmbyPlayback(writer, request, id, chapter, "", true) + if session == nil { return } + defer app.releaseEmbyPlayback(session) + app.serveEmbyPlaylist(writer, request, session) +} + +func (app *UIApp) serveEmbyPlaylist(writer http.ResponseWriter, request *http.Request, session *playbackSession) { + id, chapter, sessionID, duration := session.dramaID, session.tasks[0].Chapter.ID, session.id, session.duration var playlist strings.Builder fmt.Fprintf(&playlist, "#EXTM3U\n#EXT-X-VERSION:3\n#EXT-X-TARGETDURATION:%d\n#EXT-X-MEDIA-SEQUENCE:0\n#EXT-X-PLAYLIST-TYPE:VOD\n#EXT-X-INDEPENDENT-SEGMENTS\n", playbackNativeSegmentSeconds) for part, count := 0, int(math.Ceil(duration/playbackNativeSegmentSeconds)); part < count; part++ { @@ -304,17 +262,6 @@ func (app *UIApp) handleEmbyLegacyStream(writer http.ResponseWriter, request *ht fmt.Fprintf(&playlist, "#EXTINF:%.6f,\nsegment.ts?%s\n", math.Min(playbackNativeSegmentSeconds, duration-float64(part*playbackNativeSegmentSeconds)), query.Encode()) } playlist.WriteString("#EXT-X-ENDLIST\n") - app.playbackMu.Lock() - if app.playbacks[sessionID] == session { - session.state = "streaming" - session.duration = duration - app.touchPlaybackLocked(session) - ready = true - } - app.playbackMu.Unlock() - if !ready { - return - } writer.Header().Set("Content-Type", "application/vnd.apple.mpegurl") http.ServeContent(writer, request, "index.m3u8", time.Time{}, strings.NewReader(playlist.String())) } @@ -330,23 +277,29 @@ func (app *UIApp) handleEmbySegment(writer http.ResponseWriter, request *http.Re writeJSON(writer, http.StatusBadRequest, map[string]string{"error": "分片编号无效"}) return } - app.playbackMu.Lock() - session := app.playbacks[query.Get("session")] - if session == nil || session.native == nil || len(session.tasks) != 1 || session.tasks[0].DramaID != id || session.tasks[0].Chapter.ID != chapter { - app.playbackMu.Unlock() - writeJSON(writer, http.StatusGone, map[string]string{"error": "播放已过期,请重新播放"}) + if query.Get("session") == "" { + app.writeEmbyPlaybackError(writer, request, http.StatusGone, "播放已过期,请重新播放") return } + session := app.acquireEmbyPlayback(writer, request, id, chapter, query.Get("session"), true) + if session == nil { + return + } + defer app.releaseEmbyPlayback(session) cache := session.native - app.touchPlaybackLocked(session) - app.playbackMu.Unlock() + if cache == nil { + app.writeEmbyPlaybackError(writer, request, http.StatusGone, "播放格式已变化,请重新播放") + return + } ctx, cancel := context.WithTimeout(request.Context(), 90*time.Second) defer cancel() body, err := cache.segment(ctx, part) if err != nil { - writeJSON(writer, http.StatusBadGateway, map[string]string{"error": "读取 Emby 分片失败:" + app.redactError(err)}) + app.writeEmbyPlaybackError(writer, request, http.StatusBadGateway, "读取 Emby 分片失败:"+app.redactError(err)) return } + active := &playbackRunContext{id: session.id, run: session.run, session: session, ctx: cache.ctx} + writer = &playbackActivityWriter{ResponseWriter: writer, app: app, run: active} writer.Header().Set("Content-Type", "video/mp2t") http.ServeContent(writer, request, "segment.ts", time.Time{}, bytes.NewReader(body)) } diff --git a/internal/app/ui_emby_media.go b/internal/app/ui_emby_media.go index 3462606..d893b46 100644 --- a/internal/app/ui_emby_media.go +++ b/internal/app/ui_emby_media.go @@ -1,12 +1,8 @@ package app import ( - "context" - "errors" "net/http" - "net/url" "strings" - "time" ) func (app *UIApp) handleEmbyStream(writer http.ResponseWriter, request *http.Request) { @@ -22,141 +18,41 @@ func (app *UIApp) handleEmbyStream(writer http.ResponseWriter, request *http.Req writer.Header().Set("Content-Type", "application/octet-stream") return } - owner := request.URL.Query().Get("account") - app.playbackMu.Lock() - var session *playbackSession - for _, candidate := range app.playbacks { - if candidate.viewer == nil && candidate.accountID == owner && candidate.mediaReady != nil && candidate.dramaID == id && len(candidate.tasks) == 1 && candidate.tasks[0].Chapter.ID == chapter && candidate.mediaError == nil && (candidate.media == nil || !candidate.media.failed.Load() && candidate.media.ctx.Err() == nil && (candidate.media.proxy == nil || candidate.media.proxy.Err() == nil)) { - session = candidate - break - } - } + session := app.acquireEmbyPlayback(writer, request, id, chapter, "", false) if session == nil { - if len(app.playbacks) >= app.mediaResources().snapshot().MaxSessions { - app.playbackMu.Unlock() - writeJSON(writer, http.StatusTooManyRequests, map[string]string{"error": "同时播放数量已达上限,请稍后再试"}) - return - } - sessionID := randomHex(24) - if sessionID == "" { - app.playbackMu.Unlock() - writer.WriteHeader(http.StatusInternalServerError) - return - } - parent, cancel := context.WithCancel(context.Background()) - session = &playbackSession{id: sessionID, accountID: owner, dramaID: id, run: 1, state: "opening", mediaReady: make(chan struct{}), cancel: cancel, - tasks: []Task{{DramaID: id, Chapter: Chapter{ID: chapter}}}, expires: time.Now().Add(playbackIdleTimeout)} - if app.playbacks == nil { - app.playbacks = make(map[string]*playbackSession) - } - app.playbacks[sessionID] = session - session.timer = time.AfterFunc(playbackIdleTimeout, func() { app.expirePlayback(sessionID) }) - query := url.Values{"id": {id}, "chapter": {chapter}, "key": {request.URL.Query().Get("key")}} - if owner != "" { - query.Set("account", owner) - } - origin := request.Header.Get("Origin") - go app.prepareEmbyMedia(parent, session, query.Encode(), origin) - } - session.mediaWaiters++ - app.touchPlaybackLocked(session) - app.playbackMu.Unlock() - defer func() { - app.playbackMu.Lock() - session.mediaWaiters-- - var cancel context.CancelFunc - if session.mediaWaiters == 0 && session.state == "opening" && app.playbacks[session.id] == session { - delete(app.playbacks, session.id) - session.timer.Stop() - cancel = session.cancel - } - app.playbackMu.Unlock() - if cancel != nil { - cancel() - } - }() - select { - case <-session.mediaReady: - case <-request.Context().Done(): return } - app.playbackMu.Lock() - media, err := session.media, session.mediaError - app.playbackMu.Unlock() - if err != nil || media == nil { - if err == nil { - err = errors.New("播放会话已结束") - } - writeJSON(writer, http.StatusBadGateway, map[string]string{"error": "准备 Emby 播放失败:" + app.redactError(err)}) + defer app.releaseEmbyPlayback(session) + if session.native != nil { + app.serveEmbyPlaylist(writer, request, session) return } - if media.plan.Player == "legacy" { - app.closePlayback(session.id) - app.handleEmbyLegacyStream(writer, request) - return - } - writer.Header().Set("Location", media.plan.URL) + writer.Header().Set("Location", session.media.plan.URL) writer.Header().Set("Cache-Control", "private, no-store") writer.WriteHeader(http.StatusFound) } -func (app *UIApp) prepareEmbyMedia(parent context.Context, session *playbackSession, query, origin string) { - ctx, cancel := context.WithTimeout(parent, 90*time.Second) - defer cancel() - task, downloadID, err := app.embyTask(ctx, session.dramaID, session.tasks[0].Chapter.ID) - var media *playbackMediaSession - if err == nil { - media, err = app.resolveMediaSession(parent, ctx, task, downloadID, 0) - } - if err == nil { - base := "/api/emby/media/" + session.id + "/" - err = app.planMedia(ctx, media, playbackClientCapabilities{MP4: true, NativeHLS: true}, "auto", sourceFromDramaID(task.DramaID), origin, base, query) - } - app.playbackMu.Lock() - if err == nil && (app.playbacks[session.id] != session || ctx.Err() != nil) { - err = context.Canceled - } - if err == nil { - previous := session.cancel - session.cancel = func() { previous(); media.Close() } - session.tasks, session.media, session.state = []Task{task}, media, "streaming" - session.duration = media.plan.Duration - media.plan.Run = session.run - app.touchPlaybackLocked(session) - } else { - session.mediaError, session.state = err, "failed" - } - close(session.mediaReady) - app.playbackMu.Unlock() - if err != nil { - if media != nil { - media.Close() - } - app.closePlayback(session.id) - } -} - func (app *UIApp) handleEmbyMediaAsset(writer http.ResponseWriter, request *http.Request) { id, chapter, ok := app.authorizeEmby(writer, request) if !ok { return } parts := strings.Split(strings.TrimPrefix(request.URL.Path, "/api/emby/media/"), "/") - if len(parts) != 2 { + if len(parts) != 2 || parts[0] == "" || parts[1] == "" { http.NotFound(writer, request) return } - app.playbackMu.Lock() - session := app.playbacks[parts[0]] - if session == nil || session.viewer != nil || session.media == nil || session.accountID != request.URL.Query().Get("account") || len(session.tasks) != 1 || session.tasks[0].DramaID != id || session.tasks[0].Chapter.ID != chapter { - app.playbackMu.Unlock() - writer.WriteHeader(http.StatusGone) + session := app.acquireEmbyPlayback(writer, request, id, chapter, parts[0], false) + if session == nil { return } + defer app.releaseEmbyPlayback(session) media := session.media + if media == nil { + app.writeEmbyPlaybackError(writer, request, http.StatusGone, "播放格式已变化,请重新播放") + return + } active := &playbackRunContext{id: session.id, run: session.run, session: session, ctx: media.ctx} - app.touchPlaybackLocked(session) - app.playbackMu.Unlock() writer = &playbackActivityWriter{ResponseWriter: writer, app: app, run: active} app.serveMediaSession(writer, request, media) } diff --git a/internal/app/ui_playback.go b/internal/app/ui_playback.go index 0d2e1d4..b1f3df9 100644 --- a/internal/app/ui_playback.go +++ b/internal/app/ui_playback.go @@ -15,7 +15,6 @@ import ( ) const playbackIdleTimeout = 10 * time.Minute -const playbackSessionLimit = 4 const playbackMIME = `video/mp4; codecs="avc1.42C01F, mp4a.40.2"` type playbackSession struct { @@ -41,6 +40,9 @@ type playbackSession struct { mediaReady chan struct{} mediaError error mediaWaiters int + emby bool + embyLegacy bool + embyProcessing string quality int streamVersion uint64 openedAt time.Time @@ -252,7 +254,7 @@ func (app *UIApp) handlePlaybackOpen(writer http.ResponseWriter, request *http.R return } app.playbackMu.Lock() - if len(app.playbacks) >= app.mediaResources().snapshot().MaxSessions { + if app.activePlaybackSessionsLocked() >= app.mediaResources().snapshot().MaxSessions { app.playbackMu.Unlock() writeJSON(writer, http.StatusTooManyRequests, map[string]string{"error": "同时播放数量已达上限,请稍后再试"}) return diff --git a/internal/webui/index.html b/internal/webui/index.html index d0499ad..023a3ca 100644 --- a/internal/webui/index.html +++ b/internal/webui/index.html @@ -149,8 +149,8 @@
播放与资源限制 - -

兼容内容免转码播放;关闭后使用原有兼容链路。需要处理时优先保留视频,只在必要时转码。

+ +

默认关闭,使用原有兼容播放链路;开启后兼容内容免转码,需要处理时优先保留视频。

@@ -160,7 +160,7 @@
-

处理并发设为 0 可禁用该类任务。原始播放不占转码名额;新链路的下一集预备仅解析地址。

+

Emby 探测或取流结束即释放会话名额。处理并发设为 0 可禁用该类任务;原始播放不占转码名额。

允许尝试客户端直连的站源

默认关闭。检查通过后尝试直连,失败退回中转;已发出的源站链接可能在账号权限变更后继续有效。

diff --git a/scripts/build.bat b/scripts/build.bat index 5ce2108..19073d0 100644 --- a/scripts/build.bat +++ b/scripts/build.bat @@ -1,6 +1,12 @@ @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 diff --git a/scripts/build.sh b/scripts/build.sh index d475e7b..383adf2 100644 --- a/scripts/build.sh +++ b/scripts/build.sh @@ -1,7 +1,10 @@ #!/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..." diff --git a/scripts/docker-build.sh b/scripts/docker-build.sh index 8da77f8..38683f6 100644 --- a/scripts/docker-build.sh +++ b/scripts/docker-build.sh @@ -5,4 +5,4 @@ if ! command -v docker >/dev/null 2>&1; then echo "请先安装 Docker。" >&2 exit 1 fi -exec docker build --pull -t "${JUKU_IMAGE:-juku:local}" "$@" "$project_dir" +exec docker build --pull -t "${JUKU_IMAGE:-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/启动剧库.bat b/scripts/启动剧库.bat index 212d8ce..7171564 100644 --- a/scripts/启动剧库.bat +++ b/scripts/启动剧库.bat @@ -1,7 +1,16 @@ @echo off +setlocal +set "GOTOOLCHAIN=local" chcp 65001 >nul title 短剧库 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 diff --git a/scripts/启动剧库.command b/scripts/启动剧库.command index 8e7cf2a..e5ac14e 100644 --- a/scripts/启动剧库.command +++ b/scripts/启动剧库.command @@ -1,6 +1,11 @@ #!/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" ;; diff --git a/scripts/启动剧库.sh b/scripts/启动剧库.sh index 7f213fa..b14e59a 100644 --- a/scripts/启动剧库.sh +++ b/scripts/启动剧库.sh @@ -1,6 +1,11 @@ #!/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