Compare commits

..

25 Commits

Author SHA1 Message Date
truewhile ec5f81e6cb bug处理 2026-09-09 00:18:04 +08:00
truewhile 8eb017fedc 优化 2026-09-09 00:03:14 +08:00
truewhile fb4e762026 优化 2026-09-08 23:44:20 +08:00
truewhile 4239474100 优化 2026-09-08 23:11:57 +08:00
truewhile 8225b4e319 优化 2026-09-08 23:05:07 +08:00
truewhile c086b01381 优化 2026-09-08 22:44:36 +08:00
truewhile f196e5621f 优化 2026-09-08 22:12:26 +08:00
truewhile 13d60b3bd9 分集图片设置落库 2026-09-08 22:00:06 +08:00
truewhile 3d30f6f27c bug处理 2026-09-08 21:08:47 +08:00
truewhile bd02e194ff 优化 2026-09-08 20:52:35 +08:00
truewhile 1c9b742c8d 优化 2026-09-08 20:24:45 +08:00
truewhile 20e7d7fa99 bug处理 2026-09-08 18:12:52 +08:00
truewhile 394ea0382e 优化 2026-09-08 09:46:02 +08:00
truewhile 4d698d2757 优化播放 2026-09-08 09:24:29 +08:00
truewhile fdfb2ee987 bug处理 2026-09-08 08:41:01 +08:00
truewhile b23b1d728e bug处理 2026-09-08 00:36:56 +08:00
truewhile ad427fc0d2 bug处理 2026-09-08 00:10:25 +08:00
truewhile c489374b61 bug处理 2026-09-07 23:45:59 +08:00
truewhile 5a47e6f52d bug处理 2026-09-07 23:21:24 +08:00
truewhile c4b8114115 优化转码逻辑 2026-09-07 23:10:48 +08:00
truewhile 0e592105c4 fix(web): STRM 直连失败后回退 HLS 转码
fix(web): STRM 直连失败后回退 HLS 转码
2026-09-07 22:56:29 +08:00
truewhile f20c9c3234 fix(web): STRM 直连失败后回退 HLS 转码
不再把 STRM/云盘媒体锁死为直连,解析远端源后允许 ffmpeg 转码播放。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-07 22:52:54 +08:00
truewhile 38b85bb487 feat(strm): 同名多版本择优 / keep_ext,并支持合并展示与播放切换 (#30)
* feat(strm): 同名多版本择优/keep_ext,并支持合并展示与播放切换

默认对同名不同扩展(如 .mkv/.mp4)按体积→mtime→扩展名优先级择优生成一条 strm,冲突打日志;开启 keep_ext 则分别生成 name.ext.strm。媒体详情与播放器可切换版本,列表继续合并同片多版本,Emby MediaSources 名称区分容器/分辨率。

Co-authored-by: truewhile <truewhile@users.noreply.github.com>

* fix(meta): 边车匹配忽略 .mkv.strm 中间扩展,同片共享 NFO/海报/字幕

NFO、海报、字幕与刮削写出统一使用 mediaSidecarBase 词干,使竞女01.mkv.strm
与竞女01.mp4.strm 配对同一套 竞女01.nfo / 竞女01-poster / 竞女01.srt;
仍兼容历史上的 name.mkv.nfo 命名。

Co-authored-by: truewhile <truewhile@users.noreply.github.com>

* fix(emby): People NFO 按共享词干匹配 keep_ext strm

resolveMediaPeople 改为查找竞女01.nfo(并兼容旧的竞女01.mkv.nfo),
同时修正把 decodeNFOFile 的 partial 误当 ok 导致完整 NFO 被跳过的问题。

Co-authored-by: truewhile <truewhile@users.noreply.github.com>

---------

Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: truewhile <truewhile@users.noreply.github.com>
2026-09-07 13:56:30 +08:00
truewhile 6bf8a6878a fix(strm): 115 元数据上传幂等,避免重复上传 (#29)
上传/重试前按父目录+文件名+SHA1 探活,已有同内容副本则跳过并清脏副本;
成功后回写新 file_id;同步扫描认可 30 分钟内同路径同大小的 done 任务,
缩短「任务已完成但列表未反映」导致的再入队窗口。

Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: truewhile <truewhile@users.noreply.github.com>
2026-09-07 09:01:58 +08:00
truewhile 2e636ce43f feat(web): 支持生成电视端临时登录码
- 新增创建临时登录密码的 API 请求
- 新增电视端临时登录密码弹窗组件并在用户菜单中提供入口
- 在个人资料页面添加电视临时登录码生成与倒计时展示
2026-09-06 23:22:34 +08:00
100 changed files with 5120 additions and 674 deletions
+16
View File
@@ -0,0 +1,16 @@
{
"mcpServers": {
"ssh": {
"command": "cmd.exe",
"args": [
"/c",
"npx",
"-y",
"@aiondadotcom/mcp-ssh"
],
"env": {
"ProgramData": "C:\\ProgramData"
}
}
}
}
+25 -18
View File
@@ -11,42 +11,47 @@ require (
github.com/golang-jwt/jwt/v5 v5.2.2
github.com/google/uuid v1.6.0
github.com/gorilla/websocket v1.5.3
github.com/metatube-community/metatube-sdk-go v1.4.0
github.com/redis/go-redis/v9 v9.7.0
github.com/shirou/gopsutil/v3 v3.24.5
github.com/spf13/viper v1.18.2
github.com/stretchr/testify v1.11.1
github.com/ulikunitz/xz v0.5.12
go.uber.org/zap v1.27.0
golang.org/x/crypto v0.48.0
golang.org/x/sys v0.41.0
golang.org/x/crypto v0.49.0
golang.org/x/image v0.37.0
golang.org/x/sys v0.42.0
golang.org/x/time v0.15.0
gopkg.in/yaml.v3 v3.0.1
gorm.io/driver/postgres v1.5.7
gorm.io/gorm v1.30.0
gorm.io/driver/postgres v1.6.0
gorm.io/gorm v1.31.1
)
require (
github.com/bytedance/gopkg v0.1.3 // indirect
github.com/bytedance/gopkg v0.1.4 // indirect
github.com/bytedance/sonic v1.15.0 // indirect
github.com/bytedance/sonic/loader v0.5.0 // indirect
github.com/cespare/xxhash/v2 v2.2.0 // indirect
github.com/cloudwego/base64x v0.1.6 // indirect
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect
github.com/disintegration/imaging v1.6.3-0.20201218193011-d40f48ce0f09 // indirect
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/gabriel-vasile/mimetype v1.4.12 // indirect
github.com/esimov/pigo v1.4.7-0.20240801095032-7465ed14de47 // indirect
github.com/gabriel-vasile/mimetype v1.4.13 // indirect
github.com/gin-contrib/sse v1.1.0 // indirect
github.com/glebarez/go-sqlite v1.21.2 // indirect
github.com/glebarez/go-sqlite v1.22.0 // indirect
github.com/go-ole/go-ole v1.2.6 // indirect
github.com/go-playground/locales v0.14.1 // indirect
github.com/go-playground/universal-translator v0.18.1 // indirect
github.com/go-playground/validator/v10 v10.30.1 // indirect
github.com/goccy/go-json v0.10.5 // indirect
github.com/goccy/go-json v0.10.6 // indirect
github.com/goccy/go-yaml v1.19.2 // indirect
github.com/hashicorp/hcl v1.0.0 // indirect
github.com/jackc/pgpassfile v1.0.0 // indirect
github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a // indirect
github.com/jackc/pgx/v5 v5.4.3 // indirect
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
github.com/jackc/pgx/v5 v5.8.0 // indirect
github.com/jackc/puddle/v2 v2.2.2 // indirect
github.com/jinzhu/inflection v1.0.0 // indirect
github.com/jinzhu/now v1.1.5 // indirect
github.com/json-iterator/go v1.1.12 // indirect
@@ -58,6 +63,7 @@ require (
github.com/mitchellh/mapstructure v1.5.0 // indirect
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
github.com/modern-go/reflect2 v1.0.2 // indirect
github.com/ncruces/go-strftime v1.0.0 // indirect
github.com/pelletier/go-toml/v2 v2.2.4 // indirect
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c // indirect
@@ -79,14 +85,15 @@ require (
github.com/yusufpapurcu/wmi v1.2.4 // indirect
go.mongodb.org/mongo-driver/v2 v2.5.0 // indirect
go.uber.org/multierr v1.10.0 // indirect
golang.org/x/arch v0.22.0 // indirect
golang.org/x/exp v0.0.0-20230905200255-921286631fa9 // indirect
golang.org/x/net v0.51.0 // indirect
golang.org/x/arch v0.25.0 // indirect
golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546 // indirect
golang.org/x/net v0.52.0 // indirect
golang.org/x/sync v0.20.0 // indirect
golang.org/x/text v0.35.0 // indirect
google.golang.org/protobuf v1.36.10 // indirect
google.golang.org/protobuf v1.36.11 // indirect
gopkg.in/ini.v1 v1.67.0 // indirect
modernc.org/libc v1.22.5 // indirect
modernc.org/mathutil v1.5.0 // indirect
modernc.org/memory v1.5.0 // indirect
modernc.org/sqlite v1.23.1 // indirect
modernc.org/libc v1.70.0 // indirect
modernc.org/mathutil v1.7.1 // indirect
modernc.org/memory v1.11.0 // indirect
modernc.org/sqlite v1.47.0 // indirect
)
+83 -41
View File
@@ -4,8 +4,8 @@ github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs=
github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c=
github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA=
github.com/bsm/gomega v1.27.10/go.mod h1:JyEr/xRbxbtgWNi8tIEVPUYZ5Dzef52k01W3YH0H+O0=
github.com/bytedance/gopkg v0.1.3 h1:TPBSwH8RsouGCBcMBktLt1AymVo2TVsBVCY4b6TnZ/M=
github.com/bytedance/gopkg v0.1.3/go.mod h1:576VvJ+eJgyCzdjS+c4+77QF3p7ubbtiKARP3TxducM=
github.com/bytedance/gopkg v0.1.4 h1:oZnQwnX82KAIWb7033bEwtxvTqXcYMxDBaQxo5JJHWM=
github.com/bytedance/gopkg v0.1.4/go.mod h1:v1zWfPm21Fb+OsyXN2VAHdL6TBb2L88anLQgdyje6R4=
github.com/bytedance/sonic v1.15.0 h1:/PXeWFaR5ElNcVE84U0dOHjiMHQOwNIx3K4ymzh/uSE=
github.com/bytedance/sonic v1.15.0/go.mod h1:tFkWrPz0/CUCLEF4ri4UkHekCIcdnkqXw9VduqpJh0k=
github.com/bytedance/sonic/loader v0.5.0 h1:gXH3KVnatgY7loH5/TkeVyXPfESoqSBSBEiDd5VjlgE=
@@ -20,22 +20,26 @@ github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78=
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc=
github.com/disintegration/imaging v1.6.3-0.20201218193011-d40f48ce0f09 h1:MJFqtdxTq94XqUgg7DcGCaOIXrDTJE/tPHK66Jshguc=
github.com/disintegration/imaging v1.6.3-0.20201218193011-d40f48ce0f09/go.mod h1:44/5580QXChDfwIclfc/PCwrr44amcmDAg8hxG0Ewe4=
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/esimov/pigo v1.4.7-0.20240801095032-7465ed14de47 h1:48iGRx9HamDuG4pCbPG5IXt4bKHhgn33KGynzHUgeIA=
github.com/esimov/pigo v1.4.7-0.20240801095032-7465ed14de47/go.mod h1:bIH3W2QoHvchgAPl6DUwIUguIHJwvMS6sePQIvhxPTc=
github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8=
github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
github.com/fsnotify/fsnotify v1.7.0 h1:8JEhPFa5W2WU7YfeZzPNqzMP6Lwt7L2715Ggo0nosvA=
github.com/fsnotify/fsnotify v1.7.0/go.mod h1:40Bi/Hjc2AVfZrqy+aj+yEI+/bRxZnMJyTJwOpGvigM=
github.com/gabriel-vasile/mimetype v1.4.12 h1:e9hWvmLYvtp846tLHam2o++qitpguFiYCKbn0w9jyqw=
github.com/gabriel-vasile/mimetype v1.4.12/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s=
github.com/gabriel-vasile/mimetype v1.4.13 h1:46nXokslUBsAJE/wMsp5gtO500a4F3Nkz9Ufpk2AcUM=
github.com/gabriel-vasile/mimetype v1.4.13/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s=
github.com/gin-contrib/gzip v1.2.6 h1:OtN8DplD5DNZCSLAnQ5HxRkD2qZ5VU+JhOrcfJrcRvg=
github.com/gin-contrib/gzip v1.2.6/go.mod h1:BQy8/+JApnRjAVUplSGZiVtD2k8GmIE2e9rYu/hLzzU=
github.com/gin-contrib/sse v1.1.0 h1:n0w2GMuUpWDVp7qSpvze6fAu9iRxJY4Hmj6AmBOU05w=
github.com/gin-contrib/sse v1.1.0/go.mod h1:hxRZ5gVpWMT7Z0B0gSNYqqsSCNIJMjzvm6fqCz9vjwM=
github.com/gin-gonic/gin v1.12.0 h1:b3YAbrZtnf8N//yjKeU2+MQsh2mY5htkZidOM7O0wG8=
github.com/gin-gonic/gin v1.12.0/go.mod h1:VxccKfsSllpKshkBWgVgRniFFAzFb9csfngsqANjnLc=
github.com/glebarez/go-sqlite v1.21.2 h1:3a6LFC4sKahUunAmynQKLZceZCOzUthkRkEAl9gAXWo=
github.com/glebarez/go-sqlite v1.21.2/go.mod h1:sfxdZyhQjTM2Wry3gVYWaW072Ri1WMdWJi0k6+3382k=
github.com/glebarez/go-sqlite v1.22.0 h1:uAcMJhaA6r3LHMTFgP0SifzgXg46yJkgxqyuyec+ruQ=
github.com/glebarez/go-sqlite v1.22.0/go.mod h1:PlBIdHe0+aUEFn+r2/uthrWq4FxbzugL0L8Li6yQJbc=
github.com/glebarez/sqlite v1.11.0 h1:wSG0irqzP6VurnMEpFGer5Li19RpIRi2qvQz++w0GMw=
github.com/glebarez/sqlite v1.11.0/go.mod h1:h8/o8j5wiAsqSPoWELDUdJXhjAhsVliSn7bWZjOhrgQ=
github.com/go-ole/go-ole v1.2.6 h1:/Fpf6oFPoeFik9ty7siob0G6Ke8QvQEuVcuChpwXzpY=
@@ -48,8 +52,8 @@ github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJn
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
github.com/go-playground/validator/v10 v10.30.1 h1:f3zDSN/zOma+w6+1Wswgd9fLkdwy06ntQJp0BBvFG0w=
github.com/go-playground/validator/v10 v10.30.1/go.mod h1:oSuBIQzuJxL//3MelwSLD5hc2Tu889bF0Idm9Dg26cM=
github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4=
github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
github.com/goccy/go-json v0.10.6 h1:p8HrPJzOakx/mn/bQtjgNjdTcN+/S6FcG2CTtQOrHVU=
github.com/goccy/go-json v0.10.6/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
github.com/goccy/go-yaml v1.19.2 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM=
github.com/goccy/go-yaml v1.19.2/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=
github.com/golang-jwt/jwt/v5 v5.2.2 h1:Rl4B7itRWVtYIHFrSNd7vhTiz9UpLdi6gZhZ3wEeDy8=
@@ -58,20 +62,25 @@ github.com/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
github.com/google/pprof v0.0.0-20221118152302-e6195bd50e26 h1:Xim43kblpZXfIBQsbuBVKCudVG457BR2GZFIz3uw3hQ=
github.com/google/pprof v0.0.0-20221118152302-e6195bd50e26/go.mod h1:dDKJzRmX4S37WGHujM7tX//fmj1uioxKzKxz3lo4HJo=
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs=
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
github.com/hashicorp/golang-lru v0.5.4 h1:YDjusn29QI/Das2iO9M0BHnIbxPeyuCHsjMW+lJfyTc=
github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
github.com/hashicorp/hcl v1.0.0 h1:0Anlzjpi4vEasTeNFn2mLJgTSwt0+6sfsiTG8qcWGx4=
github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ=
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a h1:bbPeKD0xmW/Y25WS6cokEszi5g+S0QxI/d45PkRi7Nk=
github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
github.com/jackc/pgx/v5 v5.4.3 h1:cxFyXhxlvAifxnkKKdlxv8XqUf59tDlYjnV5YYfsJJY=
github.com/jackc/pgx/v5 v5.4.3/go.mod h1:Ig06C2Vu0t5qXC60W8sqIthScaEnFvojjj9dSljmHRA=
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
github.com/jackc/pgx/v5 v5.8.0 h1:TYPDoleBBme0xGSAX3/+NujXXtpZn9HBONkQC7IEZSo=
github.com/jackc/pgx/v5 v5.8.0/go.mod h1:QVeDInX2m9VyzvNeiCJVjCkNFqzsNb43204HshNSZKw=
github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E=
github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc=
github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ=
@@ -92,6 +101,8 @@ github.com/magiconair/properties v1.8.7 h1:IeQXZAiQcpL9mgcAe1Nu6cX9LLw6ExEHKjN0V
github.com/magiconair/properties v1.8.7/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/metatube-community/metatube-sdk-go v1.4.0 h1:NBbjxU5SuzgMABM58/f/tmbuTg+hn+Nv1PnlF+gihGI=
github.com/metatube-community/metatube-sdk-go v1.4.0/go.mod h1:FRgPioGhlkXgD6RmOKQDWITwxlsl0K9cXONFz06jymw=
github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY=
github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo=
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
@@ -99,6 +110,8 @@ github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4=
github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
@@ -112,11 +125,10 @@ github.com/quic-go/quic-go v0.59.0 h1:OLJkp1Mlm/aS7dpKgTc6cnpynnD2Xg7C1pwL6vy/SA
github.com/quic-go/quic-go v0.59.0/go.mod h1:upnsH4Ju1YkqpLXC305eW3yDZ4NfnNbmQRCMWS58IKU=
github.com/redis/go-redis/v9 v9.7.0 h1:HhLSs+B6O021gwzl+locl0zEDnyNkxMtf/Z3NNBMa9E=
github.com/redis/go-redis/v9 v9.7.0/go.mod h1:f6zhXITC7JUJIlPEiBOTXxJgPLdZcA93GewI7inzyWw=
github.com/remyoudompheng/bigfft v0.0.0-20200410134404-eec4a21b6bb0/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ=
github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog=
github.com/rogpeppe/go-internal v1.11.0 h1:cWPaGQEPrBb5/AsnsZesgZZ9yb1OQ+GOISoDNXVBh4M=
github.com/rogpeppe/go-internal v1.11.0/go.mod h1:ddIwULY96R17DhadqLgMfk9H9tvdUzkipdSkR5nkCZA=
github.com/sagikazarmark/locafero v0.4.0 h1:HApY1R9zGo4DBgr7dqsTH/JJxLTTsOt7u6keLGt6kNQ=
github.com/sagikazarmark/locafero v0.4.0/go.mod h1:Pe1W6UlPYUk/+wc/6KFhbORCfqzgYEpgQ3O5fPuL3H4=
github.com/sagikazarmark/slog-shim v0.1.0 h1:diDBnUNK9N/354PgrxMywXnAwEr1QZcOr6gto+ugjYE=
@@ -173,28 +185,38 @@ go.uber.org/multierr v1.10.0 h1:S0h4aNzvfcFsC3dRF1jLoaov7oRaKqRGC/pUEJ2yvPQ=
go.uber.org/multierr v1.10.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y=
go.uber.org/zap v1.27.0 h1:aJMhYGrd5QSmlpLMr2MftRKl7t8J8PTZPA732ud/XR8=
go.uber.org/zap v1.27.0/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E=
golang.org/x/arch v0.22.0 h1:c/Zle32i5ttqRXjdLyyHZESLD/bB90DCU1g9l/0YBDI=
golang.org/x/arch v0.22.0/go.mod h1:dNHoOeKiyja7GTvF9NJS1l3Z2yntpQNzgrjh1cU103A=
golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts=
golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos=
golang.org/x/exp v0.0.0-20230905200255-921286631fa9 h1:GoHiUyI/Tp2nVkLI2mCxVkOjsbSXD66ic0XW0js0R9g=
golang.org/x/exp v0.0.0-20230905200255-921286631fa9/go.mod h1:S2oDrQGGwySpoQPVqRShND87VCbxmc6bL1Yd2oYrm6k=
golang.org/x/net v0.51.0 h1:94R/GTO7mt3/4wIKpcR5gkGmRLOuE/2hNGeWq/GBIFo=
golang.org/x/net v0.51.0/go.mod h1:aamm+2QF5ogm02fjy5Bb7CQ0WMt1/WVM7FtyaTLlA9Y=
golang.org/x/arch v0.25.0 h1:qnk6Ksugpi5Bz32947rkUgDt9/s5qvqDPl/gBKdMJLE=
golang.org/x/arch v0.25.0/go.mod h1:0X+GdSIP+kL5wPmpK7sdkEVTt2XoYP0cSjQSbZBwOi8=
golang.org/x/crypto v0.49.0 h1:+Ng2ULVvLHnJ/ZFEq4KdcDd/cfjrrjjNSXNzxg0Y4U4=
golang.org/x/crypto v0.49.0/go.mod h1:ErX4dUh2UM+CFYiXZRTcMpEcN8b/1gxEuv3nODoYtCA=
golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546 h1:mgKeJMpvi0yx/sU5GsxQ7p6s2wtOnGAHZWCHUM4KGzY=
golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546/go.mod h1:j/pmGrbnkbPtQfxEe5D0VQhZC6qKbfKifgD0oM7sR70=
golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
golang.org/x/image v0.37.0 h1:ZiRjArKI8GwxZOoEtUfhrBtaCN+4b/7709dlT6SSnQA=
golang.org/x/image v0.37.0/go.mod h1:/3f6vaXC+6CEanU4KJxbcUZyEePbyKbaLoDOe4ehFYY=
golang.org/x/mod v0.33.0 h1:tHFzIWbBifEmbwtGz65eaWyGiGZatSrT9prnU8DbVL8=
golang.org/x/mod v0.33.0/go.mod h1:swjeQEj+6r7fODbD2cqrnje9PnziFuw4bmLbBZFrQ5w=
golang.org/x/net v0.52.0 h1:He/TN1l0e4mmR3QqHMT2Xab3Aj3L9qjbhRm78/6jrW0=
golang.org/x/net v0.52.0/go.mod h1:R1MAz7uMZxVMualyPXb+VaqGSa3LIaUqk0eEt3w36Sw=
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201204225414-ed752295db88/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.11.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k=
golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo=
golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8=
golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA=
golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U=
golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno=
golang.org/x/tools v0.43.0 h1:12BdW9CeB3Z+J/I/wj34VMl8X+fEXBxVR90JeMX5E7s=
golang.org/x/tools v0.43.0/go.mod h1:uHkMso649BX2cZK6+RpuIPXS3ho2hZo4FVwfoy1vIk0=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE=
google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
@@ -203,15 +225,35 @@ gopkg.in/ini.v1 v1.67.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gorm.io/driver/postgres v1.5.7 h1:8ptbNJTDbEmhdr62uReG5BGkdQyeasu/FZHxI0IMGnM=
gorm.io/driver/postgres v1.5.7/go.mod h1:3e019WlBaYI5o5LIdNV+LyxCMNtLOQETBXL2h4chKpA=
gorm.io/gorm v1.30.0 h1:qbT5aPv1UH8gI99OsRlvDToLxW5zR7FzS9acZDOZcgs=
gorm.io/gorm v1.30.0/go.mod h1:8Z33v652h4//uMA76KjeDH8mJXPm1QNCYrMeatR0DOE=
modernc.org/libc v1.22.5 h1:91BNch/e5B0uPbJFgqbxXuOnxBQjlS//icfQEGmvyjE=
modernc.org/libc v1.22.5/go.mod h1:jj+Z7dTNX8fBScMVNRAYZ/jF91K8fdT2hYMThc3YjBY=
modernc.org/mathutil v1.5.0 h1:rV0Ko/6SfM+8G+yKiyI830l3Wuz1zRutdslNoQ0kfiQ=
modernc.org/mathutil v1.5.0/go.mod h1:mZW8CKdRPY1v87qxC/wUdX5O1qDzXMP5TH3wjfpga6E=
modernc.org/memory v1.5.0 h1:N+/8c5rE6EqugZwHii4IFsaJ7MUhoWX07J5tC/iI5Ds=
modernc.org/memory v1.5.0/go.mod h1:PkUhL0Mugw21sHPeskwZW4D6VscE/GQJOnIpCnW6pSU=
modernc.org/sqlite v1.23.1 h1:nrSBg4aRQQwq59JpvGEQ15tNxoO5pX/kUjcRNwSAGQM=
modernc.org/sqlite v1.23.1/go.mod h1:OrDj17Mggn6MhE+iPbBNf7RGKODDE9NFT0f3EwDzJqk=
gorm.io/driver/postgres v1.6.0 h1:2dxzU8xJ+ivvqTRph34QX+WrRaJlmfyPqXmoGVjMBa4=
gorm.io/driver/postgres v1.6.0/go.mod h1:vUw0mrGgrTK+uPHEhAdV4sfFELrByKVGnaVRkXDhtWo=
gorm.io/gorm v1.31.1 h1:7CA8FTFz/gRfgqgpeKIBcervUn3xSyPUmr6B2WXJ7kg=
gorm.io/gorm v1.31.1/go.mod h1:XyQVbO2k6YkOis7C2437jSit3SsDK72s7n7rsSHd+Gs=
modernc.org/cc/v4 v4.27.1 h1:9W30zRlYrefrDV2JE2O8VDtJ1yPGownxciz5rrbQZis=
modernc.org/cc/v4 v4.27.1/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0=
modernc.org/ccgo/v4 v4.32.0 h1:hjG66bI/kqIPX1b2yT6fr/jt+QedtP2fqojG2VrFuVw=
modernc.org/ccgo/v4 v4.32.0/go.mod h1:6F08EBCx5uQc38kMGl+0Nm0oWczoo1c7cgpzEry7Uc0=
modernc.org/fileutil v1.4.0 h1:j6ZzNTftVS054gi281TyLjHPp6CPHr2KCxEXjEbD6SM=
modernc.org/fileutil v1.4.0/go.mod h1:EqdKFDxiByqxLk8ozOxObDSfcVOv/54xDs/DUHdvCUU=
modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI=
modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito=
modernc.org/gc/v3 v3.1.2 h1:ZtDCnhonXSZexk/AYsegNRV1lJGgaNZJuKjJSWKyEqo=
modernc.org/gc/v3 v3.1.2/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY=
modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks=
modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI=
modernc.org/libc v1.70.0 h1:U58NawXqXbgpZ/dcdS9kMshu08aiA6b7gusEusqzNkw=
modernc.org/libc v1.70.0/go.mod h1:OVmxFGP1CI/Z4L3E0Q3Mf1PDE0BucwMkcXjjLntvHJo=
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI=
modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
modernc.org/opt v0.1.4 h1:2kNGMRiUjrp4LcaPuLY2PzUfqM/w9N23quVwhKt5Qm8=
modernc.org/opt v0.1.4/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
modernc.org/sqlite v1.47.0 h1:R1XyaNpoW4Et9yly+I2EeX7pBza/w+pmYee/0HJDyKk=
modernc.org/sqlite v1.47.0/go.mod h1:hWjRO6Tj/5Ik8ieqxQybiEOUXy0NJFNp2tpvVpKlvig=
modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=
+2 -2
View File
@@ -488,7 +488,7 @@ func getMediaHandler(svc *service.Container) gin.HandlerFunc {
c.JSON(http.StatusOK, m)
return
}
m, err := svc.Media.GetMedia(ctx, id)
m, err := svc.Media.GetMediaItem(ctx, id)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
@@ -497,7 +497,7 @@ func getMediaHandler(svc *service.Container) gin.HandlerFunc {
c.JSON(http.StatusNotFound, gin.H{"error": "not found"})
return
}
if !mediaVisibleForRequest(c, svc, m) {
if !mediaVisibleForRequest(c, svc, &m.Media) {
c.JSON(http.StatusNotFound, gin.H{"error": "not found"})
return
}
+2 -2
View File
@@ -45,8 +45,8 @@ func playbackInfoHandler(svc *service.Container) gin.HandlerFunc {
token := externalPlaybackToken(c, svc, m.ID, m.DurationSec)
profileQuery := externalProfileQuery(c)
hlsURL := "/api/hls/" + m.ID + "/index.m3u8?token=" + url.QueryEscape(token) + profileQuery
if service.IsEmbyRemoteID(m.ID) || service.IsStrmMediaRow(m) {
// Emby 远程挂载与 STRM 媒体一样,默认直连播放,不提供转码地址
if service.IsEmbyRemoteID(m.ID) {
// 远程 Emby 挂载没有本地文件,不能提供转码地址。STRM 默认直连,直连失败时可走 HLS。
hlsURL = ""
}
c.JSON(http.StatusOK, gin.H{
+3 -3
View File
@@ -413,7 +413,7 @@ func newPlaybackScopeTestRouter(t *testing.T) (*gin.Engine, *service.Container,
return router, svc, cfg.Secrets.JWTSecret
}
func TestPlaybackInfoForSTRMMediaDisablesHLS(t *testing.T) {
func TestPlaybackInfoForSTRMMediaIncludesHLS(t *testing.T) {
router, _, secret := newPlaybackScopeTestRouter(t)
loginToken := signedTestToken(t, secret)
@@ -435,8 +435,8 @@ func TestPlaybackInfoForSTRMMediaDisablesHLS(t *testing.T) {
if payload.StreamURL == "" {
t.Fatalf("expected non-empty stream_url")
}
if payload.HlsURL != "" {
t.Fatalf("expected empty hls_url for STRM media, got %q", payload.HlsURL)
if payload.HlsURL == "" || !strings.Contains(payload.HlsURL, "/api/hls/media-1/") {
t.Fatalf("expected strm hls_url, got %q", payload.HlsURL)
}
}
+4 -2
View File
@@ -195,7 +195,8 @@ func listLibrarySeriesEpisodesHandler(svc *service.Container) gin.HandlerFunc {
writeInternalOrCanceled(c, err)
return
}
c.JSON(http.StatusOK, gin.H{"items": items, "total": len(items)})
grouped := service.GroupEpisodeVersionsForDisplay(items)
c.JSON(http.StatusOK, gin.H{"items": grouped, "total": len(grouped)})
}
}
@@ -235,6 +236,7 @@ func listMediaEpisodesHandler(svc *service.Container) gin.HandlerFunc {
writeInternalOrCanceled(c, err)
return
}
c.JSON(http.StatusOK, gin.H{"items": items, "total": len(items)})
grouped := service.GroupEpisodeVersionsForDisplay(items)
c.JSON(http.StatusOK, gin.H{"items": grouped, "total": len(grouped)})
}
}
+1 -1
View File
@@ -14,7 +14,7 @@ import (
func hlsPlaylistHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
id := c.Param("id")
// 远程 Emby 挂载媒体与 STRM 一样,默认直连播放,不进行转码。
// 远程 Emby 挂载没有本地文件,不能转码。STRM 允许直连失败后走 HLS。
if svc.EmbyRemote != nil && service.IsEmbyRemoteID(id) {
c.JSON(http.StatusConflict, gin.H{"error": "transcode disabled"})
return
+2
View File
@@ -232,6 +232,7 @@ type strmSyncPathReq struct {
DownloadMeta *bool `json:"download_meta"`
UploadMeta *bool `json:"upload_meta"`
DeleteDir *bool `json:"delete_dir"`
KeepExt *bool `json:"keep_ext"`
Cron string `json:"cron"`
EnableCron *bool `json:"enable_cron"`
SyncMode string `json:"sync_mode"`
@@ -703,6 +704,7 @@ func strmSyncPathFromReq(req strmSyncPathReq) *model.StrmSyncPath {
DownloadMeta: boolValue(req.DownloadMeta, true),
UploadMeta: boolValue(req.UploadMeta, false),
DeleteDir: boolValue(req.DeleteDir, false),
KeepExt: boolValue(req.KeepExt, false),
Cron: strings.TrimSpace(req.Cron),
EnableCron: boolValue(req.EnableCron, false),
SyncMode: strings.TrimSpace(req.SyncMode),
+4 -1
View File
@@ -51,6 +51,9 @@ type StrmSyncPath struct {
DownloadMeta bool `gorm:"default:true" json:"download_meta"` // 同步时下载元数据文件(nfo/图片/字幕)
UploadMeta bool `json:"upload_meta"` // 同步时把本地元数据上传到远端
DeleteDir bool `json:"delete_dir"` // 清理多余文件时删除空目录
// KeepExt=true 时为每个视频生成 name.mkv.strm / name.mp4.strm(保留全部版本);
// false(默认)时同名不同扩展只择优生成一条 name.strm,避免互相覆盖与来回抖动。
KeepExt bool `json:"keep_ext"`
Cron string `gorm:"size:128" json:"cron"` // 5 段 cron 表达式(可选)
EnableCron bool `json:"enable_cron"` // 是否按 Cron 定时同步
SyncMode string `gorm:"size:32;default:'incremental'" json:"sync_mode"` // 默认同步模式:incremental / full
@@ -128,7 +131,7 @@ type StrmUploadTask struct {
FileName string `gorm:"size:512" json:"file_name"`
LocalPath string `gorm:"size:1024" json:"local_path"` // 本地源文件
RemotePath string `gorm:"size:1024" json:"remote_path"` // 远端目标路径
RemoteRef string `gorm:"size:1024" json:"remote_ref"` // 远端同名旧文件引用(115 文件 ID;上传覆盖前先删除旧文件,WebDAV/OpenList 直接覆盖无需删除)
RemoteRef string `gorm:"size:1024" json:"remote_ref"` // 上传前:远端同名旧文件 ID(逗号分隔,覆盖前先删);上传成功后:新文件 ID
Size int64 `json:"size"`
Status string `gorm:"size:16;index" json:"status"`
Error string `gorm:"size:1024" json:"error"`
+29
View File
@@ -110,6 +110,7 @@ func (r *StrmSyncPathRepository) Update(ctx context.Context, p *model.StrmSyncPa
"download_meta": p.DownloadMeta,
"upload_meta": p.UploadMeta,
"delete_dir": p.DeleteDir,
"keep_ext": p.KeepExt,
"cron": p.Cron,
"enable_cron": p.EnableCron,
"sync_mode": p.SyncMode,
@@ -861,6 +862,34 @@ func (r *StrmUploadTaskRepository) GetActiveLocalPathMap(ctx context.Context, sy
return out, nil
}
// GetRecentDoneUploadSizeMap 返回近期已成功上传的 local_path → size。
// 用于缩短「上传已 done 但 115 列表尚未反映」窗口内的重复入队:同路径且大小未变则跳过。
// 同一路径存在多条 done 时取最新一条(finished_at 降序)。
func (r *StrmUploadTaskRepository) GetRecentDoneUploadSizeMap(ctx context.Context, syncPathID string, since time.Time) (map[string]int64, error) {
var rows []model.StrmUploadTask
err := r.db.WithContext(ctx).Model(&model.StrmUploadTask{}).
Select("local_path", "size", "finished_at").
Where("sync_path_id = ? AND status = ? AND finished_at IS NOT NULL AND finished_at >= ?",
syncPathID, model.StrmTaskDone, since).
Order("finished_at DESC").
Find(&rows).Error
if err != nil {
return nil, err
}
out := make(map[string]int64, len(rows))
for _, row := range rows {
if row.LocalPath == "" {
continue
}
// 已按 finished_at DESC;先写入的是最新,后续同路径跳过
if _, exists := out[row.LocalPath]; exists {
continue
}
out[row.LocalPath] = row.Size
}
return out, nil
}
func (r *StrmUploadTaskRepository) DeleteFinishedOlderThan(ctx context.Context, before time.Time) error {
return withSQLiteBusyRetry(ctx, func() error {
return r.db.WithContext(ctx).Unscoped().Where("status IN ? AND finished_at < ?",
+41 -26
View File
@@ -7,8 +7,17 @@ import (
"image/jpeg"
_ "image/png" // register PNG decoder
"strings"
"github.com/metatube-community/metatube-sdk-go/detector"
_ "golang.org/x/image/webp" // register WebP decoder for AVE covers
)
const adultPosterRatio = 2.0 / 3.0
var findPrimaryFaceAxisRatio = func(img image.Image, ratio float64, advanced bool) (float64, bool) {
return detector.FindPrimaryFaceAxisRatio(img, ratio, advanced)
}
// IsAdultMediaPathOrMetadata reports whether media is adult based on path, mediaType, nsfw flag, or adult code.
func IsAdultMediaPathOrMetadata(path, mediaType string, nsfw bool) bool {
if nsfw {
@@ -38,7 +47,8 @@ func IsAdultArtworkURL(raw string) bool {
}
// CropAdultCoverPoster checks if the image data is a wide full-jacket DVD cover (width > height * 1.15).
// If so, it crops the right portion (the front cover poster) and returns the encoded JPEG bytes.
// If so, it uses MetaTube's Pigo face detector to position a 2:3 poster crop.
// When no face is detected, it falls back to the conventional right-side front cover.
// If the image is already portrait or cannot be decoded, it safely returns the original data.
func CropAdultCoverPoster(data []byte) ([]byte, string, error) {
if len(data) == 0 {
@@ -59,32 +69,11 @@ func CropAdultCoverPoster(data []byte) ([]byte, string, error) {
return data, format, nil
}
// Front cover width is typically ~0.70-0.72 of the height (or right ~50-52% of total width)
cropWidth := int(float64(height) * 0.71)
maxCropWidth := int(float64(width) * 0.53)
if cropWidth > maxCropWidth {
cropWidth = maxCropWidth
}
if cropWidth <= 0 {
cropWidth = width / 2
}
minX := bounds.Max.X - cropWidth
if minX < bounds.Min.X {
minX = bounds.Min.X
}
cropRect := image.Rect(minX, bounds.Min.Y, bounds.Max.X, bounds.Max.Y)
type subImager interface {
SubImage(r image.Rectangle) image.Image
}
var cropped image.Image
if si, ok := src.(subImager); ok {
cropped = si.SubImage(cropRect)
} else {
dst := image.NewRGBA(image.Rect(0, 0, cropRect.Dx(), cropRect.Dy()))
draw.Draw(dst, dst.Bounds(), src, cropRect.Min, draw.Src)
cropped = dst
position := 1.0
if detected, ok := findPrimaryFaceAxisRatio(src, adultPosterRatio, true); ok {
position = detected
}
cropped := cropImageAtPosition(src, adultPosterRatio, position)
var buf bytes.Buffer
if err := jpeg.Encode(&buf, cropped, &jpeg.Options{Quality: 92}); err != nil {
@@ -92,3 +81,29 @@ func CropAdultCoverPoster(data []byte) ([]byte, string, error) {
}
return buf.Bytes(), "image/jpeg", nil
}
func cropImageAtPosition(src image.Image, ratio, position float64) image.Image {
bounds := src.Bounds()
width := bounds.Dx()
height := bounds.Dy()
cropWidth, cropHeight := width, height
x, y := 0, 0
if candidate := int(float64(height) * ratio); candidate < width {
cropWidth = candidate
x = max(min(int(float64(width)*position)-cropWidth/2, width-cropWidth), 0)
} else if candidate := int(float64(width) / ratio); candidate < height {
cropHeight = candidate
y = max(min(int(float64(height)*position)-cropHeight/2, height-cropHeight), 0)
}
cropRect := image.Rect(0, 0, cropWidth, cropHeight).
Add(image.Pt(x, y)).
Add(bounds.Min)
if sub, ok := src.(interface {
SubImage(image.Rectangle) image.Image
}); ok {
return sub.SubImage(cropRect)
}
dst := image.NewRGBA(image.Rect(0, 0, cropRect.Dx(), cropRect.Dy()))
draw.Draw(dst, dst.Bounds(), src, cropRect.Min, draw.Src)
return dst
}
+49 -3
View File
@@ -2,6 +2,7 @@ package service
import (
"bytes"
"encoding/base64"
"image"
"image/color"
"image/draw"
@@ -44,10 +45,10 @@ func TestCropAdultCoverPosterWideLandscape(t *testing.T) {
if croppedWidth <= 0 || croppedWidth >= 800 {
t.Fatalf("unexpected cropped width: %d", croppedWidth)
}
// Verify aspect ratio is roughly standard portrait (~0.68 - 0.73)
// MetaTube's default primary-image ratio is 2:3.
ratio := float64(croppedWidth) / float64(croppedHeight)
if ratio < 0.65 || ratio > 0.75 {
t.Fatalf("expected portrait ratio ~0.71, got %f (%dx%d)", ratio, croppedWidth, croppedHeight)
if ratio < 0.65 || ratio > 0.68 {
t.Fatalf("expected portrait ratio ~0.667, got %f (%dx%d)", ratio, croppedWidth, croppedHeight)
}
// Verify the cropped image contains the right side color (blue), not the left side color (red)
@@ -58,6 +59,51 @@ func TestCropAdultCoverPosterWideLandscape(t *testing.T) {
}
}
func TestCropAdultCoverPosterDecodesWebP(t *testing.T) {
wideWebP, err := base64.StdEncoding.DecodeString("UklGRjwAAABXRUJQVlA4IDAAAADQAQCdASoJAAYAAUAmJaACdLoB+AADsAD+8ut//NgVzXPv9//S4P0uD9Lg/9KQAAA=")
if err != nil {
t.Fatal(err)
}
croppedBytes, ctype, err := CropAdultCoverPoster(wideWebP)
if err != nil {
t.Fatalf("crop WebP: %v", err)
}
if ctype != "image/jpeg" {
t.Fatalf("cropped WebP type = %q, want image/jpeg", ctype)
}
cropped, _, err := image.Decode(bytes.NewReader(croppedBytes))
if err != nil {
t.Fatal(err)
}
if got := float64(cropped.Bounds().Dx()) / float64(cropped.Bounds().Dy()); got < 0.65 || got > 0.68 {
t.Fatalf("cropped WebP ratio = %.3f, want 2:3", got)
}
}
func TestCropAdultCoverPosterCentersDetectedFace(t *testing.T) {
originalDetector := findPrimaryFaceAxisRatio
findPrimaryFaceAxisRatio = func(image.Image, float64, bool) (float64, bool) {
return 0.25, true
}
defer func() {
findPrimaryFaceAxisRatio = originalDetector
}()
origBytes := createTestImage(900, 600, color.RGBA{R: 255, A: 255}, color.RGBA{B: 255, A: 255})
croppedBytes, _, err := CropAdultCoverPoster(origBytes)
if err != nil {
t.Fatal(err)
}
croppedImg, _, err := image.Decode(bytes.NewReader(croppedBytes))
if err != nil {
t.Fatal(err)
}
r, _, b, _ := croppedImg.At(croppedImg.Bounds().Dx()/2, croppedImg.Bounds().Dy()/2).RGBA()
if r <= b {
t.Fatalf("face-positioned crop did not follow detected left-side axis: r=%d b=%d", r, b)
}
}
func TestCropAdultCoverPosterKeepsPortrait(t *testing.T) {
// Create an already vertical portrait image (500x700)
origBytes := createTestImage(500, 700, color.RGBA{R: 255, A: 255}, color.RGBA{B: 255, A: 255})
+81
View File
@@ -9,6 +9,7 @@ import (
"net/url"
"regexp"
"strings"
"sync"
"time"
"go.uber.org/zap"
@@ -180,6 +181,7 @@ func (p *AdultProvider) SearchCandidates(ctx context.Context, query string) ([]*
if err != nil {
return nil, err
}
matches = p.enrichMetaTubeCandidates(ctx, mtCfg, code, matches)
for _, m := range matches {
m.OriginalName = code
m.Title = FormatAdultTitle(code, m.Title)
@@ -194,6 +196,7 @@ func (p *AdultProvider) SearchCandidates(ctx context.Context, query string) ([]*
if mtCfg.ServerURL != "" {
matches, err := p.metatube.Search(ctx, mtCfg, code)
if err == nil && len(matches) > 0 {
matches = p.enrichMetaTubeCandidates(ctx, mtCfg, code, matches)
for _, m := range matches {
m.OriginalName = code
m.Title = FormatAdultTitle(code, m.Title)
@@ -217,6 +220,50 @@ func (p *AdultProvider) SearchCandidates(ctx context.Context, query string) ([]*
}
}
// GetMetaTubeCandidate fetches the selected provider result instead of
// re-running a search that may choose a different provider.
func (p *AdultProvider) GetMetaTubeCandidate(ctx context.Context, provider, id string) (*Match, error) {
if p == nil || p.metatube == nil {
return nil, nil
}
provider = strings.TrimSpace(provider)
id = strings.TrimSpace(id)
if provider == "" || id == "" {
return nil, nil
}
engine := strings.ToLower(p.getSetting(ctx, "adult.scraper.engine", "builtin"))
if engine != "metatube" && engine != "auto" {
return nil, nil
}
cfg := p.ResolveMetaTubeConfig(ctx)
if cfg.ServerURL == "" {
return nil, nil
}
return p.metatube.GetMovie(ctx, cfg, provider, id)
}
func (p *AdultProvider) enrichMetaTubeCandidates(ctx context.Context, cfg MetaTubeConfig, code string, matches []*Match) []*Match {
var wg sync.WaitGroup
for i, candidate := range matches {
if candidate == nil ||
normalizeAdultCode(candidate.OriginalName) != code ||
strings.TrimSpace(candidate.DoubanID) == "" ||
strings.TrimSpace(candidate.TheTVDBID) == "" {
continue
}
wg.Add(1)
go func(index int, current *Match) {
defer wg.Done()
detailed, err := p.metatube.GetMovie(ctx, cfg, current.TheTVDBID, current.DoubanID)
if err == nil && detailed != nil {
matches[index] = detailed
}
}(i, candidate)
}
wg.Wait()
return matches
}
func (p *AdultProvider) searchBuiltin(ctx context.Context, code string) (*Match, error) {
bases := p.resolveBases(ctx)
if len(bases) == 0 {
@@ -243,12 +290,46 @@ func (p *AdultProvider) searchBuiltin(ctx context.Context, code string) (*Match,
match.OriginalName = code
match.Title = FormatAdultTitle(code, match.Title)
match.NSFW = true
p.applyMetaTubeFaceAwareArtwork(ctx, code, match)
return match, nil
}
}
return nil, lastErr
}
func (p *AdultProvider) applyMetaTubeFaceAwareArtwork(ctx context.Context, code string, match *Match) {
if p == nil || p.metatube == nil || match == nil || strings.TrimSpace(match.PosterURL) == "" {
return
}
// In explicit built-in mode MetaTube remains useful as an image processor:
// its primary endpoint performs the same automatic face-aware crop used by
// the official Jellyfin plugin. Auto mode already tried MetaTube metadata,
// so avoid repeating the same failed request during fallback.
if strings.ToLower(p.getSetting(ctx, "adult.scraper.engine", "builtin")) != "builtin" {
return
}
cfg := p.ResolveMetaTubeConfig(ctx)
if cfg.ServerURL == "" || !cfg.CropCover {
return
}
candidates, err := p.metatube.Search(ctx, cfg, code)
if err != nil {
return
}
for _, candidate := range candidates {
if candidate == nil || normalizeAdultCode(candidate.OriginalName) != code {
continue
}
if candidate.PosterURL != "" {
match.PosterURL = candidate.PosterURL
}
if candidate.BackdropURL != "" {
match.BackdropURL = candidate.BackdropURL
}
return
}
}
func (p *AdultProvider) resolveBases(ctx context.Context) []string {
customJavDB := p.getSetting(ctx, "adult.scraper.builtin_javdb_url", "")
customJavBus := p.getSetting(ctx, "adult.scraper.builtin_javbus_url", "")
+1
View File
@@ -23,6 +23,7 @@ func parseAdultDetailHTML(body, code, source, detailURL string) *Match {
}
match := &Match{
Provider: strings.ToLower(strings.TrimSpace(source)),
OriginalName: code,
MediaType: "adult",
NSFW: true,
+87 -1
View File
@@ -25,7 +25,8 @@ func TestAdultProviderRouting(t *testing.T) {
repos := repository.New(db)
mtServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/v1/movies/search" {
switch r.URL.Path {
case "/v1/movies/search":
q := r.URL.Query().Get("q")
if q == "SSIS-001" {
results := struct {
@@ -46,6 +47,21 @@ func TestAdultProviderRouting(t *testing.T) {
_ = json.NewEncoder(w).Encode(results)
return
}
case "/v1/movies/javdb/999":
_ = json.NewEncoder(w).Encode(struct {
Data MetaTubeMovieInfo `json:"data"`
}{
Data: MetaTubeMovieInfo{
ID: "999",
Number: "SSIS-001",
Title: "河北彩花 専属デビュー",
Provider: "javdb",
CoverURL: "https://example.com/poster.jpg",
PreviewImages: []string{"https://example.com/backdrop.jpg"},
ReleaseDate: "2021-06-19",
},
})
return
}
http.NotFound(w, r)
}))
@@ -80,6 +96,11 @@ func TestAdultProviderRouting(t *testing.T) {
if len(candidates) != 1 {
t.Fatalf("expected 1 candidate, got %d", len(candidates))
}
wantPoster := mtServer.URL + "/v1/images/primary/javdb/999?auto=true&pos=1&quality=90&ratio=-1&url=https%3A%2F%2Fexample.com%2Fposter.jpg"
wantBackdrop := mtServer.URL + "/v1/images/backdrop/javdb/999?quality=90"
if candidates[0].PosterURL != wantPoster || candidates[0].BackdropURL != wantBackdrop {
t.Fatalf("candidate artwork was not enriched: %#v", candidates[0])
}
// 2. Test auto mode with failing metatube query
_ = repos.Setting.Set(context.Background(), "adult.scraper.engine", "auto")
@@ -89,3 +110,68 @@ func TestAdultProviderRouting(t *testing.T) {
t.Errorf("expected nil for nonexistent in auto mode when sources unavailable")
}
}
func TestBuiltinAdultScrapeUsesMetaTubeFaceAwareArtwork(t *testing.T) {
builtinServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/search":
_, _ = w.Write([]byte(`<a class="box" href="/v/local"><strong>SSIS-001 本地候选</strong></a>`))
case "/v/local":
_, _ = w.Write([]byte(`<h2>SSIS-001 本地标题</h2><img class="video-cover" src="/wide-cover.jpg">`))
default:
http.NotFound(w, r)
}
}))
defer builtinServer.Close()
metaTubeServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/v1/movies/search" || r.URL.Query().Get("q") != "SSIS-001" {
http.NotFound(w, r)
return
}
_ = json.NewEncoder(w).Encode(struct {
Data []MetaTubeSearchResult `json:"data"`
}{
Data: []MetaTubeSearchResult{{
ID: "999",
Number: "SSIS-001",
Title: "MetaTube candidate",
Provider: "AVE",
CoverURL: "https://example.com/wide-cover.jpg",
}},
})
}))
defer metaTubeServer.Close()
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
if err != nil {
t.Fatal(err)
}
if err := db.AutoMigrate(&model.Setting{}, &model.APIConfig{}); err != nil {
t.Fatal(err)
}
repos := repository.New(db)
for key, value := range map[string]string{
"adult.scraper.engine": "builtin",
"adult.scraper.builtin_javdb_url": builtinServer.URL,
"adult.scraper.metatube_server": metaTubeServer.URL,
"adult.scraper.crop_cover": "true",
} {
if err := repos.Setting.Set(t.Context(), key, value); err != nil {
t.Fatal(err)
}
}
provider := NewAdultProvider(zap.NewNop(), nil, repos)
match, err := provider.Search(t.Context(), "SSIS-001")
if err != nil {
t.Fatal(err)
}
if match == nil {
t.Fatal("expected built-in match")
}
wantPoster := metaTubeServer.URL + "/v1/images/primary/AVE/999?auto=true&pos=1&quality=90&ratio=-1&url=https%3A%2F%2Fexample.com%2Fwide-cover.jpg"
if match.PosterURL != wantPoster {
t.Fatalf("built-in poster = %q, want face-aware URL %q", match.PosterURL, wantPoster)
}
}
+13 -10
View File
@@ -150,6 +150,7 @@ func (c *OpenClient) doJSON(ctx context.Context, method, rawURL string, form map
if err != nil {
return nil, err
}
attemptedAccess := strings.TrimPrefix(req.Header.Get("Authorization"), "Bearer ")
resp, err := c.HTTP.Do(req)
if err != nil {
@@ -218,7 +219,7 @@ func (c *OpenClient) doJSON(ctx context.Context, method, rawURL string, form map
// refresh_token 刷新后重试一次。刷新失败或重试后仍失败才返回,
// 避免长时间同步因 token 过期而整体失败。
if isTokenCode(base.Code) {
if access && c.tryRefreshTokenLocked(ctx) {
if access && c.tryRefreshTokenLocked(ctx, attemptedAccess) {
continue
}
if access {
@@ -305,21 +306,23 @@ func (c *OpenClient) doAuthJSONWithUA(ctx context.Context, method, rawURL string
// tryRefreshTokenLocked 并发安全地刷新 access_token;成功返回 true(调用方
// 应使用内存中的新 token 重试原请求)。
//
// 拿到写锁后在锁内读取 oldAccess,与持锁期间的当前值对比:若已被其他
// goroutine 刷新过则直接复用新 token,避免并发请求连环轮转消耗 115 的
// 一次性 refresh_token。全程持写锁读写 token 字段,无 TOCTOU 窗口。
// failedAccess 是失败请求实际携带的 token。拿到写锁后与当前 token 对比:
// 若已被其他 goroutine 刷新过则直接复用,避免并发请求连环轮转消耗 115
// 的一次性 refresh_token。全程持写锁读写 token 字段,无 TOCTOU 窗口。
//
// 对"refresh_token 本身已失效/被吊销"(IsRefreshTokenDead,如 40140114/116/119/120)
// 这类不可恢复的错误直接放弃并清空内存 token(提示需重新授权)。
// 对其它失败(网络瞬时抖动、刷新接口可重试错误码等)做指数退避重试几次再放弃,
// 避免同步长任务中途 token 到期时恰好撞上一个短暂的刷新失败就整体失败。
func (c *OpenClient) tryRefreshTokenLocked(ctx context.Context) bool {
func (c *OpenClient) tryRefreshTokenLocked(ctx context.Context, failedAccess string) bool {
c.tokenMu.Lock()
// 在已持有写锁内读取当前 token 作为"刷新前快照",消除双重加锁窗口:
// 若在拿锁期间已有其他 goroutine 完成刷新,refreshTokenWhileLocked
// 内的 c.AccessToken != oldAccess 判断会立即命中并返回复用。
oldAccess := c.AccessToken
newToken, ok := c.refreshTokenWhileLocked(ctx, oldAccess)
// 请求发出后若其他 goroutine 已经刷新完成,直接复用新 token 重试;
// 不能再次轮换一次性的 refresh_token。
if failedAccess != "" && c.AccessToken != failedAccess {
c.tokenMu.Unlock()
return true
}
newToken, ok := c.refreshTokenWhileLocked(ctx, failedAccess)
c.tokenMu.Unlock()
// 回调必须在 tokenMu 释放后调用,避免上层在回调内访问客户端时死锁
if ok && newToken != nil && c.OnTokenRefreshed != nil {
@@ -140,6 +140,47 @@ func TestRefreshTokenDead(t *testing.T) {
}
}
func TestConcurrentTokenFailuresShareOneRefresh(t *testing.T) {
var mu sync.Mutex
refreshCalls := 0
mockAPI(t, func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/open/refreshToken" {
t.Errorf("unexpected path %s", r.URL.Path)
}
mu.Lock()
refreshCalls++
mu.Unlock()
time.Sleep(50 * time.Millisecond)
_, _ = w.Write([]byte(`{"state":true,"data":{"access_token":"at-new","refresh_token":"rt-new","expires_in":7200}}`))
})
client := NewOpenClient("100195129", "at-old", "rt-old")
var wg sync.WaitGroup
results := make(chan bool, 2)
for range 2 {
wg.Add(1)
go func() {
defer wg.Done()
results <- client.tryRefreshTokenLocked(context.Background(), "at-old")
}()
}
wg.Wait()
close(results)
for ok := range results {
if !ok {
t.Fatal("concurrent refresh should reuse the refreshed token")
}
}
mu.Lock()
defer mu.Unlock()
if refreshCalls != 1 {
t.Fatalf("refresh calls = %d, want 1", refreshCalls)
}
if client.CurrentAccessToken() != "at-new" {
t.Fatalf("access token = %q, want at-new", client.CurrentAccessToken())
}
}
func TestFsListAndDownload(t *testing.T) {
mockAPI(t, func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
+65 -3
View File
@@ -119,6 +119,60 @@ func (c *OpenClient) GetFsListFlat(ctx context.Context, cid string, offset, limi
return files, resp.Count, nil
}
// FindNamedContentInParent 在父目录下查找与本地内容一致的同名文件。
// 匹配条件:文件名完全一致、大小一致,且 SHA1 为空或与 expectedSHA1 大小写不敏感相等。
// 同时返回该目录下全部同名文件(含未匹配的脏副本),便于上传前清理。
// 目录过大时分页扫描,最多拉取 maxListPages 页(每页 pageSize 条)。
func (c *OpenClient) FindNamedContentInParent(ctx context.Context, parentCID, fileName, expectedSHA1 string, expectedSize int64) (matched *RemoteFile, sameName []RemoteFile, err error) {
fileName = strings.TrimSpace(fileName)
if fileName == "" {
return nil, nil, nil
}
const pageSize = 200
const maxListPages = 20 // 最多扫描 4000 项,元数据父目录通常远小于此
expectedSHA1 = strings.TrimSpace(expectedSHA1)
offset := 0
for page := 0; page < maxListPages; page++ {
files, _, listErr := c.GetFsList(ctx, parentCID, offset, pageSize)
if listErr != nil {
return nil, sameName, listErr
}
if len(files) == 0 {
break
}
for i := range files {
f := files[i]
if f.Category == TypeDir {
continue
}
// fta=0/2 表示未上传完成,不可作为已存在副本
if f.Fta == "0" || f.Fta == "2" {
continue
}
if f.FileName != fileName {
continue
}
sameName = append(sameName, f)
if matched != nil {
continue
}
if f.FileSize != expectedSize {
continue
}
remoteSha := strings.TrimSpace(f.Sha1)
if remoteSha == "" || remoteSha == "-" || strings.EqualFold(remoteSha, expectedSHA1) {
cp := f
matched = &cp
}
}
if len(files) < pageSize {
break
}
offset += len(files)
}
return matched, sameName, nil
}
// GetFsDetailByCid 查询文件(夹)详情。
func (c *OpenClient) GetFsDetailByCid(ctx context.Context, fileId string) (*RemoteFileDetail, error) {
params := map[string]string{"file_id": fileId}
@@ -423,24 +477,32 @@ func (c *OpenClient) GetToken(qrCode *QrCodeDataReturn) (*TokenData, error) {
// RefreshToken 刷新访问令牌。
func (c *OpenClient) RefreshToken(refreshToken string) (*TokenData, error) {
c.tokenMu.Lock()
if refreshToken == "" {
refreshToken = c.currentRefreshToken()
refreshToken = c.RefreshTokenStr
}
if refreshToken == "" {
c.tokenMu.Unlock()
return nil, fmt.Errorf("没有可用的 refresh_token")
}
token, err := c.doRefreshToken(refreshToken)
if err != nil {
// refresh_token 已失效时清空内存令牌(提示需重新授权)
if IsRefreshTokenDead(err) {
c.SetAuthToken("", "")
c.setAuthTokenLocked("", "")
}
c.tokenMu.Unlock()
return nil, err
}
if token.AccessToken == "" || token.RefreshToken == "" {
c.tokenMu.Unlock()
return nil, fmt.Errorf("115: 刷新返回空凭证(access_token/refresh_token 缺失)")
}
c.SetAuthToken(token.AccessToken, token.RefreshToken)
c.setAuthTokenLocked(token.AccessToken, token.RefreshToken)
c.tokenMu.Unlock()
if c.OnTokenRefreshed != nil {
c.OnTokenRefreshed(token.AccessToken, token.RefreshToken)
}
return token, nil
}
@@ -0,0 +1,54 @@
package cloud115
import (
"context"
"fmt"
"net/http"
"testing"
)
func TestFindNamedContentInParentMatchAndSameName(t *testing.T) {
sha := "AABBCCDDEEFF00112233445566778899AABBCCDD"
mockAPI(t, func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/open/ufile/files" {
t.Errorf("unexpected path %s", r.URL.Path)
}
w.Write([]byte(fmt.Sprintf(`{"state":true,"data":[
{"fid":"dir-1","fc":"0","fn":"sub","fs":0},
{"fid":"keep","fc":"1","fn":"a.nfo","fs":10,"sha1":%q,"fta":"1"},
{"fid":"dirty","fc":"1","fn":"a.nfo","fs":10,"sha1":"OTHER","fta":"1"},
{"fid":"other","fc":"1","fn":"b.nfo","fs":10,"sha1":%q,"fta":"1"},
{"fid":"incomplete","fc":"1","fn":"a.nfo","fs":10,"sha1":%q,"fta":"0"}
]}`, sha, sha, sha)))
})
c := NewOpenClient("100195125", "at1", "rt1")
matched, sameName, err := c.FindNamedContentInParent(context.Background(), "parent", "a.nfo", sha, 10)
if err != nil {
t.Fatal(err)
}
if matched == nil || matched.FileId != "keep" {
t.Fatalf("matched = %+v, want keep", matched)
}
if len(sameName) != 2 { // keep + dirty;incomplete 被 fta 过滤
t.Fatalf("sameName len=%d, want 2 (incomplete excluded)", len(sameName))
}
}
func TestFindNamedContentInParentNoMatch(t *testing.T) {
mockAPI(t, func(w http.ResponseWriter, r *http.Request) {
w.Write([]byte(`{"state":true,"data":[
{"fid":"x","fc":"1","fn":"a.nfo","fs":9,"sha1":"OTHER","fta":"1"}
]}`))
})
c := NewOpenClient("100195125", "at1", "rt1")
matched, sameName, err := c.FindNamedContentInParent(context.Background(), "parent", "a.nfo", "WANT", 10)
if err != nil {
t.Fatal(err)
}
if matched != nil {
t.Fatalf("expected no match, got %+v", matched)
}
if len(sameName) != 1 {
t.Fatalf("sameName should still list name hits, got %d", len(sameName))
}
}
+5 -18
View File
@@ -369,7 +369,10 @@ func (s *DanmakuService) searchTerms(ctx context.Context, mediaID string) (danma
} else if name := strings.TrimSpace(m.Title); name != "" {
term.name = name
} else {
term.name = strings.TrimSuffix(filepath.Base(m.Path), filepath.Ext(m.Path))
term.name = mediaSidecarBase(m.Path)
if term.name == "" {
term.name = strings.TrimSuffix(filepath.Base(m.Path), filepath.Ext(m.Path))
}
}
if m.EpisodeNum > 0 {
term.episode = strconv.Itoa(m.EpisodeNum)
@@ -527,23 +530,7 @@ func (s *DanmakuService) hashCachePut(stamp, hash string) {
// ("xxx.mkv.strm") — so a second strip removes a real video extension only
// (filepath.Ext would misread names like "xxx.第01话" as having an extension).
func danmakuMatchFileName(path string) string {
if path == "" {
return ""
}
clean := strings.ReplaceAll(path, "\\", "/")
if idx := strings.LastIndex(clean, "/"); idx >= 0 {
clean = clean[idx+1:]
}
base := filepath.Base(clean)
if ext := filepath.Ext(base); ext != "" {
base = strings.TrimSuffix(base, ext)
}
if second := strings.ToLower(filepath.Ext(base)); second != "" {
if _, ok := videoExtensions[second]; ok && second != ".strm" {
base = strings.TrimSuffix(base, filepath.Ext(base))
}
}
return strings.TrimSpace(base)
return mediaSidecarBase(path)
}
// mediaHash returns the dandanplay match hash (MD5 of the first 16MB of the
+22 -8
View File
@@ -538,21 +538,35 @@ func (e *EmbyService) resolveMediaPeople(ctx context.Context, m *model.Media) []
return []map[string]any{}
}
dir := filepath.Dir(m.Path)
ext := filepath.Ext(m.Path)
base := strings.TrimSuffix(m.Path, ext)
candidates := []string{
base + ".nfo",
filepath.Join(dir, "movie.nfo"),
filepath.Join(dir, "tvshow.nfo"),
candidates := make([]string, 0, 6)
seenPath := map[string]struct{}{}
add := func(path string) {
path = strings.TrimSpace(path)
if path == "" {
return
}
key := strings.ToLower(filepath.Clean(path))
if _, ok := seenPath[key]; ok {
return
}
seenPath[key] = struct{}{}
candidates = append(candidates, path)
}
// 共享词干优先(竞女01.mkv.strm → 竞女01.nfo),并兼容旧的单层剥扩展命名。
add(nfoPath(m.Path))
for _, base := range mediaSidecarBaseVariants(m.Path) {
add(filepath.Join(dir, base+".nfo"))
}
add(filepath.Join(dir, "movie.nfo"))
add(filepath.Join(dir, "tvshow.nfo"))
people := make([]map[string]any, 0)
seen := make(map[string]bool)
for _, p := range candidates {
if fi, err := os.Stat(p); err == nil && !fi.IsDir() {
doc, ok, err := decodeNFOFile(p)
if err == nil && ok && doc != nil {
doc, _, err := decodeNFOFile(p)
if err == nil && doc != nil {
for _, d := range doc.Directors {
name := strings.TrimSpace(d)
if name == "" {
+57
View File
@@ -0,0 +1,57 @@
package service
import (
"os"
"path/filepath"
"testing"
"github.com/truewhile/MeBox/internal/model"
)
func TestResolveMediaPeopleUsesSharedStemBesideKeepExtStrm(t *testing.T) {
svc := newTestEmbyService(t)
dir := t.TempDir()
mediaPath := filepath.Join(dir, "竞女01.mkv.strm")
nfoPath := filepath.Join(dir, "竞女01.nfo")
if err := os.WriteFile(mediaPath, []byte("http://example/play"), 0o644); err != nil {
t.Fatal(err)
}
nfo := `<movie>
<title>竞女01</title>
<director>导演甲</director>
<actor><name>演员乙</name><role>主角</role></actor>
</movie>`
if err := os.WriteFile(nfoPath, []byte(nfo), 0o644); err != nil {
t.Fatal(err)
}
people := svc.resolveMediaPeople(t.Context(), &model.Media{Path: mediaPath, Title: "竞女01"})
if len(people) != 2 {
t.Fatalf("people=%d want 2: %#v", len(people), people)
}
got := map[string]string{}
for _, p := range people {
got[p["Name"].(string)] = p["Type"].(string)
}
if got["导演甲"] != "Director" || got["演员乙"] != "Actor" {
t.Fatalf("unexpected people: %#v", people)
}
}
func TestResolveMediaPeopleStillReadsLegacyKeepExtNFO(t *testing.T) {
svc := newTestEmbyService(t)
dir := t.TempDir()
mediaPath := filepath.Join(dir, "竞女01.mkv.strm")
legacyNFO := filepath.Join(dir, "竞女01.mkv.nfo")
if err := os.WriteFile(mediaPath, []byte("http://example/play"), 0o644); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(legacyNFO, []byte(`<movie><director>旧导演</director></movie>`), 0o644); err != nil {
t.Fatal(err)
}
people := svc.resolveMediaPeople(t.Context(), &model.Media{Path: mediaPath})
if len(people) != 1 || people[0]["Name"] != "旧导演" {
t.Fatalf("expected legacy keep_ext nfo people, got %#v", people)
}
}
+1 -1
View File
@@ -321,7 +321,7 @@ func (e *EmbyService) mediaSource(ctx context.Context, m *model.Media, asEmbedde
func (e *EmbyService) baseMediaSource(ctx context.Context, m *model.Media, container string, isCloud bool, playURL string, directOnly bool) map[string]any {
return map[string]any{
"Id": m.ID,
"Name": m.Title,
"Name": MediaVersionLabel(*m),
"Path": embyMediaSourcePath(m),
"Container": container,
"Size": m.SizeBytes,
+42 -14
View File
@@ -22,18 +22,19 @@ import (
)
var (
patSEnE = regexp.MustCompile(`(?i)s(\d{1,2})e(\d{1,3})`)
patSEnERange = regexp.MustCompile(`(?i)s(\d{1,2})e(\d{1,3})\s*[-~–—]\s*(?:s(\d{1,2}))?e?(\d{1,3})(?:[^0-9]|$)`)
patDanglingSE = regexp.MustCompile(`(?i)(?:^|[\s._-])s\d{1,2}e(?:[\s._-]|$)`)
patNxE = regexp.MustCompile(`(\d{1,2})x(\d{1,3})`)
patEP = regexp.MustCompile(`(?i)(?:^|[^a-z])(?:e|ep)\.?\s*(\d{1,3})(?:[^0-9]|$)`)
patCN = regexp.MustCompile(`第\s*([0-9一二三四五六七八九十百零两]+)\s*[集话話期]`)
patCNRange = regexp.MustCompile(`第\s*([0-9一二三四五六七八九十百零两]+)\s*[-~–—]\s*([0-9一二三四五六七八九十百零两]+)\s*[集话話期]`)
patDashEpisode = regexp.MustCompile(`[\s._-][-–—]\s*(\d{1,3})(?:\s*(?:v\d+)?)?(?:\s*[\[\(._-]|$)`)
patSeasonFolder = regexp.MustCompile(`(?i)(?:^|[^a-z])(?:s|season)\.?\s*(\d{1,2})(?:[^0-9]|$)|第\s*([0-9一二三四五六七八九十百零两]+)\s*季`)
patSeasonOnly = regexp.MustCompile(`(?i)(?:^|[\s._-])(?:s|season)\.?\s*\d{1,2}(?:[\s._-]|$)`)
patBareEpisode = regexp.MustCompile(`^(?:第\s*)?0?(\d{1,3})(?:\s*(?:v\d+)?)?$`)
patSpecialSeason = regexp.MustCompile(`(?i)^(?:s0+|season[\s._-]*0+|special[\s._-]*episodes?|specials?|sp|ovas?|oads?|extras?|bonus(?:es)?|omake|番外篇?|特别篇|特別篇|特典|外传|外傳|总集篇|總集篇)$`)
patSEnE = regexp.MustCompile(`(?i)s(\d{1,2})e(\d{1,3})`)
patSEnERange = regexp.MustCompile(`(?i)s(\d{1,2})e(\d{1,3})\s*[-~–—]\s*(?:s(\d{1,2}))?e?(\d{1,3})(?:[^0-9]|$)`)
patDanglingSE = regexp.MustCompile(`(?i)(?:^|[\s._-])s\d{1,2}e(?:[\s._-]|$)`)
patNxE = regexp.MustCompile(`(\d{1,2})x(\d{1,3})`)
patEP = regexp.MustCompile(`(?i)(?:^|[^a-z])(?:e|ep)\.?\s*(\d{1,3})(?:[^0-9]|$)`)
patCN = regexp.MustCompile(`第\s*([0-9一二三四五六七八九十百零两]+)\s*[集话話期]`)
patCNRange = regexp.MustCompile(`第\s*([0-9一二三四五六七八九十百零两]+)\s*[-~–—]\s*([0-9一二三四五六七八九十百零两]+)\s*[集话話期]`)
patDashEpisode = regexp.MustCompile(`[\s._-][-–—]\s*(\d{1,3})(?:\s*(?:v\d+)?)?(?:\s*[\[\(._-]|$)`)
patSeasonFolder = regexp.MustCompile(`(?i)(?:^|[^a-z])(?:s|season)\.?\s*(\d{1,2})(?:[^0-9]|$)|第\s*([0-9一二三四五六七八九十百零两]+)\s*季`)
patSeasonOnly = regexp.MustCompile(`(?i)(?:^|[\s._-])(?:s|season)\.?\s*\d{1,2}(?:[\s._-]|$)`)
patBareEpisode = regexp.MustCompile(`^(?:第\s*)?0?(\d{1,3})(?:\s*(?:v\d+)?)?$`)
patSpecialSeason = regexp.MustCompile(`(?i)^(?:s0+|season[\s._-]*0+|special[\s._-]*episodes?|specials?|sp|ovas?|oads?|extras?|bonus(?:es)?|omake|番外篇?|特别篇|特別篇|特典|外传|外傳|总集篇|總集篇)$`)
patSeasonEpisodeZero = regexp.MustCompile(`(?i)s0*([1-9]\d?)e0+(?:[^0-9]|$)`)
// patCNSeason 匹配中文季/部标记,支持阿拉伯数字与中文数字(如「第二季」「第2部」)。
patCNSeason = regexp.MustCompile(`第\s*[0-9一二三四五六七八九十百零两]+\s*[季部]`)
)
@@ -41,7 +42,10 @@ var (
// ParseEpisode tries to extract (season, episode) from an arbitrary filename.
// Returns (0, 0) when nothing recognisable is found.
func ParseEpisode(path string) (season, episode int) {
name := strings.TrimSuffix(filepath.Base(path), filepath.Ext(path))
name := mediaSidecarBase(path)
if name == "" {
name = strings.TrimSuffix(filepath.Base(path), filepath.Ext(path))
}
if m := patSEnE.FindStringSubmatch(name); len(m) == 3 {
season = mustAtoi(m[1])
@@ -90,13 +94,37 @@ func ParseEpisode(path string) (season, episode int) {
return 0, 0
}
// onlineEpisodeIdentityFromPath converts the common anime SxxE00 convention
// to provider-style specials. For example, S01E00 becomes S00E01 and S02E00
// becomes S00E02. Normal episodes retain their parsed identity.
func onlineEpisodeIdentityFromPath(path string) (season, episode int) {
season, episode = ParseEpisode(path)
if episode != 0 {
return season, episode
}
name := mediaSidecarBase(path)
if name == "" {
name = strings.TrimSuffix(filepath.Base(path), filepath.Ext(path))
}
if m := patSeasonEpisodeZero.FindStringSubmatch(name); len(m) == 2 {
specialEpisode := mustAtoi(m[1])
if specialEpisode > 0 {
return 0, specialEpisode
}
}
return season, episode
}
type episodeRef struct {
Season int
Episode int
}
func episodeRefsFromTitle(path string) []episodeRef {
name := strings.TrimSuffix(filepath.Base(path), filepath.Ext(path))
name := mediaSidecarBase(path)
if name == "" {
name = strings.TrimSuffix(filepath.Base(path), filepath.Ext(path))
}
if refs := parseSEpisodeRange(name); len(refs) > 0 {
return refs
}
+20
View File
@@ -68,3 +68,23 @@ func TestEpisodeRefsFromTitleParsesRanges(t *testing.T) {
}
}
}
func TestOnlineEpisodeIdentityFromPathMapsAnimeEpisodeZeroToSpecials(t *testing.T) {
cases := []struct {
path string
wantSeason int
wantEpisode int
}{
{`动漫/路人女主/Season 1/S01E00 - 爱与青春的杀必死回.mkv`, 0, 1},
{`动漫/路人女主/Season 2/S02E00 - 恋爱与纯情的杀必死回.mkv`, 0, 2},
{`动漫/路人女主/Season 2/S02E03 - 初稿与二稿.mkv`, 2, 3},
{`动漫/路人女主/Specials/S00E04.mkv`, 0, 4},
}
for _, tc := range cases {
season, episode := onlineEpisodeIdentityFromPath(tc.path)
if season != tc.wantSeason || episode != tc.wantEpisode {
t.Errorf("onlineEpisodeIdentityFromPath(%q) = (%d, %d), want (%d, %d)",
tc.path, season, episode, tc.wantSeason, tc.wantEpisode)
}
}
}
+1 -1
View File
@@ -73,7 +73,7 @@ func isLocalImagePath(raw string) bool {
}
ext := strings.ToLower(filepath.Ext(raw))
switch ext {
case ".jpg", ".jpeg", ".png", ".webp", ".gif", ".bmp", ".tbn":
case ".jpg", ".jpeg", ".png", ".webp", ".gif", ".bmp", ".tbn", ".img":
return true
default:
return false
+10 -4
View File
@@ -13,6 +13,12 @@ import (
"github.com/truewhile/MeBox/internal/config"
)
func TestIsLocalImagePathAcceptsLegacyImgSidecar(t *testing.T) {
if !isLocalImagePath(filepath.Join(t.TempDir(), "movie-poster.img")) {
t.Fatal("legacy .img sidecar should remain displayable")
}
}
var testJPEG = []byte{
0xff, 0xd8, 0xff, 0xe0, 0x00, 0x10, 'J', 'F', 'I', 'F',
0x00, 0x01, 0x01, 0x00, 0x00, 0x01, 0x00, 0x01,
@@ -117,12 +123,12 @@ func TestIsPrivateHost(t *testing.T) {
// Hostnames must NOT be blocked even though GFW DNS poisoning may resolve
// them to private/loopback IPs — blocking them broke legitimate posters.
allowed := []string{"image.tmdb.org", "lain.bgm.tv", "example.com", "8.8.8.8"}
for _, h := range allowed {
if isPrivateHost(h) {
t.Errorf("isPrivateHost(%q) = true, want false", h)
}
for _, h := range allowed {
if isPrivateHost(h) {
t.Errorf("isPrivateHost(%q) = true, want false", h)
}
}
}
func TestImageProxyAllowedRemoteHostBypassesPrivateCheck(t *testing.T) {
proxy := NewImageProxy(&config.Config{Cache: config.CacheConfig{CacheDir: filepath.Join(t.TempDir(), "cache")}}, zap.NewNop())
+48 -28
View File
@@ -54,39 +54,59 @@ func mergeArtworkMetadata(meta *LocalMetadata, mediaPath, showBaseDir string) {
}
func localPosterCandidates(mediaPath string) []string {
base := strings.TrimSuffix(filepath.Base(mediaPath), filepath.Ext(mediaPath))
names := []string{
base + "-poster",
base + ".poster",
"poster",
"folder",
"cover",
"movie",
"show",
base + "-cover",
base + ".cover",
base,
base + "-thumb",
base + ".thumb",
"thumb",
names := make([]string, 0, 24)
seen := map[string]struct{}{}
add := func(name string) {
name = strings.TrimSpace(name)
if name == "" {
return
}
key := strings.ToLower(name)
if _, ok := seen[key]; ok {
return
}
seen[key] = struct{}{}
names = append(names, name)
}
for _, base := range mediaSidecarBaseVariants(mediaPath) {
add(base + "-poster")
add(base + ".poster")
add(base + "-cover")
add(base + ".cover")
add(base)
add(base + "-thumb")
add(base + ".thumb")
}
for _, name := range []string{"poster", "folder", "cover", "movie", "show", "thumb"} {
add(name)
}
return append(adultArtworkNameCandidates(mediaPath, "poster"), names...)
}
func localBackdropCandidates(mediaPath string) []string {
base := strings.TrimSuffix(filepath.Base(mediaPath), filepath.Ext(mediaPath))
names := []string{
base + "-fanart",
base + ".fanart",
base + "-backdrop",
base + ".backdrop",
base + "-background",
"fanart",
"backdrop",
"background",
"landscape",
"banner",
"clearart",
names := make([]string, 0, 24)
seen := map[string]struct{}{}
add := func(name string) {
name = strings.TrimSpace(name)
if name == "" {
return
}
key := strings.ToLower(name)
if _, ok := seen[key]; ok {
return
}
seen[key] = struct{}{}
names = append(names, name)
}
for _, base := range mediaSidecarBaseVariants(mediaPath) {
add(base + "-fanart")
add(base + ".fanart")
add(base + "-backdrop")
add(base + ".backdrop")
add(base + "-background")
}
for _, name := range []string{"fanart", "backdrop", "background", "landscape", "banner", "clearart"} {
add(name)
}
return append(adultArtworkNameCandidates(mediaPath, "backdrop"), names...)
}
+4 -5
View File
@@ -28,13 +28,12 @@ func ReadLocalMetadata(mediaPath, libraryRoot string, seriesLike bool) (*LocalMe
func findMovieNFO(mediaPath, libraryRoot string) (*nfoDocument, string, error) {
mediaDir := filepath.Dir(mediaPath)
base := strings.TrimSuffix(filepath.Base(mediaPath), filepath.Ext(mediaPath))
adultCode := AdultCodeFromMediaPath(mediaPath)
names := []string{
base + ".nfo",
"movie.nfo",
filepath.Base(mediaDir) + ".nfo",
names := make([]string, 0, 8)
for _, base := range mediaSidecarBaseVariants(mediaPath) {
names = append(names, base+".nfo")
}
names = append(names, "movie.nfo", filepath.Base(mediaDir)+".nfo")
if adultCode != "" {
names = append([]string{adultCode + ".nfo", strings.ReplaceAll(adultCode, "-", "") + ".nfo"}, names...)
}
+46 -2
View File
@@ -39,10 +39,19 @@ func (r ManualScrapeRequest) EpisodeArtworkOption() *bool {
}
func (s *ScraperService) ApplyManualMatch(ctx context.Context, mediaID string, req ManualScrapeRequest) (*model.Media, error) {
return s.ApplyManualMatchWithOptions(ctx, mediaID, req, ScrapeOptions{EpisodeArtwork: req.EpisodeArtworkOption()})
return s.ApplyManualMatchWithOptions(ctx, mediaID, req, ScrapeOptions{
EpisodeArtwork: req.EpisodeArtworkOption(),
ForceRematch: true,
RebuildIdentity: true,
})
}
func (s *ScraperService) ApplyManualMatchWithOptions(ctx context.Context, mediaID string, req ManualScrapeRequest, options ScrapeOptions) (*model.Media, error) {
// A user-triggered match is authoritative. Do not let metadata written by a
// previous scrape influence the new result.
options.ForceRematch = true
options.RebuildIdentity = true
media, err := s.repo.Media.FindByID(ctx, mediaID)
if err != nil || media == nil {
return nil, errors.New("media not found")
@@ -55,12 +64,32 @@ func (s *ScraperService) ApplyManualMatchWithOptions(ctx context.Context, mediaI
if strings.TrimSpace(match.Title) == "" {
return nil, errors.New("manual match title required")
}
rebuildManualScrapeIdentity(media, lib, match)
if err := s.applyProviderMatchWithOptions(ctx, media, lib, match, options); err != nil {
return nil, err
}
return s.repo.Media.FindByID(ctx, mediaID)
}
func rebuildManualScrapeIdentity(media *model.Media, lib *model.Library, match *Match) {
if media == nil || match == nil {
return
}
mediaType := normalizeOrganizeMediaType(match.MediaType)
if mediaType == "" && librarySupportsSeasons(lib) {
mediaType = "tv"
}
switch mediaType {
case "tv", "anime", "variety":
media.SeasonNum, media.EpisodeNum = onlineEpisodeIdentityFromPath(media.Path)
media.EpisodeTitle = ""
case "movie", "adult":
media.SeasonNum = 0
media.EpisodeNum = 0
media.EpisodeTitle = ""
}
}
func (s *ScraperService) manualRequestMatch(ctx context.Context, req ManualScrapeRequest) (*Match, error) {
source := strings.ToLower(strings.TrimSpace(req.Source))
mediaType := normalizeMediaType(req.MediaType, req.Title, "")
@@ -98,7 +127,22 @@ func (s *ScraperService) manualRequestMatch(ctx context.Context, req ManualScrap
}
}
case source == "adult":
if match := s.manualAdultMatch(ctx, firstText(req.OriginalName, req.Title)); match != nil {
var match *Match
if s.adult != nil {
match, _ = s.adult.GetMetaTubeCandidate(ctx, req.TheTVDBID, req.DoubanID)
}
if match == nil {
match = s.manualAdultMatch(ctx, firstText(req.OriginalName, req.Title))
}
if match != nil {
// Older search responses used the portrait poster as a backdrop
// fallback. Do not let that placeholder replace a real preview
// image or processed primary image fetched from MetaTube.
if strings.TrimSpace(match.BackdropURL) != "" &&
strings.TrimSpace(req.BackdropURL) == strings.TrimSpace(req.PosterURL) {
req.BackdropURL = ""
req.PosterURL = ""
}
return mergeManualRequestIntoMatch(match, req), nil
}
}
+156
View File
@@ -4,6 +4,7 @@ import (
"encoding/json"
"net/http"
"net/http/httptest"
"path/filepath"
"strings"
"sync/atomic"
"testing"
@@ -34,6 +35,74 @@ func TestManualRequestMatchFallsBackToCandidatePayload(t *testing.T) {
}
}
func TestManualAdultMatchUsesSelectedMetaTubeDetailsAndRealBackdrop(t *testing.T) {
var detailCalls atomic.Int32
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/v1/movies/AVE/94600" {
http.NotFound(w, r)
return
}
detailCalls.Add(1)
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(struct {
Data MetaTubeMovieInfo `json:"data"`
}{
Data: MetaTubeMovieInfo{
ID: "94600",
Number: "CWPBD-138",
Title: "Selected title",
Provider: "AVE",
CoverURL: "https://example.com/poster.jpg",
PreviewImages: []string{"https://example.com/backdrop.jpg"},
},
})
}))
defer upstream.Close()
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
if err != nil {
t.Fatal(err)
}
if err := db.AutoMigrate(&model.Setting{}, &model.APIConfig{}); err != nil {
t.Fatal(err)
}
repos := repository.New(db)
if err := repos.Setting.Set(t.Context(), "adult.scraper.engine", "metatube"); err != nil {
t.Fatal(err)
}
if err := repos.Setting.Set(t.Context(), "adult.scraper.metatube_server", upstream.URL); err != nil {
t.Fatal(err)
}
adult := NewAdultProvider(zap.NewNop(), nil, repos)
scraper := &ScraperService{adult: adult}
match, err := scraper.manualRequestMatch(t.Context(), ManualScrapeRequest{
Source: "adult",
MediaType: "adult",
Title: "CWPBD-138 Selected title",
OriginalName: "CWPBD-138",
PosterURL: "https://example.com/search-cover.jpg",
BackdropURL: "https://example.com/search-cover.jpg",
DoubanID: "94600",
TheTVDBID: "AVE",
NSFW: true,
})
if err != nil {
t.Fatal(err)
}
if detailCalls.Load() != 1 {
t.Fatalf("selected MetaTube detail calls = %d, want 1", detailCalls.Load())
}
wantPoster := upstream.URL + "/v1/images/primary/AVE/94600?auto=true&pos=1&quality=90&ratio=-1&url=https%3A%2F%2Fexample.com%2Fposter.jpg"
if match.PosterURL != wantPoster {
t.Fatalf("selected poster = %q, want %q", match.PosterURL, wantPoster)
}
wantBackdrop := upstream.URL + "/v1/images/backdrop/AVE/94600?quality=90"
if match.BackdropURL != wantBackdrop {
t.Fatalf("backdrop = %q, want %q", match.BackdropURL, wantBackdrop)
}
}
func TestParsePositiveIDStringAcceptsProviderPrefixes(t *testing.T) {
cases := map[string]string{
"12345": "12345",
@@ -627,3 +696,90 @@ func TestApplyManualMovieMatchClearsEpisodeMarkers(t *testing.T) {
t.Fatalf("stale external IDs were not cleared for manual movie fallback: tmdb=%d thetvdb=%q", got.TMDbID, got.TheTVDBID)
}
}
func TestApplyManualTVMatchRebuildsEpisodeIdentityFromPath(t *testing.T) {
var requestedEpisodePath string
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
switch r.URL.Path {
case "/tv/69367":
_ = json.NewEncoder(w).Encode(map[string]any{
"id": 69367,
"name": "路人女主的养成方法",
"original_name": "冴えない彼女の育てかた",
"overview": "整剧简介",
"poster_path": "/show.jpg",
"backdrop_path": "/show-backdrop.jpg",
"first_air_date": "2015-01-09",
"vote_average": 7.0,
})
case "/tv/69367/season/0/episode/1":
requestedEpisodePath = r.URL.Path
_ = json.NewEncoder(w).Encode(map[string]any{
"name": "爱与青春的杀必死回",
"overview": "特别篇简介",
"still_path": "/special.jpg",
"air_date": "2015-01-07",
"vote_average": 7.5,
"runtime": 24,
})
default:
http.NotFound(w, r)
}
}))
defer upstream.Close()
db, err := gorm.Open(sqlite.Open("file::memory:?cache=shared"), &gorm.Config{})
if err != nil {
t.Fatal(err)
}
if err := db.AutoMigrate(&model.Library{}, &model.Series{}, &model.Media{}); err != nil {
t.Fatal(err)
}
repos := repository.New(db)
cfg := &config.Config{}
cfg.Secrets.TMDbAPIKey = "test-key"
cfg.Secrets.TMDbAPIProxy = upstream.URL
log := zap.NewNop()
scraper := NewScraperService(cfg, log, repos, NewTMDbProvider(cfg, log, nil), nil, nil, nil, NewHub(log))
root := t.TempDir()
lib := model.Library{Name: "动漫", Path: root, Type: "anime", Enabled: true}
if err := repos.DB.Create(&lib).Error; err != nil {
t.Fatal(err)
}
media := model.Media{
LibraryID: lib.ID,
Title: "旧标题",
Path: filepath.Join(root, "路人女主的养成方法 (2015)", "Season 1", "S01E00 - 爱与青春的杀必死回.strm"),
SeasonNum: 1,
EpisodeNum: 1,
EpisodeTitle: "错误百出的序曲",
SeriesID: "stale-series",
TMDbID: 111,
BangumiID: 222,
ScrapeStatus: "matched",
}
if err := repos.DB.Create(&media).Error; err != nil {
t.Fatal(err)
}
got, err := scraper.ApplyManualMatch(t.Context(), media.ID, ManualScrapeRequest{
Source: "tmdb",
MediaType: "tv",
Title: "路人女主的养成方法",
TMDbID: 69367,
})
if err != nil {
t.Fatal(err)
}
if requestedEpisodePath != "/tv/69367/season/0/episode/1" {
t.Fatalf("episode details path = %q, want season-zero special", requestedEpisodePath)
}
if got.SeasonNum != 0 || got.EpisodeNum != 1 || got.EpisodeTitle != "爱与青春的杀必死回" {
t.Fatalf("rebuilt episode identity = S%02dE%02d %q", got.SeasonNum, got.EpisodeNum, got.EpisodeTitle)
}
if got.SeriesID != "" || got.TMDbID != 69367 || got.BangumiID != 0 {
t.Fatalf("stale scrape identity survived manual rematch: %#v", got)
}
}
+62
View File
@@ -2,6 +2,8 @@ package service
import (
"context"
"sort"
"strings"
"time"
"go.uber.org/zap"
@@ -111,3 +113,63 @@ func (s *MediaService) GetMedia(ctx context.Context, id string) (*model.Media, e
*media = items[0]
return media, nil
}
// GetMediaItem 返回媒体详情,并附带同片多版本列表(用于详情页/播放器切换)。
func (s *MediaService) GetMediaItem(ctx context.Context, id string) (*MediaItem, error) {
media, err := s.GetMedia(ctx, id)
if err != nil || media == nil {
return nil, err
}
versions, err := s.listVersionSiblings(ctx, media)
if err != nil {
return nil, err
}
item := &MediaItem{Media: *media}
if len(versions) > 1 {
item.Versions = versions
}
return item, nil
}
// listVersionSiblings 查找与当前条目同属一个版本组的全部媒体(含自身)。
func (s *MediaService) listVersionSiblings(ctx context.Context, media *model.Media) ([]model.Media, error) {
if media == nil || strings.TrimSpace(media.ID) == "" {
return nil, nil
}
key := mediaVersionGroupKey(*media)
if key == "" {
return []model.Media{*media}, nil
}
libraryIDs, err := MergedLibraryIDsForLibrary(ctx, s.repo, media.LibraryID)
if err != nil {
return nil, err
}
if len(libraryIDs) == 0 {
libraryIDs = []string{media.LibraryID}
}
filter := repository.MediaQueryFilter{IncludeNSFW: true}
candidates, err := s.repo.Media.ListByLibrariesFilteredNoCount(ctx, libraryIDs, 0, 5000, filter)
if err != nil {
return nil, err
}
s.attachLibraryMetadata(ctx, candidates)
matched := make([]model.Media, 0, 4)
for _, row := range candidates {
if mediaVersionGroupKey(row) == key {
matched = append(matched, row)
}
}
if len(matched) == 0 {
return []model.Media{*media}, nil
}
sort.SliceStable(matched, func(i, j int) bool {
if matched[i].ID == media.ID {
return true
}
if matched[j].ID == media.ID {
return false
}
return betterMediaVersion(matched[i], matched[j])
})
return matched, nil
}
+62
View File
@@ -0,0 +1,62 @@
package service
import (
"path/filepath"
"strings"
)
// mediaSidecarBase 返回媒体文件用于配对 NFO/海报/字幕等元数据的共享基名。
// 同片多版本(含 keep_ext 的 name.mkv.strm / name.mp4.strm)应落到同一词干,
// 从而忽略中间的视频扩展,按「同一影片」匹配边车文件。
//
// movie.mkv → movie
// movie.strm → movie
// movie.mkv.strm → movie
// Show.S01E01.mp4.strm → Show.S01E01
func mediaSidecarBase(mediaPath string) string {
clean := strings.ReplaceAll(strings.TrimSpace(mediaPath), "\\", "/")
if clean == "" {
return ""
}
return mediaFileStem(filepath.Base(clean))
}
// mediaFileStem 去掉最终扩展名;若为 .strm 且前一层是视频扩展,再剥一层。
func mediaFileStem(name string) string {
name = strings.TrimSpace(name)
if name == "" || name == "." {
return ""
}
ext := filepath.Ext(name)
stem := strings.TrimSuffix(name, ext)
if strings.EqualFold(ext, ".strm") {
if second := strings.ToLower(filepath.Ext(stem)); second != "" {
if _, ok := videoExtensions[second]; ok && second != ".strm" {
stem = strings.TrimSuffix(stem, filepath.Ext(stem))
}
}
}
return strings.TrimSpace(stem)
}
// mediaSidecarBaseVariants 返回匹配用的基名候选:共享词干优先,其次保留单层剥扩展
// (兼容历史上写成 name.mkv.nfo / name.mkv-poster.jpg 的边车)。
func mediaSidecarBaseVariants(mediaPath string) []string {
stem := mediaSidecarBase(mediaPath)
single := strings.TrimSuffix(filepath.Base(strings.TrimSpace(mediaPath)), filepath.Ext(mediaPath))
single = strings.TrimSpace(single)
out := make([]string, 0, 2)
seen := map[string]struct{}{}
for _, item := range []string{stem, single} {
if item == "" || item == "." {
continue
}
key := strings.ToLower(item)
if _, ok := seen[key]; ok {
continue
}
seen[key] = struct{}{}
out = append(out, item)
}
return out
}
+107
View File
@@ -0,0 +1,107 @@
package service
import (
"os"
"path/filepath"
"strings"
"testing"
"github.com/truewhile/MeBox/internal/model"
"github.com/truewhile/MeBox/internal/repository"
)
func TestMediaSidecarBaseIgnoresKeepExt(t *testing.T) {
cases := map[string]string{
"/media/竞女01.mkv.strm": "竞女01",
"/media/竞女01.mp4.strm": "竞女01",
"/media/竞女01.strm": "竞女01",
"/media/竞女01.mkv": "竞女01",
"/lib/Show.S01E02.mkv.strm": "Show.S01E02",
`C:/lib/Show.S01E02.mkv.strm`: "Show.S01E02",
}
for in, want := range cases {
if got := mediaSidecarBase(in); got != want {
t.Fatalf("mediaSidecarBase(%q)=%q want %q", in, got, want)
}
}
}
func TestNFOPathUsesSharedStemForKeepExt(t *testing.T) {
got := nfoPath("/strm/竞女01.mkv.strm")
want := filepath.Join("/strm", "竞女01.nfo")
if got != want {
t.Fatalf("nfoPath keep_ext = %q want %q", got, want)
}
if nfoPath("/strm/竞女01.mp4.strm") != want {
t.Fatalf("mkv/mp4 versions should share nfo path")
}
}
func TestFindMovieNFOMatchesSharedStemBesideKeepExtStrm(t *testing.T) {
dir := t.TempDir()
media := filepath.Join(dir, "竞女01.mkv.strm")
nfo := filepath.Join(dir, "竞女01.nfo")
writeFileContent(t, media, "http://example/play")
writeFileContent(t, nfo, `<movie><title>竞女01</title></movie>`)
doc, path, err := findMovieNFO(media, dir)
if err != nil {
t.Fatal(err)
}
if path != nfo {
t.Fatalf("path=%q want %q", path, nfo)
}
if doc == nil || strings.TrimSpace(doc.Title) != "竞女01" {
t.Fatalf("unexpected nfo doc: %#v", doc)
}
}
func TestLocalPosterCandidatesIncludeSharedStem(t *testing.T) {
cands := localPosterCandidates("/strm/竞女01.mkv.strm")
found := false
for _, name := range cands {
if name == "竞女01-poster" || name == "竞女01" {
found = true
break
}
}
if !found {
t.Fatalf("expected shared stem poster candidates, got %#v", cands)
}
}
func TestSubtitleDiscoveryMatchesSharedStemBesideKeepExt(t *testing.T) {
dir := t.TempDir()
mediaPath := filepath.Join(dir, "竞女01.mkv.strm")
subPath := filepath.Join(dir, "竞女01.zh.srt")
writeFileContent(t, mediaPath, "http://example/play")
writeFileContent(t, subPath, "1\n00:00:01,000 --> 00:00:02,000\nhi\n")
db := newServiceTestDB(t, &model.Media{})
repos := repository.New(db)
media := model.Media{Title: "竞女01", Path: mediaPath}
if err := repos.Media.Upsert(t.Context(), &media); err != nil {
t.Fatal(err)
}
svc := &SubtitleService{repo: repos}
tracks, err := svc.discoverUncached(t.Context(), media.ID)
if err != nil {
t.Fatal(err)
}
if len(tracks) != 1 {
t.Fatalf("tracks=%d want 1 (%#v)", len(tracks), tracks)
}
if tracks[0].Path != subPath {
t.Fatalf("track path=%q want %q", tracks[0].Path, subPath)
}
}
func writeFileContent(t *testing.T, path, data string) {
t.Helper()
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(path, []byte(data), 0o644); err != nil {
t.Fatal(err)
}
}
+137
View File
@@ -2,6 +2,7 @@ package service
import (
"fmt"
"path/filepath"
"sort"
"strings"
@@ -119,6 +120,25 @@ func GroupMediaVersions(items []model.Media) []MediaItem {
return groupMediaVersions(items)
}
// GroupEpisodeVersionsForDisplay folds encoding/container variants into one
// episode while preserving season/episode ordering for episode-list APIs.
func GroupEpisodeVersionsForDisplay(items []model.Media) []MediaItem {
grouped := groupMediaVersions(items)
if grouped == nil {
return []MediaItem{}
}
sort.SliceStable(grouped, func(i, j int) bool {
if grouped[i].SeasonNum != grouped[j].SeasonNum {
return grouped[i].SeasonNum < grouped[j].SeasonNum
}
if grouped[i].EpisodeNum != grouped[j].EpisodeNum {
return grouped[i].EpisodeNum < grouped[j].EpisodeNum
}
return grouped[i].CreatedAt.Before(grouped[j].CreatedAt)
})
return grouped
}
func mediaVersionGroupKey(m model.Media) string {
// 远程 Emby 挂载条目保持独立,不与其它远程条目或本地条目折叠合并。
if IsEmbyRemoteID(m.ID) {
@@ -179,6 +199,7 @@ func mediaVersionGroupKey(m model.Media) string {
}
return "thetvdb:" + strings.ToLower(strings.TrimSpace(m.TheTVDBID))
}
title := firstNonEmpty(m.OriginalName, m.Title)
titleYear := 0
if title == "" {
@@ -187,6 +208,10 @@ func mediaVersionGroupKey(m model.Media) string {
title, titleYear = mediaVersionTitleKey(title)
}
if title == "" {
// 无标题时退回同目录词干(覆盖 keep_ext 的 name.mkv.strm / name.mp4.strm)
if stemKey := mediaVersionStemGroupKey(m, libKey); stemKey != "" {
return stemKey
}
return ""
}
year := m.Year
@@ -202,6 +227,114 @@ func mediaVersionGroupKey(m model.Media) string {
return fmt.Sprintf("movie:%s:%d", title, year)
}
// mediaVersionStemGroupKey 按「库 + 父目录 + 文件词干」折叠多版本
// (如 竞女01.mkv.strm 与 竞女01.mp4.strm)。
func mediaVersionStemGroupKey(m model.Media, libKey string) string {
path := strings.ReplaceAll(strings.TrimSpace(m.Path), "\\", "/")
if path == "" || strings.HasPrefix(strings.ToLower(path), "cloud://") {
return ""
}
dir := ""
base := path
if idx := strings.LastIndex(path, "/"); idx >= 0 {
dir = path[:idx]
base = path[idx+1:]
}
stem := mediaVersionFileStem(base)
if stem == "" {
return ""
}
stem = normalizeMediaVersionText(stem)
if stem == "" {
return ""
}
if libKey == "" {
libKey = "_"
}
return fmt.Sprintf("stem:%s:%s:%s", libKey, strings.ToLower(dir), stem)
}
// mediaVersionFileStem 去掉最终扩展名;若为 .strm 且前一层是视频扩展,再剥一层。
func mediaVersionFileStem(name string) string {
return mediaFileStem(name)
}
// MediaVersionLabel 生成版本切换展示名(分辨率 / 容器 / 编码 / 体积 / 文件名)。
func MediaVersionLabel(m model.Media) string {
parts := make([]string, 0, 4)
if m.Height > 0 {
parts = append(parts, fmt.Sprintf("%dp", m.Height))
} else if m.Width > 0 {
parts = append(parts, fmt.Sprintf("%dw", m.Width))
}
container := strings.Trim(strings.ToLower(strings.TrimSpace(m.Container)), ". ")
if container == "" || container == "strm" {
container = mediaVersionContainerFromPath(m.Path, m.STRMURL)
}
if container != "" && container != "strm" {
parts = append(parts, strings.ToUpper(container))
}
if codec := strings.TrimSpace(m.VideoCodec); codec != "" {
parts = append(parts, strings.ToUpper(codec))
}
if m.SizeBytes > 0 {
parts = append(parts, formatMediaSize(m.SizeBytes))
}
if len(parts) > 0 {
return strings.Join(parts, " · ")
}
base := filepath.Base(strings.ReplaceAll(strings.TrimSpace(m.Path), "\\", "/"))
if base == "" || base == "." {
return firstNonEmpty(m.Title, m.OriginalName, m.ID)
}
return base
}
func mediaVersionContainerFromPath(path, strmURL string) string {
base := filepath.Base(strings.ReplaceAll(strings.TrimSpace(path), "\\", "/"))
ext := strings.ToLower(filepath.Ext(base))
name := strings.TrimSuffix(base, ext)
if ext == ".strm" {
if second := strings.ToLower(filepath.Ext(name)); second != "" {
if _, ok := videoExtensions[second]; ok {
return strings.TrimPrefix(second, ".")
}
}
// 从 strm 播放 URL 的 /video.mkv 推断
u := strings.ToLower(strmURL)
if idx := strings.LastIndex(u, "/video."); idx >= 0 {
rest := u[idx+len("/video."):]
if end := strings.IndexAny(rest, "?#&/"); end >= 0 {
rest = rest[:end]
}
rest = strings.Trim(rest, ".")
if rest != "" {
return rest
}
}
return "strm"
}
if ext != "" {
if _, ok := videoExtensions[ext]; ok {
return strings.TrimPrefix(ext, ".")
}
}
return strings.TrimPrefix(ext, ".")
}
func formatMediaSize(bytes int64) string {
if bytes < 1024 {
return fmt.Sprintf("%d B", bytes)
}
const unit = 1024
div, exp := int64(unit), 0
for n := bytes / unit; n >= unit; n /= unit {
div *= unit
exp++
}
return fmt.Sprintf("%.1f %cB", float64(bytes)/float64(div), "KMGTPE"[exp])
}
func mediaVersionTitleKey(value string) (string, int) {
cleaned, year := CleanQuery(value)
if strings.TrimSpace(cleaned) == "" {
@@ -232,6 +365,10 @@ func normalizeMediaVersionText(value string) string {
if _, noise := noiseTokenSet[field]; noise {
continue
}
// 去掉视频容器词干残留(keep_ext / 旧标题「竞女01 mkv」)
if _, ok := videoExtensions["."+field]; ok {
continue
}
out = append(out, field)
}
return strings.Join(out, " ")
+63
View File
@@ -1,6 +1,7 @@
package service
import (
"strings"
"testing"
"time"
@@ -48,6 +49,28 @@ func TestGroupMediaVersionsMergesEpisodeByExternalIDAcrossLibraries(t *testing.T
}
}
func TestGroupEpisodeVersionsForDisplayMergesAndKeepsEpisodeOrder(t *testing.T) {
rows := []model.Media{
{Base: model.Base{ID: "ep2"}, LibraryID: "tv", Title: "Show", SeasonNum: 1, EpisodeNum: 2, TMDbID: 99, Path: "/show/s01e02.mkv"},
{Base: model.Base{ID: "ep1-small"}, LibraryID: "tv", Title: "Show", SeasonNum: 1, EpisodeNum: 1, TMDbID: 99, Path: "/show/s01e01.mp4", SizeBytes: 100},
{Base: model.Base{ID: "ep1-large"}, LibraryID: "tv", Title: "Show", SeasonNum: 1, EpisodeNum: 1, TMDbID: 99, Path: "/show/s01e01.mkv", SizeBytes: 200},
}
grouped := GroupEpisodeVersionsForDisplay(rows)
if len(grouped) != 2 {
t.Fatalf("grouped len = %d, want 2: %#v", len(grouped), grouped)
}
if grouped[0].EpisodeNum != 1 || len(grouped[0].Versions) != 2 {
t.Fatalf("episode 1 was not merged first: %#v", grouped)
}
if grouped[0].ID != "ep1-large" {
t.Fatalf("primary version = %q, want ep1-large", grouped[0].ID)
}
if grouped[1].EpisodeNum != 2 {
t.Fatalf("second item episode = %d, want 2", grouped[1].EpisodeNum)
}
}
func TestGroupMediaVersionsMergesMovieEncodingVariants(t *testing.T) {
hd := model.Media{
LibraryID: "movies",
@@ -264,3 +287,43 @@ func TestGroupMediaVersionsDoesNotMergeAcrossDifferentLibrariesForMovies(t *test
t.Fatalf("expected 2 separate groups for movies in different libraries, got %d", len(grouped))
}
}
func TestGroupMediaVersionsMergesKeepExtStrmVariants(t *testing.T) {
mkv := model.Media{
LibraryID: "movies",
Title: "竞女01",
Path: "/strm/竞女01.mkv.strm",
SizeBytes: 500,
STRMURL: "/api/strm/play/cloud115/video.mkv?pickcode=a",
}
mp4 := model.Media{
LibraryID: "movies",
Title: "竞女01",
Path: "/strm/竞女01.mp4.strm",
SizeBytes: 100,
STRMURL: "/api/strm/play/cloud115/video.mp4?pickcode=b",
}
grouped := groupMediaVersions([]model.Media{mkv, mp4})
if len(grouped) != 1 {
t.Fatalf("grouped len = %d, want 1", len(grouped))
}
if len(grouped[0].Versions) != 2 {
t.Fatalf("versions len = %d, want 2", len(grouped[0].Versions))
}
if grouped[0].Path != mkv.Path {
t.Fatalf("primary should be larger mkv, got %q", grouped[0].Path)
}
}
func TestMediaVersionLabelUsesContainerAndSize(t *testing.T) {
label := MediaVersionLabel(model.Media{
Title: "竞女01",
Path: "/strm/竞女01.mkv.strm",
Height: 1080,
SizeBytes: 1024 * 1024 * 1200,
STRMURL: "/api/strm/play/cloud115/video.mkv?pickcode=a",
})
if !strings.Contains(label, "1080p") || !strings.Contains(strings.ToUpper(label), "MKV") {
t.Fatalf("unexpected label %q", label)
}
}
+57 -10
View File
@@ -83,7 +83,7 @@ func (p *MetaTubeProvider) Search(ctx context.Context, cfg MetaTubeConfig, query
matches := make([]*Match, 0, len(results))
for _, res := range results {
match := p.convertSearchResultToMatch(query, &res)
match := p.convertSearchResultToMatch(cfg, query, &res)
if match != nil {
matches = append(matches, match)
}
@@ -134,7 +134,7 @@ func (p *MetaTubeProvider) GetMovie(ctx context.Context, cfg MetaTubeConfig, pro
return nil, fmt.Errorf("decode metatube movie response failed: %w", directErr)
}
return p.convertMovieInfoToMatch(&movie), nil
return p.convertMovieInfoToMatch(cfg, &movie), nil
}
// SearchAndGetBestMatch 执行搜索并拉取首个最佳结果的完整电影详情。
@@ -276,7 +276,7 @@ func (p *MetaTubeProvider) applyAuthHeader(req *http.Request, token string) {
req.Header.Set("User-Agent", "MeBox/1.0 (MetaTube Client)")
}
func (p *MetaTubeProvider) convertSearchResultToMatch(query string, res *MetaTubeSearchResult) *Match {
func (p *MetaTubeProvider) convertSearchResultToMatch(cfg MetaTubeConfig, query string, res *MetaTubeSearchResult) *Match {
if res == nil {
return nil
}
@@ -300,14 +300,22 @@ func (p *MetaTubeProvider) convertSearchResultToMatch(query string, res *MetaTub
}
}
posterURL := firstNonEmpty(res.BigCoverURL, res.CoverURL, res.BigThumbURL, res.ThumbURL)
posterSource := firstNonEmpty(res.BigCoverURL, res.CoverURL, res.BigThumbURL, res.ThumbURL)
posterURL, backdropURL := metaTubeArtworkURLs(cfg, res.Provider, res.ID, posterSource)
if posterURL == "" {
posterURL = firstNonEmpty(res.BigThumbURL, res.ThumbURL, res.BigCoverURL, res.CoverURL)
}
if backdropURL == "" {
backdropURL = firstNonEmpty(res.BigCoverURL, res.CoverURL, posterURL)
}
return &Match{
Provider: "metatube",
MediaType: "adult",
Title: formattedTitle,
OriginalName: code,
PosterURL: posterURL,
BackdropURL: posterURL,
BackdropURL: backdropURL,
Year: year,
ReleaseDate: cleanDateString(res.ReleaseDate),
Rating: res.Score,
@@ -318,7 +326,7 @@ func (p *MetaTubeProvider) convertSearchResultToMatch(query string, res *MetaTub
}
}
func (p *MetaTubeProvider) convertMovieInfoToMatch(movie *MetaTubeMovieInfo) *Match {
func (p *MetaTubeProvider) convertMovieInfoToMatch(cfg MetaTubeConfig, movie *MetaTubeMovieInfo) *Match {
if movie == nil {
return nil
}
@@ -336,10 +344,17 @@ func (p *MetaTubeProvider) convertMovieInfoToMatch(movie *MetaTubeMovieInfo) *Ma
year := parseYearFromDate(movie.ReleaseDate)
posterURL := firstNonEmpty(movie.BigCoverURL, movie.CoverURL, movie.BigThumbURL, movie.ThumbURL)
backdropURL := posterURL
if len(movie.PreviewImages) > 0 && movie.PreviewImages[0] != "" {
backdropURL = movie.PreviewImages[0]
posterSource := firstNonEmpty(movie.BigCoverURL, movie.CoverURL, movie.BigThumbURL, movie.ThumbURL)
posterURL, backdropURL := metaTubeArtworkURLs(cfg, movie.Provider, movie.ID, posterSource)
if posterURL == "" {
posterURL = firstNonEmpty(movie.BigThumbURL, movie.ThumbURL, movie.BigCoverURL, movie.CoverURL)
}
if backdropURL == "" {
if len(movie.PreviewImages) > 0 && movie.PreviewImages[0] != "" {
backdropURL = movie.PreviewImages[0]
} else {
backdropURL = firstNonEmpty(movie.BigCoverURL, movie.CoverURL, posterURL)
}
}
genres := make([]string, 0, len(movie.Genres)+len(movie.Actors)+4)
@@ -369,6 +384,7 @@ func (p *MetaTubeProvider) convertMovieInfoToMatch(movie *MetaTubeMovieInfo) *Ma
}
return &Match{
Provider: "metatube",
MediaType: "adult",
Title: formattedTitle,
OriginalName: code,
@@ -385,6 +401,37 @@ func (p *MetaTubeProvider) convertMovieInfoToMatch(movie *MetaTubeMovieInfo) *Ma
}
}
func metaTubeArtworkURLs(cfg MetaTubeConfig, provider, id, posterSource string) (string, string) {
serverURL := strings.TrimRight(strings.TrimSpace(cfg.ServerURL), "/")
provider = strings.TrimSpace(provider)
id = strings.TrimSpace(id)
if serverURL == "" || provider == "" || id == "" {
return "", ""
}
imageURL := func(kind string, primary bool) string {
base := fmt.Sprintf("%s/v1/images/%s/%s/%s", serverURL, kind, url.PathEscape(provider), url.PathEscape(id))
values := url.Values{}
values.Set("quality", "90")
if primary {
values.Set("ratio", "-1")
if strings.TrimSpace(posterSource) != "" {
values.Set("url", strings.TrimSpace(posterSource))
}
if cfg.CropCover {
values.Set("pos", "1")
values.Set("auto", "true")
} else {
values.Set("pos", "-1")
values.Set("auto", "false")
}
}
return base + "?" + values.Encode()
}
return imageURL("primary", true), imageURL("backdrop", false)
}
func parseYearFromDate(dateStr string) int {
dateStr = strings.TrimSpace(dateStr)
if len(dateStr) >= 4 {
+14 -4
View File
@@ -62,6 +62,9 @@ func TestMetaTubeProviderSearch(t *testing.T) {
t.Fatalf("expected 1 match, got %d", len(matches))
}
m := matches[0]
if m.Provider != "metatube" {
t.Errorf("expected provider metatube, got %q", m.Provider)
}
if m.OriginalName != "IPX-235" {
t.Errorf("expected original name IPX-235, got %s", m.OriginalName)
}
@@ -74,6 +77,12 @@ func TestMetaTubeProviderSearch(t *testing.T) {
if len(m.Genres) != 1 || m.Genres[0] != "相沢みなみ" {
t.Errorf("unexpected genres: %v", m.Genres)
}
if want := server.URL + "/v1/images/primary/javdb/123456?auto=false&pos=-1&quality=90&ratio=-1&url=https%3A%2F%2Fexample.com%2Fcover.jpg"; m.PosterURL != want {
t.Errorf("poster URL = %q, want %q", m.PosterURL, want)
}
if want := server.URL + "/v1/images/backdrop/javdb/123456?quality=90"; m.BackdropURL != want {
t.Errorf("backdrop URL = %q, want %q", m.BackdropURL, want)
}
}
func TestMetaTubeProviderGetMovie(t *testing.T) {
@@ -112,6 +121,7 @@ func TestMetaTubeProviderGetMovie(t *testing.T) {
provider := NewMetaTubeProvider(zap.NewNop())
cfg := MetaTubeConfig{
ServerURL: server.URL,
CropCover: true,
}
match, err := provider.GetMovie(context.Background(), cfg, "javdb", "123456")
@@ -124,11 +134,11 @@ func TestMetaTubeProviderGetMovie(t *testing.T) {
if match.Overview != "超绝美少女相沢みなみ出道作品!" {
t.Errorf("unexpected overview: %s", match.Overview)
}
if match.PosterURL != "https://example.com/big_cover.jpg" {
t.Errorf("unexpected poster URL: %s", match.PosterURL)
if want := server.URL + "/v1/images/primary/javdb/123456?auto=true&pos=1&quality=90&ratio=-1&url=https%3A%2F%2Fexample.com%2Fbig_cover.jpg"; match.PosterURL != want {
t.Errorf("poster URL = %q, want %q", match.PosterURL, want)
}
if match.BackdropURL != "https://example.com/preview1.jpg" {
t.Errorf("unexpected backdrop URL: %s", match.BackdropURL)
if want := server.URL + "/v1/images/backdrop/javdb/123456?quality=90"; match.BackdropURL != want {
t.Errorf("backdrop URL = %q, want %q", match.BackdropURL, want)
}
if match.Year != 2018 {
t.Errorf("expected year 2018, got %d", match.Year)
+4 -1
View File
@@ -115,7 +115,10 @@ func (s *NFOService) ExportLibrary(ctx context.Context, libraryID string) (int,
func nfoPath(media string) string {
dir := filepath.Dir(media)
base := strings.TrimSuffix(filepath.Base(media), filepath.Ext(media))
base := mediaSidecarBase(media)
if base == "" {
base = strings.TrimSuffix(filepath.Base(media), filepath.Ext(media))
}
return filepath.Join(dir, fmt.Sprintf("%s.nfo", base))
}
@@ -278,16 +278,21 @@ func randomSuffix() string {
// moveStagedArtwork renames artwork sidecars staged alongside `stage` into
// their final names next to `dst`.
func moveStagedArtwork(stage, dst string) {
stageBase := strings.TrimSuffix(filepath.Base(stage), filepath.Ext(stage))
dstBase := strings.TrimSuffix(filepath.Base(dst), filepath.Ext(dst))
stageBases := mediaSidecarBaseVariants(stage)
dstBase := mediaSidecarBase(dst)
if dstBase == "" {
dstBase = strings.TrimSuffix(filepath.Base(dst), filepath.Ext(dst))
}
stageDir := filepath.Dir(stage)
for _, suffix := range artworkSidecarSuffixes {
for _, ext := range artworkSidecarExtensions {
srcPath := filepath.Join(stageDir, stageBase+suffix+ext)
if _, err := os.Stat(srcPath); err != nil {
continue
for _, stageBase := range stageBases {
for _, suffix := range artworkSidecarSuffixes {
for _, ext := range artworkSidecarExtensions {
srcPath := filepath.Join(stageDir, stageBase+suffix+ext)
if _, err := os.Stat(srcPath); err != nil {
continue
}
_ = os.Rename(srcPath, filepath.Join(stageDir, dstBase+suffix+ext))
}
_ = os.Rename(srcPath, filepath.Join(stageDir, dstBase+suffix+ext))
}
}
}
@@ -306,12 +311,13 @@ func moveSidecarRename(from, to string) {
// removeStagedArtwork removes artwork sidecars that were staged alongside
// `stage`, used when a replace fails and its staged outputs must be cleaned up.
func removeStagedArtwork(stage string) {
stageBase := strings.TrimSuffix(filepath.Base(stage), filepath.Ext(stage))
stageBases := mediaSidecarBaseVariants(stage)
stageDir := filepath.Dir(stage)
for _, suffix := range artworkSidecarSuffixes {
for _, ext := range artworkSidecarExtensions {
path := filepath.Join(stageDir, stageBase+suffix+ext)
_ = os.Remove(path)
for _, stageBase := range stageBases {
for _, suffix := range artworkSidecarSuffixes {
for _, ext := range artworkSidecarExtensions {
_ = os.Remove(filepath.Join(stageDir, stageBase+suffix+ext))
}
}
}
}
+43 -20
View File
@@ -51,23 +51,25 @@ var artworkSidecarExtensions = []string{".jpg", ".jpeg", ".png", ".webp", ".gif"
// behind in the old folder; this keeps artwork with the organized file.
func transferSidecarArtwork(srcMedia, dstMedia string, mode TransferMode) error {
srcDir := filepath.Dir(srcMedia)
base := strings.TrimSuffix(filepath.Base(srcMedia), filepath.Ext(srcMedia))
if base == "" || base == "." {
bases := mediaSidecarBaseVariants(srcMedia)
if len(bases) == 0 {
return nil
}
// Find every existing sidecar by probing suffix + extension combinations.
sources := make([]string, 0, len(artworkSidecarSuffixes)*len(artworkSidecarExtensions))
sources := make([]string, 0, len(artworkSidecarSuffixes)*len(artworkSidecarExtensions)*len(bases))
seen := map[string]struct{}{}
for _, suffix := range artworkSidecarSuffixes {
for _, ext := range artworkSidecarExtensions {
path := filepath.Join(srcDir, base+suffix+ext)
key := strings.ToLower(filepath.Clean(path))
if _, ok := seen[key]; ok {
continue
}
seen[key] = struct{}{}
if _, err := os.Stat(path); err == nil {
sources = append(sources, path)
for _, base := range bases {
for _, suffix := range artworkSidecarSuffixes {
for _, ext := range artworkSidecarExtensions {
path := filepath.Join(srcDir, base+suffix+ext)
key := strings.ToLower(filepath.Clean(path))
if _, ok := seen[key]; ok {
continue
}
seen[key] = struct{}{}
if _, err := os.Stat(path); err == nil {
sources = append(sources, path)
}
}
}
}
@@ -78,15 +80,36 @@ func transferSidecarArtwork(srcMedia, dstMedia string, mode TransferMode) error
if err := os.MkdirAll(dstDir, 0o755); err != nil { // #nosec G301 -- sidecar media directories must remain readable by NAS/player users.
return err
}
var firstErr error
dstBase := mediaSidecarBase(dstMedia)
if dstBase == "" {
dstBase = strings.TrimSuffix(filepath.Base(dstMedia), filepath.Ext(dstMedia))
}
for _, src := range sources {
dst := filepath.Join(dstDir, filepath.Base(src))
if _, err := os.Stat(dst); err == nil {
continue // never clobber an existing artwork at the destination
name := filepath.Base(src)
// Remap any legacy "Title.mkv-poster.jpg" onto the shared destination stem.
suffix := ""
lowerName := strings.ToLower(name)
for _, base := range bases {
prefix := strings.ToLower(base)
if strings.HasPrefix(lowerName, prefix) {
suffix = name[len(base):]
break
}
}
if err := transferFile(src, dst, mode); err != nil && firstErr == nil {
firstErr = err
dstName := name
if suffix != "" && dstBase != "" {
dstName = dstBase + suffix
}
dst := filepath.Join(dstDir, dstName)
if strings.EqualFold(filepath.Clean(src), filepath.Clean(dst)) {
continue
}
if _, err := os.Stat(dst); err == nil {
continue
}
if err := transferFile(src, dst, mode); err != nil {
return err
}
}
return firstErr
return nil
}
+4 -1
View File
@@ -138,7 +138,10 @@ type localScanMediaInput struct {
func (s *ScannerService) buildLocalScanMedia(in localScanMediaInput) *model.Media {
title, year := CleanQueryWithRecognition(context.Background(), s.repo, in.path)
if title == "" {
title = strings.TrimSuffix(filepath.Base(in.path), in.ext)
title = mediaSidecarBase(in.path)
if title == "" {
title = strings.TrimSuffix(filepath.Base(in.path), in.ext)
}
}
title, year = preferISOParentScrapeIdentity(in.path, in.lib.Path, title, year)
+32 -3
View File
@@ -93,7 +93,11 @@ func (s *ScraperService) EnrichOneWithOptions(ctx context.Context, m *model.Medi
}
if match == nil {
if local != nil && !local.PathHint {
return s.applyLocalMetadataMatch(ctx, m, local)
err := s.applyLocalMetadataMatch(ctx, m, local)
if err == nil {
options.recordProvider("local")
}
return err
}
if err := s.repo.DB.WithContext(ctx).Model(&model.Media{}).Where("id = ?", m.ID).
Update("scrape_status", "no_match").Error; err != nil {
@@ -119,8 +123,22 @@ func (s *ScraperService) applyProviderMatch(ctx context.Context, m *model.Media,
func (s *ScraperService) applyProviderMatchWithOptions(ctx context.Context, m *model.Media, lib *model.Library, match *Match, options ScrapeOptions) error {
posterCandidate := match.PosterURL
backdropCandidate := match.BackdropURL
posterURL, removePoster := s.prepareScrapedArtworkURL(ctx, m.ID, "poster_url", m.PosterURL, posterCandidate)
backdropURL, removeBackdrop := s.prepareScrapedArtworkURL(ctx, m.ID, "backdrop_url", m.BackdropURL, backdropCandidate)
currentPoster := m.PosterURL
currentBackdrop := m.BackdropURL
if options.RebuildIdentity {
currentPoster = ""
currentBackdrop = ""
}
posterURL, removePoster := s.prepareScrapedArtworkURL(ctx, m.ID, "poster_url", currentPoster, posterCandidate)
backdropURL, removeBackdrop := s.prepareScrapedArtworkURL(ctx, m.ID, "backdrop_url", currentBackdrop, backdropCandidate)
if options.RebuildIdentity {
if strings.TrimSpace(m.PosterURL) != posterURL {
removePoster = m.PosterURL
}
if strings.TrimSpace(m.BackdropURL) != backdropURL {
removeBackdrop = m.BackdropURL
}
}
updates := map[string]any{
"title": match.Title,
"overview": match.Overview,
@@ -142,6 +160,12 @@ func (s *ScraperService) applyProviderMatchWithOptions(ctx context.Context, m *m
updates["languages"] = strings.Join(match.Languages, ",")
updates["nsfw"] = match.NSFW
}
if options.RebuildIdentity {
updates["season_num"] = m.SeasonNum
updates["episode_num"] = m.EpisodeNum
updates["episode_title"] = ""
updates["series_id"] = ""
}
if match.ReleaseDate != "" {
updates["release_date"] = match.ReleaseDate
}
@@ -207,6 +231,11 @@ func (s *ScraperService) applyProviderMatchWithOptions(ctx context.Context, m *m
"thetvdb_id": match.TheTVDBID,
"source": map[bool]string{true: "adult"}[match.NSFW],
})
provider := strings.ToLower(strings.TrimSpace(match.Provider))
if provider == "" {
provider = "unknown"
}
options.recordProvider(provider)
return nil
}
+38 -1
View File
@@ -2,6 +2,7 @@ package service
import (
"context"
"net/url"
"strings"
"time"
@@ -20,10 +21,34 @@ func (s *ScraperService) prepareScrapedArtworkURL(ctx context.Context, mediaID,
if s == nil || s.images == nil || !isHTTPish(candidate) {
return candidate, ""
}
fetchCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), 15*time.Second)
originalSource := metaTubeOriginalArtworkURL(candidate)
timeout := 15 * time.Second
if originalSource != "" {
// MetaTube face processing can become very slow under a batch scrape.
// Fail over quickly so the same source can be processed locally.
timeout = 8 * time.Second
}
fetchCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), timeout)
err := s.images.PrefetchRemote(fetchCtx, candidate)
cancel()
if err != nil {
if originalSource != "" {
fallbackCtx, fallbackCancel := context.WithTimeout(context.WithoutCancel(ctx), 15*time.Second)
fallbackErr := s.images.PrefetchRemote(fallbackCtx, originalSource)
fallbackCancel()
if fallbackErr == nil {
s.log.Warn("MetaTube artwork processing failed; using local face-aware crop",
zap.String("media_id", mediaID),
zap.String("field", field),
zap.String("candidate", candidate),
zap.String("source", originalSource),
zap.Error(err))
if current != "" && isHTTPish(current) {
return originalSource, current
}
return originalSource, ""
}
}
if current != "" {
s.log.Warn("scrape artwork prefetch failed; keeping existing artwork",
zap.String("media_id", mediaID),
@@ -46,6 +71,18 @@ func (s *ScraperService) prepareScrapedArtworkURL(ctx context.Context, mediaID,
return candidate, ""
}
func metaTubeOriginalArtworkURL(raw string) string {
parsed, err := url.Parse(strings.TrimSpace(raw))
if err != nil || !strings.Contains(parsed.Path, "/v1/images/primary/") {
return ""
}
source := strings.TrimSpace(parsed.Query().Get("url"))
if !isHTTPish(source) {
return ""
}
return source
}
func (s *ScraperService) removeCachedScrapedArtwork(urls ...string) {
if s == nil || s.images == nil {
return
+95 -2
View File
@@ -3,8 +3,11 @@ package service
import (
"bytes"
"errors"
"image"
"image/color"
"io"
"net/http"
"net/url"
"os"
"path/filepath"
"strings"
@@ -67,10 +70,14 @@ func TestApplyProviderMatchInvalidatesMediaCache(t *testing.T) {
if err := repos.DB.Create(&media).Error; err != nil {
t.Fatal(err)
}
match := &Match{Title: "Matched", PosterURL: "https://image.tmdb.org/t/p/w500/poster.jpg", BackdropURL: "https://image.tmdb.org/t/p/w1280/backdrop.jpg"}
if err := scraper.applyProviderMatch(t.Context(), &media, &lib, match); err != nil {
match := &Match{Provider: "metatube", Title: "Matched", PosterURL: "https://image.tmdb.org/t/p/w500/poster.jpg", BackdropURL: "https://image.tmdb.org/t/p/w1280/backdrop.jpg"}
usedProvider := ""
if err := scraper.applyProviderMatchWithOptions(t.Context(), &media, &lib, match, ScrapeOptions{resultProvider: &usedProvider}); err != nil {
t.Fatal(err)
}
if usedProvider != "metatube" {
t.Fatalf("recorded provider = %q, want metatube", usedProvider)
}
var stale map[string]string
if cache.GetJSON(t.Context(), "media:list:stale", &stale) {
@@ -145,6 +152,92 @@ func TestApplyProviderMatchKeepsExistingArtworkWhenNewPrefetchFails(t *testing.T
}
}
func TestApplyProviderMatchFallsBackToLocalFaceCropWhenMetaTubeImageFails(t *testing.T) {
scraper, repos, closeServer := newTestScraper(t)
defer closeServer()
wideCover := createTestImage(
900,
600,
color.RGBA{R: 255, A: 255},
color.RGBA{B: 255, A: 255},
)
sourceURL := "https://images.example.com/wide-cover.jpg"
images := NewImageProxy(&config.Config{Cache: config.CacheConfig{CacheDir: filepath.Join(t.TempDir(), "cache")}}, zap.NewNop())
images.client = &http.Client{Transport: imageRoundTripFunc(func(req *http.Request) (*http.Response, error) {
if req.URL.Host == "metatube.example.com" {
return &http.Response{
StatusCode: http.StatusGatewayTimeout,
Status: "504 Gateway Timeout",
Header: make(http.Header),
Body: io.NopCloser(strings.NewReader("timeout")),
Request: req,
}, nil
}
return &http.Response{
StatusCode: http.StatusOK,
Status: "200 OK",
Header: http.Header{"Content-Type": []string{"image/jpeg"}},
Body: io.NopCloser(bytes.NewReader(wideCover)),
Request: req,
}, nil
})}
scraper.SetImageProxy(images)
libPath := t.TempDir()
lib := model.Library{Name: "Adult", Path: libPath, Type: "adult", Enabled: true}
if err := repos.Library.Create(t.Context(), &lib); err != nil {
t.Fatal(err)
}
media := model.Media{
LibraryID: lib.ID,
Title: "Raw",
Path: filepath.Join(libPath, "IPX-235.mp4"),
PosterURL: filepath.Join(libPath, "old-poster.jpg"),
NSFW: true,
ScrapeStatus: "matched",
}
if err := repos.DB.Create(&media).Error; err != nil {
t.Fatal(err)
}
query := url.Values{
"auto": {"true"},
"pos": {"1"},
"quality": {"90"},
"ratio": {"-1"},
"url": {sourceURL},
}
match := &Match{
Title: "IPX-235 Matched",
MediaType: "adult",
NSFW: true,
PosterURL: "https://metatube.example.com/v1/images/primary/AVE/123?" + query.Encode(),
}
if err := scraper.applyProviderMatch(t.Context(), &media, &lib, match); err != nil {
t.Fatal(err)
}
var stored model.Media
if err := repos.DB.First(&stored, "id = ?", media.ID).Error; err != nil {
t.Fatal(err)
}
if filepath.Base(stored.PosterURL) != "IPX-235-poster.jpg" {
t.Fatalf("poster = %q, want local face-cropped sidecar", stored.PosterURL)
}
data, err := os.ReadFile(stored.PosterURL)
if err != nil {
t.Fatal(err)
}
cropped, _, err := image.Decode(bytes.NewReader(data))
if err != nil {
t.Fatal(err)
}
ratio := float64(cropped.Bounds().Dx()) / float64(cropped.Bounds().Dy())
if ratio < 0.65 || ratio > 0.68 {
t.Fatalf("local fallback poster ratio = %.3f", ratio)
}
}
func TestApplyProviderMatchReplacesArtworkAndRemovesOldCache(t *testing.T) {
scraper, repos, closeServer := newTestScraper(t)
defer closeServer()
+4
View File
@@ -27,11 +27,13 @@ func (s *ScraperService) lookup(ctx context.Context, lib *model.Library, media *
}
if s.tmdb != nil && s.tmdb.Enabled() {
if match := s.lookupAutomaticTMDb(ctx, kind, query, year); match != nil {
match.Provider = "tmdb"
return match
}
}
if s.douban != nil && s.douban.Enabled() {
if m, err := s.douban.SearchMatch(ctx, query); err == nil && m != nil && metadataMatchCompatibleWithType(kind, m) {
m.Provider = "douban"
return m
} else if err != nil {
s.log.Debug("douban search failed", zap.String("query", query), zap.Error(err))
@@ -39,6 +41,7 @@ func (s *ScraperService) lookup(ctx context.Context, lib *model.Library, media *
}
if s.bangumi != nil && s.bangumi.Enabled() {
if m, err := s.bangumi.Search(ctx, query); err == nil && m != nil && metadataMatchCompatibleWithType(kind, m) {
m.Provider = "bangumi"
return m
} else if err != nil {
s.log.Debug("bangumi search failed", zap.String("query", query), zap.Error(err))
@@ -46,6 +49,7 @@ func (s *ScraperService) lookup(ctx context.Context, lib *model.Library, media *
}
if (kind == "anime" || kind == "tv" || kind == "variety" || kind == "show" || kind == "shows") && s.thetvdb != nil && s.thetvdb.Enabled() {
if m, err := s.thetvdb.SearchSeries(ctx, query); err == nil && m != nil && metadataMatchCompatibleWithType(kind, m) {
m.Provider = "thetvdb"
return m
} else if err != nil {
s.log.Debug("thetvdb search failed", zap.String("query", query), zap.Error(err))
+4
View File
@@ -23,6 +23,7 @@ func (s *ScraperService) matchFromMediaExternalIDs(ctx context.Context, m *model
}
if match := s.manualTMDbMatchByID(ctx, m.TMDbID, normalizeMediaType(mediaType, m.Title, "")); match != nil {
if s.mediaExternalIDMatchTrusted(m, lib, match, "tmdb") {
match.Provider = "tmdb"
preferExistingLocalizedEpisodeTitle(m, lib, match)
return match
}
@@ -31,6 +32,7 @@ func (s *ScraperService) matchFromMediaExternalIDs(ctx context.Context, m *model
if strings.TrimSpace(m.DoubanID) != "" && s.douban != nil && s.douban.Enabled() {
if match, err := s.douban.GetMatchByID(ctx, strings.TrimSpace(m.DoubanID)); err == nil && match != nil {
if s.mediaExternalIDMatchTrusted(m, lib, match, "douban") {
match.Provider = "douban"
preferExistingLocalizedEpisodeTitle(m, lib, match)
return match
}
@@ -41,6 +43,7 @@ func (s *ScraperService) matchFromMediaExternalIDs(ctx context.Context, m *model
if m.BangumiID > 0 && s.bangumi != nil && s.bangumi.Enabled() {
if match, err := s.bangumi.GetSubject(ctx, m.BangumiID); err == nil && match != nil {
if s.mediaExternalIDMatchTrusted(m, lib, match, "bangumi") {
match.Provider = "bangumi"
preferExistingLocalizedEpisodeTitle(m, lib, match)
return match
}
@@ -51,6 +54,7 @@ func (s *ScraperService) matchFromMediaExternalIDs(ctx context.Context, m *model
if strings.TrimSpace(m.TheTVDBID) != "" && s.thetvdb != nil && s.thetvdb.Enabled() {
if match, err := s.thetvdb.GetSeriesMatchByID(ctx, strings.TrimSpace(m.TheTVDBID)); err == nil && match != nil {
if s.mediaExternalIDMatchTrusted(m, lib, match, "thetvdb") {
match.Provider = "thetvdb"
preferExistingLocalizedEpisodeTitle(m, lib, match)
return match
}
+8
View File
@@ -7,6 +7,8 @@ type ScrapeOptions struct {
EpisodeArtwork *bool
DeferEpisodeDetails bool
ForceRematch bool
RebuildIdentity bool
resultProvider *string
}
func (o ScrapeOptions) episodeArtworkEnabled() bool {
@@ -17,3 +19,9 @@ func skipEpisodeArtworkOptions(retryNoMatch bool) ScrapeOptions {
episodeArtwork := false
return ScrapeOptions{RetryNoMatch: retryNoMatch, EpisodeArtwork: &episodeArtwork}
}
func (o ScrapeOptions) recordProvider(provider string) {
if o.resultProvider != nil {
*o.resultProvider = provider
}
}
+6
View File
@@ -75,6 +75,12 @@ func CleanQuery(raw string) (title string, year int) {
base = strings.TrimSpace(raw)
}
name := strings.TrimSuffix(base, filepath.Ext(base))
// .strm 可能保留视频扩展名(name.mkv.strm),再剥一层真实视频扩展,
// 避免「竞女01.mkv」与「竞女01.mp4」被当成不同标题。
name = mediaFileStem(base)
if name == "" {
name = strings.TrimSuffix(base, filepath.Ext(base))
}
lower := strings.ToLower(name)
if m := yearPattern.FindStringSubmatch(lower); len(m) >= 2 {
+5 -10
View File
@@ -115,10 +115,12 @@ func (s *ScraperService) processScrapeTask(ctx context.Context, task *model.Scra
}
epArtwork := task.EpisodeImages
usedProvider := ""
options := ScrapeOptions{
EpisodeArtwork: &epArtwork,
IncludeMatched: task.RefreshMatched,
RetryNoMatch: true,
resultProvider: &usedProvider,
}
enrichErr := s.EnrichOneWithOptions(ctx, media, options)
@@ -133,16 +135,9 @@ func (s *ScraperService) processScrapeTask(ctx context.Context, task *model.Scra
task.MatchedYear = refreshed.Year
task.PosterURL = refreshed.PosterURL
task.BackdropURL = refreshed.BackdropURL
if refreshed.TMDbID > 0 {
task.Provider = "tmdb"
} else if strings.TrimSpace(refreshed.DoubanID) != "" {
task.Provider = "douban"
} else if refreshed.BangumiID > 0 {
task.Provider = "bangumi"
} else if strings.TrimSpace(refreshed.TheTVDBID) != "" {
task.Provider = "thetvdb"
} else {
task.Provider = "metatube"
task.Provider = strings.ToLower(strings.TrimSpace(usedProvider))
if task.Provider == "" {
task.Provider = "unknown"
}
} else {
task.Status = model.ScrapeTaskFailed
+5
View File
@@ -1,6 +1,7 @@
package service
import (
"sync"
"time"
"go.uber.org/zap"
@@ -24,6 +25,10 @@ type ScraperService struct {
hub *Hub
cache *RuntimeCacheService
images *ImageProxy
// Serializes final sidecar replacement. Windows cannot rename over an
// existing file, and concurrent scrapes can target the same sidecar.
artworkWriteMu sync.Mutex
}
// NewScraperService is the constructor.
@@ -38,44 +38,90 @@ func (s *ScraperService) writeMediaArtworkFilesAfterScrape(ctx context.Context,
if dir == "" || dir == "." {
return
}
// Scope sidecar names by the media file's base name (e.g. A.mp4 -> A-poster.jpg)
// so that multiple movies sharing one directory (A.mp4 + B.mp4) never clash.
base := strings.TrimSuffix(filepath.Base(refreshed.Path), filepath.Ext(refreshed.Path))
// Scope sidecar names by the shared media stem (e.g. A.mkv.strm / A.mp4.strm -> A-poster.jpg)
// so multi-version files in one folder share artwork and never diverge by container.
base := mediaSidecarBase(refreshed.Path)
if base == "" || base == "." {
return
}
isAdult := IsAdultMediaPathOrMetadata(refreshed.Path, refreshed.LibraryID, refreshed.NSFW) || IsAdultArtworkURL(refreshed.PosterURL)
isAdult := shouldCropAdultPoster(refreshed, lib)
artworkUpdates := map[string]any{}
if refreshed.PosterURL != "" {
s.downloadArtworkToPathWithOptions(ctx, dir, base+"-poster", refreshed.PosterURL, isAdult)
if dst := s.downloadArtworkToPathWithOptions(ctx, dir, base+"-poster", refreshed.PosterURL, isAdult); dst != "" {
artworkUpdates["poster_url"] = filepath.Join(filepath.Dir(refreshed.Path), filepath.Base(dst))
}
}
if refreshed.BackdropURL != "" {
s.downloadArtworkToPathWithOptions(ctx, dir, base+"-backdrop", refreshed.BackdropURL, false)
if dst := s.downloadArtworkToPathWithOptions(ctx, dir, base+"-backdrop", refreshed.BackdropURL, false); dst != "" {
artworkUpdates["backdrop_url"] = filepath.Join(filepath.Dir(refreshed.Path), filepath.Base(dst))
}
} else if isAdult && refreshed.PosterURL != "" {
// 番号海报原图为完整封套横图,在无独立背景图时直接作为背景图写出
s.downloadArtworkToPathWithOptions(ctx, dir, base+"-backdrop", refreshed.PosterURL, false)
if dst := s.downloadArtworkToPathWithOptions(ctx, dir, base+"-backdrop", refreshed.PosterURL, false); dst != "" {
artworkUpdates["backdrop_url"] = filepath.Join(filepath.Dir(refreshed.Path), filepath.Base(dst))
}
}
if len(artworkUpdates) > 0 {
if err := s.repo.DB.WithContext(ctx).Model(&model.Media{}).
Where("id = ?", refreshed.ID).Updates(artworkUpdates).Error; err != nil {
s.log.Warn("save local scraped artwork paths failed", zap.String("media_id", refreshed.ID), zap.Error(err))
}
}
}
func (s *ScraperService) downloadArtworkToPath(ctx context.Context, dir, name, raw string) {
s.downloadArtworkToPathWithOptions(ctx, dir, name, raw, false)
func (s *ScraperService) downloadArtworkToPath(ctx context.Context, dir, name, raw string) string {
return s.downloadArtworkToPathWithOptions(ctx, dir, name, raw, false)
}
// shouldCropAdultPoster keeps adult-cover handling independent of which
// metadata provider won. A code-numbered title may match TMDb first, so the
// provider's NSFW flag or artwork host alone is not sufficient.
func shouldCropAdultPoster(media *model.Media, lib *model.Library) bool {
if media == nil {
return false
}
mediaType := ""
if lib != nil {
mediaType = lib.Type
}
return IsAdultMediaPathOrMetadata(media.Path, mediaType, media.NSFW) ||
IsAdultArtworkURL(media.PosterURL)
}
// downloadArtworkToPathWithOptions fetches an artwork URL via the image proxy cache and
// writes it under dir/<name>.<ext>. For adult posters, it crops the right half of the cover.
func (s *ScraperService) downloadArtworkToPathWithOptions(ctx context.Context, dir, name, raw string, cropAdultPoster bool) {
if !isHTTPish(raw) {
return
func (s *ScraperService) downloadArtworkToPathWithOptions(ctx context.Context, dir, name, raw string, cropAdultPoster bool) string {
var (
data []byte
ctype string
err error
)
switch {
case isHTTPish(raw):
data, ctype, err = s.images.Fetch(ctx, raw)
case isLocalImagePath(raw):
data, err = os.ReadFile(sanitizeLocalPath(resolveMappedDestinationPath(raw)))
if err == nil {
ctype = detectContentType(data)
}
default:
return ""
}
data, ctype, err := s.images.Fetch(ctx, raw)
if err != nil || len(data) == 0 {
s.log.Warn("scrape artwork download failed",
zap.String("name", name),
zap.String("url", raw),
zap.Error(err))
return
return ""
}
if !isImageContentType(ctype) || isTransparentPlaceholderData(data) {
return
return ""
}
// Upstreams sometimes report non-standard values such as image/jpg.
// Use the decoded bytes as the source of truth so sidecars receive a
// standard extension instead of the legacy .img fallback.
if detected := detectContentType(data); isImageContentType(detected) {
ctype = detected
}
if cropAdultPoster {
if cropped, croppedType, err := CropAdultCoverPoster(data); err == nil && len(cropped) > 0 {
@@ -83,7 +129,7 @@ func (s *ScraperService) downloadArtworkToPathWithOptions(ctx context.Context, d
ctype = croppedType
}
}
s.writeArtworkDataToPath(dir, name, ctype, data)
return s.writeArtworkDataToPath(dir, name, ctype, data)
}
// writeArtworkDataToPath writes in-memory artwork bytes to dir/<name>.<ext>
@@ -110,10 +156,23 @@ func (s *ScraperService) writeArtworkDataToPath(dir, name, ctype string, data []
s.log.Warn("scrape artwork write failed", zap.String("dst", dst), zap.Error(err))
return ""
}
_ = tmp.Close()
if err := os.Rename(tmp.Name(), dst); err != nil {
if err := tmp.Close(); err != nil {
_ = os.Remove(tmp.Name())
s.log.Warn("scrape artwork rename failed", zap.String("dst", dst), zap.Error(err))
s.log.Warn("scrape artwork close failed", zap.String("dst", dst), zap.Error(err))
return ""
}
// On Windows os.Rename does not replace dst. Serialize remove+rename so two
// concurrent scrapes cannot leave the previous uncropped DVD cover behind.
s.artworkWriteMu.Lock()
err = os.Remove(dst)
if err == nil || os.IsNotExist(err) {
err = os.Rename(tmp.Name(), dst)
}
s.artworkWriteMu.Unlock()
if err != nil {
_ = os.Remove(tmp.Name())
s.log.Warn("scrape artwork replace failed", zap.String("dst", dst), zap.Error(err))
return ""
}
s.log.Debug("scrape artwork written", zap.String("dst", dst))
@@ -125,7 +184,7 @@ func (s *ScraperService) writeArtworkDataToPath(dir, name, ctype string, data []
// extension that could confuse media players.
func imageExtForContentType(ctype string) string {
switch strings.ToLower(strings.TrimSpace(strings.Split(ctype, ";")[0])) {
case "image/jpeg", "image/pjpeg":
case "image/jpeg", "image/jpg", "image/pjpeg":
return ".jpg"
case "image/png", "image/x-png":
return ".png"
@@ -1,13 +1,22 @@
package service
import (
"bytes"
"image"
"image/color"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"testing"
"github.com/glebarez/sqlite"
"go.uber.org/zap"
"gorm.io/gorm"
"github.com/truewhile/MeBox/internal/config"
"github.com/truewhile/MeBox/internal/model"
"github.com/truewhile/MeBox/internal/repository"
)
// TestWriteArtworkDataToPathWritesJellyfinSidecar verifies that in-memory
@@ -33,11 +42,53 @@ func TestWriteArtworkDataToPathWritesJellyfinSidecar(t *testing.T) {
}
}
func TestWriteArtworkDataToPathReplacesExistingSidecar(t *testing.T) {
scraper := &ScraperService{log: zap.NewNop()}
mediaDir := t.TempDir()
dst := filepath.Join(mediaDir, "poster.jpg")
if err := os.WriteFile(dst, []byte("old DVD cover"), 0o644); err != nil {
t.Fatal(err)
}
if got := scraper.writeArtworkDataToPath(mediaDir, "poster", "image/jpeg", testJPEG); got != dst {
t.Fatalf("destination = %q, want %q", got, dst)
}
data, err := os.ReadFile(dst)
if err != nil {
t.Fatal(err)
}
if string(data) != string(testJPEG) {
t.Fatal("existing sidecar was not replaced")
}
}
func TestShouldCropAdultPosterWhenTMDbArtworkMatchedCodePath(t *testing.T) {
media := &model.Media{
Path: filepath.Join(t.TempDir(), "IPX-235.mp4"),
Title: "TMDb matched title",
PosterURL: "https://image.tmdb.org/t/p/w500/poster.jpg",
}
if !shouldCropAdultPoster(media, &model.Library{Type: "movie"}) {
t.Fatal("code-numbered media should retain adult crop handling after a TMDb match")
}
}
func TestShouldCropAdultPosterUsesLibraryType(t *testing.T) {
media := &model.Media{
Path: filepath.Join(t.TempDir(), "renamed.mp4"),
PosterURL: "https://image.tmdb.org/t/p/w500/poster.jpg",
}
if !shouldCropAdultPoster(media, &model.Library{Type: "adult"}) {
t.Fatal("adult library media should be cropped regardless of provider")
}
}
// TestImageExtForContentType verifies the MIME -> extension mapping used to
// name Jellyfin sidecar files.
func TestImageExtForContentType(t *testing.T) {
cases := map[string]string{
"image/jpeg": ".jpg",
"image/jpg": ".jpg",
"image/pjpeg": ".jpg",
"image/png": ".png",
"image/webp": ".webp",
@@ -53,6 +104,32 @@ func TestImageExtForContentType(t *testing.T) {
}
}
func TestDownloadArtworkMigratesLocalImgToCanonicalCroppedJPEG(t *testing.T) {
scraper := &ScraperService{log: zap.NewNop()}
dir := t.TempDir()
legacy := filepath.Join(dir, "movie-poster.img")
wide := createTestImage(900, 600, color.RGBA{R: 255, A: 255}, color.RGBA{B: 255, A: 255})
if err := os.WriteFile(legacy, wide, 0o644); err != nil {
t.Fatal(err)
}
dst := scraper.downloadArtworkToPathWithOptions(t.Context(), dir, "movie-poster", legacy, true)
if filepath.Base(dst) != "movie-poster.jpg" {
t.Fatalf("destination = %q, want canonical JPEG sidecar", dst)
}
data, err := os.ReadFile(dst)
if err != nil {
t.Fatal(err)
}
cropped, _, err := image.Decode(bytes.NewReader(data))
if err != nil {
t.Fatal(err)
}
if got := float64(cropped.Bounds().Dx()) / float64(cropped.Bounds().Dy()); got < 0.65 || got > 0.68 {
t.Fatalf("migrated poster ratio = %.3f, want 2:3", got)
}
}
// TestWriteMediaArtworkFilesAfterScrapeSkipsCloud verifies that cloud media
// never has artwork written anywhere.
func TestWriteMediaArtworkFilesAfterScrapeSkipsCloud(t *testing.T) {
@@ -87,3 +164,65 @@ func TestSameDirectoryMediaUsesBaseScopedSidecars(t *testing.T) {
t.Fatalf("base = %q, want A-poster.jpg", filepath.Base(dst))
}
}
func TestLocalAdultArtworkStoresFaceAwarePosterPath(t *testing.T) {
wideCover := createTestImage(
900,
600,
color.RGBA{R: 255, A: 255},
color.RGBA{B: 255, A: 255},
)
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Content-Type", "image/jpeg")
_, _ = w.Write(wideCover)
}))
defer upstream.Close()
cfg := &config.Config{}
cfg.Cache.CacheDir = t.TempDir()
images := NewImageProxy(cfg, zap.NewNop())
images.allowedRemoteHostsFn = func() []string { return []string{"127.0.0.1"} }
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
if err != nil {
t.Fatal(err)
}
if err := db.AutoMigrate(&model.Media{}); err != nil {
t.Fatal(err)
}
repos := repository.New(db)
mediaDir := t.TempDir()
media := model.Media{
Title: "IPX-235",
Path: filepath.Join(mediaDir, "IPX-235.mp4"),
PosterURL: upstream.URL + "/cover.jpg",
NSFW: true,
ScrapeStatus: "matched",
}
if err := repos.DB.Create(&media).Error; err != nil {
t.Fatal(err)
}
scraper := &ScraperService{cfg: cfg, log: zap.NewNop(), repo: repos, images: images}
scraper.writeMediaArtworkFilesAfterScrape(t.Context(), &media, &model.Library{Type: "adult"})
var got model.Media
if err := repos.DB.First(&got, "id = ?", media.ID).Error; err != nil {
t.Fatal(err)
}
if filepath.Base(got.PosterURL) != "IPX-235-poster.jpg" {
t.Fatalf("poster URL = %q, want local face-aware sidecar", got.PosterURL)
}
data, err := os.ReadFile(got.PosterURL)
if err != nil {
t.Fatal(err)
}
cropped, _, err := image.Decode(bytes.NewReader(data))
if err != nil {
t.Fatal(err)
}
ratio := float64(cropped.Bounds().Dx()) / float64(cropped.Bounds().Dy())
if ratio < 0.65 || ratio > 0.68 {
t.Fatalf("local poster ratio = %.3f, want MetaTube primary ratio", ratio)
}
}
+4
View File
@@ -121,6 +121,10 @@ func (b *serviceContainerBuilder) initContentServices() {
b.c.FFTools = NewFFmpegToolsService(b.cfg, b.log, b.repos)
// 弹幕 hash 识别需要把 strm 指向解析成可拉取的直链/本地路径。
b.c.Danmaku.SetStrmResolver(b.c.Strm.ResolvePlay)
// STRM 直连失败后的 HLS 转码:把 .strm 解析成 ffmpeg 可读取的本地路径或 HTTP 直链。
b.c.Transcoder.SetStrmPlayTargetResolver(b.c.Strm.ResolvePlayTarget)
b.c.Transcoder.SetProbe(b.c.FFprobe)
b.c.Subtitle.SetStrmPlayTargetResolver(b.c.Strm.ResolvePlayTarget)
// 弹幕识别需要把远程 Emby 条目解析为 Media 元数据及可拉取前 16MB 的直链 URL。
if b.c.EmbyRemote != nil {
b.c.Danmaku.SetRemoteMediaResolver(func(ctx context.Context, encodedID string) (*model.Media, string, error) {
+1 -1
View File
@@ -87,7 +87,7 @@ func playableSTRMTarget(ctx context.Context, repo *repository.Container, raw str
}
// IsStrmMediaRow 判断媒体行是否为 .strm(远程直链)媒体:STRMURL 非空、
// container=strm 或路径以 .strm 结尾。strm 媒体只能直连播放,禁止转码。
// container=strm 或路径以 .strm 结尾。网页播放默认直连,失败后可转码。
func IsStrmMediaRow(m *model.Media) bool {
if m == nil {
return false
+87 -3
View File
@@ -6,23 +6,36 @@ import (
"net/http"
"os"
"path/filepath"
"strconv"
"strings"
"time"
)
// ServeHLSPlaylist makes sure a transcode is running and writes the m3u8.
// We block (with a 30s timeout) until the playlist file shows up.
//
// Optional query `start` (seconds) restarts ffmpeg from that source offset so
// the web player can scrub the full timeline without waiting for a full
// head-to-tail transcode.
func (s *StreamService) ServeHLSPlaylist(w http.ResponseWriter, r *http.Request, mediaID string) error {
// 「客户端直连解码」模式下宿主机不提供转码,HLS 一律拒绝,
// 迫使播放器走 direct play 本地解码。
if s.directPlayOnly(r.Context()) {
return ErrTranscodeDisabled
}
if _, err := s.transcoder.EnsureJob(r.Context(), mediaID); err != nil {
startSec := parseHLSStartSec(r)
seekGen := parseHLSSeekGen(r)
subtitleStream := parseHLSSubtitleStream(r)
if _, err := s.transcoder.EnsureJobFromSubtitle(r.Context(), mediaID, startSec, seekGen, subtitleStream); err != nil {
return err
}
s.transcoder.TouchJob(mediaID)
if !s.transcoder.WaitReady(r.Context(), mediaID, 30*time.Second) {
readyTimeout := 45 * time.Second
if startSec > 0.05 {
// Mid-file HTTP seeks (esp. WMV) need longer before the first segment appears.
readyTimeout = 120 * time.Second
}
if !s.transcoder.WaitReady(r.Context(), mediaID, readyTimeout) {
return errors.New("hls playlist not ready")
}
playlist := s.transcoder.PlaylistPath(mediaID)
@@ -48,10 +61,61 @@ func (s *StreamService) ServeHLSPlaylist(w http.ResponseWriter, r *http.Request,
return nil
}
func parseHLSSubtitleStream(r *http.Request) int {
if r == nil {
return -1
}
raw := strings.TrimSpace(r.URL.Query().Get("subtitle"))
if raw == "" {
return -1
}
v, err := strconv.Atoi(raw)
if err != nil || v < 0 {
return -1
}
return v
}
func parseHLSStartSec(r *http.Request) float64 {
if r == nil {
return 0
}
raw := strings.TrimSpace(r.URL.Query().Get("start"))
if raw == "" {
return 0
}
v, err := strconv.ParseFloat(raw, 64)
if err != nil || v < 0 {
return 0
}
return v
}
func parseHLSSeekGen(r *http.Request) int64 {
if r == nil {
return 0
}
raw := strings.TrimSpace(r.URL.Query().Get("_seek"))
if raw == "" {
return 0
}
v, err := strconv.ParseInt(raw, 10, 64)
if err != nil || v < 0 {
return 0
}
return v
}
func appendQueryToHLSSegments(playlist, rawQuery string) string {
if strings.TrimSpace(rawQuery) == "" {
return playlist
}
// Segment fetches do not need start=, but must keep _seek as a cache-busting
// generation because every transcode restart reuses seg_00000.ts names.
q := filterHLSSegmentQuery(rawQuery)
if q == "" {
return playlist
}
lines := strings.SplitAfter(playlist, "\n")
for i, line := range lines {
trimmed := strings.TrimSpace(line)
@@ -65,12 +129,32 @@ func appendQueryToHLSSegments(playlist, rawQuery string) string {
} else if strings.HasSuffix(line, "\n") {
lineEnding = "\n"
}
lines[i] = strings.TrimRight(line, "\r\n") + "?" + rawQuery + lineEnding
lines[i] = strings.TrimRight(line, "\r\n") + "?" + q + lineEnding
}
}
return strings.Join(lines, "")
}
func filterHLSSegmentQuery(rawQuery string) string {
parts := strings.Split(rawQuery, "&")
kept := make([]string, 0, len(parts))
for _, part := range parts {
if part == "" {
continue
}
key := part
if i := strings.IndexByte(part, '='); i >= 0 {
key = part[:i]
}
switch strings.ToLower(key) {
case "start":
continue
}
kept = append(kept, part)
}
return strings.Join(kept, "&")
}
// ServeHLSSegment writes a single .ts segment from the on-disk cache.
func (s *StreamService) ServeHLSSegment(w http.ResponseWriter, r *http.Request, mediaID, segment string) error {
s.transcoder.TouchJob(mediaID)
+6 -3
View File
@@ -302,9 +302,12 @@ func TestRequestTokenFromMediaBrowserAuthorizationHeader(t *testing.T) {
func TestAppendQueryToHLSSegments(t *testing.T) {
in := "#EXTM3U\n#EXTINF:4.0,\nseg_00000.ts\n#EXTINF:4.0,\nseg_00001.ts?old=1\n"
got := appendQueryToHLSSegments(in, "token=abc")
if !strings.Contains(got, "seg_00000.ts?token=abc") {
t.Fatalf("missing tokenized segment: %q", got)
got := appendQueryToHLSSegments(in, "token=abc&start=120.5&_seek=1001")
if !strings.Contains(got, "seg_00000.ts?token=abc&_seek=1001") {
t.Fatalf("missing token or seek generation on segment: %q", got)
}
if strings.Contains(got, "start=120.5") {
t.Fatalf("segment URL must not contain transcode start: %q", got)
}
if !strings.Contains(got, "seg_00001.ts?old=1") {
t.Fatalf("existing query should be preserved: %q", got)
+14 -3
View File
@@ -293,7 +293,11 @@ func (s *StrmService) save115OAuthToken(ctx context.Context, session *strm115Aut
acct.LastTestAt = &now
acct.LastTestResult = "授权成功"
acct.LastTestOK = true
return s.repo.StrmAccount.Update(ctx, acct)
if err := s.repo.StrmAccount.Update(ctx, acct); err != nil {
return err
}
s.invalidate115Provider(acct.ID)
return nil
}
func (s *StrmService) drop115AuthSession(sessionID string) {
@@ -382,8 +386,15 @@ func (s *StrmService) refresh115TokensOnce(ctx context.Context) {
}
}
}
client := cloud115.NewOpenClient(cfg["app_id"], cfg["access_token"], cfg["refresh_token"])
token, err := client.RefreshToken(cfg["refresh_token"])
provider, err := s.providerFor(ctx, acct)
if err != nil {
continue
}
openProvider, ok := provider.(interface{ OpenClient() *cloud115.OpenClient })
if !ok || openProvider.OpenClient() == nil {
continue
}
token, err := openProvider.OpenClient().RefreshToken("")
if err != nil {
msg := "令牌刷新失败:" + err.Error()
if cloud115.IsRefreshTokenDead(err) {
+85
View File
@@ -10,6 +10,7 @@ import (
"github.com/truewhile/MeBox/internal/config"
"github.com/truewhile/MeBox/internal/model"
"github.com/truewhile/MeBox/internal/repository"
"github.com/truewhile/MeBox/internal/service/cloud115"
)
func TestStrmAccountConfigPreviewOf(t *testing.T) {
@@ -71,6 +72,90 @@ func TestUpdateStrmAccountMergesConfigWithoutClearingSecrets(t *testing.T) {
}
}
func TestProviderForReuses115ClientUntilCredentialsChange(t *testing.T) {
svc := testStrmService(t)
ctx := context.Background()
acct, err := svc.CreateStrmAccount(ctx, "115", model.StrmProvider115, map[string]string{
"app_id": "100195129",
"access_token": "at-1",
"refresh_token": "rt-1",
})
if err != nil {
t.Fatalf("create account: %v", err)
}
first, err := svc.providerFor(ctx, acct)
if err != nil {
t.Fatalf("first provider: %v", err)
}
second, err := svc.providerFor(ctx, acct)
if err != nil {
t.Fatalf("second provider: %v", err)
}
if first != second {
t.Fatal("115 provider should be shared per account")
}
updated, err := svc.UpdateStrmAccount(ctx, acct.ID, "", nil, map[string]string{
"access_token": "at-2",
"refresh_token": "rt-2",
})
if err != nil {
t.Fatalf("update account: %v", err)
}
third, err := svc.providerFor(ctx, updated)
if err != nil {
t.Fatalf("provider after credential update: %v", err)
}
if first == third {
t.Fatal("credential update must invalidate the shared provider")
}
oldClient := first.(interface{ OpenClient() *cloud115.OpenClient }).OpenClient()
oldClient.OnTokenRefreshed("at-stale", "rt-stale")
fresh, err := svc.repo.StrmAccount.FindByID(ctx, acct.ID)
if err != nil || fresh == nil {
t.Fatalf("reload account: %v", err)
}
freshCfg, err := svc.strmAccountConfig(fresh)
if err != nil {
t.Fatalf("decode refreshed account: %v", err)
}
if freshCfg["access_token"] != "at-2" || freshCfg["refresh_token"] != "rt-2" {
t.Fatalf("stale client overwrote new credentials: %#v", freshCfg)
}
}
func TestPersist115TokensKeepsSharedClient(t *testing.T) {
svc := testStrmService(t)
ctx := context.Background()
acct, err := svc.CreateStrmAccount(ctx, "115", model.StrmProvider115, map[string]string{
"app_id": "100195129",
"access_token": "at-1",
"refresh_token": "rt-1",
})
if err != nil {
t.Fatalf("create account: %v", err)
}
first, err := svc.providerFor(ctx, acct)
if err != nil {
t.Fatalf("first provider: %v", err)
}
svc.persist115Tokens(acct.ID, "at-2", "rt-2")
fresh, err := svc.repo.StrmAccount.FindByID(ctx, acct.ID)
if err != nil || fresh == nil {
t.Fatalf("reload account: %v", err)
}
second, err := svc.providerFor(ctx, fresh)
if err != nil {
t.Fatalf("second provider: %v", err)
}
if first != second {
t.Fatal("automatic token persistence must keep the in-memory shared client")
}
}
func TestDeleteStrmAccountCascadesEmbyMounts(t *testing.T) {
ctx := context.Background()
db := newServiceTestDB(t, &model.StrmAccount{}, &model.EmbyMount{}, &model.StrmSyncPath{})
+104 -52
View File
@@ -26,6 +26,8 @@ import (
const (
strmMaxTaskRetry = 3
// strmRecentUploadSkipWindow:上传已成功但 115 列表尚未反映时,同步扫描跳过同路径同大小再入队的宽限窗口。
strmRecentUploadSkipWindow = 30 * time.Minute
)
// downloadWorker 下载队列 worker:认领 → 解析直链 → 下载 → 落盘。
@@ -332,18 +334,28 @@ func (s *StrmService) processUploadTask(ctx context.Context, task *model.StrmUpl
}
// processUpload115 115 元数据上传:task.RemotePath 存的是父目录 cid,FileName 为远端文件名。
//
// 幂等要点:
// 1. 上传/重试前按父目录 + 文件名 + SHA1 探活:远端已有同内容副本则跳过上传,仅清理其它脏副本;
// 2. 真正上传成功后把新 file_id 写回 RemoteRef,供下次同步/重试识别;
// 3. 115 上传不保证同名覆盖,内容不同时仍先删旧再传。
func (s *StrmService) processUpload115(ctx context.Context, task *model.StrmUploadTask) {
finish := func(status, message string) {
finish := func(status, message, remoteRef string) {
now := time.Now()
task.Status = status
task.Error = message
task.FinishedAt = &now
// 条件化收尾:与下载侧一致,防止覆盖已取消任务。
if ok, err := s.repo.StrmUpload.UpdateIfRunning(context.Background(), task.ID, map[string]any{
updates := map[string]any{
"status": status,
"error": message,
"finished_at": &now,
}); err != nil {
}
if remoteRef != "" {
task.RemoteRef = remoteRef
updates["remote_ref"] = remoteRef
}
// 条件化收尾:与下载侧一致,防止覆盖已取消任务。
if ok, err := s.repo.StrmUpload.UpdateIfRunning(context.Background(), task.ID, updates); err != nil {
s.log.Warn("update strm upload task failed", zap.Error(err))
} else if !ok {
s.log.Info("strm upload task already closed elsewhere", zap.String("id", task.ID))
@@ -351,7 +363,7 @@ func (s *StrmService) processUpload115(ctx context.Context, task *model.StrmUplo
}
acct, err := s.repo.StrmAccount.FindByID(ctx, task.AccountID)
if err != nil || acct == nil {
finish(model.StrmTaskFailed, "网盘账号不存在")
finish(model.StrmTaskFailed, "网盘账号不存在", "")
return
}
provider, err := s.providerFor(ctx, acct)
@@ -359,58 +371,98 @@ func (s *StrmService) processUpload115(ctx context.Context, task *model.StrmUplo
s.uploadTaskFailWithRetry(task, err.Error())
return
}
named, ok := provider.(interface {
PutFileNamed(ctx context.Context, parentCID, fileName string, r io.Reader) error
})
open115, ok := provider.(cloud.OpenAPI115Provider)
if !ok {
finish(model.StrmTaskFailed, "该网盘不支持元数据上传")
finish(model.StrmTaskFailed, "该网盘不支持元数据上传", "")
return
}
// 以本地为准:网盘端已有同名但内容不同的旧元数据时,先尝试批量删除所有旧副本再上传。
// 115 的上传接口不保证同名覆盖,直接上传可能产生同名重复文件。
// 删除失败时不中止任务——继续上传新文件,旧副本交由下次同步的 cleanupBatchRedundantFiles
// 按目录批量清理(下次同步会看到新旧两个版本,命中新版本后把旧版本 cid 收入 pendingDeletes
// 异步删除)。这样避免了「删旧失败 → 任务重试 → 再次删旧失败 → 永远无法上传」的死循环。
if task.RemoteRef != "" {
open115, ok := provider.(cloud.OpenAPI115Provider)
if !ok {
finish(model.StrmTaskFailed, "该网盘不支持删除远端旧元数据")
return
}
refs := strings.Split(task.RemoteRef, ",")
if err := open115.OpenClient().DeleteFiles(ctx, task.RemotePath, refs...); err != nil {
s.log.Warn("删除网盘旧元数据失败,跳过删除继续上传新文件",
zap.String("task_id", task.ID),
zap.String("local_path", task.LocalPath),
zap.Error(err))
// 不 return:继续上传新文件,旧副本由下次同步清理
}
}
// 优先使用直接本地文件上传接口,零拷贝且彻底根除并发临时文件同名碰撞
if localUploader, ok := provider.(interface {
PutLocalFile(ctx context.Context, parentCID, localPath string) error
}); ok {
if err := localUploader.PutLocalFile(ctx, task.RemotePath, task.LocalPath); err != nil {
s.uploadTaskFailWithRetry(task, "上传失败:"+err.Error())
return
}
finish(model.StrmTaskDone, "")
return
}
client := open115.OpenClient()
f, err := os.Open(task.LocalPath)
if err != nil {
s.uploadTaskFailWithRetry(task, "打开本地文件失败:"+err.Error())
return
}
if err := named.PutFileNamed(ctx, task.RemotePath, task.FileName, f); err != nil {
_ = f.Close()
s.uploadTaskFailWithRetry(task, "上传失败:"+err.Error())
return
}
_ = f.Close()
finish(model.StrmTaskDone, "")
localSHA1, shaErr := cloud115.FileSHA1(task.LocalPath)
if shaErr != nil {
s.uploadTaskFailWithRetry(task, "计算本地 SHA1 失败:"+shaErr.Error())
return
}
info, statErr := os.Stat(task.LocalPath)
if statErr != nil {
s.uploadTaskFailWithRetry(task, "读取本地文件失败:"+statErr.Error())
return
}
localSize := info.Size()
// ── 探活:父目录下是否已有同名同内容副本(覆盖「上传成功但本地当失败重试」)──
matched, sameName, probeErr := client.FindNamedContentInParent(ctx, task.RemotePath, task.FileName, localSHA1, localSize)
if probeErr != nil {
// 探活失败不阻断上传:按原路径继续,避免列表接口抖动导致任务永久卡住
s.log.Warn("115 上传前探活失败,继续上传",
zap.String("task_id", task.ID),
zap.String("local_path", task.LocalPath),
zap.Error(probeErr))
} else if matched != nil && matched.FileId != "" {
staleIDs := collectStale115FileIDs(task.RemoteRef, sameName, matched.FileId)
if len(staleIDs) > 0 {
if err := client.DeleteFiles(ctx, task.RemotePath, staleIDs...); err != nil {
s.log.Warn("探活命中后清理 115 脏副本失败(已跳过上传)",
zap.String("task_id", task.ID),
zap.String("matched_id", matched.FileId),
zap.Error(err))
}
}
s.log.Info("115 元数据已存在同内容副本,跳过上传",
zap.String("task_id", task.ID),
zap.String("local_path", task.LocalPath),
zap.String("file_id", matched.FileId))
finish(model.StrmTaskDone, "", matched.FileId)
return
}
// ── 需要上传:先尽量删掉任务携带的旧副本,再真正上传 ──
// 删除失败时不中止——继续上传新文件,旧副本交由下次同步 cleanupBatchRedundantFiles。
if task.RemoteRef != "" {
refs := strings.Split(task.RemoteRef, ",")
if err := client.DeleteFiles(ctx, task.RemotePath, refs...); err != nil {
s.log.Warn("删除网盘旧元数据失败,跳过删除继续上传新文件",
zap.String("task_id", task.ID),
zap.String("local_path", task.LocalPath),
zap.Error(err))
}
}
result, err := client.Upload(ctx, task.LocalPath, task.RemotePath, "", "")
if err != nil {
s.uploadTaskFailWithRetry(task, "上传失败:"+err.Error())
return
}
newID := ""
if result != nil {
newID = strings.TrimSpace(result.FileId)
}
finish(model.StrmTaskDone, "", newID)
}
// collectStale115FileIDs 汇总待删脏副本:任务 RemoteRef + 探活所见同名文件,排除 keepID。
func collectStale115FileIDs(remoteRef string, sameName []cloud115.RemoteFile, keepID string) []string {
seen := map[string]struct{}{}
var out []string
add := func(id string) {
id = strings.TrimSpace(id)
if id == "" || id == keepID {
return
}
if _, ok := seen[id]; ok {
return
}
seen[id] = struct{}{}
out = append(out, id)
}
for _, id := range strings.Split(remoteRef, ",") {
add(id)
}
for _, f := range sameName {
add(f.FileId)
}
return out
}
// downloadTaskFailWithRetry 下载失败任务按退避重试,超过上限标记 failed。
func (s *StrmService) downloadTaskFailWithRetry(task *model.StrmDownloadTask, message string) {
+104 -4
View File
@@ -3,10 +3,12 @@ package service
import (
"context"
"errors"
"fmt"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"sync"
"testing"
"time"
@@ -174,8 +176,8 @@ func TestRequeueDownloadTask(t *testing.T) {
}
// TestProcessUpload115DeletesStaleRemoteMetaFirst 验证 115 覆盖上传语义(以本地为准):
// 任务携带网盘旧文件 ID 时,必须先调用 /open/ufile/delete 删除旧元数据再上传本地文件,
// 避免 115 出现同名重复文件;删除请求应携带 file_ids 与父目录 cid。
// 探活未命中时,任务携带网盘旧文件 ID 必须先 /open/ufile/delete 再上传;
// 上传成功后 RemoteRef 回写为新 file_id。
func TestProcessUpload115DeletesStaleRemoteMetaFirst(t *testing.T) {
svc := testStrmService(t)
localDir := t.TempDir()
@@ -197,6 +199,10 @@ func TestProcessUpload115DeletesStaleRemoteMetaFirst(t *testing.T) {
mu.Lock()
defer mu.Unlock()
switch r.URL.Path {
case "/open/ufile/files":
calls = append(calls, "probe")
// 探活:目录为空 / 无同内容副本 → 继续删旧上传
w.Write([]byte(`{"state":true,"data":[]}`))
case "/open/ufile/delete":
calls = append(calls, "delete")
deleteForm["file_ids"] = r.FormValue("file_ids")
@@ -232,10 +238,13 @@ func TestProcessUpload115DeletesStaleRemoteMetaFirst(t *testing.T) {
if task.Status != model.StrmTaskDone {
t.Fatalf("upload task should succeed, status = %s, error = %s", task.Status, task.Error)
}
if task.RemoteRef != "new-1" {
t.Fatalf("successful upload should record new file_id in RemoteRef, got %q", task.RemoteRef)
}
mu.Lock()
defer mu.Unlock()
if len(calls) != 2 || calls[0] != "delete" || calls[1] != "upload" {
t.Fatalf("expected delete before upload, got calls = %v", calls)
if len(calls) != 3 || calls[0] != "probe" || calls[1] != "delete" || calls[2] != "upload" {
t.Fatalf("expected probe→delete→upload, got calls = %v", calls)
}
if deleteForm["file_ids"] != "old-file-1" {
t.Fatalf("delete file_ids = %q, want old-file-1", deleteForm["file_ids"])
@@ -245,6 +254,97 @@ func TestProcessUpload115DeletesStaleRemoteMetaFirst(t *testing.T) {
}
}
// TestProcessUpload115SkipsWhenProbeFindsSameContent 验证上传/重试前探活命中同内容副本时跳过上传,
// 并清理其它脏副本,RemoteRef 回写为已存在的 file_id。
func TestProcessUpload115SkipsWhenProbeFindsSameContent(t *testing.T) {
svc := testStrmService(t)
localDir := t.TempDir()
localFile := filepath.Join(localDir, "movie.nfo")
content := []byte("already-on-115")
if err := os.WriteFile(localFile, content, 0o644); err != nil {
t.Fatal(err)
}
sha, err := cloud115.FileSHA1(localFile)
if err != nil {
t.Fatal(err)
}
acct := &model.StrmAccount{Name: "fake115", Provider: "cloud115", Config: "{}", Enabled: true}
if err := svc.repo.StrmAccount.Create(context.Background(), acct); err != nil {
t.Fatal(err)
}
var mu sync.Mutex
var calls []string
deleteForm := map[string]string{}
api := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
_ = r.ParseForm()
mu.Lock()
defer mu.Unlock()
switch r.URL.Path {
case "/open/ufile/files":
calls = append(calls, "probe")
w.Write([]byte(fmt.Sprintf(
`{"state":true,"data":[
{"fid":"keep-1","fc":"1","fn":"movie.nfo","fs":%d,"sha1":%q,"fta":"1"},
{"fid":"dirty-2","fc":"1","fn":"movie.nfo","fs":9,"sha1":"DEADBEEF","fta":"1"}
]}`, len(content), sha)))
case "/open/ufile/delete":
calls = append(calls, "delete")
deleteForm["file_ids"] = r.FormValue("file_ids")
w.Write([]byte(`{"state":true,"data":[]}`))
case "/open/upload/init":
calls = append(calls, "upload")
w.Write([]byte(`{"state":true,"data":{"status":2,"file_id":"should-not","pick_code":"x","callback":null}}`))
default:
t.Errorf("unexpected 115 api path %s", r.URL.Path)
w.Write([]byte(`{"state":false,"message":"unexpected path"}`))
}
}))
defer api.Close()
oldPro := cloud115.ProAPIBase
cloud115.ProAPIBase = api.URL
defer func() { cloud115.ProAPIBase = oldPro }()
task := &model.StrmUploadTask{
Base: model.Base{ID: "up-skip-1"},
SyncPathID: "p1",
AccountID: acct.ID,
Provider: model.StrmProvider115,
FileName: "movie.nfo",
LocalPath: localFile,
RemotePath: "777",
RemoteRef: "old-ref",
Size: int64(len(content)),
Status: model.StrmTaskRunning,
}
svc.processUpload115(context.Background(), task)
if task.Status != model.StrmTaskDone {
t.Fatalf("probe hit should finish done, status=%s err=%s", task.Status, task.Error)
}
if task.RemoteRef != "keep-1" {
t.Fatalf("RemoteRef should be matched file_id keep-1, got %q", task.RemoteRef)
}
mu.Lock()
defer mu.Unlock()
if len(calls) != 2 || calls[0] != "probe" || calls[1] != "delete" {
t.Fatalf("expected probe→delete (no upload), got %v", calls)
}
// 脏副本 dirty-2 与任务旧 ref 都应被清理,keep-1 不得出现
ids := strings.Split(deleteForm["file_ids"], ",")
idSet := map[string]bool{}
for _, id := range ids {
idSet[strings.TrimSpace(id)] = true
}
if !idSet["dirty-2"] || !idSet["old-ref"] {
t.Fatalf("delete should include dirty-2 and old-ref, got %q", deleteForm["file_ids"])
}
if idSet["keep-1"] {
t.Fatalf("must not delete matched keep-1, got %q", deleteForm["file_ids"])
}
}
// TestBatchResolve115Links 验证下载队列的批量换链:同账号多个 115 任务的
// pickcode 合并为一次 downurl 请求(官方接口支持逗号分隔多 pick_code),
// 重复引用去重、非 115 任务不参与、直链携带绑定 UA。
+59 -10
View File
@@ -42,6 +42,7 @@ const (
StrmSettingDownloadMeta = "strm.download_meta"
StrmSettingUploadMeta = "strm.upload_meta"
StrmSettingDeleteDir = "strm.delete_dir"
StrmSettingKeepExt = "strm.keep_ext"
StrmSettingDownloadThreads = "strm.download_threads"
StrmSettingUploadThreads = "strm.upload_threads"
)
@@ -69,6 +70,7 @@ var StrmSettingDefs = map[string]struct {
StrmSettingDownloadMeta: {Default: "true", Label: "下载元数据", Kind: "bool", Help: "同步时把远端 nfo/图片/字幕下载到本地输出目录"},
StrmSettingUploadMeta: {Default: "false", Label: "上传元数据", Kind: "bool", Help: "同步时把本地元数据上传到远端;本地与网盘元数据不同时以本地为准覆盖(需网盘支持写入)"},
StrmSettingDeleteDir: {Default: "false", Label: "清理空目录", Kind: "bool", Help: "清理远端已删除的多余 .strm/元数据后,删除空目录"},
StrmSettingKeepExt: {Default: "false", Label: "保留视频扩展名(多版本)", Kind: "bool", Help: "关闭(默认):同名不同扩展(如 竞女01.mkv / 竞女01.mp4)按体积→mtime→扩展名优先级择优生成一条 name.strm;开启:分别生成 name.mkv.strm / name.mp4.strm,保留全部版本供播放切换"},
Strm115RelayKeySetting: {Default: "", Label: "115 中继授权共享密钥", Kind: "text", Help: "QMediaSync/MQFamily 中继授权的共享 AES 密钥(OAUTH_RELAY_ENCRYPTION_KEY);不配置则中继授权不可用"},
StrmSettingDownloadThreads: {Default: "6", Label: "下载队列线程数", Kind: "number", Help: "OpenList/CloudDrive2 元数据下载并发数(115 独立限速为 3)"},
StrmSettingUploadThreads: {Default: "2", Label: "上传队列线程数", Kind: "number", Help: "元数据上传并发数"},
@@ -93,6 +95,9 @@ type StrmService struct {
oauthSessions map[string]*strm115AuthSession
wafUntil time.Time // 115 风控/限流熔断截止时间(由 mu 保护)
providerMu sync.Mutex
provider115Cache map[string]cloud.Provider // account ID -> shared provider/OpenClient
downloadSem115 chan struct{} // 115 换直链+下载并发上限(风控兜底)
downloadSemDAV chan struct{} // WebDAV/OpenList/CloudDrive2 元数据下载并发上限
downloadSemOnce sync.Once
@@ -156,15 +161,16 @@ func (s *StrmService) releaseDownloadSlot(provider string) {
// NewStrmService constructs the STRM service.
func NewStrmService(cfg *config.Config, log *zap.Logger, repos *repository.Container, crypto *CryptoService) *StrmService {
return &StrmService{
log: log,
repo: repos,
cfg: cfg,
crypto: crypto,
http: &http.Client{Timeout: 90 * time.Second},
stopCh: make(chan struct{}),
baseCtx: context.Background(),
running: map[string]context.CancelFunc{},
oauthSessions: map[string]*strm115AuthSession{},
log: log,
repo: repos,
cfg: cfg,
crypto: crypto,
http: &http.Client{Timeout: 90 * time.Second},
stopCh: make(chan struct{}),
baseCtx: context.Background(),
running: map[string]context.CancelFunc{},
oauthSessions: map[string]*strm115AuthSession{},
provider115Cache: map[string]cloud.Provider{},
}
}
@@ -407,6 +413,9 @@ func (s *StrmService) UpdateStrmAccount(ctx context.Context, id, name string, en
if err := s.repo.StrmAccount.Update(ctx, acct); err != nil {
return nil, err
}
if acct.Provider == model.StrmProvider115 && len(config) > 0 {
s.invalidate115Provider(acct.ID)
}
return acct, nil
}
@@ -424,6 +433,7 @@ func (s *StrmService) DeleteStrmAccount(ctx context.Context, id string) error {
if err := s.repo.StrmAccount.Delete(ctx, id); err != nil {
return err
}
s.invalidate115Provider(id)
// 级联清理远程 Emby 挂载:否则留下孤儿挂载,挂载计数/列表仍会显示。
// 账号已删,挂载清理失败只记日志,不让删除请求报错。
if _, err := s.repo.EmbyMount.DeleteByAccountID(ctx, id); err != nil && s.log != nil {
@@ -462,6 +472,23 @@ func (s *StrmService) ListAccounts(ctx context.Context) ([]model.StrmAccount, er
// providerFor 依据账号配置构建网盘驱动。
func (s *StrmService) providerFor(ctx context.Context, acct *model.StrmAccount) (cloud.Provider, error) {
if acct != nil && acct.Provider == model.StrmProvider115 {
s.providerMu.Lock()
defer s.providerMu.Unlock()
if provider := s.provider115Cache[acct.ID]; provider != nil {
return provider, nil
}
provider, err := s.newProvider(ctx, acct)
if err != nil {
return nil, err
}
s.provider115Cache[acct.ID] = provider
return provider, nil
}
return s.newProvider(ctx, acct)
}
func (s *StrmService) newProvider(ctx context.Context, acct *model.StrmAccount) (cloud.Provider, error) {
cfg, err := s.strmAccountConfig(acct)
if err != nil {
return nil, err
@@ -480,13 +507,33 @@ func (s *StrmService) providerFor(ctx context.Context, acct *model.StrmAccount)
// refresh_token 再刷(一次性轮转),两者互相作废,最终把有效账号
// 标成“授权已失效”。
if oc, ok := provider.(interface{ OpenClient() *cloud115.OpenClient }); ok {
oc.OpenClient().OnTokenRefreshed = func(accessToken, refreshToken string) {
client := oc.OpenClient()
client.OnTokenRefreshed = func(accessToken, refreshToken string) {
// 账号重新授权/修改凭据后,旧客户端可能仍有在途请求。旧请求
// 刷新的令牌不能覆盖新授权写入的凭据。
if !s.isCurrent115Client(acct.ID, client) {
return
}
s.persist115Tokens(acct.ID, accessToken, refreshToken)
}
}
return provider, nil
}
func (s *StrmService) invalidate115Provider(accountID string) {
s.providerMu.Lock()
delete(s.provider115Cache, accountID)
s.providerMu.Unlock()
}
func (s *StrmService) isCurrent115Client(accountID string, client *cloud115.OpenClient) bool {
s.providerMu.Lock()
defer s.providerMu.Unlock()
provider := s.provider115Cache[accountID]
openProvider, ok := provider.(interface{ OpenClient() *cloud115.OpenClient })
return ok && openProvider.OpenClient() == client
}
// ─── 全局设置 ──────────────────────────────────────────────────────────────────
// GetStrmSettings 返回全局 strm 设置(含默认值)。
@@ -731,6 +778,7 @@ func (s *StrmService) strmEffectiveConfig(ctx context.Context, p *model.StrmSync
cfg.DownloadMeta = p.DownloadMeta
cfg.UploadMeta = p.UploadMeta
cfg.DeleteDir = p.DeleteDir
cfg.KeepExt = p.KeepExt
cfg.BaseURL = strings.TrimRight(cfg.BaseURL, "/")
return cfg, nil
}
@@ -964,6 +1012,7 @@ type strmPathConfig struct {
DownloadMeta bool
UploadMeta bool
DeleteDir bool
KeepExt bool
}
// ─── 本地目录浏览(添加同步目录用,兼容 Windows/Linux) ─────────────────────────
+177 -73
View File
@@ -49,15 +49,19 @@ type strmSyncState struct {
mu sync.Mutex
processed int // 已处理文件计数(用于定期落库进度)
lastProgressFlush time.Time // 上次进度落库时间
seenVideo map[string]bool // "v:"+去掉扩展名的相对路径 → 远端存在该视频
seenVideo map[string]bool // "v:"+strm 去扩展名相对路径 → 远端存在该视频(供 prune)
seenMeta map[string]bool // "m:"+相对路径 → 远端存在该元数据
remoteMeta map[string][]remoteMetaItem // "m:"+相对路径 → 远端元数据副本列表(多副本聚合,支持择优比对与冗余清理)
seenMetaTarget map[string]cloud.FileEntry
seenVideoTarget map[string]cloud.FileEntry
// remoteVideos:prefer 模式下同名(去扩展名)视频候选列表,walk 结束后择优写盘
remoteVideos map[string][]remoteVideoCandidate
activeDownloadPaths map[string]bool // 本地已在排队/进行的下载任务路径(内存去重)
activeUploadPaths map[string]bool // 本地已在排队/进行的上传任务路径(内存去重)
pendingDownloads []*model.StrmDownloadTask
pendingUploads []*model.StrmUploadTask
// recentDoneUploadSizes:近期已成功上传的 local_path → size,缩短「done 但列表未到」窗口内的重复入队
recentDoneUploadSizes map[string]int64
pendingDownloads []*model.StrmDownloadTask
pendingUploads []*model.StrmUploadTask
dirCache sync.Map // dirID (string) -> relativePath (string)
dirPathToID map[string]string // relativePath (string) -> dirID(115 上传父目录寻址用,walk 后构建)
dirCacheDirty map[string]string // 待批量落库的目录缓存(dirID → 相对路径),避免逐目录单条 upsert
@@ -65,6 +69,13 @@ type strmSyncState struct {
scanIncomplete atomic.Bool // 远端目录树/文件列表本次扫描不完整 → 禁止增量 prune 误删本地文件
}
// remoteVideoCandidate 记录同名视频的一个远端副本(不同扩展名/体积)。
type remoteVideoCandidate struct {
entry cloud.FileEntry
rel string
ext string
}
// StartSync 启动一次同步(异步执行,同一目录同时只允许一个任务)。
// syncType 支持 "incremental"(默认增量)和 "full"(全量同步)。
func (s *StrmService) StartSync(ctx context.Context, pathID string, syncType ...string) error {
@@ -274,6 +285,7 @@ func (s *StrmService) runSync(ctx context.Context, p *model.StrmSyncPath, rec *m
remoteMeta: map[string][]remoteMetaItem{},
seenMetaTarget: map[string]cloud.FileEntry{},
seenVideoTarget: map[string]cloud.FileEntry{},
remoteVideos: map[string][]remoteVideoCandidate{},
}
if p.Provider != model.StrmProviderLocal {
acct, err := s.repo.StrmAccount.FindByID(ctx, p.AccountID)
@@ -350,6 +362,14 @@ func (st *strmSyncState) run() error {
} else {
st.activeUploadPaths = map[string]bool{}
}
since := time.Now().Add(-strmRecentUploadSkipWindow)
if recent, err := st.s.repo.StrmUpload.GetRecentDoneUploadSizeMap(st.ctx, st.p.ID, since); err == nil {
st.recentDoneUploadSizes = recent
} else {
st.recentDoneUploadSizes = map[string]int64{}
st.s.log.Warn("加载近期已完成上传任务失败,跳过 done 窗口去重",
zap.String("path_id", st.p.ID), zap.Error(err))
}
}
if st.provider != nil {
@@ -376,6 +396,7 @@ func (st *strmSyncState) run() error {
return err
}
}
st.flushPreferredVideos()
st.flushPendingDownloads()
st.flushProgress()
if st.cfg.UploadMeta && st.provider != nil {
@@ -1185,21 +1206,123 @@ func (st *strmSyncState) fetch115FilesAdaptive(ctx context.Context, open115 *clo
return allFiles, nil
}
// handleVideo 生成/更新 .strm 文件。
// handleVideo 收集/生成 .strm 文件。
// KeepExt=true:每个视频写 name.ext.strm,保留全部版本。
// KeepExt=false(默认):同名候选先入队,walk 结束后按体积→mtime→扩展名优先级择优写一条 name.strm。
func (st *strmSyncState) handleVideo(entry cloud.FileEntry, rel, ext string) {
relSansExt := rel[:len(rel)-len(ext)]
st.mu.Lock()
if st.seenVideo["v:"+relSansExt] {
st.mu.Unlock()
if st.cfg.KeepExt {
st.writeVideoStrm(entry, rel, ext, rel+".strm", true)
return
}
st.seenVideo["v:"+relSansExt] = true
st.mu.Lock()
if st.remoteVideos == nil {
st.remoteVideos = map[string][]remoteVideoCandidate{}
}
st.remoteVideos[relSansExt] = append(st.remoteVideos[relSansExt], remoteVideoCandidate{
entry: entry,
rel: rel,
ext: ext,
})
st.mu.Unlock()
st.touchProgress()
}
// flushPreferredVideos 在 prefer 模式下对同名多版本择优写盘,并记录冲突日志。
func (st *strmSyncState) flushPreferredVideos() {
if st.cfg.KeepExt {
return
}
st.mu.Lock()
pending := st.remoteVideos
st.remoteVideos = map[string][]remoteVideoCandidate{}
st.mu.Unlock()
for base, cands := range pending {
if len(cands) == 0 {
continue
}
winnerIdx := 0
for i := 1; i < len(cands); i++ {
if betterStrmVideoCandidate(cands[i], cands[winnerIdx], st.cfg.VideoExt) {
winnerIdx = i
}
}
winner := cands[winnerIdx]
if len(cands) > 1 {
skippedRels := make([]string, 0, len(cands)-1)
skippedRefs := make([]string, 0, len(cands)-1)
for i, c := range cands {
if i == winnerIdx {
continue
}
skippedRels = append(skippedRels, c.rel)
ref := strings.TrimSpace(c.entry.PickCode)
if ref == "" {
ref = strings.TrimSpace(c.entry.ID)
}
if ref != "" {
skippedRefs = append(skippedRefs, ref)
}
}
st.s.log.Info("strm multi-version prefer",
zap.String("base", base),
zap.String("winner", winner.rel),
zap.String("winner_ref", firstNonEmpty(winner.entry.PickCode, winner.entry.ID)),
zap.Int64("winner_size", winner.entry.Size),
zap.Strings("skipped", skippedRels),
zap.Strings("skipped_refs", skippedRefs),
)
st.mu.Lock()
st.rec.Skipped += int64(len(cands) - 1)
st.mu.Unlock()
}
// 候选已在 handleVideo 计入 Total,此处不再重复 touchProgress
st.writeVideoStrm(winner.entry, winner.rel, winner.ext, base+".strm", false)
}
}
// betterStrmVideoCandidate 决定 prefer 模式下的赢家:体积更大 → mtime 更新 → video_ext 列表更靠前。
func betterStrmVideoCandidate(candidate, current remoteVideoCandidate, videoExtOrder []string) bool {
if candidate.entry.Size != current.entry.Size {
return candidate.entry.Size > current.entry.Size
}
if candidate.entry.MTime != current.entry.MTime {
return candidate.entry.MTime > current.entry.MTime
}
return strmExtPriority(candidate.ext, videoExtOrder) < strmExtPriority(current.ext, videoExtOrder)
}
func strmExtPriority(ext string, order []string) int {
ext = strings.TrimPrefix(strings.ToLower(strings.TrimSpace(ext)), ".")
for i, item := range order {
if strings.TrimPrefix(strings.ToLower(strings.TrimSpace(item)), ".") == ext {
return i
}
}
return len(order) + 1
}
// writeVideoStrm 把单个视频写成目标 .strm(targetRel 相对本地输出根)。
// countProgress 控制是否计入 Total(prefer 模式下候选已在收集阶段计数)。
func (st *strmSyncState) writeVideoStrm(entry cloud.FileEntry, rel, ext, targetRel string, countProgress bool) {
seenKey := "v:" + strings.TrimSuffix(targetRel, ".strm")
st.mu.Lock()
if st.seenVideo[seenKey] {
st.mu.Unlock()
if countProgress {
st.touchProgress()
}
return
}
st.seenVideo[seenKey] = true
st.mu.Unlock()
targetRel := relSansExt + ".strm"
target, err := joinLocalRel(st.p.LocalPath, targetRel)
if err != nil {
st.s.log.Warn("strm target path out of root", zap.String("rel", targetRel), zap.Error(err))
if countProgress {
st.touchProgress()
}
return
}
@@ -1209,28 +1332,35 @@ func (st *strmSyncState) handleVideo(entry cloud.FileEntry, rel, ext string) {
}
if _, exists := st.seenVideoTarget[target]; exists {
st.mu.Unlock()
st.touchProgress()
if countProgress {
st.touchProgress()
}
return
}
st.seenVideoTarget[target] = entry
st.mu.Unlock()
done := func() {
if countProgress {
st.touchProgress()
}
}
// 增量同步模式快速检查:本地 strm 文件存在、非空且修改时间与远端 mtime 一致,直接跳过无需读磁盘
if st.syncType == model.StrmSyncTypeIncremental && entry.MTime > 0 {
if info, err := os.Stat(target); err == nil && info.Size() > 0 && info.ModTime().Unix() == entry.MTime {
st.mu.Lock()
st.rec.Skipped++
st.mu.Unlock()
st.touchProgress()
done()
return
}
}
content, err := st.strmContent(entry, rel, ext)
if err != nil {
// 并发 worker 下 rec.Message 无锁写会有数据竞争,这里仅记录日志;
// 最终同步结果的 message 由 finishSync 统一填充。
st.s.log.Warn("build strm content failed", zap.String("file", rel), zap.Error(err))
done()
return
}
existing := ""
@@ -1238,7 +1368,6 @@ func (st *strmSyncState) handleVideo(entry cloud.FileEntry, rel, ext string) {
existing = string(data)
}
if existing == content {
// 对齐本地 strm 修改时间为远端 mtime,便于后续秒级比对
if entry.MTime > 0 {
mTime := time.Unix(entry.MTime, 0)
_ = os.Chtimes(target, mTime, mTime)
@@ -1246,21 +1375,24 @@ func (st *strmSyncState) handleVideo(entry cloud.FileEntry, rel, ext string) {
st.mu.Lock()
st.rec.Skipped++
st.mu.Unlock()
st.touchProgress()
done()
return
}
if err := os.MkdirAll(filepath.Dir(target), 0o755); err != nil {
st.s.log.Warn("mkdir strm dir failed", zap.String("dir", filepath.Dir(target)), zap.Error(err))
done()
return
}
tmp := target + ".tmp"
if err := os.WriteFile(tmp, []byte(content), 0o644); err != nil {
st.s.log.Warn("write strm tmp failed", zap.String("file", target), zap.Error(err))
done()
return
}
if err := os.Rename(tmp, target); err != nil {
_ = os.Remove(tmp)
st.s.log.Warn("rename strm failed", zap.String("file", target), zap.Error(err))
done()
return
}
if entry.MTime > 0 {
@@ -1270,7 +1402,7 @@ func (st *strmSyncState) handleVideo(entry cloud.FileEntry, rel, ext string) {
st.mu.Lock()
st.rec.NewStrm++
st.mu.Unlock()
st.touchProgress()
done()
}
// strmContent 构建 strm 文件内容(一行指向本服务播放端点的 URL)。
@@ -1524,50 +1656,8 @@ func (st *strmSyncState) walkLocalSource() error {
st.touchProgress()
return nil
}
relSansExt := rel[:len(rel)-len(ext)]
st.mu.Lock()
st.seenVideo["v:"+relSansExt] = true
st.mu.Unlock()
content, err := st.strmContent(cloud.FileEntry{Name: filepath.Base(rel), Size: info.Size()}, rel, ext)
if err != nil {
return nil
}
target, err := joinLocalRel(st.p.LocalPath, relSansExt+".strm")
if err != nil {
return nil
}
mTime := info.ModTime()
if st.syncType == model.StrmSyncTypeIncremental {
if tInfo, err := os.Stat(target); err == nil && tInfo.Size() > 0 && tInfo.ModTime().Unix() == mTime.Unix() {
st.mu.Lock()
st.rec.Skipped++
st.mu.Unlock()
st.touchProgress()
return nil
}
}
if data, err := os.ReadFile(target); err == nil && string(data) == content {
_ = os.Chtimes(target, mTime, mTime)
st.mu.Lock()
st.rec.Skipped++
st.mu.Unlock()
st.touchProgress()
return nil
}
if err := os.MkdirAll(filepath.Dir(target), 0o755); err != nil {
return nil
}
tmp := target + ".tmp"
if err := os.WriteFile(tmp, []byte(content), 0o644); err == nil {
_ = os.Rename(tmp, target)
_ = os.Chtimes(target, mTime, mTime)
} else {
_ = os.Remove(tmp)
}
st.mu.Lock()
st.rec.NewStrm++
st.mu.Unlock()
st.touchProgress()
entry := cloud.FileEntry{Name: filepath.Base(rel), Size: info.Size(), MTime: info.ModTime().Unix()}
st.handleVideo(entry, rel, ext)
return nil
})
}
@@ -1575,6 +1665,7 @@ func (st *strmSyncState) walkLocalSource() error {
// scanLocalMetaForUpload 扫描本地元数据,与远端比对后入上传队列。
// 以本地为准:网盘端不存在、同名不同大小、或同名同大小但 SHA1 不同(115 提供
// 远端哈希时做内容级比对)均入队覆盖上传;同名同大小同内容视为同一文件跳过。
// 另:近期已成功上传且大小未变的路径跳过入队,缩短「任务 done 但 115 列表滞后」窗口。
func (st *strmSyncState) scanLocalMetaForUpload() error {
defer st.flushPendingUploads()
if st.activeUploadPaths == nil {
@@ -1584,6 +1675,14 @@ func (st *strmSyncState) scanLocalMetaForUpload() error {
st.activeUploadPaths = map[string]bool{}
}
}
if st.recentDoneUploadSizes == nil {
since := time.Now().Add(-strmRecentUploadSkipWindow)
if recent, err := st.s.repo.StrmUpload.GetRecentDoneUploadSizeMap(st.ctx, st.p.ID, since); err == nil {
st.recentDoneUploadSizes = recent
} else {
st.recentDoneUploadSizes = map[string]int64{}
}
}
var pendingDeletes map[string][]string
if st.p.Provider == model.StrmProvider115 {
pendingDeletes = map[string][]string{}
@@ -1632,23 +1731,28 @@ func (st *strmSyncState) scanLocalMetaForUpload() error {
}
}
}
if matchedIdx >= 0 {
// 远端已存在完全一致的副本,跳过上传!
// 若远端还存在其他同名脏副本(副本总数 > 1),在 115 下收集待删除 ID,稍后按目录批量删除。
// 严禁将已命中的最新副本 ID (matchedID) 放入待删列表,杜绝误杀唯一有效副本。
if len(entries) > 1 && st.p.Provider == model.StrmProvider115 {
parentCID := st.uploadRemoteTarget(rel)
if parentCID != "" {
matchedID := entries[matchedIdx].ID
for i, it := range entries {
if i != matchedIdx && it.ID != "" && it.ID != matchedID {
pendingDeletes[parentCID] = append(pendingDeletes[parentCID], it.ID)
}
if matchedIdx >= 0 {
// 远端已存在完全一致的副本,跳过上传!
// 若远端还存在其他同名脏副本(副本总数 > 1),在 115 下收集待删除 ID,稍后按目录批量删除。
// 严禁将已命中的最新副本 ID (matchedID) 放入待删列表,杜绝误杀唯一有效副本。
if len(entries) > 1 && st.p.Provider == model.StrmProvider115 {
parentCID := st.uploadRemoteTarget(rel)
if parentCID != "" {
matchedID := entries[matchedIdx].ID
for i, it := range entries {
if i != matchedIdx && it.ID != "" && it.ID != matchedID {
pendingDeletes[parentCID] = append(pendingDeletes[parentCID], it.ID)
}
}
}
return nil
}
return nil
}
}
// 近期已成功上传且大小未变:115 列表可能尚未反映,避免重复入队
if doneSize, ok := st.recentDoneUploadSizes[path]; ok && doneSize == info.Size() {
return nil
}
remoteTarget := st.uploadRemoteTarget(rel)
+198
View File
@@ -1453,3 +1453,201 @@ func TestWalk115AdaptiveHierarchicalFlatScan(t *testing.T) {
}
}
}
// TestScanLocalMetaForUploadSkipsRecentDoneSameSize 验证近期已成功上传且大小未变时,
// 即使远端列表尚未反映,也不再入队,缩短「done 但列表滞后」窗口。
func TestScanLocalMetaForUploadSkipsRecentDoneSameSize(t *testing.T) {
svc := testStrmService(t)
localDir := t.TempDir()
nfoPath := filepath.Join(localDir, "movie.nfo")
content := []byte("uploaded-recently")
writeFile(t, nfoPath, string(content))
p := &model.StrmSyncPath{
Base: model.Base{ID: "recent-done-skip"},
Provider: model.StrmProvider115,
RemotePath: "root-cid",
LocalPath: localDir,
UploadMeta: true,
}
now := time.Now()
doneTask := &model.StrmUploadTask{
SyncPathID: p.ID,
Provider: model.StrmProvider115,
FileName: "movie.nfo",
LocalPath: nfoPath,
RemotePath: "parent-cid",
RemoteRef: "new-file-id",
Size: int64(len(content)),
Status: model.StrmTaskDone,
FinishedAt: &now,
}
if err := svc.repo.StrmUpload.Create(context.Background(), doneTask); err != nil {
t.Fatal(err)
}
st := &strmSyncState{
s: svc,
ctx: context.Background(),
p: p,
cfg: &strmPathConfig{UploadMeta: true, MetaExt: []string{"nfo"}},
rec: &model.StrmSyncRecord{},
seenMeta: map[string]bool{},
remoteMeta: map[string][]remoteMetaItem{}, // 远端列表空 = 滞后
dirPathToID: map[string]string{"": "parent-cid"},
activeUploadPaths: map[string]bool{},
recentDoneUploadSizes: nil, // 让 scan 自行从 DB 加载
}
if err := st.scanLocalMetaForUpload(); err != nil {
t.Fatalf("scanLocalMetaForUpload failed: %v", err)
}
if st.rec.Uploaded != 0 {
t.Fatalf("recent done same-size should skip enqueue, uploaded=%d", st.rec.Uploaded)
}
if len(st.pendingUploads) != 0 {
t.Fatalf("expected no pending uploads, got %d", len(st.pendingUploads))
}
}
// TestScanLocalMetaForUploadRequeuesWhenRecentDoneSizeChanged 本地大小变化后不应被近期 done 窗口挡住。
func TestScanLocalMetaForUploadRequeuesWhenRecentDoneSizeChanged(t *testing.T) {
svc := testStrmService(t)
localDir := t.TempDir()
nfoPath := filepath.Join(localDir, "movie.nfo")
writeFile(t, nfoPath, "new-longer-content-xxx")
p := &model.StrmSyncPath{
Base: model.Base{ID: "recent-done-resize"},
Provider: model.StrmProvider115,
RemotePath: "root-cid",
LocalPath: localDir,
UploadMeta: true,
}
now := time.Now()
doneTask := &model.StrmUploadTask{
SyncPathID: p.ID,
Provider: model.StrmProvider115,
FileName: "movie.nfo",
LocalPath: nfoPath,
RemotePath: "parent-cid",
Size: 3, // 旧大小
Status: model.StrmTaskDone,
FinishedAt: &now,
}
if err := svc.repo.StrmUpload.Create(context.Background(), doneTask); err != nil {
t.Fatal(err)
}
st := &strmSyncState{
s: svc,
ctx: context.Background(),
p: p,
cfg: &strmPathConfig{UploadMeta: true, MetaExt: []string{"nfo"}},
rec: &model.StrmSyncRecord{},
seenMeta: map[string]bool{},
remoteMeta: map[string][]remoteMetaItem{},
dirPathToID: map[string]string{"": "parent-cid"},
activeUploadPaths: map[string]bool{},
}
if err := st.scanLocalMetaForUpload(); err != nil {
t.Fatalf("scanLocalMetaForUpload failed: %v", err)
}
if st.rec.Uploaded != 1 {
t.Fatalf("size changed should re-enqueue, uploaded=%d", st.rec.Uploaded)
}
tasks, _, err := svc.repo.StrmUpload.List(context.Background(), model.StrmTaskPending, 1, 20)
if err != nil {
t.Fatal(err)
}
found := false
for _, task := range tasks {
if task.LocalPath == nfoPath && task.Status == model.StrmTaskPending {
found = true
break
}
}
if !found {
t.Fatal("expected a pending upload task for resized local file")
}
}
func TestHandleVideoPreferPicksLargestAndSkipsOthers(t *testing.T) {
svc := testStrmService(t)
local := t.TempDir()
p := syncPathRecord(t, svc, model.StrmProviderLocal, t.TempDir(), local, true)
p.KeepExt = false
st := &strmSyncState{
s: svc,
ctx: context.Background(),
p: p,
cfg: &strmPathConfig{BaseURL: "http://test.local:8096", VideoExt: csvSplit(StrmDefaultVideoExt), AddPath: 1, KeepExt: false},
rec: &model.StrmSyncRecord{},
syncType: model.StrmSyncTypeFull,
seenVideo: map[string]bool{},
seenVideoTarget: map[string]cloud.FileEntry{},
remoteVideos: map[string][]remoteVideoCandidate{},
}
st.handleVideo(cloud.FileEntry{ID: "1", Name: "竞女01.mp4", Size: 100, PickCode: "pc-mp4", MTime: 1000}, "竞女01.mp4", ".mp4")
st.handleVideo(cloud.FileEntry{ID: "2", Name: "竞女01.mkv", Size: 500, PickCode: "pc-mkv", MTime: 900}, "竞女01.mkv", ".mkv")
st.flushPreferredVideos()
preferPath := filepath.Join(local, "竞女01.strm")
if _, err := os.Stat(preferPath); err != nil {
t.Fatalf("expected prefer strm at %s: %v", preferPath, err)
}
data, err := os.ReadFile(preferPath)
if err != nil {
t.Fatal(err)
}
content := string(data)
if !strings.Contains(content, "video.mkv") {
t.Fatalf("prefer should pick larger mkv, content=%s", content)
}
if !strings.Contains(content, "%E7%AB%9E%E5%A5%B301.mkv") && !strings.Contains(content, "竞女01.mkv") {
t.Fatalf("prefer strm should point at winner path, content=%s", content)
}
if _, err := os.Stat(filepath.Join(local, "竞女01.mkv.strm")); !os.IsNotExist(err) {
t.Fatal("prefer mode must not write keep_ext names")
}
if st.rec.NewStrm != 1 {
t.Fatalf("NewStrm=%d want 1", st.rec.NewStrm)
}
if st.rec.Skipped < 1 {
t.Fatalf("Skipped=%d want >=1 for loser", st.rec.Skipped)
}
}
func TestHandleVideoKeepExtWritesAllVersions(t *testing.T) {
svc := testStrmService(t)
local := t.TempDir()
p := syncPathRecord(t, svc, model.StrmProviderLocal, t.TempDir(), local, true)
p.KeepExt = true
st := &strmSyncState{
s: svc,
ctx: context.Background(),
p: p,
cfg: &strmPathConfig{BaseURL: "http://test.local:8096", VideoExt: csvSplit(StrmDefaultVideoExt), AddPath: 1, KeepExt: true},
rec: &model.StrmSyncRecord{},
syncType: model.StrmSyncTypeFull,
seenVideo: map[string]bool{},
seenVideoTarget: map[string]cloud.FileEntry{},
remoteVideos: map[string][]remoteVideoCandidate{},
}
st.handleVideo(cloud.FileEntry{ID: "1", Name: "竞女01.mp4", Size: 100, PickCode: "pc-mp4"}, "竞女01.mp4", ".mp4")
st.handleVideo(cloud.FileEntry{ID: "2", Name: "竞女01.mkv", Size: 500, PickCode: "pc-mkv"}, "竞女01.mkv", ".mkv")
st.flushPreferredVideos()
mkvPath := filepath.Join(local, "竞女01.mkv.strm")
mp4Path := filepath.Join(local, "竞女01.mp4.strm")
for _, path := range []string{mkvPath, mp4Path} {
if _, err := os.Stat(path); err != nil {
t.Fatalf("expected keep_ext strm %s: %v", path, err)
}
}
if _, err := os.Stat(filepath.Join(local, "竞女01.strm")); !os.IsNotExist(err) {
t.Fatal("keep_ext mode must not write stripped name.strm")
}
if st.rec.NewStrm != 2 {
t.Fatalf("NewStrm=%d want 2", st.rec.NewStrm)
}
}
+217 -20
View File
@@ -16,12 +16,15 @@ package service
import (
"context"
"encoding/json"
"errors"
"fmt"
"io"
"os"
"os/exec"
"path/filepath"
"regexp"
"strconv"
"strings"
"sync"
"time"
@@ -29,14 +32,16 @@ import (
"go.uber.org/zap"
"github.com/truewhile/MeBox/internal/config"
"github.com/truewhile/MeBox/internal/model"
"github.com/truewhile/MeBox/internal/repository"
)
// SubtitleService is the discovery + conversion entry point.
type SubtitleService struct {
log *zap.Logger
repo *repository.Container
cfg *config.Config
log *zap.Logger
repo *repository.Container
cfg *config.Config
strmResolve func(ctx context.Context, raw string) (*StrmPlayResult, error)
// 目录发现是 Emby 条目列表的热路径(每个媒体源一次 DB 查询 + 最多 5 次
// os.ReadDir),而字幕文件极少变化:按 media_id 做短 TTL 缓存。
@@ -61,11 +66,14 @@ func NewSubtitleService(cfg *config.Config, log *zap.Logger, repo *repository.Co
// SubtitleTrack describes one external subtitle file.
type SubtitleTrack struct {
Lang string `json:"lang"`
Label string `json:"label"`
Path string `json:"path"`
URL string `json:"url"`
Codec string `json:"codec"`
Lang string `json:"lang"`
Label string `json:"label"`
Path string `json:"path"`
URL string `json:"url"`
Codec string `json:"codec"`
Source string `json:"source"`
Delivery string `json:"delivery"`
StreamIndex int `json:"stream_index,omitempty"`
}
// extToCodec maps the file extension to the inner codec name.
@@ -86,7 +94,17 @@ func (s *SubtitleService) Discover(ctx context.Context, mediaID string) ([]Subti
// DiscoverExternalOnly 只返回媒体旁边的外挂字幕文件,不含容器内嵌字幕轨。
// Emby 字幕接口(/Videos/:id/Subtitles/...)用。
func (s *SubtitleService) DiscoverExternalOnly(ctx context.Context, mediaID string) ([]SubtitleTrack, error) {
return s.discover(ctx, mediaID)
tracks, err := s.discover(ctx, mediaID)
if err != nil {
return nil, err
}
out := make([]SubtitleTrack, 0, len(tracks))
for _, track := range tracks {
if track.Source != "embedded" {
out = append(out, track)
}
}
return out, nil
}
func (s *SubtitleService) discover(ctx context.Context, mediaID string) ([]SubtitleTrack, error) {
@@ -142,7 +160,10 @@ func (s *SubtitleService) discoverUncached(ctx context.Context, mediaID string)
return nil, errors.New("media not found")
}
dir := filepath.Dir(m.Path)
base := strings.TrimSuffix(filepath.Base(m.Path), filepath.Ext(m.Path))
bases := mediaSidecarBaseVariants(m.Path)
if len(bases) == 0 {
bases = []string{strings.TrimSuffix(filepath.Base(m.Path), filepath.Ext(m.Path))}
}
candidates := make([]string, 0, 16)
candidates = append(candidates, dir)
@@ -166,24 +187,171 @@ func (s *SubtitleService) discoverUncached(ctx context.Context, mediaID string)
continue
}
fullName := strings.TrimSuffix(e.Name(), ext)
if !strings.HasPrefix(strings.ToLower(fullName), strings.ToLower(base)) &&
c == dir {
// In the same directory we require a basename match;
// inside subs/ subdirs we accept anything.
continue
matchedBase := ""
if c == dir {
for _, base := range bases {
if strings.HasPrefix(strings.ToLower(fullName), strings.ToLower(base)) {
matchedBase = base
break
}
}
if matchedBase == "" {
// In the same directory we require a basename match;
// inside subs/ subdirs we accept anything.
continue
}
} else if len(bases) > 0 {
matchedBase = bases[0]
}
lang := detectLang(fullName, base)
lang := detectLang(fullName, matchedBase)
tracks = append(tracks, SubtitleTrack{
Lang: lang,
Label: lang,
Path: filepath.Join(c, e.Name()),
Codec: codec,
Lang: lang,
Label: lang,
Path: filepath.Join(c, e.Name()),
Codec: codec,
Source: "external",
Delivery: "webvtt",
})
}
}
embedded, err := s.discoverEmbedded(ctx, m)
if err != nil {
if s.log != nil {
s.log.Debug("discover embedded subtitles failed", zap.String("media_id", mediaID), zap.Error(err))
}
} else {
tracks = append(tracks, embedded...)
}
return tracks, nil
}
type embeddedSubtitleProbe struct {
Streams []struct {
Index int `json:"index"`
CodecName string `json:"codec_name"`
Tags struct {
Language string `json:"language"`
Title string `json:"title"`
} `json:"tags"`
Disposition struct {
Default int `json:"default"`
Forced int `json:"forced"`
} `json:"disposition"`
} `json:"streams"`
}
var imageSubtitleCodecs = map[string]bool{
"hdmv_pgs_subtitle": true,
"dvd_subtitle": true,
"dvb_subtitle": true,
"xsub": true,
}
func (s *SubtitleService) discoverEmbedded(ctx context.Context, media *model.Media) ([]SubtitleTrack, error) {
if s == nil || s.cfg == nil {
return nil, errors.New("subtitle probe unavailable")
}
input, err := s.resolveInput(ctx, media)
if err != nil {
return nil, err
}
bin, err := resolveLocalExecutable(s.cfg.App.FFprobePath, "ffprobe")
if err != nil {
return nil, err
}
probeCtx, cancel := context.WithTimeout(ctx, 30*time.Second)
defer cancel()
args := []string{"-v", "error"}
if headers := ffmpegHeaderText(input.Headers); headers != "" {
args = append(args, "-headers", headers)
}
args = append(args,
"-select_streams", "s",
"-show_entries", "stream=index,codec_name:stream_tags=language,title:stream_disposition=default,forced",
"-of", "json", input.Source,
)
out, err := exec.CommandContext(probeCtx, bin, args...).Output() // #nosec G204 -- executable is resolved locally and arguments do not use a shell.
if err != nil {
return nil, err
}
var probe embeddedSubtitleProbe
if err := json.Unmarshal(out, &probe); err != nil {
return nil, err
}
return subtitleTracksFromProbe(probe), nil
}
func subtitleTracksFromProbe(probe embeddedSubtitleProbe) []SubtitleTrack {
tracks := make([]SubtitleTrack, 0, len(probe.Streams))
for _, stream := range probe.Streams {
codec := strings.ToLower(strings.TrimSpace(stream.CodecName))
lang := strings.ToLower(strings.TrimSpace(stream.Tags.Language))
if lang == "" {
lang = "und"
}
label := strings.TrimSpace(stream.Tags.Title)
if label == "" {
label = lang
}
if stream.Disposition.Forced != 0 {
label += "(强制)"
} else if stream.Disposition.Default != 0 {
label += "(默认)"
}
delivery := "webvtt"
if imageSubtitleCodecs[codec] {
delivery = "burn"
}
sourceLabel := "(内嵌)"
if delivery == "burn" {
sourceLabel = "(内嵌·图片)"
}
tracks = append(tracks, SubtitleTrack{
Lang: lang,
Label: label + sourceLabel,
Path: "embedded:" + strconv.Itoa(stream.Index),
Codec: codec,
Source: "embedded",
Delivery: delivery,
StreamIndex: stream.Index,
})
}
return tracks
}
func (s *SubtitleService) SetStrmPlayTargetResolver(resolve func(context.Context, string) (*StrmPlayResult, error)) {
if s != nil {
s.strmResolve = resolve
}
}
func (s *SubtitleService) resolveInput(ctx context.Context, media *model.Media) (transcodeInput, error) {
if media == nil {
return transcodeInput{}, ErrMediaNotFound
}
if !isStrmMediaRow(media) {
if _, err := os.Stat(media.Path); err != nil {
return transcodeInput{}, ErrMediaNotFound
}
return transcodeInput{Source: media.Path}, nil
}
raw := strings.TrimSpace(media.STRMURL)
if raw == "" && strings.HasSuffix(strings.ToLower(media.Path), ".strm") {
raw, _ = readLocalSTRMTarget(media.Path)
}
if s.strmResolve != nil {
resolved, err := s.strmResolve(ctx, raw)
if err != nil {
return transcodeInput{}, err
}
return transcodeInputFromPlayResult(resolved)
}
if isHTTPPlaybackTarget(raw) {
return transcodeInput{Source: raw}, nil
}
return transcodeInput{}, errors.New("subtitle source unavailable")
}
// langTag matches the .zh / .zh-cn / .chs language sub-extensions.
var langTag = regexp.MustCompile(`(?i)\.([a-z]{2,3}(?:[-_][a-z]{2,4})?)$`)
@@ -207,6 +375,13 @@ func (s *SubtitleService) Serve(ctx context.Context, mediaID, sub string, w io.W
if err != nil || m == nil {
return errors.New("media not found")
}
if strings.HasPrefix(sub, "embedded:") {
index, err := strconv.Atoi(strings.TrimPrefix(sub, "embedded:"))
if err != nil || index < 0 {
return errors.New("invalid embedded subtitle")
}
return s.serveEmbedded(ctx, m, index, w)
}
abs, err := filepath.Abs(sub)
if err != nil {
return err
@@ -239,6 +414,28 @@ func (s *SubtitleService) Serve(ctx context.Context, mediaID, sub string, w io.W
return err
}
func (s *SubtitleService) serveEmbedded(ctx context.Context, media *model.Media, streamIndex int, w io.Writer) error {
input, err := s.resolveInput(ctx, media)
if err != nil {
return err
}
bin, err := resolveLocalExecutable(s.cfg.App.FFmpegPath, "ffmpeg")
if err != nil {
return err
}
args := []string{"-hide_banner", "-loglevel", "error"}
args = append(args, ffmpegHTTPInputArgs(input)...)
args = append(args, "-i", input.Source, "-map", "0:"+strconv.Itoa(streamIndex), "-f", "webvtt", "-")
cmd := exec.CommandContext(ctx, bin, args...) // #nosec G204 -- executable is resolved locally and arguments do not use a shell.
cmd.Stdout = w
var stderr strings.Builder
cmd.Stderr = &stderr
if err := cmd.Run(); err != nil {
return fmt.Errorf("extract embedded subtitle: %w: %s", err, strings.TrimSpace(stderr.String()))
}
return nil
}
// ServeRaw writes the subtitle file in its original format without any
// WebVTT conversion. Emby/Jellyfin clients advertise the source codec (ASS,
// subrip, etc.) in MediaStreams, then fetch the subtitle bytes via the
+20 -4
View File
@@ -27,6 +27,7 @@ func srtToVTT(body string) string {
func assToVTT(body string) string {
out := strings.Builder{}
out.WriteString("WEBVTT\n\n")
seen := make(map[string]struct{})
for i, line := range strings.Split(body, "\n") {
line = strings.TrimSpace(line)
if !strings.HasPrefix(line, "Dialogue:") {
@@ -36,11 +37,22 @@ func assToVTT(body string) string {
if len(parts) < 10 {
continue
}
start := normaliseTimecode(parts[1])
end := normaliseTimecode(parts[2])
text := stripASSTags(parts[9])
if text == "" {
continue
}
key := start + "\x00" + end + "\x00" + text
if _, duplicate := seen[key]; duplicate {
continue
}
seen[key] = struct{}{}
fmt.Fprintf(&out, "%d\n%s --> %s\n%s\n\n",
i,
normaliseTimecode(parts[1]),
normaliseTimecode(parts[2]),
stripASSTags(parts[9]),
start,
end,
text,
)
}
return out.String()
@@ -86,5 +98,9 @@ func normaliseTimecode(t string) string {
var assTag = regexp.MustCompile(`\{[^}]*\}`)
func stripASSTags(s string) string {
return assTag.ReplaceAllString(s, "")
s = assTag.ReplaceAllString(s, "")
s = strings.ReplaceAll(s, `\N`, "\n")
s = strings.ReplaceAll(s, `\n`, "\n")
s = strings.ReplaceAll(s, `\h`, "\u00a0")
return strings.TrimSpace(s)
}
+42
View File
@@ -2,8 +2,10 @@ package service
import (
"bytes"
"encoding/json"
"os"
"path/filepath"
"strings"
"testing"
"github.com/glebarez/sqlite"
@@ -46,6 +48,27 @@ func TestSubtitleDiscoverNoTracksReturnsEmptySlice(t *testing.T) {
}
}
func TestEmbeddedSubtitleProbeClassifiesTextAndBitmapTracks(t *testing.T) {
var probe embeddedSubtitleProbe
raw := []byte(`{"streams":[
{"index":2,"codec_name":"ass","tags":{"language":"chi","title":"中文"},"disposition":{"default":1}},
{"index":4,"codec_name":"hdmv_pgs_subtitle","tags":{"language":"eng"},"disposition":{"forced":1}}
]}`)
if err := json.Unmarshal(raw, &probe); err != nil {
t.Fatal(err)
}
tracks := subtitleTracksFromProbe(probe)
if len(tracks) != 2 {
t.Fatalf("len(tracks) = %d, want 2", len(tracks))
}
if tracks[0].Delivery != "webvtt" || tracks[0].Path != "embedded:2" {
t.Fatalf("text track = %#v", tracks[0])
}
if tracks[1].Delivery != "burn" || tracks[1].StreamIndex != 4 {
t.Fatalf("bitmap track = %#v", tracks[1])
}
}
func TestNormaliseTimecode(t *testing.T) {
cases := map[string]string{
"0:00:01": "00:00:01",
@@ -62,6 +85,25 @@ func TestNormaliseTimecode(t *testing.T) {
}
}
func TestAssToVTTDeduplicatesDialogueAndNormalisesLineBreaks(t *testing.T) {
body := strings.Join([]string{
`Dialogue: 0,0:00:01.00,0:00:02.00,Default,,0,0,0,,{\an2}第一行\N第二行`,
`Dialogue: 1,0:00:01.00,0:00:02.00,Copy,,0,0,0,,{\bord2}第一行\N第二行`,
`Dialogue: 0,0:00:03.00,0:00:04.00,Default,,0,0,0,,{\i1}`,
}, "\n")
got := assToVTT(body)
if strings.Count(got, "第一行\n第二行") != 1 {
t.Fatalf("duplicate ASS dialogue was not collapsed:\n%s", got)
}
if strings.Contains(got, `\N`) || strings.Contains(got, `\an2`) {
t.Fatalf("ASS control sequences leaked into WebVTT:\n%s", got)
}
if strings.Contains(got, "00:00:03.000 --> 00:00:04.000") {
t.Fatalf("empty styled dialogue should be omitted:\n%s", got)
}
}
func TestSubtitleServeRawWritesSourceBytes(t *testing.T) {
db, err := gorm.Open(sqlite.Open("file::memory:?cache=shared"), &gorm.Config{})
if err != nil {
+1
View File
@@ -4,6 +4,7 @@ package service
// across providers; provider-specific IDs sit side-by-side so the scraper
// orchestrator can write them all into a single update.
type Match struct {
Provider string `json:"provider,omitempty"`
TMDbID int `json:"tmdb_id"`
BangumiID int `json:"bangumi_id"`
DoubanID string `json:"douban_id,omitempty"`
+310 -28
View File
@@ -25,8 +25,10 @@ package service
import (
"context"
"errors"
"fmt"
"os"
"path/filepath"
"strings"
"sync"
"time"
@@ -34,6 +36,7 @@ import (
"github.com/truewhile/MeBox/internal/config"
"github.com/truewhile/MeBox/internal/helper"
"github.com/truewhile/MeBox/internal/model"
"github.com/truewhile/MeBox/internal/repository"
)
@@ -44,8 +47,13 @@ type TranscoderService struct {
repo *repository.Container
hub *Hub
mu sync.Mutex
jobs map[string]*hlsJob
mu sync.Mutex
jobs map[string]*hlsJob
// startGates serializes EnsureJobFrom / StopJob per media so concurrent
// playlist hits cannot spawn multiple ffmpeg writers into one HLS dir.
startGates sync.Map // mediaID -> *sync.Mutex
strmResolve func(ctx context.Context, raw string) (*StrmPlayResult, error)
probe *FFprobeService
}
// hlsJob holds the live state of one ffmpeg run.
@@ -57,6 +65,17 @@ type hlsJob struct {
lastAccess time.Time
playlistOK bool
encoder string
// startSec is the source seek offset fed to ffmpeg (-ss). The HLS
// playlist itself always starts at t=0 for that session.
startSec float64
// seekGen is the client `_seek` token for this job. Newer gens win;
// older/missing gens must not cancel a mid-file restart.
seekGen int64
// subtitleStream is an absolute ffmpeg stream index to burn into the
// picture, or -1 when no bitmap subtitle is selected.
subtitleStream int
// done is closed when the ffmpeg goroutine fully exits (after process death).
done chan struct{}
}
var (
@@ -88,13 +107,35 @@ func (t *TranscoderService) PlaylistPath(mediaID string) string {
return filepath.Join(t.HLSDir(mediaID), "index.m3u8")
}
// EnsureJob makes sure a transcode is running for mediaID. The function is
// non-blocking: it returns the playlist path immediately. The caller is
// expected to poll until WaitReady reports true.
// EnsureJob makes sure a transcode is running for mediaID from the start of
// the source. Prefer EnsureJobFrom when the player seeks into the middle.
func (t *TranscoderService) EnsureJob(ctx context.Context, mediaID string) (string, error) {
return t.EnsureJobFrom(ctx, mediaID, 0, 0)
}
// EnsureJobFrom starts (or reuses) an HLS job that seeks the source to
// startSec before encoding. Reusing only happens when an active job already
// matches that offset; otherwise the previous job is cancelled and the HLS
// cache dir is wiped so the player can jump without waiting for a full
// head-to-tail transcode.
//
// seekGen is the client `_seek` query (unix ms). A newer gen replaces an older
// job; an older or missing gen must not clobber a mid-file restart — hls.js
// in-flight playlist refreshes from a destroyed player commonly arrive as
// start=0 right after a scrub and would otherwise reset playback to the head.
func (t *TranscoderService) EnsureJobFrom(ctx context.Context, mediaID string, startSec float64, seekGen int64) (string, error) {
return t.EnsureJobFromSubtitle(ctx, mediaID, startSec, seekGen, -1)
}
// EnsureJobFromSubtitle is EnsureJobFrom with an optional bitmap subtitle
// stream. Selecting or closing a burned subtitle creates a new HLS generation.
func (t *TranscoderService) EnsureJobFromSubtitle(ctx context.Context, mediaID string, startSec float64, seekGen int64, subtitleStream int) (string, error) {
if !t.cfg.Transcoder.Enabled {
return "", ErrTranscodeDisabled
}
if startSec < 0 {
startSec = 0
}
m, err := t.repo.Media.FindByID(ctx, mediaID)
if err != nil {
return "", err
@@ -102,49 +143,290 @@ func (t *TranscoderService) EnsureJob(ctx context.Context, mediaID string) (stri
if m == nil {
return "", ErrMediaNotFound
}
// .strm 媒体(STRMURL 或 container=strm / *.strm 路径)的内容是远程
// 直链文本,ffmpeg 无法读取,转码必然失败且白白消耗资源。直接拒绝
// 转码,迫使播放器走 /api/stream 302 直连播放。
if isStrmMediaRow(m) {
return "", ErrTranscodeDisabled
gate := t.mediaStartGate(mediaID)
gate.Lock()
defer gate.Unlock()
t.mu.Lock()
if existing, ok := t.jobs[mediaID]; ok {
if sameHLSConfiguration(existing, startSec, subtitleStream) {
t.touchJobLocked(mediaID)
t.mu.Unlock()
return t.PlaylistPath(mediaID), nil
}
if !shouldReplaceHLSJobConfiguration(existing, startSec, seekGen, subtitleStream) {
t.touchJobLocked(mediaID)
t.mu.Unlock()
return t.PlaylistPath(mediaID), nil
}
prev := t.detachJobLocked(mediaID)
t.mu.Unlock()
waitJobExit(prev, 12*time.Second)
} else {
t.mu.Unlock()
}
if _, err := os.Stat(m.Path); err != nil {
return "", ErrMediaNotFound
input, err := t.resolveTranscodeInput(ctx, m)
if err != nil {
return "", err
}
input.StartSec = startSec
if subtitleStream >= 0 {
input.SubtitleStream = &subtitleStream
}
t.maybeFillDuration(ctx, m, input)
if _, err := t.resolveFFmpegPath(); err != nil {
return "", err
}
outDir := t.HLSDir(mediaID)
// Wipe prior segments so a mid-file restart cannot serve stale early chunks.
// Only safe after the previous ffmpeg has exited (waited above / via gate).
if err := resetHLSDir(outDir); err != nil {
return "", err
}
t.mu.Lock()
if _, ok := t.jobs[mediaID]; ok {
t.touchJobLocked(mediaID)
if existing, ok := t.jobs[mediaID]; ok {
if sameHLSConfiguration(existing, startSec, subtitleStream) {
t.touchJobLocked(mediaID)
t.mu.Unlock()
return t.PlaylistPath(mediaID), nil
}
if !shouldReplaceHLSJobConfiguration(existing, startSec, seekGen, subtitleStream) {
t.touchJobLocked(mediaID)
t.mu.Unlock()
return t.PlaylistPath(mediaID), nil
}
prev := t.detachJobLocked(mediaID)
t.mu.Unlock()
return t.PlaylistPath(mediaID), nil
waitJobExit(prev, 12*time.Second)
t.mu.Lock()
}
if max := t.maxConcurrent(); max > 0 && len(t.jobs) >= max {
t.mu.Unlock()
return "", ErrTranscodeBusy
}
outDir := t.HLSDir(mediaID)
if err := os.MkdirAll(outDir, 0o750); err != nil {
t.mu.Unlock()
return "", err
}
jobCtx, cancel := context.WithCancel(context.Background())
jobEncoder := t.effectiveEncoder()
if subtitleStream >= 0 {
jobEncoder = "software (subtitle burn-in)"
}
job := &hlsJob{
mediaID: mediaID,
outputDir: outDir,
cancel: cancel,
startedAt: time.Now(),
lastAccess: time.Now(),
encoder: t.effectiveEncoder(),
mediaID: mediaID,
outputDir: outDir,
cancel: cancel,
startedAt: time.Now(),
lastAccess: time.Now(),
encoder: jobEncoder,
startSec: startSec,
seekGen: seekGen,
subtitleStream: subtitleStream,
done: make(chan struct{}),
}
t.jobs[mediaID] = job
t.mu.Unlock()
helper.Go(t.log, "transcoder.monitorIdle", func() { t.monitorIdle(jobCtx, job) })
helper.Go(t.log, "transcoder.ffmpeg", func() { t.runFFmpeg(jobCtx, job, m.Path) })
helper.Go(t.log, "transcoder.ffmpeg", func() {
defer close(job.done)
t.runFFmpeg(jobCtx, job, input)
})
return t.PlaylistPath(mediaID), nil
}
// shouldReplaceHLSJob reports whether an incoming playlist request may cancel
// the running job. Stale hls.js refreshes (older/missing `_seek`) must not win.
func shouldReplaceHLSJob(existing *hlsJob, startSec float64, seekGen int64) bool {
if existing == nil {
return true
}
if sameHLSStart(existing.startSec, startSec) {
return false
}
return newerHLSGenerationMayReplace(existing, seekGen)
}
func shouldReplaceHLSJobConfiguration(existing *hlsJob, startSec float64, seekGen int64, subtitleStream int) bool {
if existing == nil {
return true
}
if sameHLSConfiguration(existing, startSec, subtitleStream) {
return false
}
return newerHLSGenerationMayReplace(existing, seekGen)
}
func newerHLSGenerationMayReplace(existing *hlsJob, seekGen int64) bool {
if seekGen > 0 && existing.seekGen > 0 && seekGen < existing.seekGen {
return false
}
// Untagged request while a seek-tagged job is active: treat as stale.
if seekGen == 0 && existing.seekGen > 0 {
return false
}
return true
}
func sameHLSConfiguration(existing *hlsJob, startSec float64, subtitleStream int) bool {
return existing != nil &&
existing.subtitleStream == subtitleStream &&
sameHLSStart(existing.startSec, startSec)
}
func (t *TranscoderService) mediaStartGate(mediaID string) *sync.Mutex {
v, _ := t.startGates.LoadOrStore(mediaID, &sync.Mutex{})
return v.(*sync.Mutex)
}
// detachJobLocked cancels and removes a job from the map without waiting.
// Caller must hold t.mu and must waitJobExit afterwards before wiping the HLS dir.
func (t *TranscoderService) detachJobLocked(mediaID string) *hlsJob {
j, ok := t.jobs[mediaID]
if !ok {
return nil
}
j.cancel()
delete(t.jobs, mediaID)
return j
}
func waitJobExit(job *hlsJob, timeout time.Duration) {
if job == nil || job.done == nil {
return
}
timer := time.NewTimer(timeout)
defer timer.Stop()
select {
case <-job.done:
case <-timer.C:
}
}
func sameHLSStart(a, b float64) bool {
const tol = 0.75
if a < b {
return b-a < tol
}
return a-b < tol
}
func resetHLSDir(dir string) error {
var lastErr error
for i := 0; i < 6; i++ {
lastErr = os.RemoveAll(dir)
if lastErr == nil {
break
}
time.Sleep(50 * time.Millisecond)
}
if err := os.MkdirAll(dir, 0o750); err != nil {
return err
}
// Best-effort: if RemoveAll kept failing on Windows locks, at least drop the playlist
// so WaitReady cannot treat the stale file as belonging to the new job.
_ = os.Remove(filepath.Join(dir, "index.m3u8"))
return nil
}
// SetStrmPlayTargetResolver wires STRM URL resolution so ffmpeg can transcode
// remote .strm media (HTTP 直链 or local source path) after direct play fails.
func (t *TranscoderService) SetStrmPlayTargetResolver(resolve func(ctx context.Context, raw string) (*StrmPlayResult, error)) {
if t == nil {
return
}
t.strmResolve = resolve
}
// SetProbe wires ffprobe so STRM/HLS jobs can persist source duration when the
// media row still has duration_sec=0 (common for .strm that was never probed).
func (t *TranscoderService) SetProbe(probe *FFprobeService) {
if t == nil {
return
}
t.probe = probe
}
func (t *TranscoderService) maybeFillDuration(ctx context.Context, m *model.Media, input transcodeInput) {
if t == nil || t.probe == nil || m == nil || m.DurationSec > 0 || strings.TrimSpace(input.Source) == "" {
return
}
var (
res *ProbeResult
err error
)
if isHTTPSource(input.Source) {
res, err = t.probe.ProbeHTTP(ctx, input.Source, input.Headers)
} else {
res, err = t.probe.Probe(ctx, input.Source)
}
if err != nil || res == nil || res.DurationSec <= 0 {
return
}
m.DurationSec = res.DurationSec
if t.repo == nil || t.repo.DB == nil {
return
}
if err := t.repo.DB.WithContext(ctx).Model(&model.Media{}).Where("id = ?", m.ID).Update("duration_sec", res.DurationSec).Error; err != nil && t.log != nil {
t.log.Debug("persist probed duration failed", zap.String("media_id", m.ID), zap.Error(err))
}
}
func (t *TranscoderService) resolveTranscodeInput(ctx context.Context, m *model.Media) (transcodeInput, error) {
if m == nil {
return transcodeInput{}, ErrMediaNotFound
}
if !isStrmMediaRow(m) {
if _, err := os.Stat(m.Path); err != nil {
return transcodeInput{}, ErrMediaNotFound
}
return transcodeInput{Source: m.Path}, nil
}
raw := strings.TrimSpace(m.STRMURL)
if raw == "" && strings.HasSuffix(strings.ToLower(strings.TrimSpace(m.Path)), ".strm") {
parsed, err := readLocalSTRMTarget(m.Path)
if err != nil || strings.TrimSpace(parsed) == "" {
return transcodeInput{}, fmt.Errorf("strm play target missing")
}
raw = parsed
}
if raw == "" {
return transcodeInput{}, fmt.Errorf("strm play target missing")
}
if t != nil && t.strmResolve != nil {
src, err := t.strmResolve(ctx, raw)
if err != nil {
return transcodeInput{}, err
}
return transcodeInputFromPlayResult(src)
}
if isHTTPPlaybackTarget(raw) {
return transcodeInput{Source: raw}, nil
}
return transcodeInput{}, fmt.Errorf("strm transcode source unavailable")
}
func transcodeInputFromPlayResult(src *StrmPlayResult) (transcodeInput, error) {
if src == nil {
return transcodeInput{}, fmt.Errorf("strm transcode source unavailable")
}
if path := strings.TrimSpace(src.LocalPath); path != "" {
if _, err := os.Stat(path); err != nil {
return transcodeInput{}, ErrMediaNotFound
}
return transcodeInput{Source: path}, nil
}
if url := strings.TrimSpace(src.RedirectURL); url != "" {
in := transcodeInput{Source: url}
if src.Link != nil {
in.Headers = src.Link.Headers
}
return in, nil
}
if src.Link != nil && strings.TrimSpace(src.Link.URL) != "" {
return transcodeInput{Source: src.Link.URL, Headers: src.Link.Headers}, nil
}
return transcodeInput{}, fmt.Errorf("strm transcode source unavailable")
}
+14
View File
@@ -0,0 +1,14 @@
//go:build unix
package service
import (
"os/exec"
"syscall"
)
// setFFmpegSysProcAttr puts ffmpeg in its own process group so cancel can
// tear down the whole group (and any helpers) reliably on Linux/macOS.
func setFFmpegSysProcAttr(cmd *exec.Cmd) {
cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}
}
@@ -0,0 +1,9 @@
//go:build windows
package service
import "os/exec"
func setFFmpegSysProcAttr(cmd *exec.Cmd) {
// Windows: CommandContext cancel is enough for the single ffmpeg process.
}
+89 -4
View File
@@ -2,11 +2,21 @@ package service
import (
"fmt"
"net/url"
"sort"
"strconv"
"strings"
"github.com/truewhile/MeBox/internal/config"
)
type transcodeInput struct {
Source string
Headers map[string]string
StartSec float64
SubtitleStream *int
}
type ffmpegArgSettings struct {
encoder string
bitrate string
@@ -31,11 +41,25 @@ type ffmpegVideoPlan struct {
// encoder. The function is package-level so the unit test can pin its
// behaviour without spawning a real ffmpeg process.
func buildFFmpegArgs(cfg *config.Config, source, playlist, segments string) []string {
return buildFFmpegArgsForInput(cfg, transcodeInput{Source: source}, playlist, segments)
}
func buildFFmpegArgsForInput(cfg *config.Config, input transcodeInput, playlist, segments string) []string {
settings := ffmpegArgSettingsFromConfig(cfg)
video := ffmpegVideoPlanForSettings(settings)
if input.SubtitleStream != nil {
// Bitmap subtitles must be composited in software. Keeping CUDA/QSV/
// VAAPI frames here would require a download/upload filter chain that
// differs by driver and is considerably less portable.
settings.encoder = ""
video = ffmpegVideoPlanForSettings(settings)
}
args := baseFFmpegArgs(video.preInput, settings.realtime)
args = appendInputAndVideoArgs(args, source, settings, video)
// Mid-file restarts must not use -re: output -ss would otherwise crawl to the
// seek point at 1x wall-clock before emitting the first HLS segment.
realtime := settings.realtime && input.StartSec <= 0.05
args := baseFFmpegArgs(video.preInput, realtime)
args = appendInputAndVideoArgs(args, input, settings, video)
args = appendOutputHLSArgs(args, settings, segments, playlist)
return args
}
@@ -111,8 +135,29 @@ func baseFFmpegArgs(preInput string, realtime bool) []string {
return args
}
func appendInputAndVideoArgs(args []string, source string, settings ffmpegArgSettings, video ffmpegVideoPlan) []string {
args = append(args, "-i", source, "-map", "0:v:0?", "-map", "0:a:0?", "-vf", video.filter, "-c:v", video.codec)
func appendInputAndVideoArgs(args []string, input transcodeInput, settings ffmpegArgSettings, video ffmpegVideoPlan) []string {
args = append(args, ffmpegHTTPInputArgs(input)...)
ss := ""
if input.StartSec > 0.05 {
ss = strconv.FormatFloat(input.StartSec, 'f', 3, 64)
}
// Always use input -ss (before -i). Output -ss on HTTP/WMV decodes from
// byte 0 up to the offset and cannot meet playlist WaitReady for deep
// scrubbing; CDNs with Range support jump via demuxer seek instead.
if ss != "" {
args = append(args, "-ss", ss)
}
args = append(args, "-i", input.Source)
if input.SubtitleStream != nil {
filter := fmt.Sprintf(
"[0:v:0][0:%d]overlay=0:0:eof_action=pass,scale=-2:min(%d\\,ih)[v]",
*input.SubtitleStream,
settings.height,
)
args = append(args, "-filter_complex", filter, "-map", "[v]", "-map", "0:a:0?", "-c:v", video.codec)
} else {
args = append(args, "-map", "0:v:0?", "-map", "0:a:0?", "-vf", video.filter, "-c:v", video.codec)
}
if settings.threads > 0 && video.codec == "libx264" {
args = append(args, "-threads", strconv.Itoa(settings.threads))
}
@@ -164,3 +209,43 @@ func splitNonEmptyArgs(s string) []string {
flush()
return out
}
func ffmpegHTTPInputArgs(input transcodeInput) []string {
if !isHTTPSource(input.Source) {
return nil
}
args := []string{"-reconnect", "1", "-reconnect_streamed", "1", "-reconnect_delay_max", "2"}
if len(input.Headers) == 0 {
return args
}
keys := make([]string, 0, len(input.Headers))
for key := range input.Headers {
keys = append(keys, key)
}
sort.Strings(keys)
lines := make([]string, 0, len(keys))
for _, key := range keys {
value := strings.TrimSpace(input.Headers[key])
if strings.TrimSpace(key) == "" || value == "" {
continue
}
lines = append(lines, key+": "+value)
}
if len(lines) == 0 {
return args
}
return append(args, "-headers", strings.Join(lines, "\r\n")+"\r\n")
}
func isHTTPSource(source string) bool {
u, err := url.Parse(strings.TrimSpace(source))
if err != nil || u == nil {
return false
}
switch strings.ToLower(strings.TrimSpace(u.Scheme)) {
case "http", "https":
return true
default:
return false
}
}
+14 -7
View File
@@ -13,7 +13,7 @@ import (
"go.uber.org/zap"
)
func (t *TranscoderService) runFFmpeg(ctx context.Context, job *hlsJob, source string) {
func (t *TranscoderService) runFFmpeg(ctx context.Context, job *hlsJob, input transcodeInput) {
bin, err := t.resolveFFmpegPath()
if err != nil {
t.log.Warn("ffmpeg unavailable", zap.String("media_id", job.mediaID), zap.Error(err))
@@ -31,31 +31,38 @@ func (t *TranscoderService) runFFmpeg(ctx context.Context, job *hlsJob, source s
playlist := filepath.Join(job.outputDir, "index.m3u8")
segments := filepath.Join(job.outputDir, "seg_%05d.ts")
args := buildFFmpegArgs(t.cfg, source, playlist, segments)
args := buildFFmpegArgsForInput(t.cfg, input, playlist, segments)
cmd := exec.CommandContext(ctx, bin, args...) // #nosec G204 -- bin is resolved by resolveFFmpegPath and args are passed without a shell.
setFFmpegSysProcAttr(cmd)
cmd.Stderr = os.Stderr
t.log.Info("transcode started",
zap.String("media_id", job.mediaID),
zap.String("encoder", job.encoder),
zap.String("source", source),
zap.String("source", input.Source),
zap.Float64("start_sec", input.StartSec),
)
t.hub.Publish("transcode", map[string]any{
"media_id": job.mediaID,
"encoder": job.encoder,
"status": "started",
"media_id": job.mediaID,
"encoder": job.encoder,
"status": "started",
"start_sec": input.StartSec,
})
if err := cmd.Run(); err != nil && !errors.Is(ctx.Err(), context.Canceled) {
t.log.Warn("ffmpeg exited",
zap.String("media_id", job.mediaID),
zap.Float64("start_sec", input.StartSec),
zap.Error(err),
)
}
t.mu.Lock()
delete(t.jobs, job.mediaID)
// Only drop the map entry if we are still the registered generation.
if cur, ok := t.jobs[job.mediaID]; ok && cur == job {
delete(t.jobs, job.mediaID)
}
t.mu.Unlock()
t.hub.Publish("transcode", map[string]any{
+37 -17
View File
@@ -9,17 +9,30 @@ import (
)
// WaitReady blocks (with a deadline) until the playlist file shows up on
// disk. Returns true on success.
// disk for the *current* job generation. Stale playlists left behind by a
// failed RemoveAll / still-exiting ffmpeg must not unblock a mid-file restart.
func (t *TranscoderService) WaitReady(ctx context.Context, mediaID string, timeout time.Duration) bool {
deadline := time.Now().Add(timeout)
for {
if _, err := os.Stat(t.PlaylistPath(mediaID)); err == nil {
t.mu.Lock()
if j, ok := t.jobs[mediaID]; ok {
j.playlistOK = true
t.mu.Lock()
job, ok := t.jobs[mediaID]
var started time.Time
if ok {
started = job.startedAt
}
t.mu.Unlock()
if ok {
if info, err := os.Stat(t.PlaylistPath(mediaID)); err == nil {
// Allow a small clock skew; reject anything older than this job.
if !info.ModTime().Before(started.Add(-2 * time.Second)) {
t.mu.Lock()
if j, exists := t.jobs[mediaID]; exists {
j.playlistOK = true
}
t.mu.Unlock()
return true
}
}
t.mu.Unlock()
return true
}
if time.Now().After(deadline) || ctx.Err() != nil {
return false
@@ -32,14 +45,18 @@ func (t *TranscoderService) WaitReady(ctx context.Context, mediaID string, timeo
}
}
// StopJob cancels a running ffmpeg process for mediaID, if any.
// StopJob cancels a running ffmpeg process for mediaID, if any, and waits
// briefly for it to exit so a subsequent EnsureJobFrom cannot race-write the
// same HLS directory.
func (t *TranscoderService) StopJob(mediaID string) {
gate := t.mediaStartGate(mediaID)
gate.Lock()
defer gate.Unlock()
t.mu.Lock()
defer t.mu.Unlock()
if j, ok := t.jobs[mediaID]; ok {
j.cancel()
delete(t.jobs, mediaID)
}
prev := t.detachJobLocked(mediaID)
t.mu.Unlock()
waitJobExit(prev, 12*time.Second)
}
// TouchJob records client activity for the HLS playlist or segment. The idle
@@ -60,10 +77,13 @@ func (t *TranscoderService) touchJobLocked(mediaID string) {
// StopAll terminates every running transcode (called on graceful shutdown).
func (t *TranscoderService) StopAll() {
t.mu.Lock()
defer t.mu.Unlock()
for id, j := range t.jobs {
j.cancel()
delete(t.jobs, id)
pending := make([]*hlsJob, 0, len(t.jobs))
for id := range t.jobs {
pending = append(pending, t.detachJobLocked(id))
}
t.mu.Unlock()
for _, j := range pending {
waitJobExit(j, 5*time.Second)
}
}
+224
View File
@@ -1,10 +1,13 @@
package service
import (
"context"
"strings"
"testing"
"github.com/truewhile/MeBox/internal/config"
"github.com/truewhile/MeBox/internal/model"
"github.com/truewhile/MeBox/internal/service/cloud"
)
func TestBuildFFmpegArgs(t *testing.T) {
@@ -107,3 +110,224 @@ func TestHasFFmpegListEntry(t *testing.T) {
t.Fatal("must match whole ffmpeg list entries only")
}
}
func TestResolveTranscodeInputHTTPSTRM(t *testing.T) {
svc := &TranscoderService{}
got, err := svc.resolveTranscodeInput(context.Background(), &model.Media{
Container: "strm",
STRMURL: "https://cdn.example.com/a.wmv",
})
if err != nil {
t.Fatal(err)
}
if got.Source != "https://cdn.example.com/a.wmv" {
t.Fatalf("source = %q", got.Source)
}
}
func TestResolveTranscodeInputUsesResolver(t *testing.T) {
svc := &TranscoderService{}
svc.SetStrmPlayTargetResolver(func(_ context.Context, raw string) (*StrmPlayResult, error) {
if raw != "/api/strm/play/cloud115/a.wmv?acct=1&pickcode=x" {
t.Fatalf("raw = %q", raw)
}
return &StrmPlayResult{
RedirectURL: "https://cdn.example.com/a.wmv",
Link: &cloud.DirectLink{
URL: "https://cdn.example.com/a.wmv",
Headers: map[string]string{"User-Agent": "Mozilla/5.0"},
},
}, nil
})
got, err := svc.resolveTranscodeInput(context.Background(), &model.Media{
Container: "strm",
STRMURL: "/api/strm/play/cloud115/a.wmv?acct=1&pickcode=x",
})
if err != nil {
t.Fatal(err)
}
if got.Source != "https://cdn.example.com/a.wmv" {
t.Fatalf("source = %q", got.Source)
}
if got.Headers["User-Agent"] != "Mozilla/5.0" {
t.Fatalf("headers = %#v", got.Headers)
}
}
func TestResolveTranscodeInputRejectsUnresolvedRelativeSTRM(t *testing.T) {
svc := &TranscoderService{}
_, err := svc.resolveTranscodeInput(context.Background(), &model.Media{
Container: "strm",
STRMURL: "/api/strm/play/cloud115/a.wmv?acct=1&pickcode=x",
})
if err == nil {
t.Fatal("expected unresolved relative strm to fail")
}
}
func TestBuildFFmpegArgsHTTPInputReconnect(t *testing.T) {
cfg := &config.Config{}
cfg.Transcoder.MaxHeight = 720
cfg.Transcoder.SegmentSeconds = 4
args := buildFFmpegArgsForInput(cfg, transcodeInput{
Source: "https://cdn.example.com/a.wmv",
Headers: map[string]string{"User-Agent": "MeBox", "Referer": "https://cdn.example.com/"},
}, "/o/x.m3u8", "/o/seg_%05d.ts")
joined := strings.Join(args, " ")
if !strings.Contains(joined, "-reconnect") || !strings.Contains(joined, "-headers") {
t.Fatalf("expected http reconnect/headers, got: %s", joined)
}
if !strings.Contains(joined, "User-Agent: MeBox") || !strings.Contains(joined, "Referer: https://cdn.example.com/") {
t.Fatalf("expected request headers, got: %s", joined)
}
idxI, idxH := -1, -1
for i, arg := range args {
if arg == "-i" && idxI < 0 {
idxI = i
}
if arg == "-headers" {
idxH = i
}
}
if idxI < 0 || idxH < 0 || idxH > idxI {
t.Fatalf("http flags must come before -i, args=%v", args)
}
}
func TestBuildFFmpegArgsInputSeekBeforeDashI(t *testing.T) {
cfg := &config.Config{}
cfg.Transcoder.MaxHeight = 720
cfg.Transcoder.SegmentSeconds = 4
args := buildFFmpegArgsForInput(cfg, transcodeInput{
Source: "/x.mkv",
StartSec: 125.5,
}, "/o/x.m3u8", "/o/seg_%05d.ts")
idxSS, idxI := -1, -1
for i, arg := range args {
if arg == "-ss" {
idxSS = i
}
if arg == "-i" && idxI < 0 {
idxI = i
}
}
if idxSS < 0 || idxI < 0 || idxSS > idxI {
t.Fatalf("expected local -ss before -i, args=%v", args)
}
if args[idxSS+1] != "125.500" {
t.Fatalf("start = %q", args[idxSS+1])
}
}
func TestBuildFFmpegArgsHTTPSeekAfterDashI(t *testing.T) {
cfg := &config.Config{}
cfg.Transcoder.MaxHeight = 720
cfg.Transcoder.SegmentSeconds = 4
cfg.Transcoder.Realtime = true
args := buildFFmpegArgsForInput(cfg, transcodeInput{
Source: "https://cdn.example.com/a.wmv",
StartSec: 90,
}, "/o/x.m3u8", "/o/seg_%05d.ts")
joined := " " + strings.Join(args, " ") + " "
if strings.Contains(joined, " -re ") {
t.Fatalf("seek restart must disable -re, got: %s", joined)
}
idxSS, idxI, ssCount := -1, -1, 0
for i, arg := range args {
if arg == "-ss" {
ssCount++
if idxSS < 0 {
idxSS = i
}
}
if arg == "-i" && idxI < 0 {
idxI = i
}
}
if idxSS < 0 || idxI < 0 || idxSS > idxI {
t.Fatalf("expected http -ss before -i, args=%v", args)
}
if ssCount != 1 {
t.Fatalf("expected a single -ss, got %d in %v", ssCount, args)
}
if args[idxSS+1] != "90.000" {
t.Fatalf("start = %q", args[idxSS+1])
}
}
func TestBuildFFmpegArgsBurnsBitmapSubtitleInSoftware(t *testing.T) {
cfg := &config.Config{}
cfg.Transcoder.HardwareAccel = true
cfg.Transcoder.Encoder = "nvenc"
cfg.Transcoder.MaxHeight = 720
cfg.Transcoder.SegmentSeconds = 4
stream := 3
args := buildFFmpegArgsForInput(cfg, transcodeInput{
Source: "/x.mkv",
SubtitleStream: &stream,
}, "/o/x.m3u8", "/o/seg_%05d.ts")
joined := strings.Join(args, " ")
if !strings.Contains(joined, "[0:v:0][0:3]overlay=0:0:eof_action=pass") {
t.Fatalf("bitmap subtitle overlay missing: %s", joined)
}
if !strings.Contains(joined, "-map [v]") || !strings.Contains(joined, "-c:v libx264") {
t.Fatalf("burn-in should use the filtered software video stream: %s", joined)
}
if strings.Contains(joined, "cuda") || strings.Contains(joined, "h264_nvenc") {
t.Fatalf("burn-in must not retain hardware-only frames: %s", joined)
}
}
func TestSameHLSStart(t *testing.T) {
if !sameHLSStart(10, 10.2) {
t.Fatal("expected close starts to match")
}
if sameHLSStart(10, 12) {
t.Fatal("expected distant starts to differ")
}
}
func TestShouldReplaceHLSJob(t *testing.T) {
existing := &hlsJob{startSec: 120, seekGen: 1000}
if shouldReplaceHLSJob(existing, 0, 0) {
t.Fatal("untagged start=0 must not clobber seek-tagged job")
}
if shouldReplaceHLSJob(existing, 0, 900) {
t.Fatal("older _seek must not clobber newer job")
}
if !shouldReplaceHLSJob(existing, 200, 1001) {
t.Fatal("newer _seek should replace")
}
if !shouldReplaceHLSJob(&hlsJob{startSec: 0, seekGen: 0}, 120, 1000) {
t.Fatal("seek should replace untagged head job")
}
if shouldReplaceHLSJob(existing, 120.2, 1001) {
t.Fatal("same start should not replace")
}
}
func TestBitmapSubtitleChangeReplacesHLSJob(t *testing.T) {
existing := &hlsJob{startSec: 120, seekGen: 1000, subtitleStream: 2}
if !shouldReplaceHLSJobConfiguration(existing, 120, 1001, 3) {
t.Fatal("changing bitmap subtitle must replace the HLS generation")
}
if !shouldReplaceHLSJobConfiguration(existing, 120, 1001, -1) {
t.Fatal("closing bitmap subtitle must replace the HLS generation")
}
if shouldReplaceHLSJobConfiguration(existing, 120.2, 1001, 2) {
t.Fatal("same subtitle and nearby start should reuse the HLS generation")
}
}
func TestFilterHLSSegmentQueryDropsStart(t *testing.T) {
got := filterHLSSegmentQuery("token=abc&start=120.5&_seek=1001&profile_id=1")
if strings.Contains(got, "start=") {
t.Fatalf("start should be stripped, got %q", got)
}
if !strings.Contains(got, "token=abc") || !strings.Contains(got, "profile_id=1") {
t.Fatalf("auth/profile query should remain, got %q", got)
}
if !strings.Contains(got, "_seek=1001") {
t.Fatalf("_seek must remain to isolate cached segment generations, got %q", got)
}
}
+3
View File
@@ -19,4 +19,7 @@ export const authAPI = {
api
.post('/me/password', { old_password: oldPassword, new_password: newPassword })
.then((r) => r.data),
createTemporaryPassword: () =>
api.post<{ code: string; expires_in: number }>('/me/temporary-password').then((r) => r.data),
}
+26 -2
View File
@@ -146,8 +146,32 @@ export function streamURL(mediaId: string): string {
}
// hlsURL returns the m3u8 playlist URL fed into hls.js.
export function hlsURL(mediaId: string): string {
return `/api/hls/${encodeURIComponent(mediaId)}/index.m3u8?${tokenQuery()}${profileQuery()}`
// startSec > 0 asks the server to (re)start ffmpeg from that source offset.
export function hlsURL(mediaId: string, startSec = 0, subtitleStream?: number): string {
const safeStart = Math.max(0, Math.round(startSec * 1000) / 1000)
// Always send start= (including 0) so the server can tell an intentional
// restart-from-head apart from a missing query on a stale refresh.
const start = `&start=${encodeURIComponent(String(safeStart))}`
// Monotonic-ish client generation: newer seeks win; older in-flight playlist
// requests must not cancel the active ffmpeg job back to t=0.
const bust = `&_seek=${Date.now()}`
const subtitle =
subtitleStream !== undefined && subtitleStream >= 0
? `&subtitle=${encodeURIComponent(String(subtitleStream))}`
: ''
return `/api/hls/${encodeURIComponent(mediaId)}/index.m3u8?${tokenQuery()}${profileQuery()}${start}${subtitle}${bust}`
}
// Stop an on-demand HLS job. keepalive makes the request survive page
// navigation/tab close, where an axios promise can be discarded by browsers.
export function stopHLSJob(mediaId: string): void {
const url = `/api/hls/${encodeURIComponent(mediaId)}?${tokenQuery()}${profileQuery()}`
void fetch(url, {
method: 'DELETE',
credentials: 'same-origin',
keepalive: true,
cache: 'no-store',
}).catch(() => undefined)
}
// imageURL converts a remote poster URL into a same-origin proxy URL so it
+3
View File
@@ -7,6 +7,9 @@ export interface SubtitleTrack {
path: string
url: string
codec: string
source: 'external' | 'embedded'
delivery: 'webvtt' | 'burn'
stream_index?: number
}
export const subtitlesAPI = {
+20 -6
View File
@@ -2,11 +2,12 @@ import { useCallback, useEffect, useLayoutEffect, useRef, useState, type ReactNo
import { createPortal } from 'react-dom'
import { Link, useLocation } from 'react-router-dom'
import { AnimatePresence, motion } from 'framer-motion'
import { Cast, ChevronDown, Clock, Heart, ListMusic, LogOut, Settings, UserCog } from 'lucide-react'
import { Cast, ChevronDown, Clock, Heart, ListMusic, LogOut, Settings, Tv, UserCog } from 'lucide-react'
import clsx from 'clsx'
import type { PlayProfile } from '../types'
import { LayoutThemeToggle } from './LayoutThemeToggle'
import { TemporaryPasswordDialog } from './TemporaryPasswordDialog'
import type { ThemeMode } from './useThemeMode'
type MenuPosition = {
@@ -52,6 +53,7 @@ export function LayoutUserMenu({
const triggerRef = useRef<HTMLButtonElement>(null)
const onCloseRef = useRef(onClose)
const [menuPosition, setMenuPosition] = useState<MenuPosition | null>(null)
const [isOtpOpen, setIsOtpOpen] = useState(false)
onCloseRef.current = onClose
@@ -121,11 +123,22 @@ export function LayoutUserMenu({
className="fixed z-[121] w-56 origin-top-right rounded-2xl border border-[var(--app-border)] bg-[var(--app-panel)] p-2 shadow-xl"
onPointerDown={(event) => event.stopPropagation()}
>
<UserMenuLink to="/profile" icon={<Settings size={16} />} label="设置" onNavigate={onClose} />
<UserMenuLink to="/favourites" icon={<Heart size={16} />} label="我的收藏" onNavigate={onClose} />
<UserMenuLink to="/playlists" icon={<ListMusic size={16} />} label="播放列表" onNavigate={onClose} />
<UserMenuLink to="/history" icon={<Clock size={16} />} label="观看历史" onNavigate={onClose} />
<UserMenuLink to="/dlna" icon={<Cast size={16} />} label="DLNA投屏" onNavigate={onClose} />
<UserMenuLink to="/profile" icon={<Settings size={16} />} label="设置" onNavigate={onClose} />
<UserMenuLink to="/favourites" icon={<Heart size={16} />} label="我的收藏" onNavigate={onClose} />
<UserMenuLink to="/playlists" icon={<ListMusic size={16} />} label="播放列表" onNavigate={onClose} />
<UserMenuLink to="/history" icon={<Clock size={16} />} label="观看历史" onNavigate={onClose} />
<UserMenuLink to="/dlna" icon={<Cast size={16} />} label="DLNA投屏" onNavigate={onClose} />
<button
type="button"
onClick={() => {
onClose()
setIsOtpOpen(true)
}}
className="flex w-full items-center gap-3 rounded-xl px-3 py-2 text-sm text-[var(--app-subtle)] transition-colors hover:bg-[var(--app-hover)] hover:text-[var(--app-text)]"
>
<Tv size={16} />
<span>电视端临时登录码</span>
</button>
{themeMode && onThemeChange ? (
<div className="px-3 py-2 sm:hidden">
<p className="mb-2 text-[10px] font-bold uppercase tracking-wider text-[var(--app-muted)]">
@@ -203,6 +216,7 @@ export function LayoutUserMenu({
<ChevronDown size={14} className="text-[var(--app-muted)]" />
</button>
{menuPortal}
<TemporaryPasswordDialog isOpen={isOtpOpen} onClose={() => setIsOtpOpen(false)} />
</div>
)
}
@@ -0,0 +1,72 @@
import { Layers } from 'lucide-react'
import { Link } from 'react-router-dom'
import type { Media } from '../types'
import { mediaVersionLabel, mediaVersionsOf } from '../utils/mediaVersion'
type MediaVersionSwitcherProps = {
media: Media
/** 详情页:用 Link 跳转播放;播放页:回调切换 */
mode?: 'detail' | 'player'
onSelect?: (version: Media) => void
className?: string
}
export function MediaVersionSwitcher({
media,
mode = 'detail',
onSelect,
className = '',
}: MediaVersionSwitcherProps) {
const versions = mediaVersionsOf(media)
if (versions.length <= 1) return null
const dark = mode === 'player'
return (
<div className={`space-y-2 ${className}`.trim()}>
<div className={`flex items-center gap-2 text-sm font-semibold ${dark ? 'text-white/90' : 'text-ink-600'}`}>
<Layers size={14} className={dark ? 'text-white/60' : 'text-sand-500'} />
<span>版本({versions.length})</span>
</div>
<div className="flex flex-wrap gap-2">
{versions.map((version) => {
const active = version.id === media.id
const label = mediaVersionLabel(version)
const className = dark
? active
? 'border-white/40 bg-white/20 text-white'
: 'border-white/15 bg-white/5 text-white/80 hover:bg-white/15'
: active
? 'border-brand-500/40 bg-brand-50 text-[#b07d35]'
: 'border-gray-200 bg-white text-ink-100 hover:border-brand-500/30 hover:bg-brand-50/40'
if (mode === 'player') {
return (
<button
key={version.id}
type="button"
disabled={active}
onClick={() => onSelect?.(version)}
className={`rounded-xl border px-3 py-1.5 text-xs font-semibold transition disabled:cursor-default ${className}`}
title={version.path}
>
{label}
</button>
)
}
return (
<Link
key={version.id}
to={`/play/${version.id}`}
state={{ from: `/media/${media.id}` }}
className={`rounded-xl border px-3 py-1.5 text-xs font-semibold transition ${className}`}
title={version.path}
>
{label}
{active ? ' · 当前' : ''}
</Link>
)
})}
</div>
</div>
)
}
+64 -22
View File
@@ -30,6 +30,8 @@ function formatTime(s: number): string {
type PlayerControlsProps = {
videoRef: React.RefObject<HTMLVideoElement>
uiVisible: boolean
onUiVisibleChange: (visible: boolean) => void
subs: SubtitleTrack[]
/** 当前激活字幕轨道:-1=关闭,0..n-1=对应轨道。 */
subtitleIndex: number
@@ -46,10 +48,18 @@ type PlayerControlsProps = {
playlistOpen?: boolean
hasPlaylist?: boolean
onTogglePlaylist?: () => void
/** Media metadata duration (seconds). Used when HLS only knows transcoded length. */
knownDuration?: number
/** Absolute source offset of the current HLS session (seconds). */
streamOffset?: number
/** Absolute seek on the full timeline; return true when handled (e.g. HLS restart). */
onSeekAbsolute?: (seconds: number) => boolean
}
export function PlayerControls({
videoRef,
uiVisible,
onUiVisibleChange,
subs,
subtitleIndex,
onSelectSubtitle,
@@ -65,6 +75,9 @@ export function PlayerControls({
playlistOpen = false,
hasPlaylist = false,
onTogglePlaylist,
knownDuration = 0,
streamOffset = 0,
onSeekAbsolute,
}: PlayerControlsProps) {
const video = () => videoRef.current
const container = () =>
@@ -79,7 +92,6 @@ export function PlayerControls({
const [muted, setMuted] = useState(false)
const [fullscreen, setFullscreen] = useState(false)
const [pip, setPip] = useState(false)
const [uiVisible, setUiVisible] = useState(true)
const [controlsHovered, setControlsHovered] = useState(false)
const [isScrubbing, setIsScrubbing] = useState(false)
const [scrubValue, setScrubValue] = useState<number | null>(null)
@@ -91,6 +103,7 @@ export function PlayerControls({
const subtitleMenuOpenRef = useRef(false)
const danmakuOpenRef = useRef(false)
const playlistOpenRef = useRef(false)
const pendingSeekRef = useRef<number | null>(null)
useEffect(() => {
controlsHoveredRef.current = controlsHovered
@@ -149,14 +162,14 @@ export function PlayerControls({
!danmakuOpenRef.current &&
!playlistOpenRef.current
) {
setUiVisible(false)
onUiVisibleChange(false)
}
}, 3000)
}
}
const onMove = () => {
setUiVisible(true)
onUiVisibleChange(true)
resetTimer()
}
@@ -167,7 +180,7 @@ export function PlayerControls({
}
if (el.paused || controlsHoveredRef.current || isScrubbingRef.current || playlistOpenRef.current) return
if (hideTimerRef.current) clearTimeout(hideTimerRef.current)
setUiVisible(false)
onUiVisibleChange(false)
}
const syncPlay = () => {
@@ -175,15 +188,28 @@ export function PlayerControls({
onMove()
}
const syncTime = () => {
if (!isScrubbingRef.current) {
setCurrentTime(el.currentTime)
if (isScrubbingRef.current) return
if (pendingSeekRef.current !== null) {
const curAbs = streamOffset + el.currentTime
if (Math.abs(curAbs - pendingSeekRef.current) < 3 && el.currentTime > 0.1) {
pendingSeekRef.current = null
setCurrentTime(curAbs)
} else {
setCurrentTime(pendingSeekRef.current)
}
return
}
setCurrentTime(streamOffset + el.currentTime)
}
const syncMeta = () => {
setDuration(el.duration || 0)
if (!isScrubbingRef.current) {
setCurrentTime(el.currentTime)
const streamDur = Number.isFinite(el.duration) ? el.duration : 0
setDuration(Math.max(knownDuration || 0, streamOffset + streamDur))
if (isScrubbingRef.current) return
if (pendingSeekRef.current !== null) {
setCurrentTime(pendingSeekRef.current)
return
}
setCurrentTime(streamOffset + el.currentTime)
}
const syncVolume = () => {
setVolume(el.volume)
@@ -225,21 +251,28 @@ export function PlayerControls({
if (hideTimerRef.current) clearTimeout(hideTimerRef.current)
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [videoRef])
}, [videoRef, knownDuration, streamOffset, onUiVisibleChange])
// Keep the scrubber max in sync when metadata duration arrives after mount.
useEffect(() => {
const el = video()
const streamDur = el && Number.isFinite(el.duration) ? el.duration : 0
setDuration(Math.max(knownDuration || 0, streamOffset + streamDur))
}, [knownDuration, streamOffset])
// 当悬停或菜单状态改变时,更新控制栏计时器
useEffect(() => {
if (controlsHovered || isScrubbing || subtitleMenuOpen || danmakuOpen || playlistOpen) {
setUiVisible(true)
onUiVisibleChange(true)
if (hideTimerRef.current) clearTimeout(hideTimerRef.current)
} else {
const el = video()
if (el && !el.paused) {
if (hideTimerRef.current) clearTimeout(hideTimerRef.current)
hideTimerRef.current = setTimeout(() => setUiVisible(false), 3000)
hideTimerRef.current = setTimeout(() => onUiVisibleChange(false), 3000)
}
}
}, [controlsHovered, isScrubbing, subtitleMenuOpen, danmakuOpen, playlistOpen])
}, [controlsHovered, isScrubbing, subtitleMenuOpen, danmakuOpen, playlistOpen, onUiVisibleChange])
const togglePlay = () => {
const el = video()
@@ -248,27 +281,35 @@ export function PlayerControls({
else el.pause()
}
const applyAbsoluteSeek = (absolute: number) => {
const el = video()
if (!el) return
if (onSeekAbsolute?.(absolute)) {
pendingSeekRef.current = absolute
setCurrentTime(absolute)
return
}
const local = Math.max(0, absolute - streamOffset)
el.currentTime = local
setCurrentTime(streamOffset + local)
}
const handleSeekChange = (v: number) => {
setScrubValue(v)
setCurrentTime(v)
const el = video()
if (el && !isScrubbing) {
el.currentTime = v
if (!isScrubbing) {
applyAbsoluteSeek(v)
}
}
const handleSeekStart = () => {
setIsScrubbing(true)
setUiVisible(true)
onUiVisibleChange(true)
if (hideTimerRef.current) clearTimeout(hideTimerRef.current)
}
const handleSeekEnd = (v: number) => {
const el = video()
if (el) {
el.currentTime = v
setCurrentTime(v)
}
applyAbsoluteSeek(v)
setIsScrubbing(false)
setScrubValue(null)
}
@@ -323,6 +364,7 @@ export function PlayerControls({
onMouseEnter={() => setControlsHovered(true)}
onMouseLeave={() => setControlsHovered(false)}
onClick={(e) => e.stopPropagation()}
onPointerUp={(e) => e.stopPropagation()}
>
<div className="flex flex-wrap items-center gap-2 text-white sm:flex-nowrap sm:gap-2.5">
{/* 上一集 */}
@@ -0,0 +1,191 @@
import { useEffect, useState } from 'react'
import { createPortal } from 'react-dom'
import { Check, Copy, Loader2, RefreshCw, Tv, X } from 'lucide-react'
import toast from 'react-hot-toast'
import { authAPI } from '../api/auth'
import { useAuthStore } from '../stores/auth'
export function TemporaryPasswordDialog({
isOpen,
onClose,
}: {
isOpen: boolean
onClose: () => void
}) {
const user = useAuthStore((s) => s.user)
const [code, setCode] = useState('')
const [expiresIn, setExpiresIn] = useState(0)
const [loading, setLoading] = useState(false)
const [copied, setCopied] = useState(false)
const fetchCode = async () => {
setLoading(true)
try {
const res = await authAPI.createTemporaryPassword()
setCode(res.code)
setExpiresIn(res.expires_in)
setCopied(false)
} catch (err: unknown) {
const msg =
(err as { response?: { data?: { error?: string } } })?.response?.data?.error ??
'获取临时密码失败'
toast.error(msg)
} finally {
setLoading(false)
}
}
useEffect(() => {
if (isOpen) {
fetchCode()
} else {
setCode('')
setExpiresIn(0)
}
}, [isOpen])
useEffect(() => {
if (expiresIn <= 0) return
const timer = setInterval(() => {
setExpiresIn((prev) => {
if (prev <= 1) {
clearInterval(timer)
return 0
}
return prev - 1
})
}, 1000)
return () => clearInterval(timer)
}, [expiresIn])
const copyCode = async () => {
if (!code) return
try {
await navigator.clipboard.writeText(code)
setCopied(true)
toast.success('已复制到剪贴板')
setTimeout(() => setCopied(false), 2000)
} catch {
toast.error('复制失败,请手动长按复制')
}
}
if (!isOpen || typeof document === 'undefined') return null
return createPortal(
<div
className="fixed inset-0 z-[999] flex items-center justify-center bg-black/65 p-3 sm:p-4 backdrop-blur-sm"
onClick={onClose}
>
<div
role="dialog"
aria-modal="true"
className="w-full max-w-[calc(100vw-2rem)] sm:max-w-md max-h-[90dvh] flex flex-col overflow-hidden rounded-3xl border border-[var(--app-border)] bg-[var(--app-panel)] p-4 sm:p-6 shadow-2xl transition-all"
onClick={(e) => e.stopPropagation()}
>
{/* Header */}
<div className="flex items-center justify-between border-b border-[var(--app-border)] pb-3 sm:pb-4 shrink-0">
<div className="flex items-center gap-2.5 sm:gap-3 min-w-0">
<div className="flex h-9 w-9 sm:h-10 sm:w-10 shrink-0 items-center justify-center rounded-2xl bg-brand-500/10 text-brand-500">
<Tv size={20} className="sm:w-[22px] sm:h-[22px]" />
</div>
<div className="min-w-0">
<h3 className="font-display text-base sm:text-lg font-bold text-[var(--app-text)] truncate">
电视端临时登录码 (OTP)
</h3>
<p className="text-[11px] sm:text-xs text-[var(--app-muted)] truncate">
适用于 Emby / Jellyfin / Infuse 客户端快速免密登录
</p>
</div>
</div>
<button
type="button"
onClick={onClose}
className="rounded-xl p-1.5 text-[var(--app-muted)] transition-colors hover:bg-[var(--app-hover)] hover:text-[var(--app-text)] shrink-0 ml-2"
>
<X size={18} />
</button>
</div>
{/* Content */}
<div className="my-4 space-y-3.5 sm:space-y-4 overflow-y-auto flex-1 pr-0.5">
<div className="flex items-center justify-between text-[11px] sm:text-xs text-[var(--app-muted)]">
<div>
<span>登录账号:</span>
<span className="font-mono font-bold text-[var(--app-text)]">{user?.username}</span>
</div>
{expiresIn > 0 ? (
<div>
<span>有效时间剩余:</span>
<span className={`font-mono font-bold ${expiresIn < 60 ? 'text-red-500' : 'text-brand-500'}`}>
{Math.floor(expiresIn / 60).toString().padStart(2, '0')}:{(expiresIn % 60).toString().padStart(2, '0')}
</span>
</div>
) : null}
</div>
<div className="flex items-center justify-between rounded-2xl border border-[var(--app-border)] bg-[var(--app-bg)] p-3 sm:p-4">
{loading ? (
<div className="flex h-10 w-full items-center justify-center text-[var(--app-muted)]">
<Loader2 size={24} className="animate-spin text-brand-500" />
</div>
) : code && expiresIn > 0 ? (
<>
<div className="font-mono text-2xl sm:text-3xl font-black tracking-widest text-brand-500 select-all">
{code}
</div>
<button
type="button"
onClick={copyCode}
className="flex items-center gap-1.5 rounded-xl bg-brand-500/10 px-3 sm:px-3.5 py-1.5 sm:py-2 text-xs font-semibold text-brand-500 transition-colors hover:bg-brand-500/20 shrink-0"
>
{copied ? <Check size={14} /> : <Copy size={14} />}
{copied ? '已复制' : '复制密码'}
</button>
</>
) : (
<div className="flex w-full items-center justify-between text-xs sm:text-sm text-[var(--app-muted)]">
<span>登录码已失效或未生成</span>
<button
type="button"
onClick={fetchCode}
className="flex items-center gap-1.5 rounded-xl bg-brand-500 px-3 py-1.5 text-xs font-semibold text-white shrink-0"
>
<RefreshCw size={13} />
立即生成
</button>
</div>
)}
</div>
<div className="rounded-2xl border border-[var(--app-border)] bg-[var(--app-panel-elevated)] p-3 text-[11px] sm:text-xs leading-relaxed text-[var(--app-muted)]">
<span className="font-semibold text-[var(--app-text)]">使用方法:</span>
在电视端或外部设备的 Emby 登录界面输入用户名 <code className="font-bold text-brand-500">{user?.username}</code> 和上方 6 位临时码。登录后临时码立即作废(一次性阅后即焚),客户端将自动获取长期持久令牌。
</div>
</div>
{/* Footer */}
<div className="flex items-center justify-between border-t border-[var(--app-border)] pt-3 sm:pt-4 shrink-0">
<button
type="button"
onClick={fetchCode}
disabled={loading}
className="flex items-center gap-1.5 rounded-xl px-2.5 sm:px-3 py-1.5 sm:py-2 text-xs font-medium text-[var(--app-muted)] transition-colors hover:bg-[var(--app-hover)] hover:text-[var(--app-text)] disabled:opacity-50"
>
<RefreshCw size={13} className={loading ? 'animate-spin' : ''} />
重新生成
</button>
<button
type="button"
onClick={onClose}
className="rounded-xl border border-[var(--app-border)] bg-[var(--app-panel-elevated)] px-3.5 sm:px-4 py-1.5 sm:py-2 text-xs font-semibold text-[var(--app-text)] transition-colors hover:bg-[var(--app-hover)]"
>
完成并关闭
</button>
</div>
</div>
</div>,
document.body
)
}
@@ -2,6 +2,7 @@ import { useCallback, useEffect, useMemo, useState, type Dispatch, type SetState
import toast from 'react-hot-toast'
import { mediaAPI, type ManualScrapeCandidate } from '../api/library'
import { useEpisodeArtworkPreference } from '../hooks/useEpisodeArtworkPreference'
import type { Media } from '../types'
import {
candidateKey,
@@ -103,7 +104,7 @@ export function useManualScrapeDialogState({
function useManualScrapeFormState() {
const [query, setQuery] = useState('')
const [selectedProviders, setSelectedProviders] = useState<string[]>([])
const [includeEpisodeArtwork, setIncludeEpisodeArtwork] = useState(false)
const [includeEpisodeArtwork, setIncludeEpisodeArtwork] = useEpisodeArtworkPreference()
const [searching, setSearching] = useState(false)
const [applyingKey, setApplyingKey] = useState('')
const [items, setItems] = useState<ManualScrapeCandidate[]>([])
@@ -0,0 +1,53 @@
import { useCallback, useEffect, useRef, useState, type Dispatch, type SetStateAction } from 'react'
import toast from 'react-hot-toast'
import { adminAPI } from '../api/admin'
const SETTING_KEY = 'scrape.episode_images'
// This is an instance-wide scraper preference. The existing admin settings
// endpoint stores it in the Setting table, so it follows the server instead
// of a particular browser.
export function useEpisodeArtworkPreference(): [boolean, Dispatch<SetStateAction<boolean>>] {
const [enabled, setEnabledState] = useState(false)
const enabledRef = useRef(false)
const changedLocally = useRef(false)
const saveQueue = useRef(Promise.resolve())
useEffect(() => {
let active = true
adminAPI.listSettings()
.then((settings) => {
if (!active || changedLocally.current) return
const value = settings.find((setting) => setting.key === SETTING_KEY)?.value
const next = value === 'true' || value === '1'
enabledRef.current = next
setEnabledState(next)
})
.catch(() => {
if (active) {
toast.error('读取每集图片设置失败')
}
})
return () => {
active = false
}
}, [])
const setEnabled = useCallback((nextValue: SetStateAction<boolean>) => {
const next = typeof nextValue === 'function' ? nextValue(enabledRef.current) : nextValue
if (enabledRef.current === next) return
changedLocally.current = true
enabledRef.current = next
setEnabledState(next)
saveQueue.current = saveQueue.current
.catch(() => undefined)
.then(() => adminAPI.updateSetting(SETTING_KEY, String(next)))
.catch(() => {
toast.error('保存每集图片设置失败')
})
}, [])
return [enabled, setEnabled]
}
+2 -1
View File
@@ -3,6 +3,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { libraryAPI } from '../api/library'
import { toolsAPI } from '../api/tools'
import { openManageLibrariesDialog } from '../components/manageLibrariesDialog'
import { useEpisodeArtworkPreference } from '../hooks/useEpisodeArtworkPreference'
import { usePinnedLibraries } from '../hooks/usePinnedLibraries'
import {
LibrariesContent,
@@ -21,7 +22,7 @@ export function LibrariesPage() {
const { pinnedIds, loading: pinnedLoading, togglePin } = usePinnedLibraries()
const [loading, setLoading] = useState(true)
const [repairing, setRepairing] = useState(false)
const [repairEpisodeArtwork, setRepairEpisodeArtwork] = useState(false)
const [repairEpisodeArtwork, setRepairEpisodeArtwork] = useEpisodeArtworkPreference()
const [repairMsg, setRepairMsg] = useState('')
const fetchedLibIdsRef = useRef<Set<string>>(new Set())
+2
View File
@@ -65,6 +65,7 @@ export function LibrarySeriesEpisodes({
<div className="grid grid-cols-1 gap-2.5 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 2xl:grid-cols-5">
{visibleEpisodes.map((ep) => {
const displayTitle = episodeDisplayTitle(ep, visibleEpisodes)
const versionCount = ep.versions?.length ?? 1
return (
<div
key={ep.id}
@@ -102,6 +103,7 @@ export function LibrarySeriesEpisodes({
{ep.duration_sec > 0
? `${Math.floor(ep.duration_sec / 60)} 分钟`
: formatSize(ep.size_bytes)}
{versionCount > 1 ? ` · ${versionCount} 版本` : ''}
</p>
</div>
</Link>
@@ -5,6 +5,7 @@ import { PageBackButton } from '../components/PageBackButton'
import { ExternalPlayerButton } from '../components/ExternalPlayerButton'
import { ManualScrapeDialog } from '../components/ManualScrapeDialog'
import { MediaVersionSwitcher } from '../components/MediaVersionSwitcher'
import { MetadataEditDialog } from '../components/MetadataEditDialog'
import { OrganizeMediaDialog } from '../components/OrganizeMediaDialog'
import type { Media } from '../types'
@@ -163,6 +164,7 @@ export function MediaDetailMainContent({
onToggleFavourite={onToggleFavourite}
playTargetId={playTargetId}
/>
<MediaVersionSwitcher media={media} mode="detail" />
{isAdmin && (
<MediaDetailAdminPanel
media={media}
+352 -96
View File
@@ -4,7 +4,7 @@ import type Hls from 'hls.js'
import toast from 'react-hot-toast'
import { mediaAPI, libraryAPI } from '../api/library'
import { api, hlsURL, streamURL } from '../api/client'
import { hlsURL, stopHLSJob, streamURL } from '../api/client'
import { danmakuAPI, type DanmakuAnime, type DanmakuLoadedInfo } from '../api/danmaku'
import { playbackAPI } from '../api/playback'
import { subtitlesAPI, type SubtitleTrack } from '../api/subtitles'
@@ -13,11 +13,14 @@ import type { Media } from '../types'
import { getSeriesKey, seriesTitleFromPath } from '../utils/groupSeries'
import { isRemoteEmbyID } from '../utils/remoteEmby'
import { pickPlayerMode, needsTranscodeForBrowser, isDirectStreamMedia, type PlayerMode } from './playerPageModel'
import { classifyDirectPlayError } from './directPlayError'
import { apiErrorMessage } from './StrmManagePage'
import { PlayerTopBar } from './PlayerTopBar'
import { PlayerVideoStage } from './PlayerVideoStage'
import { PlayerDanmakuPanel } from '../components/PlayerDanmakuPanel'
import { PlayerPlaylistPanel } from '../components/PlayerPlaylistPanel'
import { MediaVersionSwitcher } from '../components/MediaVersionSwitcher'
import { mediaVersionsOf } from '../utils/mediaVersion'
// Fullscreen, dark-themed video page.
//
@@ -26,27 +29,11 @@ import { PlayerPlaylistPanel } from '../components/PlayerPlaylistPanel'
//
// We pick a sensible default based on the source codec: H.264 + AAC in
// MP4 / WebM containers play directly; everything else (HEVC, MKV, AV1,
// AC3 audio, …) gets routed through ffmpeg → HLS.
// AC3 audio, …) gets routed through ffmpeg → HLS. STRM / 云盘直链默认直连,
// 浏览器播不了时再切 HLS。远程 Emby 挂载只能直连。
//
// External subtitles next to the source file are auto-discovered and
// attached as <track> elements.
const SUBTITLE_STORAGE_KEY = 'mebox.subtitle'
// 初始字幕偏好:localStorage 记录上次选择的轨道(-1=关闭);没有偏好时
// 默认 0(自动加载第一条字幕)。
function initialSubtitleIndex(): number {
try {
const saved = localStorage.getItem(SUBTITLE_STORAGE_KEY)
if (saved !== null && saved !== '') {
const n = parseInt(saved, 10)
if (Number.isFinite(n)) return n
}
} catch {
// ignore
}
return 0
}
export function PlayerPage() {
const { id = '' } = useParams()
const [params, setParams] = useSearchParams()
@@ -56,19 +43,25 @@ export function PlayerPage() {
const ref = useRef<HTMLVideoElement>(null)
const hlsRef = useRef<Hls | null>(null)
const lastSentRef = useRef(0)
const directRetryRef = useRef(false)
const retryingDirectRef = useRef(false)
const fallbackTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const [media, setMedia] = useState<Media | null>(null)
const [mode, setMode] = useState<PlayerMode>('direct')
const [subs, setSubs] = useState<SubtitleTrack[]>([])
const [subtitleIndex, setSubtitleIndex] = useState<number>(initialSubtitleIndex)
const [subtitleIndex, setSubtitleIndex] = useState<number>(0)
const [hlsUnavailable, setHlsUnavailable] = useState(false)
const [playerError, setPlayerError] = useState('')
// 媒体元数据加载失败(404 / 无权限等):舞台区直接展示错误而不是永远「加载中」
const [loadError, setLoadError] = useState('')
// 「客户端直连解码」模式:宿主机不转码,播放器强制 direct play、隐藏 HLS 切换。
const [directOnly, setDirectOnly] = useState(false)
const [directOnlyKnown, setDirectOnlyKnown] = useState(false)
const [resumePosition, setResumePosition] = useState(0)
const [initialSeekDone, setInitialSeekDone] = useState(false)
// HLS session source offset: playlist t=0 maps to this absolute second.
const [hlsStartSec, setHlsStartSec] = useState(0)
// 弹幕控制:状态来自 /api/danmaku/config 初始值,用户在面板里实时调整。
const [danmakuOpen, setDanmakuOpen] = useState(false)
@@ -92,14 +85,11 @@ export function PlayerPage() {
const [playlistEpisodes, setPlaylistEpisodes] = useState<Media[]>([])
const [playlistOpen, setPlaylistOpen] = useState(false)
const teardownHls = useCallback((mediaId?: string, stopServer = false) => {
const teardownHls = useCallback(() => {
if (hlsRef.current) {
hlsRef.current.destroy()
hlsRef.current = null
}
if (stopServer && mediaId) {
api.delete(`/hls/${encodeURIComponent(mediaId)}`).catch(() => undefined)
}
}, [])
const backTarget = useCallback(() => {
@@ -119,6 +109,7 @@ export function PlayerPage() {
.info()
.then((info) => setDirectOnly(Boolean(info.direct_play_only)))
.catch(() => setDirectOnly(false))
.finally(() => setDirectOnlyKnown(true))
}, [])
// 读取宿主机已保存的弹幕参数作为面板初始值(无 admin 权限也可读)。
@@ -184,17 +175,47 @@ export function PlayerPage() {
useEffect(() => {
setMedia(null)
setLoadError('')
setHlsStartSec(0)
setDanmakuEpisodeId(null)
setDanmakuCandidates([])
setDanmakuSearch(null)
setDanmakuSelectedSource('')
setDanmakuInfo(null)
setDanmakuSearching(true)
directRetryRef.current = false
retryingDirectRef.current = false
if (fallbackTimerRef.current) {
clearTimeout(fallbackTimerRef.current)
fallbackTimerRef.current = null
}
return () => {
if (fallbackTimerRef.current) {
clearTimeout(fallbackTimerRef.current)
fallbackTimerRef.current = null
}
}
}, [id])
// 依赖收敛为 mode 参数的字符串值:避免 params 对象引用每次变化都重复拉取元数据
const modeParam = params.get('mode') as PlayerMode | null
const setPlaybackMode = useCallback(
(next: PlayerMode) => {
setMode(next)
const nextParams = new URLSearchParams(window.location.search)
nextParams.set('mode', next)
setParams(nextParams, { replace: true })
},
[setParams],
)
const clearFallbackTimer = useCallback(() => {
if (fallbackTimerRef.current) {
clearTimeout(fallbackTimerRef.current)
fallbackTimerRef.current = null
}
}, [])
// Load metadata and pick a default mode.
useEffect(() => {
if (!id) return
@@ -206,7 +227,7 @@ export function PlayerPage() {
setMedia(m)
const isDirect = isDirectStreamMedia(m)
const auto = pickPlayerMode(m)
// 直连解码模式以及 STRM / Emby 挂载等直连媒体,忽略 ?mode=hls,始终 direct play。
// 直连解码模式以及远程 Emby 挂载忽略 ?mode=hls。STRM 默认直连,但允许手动/失败后切 HLS。
setMode(directOnly || isDirect ? 'direct' : (modeParam ?? auto))
setPlayerError('')
setLoadError('')
@@ -222,9 +243,8 @@ export function PlayerPage() {
if (cancelled) return
const list = tracks ?? []
setSubs(list)
// 记忆的轨道下标可能超出当前媒体的轨道数(不同媒体字幕数量不同),
// 越界时回退到第一条;无字幕则关闭。
setSubtitleIndex((cur) => (cur >= list.length ? (list.length > 0 ? 0 : -1) : cur))
// 服务端始终把外挂字幕排在内嵌字幕前面,因此第一条就是默认优先轨。
setSubtitleIndex(list.length > 0 ? 0 : -1)
})
.catch(() => {
if (cancelled) return
@@ -236,52 +256,143 @@ export function PlayerPage() {
}, [id, modeParam, directOnly])
// Wire up the actual <video> element when we know the mode.
// Depend on media.id (not the media object): refreshing duration after
// MANIFEST_PARSED must not remount HLS or it storms EnsureJob / DELETE.
const mediaId = media?.id
const selectedSubtitle = subtitleIndex >= 0 ? subs[subtitleIndex] : undefined
const burnedSubtitleStream =
selectedSubtitle?.delivery === 'burn' ? selectedSubtitle.stream_index : undefined
// 直连不使用烧录字幕参数。字幕列表通常比媒体信息晚返回,若把该参数直接
// 作为播放 effect 的依赖,会在 STRM 的 302 直链仍在建立时重复设置 src,
// Chromium 会把被中断的首次加载报告成播放错误并误触发 HLS 回退。
const activeBurnedSubtitleStream = mode === 'hls' ? burnedSubtitleStream : undefined
const mediaRef = useRef(media)
mediaRef.current = media
useEffect(() => {
if (!media || !ref.current) return
if (!mediaId || !ref.current) return
const currentMedia = mediaRef.current
if (!currentMedia) return
let cancelled = false
teardownHls()
const video = ref.current
const durationSec = currentMedia.duration_sec || 0
if (mode === 'hls') {
const url = hlsURL(media.id)
const url = hlsURL(mediaId, hlsStartSec, activeBurnedSubtitleStream)
void import('hls.js').then(({ default: HlsCtor }) => {
if (cancelled || !ref.current) return
if (HlsCtor.isSupported()) {
const hls = new HlsCtor({ enableWorker: true, lowLatencyMode: false })
const hls = new HlsCtor({
enableWorker: true,
lowLatencyMode: false,
// The server waits up to 45s for the first segment. Slow two-core
// hosts and remote STRM sources regularly need more than hls.js's
// 10s default, which otherwise aborts a healthy transcode.
manifestLoadingTimeOut: 60_000,
manifestLoadingMaxRetry: 1,
})
try {
video.currentTime = 0
} catch {
// ignore
}
hls.loadSource(url)
hls.attachMedia(video)
hls.on(HlsCtor.Events.MANIFEST_PARSED, () => {
void video.play().catch(() => undefined)
// .strm 入库时常缺 duration;转码启动时会补探测,这里刷新一次给进度条总时长。
if (durationSec > 0) return
mediaAPI
.get(mediaId)
.then((fresh) => {
if (cancelled || fresh.id !== mediaId) return
if ((fresh.duration_sec || 0) > 0) setMedia(fresh)
})
.catch(() => undefined)
})
hls.on(HlsCtor.Events.ERROR, (_, data) => {
if (data.fatal) {
setHlsUnavailable(true)
setPlayerError('HLS 转码不可用,正在尝试直接播放原始文件。若出现有画面无声音,通常是 MKV/AC3/EAC3 音轨需要配置本机 ffmpeg 转码为 AAC。')
toast.error('HLS 转码失败,尝试切换到直接播放')
setMode('direct')
params.set('mode', 'direct')
setParams(params, { replace: true })
setPlaybackMode('direct')
}
})
if (cancelled) {
hls.destroy()
return
}
hlsRef.current = hls
} else if (video.canPlayType('application/vnd.apple.mpegurl')) {
try {
video.currentTime = 0
} catch {
// ignore
}
video.src = url
void video.play().catch(() => undefined)
} else {
setHlsUnavailable(true)
setPlayerError('当前浏览器不支持 HLS,正在尝试直接播放。')
toast.error('当前浏览器不支持 HLS,降级到直接播放')
setMode('direct')
setPlaybackMode('direct')
}
void video.play().catch(() => undefined)
}).catch(() => {
if (cancelled) return
setHlsUnavailable(true)
setPlayerError('HLS 播放组件加载失败,正在尝试直接播放。')
setMode('direct')
setPlaybackMode('direct')
})
} else {
video.src = streamURL(media.id)
if (hlsUnavailable && needsTranscodeForBrowser(media)) {
const url = streamURL(mediaId)
const absoluteURL = new URL(url, window.location.href).href
// 其它异步播放器状态更新不应重启同一个直连请求;STRM 的重定向/换链
// 比本地文件慢,重启请求可能产生一个短暂但会触发 onError 的中断。
if (video.src !== absoluteURL) {
directRetryRef.current = false
clearFallbackTimer()
video.src = url
void video.play().catch(() => undefined)
}
if (hlsUnavailable && needsTranscodeForBrowser(currentMedia)) {
setPlayerError('当前正在直连播放原始文件;此封装或音轨浏览器兼容性有限,可能只有画面没有声音。请配置本机 ffmpeg 后切回 HLS 转码播放。')
}
void video.play().catch(() => undefined)
}
return () => teardownHls(media.id, mode === 'hls')
}, [hlsUnavailable, media, mode, params, setParams, teardownHls])
const onPlaying = () => clearFallbackTimer()
video.addEventListener('playing', onPlaying)
return () => {
cancelled = true
video.removeEventListener('playing', onPlaying)
teardownHls()
}
}, [
activeBurnedSubtitleStream,
clearFallbackTimer,
hlsUnavailable,
hlsStartSec,
mediaId,
mode,
setPlaybackMode,
teardownHls,
])
// Stop host ffmpeg when leaving this HLS player. The keepalive request also
// survives route navigation while the component is being torn down.
useEffect(() => {
if (!mediaId || mode !== 'hls') return
return () => {
stopHLSJob(mediaId)
}
}, [mediaId, mode])
// React cleanup is not guaranteed when a tab/window closes. pagehide fires
// while the document can still dispatch a keepalive request.
useEffect(() => {
if (!mediaId || mode !== 'hls') return
const stopOnPageExit = () => stopHLSJob(mediaId)
window.addEventListener('pagehide', stopOnPageExit)
return () => window.removeEventListener('pagehide', stopOnPageExit)
}, [mediaId, mode])
// 自动拉取已有的播放进度并恢复播放位置
useEffect(() => {
@@ -299,38 +410,75 @@ export function PlayerPage() {
}, [id])
useEffect(() => {
if (!resumePosition || initialSeekDone) return
if (mode === 'hls') {
// Restart transcode near the resume point instead of seeking a short partial playlist.
if (Math.abs(hlsStartSec - resumePosition) > 2) {
setHlsStartSec(resumePosition)
}
setInitialSeekDone(true)
const m = Math.floor(resumePosition / 60)
const s = Math.floor(resumePosition % 60)
const timeStr = `${m.toString().padStart(2, '0')}:${s.toString().padStart(2, '0')}`
toast.success(`已恢复上次播放进度至 ${timeStr}`, { duration: 2500 })
return
}
const video = ref.current
if (!video || !resumePosition || initialSeekDone) return
if (!video) return
let onSeeked: (() => void) | undefined
const applyResume = () => {
if (resumePosition > 0 && Math.abs(video.currentTime - resumePosition) > 2) {
onSeeked = () => {
setInitialSeekDone(true)
const m = Math.floor(resumePosition / 60)
const s = Math.floor(resumePosition % 60)
const timeStr = `${m.toString().padStart(2, '0')}:${s.toString().padStart(2, '0')}`
toast.success(`已恢复上次播放进度至 ${timeStr}`, { duration: 2500 })
}
video.addEventListener('seeked', onSeeked, { once: true })
video.currentTime = resumePosition
setInitialSeekDone(true)
const m = Math.floor(resumePosition / 60)
const s = Math.floor(resumePosition % 60)
const timeStr = `${m.toString().padStart(2, '0')}:${s.toString().padStart(2, '0')}`
toast.success(`已恢复上次播放进度至 ${timeStr}`, { duration: 2500 })
return
}
setInitialSeekDone(true)
}
if (video.readyState >= 1) {
if (video.readyState >= HTMLMediaElement.HAVE_FUTURE_DATA) {
applyResume()
} else {
video.addEventListener('loadedmetadata', applyResume, { once: true })
return () => video.removeEventListener('loadedmetadata', applyResume)
video.addEventListener('canplay', applyResume, { once: true })
}
}, [resumePosition, initialSeekDone])
return () => {
video.removeEventListener('canplay', applyResume)
if (onSeeked) video.removeEventListener('seeked', onSeeked)
}
}, [resumePosition, initialSeekDone, mode, hlsStartSec])
// 使用 ref 实时同步进度计算所需的状态,避免每次 hlsStartSec 改变都触发 cleanup 并误上报旧进度
const hlsStartSecRef = useRef(hlsStartSec)
hlsStartSecRef.current = hlsStartSec
const modeRef = useRef(mode)
modeRef.current = mode
// Persist resume position every 10 seconds while playing, and immediately upon pause/unmount.
useEffect(() => {
if (!media || !ref.current) return
if (!id || !ref.current) return
const video = ref.current
const absolutePositionMs = () => {
const currentStartSec = modeRef.current === 'hls' ? hlsStartSecRef.current : 0
return Math.floor((currentStartSec + (video.currentTime || 0)) * 1000)
}
const absoluteDurationMs = () => {
const currentStartSec = modeRef.current === 'hls' ? hlsStartSecRef.current : 0
const mediaDur = mediaRef.current?.duration_sec || 0
return Math.floor(Math.max(mediaDur, currentStartSec + (video.duration || 0)) * 1000)
}
const handler = () => {
const now = Date.now()
if (now - lastSentRef.current < 10_000) return
lastSentRef.current = now
const positionMs = Math.floor(video.currentTime * 1000)
const durationMs = Math.floor((video.duration || 0) * 1000)
if (positionMs > 0) {
playbackAPI.recordProgress(media.id, positionMs, durationMs).catch(() => undefined)
const positionMs = absolutePositionMs()
const durationMs = absoluteDurationMs()
if (positionMs > 0 && mediaRef.current) {
playbackAPI.recordProgress(mediaRef.current.id, positionMs, durationMs).catch(() => undefined)
}
}
video.addEventListener('timeupdate', handler)
@@ -338,13 +486,13 @@ export function PlayerPage() {
return () => {
video.removeEventListener('timeupdate', handler)
video.removeEventListener('pause', handler)
const positionMs = Math.floor(video.currentTime * 1000)
const durationMs = Math.floor((video.duration || 0) * 1000)
if (positionMs > 0 && media) {
playbackAPI.recordProgress(media.id, positionMs, durationMs).catch(() => undefined)
const positionMs = absolutePositionMs()
const durationMs = absoluteDurationMs()
if (positionMs > 0 && mediaRef.current) {
playbackAPI.recordProgress(mediaRef.current.id, positionMs, durationMs).catch(() => undefined)
}
}
}, [media])
}, [id])
// 加载剧集/播放列表
useEffect(() => {
@@ -420,6 +568,19 @@ export function PlayerPage() {
[navigate, location.search, location.state],
)
// URL 已切换但新媒体尚未返回时,不渲染上一条媒体遗留的版本入口。
const versionList = useMemo(
() => (media?.id === id ? mediaVersionsOf(media) : []),
[id, media],
)
const switchVersion = useCallback(
(version: Media) => {
if (!version?.id || version.id === media?.id) return
playEpisode(version)
},
[media?.id, playEpisode],
)
const handlePrevEpisode = useCallback(() => {
if (prevEpisode) {
playEpisode(prevEpisode)
@@ -503,37 +664,131 @@ export function PlayerPage() {
const isDirectStream = isDirectStreamMedia(media)
// 没有外挂字幕且第一条内嵌字幕是图片时,默认轨需要通过 HLS 烧录。
useEffect(() => {
if (
!directOnlyKnown ||
directOnly ||
isDirectStream ||
selectedSubtitle?.delivery !== 'burn' ||
mode === 'hls'
) {
return
}
setHlsStartSec(ref.current?.currentTime || 0)
setPlaybackMode('hls')
}, [
directOnly,
directOnlyKnown,
isDirectStream,
mode,
selectedSubtitle?.delivery,
setPlaybackMode,
])
const toggleMode = useCallback(() => {
if (isDirectStream) {
toast('该媒体为直连播放,无需且不支持转码')
return
}
const next = mode === 'hls' ? 'direct' : 'hls'
setMode(next)
params.set('mode', next)
setParams(params, { replace: true })
}, [isDirectStream, mode, params, setParams])
// 用户切换字幕轨道:-1=关闭;记忆偏好,下次播放默认沿用。
const selectSubtitle = useCallback((index: number) => {
setSubtitleIndex(index)
try {
localStorage.setItem(SUBTITLE_STORAGE_KEY, String(index))
} catch {
// ignore
if (next === 'hls') {
setHlsStartSec(0)
}
}, [])
setPlaybackMode(next)
}, [isDirectStream, mode, setPlaybackMode])
const handleSeekAbsolute = useCallback(
(absoluteSec: number) => {
if (mode !== 'hls') return false
const video = ref.current
if (!video) return false
const target = Math.max(0, absoluteSec)
const local = target - hlsStartSec
// Live/EVENT HLS during transcoding often reports duration=Infinity.
// Never treat that as "already buffered" or currentTime seeks reset to 0.
const finiteDuration = Number.isFinite(video.duration) ? video.duration : 0
let seekableEnd = 0
if (video.seekable && video.seekable.length > 0) {
try {
seekableEnd = video.seekable.end(video.seekable.length - 1)
} catch {
seekableEnd = 0
}
}
if (!Number.isFinite(seekableEnd)) seekableEnd = 0
const windowEnd = Math.max(finiteDuration, seekableEnd)
if (local >= 0 && windowEnd > 0.5 && local <= windowEnd - 0.5) {
video.currentTime = local
return true
}
setPlayerError('')
toast('正在从该位置重新转码…', { duration: 2000 })
setHlsStartSec(target)
return true
},
[hlsStartSec, mode],
)
// 用户切换图片字幕时从当前位置创建新的 HLS 烧录任务;文本字幕只在网页层切换。
const selectSubtitle = useCallback((index: number) => {
const oldTrack = subtitleIndex >= 0 ? subs[subtitleIndex] : undefined
const nextTrack = index >= 0 ? subs[index] : undefined
if (nextTrack?.delivery === 'burn' && (directOnly || isDirectStream)) {
toast.error('图片字幕需要开启 HLS 转码后才能显示')
return
}
const burnChanged =
oldTrack?.delivery === 'burn' || nextTrack?.delivery === 'burn'
if (burnChanged && mode === 'hls' && ref.current) {
setHlsStartSec(hlsStartSec + (ref.current.currentTime || 0))
}
setSubtitleIndex(index)
if (nextTrack?.delivery === 'burn' && mode !== 'hls' && !directOnly && !isDirectStream) {
setHlsStartSec(ref.current?.currentTime || 0)
setPlaybackMode('hls')
}
}, [directOnly, hlsStartSec, isDirectStream, mode, setPlaybackMode, subs, subtitleIndex])
const handleVideoError = useCallback(() => {
// 浏览器对 <video src> 的错误描述非常有限,把详细原因
// 转给开发者控制台 + 一条 toast;常见原因是 codec 不支持。
if (mode === 'direct') {
if (isRemoteEmbyID(media?.id)) {
const video = ref.current
if (mode !== 'direct') {
setPlayerError('视频播放失败,请检查文件是否存在,或确认 ffmpeg 已正确配置。')
toast.error('视频播放失败,请检查文件是否存在')
return
}
if (retryingDirectRef.current) return
const expectedSrc = mediaId ? new URL(streamURL(mediaId), window.location.href).href : ''
const action = classifyDirectPlayError({
errorCode: video?.error?.code,
readyState: video?.readyState ?? 0,
elementSrc: video?.src ?? '',
expectedSrc,
alreadyRetried: directRetryRef.current,
})
if (action === 'ignore') return
if (action === 'retry' && video && mediaId) {
directRetryRef.current = true
retryingDirectRef.current = true
try {
video.load()
void video.play().catch(() => undefined)
} finally {
retryingDirectRef.current = false
}
return
}
const fallbackDirectPlay = () => {
if (modeRef.current !== 'direct') return
const current = ref.current
if (current && current.readyState >= HTMLMediaElement.HAVE_CURRENT_DATA) return
if (isRemoteEmbyID(mediaRef.current?.id) || isDirectStreamMedia(mediaRef.current)) {
setPlayerError('直接播放失败。该媒体为远程 Emby 挂载直连播放(不进行转码);当前浏览器可能不支持该视频编码或音频格式,建议使用外部播放器(如 PotPlayer / VLC / IINA)播放。')
toast.error('直接播放失败,建议使用外部播放器')
} else if (isDirectStreamMedia(media)) {
setPlayerError('直接播放失败。该媒体为 STRM 远程直连播放(不进行转码);当前浏览器可能不支持该视频编码或音频格式,建议使用外部播放器播放。')
toast.error('直接播放失败,建议使用外部播放器')
} else if (directOnly) {
setPlayerError('直接播放失败。当前为「客户端直连解码」模式,宿主机不转码;请使用支持该编码/封装的播放器(如 Infuse / VLC / Emby 客户端)播放,或关闭直连解码模式。')
toast.error('直接播放失败(客户端直连解码模式)')
@@ -542,16 +797,13 @@ export function PlayerPage() {
toast.error('直接播放失败,HLS 转码不可用')
} else {
toast.error('直接播放失败,切换到 HLS 转码')
setMode('hls')
params.set('mode', 'hls')
setParams(params, { replace: true })
setPlaybackMode('hls')
}
return
}
setPlayerError('视频播放失败,请检查文件是否存在,或确认 ffmpeg 已正确配置。')
toast.error('视频播放失败,请检查文件是否存在')
}, [directOnly, hlsUnavailable, media, mode, params, setParams])
clearFallbackTimer()
fallbackTimerRef.current = setTimeout(fallbackDirectPlay, 1500)
}, [clearFallbackTimer, directOnly, hlsUnavailable, mediaId, mode, setPlaybackMode])
const danmakuAutoTitle =
danmakuInfo?.animeTitle ||
@@ -564,17 +816,18 @@ export function PlayerPage() {
<PlayerTopBar
directOnly={directOnly}
isDirectStream={isDirectStream}
directStreamLabel={
isRemoteEmbyID(media?.id)
? 'Emby 直连播放'
: isDirectStream
? 'STRM 直连播放'
: undefined
}
directStreamLabel={isRemoteEmbyID(media?.id) ? 'Emby 直连播放' : undefined}
mode={mode}
onBack={goBack}
onToggleMode={toggleMode}
/>
{versionList.length > 1 && (
<div className="pointer-events-none absolute inset-x-0 top-16 z-20 flex justify-center px-4 sm:top-20">
<div className="pointer-events-auto max-w-3xl rounded-2xl border border-white/15 bg-black/70 px-3 py-2 shadow-xl backdrop-blur">
<MediaVersionSwitcher media={media!} mode="player" onSelect={switchVersion} className="text-white" />
</div>
</div>
)}
<PlayerVideoStage
media={media}
loadError={loadError}
@@ -604,6 +857,9 @@ export function PlayerPage() {
playlistOpen={playlistOpen}
hasPlaylist={playlistEpisodes.length > 0}
onTogglePlaylist={togglePlaylistOpen}
knownDuration={media?.duration_sec || 0}
streamOffset={mode === 'hls' ? hlsStartSec : 0}
onSeekAbsolute={mode === 'hls' ? handleSeekAbsolute : undefined}
playlistPanel={
<PlayerPlaylistPanel
open={playlistOpen}
+187 -27
View File
@@ -1,5 +1,5 @@
import { useEffect, useRef, useState } from 'react'
import type { ReactNode, RefObject } from 'react'
import type { PointerEvent, ReactNode, RefObject } from 'react'
import { subtitlesAPI, type SubtitleTrack } from '../api/subtitles'
import { type DanmakuAnime, type DanmakuLoadedInfo } from '../api/danmaku'
@@ -7,6 +7,54 @@ import type { Media } from '../types'
import { DanmakuStage } from '../components/DanmakuStage'
import { PlayerControls } from '../components/PlayerControls'
type SubtitleCue = {
startTime: number
endTime: number
text: string
}
function parseVTTTimestamp(value: string): number {
const parts = value.trim().replace(',', '.').split(':')
if (parts.length !== 2 && parts.length !== 3) return Number.NaN
const seconds = Number(parts.pop())
const minutes = Number(parts.pop())
const hours = parts.length > 0 ? Number(parts.pop()) : 0
if (![hours, minutes, seconds].every(Number.isFinite)) return Number.NaN
return hours * 3600 + minutes * 60 + seconds
}
function parseWebVTTCues(body: string): SubtitleCue[] {
const blocks = body
.replace(/^\uFEFF/, '')
.replace(/\r\n?/g, '\n')
.split(/\n{2,}/)
const cues: SubtitleCue[] = []
for (const block of blocks) {
const lines = block.split('\n')
const timingIndex = lines.findIndex((line) => line.includes('-->'))
if (timingIndex < 0) continue
const [rawStart, rawEnd] = lines[timingIndex].split('-->', 2)
const startTime = parseVTTTimestamp(rawStart)
const endTime = parseVTTTimestamp(rawEnd.trim().split(/\s+/, 1)[0])
const text = lines.slice(timingIndex + 1).join('\n').trim()
if (Number.isFinite(startTime) && Number.isFinite(endTime) && endTime >= startTime && text) {
cues.push({ startTime, endTime, text })
}
}
return cues
}
function uniqueSubtitleTexts(texts: string[]): string[] {
const unique = [...new Set(texts.map((text) => text.trim()).filter(Boolean))]
// ponytail: WebVTT cannot preserve ASS layers/positions. Keep at most two
// simultaneous blocks for bilingual subtitles; use libass if full ASS
// typesetting support is added later.
return unique.slice(-2)
}
type PlayerVideoStageProps = {
media: Media | null
/** 媒体元数据加载失败提示(非空时替代「加载中」展示)。 */
@@ -42,6 +90,9 @@ type PlayerVideoStageProps = {
playlistOpen?: boolean
hasPlaylist?: boolean
onTogglePlaylist?: () => void
knownDuration?: number
streamOffset?: number
onSeekAbsolute?: (seconds: number) => boolean
}
export function PlayerVideoStage({
@@ -75,12 +126,72 @@ export function PlayerVideoStage({
playlistOpen,
hasPlaylist,
onTogglePlaylist,
knownDuration,
streamOffset,
onSeekAbsolute,
}: PlayerVideoStageProps) {
const stageRef = useRef<HTMLDivElement>(null)
const [videoRatio, setVideoRatio] = useState<number | null>(null)
const [stageRect, setStageRect] = useState<{ width: number; height: number } | null>(null)
const [controlsVisible, setControlsVisible] = useState(true)
const revealControlsOnlyRef = useRef(false)
// 当前展示的字幕文本(由自定义字幕层渲染,100% 透明无黑框)
const [activeCueText, setActiveCueText] = useState<string>('')
// 独立保存完整 WebVTT 时间轴。HLS seek 会替换媒体源,Chromium 此时可能清空
// <track>.track.cues;独立时间轴不受 MediaSource 重挂载和轨道 mode 切换影响。
const [subtitleTimeline, setSubtitleTimeline] = useState<{
path: string
cues: SubtitleCue[]
} | null>(null)
// 直连 302 尚未完成时插入 <track> 会中断加载并误报播放失败;等 canplay 再挂。
const [tracksArmed, setTracksArmed] = useState(false)
useEffect(() => {
setTracksArmed(false)
const video = videoRef.current
if (!video || !media?.id) return
const arm = () => setTracksArmed(true)
if (video.readyState >= HTMLMediaElement.HAVE_FUTURE_DATA) {
arm()
return
}
video.addEventListener('canplay', arm)
video.addEventListener('playing', arm)
return () => {
video.removeEventListener('canplay', arm)
video.removeEventListener('playing', arm)
}
}, [media?.id, videoRef])
useEffect(() => {
const selectedTrack = subs[subtitleIndex]
if (!media || subtitleIndex < 0 || !selectedTrack || selectedTrack.delivery !== 'webvtt') {
setSubtitleTimeline(null)
return
}
const controller = new AbortController()
setSubtitleTimeline(null)
fetch(subtitlesAPI.url(media.id, selectedTrack.path), { signal: controller.signal })
.then((response) => {
if (!response.ok) throw new Error(`subtitle request failed: ${response.status}`)
return response.text()
})
.then((body) => {
setSubtitleTimeline({
path: selectedTrack.path,
cues: parseWebVTTCues(body),
})
})
.catch((error: unknown) => {
if (!(error instanceof DOMException && error.name === 'AbortError')) {
// 保留原生 TextTrack 作为请求失败时的降级路径。
setSubtitleTimeline(null)
}
})
return () => controller.abort()
}, [media, subs, subtitleIndex])
// 监听舞台容器的真实尺寸(响应窗口大小调整和全屏切换)
useEffect(() => {
@@ -117,13 +228,26 @@ export function PlayerVideoStage({
}
}, [videoRef, media])
// 点击视频切换播放/暂停;双击切换全屏(控制栏事件自行阻止冒泡)。
// 桌面端点击直接切换播放;移动端控制栏隐藏时首次轻触只唤出控制栏,
// 控制栏已显示时再次轻触才切换播放/暂停。
const togglePlay = () => {
const video = videoRef.current
if (!video) return
if (video.paused) void video.play()?.catch(() => undefined)
else video.pause()
}
const handleStagePointerDown = (event: PointerEvent<HTMLDivElement>) => {
revealControlsOnlyRef.current = event.pointerType === 'touch' && !controlsVisible
}
const handleStageClick = () => {
if (revealControlsOnlyRef.current) {
revealControlsOnlyRef.current = false
setControlsVisible(true)
return
}
revealControlsOnlyRef.current = false
togglePlay()
}
const toggleFullscreen = () => {
const stage = stageRef.current
if (!stage) return
@@ -136,15 +260,34 @@ export function PlayerVideoStage({
// 由下方的 React 自定义层输出 100% 纯透明背景、高清晰文字阴影的字幕。
useEffect(() => {
const video = videoRef.current
if (!video || subs.length === 0 || subtitleIndex < 0 || !subs[subtitleIndex]) {
const selectedTrack = subs[subtitleIndex]
if (
!video ||
!tracksArmed ||
subs.length === 0 ||
subtitleIndex < 0 ||
!selectedTrack ||
selectedTrack.delivery === 'burn'
) {
setActiveCueText('')
return
}
const trackIdx = subtitleIndex
const updateCue = () => {
const trackEls = Array.from(video.querySelectorAll('track'))
const selectedEl = trackEls[trackIdx]
const absoluteTime = video.currentTime + (streamOffset ?? 0)
if (subtitleTimeline?.path === selectedTrack.path) {
const texts = uniqueSubtitleTexts(
subtitleTimeline.cues
.filter((cue) => absoluteTime >= cue.startTime && absoluteTime <= cue.endTime)
.map((cue) => cue.text),
)
setActiveCueText(texts.join('\n'))
return
}
const selectedEl = video.querySelector<HTMLTrackElement>(
`track[data-subtitle-index="${subtitleIndex}"]`,
)
const tt = selectedEl?.track
if (!tt) {
setActiveCueText('')
@@ -154,35 +297,42 @@ export function PlayerVideoStage({
// 优先从浏览器 activeCues 中取当前文本;若浏览器在 hidden 模式下延迟触发 cuechange,
// 则从 tt.cues 中根据 video.currentTime 实时匹配当前字幕,确保初次加载无感立即可见。
const texts: string[] = []
if (tt.activeCues && tt.activeCues.length > 0) {
if ((!streamOffset || streamOffset <= 0.05) && tt.activeCues && tt.activeCues.length > 0) {
for (let i = 0; i < tt.activeCues.length; i++) {
const cue = tt.activeCues[i] as VTTCue
if (cue && cue.text) texts.push(cue.text)
}
} else if (tt.cues && tt.cues.length > 0) {
const cur = video.currentTime
for (let i = 0; i < tt.cues.length; i++) {
const cue = tt.cues[i] as VTTCue
if (cue && cur >= cue.startTime && cur <= cue.endTime && cue.text) {
if (
cue &&
absoluteTime >= cue.startTime &&
absoluteTime <= cue.endTime &&
cue.text
) {
texts.push(cue.text)
}
}
}
setActiveCueText(texts.join('\n'))
setActiveCueText(uniqueSubtitleTexts(texts).join('\n'))
}
const apply = () => {
const trackEls = Array.from(video.querySelectorAll('track'))
if (trackEls.length === 0) return
trackEls.forEach((el, i) => {
trackEls.forEach((el) => {
const tt = el.track
if (tt) {
// 'hidden' 模式:浏览器解析 WebVTT 并触发 cuechange,但隐藏原生黑底 UI
tt.mode = i === trackIdx ? 'hidden' : 'disabled'
tt.mode =
el.dataset.subtitleIndex === String(subtitleIndex) ? 'hidden' : 'disabled'
}
})
const selected = trackEls[trackIdx]
const selected = video.querySelector<HTMLTrackElement>(
`track[data-subtitle-index="${subtitleIndex}"]`,
)
if (!selected) return
const tt = selected.track
@@ -210,8 +360,9 @@ export function PlayerVideoStage({
video.removeEventListener('seeking', updateCue)
video.removeEventListener('seeked', updateCue)
video.removeEventListener('playing', updateCue)
const trackEls = Array.from(video.querySelectorAll('track'))
const selected = trackEls[trackIdx]
const selected = video.querySelector<HTMLTrackElement>(
`track[data-subtitle-index="${subtitleIndex}"]`,
)
if (selected) {
selected.removeEventListener('load', updateCue)
if (selected.track) {
@@ -219,7 +370,7 @@ export function PlayerVideoStage({
}
}
}
}, [subtitleIndex, subs, videoRef, media])
}, [subtitleIndex, subs, videoRef, media, streamOffset, subtitleTimeline, tracksArmed])
// 根据视频画面宽高比与舞台宽高比,确定视频在哪个轴向撑满 100%
const isWiderThanStage =
@@ -245,7 +396,8 @@ export function PlayerVideoStage({
ref={stageRef}
data-player-stage
className="relative flex h-full w-full flex-1 items-center justify-center overflow-hidden bg-black"
onClick={togglePlay}
onPointerDown={handleStagePointerDown}
onClick={handleStageClick}
onDoubleClick={toggleFullscreen}
>
{media ? (
@@ -261,16 +413,19 @@ export function PlayerVideoStage({
className="h-full w-full object-contain bg-black"
onError={onVideoError}
>
{subs.map((track, index) => (
<track
key={track.path}
kind="subtitles"
src={subtitlesAPI.url(media.id, track.path)}
srcLang={track.lang}
label={track.label || track.lang}
default={subtitleIndex === index}
/>
))}
{tracksArmed &&
subs.map((track, index) =>
track.delivery === 'burn' ? null : (
<track
key={track.path}
data-subtitle-index={index}
kind="subtitles"
src={subtitlesAPI.url(media.id, track.path)}
srcLang={track.lang}
label={track.label || track.lang}
/>
),
)}
</video>
<DanmakuStage
key={media.id}
@@ -304,6 +459,8 @@ export function PlayerVideoStage({
</div>
<PlayerControls
videoRef={videoRef}
uiVisible={controlsVisible}
onUiVisibleChange={setControlsVisible}
subs={subs}
subtitleIndex={subtitleIndex}
onSelectSubtitle={onSelectSubtitle}
@@ -319,6 +476,9 @@ export function PlayerVideoStage({
playlistOpen={playlistOpen}
hasPlaylist={hasPlaylist}
onTogglePlaylist={onTogglePlaylist}
knownDuration={knownDuration}
streamOffset={streamOffset}
onSeekAbsolute={onSeekAbsolute}
/>
{danmakuPanel}
{playlistPanel}
+119 -2
View File
@@ -1,6 +1,6 @@
import { FormEvent, useState } from 'react'
import { FormEvent, useEffect, useState } from 'react'
import toast from 'react-hot-toast'
import { EyeOff, KeyRound, Loader2, Save } from 'lucide-react'
import { Check, Copy, EyeOff, KeyRound, Loader2, Save, Tv } from 'lucide-react'
import { authAPI } from '../api/auth'
import { profileAPI } from '../api/profile'
@@ -21,6 +21,65 @@ export function ProfilePage() {
const [savingProfile, setSavingProfile] = useState(false)
const [savingPassword, setSavingPassword] = useState(false)
const [otpCode, setOtpCode] = useState('')
const [otpExpiresIn, setOtpExpiresIn] = useState(0)
const [generatingOtp, setGeneratingOtp] = useState(false)
const [copiedOtp, setCopiedOtp] = useState(false)
useEffect(() => {
if (otpExpiresIn <= 0) return
const timer = setInterval(() => {
setOtpExpiresIn((prev) => {
if (prev <= 1) {
clearInterval(timer)
return 0
}
return prev - 1
})
}, 1000)
return () => clearInterval(timer)
}, [otpExpiresIn])
useEffect(() => {
if (window.location.hash === '#tv-otp') {
const el = document.getElementById('tv-otp')
if (el) {
el.scrollIntoView({ behavior: 'smooth' })
}
}
}, [])
const onGenerateOTP = async () => {
if (generatingOtp) return
setGeneratingOtp(true)
try {
const res = await authAPI.createTemporaryPassword()
setOtpCode(res.code)
setOtpExpiresIn(res.expires_in)
setCopiedOtp(false)
toast.success('已生成 6 位临时登录码')
} catch (err: unknown) {
const msg =
(err as { response?: { data?: { error?: string } } })?.response?.data?.error ??
'生成临时密码失败'
toast.error(msg)
} finally {
setGeneratingOtp(false)
}
}
const copyOTP = async () => {
if (!otpCode) return
try {
await navigator.clipboard.writeText(otpCode)
setCopiedOtp(true)
toast.success('已复制到剪贴板')
setTimeout(() => setCopiedOtp(false), 2000)
} catch {
toast.error('复制失败,请手动长按复制')
}
}
const onProfile = async (e: FormEvent) => {
e.preventDefault()
if (savingProfile) return
@@ -171,6 +230,64 @@ export function ProfilePage() {
更新密码
</button>
</form>
<section id="tv-otp" className="glass-panel space-y-4">
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div>
<h2 className="font-display text-lg font-semibold text-ink-600 flex items-center gap-2">
<Tv size={20} className="text-brand-500" />
Emby / 电视端临时登录码 (OTP)
</h2>
<p className="mt-1 text-sm text-ink-50">
适用于电视盒子、Apple TV、车机或朋友设备上的 Emby / Infuse / Jellyfin 客户端快速登录。无需使用遥控器输入长密码。
</p>
</div>
<button
type="button"
onClick={onGenerateOTP}
disabled={generatingOtp}
className="neon-button shrink-0"
>
{generatingOtp ? <Loader2 size={16} className="animate-spin" /> : <KeyRound size={16} />}
{otpCode && otpExpiresIn > 0 ? '重新生成' : '获取 6 位临时登录码'}
</button>
</div>
{otpCode && otpExpiresIn > 0 ? (
<div className="rounded-2xl border border-brand-200 bg-brand-50/40 p-4 space-y-3">
<div className="flex items-center justify-between text-xs text-ink-50">
<div>
<span>登录账号:</span>
<span className="font-mono font-bold text-ink-600">{user?.username}</span>
</div>
<div>
<span>有效时间剩余:</span>
<span className={`font-mono font-bold ${otpExpiresIn < 60 ? 'text-red-500' : 'text-brand-600'}`}>
{Math.floor(otpExpiresIn / 60).toString().padStart(2, '0')}:{(otpExpiresIn % 60).toString().padStart(2, '0')}
</span>
</div>
</div>
<div className="flex items-center justify-between rounded-xl bg-white/90 p-4 border border-brand-100 shadow-sm">
<div className="font-mono text-3xl font-black tracking-widest text-brand-600 select-all">
{otpCode}
</div>
<button
type="button"
onClick={copyOTP}
className="flex items-center gap-1.5 rounded-lg bg-brand-100 px-3 py-1.5 text-xs font-semibold text-brand-700 hover:bg-brand-200 transition-colors"
>
{copiedOtp ? <Check size={14} /> : <Copy size={14} />}
{copiedOtp ? '已复制' : '复制密码'}
</button>
</div>
<p className="text-xs text-ink-50 leading-relaxed">
在客户端的 Emby / Jellyfin 登录界面输入用户名 <code className="font-bold text-ink-600">{user?.username}</code> 和上方 6 位数字临时密码即可完成登录。登录成功后临时密码立即作废(一次性使用),客户端将自动换取长期持久令牌。
</p>
</div>
) : null}
</section>
</div>
)
}
+25
View File
@@ -509,6 +509,12 @@ const SETTING_DEFS: SettingDef[] = [
{ value: '3', label: '不带 path' },
],
},
{
key: 'strm.keep_ext',
label: '保留视频扩展名(多版本)',
type: 'toggle',
hint: '关闭(默认):同名不同扩展(如 竞女01.mkv / 竞女01.mp4)择优生成一条 name.strm;开启:分别生成 name.mkv.strm / name.mp4.strm,保留全部版本供播放切换',
},
{ key: 'strm.115_relay_key', label: '115 中继授权共享密钥', type: 'text', hint: 'QMediaSync/MQFamily 中继授权的共享 AES 密钥;不配置则中继授权不可用' },
{ key: 'strm.download_threads', label: '下载队列线程数', type: 'number', hint: '元数据下载并发数' },
{ key: 'strm.upload_threads', label: '上传队列线程数', type: 'number', hint: '元数据上传并发数' },
@@ -610,6 +616,7 @@ export function StrmSyncPathDialog({
download_meta: existing?.download_meta ?? true,
upload_meta: existing?.upload_meta ?? false,
delete_dir: existing?.delete_dir ?? false,
keep_ext: existing?.keep_ext ?? false,
cron: existing?.cron ?? '',
enable_cron: existing?.enable_cron ?? false,
sync_mode: existing?.sync_mode ?? 'incremental',
@@ -628,6 +635,19 @@ export function StrmSyncPathDialog({
// 兼容两类历史数据:新版保存的 local_path 末段是目录名,旧版是目录 ID。
const prevRemoteTailRef = useRef(existing ? initRemoteTail(existing) : '')
useEffect(() => {
if (existing) return
strmAPI
.getSettings()
.then((settings) => {
const keepExt = settings['strm.keep_ext']
if (keepExt === 'true' || keepExt === '1') {
setForm((f) => ({ ...f, keep_ext: true }))
}
})
.catch(() => undefined)
}, [existing])
const set = <K extends keyof StrmSyncPathInput>(key: K, value: StrmSyncPathInput[K]) =>
setForm((f) => ({ ...f, [key]: value }))
@@ -917,6 +937,11 @@ export function StrmSyncPathDialog({
<ToggleRow label="下载元数据" checked={form.download_meta ?? true} onChange={(v) => set('download_meta', v)} />
<ToggleRow label="上传元数据" checked={form.upload_meta ?? false} onChange={(v) => set('upload_meta', v)} />
<ToggleRow label="清理空目录" checked={form.delete_dir ?? false} onChange={(v) => set('delete_dir', v)} />
<ToggleRow
label="保留视频扩展名(多版本)"
checked={form.keep_ext ?? false}
onChange={(v) => set('keep_ext', v)}
/>
<ToggleRow label="启用定时同步" checked={form.enable_cron ?? false} onChange={(v) => set('enable_cron', v)} />
<ToggleRow label="启用该目录" checked={form.enabled ?? true} onChange={(v) => set('enabled', v)} />
</div>
+24
View File
@@ -0,0 +1,24 @@
export type DirectPlayErrorAction = 'ignore' | 'retry' | 'fallback'
const MEDIA_ERR_ABORTED = 1
const HAVE_CURRENT_DATA = 2
/**
* Chromium 常把被中断的直连(换 src、插入 track、302 未完成就 seek)
* 报成 error。真正不兼容应 fallback;瞬时中断应忽略或静默重试一次。
*/
export function classifyDirectPlayError(input: {
errorCode: number | undefined | null
readyState: number
elementSrc: string
expectedSrc: string
alreadyRetried: boolean
}): DirectPlayErrorAction {
const code = input.errorCode ?? 0
if (code === MEDIA_ERR_ABORTED) return 'ignore'
if (input.readyState >= HAVE_CURRENT_DATA) return 'ignore'
if (!input.elementSrc) return 'ignore'
if (input.expectedSrc && input.elementSrc !== input.expectedSrc) return 'ignore'
if (!input.alreadyRetried) return 'retry'
return 'fallback'
}
+75
View File
@@ -0,0 +1,75 @@
import { classifyDirectPlayError } from './directPlayError.ts'
function check(name: string, cond: boolean) {
if (!cond) throw new Error(`classifyDirectPlayError: ${name}`)
}
const src = 'http://nas.local/api/stream/m1?token=abc'
check(
'aborted load is ignored',
classifyDirectPlayError({
errorCode: 1,
readyState: 0,
elementSrc: src,
expectedSrc: src,
alreadyRetried: false,
}) === 'ignore',
)
check(
'already-playing error is ignored',
classifyDirectPlayError({
errorCode: 4,
readyState: 3,
elementSrc: src,
expectedSrc: src,
alreadyRetried: false,
}) === 'ignore',
)
check(
'stale src error is ignored',
classifyDirectPlayError({
errorCode: 4,
readyState: 0,
elementSrc: 'http://nas.local/old',
expectedSrc: src,
alreadyRetried: false,
}) === 'ignore',
)
check(
'empty src during reload is ignored',
classifyDirectPlayError({
errorCode: 4,
readyState: 0,
elementSrc: '',
expectedSrc: src,
alreadyRetried: false,
}) === 'ignore',
)
check(
'first decode/network miss retries',
classifyDirectPlayError({
errorCode: 4,
readyState: 0,
elementSrc: src,
expectedSrc: src,
alreadyRetried: false,
}) === 'retry',
)
check(
'second failure falls back to HLS',
classifyDirectPlayError({
errorCode: 4,
readyState: 0,
elementSrc: src,
expectedSrc: src,
alreadyRetried: true,
}) === 'fallback',
)
console.log('playerPageModel.test.ts ok')
+11 -6
View File
@@ -8,13 +8,18 @@ const directVideoCodecs = ['h264', 'avc', 'avc1']
const directAudioCodecs = ['aac', 'mp3', 'opus']
/**
* 判断媒体是否为远程直链或挂载直连流媒体(STRM 或 Emby 远程挂载)。
* 这类媒体服务端 302 重定向到直链或进行原生流中继,本地不具备原始文件,
* 无法也不应该进行 ffmpeg 转码,恒走直连播放。
* 远程 Emby 挂载:本地没有原始文件,网页端只能直连,不能转码。
*/
export function isDirectStreamMedia(media?: Media | null): boolean {
if (!media) return false
if (isRemoteEmbyID(media.id)) return true
return isRemoteEmbyID(media.id)
}
/**
* STRM / 云盘直链:默认仍走直连;浏览器解不了时再回退 HLS 转码。
*/
export function isStrmMedia(media?: Media | null): boolean {
if (!media) return false
const container = (media.container ?? '').toLowerCase()
return container.includes('strm') || String(media.strm_url ?? '').trim() !== ''
}
@@ -24,8 +29,8 @@ export function pickPlayerMode(media: Media): PlayerMode {
}
export function needsTranscodeForBrowser(media: Media): boolean {
// Emby 远程挂载与 .strm 媒体一样,均为直连流,无法进行本地转码,恒走 direct play。
if (isDirectStreamMedia(media)) return false
// Emby 远程挂载无法本地转码。STRM 先直连,失败后再由播放器切 HLS。
if (isDirectStreamMedia(media) || isStrmMedia(media)) return false
const container = (media.container ?? '').toLowerCase()
const videoCodec = (media.video_codec ?? '').toLowerCase()
+2 -1
View File
@@ -6,6 +6,7 @@ import { libraryAPI } from '../api/library'
import { mediaAPI } from '../api/library'
import { toolsAPI } from '../api/tools'
import { confirmAction, confirmActionResult } from '../components/confirmAction'
import { useEpisodeArtworkPreference } from '../hooks/useEpisodeArtworkPreference'
import type { Library, Media } from '../types'
import { seriesTitle, type SeriesCard } from '../utils/groupSeries'
import { LibraryMovieActions } from './LibraryMovieActions'
@@ -33,7 +34,7 @@ export function useLibraryAdminActions({
setManualMovie,
}: UseLibraryAdminActionsOptions) {
const [scraping, setScraping] = useState(false)
const [scrapeEpisodeArtwork, setScrapeEpisodeArtwork] = useState(false)
const [scrapeEpisodeArtwork, setScrapeEpisodeArtwork] = useEpisodeArtworkPreference()
const [repairing, setRepairing] = useState(false)
const [seriesToolBusy, setSeriesToolBusy] = useState('')
const [movieToolBusy, setMovieToolBusy] = useState('')
+2 -1
View File
@@ -6,6 +6,7 @@ import { api } from '../api/client'
import { mediaAPI } from '../api/library'
import { playbackAPI } from '../api/playback'
import { confirmActionResult } from '../components/confirmAction'
import { useEpisodeArtworkPreference } from '../hooks/useEpisodeArtworkPreference'
import type { Media } from '../types'
import { mediaLibraryBackTarget } from './MediaDetailPageModel'
@@ -42,7 +43,7 @@ export function useMediaDetailPageState({ id, navigate }: MediaDetailPageStatePa
const [manualScrapeOpen, setManualScrapeOpen] = useState(false)
const [metadataEditOpen, setMetadataEditOpen] = useState(false)
const [organizeOpen, setOrganizeOpen] = useState(false)
const [scrapeEpisodeArtwork, setScrapeEpisodeArtwork] = useState(false)
const [scrapeEpisodeArtwork, setScrapeEpisodeArtwork] = useEpisodeArtworkPreference()
const refresh = useMediaDetailRefresh({
id,
+3
View File
@@ -70,6 +70,7 @@ export interface StrmSyncPath {
download_meta: boolean
upload_meta: boolean
delete_dir: boolean
keep_ext: boolean
cron: string
enable_cron: boolean
sync_mode?: 'incremental' | 'full'
@@ -98,6 +99,7 @@ export interface StrmSyncPathInput {
download_meta?: boolean
upload_meta?: boolean
delete_dir?: boolean
keep_ext?: boolean
cron?: string
enable_cron?: boolean
sync_mode?: 'incremental' | 'full'
@@ -167,6 +169,7 @@ export interface StrmSettingsMap {
'strm.download_meta': string
'strm.upload_meta': string
'strm.delete_dir': string
'strm.keep_ext': string
'strm.download_threads': string
'strm.upload_threads': string
[key: string]: string
+60
View File
@@ -0,0 +1,60 @@
import type { Media } from '../types'
/** 从路径 / strm URL 推断容器扩展名(mkv/mp4…)。 */
export function mediaVersionContainer(media: Media): string {
const path = (media.path || '').replace(/\\/g, '/')
const base = path.split('/').pop() || ''
const ext = base.includes('.') ? base.slice(base.lastIndexOf('.')).toLowerCase() : ''
let name = ext ? base.slice(0, -ext.length) : base
if (ext === '.strm') {
const second = name.includes('.') ? name.slice(name.lastIndexOf('.')).toLowerCase() : ''
if (second && second !== '.strm') {
return second.replace(/^\./, '')
}
const strm = (media.strm_url || '').toLowerCase()
const marker = '/video.'
const idx = strm.lastIndexOf(marker)
if (idx >= 0) {
let rest = strm.slice(idx + marker.length)
const end = rest.search(/[?#&/]/)
if (end >= 0) rest = rest.slice(0, end)
rest = rest.replace(/^\./, '').trim()
if (rest) return rest
}
return 'strm'
}
return ext.replace(/^\./, '')
}
function formatSize(bytes: number): string {
if (!bytes || bytes < 1024) return bytes ? `${bytes} B` : ''
const units = ['KB', 'MB', 'GB', 'TB']
let value = bytes
let unit = -1
do {
value /= 1024
unit += 1
} while (value >= 1024 && unit < units.length - 1)
return `${value.toFixed(1)} ${units[unit]}`
}
/** 版本切换展示名:分辨率 · 容器 · 编码 · 体积,缺省回退文件名。 */
export function mediaVersionLabel(media: Media): string {
const parts: string[] = []
if (media.height > 0) parts.push(`${media.height}p`)
else if (media.width > 0) parts.push(`${media.width}w`)
const container = mediaVersionContainer(media)
if (container && container !== 'strm') parts.push(container.toUpperCase())
if (media.video_codec?.trim()) parts.push(media.video_codec.trim().toUpperCase())
const size = formatSize(media.size_bytes)
if (size) parts.push(size)
if (parts.length > 0) return parts.join(' · ')
const base = (media.path || '').replace(/\\/g, '/').split('/').pop()
return base || media.title || media.id
}
export function mediaVersionsOf(media: Media | null | undefined): Media[] {
if (!media) return []
if (media.versions && media.versions.length > 1) return media.versions
return [media]
}
+2 -1
View File
@@ -20,5 +20,6 @@
"@/*": ["src/*"]
}
},
"include": ["src"]
"include": ["src"],
"exclude": ["src/**/*.test.ts"]
}