Compare commits

...

23 Commits

Author SHA1 Message Date
truewhile 0f06bdf929 支持 ASS 字幕原样渲染与字幕显示设置 2026-09-11 19:35:08 +08:00
truewhile e840cbd2c9 处理bug 2026-09-11 15:35:02 +08:00
truewhile 2bb3610da7 添加繁体转简体,简体转繁体功能 2026-09-11 15:14:24 +08:00
truewhile 9ca1b28cb4 优化 2026-09-10 22:30:17 +08:00
truewhile 9effb1422b 优化 2026-09-10 22:06:28 +08:00
truewhile b855e00345 优化 2026-09-10 16:58:41 +08:00
truewhile 8f2551a1b6 优化排序 2026-09-09 22:24:45 +08:00
truewhile 18e6cb40fd 完善动漫特别内容分类与版本判定
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-09 22:03:52 +08:00
truewhile 92e693ccac 优化 2026-09-09 20:32:03 +08:00
truewhile 62206aa05a 优化 2026-09-09 19:26:22 +08:00
truewhile f9dd082159 优化动漫刮削 2026-09-09 17:36:11 +08:00
truewhile d489c1608e 优化strm多版本显示 2026-09-09 17:07:21 +08:00
truewhile 1ea29cca9e bug处理 2026-09-09 00:39:00 +08:00
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
124 changed files with 5288 additions and 653 deletions
+1
View File
@@ -14,6 +14,7 @@ verify-cache/
verify-media/
verify-downloads/
.codex-*
.codex/
.tmp/
.tmp_*
.tmp-deploy-*
+1
View File
@@ -66,6 +66,7 @@ config.yaml
.tmp-live-backups/
.tmp-*
.codex-*
.codex/
downloads/
media/
*.pid
+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=
+35
View File
@@ -1,7 +1,9 @@
package handler
import (
"context"
"encoding/json"
"errors"
"net/http"
"net/http/httptest"
"net/url"
@@ -359,6 +361,7 @@ func newPlaybackScopeTestRouter(t *testing.T) (*gin.Engine, *service.Container,
Auth: auth,
Media: service.NewMediaService(cfg, log, repos),
Stream: service.NewStreamService(cfg, log, repos, nil),
Subtitle: service.NewSubtitleService(cfg, log, repos),
Permissions: permissions,
}
if err := repos.User.Create(t.Context(), &model.User{
@@ -440,6 +443,38 @@ func TestPlaybackInfoForSTRMMediaIncludesHLS(t *testing.T) {
}
}
func TestSubtitleListOnlyProbesEmbeddedTracksForHLS(t *testing.T) {
router, svc, secret := newPlaybackScopeTestRouter(t)
loginToken := signedTestToken(t, secret)
resolveCalls := 0
svc.Subtitle.SetStrmPlayTargetResolver(func(context.Context, string) (*service.StrmPlayResult, error) {
resolveCalls++
return nil, errors.New("probe resolver called")
})
request := func(path string) *httptest.ResponseRecorder {
req := httptest.NewRequest(http.MethodGet, "http://nas.local"+path, nil)
req.Header.Set("Authorization", "Bearer "+loginToken)
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
return w
}
if w := request("/api/media/media-1/subtitles"); w.Code != http.StatusOK {
t.Fatalf("direct subtitle status = %d body=%s", w.Code, w.Body.String())
}
if resolveCalls != 0 {
t.Fatalf("direct subtitle request resolved cloud media %d times, want 0", resolveCalls)
}
if w := request("/api/media/media-1/subtitles?include_embedded=true"); w.Code != http.StatusOK {
t.Fatalf("HLS subtitle status = %d body=%s", w.Code, w.Body.String())
}
if resolveCalls != 1 {
t.Fatalf("HLS subtitle request resolved cloud media %d times, want 1", resolveCalls)
}
}
func TestHLSPlaylistForRemoteEmbyMediaDisabled(t *testing.T) {
router, svc, secret := newPlaybackScopeTestRouter(t)
svc.EmbyRemote = &service.EmbyRemoteService{}
@@ -54,6 +54,7 @@ func registerAuthedMediaRoutes(authed *gin.RouterGroup, svc *service.Container)
authed.DELETE("/media/:id", middleware.AdminRequired(), deleteMediaHandler(svc))
authed.GET("/media/:id/subtitles", listSubtitlesHandler(svc))
authed.GET("/subtitles/:id", serveSubtitleHandler(svc))
authed.GET("/subtitles/:id/ass", serveASSSubtitleHandler(svc))
authed.POST("/media/:id/nfo", middleware.AdminRequired(), exportNFOHandler(svc))
authed.POST("/libraries/:id/nfo", middleware.AdminRequired(), exportLibraryNFOHandler(svc))
}
+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)})
}
}
+23 -1
View File
@@ -16,7 +16,13 @@ func listSubtitlesHandler(svc *service.Container) gin.HandlerFunc {
c.JSON(http.StatusOK, gin.H{"tracks": []service.SubtitleTrack{}})
return
}
tracks, err := svc.Subtitle.Discover(c.Request.Context(), id)
var tracks []service.SubtitleTrack
var err error
if c.Query("include_embedded") == "true" {
tracks, err = svc.Subtitle.Discover(c.Request.Context(), id)
} else {
tracks, err = svc.Subtitle.DiscoverExternalOnly(c.Request.Context(), id)
}
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": err.Error()})
return
@@ -28,6 +34,22 @@ func listSubtitlesHandler(svc *service.Container) gin.HandlerFunc {
}
}
func serveASSSubtitleHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
path := c.Query("path")
if path == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "missing path"})
return
}
c.Header("Content-Type", "text/plain; charset=utf-8")
c.Header("Cache-Control", "no-cache, no-store, must-revalidate")
if err := svc.Subtitle.ServeASS(c.Request.Context(), c.Param("id"), path, c.Writer); err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": err.Error()})
return
}
}
}
func serveSubtitleHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
path := c.Query("path")
+3
View File
@@ -28,6 +28,9 @@ type User struct {
// PinnedLibraryIDs 存储用户置顶的媒体库 ID 列表(JSON 字符串),顺序即置顶优先级。
PinnedLibraryIDs string `gorm:"type:text" json:"-"`
PinnedLibraryList []string `gorm:"-" json:"pinned_library_ids,omitempty"`
// SubtitleChineseMode 是网页播放器外挂字幕的简繁转换偏好:
// original / simplified / traditional。
SubtitleChineseMode string `gorm:"size:16;not null;default:original" json:"subtitle_chinese_mode"`
// ExpiredAt is the account expiry time. Nil means the account never
// expires. When set and in the past, the account is treated as expired
// (login blocked) until an admin or a redemption code renews it.
+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 {
+11 -3
View File
@@ -477,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
}
+2 -2
View File
@@ -120,8 +120,8 @@ const (
)
var (
embySeasonDirRE = regexp.MustCompile(`(?i)^(season[\s._-]*\d+|s\d+|specials?|sp|ova|oad|extra|extras|第\s*[0-9一二三四五六七八九十百零两]+\s*季|特别篇|特別篇|番外|特典)$`)
embySeasonSuffixRE = regexp.MustCompile(`(?i)(?:[\s._-]+(?:season[\s._-]*\d+|s\d+|第\s*[0-9一二三四五六七八九十百零两]+\s*季|specials?|sp|ova|oad|extra|extras|特别篇|特別篇|番外|特典)|\s*第\s*[0-9一二三四五六七八九十百零两]+\s*季)\s*$`)
embySeasonDirRE = regexp.MustCompile(`(?i)^(season[\s._-]*\d+|s\d+|specials?|sp|ovas?|oads?|ovds?|onas?|extras?|bonus(?:es)?|omake|picture[\s._-]*drama|ncop|nced|第\s*[0-9一二三四五六七八九十百零两]+\s*季|特别篇|特別篇|番外|特典|画像特典)$`)
embySeasonSuffixRE = regexp.MustCompile(`(?i)(?:[\s._-]+(?:season[\s._-]*\d+|s\d+|第\s*[0-9一二三四五六七八九十百零两]+\s*季|specials?|sp|ovas?|oads?|ovds?|onas?|extras?|bonus(?:es)?|omake|picture[\s._-]*drama|ncop|nced|特别篇|特別篇|番外|特典|画像特典)|\s*第\s*[0-9一二三四五六七八九十百零两]+\s*季)\s*$`)
embyYearSuffixRE = regexp.MustCompile(`\s*[\((\[]\d{4}[\))\]]\s*$`)
embyEpisodeTitleRE = regexp.MustCompile(`(?i)\s*[-_ ]*s\d{1,2}e\d{1,3}.*$`)
)
+31 -3
View File
@@ -55,6 +55,17 @@ func (e *EmbyService) mediaVersionSiblings(ctx context.Context, m *model.Media)
if err := q.Find(&rows).Error; err != nil || len(rows) == 0 {
return []model.Media{*m}
}
targetKey := e.mediaVersionKey(ctx, m)
filtered := rows[:0]
for i := range rows {
if e.mediaVersionKey(ctx, &rows[i]) == targetKey {
filtered = append(filtered, rows[i])
}
}
rows = filtered
if len(rows) == 0 {
return []model.Media{*m}
}
rows = e.collapseExactPathRows(rows)
sort.SliceStable(rows, func(i, j int) bool {
if rows[i].ID == m.ID {
@@ -97,11 +108,28 @@ func (e *EmbyService) mediaVersionKey(ctx context.Context, m *model.Media) strin
if libraryGroup == "" {
libraryGroup = strings.TrimSpace(m.LibraryID)
}
kind := mediaSpecialKind(m.Path)
season, episode := m.SeasonNum, m.EpisodeNum
if kind != "" && kind != mediaSpecialTheatrical && episode <= 0 {
if parsedSeason, parsedEpisode := ParseEpisode(m.Path); parsedEpisode > 0 {
season, episode = parsedSeason, parsedEpisode
}
}
kindKey := ""
if kind != "" {
kindKey = "|kind:" + kind
}
if kind != "" && kind != mediaSpecialTheatrical && episode <= 0 {
if stemKey := mediaVersionStemGroupKey(*m, libraryGroup); stemKey != "" {
return stemKey + kindKey
}
return libraryGroup + "|special-item:" + kind + "|id:" + m.ID
}
if m.TMDbID > 0 {
return fmt.Sprintf("%s|tmdb:%d|s:%d|e:%d", libraryGroup, m.TMDbID, m.SeasonNum, m.EpisodeNum)
return fmt.Sprintf("%s|tmdb:%d|s:%d|e:%d%s", libraryGroup, m.TMDbID, season, episode, kindKey)
}
if m.BangumiID > 0 {
return fmt.Sprintf("%s|bangumi:%d|s:%d|e:%d", libraryGroup, m.BangumiID, m.SeasonNum, m.EpisodeNum)
return fmt.Sprintf("%s|bangumi:%d|s:%d|e:%d%s", libraryGroup, m.BangumiID, season, episode, kindKey)
}
title := strings.ToLower(strings.TrimSpace(m.Title))
if title == "" {
@@ -110,7 +138,7 @@ func (e *EmbyService) mediaVersionKey(ctx context.Context, m *model.Media) strin
if title == "" {
return ""
}
return fmt.Sprintf("%s|title:%s|y:%d|s:%d|e:%d", libraryGroup, title, m.Year, m.SeasonNum, m.EpisodeNum)
return fmt.Sprintf("%s|title:%s|y:%d|s:%d|e:%d%s", libraryGroup, title, m.Year, season, episode, kindKey)
}
func preferMediaVersion(candidate, current model.Media) bool {
+2 -2
View File
@@ -220,7 +220,7 @@ func embyLikelyEpisodicPathSQL() (string, []any) {
patterns := []string{
"%/season %/%", "%/season.%/%", "%/season-%/%", "%/season_%/%",
"%/s0%/%", "%/s1%/%", "%/s2%/%", "%/s3%/%", "%/s4%/%", "%/s5%/%", "%/s6%/%", "%/s7%/%", "%/s8%/%", "%/s9%/%",
"%/special/%", "%/specials/%", "%/sp/%", "%/ova/%", "%/oad/%", "%/extra/%", "%/extras/%",
"%/special/%", "%/specials/%", "%/sp/%", "%/ova/%", "%/ovas/%", "%/oad/%", "%/oads/%", "%/ovd/%", "%/ovds/%", "%/ona/%", "%/onas/%", "%/extra/%", "%/extras/%", "%/bonus/%", "%/bonuses/%", "%/omake/%", "%/picture drama/%", "%/ncop/%", "%/nced/%",
"%/电视剧/%", "%/剧集/%", "%/连续剧/%", "%/短剧/%", "%/国产剧/%", "%/国剧/%", "%/大陆剧/%", "%/华语剧/%", "%/国产电视剧/%", "%/大陆电视剧/%", "%/华语电视剧/%", "%/欧美剧/%", "%/欧美电视剧/%", "%/美剧/%", "%/英剧/%", "%/日韩剧/%", "%/日韩电视剧/%", "%/日剧/%", "%/韩剧/%", "%/港剧/%", "%/台剧/%", "%/港台剧/%", "%/泰剧/%",
"%/日番/%", "%/国漫/%", "%/番剧/%", "%/动漫/%", "%/特别篇/%", "%/特別篇/%", "%/番外/%", "%/特典/%",
}
@@ -243,7 +243,7 @@ func embyMediaPathLooksEpisodic(path string) bool {
return false
}
for _, marker := range []string{
"/season ", "/season.", "/season-", "/season_", "/special/", "/specials/", "/sp/", "/ova/", "/oad/", "/extra/", "/extras/",
"/season ", "/season.", "/season-", "/season_", "/special/", "/specials/", "/sp/", "/ova/", "/ovas/", "/oad/", "/oads/", "/ovd/", "/ovds/", "/ona/", "/onas/", "/extra/", "/extras/", "/bonus/", "/bonuses/", "/omake/", "/picture drama/", "/ncop/", "/nced/",
"/电视剧/", "/剧集/", "/连续剧/", "/短剧/", "/国产剧/", "/国剧/", "/大陆剧/", "/华语剧/", "/国产电视剧/", "/大陆电视剧/", "/华语电视剧/", "/欧美剧/", "/欧美电视剧/", "/美剧/", "/英剧/", "/日韩剧/", "/日韩电视剧/", "/日剧/", "/韩剧/", "/港剧/", "/台剧/", "/港台剧/", "/泰剧/",
"/日番/", "/国漫/", "/番剧/", "/动漫/", "/特别篇/", "/特別篇/", "/番外/", "/特典/",
} {
+1 -1
View File
@@ -19,7 +19,7 @@ const pollutedEpisodeCleanupSettingKey = "media.polluted_episode_cleanup_v2_done
// seasonFolderTailRE 去掉路径末尾的「季文件夹 + 文件名」,得到整剧目录(show_dir)。
// 例: /tv/国漫/遮天 (2023)/Season 01/遮天 - S01E01.mkv → /tv/国漫/遮天 (2023)
var seasonFolderTailRE = regexp.MustCompile(`(?i)[\\/](?:season[\s._-]*\d+|s\d{1,2}|specials?|sp|ova|oad|extra|extras|第\s*[0-9一二三四五六七八九十百零两]+\s*季|特别篇|特別篇|番外|特典)[\\/][^\\/]*$`)
var seasonFolderTailRE = regexp.MustCompile(`(?i)[\\/](?:season[\s._-]*\d+|s\d{1,2}|specials?|sp|ovas?|oads?|ovds?|onas?|extras?|bonus(?:es)?|omake|picture[\s._-]*drama|ncop|nced|第\s*[0-9一二三四五六七八九十百零两]+\s*季|特别篇|特別篇|番外|特典|画像特典)[\\/][^\\/]*$`)
// showDirFromEpisodePath 从单集路径推出整剧目录, 作为「同一部剧」的聚合键。
// 若没有季文件夹, 则退而去掉文件名取其父目录。
+135 -13
View File
@@ -6,6 +6,11 @@
// 1x02 / 01x02
// EP02 / E02
// 第2集 / 第02集
// [01] / [001](字幕组方括号集号,如 [UHA-WINGS][…][01][BDRIP])
//
// The "1x02" pattern only matches when it is not embedded in a larger number,
// so pixel dimensions such as 1920x1080 / 3840x2160 are never mistaken for
// season×episode (the old pattern read those as S20E108 / S40E216).
//
// For bare episode markers such as "EP02", the parser also looks at parent
// folders like "Season 02" / "S02" / "第2季" before falling back to season 1.
@@ -14,6 +19,7 @@
package service
import (
"fmt"
"path/filepath"
"regexp"
"strconv"
@@ -22,20 +28,32 @@ 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 匹配 1x02 这类季集写法的捕获组,同时用于从标题里剔除季集残留
// (ReplaceAllString),因此本身不带边界守卫。解析时改用 patNxEGuarded,
// 避免 "1920x1080" 被从中间匹配出 "20x108" 而误判成 S20E108。
patNxE = regexp.MustCompile(`(\d{1,2})x(\d{1,3})`)
patNxEGuarded = regexp.MustCompile(`(?:^|[^0-9])(\d{1,2})x(\d{1,3})(?:[^0-9]|$)`)
// patBracketEpisode 匹配字幕组常见的纯数字方括号集号,如 [01] / [012]。
// 限定 1-3 位,避免把 [2024] 这类年份当成集号;含字母的 [NCOP]/[1080p]
// 自然不匹配。
patBracketEpisode = regexp.MustCompile(`\[0*(\d{1,3})\]`)
patEP = regexp.MustCompile(`(?i)(?:^|[^a-z])(?:e|ep)\.?\s*(\d{1,3})(?:[^0-9]|$)`)
patSpecialEpisode = regexp.MustCompile(`(?i)(?:^|[^a-z0-9])(?:ova|oad|ovd|ona|sp|special(?:[\s._-]*episode)?|extra|bonus|omake)[\s._-]*0*(\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?|ovds?|onas?|extras?|bonus(?:es)?|omake|picture[\s._-]*drama|ncop|nced|番外篇?|特别篇|特別篇|特典|外传|外傳|总集篇|總集篇|画像特典)$`)
patSeasonEpisodeZero = regexp.MustCompile(`(?i)s0*([1-9]\d?)e0+(?:[^0-9]|$)`)
// patCNSeason 匹配中文季/部标记,支持阿拉伯数字与中文数字(如「第二季」「第2部」)。
patCNSeason = regexp.MustCompile(`第\s*[0-9一二三四五六七八九十百零两]+\s*[季部]`)
// patResolutionDims 匹配 1920x1080 / 3840×2160 这类像素尺寸。
patResolutionDims = regexp.MustCompile(`(?i)(\d{3,4})\s*[x×]\s*(\d{3,4})`)
)
// ParseEpisode tries to extract (season, episode) from an arbitrary filename.
@@ -51,11 +69,23 @@ func ParseEpisode(path string) (season, episode int) {
episode = mustAtoi(m[2])
return
}
if m := patNxE.FindStringSubmatch(name); len(m) == 3 {
if m := patNxEGuarded.FindStringSubmatch(name); len(m) == 3 {
season = mustAtoi(m[1])
episode = mustAtoi(m[2])
return
}
if m := patBracketEpisode.FindStringSubmatch(name); len(m) == 2 {
var found bool
season, found = seasonFromParents(path)
if !found {
season = 1
}
episode = mustAtoi(m[1])
return
}
if m := patSpecialEpisode.FindStringSubmatch(name); len(m) >= 2 {
return 0, mustAtoi(m[1])
}
if m := patEP.FindStringSubmatch(name); len(m) >= 2 {
var found bool
season, found = seasonFromParents(path)
@@ -93,6 +123,98 @@ func ParseEpisode(path string) (season, episode int) {
return 0, 0
}
// resolutionEpisodeArtifact returns the bogus (season, episode) pair the legacy
// `(\d{1,2})x(\d{1,3})` pattern would extract from a WxH pixel-dimension token
// in path — e.g. 1920x1080 -> (20, 108), 3840x2160 -> (40, 216). Reports ok=false
// when the name carries no such token.
//
// MeBox used to persist these pairs into sidecar NFOs, so on rescan the NFO
// would inject the wrong identity back even after the parser was fixed.
func resolutionEpisodeArtifact(path string) (season, episode int, ok bool) {
name := mediaSidecarBase(path)
if name == "" {
return 0, 0, false
}
dims := patResolutionDims.FindStringSubmatch(name)
if len(dims) < 3 {
return 0, 0, false
}
m := patNxE.FindStringSubmatch(dims[1] + "x" + dims[2])
if len(m) != 3 {
return 0, 0, false
}
return mustAtoi(m[1]), mustAtoi(m[2]), true
}
// dropResolutionArtifactEpisodeIdentity clears a season/episode pair (and the
// episode title generated from it) that only exists because a resolution token
// was once mistaken for an SxxExx marker.
func dropResolutionArtifactEpisodeIdentity(meta *LocalMetadata, mediaPath string) {
if meta == nil {
return
}
season, episode, ok := resolutionEpisodeArtifact(mediaPath)
if !ok || meta.SeasonNum != season || meta.EpisodeNum != episode {
return
}
// 只有当文件名本身也无法权威地解析出同一季集号时才判定为伪集号。
// 例如 Show.S20E108.1920x1080.mkv 的 S20E108 是真实标记,必须保留。
if parsedSeason, parsedEpisode := ParseEpisode(mediaPath); parsedEpisode > 0 &&
parsedSeason == meta.SeasonNum && parsedEpisode == meta.EpisodeNum {
return
}
meta.SeasonNum = 0
meta.EpisodeNum = 0
if isGeneratedEpisodeTitle(meta.EpisodeTitle, episode) {
meta.EpisodeTitle = ""
}
}
// isGeneratedEpisodeTitle reports whether title is the default "第 N 集" /
// "Episode N" form auto-written from an episode number rather than a real name.
func isGeneratedEpisodeTitle(title string, episode int) bool {
title = strings.TrimSpace(title)
if title == "" || episode <= 0 {
return false
}
for _, candidate := range []string{
fmt.Sprintf("第 %d 集", episode),
fmt.Sprintf("第%d集", episode),
fmt.Sprintf("第 %d 话", episode),
fmt.Sprintf("第%d话", episode),
fmt.Sprintf("第 %d 話", episode),
fmt.Sprintf("第%d話", episode),
fmt.Sprintf("Episode %d", episode),
fmt.Sprintf("EP%d", episode),
} {
if strings.EqualFold(title, candidate) {
return true
}
}
return false
}
// 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
+84
View File
@@ -29,7 +29,20 @@ func TestParseEpisode(t *testing.T) {
{`剧集/Specials/剧集 - 02.mkv`, 0, 2},
{`剧集/特别篇/03.mkv`, 0, 3},
{`剧集/剧集 - S00E04.mkv`, 0, 4},
{`动漫/摇曳露营/OVA/Season 3 [OVA01 [1080p].mkv`, 0, 1},
{`动漫/示例/OAD/示例.OAD02.mkv`, 0, 2},
{`动漫/示例/OVD/示例-OVD03.mkv`, 0, 3},
{`动漫/示例/ONA/示例_ONA04.mkv`, 0, 4},
{"Movie.2020.1080p.mkv", 0, 0},
// 分辨率不能被当成季集号:1920x1080 曾匹配出 20x108。
{"Movie.2020.1920x1080.mkv", 0, 0},
{"1920x1080.mkv", 0, 0},
{"[Group][Show][02][3840x2160].mkv", 1, 2},
// 字幕组方括号集号。
{"[UHA-WINGS][Peter Grill to Kenja no Jikan][01][BDRIP 1920x1080 HEVC-YUV420P10 FLAC].strm", 1, 1},
{"[UHA-WINGS][Peter Grill to Kenja no Jikan][12][BDRIP 1920x1080 HEVC-YUV420P10 FLAC].strm", 1, 12},
// 方括号里的年份/分辨率不是集号。
{"[Group][Show][2024][1080p].mkv", 0, 0},
}
for _, tc := range cases {
t.Run(tc.in, func(t *testing.T) {
@@ -68,3 +81,74 @@ 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)
}
}
}
func TestResolutionEpisodeArtifact(t *testing.T) {
cases := []struct {
path string
wantSeason int
wantEpisode int
wantOK bool
}{
{"[UHA-WINGS][Peter Grill to Kenja no Jikan][01][BDRIP 1920x1080 HEVC-YUV420P10 FLAC].strm", 20, 108, true},
{"今永纱奈/20170601000150_2,00x_3840x2160_amq-13.strm", 40, 216, true},
{"Movie.2020.1080p.mkv", 0, 0, false},
{"Show.S01E02.mkv", 0, 0, false},
}
for _, tc := range cases {
season, episode, ok := resolutionEpisodeArtifact(tc.path)
if season != tc.wantSeason || episode != tc.wantEpisode || ok != tc.wantOK {
t.Errorf("resolutionEpisodeArtifact(%q) = (%d, %d, %v), want (%d, %d, %v)",
tc.path, season, episode, ok, tc.wantSeason, tc.wantEpisode, tc.wantOK)
}
}
}
func TestDropResolutionArtifactEpisodeIdentity(t *testing.T) {
// 分辨率伪集号被剔除,并从生成的「第 N 集」标题里清掉。
polluted := &LocalMetadata{SeasonNum: 20, EpisodeNum: 108, EpisodeTitle: "第 108 集"}
dropResolutionArtifactEpisodeIdentity(polluted, "[G][Show][01][BDRIP 1920x1080 x].strm")
if polluted.SeasonNum != 0 || polluted.EpisodeNum != 0 || polluted.EpisodeTitle != "" {
t.Fatalf("resolution artifact not dropped: %+v", polluted)
}
// 真实单集号不受影响。
real := &LocalMetadata{SeasonNum: 1, EpisodeNum: 3, EpisodeTitle: "本地第三集"}
dropResolutionArtifactEpisodeIdentity(real, "Show/S01E03 1920x1080.mkv")
if real.SeasonNum != 1 || real.EpisodeNum != 3 || real.EpisodeTitle != "本地第三集" {
t.Fatalf("real episode identity was modified: %+v", real)
}
// 名称里没有分辨率时不改动任何值。
plain := &LocalMetadata{SeasonNum: 20, EpisodeNum: 108}
dropResolutionArtifactEpisodeIdentity(plain, "Show/S20E108.mkv")
if plain.SeasonNum != 20 || plain.EpisodeNum != 108 {
t.Fatalf("unrelated identity was cleared: %+v", plain)
}
// 文件名里 S20E108 是真实标记时,即使同时含分辨率也必须保留。
legit := &LocalMetadata{SeasonNum: 20, EpisodeNum: 108}
dropResolutionArtifactEpisodeIdentity(legit, "Show/Show.S20E108.1920x1080.mkv")
if legit.SeasonNum != 20 || legit.EpisodeNum != 108 {
t.Fatalf("legitimate S20E108 identity was cleared: %+v", legit)
}
}
+7
View File
@@ -78,6 +78,13 @@ func pathHintMetadata(raw string, seriesLike bool) (*LocalMetadata, mediaExterna
title, year := "", 0
if seriesLike {
title, year = CleanQuery(source)
} else if patTheatricalTitle.MatchString(raw) || patTheatricalFolder.MatchString(raw) {
base := pathBaseSlash(raw)
stem := mediaFileStem(base)
if stem == "" {
stem = strings.TrimSuffix(base, filepath.Ext(base))
}
title, year = CleanQuery(stem)
} else {
title, year = cloudSeriesTitleFromMediaPath(source)
if title == "" {
+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())
+6 -3
View File
@@ -74,12 +74,15 @@ func localPosterCandidates(mediaPath string) []string {
add(base + "-cover")
add(base + ".cover")
add(base)
}
for _, name := range []string{"poster", "folder", "cover", "movie", "show"} {
add(name)
}
for _, base := range mediaSidecarBaseVariants(mediaPath) {
add(base + "-thumb")
add(base + ".thumb")
}
for _, name := range []string{"poster", "folder", "cover", "movie", "show", "thumb"} {
add(name)
}
add("thumb")
return append(adultArtworkNameCandidates(mediaPath, "poster"), names...)
}
+6
View File
@@ -23,6 +23,9 @@ func ReadLocalMetadata(mediaPath, libraryRoot string, seriesLike bool) (*LocalMe
}
meta := metadataFromDoc(doc, filepath.Dir(path), false)
mergeArtworkMetadata(meta, mediaPath, filepath.Dir(path))
// A sidecar written while resolution tokens were misread as SxxExx must not
// reintroduce the bogus season/episode on rescan.
dropResolutionArtifactEpisodeIdentity(meta, mediaPath)
return meta, nil
}
@@ -116,6 +119,9 @@ func readSeriesMetadata(mediaPath, libraryRoot string) (*LocalMetadata, error) {
} else {
mergeArtworkMetadata(meta, mediaPath, showBaseDir)
}
// A sidecar written while resolution tokens were misread as SxxExx must not
// reintroduce the bogus season/episode on rescan.
dropResolutionArtifactEpisodeIdentity(meta, mediaPath)
return meta, nil
}
+42
View File
@@ -6,6 +6,48 @@ import (
"testing"
)
func TestReadLocalMetadataDropsResolutionArtifactEpisode(t *testing.T) {
// 刮削曾把分辨率 1920x1080 误读成 S20E108 并回写进边车 NFO;
// 重扫时必须忽略这个伪季集号,否则旧文件会把错误身份灌回 DB。
root := t.TempDir()
showDir := filepath.Join(root, "彼得·格里尔的贤者时间")
if err := os.MkdirAll(showDir, 0o755); err != nil {
t.Fatal(err)
}
mediaPath := filepath.Join(showDir,
"[UHA-WINGS][Peter Grill to Kenja no Jikan][01][BDRIP 1920x1080 HEVC-YUV420P10 FLAC].strm")
if err := os.WriteFile(mediaPath, []byte("x"), 0o644); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(nfoPath(mediaPath), []byte(`<?xml version="1.0" encoding="UTF-8"?>
<episodedetails>
<title>第 108 集</title>
<showtitle>彼得·格里尔的贤者时间</showtitle>
<season>20</season>
<episode>108</episode>
<tmdbid>99080</tmdbid>
</episodedetails>`), 0o644); err != nil {
t.Fatal(err)
}
got, err := ReadLocalMetadata(mediaPath, root, true)
if err != nil {
t.Fatal(err)
}
if got == nil {
t.Fatal("metadata is nil")
}
if got.SeasonNum != 0 || got.EpisodeNum != 0 {
t.Fatalf("resolution artifact season/episode not dropped: s=%d e=%d", got.SeasonNum, got.EpisodeNum)
}
if got.EpisodeTitle != "" {
t.Fatalf("generated episode title not dropped: %q", got.EpisodeTitle)
}
if got.Title != "彼得·格里尔的贤者时间" {
t.Fatalf("series title = %q, want 彼得·格里尔的贤者时间", got.Title)
}
}
func TestReadLocalMovieMetadata(t *testing.T) {
dir := t.TempDir()
mediaPath := filepath.Join(dir, "Inception.2010.mkv")
+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
}
}
+7
View File
@@ -24,6 +24,13 @@ func (s *ScraperService) ManualSearch(ctx context.Context, media *model.Media, q
mediaType = lib.Type
}
}
// Keep manual scraping consistent with automatic scraping for theatrical
// features stored inside anime libraries. The UI may pass the library type
// ("anime"), which otherwise makes TMDb stop after a TV result and hide the
// actual movie candidate.
if mediaLooksLikeTheatricalFeature(media) {
mediaType = "movie"
}
mediaType = normalizeMediaType(mediaType, queries[0], "")
providers := manualSearchProviderSet(provider)
year := mediaYearHint(media)
+230
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",
@@ -244,6 +313,80 @@ func TestManualSearchReturnsMovieFallbackForTVTypedTMDbSearch(t *testing.T) {
}
}
func TestManualSearchAnimeTheatricalPrefersTMDbMovie(t *testing.T) {
var paths []string
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
paths = append(paths, r.URL.Path)
w.Header().Set("Content-Type", "application/json")
switch r.URL.Path {
case "/search/movie":
_ = json.NewEncoder(w).Encode(map[string]any{
"results": []map[string]any{{
"id": 635302,
"title": "鬼灭之刃 剧场版 无限列车篇",
"original_title": "劇場版「鬼滅の刃」無限列車編",
"release_date": "2020-10-16",
}},
})
case "/search/tv":
_ = json.NewEncoder(w).Encode(map[string]any{
"results": []map[string]any{{
"id": 85937,
"name": "鬼灭之刃",
"original_name": "鬼滅の刃",
"first_air_date": "2019-04-06",
}},
})
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))
lib := model.Library{Name: "动漫", Path: `/media/anime`, Type: "anime", Enabled: true}
if err := repos.DB.Create(&lib).Error; err != nil {
t.Fatal(err)
}
media := model.Media{
LibraryID: lib.ID,
Title: "鬼灭之刃 剧场版 无限列车篇",
Path: `/media/anime/鬼灭之刃/鬼灭之刃 剧场版 无限列车篇.mkv`,
}
if err := repos.DB.Create(&media).Error; err != nil {
t.Fatal(err)
}
results, err := scraper.ManualSearch(t.Context(), &media, media.Title, "tmdb", "anime")
if err != nil {
t.Fatal(err)
}
if len(results) == 0 || results[0].TMDbID != 635302 || results[0].MediaType != "movie" {
t.Fatalf("manual theatrical results=%#v, paths=%v", results, paths)
}
if len(paths) == 0 || paths[0] != "/search/movie" {
t.Fatalf("TMDb search paths=%v, want movie first", paths)
}
for _, path := range paths {
if path == "/search/tv" {
t.Fatalf("manual theatrical search unexpectedly queried TV after finding movie: paths=%v", paths)
}
}
}
func TestManualSearchAllProvidersTMDbNumericIDTriesMovieAndTVNamespaces(t *testing.T) {
var paths []string
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
@@ -627,3 +770,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)
}
}
+16 -16
View File
@@ -62,7 +62,7 @@ func classifyMediaCategory(input mediaClassifyInput, categories map[string]strin
isWesternByCategory := containsAnyText(categoryText, "欧美剧", "欧美电视剧", "美剧", "英剧", "欧美电影", "外语电影")
isWestern := isWesternByMetadata || (!hasRegionMetadata && isWesternByCategory)
isUSAnime := hasAny(countries, "US")
hasAnimeText := containsAnyText(contentText, "动画", "动漫", "番剧", "年番", "国漫", "日番", "韩漫", "美漫", "bangumi", "anime", "b-global", "ani-one", "crunchyroll")
hasAnimeText := containsAnyText(contentText, "动画", "动漫", "番剧", "年番", "国漫", "日番", "韩漫", "美漫", "bangumi", "anime", "b-global", "ani-one", "crunchyroll", "剧场版", "劇場版", "动画电影", "動畫電影")
hasVarietyText := containsAnyText(contentText, "综艺", "真人秀", "脱口秀", "晚会", "春晚", "gala", "festival gala", "reality", "talk show")
hasDocumentaryText := containsAnyText(contentText, "纪录", "纪录片", "documentary", "docu", "national geographic", "natgeo")
hasConcertText := containsAnyText(contentText, "演唱会", "音乐会", "concert", "live concert")
@@ -191,21 +191,21 @@ func normalizeMediaType(mediaType, title, category string) string {
return "adult"
case containsAnyText(raw, "综艺", "真人秀"):
return "variety"
case (containsAnyText(raw, "国漫", "日漫", "日番", "韩漫", "美漫", "欧美动漫", "其他动漫", "动漫", "动画") || classifierAnimeRE.MatchString(raw)) && !containsAnyText(raw, "动画电影"):
return "anime"
case containsAnyText(raw, "电视剧", "剧集", "连续剧", "短剧", "国产剧", "国剧", "大陆剧", "华语剧", "国产电视剧", "大陆电视剧", "华语电视剧", "欧美剧", "欧美电视剧", "美剧", "英剧", "日韩剧", "日韩电视剧", "日剧", "韩剧", "港剧", "台剧", "港台剧", "泰剧") || classifierTVRE.MatchString(raw):
return "tv"
case containsAnyText(raw, "电影", "演唱会") || classifierMovieRE.MatchString(raw):
return "movie"
}
text := strings.ToLower(title + " " + category)
switch {
case strings.Contains(text, "adult") || strings.Contains(text, "nsfw") || strings.Contains(text, "成人") || strings.Contains(text, "番号") || strings.Contains(text, "jav") || strings.Contains(text, "9kg") || classifierJAVCodeRE.MatchString(strings.ToUpper(title+" "+category)):
return "adult"
case containsAnyText(text, "综艺", "真人秀", "脱口秀", "晚会", "春晚", "gala", "festival gala", "reality", "talk show"):
return "variety"
case strings.Contains(text, "电影") || classifierMovieRE.MatchString(text):
return "movie"
case (containsAnyText(raw, "国漫", "日漫", "日番", "韩漫", "美漫", "欧美动漫", "其他动漫", "动漫", "动画") || classifierAnimeRE.MatchString(raw)) && !containsAnyText(raw, "动画电影", "剧场版", "劇場版"):
return "anime"
case containsAnyText(raw, "电视剧", "剧集", "连续剧", "短剧", "国产剧", "国剧", "大陆剧", "华语剧", "国产电视剧", "大陆电视剧", "华语电视剧", "欧美剧", "欧美电视剧", "美剧", "英剧", "日韩剧", "日韩电视剧", "日剧", "韩剧", "港剧", "台剧", "港台剧", "泰剧") || classifierTVRE.MatchString(raw):
return "tv"
case containsAnyText(raw, "电影", "演唱会", "剧场版", "劇場版") || classifierMovieRE.MatchString(raw):
return "movie"
}
text := strings.ToLower(title + " " + category)
switch {
case strings.Contains(text, "adult") || strings.Contains(text, "nsfw") || strings.Contains(text, "成人") || strings.Contains(text, "番号") || strings.Contains(text, "jav") || strings.Contains(text, "9kg") || classifierJAVCodeRE.MatchString(strings.ToUpper(title+" "+category)):
return "adult"
case containsAnyText(text, "综艺", "真人秀", "脱口秀", "晚会", "春晚", "gala", "festival gala", "reality", "talk show"):
return "variety"
case containsAnyText(text, "电影", "剧场版", "劇場版") || classifierMovieRE.MatchString(text):
return "movie"
case classifierAnimeRE.MatchString(text) || strings.Contains(text, "动漫") || strings.Contains(text, "动画"):
return "anime"
case strings.Contains(text, "variety") || strings.Contains(text, "综艺") || strings.Contains(text, "真人秀"):
+5 -5
View File
@@ -9,7 +9,7 @@ import (
"github.com/truewhile/MeBox/internal/model"
)
var episodicPathRE = regexp.MustCompile(`(?i)[\\/](?:电视剧|剧集|连续剧|短剧|国产剧|国剧|大陆剧|华语剧|国产电视剧|大陆电视剧|华语电视剧|欧美剧|欧美电视剧|美剧|英剧|日韩剧|日韩电视剧|日剧|韩剧|港剧|台剧|港台剧|泰剧|综艺|纪录片|儿童|动漫|番剧|国漫|日番|韩漫|美漫|欧美动漫|欧美动画|其他动漫|tv|series|shows?|season[\s._-]*\d|s\d{1,2}(?:[\s._-]|[\\/])|special[\s._-]*episodes?|specials?|sp|ovas?|oads?|extras?|bonus(?:es)?|omake|特别篇|特別篇|番外篇?|特典|外传|外傳|总集篇|總集篇)[\\/]`)
var episodicPathRE = regexp.MustCompile(`(?i)[\\/](?:电视剧|剧集|连续剧|短剧|国产剧|国剧|大陆剧|华语剧|国产电视剧|大陆电视剧|华语电视剧|欧美剧|欧美电视剧|美剧|英剧|日韩剧|日韩电视剧|日剧|韩剧|港剧|台剧|港台剧|泰剧|综艺|纪录片|儿童|动漫|番剧|国漫|日番|韩漫|美漫|欧美动漫|欧美动画|其他动漫|tv|series|shows?|season[\s._-]*\d|s\d{1,2}(?:[\s._-]|[\\/])|special[\s._-]*episodes?|specials?|sp|ovas?|oads?|ovds?|onas?|extras?|bonus(?:es)?|omake|picture[\s._-]*drama|ncop|nced|特别篇|特別篇|番外篇?|特典|外传|外傳|总集篇|總集篇|画像特典)[\\/]`)
var genericMovieTitleRE = regexp.MustCompile(`(?i)^(?:cd\s*\d+|part\s*\d+|disc\s*\d+|disk\s*\d+|dvd\s*\d+|movie|film|video|main|feature|track\s*\d+|preview|sample|trailer|\d{3,4}p|4k|2160p|1080p|720p)$`)
@@ -123,9 +123,9 @@ var (
seriesIDRE = regexp.MustCompile(`(?i)\s*\[(?:tmdb|tmdbid)[=-]\d+\]\s*`)
seriesBraceRE = regexp.MustCompile(`(?i)\s*\{(?:tmdb|tmdbid|douban|bangumi|bgm|thetvdb|tvdb)[\s:=#-]*[a-z0-9_-]+\}\s*`)
seriesSpacerRE = regexp.MustCompile(`[\s._-]+`)
seriesSeasonDirRE = regexp.MustCompile(`(?i)^(?:s\d{1,2}|season[\s._-]*\d{1,2}|第\s*[0-9一二三四五六七八九十百零两]+\s*季|special[\s._-]*episodes?|specials?|sp|ovas?|oads?|extras?|bonus(?:es)?|omake|特别篇|特別篇|番外篇?|特典|外传|外傳|总集篇|總集篇)$`)
seriesSpecialCodeRE = regexp.MustCompile(`(?i)\s*[\[((【]?\s*(?:s0+\s*e?\s*\d+|season\s*0+(?:\s*episode)?\s*\d*|special(?:\s*episode)?s?\s*\d*|sp\s*\d*|ovas?\s*\d*|oads?\s*\d*|extras?\s*\d*|bonus(?:es)?\s*\d*|omake\s*\d*)\s*[\]))】]?$`)
seriesSpecialCJKRE = regexp.MustCompile(`(?i)\s*[\[((【]?\s*(?:特别篇|特別篇|番外篇?|特典|外传|外傳|总集篇|總集篇)(?:\s*第?\s*[0-9一二三四五六七八九十百零两]+(?:[集话話期])?)?\s*[\]))】]?$`)
seriesSeasonDirRE = regexp.MustCompile(`(?i)^(?:s\d{1,2}|season[\s._-]*\d{1,2}|第\s*[0-9一二三四五六七八九十百零两]+\s*季|special[\s._-]*episodes?|specials?|sp|ovas?|oads?|ovds?|onas?|extras?|bonus(?:es)?|omake|picture[\s._-]*drama|ncop|nced|特别篇|特別篇|番外篇?|特典|外传|外傳|总集篇|總集篇|画像特典)$`)
seriesSpecialCodeRE = regexp.MustCompile(`(?i)\s*[\[((【]?\s*(?:s0+\s*e?\s*\d+|season\s*0+(?:\s*episode)?\s*\d*|special(?:\s*episode)?s?\s*\d*|sp\s*\d*|ovas?\s*\d*|oads?\s*\d*|ovds?\s*\d*|onas?\s*\d*|extras?\s*\d*|bonus(?:es)?\s*\d*|omake\s*\d*|picture[\s._-]*drama\s*\d*|ncop\s*\d*|nced\s*\d*)\s*[\]))】]?$`)
seriesSpecialCJKRE = regexp.MustCompile(`(?i)\s*[\[((【]?\s*(?:特别篇|特別篇|番外篇?|特典|外传|外傳|总集篇|總集篇|画像特典)(?:\s*第?\s*[0-9一二三四五六七八九十百零两]+(?:[集话話期])?)?\s*[\]))】]?$`)
)
func normalizeSeriesTitle(value string) string {
@@ -173,7 +173,7 @@ func seriesTitleFromMediaPath(path string) string {
if last := parts[len(parts)-1]; !seriesPathPartLooksLikeFile(last) && !seriesSeasonDirRE.MatchString(filepath.Base(last)) {
dirIndex = len(parts) - 1
}
for dirIndex >= 0 && seriesSeasonDirRE.MatchString(filepath.Base(parts[dirIndex])) {
for dirIndex >= 0 && (seriesSeasonDirRE.MatchString(filepath.Base(parts[dirIndex])) || isTheatricalFolder(parts[dirIndex])) {
dirIndex--
}
if dirIndex < 0 {
+26 -1
View File
@@ -404,6 +404,32 @@ func TestGroupMediaSeriesCardsKeepsMovieVersionsAsOneMovie(t *testing.T) {
}
}
func TestGroupMediaSeriesCardsKeepsTheatricalMovieWithTVSeries(t *testing.T) {
episode := model.Media{
Base: model.Base{ID: "episode"},
LibraryID: "anime",
Title: "摇曳露营△",
Path: `/media/动漫/摇曳露营△ (2018)/Season 01/摇曳露营△.S01E01.mkv`,
SeasonNum: 1,
EpisodeNum: 1,
}
theatrical := model.Media{
Base: model.Base{ID: "theatrical"},
LibraryID: "anime",
Title: "摇曳露营△ 剧场版",
Path: `/media/动漫/摇曳露营△ (2018)/摇曳露营△ 剧场版 (2022)/Eiga.Yurukyan.2022.Bluray.mkv`,
TMDbID: 566466,
}
cards := groupMediaSeriesCards([]model.Media{episode, theatrical})
if len(cards) != 1 {
t.Fatalf("cards=%#v, want theatrical movie retained with TV series", cards)
}
if cards[0].Count != 2 {
t.Fatalf("series card count=%d, want TV episode plus theatrical movie", cards[0].Count)
}
}
func TestGroupMediaSeriesCardsDoesNotCollideMovieAndTVExternalIDs(t *testing.T) {
movie := model.Media{
Base: model.Base{ID: "movie"},
@@ -512,4 +538,3 @@ func TestListMediaEpisodesKeepsIndependentMoviesSeparate(t *testing.T) {
t.Fatalf("ListMediaEpisodes got %#v, want exactly m1", eps)
}
}
+54
View File
@@ -0,0 +1,54 @@
package service
import (
"regexp"
"strings"
)
const (
mediaSpecialTheatrical = "theatrical"
mediaSpecialOVA = "ova"
mediaSpecialOAD = "oad"
mediaSpecialOVD = "ovd"
mediaSpecialONA = "ona"
mediaSpecialExtra = "extra"
mediaSpecialBonus = "bonus"
mediaSpecialOmake = "omake"
mediaSpecialPicture = "picture_drama"
mediaSpecialNCOP = "ncop"
mediaSpecialNCED = "nced"
mediaSpecialGeneric = "special"
)
var mediaSpecialKindPatterns = []struct {
kind string
re *regexp.Regexp
}{
{mediaSpecialOVA, regexp.MustCompile(`(?i)(?:^|[^a-z0-9])ova(?:s)?(?:[\s._-]*\d+)?(?:[^a-z0-9]|$)`)},
{mediaSpecialOAD, regexp.MustCompile(`(?i)(?:^|[^a-z0-9])oad(?:s)?(?:[\s._-]*\d+)?(?:[^a-z0-9]|$)`)},
{mediaSpecialOVD, regexp.MustCompile(`(?i)(?:^|[^a-z0-9])ovd(?:s)?(?:[\s._-]*\d+)?(?:[^a-z0-9]|$)`)},
{mediaSpecialONA, regexp.MustCompile(`(?i)(?:^|[^a-z0-9])ona(?:s)?(?:[\s._-]*\d+)?(?:[^a-z0-9]|$)`)},
{mediaSpecialPicture, regexp.MustCompile(`(?i)(?:^|[^a-z0-9])(?:picture[\s._-]*drama|画像特典)(?:[^a-z0-9]|$)`)},
{mediaSpecialNCOP, regexp.MustCompile(`(?i)(?:^|[^a-z0-9])ncop(?:\d+)?(?:[^a-z0-9]|$)`)},
{mediaSpecialNCED, regexp.MustCompile(`(?i)(?:^|[^a-z0-9])nced(?:\d+)?(?:[^a-z0-9]|$)`)},
{mediaSpecialExtra, regexp.MustCompile(`(?i)(?:^|[^a-z0-9])extras?(?:[\s._-]*\d+)?(?:[^a-z0-9]|$)`)},
{mediaSpecialBonus, regexp.MustCompile(`(?i)(?:^|[^a-z0-9])bonus(?:es)?(?:[\s._-]*\d+)?(?:[^a-z0-9]|$)`)},
{mediaSpecialOmake, regexp.MustCompile(`(?i)(?:^|[^a-z0-9])omake(?:[\s._-]*\d+)?(?:[^a-z0-9]|$)`)},
{mediaSpecialGeneric, regexp.MustCompile(`(?i)(?:^|[^a-z0-9])(?:special(?:[\s._-]*episodes?)?|specials|sps?)(?:[\s._-]*\d+)?(?:[^a-z0-9]|$)|特别篇|特別篇|番外篇?|特典|外传|外傳|总集篇|總集篇`)},
}
func mediaSpecialKind(path string) string {
if pathHasTheatricalFolder(path) || patTheatricalTitle.MatchString(path) {
return mediaSpecialTheatrical
}
for _, part := range strings.FieldsFunc(path, func(r rune) bool {
return r == '/' || r == '\\'
}) {
for _, pattern := range mediaSpecialKindPatterns {
if pattern.re.MatchString(part) {
return pattern.kind
}
}
}
return ""
}
+69 -10
View File
@@ -120,22 +120,57 @@ 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) {
return fmt.Sprintf("embyremote:%s", m.ID)
}
if m.SeasonNum > 0 || m.EpisodeNum > 0 {
libKey := strings.ToLower(strings.TrimSpace(m.LibraryID))
if libKey == "" {
libKey = strings.ToLower(strings.TrimSpace(m.DisplayLibraryID))
}
specialKind := mediaSpecialKind(m.Path)
season, episode := m.SeasonNum, m.EpisodeNum
if specialKind != "" && specialKind != mediaSpecialTheatrical && episode <= 0 {
if parsedSeason, parsedEpisode := ParseEpisode(m.Path); parsedEpisode > 0 {
season, episode = parsedSeason, parsedEpisode
}
}
if season > 0 || episode > 0 {
kind := specialKind
if kind == "" {
kind = "episode"
}
switch {
case m.TMDbID > 0:
return fmt.Sprintf("episode:tmdb:%d:%d:%d", m.TMDbID, m.SeasonNum, m.EpisodeNum)
return fmt.Sprintf("episode:%s:tmdb:%d:%d:%d", kind, m.TMDbID, season, episode)
case m.BangumiID > 0:
return fmt.Sprintf("episode:bangumi:%d:%d:%d", m.BangumiID, m.SeasonNum, m.EpisodeNum)
return fmt.Sprintf("episode:%s:bangumi:%d:%d:%d", kind, m.BangumiID, season, episode)
case strings.TrimSpace(m.DoubanID) != "":
return fmt.Sprintf("episode:douban:%s:%d:%d", strings.ToLower(strings.TrimSpace(m.DoubanID)), m.SeasonNum, m.EpisodeNum)
return fmt.Sprintf("episode:%s:douban:%s:%d:%d", kind, strings.ToLower(strings.TrimSpace(m.DoubanID)), season, episode)
case strings.TrimSpace(m.TheTVDBID) != "":
return fmt.Sprintf("episode:thetvdb:%s:%d:%d", strings.ToLower(strings.TrimSpace(m.TheTVDBID)), m.SeasonNum, m.EpisodeNum)
return fmt.Sprintf("episode:%s:thetvdb:%s:%d:%d", kind, strings.ToLower(strings.TrimSpace(m.TheTVDBID)), season, episode)
}
title := firstNonEmpty(m.OriginalName, m.Title)
if title == "" {
@@ -147,37 +182,61 @@ func mediaVersionGroupKey(m model.Media) string {
}
return strings.Join([]string{
"episode",
strings.ToLower(strings.TrimSpace(m.LibraryID)),
kind,
libKey,
title,
fmt.Sprintf("%d:%d", m.SeasonNum, m.EpisodeNum),
fmt.Sprintf("%d:%d", season, episode),
}, "|")
}
libKey := strings.ToLower(strings.TrimSpace(m.LibraryID))
if libKey == "" {
libKey = strings.ToLower(strings.TrimSpace(m.DisplayLibraryID))
if specialKind != "" && specialKind != mediaSpecialTheatrical {
return mediaVersionStemGroupKey(m, libKey)
}
switch {
case m.TMDbID > 0:
if libKey != "" {
if specialKind != "" {
return fmt.Sprintf("movie:%s:%s:tmdb:%d", specialKind, libKey, m.TMDbID)
}
return fmt.Sprintf("movie:%s:tmdb:%d", libKey, m.TMDbID)
}
if specialKind != "" {
return fmt.Sprintf("movie:%s:tmdb:%d", specialKind, m.TMDbID)
}
return fmt.Sprintf("tmdb:%d", m.TMDbID)
case m.BangumiID > 0:
if libKey != "" {
if specialKind != "" {
return fmt.Sprintf("movie:%s:%s:bangumi:%d", specialKind, libKey, m.BangumiID)
}
return fmt.Sprintf("movie:%s:bangumi:%d", libKey, m.BangumiID)
}
if specialKind != "" {
return fmt.Sprintf("movie:%s:bangumi:%d", specialKind, m.BangumiID)
}
return fmt.Sprintf("bangumi:%d", m.BangumiID)
case strings.TrimSpace(m.DoubanID) != "":
if libKey != "" {
if specialKind != "" {
return fmt.Sprintf("movie:%s:%s:douban:%s", specialKind, libKey, strings.ToLower(strings.TrimSpace(m.DoubanID)))
}
return fmt.Sprintf("movie:%s:douban:%s", libKey, strings.ToLower(strings.TrimSpace(m.DoubanID)))
}
if specialKind != "" {
return "movie:" + specialKind + ":douban:" + strings.ToLower(strings.TrimSpace(m.DoubanID))
}
return "douban:" + strings.ToLower(strings.TrimSpace(m.DoubanID))
case strings.TrimSpace(m.TheTVDBID) != "":
if libKey != "" {
if specialKind != "" {
return fmt.Sprintf("movie:%s:%s:thetvdb:%s", specialKind, libKey, strings.ToLower(strings.TrimSpace(m.TheTVDBID)))
}
return fmt.Sprintf("movie:%s:thetvdb:%s", libKey, strings.ToLower(strings.TrimSpace(m.TheTVDBID)))
}
if specialKind != "" {
return "movie:" + specialKind + ":thetvdb:" + strings.ToLower(strings.TrimSpace(m.TheTVDBID))
}
return "thetvdb:" + strings.ToLower(strings.TrimSpace(m.TheTVDBID))
}
+125
View File
@@ -1,6 +1,7 @@
package service
import (
"fmt"
"strings"
"testing"
"time"
@@ -49,6 +50,130 @@ 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 TestGroupMediaVersionsSeparatesAnimeSpecialKindsAndNumbers(t *testing.T) {
rows := []model.Media{
{
Base: model.Base{ID: "movie"},
LibraryID: "anime",
Title: "摇曳露营△",
Path: "/media/动漫/摇曳露营△/摇曳露营△ 剧场版 (2022)/movie.strm",
TMDbID: 76075,
},
{
Base: model.Base{ID: "ova-1"},
LibraryID: "anime",
Title: "摇曳露营△",
Path: "/media/动漫/摇曳露营△/OVA/Yuru Camp OVA01.strm",
TMDbID: 76075,
},
{
Base: model.Base{ID: "ova-2"},
LibraryID: "anime",
Title: "摇曳露营△",
Path: "/media/动漫/摇曳露营△/OVA/Yuru Camp OVA02.strm",
TMDbID: 76075,
},
{
Base: model.Base{ID: "oad-1"},
LibraryID: "anime",
Title: "摇曳露营△",
Path: "/media/动漫/摇曳露营△/OAD/Yuru Camp OAD01.strm",
TMDbID: 76075,
},
}
grouped := groupMediaVersions(rows)
if len(grouped) != 4 {
t.Fatalf("grouped len = %d, want theatrical, OVA01, OVA02 and OAD01 separate: %#v", len(grouped), grouped)
}
for _, item := range grouped {
if len(item.Versions) > 1 {
t.Fatalf("unrelated anime extras were merged as versions: %#v", item.Versions)
}
}
}
func TestGroupMediaVersionsKeepsBracketNumberedEpisodesSeparate(t *testing.T) {
// UHA-WINGS 命名:[组名][标题][01][BDRIP 1920x1080 ...].strm。
// 曾因 1920x1080 被解析成 S20E108,12 集全部折叠成一集的多个版本。
rows := make([]model.Media, 0, 12)
for episode := 1; episode <= 12; episode++ {
rows = append(rows, model.Media{
Base: model.Base{ID: fmt.Sprintf("ep-%02d", episode)},
LibraryID: "anime",
Title: "彼得·格里尔的贤者时间",
Path: fmt.Sprintf(
"/media/影视库/动漫/彼得·格里尔的贤者时间/[UHA-WINGS][Peter Grill to Kenja no Jikan][%02d][BDRIP 1920x1080 HEVC-YUV420P10 FLAC].strm",
episode),
TMDbID: 99080,
})
}
for i := range rows {
season, episode := ParseEpisode(rows[i].Path)
rows[i].SeasonNum = season
rows[i].EpisodeNum = episode
}
grouped := groupMediaVersions(rows)
if len(grouped) != 12 {
t.Fatalf("grouped len = %d, want 12 distinct episodes: %#v", len(grouped), grouped)
}
for _, item := range grouped {
if len(item.Versions) > 1 {
t.Fatalf("distinct episodes were folded into versions: %#v", item.Versions)
}
if item.SeasonNum != 1 || item.EpisodeNum < 1 || item.EpisodeNum > 12 {
t.Fatalf("unexpected episode identity s=%d e=%d", item.SeasonNum, item.EpisodeNum)
}
}
}
func TestGroupMediaVersionsMergesSameNumberedOVAEncodes(t *testing.T) {
rows := []model.Media{
{
Base: model.Base{ID: "ova-1-hd"},
LibraryID: "anime",
Path: "/media/动漫/示例/OVA/Show OVA01 1080p.mkv",
TMDbID: 123,
SizeBytes: 100,
},
{
Base: model.Base{ID: "ova-1-uhd"},
LibraryID: "anime",
Path: "/media/动漫/示例/OVA/Show OVA01 2160p.mkv",
TMDbID: 123,
SizeBytes: 200,
},
}
grouped := groupMediaVersions(rows)
if len(grouped) != 1 || len(grouped[0].Versions) != 2 {
t.Fatalf("same numbered OVA encodes should be versions: %#v", grouped)
}
}
func TestGroupMediaVersionsMergesMovieEncodingVariants(t *testing.T) {
hd := model.Media{
LibraryID: "movies",
+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)
@@ -55,7 +55,7 @@ func (o *OrganizerService) lookupOrganizeMetadata(ctx context.Context, src, sour
continue
}
}
match := o.scraper.lookup(ctx, lib, media, candidate, year)
match := o.scraper.lookup(ctx, lib, media, candidate, year, false)
if match != nil && strings.TrimSpace(match.Title) != "" {
if !organizeMetadataMatchTrusted(candidate, year, match) {
if cache != nil {
+5 -1
View File
@@ -84,6 +84,7 @@ func transferSidecarArtwork(srcMedia, dstMedia string, mode TransferMode) error
if dstBase == "" {
dstBase = strings.TrimSuffix(filepath.Base(dstMedia), filepath.Ext(dstMedia))
}
srcAdultCode := AdultCodeFromMediaPath(srcMedia)
for _, src := range sources {
name := filepath.Base(src)
// Remap any legacy "Title.mkv-poster.jpg" onto the shared destination stem.
@@ -97,7 +98,10 @@ func transferSidecarArtwork(srcMedia, dstMedia string, mode TransferMode) error
}
}
dstName := name
if suffix != "" && dstBase != "" {
if srcAdultCode != "" && strings.HasPrefix(strings.ToLower(name), strings.ToLower(srcAdultCode)) {
// Adult sidecars are keyed by code (e.g. ADN-188-poster.jpg) and should
// follow the media without being renamed to the display title stem.
} else if suffix != "" && dstBase != "" {
dstName = dstBase + suffix
}
dst := filepath.Join(dstDir, dstName)
+16 -6
View File
@@ -27,12 +27,13 @@ func NewProfileService(log *zap.Logger, repo *repository.Container) *ProfileServ
// ProfileUpdate is the patch object accepted by UpdateProfile. Empty
// fields are ignored so the same payload can be reused across screens.
type ProfileUpdate struct {
Username *string `json:"username,omitempty"`
Nickname *string `json:"nickname,omitempty"`
Email *string `json:"email,omitempty"`
AvatarURL *string `json:"avatar_url,omitempty"`
HideAdult *bool `json:"hide_adult,omitempty"`
Password string `json:"password,omitempty"`
Username *string `json:"username,omitempty"`
Nickname *string `json:"nickname,omitempty"`
Email *string `json:"email,omitempty"`
AvatarURL *string `json:"avatar_url,omitempty"`
HideAdult *bool `json:"hide_adult,omitempty"`
SubtitleChineseMode *string `json:"subtitle_chinese_mode,omitempty"`
Password string `json:"password,omitempty"`
}
// UpdateProfile applies a non-credential patch to the user.
@@ -73,6 +74,15 @@ func (p *ProfileService) UpdateProfile(ctx context.Context, userID string, patch
if patch.HideAdult != nil {
updates["hide_adult"] = *patch.HideAdult
}
if patch.SubtitleChineseMode != nil {
mode := strings.ToLower(strings.TrimSpace(*patch.SubtitleChineseMode))
switch mode {
case "original", "simplified", "traditional":
updates["subtitle_chinese_mode"] = mode
default:
return nil, errors.New("subtitle_chinese_mode must be original, simplified, or traditional")
}
}
if len(updates) > 0 {
if err := p.repo.DB.Model(&model.User{}).Where("id = ?", userID).
Updates(updates).Error; err != nil {
+60
View File
@@ -0,0 +1,60 @@
package service
import (
"testing"
"github.com/glebarez/sqlite"
"go.uber.org/zap"
"gorm.io/gorm"
"github.com/truewhile/MeBox/internal/model"
"github.com/truewhile/MeBox/internal/repository"
)
func TestProfileSubtitleChineseModePersistsAndValidates(t *testing.T) {
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
if err != nil {
t.Fatal(err)
}
if err := db.AutoMigrate(&model.User{}); err != nil {
t.Fatal(err)
}
repos := repository.New(db)
svc := NewProfileService(zap.NewNop(), repos)
user := &model.User{
Username: "subtitle-viewer",
PasswordHash: "hash",
Role: "user",
IsActive: true,
}
if err := repos.User.Create(t.Context(), user); err != nil {
t.Fatal(err)
}
traditional := "traditional"
updated, err := svc.UpdateProfile(t.Context(), user.ID, ProfileUpdate{
SubtitleChineseMode: &traditional,
})
if err != nil {
t.Fatal(err)
}
if updated.SubtitleChineseMode != traditional {
t.Fatalf("subtitle mode = %q, want %q", updated.SubtitleChineseMode, traditional)
}
invalid := "automatic"
if _, err := svc.UpdateProfile(t.Context(), user.ID, ProfileUpdate{
SubtitleChineseMode: &invalid,
}); err == nil {
t.Fatal("invalid subtitle mode should be rejected")
}
reloaded, err := repos.User.FindByID(t.Context(), user.ID)
if err != nil {
t.Fatal(err)
}
if reloaded.SubtitleChineseMode != traditional {
t.Fatalf("invalid update changed subtitle mode to %q", reloaded.SubtitleChineseMode)
}
}
+73 -6
View File
@@ -5,8 +5,10 @@ import (
"encoding/json"
"fmt"
"net/http"
"regexp"
"strconv"
"strings"
"sync"
"time"
"go.uber.org/zap"
@@ -61,8 +63,9 @@ func NewRecognitionWordsService(log *zap.Logger, repo *repository.Container) *Re
}
func (s *RecognitionWordsService) Config(ctx context.Context) RecognitionWordsConfig {
cfg := recognitionWordsConfig(ctx, s.repo)
cfg.RuleCount = len(parseRecognitionWordRules(recognitionWordsCombinedText(cfg)))
cfg, rules := recognitionWordsRuntime(ctx, s.repo)
cfg = cloneRecognitionWordsConfig(cfg)
cfg.RuleCount = len(rules)
return cfg
}
@@ -80,7 +83,11 @@ func (s *RecognitionWordsService) SaveConfig(ctx context.Context, cfg Recognitio
if err != nil {
return err
}
return s.repo.Setting.Set(ctx, RecognitionWordsSharedURLsKey, string(rawURLs))
if err := s.repo.Setting.Set(ctx, RecognitionWordsSharedURLsKey, string(rawURLs)); err != nil {
return err
}
invalidateRecognitionWordsRuntime(s.repo)
return nil
}
func (s *RecognitionWordsService) SyncShared(ctx context.Context) (RecognitionWordsConfig, error) {
@@ -104,6 +111,7 @@ func (s *RecognitionWordsService) SyncShared(ctx context.Context) (RecognitionWo
if err := s.repo.Setting.Set(ctx, RecognitionWordsSyncedAtKey, now); err != nil {
return cfg, err
}
invalidateRecognitionWordsRuntime(s.repo)
return s.Config(ctx), nil
}
@@ -120,11 +128,10 @@ func (s *RecognitionWordsService) Test(ctx context.Context, input string) Recogn
}
func ApplyRecognitionWords(ctx context.Context, repo *repository.Container, raw string) string {
cfg := recognitionWordsConfig(ctx, repo)
if !cfg.Enabled {
cfg, rules := recognitionWordsRuntime(ctx, repo)
if !cfg.Enabled || len(rules) == 0 {
return raw
}
rules := parseRecognitionWordRules(recognitionWordsCombinedText(cfg))
return applyRecognitionWordRules(raw, rules)
}
@@ -189,9 +196,69 @@ func normalizeRecognitionWordURLs(values []string) []string {
type recognitionWordRule struct {
raw string
block string
blockRE *regexp.Regexp
replaceFrom string
replaceTo string
replaceRE *regexp.Regexp
offsetLeft string
offsetRight string
offsetExpr string
offsetRE *regexp.Regexp
}
// recognitionWordsRuntime caches the parsed rule set (with pre-compiled
// regexes) per repository. Reading five settings rows and parsing/recompiling
// the whole shared word list used to happen on every query candidate, which is
// the scraper's hottest path. Writes through SaveConfig/SyncShared invalidate
// the entry immediately; the TTL only bounds staleness for out-of-process edits.
type recognitionWordsRuntimeEntry struct {
repo *repository.Container
cfg RecognitionWordsConfig
rules []recognitionWordRule
expiresAt time.Time
}
const recognitionWordsRuntimeTTL = 30 * time.Second
var recognitionWordsRuntimeCache struct {
sync.RWMutex
entry recognitionWordsRuntimeEntry
valid bool
}
func recognitionWordsRuntime(ctx context.Context, repo *repository.Container) (RecognitionWordsConfig, []recognitionWordRule) {
now := time.Now()
recognitionWordsRuntimeCache.RLock()
cached := recognitionWordsRuntimeCache.entry
hit := recognitionWordsRuntimeCache.valid && cached.repo == repo && now.Before(cached.expiresAt)
recognitionWordsRuntimeCache.RUnlock()
if hit {
return cached.cfg, cached.rules
}
cfg := recognitionWordsConfig(ctx, repo)
rules := parseRecognitionWordRules(recognitionWordsCombinedText(cfg))
recognitionWordsRuntimeCache.Lock()
recognitionWordsRuntimeCache.entry = recognitionWordsRuntimeEntry{
repo: repo,
cfg: cfg,
rules: rules,
expiresAt: now.Add(recognitionWordsRuntimeTTL),
}
recognitionWordsRuntimeCache.valid = true
recognitionWordsRuntimeCache.Unlock()
return cfg, rules
}
func invalidateRecognitionWordsRuntime(repo *repository.Container) {
recognitionWordsRuntimeCache.Lock()
if recognitionWordsRuntimeCache.entry.repo == repo {
recognitionWordsRuntimeCache.valid = false
}
recognitionWordsRuntimeCache.Unlock()
}
func cloneRecognitionWordsConfig(cfg RecognitionWordsConfig) RecognitionWordsConfig {
cfg.SharedURLs = append([]string(nil), cfg.SharedURLs...)
return cfg
}
@@ -0,0 +1,49 @@
package service
import (
"testing"
"go.uber.org/zap"
)
// The rule set is cached process-wide, so a config write must invalidate it
// immediately; otherwise the admin would have to wait out the TTL before a
// saved word list takes effect.
func TestRecognitionWordsCacheInvalidatedBySaveConfig(t *testing.T) {
repos := newOrganizerTestRepo(t)
svc := NewRecognitionWordsService(zap.NewNop(), repos)
if err := svc.SaveConfig(t.Context(), RecognitionWordsConfig{
Enabled: true,
LocalText: "BADWORD => 好标题",
}); err != nil {
t.Fatal(err)
}
if got := ApplyRecognitionWords(t.Context(), repos, "BADWORD"); got != "好标题" {
t.Fatalf("first apply = %q, want 好标题", got)
}
if err := svc.SaveConfig(t.Context(), RecognitionWordsConfig{
Enabled: true,
LocalText: "BADWORD => 新标题",
}); err != nil {
t.Fatal(err)
}
if got := ApplyRecognitionWords(t.Context(), repos, "BADWORD"); got != "新标题" {
t.Fatalf("cached rules were not invalidated after SaveConfig: got %q, want 新标题", got)
}
}
func TestRecognitionWordsDisabledReturnsRawInput(t *testing.T) {
repos := newOrganizerTestRepo(t)
svc := NewRecognitionWordsService(zap.NewNop(), repos)
if err := svc.SaveConfig(t.Context(), RecognitionWordsConfig{
Enabled: false,
LocalText: "BADWORD => 好标题",
}); err != nil {
t.Fatal(err)
}
if got := ApplyRecognitionWords(t.Context(), repos, "BADWORD"); got != "BADWORD" {
t.Fatalf("disabled recognition words changed input: got %q", got)
}
}
+59 -21
View File
@@ -31,6 +31,11 @@ func parseRecognitionWordRule(line string) recognitionWordRule {
case strings.Contains(part, "<>") && strings.Contains(part, ">>"):
beforeAfter := strings.SplitN(part, ">>", 2)
bounds := strings.SplitN(beforeAfter[0], "<>", 2)
// A malformed rule without "<>" would otherwise index past the
// slice; word lists are fetched from the network, so stay defensive.
if len(bounds) < 2 {
continue
}
rule.offsetLeft = strings.TrimSpace(bounds[0])
rule.offsetRight = strings.TrimSpace(bounds[1])
rule.offsetExpr = strings.TrimSpace(beforeAfter[1])
@@ -38,56 +43,89 @@ func parseRecognitionWordRule(line string) recognitionWordRule {
rule.block = part
}
}
compileRecognitionWordRule(&rule)
return rule
}
var recognitionReplacementRE = regexp.MustCompile(`\\([0-9]+)`)
func normalizeRecognitionReplacement(value string) string {
re := regexp.MustCompile(`\\([0-9]+)`)
return re.ReplaceAllString(value, "$$$1")
return recognitionReplacementRE.ReplaceAllString(value, "$$$1")
}
// compileRecognitionWordRule pre-compiles every pattern in a rule so the hot
// clean-query path never recompiles regexes per candidate.
func compileRecognitionWordRule(rule *recognitionWordRule) {
if rule == nil {
return
}
if rule.block != "" {
if re, err := regexp.Compile(rule.block); err == nil {
rule.blockRE = re
}
}
if rule.replaceFrom != "" {
if re, err := regexp.Compile(rule.replaceFrom); err == nil {
rule.replaceRE = re
}
}
if rule.offsetExpr != "" && (rule.offsetLeft != "" || rule.offsetRight != "") {
if re, err := compileRecognitionOffsetRE(rule.offsetLeft, rule.offsetRight); err == nil {
rule.offsetRE = re
}
}
}
func compileRecognitionOffsetRE(left, right string) (*regexp.Regexp, error) {
leftPattern := firstNonEmpty(left, `^`)
rightPattern := firstNonEmpty(right, `$`)
return regexp.Compile(`(?i)(` + leftPattern + `)(\d{1,5})(` + rightPattern + `)`)
}
func applyRecognitionWordRules(raw string, rules []recognitionWordRule) string {
out := strings.TrimSpace(raw)
for _, rule := range rules {
if rule.block != "" {
out = applyRecognitionBlock(out, rule.block)
out = applyRecognitionBlock(out, rule)
}
if rule.replaceFrom != "" {
out = applyRecognitionReplace(out, rule.replaceFrom, rule.replaceTo)
out = applyRecognitionReplace(out, rule)
}
if rule.offsetLeft != "" || rule.offsetRight != "" {
out = applyRecognitionOffset(out, rule.offsetLeft, rule.offsetRight, rule.offsetExpr)
out = applyRecognitionOffset(out, rule)
}
}
return strings.Join(strings.Fields(out), " ")
}
func applyRecognitionBlock(raw, block string) string {
if re, err := regexp.Compile(block); err == nil {
return re.ReplaceAllString(raw, " ")
func applyRecognitionBlock(raw string, rule recognitionWordRule) string {
if rule.blockRE != nil {
return rule.blockRE.ReplaceAllString(raw, " ")
}
return strings.ReplaceAll(raw, block, " ")
return strings.ReplaceAll(raw, rule.block, " ")
}
func applyRecognitionReplace(raw, from, to string) string {
if re, err := regexp.Compile(from); err == nil {
return re.ReplaceAllString(raw, to)
func applyRecognitionReplace(raw string, rule recognitionWordRule) string {
if rule.replaceRE != nil {
return rule.replaceRE.ReplaceAllString(raw, rule.replaceTo)
}
return strings.ReplaceAll(raw, from, to)
return strings.ReplaceAll(raw, rule.replaceFrom, rule.replaceTo)
}
func applyRecognitionOffset(raw, left, right, expr string) string {
if strings.TrimSpace(expr) == "" {
func applyRecognitionOffset(raw string, rule recognitionWordRule) string {
if strings.TrimSpace(rule.offsetExpr) == "" {
return raw
}
leftPattern := firstNonEmpty(left, `^`)
rightPattern := firstNonEmpty(right, `$`)
re, err := regexp.Compile(`(?i)(` + leftPattern + `)(\d{1,5})(` + rightPattern + `)`)
if err != nil {
return raw
re := rule.offsetRE
if re == nil {
compiled, err := compileRecognitionOffsetRE(rule.offsetLeft, rule.offsetRight)
if err != nil {
return raw
}
re = compiled
}
return re.ReplaceAllStringFunc(raw, func(match string) string {
return applyRecognitionOffsetMatch(re, match, expr)
return applyRecognitionOffsetMatch(re, match, rule.offsetExpr)
})
}
+5 -1
View File
@@ -93,7 +93,11 @@ func (s *ScannerService) readLocalScanMetadata(lib *model.Library, root *model.L
if root != nil && strings.TrimSpace(root.Path) != "" {
rootPath = root.Path
}
localMeta, err := ReadLocalMetadata(path, rootPath, librarySupportsSeasons(lib) || parsedSeason > 0 || parsedEpisode > 0)
seriesLike := librarySupportsSeasons(lib) || parsedSeason > 0 || parsedEpisode > 0
if mediaLooksLikeTheatricalFeature(&model.Media{Path: path}) {
seriesLike = false
}
localMeta, err := ReadLocalMetadata(path, rootPath, seriesLike)
if err != nil {
s.log.Warn("read local metadata failed", zap.String("path", path), zap.Error(err))
}
@@ -69,6 +69,49 @@ func TestScanLibraryUsesLocalMetadata(t *testing.T) {
}
}
func TestScanAnimeTheatricalFolderUsesMovieNFO(t *testing.T) {
root := t.TempDir()
showDir := filepath.Join(root, "摇曳露营△ (2018)")
movieDir := filepath.Join(showDir, "摇曳露营△ 剧场版 (2022)")
if err := os.MkdirAll(movieDir, 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(showDir, "tvshow.nfo"), []byte(
`<tvshow><title>错误的剧集标题</title><tmdbid>76075</tmdbid><year>2018</year></tvshow>`,
), 0o644); err != nil {
t.Fatal(err)
}
mediaPath := filepath.Join(movieDir, "Eiga.Yurukyan.2022.Bluray.mkv")
if err := os.WriteFile(mediaPath, []byte("x"), 0o644); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(nfoPath(mediaPath), []byte(
`<movie><title>摇曳露营△ 剧场版</title><tmdbid>566466</tmdbid><year>2022</year></movie>`,
), 0o644); err != nil {
t.Fatal(err)
}
db := newServiceTestDB(t, &model.Library{}, &model.Media{}, &model.Setting{})
repos := repository.New(db)
lib := model.Library{Name: "动漫", Path: root, Type: "anime", Enabled: true}
if err := repos.Library.Create(t.Context(), &lib); err != nil {
t.Fatal(err)
}
scanner := NewScannerService(&config.Config{}, zap.NewNop(), repos, NewHub(zap.NewNop()), nil, nil)
if _, err := scanner.ScanLibrary(t.Context(), lib.ID); err != nil {
t.Fatal(err)
}
var media model.Media
if err := db.First(&media, "path = ?", mediaPath).Error; err != nil {
t.Fatal(err)
}
if media.Title != "摇曳露营△ 剧场版" || media.TMDbID != 566466 || media.Year != 2022 {
t.Fatalf("theatrical movie metadata = title %q tmdb %d year %d", media.Title, media.TMDbID, media.Year)
}
}
func TestScanLibraryDoesNotMarkArtworkOnlyAsMatched(t *testing.T) {
root := t.TempDir()
mediaPath := filepath.Join(root, "SSIS-001-CD1.mp4")
+38 -4
View File
@@ -63,12 +63,17 @@ func (s *ScraperService) EnrichOneWithOptions(ctx context.Context, m *model.Medi
}
}
// Same stale-negative problem as the library path: a single-item retry
// (queue task / manual rescrape) must get a fresh provider round-trip.
if options.RetryNoMatch && s != nil {
s.lookupCache.clearNegatives()
}
candidates := scrapeQueryCandidatesWithRecognition(ctx, s.repo, m, lib)
var query string
match := (*Match)(nil)
for _, candidate := range candidates {
query = candidate
candidateMatch := s.lookup(ctx, lib, m, candidate, year)
candidateMatch := s.lookup(ctx, lib, m, candidate, year, options.ForceRematch)
if candidateMatch == nil {
continue
}
@@ -93,7 +98,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 +128,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 +165,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 +236,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
}
@@ -0,0 +1,313 @@
package service
import (
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"go.uber.org/zap"
"github.com/truewhile/MeBox/internal/config"
"github.com/truewhile/MeBox/internal/model"
)
func TestTheatricalTitleVariants(t *testing.T) {
tests := []struct {
input string
want []string
}{
{
input: "名侦探柯南 剧场版01 引爆摩天楼",
want: []string{"名侦探柯南 引爆摩天楼", "名侦探柯南 剧场版01 引爆摩天楼"},
},
{
input: "名侦探柯南 剧场版26 黑铁的鱼影",
want: []string{"名侦探柯南 黑铁的鱼影", "名侦探柯南 剧场版26 黑铁的鱼影"},
},
{
input: "鬼灭之刃 剧场版 无限列车篇",
want: []string{"鬼灭之刃 无限列车篇", "鬼灭之刃 剧场版 无限列车篇"},
},
{
input: "航海王 The Movie 黄金之城",
want: []string{"航海王 The Movie 黄金之城"},
},
{
input: "普通动漫 第01集",
want: []string{"普通动漫 第01集"},
},
}
for _, tc := range tests {
got := theatricalTitleVariants(tc.input)
if len(got) != len(tc.want) {
t.Fatalf("theatricalTitleVariants(%q) = %v, want %v", tc.input, got, tc.want)
}
for i := range got {
if got[i] != tc.want[i] {
t.Fatalf("theatricalTitleVariants(%q)[%d] = %q, want %q", tc.input, i, got[i], tc.want[i])
}
}
}
}
func TestMetadataMatchCompatibilityForTheatricalFeatures(t *testing.T) {
animeMatch := &Match{MediaType: "anime"}
if metadataMatchCompatibleWithType("movie", animeMatch) {
t.Fatal("ordinary movie lookups must not accept anime matches")
}
if !metadataMatchCompatibleWithTheatrical("movie", true, animeMatch) {
t.Fatal("theatrical movie lookup should accept an anime provider match")
}
if metadataMatchCompatibleWithTheatrical("movie", false, animeMatch) {
t.Fatal("non-theatrical movie lookup must not accept an anime provider match")
}
}
func TestMediaLooksLikeTheatricalFeatureIgnoresStaleEpisodeIdentity(t *testing.T) {
media := &model.Media{
Title: "未命名",
Path: `/media/anime/超人高校生们/剧场版/超人高校生们 剧场版.mkv`,
SeasonNum: 1,
EpisodeNum: 1,
}
if !mediaLooksLikeTheatricalFeature(media) {
t.Fatal("theatrical path should override stale persisted season/episode fields")
}
namedFolder := &model.Media{
Title: "Eiga Yurukyan 2022 Bluray",
Path: `/media/anime/摇曳露营△ (2018)/摇曳露营△ 剧场版 (2022)/Eiga.Yurukyan.2022.Bluray.mkv`,
}
if !mediaLooksLikeTheatricalFeature(namedFolder) {
t.Fatal("a named theatrical folder with a year should be detected from the full path")
}
realEpisode := &model.Media{
Title: "剧场版制作幕后",
Path: `/media/anime/超人高校生们/Season 01/超人高校生们.S01E01.mkv`,
SeasonNum: 1,
EpisodeNum: 1,
}
if mediaLooksLikeTheatricalFeature(realEpisode) {
t.Fatal("an actual episode marker in the path must remain episodic")
}
}
func TestScrapeQueryCandidatesForAnimeTheatricalMix(t *testing.T) {
lib := &model.Library{
Path: `/media/anime`,
Type: "anime",
}
// 1. 同目录下包含剧场版01
theatricalMedia := &model.Media{
Title: "名侦探柯南 剧场版01 引爆摩天楼",
Path: `/media/anime/名侦探柯南/名侦探柯南 剧场版01 引爆摩天楼.mkv`,
}
candidates := scrapeQueryCandidates(theatricalMedia, lib)
if len(candidates) == 0 {
t.Fatal("scrapeQueryCandidates returned no candidates for theatrical media")
}
if candidates[0] != "名侦探柯南 引爆摩天楼" {
t.Fatalf("first candidate for theatrical media = %q, want cleaned movie title '名侦探柯南 引爆摩天楼'; all=%v", candidates[0], candidates)
}
// 2. 剧场版放在以剧场版命名的子目录
subfolderTheatricalMedia := &model.Media{
Title: "无限列车篇",
Path: `/media/anime/鬼灭之刃/剧场版/无限列车篇.mkv`,
}
subCandidates := scrapeQueryCandidates(subfolderTheatricalMedia, lib)
if len(subCandidates) == 0 {
t.Fatal("scrapeQueryCandidates returned no candidates for subfolder theatrical media")
}
foundCombined := false
for _, c := range subCandidates {
if c == "鬼灭之刃 无限列车篇" {
foundCombined = true
break
}
}
if !foundCombined {
t.Fatalf("candidates for subfolder theatrical did not contain '鬼灭之刃 无限列车篇': %v", subCandidates)
}
// 3. 混合放置的普通剧集分集不应受剧场版影响
episodeMedia := &model.Media{
Title: "名侦探柯南 - S01E01",
Path: `/media/anime/名侦探柯南/名侦探柯南 - S01E01.mp4`,
SeasonNum: 1,
EpisodeNum: 1,
}
epCandidates := scrapeQueryCandidates(episodeMedia, lib)
if len(epCandidates) == 0 {
t.Fatal("scrapeQueryCandidates returned no candidates for episode media")
}
if epCandidates[0] != "名侦探柯南" {
t.Fatalf("first candidate for tv episode = %q, want series folder title '名侦探柯南'", epCandidates[0])
}
}
func TestEnrichOneAnimeMixedEpisodesAndTheatrical(t *testing.T) {
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
switch r.URL.Path {
case "/search/tv":
q := r.URL.Query().Get("query")
if q == "鬼灭之刃" {
_ = json.NewEncoder(w).Encode(map[string]any{
"results": []map[string]any{{
"id": 85937,
"name": "鬼灭之刃",
"original_name": "鬼滅の刃",
"overview": "大正时期、日本...",
"first_air_date": "2019-04-06",
}},
})
return
}
_ = json.NewEncoder(w).Encode(map[string]any{"results": []any{}})
case "/search/movie":
q := r.URL.Query().Get("query")
if q == "鬼灭之刃 无限列车篇" || q == "鬼灭之刃 剧场版 无限列车篇" {
_ = json.NewEncoder(w).Encode(map[string]any{
"results": []map[string]any{{
"id": 635302,
"title": "鬼灭之刃 剧场版 无限列车篇",
"original_title": "劇場版「鬼滅の刃」無限列車編",
"overview": "在结束了蝴蝶屋的修业之后...",
"release_date": "2020-10-16",
}},
})
return
}
_ = json.NewEncoder(w).Encode(map[string]any{"results": []any{}})
case "/tv/85937":
_ = json.NewEncoder(w).Encode(map[string]any{
"id": 85937,
"name": "鬼灭之刃",
"original_name": "鬼滅の刃",
"first_air_date": "2019-04-06",
})
case "/movie/635302":
_ = json.NewEncoder(w).Encode(map[string]any{
"id": 635302,
"title": "鬼灭之刃 剧场版 无限列车篇",
"original_title": "劇場版「鬼滅の刃」無限列車編",
"release_date": "2020-10-16",
})
default:
http.NotFound(w, r)
}
}))
defer upstream.Close()
repos := newOrganizerTestRepo(t)
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))
lib := model.Library{Name: "动漫", Path: `/media/anime`, Type: "anime", Enabled: true}
if err := repos.DB.Create(&lib).Error; err != nil {
t.Fatal(err)
}
// 1. 同一目录下的 TV 剧集
tvEpisode := model.Media{
LibraryID: lib.ID,
Title: "鬼灭之刃 S01E01",
Path: `/media/anime/鬼灭之刃/Season 01/鬼灭之刃 - S01E01.mkv`,
SeasonNum: 1,
EpisodeNum: 1,
ScrapeStatus: "pending",
}
if err := repos.DB.Create(&tvEpisode).Error; err != nil {
t.Fatal(err)
}
// 2. 同一目录下的剧场版文件
movieMedia := model.Media{
LibraryID: lib.ID,
Title: "鬼灭之刃 剧场版 无限列车篇",
Path: `/media/anime/鬼灭之刃/鬼灭之刃 剧场版 无限列车篇.mkv`,
ScrapeStatus: "pending",
}
if err := repos.DB.Create(&movieMedia).Error; err != nil {
t.Fatal(err)
}
// 刮削剧集
if err := scraper.EnrichOne(t.Context(), &tvEpisode); err != nil {
t.Fatalf("enrich tv episode: %v", err)
}
gotTV, err := repos.Media.FindByID(t.Context(), tvEpisode.ID)
if err != nil || gotTV == nil {
t.Fatalf("load tv episode: %v", err)
}
if gotTV.ScrapeStatus != "matched" || gotTV.TMDbID != 85937 {
t.Fatalf("tv episode scrape mismatch: status=%s, tmdb_id=%d", gotTV.ScrapeStatus, gotTV.TMDbID)
}
// 刮削剧场版
if err := scraper.EnrichOne(t.Context(), &movieMedia); err != nil {
t.Fatalf("enrich movie: %v", err)
}
gotMovie, err := repos.Media.FindByID(t.Context(), movieMedia.ID)
if err != nil || gotMovie == nil {
t.Fatalf("load movie: %v", err)
}
if gotMovie.ScrapeStatus != "matched" || gotMovie.TMDbID != 635302 {
t.Fatalf("theatrical movie scrape mismatch: status=%s, tmdb_id=%d, want 635302", gotMovie.ScrapeStatus, gotMovie.TMDbID)
}
if gotMovie.Title != "鬼灭之刃 剧场版 无限列车篇" {
t.Fatalf("theatrical movie title = %q, want '鬼灭之刃 剧场版 无限列车篇'", gotMovie.Title)
}
}
func TestClassifyAnimeTheatricalMovie(t *testing.T) {
categories := map[string]string{
"animation_movie": "动画电影",
"jp_anime": "日番",
"chinese_movie": "华语电影",
}
tests := []struct {
title string
mediaType string
want string
}{
{
title: "名侦探柯南 剧场版01 引爆摩天楼",
mediaType: "movie",
want: "动画电影",
},
{
title: "鬼灭之刃 剧场版 无限列车篇",
mediaType: "movie",
want: "动画电影",
},
{
title: "鬼灭之刃 S01E01",
mediaType: "tv",
want: "日番",
},
}
for _, tc := range tests {
input := mediaClassifyInput{
MediaType: tc.mediaType,
Title: tc.title,
Languages: []string{"JA"},
Countries: []string{"JP"},
Genres: []string{"Animation"},
}
got := classifyMediaCategory(input, categories)
if got != tc.want {
t.Fatalf("classifyMediaCategory(%q, %q) = %q, want %q", tc.title, tc.mediaType, got, tc.want)
}
}
}
+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()
@@ -8,6 +8,27 @@ import (
)
func (s *ScraperService) lookupAutomaticTMDb(ctx context.Context, kind, query string, year int) *Match {
if match := s.lookupAutomaticTMDbPrimary(ctx, kind, query, year); match != nil {
return match
}
fallbackKind := ""
normalized := normalizeOrganizeMediaType(kind)
if normalized == "anime" {
if isTVMetadataKind(kind) {
fallbackKind = "movie"
} else {
fallbackKind = "tv"
}
}
if fallbackKind != "" {
if fbMatch := s.lookupAutomaticTMDbPrimary(ctx, fallbackKind, query, year); fbMatch != nil {
return fbMatch
}
}
return nil
}
func (s *ScraperService) lookupAutomaticTMDbPrimary(ctx context.Context, kind, query string, year int) *Match {
var (
candidates []*Match
err error
@@ -175,8 +196,10 @@ func metadataMatchCompatibleWithType(expectedType string, match *Match) bool {
return true
}
switch expectedType {
case "tv", "anime", "variety":
case "tv", "variety":
return matchType == "tv" || matchType == "anime" || matchType == "variety"
case "anime":
return matchType == "anime" || matchType == "tv" || matchType == "movie" || matchType == "variety"
case "movie", "adult":
return matchType == "movie" || matchType == "adult"
default:
@@ -184,6 +207,16 @@ func metadataMatchCompatibleWithType(expectedType string, match *Match) bool {
}
}
func metadataMatchCompatibleWithTheatrical(expectedType string, isTheatrical bool, match *Match) bool {
if isTheatrical &&
normalizeOrganizeMediaType(expectedType) == "movie" &&
match != nil &&
normalizeOrganizeMediaType(match.MediaType) == "anime" {
return true
}
return metadataMatchCompatibleWithType(expectedType, match)
}
func queryNeedsEnglishTMDbFallback(query string) bool {
for _, r := range query {
if (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') {
+44 -4
View File
@@ -13,7 +13,12 @@ import (
// lookup runs the provider chain after local NFO has been considered:
// TMDb -> Douban -> Bangumi -> TheTVDB. Douban and Bangumi do not require API
// keys; providers that are unavailable or return an error are skipped.
func (s *ScraperService) lookup(ctx context.Context, lib *model.Library, media *model.Media, query string, year int) *Match {
//
// Results are cached per (kind, theatrical, query, year) because every
// episode of a show produces the same candidate; bypassCache forces a fresh
// provider round-trip for user-triggered rematches but still refreshes the
// cache so later episodes reuse the corrected result.
func (s *ScraperService) lookup(ctx context.Context, lib *model.Library, media *model.Media, query string, year int, bypassCache bool) *Match {
kind := ""
if lib != nil {
kind = lib.Type
@@ -22,23 +27,50 @@ func (s *ScraperService) lookup(ctx context.Context, lib *model.Library, media *
// was previously polluted into S01E20x. Do not let the dirty path override
// that caller-supplied media type; TV/anime libraries still force TV below.
explicitEpisode := media != nil && (media.SeasonNum > 0 || media.EpisodeNum > 0)
if (normalizeOrganizeMediaType(kind) != "movie" || explicitEpisode) && mediaIsEpisodic(media, lib) {
isTheatrical := mediaLooksLikeTheatricalFeature(media)
if isTheatrical {
kind = "movie"
} else if (normalizeOrganizeMediaType(kind) != "movie" || explicitEpisode) && mediaIsEpisodic(media, lib) {
kind = "tv"
}
cacheKey := scrapeLookupCacheKey(kind, query, year, isTheatrical)
if !bypassCache {
if cached, ok := s.lookupCache.get(cacheKey); ok {
return cached
}
}
match := s.lookupUncached(ctx, kind, isTheatrical, query, year)
// Always refresh the cache, even on bypassed (forced) lookups, so a
// corrected rematch replaces the stale entry for later episodes.
s.lookupCache.set(cacheKey, match)
return match
}
func (s *ScraperService) lookupUncached(ctx context.Context, kind string, isTheatrical bool, query string, year int) *Match {
if s.tmdb != nil && s.tmdb.Enabled() {
if match := s.lookupAutomaticTMDb(ctx, kind, query, year); match != nil {
match.Provider = "tmdb"
return match
}
if isTheatrical {
if match := s.lookupAutomaticTMDb(ctx, "tv", 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) {
if m, err := s.douban.SearchMatch(ctx, query); err == nil && m != nil && metadataMatchCompatibleWithTheatrical(kind, isTheatrical, m) {
m.Provider = "douban"
return m
} else if err != nil {
s.log.Debug("douban search failed", zap.String("query", query), zap.Error(err))
}
}
if s.bangumi != nil && s.bangumi.Enabled() {
if m, err := s.bangumi.Search(ctx, query); err == nil && m != nil && metadataMatchCompatibleWithType(kind, m) {
if m, err := s.bangumi.Search(ctx, query); err == nil && m != nil && metadataMatchCompatibleWithTheatrical(kind, isTheatrical, 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 +78,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))
@@ -94,6 +127,13 @@ func (s *ScraperService) EnrichLibraryDetailed(ctx context.Context, libraryID st
func (s *ScraperService) EnrichLibraryDetailedWithOptions(ctx context.Context, libraryID string, options ScrapeOptions) (EnrichLibraryResult, error) {
result := EnrichLibraryResult{LibraryID: libraryID}
// A manual retry must not reuse stale negative cache entries from the
// previous run, otherwise "重新刮削" looks like it did nothing. Positives
// are kept so the rest of the season still dedupes; the first re-queried
// episode refreshes the entry for the rows behind it.
if options.RetryNoMatch && s != nil {
s.lookupCache.clearNegatives()
}
rows, err := s.scrapeCandidateRows(ctx, libraryID, options)
if err != nil {
return result, 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
}
+126
View File
@@ -0,0 +1,126 @@
package service
import (
"strconv"
"strings"
"sync"
"time"
)
// A TV library scrapes one media row per episode, and every row in the same
// show computes the same query candidate (the series folder title). Without a
// cache each episode re-issues the identical TMDb/Douban/Bangumi/TheTVDB
// search, so a 100-episode season costs 100x the network calls it needs.
//
// The cache lives on the ScraperService instance (not a package global) so that
// two services with different provider configuration never share results. TTL
// bounds staleness for out-of-band edits; explicit invalidation is unnecessary
// because the key includes the effective media kind, theatrical flag, query
// and year. Negative entries use a shorter TTL so a failed query is retried
// sooner without manual intervention.
const (
scrapeLookupCacheTTL = 10 * time.Minute
scrapeLookupNegativeCacheTTL = 2 * time.Minute
scrapeLookupCacheMaxItems = 1024
)
type scrapeLookupCache struct {
mu sync.Mutex
entries map[string]scrapeLookupCacheEntry
}
type scrapeLookupCacheEntry struct {
match *Match
expiresAt time.Time
}
func newScrapeLookupCache() *scrapeLookupCache {
return &scrapeLookupCache{entries: map[string]scrapeLookupCacheEntry{}}
}
func scrapeLookupCacheKey(kind, query string, year int, isTheatrical bool) string {
theatrical := "0"
if isTheatrical {
theatrical = "1"
}
return strings.ToLower(strings.TrimSpace(kind)) + "|" +
theatrical + "|" +
strconv.Itoa(year) + "|" +
strings.ToLower(strings.TrimSpace(query))
}
// get reports whether the key is cached. A hit may carry a nil match, which
// means the provider chain already ran and found nothing (negative cache).
// Use clearNegative before a user-triggered retry so stale negative entries
// do not suppress the fresh provider round-trip.
func (c *scrapeLookupCache) get(key string) (*Match, bool) {
if c == nil || key == "" {
return nil, false
}
now := time.Now()
c.mu.Lock()
defer c.mu.Unlock()
item, ok := c.entries[key]
if !ok {
return nil, false
}
if now.After(item.expiresAt) {
delete(c.entries, key)
return nil, false
}
return cloneMatch(item.match), true
}
func (c *scrapeLookupCache) set(key string, match *Match) {
if c == nil || key == "" {
return
}
now := time.Now()
c.mu.Lock()
defer c.mu.Unlock()
if len(c.entries) >= scrapeLookupCacheMaxItems {
for k, item := range c.entries {
if now.After(item.expiresAt) || len(c.entries) >= scrapeLookupCacheMaxItems {
delete(c.entries, k)
}
if len(c.entries) < scrapeLookupCacheMaxItems {
break
}
}
}
ttl := scrapeLookupCacheTTL
if match == nil {
ttl = scrapeLookupNegativeCacheTTL
}
c.entries[key] = scrapeLookupCacheEntry{match: cloneMatch(match), expiresAt: now.Add(ttl)}
}
// clearNegatives drops cached misses so a user-triggered retry gets a fresh
// provider round-trip instead of reusing a stale negative entry.
func (c *scrapeLookupCache) clearNegatives() {
if c == nil {
return
}
c.mu.Lock()
defer c.mu.Unlock()
for k, item := range c.entries {
if item.match == nil {
delete(c.entries, k)
}
}
}
// cloneMatch returns an independent copy so callers that mutate the result
// (localized title preference, local metadata merge, fanart artwork) cannot
// corrupt the cached entry or leak state between media rows.
func cloneMatch(m *Match) *Match {
if m == nil {
return nil
}
out := *m
out.Languages = append([]string(nil), m.Languages...)
out.Countries = append([]string(nil), m.Countries...)
out.Genres = append([]string(nil), m.Genres...)
out.Aliases = append([]string(nil), m.Aliases...)
return &out
}
@@ -0,0 +1,165 @@
package service
import (
"encoding/json"
"net/http"
"net/http/httptest"
"sync/atomic"
"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"
)
// Every episode of a show produces the same query candidate (the series folder
// title). Without the lookup cache each episode re-issues the identical search,
// so a whole season costs one request per episode. This asserts the provider is
// hit once and subsequent episodes reuse the cached match.
func TestEnrichLibraryReusesLookupCacheAcrossEpisodes(t *testing.T) {
var searchCalls int32
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
switch r.URL.Path {
case "/search/tv":
if r.URL.Query().Get("query") != "折腰" {
_ = json.NewEncoder(w).Encode(map[string]any{"results": []any{}})
return
}
atomic.AddInt32(&searchCalls, 1)
_ = json.NewEncoder(w).Encode(map[string]any{
"results": []map[string]any{{
"id": 296753,
"name": "折腰",
"overview": "正确的剧集条目",
"poster_path": "/zheyao.jpg",
"first_air_date": "2025-05-13",
"origin_country": []string{"CN"},
}},
})
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
cfg.Secrets.TMDbImageProxy = upstream.URL + "/images"
log := zap.NewNop()
scraper := NewScraperService(cfg, log, repos, NewTMDbProvider(cfg, log, nil), nil, nil, nil, NewHub(log))
lib := model.Library{Name: "OpenList · 刮削缓存测试库", Path: "cloud://openlist/scrape-lookup-cache", Type: "tv", Enabled: true}
if err := repos.DB.Create(&lib).Error; err != nil {
t.Fatal(err)
}
const episodeCount = 4
for episode := 1; episode <= episodeCount; episode++ {
media := model.Media{
LibraryID: lib.ID,
Title: "折腰",
Path: "cloud://openlist/scrape-lookup-cache/折腰 (2025)/Season 1/折腰.S01E0" + string(rune('0'+episode)) + ".mkv",
SeasonNum: 1,
EpisodeNum: episode,
ScrapeStatus: "pending",
}
if err := repos.DB.Create(&media).Error; err != nil {
t.Fatal(err)
}
}
result, err := scraper.EnrichLibraryDetailedWithOptions(t.Context(), lib.ID, ScrapeOptions{})
if err != nil {
t.Fatal(err)
}
if result.Matched != episodeCount {
t.Fatalf("matched = %d, want %d", result.Matched, episodeCount)
}
if calls := atomic.LoadInt32(&searchCalls); calls != 1 {
t.Fatalf("tmdb /search/tv called %d times for %d episodes; want 1 (cache should dedupe identical queries)", calls, episodeCount)
}
}
// A cached match must be handed out as an independent copy: mutating one row's
// result (localized title preference, local metadata merge) must not bleed into
// the next row.
func TestScrapeLookupCacheReturnsIndependentCopies(t *testing.T) {
cache := newScrapeLookupCache()
original := &Match{
Title: "折腰",
TMDbID: 296753,
Genres: []string{"剧情"},
Countries: []string{"CN"},
Aliases: []string{"Zhe Yao"},
}
key := scrapeLookupCacheKey("tv", "折腰", 2025, false)
cache.set(key, original)
first, ok := cache.get(key)
if !ok || first == nil {
t.Fatal("expected a cache hit")
}
first.Title = "mutated"
first.Genres[0] = "mutated"
first.Aliases = append(first.Aliases, "extra")
second, ok := cache.get(key)
if !ok || second == nil {
t.Fatal("expected a second cache hit")
}
if second.Title != "折腰" || second.Genres[0] != "剧情" || len(second.Aliases) != 1 {
t.Fatalf("cached match was mutated through a returned copy: %+v", second)
}
}
// Negative results are cached too: a query that matched nothing must not be
// re-issued for every remaining episode of the same show.
func TestScrapeLookupCacheStoresNegativeResults(t *testing.T) {
cache := newScrapeLookupCache()
key := scrapeLookupCacheKey("tv", "no-such-show", 0, false)
cache.set(key, nil)
if _, ok := cache.get(key); !ok {
t.Fatal("negative result should be cached to avoid repeated provider calls")
}
}
// The theatrical flag is part of the key: a theatrical feature gets a tv
// fallback lookup, so its result must not be shared with (or returned for)
// the same query issued for a non-theatrical media row.
func TestScrapeLookupCacheKeySeparatesTheatrical(t *testing.T) {
plain := scrapeLookupCacheKey("movie", "query", 2024, false)
theatrical := scrapeLookupCacheKey("movie", "query", 2024, true)
if plain == theatrical {
t.Fatalf("theatrical flag must be part of the cache key: %q", plain)
}
}
// A manual "retry no match" run clears stale negative entries so the provider
// chain is actually re-queried instead of short-circuiting on the cached miss.
func TestScrapeLookupCacheClearNegativesKeepsPositives(t *testing.T) {
cache := newScrapeLookupCache()
negKey := scrapeLookupCacheKey("tv", "missing", 0, false)
posKey := scrapeLookupCacheKey("tv", "found", 0, false)
cache.set(negKey, nil)
cache.set(posKey, &Match{Title: "found"})
cache.clearNegatives()
if _, ok := cache.get(negKey); ok {
t.Fatal("negative entry should be cleared before a manual retry")
}
if got, ok := cache.get(posKey); !ok || got == nil || got.Title != "found" {
t.Fatalf("positive entry should survive clearNegatives: %+v", got)
}
}
+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
}
}
+85 -20
View File
@@ -15,8 +15,42 @@ var (
episodeTitleQueryRE = regexp.MustCompile(`^\s*第\s*[0-9一二三四五六七八九十百零两]+\s*[集期话話](?:\s*[上下])?\s*[::].+`)
genericEpisodeWordsRE = regexp.MustCompile(`^\s*第\s*[集期话話]\s*$`)
episodeReleaseTitleTagRE = regexp.MustCompile(`(?i)(?:^|[\s._-])s\d{1,2}e\d{1,3}(?:[\s._-]|$)`)
patTheatricalTitle = regexp.MustCompile(`(?i)(?:剧场版|劇場版|动画电影|動畫電影|电影版|電影版|\bthe\s+movie\b|\bmovie\s*\d{1,2}\b)`)
patTheatricalFolder = regexp.MustCompile(`(?i)[\\/][^\\/]*(?:剧场版|劇場版|動畫電影|动画电影|电影版|電影版)[^\\/]*[\\/]`)
theatricalNoiseRE = regexp.MustCompile(`(?i)(?:剧场版|劇場版)\s*(?:第?\s*\d{1,3}\s*[部篇]?)?|电影版|電影版|动画电影|動畫電影`)
)
func theatricalTitleVariants(raw string) []string {
raw = strings.TrimSpace(raw)
if raw == "" {
return nil
}
if !theatricalNoiseRE.MatchString(raw) {
return []string{raw}
}
stripped := theatricalNoiseRE.ReplaceAllString(raw, " ")
stripped = strings.Join(strings.Fields(stripped), " ")
if stripped != "" && !strings.EqualFold(stripped, raw) {
return []string{stripped, raw}
}
return []string{raw}
}
func mediaLooksLikeTheatricalFeature(m *model.Media) bool {
if m == nil {
return false
}
// Trust an episode marker that is actually present in the path, but do not
// trust persisted season/episode fields here. Older scans could incorrectly
// assign those fields to a theatrical file, which would permanently prevent
// both manual re-scraping and separation from the TV series.
if season, ep := ParseEpisode(m.Path); season > 0 || ep > 0 {
return false
}
text := m.Title + " " + pathBaseSlash(m.Path)
return patTheatricalTitle.MatchString(text) || pathHasTheatricalFolder(m.Path)
}
func scrapeQueryCandidates(m *model.Media, lib *model.Library) []string {
return scrapeQueryCandidatesWithNormalizer(m, lib, func(raw string) (string, int) {
return CleanQuery(raw)
@@ -33,31 +67,59 @@ func scrapeQueryCandidatesWithNormalizer(m *model.Media, lib *model.Library, cle
seen := map[string]struct{}{}
var out []string
add := func(raw string) {
cleaned, _ := clean(raw)
if cleaned == "" {
cleaned = strings.TrimSpace(raw)
}
for _, candidate := range titleCandidates(cleaned) {
if unsafeAutomaticEpisodeQuery(candidate) {
continue
for _, variant := range theatricalTitleVariants(raw) {
cleaned, _ := clean(variant)
if cleaned == "" {
cleaned = strings.TrimSpace(variant)
}
key := strings.ToLower(candidate)
if _, ok := seen[key]; ok || candidate == "" {
continue
for _, candidate := range titleCandidates(cleaned) {
if unsafeAutomaticEpisodeQuery(candidate) {
continue
}
key := strings.ToLower(candidate)
if _, ok := seen[key]; ok || candidate == "" {
continue
}
seen[key] = struct{}{}
out = append(out, candidate)
}
seen[key] = struct{}{}
out = append(out, candidate)
}
}
episodic := mediaIsEpisodic(m, lib)
if lib != nil && episodic {
add(seriesFolderTitle(m.Path, lib.Path))
isTheatrical := mediaLooksLikeTheatricalFeature(m)
if isTheatrical {
add(m.Title)
add(m.Path)
if lib != nil {
seriesTitle := seriesFolderTitle(m.Path, lib.Path)
if seriesTitle != "" {
baseName := pathBaseSlash(m.Path)
stem := mediaFileStem(baseName)
if stem == "" {
stem = strings.TrimSuffix(baseName, filepath.Ext(baseName))
}
cleanStem, _ := clean(stem)
if cleanStem == "" {
cleanStem = stem
}
if !strings.Contains(strings.ToLower(cleanStem), strings.ToLower(seriesTitle)) {
add(seriesTitle + " " + cleanStem)
}
}
add(mediaFolderTitle(m.Path, lib.Path))
}
} else {
episodic := mediaIsEpisodic(m, lib)
if lib != nil && episodic {
add(seriesFolderTitle(m.Path, lib.Path))
}
if lib != nil {
add(mediaFolderTitle(m.Path, lib.Path))
}
add(m.Title)
add(m.Path)
}
if lib != nil {
add(mediaFolderTitle(m.Path, lib.Path))
}
add(m.Title)
add(m.Path)
if len(out) == 0 {
base := pathBaseSlash(m.Path)
out = append(out, strings.TrimSuffix(base, filepath.Ext(base)))
@@ -106,6 +168,9 @@ func containsCJK(s string) bool {
}
func mediaIsEpisodic(m *model.Media, lib *model.Library) bool {
if m != nil && mediaLooksLikeTheatricalFeature(m) {
return false
}
if m != nil && (m.SeasonNum > 0 || m.EpisodeNum > 0) {
return true
}
+43 -5
View File
@@ -24,11 +24,15 @@ var noiseTokens = []string{
"hkfree", "yify", "rarbg", "ettv", "fgt", "tgx", "ctrlhd", "ntb", "flux", "qhstudio",
// 流媒体平台 / 字幕组 / 国家版本(动漫常见)
"netflix", "nf", "amzn", "hulu", "disney", "max", "hbo",
// 注意:不要把同时是常见英文单词的标记放进来(如 max / web / judas),
// 否则 "Mad Max"、"Web Therapy" 这类正常标题会被误删。裸 "WEB" 发布标记
// 通常位于分辨率之后,由 releaseBoundary 截断规则处理;"WEB-DL" 则由
// multiWordNoise 单独匹配。
"netflix", "nf", "amzn", "hulu", "disney", "hbo",
"linetv", "ourtv", "iqiyi", "youku", "bilibili", "qiyi", "krj",
"atvp", "appletv", "apple-tv", "tx", "txweb",
"crunchyroll", "funimation", "anidb", "horriblesubs", "subsplease",
"erai-raws", "judas", "asw", "smcat", "leopard-raws", "ohys-raws", "colortv",
"erai-raws", "asw", "smcat", "leopard-raws", "ohys-raws", "colortv",
"mweb", "ubweb", "hhweb", "adweb", "chdweb", "kurosawa", "qhstudio",
// 中文字幕标记
@@ -55,6 +59,20 @@ var releaseBoundaryTokenSet = map[string]struct{}{
"x264": {}, "x265": {}, "h264": {}, "h265": {}, "h266": {}, "hevc": {}, "avc": {}, "av1": {}, "vvc": {},
}
// weakReleaseBoundaryTokenSet are release tags that double as plausible title
// words. Before any release signal has been seen they are kept as part of the
// title ("Mad Max", "Web Therapy"), so a title is never truncated to nothing;
// after a real signal they behave like any other tag.
var weakReleaseBoundaryTokenSet = map[string]struct{}{
"bd": {}, "dvd": {}, "web": {},
}
// releaseSignalToken marks the position of an extracted year. The year itself
// is not a title token, but its presence still proves the following tokens are
// release tags ("复仇者联盟4.2019.BD.1080p" must drop "BD"). A control rune is
// used so it can never collide with a real filename token.
const releaseSignalToken = "\u0001"
var dynamicReleaseBoundaryTokenRE = regexp.MustCompile(`(?i)^(?:\d{3,4}p|\d{2,3}fps)$`)
// bracketedTag matches "[anything]", "(anything)" or "{anything}" segments.
@@ -86,7 +104,9 @@ func CleanQuery(raw string) (title string, year int) {
if m := yearPattern.FindStringSubmatch(lower); len(m) >= 2 {
if v, err := strconv.Atoi(m[1]); err == nil {
year = v
lower = strings.ReplaceAll(lower, m[1], " ")
// Keep a positional marker: the year is not a title token, but its
// presence arms release-tag truncation for what follows.
lower = strings.ReplaceAll(lower, m[1], " "+releaseSignalToken+" ")
}
}
@@ -110,17 +130,35 @@ func CleanQuery(raw string) (title string, year int) {
}
// 拆分后丢掉过短(≤1)且全为 ASCII 数字 / 字母的"碎片",避免
// 「2」「0」「v」之类残留干扰 TMDb 搜索。中文字符不算碎片。
//
// seenReleaseBoundary 只有在遇到分辨率/编码等强标记后才置位,置位后其余
// ASCII 词一律视为发布尾巴丢弃;releaseSignalled 则宽松得多,年份标记也会
// 置位,它只用来武装弱标记(bd/dvd/web)。这样 "Web Therapy" 不会被截空,
// 而 "2019.Avatar.1080p" 这种年份前置的标题也不会因为年份标记把后面的
// 真实标题词当成尾巴丢掉。
out := make([]string, 0, 8)
seenReleaseBoundary := false
releaseSignalled := false
for _, w := range strings.Fields(lower) {
if w == releaseSignalToken {
releaseSignalled = true
continue
}
if dynamicReleaseBoundaryTokenRE.MatchString(w) {
releaseSignalled = true
seenReleaseBoundary = true
continue
}
if _, ok := noiseTokenSet[w]; ok {
if _, boundary := releaseBoundaryTokenSet[w]; boundary {
if _, boundary := releaseBoundaryTokenSet[w]; boundary {
if _, weak := weakReleaseBoundaryTokenSet[w]; weak && !releaseSignalled {
// Ambiguous tag that is also a plausible title word; keep it
// until a real release signal proves we are past the title.
} else {
releaseSignalled = true
seenReleaseBoundary = true
continue
}
} else if _, ok := noiseTokenSet[w]; ok {
continue
}
if seenReleaseBoundary && isASCIIWord(w) {
@@ -0,0 +1,77 @@
package service
import "testing"
// CleanQuery must not delete words that are also ordinary English title words
// just because a release group reused them as a tag. Regression guard: "max"
// and "web" used to be unconditional noise/boundary tokens, which turned
// "Mad Max" into "mad" and "Web Therapy" into an empty query.
func TestCleanQueryKeepsCommonEnglishTitleWords(t *testing.T) {
cases := []struct {
in string
wantTitle string
wantYear int
}{
{"Mad.Max.1979.1080p.BluRay.mkv", "mad max", 1979},
{"Max.Payne.2008.1080p.WEB-DL.mkv", "max payne", 2008},
{"Web.Therapy.S01E01.1080p.WEB-DL.mkv", "web therapy", 0},
{"The.Web.2019.1080p.mkv", "the web", 2019},
}
for _, tc := range cases {
t.Run(tc.in, func(t *testing.T) {
gotTitle, gotYear := CleanQuery(tc.in)
if gotTitle != tc.wantTitle || gotYear != tc.wantYear {
t.Errorf("CleanQuery(%q) = (%q, %d), want (%q, %d)",
tc.in, gotTitle, gotYear, tc.wantTitle, tc.wantYear)
}
})
}
}
// A title consisting only of an ambiguous tag plus a release tail must not be
// truncated to nothing; the tag stays so the query still has a chance.
func TestCleanQueryDoesNotTruncateAmbiguousTagToNothing(t *testing.T) {
for _, in := range []string{"Web.1080p.WEB-DL.mkv", "BD.720p.mkv"} {
title, _ := CleanQuery(in)
if title == "" {
t.Errorf("CleanQuery(%q) returned an empty title", in)
}
}
}
// A year-prefixed filename must keep its title: the year arms weak-tag
// truncation but must not discard the real title words that follow it.
func TestCleanQueryKeepsTitleAfterYearPrefix(t *testing.T) {
cases := map[string]string{
"2019.Avatar.1080p.BluRay.mkv": "avatar",
"2024.Dune.Part.Two.2160p.WEB.mkv": "dune part two",
}
for in, want := range cases {
t.Run(in, func(t *testing.T) {
got, year := CleanQuery(in)
if got != want {
t.Errorf("CleanQuery(%q) = (%q, %d), want title %q", in, got, year, want)
}
})
}
}
// Release-tag truncation must still fire once a real release signal (an
// extracted year, resolution or codec) has been seen, so tags after it are
// dropped as before.
func TestCleanQueryStillDropsTagsAfterReleaseSignal(t *testing.T) {
cases := map[string]string{
"复仇者联盟4.2019.BD.1080p.mkv": "复仇者联盟4",
"The.Matrix.1999.1080p.WEB-DL.H265.mp4": "the matrix",
"Oppenheimer.2023.2160p.UHD.BluRay.mkv": "oppenheimer",
"Interstellar.2014.4k.hdr.dts.atmos.mkv": "interstellar",
}
for in, want := range cases {
t.Run(in, func(t *testing.T) {
got, _ := CleanQuery(in)
if got != want {
t.Errorf("CleanQuery(%q) = %q, want %q", in, got, want)
}
})
}
}
+36 -4
View File
@@ -1,6 +1,11 @@
package service
import "strings"
import (
"regexp"
"strings"
)
var namedTheatricalFolderRE = regexp.MustCompile(`(?i)(?:剧场版|劇場版|动画电影|動畫電影|电影版|電影版)`)
func mediaFolderTitle(mediaPath, libraryRoot string) string {
dir := parentSlashPath(mediaPath)
@@ -13,7 +18,7 @@ func mediaFolderTitle(mediaPath, libraryRoot string) string {
if base == "" || base == "." {
return ""
}
if isTechnicalMediaFolder(base) || strictSeasonFolderMatched(base) {
if isTechnicalMediaFolder(base) || strictSeasonFolderMatched(base) || isTheatricalFolder(base) {
dir = parentSlashPath(dir)
continue
}
@@ -104,7 +109,7 @@ func parentSlashPath(value string) string {
func seriesFolderTitle(mediaPath, libraryRoot string) string {
dir := parentSlashPath(mediaPath)
if strictSeasonFolderMatched(pathBaseSlash(dir)) {
if strictSeasonFolderMatched(pathBaseSlash(dir)) || isTheatricalFolder(pathBaseSlash(dir)) {
dir = parentSlashPath(dir)
}
if root := comparableLibraryRoot(libraryRoot); root != "" && sameSlashPath(dir, root) {
@@ -114,12 +119,39 @@ func seriesFolderTitle(mediaPath, libraryRoot string) string {
if base == "" || base == "." {
return ""
}
if isGenericMediaCategoryFolder(base) || isTechnicalMediaFolder(base) || strictSeasonFolderMatched(base) {
if isGenericMediaCategoryFolder(base) || isTechnicalMediaFolder(base) || strictSeasonFolderMatched(base) || isTheatricalFolder(base) {
return ""
}
return base
}
func isTheatricalFolder(name string) bool {
key := strings.ToLower(strings.TrimSpace(name))
key = strings.Trim(key, `\/`)
if namedTheatricalFolderRE.MatchString(key) {
return true
}
switch key {
case "剧场版", "劇場版", "动画电影", "動畫電影", "特别篇", "特別篇",
"special", "specials", "sp", "ova", "ovas", "oad", "oads", "ovd", "ovds", "ona", "onas",
"extra", "extras", "bonus", "bonuses", "omake", "picture drama", "ncop", "nced", "画像特典":
return true
default:
return false
}
}
func pathHasTheatricalFolder(path string) bool {
for _, part := range strings.FieldsFunc(path, func(r rune) bool {
return r == '/' || r == '\\'
}) {
if isTheatricalFolder(part) && namedTheatricalFolderRE.MatchString(part) {
return true
}
}
return false
}
func libraryRootTitle(libraryRoot string) string {
base := ""
if info, ok := ParseCloudLibraryMount(libraryRoot); ok {
+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
+10
View File
@@ -1,6 +1,7 @@
package service
import (
"sync"
"time"
"go.uber.org/zap"
@@ -24,6 +25,14 @@ type ScraperService struct {
hub *Hub
cache *RuntimeCacheService
images *ImageProxy
// Caches provider-chain results keyed by media kind/query/year. Per-instance
// so services with different provider config never share results.
lookupCache *scrapeLookupCache
// 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.
@@ -45,6 +54,7 @@ func NewScraperService(
return &ScraperService{
cfg: cfg, log: log, repo: repo,
tmdb: tmdb, bangumi: bangumi, thetvdb: thetvdb, fanart: fanart, adult: adultProvider, hub: hub,
lookupCache: newScrapeLookupCache(),
}
}
@@ -44,38 +44,91 @@ func (s *ScraperService) writeMediaArtworkFilesAfterScrape(ctx context.Context,
if base == "" || base == "." {
return
}
isAdult := IsAdultMediaPathOrMetadata(refreshed.Path, refreshed.LibraryID, refreshed.NSFW) || IsAdultArtworkURL(refreshed.PosterURL)
isAdult := shouldCropAdultPoster(refreshed, lib)
artworkUpdates := map[string]any{}
shouldPersistLocalArtworkURL := func(raw string) bool {
return isAdult || !isHTTPish(raw)
}
if refreshed.PosterURL != "" {
s.downloadArtworkToPathWithOptions(ctx, dir, base+"-poster", refreshed.PosterURL, isAdult)
if dst := s.downloadArtworkToPathWithOptions(ctx, dir, base+"-poster", refreshed.PosterURL, isAdult); dst != "" {
if shouldPersistLocalArtworkURL(refreshed.PosterURL) {
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 != "" {
if shouldPersistLocalArtworkURL(refreshed.BackdropURL) {
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 +136,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 +163,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 +191,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)
}
}
+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{})
+55 -10
View File
@@ -95,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
@@ -158,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{},
}
}
@@ -409,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
}
@@ -426,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 {
@@ -464,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
@@ -482,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 设置(含默认值)。
+217 -54
View File
@@ -46,25 +46,26 @@ type strmSyncState struct {
rec *model.StrmSyncRecord
syncType string
mu sync.Mutex
processed int // 已处理文件计数(用于定期落库进度)
lastProgressFlush time.Time // 上次进度落库时间
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
mu sync.Mutex
processed int // 已处理文件计数(用于定期落库进度)
lastProgressFlush time.Time // 上次进度落库时间
seenVideo map[string]bool // "v:"+strm 去扩展名相对路径 → 远端存在该视频(供 prune)
seenDir map[string]bool // 清洗后的目录相对路径 → 远端存在该目录(供整目录 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
remoteVideos map[string][]remoteVideoCandidate
activeDownloadPaths map[string]bool // 本地已在排队/进行的下载任务路径(内存去重)
activeUploadPaths map[string]bool // 本地已在排队/进行的上传任务路径(内存去重)
// 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
dirCache sync.Map // dirID (string) -> relativePath (string)
dirPathToID map[string]string // relativePath (string) -> dirID(115 上传父目录寻址用,walk 后构建)
dirCacheDirty map[string]string // 待批量落库的目录缓存(dirID → 相对路径),避免逐目录单条 upsert
scanIncomplete atomic.Bool // 远端目录树/文件列表本次扫描不完整 → 禁止增量 prune 误删本地文件
}
@@ -281,6 +282,7 @@ func (s *StrmService) runSync(ctx context.Context, p *model.StrmSyncPath, rec *m
rec: rec,
syncType: rec.SyncType,
seenVideo: map[string]bool{},
seenDir: map[string]bool{"": true},
seenMeta: map[string]bool{},
remoteMeta: map[string][]remoteMetaItem{},
seenMetaTarget: map[string]cloud.FileEntry{},
@@ -535,36 +537,38 @@ func (st *strmSyncState) walkRemote() error {
if task.rel != "" {
rel = task.rel + "/" + cleanName
}
if entry.IsDir {
st.dirCache.Store(entry.ID, rel)
st.deferDirCacheSave(entry.ID, rel)
push(dirTask{id: entry.ID, rel: rel})
} else {
st.processRemoteFile(entry, rel)
}
if entry.IsDir {
st.markSeenDir(rel)
st.dirCache.Store(entry.ID, rel)
st.deferDirCacheSave(entry.ID, rel)
push(dirTask{id: entry.ID, rel: rel})
} else {
st.processRemoteFile(entry, rel)
}
walkMu.Lock()
pending--
if pending == 0 {
walkCond.Broadcast()
}
walkMu.Unlock()
}
}); err != nil {
cancel()
walkMu.Lock()
pending--
if pending == 0 {
walkCond.Broadcast()
}
walkMu.Unlock()
}
}()
}
wg.Wait()
st.flushDirCacheSave()
if firstErr != nil {
return firstErr
}
}); err != nil {
cancel()
}
}()
}
wg.Wait()
st.flushDirCacheSave()
if firstErr != nil {
return firstErr
}
return ctx.Err()
}
// processRemoteFile 分类处理远端文件:视频生成 STRM,元数据入下载队列。
func (st *strmSyncState) processRemoteFile(entry cloud.FileEntry, rel string) {
st.markSeenDir(filepath.ToSlash(filepath.Dir(rel)))
fileName := entry.Name
if st.isExcluded(fileName) {
return
@@ -698,16 +702,16 @@ func (st *strmSyncState) walk115Flat(open115 *cloud115.OpenClient) error {
if pathCounts[item.Path] > 1 {
continue
}
st.dirCache.Store(item.DirID, cleanDirRel(item.Path))
}
st.dirCache.Store(item.DirID, cleanDirRel(item.Path))
}
}
}
// 2. 自适应分治拉取文件列表(单目录超 9500 时自动对子目录并发分治扁平化)
allFiles, err := st.fetch115FilesAdaptive(ctx, open115, rootCID)
if err != nil {
return err
}
// 2. 自适应分治拉取文件列表(单目录超 9500 时自动对子目录并发分治扁平化)
allFiles, err := st.fetch115FilesAdaptive(ctx, open115, rootCID)
if err != nil {
return err
}
if ctx.Err() != nil {
return ctx.Err()
@@ -1031,10 +1035,10 @@ func list115DirDirect(ctx context.Context, open115 *cloud115.OpenClient, cid str
// fetch115FilesAdaptive 采用自适应分治策略抓取 115 目录树下的全部文件:
// 115 开放平台扁平搜索对 offset+limit 有 10000 的最大深度限制。
// - 若子树文件总数 < 9500,直接使用全速扁平分页批量拉取;
// - 若子树文件总数 >= 9500(大库或超大分类目录),自动分治:仅单层列出该目录的直属子项(cur=1),
// 直属纯文件直接收集,直属子目录则派发为独立的子树任务继续递归探测与拉取;
// - 若超大单目录下无子目录或层级过深(>10层),安全回退到 errFallbackToWalkRemote。
// - 若子树文件总数 < 9500,直接使用全速扁平分页批量拉取;
// - 若子树文件总数 >= 9500(大库或超大分类目录),自动分治:仅单层列出该目录的直属子项(cur=1),
// 直属纯文件直接收集,直属子目录则派发为独立的子树任务继续递归探测与拉取;
// - 若超大单目录下无子目录或层级过深(>10层),安全回退到 errFallbackToWalkRemote。
func (st *strmSyncState) fetch115FilesAdaptive(ctx context.Context, open115 *cloud115.OpenClient, rootCID string) ([]cloud115.RemoteFile, error) {
var (
allFiles []cloud115.RemoteFile
@@ -1637,6 +1641,10 @@ func (st *strmSyncState) walkLocalSource() error {
default:
}
if d.IsDir() {
rel, relErr := filepath.Rel(srcRoot, path)
if relErr == nil {
st.markSeenDir(filepath.ToSlash(rel))
}
return nil
}
rel, err := filepath.Rel(srcRoot, path)
@@ -1898,8 +1906,104 @@ func (st *strmSyncState) taskExists(kind, syncPathID, localPath string) bool {
return count > 0
}
// pruneLocal 清理本地多余 .strm(远端已不存在的视频),可选删除空目录。
// 元数据文件(nfo/图片/字幕等)一律保留:本地刮削结果不因网盘端缺失而被删除。
// markSeenDir 记录远端存在的目录及其全部祖先目录。
func (st *strmSyncState) markSeenDir(rel string) {
rel = strings.Trim(filepath.ToSlash(rel), "/")
if rel == "." {
rel = ""
}
st.mu.Lock()
if st.seenDir == nil {
st.seenDir = map[string]bool{"": true}
}
for {
st.seenDir[rel] = true
if rel == "" {
break
}
if idx := strings.LastIndexByte(rel, '/'); idx >= 0 {
rel = rel[:idx]
} else {
rel = ""
}
}
st.mu.Unlock()
}
// refreshUnseen115Dirs 补查本地存在、但 115 扁平文件列表未覆盖的目录。
// 扁平接口不返回空目录;逐层补查这些候选目录可以避免把远端仍存在的空目录误删。
func (st *strmSyncState) refreshUnseen115Dirs(localRoot string, dirs []string) error {
if st.p.Provider != model.StrmProvider115 || st.provider == nil {
return nil
}
dirIDs := map[string]string{"": strings.TrimSpace(st.p.RemotePath)}
if dirIDs[""] == "" {
dirIDs[""] = "0"
}
st.dirCache.Range(func(key, value any) bool {
id, idOK := key.(string)
rel, relOK := value.(string)
if idOK && relOK && id != "" {
dirIDs[cleanDirRel(rel)] = id
}
return true
})
liveIDs := map[string]string{"": dirIDs[""]}
listed := map[string]bool{}
sort.Strings(dirs)
for _, dir := range dirs {
rel, err := filepath.Rel(localRoot, dir)
if err != nil {
continue
}
rel = cleanDirRel(filepath.ToSlash(rel))
st.mu.Lock()
seen := st.seenDir[rel]
st.mu.Unlock()
if seen {
if id := dirIDs[rel]; id != "" {
liveIDs[rel] = id
}
continue
}
parentRel := ""
if idx := strings.LastIndexByte(rel, '/'); idx >= 0 {
parentRel = rel[:idx]
}
parentID := liveIDs[parentRel]
if parentID == "" {
continue // 父目录已确认不存在,子目录也必然是本地孤儿
}
if listed[parentRel] {
continue
}
entries, err := st.provider.List(st.ctx, parentID)
if err != nil {
return fmt.Errorf("核对 115 远端目录 %s 失败:%w", parentRel, err)
}
listed[parentRel] = true
for _, entry := range entries {
if !entry.IsDir {
continue
}
childRel := cleanEntryName(entry.Name, true)
if parentRel != "" {
childRel = parentRel + "/" + childRel
}
st.markSeenDir(childRel)
liveIDs[childRel] = entry.ID
dirIDs[childRel] = entry.ID
}
}
return nil
}
// pruneLocal 清理本地远端已不存在的内容:
// - 整个目录在远端不存在时,递归删除该本地目录(包括元数据);
// - 目录仍存在但视频已删除时,仅删除对应的 .strm,保留本地元数据;
// - DeleteDir 开启时,最后再清理其余空目录。
func (st *strmSyncState) pruneLocal() error {
// 增量同步保护:本次远端扫描不完整(目录详情解析失败 / 文件父路径降级)时,
// seenVideo 覆盖不全,按"远端不存在"清理会误删刚下载或已存在的本地 .strm,
@@ -1911,6 +2015,7 @@ func (st *strmSyncState) pruneLocal() error {
}
localRoot := filepath.Clean(st.p.LocalPath)
var dirs []string
var strmFiles []string
err := filepath.WalkDir(localRoot, func(path string, d os.DirEntry, err error) error {
if err != nil {
return nil
@@ -1933,13 +2038,74 @@ func (st *strmSyncState) pruneLocal() error {
}
rel = filepath.ToSlash(rel)
ext := strings.ToLower(filepath.Ext(rel))
remove := false
if ext == ".strm" {
relSansExt := rel[:len(rel)-len(ext)]
strmFiles = append(strmFiles, path)
}
return nil
})
if err != nil {
return err
}
if err := st.refreshUnseen115Dirs(localRoot, dirs); err != nil {
return err
}
// 先从浅到深找出最上层孤儿目录;父目录已判定为孤儿时无需重复处理子目录。
sort.Strings(dirs)
orphanRoots := make([]string, 0)
for _, dir := range dirs {
rel, relErr := filepath.Rel(localRoot, dir)
if relErr != nil {
continue
}
rel = filepath.ToSlash(rel)
st.mu.Lock()
existsRemotely := st.seenDir[rel]
st.mu.Unlock()
if existsRemotely {
continue
}
underOrphan := false
for _, root := range orphanRoots {
childRel, childErr := filepath.Rel(root, dir)
if childErr == nil && childRel != ".." && !strings.HasPrefix(childRel, ".."+string(filepath.Separator)) {
underOrphan = true
break
}
}
if !underOrphan {
orphanRoots = append(orphanRoots, dir)
}
}
for _, dir := range orphanRoots {
var fileCount int64
_ = filepath.WalkDir(dir, func(_ string, d os.DirEntry, walkErr error) error {
if walkErr == nil && !d.IsDir() {
fileCount++
}
return nil
})
if err := os.RemoveAll(dir); err == nil {
st.mu.Lock()
remove = !st.seenVideo["v:"+relSansExt]
st.rec.Pruned += fileCount
st.mu.Unlock()
}
}
// 对仍存在于远端的目录,按原规则清理失去远端视频来源的单个 .strm。
for _, path := range strmFiles {
if _, err := os.Stat(path); err != nil {
continue // 已随孤儿目录递归删除
}
rel, relErr := filepath.Rel(localRoot, path)
if relErr != nil {
continue
}
rel = filepath.ToSlash(rel)
relSansExt := strings.TrimSuffix(rel, filepath.Ext(rel))
st.mu.Lock()
remove := !st.seenVideo["v:"+relSansExt]
st.mu.Unlock()
if remove {
if err := os.Remove(path); err == nil {
st.mu.Lock()
@@ -1947,11 +2113,8 @@ func (st *strmSyncState) pruneLocal() error {
st.mu.Unlock()
}
}
return nil
})
if err != nil {
return err
}
if st.cfg.DeleteDir {
sort.Sort(sort.Reverse(sort.StringSlice(dirs)))
for _, dir := range dirs {
+39 -10
View File
@@ -489,14 +489,12 @@ func taskNames(tasks []model.StrmUploadTask) []string {
return names
}
// TestPruneLocalKeepsLocalMeta 验证清理规则:远端已删除的视频 .strm 仍会被清理,
// 但本地元数据一律保留(即使开启"下载元数据"且未开启"上传元数据"、网盘端没有
// 该元数据,也不再删除本地刮削好的 nfo/图片/字幕)。
func TestPruneLocalKeepsLocalMeta(t *testing.T) {
// TestPruneLocalRemovesOrphanDirectory 验证远端目录不存在时,会递归删除整个本地
// 目录,包括其中的 strm、NFO、图片等文件。
func TestPruneLocalRemovesOrphanDirectory(t *testing.T) {
svc := testStrmService(t)
localDir := t.TempDir()
// 阿凡达.strm(对应视频已被网盘删除 → 应清理)+ 阿凡达.nfo(网盘没有 → 保留)
writeFile(t, filepath.Join(localDir, "电影", "阿凡达.strm"), "http://test.local:8096/x")
writeFile(t, filepath.Join(localDir, "电影", "阿凡达.nfo"), "<local scraped meta/>")
writeFile(t, filepath.Join(localDir, "电影", "poster.jpg"), "local-poster")
@@ -518,10 +516,40 @@ func TestPruneLocalKeepsLocalMeta(t *testing.T) {
rec: &model.StrmSyncRecord{},
syncType: model.StrmSyncTypeFull,
seenVideo: map[string]bool{},
seenDir: map[string]bool{"": true},
seenMeta: map[string]bool{},
remoteMeta: map[string][]remoteMetaItem{},
}
// 本次远端扫描既没有看到视频,也没有看到任何元数据
if err := st.pruneLocal(); err != nil {
t.Fatalf("pruneLocal failed: %v", err)
}
if _, err := os.Stat(filepath.Join(localDir, "电影")); !os.IsNotExist(err) {
t.Fatalf("orphan directory should be removed recursively, stat err = %v", err)
}
if st.rec.Pruned != 3 {
t.Fatalf("expected 3 pruned files, got %d", st.rec.Pruned)
}
}
// TestPruneLocalKeepsMetaInExistingDirectory 验证目录仍在远端时,只清理失去
// 视频来源的 strm,不删除本地刮削元数据。
func TestPruneLocalKeepsMetaInExistingDirectory(t *testing.T) {
svc := testStrmService(t)
localDir := t.TempDir()
writeFile(t, filepath.Join(localDir, "电影", "阿凡达.strm"), "http://test.local:8096/x")
writeFile(t, filepath.Join(localDir, "电影", "阿凡达.nfo"), "<local scraped meta/>")
st := &strmSyncState{
s: svc,
ctx: context.Background(),
p: &model.StrmSyncPath{Base: model.Base{ID: "prune-existing-dir"}, LocalPath: localDir},
cfg: &strmPathConfig{},
rec: &model.StrmSyncRecord{},
syncType: model.StrmSyncTypeFull,
seenVideo: map[string]bool{},
seenDir: map[string]bool{"": true, "电影": true},
}
if err := st.pruneLocal(); err != nil {
t.Fatalf("pruneLocal failed: %v", err)
}
@@ -530,10 +558,7 @@ func TestPruneLocalKeepsLocalMeta(t *testing.T) {
t.Fatalf("orphan .strm should be pruned, stat err = %v", err)
}
if _, err := os.Stat(filepath.Join(localDir, "电影", "阿凡达.nfo")); err != nil {
t.Fatalf("local meta must be kept even when missing on remote: %v", err)
}
if _, err := os.Stat(filepath.Join(localDir, "电影", "poster.jpg")); err != nil {
t.Fatalf("local poster must be kept even when missing on remote: %v", err)
t.Fatalf("local meta in an existing remote directory must be kept: %v", err)
}
if st.rec.Pruned != 1 {
t.Fatalf("expected 1 pruned (strm only), got %d", st.rec.Pruned)
@@ -800,6 +825,7 @@ func TestWalkRemoteConcurrent(t *testing.T) {
if err := st.walkRemote(); err != nil {
t.Fatalf("walkRemote failed: %v", err)
}
st.flushPreferredVideos()
for _, dir := range []string{"root", "a", "a1", "b"} {
if provider.listed[dir] != 1 {
@@ -1088,6 +1114,7 @@ func TestWalk115FlatConcurrentProcessing(t *testing.T) {
if err := st.walk115Flat(oc); err != nil {
t.Fatalf("walk115Flat: %v", err)
}
st.flushPreferredVideos()
// 3 个视频的 strm 全部生成
if st.rec.NewStrm != 3 {
@@ -1143,6 +1170,7 @@ func TestWalk115FlatConcurrentProcessing(t *testing.T) {
if err := st2.walk115Flat(oc); err != nil {
t.Fatalf("incremental walk115Flat: %v", err)
}
st2.flushPreferredVideos()
if infoCalls != 0 {
t.Fatalf("incremental sync should reuse dir cache, got %d get_info calls", infoCalls)
}
@@ -1436,6 +1464,7 @@ func TestWalk115AdaptiveHierarchicalFlatScan(t *testing.T) {
if err := st.walk115Flat(oc); err != nil {
t.Fatalf("walk115Flat adaptive failed: %v", err)
}
st.flushPreferredVideos()
// 1个根目录视频 + 2个Heyzo视频 + 1个S1视频 = 共4个视频成功生成 .strm
if st.rec.NewStrm != 4 {
+101 -21
View File
@@ -94,28 +94,28 @@ 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) {
tracks, err := s.discover(ctx, mediaID)
cacheKey := "external:" + mediaID
if tracks, ok := s.cachedDiscovery(cacheKey); ok {
return tracks, nil
}
tracks, err := s.discoverExternalUncached(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
s.rememberDiscovery(cacheKey, tracks)
return tracks, nil
}
func (s *SubtitleService) discover(ctx context.Context, mediaID string) ([]SubtitleTrack, error) {
if tracks, ok := s.cachedDiscovery(mediaID); ok {
cacheKey := "all:" + mediaID
if tracks, ok := s.cachedDiscovery(cacheKey); ok {
return tracks, nil
}
tracks, err := s.discoverUncached(ctx, mediaID)
if err != nil {
return nil, err
}
s.rememberDiscovery(mediaID, tracks)
s.rememberDiscovery(cacheKey, tracks)
return tracks, nil
}
@@ -159,6 +159,30 @@ func (s *SubtitleService) discoverUncached(ctx context.Context, mediaID string)
if m == nil {
return nil, errors.New("media not found")
}
tracks := discoverExternalSubtitleTracks(m)
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
}
func (s *SubtitleService) discoverExternalUncached(ctx context.Context, mediaID string) ([]SubtitleTrack, error) {
m, err := s.repo.Media.FindByID(ctx, mediaID)
if err != nil {
return nil, err
}
if m == nil {
return nil, errors.New("media not found")
}
return discoverExternalSubtitleTracks(m), nil
}
func discoverExternalSubtitleTracks(m *model.Media) []SubtitleTrack {
dir := filepath.Dir(m.Path)
bases := mediaSidecarBaseVariants(m.Path)
if len(bases) == 0 {
@@ -210,19 +234,11 @@ func (s *SubtitleService) discoverUncached(ctx context.Context, mediaID string)
Path: filepath.Join(c, e.Name()),
Codec: codec,
Source: "external",
Delivery: "webvtt",
Delivery: subtitleDeliveryForCodec(codec),
})
}
}
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
return tracks
}
type embeddedSubtitleProbe struct {
@@ -281,6 +297,18 @@ func (s *SubtitleService) discoverEmbedded(ctx context.Context, media *model.Med
return subtitleTracksFromProbe(probe), nil
}
// subtitleDeliveryForCodec selects the browser rendering path for a text
// subtitle codec. ASS/SSA keep their original bytes and are rendered by
// libass in the web player; other text formats are converted to WebVTT.
func subtitleDeliveryForCodec(codec string) string {
switch strings.ToLower(strings.TrimSpace(codec)) {
case "ass", "ssa":
return "ass"
default:
return "webvtt"
}
}
func subtitleTracksFromProbe(probe embeddedSubtitleProbe) []SubtitleTrack {
tracks := make([]SubtitleTrack, 0, len(probe.Streams))
for _, stream := range probe.Streams {
@@ -298,7 +326,7 @@ func subtitleTracksFromProbe(probe embeddedSubtitleProbe) []SubtitleTrack {
} else if stream.Disposition.Default != 0 {
label += "(默认)"
}
delivery := "webvtt"
delivery := subtitleDeliveryForCodec(codec)
if imageSubtitleCodecs[codec] {
delivery = "burn"
}
@@ -448,6 +476,9 @@ func (s *SubtitleService) ServeRaw(ctx context.Context, mediaID, sub string, w i
if err != nil || m == nil {
return errors.New("media not found")
}
if strings.HasPrefix(sub, "embedded:") {
return errors.New("embedded subtitle is not available in raw mode")
}
abs, err := filepath.Abs(sub)
if err != nil {
return err
@@ -456,6 +487,9 @@ func (s *SubtitleService) ServeRaw(ctx context.Context, mediaID, sub string, w i
if !pathWithin(abs, mediaDir) {
return fmt.Errorf("path escape")
}
if _, ok := extToCodec[strings.ToLower(filepath.Ext(abs))]; !ok {
return errors.New("unsupported subtitle format")
}
f, err := os.Open(abs) // #nosec G304 -- abs is constrained to the media file directory with pathWithin.
if err != nil {
return err
@@ -464,3 +498,49 @@ func (s *SubtitleService) ServeRaw(ctx context.Context, mediaID, sub string, w i
_, err = io.Copy(w, f)
return err
}
// ServeASS returns an ASS/SSA stream suitable for libass-wasm. External ASS
// files are sent unchanged; embedded ASS/SSA tracks are remuxed to ASS by
// ffmpeg. This keeps fonts, positioning and typesetting data available to the
// browser renderer instead of flattening the track through assToVTT first.
func (s *SubtitleService) ServeASS(ctx context.Context, mediaID, sub string, w io.Writer) error {
m, err := s.repo.Media.FindByID(ctx, mediaID)
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.serveEmbeddedASS(ctx, m, index, w)
}
switch strings.ToLower(filepath.Ext(sub)) {
case ".ass", ".ssa":
return s.ServeRaw(ctx, mediaID, sub, w)
default:
return errors.New("subtitle is not ASS/SSA")
}
}
func (s *SubtitleService) serveEmbeddedASS(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), "-c:s", "ass", "-f", "ass", "-")
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 ASS subtitle: %w: %s", err, strings.TrimSpace(stderr.String()))
}
return nil
}
+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)
}
+85 -1
View File
@@ -2,9 +2,12 @@ package service
import (
"bytes"
"context"
"encoding/json"
"errors"
"os"
"path/filepath"
"strings"
"testing"
"github.com/glebarez/sqlite"
@@ -47,6 +50,43 @@ func TestSubtitleDiscoverNoTracksReturnsEmptySlice(t *testing.T) {
}
}
func TestDiscoverExternalOnlyDoesNotResolveOrProbeCloudMedia(t *testing.T) {
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.Media{}); err != nil {
t.Fatal(err)
}
media := model.Media{
Title: "Cloud Media",
Path: "cloud://115/example/video.mkv",
STRMURL: "cloud://115/example/video.mkv",
}
if err := db.Create(&media).Error; err != nil {
t.Fatal(err)
}
svc := NewSubtitleService(&config.Config{}, zap.NewNop(), repository.New(db))
resolveCalls := 0
svc.SetStrmPlayTargetResolver(func(context.Context, string) (*StrmPlayResult, error) {
resolveCalls++
return nil, errors.New("resolver must not be called")
})
tracks, err := svc.DiscoverExternalOnly(t.Context(), media.ID)
if err != nil {
t.Fatal(err)
}
if resolveCalls != 0 {
t.Fatalf("STRM resolver called %d times, want 0", resolveCalls)
}
if len(tracks) != 0 {
t.Fatalf("len(tracks) = %d, want 0", len(tracks))
}
}
func TestEmbeddedSubtitleProbeClassifiesTextAndBitmapTracks(t *testing.T) {
var probe embeddedSubtitleProbe
raw := []byte(`{"streams":[
@@ -60,7 +100,7 @@ func TestEmbeddedSubtitleProbeClassifiesTextAndBitmapTracks(t *testing.T) {
if len(tracks) != 2 {
t.Fatalf("len(tracks) = %d, want 2", len(tracks))
}
if tracks[0].Delivery != "webvtt" || tracks[0].Path != "embedded:2" {
if tracks[0].Delivery != "ass" || tracks[0].Path != "embedded:2" {
t.Fatalf("text track = %#v", tracks[0])
}
if tracks[1].Delivery != "burn" || tracks[1].StreamIndex != 4 {
@@ -68,6 +108,18 @@ func TestEmbeddedSubtitleProbeClassifiesTextAndBitmapTracks(t *testing.T) {
}
}
func TestSubtitleDeliveryClassifiesASSForLibass(t *testing.T) {
if got := subtitleDeliveryForCodec("ass"); got != "ass" {
t.Fatalf("ass delivery = %q, want ass", got)
}
if got := subtitleDeliveryForCodec("ssa"); got != "ass" {
t.Fatalf("ssa delivery = %q, want ass", got)
}
if got := subtitleDeliveryForCodec("subrip"); got != "webvtt" {
t.Fatalf("subrip delivery = %q, want webvtt", got)
}
}
func TestNormaliseTimecode(t *testing.T) {
cases := map[string]string{
"0:00:01": "00:00:01",
@@ -84,6 +136,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 {
@@ -117,4 +188,17 @@ func TestSubtitleServeRawWritesSourceBytes(t *testing.T) {
if got := buf.String(); got != raw {
t.Fatalf("ServeRaw returned %q, want raw %q", got, raw)
}
buf.Reset()
if err := svc.ServeASS(t.Context(), media.ID, subPath, &buf); err != nil {
t.Fatal(err)
}
if got := buf.String(); got != raw {
t.Fatalf("ServeASS returned %q, want raw ASS %q", got, raw)
}
buf.Reset()
if err := svc.ServeRaw(t.Context(), media.ID, videoPath, &buf); err == nil {
t.Fatal("ServeRaw accepted a non-subtitle file")
}
}
+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"`
+45
View File
@@ -13,7 +13,9 @@
"danmu": "^0.20.0",
"framer-motion": "^12.38.0",
"hls.js": "^1.5.11",
"jassub": "^2.5.16",
"lucide-react": "^0.379.0",
"opencc-js": "^1.4.2",
"qrcode": "^1.5.4",
"react": "^18.3.1",
"react-dom": "^18.3.1",
@@ -1752,6 +1754,12 @@
"vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0"
}
},
"node_modules/abslink": {
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/abslink/-/abslink-1.2.2.tgz",
"integrity": "sha512-HCOItvzORPjMzddCmEpbH8+W1VM3yjH+CR7DLtxlxFKX/jz5twJbm7KLfblO7eKLnXINLah8Hk2ZAJqxXeSkOg==",
"license": "Apache-2.0"
},
"node_modules/acorn": {
"version": "8.16.0",
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz",
@@ -3107,6 +3115,18 @@
"dev": true,
"license": "ISC"
},
"node_modules/jassub": {
"version": "2.5.16",
"resolved": "https://registry.npmjs.org/jassub/-/jassub-2.5.16.tgz",
"integrity": "sha512-n4vjXfraf3o8k3/5TK6DhCYVcbQ/VqhVOgwqLd3l0xfMtsN4fpE/hPq2hPaGagBTOtJS9yHwRoRp3mmchQ9XVg==",
"license": "LGPL-2.1-or-later AND (FTL OR GPL-2.0-or-later) AND MIT AND MIT-Modern-Variant AND ISC AND NTP AND Zlib AND BSL-1.0",
"dependencies": {
"abslink": "^1.2.2",
"lfa-ponyfill": "^1.1.0",
"rvfc-polyfill": "^1.0.8",
"throughput": "^1.0.2"
}
},
"node_modules/jiti": {
"version": "1.21.7",
"resolved": "https://registry.npmjs.org/jiti/-/jiti-1.21.7.tgz",
@@ -3194,6 +3214,13 @@
"node": ">= 0.8.0"
}
},
"node_modules/lfa-ponyfill": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/lfa-ponyfill/-/lfa-ponyfill-1.1.1.tgz",
"integrity": "sha512-Had4tsGEHtIj58MRtf6SlubNDltxinxMSdxIn1GdwrWbye8TD+pZ+D6qKETnJ9+vQOfG9q/Ds4WUPskhyAMp5Q==",
"hasInstallScript": true,
"license": "MIT"
},
"node_modules/lilconfig": {
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.3.tgz",
@@ -3427,6 +3454,12 @@
"node": ">= 6"
}
},
"node_modules/opencc-js": {
"version": "1.4.2",
"resolved": "https://registry.npmjs.org/opencc-js/-/opencc-js-1.4.2.tgz",
"integrity": "sha512-EdcspgdROiCCZDM+jZR6Pa670xW+EW2QhctPBQ8gRjg6lrx4IHEl+2s+kt5izk/TkxVRgdWuFgaGkaEdIUfdaw==",
"license": "MIT AND Apache-2.0"
},
"node_modules/optionator": {
"version": "0.9.4",
"resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz",
@@ -4025,6 +4058,12 @@
"queue-microtask": "^1.2.2"
}
},
"node_modules/rvfc-polyfill": {
"version": "1.0.8",
"resolved": "https://registry.npmjs.org/rvfc-polyfill/-/rvfc-polyfill-1.0.8.tgz",
"integrity": "sha512-uA+0wwTkZ4OT8v45pfDfH+7Yq8mY6MvNngiF5Sq6VBgjJsvsfgt7Q18cyZqZjfAhW9rhkgXPX0cW0R9uw7yElA==",
"license": "GPL-3.0"
},
"node_modules/scheduler": {
"version": "0.23.2",
"resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz",
@@ -4212,6 +4251,12 @@
"node": ">=0.8"
}
},
"node_modules/throughput": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/throughput/-/throughput-1.0.2.tgz",
"integrity": "sha512-jvK1ZXuhsggjb3qYQjMiU/AVYYiTeqT5thWvYR2yuy2LGM84P5MSSyAinwHahGsdBYKR9m9HncVR/3f3nFKkxg==",
"license": "MIT"
},
"node_modules/tinyglobby": {
"version": "0.2.16",
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.16.tgz",
+2
View File
@@ -15,7 +15,9 @@
"danmu": "^0.20.0",
"framer-motion": "^12.38.0",
"hls.js": "^1.5.11",
"jassub": "^2.5.16",
"lucide-react": "^0.379.0",
"opencc-js": "^1.4.2",
"qrcode": "^1.5.4",
"react": "^18.3.1",
"react-dom": "^18.3.1",
+3
View File
@@ -2,12 +2,15 @@ import { api } from './client'
import type { User } from '../types'
export const profileAPI = {
get: () => api.get<User>('/me').then((r) => r.data),
update: (patch: {
username?: string
nickname?: string
email?: string
avatar_url?: string
hide_adult?: boolean
subtitle_chinese_mode?: 'original' | 'simplified' | 'traditional'
password?: string
}) =>
api.patch<User>('/me', patch).then((r) => r.data),
+12 -3
View File
@@ -8,14 +8,16 @@ export interface SubtitleTrack {
url: string
codec: string
source: 'external' | 'embedded'
delivery: 'webvtt' | 'burn'
delivery: 'webvtt' | 'ass' | 'burn'
stream_index?: number
}
export const subtitlesAPI = {
list: (mediaId: string) =>
list: (mediaId: string, includeEmbedded = false) =>
api
.get<{ tracks: SubtitleTrack[] | null }>(`/media/${mediaId}/subtitles`)
.get<{ tracks: SubtitleTrack[] | null }>(`/media/${mediaId}/subtitles`, {
params: includeEmbedded ? { include_embedded: 'true' } : undefined,
})
.then((r) => r.data.tracks ?? []),
url: (mediaId: string, path: string) => {
@@ -24,4 +26,11 @@ export const subtitlesAPI = {
path,
)}&token=${encodeURIComponent(token)}`
},
assUrl: (mediaId: string, path: string) => {
const token = useAuthStore.getState().token ?? ''
return `/api/subtitles/${encodeURIComponent(mediaId)}/ass?path=${encodeURIComponent(
path,
)}&token=${encodeURIComponent(token)}`
},
}
@@ -0,0 +1,92 @@
This Font Software is licensed under the SIL Open Font License,
Version 1.1.
This license is copied below, and is also available with a FAQ at:
http://scripts.sil.org/OFL
-----------------------------------------------------------
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
-----------------------------------------------------------
PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font
creation efforts of academic and linguistic communities, and to
provide a free and open framework in which fonts may be shared and
improved in partnership with others.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply to
any document created using the fonts or their derivatives.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.
"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).
"Original Version" refers to the collection of Font Software
components as distributed by the Copyright Holder(s).
"Modified Version" refers to any derivative made by adding to,
deleting, or substituting -- in part or in whole -- any of the
components of the Original Version, by changing formats or by porting
the Font Software to a new environment.
"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.
PERMISSION & CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed,
modify, redistribute, and sell modified and unmodified copies of the
Font Software, subject to the following conditions:
1) Neither the Font Software nor any of its individual components, in
Original or Modified Versions, may be sold by itself.
2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.
3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the
corresponding Copyright Holder. This restriction only applies to the
primary font name as presented to the users.
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.
5) The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created using
the Font Software.
TERMINATION
This license becomes null and void if any of the above conditions are
not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.
Binary file not shown.
+107
View File
@@ -0,0 +1,107 @@
import { useEffect, useRef } from 'react'
import JASSUB from 'jassub'
import jassubWorkerUrl from 'jassub/dist/worker/worker.js?worker&url'
import jassubWasmUrl from 'jassub/dist/wasm/jassub-worker.wasm?url'
import jassubModernWasmUrl from 'jassub/dist/wasm/jassub-worker-modern.wasm?url'
import notoSansSCUrl from '../assets/fonts/noto-sans-sc-regular.woff2?url'
import {
loadSubtitleChineseConverter,
type SubtitleChineseMode,
} from '../utils/subtitleChinese'
import { convertASSContent } from '../utils/subtitleASS'
type AssSubtitleStageProps = {
videoRef: React.RefObject<HTMLVideoElement>
source: string
timeOffset?: number
chineseMode?: SubtitleChineseMode
onReady?: () => void
onError?: (error: unknown) => void
}
/**
* libass-wasm subtitle layer for external and embedded ASS/SSA tracks. JASSUB
* owns its canvas and attaches it next to the video, so this component itself
* renders no visible DOM nodes.
*/
export function AssSubtitleStage({
videoRef,
source,
timeOffset = 0,
chineseMode = 'original',
onReady,
onError,
}: AssSubtitleStageProps) {
const instanceRef = useRef<JASSUB | null>(null)
const timeOffsetRef = useRef(timeOffset)
const callbacksRef = useRef({ onReady, onError })
useEffect(() => {
callbacksRef.current = { onReady, onError }
}, [onReady, onError])
useEffect(() => {
const video = videoRef.current
if (!video || !source) return
let cancelled = false
let instance: JASSUB | null = null
const controller = new AbortController()
const load = async () => {
try {
const [response, converter] = await Promise.all([
fetch(source, { signal: controller.signal }),
loadSubtitleChineseConverter(chineseMode).catch(() => (text: string) => text),
])
if (!response.ok) throw new Error(`subtitle request failed: ${response.status}`)
const body = await response.text()
if (!body.trim()) throw new Error('empty subtitle payload')
if (cancelled) return
instance = new JASSUB({
video,
subContent: convertASSContent(body, converter),
timeOffset: timeOffsetRef.current,
workerUrl: jassubWorkerUrl,
wasmUrl: jassubWasmUrl,
modernWasmUrl: jassubModernWasmUrl,
fonts: [notoSansSCUrl],
defaultFont: 'Noto Sans SC',
queryFonts: 'local',
prescaleFactor: 1,
})
instanceRef.current = instance
await instance.ready
if (cancelled) {
void instance.destroy().catch(() => undefined)
return
}
callbacksRef.current.onReady?.()
} catch (error: unknown) {
if (cancelled) return
if (error instanceof DOMException && error.name === 'AbortError') return
callbacksRef.current.onError?.(error)
}
}
void load()
return () => {
cancelled = true
controller.abort()
instanceRef.current = null
if (instance) void instance.destroy().catch(() => undefined)
}
// timeOffset is applied live below. Recreating the full libass worker on a
// seek offset change would be unnecessarily expensive.
}, [source, chineseMode, videoRef])
useEffect(() => {
timeOffsetRef.current = timeOffset
if (instanceRef.current) instanceRef.current.timeOffset = timeOffset
}, [timeOffset])
return null
}
+3 -1
View File
@@ -55,7 +55,9 @@ export function LayoutUserMenu({
const [menuPosition, setMenuPosition] = useState<MenuPosition | null>(null)
const [isOtpOpen, setIsOtpOpen] = useState(false)
onCloseRef.current = onClose
useEffect(() => {
onCloseRef.current = onClose
}, [onClose])
const updateMenuPosition = useCallback(() => {
const trigger = triggerRef.current
@@ -0,0 +1,34 @@
import { X } from 'lucide-react'
import { AdminLibraryPanel } from '../pages/AdminLibraryPanel'
export function ManageLibrariesDialogView({ onClose }: { onClose: () => void }) {
return (
<div
className="fixed inset-0 z-[100] flex items-center justify-center bg-black/35 p-2 sm:p-4 backdrop-blur-sm"
onClick={onClose}
>
<div
role="dialog"
aria-modal="true"
className="flex h-[calc(100dvh-1rem)] sm:h-[85vh] w-full max-w-5xl flex-col overflow-hidden rounded-2xl sm:rounded-3xl border border-white/70 bg-white shadow-2xl"
onClick={(event) => event.stopPropagation()}
>
<div className="flex items-center justify-between border-b border-gray-100 px-4 py-3 sm:px-6 sm:py-4">
<h3 className="font-display text-base sm:text-lg font-bold text-ink-600">管理媒体库</h3>
<button
type="button"
onClick={onClose}
className="rounded-xl p-1.5 text-ink-50 transition hover:bg-gray-100 hover:text-ink-600"
title="关闭"
>
<X size={20} />
</button>
</div>
<div className="flex-1 overflow-y-auto p-3 sm:p-6">
<AdminLibraryPanel />
</div>
</div>
</div>
)
}
+3 -3
View File
@@ -44,8 +44,8 @@ export function ManualScrapeDialog({
if (!open || !media) return null
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-ink-900/40 px-4 py-8 backdrop-blur-sm">
<div className="flex max-h-[86vh] w-full max-w-4xl flex-col overflow-hidden rounded-2xl border border-sand-200 bg-white shadow-2xl">
<div className="fixed inset-0 z-50 flex min-w-0 items-center justify-center overflow-hidden bg-ink-900/40 px-4 py-8 backdrop-blur-sm">
<div className="flex min-w-0 max-h-[86vh] w-full max-w-4xl flex-col overflow-hidden rounded-2xl border border-sand-200 bg-white shadow-2xl">
<ManualScrapeDialogHeader title={scopeLabel || media.title} targetCount={dialog.targetIds.length} onClose={onClose} />
<ManualScrapeSearchControls
@@ -60,7 +60,7 @@ export function ManualScrapeDialog({
onEpisodeArtworkChange={dialog.setIncludeEpisodeArtwork}
/>
<div className="flex-1 overflow-y-auto p-5">
<div className="min-w-0 flex-1 overflow-x-hidden overflow-y-auto p-5">
<ManualScrapeCandidateList items={dialog.items} applyingKey={dialog.applyingKey} onApply={dialog.apply} />
</div>
</div>
@@ -20,14 +20,14 @@ export function ManualScrapeDialogHeader({
onClose: () => void
}) {
return (
<div className="flex items-start justify-between gap-4 border-b border-sand-200 px-5 py-4">
<div>
<div className="flex min-w-0 items-start justify-between gap-4 border-b border-sand-200 px-5 py-4">
<div className="min-w-0">
<h2 className="font-display text-xl font-bold text-ink-600">手动搜索刮削</h2>
<p className="mt-1 text-xs text-sand-500">
<p className="mt-1 truncate text-xs text-sand-500">
{title} · {targetCount > 1 ? `将应用到 ${targetCount} 个媒体` : '单个媒体'}
</p>
</div>
<button onClick={onClose} className="btn-ghost h-9 w-9 p-0" aria-label="关闭">
<button onClick={onClose} className="btn-ghost h-9 w-9 shrink-0 p-0" aria-label="关闭">
<X size={16} />
</button>
</div>
@@ -58,7 +58,7 @@ export function ManualScrapeSearchControls({
onEpisodeArtworkChange,
}: ManualScrapeSearchControlsProps) {
return (
<div className="grid gap-4 border-b border-sand-200 bg-sand-50/40 p-5">
<div className="grid min-w-0 gap-4 border-b border-sand-200 bg-sand-50/40 p-5">
<ManualScrapeQueryBar
query={query}
searching={searching}
@@ -170,7 +170,7 @@ export function ManualScrapeCandidateList({
}
return (
<div className="grid gap-3">
<div className="grid min-w-0 gap-3">
{items.map((item) => {
const key = candidateKey(item)
return (
@@ -199,7 +199,7 @@ function ManualScrapeCandidateRow({
onApply: (item: ManualScrapeCandidate) => void
}) {
return (
<div className="flex flex-col gap-4 rounded-xl border border-sand-200 bg-white p-3 shadow-sm sm:flex-row">
<div className="flex min-w-0 max-w-full flex-col gap-4 overflow-hidden rounded-xl border border-sand-200 bg-white p-3 shadow-sm sm:flex-row">
<div className="h-28 w-20 shrink-0 overflow-hidden rounded-lg bg-sand-100">
{item.poster_url ? (
<img src={imageURL(item.poster_url)} alt={item.title} loading="lazy" decoding="async" className="h-full w-full object-cover" referrerPolicy="no-referrer" />
@@ -208,14 +208,14 @@ function ManualScrapeCandidateRow({
)}
</div>
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-center gap-2">
<h3 className="truncate font-semibold text-ink-600">{item.title}</h3>
<span className="rounded-full bg-brand-50 px-2 py-0.5 text-[11px] font-bold uppercase text-brand-700">{item.source}</span>
{item.nsfw ? <span className="rounded-full bg-rose-50 px-2 py-0.5 text-[11px] font-bold text-rose-600">成人</span> : null}
{item.year ? <span className="text-xs text-sand-500">{item.year}</span> : null}
<div className="flex min-w-0 flex-wrap items-center gap-2">
<h3 className="min-w-0 max-w-full flex-1 truncate font-semibold text-ink-600">{item.title}</h3>
<span className="shrink-0 rounded-full bg-brand-50 px-2 py-0.5 text-[11px] font-bold uppercase text-brand-700">{item.source}</span>
{item.nsfw ? <span className="shrink-0 rounded-full bg-rose-50 px-2 py-0.5 text-[11px] font-bold text-rose-600">成人</span> : null}
{item.year ? <span className="shrink-0 text-xs text-sand-500">{item.year}</span> : null}
</div>
<p className="mt-1 line-clamp-2 text-xs leading-relaxed text-ink-50">{item.overview || '暂无简介'}</p>
<p className="mt-2 text-[11px] font-semibold text-sand-500">{candidateIDText(item)}</p>
<p className="mt-2 break-words text-[11px] font-semibold text-sand-500">{candidateIDText(item)}</p>
</div>
<button onClick={() => onApply(item)} disabled={disabled} className="btn-outline h-10 w-full shrink-0 justify-center px-3 text-xs sm:w-auto sm:self-center">
{applying ? <LoaderCircle size={14} className="animate-spin" /> : <Check size={14} />}
+28 -13
View File
@@ -1,8 +1,8 @@
import { Layers } from 'lucide-react'
import { Cloud, Layers } from 'lucide-react'
import { Link } from 'react-router-dom'
import type { Media } from '../types'
import { mediaVersionLabel, mediaVersionsOf } from '../utils/mediaVersion'
import { isStrmMedia, mediaVersionLabel, mediaVersionsOf } from '../utils/mediaVersion'
type MediaVersionSwitcherProps = {
media: Media
@@ -31,14 +31,30 @@ export function MediaVersionSwitcher({
<div className="flex flex-wrap gap-2">
{versions.map((version) => {
const active = version.id === media.id
const isStrm = isStrmMedia(version)
const label = mediaVersionLabel(version)
const className = dark
const btnClass = dark
? active
? 'border-white/40 bg-white/20 text-white'
: 'border-white/15 bg-white/5 text-white/80 hover:bg-white/15'
? 'border-white/50 bg-white/20 text-white shadow-sm ring-1 ring-white/20'
: 'border-white/15 bg-white/5 text-white/80 hover:bg-white/15 hover:text-white'
: active
? 'border-brand-500/40 bg-brand-50 text-[#b07d35]'
? 'border-brand-500/40 bg-brand-50 text-[#b07d35] font-bold shadow-sm'
: 'border-gray-200 bg-white text-ink-100 hover:border-brand-500/30 hover:bg-brand-50/40'
const content = (
<span className="inline-flex items-center gap-1.5">
{isStrm && (
<Cloud
size={12}
className={dark ? (active ? 'text-brand-300' : 'text-white/60') : (active ? 'text-brand-500' : 'text-sand-400')}
aria-label="云端 / STRM"
/>
)}
<span>{label}</span>
{!dark && active ? <span className="opacity-75">· 当前</span> : null}
</span>
)
if (mode === 'player') {
return (
<button
@@ -46,10 +62,10 @@ export function MediaVersionSwitcher({
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}
className={`rounded-xl border px-3 py-1.5 text-xs font-semibold transition disabled:cursor-default ${btnClass}`}
title={version.path || version.strm_url}
>
{label}
{content}
</button>
)
}
@@ -58,11 +74,10 @@ export function MediaVersionSwitcher({
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}
className={`rounded-xl border px-3 py-1.5 text-xs font-semibold transition ${btnClass}`}
title={version.path || version.strm_url}
>
{label}
{active ? ' · 当前' : ''}
{content}
</Link>
)
})}
+109 -10
View File
@@ -1,4 +1,4 @@
import { useEffect, useRef, useState } from 'react'
import { useCallback, useEffect, useRef, useState } from 'react'
import {
Captions,
CaptionsOff,
@@ -15,6 +15,13 @@ import {
VolumeX,
} from 'lucide-react'
import type { SubtitleTrack } from '../api/subtitles'
import type { SubtitleChineseMode } from '../utils/subtitleChinese'
import {
SUBTITLE_POSITION_OPTIONS,
SUBTITLE_STYLE_OPTIONS,
type SubtitlePosition,
type SubtitleStylePreset,
} from '../utils/subtitleDisplay'
// PlayerControls — custom bottom control bar replacing the native <video
// controls> (which cannot host custom buttons). The danmaku toggle sits right
@@ -36,6 +43,12 @@ type PlayerControlsProps = {
/** 当前激活字幕轨道:-1=关闭,0..n-1=对应轨道。 */
subtitleIndex: number
onSelectSubtitle: (index: number) => void
subtitleChineseMode: SubtitleChineseMode
onSubtitleChineseModeChange: (mode: SubtitleChineseMode) => void
subtitlePosition: SubtitlePosition
onSubtitlePositionChange: (position: SubtitlePosition) => void
subtitleStyle: SubtitleStylePreset
onSubtitleStyleChange: (style: SubtitleStylePreset) => void
danmakuOpen: boolean
danmakuEnabled: boolean
onToggleDanmaku: () => void
@@ -63,6 +76,12 @@ export function PlayerControls({
subs,
subtitleIndex,
onSelectSubtitle,
subtitleChineseMode,
onSubtitleChineseModeChange,
subtitlePosition,
onSubtitlePositionChange,
subtitleStyle,
onSubtitleStyleChange,
danmakuOpen,
danmakuEnabled,
onToggleDanmaku,
@@ -79,11 +98,11 @@ export function PlayerControls({
streamOffset = 0,
onSeekAbsolute,
}: PlayerControlsProps) {
const video = () => videoRef.current
const container = () =>
const video = useCallback(() => videoRef.current, [videoRef])
const container = useCallback(() =>
videoRef.current?.closest<HTMLElement>('[data-player-stage]') ??
videoRef.current?.parentElement ??
null
null, [videoRef])
const [playing, setPlaying] = useState(false)
const [currentTime, setCurrentTime] = useState(0)
@@ -250,15 +269,14 @@ export function PlayerControls({
el.removeEventListener('leavepictureinpicture', syncPip)
if (hideTimerRef.current) clearTimeout(hideTimerRef.current)
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [videoRef, knownDuration, streamOffset, onUiVisibleChange])
}, [container, video, 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])
}, [knownDuration, streamOffset, video])
// 当悬停或菜单状态改变时,更新控制栏计时器
useEffect(() => {
@@ -272,7 +290,7 @@ export function PlayerControls({
hideTimerRef.current = setTimeout(() => onUiVisibleChange(false), 3000)
}
}
}, [controlsHovered, isScrubbing, subtitleMenuOpen, danmakuOpen, playlistOpen, onUiVisibleChange])
}, [controlsHovered, isScrubbing, subtitleMenuOpen, danmakuOpen, playlistOpen, onUiVisibleChange, video])
const togglePlay = () => {
const el = video()
@@ -355,6 +373,12 @@ export function PlayerControls({
document.pictureInPictureEnabled
const displayTime = isScrubbing && scrubValue !== null ? scrubValue : currentTime
const selectedSubtitle = subtitleIndex >= 0 ? subs[subtitleIndex] : undefined
const canConvertSelectedSubtitle =
selectedSubtitle?.source === 'external' &&
(selectedSubtitle.delivery === 'webvtt' || selectedSubtitle.delivery === 'ass')
const canAdjustSelectedSubtitle = selectedSubtitle?.delivery === 'webvtt'
const usesOriginalASS = selectedSubtitle?.delivery === 'ass'
return (
<div
@@ -448,7 +472,7 @@ export function PlayerControls({
)}
</button>
{subtitleMenuOpen && (
<div className="absolute bottom-11 right-0 z-30 min-w-36 rounded-xl border border-white/15 bg-black/85 p-1 shadow-2xl backdrop-blur">
<div className="absolute bottom-11 right-0 z-30 max-h-[70vh] min-w-44 overflow-y-auto rounded-xl border border-white/15 bg-black/85 p-1 shadow-2xl backdrop-blur">
<button
type="button"
onClick={() => {
@@ -474,10 +498,85 @@ export function PlayerControls({
}`}
title={track.label || track.lang}
>
<span className="truncate">{track.label || track.lang || `字幕 ${index + 1}`}</span>
<span className="truncate">
{track.label || track.lang || `字幕 ${index + 1}`}
{track.delivery === 'ass' ? ' · ASS' : ''}
</span>
{subtitleIndex === index && <span className="ml-auto text-rose-400">●</span>}
</button>
))}
{canAdjustSelectedSubtitle && (
<div className="mt-1 border-t border-white/10 pt-1">
<p className="px-3 py-1 text-[10px] text-white/45">字幕位置</p>
<div className="flex flex-wrap gap-1 px-2 pb-1">
{SUBTITLE_POSITION_OPTIONS.map(([value, label]) => (
<button
key={value}
type="button"
onClick={() => onSubtitlePositionChange(value)}
className={`rounded-md px-2 py-1 text-[10px] transition ${
subtitlePosition === value
? 'bg-rose-500/20 text-rose-300'
: 'bg-white/5 text-white/70 hover:bg-white/10'
}`}
>
{label}
</button>
))}
</div>
<p className="px-3 py-1 text-[10px] text-white/45">字幕样式</p>
<div className="flex flex-wrap gap-1 px-2 pb-1">
{SUBTITLE_STYLE_OPTIONS.map(([value, label]) => (
<button
key={value}
type="button"
onClick={() => onSubtitleStyleChange(value)}
className={`rounded-md px-2 py-1 text-[10px] transition ${
subtitleStyle === value
? 'bg-rose-500/20 text-rose-300'
: 'bg-white/5 text-white/70 hover:bg-white/10'
}`}
>
{label}
</button>
))}
</div>
</div>
)}
{usesOriginalASS && (
<p className="mx-2 mt-1 border-t border-white/10 px-1 pt-2 text-[10px] leading-relaxed text-white/45">
ASS/SSA 使用字幕文件自带的样式与位置,不做统一覆盖。
</p>
)}
{canConvertSelectedSubtitle && (
<div className="mt-1 border-t border-white/10 pt-1">
<p className="px-3 py-1 text-[10px] text-white/45">外挂字幕简繁转换</p>
{(
[
['original', '保持原文'],
['simplified', '转换为简体'],
['traditional', '转换为繁体'],
] as const
).map(([mode, label]) => (
<button
key={mode}
type="button"
onClick={() => {
onSubtitleChineseModeChange(mode)
setSubtitleMenuOpen(false)
}}
className={`flex w-full items-center gap-2 rounded-lg px-3 py-1.5 text-left text-xs transition hover:bg-white/10 ${
subtitleChineseMode === mode ? 'text-rose-400' : 'text-white/85'
}`}
>
<span>{label}</span>
{subtitleChineseMode === mode && (
<span className="ml-auto text-rose-400">●</span>
)}
</button>
))}
</div>
)}
</div>
)}
</div>
+3 -3
View File
@@ -3,7 +3,7 @@ import { Check, Film, ListVideo, Play, Search, X } from 'lucide-react'
import { imageURL } from '../api/client'
import type { Media } from '../types'
import { seriesTitleFromPath } from '../utils/groupSeries'
import { seasonLabel, seasonSortOrder, seriesTitleFromPath } from '../utils/groupSeries'
export type SeasonGroup = {
season: number
@@ -42,7 +42,7 @@ export function PlayerPlaylistPanel({
list.sort((a, b) => (a.episode_num || 0) - (b.episode_num || 0))
}
return Array.from(seasonsMap.entries())
.sort(([a], [b]) => a - b)
.sort(([a], [b]) => seasonSortOrder(a) - seasonSortOrder(b))
.map(([season, list]) => ({ season, episodes: list }))
}, [episodes])
@@ -128,7 +128,7 @@ export function PlayerPlaylistPanel({
: 'bg-white/5 text-white/70 hover:bg-white/10 hover:text-white'
}`}
>
<span>{season === 0 ? '特别篇' : `第 ${season} 季`}</span>
<span>{seasonLabel(season)}</span>
<span className="text-[10px] opacity-75">({sesEps.length})</span>
{isPlayingThisSeason && !isSelected && (
<span className="h-1.5 w-1.5 rounded-full bg-rose-400" />
+2 -34
View File
@@ -1,7 +1,6 @@
import { createRoot } from 'react-dom/client'
import { X } from 'lucide-react'
import { AdminLibraryPanel } from '../pages/AdminLibraryPanel'
import { ManageLibrariesDialogView } from './ManageLibrariesDialogView'
export function openManageLibrariesDialog(): Promise<void> {
return new Promise((resolve) => {
@@ -15,37 +14,6 @@ export function openManageLibrariesDialog(): Promise<void> {
resolve()
}
root.render(<ManageLibrariesDialog onClose={close} />)
root.render(<ManageLibrariesDialogView onClose={close} />)
})
}
function ManageLibrariesDialog({ onClose }: { onClose: () => void }) {
return (
<div
className="fixed inset-0 z-[100] flex items-center justify-center bg-black/35 p-2 sm:p-4 backdrop-blur-sm"
onClick={onClose}
>
<div
role="dialog"
aria-modal="true"
className="flex h-[calc(100dvh-1rem)] sm:h-[85vh] w-full max-w-5xl flex-col overflow-hidden rounded-2xl sm:rounded-3xl border border-white/70 bg-white shadow-2xl"
onClick={(event) => event.stopPropagation()}
>
<div className="flex items-center justify-between border-b border-gray-100 px-4 py-3 sm:px-6 sm:py-4">
<h3 className="font-display text-base sm:text-lg font-bold text-ink-600">管理媒体库</h3>
<button
type="button"
onClick={onClose}
className="rounded-xl p-1.5 text-ink-50 transition hover:bg-gray-100 hover:text-ink-600"
title="关闭"
>
<X size={20} />
</button>
</div>
<div className="flex-1 overflow-y-auto p-3 sm:p-6">
<AdminLibraryPanel />
</div>
</div>
</div>
)
}
@@ -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]
}
+4 -1
View File
@@ -15,7 +15,10 @@ export function useWebSocket(onEvent: (topic: string, payload: unknown) => void)
const ref = useRef<WebSocket | null>(null)
const token = useAuthStore((s) => s.token)
const onEventRef = useRef(onEvent)
onEventRef.current = onEvent
useEffect(() => {
onEventRef.current = onEvent
}, [onEvent])
useEffect(() => {
if (!token) return
+6 -6
View File
@@ -28,7 +28,7 @@ export function AdminLibraryTable({ libs, ...actions }: LibraryTableProps) {
const [browsingRoot, setBrowsingRoot] = useState<{ libraryID: string; root: LibraryRoot; initialPath?: string } | null>(null)
const [addingRootLib, setAddingRootLib] = useState<Library | null>(null)
const [draggingId, setDraggingId] = useState<string | null>(null)
const dragOverId = useRef<string | null>(null)
const [dragOverId, setDragOverId] = useState<string | null>(null)
const handleSelectRootPath = (selectedPath: string) => {
if (browsingRoot) {
@@ -59,7 +59,7 @@ export function AdminLibraryTable({ libs, ...actions }: LibraryTableProps) {
const handleDrop = (e: DragEvent, overId: string) => {
e.preventDefault()
dragOverId.current = null
setDragOverId(null)
setDraggingId(null)
if (draggingId && overId !== draggingId) {
handleReorder(draggingId, overId)
@@ -90,19 +90,19 @@ export function AdminLibraryTable({ libs, ...actions }: LibraryTableProps) {
onBrowseRoot={(root) => setBrowsingRoot({ libraryID: library.id, root, initialPath: root.path })}
onOpenAddRoot={() => setAddingRootLib(library)}
dragging={draggingId === library.id}
dragOver={dragOverId.current === library.id}
dragOver={dragOverId === library.id}
onDragStart={(e) => {
e.dataTransfer.effectAllowed = 'move'
dragOverId.current = null
setDragOverId(null)
setDraggingId(library.id)
}}
onDragOver={(e) => {
e.preventDefault()
e.dataTransfer.dropEffect = 'move'
if (dragOverId.current !== library.id) dragOverId.current = library.id
if (dragOverId !== library.id) setDragOverId(library.id)
}}
onDragEnd={() => {
dragOverId.current = null
setDragOverId(null)
setDraggingId(null)
}}
onDrop={(e) => handleDrop(e, library.id)}
-1
View File
@@ -1,4 +1,3 @@
/* eslint-disable react-refresh/only-export-components */
import { useCallback, useEffect, useState } from 'react'
import toast from 'react-hot-toast'
import {
+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())
+8 -2
View File
@@ -5,7 +5,7 @@ import { motion } from 'framer-motion'
import { historyAPI } from '../api/history'
import type { Media } from '../types'
import { useAuthStore } from '../stores/auth'
import type { SeriesCard } from '../utils/groupSeries'
import { isTheatricalFeature, type SeriesCard } from '../utils/groupSeries'
import {
sortMediaList,
sortSeriesList,
@@ -132,6 +132,7 @@ export function LibraryPage() {
selectedEpisodes,
visibleEpisodes,
selectedSeriesEpisodes,
selectedSeriesAllEpisodes,
selectedSeriesMediaIDs,
handleSeriesClick,
clearSelectedSeries,
@@ -150,6 +151,9 @@ export function LibraryPage() {
setSelectedSeason,
onClearSeriesState: () => setSeriesMetadataEditOpen(false),
})
const selectedSeriesScrapeMedia = selectedSeriesEpisodes.find((media) => !isTheatricalFeature(media))
?? selectedSeries?.rep
?? null
const {
scraping,
@@ -169,7 +173,7 @@ export function LibraryPage() {
role,
library,
selectedSeries,
selectedSeriesEpisodes,
selectedSeriesEpisodes: selectedSeriesAllEpisodes,
reloadCurrentLibrary,
clearSelectedSeries,
setManualMovie,
@@ -287,6 +291,7 @@ export function LibraryPage() {
onOrganize={handleSeriesOrganize}
onDelete={handleSeriesDelete}
onSeasonChange={setSelectedSeason}
onManualScrapeMedia={setManualMovie}
/>
<LibraryPageDialogs
@@ -296,6 +301,7 @@ export function LibraryPage() {
seriesMetadataEditOpen={seriesMetadataEditOpen}
manualMovie={manualMovie}
selectedSeries={selectedSeries}
selectedSeriesScrapeMedia={selectedSeriesScrapeMedia}
selectedSeriesMediaIDs={selectedSeriesMediaIDs}
libraryType={library?.type}
scrapeEpisodeArtwork={scrapeEpisodeArtwork}
+8 -3
View File
@@ -2,7 +2,7 @@ import { ManualScrapeDialog } from '../components/ManualScrapeDialog'
import { MetadataEditDialog } from '../components/MetadataEditDialog'
import { ScrapeMetadataDialog } from '../components/ScrapeMetadataDialog'
import type { Library, Media } from '../types'
import { seriesTitle, type SeriesCard } from '../utils/groupSeries'
import { isTheatricalFeature, seriesTitle, type SeriesCard } from '../utils/groupSeries'
type LibraryPageDialogsProps = {
scrapeDialogOpen: boolean
@@ -11,6 +11,7 @@ type LibraryPageDialogsProps = {
seriesMetadataEditOpen: boolean
manualMovie: Media | null
selectedSeries: SeriesCard | null
selectedSeriesScrapeMedia: Media | null
selectedSeriesMediaIDs: string[]
libraryType?: string
scrapeEpisodeArtwork: boolean
@@ -29,6 +30,7 @@ export function LibraryPageDialogs({
seriesMetadataEditOpen,
manualMovie,
selectedSeries,
selectedSeriesScrapeMedia,
selectedSeriesMediaIDs,
libraryType,
scrapeEpisodeArtwork,
@@ -53,10 +55,10 @@ export function LibraryPageDialogs({
/>
<ManualScrapeDialog
open={manualSeriesScrapeOpen}
media={selectedSeries?.rep ?? null}
media={selectedSeriesScrapeMedia}
mediaIds={selectedSeriesMediaIDs}
defaultQuery={selectedSeriesTitle}
mediaType={selectedSeries ? scrapeMediaType(libraryType, selectedSeries.rep) : 'tv'}
mediaType="tv"
scopeLabel={selectedSeriesTitle || '当前剧集'}
episodeArtwork={scrapeEpisodeArtwork}
onClose={onCloseManualSeriesScrape}
@@ -86,6 +88,9 @@ export function LibraryPageDialogs({
}
function scrapeMediaType(libraryType: string | undefined, media: Media): string {
if (isTheatricalFeature(media)) {
return 'movie'
}
if ((media.season_num ?? 0) > 0 || (media.episode_num ?? 0) > 0) {
return 'tv'
}
+8 -6
View File
@@ -6,11 +6,10 @@ import { imageURL } from '../api/client'
import { ExternalPlayerButton } from '../components/ExternalPlayerButton'
import { MediaFavouriteButton } from '../components/MediaFavouriteButton'
import type { Media } from '../types'
import { seriesTitle, type SeriesCard } from '../utils/groupSeries'
import { isTheatricalFeature, seriesTitle, type SeriesCard } from '../utils/groupSeries'
type LibrarySeriesDetailHeaderProps = {
series: SeriesCard
visibleEpisodes: Media[]
allEpisodes: Media[]
playbackFrom: string
isAdmin: boolean
@@ -31,7 +30,6 @@ type LibrarySeriesDetailHeaderProps = {
export function LibrarySeriesDetailHeader({
series,
visibleEpisodes,
allEpisodes,
playbackFrom,
isAdmin,
@@ -49,7 +47,9 @@ export function LibrarySeriesDetailHeader({
onOrganize,
onDelete,
}: LibrarySeriesDetailHeaderProps) {
const firstEpisode = firstPlayableEpisode(visibleEpisodes.length > 0 ? visibleEpisodes : allEpisodes)
const tvEpisodes = allEpisodes.filter((media) => !isTheatricalFeature(media))
const theatricalCount = allEpisodes.length - tvEpisodes.length
const firstEpisode = firstPlayableEpisode(tvEpisodes)
return (
<>
@@ -61,7 +61,9 @@ export function LibrarySeriesDetailHeader({
<h2 className="truncate font-display text-2xl font-bold text-ink-600">
{seriesTitle(series.rep)}
</h2>
<span className="text-sm text-sand-500">共 {series.count} 集</span>
<span className="text-sm text-sand-500">
共 {tvEpisodes.length} 集{theatricalCount > 0 ? ` · ${theatricalCount} 部剧场版` : ''}
</span>
</div>
<div className="flex flex-col gap-6 sm:flex-row">
@@ -115,7 +117,7 @@ export function LibrarySeriesDetailHeader({
</button>
<button onClick={onManualScrape} disabled={!!seriesToolBusy} className="btn-outline px-3.5 py-2 text-xs gap-1.5">
<Search size={13} className="text-[#c9954a]" />
<span>手动匹配整剧</span>
<span>{theatricalCount > 0 ? '手动匹配 TV 版' : '手动匹配整剧'}</span>
</button>
<button onClick={onMetadataEdit} disabled={!!seriesToolBusy} className="btn-outline px-3.5 py-2 text-xs gap-1.5">
<Pencil size={13} />

Some files were not shown because too many files have changed in this diff Show More