mirror of
https://github.com/truewhile/MeBox.git
synced 2026-09-28 11:16:37 +08:00
Compare commits
16 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 18e6cb40fd | |||
| 92e693ccac | |||
| 62206aa05a | |||
| f9dd082159 | |||
| d489c1608e | |||
| 1ea29cca9e | |||
| ec5f81e6cb | |||
| 8eb017fedc | |||
| fb4e762026 | |||
| 4239474100 | |||
| 8225b4e319 | |||
| c086b01381 | |||
| f196e5621f | |||
| 13d60b3bd9 | |||
| 3d30f6f27c | |||
| bd02e194ff |
@@ -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
|
||||
)
|
||||
|
||||
@@ -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=
|
||||
|
||||
@@ -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{}
|
||||
|
||||
@@ -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)})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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})
|
||||
|
||||
@@ -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", "")
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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}.*$`)
|
||||
)
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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/",
|
||||
"/电视剧/", "/剧集/", "/连续剧/", "/短剧/", "/国产剧/", "/国剧/", "/大陆剧/", "/华语剧/", "/国产电视剧/", "/大陆电视剧/", "/华语电视剧/", "/欧美剧/", "/欧美电视剧/", "/美剧/", "/英剧/", "/日韩剧/", "/日韩电视剧/", "/日剧/", "/韩剧/", "/港剧/", "/台剧/", "/港台剧/", "/泰剧/",
|
||||
"/日番/", "/国漫/", "/番剧/", "/动漫/", "/特别篇/", "/特別篇/", "/番外/", "/特典/",
|
||||
} {
|
||||
|
||||
@@ -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 从单集路径推出整剧目录, 作为「同一部剧」的聚合键。
|
||||
// 若没有季文件夹, 则退而去掉文件名取其父目录。
|
||||
|
||||
@@ -22,18 +22,20 @@ import (
|
||||
)
|
||||
|
||||
var (
|
||||
patSEnE = regexp.MustCompile(`(?i)s(\d{1,2})e(\d{1,3})`)
|
||||
patSEnERange = regexp.MustCompile(`(?i)s(\d{1,2})e(\d{1,3})\s*[-~–—]\s*(?:s(\d{1,2}))?e?(\d{1,3})(?:[^0-9]|$)`)
|
||||
patDanglingSE = regexp.MustCompile(`(?i)(?:^|[\s._-])s\d{1,2}e(?:[\s._-]|$)`)
|
||||
patNxE = regexp.MustCompile(`(\d{1,2})x(\d{1,3})`)
|
||||
patEP = regexp.MustCompile(`(?i)(?:^|[^a-z])(?:e|ep)\.?\s*(\d{1,3})(?:[^0-9]|$)`)
|
||||
patCN = regexp.MustCompile(`第\s*([0-9一二三四五六七八九十百零两]+)\s*[集话話期]`)
|
||||
patCNRange = regexp.MustCompile(`第\s*([0-9一二三四五六七八九十百零两]+)\s*[-~–—]\s*([0-9一二三四五六七八九十百零两]+)\s*[集话話期]`)
|
||||
patDashEpisode = regexp.MustCompile(`[\s._-][-–—]\s*(\d{1,3})(?:\s*(?:v\d+)?)?(?:\s*[\[\(._-]|$)`)
|
||||
patSeasonFolder = regexp.MustCompile(`(?i)(?:^|[^a-z])(?:s|season)\.?\s*(\d{1,2})(?:[^0-9]|$)|第\s*([0-9一二三四五六七八九十百零两]+)\s*季`)
|
||||
patSeasonOnly = regexp.MustCompile(`(?i)(?:^|[\s._-])(?:s|season)\.?\s*\d{1,2}(?:[\s._-]|$)`)
|
||||
patBareEpisode = regexp.MustCompile(`^(?:第\s*)?0?(\d{1,3})(?:\s*(?:v\d+)?)?$`)
|
||||
patSpecialSeason = regexp.MustCompile(`(?i)^(?:s0+|season[\s._-]*0+|special[\s._-]*episodes?|specials?|sp|ovas?|oads?|extras?|bonus(?:es)?|omake|番外篇?|特别篇|特別篇|特典|外传|外傳|总集篇|總集篇)$`)
|
||||
patSEnE = regexp.MustCompile(`(?i)s(\d{1,2})e(\d{1,3})`)
|
||||
patSEnERange = regexp.MustCompile(`(?i)s(\d{1,2})e(\d{1,3})\s*[-~–—]\s*(?:s(\d{1,2}))?e?(\d{1,3})(?:[^0-9]|$)`)
|
||||
patDanglingSE = regexp.MustCompile(`(?i)(?:^|[\s._-])s\d{1,2}e(?:[\s._-]|$)`)
|
||||
patNxE = regexp.MustCompile(`(\d{1,2})x(\d{1,3})`)
|
||||
patEP = regexp.MustCompile(`(?i)(?:^|[^a-z])(?:e|ep)\.?\s*(\d{1,3})(?:[^0-9]|$)`)
|
||||
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*[季部]`)
|
||||
)
|
||||
@@ -56,6 +58,9 @@ func ParseEpisode(path string) (season, episode int) {
|
||||
episode = mustAtoi(m[2])
|
||||
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 +98,27 @@ func ParseEpisode(path string) (season, episode int) {
|
||||
return 0, 0
|
||||
}
|
||||
|
||||
// onlineEpisodeIdentityFromPath converts the common anime SxxE00 convention
|
||||
// to provider-style specials. For example, S01E00 becomes S00E01 and S02E00
|
||||
// becomes S00E02. Normal episodes retain their parsed identity.
|
||||
func onlineEpisodeIdentityFromPath(path string) (season, episode int) {
|
||||
season, episode = ParseEpisode(path)
|
||||
if episode != 0 {
|
||||
return season, episode
|
||||
}
|
||||
name := mediaSidecarBase(path)
|
||||
if name == "" {
|
||||
name = strings.TrimSuffix(filepath.Base(path), filepath.Ext(path))
|
||||
}
|
||||
if m := patSeasonEpisodeZero.FindStringSubmatch(name); len(m) == 2 {
|
||||
specialEpisode := mustAtoi(m[1])
|
||||
if specialEpisode > 0 {
|
||||
return 0, specialEpisode
|
||||
}
|
||||
}
|
||||
return season, episode
|
||||
}
|
||||
|
||||
type episodeRef struct {
|
||||
Season int
|
||||
Episode int
|
||||
|
||||
@@ -29,6 +29,10 @@ 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},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
@@ -68,3 +72,23 @@ func TestEpisodeRefsFromTitleParsesRanges(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestOnlineEpisodeIdentityFromPathMapsAnimeEpisodeZeroToSpecials(t *testing.T) {
|
||||
cases := []struct {
|
||||
path string
|
||||
wantSeason int
|
||||
wantEpisode int
|
||||
}{
|
||||
{`动漫/路人女主/Season 1/S01E00 - 爱与青春的杀必死回.mkv`, 0, 1},
|
||||
{`动漫/路人女主/Season 2/S02E00 - 恋爱与纯情的杀必死回.mkv`, 0, 2},
|
||||
{`动漫/路人女主/Season 2/S02E03 - 初稿与二稿.mkv`, 2, 3},
|
||||
{`动漫/路人女主/Specials/S00E04.mkv`, 0, 4},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
season, episode := onlineEpisodeIdentityFromPath(tc.path)
|
||||
if season != tc.wantSeason || episode != tc.wantEpisode {
|
||||
t.Errorf("onlineEpisodeIdentityFromPath(%q) = (%d, %d), want (%d, %d)",
|
||||
tc.path, season, episode, tc.wantSeason, tc.wantEpisode)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 == "" {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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, "真人秀"):
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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 ""
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
|
||||
@@ -49,6 +49,95 @@ 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 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",
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -93,7 +93,11 @@ func (s *ScraperService) EnrichOneWithOptions(ctx context.Context, m *model.Medi
|
||||
}
|
||||
if match == nil {
|
||||
if local != nil && !local.PathHint {
|
||||
return s.applyLocalMetadataMatch(ctx, m, local)
|
||||
err := s.applyLocalMetadataMatch(ctx, m, local)
|
||||
if err == nil {
|
||||
options.recordProvider("local")
|
||||
}
|
||||
return err
|
||||
}
|
||||
if err := s.repo.DB.WithContext(ctx).Model(&model.Media{}).Where("id = ?", m.ID).
|
||||
Update("scrape_status", "no_match").Error; err != nil {
|
||||
@@ -119,8 +123,22 @@ func (s *ScraperService) applyProviderMatch(ctx context.Context, m *model.Media,
|
||||
func (s *ScraperService) applyProviderMatchWithOptions(ctx context.Context, m *model.Media, lib *model.Library, match *Match, options ScrapeOptions) error {
|
||||
posterCandidate := match.PosterURL
|
||||
backdropCandidate := match.BackdropURL
|
||||
posterURL, removePoster := s.prepareScrapedArtworkURL(ctx, m.ID, "poster_url", m.PosterURL, posterCandidate)
|
||||
backdropURL, removeBackdrop := s.prepareScrapedArtworkURL(ctx, m.ID, "backdrop_url", m.BackdropURL, backdropCandidate)
|
||||
currentPoster := m.PosterURL
|
||||
currentBackdrop := m.BackdropURL
|
||||
if options.RebuildIdentity {
|
||||
currentPoster = ""
|
||||
currentBackdrop = ""
|
||||
}
|
||||
posterURL, removePoster := s.prepareScrapedArtworkURL(ctx, m.ID, "poster_url", currentPoster, posterCandidate)
|
||||
backdropURL, removeBackdrop := s.prepareScrapedArtworkURL(ctx, m.ID, "backdrop_url", currentBackdrop, backdropCandidate)
|
||||
if options.RebuildIdentity {
|
||||
if strings.TrimSpace(m.PosterURL) != posterURL {
|
||||
removePoster = m.PosterURL
|
||||
}
|
||||
if strings.TrimSpace(m.BackdropURL) != backdropURL {
|
||||
removeBackdrop = m.BackdropURL
|
||||
}
|
||||
}
|
||||
updates := map[string]any{
|
||||
"title": match.Title,
|
||||
"overview": match.Overview,
|
||||
@@ -142,6 +160,12 @@ func (s *ScraperService) applyProviderMatchWithOptions(ctx context.Context, m *m
|
||||
updates["languages"] = strings.Join(match.Languages, ",")
|
||||
updates["nsfw"] = match.NSFW
|
||||
}
|
||||
if options.RebuildIdentity {
|
||||
updates["season_num"] = m.SeasonNum
|
||||
updates["episode_num"] = m.EpisodeNum
|
||||
updates["episode_title"] = ""
|
||||
updates["series_id"] = ""
|
||||
}
|
||||
if match.ReleaseDate != "" {
|
||||
updates["release_date"] = match.ReleaseDate
|
||||
}
|
||||
@@ -207,6 +231,11 @@ func (s *ScraperService) applyProviderMatchWithOptions(ctx context.Context, m *m
|
||||
"thetvdb_id": match.TheTVDBID,
|
||||
"source": map[bool]string{true: "adult"}[match.NSFW],
|
||||
})
|
||||
provider := strings.ToLower(strings.TrimSpace(match.Provider))
|
||||
if provider == "" {
|
||||
provider = "unknown"
|
||||
}
|
||||
options.recordProvider(provider)
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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') {
|
||||
|
||||
@@ -22,23 +22,35 @@ 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"
|
||||
}
|
||||
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 +58,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))
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"go.uber.org/zap"
|
||||
@@ -24,6 +25,10 @@ type ScraperService struct {
|
||||
hub *Hub
|
||||
cache *RuntimeCacheService
|
||||
images *ImageProxy
|
||||
|
||||
// Serializes final sidecar replacement. Windows cannot rename over an
|
||||
// existing file, and concurrent scrapes can target the same sidecar.
|
||||
artworkWriteMu sync.Mutex
|
||||
}
|
||||
|
||||
// NewScraperService is the constructor.
|
||||
|
||||
@@ -44,38 +44,84 @@ 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{}
|
||||
if refreshed.PosterURL != "" {
|
||||
s.downloadArtworkToPathWithOptions(ctx, dir, base+"-poster", refreshed.PosterURL, isAdult)
|
||||
if dst := s.downloadArtworkToPathWithOptions(ctx, dir, base+"-poster", refreshed.PosterURL, isAdult); dst != "" {
|
||||
artworkUpdates["poster_url"] = filepath.Join(filepath.Dir(refreshed.Path), filepath.Base(dst))
|
||||
}
|
||||
}
|
||||
if refreshed.BackdropURL != "" {
|
||||
s.downloadArtworkToPathWithOptions(ctx, dir, base+"-backdrop", refreshed.BackdropURL, false)
|
||||
if dst := s.downloadArtworkToPathWithOptions(ctx, dir, base+"-backdrop", refreshed.BackdropURL, false); dst != "" {
|
||||
artworkUpdates["backdrop_url"] = filepath.Join(filepath.Dir(refreshed.Path), filepath.Base(dst))
|
||||
}
|
||||
} else if isAdult && refreshed.PosterURL != "" {
|
||||
// 番号海报原图为完整封套横图,在无独立背景图时直接作为背景图写出
|
||||
s.downloadArtworkToPathWithOptions(ctx, dir, base+"-backdrop", refreshed.PosterURL, false)
|
||||
if dst := s.downloadArtworkToPathWithOptions(ctx, dir, base+"-backdrop", refreshed.PosterURL, false); dst != "" {
|
||||
artworkUpdates["backdrop_url"] = filepath.Join(filepath.Dir(refreshed.Path), filepath.Base(dst))
|
||||
}
|
||||
}
|
||||
if len(artworkUpdates) > 0 {
|
||||
if err := s.repo.DB.WithContext(ctx).Model(&model.Media{}).
|
||||
Where("id = ?", refreshed.ID).Updates(artworkUpdates).Error; err != nil {
|
||||
s.log.Warn("save local scraped artwork paths failed", zap.String("media_id", refreshed.ID), zap.Error(err))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (s *ScraperService) downloadArtworkToPath(ctx context.Context, dir, name, raw string) {
|
||||
s.downloadArtworkToPathWithOptions(ctx, dir, name, raw, false)
|
||||
func (s *ScraperService) downloadArtworkToPath(ctx context.Context, dir, name, raw string) string {
|
||||
return s.downloadArtworkToPathWithOptions(ctx, dir, name, raw, false)
|
||||
}
|
||||
|
||||
// shouldCropAdultPoster keeps adult-cover handling independent of which
|
||||
// metadata provider won. A code-numbered title may match TMDb first, so the
|
||||
// provider's NSFW flag or artwork host alone is not sufficient.
|
||||
func shouldCropAdultPoster(media *model.Media, lib *model.Library) bool {
|
||||
if media == nil {
|
||||
return false
|
||||
}
|
||||
mediaType := ""
|
||||
if lib != nil {
|
||||
mediaType = lib.Type
|
||||
}
|
||||
return IsAdultMediaPathOrMetadata(media.Path, mediaType, media.NSFW) ||
|
||||
IsAdultArtworkURL(media.PosterURL)
|
||||
}
|
||||
|
||||
// downloadArtworkToPathWithOptions fetches an artwork URL via the image proxy cache and
|
||||
// writes it under dir/<name>.<ext>. For adult posters, it crops the right half of the cover.
|
||||
func (s *ScraperService) downloadArtworkToPathWithOptions(ctx context.Context, dir, name, raw string, cropAdultPoster bool) {
|
||||
if !isHTTPish(raw) {
|
||||
return
|
||||
func (s *ScraperService) downloadArtworkToPathWithOptions(ctx context.Context, dir, name, raw string, cropAdultPoster bool) string {
|
||||
var (
|
||||
data []byte
|
||||
ctype string
|
||||
err error
|
||||
)
|
||||
switch {
|
||||
case isHTTPish(raw):
|
||||
data, ctype, err = s.images.Fetch(ctx, raw)
|
||||
case isLocalImagePath(raw):
|
||||
data, err = os.ReadFile(sanitizeLocalPath(resolveMappedDestinationPath(raw)))
|
||||
if err == nil {
|
||||
ctype = detectContentType(data)
|
||||
}
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
data, ctype, err := s.images.Fetch(ctx, raw)
|
||||
if err != nil || len(data) == 0 {
|
||||
s.log.Warn("scrape artwork download failed",
|
||||
zap.String("name", name),
|
||||
zap.String("url", raw),
|
||||
zap.Error(err))
|
||||
return
|
||||
return ""
|
||||
}
|
||||
if !isImageContentType(ctype) || isTransparentPlaceholderData(data) {
|
||||
return
|
||||
return ""
|
||||
}
|
||||
// Upstreams sometimes report non-standard values such as image/jpg.
|
||||
// Use the decoded bytes as the source of truth so sidecars receive a
|
||||
// standard extension instead of the legacy .img fallback.
|
||||
if detected := detectContentType(data); isImageContentType(detected) {
|
||||
ctype = detected
|
||||
}
|
||||
if cropAdultPoster {
|
||||
if cropped, croppedType, err := CropAdultCoverPoster(data); err == nil && len(cropped) > 0 {
|
||||
@@ -83,7 +129,7 @@ func (s *ScraperService) downloadArtworkToPathWithOptions(ctx context.Context, d
|
||||
ctype = croppedType
|
||||
}
|
||||
}
|
||||
s.writeArtworkDataToPath(dir, name, ctype, data)
|
||||
return s.writeArtworkDataToPath(dir, name, ctype, data)
|
||||
}
|
||||
|
||||
// writeArtworkDataToPath writes in-memory artwork bytes to dir/<name>.<ext>
|
||||
@@ -110,10 +156,23 @@ func (s *ScraperService) writeArtworkDataToPath(dir, name, ctype string, data []
|
||||
s.log.Warn("scrape artwork write failed", zap.String("dst", dst), zap.Error(err))
|
||||
return ""
|
||||
}
|
||||
_ = tmp.Close()
|
||||
if err := os.Rename(tmp.Name(), dst); err != nil {
|
||||
if err := tmp.Close(); err != nil {
|
||||
_ = os.Remove(tmp.Name())
|
||||
s.log.Warn("scrape artwork rename failed", zap.String("dst", dst), zap.Error(err))
|
||||
s.log.Warn("scrape artwork close failed", zap.String("dst", dst), zap.Error(err))
|
||||
return ""
|
||||
}
|
||||
|
||||
// On Windows os.Rename does not replace dst. Serialize remove+rename so two
|
||||
// concurrent scrapes cannot leave the previous uncropped DVD cover behind.
|
||||
s.artworkWriteMu.Lock()
|
||||
err = os.Remove(dst)
|
||||
if err == nil || os.IsNotExist(err) {
|
||||
err = os.Rename(tmp.Name(), dst)
|
||||
}
|
||||
s.artworkWriteMu.Unlock()
|
||||
if err != nil {
|
||||
_ = os.Remove(tmp.Name())
|
||||
s.log.Warn("scrape artwork replace failed", zap.String("dst", dst), zap.Error(err))
|
||||
return ""
|
||||
}
|
||||
s.log.Debug("scrape artwork written", zap.String("dst", dst))
|
||||
@@ -125,7 +184,7 @@ func (s *ScraperService) writeArtworkDataToPath(dir, name, ctype string, data []
|
||||
// extension that could confuse media players.
|
||||
func imageExtForContentType(ctype string) string {
|
||||
switch strings.ToLower(strings.TrimSpace(strings.Split(ctype, ";")[0])) {
|
||||
case "image/jpeg", "image/pjpeg":
|
||||
case "image/jpeg", "image/jpg", "image/pjpeg":
|
||||
return ".jpg"
|
||||
case "image/png", "image/x-png":
|
||||
return ".png"
|
||||
|
||||
@@ -1,13 +1,22 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"image"
|
||||
"image/color"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/glebarez/sqlite"
|
||||
"go.uber.org/zap"
|
||||
"gorm.io/gorm"
|
||||
|
||||
"github.com/truewhile/MeBox/internal/config"
|
||||
"github.com/truewhile/MeBox/internal/model"
|
||||
"github.com/truewhile/MeBox/internal/repository"
|
||||
)
|
||||
|
||||
// TestWriteArtworkDataToPathWritesJellyfinSidecar verifies that in-memory
|
||||
@@ -33,11 +42,53 @@ func TestWriteArtworkDataToPathWritesJellyfinSidecar(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriteArtworkDataToPathReplacesExistingSidecar(t *testing.T) {
|
||||
scraper := &ScraperService{log: zap.NewNop()}
|
||||
mediaDir := t.TempDir()
|
||||
dst := filepath.Join(mediaDir, "poster.jpg")
|
||||
if err := os.WriteFile(dst, []byte("old DVD cover"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if got := scraper.writeArtworkDataToPath(mediaDir, "poster", "image/jpeg", testJPEG); got != dst {
|
||||
t.Fatalf("destination = %q, want %q", got, dst)
|
||||
}
|
||||
data, err := os.ReadFile(dst)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if string(data) != string(testJPEG) {
|
||||
t.Fatal("existing sidecar was not replaced")
|
||||
}
|
||||
}
|
||||
|
||||
func TestShouldCropAdultPosterWhenTMDbArtworkMatchedCodePath(t *testing.T) {
|
||||
media := &model.Media{
|
||||
Path: filepath.Join(t.TempDir(), "IPX-235.mp4"),
|
||||
Title: "TMDb matched title",
|
||||
PosterURL: "https://image.tmdb.org/t/p/w500/poster.jpg",
|
||||
}
|
||||
if !shouldCropAdultPoster(media, &model.Library{Type: "movie"}) {
|
||||
t.Fatal("code-numbered media should retain adult crop handling after a TMDb match")
|
||||
}
|
||||
}
|
||||
|
||||
func TestShouldCropAdultPosterUsesLibraryType(t *testing.T) {
|
||||
media := &model.Media{
|
||||
Path: filepath.Join(t.TempDir(), "renamed.mp4"),
|
||||
PosterURL: "https://image.tmdb.org/t/p/w500/poster.jpg",
|
||||
}
|
||||
if !shouldCropAdultPoster(media, &model.Library{Type: "adult"}) {
|
||||
t.Fatal("adult library media should be cropped regardless of provider")
|
||||
}
|
||||
}
|
||||
|
||||
// TestImageExtForContentType verifies the MIME -> extension mapping used to
|
||||
// name Jellyfin sidecar files.
|
||||
func TestImageExtForContentType(t *testing.T) {
|
||||
cases := map[string]string{
|
||||
"image/jpeg": ".jpg",
|
||||
"image/jpg": ".jpg",
|
||||
"image/pjpeg": ".jpg",
|
||||
"image/png": ".png",
|
||||
"image/webp": ".webp",
|
||||
@@ -53,6 +104,32 @@ func TestImageExtForContentType(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestDownloadArtworkMigratesLocalImgToCanonicalCroppedJPEG(t *testing.T) {
|
||||
scraper := &ScraperService{log: zap.NewNop()}
|
||||
dir := t.TempDir()
|
||||
legacy := filepath.Join(dir, "movie-poster.img")
|
||||
wide := createTestImage(900, 600, color.RGBA{R: 255, A: 255}, color.RGBA{B: 255, A: 255})
|
||||
if err := os.WriteFile(legacy, wide, 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
dst := scraper.downloadArtworkToPathWithOptions(t.Context(), dir, "movie-poster", legacy, true)
|
||||
if filepath.Base(dst) != "movie-poster.jpg" {
|
||||
t.Fatalf("destination = %q, want canonical JPEG sidecar", dst)
|
||||
}
|
||||
data, err := os.ReadFile(dst)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
cropped, _, err := image.Decode(bytes.NewReader(data))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := float64(cropped.Bounds().Dx()) / float64(cropped.Bounds().Dy()); got < 0.65 || got > 0.68 {
|
||||
t.Fatalf("migrated poster ratio = %.3f, want 2:3", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestWriteMediaArtworkFilesAfterScrapeSkipsCloud verifies that cloud media
|
||||
// never has artwork written anywhere.
|
||||
func TestWriteMediaArtworkFilesAfterScrapeSkipsCloud(t *testing.T) {
|
||||
@@ -87,3 +164,65 @@ func TestSameDirectoryMediaUsesBaseScopedSidecars(t *testing.T) {
|
||||
t.Fatalf("base = %q, want A-poster.jpg", filepath.Base(dst))
|
||||
}
|
||||
}
|
||||
|
||||
func TestLocalAdultArtworkStoresFaceAwarePosterPath(t *testing.T) {
|
||||
wideCover := createTestImage(
|
||||
900,
|
||||
600,
|
||||
color.RGBA{R: 255, A: 255},
|
||||
color.RGBA{B: 255, A: 255},
|
||||
)
|
||||
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.Header().Set("Content-Type", "image/jpeg")
|
||||
_, _ = w.Write(wideCover)
|
||||
}))
|
||||
defer upstream.Close()
|
||||
|
||||
cfg := &config.Config{}
|
||||
cfg.Cache.CacheDir = t.TempDir()
|
||||
images := NewImageProxy(cfg, zap.NewNop())
|
||||
images.allowedRemoteHostsFn = func() []string { return []string{"127.0.0.1"} }
|
||||
|
||||
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := db.AutoMigrate(&model.Media{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
repos := repository.New(db)
|
||||
mediaDir := t.TempDir()
|
||||
media := model.Media{
|
||||
Title: "IPX-235",
|
||||
Path: filepath.Join(mediaDir, "IPX-235.mp4"),
|
||||
PosterURL: upstream.URL + "/cover.jpg",
|
||||
NSFW: true,
|
||||
ScrapeStatus: "matched",
|
||||
}
|
||||
if err := repos.DB.Create(&media).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
scraper := &ScraperService{cfg: cfg, log: zap.NewNop(), repo: repos, images: images}
|
||||
scraper.writeMediaArtworkFilesAfterScrape(t.Context(), &media, &model.Library{Type: "adult"})
|
||||
|
||||
var got model.Media
|
||||
if err := repos.DB.First(&got, "id = ?", media.ID).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if filepath.Base(got.PosterURL) != "IPX-235-poster.jpg" {
|
||||
t.Fatalf("poster URL = %q, want local face-aware sidecar", got.PosterURL)
|
||||
}
|
||||
data, err := os.ReadFile(got.PosterURL)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
cropped, _, err := image.Decode(bytes.NewReader(data))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ratio := float64(cropped.Bounds().Dx()) / float64(cropped.Bounds().Dy())
|
||||
if ratio < 0.65 || ratio > 0.68 {
|
||||
t.Fatalf("local poster ratio = %.3f, want MetaTube primary ratio", ratio)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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{})
|
||||
|
||||
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 {
|
||||
@@ -214,15 +238,7 @@ func (s *SubtitleService) discoverUncached(ctx context.Context, mediaID string)
|
||||
})
|
||||
}
|
||||
}
|
||||
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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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":[
|
||||
@@ -84,6 +124,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 {
|
||||
|
||||
@@ -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"`
|
||||
|
||||
@@ -13,9 +13,11 @@ export interface SubtitleTrack {
|
||||
}
|
||||
|
||||
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) => {
|
||||
|
||||
@@ -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} />}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
})}
|
||||
|
||||
@@ -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]
|
||||
}
|
||||
@@ -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())
|
||||
|
||||
@@ -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,
|
||||
@@ -150,6 +150,9 @@ export function LibraryPage() {
|
||||
setSelectedSeason,
|
||||
onClearSeriesState: () => setSeriesMetadataEditOpen(false),
|
||||
})
|
||||
const selectedSeriesScrapeMedia = selectedSeriesEpisodes.find((media) => !isTheatricalFeature(media))
|
||||
?? selectedSeries?.rep
|
||||
?? null
|
||||
|
||||
const {
|
||||
scraping,
|
||||
@@ -287,6 +290,7 @@ export function LibraryPage() {
|
||||
onOrganize={handleSeriesOrganize}
|
||||
onDelete={handleSeriesDelete}
|
||||
onSeasonChange={setSelectedSeason}
|
||||
onManualScrapeMedia={setManualMovie}
|
||||
/>
|
||||
|
||||
<LibraryPageDialogs
|
||||
@@ -296,6 +300,7 @@ export function LibraryPage() {
|
||||
seriesMetadataEditOpen={seriesMetadataEditOpen}
|
||||
manualMovie={manualMovie}
|
||||
selectedSeries={selectedSeries}
|
||||
selectedSeriesScrapeMedia={selectedSeriesScrapeMedia}
|
||||
selectedSeriesMediaIDs={selectedSeriesMediaIDs}
|
||||
libraryType={library?.type}
|
||||
scrapeEpisodeArtwork={scrapeEpisodeArtwork}
|
||||
|
||||
@@ -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'
|
||||
}
|
||||
|
||||
@@ -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} />
|
||||
|
||||
@@ -33,6 +33,7 @@ type LibrarySeriesDetailSectionProps = {
|
||||
onOrganize: () => void
|
||||
onDelete: () => void
|
||||
onSeasonChange: (season: number) => void
|
||||
onManualScrapeMedia: (media: Media) => void
|
||||
}
|
||||
|
||||
export function LibrarySeriesDetailSection({
|
||||
@@ -58,6 +59,7 @@ export function LibrarySeriesDetailSection({
|
||||
onOrganize,
|
||||
onDelete,
|
||||
onSeasonChange,
|
||||
onManualScrapeMedia,
|
||||
}: LibrarySeriesDetailSectionProps) {
|
||||
return (
|
||||
<AnimatePresence mode="wait">
|
||||
@@ -70,7 +72,6 @@ export function LibrarySeriesDetailSection({
|
||||
>
|
||||
<LibrarySeriesDetailHeader
|
||||
series={selectedSeries}
|
||||
visibleEpisodes={visibleEpisodes}
|
||||
allEpisodes={allEpisodes}
|
||||
playbackFrom={playbackFrom}
|
||||
isAdmin={isAdmin}
|
||||
@@ -95,7 +96,9 @@ export function LibrarySeriesDetailSection({
|
||||
selectedSeason={selectedSeason}
|
||||
visibleEpisodes={visibleEpisodes}
|
||||
playbackFrom={playbackFrom}
|
||||
isAdmin={isAdmin}
|
||||
onSeasonChange={onSeasonChange}
|
||||
onManualScrape={onManualScrapeMedia}
|
||||
/>
|
||||
</motion.div>
|
||||
)}
|
||||
@@ -109,7 +112,9 @@ type LibrarySeriesEpisodesPanelProps = {
|
||||
selectedSeason: number | null
|
||||
visibleEpisodes: Media[]
|
||||
playbackFrom: string
|
||||
isAdmin: boolean
|
||||
onSeasonChange: (season: number) => void
|
||||
onManualScrape: (media: Media) => void
|
||||
}
|
||||
|
||||
function LibrarySeriesEpisodesPanel({
|
||||
@@ -118,7 +123,9 @@ function LibrarySeriesEpisodesPanel({
|
||||
selectedSeason,
|
||||
visibleEpisodes,
|
||||
playbackFrom,
|
||||
isAdmin,
|
||||
onSeasonChange,
|
||||
onManualScrape,
|
||||
}: LibrarySeriesEpisodesPanelProps) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
@@ -128,7 +135,9 @@ function LibrarySeriesEpisodesPanel({
|
||||
selectedSeason={selectedSeason}
|
||||
visibleEpisodes={visibleEpisodes}
|
||||
playbackFrom={playbackFrom}
|
||||
isAdmin={isAdmin}
|
||||
onSeasonChange={onSeasonChange}
|
||||
onManualScrape={onManualScrape}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -1,10 +1,24 @@
|
||||
import { Link } from 'react-router-dom'
|
||||
import { Play } from 'lucide-react'
|
||||
import { Play, Search } from 'lucide-react'
|
||||
|
||||
import { imageURL } from '../api/client'
|
||||
import { ExternalPlayerButton } from '../components/ExternalPlayerButton'
|
||||
import type { Media } from '../types'
|
||||
import { seriesTitleFromPath } from '../utils/groupSeries'
|
||||
import {
|
||||
BONUS_SEASON,
|
||||
EXTRA_SEASON,
|
||||
isTheatricalFeature,
|
||||
NCED_SEASON,
|
||||
NCOP_SEASON,
|
||||
OAD_SEASON,
|
||||
OMAKE_SEASON,
|
||||
ONA_SEASON,
|
||||
OVA_SEASON,
|
||||
OVD_SEASON,
|
||||
PICTURE_DRAMA_SEASON,
|
||||
seriesTitleFromPath,
|
||||
THEATRICAL_SEASON,
|
||||
} from '../utils/groupSeries'
|
||||
import { formatSize } from './libraryPageModel'
|
||||
|
||||
type SeasonGroup = {
|
||||
@@ -18,7 +32,9 @@ type LibrarySeriesEpisodesProps = {
|
||||
selectedSeason: number | null
|
||||
visibleEpisodes: Media[]
|
||||
playbackFrom: string
|
||||
isAdmin?: boolean
|
||||
onSeasonChange: (season: number) => void
|
||||
onManualScrape?: (media: Media) => void
|
||||
}
|
||||
|
||||
export function LibrarySeriesEpisodes({
|
||||
@@ -27,7 +43,9 @@ export function LibrarySeriesEpisodes({
|
||||
selectedSeason,
|
||||
visibleEpisodes,
|
||||
playbackFrom,
|
||||
isAdmin = false,
|
||||
onSeasonChange,
|
||||
onManualScrape,
|
||||
}: LibrarySeriesEpisodesProps) {
|
||||
if (loading) {
|
||||
return (
|
||||
@@ -53,18 +71,19 @@ export function LibrarySeriesEpisodes({
|
||||
: 'border-sand-200 bg-white text-ink-100 hover:border-brand-200 hover:text-brand-600')
|
||||
}
|
||||
>
|
||||
{season === 0 ? '特别篇' : `第 ${season} 季`} · {episodes.length} 集
|
||||
{seasonLabel(season)} · {episodes.length} {season === THEATRICAL_SEASON ? '部' : '集'}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h3 className="mb-3 font-display text-lg font-semibold text-ink-600">
|
||||
{displaySeason === 0 ? '特别篇' : `第 ${displaySeason} 季`}
|
||||
{seasonLabel(displaySeason)}
|
||||
</h3>
|
||||
<div className="grid grid-cols-1 gap-2.5 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 2xl:grid-cols-5">
|
||||
{visibleEpisodes.map((ep) => {
|
||||
const displayTitle = episodeDisplayTitle(ep, visibleEpisodes)
|
||||
const versionCount = ep.versions?.length ?? 1
|
||||
return (
|
||||
<div
|
||||
key={ep.id}
|
||||
@@ -102,10 +121,24 @@ export function LibrarySeriesEpisodes({
|
||||
{ep.duration_sec > 0
|
||||
? `${Math.floor(ep.duration_sec / 60)} 分钟`
|
||||
: formatSize(ep.size_bytes)}
|
||||
{versionCount > 1 ? ` · ${versionCount} 版本` : ''}
|
||||
</p>
|
||||
</div>
|
||||
</Link>
|
||||
<ExternalPlayerButton mediaId={ep.id} label="外部" compact />
|
||||
<div className="flex shrink-0 items-center gap-1">
|
||||
{isAdmin && onManualScrape && isTheatricalFeature(ep) && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onManualScrape(ep)}
|
||||
className="btn-outline !px-2 !py-1.5 text-[11px]"
|
||||
title="手动匹配剧场版"
|
||||
>
|
||||
<Search size={12} />
|
||||
匹配
|
||||
</button>
|
||||
)}
|
||||
<ExternalPlayerButton mediaId={ep.id} label="外部" compact />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
@@ -115,6 +148,24 @@ export function LibrarySeriesEpisodes({
|
||||
)
|
||||
}
|
||||
|
||||
function seasonLabel(season: number): string {
|
||||
switch (season) {
|
||||
case 0: return '特别篇'
|
||||
case THEATRICAL_SEASON: return '剧场版'
|
||||
case OVA_SEASON: return 'OVA'
|
||||
case OAD_SEASON: return 'OAD'
|
||||
case OVD_SEASON: return 'OVD'
|
||||
case ONA_SEASON: return 'ONA'
|
||||
case EXTRA_SEASON: return 'Extra'
|
||||
case BONUS_SEASON: return 'Bonus'
|
||||
case OMAKE_SEASON: return 'Omake'
|
||||
case PICTURE_DRAMA_SEASON: return 'Picture Drama'
|
||||
case NCOP_SEASON: return 'NCOP'
|
||||
case NCED_SEASON: return 'NCED'
|
||||
default: return `第 ${season} 季`
|
||||
}
|
||||
}
|
||||
|
||||
function episodeDisplayTitle(ep: Media, siblings: Media[]): string {
|
||||
let mainTitle = ''
|
||||
const epTitle = ep.episode_title?.trim()
|
||||
|
||||
@@ -237,8 +237,22 @@ export function PlayerPage() {
|
||||
// 404 / 无权限等:给出可见错误提示,避免永久「加载中」
|
||||
setLoadError(`无法加载该媒体:${apiErrorMessage(err)}`)
|
||||
})
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [id, modeParam, directOnly])
|
||||
|
||||
// Wire up the actual <video> element when we know the mode.
|
||||
// Depend on media.id (not the media object): refreshing duration after
|
||||
// MANIFEST_PARSED must not remount HLS or it storms EnsureJob / DELETE.
|
||||
const mediaId = media?.id
|
||||
|
||||
// 直连播放只发现外挂字幕;只有 HLS 模式需要探测可烧录的内嵌字幕。
|
||||
useEffect(() => {
|
||||
if (!mediaId) return
|
||||
let cancelled = false
|
||||
subtitlesAPI
|
||||
.list(id)
|
||||
.list(mediaId, mode === 'hls')
|
||||
.then((tracks) => {
|
||||
if (cancelled) return
|
||||
const list = tracks ?? []
|
||||
@@ -249,16 +263,13 @@ export function PlayerPage() {
|
||||
.catch(() => {
|
||||
if (cancelled) return
|
||||
setSubs([])
|
||||
setSubtitleIndex(-1)
|
||||
})
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [id, modeParam, directOnly])
|
||||
}, [mediaId, mode])
|
||||
|
||||
// Wire up the actual <video> element when we know the mode.
|
||||
// Depend on media.id (not the media object): refreshing duration after
|
||||
// MANIFEST_PARSED must not remount HLS or it storms EnsureJob / DELETE.
|
||||
const mediaId = media?.id
|
||||
const selectedSubtitle = subtitleIndex >= 0 ? subs[subtitleIndex] : undefined
|
||||
const burnedSubtitleStream =
|
||||
selectedSubtitle?.delivery === 'burn' ? selectedSubtitle.stream_index : undefined
|
||||
|
||||
@@ -47,6 +47,14 @@ function parseWebVTTCues(body: string): SubtitleCue[] {
|
||||
return cues
|
||||
}
|
||||
|
||||
function uniqueSubtitleTexts(texts: string[]): string[] {
|
||||
const unique = [...new Set(texts.map((text) => text.trim()).filter(Boolean))]
|
||||
// ponytail: WebVTT cannot preserve ASS layers/positions. Keep at most two
|
||||
// simultaneous blocks for bilingual subtitles; use libass if full ASS
|
||||
// typesetting support is added later.
|
||||
return unique.slice(-2)
|
||||
}
|
||||
|
||||
type PlayerVideoStageProps = {
|
||||
media: Media | null
|
||||
/** 媒体元数据加载失败提示(非空时替代「加载中」展示)。 */
|
||||
@@ -255,6 +263,7 @@ export function PlayerVideoStage({
|
||||
const selectedTrack = subs[subtitleIndex]
|
||||
if (
|
||||
!video ||
|
||||
!tracksArmed ||
|
||||
subs.length === 0 ||
|
||||
subtitleIndex < 0 ||
|
||||
!selectedTrack ||
|
||||
@@ -267,9 +276,11 @@ export function PlayerVideoStage({
|
||||
const updateCue = () => {
|
||||
const absoluteTime = video.currentTime + (streamOffset ?? 0)
|
||||
if (subtitleTimeline?.path === selectedTrack.path) {
|
||||
const texts = subtitleTimeline.cues
|
||||
.filter((cue) => absoluteTime >= cue.startTime && absoluteTime <= cue.endTime)
|
||||
.map((cue) => cue.text)
|
||||
const texts = uniqueSubtitleTexts(
|
||||
subtitleTimeline.cues
|
||||
.filter((cue) => absoluteTime >= cue.startTime && absoluteTime <= cue.endTime)
|
||||
.map((cue) => cue.text),
|
||||
)
|
||||
setActiveCueText(texts.join('\n'))
|
||||
return
|
||||
}
|
||||
@@ -304,7 +315,7 @@ export function PlayerVideoStage({
|
||||
}
|
||||
}
|
||||
}
|
||||
setActiveCueText(texts.join('\n'))
|
||||
setActiveCueText(uniqueSubtitleTexts(texts).join('\n'))
|
||||
}
|
||||
|
||||
const apply = () => {
|
||||
@@ -359,7 +370,7 @@ export function PlayerVideoStage({
|
||||
}
|
||||
}
|
||||
}
|
||||
}, [subtitleIndex, subs, videoRef, media, streamOffset, subtitleTimeline])
|
||||
}, [subtitleIndex, subs, videoRef, media, streamOffset, subtitleTimeline, tracksArmed])
|
||||
|
||||
// 根据视频画面宽高比与舞台宽高比,确定视频在哪个轴向撑满 100%
|
||||
const isWiderThanStage =
|
||||
@@ -412,7 +423,6 @@ export function PlayerVideoStage({
|
||||
src={subtitlesAPI.url(media.id, track.path)}
|
||||
srcLang={track.lang}
|
||||
label={track.label || track.lang}
|
||||
default={subtitleIndex === index}
|
||||
/>
|
||||
),
|
||||
)}
|
||||
|
||||
@@ -15,7 +15,7 @@ export function seriesSourceRoot(episodes: Media[]): string {
|
||||
if (!firstPath) return ''
|
||||
const dir = dirname(firstPath)
|
||||
const base = basename(dir)
|
||||
if (/^(?:s\d{1,2}|season[\s._-]*\d{1,2}|第\s*\d{1,2}\s*季|specials?|sp|ova|oad|extra|extras|特别篇|特別篇|番外|特典)$/i.test(base)) {
|
||||
if (/^(?:s\d{1,2}|season[\s._-]*\d{1,2}|第\s*\d{1,2}\s*季|specials?|sp|ovas?|oads?|ovds?|onas?|extras?|bonus(?:es)?|omake|picture[\s._-]*drama|ncop|nced|特别篇|特別篇|番外|特典|画像特典)$/i.test(base)) {
|
||||
return dirname(dir)
|
||||
}
|
||||
return dir
|
||||
|
||||
@@ -6,6 +6,7 @@ import { libraryAPI } from '../api/library'
|
||||
import { mediaAPI } from '../api/library'
|
||||
import { toolsAPI } from '../api/tools'
|
||||
import { confirmAction, confirmActionResult } from '../components/confirmAction'
|
||||
import { useEpisodeArtworkPreference } from '../hooks/useEpisodeArtworkPreference'
|
||||
import type { Library, Media } from '../types'
|
||||
import { seriesTitle, type SeriesCard } from '../utils/groupSeries'
|
||||
import { LibraryMovieActions } from './LibraryMovieActions'
|
||||
@@ -33,7 +34,7 @@ export function useLibraryAdminActions({
|
||||
setManualMovie,
|
||||
}: UseLibraryAdminActionsOptions) {
|
||||
const [scraping, setScraping] = useState(false)
|
||||
const [scrapeEpisodeArtwork, setScrapeEpisodeArtwork] = useState(false)
|
||||
const [scrapeEpisodeArtwork, setScrapeEpisodeArtwork] = useEpisodeArtworkPreference()
|
||||
const [repairing, setRepairing] = useState(false)
|
||||
const [seriesToolBusy, setSeriesToolBusy] = useState('')
|
||||
const [movieToolBusy, setMovieToolBusy] = useState('')
|
||||
|
||||
@@ -1,7 +1,23 @@
|
||||
import { useEffect, useMemo } from 'react'
|
||||
|
||||
import type { Media } from '../types'
|
||||
import { getSeriesKey, type SeriesCard } from '../utils/groupSeries'
|
||||
import {
|
||||
BONUS_SEASON,
|
||||
EXTRA_SEASON,
|
||||
getSeriesKey,
|
||||
isTheatricalFeature,
|
||||
NCED_SEASON,
|
||||
NCOP_SEASON,
|
||||
OAD_SEASON,
|
||||
OMAKE_SEASON,
|
||||
ONA_SEASON,
|
||||
OVA_SEASON,
|
||||
OVD_SEASON,
|
||||
PICTURE_DRAMA_SEASON,
|
||||
specialSectionForMedia,
|
||||
THEATRICAL_SEASON,
|
||||
type SeriesCard,
|
||||
} from '../utils/groupSeries'
|
||||
|
||||
type SeasonEpisodes = {
|
||||
season: number
|
||||
@@ -47,7 +63,9 @@ export function useLibrarySeriesSelection({
|
||||
: sourceItems.filter((m) => getSeriesKey(m) === selectedSeries.key)
|
||||
const seasons = new Map<number, Media[]>()
|
||||
for (const ep of eps) {
|
||||
const s = ep.episode_num > 0 ? (ep.season_num ?? 0) : (ep.season_num || 1)
|
||||
const specialSection = specialSectionForMedia(ep)
|
||||
const s = specialSection ??
|
||||
(ep.episode_num > 0 ? (ep.season_num ?? 0) : (ep.season_num || 1))
|
||||
if (!seasons.has(s)) seasons.set(s, [])
|
||||
seasons.get(s)!.push(ep)
|
||||
}
|
||||
@@ -55,7 +73,7 @@ export function useLibrarySeriesSelection({
|
||||
list.sort((a, b) => (a.episode_num || 0) - (b.episode_num || 0))
|
||||
}
|
||||
return Array.from(seasons.entries())
|
||||
.sort(([a], [b]) => a - b)
|
||||
.sort(([a], [b]) => seasonSortOrder(a) - seasonSortOrder(b))
|
||||
.map(([season, episodes]) => ({ season, episodes }))
|
||||
}, [isSeriesLibrary, selectedSeries, items, seriesEpisodeItems])
|
||||
|
||||
@@ -70,7 +88,7 @@ export function useLibrarySeriesSelection({
|
||||
)
|
||||
|
||||
const selectedSeriesMediaIDs = useMemo(
|
||||
() => selectedSeriesEpisodes.map((ep) => ep.id),
|
||||
() => selectedSeriesEpisodes.filter((ep) => !isTheatricalFeature(ep)).map((ep) => ep.id),
|
||||
[selectedSeriesEpisodes],
|
||||
)
|
||||
|
||||
@@ -128,3 +146,21 @@ export function useLibrarySeriesSelection({
|
||||
clearSelectedSeries,
|
||||
}
|
||||
}
|
||||
|
||||
function seasonSortOrder(season: number): number {
|
||||
switch (season) {
|
||||
case 0: return 0
|
||||
case OVA_SEASON: return 1
|
||||
case OAD_SEASON: return 2
|
||||
case OVD_SEASON: return 3
|
||||
case ONA_SEASON: return 4
|
||||
case EXTRA_SEASON: return 5
|
||||
case BONUS_SEASON: return 6
|
||||
case OMAKE_SEASON: return 7
|
||||
case PICTURE_DRAMA_SEASON: return 8
|
||||
case NCOP_SEASON: return 9
|
||||
case NCED_SEASON: return 10
|
||||
case THEATRICAL_SEASON: return 11
|
||||
default: return 100 + season
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import { api } from '../api/client'
|
||||
import { mediaAPI } from '../api/library'
|
||||
import { playbackAPI } from '../api/playback'
|
||||
import { confirmActionResult } from '../components/confirmAction'
|
||||
import { useEpisodeArtworkPreference } from '../hooks/useEpisodeArtworkPreference'
|
||||
import type { Media } from '../types'
|
||||
import { mediaLibraryBackTarget } from './MediaDetailPageModel'
|
||||
|
||||
@@ -42,7 +43,7 @@ export function useMediaDetailPageState({ id, navigate }: MediaDetailPageStatePa
|
||||
const [manualScrapeOpen, setManualScrapeOpen] = useState(false)
|
||||
const [metadataEditOpen, setMetadataEditOpen] = useState(false)
|
||||
const [organizeOpen, setOrganizeOpen] = useState(false)
|
||||
const [scrapeEpisodeArtwork, setScrapeEpisodeArtwork] = useState(false)
|
||||
const [scrapeEpisodeArtwork, setScrapeEpisodeArtwork] = useEpisodeArtworkPreference()
|
||||
|
||||
const refresh = useMediaDetailRefresh({
|
||||
id,
|
||||
|
||||
@@ -38,6 +38,18 @@ export type SeriesCard = {
|
||||
last_added_at?: string
|
||||
}
|
||||
|
||||
export const THEATRICAL_SEASON = -1
|
||||
export const OVA_SEASON = -2
|
||||
export const OAD_SEASON = -3
|
||||
export const OVD_SEASON = -4
|
||||
export const ONA_SEASON = -5
|
||||
export const EXTRA_SEASON = -6
|
||||
export const BONUS_SEASON = -7
|
||||
export const OMAKE_SEASON = -8
|
||||
export const PICTURE_DRAMA_SEASON = -9
|
||||
export const NCOP_SEASON = -10
|
||||
export const NCED_SEASON = -11
|
||||
|
||||
export function getSeriesKey(media: Media): string {
|
||||
return compactSeriesKey(getSeriesRawKey(media))
|
||||
}
|
||||
@@ -90,16 +102,55 @@ export function isEpisodeLike(media: Media): boolean {
|
||||
// 剧集类目录名(电视剧/动漫及其二级分类)。媒体路径落在这些目录下时, 即便
|
||||
// 季集号未识别出来, 也应按剧集对待, 跳转到 /library 分类视图而非 /media 单页。
|
||||
const EPISODIC_PATH_RE =
|
||||
/[\\/](?:电视剧|剧集|连续剧|短剧|国产剧|国剧|大陆剧|华语剧|国产电视剧|大陆电视剧|华语电视剧|欧美剧|欧美电视剧|美剧|英剧|日韩剧|日韩电视剧|日剧|韩剧|港剧|台剧|港台剧|泰剧|综艺|纪录片|儿童|动漫|番剧|国漫|日番|韩漫|美漫|欧美动漫|欧美动画|其他动漫|anime|tv|series|shows?|season[\s._-]*\d|s\d{1,2}(?:[\s._-]|[\\/])|special[\s._-]*episodes?|specials?|sp|ovas?|oads?|extras?|bonus(?:es)?|omake|特别篇|特別篇|番外篇?|特典|外传|外傳|总集篇|總集篇)[\\/]/i
|
||||
/[\\/](?:电视剧|剧集|连续剧|短剧|国产剧|国剧|大陆剧|华语剧|国产电视剧|大陆电视剧|华语电视剧|欧美剧|欧美电视剧|美剧|英剧|日韩剧|日韩电视剧|日剧|韩剧|港剧|台剧|港台剧|泰剧|综艺|纪录片|儿童|动漫|番剧|国漫|日番|韩漫|美漫|欧美动漫|欧美动画|其他动漫|anime|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|特别篇|特別篇|番外篇?|特典|外传|外傳|总集篇|總集篇|画像特典)[\\/]/i
|
||||
|
||||
const THEATRICAL_TITLE_RE =
|
||||
/(?:剧场版|劇場版|动画电影|動畫電影|电影版|電影版|\bthe\s+movie\b|\bmovie\s*\d{1,2}\b)/i
|
||||
|
||||
const THEATRICAL_FOLDER_NAME_RE =
|
||||
/(?:剧场版|劇場版|动画电影|動畫電影|电影版|電影版)/i
|
||||
|
||||
const THEATRICAL_FOLDER_RE =
|
||||
/[\\/][^\\/]*(?:剧场版|劇場版|动画电影|動畫電影|电影版|電影版)[^\\/]*[\\/]/
|
||||
|
||||
const SEASON_FOLDER_RE =
|
||||
/^(?:s\d{1,2}|season[\s._-]*\d{1,2}|第\s*[0-9一二三四五六七八九十百零两]+\s*季|special[\s._-]*episodes?|specials?|sp|ovas?|oads?|extras?|bonus(?:es)?|omake|特别篇|特別篇|番外篇?|特典|外传|外傳|总集篇|總集篇)$/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|特别篇|特別篇|番外篇?|特典|外传|外傳|总集篇|總集篇|画像特典|剧场版|劇場版|动画电影|動畫電影)$/i
|
||||
|
||||
export function pathLooksEpisodic(media: Media): boolean {
|
||||
const path = (media.path || media.display_library_path || media.library_path || '')
|
||||
return EPISODIC_PATH_RE.test(path)
|
||||
}
|
||||
|
||||
export function isTheatricalFeature(media: Media): boolean {
|
||||
const path = media.path || ''
|
||||
if (SERIES_FILE_EPISODE_RE.test(path)) return false
|
||||
return THEATRICAL_TITLE_RE.test(`${media.title || ''} ${path}`) || THEATRICAL_FOLDER_RE.test(path)
|
||||
}
|
||||
|
||||
const SPECIAL_SECTION_PATTERNS: Array<[number, RegExp]> = [
|
||||
[OVA_SEASON, /(?:^|[^a-z0-9])ovas?(?:[\s._-]*\d+)?(?:[^a-z0-9]|$)/i],
|
||||
[OAD_SEASON, /(?:^|[^a-z0-9])oads?(?:[\s._-]*\d+)?(?:[^a-z0-9]|$)/i],
|
||||
[OVD_SEASON, /(?:^|[^a-z0-9])ovds?(?:[\s._-]*\d+)?(?:[^a-z0-9]|$)/i],
|
||||
[ONA_SEASON, /(?:^|[^a-z0-9])onas?(?:[\s._-]*\d+)?(?:[^a-z0-9]|$)/i],
|
||||
[PICTURE_DRAMA_SEASON, /(?:^|[^a-z0-9])(?:picture[\s._-]*drama|画像特典)(?:[^a-z0-9]|$)/i],
|
||||
[NCOP_SEASON, /(?:^|[^a-z0-9])ncop(?:\d+)?(?:[^a-z0-9]|$)/i],
|
||||
[NCED_SEASON, /(?:^|[^a-z0-9])nced(?:\d+)?(?:[^a-z0-9]|$)/i],
|
||||
[EXTRA_SEASON, /(?:^|[^a-z0-9])extras?(?:[\s._-]*\d+)?(?:[^a-z0-9]|$)/i],
|
||||
[BONUS_SEASON, /(?:^|[^a-z0-9])bonus(?:es)?(?:[\s._-]*\d+)?(?:[^a-z0-9]|$)/i],
|
||||
[OMAKE_SEASON, /(?:^|[^a-z0-9])omake(?:[\s._-]*\d+)?(?:[^a-z0-9]|$)/i],
|
||||
]
|
||||
|
||||
export function specialSectionForMedia(media: Media): number | null {
|
||||
if (isTheatricalFeature(media)) return THEATRICAL_SEASON
|
||||
const parts = (media.path || '').split(/[\\/]+/).filter(Boolean)
|
||||
for (const part of parts) {
|
||||
for (const [section, pattern] of SPECIAL_SECTION_PATTERNS) {
|
||||
if (pattern.test(part)) return section
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
export function isSeriesCard(card: SeriesCard): boolean {
|
||||
return (
|
||||
card.count > 1 ||
|
||||
@@ -156,10 +207,10 @@ function normalizeTitle(value?: string): string {
|
||||
}
|
||||
|
||||
const SERIES_SPECIAL_CODE_RE =
|
||||
/\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*[\]))】]?$/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*[\]))】]?$/i
|
||||
|
||||
const SERIES_SPECIAL_CJK_RE =
|
||||
/\s*[[((【]?\s*(?:特别篇|特別篇|番外篇?|特典|外传|外傳|总集篇|總集篇)(?:\s*第?\s*[0-9一二三四五六七八九十百零两]+(?:[集话話期])?)?\s*[\]))】]?$/i
|
||||
/\s*[[((【]?\s*(?:特别篇|特別篇|番外篇?|特典|外传|外傳|总集篇|總集篇|画像特典)(?:\s*第?\s*[0-9一二三四五六七八九十百零两]+(?:[集话話期])?)?\s*[\]))】]?$/i
|
||||
|
||||
function normalizePathSeriesTitle(value?: string): string {
|
||||
const title = normalizeTitle(value)
|
||||
@@ -257,16 +308,20 @@ function seriesDirectoryNameFromPath(path?: string): string {
|
||||
if (parts.length < 2) return ''
|
||||
let dirIndex = parts.length - 2
|
||||
const lastPart = parts[parts.length - 1]
|
||||
if (!seriesPathPartLooksLikeFile(lastPart) && !SEASON_FOLDER_RE.test(lastPart)) {
|
||||
if (!seriesPathPartLooksLikeFile(lastPart) && !isSeriesContainerFolder(lastPart)) {
|
||||
dirIndex = parts.length - 1
|
||||
}
|
||||
while (dirIndex >= 0 && SEASON_FOLDER_RE.test(parts[dirIndex])) {
|
||||
while (dirIndex >= 0 && isSeriesContainerFolder(parts[dirIndex])) {
|
||||
dirIndex -= 1
|
||||
}
|
||||
if (dirIndex < 0) return ''
|
||||
return parts[dirIndex]
|
||||
}
|
||||
|
||||
function isSeriesContainerFolder(name: string): boolean {
|
||||
return SEASON_FOLDER_RE.test(name) || THEATRICAL_FOLDER_NAME_RE.test(name)
|
||||
}
|
||||
|
||||
function seriesExternalIDFromPath(path?: string): string {
|
||||
const part = seriesDirectoryNameFromPath(path)
|
||||
if (!part) return ''
|
||||
|
||||
+134
-25
@@ -1,33 +1,62 @@
|
||||
import type { Media } from '../types'
|
||||
|
||||
/** 从路径 / strm URL 推断容器扩展名(mkv/mp4…)。 */
|
||||
const VIDEO_EXTENSIONS = new Set([
|
||||
'mkv', 'mp4', 'm4v', 'avi', 'mov', 'webm', 'flv', 'wmv', 'ts', 'm2ts', 'mts', 'vob', 'rmvb', 'rm', 'iso',
|
||||
])
|
||||
|
||||
export function isStrmMedia(media?: Media | null): boolean {
|
||||
if (!media) return false
|
||||
const path = (media.path || '').toLowerCase()
|
||||
const strmUrl = (media.strm_url || '').toLowerCase()
|
||||
return path.endsWith('.strm') || path.startsWith('cloud://') || Boolean(strmUrl.trim())
|
||||
}
|
||||
|
||||
/** 从路径 / strm URL 推断真实视频容器扩展名(mkv/mp4…)。 */
|
||||
export function mediaVersionContainer(media: Media): string {
|
||||
const explicit = (media.container || '').trim().toLowerCase().replace(/^\./, '')
|
||||
if (explicit && explicit !== 'strm' && VIDEO_EXTENSIONS.has(explicit)) {
|
||||
return explicit
|
||||
}
|
||||
|
||||
const path = (media.path || '').replace(/\\/g, '/')
|
||||
const base = path.split('/').pop() || ''
|
||||
const ext = base.includes('.') ? base.slice(base.lastIndexOf('.')).toLowerCase() : ''
|
||||
let name = ext ? base.slice(0, -ext.length) : base
|
||||
const name = ext ? base.slice(0, -ext.length) : base
|
||||
|
||||
if (ext === '.strm') {
|
||||
const second = name.includes('.') ? name.slice(name.lastIndexOf('.')).toLowerCase() : ''
|
||||
if (second && second !== '.strm') {
|
||||
return second.replace(/^\./, '')
|
||||
const second = name.includes('.') ? name.slice(name.lastIndexOf('.')).toLowerCase().replace(/^\./, '') : ''
|
||||
if (second && VIDEO_EXTENSIONS.has(second)) {
|
||||
return second
|
||||
}
|
||||
|
||||
const strm = (media.strm_url || '').toLowerCase()
|
||||
const marker = '/video.'
|
||||
const idx = strm.lastIndexOf(marker)
|
||||
if (idx >= 0) {
|
||||
let rest = strm.slice(idx + marker.length)
|
||||
const end = rest.search(/[?#&/]/)
|
||||
if (end >= 0) rest = rest.slice(0, end)
|
||||
rest = rest.replace(/^\./, '').trim()
|
||||
if (rest) return rest
|
||||
if (strm) {
|
||||
// 1. /video.mkv 或 /video.mp4 模式
|
||||
const marker = '/video.'
|
||||
const idx = strm.lastIndexOf(marker)
|
||||
if (idx >= 0) {
|
||||
let rest = strm.slice(idx + marker.length)
|
||||
const end = rest.search(/[?#&/]/)
|
||||
if (end >= 0) rest = rest.slice(0, end)
|
||||
rest = rest.replace(/^\./, '').trim()
|
||||
if (rest && VIDEO_EXTENSIONS.has(rest)) return rest
|
||||
}
|
||||
|
||||
// 2. 从 query 参数(如 ref=/movies/abc.mkv 或 path=...)或 pathname 里匹配扩展名
|
||||
const match = strm.match(/\.(mkv|mp4|m4v|avi|mov|webm|flv|wmv|ts|m2ts|iso)(?=[?#&/]|$)/i)
|
||||
if (match?.[1]) {
|
||||
return match[1].toLowerCase()
|
||||
}
|
||||
}
|
||||
return 'strm'
|
||||
}
|
||||
return ext.replace(/^\./, '')
|
||||
|
||||
const cleanExt = ext.replace(/^\./, '')
|
||||
return VIDEO_EXTENSIONS.has(cleanExt) ? cleanExt : cleanExt
|
||||
}
|
||||
|
||||
function formatSize(bytes: number): string {
|
||||
if (!bytes || bytes < 1024) return bytes ? `${bytes} B` : ''
|
||||
if (!bytes || bytes < 1024) return ''
|
||||
const units = ['KB', 'MB', 'GB', 'TB']
|
||||
let value = bytes
|
||||
let unit = -1
|
||||
@@ -38,19 +67,99 @@ function formatSize(bytes: number): string {
|
||||
return `${value.toFixed(1)} ${units[unit]}`
|
||||
}
|
||||
|
||||
/** 版本切换展示名:分辨率 · 容器 · 编码 · 体积,缺省回退文件名。 */
|
||||
/** 从文件名/路径中提取质量或规格标签(如 4K, 2160p, REMUX, HDR, 1080p, 60fps 等) */
|
||||
function extractQualityKeywords(text: string): string[] {
|
||||
const result: string[] = []
|
||||
const upper = text.toUpperCase()
|
||||
|
||||
// 分辨率
|
||||
if (/\b(4K|2160P|UHD)\b/i.test(upper)) result.push('4K')
|
||||
else if (/\b(1080P|FHD)\b/i.test(upper)) result.push('1080p')
|
||||
else if (/\b(720P|HD)\b/i.test(upper)) result.push('720p')
|
||||
|
||||
// 格式/版本规格
|
||||
if (/\bREMUX\b/i.test(upper)) result.push('REMUX')
|
||||
else if (/\b(BLURAY|BLU-RAY|BD)\b/i.test(upper)) result.push('BluRay')
|
||||
else if (/\b(WEB-?DL|WEBRIP)\b/i.test(upper)) result.push('WEB-DL')
|
||||
|
||||
// 动态范围/画质技术
|
||||
if (/\b(DV|DOVI|DOLBY[\s._-]?VISION)\b/i.test(upper)) result.push('DV')
|
||||
if (/\b(HDR10\+|HDR10PLUS)\b/i.test(upper)) result.push('HDR10+')
|
||||
else if (/\bHDR\b/i.test(upper) && !result.includes('DV')) result.push('HDR')
|
||||
|
||||
// 帧率
|
||||
if (/\b(60FPS|120FPS)\b/i.test(upper)) {
|
||||
const fpsMatch = upper.match(/\b(60FPS|120FPS)\b/i)
|
||||
if (fpsMatch) result.push(fpsMatch[1])
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
/** 清理文件名以用作干净的兜底展示 */
|
||||
function cleanFallbackFilename(raw: string): string {
|
||||
let name = raw.replace(/\\/g, '/').split('/').pop() || ''
|
||||
// 去除 .strm 及前缀扩展名
|
||||
name = name.replace(/\.strm$/i, '')
|
||||
name = name.replace(/\.(mkv|mp4|m4v|avi|mov|webm|flv|wmv|ts|iso)$/i, '')
|
||||
// 替换连续的点或下划线为空格
|
||||
name = name.replace(/[._]+/g, ' ').trim()
|
||||
return name
|
||||
}
|
||||
|
||||
/** 版本切换展示名:分辨率 · 容器 · 编码 · 体积 / 规格 / 来源标签。 */
|
||||
export function mediaVersionLabel(media: Media): string {
|
||||
const isStrm = isStrmMedia(media)
|
||||
const parts: string[] = []
|
||||
if (media.height > 0) parts.push(`${media.height}p`)
|
||||
else if (media.width > 0) parts.push(`${media.width}w`)
|
||||
|
||||
// 1. 分辨率(优先从媒体元数据获取,其次从路径中提取)
|
||||
if (media.height > 0) {
|
||||
if (media.height >= 2100) parts.push('4K')
|
||||
else parts.push(`${media.height}p`)
|
||||
} else if (media.width > 0) {
|
||||
if (media.width >= 3800) parts.push('4K')
|
||||
else parts.push(`${media.width}w`)
|
||||
}
|
||||
|
||||
// 2. 容器
|
||||
const container = mediaVersionContainer(media)
|
||||
if (container && container !== 'strm') parts.push(container.toUpperCase())
|
||||
if (media.video_codec?.trim()) parts.push(media.video_codec.trim().toUpperCase())
|
||||
const size = formatSize(media.size_bytes)
|
||||
if (size) parts.push(size)
|
||||
if (parts.length > 0) return parts.join(' · ')
|
||||
const base = (media.path || '').replace(/\\/g, '/').split('/').pop()
|
||||
return base || media.title || media.id
|
||||
if (container && container !== 'strm') {
|
||||
parts.push(container.toUpperCase())
|
||||
}
|
||||
|
||||
// 3. 视频编码
|
||||
if (media.video_codec?.trim()) {
|
||||
parts.push(media.video_codec.trim().toUpperCase())
|
||||
}
|
||||
|
||||
// 4. 从文件名/路径补充规格特征(针对 STRM 缺乏探测数据时的关键画质提示)
|
||||
const pathKeywords = extractQualityKeywords(`${media.path || ''} ${media.strm_url || ''}`)
|
||||
for (const kw of pathKeywords) {
|
||||
// 避免重复(如已经有了 4K / 1080p)
|
||||
if (!parts.some((p) => p.toUpperCase() === kw.toUpperCase())) {
|
||||
parts.push(kw)
|
||||
}
|
||||
}
|
||||
|
||||
// 5. 文件大小:过滤掉本地 STRM 占位小文件(< 1MB 的 STRM 索引通常不是真实视频大小)
|
||||
const isPlaceholderSize = isStrm && media.size_bytes > 0 && media.size_bytes < 1024 * 1024
|
||||
if (media.size_bytes > 0 && !isPlaceholderSize) {
|
||||
const size = formatSize(media.size_bytes)
|
||||
if (size) parts.push(size)
|
||||
}
|
||||
|
||||
// 6. 如果仍然没有解析出足够特征,标记来源(云盘 / STRM)
|
||||
if (isStrm && !parts.some((p) => ['4K', '1080P', '720P', 'REMUX', 'WEB-DL', 'MKV', 'MP4'].includes(p.toUpperCase()))) {
|
||||
parts.push('云端直链')
|
||||
}
|
||||
|
||||
if (parts.length > 0) {
|
||||
return parts.join(' · ')
|
||||
}
|
||||
|
||||
// 兜底:使用清理后的文件名
|
||||
const cleaned = cleanFallbackFilename(media.path || '')
|
||||
return cleaned || media.title || media.id
|
||||
}
|
||||
|
||||
export function mediaVersionsOf(media: Media | null | undefined): Media[] {
|
||||
|
||||
Reference in New Issue
Block a user