mirror of
https://github.com/truewhile/MeBox.git
synced 2026-09-28 11:16:37 +08:00
Compare commits
42 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| b855e00345 | |||
| 8f2551a1b6 | |||
| 18e6cb40fd | |||
| 92e693ccac | |||
| 62206aa05a | |||
| f9dd082159 | |||
| d489c1608e | |||
| 1ea29cca9e | |||
| ec5f81e6cb | |||
| 8eb017fedc | |||
| fb4e762026 | |||
| 4239474100 | |||
| 8225b4e319 | |||
| c086b01381 | |||
| f196e5621f | |||
| 13d60b3bd9 | |||
| 3d30f6f27c | |||
| bd02e194ff | |||
| 1c9b742c8d | |||
| 20e7d7fa99 | |||
| 394ea0382e | |||
| 4d698d2757 | |||
| fdfb2ee987 | |||
| b23b1d728e | |||
| ad427fc0d2 | |||
| c489374b61 | |||
| 5a47e6f52d | |||
| c4b8114115 | |||
| 0e592105c4 | |||
| f20c9c3234 | |||
| 38b85bb487 | |||
| 6bf8a6878a | |||
| 2e636ce43f | |||
| 28aa466107 | |||
| f1b8010496 | |||
| 394d1a3cda | |||
| 465e98a792 | |||
| 292ae22dcd | |||
| bc7e5fc79d | |||
| f7fec93d44 | |||
| b4a1dc38bb | |||
| 3150971f10 |
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"mcpServers": {
|
||||
"ssh": {
|
||||
"command": "cmd.exe",
|
||||
"args": [
|
||||
"/c",
|
||||
"npx",
|
||||
"-y",
|
||||
"@aiondadotcom/mcp-ssh"
|
||||
],
|
||||
"env": {
|
||||
"ProgramData": "C:\\ProgramData"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -70,8 +70,11 @@ func serveSPA(r *gin.Engine, root fs.FS) {
|
||||
r.GET(rootFile, serveFSFile(root, name))
|
||||
r.HEAD(rootFile, serveFSFile(root, name))
|
||||
}
|
||||
r.NoRoute(middleware.GzipStatic(), func(c *gin.Context) {
|
||||
path := c.Request.URL.Path
|
||||
r.NoRoute(middleware.GzipStatic(), func(c *gin.Context) {
|
||||
if handler.TryHandleEmbyNormalizedRoute(c, r) {
|
||||
return
|
||||
}
|
||||
path := c.Request.URL.Path
|
||||
if shouldBypassSPAFallback(path) {
|
||||
c.Status(http.StatusNotFound)
|
||||
return
|
||||
|
||||
@@ -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=
|
||||
|
||||
@@ -120,6 +120,25 @@ func changePasswordHandler(svc *service.Container) gin.HandlerFunc {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.Status(http.StatusNoContent)
|
||||
c.Status(http.StatusNoContent)
|
||||
}
|
||||
}
|
||||
|
||||
func temporaryPasswordHandler(svc *service.Container) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
uid, ok := c.Get(middleware.CtxUserID)
|
||||
if !ok || uid == nil {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "unauthorized"})
|
||||
return
|
||||
}
|
||||
code, expireSec, err := svc.Auth.CreateTemporaryPassword(c.Request.Context(), uid.(string))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"code": code,
|
||||
"expires_in": expireSec,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -257,7 +257,10 @@ func embyClientInfoFromRequest(c *gin.Context) embyClientInfo {
|
||||
firstHeaderValue(c, "X-Emby-Client", "X-MediaBrowser-Client"),
|
||||
c.Query("Client"),
|
||||
c.Query("client"),
|
||||
c.Query("X-Emby-Client"),
|
||||
c.Query("X-MediaBrowser-Client"),
|
||||
auth["Client"],
|
||||
auth["client"],
|
||||
),
|
||||
}
|
||||
ua := strings.TrimSpace(c.GetHeader("User-Agent"))
|
||||
@@ -315,10 +318,36 @@ func embyClientFromUserAgent(ua string) string {
|
||||
return "Emby"
|
||||
case strings.Contains(lower, "jellyfin"):
|
||||
return "Jellyfin"
|
||||
case strings.Contains(lower, "capyplayer") || strings.Contains(lower, "capy player") || strings.Contains(lower, "卡皮巴拉"):
|
||||
return "CapyPlayer"
|
||||
case strings.Contains(lower, "senplayer") || strings.Contains(lower, "sen player") || strings.Contains(lower, "森播"):
|
||||
return "SenPlayer"
|
||||
case strings.Contains(lower, "yamby"):
|
||||
return "Yamby"
|
||||
case strings.Contains(lower, "vidhub"):
|
||||
return "VidHub"
|
||||
case strings.Contains(lower, "fileball"):
|
||||
return "Fileball"
|
||||
case strings.Contains(lower, "hamhub"):
|
||||
return "HamHub"
|
||||
case strings.Contains(lower, "afusekt") || strings.Contains(lower, "afuse"):
|
||||
return "AfuseKt"
|
||||
case strings.Contains(lower, "cony"):
|
||||
return "Cony"
|
||||
case strings.Contains(lower, "kodi"):
|
||||
return "Kodi"
|
||||
case strings.Contains(lower, "mrmc"):
|
||||
return "MrMC"
|
||||
case strings.Contains(lower, "forward"):
|
||||
return "Forward"
|
||||
case strings.Contains(lower, "alpha"):
|
||||
return "Alpha"
|
||||
case strings.Contains(lower, "dandanplay") || strings.Contains(lower, "弹弹play"):
|
||||
return "DanDanPlay"
|
||||
case strings.Contains(lower, "potplayer"):
|
||||
return "PotPlayer"
|
||||
case strings.Contains(lower, "vlc"):
|
||||
return "VLC"
|
||||
case strings.Contains(lower, "hills"):
|
||||
return "Hills"
|
||||
default:
|
||||
@@ -343,7 +372,7 @@ func embyDeviceFromUserAgent(ua string) string {
|
||||
return "Mac"
|
||||
case strings.Contains(lower, "linux"):
|
||||
return "Linux PC"
|
||||
case strings.Contains(lower, "appletv") || strings.Contains(lower, "apple tv"):
|
||||
case strings.Contains(lower, "appletv") || strings.Contains(lower, "apple tv") || strings.Contains(lower, "appletvos"):
|
||||
return "Apple TV"
|
||||
default:
|
||||
return ""
|
||||
|
||||
@@ -0,0 +1,594 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/glebarez/sqlite"
|
||||
"go.uber.org/zap"
|
||||
"gorm.io/gorm"
|
||||
|
||||
"github.com/truewhile/MeBox/internal/config"
|
||||
"github.com/truewhile/MeBox/internal/middleware"
|
||||
"github.com/truewhile/MeBox/internal/model"
|
||||
"github.com/truewhile/MeBox/internal/repository"
|
||||
"github.com/truewhile/MeBox/internal/service"
|
||||
)
|
||||
|
||||
func TestNormalizeEmbyPath(t *testing.T) {
|
||||
tests := []struct {
|
||||
input string
|
||||
wantPath string
|
||||
changed bool
|
||||
}{
|
||||
{
|
||||
input: "/emby/System/Info",
|
||||
wantPath: "/emby/system/info",
|
||||
changed: true,
|
||||
},
|
||||
{
|
||||
input: "/emby/emby/System/Info",
|
||||
wantPath: "/emby/system/info",
|
||||
changed: true,
|
||||
},
|
||||
{
|
||||
input: "/emby/emby/emby/items/123/playbackInfo",
|
||||
wantPath: "/emby/items/123/playbackinfo",
|
||||
changed: true,
|
||||
},
|
||||
{
|
||||
input: "//emby//System//Info//Public",
|
||||
wantPath: "/emby/system/info/public",
|
||||
changed: true,
|
||||
},
|
||||
{
|
||||
input: "/Items/msgo-series-1/PlaybackInfo",
|
||||
wantPath: "/items/msgo-series-1/playbackinfo",
|
||||
changed: true,
|
||||
},
|
||||
{
|
||||
input: "/Videos/m-123/Master.m3u8",
|
||||
wantPath: "/videos/m-123/master.m3u8",
|
||||
changed: true,
|
||||
},
|
||||
{
|
||||
input: "/api/unknown/other",
|
||||
wantPath: "/api/unknown/other",
|
||||
changed: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
gotPath, changed := NormalizeEmbyPath(tt.input)
|
||||
if gotPath != tt.wantPath || changed != tt.changed {
|
||||
t.Errorf("NormalizeEmbyPath(%q) = (%q, %v), want (%q, %v)", tt.input, gotPath, changed, tt.wantPath, tt.changed)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestEmbyDuplicatePrefixHandling(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
|
||||
if err != nil {
|
||||
t.Fatalf("open db: %v", err)
|
||||
}
|
||||
if err := db.AutoMigrate(&model.User{}); err != nil {
|
||||
t.Fatalf("migrate: %v", err)
|
||||
}
|
||||
repos := repository.New(db)
|
||||
svc := &service.Container{
|
||||
Repo: repos,
|
||||
Emby: service.NewEmbyService(nil, nil, repos),
|
||||
}
|
||||
|
||||
router := gin.New()
|
||||
registerEmbyRoutes(router, "secret", svc)
|
||||
|
||||
// 模拟重复拼接前缀的客户端请求: /emby/emby/System/Info/Public
|
||||
req := httptest.NewRequest(http.MethodGet, "/emby/emby/System/Info/Public", nil)
|
||||
w := httptest.NewRecorder()
|
||||
router.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("expected 200 OK for /emby/emby/System/Info/Public, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
if !strings.Contains(w.Body.String(), "ServerName") {
|
||||
t.Fatalf("expected server info body, got: %s", w.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestEmbyMixedCaseHandling(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
|
||||
if err != nil {
|
||||
t.Fatalf("open db: %v", err)
|
||||
}
|
||||
if err := db.AutoMigrate(&model.User{}); err != nil {
|
||||
t.Fatalf("migrate: %v", err)
|
||||
}
|
||||
repos := repository.New(db)
|
||||
svc := &service.Container{
|
||||
Repo: repos,
|
||||
Emby: service.NewEmbyService(nil, nil, repos),
|
||||
}
|
||||
|
||||
router := gin.New()
|
||||
registerEmbyRoutes(router, "secret", svc)
|
||||
|
||||
// 混合大小写驼峰: /emby/system/Info/Public
|
||||
req := httptest.NewRequest(http.MethodGet, "/emby/system/Info/Public", nil)
|
||||
w := httptest.NewRecorder()
|
||||
router.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("expected 200 OK for /emby/system/Info/Public, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestEmbyClientIdentification(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
ua string
|
||||
query string
|
||||
headerAuth string
|
||||
wantClient string
|
||||
}{
|
||||
{
|
||||
name: "CapyPlayer via UA",
|
||||
ua: "CapyPlayer/1.2.0 (iOS)",
|
||||
wantClient: "CapyPlayer",
|
||||
},
|
||||
{
|
||||
name: "SenPlayer via UA",
|
||||
ua: "SenPlayer/2.1",
|
||||
wantClient: "SenPlayer",
|
||||
},
|
||||
{
|
||||
name: "Fileball via UA",
|
||||
ua: "Fileball/1.0.0",
|
||||
wantClient: "Fileball",
|
||||
},
|
||||
{
|
||||
name: "Kodi via UA",
|
||||
ua: "Kodi/20.2",
|
||||
wantClient: "Kodi",
|
||||
},
|
||||
{
|
||||
name: "Client in query",
|
||||
ua: "CustomApp/1.0",
|
||||
query: "?X-Emby-Client=CapyPlayer",
|
||||
wantClient: "CapyPlayer",
|
||||
},
|
||||
{
|
||||
name: "Client in auth header",
|
||||
ua: "Custom/1.0",
|
||||
headerAuth: `MediaBrowser Client="SenPlayer", Device="AppleTV", DeviceId="abc"`,
|
||||
wantClient: "SenPlayer",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
c, _ := gin.CreateTestContext(httptest.NewRecorder())
|
||||
url := "/test"
|
||||
if tt.query != "" {
|
||||
url += tt.query
|
||||
}
|
||||
req := httptest.NewRequest(http.MethodGet, url, nil)
|
||||
if tt.ua != "" {
|
||||
req.Header.Set("User-Agent", tt.ua)
|
||||
}
|
||||
if tt.headerAuth != "" {
|
||||
req.Header.Set("X-Emby-Authorization", tt.headerAuth)
|
||||
}
|
||||
c.Request = req
|
||||
|
||||
info := embyClientInfoFromRequest(c)
|
||||
if info.Client != tt.wantClient {
|
||||
t.Fatalf("embyClientInfoFromRequest Client = %q, want %q", info.Client, tt.wantClient)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestEmbyAdaptivePrefixPlaybackInfo(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
|
||||
if err != nil {
|
||||
t.Fatalf("open db: %v", err)
|
||||
}
|
||||
if err := db.AutoMigrate(model.AllModels()...); err != nil {
|
||||
t.Fatalf("migrate: %v", err)
|
||||
}
|
||||
repos := repository.New(db)
|
||||
svc := &service.Container{
|
||||
Repo: repos,
|
||||
Emby: service.NewEmbyService(nil, nil, repos),
|
||||
}
|
||||
const secret = "test-secret"
|
||||
router := gin.New()
|
||||
registerEmbyRoutes(router, secret, svc)
|
||||
|
||||
token := signedTestToken(t, secret)
|
||||
if err := repos.User.Create(t.Context(), &model.User{
|
||||
Base: model.Base{ID: "user-1"},
|
||||
Username: "tester",
|
||||
PasswordHash: "x",
|
||||
Role: "admin",
|
||||
Tier: "plus",
|
||||
IsActive: true,
|
||||
}); err != nil {
|
||||
t.Fatalf("create user: %v", err)
|
||||
}
|
||||
if err := db.Create(&model.Media{
|
||||
Base: model.Base{ID: "m-adaptive-1"},
|
||||
Title: "测试媒体",
|
||||
Path: "D:\\media\\test.mkv",
|
||||
LibraryID: "lib-1",
|
||||
}).Error; err != nil {
|
||||
t.Fatalf("create media: %v", err)
|
||||
}
|
||||
|
||||
// 1. 从 /emby 前缀请求 PlaybackInfo
|
||||
req1 := httptest.NewRequest(http.MethodGet, "/emby/Items/m-adaptive-1/PlaybackInfo", nil)
|
||||
req1.Header.Set("X-Emby-Token", token)
|
||||
w1 := httptest.NewRecorder()
|
||||
router.ServeHTTP(w1, req1)
|
||||
|
||||
if w1.Code != http.StatusOK {
|
||||
t.Fatalf("playbackinfo /emby code = %d: %s", w1.Code, w1.Body.String())
|
||||
}
|
||||
var res1 struct {
|
||||
MediaSources []struct {
|
||||
DirectStreamURL string `json:"DirectStreamUrl"`
|
||||
} `json:"MediaSources"`
|
||||
}
|
||||
if err := json.Unmarshal(w1.Body.Bytes(), &res1); err != nil || len(res1.MediaSources) == 0 {
|
||||
t.Fatalf("unmarshal /emby response: %v, body: %s", err, w1.Body.String())
|
||||
}
|
||||
if !strings.Contains(res1.MediaSources[0].DirectStreamURL, "/Videos/m-adaptive-1/stream") {
|
||||
t.Fatalf("DirectStreamUrl should point to video stream endpoint, got: %s", res1.MediaSources[0].DirectStreamURL)
|
||||
}
|
||||
if !strings.Contains(res1.MediaSources[0].DirectStreamURL, "api_key="+token) {
|
||||
t.Fatalf("DirectStreamUrl should carry api_key token, got: %s", res1.MediaSources[0].DirectStreamURL)
|
||||
}
|
||||
|
||||
// 2. 从重复前缀 /emby/emby 请求 PlaybackInfo (模拟客户端再次追加 BaseUrl 场景)
|
||||
req2 := httptest.NewRequest(http.MethodGet, "/emby/emby/Items/m-adaptive-1/PlaybackInfo", nil)
|
||||
req2.Header.Set("X-Emby-Token", token)
|
||||
w2 := httptest.NewRecorder()
|
||||
router.ServeHTTP(w2, req2)
|
||||
|
||||
if w2.Code != http.StatusOK {
|
||||
t.Fatalf("playbackinfo /emby/emby code = %d: %s", w2.Code, w2.Body.String())
|
||||
}
|
||||
var res2 struct {
|
||||
MediaSources []struct {
|
||||
DirectStreamURL string `json:"DirectStreamUrl"`
|
||||
} `json:"MediaSources"`
|
||||
}
|
||||
if err := json.Unmarshal(w2.Body.Bytes(), &res2); err != nil || len(res2.MediaSources) == 0 {
|
||||
t.Fatalf("unmarshal /emby/emby response: %v, body: %s", err, w2.Body.String())
|
||||
}
|
||||
|
||||
// 3. 从根路径 /Items 请求 PlaybackInfo
|
||||
req3 := httptest.NewRequest(http.MethodGet, "/Items/m-adaptive-1/PlaybackInfo", nil)
|
||||
req3.Header.Set("X-Emby-Token", token)
|
||||
w3 := httptest.NewRecorder()
|
||||
router.ServeHTTP(w3, req3)
|
||||
|
||||
if w3.Code != http.StatusOK {
|
||||
t.Fatalf("playbackinfo root code = %d: %s", w3.Code, w3.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestEmbyImageClearNoStore(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
|
||||
if err != nil {
|
||||
t.Fatalf("open db: %v", err)
|
||||
}
|
||||
if err := db.AutoMigrate(model.AllModels()...); err != nil {
|
||||
t.Fatalf("migrate: %v", err)
|
||||
}
|
||||
repos := repository.New(db)
|
||||
svc := &service.Container{
|
||||
Repo: repos,
|
||||
Emby: service.NewEmbyService(nil, nil, repos),
|
||||
}
|
||||
|
||||
router := gin.New()
|
||||
registerEmbyRoutes(router, "secret", svc)
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/emby/Items/non-existent-item/Images/Primary", nil)
|
||||
w := httptest.NewRecorder()
|
||||
router.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("placeholder image should return 200, got %d", w.Code)
|
||||
}
|
||||
cacheControl := w.Header().Get("Cache-Control")
|
||||
if strings.Contains(cacheControl, "no-store") {
|
||||
t.Fatalf("image response should not have no-store, got: %s", cacheControl)
|
||||
}
|
||||
if !strings.Contains(cacheControl, "public") {
|
||||
t.Fatalf("image response should have public cache-control, got: %s", cacheControl)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEmbyTemporaryPasswordLogin(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
|
||||
if err != nil {
|
||||
t.Fatalf("open db: %v", err)
|
||||
}
|
||||
if err := db.AutoMigrate(model.AllModels()...); err != nil {
|
||||
t.Fatalf("migrate: %v", err)
|
||||
}
|
||||
repos := repository.New(db)
|
||||
cfg := &config.Config{}
|
||||
cfg.Secrets.JWTSecret = "test-jwt-secret-very-secure-key-12345"
|
||||
log := zap.NewNop()
|
||||
permissions := service.NewPermissionService(log, repos)
|
||||
tokenSvc := service.NewTokenService(cfg, log, repos)
|
||||
authSvc := service.NewAuthService(cfg, log, repos, tokenSvc, permissions)
|
||||
embySvc := service.NewEmbyService(nil, nil, repos)
|
||||
svc := &service.Container{
|
||||
Repo: repos,
|
||||
Auth: authSvc,
|
||||
Token: tokenSvc,
|
||||
Emby: embySvc,
|
||||
}
|
||||
|
||||
user, _, err := authSvc.Register(t.Context(), "tvuser", "strongpassword123")
|
||||
if err != nil {
|
||||
t.Fatalf("register: %v", err)
|
||||
}
|
||||
|
||||
// 1. 生成 6 位纯数字临时密码 (OTP)
|
||||
code, expireSec, err := authSvc.CreateTemporaryPassword(t.Context(), user.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("create temp password: %v", err)
|
||||
}
|
||||
if len(code) != 6 || expireSec <= 0 {
|
||||
t.Fatalf("invalid temp password format: %s, expire: %d", code, expireSec)
|
||||
}
|
||||
|
||||
router := gin.New()
|
||||
registerEmbyRoutes(router, "test-jwt-secret-very-secure-key-12345", svc)
|
||||
|
||||
// 2. 使用临时密码在 Emby 接口登录
|
||||
body := `{"Username":"tvuser","Pw":"` + code + `"}`
|
||||
req := httptest.NewRequest(http.MethodPost, "/emby/Users/AuthenticateByName", strings.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
w := httptest.NewRecorder()
|
||||
router.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("login with temp password code = %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
var loginResp struct {
|
||||
AccessToken string `json:"AccessToken"`
|
||||
User struct {
|
||||
ID string `json:"Id"`
|
||||
Name string `json:"Name"`
|
||||
} `json:"User"`
|
||||
}
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &loginResp); err != nil {
|
||||
t.Fatalf("unmarshal login resp: %v", err)
|
||||
}
|
||||
if loginResp.AccessToken == "" || loginResp.User.ID != user.ID {
|
||||
t.Fatalf("unexpected login payload: %#v", loginResp)
|
||||
}
|
||||
|
||||
// 3. 验证阅后即焚:第二次使用同一临时密码应登录失败 (401)
|
||||
req2 := httptest.NewRequest(http.MethodPost, "/emby/Users/AuthenticateByName", strings.NewReader(body))
|
||||
req2.Header.Set("Content-Type", "application/json")
|
||||
w2 := httptest.NewRecorder()
|
||||
router.ServeHTTP(w2, req2)
|
||||
|
||||
if w2.Code != http.StatusUnauthorized {
|
||||
t.Fatalf("second login with consumed temp password should fail, got %d", w2.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEmbySeriesArtworkInheritanceAndRunTimeTicksFallback(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
|
||||
if err != nil {
|
||||
t.Fatalf("open db: %v", err)
|
||||
}
|
||||
if err := db.AutoMigrate(model.AllModels()...); err != nil {
|
||||
t.Fatalf("migrate: %v", err)
|
||||
}
|
||||
repos := repository.New(db)
|
||||
cfg := &config.Config{}
|
||||
cfg.Secrets.JWTSecret = "test-secret-compat"
|
||||
log := zap.NewNop()
|
||||
permissions := service.NewPermissionService(log, repos)
|
||||
tokenSvc := service.NewTokenService(cfg, log, repos)
|
||||
authSvc := service.NewAuthService(cfg, log, repos, tokenSvc, permissions)
|
||||
embySvc := service.NewEmbyService(nil, nil, repos)
|
||||
svc := &service.Container{
|
||||
Repo: repos,
|
||||
Auth: authSvc,
|
||||
Token: tokenSvc,
|
||||
Emby: embySvc,
|
||||
}
|
||||
|
||||
user, _, err := authSvc.Register(t.Context(), "artworkuser", "password123")
|
||||
if err != nil {
|
||||
t.Fatalf("register: %v", err)
|
||||
}
|
||||
token, err := authSvc.IssueEmbyToken(user)
|
||||
if err != nil {
|
||||
t.Fatalf("issue token: %v", err)
|
||||
}
|
||||
|
||||
// 创建 TV Library
|
||||
lib := &model.Library{
|
||||
Name: "电视剧",
|
||||
Path: "/media/电视剧",
|
||||
Type: "tv",
|
||||
}
|
||||
lib.ID = "lib-tv-1"
|
||||
if err := db.Create(lib).Error; err != nil {
|
||||
t.Fatalf("create lib: %v", err)
|
||||
}
|
||||
|
||||
// 创建 Series
|
||||
series := &model.Series{
|
||||
LibraryID: "lib-tv-1",
|
||||
Title: "Test Drama",
|
||||
PosterURL: "https://example.com/series_poster.jpg",
|
||||
BackdropURL: "https://example.com/series_backdrop.jpg",
|
||||
}
|
||||
series.ID = "s-test-1"
|
||||
if err := db.Create(series).Error; err != nil {
|
||||
t.Fatalf("create series: %v", err)
|
||||
}
|
||||
|
||||
// 创建单集 Episode(DurationSec 为 0,但有播放进度 posMs,用于测试 RunTimeTicks 兜底)
|
||||
ep := &model.Media{
|
||||
LibraryID: "lib-tv-1",
|
||||
SeriesID: "s-test-1",
|
||||
Title: "Test Episode 1",
|
||||
Path: "/media/电视剧/Test Drama/Season 1/S01E01.mp4",
|
||||
SeasonNum: 1,
|
||||
EpisodeNum: 1,
|
||||
DurationSec: 0, // 未知时长
|
||||
PosterURL: "https://example.com/ep1_still.jpg",
|
||||
}
|
||||
ep.ID = "ep-test-1"
|
||||
if err := db.Create(ep).Error; err != nil {
|
||||
t.Fatalf("create ep: %v", err)
|
||||
}
|
||||
|
||||
router := gin.New()
|
||||
registerEmbyRoutes(router, cfg.Secrets.JWTSecret, svc)
|
||||
|
||||
// 1. 获取 Series 详情
|
||||
reqSeries := httptest.NewRequest(http.MethodGet, "/emby/Items/s-test-1", nil)
|
||||
reqSeries.Header.Set("X-Emby-Token", token)
|
||||
wSeries := httptest.NewRecorder()
|
||||
router.ServeHTTP(wSeries, reqSeries)
|
||||
if wSeries.Code != http.StatusOK {
|
||||
t.Fatalf("get series code = %d: %s", wSeries.Code, wSeries.Body.String())
|
||||
}
|
||||
var seriesPayload map[string]any
|
||||
_ = json.Unmarshal(wSeries.Body.Bytes(), &seriesPayload)
|
||||
if seriesPayload["PrimaryImageTag"] != "s-test-1" {
|
||||
t.Fatalf("series PrimaryImageTag should match series ID, got %v", seriesPayload["PrimaryImageTag"])
|
||||
}
|
||||
if _, ok := seriesPayload["People"]; !ok {
|
||||
t.Fatalf("series payload should include People array")
|
||||
}
|
||||
|
||||
// 2. 获取 Episode 详情,验证继承 SeriesPrimaryImageTag 和 ParentBackdropItemId
|
||||
// 添加一条播放进度记录 (posMs = 60000)
|
||||
hist := &model.PlaybackHistory{
|
||||
UserID: user.ID,
|
||||
MediaID: ep.ID,
|
||||
PositionMs: 60000,
|
||||
}
|
||||
_ = db.Create(hist).Error
|
||||
|
||||
reqEp := httptest.NewRequest(http.MethodGet, "/emby/Users/"+user.ID+"/Items/ep-test-1", nil)
|
||||
reqEp.Header.Set("X-Emby-Token", token)
|
||||
wEp := httptest.NewRecorder()
|
||||
router.ServeHTTP(wEp, reqEp)
|
||||
if wEp.Code != http.StatusOK {
|
||||
t.Fatalf("get ep code = %d: %s", wEp.Code, wEp.Body.String())
|
||||
}
|
||||
var epPayload map[string]any
|
||||
_ = json.Unmarshal(wEp.Body.Bytes(), &epPayload)
|
||||
t.Logf("epPayload: %#v", epPayload)
|
||||
|
||||
// 验证图片继承
|
||||
if epPayload["SeriesPrimaryImageTag"] != "s-test-1" {
|
||||
t.Fatalf("ep SeriesPrimaryImageTag should inherit series ID, got %v", epPayload["SeriesPrimaryImageTag"])
|
||||
}
|
||||
if epPayload["ParentBackdropItemId"] != "s-test-1" {
|
||||
t.Fatalf("ep ParentBackdropItemId should inherit series ID, got %v", epPayload["ParentBackdropItemId"])
|
||||
}
|
||||
if _, ok := epPayload["People"]; !ok {
|
||||
t.Fatalf("ep payload should include People array")
|
||||
}
|
||||
|
||||
// 验证 RunTimeTicks 兜底
|
||||
runTimeTicks, _ := epPayload["RunTimeTicks"].(float64)
|
||||
if runTimeTicks <= 0 {
|
||||
t.Fatalf("ep RunTimeTicks should be safely fallback to positive value, got %v", runTimeTicks)
|
||||
}
|
||||
userData, _ := epPayload["UserData"].(map[string]any)
|
||||
playedPct, _ := userData["PlayedPercentage"].(float64)
|
||||
if playedPct <= 0 {
|
||||
t.Fatalf("ep PlayedPercentage should be > 0, got %v", playedPct)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMeTemporaryPasswordEndpoint(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
|
||||
if err != nil {
|
||||
t.Fatalf("open db: %v", err)
|
||||
}
|
||||
if err := db.AutoMigrate(model.AllModels()...); err != nil {
|
||||
t.Fatalf("migrate: %v", err)
|
||||
}
|
||||
repos := repository.New(db)
|
||||
cfg := &config.Config{}
|
||||
cfg.Secrets.JWTSecret = "test-secret-temp"
|
||||
log := zap.NewNop()
|
||||
permissions := service.NewPermissionService(log, repos)
|
||||
tokenSvc := service.NewTokenService(cfg, log, repos)
|
||||
authSvc := service.NewAuthService(cfg, log, repos, tokenSvc, permissions)
|
||||
svc := &service.Container{
|
||||
Repo: repos,
|
||||
Auth: authSvc,
|
||||
Token: tokenSvc,
|
||||
}
|
||||
|
||||
user, tokens, err := authSvc.Register(t.Context(), "optuser", "password123")
|
||||
if err != nil {
|
||||
t.Fatalf("register: %v", err)
|
||||
}
|
||||
|
||||
router := gin.New()
|
||||
api := router.Group("/api")
|
||||
authed := api.Group("")
|
||||
authed.Use(func(c *gin.Context) {
|
||||
c.Set(middleware.CtxUserID, user.ID)
|
||||
c.Next()
|
||||
})
|
||||
registerAuthedUserAndLicenseRoutes(authed, svc)
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/me/temporary-password", nil)
|
||||
w := httptest.NewRecorder()
|
||||
router.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("generate temp password code = %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
var resp struct {
|
||||
Code string `json:"code"`
|
||||
ExpiresIn int `json:"expires_in"`
|
||||
}
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
|
||||
t.Fatalf("unmarshal resp: %v", err)
|
||||
}
|
||||
if len(resp.Code) != 6 || resp.ExpiresIn <= 0 {
|
||||
t.Fatalf("invalid temp password resp: %#v", resp)
|
||||
}
|
||||
|
||||
// 验证使用生成的临时密码能登录
|
||||
loginResp, err := authSvc.LoginWithTemporaryPassword(t.Context(), "optuser", resp.Code)
|
||||
if err != nil || loginResp == nil || loginResp.User.ID != user.ID {
|
||||
t.Fatalf("login with temp pass failed: %v", err)
|
||||
}
|
||||
_ = tokens
|
||||
}
|
||||
@@ -51,13 +51,14 @@ func embyItemImageHandler(svc *service.Container) gin.HandlerFunc {
|
||||
}
|
||||
|
||||
func clearEmbyImageNoStoreHeaders(c *gin.Context) {
|
||||
c.Writer.Header().Del("Cache-Control")
|
||||
c.Writer.Header().Del("Pragma")
|
||||
c.Writer.Header().Del("Expires")
|
||||
}
|
||||
|
||||
func embyServePlaceholderImage(c *gin.Context) {
|
||||
c.Header("Content-Type", "image/png")
|
||||
c.Header("Cache-Control", "public, max-age=3600")
|
||||
c.Header("Cache-Control", "public, max-age=86400")
|
||||
c.Header("Content-Length", strconv.Itoa(len(embyPlaceholderPNG)))
|
||||
if c.Request.Method == http.MethodHead {
|
||||
c.Status(http.StatusOK)
|
||||
|
||||
@@ -0,0 +1,190 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
var (
|
||||
multipleSlashesRE = regexp.MustCompile(`/{2,}`)
|
||||
)
|
||||
|
||||
// embyStaticSegments 包含 Emby API 中已知的保留静态路由分段(全部小写),
|
||||
// 用于在遇到客户端混合大小写(如 /System/info, /items/:id/playbackInfo)时,
|
||||
// 将静态段规范化为对应的小写形式,而保留动态参数段(:id, :userId 等)的原大小写。
|
||||
var embyStaticSegments = map[string]struct{}{
|
||||
"system": {},
|
||||
"info": {},
|
||||
"public": {},
|
||||
"endpoint": {},
|
||||
"configuration": {},
|
||||
"ping": {},
|
||||
"users": {},
|
||||
"useritems": {},
|
||||
"me": {},
|
||||
"authenticatebyname": {},
|
||||
"items": {},
|
||||
"counts": {},
|
||||
"latest": {},
|
||||
"resume": {},
|
||||
"playbackinfo": {},
|
||||
"shows": {},
|
||||
"seasons": {},
|
||||
"episodes": {},
|
||||
"nextup": {},
|
||||
"upcoming": {},
|
||||
"similar": {},
|
||||
"thumbnailset": {},
|
||||
"thememedia": {},
|
||||
"specialfeatures": {},
|
||||
"intros": {},
|
||||
"videos": {},
|
||||
"stream": {},
|
||||
"subtitles": {},
|
||||
"master.m3u8": {},
|
||||
"main.m3u8": {},
|
||||
"sessions": {},
|
||||
"playing": {},
|
||||
"progress": {},
|
||||
"stopped": {},
|
||||
"capabilities": {},
|
||||
"full": {},
|
||||
"logout": {},
|
||||
"views": {},
|
||||
"library": {},
|
||||
"mediafolders": {},
|
||||
"virtualfolders": {},
|
||||
"selectablemediafolders": {},
|
||||
"branding": {},
|
||||
"css": {},
|
||||
"localization": {},
|
||||
"options": {},
|
||||
"cultures": {},
|
||||
"customcssjs": {},
|
||||
"scripts": {},
|
||||
"displaypreferences": {},
|
||||
"quickconnect": {},
|
||||
"enabled": {},
|
||||
"startup": {},
|
||||
"complete": {},
|
||||
"favoriteitems": {},
|
||||
"playeditems": {},
|
||||
"images": {},
|
||||
"primary": {},
|
||||
"backdrop": {},
|
||||
"banner": {},
|
||||
"thumb": {},
|
||||
"logo": {},
|
||||
"serverdomains": {},
|
||||
"ext": {},
|
||||
"danmu": {},
|
||||
"raw": {},
|
||||
"mediasegments": {},
|
||||
"artists": {},
|
||||
"persons": {},
|
||||
"genres": {},
|
||||
"embywebsocket": {},
|
||||
}
|
||||
|
||||
// NormalizeEmbyPath 规范化 Emby 请求路径:
|
||||
// 1. 折叠重复斜杠(如 //emby/ -> /emby/);
|
||||
// 2. 折叠重复前缀(如 /emby/emby/System/Info -> /emby/System/Info);
|
||||
// 3. 将静态关键字段归一化为小写,同时保留动态 ID/参数的原有大小写。
|
||||
func NormalizeEmbyPath(p string) (string, bool) {
|
||||
if p == "" {
|
||||
return "", false
|
||||
}
|
||||
original := p
|
||||
|
||||
// 1. 折叠多余斜杠
|
||||
p = multipleSlashesRE.ReplaceAllString(p, "/")
|
||||
|
||||
// 2. 折叠重复的 /emby 前缀
|
||||
for {
|
||||
lower := strings.ToLower(p)
|
||||
if strings.HasPrefix(lower, "/emby/emby/") {
|
||||
p = "/emby/" + p[len("/emby/emby/"):]
|
||||
continue
|
||||
}
|
||||
if lower == "/emby/emby" {
|
||||
p = "/emby"
|
||||
break
|
||||
}
|
||||
break
|
||||
}
|
||||
|
||||
// 3. 分析是否具有 Emby 路由特征
|
||||
hasEmbyPrefix := false
|
||||
workPath := p
|
||||
if strings.HasPrefix(strings.ToLower(workPath), "/emby/") {
|
||||
hasEmbyPrefix = true
|
||||
workPath = workPath[len("/emby"):]
|
||||
} else if strings.EqualFold(workPath, "/emby") {
|
||||
return "/emby", original != "/emby"
|
||||
}
|
||||
|
||||
segments := strings.Split(strings.Trim(workPath, "/"), "/")
|
||||
if len(segments) == 0 || segments[0] == "" {
|
||||
return p, p != original
|
||||
}
|
||||
|
||||
// 检查第一段是否为 Emby 根路由关键字
|
||||
firstLower := strings.ToLower(segments[0])
|
||||
if _, ok := embyStaticSegments[firstLower]; !ok && firstLower != "api" {
|
||||
// 不是 Emby 相关路径,保持原样
|
||||
return original, false
|
||||
}
|
||||
|
||||
// 4. 将已知静态段转为小写,动态段保留原样
|
||||
for i, seg := range segments {
|
||||
segLower := strings.ToLower(seg)
|
||||
if _, isStatic := embyStaticSegments[segLower]; isStatic {
|
||||
if seg != segLower {
|
||||
segments[i] = segLower
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var builder strings.Builder
|
||||
if hasEmbyPrefix {
|
||||
builder.WriteString("/emby")
|
||||
}
|
||||
for _, seg := range segments {
|
||||
builder.WriteString("/")
|
||||
builder.WriteString(seg)
|
||||
}
|
||||
if strings.HasSuffix(original, "/") && !strings.HasSuffix(builder.String(), "/") {
|
||||
builder.WriteString("/")
|
||||
}
|
||||
|
||||
normalized := builder.String()
|
||||
return normalized, normalized != original
|
||||
}
|
||||
|
||||
const embyNormalizedCtxKey = "emby_normalized_path"
|
||||
|
||||
// TryHandleEmbyNormalizedRoute 尝试在 404 NoRoute 阶段对 Emby 路径做前缀与大小写纠偏并重定向分发。
|
||||
// 若成功分发并处理,返回 true;否则返回 false。
|
||||
func TryHandleEmbyNormalizedRoute(c *gin.Context, r *gin.Engine) bool {
|
||||
if c == nil || r == nil {
|
||||
return false
|
||||
}
|
||||
if c.GetBool(embyNormalizedCtxKey) {
|
||||
return false
|
||||
}
|
||||
normalized, changed := NormalizeEmbyPath(c.Request.URL.Path)
|
||||
if !changed {
|
||||
return false
|
||||
}
|
||||
|
||||
c.Set(embyNormalizedCtxKey, true)
|
||||
c.Request.URL.Path = normalized
|
||||
|
||||
// 重置 context 状态并由 engine 重新查找路由树
|
||||
c.Params = nil
|
||||
c.Writer.Header().Del("Content-Type")
|
||||
r.HandleContext(c)
|
||||
return true
|
||||
}
|
||||
@@ -23,6 +23,14 @@ func registerEmbyRoutes(r *gin.Engine, jwtSecret string, svc *service.Container)
|
||||
auth := grp.Group("", embyAuthRequiredWithSessionFallback(jwtSecret), activeEmbyUserRequired(svc), embyRealtimeSessionActivity(svc))
|
||||
registerEmbyAuthenticatedRoutes(auth, prefix, svc)
|
||||
}
|
||||
|
||||
// 为没有设置 NoRoute 的 Engine 提供 Emby 路径前缀与大小写纠偏(如单测或独立挂载环境)
|
||||
r.NoRoute(func(c *gin.Context) {
|
||||
if TryHandleEmbyNormalizedRoute(c, r) {
|
||||
return
|
||||
}
|
||||
c.Status(404)
|
||||
})
|
||||
}
|
||||
|
||||
type embyRouteHandlerFactory func(*service.Container) gin.HandlerFunc
|
||||
|
||||
@@ -34,11 +34,18 @@ func embyAuthByNameHandler(svc *service.Container) gin.HandlerFunc {
|
||||
embyError(c, http.StatusBadRequest, "missing username or password")
|
||||
return
|
||||
}
|
||||
resp, err := svc.Auth.Login(c.Request.Context(), req.Username, password)
|
||||
if err != nil {
|
||||
embyError(c, http.StatusUnauthorized, err.Error())
|
||||
return
|
||||
}
|
||||
resp, err := svc.Auth.Login(c.Request.Context(), req.Username, password)
|
||||
if err != nil {
|
||||
// 支持电视端/客户端一次性 6 位临时密码登录 (OTP)
|
||||
if tempResp, tempErr := svc.Auth.LoginWithTemporaryPassword(c.Request.Context(), req.Username, password); tempErr == nil {
|
||||
resp = tempResp
|
||||
err = nil
|
||||
}
|
||||
}
|
||||
if err != nil {
|
||||
embyError(c, http.StatusUnauthorized, err.Error())
|
||||
return
|
||||
}
|
||||
// 记录登录设备会话并执行防共享检测(登录客户端数 / 设备指纹)。
|
||||
clientInfo := embyClientInfoFromRequest(c)
|
||||
if svc.Sessions != nil {
|
||||
|
||||
+27
-10
@@ -170,7 +170,13 @@ func listLibrariesHandler(svc *service.Container) gin.HandlerFunc {
|
||||
if acct == nil {
|
||||
return
|
||||
}
|
||||
tmpMount := &model.EmbyMount{Base: model.Base{ID: v.MountID}}
|
||||
tmpMount := &model.EmbyMount{
|
||||
Base: model.Base{ID: v.MountID},
|
||||
AccountID: v.AccountID,
|
||||
RemoteViewID: v.RemoteID,
|
||||
CollectionType: v.CollectionType,
|
||||
Name: v.Library.Name,
|
||||
}
|
||||
itemTypes := remoteLibraryItemTypes(v.CollectionType)
|
||||
if _, total, err := svc.EmbyRemote.RemoteLibraryMedia(ctx, tmpMount, acct, v.RemoteID, itemTypes, 0, 1); err == nil {
|
||||
remotePayloads[i].Total = total
|
||||
@@ -482,7 +488,7 @@ func getMediaHandler(svc *service.Container) gin.HandlerFunc {
|
||||
c.JSON(http.StatusOK, m)
|
||||
return
|
||||
}
|
||||
m, err := svc.Media.GetMedia(ctx, id)
|
||||
m, err := svc.Media.GetMediaItem(ctx, id)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
@@ -491,7 +497,7 @@ func getMediaHandler(svc *service.Container) gin.HandlerFunc {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "not found"})
|
||||
return
|
||||
}
|
||||
if !mediaVisibleForRequest(c, svc, m) {
|
||||
if !mediaVisibleForRequest(c, svc, &m.Media) {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "not found"})
|
||||
return
|
||||
}
|
||||
@@ -660,14 +666,25 @@ func streamHandler(svc *service.Container) gin.HandlerFunc {
|
||||
}
|
||||
return
|
||||
}
|
||||
target, err := svc.EmbyRemote.WebStreamURL(ctx, acct, remoteID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()})
|
||||
target, err := svc.EmbyRemote.WebStreamURL(ctx, acct, remoteID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
// 现代浏览器在 HTTPS 页面中请求不安全源(HTTP 视频流)会直接报 Mixed Content 拦截导致播放失败。
|
||||
// 仅当当前前端请求为 HTTPS 且远程直连目标为 HTTP 时,自动降级通过本机反向代理传输流,避免播放被浏览器阻断;
|
||||
// 其它场景(HTTP 页面访问 HTTP/HTTPS,或 HTTPS 访问 HTTPS)继续 302 直连,最大化节省服务器带宽与流量。
|
||||
if requestIsHTTPS(c) && strings.HasPrefix(strings.ToLower(target), "http://") {
|
||||
if err := svc.Emby.ProxyRemoteVideoStream(ctx, c.Writer, c.Request, mountID, remoteID); err != nil {
|
||||
if !c.Writer.Written() {
|
||||
c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()})
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
setRedirectNoStoreHeaders(c)
|
||||
c.Redirect(http.StatusFound, target)
|
||||
return
|
||||
}
|
||||
setRedirectNoStoreHeaders(c)
|
||||
c.Redirect(http.StatusFound, target)
|
||||
return
|
||||
}
|
||||
m, err := svc.Media.GetMedia(ctx, id)
|
||||
if err != nil || m == nil || !mediaVisibleForRequest(c, svc, m) {
|
||||
|
||||
@@ -45,8 +45,8 @@ func playbackInfoHandler(svc *service.Container) gin.HandlerFunc {
|
||||
token := externalPlaybackToken(c, svc, m.ID, m.DurationSec)
|
||||
profileQuery := externalProfileQuery(c)
|
||||
hlsURL := "/api/hls/" + m.ID + "/index.m3u8?token=" + url.QueryEscape(token) + profileQuery
|
||||
if service.IsEmbyRemoteID(m.ID) || service.IsStrmMediaRow(m) {
|
||||
// Emby 远程挂载与 STRM 媒体一样,默认直连播放,不提供转码地址
|
||||
if service.IsEmbyRemoteID(m.ID) {
|
||||
// 远程 Emby 挂载没有本地文件,不能提供转码地址。STRM 默认直连,直连失败时可走 HLS。
|
||||
hlsURL = ""
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
|
||||
@@ -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{
|
||||
@@ -413,7 +416,7 @@ func newPlaybackScopeTestRouter(t *testing.T) (*gin.Engine, *service.Container,
|
||||
return router, svc, cfg.Secrets.JWTSecret
|
||||
}
|
||||
|
||||
func TestPlaybackInfoForSTRMMediaDisablesHLS(t *testing.T) {
|
||||
func TestPlaybackInfoForSTRMMediaIncludesHLS(t *testing.T) {
|
||||
router, _, secret := newPlaybackScopeTestRouter(t)
|
||||
loginToken := signedTestToken(t, secret)
|
||||
|
||||
@@ -435,8 +438,40 @@ func TestPlaybackInfoForSTRMMediaDisablesHLS(t *testing.T) {
|
||||
if payload.StreamURL == "" {
|
||||
t.Fatalf("expected non-empty stream_url")
|
||||
}
|
||||
if payload.HlsURL != "" {
|
||||
t.Fatalf("expected empty hls_url for STRM media, got %q", payload.HlsURL)
|
||||
if payload.HlsURL == "" || !strings.Contains(payload.HlsURL, "/api/hls/media-1/") {
|
||||
t.Fatalf("expected strm hls_url, got %q", payload.HlsURL)
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -14,6 +14,8 @@ func registerAuthedUserAndLicenseRoutes(authed *gin.RouterGroup, svc *service.Co
|
||||
authed.PUT("/me/pinned-libraries", setPinnedLibrariesHandler(svc))
|
||||
authed.POST("/me/password", changePasswordHandler(svc))
|
||||
authed.POST("/me/logout", logoutHandler(svc))
|
||||
authed.GET("/me/temporary-password", temporaryPasswordHandler(svc))
|
||||
authed.POST("/me/temporary-password", temporaryPasswordHandler(svc))
|
||||
|
||||
authed.GET("/auth/permissions", getMyPermissionsHandler(svc))
|
||||
}
|
||||
|
||||
@@ -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)})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,7 +14,7 @@ import (
|
||||
func hlsPlaylistHandler(svc *service.Container) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
id := c.Param("id")
|
||||
// 远程 Emby 挂载媒体与 STRM 一样,默认直连播放,不进行转码。
|
||||
// 远程 Emby 挂载没有本地文件,不能转码。STRM 允许直连失败后走 HLS。
|
||||
if svc.EmbyRemote != nil && service.IsEmbyRemoteID(id) {
|
||||
c.JSON(http.StatusConflict, gin.H{"error": "transcode disabled"})
|
||||
return
|
||||
|
||||
@@ -232,6 +232,7 @@ type strmSyncPathReq struct {
|
||||
DownloadMeta *bool `json:"download_meta"`
|
||||
UploadMeta *bool `json:"upload_meta"`
|
||||
DeleteDir *bool `json:"delete_dir"`
|
||||
KeepExt *bool `json:"keep_ext"`
|
||||
Cron string `json:"cron"`
|
||||
EnableCron *bool `json:"enable_cron"`
|
||||
SyncMode string `json:"sync_mode"`
|
||||
@@ -703,6 +704,7 @@ func strmSyncPathFromReq(req strmSyncPathReq) *model.StrmSyncPath {
|
||||
DownloadMeta: boolValue(req.DownloadMeta, true),
|
||||
UploadMeta: boolValue(req.UploadMeta, false),
|
||||
DeleteDir: boolValue(req.DeleteDir, false),
|
||||
KeepExt: boolValue(req.KeepExt, false),
|
||||
Cron: strings.TrimSpace(req.Cron),
|
||||
EnableCron: boolValue(req.EnableCron, false),
|
||||
SyncMode: strings.TrimSpace(req.SyncMode),
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -51,6 +51,9 @@ type StrmSyncPath struct {
|
||||
DownloadMeta bool `gorm:"default:true" json:"download_meta"` // 同步时下载元数据文件(nfo/图片/字幕)
|
||||
UploadMeta bool `json:"upload_meta"` // 同步时把本地元数据上传到远端
|
||||
DeleteDir bool `json:"delete_dir"` // 清理多余文件时删除空目录
|
||||
// KeepExt=true 时为每个视频生成 name.mkv.strm / name.mp4.strm(保留全部版本);
|
||||
// false(默认)时同名不同扩展只择优生成一条 name.strm,避免互相覆盖与来回抖动。
|
||||
KeepExt bool `json:"keep_ext"`
|
||||
Cron string `gorm:"size:128" json:"cron"` // 5 段 cron 表达式(可选)
|
||||
EnableCron bool `json:"enable_cron"` // 是否按 Cron 定时同步
|
||||
SyncMode string `gorm:"size:32;default:'incremental'" json:"sync_mode"` // 默认同步模式:incremental / full
|
||||
@@ -128,7 +131,7 @@ type StrmUploadTask struct {
|
||||
FileName string `gorm:"size:512" json:"file_name"`
|
||||
LocalPath string `gorm:"size:1024" json:"local_path"` // 本地源文件
|
||||
RemotePath string `gorm:"size:1024" json:"remote_path"` // 远端目标路径
|
||||
RemoteRef string `gorm:"size:1024" json:"remote_ref"` // 远端同名旧文件引用(115 文件 ID;上传覆盖前先删除旧文件,WebDAV/OpenList 直接覆盖无需删除)
|
||||
RemoteRef string `gorm:"size:1024" json:"remote_ref"` // 上传前:远端同名旧文件 ID(逗号分隔,覆盖前先删);上传成功后:新文件 ID
|
||||
Size int64 `json:"size"`
|
||||
Status string `gorm:"size:16;index" json:"status"`
|
||||
Error string `gorm:"size:1024" json:"error"`
|
||||
|
||||
@@ -110,6 +110,7 @@ func (r *StrmSyncPathRepository) Update(ctx context.Context, p *model.StrmSyncPa
|
||||
"download_meta": p.DownloadMeta,
|
||||
"upload_meta": p.UploadMeta,
|
||||
"delete_dir": p.DeleteDir,
|
||||
"keep_ext": p.KeepExt,
|
||||
"cron": p.Cron,
|
||||
"enable_cron": p.EnableCron,
|
||||
"sync_mode": p.SyncMode,
|
||||
@@ -861,6 +862,34 @@ func (r *StrmUploadTaskRepository) GetActiveLocalPathMap(ctx context.Context, sy
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// GetRecentDoneUploadSizeMap 返回近期已成功上传的 local_path → size。
|
||||
// 用于缩短「上传已 done 但 115 列表尚未反映」窗口内的重复入队:同路径且大小未变则跳过。
|
||||
// 同一路径存在多条 done 时取最新一条(finished_at 降序)。
|
||||
func (r *StrmUploadTaskRepository) GetRecentDoneUploadSizeMap(ctx context.Context, syncPathID string, since time.Time) (map[string]int64, error) {
|
||||
var rows []model.StrmUploadTask
|
||||
err := r.db.WithContext(ctx).Model(&model.StrmUploadTask{}).
|
||||
Select("local_path", "size", "finished_at").
|
||||
Where("sync_path_id = ? AND status = ? AND finished_at IS NOT NULL AND finished_at >= ?",
|
||||
syncPathID, model.StrmTaskDone, since).
|
||||
Order("finished_at DESC").
|
||||
Find(&rows).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := make(map[string]int64, len(rows))
|
||||
for _, row := range rows {
|
||||
if row.LocalPath == "" {
|
||||
continue
|
||||
}
|
||||
// 已按 finished_at DESC;先写入的是最新,后续同路径跳过
|
||||
if _, exists := out[row.LocalPath]; exists {
|
||||
continue
|
||||
}
|
||||
out[row.LocalPath] = row.Size
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (r *StrmUploadTaskRepository) DeleteFinishedOlderThan(ctx context.Context, before time.Time) error {
|
||||
return withSQLiteBusyRetry(ctx, func() error {
|
||||
return r.db.WithContext(ctx).Unscoped().Where("status IN ? AND finished_at < ?",
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
+115
-1
@@ -3,10 +3,13 @@ package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"errors"
|
||||
"fmt"
|
||||
"math/big"
|
||||
"os"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"go.uber.org/zap"
|
||||
@@ -18,6 +21,12 @@ import (
|
||||
"github.com/truewhile/MeBox/internal/repository"
|
||||
)
|
||||
|
||||
type temporaryPasswordEntry struct {
|
||||
userID string
|
||||
username string
|
||||
expiresAt time.Time
|
||||
}
|
||||
|
||||
// AuthService handles registration, login, and JWT issuance.
|
||||
type AuthService struct {
|
||||
cfg *config.Config
|
||||
@@ -25,11 +34,21 @@ type AuthService struct {
|
||||
repo *repository.Container
|
||||
tokenSvc *TokenService
|
||||
permissionSvc *PermissionService
|
||||
|
||||
tempPassMu sync.RWMutex
|
||||
tempPasswords map[string]temporaryPasswordEntry
|
||||
}
|
||||
|
||||
// NewAuthService is the constructor.
|
||||
func NewAuthService(cfg *config.Config, log *zap.Logger, repo *repository.Container, tokenSvc *TokenService, permissionSvc *PermissionService) *AuthService {
|
||||
return &AuthService{cfg: cfg, log: log, repo: repo, tokenSvc: tokenSvc, permissionSvc: permissionSvc}
|
||||
return &AuthService{
|
||||
cfg: cfg,
|
||||
log: log,
|
||||
repo: repo,
|
||||
tokenSvc: tokenSvc,
|
||||
permissionSvc: permissionSvc,
|
||||
tempPasswords: make(map[string]temporaryPasswordEntry),
|
||||
}
|
||||
}
|
||||
|
||||
// Common service-level errors.
|
||||
@@ -254,3 +273,98 @@ func hashPassword(p string) (string, error) {
|
||||
}
|
||||
return string(h), nil
|
||||
}
|
||||
|
||||
const temporaryPasswordTTL = 5 * time.Minute
|
||||
|
||||
// CreateTemporaryPassword 为指定用户生成一个 6 位数字的临时登录密码(有效期 5 分钟),
|
||||
// 供 Emby 电视端/客户端进行无键盘或快速输入登录。
|
||||
func (s *AuthService) CreateTemporaryPassword(ctx context.Context, userID string) (string, int, error) {
|
||||
if s == nil || s.repo == nil {
|
||||
return "", 0, errors.New("auth service unavailable")
|
||||
}
|
||||
user, err := s.repo.User.FindByID(ctx, userID)
|
||||
if err != nil || user == nil {
|
||||
return "", 0, ErrInvalidCredentials
|
||||
}
|
||||
if !user.IsActive {
|
||||
return "", 0, ErrUserInactive
|
||||
}
|
||||
if user.ExpiredAt != nil && time.Now().After(*user.ExpiredAt) {
|
||||
return "", 0, ErrUserExpired
|
||||
}
|
||||
|
||||
n, err := rand.Int(rand.Reader, big.NewInt(900000))
|
||||
if err != nil {
|
||||
return "", 0, err
|
||||
}
|
||||
code := fmt.Sprintf("%06d", n.Int64()+100000)
|
||||
|
||||
s.tempPassMu.Lock()
|
||||
defer s.tempPassMu.Unlock()
|
||||
now := time.Now()
|
||||
for k, v := range s.tempPasswords {
|
||||
if now.After(v.expiresAt) {
|
||||
delete(s.tempPasswords, k)
|
||||
}
|
||||
}
|
||||
s.tempPasswords[code] = temporaryPasswordEntry{
|
||||
userID: user.ID,
|
||||
username: user.Username,
|
||||
expiresAt: now.Add(temporaryPasswordTTL),
|
||||
}
|
||||
|
||||
return code, int(temporaryPasswordTTL.Seconds()), nil
|
||||
}
|
||||
|
||||
// VerifyAndConsumeTemporaryPassword 校验并消费临时登录密码(阅后即焚)。
|
||||
func (s *AuthService) VerifyAndConsumeTemporaryPassword(ctx context.Context, username, code string) (*model.User, bool) {
|
||||
if s == nil || s.repo == nil {
|
||||
return nil, false
|
||||
}
|
||||
code = strings.TrimSpace(code)
|
||||
if len(code) != 6 {
|
||||
return nil, false
|
||||
}
|
||||
s.tempPassMu.Lock()
|
||||
entry, ok := s.tempPasswords[code]
|
||||
if ok {
|
||||
delete(s.tempPasswords, code)
|
||||
}
|
||||
s.tempPassMu.Unlock()
|
||||
|
||||
if !ok || time.Now().After(entry.expiresAt) {
|
||||
return nil, false
|
||||
}
|
||||
|
||||
if strings.TrimSpace(username) != "" && !strings.EqualFold(strings.TrimSpace(username), entry.username) {
|
||||
return nil, false
|
||||
}
|
||||
|
||||
user, err := s.repo.User.FindByID(ctx, entry.userID)
|
||||
if err != nil || user == nil || !user.IsActive {
|
||||
return nil, false
|
||||
}
|
||||
if user.ExpiredAt != nil && time.Now().After(*user.ExpiredAt) {
|
||||
return nil, false
|
||||
}
|
||||
return user, true
|
||||
}
|
||||
|
||||
// LoginWithTemporaryPassword 尝试使用 6 位数字临时登录密码 (OTP) 进行登录。
|
||||
func (s *AuthService) LoginWithTemporaryPassword(ctx context.Context, username, code string) (*LoginResponse, error) {
|
||||
user, ok := s.VerifyAndConsumeTemporaryPassword(ctx, username, code)
|
||||
if !ok || user == nil {
|
||||
return nil, ErrInvalidCredentials
|
||||
}
|
||||
if s.tokenSvc == nil {
|
||||
return nil, errors.New("token service unavailable")
|
||||
}
|
||||
tokens, err := s.tokenSvc.IssuePair(ctx, user.ID, user.Role, user.Tier)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &LoginResponse{
|
||||
User: user,
|
||||
Tokens: tokens,
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -123,35 +123,41 @@ func (p *openAPI115Provider) ResolveBatch(ctx context.Context, fileRefs []string
|
||||
// OpenClient 暴露底层客户端(token 刷新用)。
|
||||
func (p *openAPI115Provider) OpenClient() *cloud115.OpenClient { return p.c }
|
||||
|
||||
// PutLocalFile 直接上传本地文件,避免通过 io.Reader 复制临时文件产生的磁盘开销与并发重命名碰撞。
|
||||
func (p *openAPI115Provider) PutLocalFile(ctx context.Context, parentCID, localPath string) error {
|
||||
_, err := p.c.Upload(ctx, localPath, parentCID, "", "")
|
||||
return err
|
||||
}
|
||||
|
||||
// PutFileNamed 把本地元数据上传到 115 指定父目录(parentCID 为父目录 cid)。
|
||||
// io.Reader 无法携带文件名,因此走独立的 named 上传接口。将内容落为临时文件后
|
||||
// 重命名为目标文件名,再交给 115 上传(/open/upload/init 的 file_name 取真实文件名)。
|
||||
// 为防止多并发上传线程在同一临时目录下发生同名文件(如 poster.jpg)碰撞覆盖与误删,
|
||||
// 为每个上传任务分配专属临时子目录。
|
||||
func (p *openAPI115Provider) PutFileNamed(ctx context.Context, parentCID, fileName string, r io.Reader) error {
|
||||
tmp, err := os.CreateTemp("", "mebox-upload-*")
|
||||
tmpDir, err := os.MkdirTemp("", "mebox-upload-*")
|
||||
if err != nil {
|
||||
return fmt.Errorf("115: 创建临时目录失败:%w", err)
|
||||
}
|
||||
defer func() {
|
||||
_ = os.RemoveAll(tmpDir)
|
||||
}()
|
||||
|
||||
safeName := filepath.Base(fileName)
|
||||
if safeName == "" || safeName == "." {
|
||||
safeName = "file"
|
||||
}
|
||||
tmpPath := filepath.Join(tmpDir, safeName)
|
||||
dst, err := os.OpenFile(tmpPath, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0o644)
|
||||
if err != nil {
|
||||
return fmt.Errorf("115: 创建临时文件失败:%w", err)
|
||||
}
|
||||
tmpPath := tmp.Name()
|
||||
defer func() {
|
||||
_ = tmp.Close()
|
||||
_ = os.Remove(tmpPath)
|
||||
}()
|
||||
if _, err := io.Copy(tmp, r); err != nil {
|
||||
if _, err := io.Copy(dst, r); err != nil {
|
||||
_ = dst.Close()
|
||||
return fmt.Errorf("115: 写入临时文件失败:%w", err)
|
||||
}
|
||||
if err := tmp.Close(); err != nil {
|
||||
if err := dst.Close(); err != nil {
|
||||
return fmt.Errorf("115: 关闭临时文件失败:%w", err)
|
||||
}
|
||||
// 重命名为目标文件名,保证上传到 115 后保留原始文件名。
|
||||
// 重命名失败必须 fail fast:静默用随机临时名上传会导致 115 上的文件名
|
||||
// 变成 mebox-upload-xxx,破坏元数据文件名契约。
|
||||
if fileName != "" && fileName != filepath.Base(tmpPath) {
|
||||
namedPath := filepath.Join(filepath.Dir(tmpPath), fileName)
|
||||
if err := os.Rename(tmpPath, namedPath); err != nil {
|
||||
return fmt.Errorf("115: 重命名临时文件为 %s 失败:%w", fileName, err)
|
||||
}
|
||||
tmpPath = namedPath
|
||||
}
|
||||
|
||||
_, err = p.c.Upload(ctx, tmpPath, parentCID, "", "")
|
||||
if err != nil {
|
||||
return err
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -119,6 +119,60 @@ func (c *OpenClient) GetFsListFlat(ctx context.Context, cid string, offset, limi
|
||||
return files, resp.Count, nil
|
||||
}
|
||||
|
||||
// FindNamedContentInParent 在父目录下查找与本地内容一致的同名文件。
|
||||
// 匹配条件:文件名完全一致、大小一致,且 SHA1 为空或与 expectedSHA1 大小写不敏感相等。
|
||||
// 同时返回该目录下全部同名文件(含未匹配的脏副本),便于上传前清理。
|
||||
// 目录过大时分页扫描,最多拉取 maxListPages 页(每页 pageSize 条)。
|
||||
func (c *OpenClient) FindNamedContentInParent(ctx context.Context, parentCID, fileName, expectedSHA1 string, expectedSize int64) (matched *RemoteFile, sameName []RemoteFile, err error) {
|
||||
fileName = strings.TrimSpace(fileName)
|
||||
if fileName == "" {
|
||||
return nil, nil, nil
|
||||
}
|
||||
const pageSize = 200
|
||||
const maxListPages = 20 // 最多扫描 4000 项,元数据父目录通常远小于此
|
||||
expectedSHA1 = strings.TrimSpace(expectedSHA1)
|
||||
offset := 0
|
||||
for page := 0; page < maxListPages; page++ {
|
||||
files, _, listErr := c.GetFsList(ctx, parentCID, offset, pageSize)
|
||||
if listErr != nil {
|
||||
return nil, sameName, listErr
|
||||
}
|
||||
if len(files) == 0 {
|
||||
break
|
||||
}
|
||||
for i := range files {
|
||||
f := files[i]
|
||||
if f.Category == TypeDir {
|
||||
continue
|
||||
}
|
||||
// fta=0/2 表示未上传完成,不可作为已存在副本
|
||||
if f.Fta == "0" || f.Fta == "2" {
|
||||
continue
|
||||
}
|
||||
if f.FileName != fileName {
|
||||
continue
|
||||
}
|
||||
sameName = append(sameName, f)
|
||||
if matched != nil {
|
||||
continue
|
||||
}
|
||||
if f.FileSize != expectedSize {
|
||||
continue
|
||||
}
|
||||
remoteSha := strings.TrimSpace(f.Sha1)
|
||||
if remoteSha == "" || remoteSha == "-" || strings.EqualFold(remoteSha, expectedSHA1) {
|
||||
cp := f
|
||||
matched = &cp
|
||||
}
|
||||
}
|
||||
if len(files) < pageSize {
|
||||
break
|
||||
}
|
||||
offset += len(files)
|
||||
}
|
||||
return matched, sameName, nil
|
||||
}
|
||||
|
||||
// GetFsDetailByCid 查询文件(夹)详情。
|
||||
func (c *OpenClient) GetFsDetailByCid(ctx context.Context, fileId string) (*RemoteFileDetail, error) {
|
||||
params := map[string]string{"file_id": fileId}
|
||||
@@ -423,24 +477,32 @@ func (c *OpenClient) GetToken(qrCode *QrCodeDataReturn) (*TokenData, error) {
|
||||
|
||||
// RefreshToken 刷新访问令牌。
|
||||
func (c *OpenClient) RefreshToken(refreshToken string) (*TokenData, error) {
|
||||
c.tokenMu.Lock()
|
||||
if refreshToken == "" {
|
||||
refreshToken = c.currentRefreshToken()
|
||||
refreshToken = c.RefreshTokenStr
|
||||
}
|
||||
if refreshToken == "" {
|
||||
c.tokenMu.Unlock()
|
||||
return nil, fmt.Errorf("没有可用的 refresh_token")
|
||||
}
|
||||
token, err := c.doRefreshToken(refreshToken)
|
||||
if err != nil {
|
||||
// refresh_token 已失效时清空内存令牌(提示需重新授权)
|
||||
if IsRefreshTokenDead(err) {
|
||||
c.SetAuthToken("", "")
|
||||
c.setAuthTokenLocked("", "")
|
||||
}
|
||||
c.tokenMu.Unlock()
|
||||
return nil, err
|
||||
}
|
||||
if token.AccessToken == "" || token.RefreshToken == "" {
|
||||
c.tokenMu.Unlock()
|
||||
return nil, fmt.Errorf("115: 刷新返回空凭证(access_token/refresh_token 缺失)")
|
||||
}
|
||||
c.SetAuthToken(token.AccessToken, token.RefreshToken)
|
||||
c.setAuthTokenLocked(token.AccessToken, token.RefreshToken)
|
||||
c.tokenMu.Unlock()
|
||||
if c.OnTokenRefreshed != nil {
|
||||
c.OnTokenRefreshed(token.AccessToken, token.RefreshToken)
|
||||
}
|
||||
return token, nil
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
package cloud115
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestFindNamedContentInParentMatchAndSameName(t *testing.T) {
|
||||
sha := "AABBCCDDEEFF00112233445566778899AABBCCDD"
|
||||
mockAPI(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != "/open/ufile/files" {
|
||||
t.Errorf("unexpected path %s", r.URL.Path)
|
||||
}
|
||||
w.Write([]byte(fmt.Sprintf(`{"state":true,"data":[
|
||||
{"fid":"dir-1","fc":"0","fn":"sub","fs":0},
|
||||
{"fid":"keep","fc":"1","fn":"a.nfo","fs":10,"sha1":%q,"fta":"1"},
|
||||
{"fid":"dirty","fc":"1","fn":"a.nfo","fs":10,"sha1":"OTHER","fta":"1"},
|
||||
{"fid":"other","fc":"1","fn":"b.nfo","fs":10,"sha1":%q,"fta":"1"},
|
||||
{"fid":"incomplete","fc":"1","fn":"a.nfo","fs":10,"sha1":%q,"fta":"0"}
|
||||
]}`, sha, sha, sha)))
|
||||
})
|
||||
c := NewOpenClient("100195125", "at1", "rt1")
|
||||
matched, sameName, err := c.FindNamedContentInParent(context.Background(), "parent", "a.nfo", sha, 10)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if matched == nil || matched.FileId != "keep" {
|
||||
t.Fatalf("matched = %+v, want keep", matched)
|
||||
}
|
||||
if len(sameName) != 2 { // keep + dirty;incomplete 被 fta 过滤
|
||||
t.Fatalf("sameName len=%d, want 2 (incomplete excluded)", len(sameName))
|
||||
}
|
||||
}
|
||||
|
||||
func TestFindNamedContentInParentNoMatch(t *testing.T) {
|
||||
mockAPI(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Write([]byte(`{"state":true,"data":[
|
||||
{"fid":"x","fc":"1","fn":"a.nfo","fs":9,"sha1":"OTHER","fta":"1"}
|
||||
]}`))
|
||||
})
|
||||
c := NewOpenClient("100195125", "at1", "rt1")
|
||||
matched, sameName, err := c.FindNamedContentInParent(context.Background(), "parent", "a.nfo", "WANT", 10)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if matched != nil {
|
||||
t.Fatalf("expected no match, got %+v", matched)
|
||||
}
|
||||
if len(sameName) != 1 {
|
||||
t.Fatalf("sameName should still list name hits, got %d", len(sameName))
|
||||
}
|
||||
}
|
||||
@@ -369,7 +369,10 @@ func (s *DanmakuService) searchTerms(ctx context.Context, mediaID string) (danma
|
||||
} else if name := strings.TrimSpace(m.Title); name != "" {
|
||||
term.name = name
|
||||
} else {
|
||||
term.name = strings.TrimSuffix(filepath.Base(m.Path), filepath.Ext(m.Path))
|
||||
term.name = mediaSidecarBase(m.Path)
|
||||
if term.name == "" {
|
||||
term.name = strings.TrimSuffix(filepath.Base(m.Path), filepath.Ext(m.Path))
|
||||
}
|
||||
}
|
||||
if m.EpisodeNum > 0 {
|
||||
term.episode = strconv.Itoa(m.EpisodeNum)
|
||||
@@ -527,23 +530,7 @@ func (s *DanmakuService) hashCachePut(stamp, hash string) {
|
||||
// ("xxx.mkv.strm") — so a second strip removes a real video extension only
|
||||
// (filepath.Ext would misread names like "xxx.第01话" as having an extension).
|
||||
func danmakuMatchFileName(path string) string {
|
||||
if path == "" {
|
||||
return ""
|
||||
}
|
||||
clean := strings.ReplaceAll(path, "\\", "/")
|
||||
if idx := strings.LastIndex(clean, "/"); idx >= 0 {
|
||||
clean = clean[idx+1:]
|
||||
}
|
||||
base := filepath.Base(clean)
|
||||
if ext := filepath.Ext(base); ext != "" {
|
||||
base = strings.TrimSuffix(base, ext)
|
||||
}
|
||||
if second := strings.ToLower(filepath.Ext(base)); second != "" {
|
||||
if _, ok := videoExtensions[second]; ok && second != ".strm" {
|
||||
base = strings.TrimSuffix(base, filepath.Ext(base))
|
||||
}
|
||||
}
|
||||
return strings.TrimSpace(base)
|
||||
return mediaSidecarBase(path)
|
||||
}
|
||||
|
||||
// mediaHash returns the dandanplay match hash (MD5 of the first 16MB of the
|
||||
|
||||
@@ -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}.*$`)
|
||||
)
|
||||
|
||||
@@ -2,7 +2,11 @@ package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@@ -455,6 +459,13 @@ func (e *EmbyService) itemPayload(ctx context.Context, m *model.Media, fav bool,
|
||||
|
||||
runTimeTicks := int64(m.DurationSec) * 10_000_000
|
||||
durationMs := int64(m.DurationSec) * 1000
|
||||
if durationMs <= 0 && posMs > 0 {
|
||||
durationMs = posMs * 2
|
||||
if durationMs < 30*60*1000 {
|
||||
durationMs = 30 * 60 * 1000
|
||||
}
|
||||
runTimeTicks = durationMs * 10_000
|
||||
}
|
||||
played := posMs > 0 && durationMs > 0 && posMs >= durationMs*9/10
|
||||
pct := 0.0
|
||||
if durationMs > 0 {
|
||||
@@ -488,6 +499,7 @@ func (e *EmbyService) itemPayload(ctx context.Context, m *model.Media, fav bool,
|
||||
"ImageTags": imageTags,
|
||||
"BackdropImageTags": backdropTags,
|
||||
"Genres": splitCSV(m.Genres),
|
||||
"People": e.resolveMediaPeople(ctx, m),
|
||||
"ProviderIds": map[string]string{
|
||||
"Tmdb": intToStr(m.TMDbID),
|
||||
"Bangumi": intToStr(m.BangumiID),
|
||||
@@ -501,8 +513,106 @@ func (e *EmbyService) itemPayload(ctx context.Context, m *model.Media, fav bool,
|
||||
},
|
||||
"MediaSources": e.mediaSourcesForItem(ctx, m, true, false),
|
||||
}
|
||||
if primaryArtwork != "" {
|
||||
item["PrimaryImageTag"] = m.ID
|
||||
}
|
||||
if seriesID != "" {
|
||||
if sEntry, ok, _ := e.payloadSeriesEntry(ctx, seriesID); ok {
|
||||
if sEntry.posterURL != "" {
|
||||
item["SeriesPrimaryImageTag"] = seriesID
|
||||
}
|
||||
if len(backdropTags) == 0 && sEntry.backdropURL != "" {
|
||||
item["ParentBackdropItemId"] = seriesID
|
||||
item["ParentBackdropImageTags"] = []string{seriesID + "-bd"}
|
||||
}
|
||||
}
|
||||
}
|
||||
if premiered, ok := embyPremiereDate(m.ReleaseDate); ok {
|
||||
item["PremiereDate"] = premiered
|
||||
}
|
||||
return item
|
||||
}
|
||||
|
||||
func (e *EmbyService) resolveMediaPeople(ctx context.Context, m *model.Media) []map[string]any {
|
||||
if m == nil || strings.TrimSpace(m.Path) == "" {
|
||||
return []map[string]any{}
|
||||
}
|
||||
dir := filepath.Dir(m.Path)
|
||||
candidates := make([]string, 0, 6)
|
||||
seenPath := map[string]struct{}{}
|
||||
add := func(path string) {
|
||||
path = strings.TrimSpace(path)
|
||||
if path == "" {
|
||||
return
|
||||
}
|
||||
key := strings.ToLower(filepath.Clean(path))
|
||||
if _, ok := seenPath[key]; ok {
|
||||
return
|
||||
}
|
||||
seenPath[key] = struct{}{}
|
||||
candidates = append(candidates, path)
|
||||
}
|
||||
// 共享词干优先(竞女01.mkv.strm → 竞女01.nfo),并兼容旧的单层剥扩展命名。
|
||||
add(nfoPath(m.Path))
|
||||
for _, base := range mediaSidecarBaseVariants(m.Path) {
|
||||
add(filepath.Join(dir, base+".nfo"))
|
||||
}
|
||||
add(filepath.Join(dir, "movie.nfo"))
|
||||
add(filepath.Join(dir, "tvshow.nfo"))
|
||||
|
||||
people := make([]map[string]any, 0)
|
||||
seen := make(map[string]bool)
|
||||
|
||||
for _, p := range candidates {
|
||||
if fi, err := os.Stat(p); err == nil && !fi.IsDir() {
|
||||
doc, _, err := decodeNFOFile(p)
|
||||
if err == nil && doc != nil {
|
||||
for _, d := range doc.Directors {
|
||||
name := strings.TrimSpace(d)
|
||||
if name == "" {
|
||||
continue
|
||||
}
|
||||
personID := embyPersonID(name, "Director")
|
||||
if seen[personID] {
|
||||
continue
|
||||
}
|
||||
seen[personID] = true
|
||||
people = append(people, map[string]any{
|
||||
"Id": personID,
|
||||
"Name": name,
|
||||
"Type": "Director",
|
||||
"Role": "Director",
|
||||
})
|
||||
}
|
||||
for _, a := range doc.Actors {
|
||||
name := strings.TrimSpace(a.Name)
|
||||
if name == "" {
|
||||
continue
|
||||
}
|
||||
personID := embyPersonID(name, "Actor")
|
||||
if seen[personID] {
|
||||
continue
|
||||
}
|
||||
seen[personID] = true
|
||||
role := strings.TrimSpace(a.Role)
|
||||
if role == "" {
|
||||
role = "Actor"
|
||||
}
|
||||
people = append(people, map[string]any{
|
||||
"Id": personID,
|
||||
"Name": name,
|
||||
"Type": "Actor",
|
||||
"Role": role,
|
||||
})
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
return people
|
||||
}
|
||||
|
||||
func embyPersonID(name, roleType string) string {
|
||||
sum := sha256.Sum256([]byte(strings.ToLower(strings.TrimSpace(name)) + ":" + strings.ToLower(strings.TrimSpace(roleType))))
|
||||
return "person-" + hex.EncodeToString(sum[:8])
|
||||
}
|
||||
|
||||
@@ -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/",
|
||||
"/电视剧/", "/剧集/", "/连续剧/", "/短剧/", "/国产剧/", "/国剧/", "/大陆剧/", "/华语剧/", "/国产电视剧/", "/大陆电视剧/", "/华语电视剧/", "/欧美剧/", "/欧美电视剧/", "/美剧/", "/英剧/", "/日韩剧/", "/日韩电视剧/", "/日剧/", "/韩剧/", "/港剧/", "/台剧/", "/港台剧/", "/泰剧/",
|
||||
"/日番/", "/国漫/", "/番剧/", "/动漫/", "/特别篇/", "/特別篇/", "/番外/", "/特典/",
|
||||
} {
|
||||
|
||||
@@ -23,10 +23,17 @@ type embyLibraryTypeEntry struct {
|
||||
found bool // 库不存在时 found=false,调用方可退回计数启发式
|
||||
}
|
||||
|
||||
type embyPayloadSeriesEntry struct {
|
||||
title string
|
||||
posterURL string
|
||||
backdropURL string
|
||||
found bool
|
||||
}
|
||||
|
||||
type embyPayloadCache struct {
|
||||
mu sync.Mutex
|
||||
libTypes map[string]embyLibraryTypeEntry
|
||||
series map[string]string // series_id -> title("" 表示不存在/无标题)
|
||||
series map[string]embyPayloadSeriesEntry
|
||||
}
|
||||
|
||||
func (c *embyPayloadCache) libraryType(id string) (embyLibraryTypeEntry, bool) {
|
||||
@@ -45,14 +52,33 @@ func (c *embyPayloadCache) setLibraryType(id string, entry embyLibraryTypeEntry)
|
||||
func (c *embyPayloadCache) seriesTitle(id string) (string, bool) {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
title, ok := c.series[id]
|
||||
return title, ok
|
||||
entry, ok := c.series[id]
|
||||
if !ok {
|
||||
return "", false
|
||||
}
|
||||
return entry.title, true
|
||||
}
|
||||
|
||||
func (c *embyPayloadCache) seriesEntry(id string) (embyPayloadSeriesEntry, bool) {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
entry, ok := c.series[id]
|
||||
return entry, ok
|
||||
}
|
||||
|
||||
func (c *embyPayloadCache) setSeriesEntry(id string, entry embyPayloadSeriesEntry) {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
c.series[id] = entry
|
||||
}
|
||||
|
||||
func (c *embyPayloadCache) setSeriesTitle(id, title string) {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
c.series[id] = title
|
||||
entry := c.series[id]
|
||||
entry.title = title
|
||||
entry.found = true
|
||||
c.series[id] = entry
|
||||
}
|
||||
|
||||
// withPayloadCache attaches a fresh request-scoped cache if none exists yet.
|
||||
@@ -65,7 +91,7 @@ func (e *EmbyService) withPayloadCache(ctx context.Context) context.Context {
|
||||
}
|
||||
return context.WithValue(ctx, embyPayloadCacheKey{}, &embyPayloadCache{
|
||||
libTypes: map[string]embyLibraryTypeEntry{},
|
||||
series: map[string]string{},
|
||||
series: map[string]embyPayloadSeriesEntry{},
|
||||
})
|
||||
}
|
||||
|
||||
@@ -113,17 +139,22 @@ func (e *EmbyService) prefetchPayloadCache(ctx context.Context, rows []model.Med
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(seriesIDs) > 0 {
|
||||
var series []model.Series
|
||||
if err := e.repo.DB.WithContext(ctx).Select("id, title").Where("id IN ?", seriesIDs).Find(&series).Error; err == nil {
|
||||
for _, s := range series {
|
||||
cache.setSeriesTitle(s.ID, s.Title)
|
||||
if len(seriesIDs) > 0 {
|
||||
var series []model.Series
|
||||
if err := e.repo.DB.WithContext(ctx).Select("id, title, poster_url, backdrop_url").Where("id IN ?", seriesIDs).Find(&series).Error; err == nil {
|
||||
for _, s := range series {
|
||||
cache.setSeriesEntry(s.ID, embyPayloadSeriesEntry{
|
||||
title: s.Title,
|
||||
posterURL: s.PosterURL,
|
||||
backdropURL: s.BackdropURL,
|
||||
found: true,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// payloadLibraryType resolves a library type through the request cache,
|
||||
// payloadLibraryType resolves a library type through the request cache,
|
||||
// falling back to a direct lookup when no cache is attached. found=false
|
||||
// means the library row does not exist (soft-deleted or orphaned id).
|
||||
func (e *EmbyService) payloadLibraryType(ctx context.Context, libraryID string) (typ string, found bool, err error) {
|
||||
@@ -152,24 +183,50 @@ func (e *EmbyService) payloadLibraryType(ctx context.Context, libraryID string)
|
||||
// payloadSeriesTitle resolves a series title through the request cache,
|
||||
// falling back to a direct lookup when no cache is attached.
|
||||
func (e *EmbyService) payloadSeriesTitle(ctx context.Context, seriesID string) (string, bool, error) {
|
||||
if cache, ok := ctx.Value(embyPayloadCacheKey{}).(*embyPayloadCache); ok {
|
||||
if title, hit := cache.seriesTitle(seriesID); hit {
|
||||
return title, true, nil
|
||||
}
|
||||
var s model.Series
|
||||
if err := e.repo.DB.WithContext(ctx).Select("id, title").Where("id = ?", seriesID).First(&s).Error; err != nil {
|
||||
cache.setSeriesTitle(seriesID, "")
|
||||
return "", true, nil
|
||||
}
|
||||
cache.setSeriesTitle(s.ID, s.Title)
|
||||
return s.Title, true, nil
|
||||
}
|
||||
series, err := e.repo.Series.FindByID(ctx, seriesID)
|
||||
if err != nil {
|
||||
entry, ok, err := e.payloadSeriesEntry(ctx, seriesID)
|
||||
if err != nil || !ok {
|
||||
return "", false, err
|
||||
}
|
||||
if series == nil {
|
||||
return "", false, nil
|
||||
}
|
||||
return series.Title, true, nil
|
||||
return entry.title, true, nil
|
||||
}
|
||||
|
||||
// payloadSeriesEntry resolves a series entry through the request cache,
|
||||
// falling back to a direct lookup when no cache is attached.
|
||||
func (e *EmbyService) payloadSeriesEntry(ctx context.Context, seriesID string) (embyPayloadSeriesEntry, bool, error) {
|
||||
if cache, ok := ctx.Value(embyPayloadCacheKey{}).(*embyPayloadCache); ok {
|
||||
if entry, hit := cache.seriesEntry(seriesID); hit {
|
||||
return entry, entry.found, nil
|
||||
}
|
||||
if e.repo != nil && e.repo.Series != nil {
|
||||
s, err := e.repo.Series.FindByID(ctx, seriesID)
|
||||
if err != nil || s == nil {
|
||||
cache.setSeriesEntry(seriesID, embyPayloadSeriesEntry{})
|
||||
return embyPayloadSeriesEntry{}, false, err
|
||||
}
|
||||
entry := embyPayloadSeriesEntry{
|
||||
title: s.Title,
|
||||
posterURL: s.PosterURL,
|
||||
backdropURL: s.BackdropURL,
|
||||
found: true,
|
||||
}
|
||||
cache.setSeriesEntry(seriesID, entry)
|
||||
return entry, true, nil
|
||||
}
|
||||
}
|
||||
if e.repo != nil && e.repo.Series != nil {
|
||||
series, err := e.repo.Series.FindByID(ctx, seriesID)
|
||||
if err != nil {
|
||||
return embyPayloadSeriesEntry{}, false, err
|
||||
}
|
||||
if series == nil {
|
||||
return embyPayloadSeriesEntry{}, false, nil
|
||||
}
|
||||
return embyPayloadSeriesEntry{
|
||||
title: series.Title,
|
||||
posterURL: series.PosterURL,
|
||||
backdropURL: series.BackdropURL,
|
||||
found: true,
|
||||
}, true, nil
|
||||
}
|
||||
return embyPayloadSeriesEntry{}, false, nil
|
||||
}
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/truewhile/MeBox/internal/model"
|
||||
)
|
||||
|
||||
func TestResolveMediaPeopleUsesSharedStemBesideKeepExtStrm(t *testing.T) {
|
||||
svc := newTestEmbyService(t)
|
||||
dir := t.TempDir()
|
||||
mediaPath := filepath.Join(dir, "竞女01.mkv.strm")
|
||||
nfoPath := filepath.Join(dir, "竞女01.nfo")
|
||||
if err := os.WriteFile(mediaPath, []byte("http://example/play"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
nfo := `<movie>
|
||||
<title>竞女01</title>
|
||||
<director>导演甲</director>
|
||||
<actor><name>演员乙</name><role>主角</role></actor>
|
||||
</movie>`
|
||||
if err := os.WriteFile(nfoPath, []byte(nfo), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
people := svc.resolveMediaPeople(t.Context(), &model.Media{Path: mediaPath, Title: "竞女01"})
|
||||
if len(people) != 2 {
|
||||
t.Fatalf("people=%d want 2: %#v", len(people), people)
|
||||
}
|
||||
got := map[string]string{}
|
||||
for _, p := range people {
|
||||
got[p["Name"].(string)] = p["Type"].(string)
|
||||
}
|
||||
if got["导演甲"] != "Director" || got["演员乙"] != "Actor" {
|
||||
t.Fatalf("unexpected people: %#v", people)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveMediaPeopleStillReadsLegacyKeepExtNFO(t *testing.T) {
|
||||
svc := newTestEmbyService(t)
|
||||
dir := t.TempDir()
|
||||
mediaPath := filepath.Join(dir, "竞女01.mkv.strm")
|
||||
legacyNFO := filepath.Join(dir, "竞女01.mkv.nfo")
|
||||
if err := os.WriteFile(mediaPath, []byte("http://example/play"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(legacyNFO, []byte(`<movie><director>旧导演</director></movie>`), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
people := svc.resolveMediaPeople(t.Context(), &model.Media{Path: mediaPath})
|
||||
if len(people) != 1 || people[0]["Name"] != "旧导演" {
|
||||
t.Fatalf("expected legacy keep_ext nfo people, got %#v", people)
|
||||
}
|
||||
}
|
||||
@@ -321,7 +321,7 @@ func (e *EmbyService) mediaSource(ctx context.Context, m *model.Media, asEmbedde
|
||||
func (e *EmbyService) baseMediaSource(ctx context.Context, m *model.Media, container string, isCloud bool, playURL string, directOnly bool) map[string]any {
|
||||
return map[string]any{
|
||||
"Id": m.ID,
|
||||
"Name": m.Title,
|
||||
"Name": MediaVersionLabel(*m),
|
||||
"Path": embyMediaSourcePath(m),
|
||||
"Container": container,
|
||||
"Size": m.SizeBytes,
|
||||
|
||||
@@ -139,6 +139,37 @@ func (r *EmbyRemoteService) ListAccounts(ctx context.Context) ([]model.StrmAccou
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// ConfiguredRemoteHosts 返回所有已配置的远程 Emby 线路的主机名/IP(去重、不含端口)。
|
||||
func (r *EmbyRemoteService) ConfiguredRemoteHosts(ctx context.Context) []string {
|
||||
if r == nil || r.repo == nil || r.repo.StrmAccount == nil {
|
||||
return nil
|
||||
}
|
||||
accounts, err := r.ListAccounts(ctx)
|
||||
if err != nil || len(accounts) == 0 {
|
||||
return nil
|
||||
}
|
||||
seen := make(map[string]bool)
|
||||
var hosts []string
|
||||
for _, acct := range accounts {
|
||||
lines, _, err := r.LinesOf(&acct)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
for _, line := range lines {
|
||||
u, err := url.Parse(line.URL)
|
||||
if err != nil || u.Hostname() == "" {
|
||||
continue
|
||||
}
|
||||
h := strings.ToLower(u.Hostname())
|
||||
if !seen[h] {
|
||||
seen[h] = true
|
||||
hosts = append(hosts, h)
|
||||
}
|
||||
}
|
||||
}
|
||||
return hosts
|
||||
}
|
||||
|
||||
// AccountByID 按 ID 查找远程 Emby 挂载账号(不存在或类型不符返回 nil)。
|
||||
func (r *EmbyRemoteService) AccountByID(ctx context.Context, id string) *model.StrmAccount {
|
||||
if strings.TrimSpace(id) == "" {
|
||||
@@ -789,6 +820,7 @@ func (r *EmbyRemoteService) RemoteLatest(ctx context.Context, mount *model.EmbyM
|
||||
if parentID != "" {
|
||||
q.Set("ParentId", parentID)
|
||||
}
|
||||
q.Set("Fields", "Overview,Genres,ProviderIds,Path,SeriesPrimaryImage,DateCreated,DateLastMediaAdded,PremiereDate,ProductionYear,CommunityRating,CriticRating")
|
||||
path := "/Users/" + url.PathEscape(r.remoteUserID(cfg)) + "/Items/Latest"
|
||||
var out []map[string]any
|
||||
if err := r.doGet(ctx, acct, cfg, path, q, &out); err != nil {
|
||||
|
||||
@@ -180,21 +180,31 @@ func (r *EmbyRemoteService) MapRemoteItemToMedia(ctx context.Context, mount *mod
|
||||
if _, rid, ok := DecodeEmbyRemoteID(seriesID); ok {
|
||||
seriesID = rid
|
||||
}
|
||||
rating := remoteItemFloat(item, "CommunityRating")
|
||||
if rating == 0 {
|
||||
rating = remoteItemFloat(item, "CriticRating")
|
||||
}
|
||||
media := model.Media{
|
||||
Base: model.Base{ID: EncodeEmbyRemoteID(encodeScope, remoteID)},
|
||||
Title: remoteItemString(item, "Name"),
|
||||
OriginalName: remoteItemString(item, "OriginalTitle"),
|
||||
Overview: remoteItemString(item, "Overview"),
|
||||
Year: remoteItemInt(item, "ProductionYear"),
|
||||
Rating: float32(rating),
|
||||
Path: remoteItemString(item, "Path"),
|
||||
Genres: remoteItemGenres(item),
|
||||
ScrapeStatus: "done",
|
||||
}
|
||||
rating := remoteItemFloat(item, "CommunityRating")
|
||||
if rating == 0 {
|
||||
rating = remoteItemFloat(item, "CriticRating")
|
||||
}
|
||||
year := remoteItemInt(item, "ProductionYear")
|
||||
if year == 0 {
|
||||
year = remoteItemInt(item, "Year")
|
||||
}
|
||||
if year == 0 {
|
||||
year = remoteItemInt(item, "SeriesProductionYear")
|
||||
}
|
||||
if year == 0 {
|
||||
year = remoteItemInt(item, "SeriesYear")
|
||||
}
|
||||
media := model.Media{
|
||||
Base: model.Base{ID: EncodeEmbyRemoteID(encodeScope, remoteID)},
|
||||
Title: remoteItemString(item, "Name"),
|
||||
OriginalName: remoteItemString(item, "OriginalTitle"),
|
||||
Overview: remoteItemString(item, "Overview"),
|
||||
Year: year,
|
||||
Rating: float32(rating),
|
||||
Path: remoteItemString(item, "Path"),
|
||||
Genres: remoteItemGenres(item),
|
||||
ScrapeStatus: "done",
|
||||
}
|
||||
if date, ok := parseEmbyRemoteDate(remoteItemString(item, "DateCreated")); ok {
|
||||
media.CreatedAt = date
|
||||
media.UpdatedAt = date
|
||||
@@ -561,6 +571,46 @@ func (r *EmbyRemoteService) RemoteLatestCards(ctx context.Context, mount *model.
|
||||
if r.cache != nil && r.cache.GetJSON(ctx, cacheKey, &cached) {
|
||||
return cached, nil
|
||||
}
|
||||
|
||||
// 剧集类媒体库:直接拉取最新入库/更新的 Series 剧集本身(按上次添加集日期倒序)。
|
||||
// 避免 Emby /Items/Latest 默认返回无年份/无系列海报的单集(Episode)。
|
||||
if mount != nil && (mount.CollectionType == "tvshows" || mount.CollectionType == "tv") {
|
||||
q := url.Values{}
|
||||
q.Set("ParentId", remoteViewID)
|
||||
q.Set("IncludeItemTypes", "Series")
|
||||
q.Set("Recursive", "false")
|
||||
q.Set("SortBy", "DateLastContentAdded")
|
||||
q.Set("SortOrder", "Descending")
|
||||
q.Set("Limit", strconv.Itoa(limit))
|
||||
q.Set("Fields", "Overview,Genres,ProviderIds,Path,RecursiveItemCount,SeriesPrimaryImage,DateCreated,DateLastMediaAdded,PremiereDate,ProductionYear,CommunityRating,CriticRating")
|
||||
var body struct {
|
||||
Items []map[string]any `json:"Items"`
|
||||
}
|
||||
if err := r.doGet(ctx, acct, cfg, "/Users/"+url.PathEscape(r.remoteUserID(cfg))+"/Items", q, &body); err == nil && len(body.Items) > 0 {
|
||||
cards := make([]SeriesCard, 0, len(body.Items))
|
||||
for _, it := range body.Items {
|
||||
RewriteEmbyRemoteIDs(it, mount.ID)
|
||||
m := r.MapRemoteItemToMedia(ctx, mount, acct, cfg, it)
|
||||
count := remoteItemInt(it, "RecursiveItemCount")
|
||||
if count == 0 {
|
||||
count = remoteItemInt(it, "ChildCount")
|
||||
}
|
||||
if count == 0 {
|
||||
count = 1
|
||||
}
|
||||
var lastAdded *time.Time
|
||||
if date, ok := parseEmbyRemoteDate(remoteItemString(it, "DateLastMediaAdded")); ok {
|
||||
lastAdded = &date
|
||||
}
|
||||
cards = append(cards, SeriesCard{Key: m.ID, Rep: m, LinkMedia: m, Count: count, LastAddedAt: lastAdded})
|
||||
}
|
||||
if r.cache != nil {
|
||||
r.cache.SetJSON(ctx, cacheKey, cards, r.remoteMediaCacheTTL())
|
||||
}
|
||||
return cards, nil
|
||||
}
|
||||
}
|
||||
|
||||
items, err := r.RemoteLatest(ctx, mount, acct, remoteViewID, limit)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -576,7 +626,11 @@ func (r *EmbyRemoteService) RemoteLatestCards(ctx context.Context, mount *model.
|
||||
t := m.CreatedAt
|
||||
lastAdded = &t
|
||||
}
|
||||
cards = append(cards, SeriesCard{Key: m.ID, Rep: m, LinkMedia: m, Count: 0, LastAddedAt: lastAdded})
|
||||
count := remoteItemInt(it, "RecursiveItemCount")
|
||||
if count == 0 {
|
||||
count = remoteItemInt(it, "ChildCount")
|
||||
}
|
||||
cards = append(cards, SeriesCard{Key: m.ID, Rep: m, LinkMedia: m, Count: count, LastAddedAt: lastAdded})
|
||||
}
|
||||
if r.cache != nil {
|
||||
r.cache.SetJSON(ctx, cacheKey, cards, r.remoteMediaCacheTTL())
|
||||
|
||||
@@ -400,3 +400,130 @@ func TestRemoteSearchMedia(t *testing.T) {
|
||||
t.Fatalf("expected 0 items due to HiddenLibraryIDs, got %d", len(hiddenFiltered))
|
||||
}
|
||||
}
|
||||
|
||||
func TestRemoteLatestCardsTvShowsYearAndPoster(t *testing.T) {
|
||||
var requestedFields string
|
||||
var requestedIncludeItemTypes string
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
requestedFields = r.URL.Query().Get("Fields")
|
||||
requestedIncludeItemTypes = r.URL.Query().Get("IncludeItemTypes")
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{
|
||||
"TotalRecordCount": 1,
|
||||
"Items": []map[string]any{
|
||||
{
|
||||
"Id": "series-100",
|
||||
"Name": "炒翻天",
|
||||
"Type": "Series",
|
||||
"ProductionYear": 2024,
|
||||
"ImageTags": map[string]any{
|
||||
"Primary": "tag123",
|
||||
},
|
||||
"RecursiveItemCount": 12,
|
||||
},
|
||||
},
|
||||
})
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
db := newServiceTestDB(t, &model.StrmAccount{}, &model.EmbyMount{})
|
||||
repos := repository.New(db)
|
||||
svc := NewEmbyRemoteService(&config.Config{}, zap.NewNop(), repos, NewCryptoService("", zap.NewNop()))
|
||||
|
||||
rawConfig, _ := json.Marshal(map[string]string{
|
||||
"url": server.URL,
|
||||
"token": "fake-token",
|
||||
})
|
||||
acct := &model.StrmAccount{
|
||||
Base: model.Base{ID: "acct-tv"},
|
||||
Name: "tv-emby",
|
||||
Provider: model.StrmProviderEmbyRemote,
|
||||
Config: string(rawConfig),
|
||||
Enabled: true,
|
||||
}
|
||||
_ = repos.StrmAccount.Create(t.Context(), acct)
|
||||
mount := &model.EmbyMount{
|
||||
Base: model.Base{ID: "mount-tv"},
|
||||
AccountID: acct.ID,
|
||||
RemoteViewID: "view-tv",
|
||||
RemoteViewName: "新番连载",
|
||||
CollectionType: "tvshows",
|
||||
Enabled: true,
|
||||
}
|
||||
_ = repos.EmbyMount.Create(t.Context(), mount)
|
||||
|
||||
cards, err := svc.RemoteLatestCards(t.Context(), mount, acct, "view-tv", 10)
|
||||
if err != nil {
|
||||
t.Fatalf("RemoteLatestCards failed: %v", err)
|
||||
}
|
||||
if len(cards) != 1 {
|
||||
t.Fatalf("expected 1 card, got %d", len(cards))
|
||||
}
|
||||
if cards[0].Rep.Year != 2024 {
|
||||
t.Fatalf("expected Year 2024, got %d", cards[0].Rep.Year)
|
||||
}
|
||||
if cards[0].Count != 12 {
|
||||
t.Fatalf("expected Count 12, got %d", cards[0].Count)
|
||||
}
|
||||
if cards[0].Rep.PosterURL == "" {
|
||||
t.Fatalf("expected PosterURL not empty")
|
||||
}
|
||||
if requestedIncludeItemTypes != "Series" {
|
||||
t.Fatalf("expected IncludeItemTypes=Series, got %q", requestedIncludeItemTypes)
|
||||
}
|
||||
if !strings.Contains(requestedFields, "ProductionYear") {
|
||||
t.Fatalf("expected Fields to contain ProductionYear, got %q", requestedFields)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRemoteLatestFields(t *testing.T) {
|
||||
var requestedFields string
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
requestedFields = r.URL.Query().Get("Fields")
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode([]map[string]any{
|
||||
{
|
||||
"Id": "movie-100",
|
||||
"Name": "测试电影",
|
||||
"Type": "Movie",
|
||||
"ProductionYear": 2023,
|
||||
},
|
||||
})
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
db := newServiceTestDB(t, &model.StrmAccount{}, &model.EmbyMount{})
|
||||
repos := repository.New(db)
|
||||
svc := NewEmbyRemoteService(&config.Config{}, zap.NewNop(), repos, NewCryptoService("", zap.NewNop()))
|
||||
|
||||
rawConfig, _ := json.Marshal(map[string]string{
|
||||
"url": server.URL,
|
||||
"token": "fake-token",
|
||||
})
|
||||
acct := &model.StrmAccount{
|
||||
Base: model.Base{ID: "acct-movie"},
|
||||
Name: "movie-emby",
|
||||
Provider: model.StrmProviderEmbyRemote,
|
||||
Config: string(rawConfig),
|
||||
Enabled: true,
|
||||
}
|
||||
_ = repos.StrmAccount.Create(t.Context(), acct)
|
||||
mount := &model.EmbyMount{
|
||||
Base: model.Base{ID: "mount-movie"},
|
||||
AccountID: acct.ID,
|
||||
RemoteViewID: "view-movie",
|
||||
CollectionType: "movies",
|
||||
Enabled: true,
|
||||
}
|
||||
|
||||
items, err := svc.RemoteLatest(t.Context(), mount, acct, "view-movie", 10)
|
||||
if err != nil {
|
||||
t.Fatalf("RemoteLatest failed: %v", err)
|
||||
}
|
||||
if len(items) != 1 {
|
||||
t.Fatalf("expected 1 item, got %d", len(items))
|
||||
}
|
||||
if !strings.Contains(requestedFields, "ProductionYear") {
|
||||
t.Fatalf("expected Fields to contain ProductionYear, got %q", requestedFields)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -27,16 +27,20 @@ func (e *EmbyService) seriesPayload(group embySeriesGroup) map[string]any {
|
||||
"CommunityRating": group.Rating,
|
||||
"RecursiveItemCount": len(group.Episodes),
|
||||
"ChildCount": len(e.seasonsForSeries(group)),
|
||||
"DateCreated": group.CreatedAt,
|
||||
"DateLastMediaAdded": lastMediaAdded,
|
||||
"ImageTags": imageTags,
|
||||
"BackdropImageTags": backdropTags,
|
||||
"DateCreated": group.CreatedAt,
|
||||
"DateLastMediaAdded": lastMediaAdded,
|
||||
"ImageTags": imageTags,
|
||||
"BackdropImageTags": backdropTags,
|
||||
"People": []map[string]any{},
|
||||
"ProviderIds": map[string]string{
|
||||
"Tmdb": intToStr(group.TMDbID),
|
||||
"Bangumi": intToStr(group.BangumiID),
|
||||
},
|
||||
"UserData": emptyUserData(),
|
||||
}
|
||||
if group.PosterURL != "" {
|
||||
item["PrimaryImageTag"] = group.ID
|
||||
}
|
||||
if premiered, ok := embyPremiereDate(group.ReleaseDate); ok {
|
||||
item["PremiereDate"] = premiered
|
||||
}
|
||||
@@ -53,7 +57,7 @@ func (e *EmbyService) seasonPayload(season embySeasonGroup) map[string]any {
|
||||
if season.Series.BackdropURL != "" {
|
||||
backdropTags = append(backdropTags, season.ID+"-bd")
|
||||
}
|
||||
return map[string]any{
|
||||
item := map[string]any{
|
||||
"Id": season.ID,
|
||||
"Name": season.Name,
|
||||
"ServerId": embyServerID,
|
||||
@@ -67,6 +71,16 @@ func (e *EmbyService) seasonPayload(season embySeasonGroup) map[string]any {
|
||||
"ChildCount": len(season.Episodes),
|
||||
"ImageTags": imageTags,
|
||||
"BackdropImageTags": backdropTags,
|
||||
"People": []map[string]any{},
|
||||
"UserData": emptyUserData(),
|
||||
}
|
||||
if season.Series.PosterURL != "" {
|
||||
item["PrimaryImageTag"] = season.ID
|
||||
item["SeriesPrimaryImageTag"] = season.Series.ID
|
||||
}
|
||||
if season.Series.BackdropURL != "" {
|
||||
item["ParentBackdropItemId"] = season.Series.ID
|
||||
item["ParentBackdropImageTags"] = []string{season.Series.ID + "-bd"}
|
||||
}
|
||||
return item
|
||||
}
|
||||
|
||||
@@ -11,6 +11,10 @@ import (
|
||||
|
||||
// SystemInfo returns the full Emby identity payload.
|
||||
func (e *EmbyService) SystemInfo() map[string]any {
|
||||
port := 8096
|
||||
if e != nil && e.cfg != nil {
|
||||
port = e.cfg.App.Port
|
||||
}
|
||||
return map[string]any{
|
||||
"Id": embyServerID,
|
||||
"ServerId": embyServerID,
|
||||
@@ -27,10 +31,10 @@ func (e *EmbyService) SystemInfo() map[string]any {
|
||||
"SupportsLibraryMonitor": true,
|
||||
"SupportsHttps": false,
|
||||
"SupportsAutoDiscovery": true,
|
||||
"HttpServerPortNumber": e.cfg.App.Port,
|
||||
"HttpServerPortNumber": port,
|
||||
"HttpsPortNumber": 0,
|
||||
"PublishedServerUrl": "",
|
||||
"WebSocketPortNumber": e.cfg.App.Port,
|
||||
"WebSocketPortNumber": port,
|
||||
"CompletedInstallations": []any{},
|
||||
"CanSelfRestart": false,
|
||||
"CanLaunchWebBrowser": false,
|
||||
@@ -40,6 +44,10 @@ func (e *EmbyService) SystemInfo() map[string]any {
|
||||
|
||||
// SystemInfoPublic 是不需要认证的精简版(Emby Web 客户端登陆前会拉)。
|
||||
func (e *EmbyService) SystemInfoPublic() map[string]any {
|
||||
port := 8096
|
||||
if e != nil && e.cfg != nil {
|
||||
port = e.cfg.App.Port
|
||||
}
|
||||
return map[string]any{
|
||||
"Id": embyServerID,
|
||||
"ServerId": embyServerID,
|
||||
@@ -50,7 +58,7 @@ func (e *EmbyService) SystemInfoPublic() map[string]any {
|
||||
"OperatingSystem": "Windows",
|
||||
"LocalAddress": "",
|
||||
"WanAddress": "",
|
||||
"HttpServerPortNumber": e.cfg.App.Port,
|
||||
"HttpServerPortNumber": port,
|
||||
"HttpsPortNumber": 0,
|
||||
"SupportsHttps": false,
|
||||
"SupportsAutoDiscovery": true,
|
||||
|
||||
@@ -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*[季部]`)
|
||||
)
|
||||
@@ -41,7 +43,10 @@ var (
|
||||
// ParseEpisode tries to extract (season, episode) from an arbitrary filename.
|
||||
// Returns (0, 0) when nothing recognisable is found.
|
||||
func ParseEpisode(path string) (season, episode int) {
|
||||
name := strings.TrimSuffix(filepath.Base(path), filepath.Ext(path))
|
||||
name := mediaSidecarBase(path)
|
||||
if name == "" {
|
||||
name = strings.TrimSuffix(filepath.Base(path), filepath.Ext(path))
|
||||
}
|
||||
|
||||
if m := patSEnE.FindStringSubmatch(name); len(m) == 3 {
|
||||
season = mustAtoi(m[1])
|
||||
@@ -53,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)
|
||||
@@ -90,13 +98,37 @@ func ParseEpisode(path string) (season, episode int) {
|
||||
return 0, 0
|
||||
}
|
||||
|
||||
// onlineEpisodeIdentityFromPath converts the common anime SxxE00 convention
|
||||
// to provider-style specials. For example, S01E00 becomes S00E01 and S02E00
|
||||
// becomes S00E02. Normal episodes retain their parsed identity.
|
||||
func onlineEpisodeIdentityFromPath(path string) (season, episode int) {
|
||||
season, episode = ParseEpisode(path)
|
||||
if episode != 0 {
|
||||
return season, episode
|
||||
}
|
||||
name := mediaSidecarBase(path)
|
||||
if name == "" {
|
||||
name = strings.TrimSuffix(filepath.Base(path), filepath.Ext(path))
|
||||
}
|
||||
if m := patSeasonEpisodeZero.FindStringSubmatch(name); len(m) == 2 {
|
||||
specialEpisode := mustAtoi(m[1])
|
||||
if specialEpisode > 0 {
|
||||
return 0, specialEpisode
|
||||
}
|
||||
}
|
||||
return season, episode
|
||||
}
|
||||
|
||||
type episodeRef struct {
|
||||
Season int
|
||||
Episode int
|
||||
}
|
||||
|
||||
func episodeRefsFromTitle(path string) []episodeRef {
|
||||
name := strings.TrimSuffix(filepath.Base(path), filepath.Ext(path))
|
||||
name := mediaSidecarBase(path)
|
||||
if name == "" {
|
||||
name = strings.TrimSuffix(filepath.Base(path), filepath.Ext(path))
|
||||
}
|
||||
if refs := parseSEpisodeRange(name); len(refs) > 0 {
|
||||
return refs
|
||||
}
|
||||
|
||||
@@ -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 == "" {
|
||||
|
||||
@@ -17,6 +17,7 @@ import (
|
||||
"net"
|
||||
"net/http"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"syscall"
|
||||
"time"
|
||||
@@ -42,6 +43,13 @@ type ImageProxy struct {
|
||||
libRootsMu sync.Mutex
|
||||
libRootsCache []string
|
||||
libRootsAt time.Time
|
||||
|
||||
// allowedRemoteHostsFn returns hostnames or IPs of explicitly configured
|
||||
// upstream services (e.g. remote Emby mounts) that should bypass SSRF private IP checks.
|
||||
allowedRemoteHostsFn func() []string
|
||||
allowedHostsMu sync.Mutex
|
||||
allowedHostsCache map[string]bool
|
||||
allowedHostsAt time.Time
|
||||
}
|
||||
|
||||
const (
|
||||
@@ -51,6 +59,12 @@ const (
|
||||
|
||||
// NewImageProxy is the constructor.
|
||||
func NewImageProxy(cfg *config.Config, log *zap.Logger) *ImageProxy {
|
||||
proxy := &ImageProxy{
|
||||
cfg: cfg,
|
||||
log: log,
|
||||
cacheDir: filepath.Join(cfg.Cache.CacheDir, "images"),
|
||||
}
|
||||
|
||||
// Honor HTTP(S)_PROXY env vars so deployments behind GFW can pull
|
||||
// from image.tmdb.org via their HTTP proxy without extra config. On
|
||||
// Windows we also honor the current user's system proxy settings.
|
||||
@@ -63,6 +77,7 @@ func NewImageProxy(cfg *config.Config, log *zap.Logger) *ImageProxy {
|
||||
// 仅 URL 解析层的 isPrivateHost 可被十进制/十六进制 IP、解析到
|
||||
// 私网的域名与 DNS rebinding 绕过;在拨号层对最终连接 IP 做二次
|
||||
// 校验(含重定向后的每条连接)堵住该旁路。
|
||||
// 用户明确配置的远程挂载源(如内网 Emby)豁免该私网限制。
|
||||
dialer := &net.Dialer{
|
||||
Timeout: 15 * time.Second,
|
||||
Control: func(_, address string, _ syscall.RawConn) error {
|
||||
@@ -70,6 +85,9 @@ func NewImageProxy(cfg *config.Config, log *zap.Logger) *ImageProxy {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if proxy.isAllowedRemoteHost(host) {
|
||||
return nil
|
||||
}
|
||||
ip := net.ParseIP(host)
|
||||
if ip == nil {
|
||||
return errors.New("image proxy: refusing non-IP dial target")
|
||||
@@ -82,12 +100,9 @@ func NewImageProxy(cfg *config.Config, log *zap.Logger) *ImageProxy {
|
||||
}
|
||||
transport.DialContext = dialer.DialContext
|
||||
}
|
||||
return &ImageProxy{
|
||||
cfg: cfg,
|
||||
log: log,
|
||||
cacheDir: filepath.Join(cfg.Cache.CacheDir, "images"),
|
||||
client: &http.Client{Timeout: 30 * time.Second, Transport: transport},
|
||||
}
|
||||
|
||||
proxy.client = &http.Client{Timeout: 30 * time.Second, Transport: transport}
|
||||
return proxy
|
||||
}
|
||||
|
||||
// proxyConfiguredForImageFetch 探测环境变量或系统代理是否会影响图片抓取。
|
||||
@@ -125,6 +140,47 @@ func (p *ImageProxy) libraryRoots() []string {
|
||||
return p.libRootsCache
|
||||
}
|
||||
|
||||
// SetAllowedRemoteHostsProvider injects a callback that returns hostnames or IPs
|
||||
// of explicitly configured remote services (e.g. remote Emby mounts). Requests to
|
||||
// these hosts bypass SSRF private-IP restrictions.
|
||||
func (p *ImageProxy) SetAllowedRemoteHostsProvider(fn func() []string) {
|
||||
p.allowedRemoteHostsFn = fn
|
||||
}
|
||||
|
||||
func (p *ImageProxy) isAllowedRemoteHost(host string) bool {
|
||||
if p == nil || p.allowedRemoteHostsFn == nil {
|
||||
return false
|
||||
}
|
||||
host = strings.ToLower(strings.TrimSpace(host))
|
||||
if host == "" {
|
||||
return false
|
||||
}
|
||||
// Strip port if present
|
||||
if h, _, err := net.SplitHostPort(host); err == nil {
|
||||
host = strings.ToLower(strings.TrimSpace(h))
|
||||
}
|
||||
|
||||
p.allowedHostsMu.Lock()
|
||||
defer p.allowedHostsMu.Unlock()
|
||||
if p.allowedHostsCache == nil || time.Since(p.allowedHostsAt) >= 30*time.Second {
|
||||
rawList := p.allowedRemoteHostsFn()
|
||||
cache := make(map[string]bool, len(rawList))
|
||||
for _, item := range rawList {
|
||||
item = strings.ToLower(strings.TrimSpace(item))
|
||||
if item == "" {
|
||||
continue
|
||||
}
|
||||
if h, _, err := net.SplitHostPort(item); err == nil {
|
||||
item = strings.ToLower(strings.TrimSpace(h))
|
||||
}
|
||||
cache[item] = true
|
||||
}
|
||||
p.allowedHostsCache = cache
|
||||
p.allowedHostsAt = time.Now()
|
||||
}
|
||||
return p.allowedHostsCache[host]
|
||||
}
|
||||
|
||||
// Prune removes oldest cached images until disk usage is within the configured limit.
|
||||
func (p *ImageProxy) Prune() (PruneImageCacheResult, error) {
|
||||
if p.cfg == nil || p.cfg.Cache.ImagesMaxSizeMB <= 0 {
|
||||
|
||||
@@ -22,7 +22,7 @@ func (p *ImageProxy) validateURL(raw string) (*url.URL, error) {
|
||||
if scheme != "http" && scheme != "https" {
|
||||
return nil, errors.New("unsupported scheme")
|
||||
}
|
||||
if isPrivateHost(u.Hostname()) {
|
||||
if !p.isAllowedRemoteHost(u.Hostname()) && isPrivateHost(u.Hostname()) {
|
||||
return nil, errors.New("requests to private/internal hosts are not allowed")
|
||||
}
|
||||
return u, nil
|
||||
@@ -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
|
||||
|
||||
@@ -51,7 +51,7 @@ func (p *ImageProxy) fetchRemoteImageOnce(ctx context.Context, raw, host string,
|
||||
p.log.Warn("imageproxy: build request failed", zap.String("url", raw), zap.Error(err))
|
||||
return nil, "", "", errImageProxyRequestSetup
|
||||
}
|
||||
applyRemoteImageHeaders(req, host)
|
||||
applyRemoteImageHeaders(req, host, raw)
|
||||
|
||||
resp, err := candidate.client.Do(req)
|
||||
if err != nil {
|
||||
@@ -79,7 +79,7 @@ func (p *ImageProxy) fetchRemoteImageOnce(ctx context.Context, raw, host string,
|
||||
return data, ctype, resp.Header.Get("Content-Length"), nil
|
||||
}
|
||||
|
||||
func applyRemoteImageHeaders(req *http.Request, host string) {
|
||||
func applyRemoteImageHeaders(req *http.Request, host, raw string) {
|
||||
req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/125.0 Safari/537.36")
|
||||
req.Header.Set("Accept", "image/avif,image/webp,image/apng,image/svg+xml,image/*,*/*;q=0.8")
|
||||
req.Header.Set("Accept-Language", "zh-CN,zh;q=0.9,ja;q=0.8,en;q=0.7")
|
||||
@@ -88,7 +88,7 @@ func applyRemoteImageHeaders(req *http.Request, host string) {
|
||||
if cookie := remoteImageCookie(host); cookie != "" {
|
||||
req.Header.Set("Cookie", cookie)
|
||||
}
|
||||
if referer := remoteImageReferer(host); referer != "" {
|
||||
if referer := remoteImageReferer(host, raw); referer != "" {
|
||||
req.Header.Set("Referer", referer)
|
||||
}
|
||||
}
|
||||
@@ -105,7 +105,7 @@ func remoteImageCookie(host string) string {
|
||||
}
|
||||
}
|
||||
|
||||
func remoteImageReferer(host string) string {
|
||||
func remoteImageReferer(host, raw string) string {
|
||||
h := strings.ToLower(strings.TrimSpace(host))
|
||||
switch {
|
||||
case strings.Contains(h, "doubanio.com"):
|
||||
@@ -125,7 +125,11 @@ func remoteImageReferer(host string) string {
|
||||
case strings.Contains(h, "fc2.com"):
|
||||
return "https://adult.contents.fc2.com/"
|
||||
case h != "":
|
||||
return "https://" + h + "/"
|
||||
scheme := "https"
|
||||
if strings.HasPrefix(strings.ToLower(strings.TrimSpace(raw)), "http://") {
|
||||
scheme = "http"
|
||||
}
|
||||
return scheme + "://" + h + "/"
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
@@ -158,9 +162,9 @@ func fetchRemoteImageWithCurl(ctx context.Context, raw, host string) ([]byte, st
|
||||
"--header", "Cache-Control: no-cache",
|
||||
"--header", "Pragma: no-cache",
|
||||
}
|
||||
if referer := remoteImageReferer(host); referer != "" {
|
||||
args = append(args, "--referer", referer)
|
||||
}
|
||||
if referer := remoteImageReferer(host, raw); referer != "" {
|
||||
args = append(args, "--referer", referer)
|
||||
}
|
||||
if cookie := remoteImageCookie(host); cookie != "" {
|
||||
args = append(args, "--cookie", cookie)
|
||||
}
|
||||
|
||||
@@ -317,9 +317,14 @@ func TestRemoteImageRefererForAdultHosts(t *testing.T) {
|
||||
{"example.com", "https://example.com/"},
|
||||
{"", ""},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
if got := remoteImageReferer(tt.host); got != tt.want {
|
||||
t.Errorf("remoteImageReferer(%q) = %q, want %q", tt.host, got, tt.want)
|
||||
for _, tt := range tests {
|
||||
if got := remoteImageReferer(tt.host, "https://"+tt.host+"/img.jpg"); got != tt.want {
|
||||
t.Errorf("remoteImageReferer(%q) = %q, want %q", tt.host, got, tt.want)
|
||||
}
|
||||
}
|
||||
|
||||
// Also verify HTTP protocol preservation for generic hosts
|
||||
if got := remoteImageReferer("192.168.1.100", "http://192.168.1.100:8096/image"); got != "http://192.168.1.100/" {
|
||||
t.Errorf("remoteImageReferer for HTTP host = %q, want http://192.168.1.100/", got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
@@ -123,3 +129,26 @@ func TestIsPrivateHost(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestImageProxyAllowedRemoteHostBypassesPrivateCheck(t *testing.T) {
|
||||
proxy := NewImageProxy(&config.Config{Cache: config.CacheConfig{CacheDir: filepath.Join(t.TempDir(), "cache")}}, zap.NewNop())
|
||||
|
||||
rawURL := "http://192.168.1.100:8096/emby/Items/123/Images/Primary"
|
||||
// Before setting allowed remote hosts, private host is rejected by validateURL
|
||||
if _, err := proxy.validateURL(rawURL); err == nil {
|
||||
t.Fatal("expected validateURL to reject private IP before whitelist")
|
||||
}
|
||||
|
||||
// After configuring whitelist with the Emby host
|
||||
proxy.SetAllowedRemoteHostsProvider(func() []string {
|
||||
return []string{"192.168.1.100:8096"}
|
||||
})
|
||||
|
||||
u, err := proxy.validateURL(rawURL)
|
||||
if err != nil {
|
||||
t.Fatalf("expected validateURL to allow whitelisted host, got: %v", err)
|
||||
}
|
||||
if u.Hostname() != "192.168.1.100" {
|
||||
t.Fatalf("hostname = %s, want 192.168.1.100", u.Hostname())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -54,39 +54,59 @@ func mergeArtworkMetadata(meta *LocalMetadata, mediaPath, showBaseDir string) {
|
||||
}
|
||||
|
||||
func localPosterCandidates(mediaPath string) []string {
|
||||
base := strings.TrimSuffix(filepath.Base(mediaPath), filepath.Ext(mediaPath))
|
||||
names := []string{
|
||||
base + "-poster",
|
||||
base + ".poster",
|
||||
"poster",
|
||||
"folder",
|
||||
"cover",
|
||||
"movie",
|
||||
"show",
|
||||
base + "-cover",
|
||||
base + ".cover",
|
||||
base,
|
||||
base + "-thumb",
|
||||
base + ".thumb",
|
||||
"thumb",
|
||||
names := make([]string, 0, 24)
|
||||
seen := map[string]struct{}{}
|
||||
add := func(name string) {
|
||||
name = strings.TrimSpace(name)
|
||||
if name == "" {
|
||||
return
|
||||
}
|
||||
key := strings.ToLower(name)
|
||||
if _, ok := seen[key]; ok {
|
||||
return
|
||||
}
|
||||
seen[key] = struct{}{}
|
||||
names = append(names, name)
|
||||
}
|
||||
for _, base := range mediaSidecarBaseVariants(mediaPath) {
|
||||
add(base + "-poster")
|
||||
add(base + ".poster")
|
||||
add(base + "-cover")
|
||||
add(base + ".cover")
|
||||
add(base)
|
||||
add(base + "-thumb")
|
||||
add(base + ".thumb")
|
||||
}
|
||||
for _, name := range []string{"poster", "folder", "cover", "movie", "show", "thumb"} {
|
||||
add(name)
|
||||
}
|
||||
return append(adultArtworkNameCandidates(mediaPath, "poster"), names...)
|
||||
}
|
||||
|
||||
func localBackdropCandidates(mediaPath string) []string {
|
||||
base := strings.TrimSuffix(filepath.Base(mediaPath), filepath.Ext(mediaPath))
|
||||
names := []string{
|
||||
base + "-fanart",
|
||||
base + ".fanart",
|
||||
base + "-backdrop",
|
||||
base + ".backdrop",
|
||||
base + "-background",
|
||||
"fanart",
|
||||
"backdrop",
|
||||
"background",
|
||||
"landscape",
|
||||
"banner",
|
||||
"clearart",
|
||||
names := make([]string, 0, 24)
|
||||
seen := map[string]struct{}{}
|
||||
add := func(name string) {
|
||||
name = strings.TrimSpace(name)
|
||||
if name == "" {
|
||||
return
|
||||
}
|
||||
key := strings.ToLower(name)
|
||||
if _, ok := seen[key]; ok {
|
||||
return
|
||||
}
|
||||
seen[key] = struct{}{}
|
||||
names = append(names, name)
|
||||
}
|
||||
for _, base := range mediaSidecarBaseVariants(mediaPath) {
|
||||
add(base + "-fanart")
|
||||
add(base + ".fanart")
|
||||
add(base + "-backdrop")
|
||||
add(base + ".backdrop")
|
||||
add(base + "-background")
|
||||
}
|
||||
for _, name := range []string{"fanart", "backdrop", "background", "landscape", "banner", "clearart"} {
|
||||
add(name)
|
||||
}
|
||||
return append(adultArtworkNameCandidates(mediaPath, "backdrop"), names...)
|
||||
}
|
||||
|
||||
@@ -28,13 +28,12 @@ func ReadLocalMetadata(mediaPath, libraryRoot string, seriesLike bool) (*LocalMe
|
||||
|
||||
func findMovieNFO(mediaPath, libraryRoot string) (*nfoDocument, string, error) {
|
||||
mediaDir := filepath.Dir(mediaPath)
|
||||
base := strings.TrimSuffix(filepath.Base(mediaPath), filepath.Ext(mediaPath))
|
||||
adultCode := AdultCodeFromMediaPath(mediaPath)
|
||||
names := []string{
|
||||
base + ".nfo",
|
||||
"movie.nfo",
|
||||
filepath.Base(mediaDir) + ".nfo",
|
||||
names := make([]string, 0, 8)
|
||||
for _, base := range mediaSidecarBaseVariants(mediaPath) {
|
||||
names = append(names, base+".nfo")
|
||||
}
|
||||
names = append(names, "movie.nfo", filepath.Base(mediaDir)+".nfo")
|
||||
if adultCode != "" {
|
||||
names = append([]string{adultCode + ".nfo", strings.ReplaceAll(adultCode, "-", "") + ".nfo"}, names...)
|
||||
}
|
||||
|
||||
@@ -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, "真人秀"):
|
||||
|
||||
@@ -2,6 +2,8 @@ package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"go.uber.org/zap"
|
||||
@@ -111,3 +113,63 @@ func (s *MediaService) GetMedia(ctx context.Context, id string) (*model.Media, e
|
||||
*media = items[0]
|
||||
return media, nil
|
||||
}
|
||||
|
||||
// GetMediaItem 返回媒体详情,并附带同片多版本列表(用于详情页/播放器切换)。
|
||||
func (s *MediaService) GetMediaItem(ctx context.Context, id string) (*MediaItem, error) {
|
||||
media, err := s.GetMedia(ctx, id)
|
||||
if err != nil || media == nil {
|
||||
return nil, err
|
||||
}
|
||||
versions, err := s.listVersionSiblings(ctx, media)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
item := &MediaItem{Media: *media}
|
||||
if len(versions) > 1 {
|
||||
item.Versions = versions
|
||||
}
|
||||
return item, nil
|
||||
}
|
||||
|
||||
// listVersionSiblings 查找与当前条目同属一个版本组的全部媒体(含自身)。
|
||||
func (s *MediaService) listVersionSiblings(ctx context.Context, media *model.Media) ([]model.Media, error) {
|
||||
if media == nil || strings.TrimSpace(media.ID) == "" {
|
||||
return nil, nil
|
||||
}
|
||||
key := mediaVersionGroupKey(*media)
|
||||
if key == "" {
|
||||
return []model.Media{*media}, nil
|
||||
}
|
||||
libraryIDs, err := MergedLibraryIDsForLibrary(ctx, s.repo, media.LibraryID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(libraryIDs) == 0 {
|
||||
libraryIDs = []string{media.LibraryID}
|
||||
}
|
||||
filter := repository.MediaQueryFilter{IncludeNSFW: true}
|
||||
candidates, err := s.repo.Media.ListByLibrariesFilteredNoCount(ctx, libraryIDs, 0, 5000, filter)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
s.attachLibraryMetadata(ctx, candidates)
|
||||
matched := make([]model.Media, 0, 4)
|
||||
for _, row := range candidates {
|
||||
if mediaVersionGroupKey(row) == key {
|
||||
matched = append(matched, row)
|
||||
}
|
||||
}
|
||||
if len(matched) == 0 {
|
||||
return []model.Media{*media}, nil
|
||||
}
|
||||
sort.SliceStable(matched, func(i, j int) bool {
|
||||
if matched[i].ID == media.ID {
|
||||
return true
|
||||
}
|
||||
if matched[j].ID == media.ID {
|
||||
return false
|
||||
}
|
||||
return betterMediaVersion(matched[i], matched[j])
|
||||
})
|
||||
return matched, nil
|
||||
}
|
||||
|
||||
@@ -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,62 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// mediaSidecarBase 返回媒体文件用于配对 NFO/海报/字幕等元数据的共享基名。
|
||||
// 同片多版本(含 keep_ext 的 name.mkv.strm / name.mp4.strm)应落到同一词干,
|
||||
// 从而忽略中间的视频扩展,按「同一影片」匹配边车文件。
|
||||
//
|
||||
// movie.mkv → movie
|
||||
// movie.strm → movie
|
||||
// movie.mkv.strm → movie
|
||||
// Show.S01E01.mp4.strm → Show.S01E01
|
||||
func mediaSidecarBase(mediaPath string) string {
|
||||
clean := strings.ReplaceAll(strings.TrimSpace(mediaPath), "\\", "/")
|
||||
if clean == "" {
|
||||
return ""
|
||||
}
|
||||
return mediaFileStem(filepath.Base(clean))
|
||||
}
|
||||
|
||||
// mediaFileStem 去掉最终扩展名;若为 .strm 且前一层是视频扩展,再剥一层。
|
||||
func mediaFileStem(name string) string {
|
||||
name = strings.TrimSpace(name)
|
||||
if name == "" || name == "." {
|
||||
return ""
|
||||
}
|
||||
ext := filepath.Ext(name)
|
||||
stem := strings.TrimSuffix(name, ext)
|
||||
if strings.EqualFold(ext, ".strm") {
|
||||
if second := strings.ToLower(filepath.Ext(stem)); second != "" {
|
||||
if _, ok := videoExtensions[second]; ok && second != ".strm" {
|
||||
stem = strings.TrimSuffix(stem, filepath.Ext(stem))
|
||||
}
|
||||
}
|
||||
}
|
||||
return strings.TrimSpace(stem)
|
||||
}
|
||||
|
||||
// mediaSidecarBaseVariants 返回匹配用的基名候选:共享词干优先,其次保留单层剥扩展
|
||||
// (兼容历史上写成 name.mkv.nfo / name.mkv-poster.jpg 的边车)。
|
||||
func mediaSidecarBaseVariants(mediaPath string) []string {
|
||||
stem := mediaSidecarBase(mediaPath)
|
||||
single := strings.TrimSuffix(filepath.Base(strings.TrimSpace(mediaPath)), filepath.Ext(mediaPath))
|
||||
single = strings.TrimSpace(single)
|
||||
out := make([]string, 0, 2)
|
||||
seen := map[string]struct{}{}
|
||||
for _, item := range []string{stem, single} {
|
||||
if item == "" || item == "." {
|
||||
continue
|
||||
}
|
||||
key := strings.ToLower(item)
|
||||
if _, ok := seen[key]; ok {
|
||||
continue
|
||||
}
|
||||
seen[key] = struct{}{}
|
||||
out = append(out, item)
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/truewhile/MeBox/internal/model"
|
||||
"github.com/truewhile/MeBox/internal/repository"
|
||||
)
|
||||
|
||||
func TestMediaSidecarBaseIgnoresKeepExt(t *testing.T) {
|
||||
cases := map[string]string{
|
||||
"/media/竞女01.mkv.strm": "竞女01",
|
||||
"/media/竞女01.mp4.strm": "竞女01",
|
||||
"/media/竞女01.strm": "竞女01",
|
||||
"/media/竞女01.mkv": "竞女01",
|
||||
"/lib/Show.S01E02.mkv.strm": "Show.S01E02",
|
||||
`C:/lib/Show.S01E02.mkv.strm`: "Show.S01E02",
|
||||
}
|
||||
for in, want := range cases {
|
||||
if got := mediaSidecarBase(in); got != want {
|
||||
t.Fatalf("mediaSidecarBase(%q)=%q want %q", in, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestNFOPathUsesSharedStemForKeepExt(t *testing.T) {
|
||||
got := nfoPath("/strm/竞女01.mkv.strm")
|
||||
want := filepath.Join("/strm", "竞女01.nfo")
|
||||
if got != want {
|
||||
t.Fatalf("nfoPath keep_ext = %q want %q", got, want)
|
||||
}
|
||||
if nfoPath("/strm/竞女01.mp4.strm") != want {
|
||||
t.Fatalf("mkv/mp4 versions should share nfo path")
|
||||
}
|
||||
}
|
||||
|
||||
func TestFindMovieNFOMatchesSharedStemBesideKeepExtStrm(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
media := filepath.Join(dir, "竞女01.mkv.strm")
|
||||
nfo := filepath.Join(dir, "竞女01.nfo")
|
||||
writeFileContent(t, media, "http://example/play")
|
||||
writeFileContent(t, nfo, `<movie><title>竞女01</title></movie>`)
|
||||
|
||||
doc, path, err := findMovieNFO(media, dir)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if path != nfo {
|
||||
t.Fatalf("path=%q want %q", path, nfo)
|
||||
}
|
||||
if doc == nil || strings.TrimSpace(doc.Title) != "竞女01" {
|
||||
t.Fatalf("unexpected nfo doc: %#v", doc)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLocalPosterCandidatesIncludeSharedStem(t *testing.T) {
|
||||
cands := localPosterCandidates("/strm/竞女01.mkv.strm")
|
||||
found := false
|
||||
for _, name := range cands {
|
||||
if name == "竞女01-poster" || name == "竞女01" {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Fatalf("expected shared stem poster candidates, got %#v", cands)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSubtitleDiscoveryMatchesSharedStemBesideKeepExt(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
mediaPath := filepath.Join(dir, "竞女01.mkv.strm")
|
||||
subPath := filepath.Join(dir, "竞女01.zh.srt")
|
||||
writeFileContent(t, mediaPath, "http://example/play")
|
||||
writeFileContent(t, subPath, "1\n00:00:01,000 --> 00:00:02,000\nhi\n")
|
||||
|
||||
db := newServiceTestDB(t, &model.Media{})
|
||||
repos := repository.New(db)
|
||||
media := model.Media{Title: "竞女01", Path: mediaPath}
|
||||
if err := repos.Media.Upsert(t.Context(), &media); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
svc := &SubtitleService{repo: repos}
|
||||
tracks, err := svc.discoverUncached(t.Context(), media.ID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(tracks) != 1 {
|
||||
t.Fatalf("tracks=%d want 1 (%#v)", len(tracks), tracks)
|
||||
}
|
||||
if tracks[0].Path != subPath {
|
||||
t.Fatalf("track path=%q want %q", tracks[0].Path, subPath)
|
||||
}
|
||||
}
|
||||
|
||||
func writeFileContent(t *testing.T, path, data string) {
|
||||
t.Helper()
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(path, []byte(data), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
@@ -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 ""
|
||||
}
|
||||
@@ -2,6 +2,7 @@ package service
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
@@ -119,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 == "" {
|
||||
@@ -146,39 +182,64 @@ 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))
|
||||
}
|
||||
|
||||
title := firstNonEmpty(m.OriginalName, m.Title)
|
||||
titleYear := 0
|
||||
if title == "" {
|
||||
@@ -187,6 +248,10 @@ func mediaVersionGroupKey(m model.Media) string {
|
||||
title, titleYear = mediaVersionTitleKey(title)
|
||||
}
|
||||
if title == "" {
|
||||
// 无标题时退回同目录词干(覆盖 keep_ext 的 name.mkv.strm / name.mp4.strm)
|
||||
if stemKey := mediaVersionStemGroupKey(m, libKey); stemKey != "" {
|
||||
return stemKey
|
||||
}
|
||||
return ""
|
||||
}
|
||||
year := m.Year
|
||||
@@ -202,6 +267,114 @@ func mediaVersionGroupKey(m model.Media) string {
|
||||
return fmt.Sprintf("movie:%s:%d", title, year)
|
||||
}
|
||||
|
||||
// mediaVersionStemGroupKey 按「库 + 父目录 + 文件词干」折叠多版本
|
||||
// (如 竞女01.mkv.strm 与 竞女01.mp4.strm)。
|
||||
func mediaVersionStemGroupKey(m model.Media, libKey string) string {
|
||||
path := strings.ReplaceAll(strings.TrimSpace(m.Path), "\\", "/")
|
||||
if path == "" || strings.HasPrefix(strings.ToLower(path), "cloud://") {
|
||||
return ""
|
||||
}
|
||||
dir := ""
|
||||
base := path
|
||||
if idx := strings.LastIndex(path, "/"); idx >= 0 {
|
||||
dir = path[:idx]
|
||||
base = path[idx+1:]
|
||||
}
|
||||
stem := mediaVersionFileStem(base)
|
||||
if stem == "" {
|
||||
return ""
|
||||
}
|
||||
stem = normalizeMediaVersionText(stem)
|
||||
if stem == "" {
|
||||
return ""
|
||||
}
|
||||
if libKey == "" {
|
||||
libKey = "_"
|
||||
}
|
||||
return fmt.Sprintf("stem:%s:%s:%s", libKey, strings.ToLower(dir), stem)
|
||||
}
|
||||
|
||||
// mediaVersionFileStem 去掉最终扩展名;若为 .strm 且前一层是视频扩展,再剥一层。
|
||||
func mediaVersionFileStem(name string) string {
|
||||
return mediaFileStem(name)
|
||||
}
|
||||
|
||||
// MediaVersionLabel 生成版本切换展示名(分辨率 / 容器 / 编码 / 体积 / 文件名)。
|
||||
func MediaVersionLabel(m model.Media) string {
|
||||
parts := make([]string, 0, 4)
|
||||
if m.Height > 0 {
|
||||
parts = append(parts, fmt.Sprintf("%dp", m.Height))
|
||||
} else if m.Width > 0 {
|
||||
parts = append(parts, fmt.Sprintf("%dw", m.Width))
|
||||
}
|
||||
container := strings.Trim(strings.ToLower(strings.TrimSpace(m.Container)), ". ")
|
||||
if container == "" || container == "strm" {
|
||||
container = mediaVersionContainerFromPath(m.Path, m.STRMURL)
|
||||
}
|
||||
if container != "" && container != "strm" {
|
||||
parts = append(parts, strings.ToUpper(container))
|
||||
}
|
||||
if codec := strings.TrimSpace(m.VideoCodec); codec != "" {
|
||||
parts = append(parts, strings.ToUpper(codec))
|
||||
}
|
||||
if m.SizeBytes > 0 {
|
||||
parts = append(parts, formatMediaSize(m.SizeBytes))
|
||||
}
|
||||
if len(parts) > 0 {
|
||||
return strings.Join(parts, " · ")
|
||||
}
|
||||
base := filepath.Base(strings.ReplaceAll(strings.TrimSpace(m.Path), "\\", "/"))
|
||||
if base == "" || base == "." {
|
||||
return firstNonEmpty(m.Title, m.OriginalName, m.ID)
|
||||
}
|
||||
return base
|
||||
}
|
||||
|
||||
func mediaVersionContainerFromPath(path, strmURL string) string {
|
||||
base := filepath.Base(strings.ReplaceAll(strings.TrimSpace(path), "\\", "/"))
|
||||
ext := strings.ToLower(filepath.Ext(base))
|
||||
name := strings.TrimSuffix(base, ext)
|
||||
if ext == ".strm" {
|
||||
if second := strings.ToLower(filepath.Ext(name)); second != "" {
|
||||
if _, ok := videoExtensions[second]; ok {
|
||||
return strings.TrimPrefix(second, ".")
|
||||
}
|
||||
}
|
||||
// 从 strm 播放 URL 的 /video.mkv 推断
|
||||
u := strings.ToLower(strmURL)
|
||||
if idx := strings.LastIndex(u, "/video."); idx >= 0 {
|
||||
rest := u[idx+len("/video."):]
|
||||
if end := strings.IndexAny(rest, "?#&/"); end >= 0 {
|
||||
rest = rest[:end]
|
||||
}
|
||||
rest = strings.Trim(rest, ".")
|
||||
if rest != "" {
|
||||
return rest
|
||||
}
|
||||
}
|
||||
return "strm"
|
||||
}
|
||||
if ext != "" {
|
||||
if _, ok := videoExtensions[ext]; ok {
|
||||
return strings.TrimPrefix(ext, ".")
|
||||
}
|
||||
}
|
||||
return strings.TrimPrefix(ext, ".")
|
||||
}
|
||||
|
||||
func formatMediaSize(bytes int64) string {
|
||||
if bytes < 1024 {
|
||||
return fmt.Sprintf("%d B", bytes)
|
||||
}
|
||||
const unit = 1024
|
||||
div, exp := int64(unit), 0
|
||||
for n := bytes / unit; n >= unit; n /= unit {
|
||||
div *= unit
|
||||
exp++
|
||||
}
|
||||
return fmt.Sprintf("%.1f %cB", float64(bytes)/float64(div), "KMGTPE"[exp])
|
||||
}
|
||||
|
||||
func mediaVersionTitleKey(value string) (string, int) {
|
||||
cleaned, year := CleanQuery(value)
|
||||
if strings.TrimSpace(cleaned) == "" {
|
||||
@@ -232,6 +405,10 @@ func normalizeMediaVersionText(value string) string {
|
||||
if _, noise := noiseTokenSet[field]; noise {
|
||||
continue
|
||||
}
|
||||
// 去掉视频容器词干残留(keep_ext / 旧标题「竞女01 mkv」)
|
||||
if _, ok := videoExtensions["."+field]; ok {
|
||||
continue
|
||||
}
|
||||
out = append(out, field)
|
||||
}
|
||||
return strings.Join(out, " ")
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
@@ -48,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",
|
||||
@@ -264,3 +354,43 @@ func TestGroupMediaVersionsDoesNotMergeAcrossDifferentLibrariesForMovies(t *test
|
||||
t.Fatalf("expected 2 separate groups for movies in different libraries, got %d", len(grouped))
|
||||
}
|
||||
}
|
||||
|
||||
func TestGroupMediaVersionsMergesKeepExtStrmVariants(t *testing.T) {
|
||||
mkv := model.Media{
|
||||
LibraryID: "movies",
|
||||
Title: "竞女01",
|
||||
Path: "/strm/竞女01.mkv.strm",
|
||||
SizeBytes: 500,
|
||||
STRMURL: "/api/strm/play/cloud115/video.mkv?pickcode=a",
|
||||
}
|
||||
mp4 := model.Media{
|
||||
LibraryID: "movies",
|
||||
Title: "竞女01",
|
||||
Path: "/strm/竞女01.mp4.strm",
|
||||
SizeBytes: 100,
|
||||
STRMURL: "/api/strm/play/cloud115/video.mp4?pickcode=b",
|
||||
}
|
||||
grouped := groupMediaVersions([]model.Media{mkv, mp4})
|
||||
if len(grouped) != 1 {
|
||||
t.Fatalf("grouped len = %d, want 1", len(grouped))
|
||||
}
|
||||
if len(grouped[0].Versions) != 2 {
|
||||
t.Fatalf("versions len = %d, want 2", len(grouped[0].Versions))
|
||||
}
|
||||
if grouped[0].Path != mkv.Path {
|
||||
t.Fatalf("primary should be larger mkv, got %q", grouped[0].Path)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMediaVersionLabelUsesContainerAndSize(t *testing.T) {
|
||||
label := MediaVersionLabel(model.Media{
|
||||
Title: "竞女01",
|
||||
Path: "/strm/竞女01.mkv.strm",
|
||||
Height: 1080,
|
||||
SizeBytes: 1024 * 1024 * 1200,
|
||||
STRMURL: "/api/strm/play/cloud115/video.mkv?pickcode=a",
|
||||
})
|
||||
if !strings.Contains(label, "1080p") || !strings.Contains(strings.ToUpper(label), "MKV") {
|
||||
t.Fatalf("unexpected label %q", label)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -115,7 +115,10 @@ func (s *NFOService) ExportLibrary(ctx context.Context, libraryID string) (int,
|
||||
|
||||
func nfoPath(media string) string {
|
||||
dir := filepath.Dir(media)
|
||||
base := strings.TrimSuffix(filepath.Base(media), filepath.Ext(media))
|
||||
base := mediaSidecarBase(media)
|
||||
if base == "" {
|
||||
base = strings.TrimSuffix(filepath.Base(media), filepath.Ext(media))
|
||||
}
|
||||
return filepath.Join(dir, fmt.Sprintf("%s.nfo", base))
|
||||
}
|
||||
|
||||
|
||||
@@ -55,7 +55,7 @@ func (o *OrganizerService) lookupOrganizeMetadata(ctx context.Context, src, sour
|
||||
continue
|
||||
}
|
||||
}
|
||||
match := o.scraper.lookup(ctx, lib, media, candidate, year)
|
||||
match := o.scraper.lookup(ctx, lib, media, candidate, year, false)
|
||||
if match != nil && strings.TrimSpace(match.Title) != "" {
|
||||
if !organizeMetadataMatchTrusted(candidate, year, match) {
|
||||
if cache != nil {
|
||||
|
||||
@@ -278,16 +278,21 @@ func randomSuffix() string {
|
||||
// moveStagedArtwork renames artwork sidecars staged alongside `stage` into
|
||||
// their final names next to `dst`.
|
||||
func moveStagedArtwork(stage, dst string) {
|
||||
stageBase := strings.TrimSuffix(filepath.Base(stage), filepath.Ext(stage))
|
||||
dstBase := strings.TrimSuffix(filepath.Base(dst), filepath.Ext(dst))
|
||||
stageBases := mediaSidecarBaseVariants(stage)
|
||||
dstBase := mediaSidecarBase(dst)
|
||||
if dstBase == "" {
|
||||
dstBase = strings.TrimSuffix(filepath.Base(dst), filepath.Ext(dst))
|
||||
}
|
||||
stageDir := filepath.Dir(stage)
|
||||
for _, suffix := range artworkSidecarSuffixes {
|
||||
for _, ext := range artworkSidecarExtensions {
|
||||
srcPath := filepath.Join(stageDir, stageBase+suffix+ext)
|
||||
if _, err := os.Stat(srcPath); err != nil {
|
||||
continue
|
||||
for _, stageBase := range stageBases {
|
||||
for _, suffix := range artworkSidecarSuffixes {
|
||||
for _, ext := range artworkSidecarExtensions {
|
||||
srcPath := filepath.Join(stageDir, stageBase+suffix+ext)
|
||||
if _, err := os.Stat(srcPath); err != nil {
|
||||
continue
|
||||
}
|
||||
_ = os.Rename(srcPath, filepath.Join(stageDir, dstBase+suffix+ext))
|
||||
}
|
||||
_ = os.Rename(srcPath, filepath.Join(stageDir, dstBase+suffix+ext))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -306,12 +311,13 @@ func moveSidecarRename(from, to string) {
|
||||
// removeStagedArtwork removes artwork sidecars that were staged alongside
|
||||
// `stage`, used when a replace fails and its staged outputs must be cleaned up.
|
||||
func removeStagedArtwork(stage string) {
|
||||
stageBase := strings.TrimSuffix(filepath.Base(stage), filepath.Ext(stage))
|
||||
stageBases := mediaSidecarBaseVariants(stage)
|
||||
stageDir := filepath.Dir(stage)
|
||||
for _, suffix := range artworkSidecarSuffixes {
|
||||
for _, ext := range artworkSidecarExtensions {
|
||||
path := filepath.Join(stageDir, stageBase+suffix+ext)
|
||||
_ = os.Remove(path)
|
||||
for _, stageBase := range stageBases {
|
||||
for _, suffix := range artworkSidecarSuffixes {
|
||||
for _, ext := range artworkSidecarExtensions {
|
||||
_ = os.Remove(filepath.Join(stageDir, stageBase+suffix+ext))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -51,23 +51,25 @@ var artworkSidecarExtensions = []string{".jpg", ".jpeg", ".png", ".webp", ".gif"
|
||||
// behind in the old folder; this keeps artwork with the organized file.
|
||||
func transferSidecarArtwork(srcMedia, dstMedia string, mode TransferMode) error {
|
||||
srcDir := filepath.Dir(srcMedia)
|
||||
base := strings.TrimSuffix(filepath.Base(srcMedia), filepath.Ext(srcMedia))
|
||||
if base == "" || base == "." {
|
||||
bases := mediaSidecarBaseVariants(srcMedia)
|
||||
if len(bases) == 0 {
|
||||
return nil
|
||||
}
|
||||
// Find every existing sidecar by probing suffix + extension combinations.
|
||||
sources := make([]string, 0, len(artworkSidecarSuffixes)*len(artworkSidecarExtensions))
|
||||
sources := make([]string, 0, len(artworkSidecarSuffixes)*len(artworkSidecarExtensions)*len(bases))
|
||||
seen := map[string]struct{}{}
|
||||
for _, suffix := range artworkSidecarSuffixes {
|
||||
for _, ext := range artworkSidecarExtensions {
|
||||
path := filepath.Join(srcDir, base+suffix+ext)
|
||||
key := strings.ToLower(filepath.Clean(path))
|
||||
if _, ok := seen[key]; ok {
|
||||
continue
|
||||
}
|
||||
seen[key] = struct{}{}
|
||||
if _, err := os.Stat(path); err == nil {
|
||||
sources = append(sources, path)
|
||||
for _, base := range bases {
|
||||
for _, suffix := range artworkSidecarSuffixes {
|
||||
for _, ext := range artworkSidecarExtensions {
|
||||
path := filepath.Join(srcDir, base+suffix+ext)
|
||||
key := strings.ToLower(filepath.Clean(path))
|
||||
if _, ok := seen[key]; ok {
|
||||
continue
|
||||
}
|
||||
seen[key] = struct{}{}
|
||||
if _, err := os.Stat(path); err == nil {
|
||||
sources = append(sources, path)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -78,15 +80,36 @@ func transferSidecarArtwork(srcMedia, dstMedia string, mode TransferMode) error
|
||||
if err := os.MkdirAll(dstDir, 0o755); err != nil { // #nosec G301 -- sidecar media directories must remain readable by NAS/player users.
|
||||
return err
|
||||
}
|
||||
var firstErr error
|
||||
dstBase := mediaSidecarBase(dstMedia)
|
||||
if dstBase == "" {
|
||||
dstBase = strings.TrimSuffix(filepath.Base(dstMedia), filepath.Ext(dstMedia))
|
||||
}
|
||||
for _, src := range sources {
|
||||
dst := filepath.Join(dstDir, filepath.Base(src))
|
||||
if _, err := os.Stat(dst); err == nil {
|
||||
continue // never clobber an existing artwork at the destination
|
||||
name := filepath.Base(src)
|
||||
// Remap any legacy "Title.mkv-poster.jpg" onto the shared destination stem.
|
||||
suffix := ""
|
||||
lowerName := strings.ToLower(name)
|
||||
for _, base := range bases {
|
||||
prefix := strings.ToLower(base)
|
||||
if strings.HasPrefix(lowerName, prefix) {
|
||||
suffix = name[len(base):]
|
||||
break
|
||||
}
|
||||
}
|
||||
if err := transferFile(src, dst, mode); err != nil && firstErr == nil {
|
||||
firstErr = err
|
||||
dstName := name
|
||||
if suffix != "" && dstBase != "" {
|
||||
dstName = dstBase + suffix
|
||||
}
|
||||
dst := filepath.Join(dstDir, dstName)
|
||||
if strings.EqualFold(filepath.Clean(src), filepath.Clean(dst)) {
|
||||
continue
|
||||
}
|
||||
if _, err := os.Stat(dst); err == nil {
|
||||
continue
|
||||
}
|
||||
if err := transferFile(src, dst, mode); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return firstErr
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -5,8 +5,10 @@ import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"go.uber.org/zap"
|
||||
@@ -61,8 +63,9 @@ func NewRecognitionWordsService(log *zap.Logger, repo *repository.Container) *Re
|
||||
}
|
||||
|
||||
func (s *RecognitionWordsService) Config(ctx context.Context) RecognitionWordsConfig {
|
||||
cfg := recognitionWordsConfig(ctx, s.repo)
|
||||
cfg.RuleCount = len(parseRecognitionWordRules(recognitionWordsCombinedText(cfg)))
|
||||
cfg, rules := recognitionWordsRuntime(ctx, s.repo)
|
||||
cfg = cloneRecognitionWordsConfig(cfg)
|
||||
cfg.RuleCount = len(rules)
|
||||
return cfg
|
||||
}
|
||||
|
||||
@@ -80,7 +83,11 @@ func (s *RecognitionWordsService) SaveConfig(ctx context.Context, cfg Recognitio
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return s.repo.Setting.Set(ctx, RecognitionWordsSharedURLsKey, string(rawURLs))
|
||||
if err := s.repo.Setting.Set(ctx, RecognitionWordsSharedURLsKey, string(rawURLs)); err != nil {
|
||||
return err
|
||||
}
|
||||
invalidateRecognitionWordsRuntime(s.repo)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *RecognitionWordsService) SyncShared(ctx context.Context) (RecognitionWordsConfig, error) {
|
||||
@@ -104,6 +111,7 @@ func (s *RecognitionWordsService) SyncShared(ctx context.Context) (RecognitionWo
|
||||
if err := s.repo.Setting.Set(ctx, RecognitionWordsSyncedAtKey, now); err != nil {
|
||||
return cfg, err
|
||||
}
|
||||
invalidateRecognitionWordsRuntime(s.repo)
|
||||
return s.Config(ctx), nil
|
||||
}
|
||||
|
||||
@@ -120,11 +128,10 @@ func (s *RecognitionWordsService) Test(ctx context.Context, input string) Recogn
|
||||
}
|
||||
|
||||
func ApplyRecognitionWords(ctx context.Context, repo *repository.Container, raw string) string {
|
||||
cfg := recognitionWordsConfig(ctx, repo)
|
||||
if !cfg.Enabled {
|
||||
cfg, rules := recognitionWordsRuntime(ctx, repo)
|
||||
if !cfg.Enabled || len(rules) == 0 {
|
||||
return raw
|
||||
}
|
||||
rules := parseRecognitionWordRules(recognitionWordsCombinedText(cfg))
|
||||
return applyRecognitionWordRules(raw, rules)
|
||||
}
|
||||
|
||||
@@ -189,9 +196,69 @@ func normalizeRecognitionWordURLs(values []string) []string {
|
||||
type recognitionWordRule struct {
|
||||
raw string
|
||||
block string
|
||||
blockRE *regexp.Regexp
|
||||
replaceFrom string
|
||||
replaceTo string
|
||||
replaceRE *regexp.Regexp
|
||||
offsetLeft string
|
||||
offsetRight string
|
||||
offsetExpr string
|
||||
offsetRE *regexp.Regexp
|
||||
}
|
||||
|
||||
// recognitionWordsRuntime caches the parsed rule set (with pre-compiled
|
||||
// regexes) per repository. Reading five settings rows and parsing/recompiling
|
||||
// the whole shared word list used to happen on every query candidate, which is
|
||||
// the scraper's hottest path. Writes through SaveConfig/SyncShared invalidate
|
||||
// the entry immediately; the TTL only bounds staleness for out-of-process edits.
|
||||
type recognitionWordsRuntimeEntry struct {
|
||||
repo *repository.Container
|
||||
cfg RecognitionWordsConfig
|
||||
rules []recognitionWordRule
|
||||
expiresAt time.Time
|
||||
}
|
||||
|
||||
const recognitionWordsRuntimeTTL = 30 * time.Second
|
||||
|
||||
var recognitionWordsRuntimeCache struct {
|
||||
sync.RWMutex
|
||||
entry recognitionWordsRuntimeEntry
|
||||
valid bool
|
||||
}
|
||||
|
||||
func recognitionWordsRuntime(ctx context.Context, repo *repository.Container) (RecognitionWordsConfig, []recognitionWordRule) {
|
||||
now := time.Now()
|
||||
recognitionWordsRuntimeCache.RLock()
|
||||
cached := recognitionWordsRuntimeCache.entry
|
||||
hit := recognitionWordsRuntimeCache.valid && cached.repo == repo && now.Before(cached.expiresAt)
|
||||
recognitionWordsRuntimeCache.RUnlock()
|
||||
if hit {
|
||||
return cached.cfg, cached.rules
|
||||
}
|
||||
|
||||
cfg := recognitionWordsConfig(ctx, repo)
|
||||
rules := parseRecognitionWordRules(recognitionWordsCombinedText(cfg))
|
||||
recognitionWordsRuntimeCache.Lock()
|
||||
recognitionWordsRuntimeCache.entry = recognitionWordsRuntimeEntry{
|
||||
repo: repo,
|
||||
cfg: cfg,
|
||||
rules: rules,
|
||||
expiresAt: now.Add(recognitionWordsRuntimeTTL),
|
||||
}
|
||||
recognitionWordsRuntimeCache.valid = true
|
||||
recognitionWordsRuntimeCache.Unlock()
|
||||
return cfg, rules
|
||||
}
|
||||
|
||||
func invalidateRecognitionWordsRuntime(repo *repository.Container) {
|
||||
recognitionWordsRuntimeCache.Lock()
|
||||
if recognitionWordsRuntimeCache.entry.repo == repo {
|
||||
recognitionWordsRuntimeCache.valid = false
|
||||
}
|
||||
recognitionWordsRuntimeCache.Unlock()
|
||||
}
|
||||
|
||||
func cloneRecognitionWordsConfig(cfg RecognitionWordsConfig) RecognitionWordsConfig {
|
||||
cfg.SharedURLs = append([]string(nil), cfg.SharedURLs...)
|
||||
return cfg
|
||||
}
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
// The rule set is cached process-wide, so a config write must invalidate it
|
||||
// immediately; otherwise the admin would have to wait out the TTL before a
|
||||
// saved word list takes effect.
|
||||
func TestRecognitionWordsCacheInvalidatedBySaveConfig(t *testing.T) {
|
||||
repos := newOrganizerTestRepo(t)
|
||||
svc := NewRecognitionWordsService(zap.NewNop(), repos)
|
||||
|
||||
if err := svc.SaveConfig(t.Context(), RecognitionWordsConfig{
|
||||
Enabled: true,
|
||||
LocalText: "BADWORD => 好标题",
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := ApplyRecognitionWords(t.Context(), repos, "BADWORD"); got != "好标题" {
|
||||
t.Fatalf("first apply = %q, want 好标题", got)
|
||||
}
|
||||
|
||||
if err := svc.SaveConfig(t.Context(), RecognitionWordsConfig{
|
||||
Enabled: true,
|
||||
LocalText: "BADWORD => 新标题",
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := ApplyRecognitionWords(t.Context(), repos, "BADWORD"); got != "新标题" {
|
||||
t.Fatalf("cached rules were not invalidated after SaveConfig: got %q, want 新标题", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRecognitionWordsDisabledReturnsRawInput(t *testing.T) {
|
||||
repos := newOrganizerTestRepo(t)
|
||||
svc := NewRecognitionWordsService(zap.NewNop(), repos)
|
||||
if err := svc.SaveConfig(t.Context(), RecognitionWordsConfig{
|
||||
Enabled: false,
|
||||
LocalText: "BADWORD => 好标题",
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := ApplyRecognitionWords(t.Context(), repos, "BADWORD"); got != "BADWORD" {
|
||||
t.Fatalf("disabled recognition words changed input: got %q", got)
|
||||
}
|
||||
}
|
||||
@@ -31,6 +31,11 @@ func parseRecognitionWordRule(line string) recognitionWordRule {
|
||||
case strings.Contains(part, "<>") && strings.Contains(part, ">>"):
|
||||
beforeAfter := strings.SplitN(part, ">>", 2)
|
||||
bounds := strings.SplitN(beforeAfter[0], "<>", 2)
|
||||
// A malformed rule without "<>" would otherwise index past the
|
||||
// slice; word lists are fetched from the network, so stay defensive.
|
||||
if len(bounds) < 2 {
|
||||
continue
|
||||
}
|
||||
rule.offsetLeft = strings.TrimSpace(bounds[0])
|
||||
rule.offsetRight = strings.TrimSpace(bounds[1])
|
||||
rule.offsetExpr = strings.TrimSpace(beforeAfter[1])
|
||||
@@ -38,56 +43,89 @@ func parseRecognitionWordRule(line string) recognitionWordRule {
|
||||
rule.block = part
|
||||
}
|
||||
}
|
||||
compileRecognitionWordRule(&rule)
|
||||
return rule
|
||||
}
|
||||
|
||||
var recognitionReplacementRE = regexp.MustCompile(`\\([0-9]+)`)
|
||||
|
||||
func normalizeRecognitionReplacement(value string) string {
|
||||
re := regexp.MustCompile(`\\([0-9]+)`)
|
||||
return re.ReplaceAllString(value, "$$$1")
|
||||
return recognitionReplacementRE.ReplaceAllString(value, "$$$1")
|
||||
}
|
||||
|
||||
// compileRecognitionWordRule pre-compiles every pattern in a rule so the hot
|
||||
// clean-query path never recompiles regexes per candidate.
|
||||
func compileRecognitionWordRule(rule *recognitionWordRule) {
|
||||
if rule == nil {
|
||||
return
|
||||
}
|
||||
if rule.block != "" {
|
||||
if re, err := regexp.Compile(rule.block); err == nil {
|
||||
rule.blockRE = re
|
||||
}
|
||||
}
|
||||
if rule.replaceFrom != "" {
|
||||
if re, err := regexp.Compile(rule.replaceFrom); err == nil {
|
||||
rule.replaceRE = re
|
||||
}
|
||||
}
|
||||
if rule.offsetExpr != "" && (rule.offsetLeft != "" || rule.offsetRight != "") {
|
||||
if re, err := compileRecognitionOffsetRE(rule.offsetLeft, rule.offsetRight); err == nil {
|
||||
rule.offsetRE = re
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func compileRecognitionOffsetRE(left, right string) (*regexp.Regexp, error) {
|
||||
leftPattern := firstNonEmpty(left, `^`)
|
||||
rightPattern := firstNonEmpty(right, `$`)
|
||||
return regexp.Compile(`(?i)(` + leftPattern + `)(\d{1,5})(` + rightPattern + `)`)
|
||||
}
|
||||
|
||||
func applyRecognitionWordRules(raw string, rules []recognitionWordRule) string {
|
||||
out := strings.TrimSpace(raw)
|
||||
for _, rule := range rules {
|
||||
if rule.block != "" {
|
||||
out = applyRecognitionBlock(out, rule.block)
|
||||
out = applyRecognitionBlock(out, rule)
|
||||
}
|
||||
if rule.replaceFrom != "" {
|
||||
out = applyRecognitionReplace(out, rule.replaceFrom, rule.replaceTo)
|
||||
out = applyRecognitionReplace(out, rule)
|
||||
}
|
||||
if rule.offsetLeft != "" || rule.offsetRight != "" {
|
||||
out = applyRecognitionOffset(out, rule.offsetLeft, rule.offsetRight, rule.offsetExpr)
|
||||
out = applyRecognitionOffset(out, rule)
|
||||
}
|
||||
}
|
||||
return strings.Join(strings.Fields(out), " ")
|
||||
}
|
||||
|
||||
func applyRecognitionBlock(raw, block string) string {
|
||||
if re, err := regexp.Compile(block); err == nil {
|
||||
return re.ReplaceAllString(raw, " ")
|
||||
func applyRecognitionBlock(raw string, rule recognitionWordRule) string {
|
||||
if rule.blockRE != nil {
|
||||
return rule.blockRE.ReplaceAllString(raw, " ")
|
||||
}
|
||||
return strings.ReplaceAll(raw, block, " ")
|
||||
return strings.ReplaceAll(raw, rule.block, " ")
|
||||
}
|
||||
|
||||
func applyRecognitionReplace(raw, from, to string) string {
|
||||
if re, err := regexp.Compile(from); err == nil {
|
||||
return re.ReplaceAllString(raw, to)
|
||||
func applyRecognitionReplace(raw string, rule recognitionWordRule) string {
|
||||
if rule.replaceRE != nil {
|
||||
return rule.replaceRE.ReplaceAllString(raw, rule.replaceTo)
|
||||
}
|
||||
return strings.ReplaceAll(raw, from, to)
|
||||
return strings.ReplaceAll(raw, rule.replaceFrom, rule.replaceTo)
|
||||
}
|
||||
|
||||
func applyRecognitionOffset(raw, left, right, expr string) string {
|
||||
if strings.TrimSpace(expr) == "" {
|
||||
func applyRecognitionOffset(raw string, rule recognitionWordRule) string {
|
||||
if strings.TrimSpace(rule.offsetExpr) == "" {
|
||||
return raw
|
||||
}
|
||||
leftPattern := firstNonEmpty(left, `^`)
|
||||
rightPattern := firstNonEmpty(right, `$`)
|
||||
re, err := regexp.Compile(`(?i)(` + leftPattern + `)(\d{1,5})(` + rightPattern + `)`)
|
||||
if err != nil {
|
||||
return raw
|
||||
re := rule.offsetRE
|
||||
if re == nil {
|
||||
compiled, err := compileRecognitionOffsetRE(rule.offsetLeft, rule.offsetRight)
|
||||
if err != nil {
|
||||
return raw
|
||||
}
|
||||
re = compiled
|
||||
}
|
||||
return re.ReplaceAllStringFunc(raw, func(match string) string {
|
||||
return applyRecognitionOffsetMatch(re, match, expr)
|
||||
return applyRecognitionOffsetMatch(re, match, rule.offsetExpr)
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
@@ -138,7 +142,10 @@ type localScanMediaInput struct {
|
||||
func (s *ScannerService) buildLocalScanMedia(in localScanMediaInput) *model.Media {
|
||||
title, year := CleanQueryWithRecognition(context.Background(), s.repo, in.path)
|
||||
if title == "" {
|
||||
title = strings.TrimSuffix(filepath.Base(in.path), in.ext)
|
||||
title = mediaSidecarBase(in.path)
|
||||
if title == "" {
|
||||
title = strings.TrimSuffix(filepath.Base(in.path), in.ext)
|
||||
}
|
||||
}
|
||||
title, year = preferISOParentScrapeIdentity(in.path, in.lib.Path, title, year)
|
||||
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -63,12 +63,17 @@ func (s *ScraperService) EnrichOneWithOptions(ctx context.Context, m *model.Medi
|
||||
}
|
||||
}
|
||||
|
||||
// Same stale-negative problem as the library path: a single-item retry
|
||||
// (queue task / manual rescrape) must get a fresh provider round-trip.
|
||||
if options.RetryNoMatch && s != nil {
|
||||
s.lookupCache.clearNegatives()
|
||||
}
|
||||
candidates := scrapeQueryCandidatesWithRecognition(ctx, s.repo, m, lib)
|
||||
var query string
|
||||
match := (*Match)(nil)
|
||||
for _, candidate := range candidates {
|
||||
query = candidate
|
||||
candidateMatch := s.lookup(ctx, lib, m, candidate, year)
|
||||
candidateMatch := s.lookup(ctx, lib, m, candidate, year, options.ForceRematch)
|
||||
if candidateMatch == nil {
|
||||
continue
|
||||
}
|
||||
@@ -93,7 +98,11 @@ func (s *ScraperService) EnrichOneWithOptions(ctx context.Context, m *model.Medi
|
||||
}
|
||||
if match == nil {
|
||||
if local != nil && !local.PathHint {
|
||||
return s.applyLocalMetadataMatch(ctx, m, local)
|
||||
err := s.applyLocalMetadataMatch(ctx, m, local)
|
||||
if err == nil {
|
||||
options.recordProvider("local")
|
||||
}
|
||||
return err
|
||||
}
|
||||
if err := s.repo.DB.WithContext(ctx).Model(&model.Media{}).Where("id = ?", m.ID).
|
||||
Update("scrape_status", "no_match").Error; err != nil {
|
||||
@@ -119,8 +128,22 @@ func (s *ScraperService) applyProviderMatch(ctx context.Context, m *model.Media,
|
||||
func (s *ScraperService) applyProviderMatchWithOptions(ctx context.Context, m *model.Media, lib *model.Library, match *Match, options ScrapeOptions) error {
|
||||
posterCandidate := match.PosterURL
|
||||
backdropCandidate := match.BackdropURL
|
||||
posterURL, removePoster := s.prepareScrapedArtworkURL(ctx, m.ID, "poster_url", m.PosterURL, posterCandidate)
|
||||
backdropURL, removeBackdrop := s.prepareScrapedArtworkURL(ctx, m.ID, "backdrop_url", m.BackdropURL, backdropCandidate)
|
||||
currentPoster := m.PosterURL
|
||||
currentBackdrop := m.BackdropURL
|
||||
if options.RebuildIdentity {
|
||||
currentPoster = ""
|
||||
currentBackdrop = ""
|
||||
}
|
||||
posterURL, removePoster := s.prepareScrapedArtworkURL(ctx, m.ID, "poster_url", currentPoster, posterCandidate)
|
||||
backdropURL, removeBackdrop := s.prepareScrapedArtworkURL(ctx, m.ID, "backdrop_url", currentBackdrop, backdropCandidate)
|
||||
if options.RebuildIdentity {
|
||||
if strings.TrimSpace(m.PosterURL) != posterURL {
|
||||
removePoster = m.PosterURL
|
||||
}
|
||||
if strings.TrimSpace(m.BackdropURL) != backdropURL {
|
||||
removeBackdrop = m.BackdropURL
|
||||
}
|
||||
}
|
||||
updates := map[string]any{
|
||||
"title": match.Title,
|
||||
"overview": match.Overview,
|
||||
@@ -142,6 +165,12 @@ func (s *ScraperService) applyProviderMatchWithOptions(ctx context.Context, m *m
|
||||
updates["languages"] = strings.Join(match.Languages, ",")
|
||||
updates["nsfw"] = match.NSFW
|
||||
}
|
||||
if options.RebuildIdentity {
|
||||
updates["season_num"] = m.SeasonNum
|
||||
updates["episode_num"] = m.EpisodeNum
|
||||
updates["episode_title"] = ""
|
||||
updates["series_id"] = ""
|
||||
}
|
||||
if match.ReleaseDate != "" {
|
||||
updates["release_date"] = match.ReleaseDate
|
||||
}
|
||||
@@ -207,6 +236,11 @@ func (s *ScraperService) applyProviderMatchWithOptions(ctx context.Context, m *m
|
||||
"thetvdb_id": match.TheTVDBID,
|
||||
"source": map[bool]string{true: "adult"}[match.NSFW],
|
||||
})
|
||||
provider := strings.ToLower(strings.TrimSpace(match.Provider))
|
||||
if provider == "" {
|
||||
provider = "unknown"
|
||||
}
|
||||
options.recordProvider(provider)
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,313 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"go.uber.org/zap"
|
||||
|
||||
"github.com/truewhile/MeBox/internal/config"
|
||||
"github.com/truewhile/MeBox/internal/model"
|
||||
)
|
||||
|
||||
func TestTheatricalTitleVariants(t *testing.T) {
|
||||
tests := []struct {
|
||||
input string
|
||||
want []string
|
||||
}{
|
||||
{
|
||||
input: "名侦探柯南 剧场版01 引爆摩天楼",
|
||||
want: []string{"名侦探柯南 引爆摩天楼", "名侦探柯南 剧场版01 引爆摩天楼"},
|
||||
},
|
||||
{
|
||||
input: "名侦探柯南 剧场版26 黑铁的鱼影",
|
||||
want: []string{"名侦探柯南 黑铁的鱼影", "名侦探柯南 剧场版26 黑铁的鱼影"},
|
||||
},
|
||||
{
|
||||
input: "鬼灭之刃 剧场版 无限列车篇",
|
||||
want: []string{"鬼灭之刃 无限列车篇", "鬼灭之刃 剧场版 无限列车篇"},
|
||||
},
|
||||
{
|
||||
input: "航海王 The Movie 黄金之城",
|
||||
want: []string{"航海王 The Movie 黄金之城"},
|
||||
},
|
||||
{
|
||||
input: "普通动漫 第01集",
|
||||
want: []string{"普通动漫 第01集"},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
got := theatricalTitleVariants(tc.input)
|
||||
if len(got) != len(tc.want) {
|
||||
t.Fatalf("theatricalTitleVariants(%q) = %v, want %v", tc.input, got, tc.want)
|
||||
}
|
||||
for i := range got {
|
||||
if got[i] != tc.want[i] {
|
||||
t.Fatalf("theatricalTitleVariants(%q)[%d] = %q, want %q", tc.input, i, got[i], tc.want[i])
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestMetadataMatchCompatibilityForTheatricalFeatures(t *testing.T) {
|
||||
animeMatch := &Match{MediaType: "anime"}
|
||||
|
||||
if metadataMatchCompatibleWithType("movie", animeMatch) {
|
||||
t.Fatal("ordinary movie lookups must not accept anime matches")
|
||||
}
|
||||
if !metadataMatchCompatibleWithTheatrical("movie", true, animeMatch) {
|
||||
t.Fatal("theatrical movie lookup should accept an anime provider match")
|
||||
}
|
||||
if metadataMatchCompatibleWithTheatrical("movie", false, animeMatch) {
|
||||
t.Fatal("non-theatrical movie lookup must not accept an anime provider match")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMediaLooksLikeTheatricalFeatureIgnoresStaleEpisodeIdentity(t *testing.T) {
|
||||
media := &model.Media{
|
||||
Title: "未命名",
|
||||
Path: `/media/anime/超人高校生们/剧场版/超人高校生们 剧场版.mkv`,
|
||||
SeasonNum: 1,
|
||||
EpisodeNum: 1,
|
||||
}
|
||||
if !mediaLooksLikeTheatricalFeature(media) {
|
||||
t.Fatal("theatrical path should override stale persisted season/episode fields")
|
||||
}
|
||||
|
||||
namedFolder := &model.Media{
|
||||
Title: "Eiga Yurukyan 2022 Bluray",
|
||||
Path: `/media/anime/摇曳露营△ (2018)/摇曳露营△ 剧场版 (2022)/Eiga.Yurukyan.2022.Bluray.mkv`,
|
||||
}
|
||||
if !mediaLooksLikeTheatricalFeature(namedFolder) {
|
||||
t.Fatal("a named theatrical folder with a year should be detected from the full path")
|
||||
}
|
||||
|
||||
realEpisode := &model.Media{
|
||||
Title: "剧场版制作幕后",
|
||||
Path: `/media/anime/超人高校生们/Season 01/超人高校生们.S01E01.mkv`,
|
||||
SeasonNum: 1,
|
||||
EpisodeNum: 1,
|
||||
}
|
||||
if mediaLooksLikeTheatricalFeature(realEpisode) {
|
||||
t.Fatal("an actual episode marker in the path must remain episodic")
|
||||
}
|
||||
}
|
||||
|
||||
func TestScrapeQueryCandidatesForAnimeTheatricalMix(t *testing.T) {
|
||||
lib := &model.Library{
|
||||
Path: `/media/anime`,
|
||||
Type: "anime",
|
||||
}
|
||||
|
||||
// 1. 同目录下包含剧场版01
|
||||
theatricalMedia := &model.Media{
|
||||
Title: "名侦探柯南 剧场版01 引爆摩天楼",
|
||||
Path: `/media/anime/名侦探柯南/名侦探柯南 剧场版01 引爆摩天楼.mkv`,
|
||||
}
|
||||
candidates := scrapeQueryCandidates(theatricalMedia, lib)
|
||||
if len(candidates) == 0 {
|
||||
t.Fatal("scrapeQueryCandidates returned no candidates for theatrical media")
|
||||
}
|
||||
if candidates[0] != "名侦探柯南 引爆摩天楼" {
|
||||
t.Fatalf("first candidate for theatrical media = %q, want cleaned movie title '名侦探柯南 引爆摩天楼'; all=%v", candidates[0], candidates)
|
||||
}
|
||||
|
||||
// 2. 剧场版放在以剧场版命名的子目录
|
||||
subfolderTheatricalMedia := &model.Media{
|
||||
Title: "无限列车篇",
|
||||
Path: `/media/anime/鬼灭之刃/剧场版/无限列车篇.mkv`,
|
||||
}
|
||||
subCandidates := scrapeQueryCandidates(subfolderTheatricalMedia, lib)
|
||||
if len(subCandidates) == 0 {
|
||||
t.Fatal("scrapeQueryCandidates returned no candidates for subfolder theatrical media")
|
||||
}
|
||||
foundCombined := false
|
||||
for _, c := range subCandidates {
|
||||
if c == "鬼灭之刃 无限列车篇" {
|
||||
foundCombined = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !foundCombined {
|
||||
t.Fatalf("candidates for subfolder theatrical did not contain '鬼灭之刃 无限列车篇': %v", subCandidates)
|
||||
}
|
||||
|
||||
// 3. 混合放置的普通剧集分集不应受剧场版影响
|
||||
episodeMedia := &model.Media{
|
||||
Title: "名侦探柯南 - S01E01",
|
||||
Path: `/media/anime/名侦探柯南/名侦探柯南 - S01E01.mp4`,
|
||||
SeasonNum: 1,
|
||||
EpisodeNum: 1,
|
||||
}
|
||||
epCandidates := scrapeQueryCandidates(episodeMedia, lib)
|
||||
if len(epCandidates) == 0 {
|
||||
t.Fatal("scrapeQueryCandidates returned no candidates for episode media")
|
||||
}
|
||||
if epCandidates[0] != "名侦探柯南" {
|
||||
t.Fatalf("first candidate for tv episode = %q, want series folder title '名侦探柯南'", epCandidates[0])
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnrichOneAnimeMixedEpisodesAndTheatrical(t *testing.T) {
|
||||
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
switch r.URL.Path {
|
||||
case "/search/tv":
|
||||
q := r.URL.Query().Get("query")
|
||||
if q == "鬼灭之刃" {
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{
|
||||
"results": []map[string]any{{
|
||||
"id": 85937,
|
||||
"name": "鬼灭之刃",
|
||||
"original_name": "鬼滅の刃",
|
||||
"overview": "大正时期、日本...",
|
||||
"first_air_date": "2019-04-06",
|
||||
}},
|
||||
})
|
||||
return
|
||||
}
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{"results": []any{}})
|
||||
case "/search/movie":
|
||||
q := r.URL.Query().Get("query")
|
||||
if q == "鬼灭之刃 无限列车篇" || q == "鬼灭之刃 剧场版 无限列车篇" {
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{
|
||||
"results": []map[string]any{{
|
||||
"id": 635302,
|
||||
"title": "鬼灭之刃 剧场版 无限列车篇",
|
||||
"original_title": "劇場版「鬼滅の刃」無限列車編",
|
||||
"overview": "在结束了蝴蝶屋的修业之后...",
|
||||
"release_date": "2020-10-16",
|
||||
}},
|
||||
})
|
||||
return
|
||||
}
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{"results": []any{}})
|
||||
case "/tv/85937":
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{
|
||||
"id": 85937,
|
||||
"name": "鬼灭之刃",
|
||||
"original_name": "鬼滅の刃",
|
||||
"first_air_date": "2019-04-06",
|
||||
})
|
||||
case "/movie/635302":
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{
|
||||
"id": 635302,
|
||||
"title": "鬼灭之刃 剧场版 无限列车篇",
|
||||
"original_title": "劇場版「鬼滅の刃」無限列車編",
|
||||
"release_date": "2020-10-16",
|
||||
})
|
||||
default:
|
||||
http.NotFound(w, r)
|
||||
}
|
||||
}))
|
||||
defer upstream.Close()
|
||||
|
||||
repos := newOrganizerTestRepo(t)
|
||||
cfg := &config.Config{}
|
||||
cfg.Secrets.TMDbAPIKey = "test-key"
|
||||
cfg.Secrets.TMDbAPIProxy = upstream.URL
|
||||
log := zap.NewNop()
|
||||
scraper := NewScraperService(cfg, log, repos, NewTMDbProvider(cfg, log, nil), nil, nil, nil, NewHub(log))
|
||||
|
||||
lib := model.Library{Name: "动漫", Path: `/media/anime`, Type: "anime", Enabled: true}
|
||||
if err := repos.DB.Create(&lib).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// 1. 同一目录下的 TV 剧集
|
||||
tvEpisode := model.Media{
|
||||
LibraryID: lib.ID,
|
||||
Title: "鬼灭之刃 S01E01",
|
||||
Path: `/media/anime/鬼灭之刃/Season 01/鬼灭之刃 - S01E01.mkv`,
|
||||
SeasonNum: 1,
|
||||
EpisodeNum: 1,
|
||||
ScrapeStatus: "pending",
|
||||
}
|
||||
if err := repos.DB.Create(&tvEpisode).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// 2. 同一目录下的剧场版文件
|
||||
movieMedia := model.Media{
|
||||
LibraryID: lib.ID,
|
||||
Title: "鬼灭之刃 剧场版 无限列车篇",
|
||||
Path: `/media/anime/鬼灭之刃/鬼灭之刃 剧场版 无限列车篇.mkv`,
|
||||
ScrapeStatus: "pending",
|
||||
}
|
||||
if err := repos.DB.Create(&movieMedia).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// 刮削剧集
|
||||
if err := scraper.EnrichOne(t.Context(), &tvEpisode); err != nil {
|
||||
t.Fatalf("enrich tv episode: %v", err)
|
||||
}
|
||||
gotTV, err := repos.Media.FindByID(t.Context(), tvEpisode.ID)
|
||||
if err != nil || gotTV == nil {
|
||||
t.Fatalf("load tv episode: %v", err)
|
||||
}
|
||||
if gotTV.ScrapeStatus != "matched" || gotTV.TMDbID != 85937 {
|
||||
t.Fatalf("tv episode scrape mismatch: status=%s, tmdb_id=%d", gotTV.ScrapeStatus, gotTV.TMDbID)
|
||||
}
|
||||
|
||||
// 刮削剧场版
|
||||
if err := scraper.EnrichOne(t.Context(), &movieMedia); err != nil {
|
||||
t.Fatalf("enrich movie: %v", err)
|
||||
}
|
||||
gotMovie, err := repos.Media.FindByID(t.Context(), movieMedia.ID)
|
||||
if err != nil || gotMovie == nil {
|
||||
t.Fatalf("load movie: %v", err)
|
||||
}
|
||||
if gotMovie.ScrapeStatus != "matched" || gotMovie.TMDbID != 635302 {
|
||||
t.Fatalf("theatrical movie scrape mismatch: status=%s, tmdb_id=%d, want 635302", gotMovie.ScrapeStatus, gotMovie.TMDbID)
|
||||
}
|
||||
if gotMovie.Title != "鬼灭之刃 剧场版 无限列车篇" {
|
||||
t.Fatalf("theatrical movie title = %q, want '鬼灭之刃 剧场版 无限列车篇'", gotMovie.Title)
|
||||
}
|
||||
}
|
||||
|
||||
func TestClassifyAnimeTheatricalMovie(t *testing.T) {
|
||||
categories := map[string]string{
|
||||
"animation_movie": "动画电影",
|
||||
"jp_anime": "日番",
|
||||
"chinese_movie": "华语电影",
|
||||
}
|
||||
tests := []struct {
|
||||
title string
|
||||
mediaType string
|
||||
want string
|
||||
}{
|
||||
{
|
||||
title: "名侦探柯南 剧场版01 引爆摩天楼",
|
||||
mediaType: "movie",
|
||||
want: "动画电影",
|
||||
},
|
||||
{
|
||||
title: "鬼灭之刃 剧场版 无限列车篇",
|
||||
mediaType: "movie",
|
||||
want: "动画电影",
|
||||
},
|
||||
{
|
||||
title: "鬼灭之刃 S01E01",
|
||||
mediaType: "tv",
|
||||
want: "日番",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
input := mediaClassifyInput{
|
||||
MediaType: tc.mediaType,
|
||||
Title: tc.title,
|
||||
Languages: []string{"JA"},
|
||||
Countries: []string{"JP"},
|
||||
Genres: []string{"Animation"},
|
||||
}
|
||||
got := classifyMediaCategory(input, categories)
|
||||
if got != tc.want {
|
||||
t.Fatalf("classifyMediaCategory(%q, %q) = %q, want %q", tc.title, tc.mediaType, got, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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') {
|
||||
|
||||
@@ -13,7 +13,12 @@ import (
|
||||
// lookup runs the provider chain after local NFO has been considered:
|
||||
// TMDb -> Douban -> Bangumi -> TheTVDB. Douban and Bangumi do not require API
|
||||
// keys; providers that are unavailable or return an error are skipped.
|
||||
func (s *ScraperService) lookup(ctx context.Context, lib *model.Library, media *model.Media, query string, year int) *Match {
|
||||
//
|
||||
// Results are cached per (kind, theatrical, query, year) because every
|
||||
// episode of a show produces the same candidate; bypassCache forces a fresh
|
||||
// provider round-trip for user-triggered rematches but still refreshes the
|
||||
// cache so later episodes reuse the corrected result.
|
||||
func (s *ScraperService) lookup(ctx context.Context, lib *model.Library, media *model.Media, query string, year int, bypassCache bool) *Match {
|
||||
kind := ""
|
||||
if lib != nil {
|
||||
kind = lib.Type
|
||||
@@ -22,23 +27,50 @@ func (s *ScraperService) lookup(ctx context.Context, lib *model.Library, media *
|
||||
// was previously polluted into S01E20x. Do not let the dirty path override
|
||||
// that caller-supplied media type; TV/anime libraries still force TV below.
|
||||
explicitEpisode := media != nil && (media.SeasonNum > 0 || media.EpisodeNum > 0)
|
||||
if (normalizeOrganizeMediaType(kind) != "movie" || explicitEpisode) && mediaIsEpisodic(media, lib) {
|
||||
isTheatrical := mediaLooksLikeTheatricalFeature(media)
|
||||
if isTheatrical {
|
||||
kind = "movie"
|
||||
} else if (normalizeOrganizeMediaType(kind) != "movie" || explicitEpisode) && mediaIsEpisodic(media, lib) {
|
||||
kind = "tv"
|
||||
}
|
||||
|
||||
cacheKey := scrapeLookupCacheKey(kind, query, year, isTheatrical)
|
||||
if !bypassCache {
|
||||
if cached, ok := s.lookupCache.get(cacheKey); ok {
|
||||
return cached
|
||||
}
|
||||
}
|
||||
match := s.lookupUncached(ctx, kind, isTheatrical, query, year)
|
||||
// Always refresh the cache, even on bypassed (forced) lookups, so a
|
||||
// corrected rematch replaces the stale entry for later episodes.
|
||||
s.lookupCache.set(cacheKey, match)
|
||||
return match
|
||||
}
|
||||
|
||||
func (s *ScraperService) lookupUncached(ctx context.Context, kind string, isTheatrical bool, query string, year int) *Match {
|
||||
if s.tmdb != nil && s.tmdb.Enabled() {
|
||||
if match := s.lookupAutomaticTMDb(ctx, kind, query, year); match != nil {
|
||||
match.Provider = "tmdb"
|
||||
return match
|
||||
}
|
||||
if isTheatrical {
|
||||
if match := s.lookupAutomaticTMDb(ctx, "tv", query, year); match != nil {
|
||||
match.Provider = "tmdb"
|
||||
return match
|
||||
}
|
||||
}
|
||||
}
|
||||
if s.douban != nil && s.douban.Enabled() {
|
||||
if m, err := s.douban.SearchMatch(ctx, query); err == nil && m != nil && metadataMatchCompatibleWithType(kind, m) {
|
||||
if m, err := s.douban.SearchMatch(ctx, query); err == nil && m != nil && metadataMatchCompatibleWithTheatrical(kind, isTheatrical, m) {
|
||||
m.Provider = "douban"
|
||||
return m
|
||||
} else if err != nil {
|
||||
s.log.Debug("douban search failed", zap.String("query", query), zap.Error(err))
|
||||
}
|
||||
}
|
||||
if s.bangumi != nil && s.bangumi.Enabled() {
|
||||
if m, err := s.bangumi.Search(ctx, query); err == nil && m != nil && metadataMatchCompatibleWithType(kind, m) {
|
||||
if m, err := s.bangumi.Search(ctx, query); err == nil && m != nil && metadataMatchCompatibleWithTheatrical(kind, isTheatrical, m) {
|
||||
m.Provider = "bangumi"
|
||||
return m
|
||||
} else if err != nil {
|
||||
s.log.Debug("bangumi search failed", zap.String("query", query), zap.Error(err))
|
||||
@@ -46,6 +78,7 @@ func (s *ScraperService) lookup(ctx context.Context, lib *model.Library, media *
|
||||
}
|
||||
if (kind == "anime" || kind == "tv" || kind == "variety" || kind == "show" || kind == "shows") && s.thetvdb != nil && s.thetvdb.Enabled() {
|
||||
if m, err := s.thetvdb.SearchSeries(ctx, query); err == nil && m != nil && metadataMatchCompatibleWithType(kind, m) {
|
||||
m.Provider = "thetvdb"
|
||||
return m
|
||||
} else if err != nil {
|
||||
s.log.Debug("thetvdb search failed", zap.String("query", query), zap.Error(err))
|
||||
@@ -94,6 +127,13 @@ func (s *ScraperService) EnrichLibraryDetailed(ctx context.Context, libraryID st
|
||||
|
||||
func (s *ScraperService) EnrichLibraryDetailedWithOptions(ctx context.Context, libraryID string, options ScrapeOptions) (EnrichLibraryResult, error) {
|
||||
result := EnrichLibraryResult{LibraryID: libraryID}
|
||||
// A manual retry must not reuse stale negative cache entries from the
|
||||
// previous run, otherwise "重新刮削" looks like it did nothing. Positives
|
||||
// are kept so the rest of the season still dedupes; the first re-queried
|
||||
// episode refreshes the entry for the rows behind it.
|
||||
if options.RetryNoMatch && s != nil {
|
||||
s.lookupCache.clearNegatives()
|
||||
}
|
||||
rows, err := s.scrapeCandidateRows(ctx, libraryID, options)
|
||||
if err != nil {
|
||||
return result, err
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -0,0 +1,126 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// A TV library scrapes one media row per episode, and every row in the same
|
||||
// show computes the same query candidate (the series folder title). Without a
|
||||
// cache each episode re-issues the identical TMDb/Douban/Bangumi/TheTVDB
|
||||
// search, so a 100-episode season costs 100x the network calls it needs.
|
||||
//
|
||||
// The cache lives on the ScraperService instance (not a package global) so that
|
||||
// two services with different provider configuration never share results. TTL
|
||||
// bounds staleness for out-of-band edits; explicit invalidation is unnecessary
|
||||
// because the key includes the effective media kind, theatrical flag, query
|
||||
// and year. Negative entries use a shorter TTL so a failed query is retried
|
||||
// sooner without manual intervention.
|
||||
const (
|
||||
scrapeLookupCacheTTL = 10 * time.Minute
|
||||
scrapeLookupNegativeCacheTTL = 2 * time.Minute
|
||||
scrapeLookupCacheMaxItems = 1024
|
||||
)
|
||||
|
||||
type scrapeLookupCache struct {
|
||||
mu sync.Mutex
|
||||
entries map[string]scrapeLookupCacheEntry
|
||||
}
|
||||
|
||||
type scrapeLookupCacheEntry struct {
|
||||
match *Match
|
||||
expiresAt time.Time
|
||||
}
|
||||
|
||||
func newScrapeLookupCache() *scrapeLookupCache {
|
||||
return &scrapeLookupCache{entries: map[string]scrapeLookupCacheEntry{}}
|
||||
}
|
||||
|
||||
func scrapeLookupCacheKey(kind, query string, year int, isTheatrical bool) string {
|
||||
theatrical := "0"
|
||||
if isTheatrical {
|
||||
theatrical = "1"
|
||||
}
|
||||
return strings.ToLower(strings.TrimSpace(kind)) + "|" +
|
||||
theatrical + "|" +
|
||||
strconv.Itoa(year) + "|" +
|
||||
strings.ToLower(strings.TrimSpace(query))
|
||||
}
|
||||
|
||||
// get reports whether the key is cached. A hit may carry a nil match, which
|
||||
// means the provider chain already ran and found nothing (negative cache).
|
||||
// Use clearNegative before a user-triggered retry so stale negative entries
|
||||
// do not suppress the fresh provider round-trip.
|
||||
func (c *scrapeLookupCache) get(key string) (*Match, bool) {
|
||||
if c == nil || key == "" {
|
||||
return nil, false
|
||||
}
|
||||
now := time.Now()
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
item, ok := c.entries[key]
|
||||
if !ok {
|
||||
return nil, false
|
||||
}
|
||||
if now.After(item.expiresAt) {
|
||||
delete(c.entries, key)
|
||||
return nil, false
|
||||
}
|
||||
return cloneMatch(item.match), true
|
||||
}
|
||||
|
||||
func (c *scrapeLookupCache) set(key string, match *Match) {
|
||||
if c == nil || key == "" {
|
||||
return
|
||||
}
|
||||
now := time.Now()
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
if len(c.entries) >= scrapeLookupCacheMaxItems {
|
||||
for k, item := range c.entries {
|
||||
if now.After(item.expiresAt) || len(c.entries) >= scrapeLookupCacheMaxItems {
|
||||
delete(c.entries, k)
|
||||
}
|
||||
if len(c.entries) < scrapeLookupCacheMaxItems {
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
ttl := scrapeLookupCacheTTL
|
||||
if match == nil {
|
||||
ttl = scrapeLookupNegativeCacheTTL
|
||||
}
|
||||
c.entries[key] = scrapeLookupCacheEntry{match: cloneMatch(match), expiresAt: now.Add(ttl)}
|
||||
}
|
||||
|
||||
// clearNegatives drops cached misses so a user-triggered retry gets a fresh
|
||||
// provider round-trip instead of reusing a stale negative entry.
|
||||
func (c *scrapeLookupCache) clearNegatives() {
|
||||
if c == nil {
|
||||
return
|
||||
}
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
for k, item := range c.entries {
|
||||
if item.match == nil {
|
||||
delete(c.entries, k)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// cloneMatch returns an independent copy so callers that mutate the result
|
||||
// (localized title preference, local metadata merge, fanart artwork) cannot
|
||||
// corrupt the cached entry or leak state between media rows.
|
||||
func cloneMatch(m *Match) *Match {
|
||||
if m == nil {
|
||||
return nil
|
||||
}
|
||||
out := *m
|
||||
out.Languages = append([]string(nil), m.Languages...)
|
||||
out.Countries = append([]string(nil), m.Countries...)
|
||||
out.Genres = append([]string(nil), m.Genres...)
|
||||
out.Aliases = append([]string(nil), m.Aliases...)
|
||||
return &out
|
||||
}
|
||||
@@ -0,0 +1,165 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
|
||||
"github.com/glebarez/sqlite"
|
||||
"go.uber.org/zap"
|
||||
"gorm.io/gorm"
|
||||
|
||||
"github.com/truewhile/MeBox/internal/config"
|
||||
"github.com/truewhile/MeBox/internal/model"
|
||||
"github.com/truewhile/MeBox/internal/repository"
|
||||
)
|
||||
|
||||
// Every episode of a show produces the same query candidate (the series folder
|
||||
// title). Without the lookup cache each episode re-issues the identical search,
|
||||
// so a whole season costs one request per episode. This asserts the provider is
|
||||
// hit once and subsequent episodes reuse the cached match.
|
||||
func TestEnrichLibraryReusesLookupCacheAcrossEpisodes(t *testing.T) {
|
||||
var searchCalls int32
|
||||
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
switch r.URL.Path {
|
||||
case "/search/tv":
|
||||
if r.URL.Query().Get("query") != "折腰" {
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{"results": []any{}})
|
||||
return
|
||||
}
|
||||
atomic.AddInt32(&searchCalls, 1)
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{
|
||||
"results": []map[string]any{{
|
||||
"id": 296753,
|
||||
"name": "折腰",
|
||||
"overview": "正确的剧集条目",
|
||||
"poster_path": "/zheyao.jpg",
|
||||
"first_air_date": "2025-05-13",
|
||||
"origin_country": []string{"CN"},
|
||||
}},
|
||||
})
|
||||
default:
|
||||
http.NotFound(w, r)
|
||||
}
|
||||
}))
|
||||
defer upstream.Close()
|
||||
|
||||
db, err := gorm.Open(sqlite.Open("file::memory:?cache=shared"), &gorm.Config{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := db.AutoMigrate(&model.Library{}, &model.Series{}, &model.Media{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
repos := repository.New(db)
|
||||
cfg := &config.Config{}
|
||||
cfg.Secrets.TMDbAPIKey = "test-key"
|
||||
cfg.Secrets.TMDbAPIProxy = upstream.URL
|
||||
cfg.Secrets.TMDbImageProxy = upstream.URL + "/images"
|
||||
log := zap.NewNop()
|
||||
scraper := NewScraperService(cfg, log, repos, NewTMDbProvider(cfg, log, nil), nil, nil, nil, NewHub(log))
|
||||
|
||||
lib := model.Library{Name: "OpenList · 刮削缓存测试库", Path: "cloud://openlist/scrape-lookup-cache", Type: "tv", Enabled: true}
|
||||
if err := repos.DB.Create(&lib).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
const episodeCount = 4
|
||||
for episode := 1; episode <= episodeCount; episode++ {
|
||||
media := model.Media{
|
||||
LibraryID: lib.ID,
|
||||
Title: "折腰",
|
||||
Path: "cloud://openlist/scrape-lookup-cache/折腰 (2025)/Season 1/折腰.S01E0" + string(rune('0'+episode)) + ".mkv",
|
||||
SeasonNum: 1,
|
||||
EpisodeNum: episode,
|
||||
ScrapeStatus: "pending",
|
||||
}
|
||||
if err := repos.DB.Create(&media).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
result, err := scraper.EnrichLibraryDetailedWithOptions(t.Context(), lib.ID, ScrapeOptions{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if result.Matched != episodeCount {
|
||||
t.Fatalf("matched = %d, want %d", result.Matched, episodeCount)
|
||||
}
|
||||
if calls := atomic.LoadInt32(&searchCalls); calls != 1 {
|
||||
t.Fatalf("tmdb /search/tv called %d times for %d episodes; want 1 (cache should dedupe identical queries)", calls, episodeCount)
|
||||
}
|
||||
}
|
||||
|
||||
// A cached match must be handed out as an independent copy: mutating one row's
|
||||
// result (localized title preference, local metadata merge) must not bleed into
|
||||
// the next row.
|
||||
func TestScrapeLookupCacheReturnsIndependentCopies(t *testing.T) {
|
||||
cache := newScrapeLookupCache()
|
||||
original := &Match{
|
||||
Title: "折腰",
|
||||
TMDbID: 296753,
|
||||
Genres: []string{"剧情"},
|
||||
Countries: []string{"CN"},
|
||||
Aliases: []string{"Zhe Yao"},
|
||||
}
|
||||
key := scrapeLookupCacheKey("tv", "折腰", 2025, false)
|
||||
cache.set(key, original)
|
||||
|
||||
first, ok := cache.get(key)
|
||||
if !ok || first == nil {
|
||||
t.Fatal("expected a cache hit")
|
||||
}
|
||||
first.Title = "mutated"
|
||||
first.Genres[0] = "mutated"
|
||||
first.Aliases = append(first.Aliases, "extra")
|
||||
|
||||
second, ok := cache.get(key)
|
||||
if !ok || second == nil {
|
||||
t.Fatal("expected a second cache hit")
|
||||
}
|
||||
if second.Title != "折腰" || second.Genres[0] != "剧情" || len(second.Aliases) != 1 {
|
||||
t.Fatalf("cached match was mutated through a returned copy: %+v", second)
|
||||
}
|
||||
}
|
||||
|
||||
// Negative results are cached too: a query that matched nothing must not be
|
||||
// re-issued for every remaining episode of the same show.
|
||||
func TestScrapeLookupCacheStoresNegativeResults(t *testing.T) {
|
||||
cache := newScrapeLookupCache()
|
||||
key := scrapeLookupCacheKey("tv", "no-such-show", 0, false)
|
||||
cache.set(key, nil)
|
||||
if _, ok := cache.get(key); !ok {
|
||||
t.Fatal("negative result should be cached to avoid repeated provider calls")
|
||||
}
|
||||
}
|
||||
|
||||
// The theatrical flag is part of the key: a theatrical feature gets a tv
|
||||
// fallback lookup, so its result must not be shared with (or returned for)
|
||||
// the same query issued for a non-theatrical media row.
|
||||
func TestScrapeLookupCacheKeySeparatesTheatrical(t *testing.T) {
|
||||
plain := scrapeLookupCacheKey("movie", "query", 2024, false)
|
||||
theatrical := scrapeLookupCacheKey("movie", "query", 2024, true)
|
||||
if plain == theatrical {
|
||||
t.Fatalf("theatrical flag must be part of the cache key: %q", plain)
|
||||
}
|
||||
}
|
||||
|
||||
// A manual "retry no match" run clears stale negative entries so the provider
|
||||
// chain is actually re-queried instead of short-circuiting on the cached miss.
|
||||
func TestScrapeLookupCacheClearNegativesKeepsPositives(t *testing.T) {
|
||||
cache := newScrapeLookupCache()
|
||||
negKey := scrapeLookupCacheKey("tv", "missing", 0, false)
|
||||
posKey := scrapeLookupCacheKey("tv", "found", 0, false)
|
||||
cache.set(negKey, nil)
|
||||
cache.set(posKey, &Match{Title: "found"})
|
||||
cache.clearNegatives()
|
||||
if _, ok := cache.get(negKey); ok {
|
||||
t.Fatal("negative entry should be cleared before a manual retry")
|
||||
}
|
||||
if got, ok := cache.get(posKey); !ok || got == nil || got.Title != "found" {
|
||||
t.Fatalf("positive entry should survive clearNegatives: %+v", got)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -24,11 +24,15 @@ var noiseTokens = []string{
|
||||
"hkfree", "yify", "rarbg", "ettv", "fgt", "tgx", "ctrlhd", "ntb", "flux", "qhstudio",
|
||||
|
||||
// 流媒体平台 / 字幕组 / 国家版本(动漫常见)
|
||||
"netflix", "nf", "amzn", "hulu", "disney", "max", "hbo",
|
||||
// 注意:不要把同时是常见英文单词的标记放进来(如 max / web / judas),
|
||||
// 否则 "Mad Max"、"Web Therapy" 这类正常标题会被误删。裸 "WEB" 发布标记
|
||||
// 通常位于分辨率之后,由 releaseBoundary 截断规则处理;"WEB-DL" 则由
|
||||
// multiWordNoise 单独匹配。
|
||||
"netflix", "nf", "amzn", "hulu", "disney", "hbo",
|
||||
"linetv", "ourtv", "iqiyi", "youku", "bilibili", "qiyi", "krj",
|
||||
"atvp", "appletv", "apple-tv", "tx", "txweb",
|
||||
"crunchyroll", "funimation", "anidb", "horriblesubs", "subsplease",
|
||||
"erai-raws", "judas", "asw", "smcat", "leopard-raws", "ohys-raws", "colortv",
|
||||
"erai-raws", "asw", "smcat", "leopard-raws", "ohys-raws", "colortv",
|
||||
"mweb", "ubweb", "hhweb", "adweb", "chdweb", "kurosawa", "qhstudio",
|
||||
|
||||
// 中文字幕标记
|
||||
@@ -55,6 +59,20 @@ var releaseBoundaryTokenSet = map[string]struct{}{
|
||||
"x264": {}, "x265": {}, "h264": {}, "h265": {}, "h266": {}, "hevc": {}, "avc": {}, "av1": {}, "vvc": {},
|
||||
}
|
||||
|
||||
// weakReleaseBoundaryTokenSet are release tags that double as plausible title
|
||||
// words. Before any release signal has been seen they are kept as part of the
|
||||
// title ("Mad Max", "Web Therapy"), so a title is never truncated to nothing;
|
||||
// after a real signal they behave like any other tag.
|
||||
var weakReleaseBoundaryTokenSet = map[string]struct{}{
|
||||
"bd": {}, "dvd": {}, "web": {},
|
||||
}
|
||||
|
||||
// releaseSignalToken marks the position of an extracted year. The year itself
|
||||
// is not a title token, but its presence still proves the following tokens are
|
||||
// release tags ("复仇者联盟4.2019.BD.1080p" must drop "BD"). A control rune is
|
||||
// used so it can never collide with a real filename token.
|
||||
const releaseSignalToken = "\u0001"
|
||||
|
||||
var dynamicReleaseBoundaryTokenRE = regexp.MustCompile(`(?i)^(?:\d{3,4}p|\d{2,3}fps)$`)
|
||||
|
||||
// bracketedTag matches "[anything]", "(anything)" or "{anything}" segments.
|
||||
@@ -75,12 +93,20 @@ func CleanQuery(raw string) (title string, year int) {
|
||||
base = strings.TrimSpace(raw)
|
||||
}
|
||||
name := strings.TrimSuffix(base, filepath.Ext(base))
|
||||
// .strm 可能保留视频扩展名(name.mkv.strm),再剥一层真实视频扩展,
|
||||
// 避免「竞女01.mkv」与「竞女01.mp4」被当成不同标题。
|
||||
name = mediaFileStem(base)
|
||||
if name == "" {
|
||||
name = strings.TrimSuffix(base, filepath.Ext(base))
|
||||
}
|
||||
lower := strings.ToLower(name)
|
||||
|
||||
if m := yearPattern.FindStringSubmatch(lower); len(m) >= 2 {
|
||||
if v, err := strconv.Atoi(m[1]); err == nil {
|
||||
year = v
|
||||
lower = strings.ReplaceAll(lower, m[1], " ")
|
||||
// Keep a positional marker: the year is not a title token, but its
|
||||
// presence arms release-tag truncation for what follows.
|
||||
lower = strings.ReplaceAll(lower, m[1], " "+releaseSignalToken+" ")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -104,17 +130,35 @@ func CleanQuery(raw string) (title string, year int) {
|
||||
}
|
||||
// 拆分后丢掉过短(≤1)且全为 ASCII 数字 / 字母的"碎片",避免
|
||||
// 「2」「0」「v」之类残留干扰 TMDb 搜索。中文字符不算碎片。
|
||||
//
|
||||
// seenReleaseBoundary 只有在遇到分辨率/编码等强标记后才置位,置位后其余
|
||||
// ASCII 词一律视为发布尾巴丢弃;releaseSignalled 则宽松得多,年份标记也会
|
||||
// 置位,它只用来武装弱标记(bd/dvd/web)。这样 "Web Therapy" 不会被截空,
|
||||
// 而 "2019.Avatar.1080p" 这种年份前置的标题也不会因为年份标记把后面的
|
||||
// 真实标题词当成尾巴丢掉。
|
||||
out := make([]string, 0, 8)
|
||||
seenReleaseBoundary := false
|
||||
releaseSignalled := false
|
||||
for _, w := range strings.Fields(lower) {
|
||||
if w == releaseSignalToken {
|
||||
releaseSignalled = true
|
||||
continue
|
||||
}
|
||||
if dynamicReleaseBoundaryTokenRE.MatchString(w) {
|
||||
releaseSignalled = true
|
||||
seenReleaseBoundary = true
|
||||
continue
|
||||
}
|
||||
if _, ok := noiseTokenSet[w]; ok {
|
||||
if _, boundary := releaseBoundaryTokenSet[w]; boundary {
|
||||
if _, boundary := releaseBoundaryTokenSet[w]; boundary {
|
||||
if _, weak := weakReleaseBoundaryTokenSet[w]; weak && !releaseSignalled {
|
||||
// Ambiguous tag that is also a plausible title word; keep it
|
||||
// until a real release signal proves we are past the title.
|
||||
} else {
|
||||
releaseSignalled = true
|
||||
seenReleaseBoundary = true
|
||||
continue
|
||||
}
|
||||
} else if _, ok := noiseTokenSet[w]; ok {
|
||||
continue
|
||||
}
|
||||
if seenReleaseBoundary && isASCIIWord(w) {
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
package service
|
||||
|
||||
import "testing"
|
||||
|
||||
// CleanQuery must not delete words that are also ordinary English title words
|
||||
// just because a release group reused them as a tag. Regression guard: "max"
|
||||
// and "web" used to be unconditional noise/boundary tokens, which turned
|
||||
// "Mad Max" into "mad" and "Web Therapy" into an empty query.
|
||||
func TestCleanQueryKeepsCommonEnglishTitleWords(t *testing.T) {
|
||||
cases := []struct {
|
||||
in string
|
||||
wantTitle string
|
||||
wantYear int
|
||||
}{
|
||||
{"Mad.Max.1979.1080p.BluRay.mkv", "mad max", 1979},
|
||||
{"Max.Payne.2008.1080p.WEB-DL.mkv", "max payne", 2008},
|
||||
{"Web.Therapy.S01E01.1080p.WEB-DL.mkv", "web therapy", 0},
|
||||
{"The.Web.2019.1080p.mkv", "the web", 2019},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.in, func(t *testing.T) {
|
||||
gotTitle, gotYear := CleanQuery(tc.in)
|
||||
if gotTitle != tc.wantTitle || gotYear != tc.wantYear {
|
||||
t.Errorf("CleanQuery(%q) = (%q, %d), want (%q, %d)",
|
||||
tc.in, gotTitle, gotYear, tc.wantTitle, tc.wantYear)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// A title consisting only of an ambiguous tag plus a release tail must not be
|
||||
// truncated to nothing; the tag stays so the query still has a chance.
|
||||
func TestCleanQueryDoesNotTruncateAmbiguousTagToNothing(t *testing.T) {
|
||||
for _, in := range []string{"Web.1080p.WEB-DL.mkv", "BD.720p.mkv"} {
|
||||
title, _ := CleanQuery(in)
|
||||
if title == "" {
|
||||
t.Errorf("CleanQuery(%q) returned an empty title", in)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A year-prefixed filename must keep its title: the year arms weak-tag
|
||||
// truncation but must not discard the real title words that follow it.
|
||||
func TestCleanQueryKeepsTitleAfterYearPrefix(t *testing.T) {
|
||||
cases := map[string]string{
|
||||
"2019.Avatar.1080p.BluRay.mkv": "avatar",
|
||||
"2024.Dune.Part.Two.2160p.WEB.mkv": "dune part two",
|
||||
}
|
||||
for in, want := range cases {
|
||||
t.Run(in, func(t *testing.T) {
|
||||
got, year := CleanQuery(in)
|
||||
if got != want {
|
||||
t.Errorf("CleanQuery(%q) = (%q, %d), want title %q", in, got, year, want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Release-tag truncation must still fire once a real release signal (an
|
||||
// extracted year, resolution or codec) has been seen, so tags after it are
|
||||
// dropped as before.
|
||||
func TestCleanQueryStillDropsTagsAfterReleaseSignal(t *testing.T) {
|
||||
cases := map[string]string{
|
||||
"复仇者联盟4.2019.BD.1080p.mkv": "复仇者联盟4",
|
||||
"The.Matrix.1999.1080p.WEB-DL.H265.mp4": "the matrix",
|
||||
"Oppenheimer.2023.2160p.UHD.BluRay.mkv": "oppenheimer",
|
||||
"Interstellar.2014.4k.hdr.dts.atmos.mkv": "interstellar",
|
||||
}
|
||||
for in, want := range cases {
|
||||
t.Run(in, func(t *testing.T) {
|
||||
got, _ := CleanQuery(in)
|
||||
if got != want {
|
||||
t.Errorf("CleanQuery(%q) = %q, want %q", in, got, want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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,14 @@ type ScraperService struct {
|
||||
hub *Hub
|
||||
cache *RuntimeCacheService
|
||||
images *ImageProxy
|
||||
|
||||
// Caches provider-chain results keyed by media kind/query/year. Per-instance
|
||||
// so services with different provider config never share results.
|
||||
lookupCache *scrapeLookupCache
|
||||
|
||||
// Serializes final sidecar replacement. Windows cannot rename over an
|
||||
// existing file, and concurrent scrapes can target the same sidecar.
|
||||
artworkWriteMu sync.Mutex
|
||||
}
|
||||
|
||||
// NewScraperService is the constructor.
|
||||
@@ -45,6 +54,7 @@ func NewScraperService(
|
||||
return &ScraperService{
|
||||
cfg: cfg, log: log, repo: repo,
|
||||
tmdb: tmdb, bangumi: bangumi, thetvdb: thetvdb, fanart: fanart, adult: adultProvider, hub: hub,
|
||||
lookupCache: newScrapeLookupCache(),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -38,44 +38,90 @@ func (s *ScraperService) writeMediaArtworkFilesAfterScrape(ctx context.Context,
|
||||
if dir == "" || dir == "." {
|
||||
return
|
||||
}
|
||||
// Scope sidecar names by the media file's base name (e.g. A.mp4 -> A-poster.jpg)
|
||||
// so that multiple movies sharing one directory (A.mp4 + B.mp4) never clash.
|
||||
base := strings.TrimSuffix(filepath.Base(refreshed.Path), filepath.Ext(refreshed.Path))
|
||||
// Scope sidecar names by the shared media stem (e.g. A.mkv.strm / A.mp4.strm -> A-poster.jpg)
|
||||
// so multi-version files in one folder share artwork and never diverge by container.
|
||||
base := mediaSidecarBase(refreshed.Path)
|
||||
if base == "" || base == "." {
|
||||
return
|
||||
}
|
||||
isAdult := IsAdultMediaPathOrMetadata(refreshed.Path, refreshed.LibraryID, refreshed.NSFW) || IsAdultArtworkURL(refreshed.PosterURL)
|
||||
isAdult := shouldCropAdultPoster(refreshed, lib)
|
||||
artworkUpdates := map[string]any{}
|
||||
if refreshed.PosterURL != "" {
|
||||
s.downloadArtworkToPathWithOptions(ctx, dir, base+"-poster", refreshed.PosterURL, isAdult)
|
||||
if dst := s.downloadArtworkToPathWithOptions(ctx, dir, base+"-poster", refreshed.PosterURL, isAdult); dst != "" {
|
||||
artworkUpdates["poster_url"] = filepath.Join(filepath.Dir(refreshed.Path), filepath.Base(dst))
|
||||
}
|
||||
}
|
||||
if refreshed.BackdropURL != "" {
|
||||
s.downloadArtworkToPathWithOptions(ctx, dir, base+"-backdrop", refreshed.BackdropURL, false)
|
||||
if dst := s.downloadArtworkToPathWithOptions(ctx, dir, base+"-backdrop", refreshed.BackdropURL, false); dst != "" {
|
||||
artworkUpdates["backdrop_url"] = filepath.Join(filepath.Dir(refreshed.Path), filepath.Base(dst))
|
||||
}
|
||||
} else if isAdult && refreshed.PosterURL != "" {
|
||||
// 番号海报原图为完整封套横图,在无独立背景图时直接作为背景图写出
|
||||
s.downloadArtworkToPathWithOptions(ctx, dir, base+"-backdrop", refreshed.PosterURL, false)
|
||||
if dst := s.downloadArtworkToPathWithOptions(ctx, dir, base+"-backdrop", refreshed.PosterURL, false); dst != "" {
|
||||
artworkUpdates["backdrop_url"] = filepath.Join(filepath.Dir(refreshed.Path), filepath.Base(dst))
|
||||
}
|
||||
}
|
||||
if len(artworkUpdates) > 0 {
|
||||
if err := s.repo.DB.WithContext(ctx).Model(&model.Media{}).
|
||||
Where("id = ?", refreshed.ID).Updates(artworkUpdates).Error; err != nil {
|
||||
s.log.Warn("save local scraped artwork paths failed", zap.String("media_id", refreshed.ID), zap.Error(err))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (s *ScraperService) downloadArtworkToPath(ctx context.Context, dir, name, raw string) {
|
||||
s.downloadArtworkToPathWithOptions(ctx, dir, name, raw, false)
|
||||
func (s *ScraperService) downloadArtworkToPath(ctx context.Context, dir, name, raw string) string {
|
||||
return s.downloadArtworkToPathWithOptions(ctx, dir, name, raw, false)
|
||||
}
|
||||
|
||||
// shouldCropAdultPoster keeps adult-cover handling independent of which
|
||||
// metadata provider won. A code-numbered title may match TMDb first, so the
|
||||
// provider's NSFW flag or artwork host alone is not sufficient.
|
||||
func shouldCropAdultPoster(media *model.Media, lib *model.Library) bool {
|
||||
if media == nil {
|
||||
return false
|
||||
}
|
||||
mediaType := ""
|
||||
if lib != nil {
|
||||
mediaType = lib.Type
|
||||
}
|
||||
return IsAdultMediaPathOrMetadata(media.Path, mediaType, media.NSFW) ||
|
||||
IsAdultArtworkURL(media.PosterURL)
|
||||
}
|
||||
|
||||
// downloadArtworkToPathWithOptions fetches an artwork URL via the image proxy cache and
|
||||
// writes it under dir/<name>.<ext>. For adult posters, it crops the right half of the cover.
|
||||
func (s *ScraperService) downloadArtworkToPathWithOptions(ctx context.Context, dir, name, raw string, cropAdultPoster bool) {
|
||||
if !isHTTPish(raw) {
|
||||
return
|
||||
func (s *ScraperService) downloadArtworkToPathWithOptions(ctx context.Context, dir, name, raw string, cropAdultPoster bool) string {
|
||||
var (
|
||||
data []byte
|
||||
ctype string
|
||||
err error
|
||||
)
|
||||
switch {
|
||||
case isHTTPish(raw):
|
||||
data, ctype, err = s.images.Fetch(ctx, raw)
|
||||
case isLocalImagePath(raw):
|
||||
data, err = os.ReadFile(sanitizeLocalPath(resolveMappedDestinationPath(raw)))
|
||||
if err == nil {
|
||||
ctype = detectContentType(data)
|
||||
}
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
data, ctype, err := s.images.Fetch(ctx, raw)
|
||||
if err != nil || len(data) == 0 {
|
||||
s.log.Warn("scrape artwork download failed",
|
||||
zap.String("name", name),
|
||||
zap.String("url", raw),
|
||||
zap.Error(err))
|
||||
return
|
||||
return ""
|
||||
}
|
||||
if !isImageContentType(ctype) || isTransparentPlaceholderData(data) {
|
||||
return
|
||||
return ""
|
||||
}
|
||||
// Upstreams sometimes report non-standard values such as image/jpg.
|
||||
// Use the decoded bytes as the source of truth so sidecars receive a
|
||||
// standard extension instead of the legacy .img fallback.
|
||||
if detected := detectContentType(data); isImageContentType(detected) {
|
||||
ctype = detected
|
||||
}
|
||||
if cropAdultPoster {
|
||||
if cropped, croppedType, err := CropAdultCoverPoster(data); err == nil && len(cropped) > 0 {
|
||||
@@ -83,7 +129,7 @@ func (s *ScraperService) downloadArtworkToPathWithOptions(ctx context.Context, d
|
||||
ctype = croppedType
|
||||
}
|
||||
}
|
||||
s.writeArtworkDataToPath(dir, name, ctype, data)
|
||||
return s.writeArtworkDataToPath(dir, name, ctype, data)
|
||||
}
|
||||
|
||||
// writeArtworkDataToPath writes in-memory artwork bytes to dir/<name>.<ext>
|
||||
@@ -110,10 +156,23 @@ func (s *ScraperService) writeArtworkDataToPath(dir, name, ctype string, data []
|
||||
s.log.Warn("scrape artwork write failed", zap.String("dst", dst), zap.Error(err))
|
||||
return ""
|
||||
}
|
||||
_ = tmp.Close()
|
||||
if err := os.Rename(tmp.Name(), dst); err != nil {
|
||||
if err := tmp.Close(); err != nil {
|
||||
_ = os.Remove(tmp.Name())
|
||||
s.log.Warn("scrape artwork rename failed", zap.String("dst", dst), zap.Error(err))
|
||||
s.log.Warn("scrape artwork close failed", zap.String("dst", dst), zap.Error(err))
|
||||
return ""
|
||||
}
|
||||
|
||||
// On Windows os.Rename does not replace dst. Serialize remove+rename so two
|
||||
// concurrent scrapes cannot leave the previous uncropped DVD cover behind.
|
||||
s.artworkWriteMu.Lock()
|
||||
err = os.Remove(dst)
|
||||
if err == nil || os.IsNotExist(err) {
|
||||
err = os.Rename(tmp.Name(), dst)
|
||||
}
|
||||
s.artworkWriteMu.Unlock()
|
||||
if err != nil {
|
||||
_ = os.Remove(tmp.Name())
|
||||
s.log.Warn("scrape artwork replace failed", zap.String("dst", dst), zap.Error(err))
|
||||
return ""
|
||||
}
|
||||
s.log.Debug("scrape artwork written", zap.String("dst", dst))
|
||||
@@ -125,7 +184,7 @@ func (s *ScraperService) writeArtworkDataToPath(dir, name, ctype string, data []
|
||||
// extension that could confuse media players.
|
||||
func imageExtForContentType(ctype string) string {
|
||||
switch strings.ToLower(strings.TrimSpace(strings.Split(ctype, ";")[0])) {
|
||||
case "image/jpeg", "image/pjpeg":
|
||||
case "image/jpeg", "image/jpg", "image/pjpeg":
|
||||
return ".jpg"
|
||||
case "image/png", "image/x-png":
|
||||
return ".png"
|
||||
|
||||
@@ -1,13 +1,22 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"image"
|
||||
"image/color"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/glebarez/sqlite"
|
||||
"go.uber.org/zap"
|
||||
"gorm.io/gorm"
|
||||
|
||||
"github.com/truewhile/MeBox/internal/config"
|
||||
"github.com/truewhile/MeBox/internal/model"
|
||||
"github.com/truewhile/MeBox/internal/repository"
|
||||
)
|
||||
|
||||
// TestWriteArtworkDataToPathWritesJellyfinSidecar verifies that in-memory
|
||||
@@ -33,11 +42,53 @@ func TestWriteArtworkDataToPathWritesJellyfinSidecar(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriteArtworkDataToPathReplacesExistingSidecar(t *testing.T) {
|
||||
scraper := &ScraperService{log: zap.NewNop()}
|
||||
mediaDir := t.TempDir()
|
||||
dst := filepath.Join(mediaDir, "poster.jpg")
|
||||
if err := os.WriteFile(dst, []byte("old DVD cover"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if got := scraper.writeArtworkDataToPath(mediaDir, "poster", "image/jpeg", testJPEG); got != dst {
|
||||
t.Fatalf("destination = %q, want %q", got, dst)
|
||||
}
|
||||
data, err := os.ReadFile(dst)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if string(data) != string(testJPEG) {
|
||||
t.Fatal("existing sidecar was not replaced")
|
||||
}
|
||||
}
|
||||
|
||||
func TestShouldCropAdultPosterWhenTMDbArtworkMatchedCodePath(t *testing.T) {
|
||||
media := &model.Media{
|
||||
Path: filepath.Join(t.TempDir(), "IPX-235.mp4"),
|
||||
Title: "TMDb matched title",
|
||||
PosterURL: "https://image.tmdb.org/t/p/w500/poster.jpg",
|
||||
}
|
||||
if !shouldCropAdultPoster(media, &model.Library{Type: "movie"}) {
|
||||
t.Fatal("code-numbered media should retain adult crop handling after a TMDb match")
|
||||
}
|
||||
}
|
||||
|
||||
func TestShouldCropAdultPosterUsesLibraryType(t *testing.T) {
|
||||
media := &model.Media{
|
||||
Path: filepath.Join(t.TempDir(), "renamed.mp4"),
|
||||
PosterURL: "https://image.tmdb.org/t/p/w500/poster.jpg",
|
||||
}
|
||||
if !shouldCropAdultPoster(media, &model.Library{Type: "adult"}) {
|
||||
t.Fatal("adult library media should be cropped regardless of provider")
|
||||
}
|
||||
}
|
||||
|
||||
// TestImageExtForContentType verifies the MIME -> extension mapping used to
|
||||
// name Jellyfin sidecar files.
|
||||
func TestImageExtForContentType(t *testing.T) {
|
||||
cases := map[string]string{
|
||||
"image/jpeg": ".jpg",
|
||||
"image/jpg": ".jpg",
|
||||
"image/pjpeg": ".jpg",
|
||||
"image/png": ".png",
|
||||
"image/webp": ".webp",
|
||||
@@ -53,6 +104,32 @@ func TestImageExtForContentType(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestDownloadArtworkMigratesLocalImgToCanonicalCroppedJPEG(t *testing.T) {
|
||||
scraper := &ScraperService{log: zap.NewNop()}
|
||||
dir := t.TempDir()
|
||||
legacy := filepath.Join(dir, "movie-poster.img")
|
||||
wide := createTestImage(900, 600, color.RGBA{R: 255, A: 255}, color.RGBA{B: 255, A: 255})
|
||||
if err := os.WriteFile(legacy, wide, 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
dst := scraper.downloadArtworkToPathWithOptions(t.Context(), dir, "movie-poster", legacy, true)
|
||||
if filepath.Base(dst) != "movie-poster.jpg" {
|
||||
t.Fatalf("destination = %q, want canonical JPEG sidecar", dst)
|
||||
}
|
||||
data, err := os.ReadFile(dst)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
cropped, _, err := image.Decode(bytes.NewReader(data))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := float64(cropped.Bounds().Dx()) / float64(cropped.Bounds().Dy()); got < 0.65 || got > 0.68 {
|
||||
t.Fatalf("migrated poster ratio = %.3f, want 2:3", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestWriteMediaArtworkFilesAfterScrapeSkipsCloud verifies that cloud media
|
||||
// never has artwork written anywhere.
|
||||
func TestWriteMediaArtworkFilesAfterScrapeSkipsCloud(t *testing.T) {
|
||||
@@ -87,3 +164,65 @@ func TestSameDirectoryMediaUsesBaseScopedSidecars(t *testing.T) {
|
||||
t.Fatalf("base = %q, want A-poster.jpg", filepath.Base(dst))
|
||||
}
|
||||
}
|
||||
|
||||
func TestLocalAdultArtworkStoresFaceAwarePosterPath(t *testing.T) {
|
||||
wideCover := createTestImage(
|
||||
900,
|
||||
600,
|
||||
color.RGBA{R: 255, A: 255},
|
||||
color.RGBA{B: 255, A: 255},
|
||||
)
|
||||
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.Header().Set("Content-Type", "image/jpeg")
|
||||
_, _ = w.Write(wideCover)
|
||||
}))
|
||||
defer upstream.Close()
|
||||
|
||||
cfg := &config.Config{}
|
||||
cfg.Cache.CacheDir = t.TempDir()
|
||||
images := NewImageProxy(cfg, zap.NewNop())
|
||||
images.allowedRemoteHostsFn = func() []string { return []string{"127.0.0.1"} }
|
||||
|
||||
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := db.AutoMigrate(&model.Media{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
repos := repository.New(db)
|
||||
mediaDir := t.TempDir()
|
||||
media := model.Media{
|
||||
Title: "IPX-235",
|
||||
Path: filepath.Join(mediaDir, "IPX-235.mp4"),
|
||||
PosterURL: upstream.URL + "/cover.jpg",
|
||||
NSFW: true,
|
||||
ScrapeStatus: "matched",
|
||||
}
|
||||
if err := repos.DB.Create(&media).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
scraper := &ScraperService{cfg: cfg, log: zap.NewNop(), repo: repos, images: images}
|
||||
scraper.writeMediaArtworkFilesAfterScrape(t.Context(), &media, &model.Library{Type: "adult"})
|
||||
|
||||
var got model.Media
|
||||
if err := repos.DB.First(&got, "id = ?", media.ID).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if filepath.Base(got.PosterURL) != "IPX-235-poster.jpg" {
|
||||
t.Fatalf("poster URL = %q, want local face-aware sidecar", got.PosterURL)
|
||||
}
|
||||
data, err := os.ReadFile(got.PosterURL)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
cropped, _, err := image.Decode(bytes.NewReader(data))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ratio := float64(cropped.Bounds().Dx()) / float64(cropped.Bounds().Dy())
|
||||
if ratio < 0.65 || ratio > 0.68 {
|
||||
t.Fatalf("local poster ratio = %.3f, want MetaTube primary ratio", ratio)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -121,6 +121,10 @@ func (b *serviceContainerBuilder) initContentServices() {
|
||||
b.c.FFTools = NewFFmpegToolsService(b.cfg, b.log, b.repos)
|
||||
// 弹幕 hash 识别需要把 strm 指向解析成可拉取的直链/本地路径。
|
||||
b.c.Danmaku.SetStrmResolver(b.c.Strm.ResolvePlay)
|
||||
// STRM 直连失败后的 HLS 转码:把 .strm 解析成 ffmpeg 可读取的本地路径或 HTTP 直链。
|
||||
b.c.Transcoder.SetStrmPlayTargetResolver(b.c.Strm.ResolvePlayTarget)
|
||||
b.c.Transcoder.SetProbe(b.c.FFprobe)
|
||||
b.c.Subtitle.SetStrmPlayTargetResolver(b.c.Strm.ResolvePlayTarget)
|
||||
// 弹幕识别需要把远程 Emby 条目解析为 Media 元数据及可拉取前 16MB 的直链 URL。
|
||||
if b.c.EmbyRemote != nil {
|
||||
b.c.Danmaku.SetRemoteMediaResolver(func(ctx context.Context, encodedID string) (*model.Media, string, error) {
|
||||
@@ -177,6 +181,11 @@ func (b *serviceContainerBuilder) initIdentityServices() {
|
||||
func (b *serviceContainerBuilder) initImageProxy() {
|
||||
b.c.ImageProxy = NewImageProxy(b.cfg, b.log)
|
||||
b.c.ImageProxy.SetLibraryRootsProvider(b.libraryRoots)
|
||||
if b.c.EmbyRemote != nil {
|
||||
b.c.ImageProxy.SetAllowedRemoteHostsProvider(func() []string {
|
||||
return b.c.EmbyRemote.ConfiguredRemoteHosts(context.Background())
|
||||
})
|
||||
}
|
||||
b.c.Scan.SetImageProxy(b.c.ImageProxy)
|
||||
b.c.Scraper.SetImageProxy(b.c.ImageProxy)
|
||||
}
|
||||
|
||||
@@ -87,7 +87,7 @@ func playableSTRMTarget(ctx context.Context, repo *repository.Container, raw str
|
||||
}
|
||||
|
||||
// IsStrmMediaRow 判断媒体行是否为 .strm(远程直链)媒体:STRMURL 非空、
|
||||
// container=strm 或路径以 .strm 结尾。strm 媒体只能直连播放,禁止转码。
|
||||
// container=strm 或路径以 .strm 结尾。网页播放默认直连,失败后可转码。
|
||||
func IsStrmMediaRow(m *model.Media) bool {
|
||||
if m == nil {
|
||||
return false
|
||||
|
||||
@@ -6,23 +6,36 @@ import (
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// ServeHLSPlaylist makes sure a transcode is running and writes the m3u8.
|
||||
// We block (with a 30s timeout) until the playlist file shows up.
|
||||
//
|
||||
// Optional query `start` (seconds) restarts ffmpeg from that source offset so
|
||||
// the web player can scrub the full timeline without waiting for a full
|
||||
// head-to-tail transcode.
|
||||
func (s *StreamService) ServeHLSPlaylist(w http.ResponseWriter, r *http.Request, mediaID string) error {
|
||||
// 「客户端直连解码」模式下宿主机不提供转码,HLS 一律拒绝,
|
||||
// 迫使播放器走 direct play 本地解码。
|
||||
if s.directPlayOnly(r.Context()) {
|
||||
return ErrTranscodeDisabled
|
||||
}
|
||||
if _, err := s.transcoder.EnsureJob(r.Context(), mediaID); err != nil {
|
||||
startSec := parseHLSStartSec(r)
|
||||
seekGen := parseHLSSeekGen(r)
|
||||
subtitleStream := parseHLSSubtitleStream(r)
|
||||
if _, err := s.transcoder.EnsureJobFromSubtitle(r.Context(), mediaID, startSec, seekGen, subtitleStream); err != nil {
|
||||
return err
|
||||
}
|
||||
s.transcoder.TouchJob(mediaID)
|
||||
if !s.transcoder.WaitReady(r.Context(), mediaID, 30*time.Second) {
|
||||
readyTimeout := 45 * time.Second
|
||||
if startSec > 0.05 {
|
||||
// Mid-file HTTP seeks (esp. WMV) need longer before the first segment appears.
|
||||
readyTimeout = 120 * time.Second
|
||||
}
|
||||
if !s.transcoder.WaitReady(r.Context(), mediaID, readyTimeout) {
|
||||
return errors.New("hls playlist not ready")
|
||||
}
|
||||
playlist := s.transcoder.PlaylistPath(mediaID)
|
||||
@@ -48,10 +61,61 @@ func (s *StreamService) ServeHLSPlaylist(w http.ResponseWriter, r *http.Request,
|
||||
return nil
|
||||
}
|
||||
|
||||
func parseHLSSubtitleStream(r *http.Request) int {
|
||||
if r == nil {
|
||||
return -1
|
||||
}
|
||||
raw := strings.TrimSpace(r.URL.Query().Get("subtitle"))
|
||||
if raw == "" {
|
||||
return -1
|
||||
}
|
||||
v, err := strconv.Atoi(raw)
|
||||
if err != nil || v < 0 {
|
||||
return -1
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
func parseHLSStartSec(r *http.Request) float64 {
|
||||
if r == nil {
|
||||
return 0
|
||||
}
|
||||
raw := strings.TrimSpace(r.URL.Query().Get("start"))
|
||||
if raw == "" {
|
||||
return 0
|
||||
}
|
||||
v, err := strconv.ParseFloat(raw, 64)
|
||||
if err != nil || v < 0 {
|
||||
return 0
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
func parseHLSSeekGen(r *http.Request) int64 {
|
||||
if r == nil {
|
||||
return 0
|
||||
}
|
||||
raw := strings.TrimSpace(r.URL.Query().Get("_seek"))
|
||||
if raw == "" {
|
||||
return 0
|
||||
}
|
||||
v, err := strconv.ParseInt(raw, 10, 64)
|
||||
if err != nil || v < 0 {
|
||||
return 0
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
func appendQueryToHLSSegments(playlist, rawQuery string) string {
|
||||
if strings.TrimSpace(rawQuery) == "" {
|
||||
return playlist
|
||||
}
|
||||
// Segment fetches do not need start=, but must keep _seek as a cache-busting
|
||||
// generation because every transcode restart reuses seg_00000.ts names.
|
||||
q := filterHLSSegmentQuery(rawQuery)
|
||||
if q == "" {
|
||||
return playlist
|
||||
}
|
||||
lines := strings.SplitAfter(playlist, "\n")
|
||||
for i, line := range lines {
|
||||
trimmed := strings.TrimSpace(line)
|
||||
@@ -65,12 +129,32 @@ func appendQueryToHLSSegments(playlist, rawQuery string) string {
|
||||
} else if strings.HasSuffix(line, "\n") {
|
||||
lineEnding = "\n"
|
||||
}
|
||||
lines[i] = strings.TrimRight(line, "\r\n") + "?" + rawQuery + lineEnding
|
||||
lines[i] = strings.TrimRight(line, "\r\n") + "?" + q + lineEnding
|
||||
}
|
||||
}
|
||||
return strings.Join(lines, "")
|
||||
}
|
||||
|
||||
func filterHLSSegmentQuery(rawQuery string) string {
|
||||
parts := strings.Split(rawQuery, "&")
|
||||
kept := make([]string, 0, len(parts))
|
||||
for _, part := range parts {
|
||||
if part == "" {
|
||||
continue
|
||||
}
|
||||
key := part
|
||||
if i := strings.IndexByte(part, '='); i >= 0 {
|
||||
key = part[:i]
|
||||
}
|
||||
switch strings.ToLower(key) {
|
||||
case "start":
|
||||
continue
|
||||
}
|
||||
kept = append(kept, part)
|
||||
}
|
||||
return strings.Join(kept, "&")
|
||||
}
|
||||
|
||||
// ServeHLSSegment writes a single .ts segment from the on-disk cache.
|
||||
func (s *StreamService) ServeHLSSegment(w http.ResponseWriter, r *http.Request, mediaID, segment string) error {
|
||||
s.transcoder.TouchJob(mediaID)
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user