mirror of
https://github.com/truewhile/MeBox.git
synced 2026-09-28 11:16:37 +08:00
Compare commits
14 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 8f2551a1b6 | |||
| 18e6cb40fd | |||
| 92e693ccac | |||
| 62206aa05a | |||
| f9dd082159 | |||
| d489c1608e | |||
| 1ea29cca9e | |||
| ec5f81e6cb | |||
| 8eb017fedc | |||
| fb4e762026 | |||
| 4239474100 | |||
| 8225b4e319 | |||
| c086b01381 | |||
| f196e5621f |
@@ -11,42 +11,47 @@ require (
|
|||||||
github.com/golang-jwt/jwt/v5 v5.2.2
|
github.com/golang-jwt/jwt/v5 v5.2.2
|
||||||
github.com/google/uuid v1.6.0
|
github.com/google/uuid v1.6.0
|
||||||
github.com/gorilla/websocket v1.5.3
|
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/redis/go-redis/v9 v9.7.0
|
||||||
github.com/shirou/gopsutil/v3 v3.24.5
|
github.com/shirou/gopsutil/v3 v3.24.5
|
||||||
github.com/spf13/viper v1.18.2
|
github.com/spf13/viper v1.18.2
|
||||||
github.com/stretchr/testify v1.11.1
|
github.com/stretchr/testify v1.11.1
|
||||||
github.com/ulikunitz/xz v0.5.12
|
github.com/ulikunitz/xz v0.5.12
|
||||||
go.uber.org/zap v1.27.0
|
go.uber.org/zap v1.27.0
|
||||||
golang.org/x/crypto v0.48.0
|
golang.org/x/crypto v0.49.0
|
||||||
golang.org/x/sys v0.41.0
|
golang.org/x/image v0.37.0
|
||||||
|
golang.org/x/sys v0.42.0
|
||||||
golang.org/x/time v0.15.0
|
golang.org/x/time v0.15.0
|
||||||
gopkg.in/yaml.v3 v3.0.1
|
gopkg.in/yaml.v3 v3.0.1
|
||||||
gorm.io/driver/postgres v1.5.7
|
gorm.io/driver/postgres v1.6.0
|
||||||
gorm.io/gorm v1.30.0
|
gorm.io/gorm v1.31.1
|
||||||
)
|
)
|
||||||
|
|
||||||
require (
|
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 v1.15.0 // indirect
|
||||||
github.com/bytedance/sonic/loader v0.5.0 // indirect
|
github.com/bytedance/sonic/loader v0.5.0 // indirect
|
||||||
github.com/cespare/xxhash/v2 v2.2.0 // indirect
|
github.com/cespare/xxhash/v2 v2.2.0 // indirect
|
||||||
github.com/cloudwego/base64x v0.1.6 // indirect
|
github.com/cloudwego/base64x v0.1.6 // indirect
|
||||||
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // 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/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/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/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-ole/go-ole v1.2.6 // indirect
|
||||||
github.com/go-playground/locales v0.14.1 // indirect
|
github.com/go-playground/locales v0.14.1 // indirect
|
||||||
github.com/go-playground/universal-translator v0.18.1 // indirect
|
github.com/go-playground/universal-translator v0.18.1 // indirect
|
||||||
github.com/go-playground/validator/v10 v10.30.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/goccy/go-yaml v1.19.2 // indirect
|
||||||
github.com/hashicorp/hcl v1.0.0 // indirect
|
github.com/hashicorp/hcl v1.0.0 // indirect
|
||||||
github.com/jackc/pgpassfile 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/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
|
||||||
github.com/jackc/pgx/v5 v5.4.3 // 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/inflection v1.0.0 // indirect
|
||||||
github.com/jinzhu/now v1.1.5 // indirect
|
github.com/jinzhu/now v1.1.5 // indirect
|
||||||
github.com/json-iterator/go v1.1.12 // 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/mitchellh/mapstructure v1.5.0 // indirect
|
||||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
|
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
|
||||||
github.com/modern-go/reflect2 v1.0.2 // 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/pelletier/go-toml/v2 v2.2.4 // indirect
|
||||||
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
|
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
|
||||||
github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c // 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
|
github.com/yusufpapurcu/wmi v1.2.4 // indirect
|
||||||
go.mongodb.org/mongo-driver/v2 v2.5.0 // indirect
|
go.mongodb.org/mongo-driver/v2 v2.5.0 // indirect
|
||||||
go.uber.org/multierr v1.10.0 // indirect
|
go.uber.org/multierr v1.10.0 // indirect
|
||||||
golang.org/x/arch v0.22.0 // indirect
|
golang.org/x/arch v0.25.0 // indirect
|
||||||
golang.org/x/exp v0.0.0-20230905200255-921286631fa9 // indirect
|
golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546 // indirect
|
||||||
golang.org/x/net v0.51.0 // 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
|
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
|
gopkg.in/ini.v1 v1.67.0 // indirect
|
||||||
modernc.org/libc v1.22.5 // indirect
|
modernc.org/libc v1.70.0 // indirect
|
||||||
modernc.org/mathutil v1.5.0 // indirect
|
modernc.org/mathutil v1.7.1 // indirect
|
||||||
modernc.org/memory v1.5.0 // indirect
|
modernc.org/memory v1.11.0 // indirect
|
||||||
modernc.org/sqlite v1.23.1 // 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/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 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA=
|
||||||
github.com/bsm/gomega v1.27.10/go.mod h1:JyEr/xRbxbtgWNi8tIEVPUYZ5Dzef52k01W3YH0H+O0=
|
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.4 h1:oZnQwnX82KAIWb7033bEwtxvTqXcYMxDBaQxo5JJHWM=
|
||||||
github.com/bytedance/gopkg v0.1.3/go.mod h1:576VvJ+eJgyCzdjS+c4+77QF3p7ubbtiKARP3TxducM=
|
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 h1:/PXeWFaR5ElNcVE84U0dOHjiMHQOwNIx3K4ymzh/uSE=
|
||||||
github.com/bytedance/sonic v1.15.0/go.mod h1:tFkWrPz0/CUCLEF4ri4UkHekCIcdnkqXw9VduqpJh0k=
|
github.com/bytedance/sonic v1.15.0/go.mod h1:tFkWrPz0/CUCLEF4ri4UkHekCIcdnkqXw9VduqpJh0k=
|
||||||
github.com/bytedance/sonic/loader v0.5.0 h1:gXH3KVnatgY7loH5/TkeVyXPfESoqSBSBEiDd5VjlgE=
|
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/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 h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78=
|
||||||
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc=
|
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 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
||||||
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
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 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8=
|
||||||
github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
|
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 h1:8JEhPFa5W2WU7YfeZzPNqzMP6Lwt7L2715Ggo0nosvA=
|
||||||
github.com/fsnotify/fsnotify v1.7.0/go.mod h1:40Bi/Hjc2AVfZrqy+aj+yEI+/bRxZnMJyTJwOpGvigM=
|
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.13 h1:46nXokslUBsAJE/wMsp5gtO500a4F3Nkz9Ufpk2AcUM=
|
||||||
github.com/gabriel-vasile/mimetype v1.4.12/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s=
|
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 h1:OtN8DplD5DNZCSLAnQ5HxRkD2qZ5VU+JhOrcfJrcRvg=
|
||||||
github.com/gin-contrib/gzip v1.2.6/go.mod h1:BQy8/+JApnRjAVUplSGZiVtD2k8GmIE2e9rYu/hLzzU=
|
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 h1:n0w2GMuUpWDVp7qSpvze6fAu9iRxJY4Hmj6AmBOU05w=
|
||||||
github.com/gin-contrib/sse v1.1.0/go.mod h1:hxRZ5gVpWMT7Z0B0gSNYqqsSCNIJMjzvm6fqCz9vjwM=
|
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 h1:b3YAbrZtnf8N//yjKeU2+MQsh2mY5htkZidOM7O0wG8=
|
||||||
github.com/gin-gonic/gin v1.12.0/go.mod h1:VxccKfsSllpKshkBWgVgRniFFAzFb9csfngsqANjnLc=
|
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.22.0 h1:uAcMJhaA6r3LHMTFgP0SifzgXg46yJkgxqyuyec+ruQ=
|
||||||
github.com/glebarez/go-sqlite v1.21.2/go.mod h1:sfxdZyhQjTM2Wry3gVYWaW072Ri1WMdWJi0k6+3382k=
|
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 h1:wSG0irqzP6VurnMEpFGer5Li19RpIRi2qvQz++w0GMw=
|
||||||
github.com/glebarez/sqlite v1.11.0/go.mod h1:h8/o8j5wiAsqSPoWELDUdJXhjAhsVliSn7bWZjOhrgQ=
|
github.com/glebarez/sqlite v1.11.0/go.mod h1:h8/o8j5wiAsqSPoWELDUdJXhjAhsVliSn7bWZjOhrgQ=
|
||||||
github.com/go-ole/go-ole v1.2.6 h1:/Fpf6oFPoeFik9ty7siob0G6Ke8QvQEuVcuChpwXzpY=
|
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/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 h1:f3zDSN/zOma+w6+1Wswgd9fLkdwy06ntQJp0BBvFG0w=
|
||||||
github.com/go-playground/validator/v10 v10.30.1/go.mod h1:oSuBIQzuJxL//3MelwSLD5hc2Tu889bF0Idm9Dg26cM=
|
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.6 h1:p8HrPJzOakx/mn/bQtjgNjdTcN+/S6FcG2CTtQOrHVU=
|
||||||
github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
|
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 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM=
|
||||||
github.com/goccy/go-yaml v1.19.2/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=
|
github.com/goccy/go-yaml v1.19.2/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=
|
||||||
github.com/golang-jwt/jwt/v5 v5.2.2 h1:Rl4B7itRWVtYIHFrSNd7vhTiz9UpLdi6gZhZ3wEeDy8=
|
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 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||||
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
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/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-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs=
|
||||||
github.com/google/pprof v0.0.0-20221118152302-e6195bd50e26/go.mod h1:dDKJzRmX4S37WGHujM7tX//fmj1uioxKzKxz3lo4HJo=
|
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 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
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 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
|
||||||
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
|
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 h1:0Anlzjpi4vEasTeNFn2mLJgTSwt0+6sfsiTG8qcWGx4=
|
||||||
github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ=
|
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 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
|
||||||
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
|
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-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
|
||||||
github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
|
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
|
||||||
github.com/jackc/pgx/v5 v5.4.3 h1:cxFyXhxlvAifxnkKKdlxv8XqUf59tDlYjnV5YYfsJJY=
|
github.com/jackc/pgx/v5 v5.8.0 h1:TYPDoleBBme0xGSAX3/+NujXXtpZn9HBONkQC7IEZSo=
|
||||||
github.com/jackc/pgx/v5 v5.4.3/go.mod h1:Ig06C2Vu0t5qXC60W8sqIthScaEnFvojjj9dSljmHRA=
|
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 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E=
|
||||||
github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc=
|
github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc=
|
||||||
github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ=
|
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/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 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
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 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY=
|
||||||
github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo=
|
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=
|
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/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 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
|
||||||
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
|
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 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4=
|
||||||
github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
|
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=
|
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/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 h1:HhLSs+B6O021gwzl+locl0zEDnyNkxMtf/Z3NNBMa9E=
|
||||||
github.com/redis/go-redis/v9 v9.7.0/go.mod h1:f6zhXITC7JUJIlPEiBOTXxJgPLdZcA93GewI7inzyWw=
|
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 h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
|
||||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
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.11.0 h1:cWPaGQEPrBb5/AsnsZesgZZ9yb1OQ+GOISoDNXVBh4M=
|
||||||
github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog=
|
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 h1:HApY1R9zGo4DBgr7dqsTH/JJxLTTsOt7u6keLGt6kNQ=
|
||||||
github.com/sagikazarmark/locafero v0.4.0/go.mod h1:Pe1W6UlPYUk/+wc/6KFhbORCfqzgYEpgQ3O5fPuL3H4=
|
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=
|
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/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 h1:aJMhYGrd5QSmlpLMr2MftRKl7t8J8PTZPA732ud/XR8=
|
||||||
go.uber.org/zap v1.27.0/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E=
|
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.25.0 h1:qnk6Ksugpi5Bz32947rkUgDt9/s5qvqDPl/gBKdMJLE=
|
||||||
golang.org/x/arch v0.22.0/go.mod h1:dNHoOeKiyja7GTvF9NJS1l3Z2yntpQNzgrjh1cU103A=
|
golang.org/x/arch v0.25.0/go.mod h1:0X+GdSIP+kL5wPmpK7sdkEVTt2XoYP0cSjQSbZBwOi8=
|
||||||
golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts=
|
golang.org/x/crypto v0.49.0 h1:+Ng2ULVvLHnJ/ZFEq4KdcDd/cfjrrjjNSXNzxg0Y4U4=
|
||||||
golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos=
|
golang.org/x/crypto v0.49.0/go.mod h1:ErX4dUh2UM+CFYiXZRTcMpEcN8b/1gxEuv3nODoYtCA=
|
||||||
golang.org/x/exp v0.0.0-20230905200255-921286631fa9 h1:GoHiUyI/Tp2nVkLI2mCxVkOjsbSXD66ic0XW0js0R9g=
|
golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546 h1:mgKeJMpvi0yx/sU5GsxQ7p6s2wtOnGAHZWCHUM4KGzY=
|
||||||
golang.org/x/exp v0.0.0-20230905200255-921286631fa9/go.mod h1:S2oDrQGGwySpoQPVqRShND87VCbxmc6bL1Yd2oYrm6k=
|
golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546/go.mod h1:j/pmGrbnkbPtQfxEe5D0VQhZC6qKbfKifgD0oM7sR70=
|
||||||
golang.org/x/net v0.51.0 h1:94R/GTO7mt3/4wIKpcR5gkGmRLOuE/2hNGeWq/GBIFo=
|
golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
|
||||||
golang.org/x/net v0.51.0/go.mod h1:aamm+2QF5ogm02fjy5Bb7CQ0WMt1/WVM7FtyaTLlA9Y=
|
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-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
golang.org/x/sys v0.0.0-20201204225414-ed752295db88/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.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.8.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.11.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k=
|
golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo=
|
||||||
golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
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 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8=
|
||||||
golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA=
|
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 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U=
|
||||||
golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno=
|
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=
|
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.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
|
||||||
google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
|
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 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 h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
|
||||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
|
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.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 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
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.6.0 h1:2dxzU8xJ+ivvqTRph34QX+WrRaJlmfyPqXmoGVjMBa4=
|
||||||
gorm.io/driver/postgres v1.5.7/go.mod h1:3e019WlBaYI5o5LIdNV+LyxCMNtLOQETBXL2h4chKpA=
|
gorm.io/driver/postgres v1.6.0/go.mod h1:vUw0mrGgrTK+uPHEhAdV4sfFELrByKVGnaVRkXDhtWo=
|
||||||
gorm.io/gorm v1.30.0 h1:qbT5aPv1UH8gI99OsRlvDToLxW5zR7FzS9acZDOZcgs=
|
gorm.io/gorm v1.31.1 h1:7CA8FTFz/gRfgqgpeKIBcervUn3xSyPUmr6B2WXJ7kg=
|
||||||
gorm.io/gorm v1.30.0/go.mod h1:8Z33v652h4//uMA76KjeDH8mJXPm1QNCYrMeatR0DOE=
|
gorm.io/gorm v1.31.1/go.mod h1:XyQVbO2k6YkOis7C2437jSit3SsDK72s7n7rsSHd+Gs=
|
||||||
modernc.org/libc v1.22.5 h1:91BNch/e5B0uPbJFgqbxXuOnxBQjlS//icfQEGmvyjE=
|
modernc.org/cc/v4 v4.27.1 h1:9W30zRlYrefrDV2JE2O8VDtJ1yPGownxciz5rrbQZis=
|
||||||
modernc.org/libc v1.22.5/go.mod h1:jj+Z7dTNX8fBScMVNRAYZ/jF91K8fdT2hYMThc3YjBY=
|
modernc.org/cc/v4 v4.27.1/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0=
|
||||||
modernc.org/mathutil v1.5.0 h1:rV0Ko/6SfM+8G+yKiyI830l3Wuz1zRutdslNoQ0kfiQ=
|
modernc.org/ccgo/v4 v4.32.0 h1:hjG66bI/kqIPX1b2yT6fr/jt+QedtP2fqojG2VrFuVw=
|
||||||
modernc.org/mathutil v1.5.0/go.mod h1:mZW8CKdRPY1v87qxC/wUdX5O1qDzXMP5TH3wjfpga6E=
|
modernc.org/ccgo/v4 v4.32.0/go.mod h1:6F08EBCx5uQc38kMGl+0Nm0oWczoo1c7cgpzEry7Uc0=
|
||||||
modernc.org/memory v1.5.0 h1:N+/8c5rE6EqugZwHii4IFsaJ7MUhoWX07J5tC/iI5Ds=
|
modernc.org/fileutil v1.4.0 h1:j6ZzNTftVS054gi281TyLjHPp6CPHr2KCxEXjEbD6SM=
|
||||||
modernc.org/memory v1.5.0/go.mod h1:PkUhL0Mugw21sHPeskwZW4D6VscE/GQJOnIpCnW6pSU=
|
modernc.org/fileutil v1.4.0/go.mod h1:EqdKFDxiByqxLk8ozOxObDSfcVOv/54xDs/DUHdvCUU=
|
||||||
modernc.org/sqlite v1.23.1 h1:nrSBg4aRQQwq59JpvGEQ15tNxoO5pX/kUjcRNwSAGQM=
|
modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI=
|
||||||
modernc.org/sqlite v1.23.1/go.mod h1:OrDj17Mggn6MhE+iPbBNf7RGKODDE9NFT0f3EwDzJqk=
|
modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito=
|
||||||
|
modernc.org/gc/v3 v3.1.2 h1:ZtDCnhonXSZexk/AYsegNRV1lJGgaNZJuKjJSWKyEqo=
|
||||||
|
modernc.org/gc/v3 v3.1.2/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY=
|
||||||
|
modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks=
|
||||||
|
modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI=
|
||||||
|
modernc.org/libc v1.70.0 h1:U58NawXqXbgpZ/dcdS9kMshu08aiA6b7gusEusqzNkw=
|
||||||
|
modernc.org/libc v1.70.0/go.mod h1:OVmxFGP1CI/Z4L3E0Q3Mf1PDE0BucwMkcXjjLntvHJo=
|
||||||
|
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
|
||||||
|
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
|
||||||
|
modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI=
|
||||||
|
modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
|
||||||
|
modernc.org/opt v0.1.4 h1:2kNGMRiUjrp4LcaPuLY2PzUfqM/w9N23quVwhKt5Qm8=
|
||||||
|
modernc.org/opt v0.1.4/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
|
||||||
|
modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
|
||||||
|
modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
|
||||||
|
modernc.org/sqlite v1.47.0 h1:R1XyaNpoW4Et9yly+I2EeX7pBza/w+pmYee/0HJDyKk=
|
||||||
|
modernc.org/sqlite v1.47.0/go.mod h1:hWjRO6Tj/5Ik8ieqxQybiEOUXy0NJFNp2tpvVpKlvig=
|
||||||
|
modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
|
||||||
|
modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
|
||||||
|
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
|
||||||
|
modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
package handler
|
package handler
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
"net/url"
|
"net/url"
|
||||||
@@ -359,6 +361,7 @@ func newPlaybackScopeTestRouter(t *testing.T) (*gin.Engine, *service.Container,
|
|||||||
Auth: auth,
|
Auth: auth,
|
||||||
Media: service.NewMediaService(cfg, log, repos),
|
Media: service.NewMediaService(cfg, log, repos),
|
||||||
Stream: service.NewStreamService(cfg, log, repos, nil),
|
Stream: service.NewStreamService(cfg, log, repos, nil),
|
||||||
|
Subtitle: service.NewSubtitleService(cfg, log, repos),
|
||||||
Permissions: permissions,
|
Permissions: permissions,
|
||||||
}
|
}
|
||||||
if err := repos.User.Create(t.Context(), &model.User{
|
if err := repos.User.Create(t.Context(), &model.User{
|
||||||
@@ -440,6 +443,38 @@ func TestPlaybackInfoForSTRMMediaIncludesHLS(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestSubtitleListOnlyProbesEmbeddedTracksForHLS(t *testing.T) {
|
||||||
|
router, svc, secret := newPlaybackScopeTestRouter(t)
|
||||||
|
loginToken := signedTestToken(t, secret)
|
||||||
|
resolveCalls := 0
|
||||||
|
svc.Subtitle.SetStrmPlayTargetResolver(func(context.Context, string) (*service.StrmPlayResult, error) {
|
||||||
|
resolveCalls++
|
||||||
|
return nil, errors.New("probe resolver called")
|
||||||
|
})
|
||||||
|
|
||||||
|
request := func(path string) *httptest.ResponseRecorder {
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "http://nas.local"+path, nil)
|
||||||
|
req.Header.Set("Authorization", "Bearer "+loginToken)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
router.ServeHTTP(w, req)
|
||||||
|
return w
|
||||||
|
}
|
||||||
|
|
||||||
|
if w := request("/api/media/media-1/subtitles"); w.Code != http.StatusOK {
|
||||||
|
t.Fatalf("direct subtitle status = %d body=%s", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
if resolveCalls != 0 {
|
||||||
|
t.Fatalf("direct subtitle request resolved cloud media %d times, want 0", resolveCalls)
|
||||||
|
}
|
||||||
|
|
||||||
|
if w := request("/api/media/media-1/subtitles?include_embedded=true"); w.Code != http.StatusOK {
|
||||||
|
t.Fatalf("HLS subtitle status = %d body=%s", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
if resolveCalls != 1 {
|
||||||
|
t.Fatalf("HLS subtitle request resolved cloud media %d times, want 1", resolveCalls)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestHLSPlaylistForRemoteEmbyMediaDisabled(t *testing.T) {
|
func TestHLSPlaylistForRemoteEmbyMediaDisabled(t *testing.T) {
|
||||||
router, svc, secret := newPlaybackScopeTestRouter(t)
|
router, svc, secret := newPlaybackScopeTestRouter(t)
|
||||||
svc.EmbyRemote = &service.EmbyRemoteService{}
|
svc.EmbyRemote = &service.EmbyRemoteService{}
|
||||||
|
|||||||
@@ -195,7 +195,8 @@ func listLibrarySeriesEpisodesHandler(svc *service.Container) gin.HandlerFunc {
|
|||||||
writeInternalOrCanceled(c, err)
|
writeInternalOrCanceled(c, err)
|
||||||
return
|
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)
|
writeInternalOrCanceled(c, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
c.JSON(http.StatusOK, gin.H{"items": items, "total": len(items)})
|
grouped := service.GroupEpisodeVersionsForDisplay(items)
|
||||||
|
c.JSON(http.StatusOK, gin.H{"items": grouped, "total": len(grouped)})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,7 +16,13 @@ func listSubtitlesHandler(svc *service.Container) gin.HandlerFunc {
|
|||||||
c.JSON(http.StatusOK, gin.H{"tracks": []service.SubtitleTrack{}})
|
c.JSON(http.StatusOK, gin.H{"tracks": []service.SubtitleTrack{}})
|
||||||
return
|
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 {
|
if err != nil {
|
||||||
c.JSON(http.StatusNotFound, gin.H{"error": err.Error()})
|
c.JSON(http.StatusNotFound, gin.H{"error": err.Error()})
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -7,8 +7,17 @@ import (
|
|||||||
"image/jpeg"
|
"image/jpeg"
|
||||||
_ "image/png" // register PNG decoder
|
_ "image/png" // register PNG decoder
|
||||||
"strings"
|
"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.
|
// IsAdultMediaPathOrMetadata reports whether media is adult based on path, mediaType, nsfw flag, or adult code.
|
||||||
func IsAdultMediaPathOrMetadata(path, mediaType string, nsfw bool) bool {
|
func IsAdultMediaPathOrMetadata(path, mediaType string, nsfw bool) bool {
|
||||||
if nsfw {
|
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).
|
// 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.
|
// If the image is already portrait or cannot be decoded, it safely returns the original data.
|
||||||
func CropAdultCoverPoster(data []byte) ([]byte, string, error) {
|
func CropAdultCoverPoster(data []byte) ([]byte, string, error) {
|
||||||
if len(data) == 0 {
|
if len(data) == 0 {
|
||||||
@@ -59,32 +69,11 @@ func CropAdultCoverPoster(data []byte) ([]byte, string, error) {
|
|||||||
return data, format, nil
|
return data, format, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Front cover width is typically ~0.70-0.72 of the height (or right ~50-52% of total width)
|
position := 1.0
|
||||||
cropWidth := int(float64(height) * 0.71)
|
if detected, ok := findPrimaryFaceAxisRatio(src, adultPosterRatio, true); ok {
|
||||||
maxCropWidth := int(float64(width) * 0.53)
|
position = detected
|
||||||
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
|
|
||||||
}
|
}
|
||||||
|
cropped := cropImageAtPosition(src, adultPosterRatio, position)
|
||||||
|
|
||||||
var buf bytes.Buffer
|
var buf bytes.Buffer
|
||||||
if err := jpeg.Encode(&buf, cropped, &jpeg.Options{Quality: 92}); err != nil {
|
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
|
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 (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
|
"encoding/base64"
|
||||||
"image"
|
"image"
|
||||||
"image/color"
|
"image/color"
|
||||||
"image/draw"
|
"image/draw"
|
||||||
@@ -44,10 +45,10 @@ func TestCropAdultCoverPosterWideLandscape(t *testing.T) {
|
|||||||
if croppedWidth <= 0 || croppedWidth >= 800 {
|
if croppedWidth <= 0 || croppedWidth >= 800 {
|
||||||
t.Fatalf("unexpected cropped width: %d", croppedWidth)
|
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)
|
ratio := float64(croppedWidth) / float64(croppedHeight)
|
||||||
if ratio < 0.65 || ratio > 0.75 {
|
if ratio < 0.65 || ratio > 0.68 {
|
||||||
t.Fatalf("expected portrait ratio ~0.71, got %f (%dx%d)", ratio, croppedWidth, croppedHeight)
|
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)
|
// 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) {
|
func TestCropAdultCoverPosterKeepsPortrait(t *testing.T) {
|
||||||
// Create an already vertical portrait image (500x700)
|
// Create an already vertical portrait image (500x700)
|
||||||
origBytes := createTestImage(500, 700, color.RGBA{R: 255, A: 255}, color.RGBA{B: 255, A: 255})
|
origBytes := createTestImage(500, 700, color.RGBA{R: 255, A: 255}, color.RGBA{B: 255, A: 255})
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import (
|
|||||||
"net/url"
|
"net/url"
|
||||||
"regexp"
|
"regexp"
|
||||||
"strings"
|
"strings"
|
||||||
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"go.uber.org/zap"
|
"go.uber.org/zap"
|
||||||
@@ -180,6 +181,7 @@ func (p *AdultProvider) SearchCandidates(ctx context.Context, query string) ([]*
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
matches = p.enrichMetaTubeCandidates(ctx, mtCfg, code, matches)
|
||||||
for _, m := range matches {
|
for _, m := range matches {
|
||||||
m.OriginalName = code
|
m.OriginalName = code
|
||||||
m.Title = FormatAdultTitle(code, m.Title)
|
m.Title = FormatAdultTitle(code, m.Title)
|
||||||
@@ -194,6 +196,7 @@ func (p *AdultProvider) SearchCandidates(ctx context.Context, query string) ([]*
|
|||||||
if mtCfg.ServerURL != "" {
|
if mtCfg.ServerURL != "" {
|
||||||
matches, err := p.metatube.Search(ctx, mtCfg, code)
|
matches, err := p.metatube.Search(ctx, mtCfg, code)
|
||||||
if err == nil && len(matches) > 0 {
|
if err == nil && len(matches) > 0 {
|
||||||
|
matches = p.enrichMetaTubeCandidates(ctx, mtCfg, code, matches)
|
||||||
for _, m := range matches {
|
for _, m := range matches {
|
||||||
m.OriginalName = code
|
m.OriginalName = code
|
||||||
m.Title = FormatAdultTitle(code, m.Title)
|
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) {
|
func (p *AdultProvider) searchBuiltin(ctx context.Context, code string) (*Match, error) {
|
||||||
bases := p.resolveBases(ctx)
|
bases := p.resolveBases(ctx)
|
||||||
if len(bases) == 0 {
|
if len(bases) == 0 {
|
||||||
@@ -243,12 +290,46 @@ func (p *AdultProvider) searchBuiltin(ctx context.Context, code string) (*Match,
|
|||||||
match.OriginalName = code
|
match.OriginalName = code
|
||||||
match.Title = FormatAdultTitle(code, match.Title)
|
match.Title = FormatAdultTitle(code, match.Title)
|
||||||
match.NSFW = true
|
match.NSFW = true
|
||||||
|
p.applyMetaTubeFaceAwareArtwork(ctx, code, match)
|
||||||
return match, nil
|
return match, nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return nil, lastErr
|
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 {
|
func (p *AdultProvider) resolveBases(ctx context.Context) []string {
|
||||||
customJavDB := p.getSetting(ctx, "adult.scraper.builtin_javdb_url", "")
|
customJavDB := p.getSetting(ctx, "adult.scraper.builtin_javdb_url", "")
|
||||||
customJavBus := p.getSetting(ctx, "adult.scraper.builtin_javbus_url", "")
|
customJavBus := p.getSetting(ctx, "adult.scraper.builtin_javbus_url", "")
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ func parseAdultDetailHTML(body, code, source, detailURL string) *Match {
|
|||||||
}
|
}
|
||||||
|
|
||||||
match := &Match{
|
match := &Match{
|
||||||
|
Provider: strings.ToLower(strings.TrimSpace(source)),
|
||||||
OriginalName: code,
|
OriginalName: code,
|
||||||
MediaType: "adult",
|
MediaType: "adult",
|
||||||
NSFW: true,
|
NSFW: true,
|
||||||
|
|||||||
@@ -25,7 +25,8 @@ func TestAdultProviderRouting(t *testing.T) {
|
|||||||
repos := repository.New(db)
|
repos := repository.New(db)
|
||||||
|
|
||||||
mtServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
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")
|
q := r.URL.Query().Get("q")
|
||||||
if q == "SSIS-001" {
|
if q == "SSIS-001" {
|
||||||
results := struct {
|
results := struct {
|
||||||
@@ -46,6 +47,21 @@ func TestAdultProviderRouting(t *testing.T) {
|
|||||||
_ = json.NewEncoder(w).Encode(results)
|
_ = json.NewEncoder(w).Encode(results)
|
||||||
return
|
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)
|
http.NotFound(w, r)
|
||||||
}))
|
}))
|
||||||
@@ -80,6 +96,11 @@ func TestAdultProviderRouting(t *testing.T) {
|
|||||||
if len(candidates) != 1 {
|
if len(candidates) != 1 {
|
||||||
t.Fatalf("expected 1 candidate, got %d", len(candidates))
|
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
|
// 2. Test auto mode with failing metatube query
|
||||||
_ = repos.Setting.Set(context.Background(), "adult.scraper.engine", "auto")
|
_ = 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")
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -120,8 +120,8 @@ const (
|
|||||||
)
|
)
|
||||||
|
|
||||||
var (
|
var (
|
||||||
embySeasonDirRE = regexp.MustCompile(`(?i)^(season[\s._-]*\d+|s\d+|specials?|sp|ova|oad|extra|extras|第\s*[0-9一二三四五六七八九十百零两]+\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|ova|oad|extra|extras|特别篇|特別篇|番外|特典)|\s*第\s*[0-9一二三四五六七八九十百零两]+\s*季)\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*$`)
|
embyYearSuffixRE = regexp.MustCompile(`\s*[\((\[]\d{4}[\))\]]\s*$`)
|
||||||
embyEpisodeTitleRE = regexp.MustCompile(`(?i)\s*[-_ ]*s\d{1,2}e\d{1,3}.*$`)
|
embyEpisodeTitleRE = regexp.MustCompile(`(?i)\s*[-_ ]*s\d{1,2}e\d{1,3}.*$`)
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -55,6 +55,17 @@ func (e *EmbyService) mediaVersionSiblings(ctx context.Context, m *model.Media)
|
|||||||
if err := q.Find(&rows).Error; err != nil || len(rows) == 0 {
|
if err := q.Find(&rows).Error; err != nil || len(rows) == 0 {
|
||||||
return []model.Media{*m}
|
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)
|
rows = e.collapseExactPathRows(rows)
|
||||||
sort.SliceStable(rows, func(i, j int) bool {
|
sort.SliceStable(rows, func(i, j int) bool {
|
||||||
if rows[i].ID == m.ID {
|
if rows[i].ID == m.ID {
|
||||||
@@ -97,11 +108,28 @@ func (e *EmbyService) mediaVersionKey(ctx context.Context, m *model.Media) strin
|
|||||||
if libraryGroup == "" {
|
if libraryGroup == "" {
|
||||||
libraryGroup = strings.TrimSpace(m.LibraryID)
|
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 {
|
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 {
|
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))
|
title := strings.ToLower(strings.TrimSpace(m.Title))
|
||||||
if title == "" {
|
if title == "" {
|
||||||
@@ -110,7 +138,7 @@ func (e *EmbyService) mediaVersionKey(ctx context.Context, m *model.Media) strin
|
|||||||
if title == "" {
|
if title == "" {
|
||||||
return ""
|
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 {
|
func preferMediaVersion(candidate, current model.Media) bool {
|
||||||
|
|||||||
@@ -220,7 +220,7 @@ func embyLikelyEpisodicPathSQL() (string, []any) {
|
|||||||
patterns := []string{
|
patterns := []string{
|
||||||
"%/season %/%", "%/season.%/%", "%/season-%/%", "%/season_%/%",
|
"%/season %/%", "%/season.%/%", "%/season-%/%", "%/season_%/%",
|
||||||
"%/s0%/%", "%/s1%/%", "%/s2%/%", "%/s3%/%", "%/s4%/%", "%/s5%/%", "%/s6%/%", "%/s7%/%", "%/s8%/%", "%/s9%/%",
|
"%/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
|
return false
|
||||||
}
|
}
|
||||||
for _, marker := range []string{
|
for _, marker := range []string{
|
||||||
"/season ", "/season.", "/season-", "/season_", "/special/", "/specials/", "/sp/", "/ova/", "/oad/", "/extra/", "/extras/",
|
"/season ", "/season.", "/season-", "/season_", "/special/", "/specials/", "/sp/", "/ova/", "/ovas/", "/oad/", "/oads/", "/ovd/", "/ovds/", "/ona/", "/onas/", "/extra/", "/extras/", "/bonus/", "/bonuses/", "/omake/", "/picture drama/", "/ncop/", "/nced/",
|
||||||
"/电视剧/", "/剧集/", "/连续剧/", "/短剧/", "/国产剧/", "/国剧/", "/大陆剧/", "/华语剧/", "/国产电视剧/", "/大陆电视剧/", "/华语电视剧/", "/欧美剧/", "/欧美电视剧/", "/美剧/", "/英剧/", "/日韩剧/", "/日韩电视剧/", "/日剧/", "/韩剧/", "/港剧/", "/台剧/", "/港台剧/", "/泰剧/",
|
"/电视剧/", "/剧集/", "/连续剧/", "/短剧/", "/国产剧/", "/国剧/", "/大陆剧/", "/华语剧/", "/国产电视剧/", "/大陆电视剧/", "/华语电视剧/", "/欧美剧/", "/欧美电视剧/", "/美剧/", "/英剧/", "/日韩剧/", "/日韩电视剧/", "/日剧/", "/韩剧/", "/港剧/", "/台剧/", "/港台剧/", "/泰剧/",
|
||||||
"/日番/", "/国漫/", "/番剧/", "/动漫/", "/特别篇/", "/特別篇/", "/番外/", "/特典/",
|
"/日番/", "/国漫/", "/番剧/", "/动漫/", "/特别篇/", "/特別篇/", "/番外/", "/特典/",
|
||||||
} {
|
} {
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ const pollutedEpisodeCleanupSettingKey = "media.polluted_episode_cleanup_v2_done
|
|||||||
|
|
||||||
// seasonFolderTailRE 去掉路径末尾的「季文件夹 + 文件名」,得到整剧目录(show_dir)。
|
// seasonFolderTailRE 去掉路径末尾的「季文件夹 + 文件名」,得到整剧目录(show_dir)。
|
||||||
// 例: /tv/国漫/遮天 (2023)/Season 01/遮天 - S01E01.mkv → /tv/国漫/遮天 (2023)
|
// 例: /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 从单集路径推出整剧目录, 作为「同一部剧」的聚合键。
|
// showDirFromEpisodePath 从单集路径推出整剧目录, 作为「同一部剧」的聚合键。
|
||||||
// 若没有季文件夹, 则退而去掉文件名取其父目录。
|
// 若没有季文件夹, 则退而去掉文件名取其父目录。
|
||||||
|
|||||||
@@ -22,18 +22,20 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
var (
|
var (
|
||||||
patSEnE = regexp.MustCompile(`(?i)s(\d{1,2})e(\d{1,3})`)
|
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]|$)`)
|
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._-]|$)`)
|
patDanglingSE = regexp.MustCompile(`(?i)(?:^|[\s._-])s\d{1,2}e(?:[\s._-]|$)`)
|
||||||
patNxE = regexp.MustCompile(`(\d{1,2})x(\d{1,3})`)
|
patNxE = regexp.MustCompile(`(\d{1,2})x(\d{1,3})`)
|
||||||
patEP = regexp.MustCompile(`(?i)(?:^|[^a-z])(?:e|ep)\.?\s*(\d{1,3})(?:[^0-9]|$)`)
|
patEP = regexp.MustCompile(`(?i)(?:^|[^a-z])(?:e|ep)\.?\s*(\d{1,3})(?:[^0-9]|$)`)
|
||||||
patCN = regexp.MustCompile(`第\s*([0-9一二三四五六七八九十百零两]+)\s*[集话話期]`)
|
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]|$)`)
|
||||||
patCNRange = regexp.MustCompile(`第\s*([0-9一二三四五六七八九十百零两]+)\s*[-~–—]\s*([0-9一二三四五六七八九十百零两]+)\s*[集话話期]`)
|
patCN = regexp.MustCompile(`第\s*([0-9一二三四五六七八九十百零两]+)\s*[集话話期]`)
|
||||||
patDashEpisode = regexp.MustCompile(`[\s._-][-–—]\s*(\d{1,3})(?:\s*(?:v\d+)?)?(?:\s*[\[\(._-]|$)`)
|
patCNRange = regexp.MustCompile(`第\s*([0-9一二三四五六七八九十百零两]+)\s*[-~–—]\s*([0-9一二三四五六七八九十百零两]+)\s*[集话話期]`)
|
||||||
patSeasonFolder = regexp.MustCompile(`(?i)(?:^|[^a-z])(?:s|season)\.?\s*(\d{1,2})(?:[^0-9]|$)|第\s*([0-9一二三四五六七八九十百零两]+)\s*季`)
|
patDashEpisode = regexp.MustCompile(`[\s._-][-–—]\s*(\d{1,3})(?:\s*(?:v\d+)?)?(?:\s*[\[\(._-]|$)`)
|
||||||
patSeasonOnly = regexp.MustCompile(`(?i)(?:^|[\s._-])(?:s|season)\.?\s*\d{1,2}(?:[\s._-]|$)`)
|
patSeasonFolder = regexp.MustCompile(`(?i)(?:^|[^a-z])(?:s|season)\.?\s*(\d{1,2})(?:[^0-9]|$)|第\s*([0-9一二三四五六七八九十百零两]+)\s*季`)
|
||||||
patBareEpisode = regexp.MustCompile(`^(?:第\s*)?0?(\d{1,3})(?:\s*(?:v\d+)?)?$`)
|
patSeasonOnly = regexp.MustCompile(`(?i)(?:^|[\s._-])(?:s|season)\.?\s*\d{1,2}(?:[\s._-]|$)`)
|
||||||
patSpecialSeason = regexp.MustCompile(`(?i)^(?:s0+|season[\s._-]*0+|special[\s._-]*episodes?|specials?|sp|ovas?|oads?|extras?|bonus(?:es)?|omake|番外篇?|特别篇|特別篇|特典|外传|外傳|总集篇|總集篇)$`)
|
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 匹配中文季/部标记,支持阿拉伯数字与中文数字(如「第二季」「第2部」)。
|
||||||
patCNSeason = regexp.MustCompile(`第\s*[0-9一二三四五六七八九十百零两]+\s*[季部]`)
|
patCNSeason = regexp.MustCompile(`第\s*[0-9一二三四五六七八九十百零两]+\s*[季部]`)
|
||||||
)
|
)
|
||||||
@@ -56,6 +58,9 @@ func ParseEpisode(path string) (season, episode int) {
|
|||||||
episode = mustAtoi(m[2])
|
episode = mustAtoi(m[2])
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if m := patSpecialEpisode.FindStringSubmatch(name); len(m) >= 2 {
|
||||||
|
return 0, mustAtoi(m[1])
|
||||||
|
}
|
||||||
if m := patEP.FindStringSubmatch(name); len(m) >= 2 {
|
if m := patEP.FindStringSubmatch(name); len(m) >= 2 {
|
||||||
var found bool
|
var found bool
|
||||||
season, found = seasonFromParents(path)
|
season, found = seasonFromParents(path)
|
||||||
@@ -93,6 +98,27 @@ func ParseEpisode(path string) (season, episode int) {
|
|||||||
return 0, 0
|
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 {
|
type episodeRef struct {
|
||||||
Season int
|
Season int
|
||||||
Episode int
|
Episode int
|
||||||
|
|||||||
@@ -29,6 +29,10 @@ func TestParseEpisode(t *testing.T) {
|
|||||||
{`剧集/Specials/剧集 - 02.mkv`, 0, 2},
|
{`剧集/Specials/剧集 - 02.mkv`, 0, 2},
|
||||||
{`剧集/特别篇/03.mkv`, 0, 3},
|
{`剧集/特别篇/03.mkv`, 0, 3},
|
||||||
{`剧集/剧集 - S00E04.mkv`, 0, 4},
|
{`剧集/剧集 - 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},
|
{"Movie.2020.1080p.mkv", 0, 0},
|
||||||
}
|
}
|
||||||
for _, tc := range cases {
|
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
|
title, year := "", 0
|
||||||
if seriesLike {
|
if seriesLike {
|
||||||
title, year = CleanQuery(source)
|
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 {
|
} else {
|
||||||
title, year = cloudSeriesTitleFromMediaPath(source)
|
title, year = cloudSeriesTitleFromMediaPath(source)
|
||||||
if title == "" {
|
if title == "" {
|
||||||
|
|||||||
@@ -73,7 +73,7 @@ func isLocalImagePath(raw string) bool {
|
|||||||
}
|
}
|
||||||
ext := strings.ToLower(filepath.Ext(raw))
|
ext := strings.ToLower(filepath.Ext(raw))
|
||||||
switch ext {
|
switch ext {
|
||||||
case ".jpg", ".jpeg", ".png", ".webp", ".gif", ".bmp", ".tbn":
|
case ".jpg", ".jpeg", ".png", ".webp", ".gif", ".bmp", ".tbn", ".img":
|
||||||
return true
|
return true
|
||||||
default:
|
default:
|
||||||
return false
|
return false
|
||||||
|
|||||||
@@ -13,6 +13,12 @@ import (
|
|||||||
"github.com/truewhile/MeBox/internal/config"
|
"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{
|
var testJPEG = []byte{
|
||||||
0xff, 0xd8, 0xff, 0xe0, 0x00, 0x10, 'J', 'F', 'I', 'F',
|
0xff, 0xd8, 0xff, 0xe0, 0x00, 0x10, 'J', 'F', 'I', 'F',
|
||||||
0x00, 0x01, 0x01, 0x00, 0x00, 0x01, 0x00, 0x01,
|
0x00, 0x01, 0x01, 0x00, 0x00, 0x01, 0x00, 0x01,
|
||||||
@@ -117,12 +123,12 @@ func TestIsPrivateHost(t *testing.T) {
|
|||||||
// Hostnames must NOT be blocked even though GFW DNS poisoning may resolve
|
// Hostnames must NOT be blocked even though GFW DNS poisoning may resolve
|
||||||
// them to private/loopback IPs — blocking them broke legitimate posters.
|
// them to private/loopback IPs — blocking them broke legitimate posters.
|
||||||
allowed := []string{"image.tmdb.org", "lain.bgm.tv", "example.com", "8.8.8.8"}
|
allowed := []string{"image.tmdb.org", "lain.bgm.tv", "example.com", "8.8.8.8"}
|
||||||
for _, h := range allowed {
|
for _, h := range allowed {
|
||||||
if isPrivateHost(h) {
|
if isPrivateHost(h) {
|
||||||
t.Errorf("isPrivateHost(%q) = true, want false", h)
|
t.Errorf("isPrivateHost(%q) = true, want false", h)
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestImageProxyAllowedRemoteHostBypassesPrivateCheck(t *testing.T) {
|
func TestImageProxyAllowedRemoteHostBypassesPrivateCheck(t *testing.T) {
|
||||||
proxy := NewImageProxy(&config.Config{Cache: config.CacheConfig{CacheDir: filepath.Join(t.TempDir(), "cache")}}, zap.NewNop())
|
proxy := NewImageProxy(&config.Config{Cache: config.CacheConfig{CacheDir: filepath.Join(t.TempDir(), "cache")}}, zap.NewNop())
|
||||||
|
|||||||
@@ -39,10 +39,19 @@ func (r ManualScrapeRequest) EpisodeArtworkOption() *bool {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (s *ScraperService) ApplyManualMatch(ctx context.Context, mediaID string, req ManualScrapeRequest) (*model.Media, error) {
|
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) {
|
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)
|
media, err := s.repo.Media.FindByID(ctx, mediaID)
|
||||||
if err != nil || media == nil {
|
if err != nil || media == nil {
|
||||||
return nil, errors.New("media not found")
|
return nil, errors.New("media not found")
|
||||||
@@ -55,12 +64,32 @@ func (s *ScraperService) ApplyManualMatchWithOptions(ctx context.Context, mediaI
|
|||||||
if strings.TrimSpace(match.Title) == "" {
|
if strings.TrimSpace(match.Title) == "" {
|
||||||
return nil, errors.New("manual match title required")
|
return nil, errors.New("manual match title required")
|
||||||
}
|
}
|
||||||
|
rebuildManualScrapeIdentity(media, lib, match)
|
||||||
if err := s.applyProviderMatchWithOptions(ctx, media, lib, match, options); err != nil {
|
if err := s.applyProviderMatchWithOptions(ctx, media, lib, match, options); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
return s.repo.Media.FindByID(ctx, mediaID)
|
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) {
|
func (s *ScraperService) manualRequestMatch(ctx context.Context, req ManualScrapeRequest) (*Match, error) {
|
||||||
source := strings.ToLower(strings.TrimSpace(req.Source))
|
source := strings.ToLower(strings.TrimSpace(req.Source))
|
||||||
mediaType := normalizeMediaType(req.MediaType, req.Title, "")
|
mediaType := normalizeMediaType(req.MediaType, req.Title, "")
|
||||||
@@ -98,7 +127,22 @@ func (s *ScraperService) manualRequestMatch(ctx context.Context, req ManualScrap
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
case source == "adult":
|
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
|
return mergeManualRequestIntoMatch(match, req), nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,6 +24,13 @@ func (s *ScraperService) ManualSearch(ctx context.Context, media *model.Media, q
|
|||||||
mediaType = lib.Type
|
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], "")
|
mediaType = normalizeMediaType(mediaType, queries[0], "")
|
||||||
providers := manualSearchProviderSet(provider)
|
providers := manualSearchProviderSet(provider)
|
||||||
year := mediaYearHint(media)
|
year := mediaYearHint(media)
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import (
|
|||||||
"encoding/json"
|
"encoding/json"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
"sync/atomic"
|
"sync/atomic"
|
||||||
"testing"
|
"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) {
|
func TestParsePositiveIDStringAcceptsProviderPrefixes(t *testing.T) {
|
||||||
cases := map[string]string{
|
cases := map[string]string{
|
||||||
"12345": "12345",
|
"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) {
|
func TestManualSearchAllProvidersTMDbNumericIDTriesMovieAndTVNamespaces(t *testing.T) {
|
||||||
var paths []string
|
var paths []string
|
||||||
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
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)
|
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, "欧美剧", "欧美电视剧", "美剧", "英剧", "欧美电影", "外语电影")
|
isWesternByCategory := containsAnyText(categoryText, "欧美剧", "欧美电视剧", "美剧", "英剧", "欧美电影", "外语电影")
|
||||||
isWestern := isWesternByMetadata || (!hasRegionMetadata && isWesternByCategory)
|
isWestern := isWesternByMetadata || (!hasRegionMetadata && isWesternByCategory)
|
||||||
isUSAnime := hasAny(countries, "US")
|
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")
|
hasVarietyText := containsAnyText(contentText, "综艺", "真人秀", "脱口秀", "晚会", "春晚", "gala", "festival gala", "reality", "talk show")
|
||||||
hasDocumentaryText := containsAnyText(contentText, "纪录", "纪录片", "documentary", "docu", "national geographic", "natgeo")
|
hasDocumentaryText := containsAnyText(contentText, "纪录", "纪录片", "documentary", "docu", "national geographic", "natgeo")
|
||||||
hasConcertText := containsAnyText(contentText, "演唱会", "音乐会", "concert", "live concert")
|
hasConcertText := containsAnyText(contentText, "演唱会", "音乐会", "concert", "live concert")
|
||||||
@@ -191,21 +191,21 @@ func normalizeMediaType(mediaType, title, category string) string {
|
|||||||
return "adult"
|
return "adult"
|
||||||
case containsAnyText(raw, "综艺", "真人秀"):
|
case containsAnyText(raw, "综艺", "真人秀"):
|
||||||
return "variety"
|
return "variety"
|
||||||
case (containsAnyText(raw, "国漫", "日漫", "日番", "韩漫", "美漫", "欧美动漫", "其他动漫", "动漫", "动画") || classifierAnimeRE.MatchString(raw)) && !containsAnyText(raw, "动画电影"):
|
case (containsAnyText(raw, "国漫", "日漫", "日番", "韩漫", "美漫", "欧美动漫", "其他动漫", "动漫", "动画") || classifierAnimeRE.MatchString(raw)) && !containsAnyText(raw, "动画电影", "剧场版", "劇場版"):
|
||||||
return "anime"
|
return "anime"
|
||||||
case containsAnyText(raw, "电视剧", "剧集", "连续剧", "短剧", "国产剧", "国剧", "大陆剧", "华语剧", "国产电视剧", "大陆电视剧", "华语电视剧", "欧美剧", "欧美电视剧", "美剧", "英剧", "日韩剧", "日韩电视剧", "日剧", "韩剧", "港剧", "台剧", "港台剧", "泰剧") || classifierTVRE.MatchString(raw):
|
case containsAnyText(raw, "电视剧", "剧集", "连续剧", "短剧", "国产剧", "国剧", "大陆剧", "华语剧", "国产电视剧", "大陆电视剧", "华语电视剧", "欧美剧", "欧美电视剧", "美剧", "英剧", "日韩剧", "日韩电视剧", "日剧", "韩剧", "港剧", "台剧", "港台剧", "泰剧") || classifierTVRE.MatchString(raw):
|
||||||
return "tv"
|
return "tv"
|
||||||
case containsAnyText(raw, "电影", "演唱会") || classifierMovieRE.MatchString(raw):
|
case containsAnyText(raw, "电影", "演唱会", "剧场版", "劇場版") || classifierMovieRE.MatchString(raw):
|
||||||
return "movie"
|
return "movie"
|
||||||
}
|
}
|
||||||
text := strings.ToLower(title + " " + category)
|
text := strings.ToLower(title + " " + category)
|
||||||
switch {
|
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)):
|
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"
|
return "adult"
|
||||||
case containsAnyText(text, "综艺", "真人秀", "脱口秀", "晚会", "春晚", "gala", "festival gala", "reality", "talk show"):
|
case containsAnyText(text, "综艺", "真人秀", "脱口秀", "晚会", "春晚", "gala", "festival gala", "reality", "talk show"):
|
||||||
return "variety"
|
return "variety"
|
||||||
case strings.Contains(text, "电影") || classifierMovieRE.MatchString(text):
|
case containsAnyText(text, "电影", "剧场版", "劇場版") || classifierMovieRE.MatchString(text):
|
||||||
return "movie"
|
return "movie"
|
||||||
case classifierAnimeRE.MatchString(text) || strings.Contains(text, "动漫") || strings.Contains(text, "动画"):
|
case classifierAnimeRE.MatchString(text) || strings.Contains(text, "动漫") || strings.Contains(text, "动画"):
|
||||||
return "anime"
|
return "anime"
|
||||||
case strings.Contains(text, "variety") || strings.Contains(text, "综艺") || strings.Contains(text, "真人秀"):
|
case strings.Contains(text, "variety") || strings.Contains(text, "综艺") || strings.Contains(text, "真人秀"):
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import (
|
|||||||
"github.com/truewhile/MeBox/internal/model"
|
"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)$`)
|
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*`)
|
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*`)
|
seriesBraceRE = regexp.MustCompile(`(?i)\s*\{(?:tmdb|tmdbid|douban|bangumi|bgm|thetvdb|tvdb)[\s:=#-]*[a-z0-9_-]+\}\s*`)
|
||||||
seriesSpacerRE = regexp.MustCompile(`[\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|特别篇|特別篇|番外篇?|特典|外传|外傳|总集篇|總集篇)$`)
|
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*|extras?\s*\d*|bonus(?:es)?\s*\d*|omake\s*\d*)\s*[\]))】]?$`)
|
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*[\]))】]?$`)
|
seriesSpecialCJKRE = regexp.MustCompile(`(?i)\s*[\[((【]?\s*(?:特别篇|特別篇|番外篇?|特典|外传|外傳|总集篇|總集篇|画像特典)(?:\s*第?\s*[0-9一二三四五六七八九十百零两]+(?:[集话話期])?)?\s*[\]))】]?$`)
|
||||||
)
|
)
|
||||||
|
|
||||||
func normalizeSeriesTitle(value string) string {
|
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)) {
|
if last := parts[len(parts)-1]; !seriesPathPartLooksLikeFile(last) && !seriesSeasonDirRE.MatchString(filepath.Base(last)) {
|
||||||
dirIndex = len(parts) - 1
|
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--
|
dirIndex--
|
||||||
}
|
}
|
||||||
if dirIndex < 0 {
|
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) {
|
func TestGroupMediaSeriesCardsDoesNotCollideMovieAndTVExternalIDs(t *testing.T) {
|
||||||
movie := model.Media{
|
movie := model.Media{
|
||||||
Base: model.Base{ID: "movie"},
|
Base: model.Base{ID: "movie"},
|
||||||
@@ -512,4 +538,3 @@ func TestListMediaEpisodesKeepsIndependentMoviesSeparate(t *testing.T) {
|
|||||||
t.Fatalf("ListMediaEpisodes got %#v, want exactly m1", eps)
|
t.Fatalf("ListMediaEpisodes got %#v, want exactly m1", eps)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,54 @@
|
|||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"regexp"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
mediaSpecialTheatrical = "theatrical"
|
||||||
|
mediaSpecialOVA = "ova"
|
||||||
|
mediaSpecialOAD = "oad"
|
||||||
|
mediaSpecialOVD = "ovd"
|
||||||
|
mediaSpecialONA = "ona"
|
||||||
|
mediaSpecialExtra = "extra"
|
||||||
|
mediaSpecialBonus = "bonus"
|
||||||
|
mediaSpecialOmake = "omake"
|
||||||
|
mediaSpecialPicture = "picture_drama"
|
||||||
|
mediaSpecialNCOP = "ncop"
|
||||||
|
mediaSpecialNCED = "nced"
|
||||||
|
mediaSpecialGeneric = "special"
|
||||||
|
)
|
||||||
|
|
||||||
|
var mediaSpecialKindPatterns = []struct {
|
||||||
|
kind string
|
||||||
|
re *regexp.Regexp
|
||||||
|
}{
|
||||||
|
{mediaSpecialOVA, regexp.MustCompile(`(?i)(?:^|[^a-z0-9])ova(?:s)?(?:[\s._-]*\d+)?(?:[^a-z0-9]|$)`)},
|
||||||
|
{mediaSpecialOAD, regexp.MustCompile(`(?i)(?:^|[^a-z0-9])oad(?:s)?(?:[\s._-]*\d+)?(?:[^a-z0-9]|$)`)},
|
||||||
|
{mediaSpecialOVD, regexp.MustCompile(`(?i)(?:^|[^a-z0-9])ovd(?:s)?(?:[\s._-]*\d+)?(?:[^a-z0-9]|$)`)},
|
||||||
|
{mediaSpecialONA, regexp.MustCompile(`(?i)(?:^|[^a-z0-9])ona(?:s)?(?:[\s._-]*\d+)?(?:[^a-z0-9]|$)`)},
|
||||||
|
{mediaSpecialPicture, regexp.MustCompile(`(?i)(?:^|[^a-z0-9])(?:picture[\s._-]*drama|画像特典)(?:[^a-z0-9]|$)`)},
|
||||||
|
{mediaSpecialNCOP, regexp.MustCompile(`(?i)(?:^|[^a-z0-9])ncop(?:\d+)?(?:[^a-z0-9]|$)`)},
|
||||||
|
{mediaSpecialNCED, regexp.MustCompile(`(?i)(?:^|[^a-z0-9])nced(?:\d+)?(?:[^a-z0-9]|$)`)},
|
||||||
|
{mediaSpecialExtra, regexp.MustCompile(`(?i)(?:^|[^a-z0-9])extras?(?:[\s._-]*\d+)?(?:[^a-z0-9]|$)`)},
|
||||||
|
{mediaSpecialBonus, regexp.MustCompile(`(?i)(?:^|[^a-z0-9])bonus(?:es)?(?:[\s._-]*\d+)?(?:[^a-z0-9]|$)`)},
|
||||||
|
{mediaSpecialOmake, regexp.MustCompile(`(?i)(?:^|[^a-z0-9])omake(?:[\s._-]*\d+)?(?:[^a-z0-9]|$)`)},
|
||||||
|
{mediaSpecialGeneric, regexp.MustCompile(`(?i)(?:^|[^a-z0-9])(?:special(?:[\s._-]*episodes?)?|specials|sps?)(?:[\s._-]*\d+)?(?:[^a-z0-9]|$)|特别篇|特別篇|番外篇?|特典|外传|外傳|总集篇|總集篇`)},
|
||||||
|
}
|
||||||
|
|
||||||
|
func mediaSpecialKind(path string) string {
|
||||||
|
if pathHasTheatricalFolder(path) || patTheatricalTitle.MatchString(path) {
|
||||||
|
return mediaSpecialTheatrical
|
||||||
|
}
|
||||||
|
for _, part := range strings.FieldsFunc(path, func(r rune) bool {
|
||||||
|
return r == '/' || r == '\\'
|
||||||
|
}) {
|
||||||
|
for _, pattern := range mediaSpecialKindPatterns {
|
||||||
|
if pattern.re.MatchString(part) {
|
||||||
|
return pattern.kind
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
@@ -120,22 +120,57 @@ func GroupMediaVersions(items []model.Media) []MediaItem {
|
|||||||
return groupMediaVersions(items)
|
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 {
|
func mediaVersionGroupKey(m model.Media) string {
|
||||||
// 远程 Emby 挂载条目保持独立,不与其它远程条目或本地条目折叠合并。
|
// 远程 Emby 挂载条目保持独立,不与其它远程条目或本地条目折叠合并。
|
||||||
if IsEmbyRemoteID(m.ID) {
|
if IsEmbyRemoteID(m.ID) {
|
||||||
return fmt.Sprintf("embyremote:%s", 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 {
|
switch {
|
||||||
case m.TMDbID > 0:
|
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:
|
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) != "":
|
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) != "":
|
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)
|
title := firstNonEmpty(m.OriginalName, m.Title)
|
||||||
if title == "" {
|
if title == "" {
|
||||||
@@ -147,37 +182,61 @@ func mediaVersionGroupKey(m model.Media) string {
|
|||||||
}
|
}
|
||||||
return strings.Join([]string{
|
return strings.Join([]string{
|
||||||
"episode",
|
"episode",
|
||||||
strings.ToLower(strings.TrimSpace(m.LibraryID)),
|
kind,
|
||||||
|
libKey,
|
||||||
title,
|
title,
|
||||||
fmt.Sprintf("%d:%d", m.SeasonNum, m.EpisodeNum),
|
fmt.Sprintf("%d:%d", season, episode),
|
||||||
}, "|")
|
}, "|")
|
||||||
}
|
}
|
||||||
|
|
||||||
libKey := strings.ToLower(strings.TrimSpace(m.LibraryID))
|
if specialKind != "" && specialKind != mediaSpecialTheatrical {
|
||||||
if libKey == "" {
|
return mediaVersionStemGroupKey(m, libKey)
|
||||||
libKey = strings.ToLower(strings.TrimSpace(m.DisplayLibraryID))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
switch {
|
switch {
|
||||||
case m.TMDbID > 0:
|
case m.TMDbID > 0:
|
||||||
if libKey != "" {
|
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)
|
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)
|
return fmt.Sprintf("tmdb:%d", m.TMDbID)
|
||||||
case m.BangumiID > 0:
|
case m.BangumiID > 0:
|
||||||
if libKey != "" {
|
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)
|
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)
|
return fmt.Sprintf("bangumi:%d", m.BangumiID)
|
||||||
case strings.TrimSpace(m.DoubanID) != "":
|
case strings.TrimSpace(m.DoubanID) != "":
|
||||||
if libKey != "" {
|
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)))
|
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))
|
return "douban:" + strings.ToLower(strings.TrimSpace(m.DoubanID))
|
||||||
case strings.TrimSpace(m.TheTVDBID) != "":
|
case strings.TrimSpace(m.TheTVDBID) != "":
|
||||||
if libKey != "" {
|
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)))
|
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))
|
return "thetvdb:" + strings.ToLower(strings.TrimSpace(m.TheTVDBID))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -49,6 +49,95 @@ func TestGroupMediaVersionsMergesEpisodeByExternalIDAcrossLibraries(t *testing.T
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestGroupEpisodeVersionsForDisplayMergesAndKeepsEpisodeOrder(t *testing.T) {
|
||||||
|
rows := []model.Media{
|
||||||
|
{Base: model.Base{ID: "ep2"}, LibraryID: "tv", Title: "Show", SeasonNum: 1, EpisodeNum: 2, TMDbID: 99, Path: "/show/s01e02.mkv"},
|
||||||
|
{Base: model.Base{ID: "ep1-small"}, LibraryID: "tv", Title: "Show", SeasonNum: 1, EpisodeNum: 1, TMDbID: 99, Path: "/show/s01e01.mp4", SizeBytes: 100},
|
||||||
|
{Base: model.Base{ID: "ep1-large"}, LibraryID: "tv", Title: "Show", SeasonNum: 1, EpisodeNum: 1, TMDbID: 99, Path: "/show/s01e01.mkv", SizeBytes: 200},
|
||||||
|
}
|
||||||
|
|
||||||
|
grouped := GroupEpisodeVersionsForDisplay(rows)
|
||||||
|
if len(grouped) != 2 {
|
||||||
|
t.Fatalf("grouped len = %d, want 2: %#v", len(grouped), grouped)
|
||||||
|
}
|
||||||
|
if grouped[0].EpisodeNum != 1 || len(grouped[0].Versions) != 2 {
|
||||||
|
t.Fatalf("episode 1 was not merged first: %#v", grouped)
|
||||||
|
}
|
||||||
|
if grouped[0].ID != "ep1-large" {
|
||||||
|
t.Fatalf("primary version = %q, want ep1-large", grouped[0].ID)
|
||||||
|
}
|
||||||
|
if grouped[1].EpisodeNum != 2 {
|
||||||
|
t.Fatalf("second item episode = %d, want 2", grouped[1].EpisodeNum)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGroupMediaVersionsSeparatesAnimeSpecialKindsAndNumbers(t *testing.T) {
|
||||||
|
rows := []model.Media{
|
||||||
|
{
|
||||||
|
Base: model.Base{ID: "movie"},
|
||||||
|
LibraryID: "anime",
|
||||||
|
Title: "摇曳露营△",
|
||||||
|
Path: "/media/动漫/摇曳露营△/摇曳露营△ 剧场版 (2022)/movie.strm",
|
||||||
|
TMDbID: 76075,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Base: model.Base{ID: "ova-1"},
|
||||||
|
LibraryID: "anime",
|
||||||
|
Title: "摇曳露营△",
|
||||||
|
Path: "/media/动漫/摇曳露营△/OVA/Yuru Camp OVA01.strm",
|
||||||
|
TMDbID: 76075,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Base: model.Base{ID: "ova-2"},
|
||||||
|
LibraryID: "anime",
|
||||||
|
Title: "摇曳露营△",
|
||||||
|
Path: "/media/动漫/摇曳露营△/OVA/Yuru Camp OVA02.strm",
|
||||||
|
TMDbID: 76075,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Base: model.Base{ID: "oad-1"},
|
||||||
|
LibraryID: "anime",
|
||||||
|
Title: "摇曳露营△",
|
||||||
|
Path: "/media/动漫/摇曳露营△/OAD/Yuru Camp OAD01.strm",
|
||||||
|
TMDbID: 76075,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
grouped := groupMediaVersions(rows)
|
||||||
|
if len(grouped) != 4 {
|
||||||
|
t.Fatalf("grouped len = %d, want theatrical, OVA01, OVA02 and OAD01 separate: %#v", len(grouped), grouped)
|
||||||
|
}
|
||||||
|
for _, item := range grouped {
|
||||||
|
if len(item.Versions) > 1 {
|
||||||
|
t.Fatalf("unrelated anime extras were merged as versions: %#v", item.Versions)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGroupMediaVersionsMergesSameNumberedOVAEncodes(t *testing.T) {
|
||||||
|
rows := []model.Media{
|
||||||
|
{
|
||||||
|
Base: model.Base{ID: "ova-1-hd"},
|
||||||
|
LibraryID: "anime",
|
||||||
|
Path: "/media/动漫/示例/OVA/Show OVA01 1080p.mkv",
|
||||||
|
TMDbID: 123,
|
||||||
|
SizeBytes: 100,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Base: model.Base{ID: "ova-1-uhd"},
|
||||||
|
LibraryID: "anime",
|
||||||
|
Path: "/media/动漫/示例/OVA/Show OVA01 2160p.mkv",
|
||||||
|
TMDbID: 123,
|
||||||
|
SizeBytes: 200,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
grouped := groupMediaVersions(rows)
|
||||||
|
if len(grouped) != 1 || len(grouped[0].Versions) != 2 {
|
||||||
|
t.Fatalf("same numbered OVA encodes should be versions: %#v", grouped)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestGroupMediaVersionsMergesMovieEncodingVariants(t *testing.T) {
|
func TestGroupMediaVersionsMergesMovieEncodingVariants(t *testing.T) {
|
||||||
hd := model.Media{
|
hd := model.Media{
|
||||||
LibraryID: "movies",
|
LibraryID: "movies",
|
||||||
|
|||||||
@@ -83,7 +83,7 @@ func (p *MetaTubeProvider) Search(ctx context.Context, cfg MetaTubeConfig, query
|
|||||||
|
|
||||||
matches := make([]*Match, 0, len(results))
|
matches := make([]*Match, 0, len(results))
|
||||||
for _, res := range results {
|
for _, res := range results {
|
||||||
match := p.convertSearchResultToMatch(query, &res)
|
match := p.convertSearchResultToMatch(cfg, query, &res)
|
||||||
if match != nil {
|
if match != nil {
|
||||||
matches = append(matches, match)
|
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 nil, fmt.Errorf("decode metatube movie response failed: %w", directErr)
|
||||||
}
|
}
|
||||||
|
|
||||||
return p.convertMovieInfoToMatch(&movie), nil
|
return p.convertMovieInfoToMatch(cfg, &movie), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// SearchAndGetBestMatch 执行搜索并拉取首个最佳结果的完整电影详情。
|
// SearchAndGetBestMatch 执行搜索并拉取首个最佳结果的完整电影详情。
|
||||||
@@ -276,7 +276,7 @@ func (p *MetaTubeProvider) applyAuthHeader(req *http.Request, token string) {
|
|||||||
req.Header.Set("User-Agent", "MeBox/1.0 (MetaTube Client)")
|
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 {
|
if res == nil {
|
||||||
return 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{
|
return &Match{
|
||||||
|
Provider: "metatube",
|
||||||
MediaType: "adult",
|
MediaType: "adult",
|
||||||
Title: formattedTitle,
|
Title: formattedTitle,
|
||||||
OriginalName: code,
|
OriginalName: code,
|
||||||
PosterURL: posterURL,
|
PosterURL: posterURL,
|
||||||
BackdropURL: posterURL,
|
BackdropURL: backdropURL,
|
||||||
Year: year,
|
Year: year,
|
||||||
ReleaseDate: cleanDateString(res.ReleaseDate),
|
ReleaseDate: cleanDateString(res.ReleaseDate),
|
||||||
Rating: res.Score,
|
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 {
|
if movie == nil {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@@ -336,10 +344,17 @@ func (p *MetaTubeProvider) convertMovieInfoToMatch(movie *MetaTubeMovieInfo) *Ma
|
|||||||
|
|
||||||
year := parseYearFromDate(movie.ReleaseDate)
|
year := parseYearFromDate(movie.ReleaseDate)
|
||||||
|
|
||||||
posterURL := firstNonEmpty(movie.BigCoverURL, movie.CoverURL, movie.BigThumbURL, movie.ThumbURL)
|
posterSource := firstNonEmpty(movie.BigCoverURL, movie.CoverURL, movie.BigThumbURL, movie.ThumbURL)
|
||||||
backdropURL := posterURL
|
posterURL, backdropURL := metaTubeArtworkURLs(cfg, movie.Provider, movie.ID, posterSource)
|
||||||
if len(movie.PreviewImages) > 0 && movie.PreviewImages[0] != "" {
|
if posterURL == "" {
|
||||||
backdropURL = movie.PreviewImages[0]
|
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)
|
genres := make([]string, 0, len(movie.Genres)+len(movie.Actors)+4)
|
||||||
@@ -369,6 +384,7 @@ func (p *MetaTubeProvider) convertMovieInfoToMatch(movie *MetaTubeMovieInfo) *Ma
|
|||||||
}
|
}
|
||||||
|
|
||||||
return &Match{
|
return &Match{
|
||||||
|
Provider: "metatube",
|
||||||
MediaType: "adult",
|
MediaType: "adult",
|
||||||
Title: formattedTitle,
|
Title: formattedTitle,
|
||||||
OriginalName: code,
|
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 {
|
func parseYearFromDate(dateStr string) int {
|
||||||
dateStr = strings.TrimSpace(dateStr)
|
dateStr = strings.TrimSpace(dateStr)
|
||||||
if len(dateStr) >= 4 {
|
if len(dateStr) >= 4 {
|
||||||
|
|||||||
@@ -62,6 +62,9 @@ func TestMetaTubeProviderSearch(t *testing.T) {
|
|||||||
t.Fatalf("expected 1 match, got %d", len(matches))
|
t.Fatalf("expected 1 match, got %d", len(matches))
|
||||||
}
|
}
|
||||||
m := matches[0]
|
m := matches[0]
|
||||||
|
if m.Provider != "metatube" {
|
||||||
|
t.Errorf("expected provider metatube, got %q", m.Provider)
|
||||||
|
}
|
||||||
if m.OriginalName != "IPX-235" {
|
if m.OriginalName != "IPX-235" {
|
||||||
t.Errorf("expected original name IPX-235, got %s", m.OriginalName)
|
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] != "相沢みなみ" {
|
if len(m.Genres) != 1 || m.Genres[0] != "相沢みなみ" {
|
||||||
t.Errorf("unexpected genres: %v", m.Genres)
|
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) {
|
func TestMetaTubeProviderGetMovie(t *testing.T) {
|
||||||
@@ -112,6 +121,7 @@ func TestMetaTubeProviderGetMovie(t *testing.T) {
|
|||||||
provider := NewMetaTubeProvider(zap.NewNop())
|
provider := NewMetaTubeProvider(zap.NewNop())
|
||||||
cfg := MetaTubeConfig{
|
cfg := MetaTubeConfig{
|
||||||
ServerURL: server.URL,
|
ServerURL: server.URL,
|
||||||
|
CropCover: true,
|
||||||
}
|
}
|
||||||
|
|
||||||
match, err := provider.GetMovie(context.Background(), cfg, "javdb", "123456")
|
match, err := provider.GetMovie(context.Background(), cfg, "javdb", "123456")
|
||||||
@@ -124,11 +134,11 @@ func TestMetaTubeProviderGetMovie(t *testing.T) {
|
|||||||
if match.Overview != "超绝美少女相沢みなみ出道作品!" {
|
if match.Overview != "超绝美少女相沢みなみ出道作品!" {
|
||||||
t.Errorf("unexpected overview: %s", match.Overview)
|
t.Errorf("unexpected overview: %s", match.Overview)
|
||||||
}
|
}
|
||||||
if match.PosterURL != "https://example.com/big_cover.jpg" {
|
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("unexpected poster URL: %s", match.PosterURL)
|
t.Errorf("poster URL = %q, want %q", match.PosterURL, want)
|
||||||
}
|
}
|
||||||
if match.BackdropURL != "https://example.com/preview1.jpg" {
|
if want := server.URL + "/v1/images/backdrop/javdb/123456?quality=90"; match.BackdropURL != want {
|
||||||
t.Errorf("unexpected backdrop URL: %s", match.BackdropURL)
|
t.Errorf("backdrop URL = %q, want %q", match.BackdropURL, want)
|
||||||
}
|
}
|
||||||
if match.Year != 2018 {
|
if match.Year != 2018 {
|
||||||
t.Errorf("expected year 2018, got %d", match.Year)
|
t.Errorf("expected year 2018, got %d", match.Year)
|
||||||
|
|||||||
@@ -93,7 +93,11 @@ func (s *ScannerService) readLocalScanMetadata(lib *model.Library, root *model.L
|
|||||||
if root != nil && strings.TrimSpace(root.Path) != "" {
|
if root != nil && strings.TrimSpace(root.Path) != "" {
|
||||||
rootPath = 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 {
|
if err != nil {
|
||||||
s.log.Warn("read local metadata failed", zap.String("path", path), zap.Error(err))
|
s.log.Warn("read local metadata failed", zap.String("path", path), zap.Error(err))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -69,6 +69,49 @@ func TestScanLibraryUsesLocalMetadata(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestScanAnimeTheatricalFolderUsesMovieNFO(t *testing.T) {
|
||||||
|
root := t.TempDir()
|
||||||
|
showDir := filepath.Join(root, "摇曳露营△ (2018)")
|
||||||
|
movieDir := filepath.Join(showDir, "摇曳露营△ 剧场版 (2022)")
|
||||||
|
if err := os.MkdirAll(movieDir, 0o755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(filepath.Join(showDir, "tvshow.nfo"), []byte(
|
||||||
|
`<tvshow><title>错误的剧集标题</title><tmdbid>76075</tmdbid><year>2018</year></tvshow>`,
|
||||||
|
), 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
mediaPath := filepath.Join(movieDir, "Eiga.Yurukyan.2022.Bluray.mkv")
|
||||||
|
if err := os.WriteFile(mediaPath, []byte("x"), 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(nfoPath(mediaPath), []byte(
|
||||||
|
`<movie><title>摇曳露营△ 剧场版</title><tmdbid>566466</tmdbid><year>2022</year></movie>`,
|
||||||
|
), 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
db := newServiceTestDB(t, &model.Library{}, &model.Media{}, &model.Setting{})
|
||||||
|
repos := repository.New(db)
|
||||||
|
lib := model.Library{Name: "动漫", Path: root, Type: "anime", Enabled: true}
|
||||||
|
if err := repos.Library.Create(t.Context(), &lib); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
scanner := NewScannerService(&config.Config{}, zap.NewNop(), repos, NewHub(zap.NewNop()), nil, nil)
|
||||||
|
if _, err := scanner.ScanLibrary(t.Context(), lib.ID); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var media model.Media
|
||||||
|
if err := db.First(&media, "path = ?", mediaPath).Error; err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if media.Title != "摇曳露营△ 剧场版" || media.TMDbID != 566466 || media.Year != 2022 {
|
||||||
|
t.Fatalf("theatrical movie metadata = title %q tmdb %d year %d", media.Title, media.TMDbID, media.Year)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestScanLibraryDoesNotMarkArtworkOnlyAsMatched(t *testing.T) {
|
func TestScanLibraryDoesNotMarkArtworkOnlyAsMatched(t *testing.T) {
|
||||||
root := t.TempDir()
|
root := t.TempDir()
|
||||||
mediaPath := filepath.Join(root, "SSIS-001-CD1.mp4")
|
mediaPath := filepath.Join(root, "SSIS-001-CD1.mp4")
|
||||||
|
|||||||
@@ -93,7 +93,11 @@ func (s *ScraperService) EnrichOneWithOptions(ctx context.Context, m *model.Medi
|
|||||||
}
|
}
|
||||||
if match == nil {
|
if match == nil {
|
||||||
if local != nil && !local.PathHint {
|
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).
|
if err := s.repo.DB.WithContext(ctx).Model(&model.Media{}).Where("id = ?", m.ID).
|
||||||
Update("scrape_status", "no_match").Error; err != nil {
|
Update("scrape_status", "no_match").Error; err != nil {
|
||||||
@@ -119,8 +123,22 @@ func (s *ScraperService) applyProviderMatch(ctx context.Context, m *model.Media,
|
|||||||
func (s *ScraperService) applyProviderMatchWithOptions(ctx context.Context, m *model.Media, lib *model.Library, match *Match, options ScrapeOptions) error {
|
func (s *ScraperService) applyProviderMatchWithOptions(ctx context.Context, m *model.Media, lib *model.Library, match *Match, options ScrapeOptions) error {
|
||||||
posterCandidate := match.PosterURL
|
posterCandidate := match.PosterURL
|
||||||
backdropCandidate := match.BackdropURL
|
backdropCandidate := match.BackdropURL
|
||||||
posterURL, removePoster := s.prepareScrapedArtworkURL(ctx, m.ID, "poster_url", m.PosterURL, posterCandidate)
|
currentPoster := m.PosterURL
|
||||||
backdropURL, removeBackdrop := s.prepareScrapedArtworkURL(ctx, m.ID, "backdrop_url", m.BackdropURL, backdropCandidate)
|
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{
|
updates := map[string]any{
|
||||||
"title": match.Title,
|
"title": match.Title,
|
||||||
"overview": match.Overview,
|
"overview": match.Overview,
|
||||||
@@ -142,6 +160,12 @@ func (s *ScraperService) applyProviderMatchWithOptions(ctx context.Context, m *m
|
|||||||
updates["languages"] = strings.Join(match.Languages, ",")
|
updates["languages"] = strings.Join(match.Languages, ",")
|
||||||
updates["nsfw"] = match.NSFW
|
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 != "" {
|
if match.ReleaseDate != "" {
|
||||||
updates["release_date"] = match.ReleaseDate
|
updates["release_date"] = match.ReleaseDate
|
||||||
}
|
}
|
||||||
@@ -207,6 +231,11 @@ func (s *ScraperService) applyProviderMatchWithOptions(ctx context.Context, m *m
|
|||||||
"thetvdb_id": match.TheTVDBID,
|
"thetvdb_id": match.TheTVDBID,
|
||||||
"source": map[bool]string{true: "adult"}[match.NSFW],
|
"source": map[bool]string{true: "adult"}[match.NSFW],
|
||||||
})
|
})
|
||||||
|
provider := strings.ToLower(strings.TrimSpace(match.Provider))
|
||||||
|
if provider == "" {
|
||||||
|
provider = "unknown"
|
||||||
|
}
|
||||||
|
options.recordProvider(provider)
|
||||||
return nil
|
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 (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"net/url"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -20,10 +21,34 @@ func (s *ScraperService) prepareScrapedArtworkURL(ctx context.Context, mediaID,
|
|||||||
if s == nil || s.images == nil || !isHTTPish(candidate) {
|
if s == nil || s.images == nil || !isHTTPish(candidate) {
|
||||||
return 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)
|
err := s.images.PrefetchRemote(fetchCtx, candidate)
|
||||||
cancel()
|
cancel()
|
||||||
if err != nil {
|
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 != "" {
|
if current != "" {
|
||||||
s.log.Warn("scrape artwork prefetch failed; keeping existing artwork",
|
s.log.Warn("scrape artwork prefetch failed; keeping existing artwork",
|
||||||
zap.String("media_id", mediaID),
|
zap.String("media_id", mediaID),
|
||||||
@@ -46,6 +71,18 @@ func (s *ScraperService) prepareScrapedArtworkURL(ctx context.Context, mediaID,
|
|||||||
return candidate, ""
|
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) {
|
func (s *ScraperService) removeCachedScrapedArtwork(urls ...string) {
|
||||||
if s == nil || s.images == nil {
|
if s == nil || s.images == nil {
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -3,8 +3,11 @@ package service
|
|||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
"errors"
|
"errors"
|
||||||
|
"image"
|
||||||
|
"image/color"
|
||||||
"io"
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"net/url"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
@@ -67,10 +70,14 @@ func TestApplyProviderMatchInvalidatesMediaCache(t *testing.T) {
|
|||||||
if err := repos.DB.Create(&media).Error; err != nil {
|
if err := repos.DB.Create(&media).Error; err != nil {
|
||||||
t.Fatal(err)
|
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"}
|
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"}
|
||||||
if err := scraper.applyProviderMatch(t.Context(), &media, &lib, match); err != nil {
|
usedProvider := ""
|
||||||
|
if err := scraper.applyProviderMatchWithOptions(t.Context(), &media, &lib, match, ScrapeOptions{resultProvider: &usedProvider}); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
|
if usedProvider != "metatube" {
|
||||||
|
t.Fatalf("recorded provider = %q, want metatube", usedProvider)
|
||||||
|
}
|
||||||
|
|
||||||
var stale map[string]string
|
var stale map[string]string
|
||||||
if cache.GetJSON(t.Context(), "media:list:stale", &stale) {
|
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) {
|
func TestApplyProviderMatchReplacesArtworkAndRemovesOldCache(t *testing.T) {
|
||||||
scraper, repos, closeServer := newTestScraper(t)
|
scraper, repos, closeServer := newTestScraper(t)
|
||||||
defer closeServer()
|
defer closeServer()
|
||||||
|
|||||||
@@ -8,6 +8,27 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
func (s *ScraperService) lookupAutomaticTMDb(ctx context.Context, kind, query string, year int) *Match {
|
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 (
|
var (
|
||||||
candidates []*Match
|
candidates []*Match
|
||||||
err error
|
err error
|
||||||
@@ -175,8 +196,10 @@ func metadataMatchCompatibleWithType(expectedType string, match *Match) bool {
|
|||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
switch expectedType {
|
switch expectedType {
|
||||||
case "tv", "anime", "variety":
|
case "tv", "variety":
|
||||||
return matchType == "tv" || matchType == "anime" || matchType == "variety"
|
return matchType == "tv" || matchType == "anime" || matchType == "variety"
|
||||||
|
case "anime":
|
||||||
|
return matchType == "anime" || matchType == "tv" || matchType == "movie" || matchType == "variety"
|
||||||
case "movie", "adult":
|
case "movie", "adult":
|
||||||
return matchType == "movie" || matchType == "adult"
|
return matchType == "movie" || matchType == "adult"
|
||||||
default:
|
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 {
|
func queryNeedsEnglishTMDbFallback(query string) bool {
|
||||||
for _, r := range query {
|
for _, r := range query {
|
||||||
if (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') {
|
if (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') {
|
||||||
|
|||||||
@@ -22,23 +22,35 @@ func (s *ScraperService) lookup(ctx context.Context, lib *model.Library, media *
|
|||||||
// was previously polluted into S01E20x. Do not let the dirty path override
|
// was previously polluted into S01E20x. Do not let the dirty path override
|
||||||
// that caller-supplied media type; TV/anime libraries still force TV below.
|
// that caller-supplied media type; TV/anime libraries still force TV below.
|
||||||
explicitEpisode := media != nil && (media.SeasonNum > 0 || media.EpisodeNum > 0)
|
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"
|
kind = "tv"
|
||||||
}
|
}
|
||||||
if s.tmdb != nil && s.tmdb.Enabled() {
|
if s.tmdb != nil && s.tmdb.Enabled() {
|
||||||
if match := s.lookupAutomaticTMDb(ctx, kind, query, year); match != nil {
|
if match := s.lookupAutomaticTMDb(ctx, kind, query, year); match != nil {
|
||||||
|
match.Provider = "tmdb"
|
||||||
return match
|
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 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
|
return m
|
||||||
} else if err != nil {
|
} else if err != nil {
|
||||||
s.log.Debug("douban search failed", zap.String("query", query), zap.Error(err))
|
s.log.Debug("douban search failed", zap.String("query", query), zap.Error(err))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if s.bangumi != nil && s.bangumi.Enabled() {
|
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
|
return m
|
||||||
} else if err != nil {
|
} else if err != nil {
|
||||||
s.log.Debug("bangumi search failed", zap.String("query", query), zap.Error(err))
|
s.log.Debug("bangumi search failed", zap.String("query", query), zap.Error(err))
|
||||||
@@ -46,6 +58,7 @@ func (s *ScraperService) lookup(ctx context.Context, lib *model.Library, media *
|
|||||||
}
|
}
|
||||||
if (kind == "anime" || kind == "tv" || kind == "variety" || kind == "show" || kind == "shows") && s.thetvdb != nil && s.thetvdb.Enabled() {
|
if (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) {
|
if m, err := s.thetvdb.SearchSeries(ctx, query); err == nil && m != nil && metadataMatchCompatibleWithType(kind, m) {
|
||||||
|
m.Provider = "thetvdb"
|
||||||
return m
|
return m
|
||||||
} else if err != nil {
|
} else if err != nil {
|
||||||
s.log.Debug("thetvdb search failed", zap.String("query", query), zap.Error(err))
|
s.log.Debug("thetvdb search failed", zap.String("query", query), zap.Error(err))
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ func (s *ScraperService) matchFromMediaExternalIDs(ctx context.Context, m *model
|
|||||||
}
|
}
|
||||||
if match := s.manualTMDbMatchByID(ctx, m.TMDbID, normalizeMediaType(mediaType, m.Title, "")); match != nil {
|
if match := s.manualTMDbMatchByID(ctx, m.TMDbID, normalizeMediaType(mediaType, m.Title, "")); match != nil {
|
||||||
if s.mediaExternalIDMatchTrusted(m, lib, match, "tmdb") {
|
if s.mediaExternalIDMatchTrusted(m, lib, match, "tmdb") {
|
||||||
|
match.Provider = "tmdb"
|
||||||
preferExistingLocalizedEpisodeTitle(m, lib, match)
|
preferExistingLocalizedEpisodeTitle(m, lib, match)
|
||||||
return 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 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 match, err := s.douban.GetMatchByID(ctx, strings.TrimSpace(m.DoubanID)); err == nil && match != nil {
|
||||||
if s.mediaExternalIDMatchTrusted(m, lib, match, "douban") {
|
if s.mediaExternalIDMatchTrusted(m, lib, match, "douban") {
|
||||||
|
match.Provider = "douban"
|
||||||
preferExistingLocalizedEpisodeTitle(m, lib, match)
|
preferExistingLocalizedEpisodeTitle(m, lib, match)
|
||||||
return 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 m.BangumiID > 0 && s.bangumi != nil && s.bangumi.Enabled() {
|
||||||
if match, err := s.bangumi.GetSubject(ctx, m.BangumiID); err == nil && match != nil {
|
if match, err := s.bangumi.GetSubject(ctx, m.BangumiID); err == nil && match != nil {
|
||||||
if s.mediaExternalIDMatchTrusted(m, lib, match, "bangumi") {
|
if s.mediaExternalIDMatchTrusted(m, lib, match, "bangumi") {
|
||||||
|
match.Provider = "bangumi"
|
||||||
preferExistingLocalizedEpisodeTitle(m, lib, match)
|
preferExistingLocalizedEpisodeTitle(m, lib, match)
|
||||||
return 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 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 match, err := s.thetvdb.GetSeriesMatchByID(ctx, strings.TrimSpace(m.TheTVDBID)); err == nil && match != nil {
|
||||||
if s.mediaExternalIDMatchTrusted(m, lib, match, "thetvdb") {
|
if s.mediaExternalIDMatchTrusted(m, lib, match, "thetvdb") {
|
||||||
|
match.Provider = "thetvdb"
|
||||||
preferExistingLocalizedEpisodeTitle(m, lib, match)
|
preferExistingLocalizedEpisodeTitle(m, lib, match)
|
||||||
return match
|
return match
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,6 +7,8 @@ type ScrapeOptions struct {
|
|||||||
EpisodeArtwork *bool
|
EpisodeArtwork *bool
|
||||||
DeferEpisodeDetails bool
|
DeferEpisodeDetails bool
|
||||||
ForceRematch bool
|
ForceRematch bool
|
||||||
|
RebuildIdentity bool
|
||||||
|
resultProvider *string
|
||||||
}
|
}
|
||||||
|
|
||||||
func (o ScrapeOptions) episodeArtworkEnabled() bool {
|
func (o ScrapeOptions) episodeArtworkEnabled() bool {
|
||||||
@@ -17,3 +19,9 @@ func skipEpisodeArtworkOptions(retryNoMatch bool) ScrapeOptions {
|
|||||||
episodeArtwork := false
|
episodeArtwork := false
|
||||||
return ScrapeOptions{RetryNoMatch: retryNoMatch, EpisodeArtwork: &episodeArtwork}
|
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*[::].+`)
|
episodeTitleQueryRE = regexp.MustCompile(`^\s*第\s*[0-9一二三四五六七八九十百零两]+\s*[集期话話](?:\s*[上下])?\s*[::].+`)
|
||||||
genericEpisodeWordsRE = regexp.MustCompile(`^\s*第\s*[集期话話]\s*$`)
|
genericEpisodeWordsRE = regexp.MustCompile(`^\s*第\s*[集期话話]\s*$`)
|
||||||
episodeReleaseTitleTagRE = regexp.MustCompile(`(?i)(?:^|[\s._-])s\d{1,2}e\d{1,3}(?:[\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 {
|
func scrapeQueryCandidates(m *model.Media, lib *model.Library) []string {
|
||||||
return scrapeQueryCandidatesWithNormalizer(m, lib, func(raw string) (string, int) {
|
return scrapeQueryCandidatesWithNormalizer(m, lib, func(raw string) (string, int) {
|
||||||
return CleanQuery(raw)
|
return CleanQuery(raw)
|
||||||
@@ -33,31 +67,59 @@ func scrapeQueryCandidatesWithNormalizer(m *model.Media, lib *model.Library, cle
|
|||||||
seen := map[string]struct{}{}
|
seen := map[string]struct{}{}
|
||||||
var out []string
|
var out []string
|
||||||
add := func(raw string) {
|
add := func(raw string) {
|
||||||
cleaned, _ := clean(raw)
|
for _, variant := range theatricalTitleVariants(raw) {
|
||||||
if cleaned == "" {
|
cleaned, _ := clean(variant)
|
||||||
cleaned = strings.TrimSpace(raw)
|
if cleaned == "" {
|
||||||
}
|
cleaned = strings.TrimSpace(variant)
|
||||||
for _, candidate := range titleCandidates(cleaned) {
|
|
||||||
if unsafeAutomaticEpisodeQuery(candidate) {
|
|
||||||
continue
|
|
||||||
}
|
}
|
||||||
key := strings.ToLower(candidate)
|
for _, candidate := range titleCandidates(cleaned) {
|
||||||
if _, ok := seen[key]; ok || candidate == "" {
|
if unsafeAutomaticEpisodeQuery(candidate) {
|
||||||
continue
|
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 {
|
isTheatrical := mediaLooksLikeTheatricalFeature(m)
|
||||||
add(seriesFolderTitle(m.Path, lib.Path))
|
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 {
|
if len(out) == 0 {
|
||||||
base := pathBaseSlash(m.Path)
|
base := pathBaseSlash(m.Path)
|
||||||
out = append(out, strings.TrimSuffix(base, filepath.Ext(base)))
|
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 {
|
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) {
|
if m != nil && (m.SeasonNum > 0 || m.EpisodeNum > 0) {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,11 @@
|
|||||||
package service
|
package service
|
||||||
|
|
||||||
import "strings"
|
import (
|
||||||
|
"regexp"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
var namedTheatricalFolderRE = regexp.MustCompile(`(?i)(?:剧场版|劇場版|动画电影|動畫電影|电影版|電影版)`)
|
||||||
|
|
||||||
func mediaFolderTitle(mediaPath, libraryRoot string) string {
|
func mediaFolderTitle(mediaPath, libraryRoot string) string {
|
||||||
dir := parentSlashPath(mediaPath)
|
dir := parentSlashPath(mediaPath)
|
||||||
@@ -13,7 +18,7 @@ func mediaFolderTitle(mediaPath, libraryRoot string) string {
|
|||||||
if base == "" || base == "." {
|
if base == "" || base == "." {
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
if isTechnicalMediaFolder(base) || strictSeasonFolderMatched(base) {
|
if isTechnicalMediaFolder(base) || strictSeasonFolderMatched(base) || isTheatricalFolder(base) {
|
||||||
dir = parentSlashPath(dir)
|
dir = parentSlashPath(dir)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
@@ -104,7 +109,7 @@ func parentSlashPath(value string) string {
|
|||||||
|
|
||||||
func seriesFolderTitle(mediaPath, libraryRoot string) string {
|
func seriesFolderTitle(mediaPath, libraryRoot string) string {
|
||||||
dir := parentSlashPath(mediaPath)
|
dir := parentSlashPath(mediaPath)
|
||||||
if strictSeasonFolderMatched(pathBaseSlash(dir)) {
|
if strictSeasonFolderMatched(pathBaseSlash(dir)) || isTheatricalFolder(pathBaseSlash(dir)) {
|
||||||
dir = parentSlashPath(dir)
|
dir = parentSlashPath(dir)
|
||||||
}
|
}
|
||||||
if root := comparableLibraryRoot(libraryRoot); root != "" && sameSlashPath(dir, root) {
|
if root := comparableLibraryRoot(libraryRoot); root != "" && sameSlashPath(dir, root) {
|
||||||
@@ -114,12 +119,39 @@ func seriesFolderTitle(mediaPath, libraryRoot string) string {
|
|||||||
if base == "" || base == "." {
|
if base == "" || base == "." {
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
if isGenericMediaCategoryFolder(base) || isTechnicalMediaFolder(base) || strictSeasonFolderMatched(base) {
|
if isGenericMediaCategoryFolder(base) || isTechnicalMediaFolder(base) || strictSeasonFolderMatched(base) || isTheatricalFolder(base) {
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
return base
|
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 {
|
func libraryRootTitle(libraryRoot string) string {
|
||||||
base := ""
|
base := ""
|
||||||
if info, ok := ParseCloudLibraryMount(libraryRoot); ok {
|
if info, ok := ParseCloudLibraryMount(libraryRoot); ok {
|
||||||
|
|||||||
@@ -115,10 +115,12 @@ func (s *ScraperService) processScrapeTask(ctx context.Context, task *model.Scra
|
|||||||
}
|
}
|
||||||
|
|
||||||
epArtwork := task.EpisodeImages
|
epArtwork := task.EpisodeImages
|
||||||
|
usedProvider := ""
|
||||||
options := ScrapeOptions{
|
options := ScrapeOptions{
|
||||||
EpisodeArtwork: &epArtwork,
|
EpisodeArtwork: &epArtwork,
|
||||||
IncludeMatched: task.RefreshMatched,
|
IncludeMatched: task.RefreshMatched,
|
||||||
RetryNoMatch: true,
|
RetryNoMatch: true,
|
||||||
|
resultProvider: &usedProvider,
|
||||||
}
|
}
|
||||||
|
|
||||||
enrichErr := s.EnrichOneWithOptions(ctx, media, options)
|
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.MatchedYear = refreshed.Year
|
||||||
task.PosterURL = refreshed.PosterURL
|
task.PosterURL = refreshed.PosterURL
|
||||||
task.BackdropURL = refreshed.BackdropURL
|
task.BackdropURL = refreshed.BackdropURL
|
||||||
if refreshed.TMDbID > 0 {
|
task.Provider = strings.ToLower(strings.TrimSpace(usedProvider))
|
||||||
task.Provider = "tmdb"
|
if task.Provider == "" {
|
||||||
} else if strings.TrimSpace(refreshed.DoubanID) != "" {
|
task.Provider = "unknown"
|
||||||
task.Provider = "douban"
|
|
||||||
} else if refreshed.BangumiID > 0 {
|
|
||||||
task.Provider = "bangumi"
|
|
||||||
} else if strings.TrimSpace(refreshed.TheTVDBID) != "" {
|
|
||||||
task.Provider = "thetvdb"
|
|
||||||
} else {
|
|
||||||
task.Provider = "metatube"
|
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
task.Status = model.ScrapeTaskFailed
|
task.Status = model.ScrapeTaskFailed
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package service
|
package service
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"go.uber.org/zap"
|
"go.uber.org/zap"
|
||||||
@@ -24,6 +25,10 @@ type ScraperService struct {
|
|||||||
hub *Hub
|
hub *Hub
|
||||||
cache *RuntimeCacheService
|
cache *RuntimeCacheService
|
||||||
images *ImageProxy
|
images *ImageProxy
|
||||||
|
|
||||||
|
// Serializes final sidecar replacement. Windows cannot rename over an
|
||||||
|
// existing file, and concurrent scrapes can target the same sidecar.
|
||||||
|
artworkWriteMu sync.Mutex
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewScraperService is the constructor.
|
// NewScraperService is the constructor.
|
||||||
|
|||||||
@@ -44,38 +44,84 @@ func (s *ScraperService) writeMediaArtworkFilesAfterScrape(ctx context.Context,
|
|||||||
if base == "" || base == "." {
|
if base == "" || base == "." {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
isAdult := IsAdultMediaPathOrMetadata(refreshed.Path, refreshed.LibraryID, refreshed.NSFW) || IsAdultArtworkURL(refreshed.PosterURL)
|
isAdult := shouldCropAdultPoster(refreshed, lib)
|
||||||
|
artworkUpdates := map[string]any{}
|
||||||
if refreshed.PosterURL != "" {
|
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 != "" {
|
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 != "" {
|
} 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) {
|
func (s *ScraperService) downloadArtworkToPath(ctx context.Context, dir, name, raw string) string {
|
||||||
s.downloadArtworkToPathWithOptions(ctx, dir, name, raw, false)
|
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
|
// 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.
|
// 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) {
|
func (s *ScraperService) downloadArtworkToPathWithOptions(ctx context.Context, dir, name, raw string, cropAdultPoster bool) string {
|
||||||
if !isHTTPish(raw) {
|
var (
|
||||||
return
|
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 {
|
if err != nil || len(data) == 0 {
|
||||||
s.log.Warn("scrape artwork download failed",
|
s.log.Warn("scrape artwork download failed",
|
||||||
zap.String("name", name),
|
zap.String("name", name),
|
||||||
zap.String("url", raw),
|
zap.String("url", raw),
|
||||||
zap.Error(err))
|
zap.Error(err))
|
||||||
return
|
return ""
|
||||||
}
|
}
|
||||||
if !isImageContentType(ctype) || isTransparentPlaceholderData(data) {
|
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 cropAdultPoster {
|
||||||
if cropped, croppedType, err := CropAdultCoverPoster(data); err == nil && len(cropped) > 0 {
|
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
|
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>
|
// 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))
|
s.log.Warn("scrape artwork write failed", zap.String("dst", dst), zap.Error(err))
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
_ = tmp.Close()
|
if err := tmp.Close(); err != nil {
|
||||||
if err := os.Rename(tmp.Name(), dst); err != nil {
|
|
||||||
_ = os.Remove(tmp.Name())
|
_ = 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 ""
|
return ""
|
||||||
}
|
}
|
||||||
s.log.Debug("scrape artwork written", zap.String("dst", dst))
|
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.
|
// extension that could confuse media players.
|
||||||
func imageExtForContentType(ctype string) string {
|
func imageExtForContentType(ctype string) string {
|
||||||
switch strings.ToLower(strings.TrimSpace(strings.Split(ctype, ";")[0])) {
|
switch strings.ToLower(strings.TrimSpace(strings.Split(ctype, ";")[0])) {
|
||||||
case "image/jpeg", "image/pjpeg":
|
case "image/jpeg", "image/jpg", "image/pjpeg":
|
||||||
return ".jpg"
|
return ".jpg"
|
||||||
case "image/png", "image/x-png":
|
case "image/png", "image/x-png":
|
||||||
return ".png"
|
return ".png"
|
||||||
|
|||||||
@@ -1,13 +1,22 @@
|
|||||||
package service
|
package service
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"bytes"
|
||||||
|
"image"
|
||||||
|
"image/color"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
|
"github.com/glebarez/sqlite"
|
||||||
"go.uber.org/zap"
|
"go.uber.org/zap"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
|
||||||
|
"github.com/truewhile/MeBox/internal/config"
|
||||||
"github.com/truewhile/MeBox/internal/model"
|
"github.com/truewhile/MeBox/internal/model"
|
||||||
|
"github.com/truewhile/MeBox/internal/repository"
|
||||||
)
|
)
|
||||||
|
|
||||||
// TestWriteArtworkDataToPathWritesJellyfinSidecar verifies that in-memory
|
// 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
|
// TestImageExtForContentType verifies the MIME -> extension mapping used to
|
||||||
// name Jellyfin sidecar files.
|
// name Jellyfin sidecar files.
|
||||||
func TestImageExtForContentType(t *testing.T) {
|
func TestImageExtForContentType(t *testing.T) {
|
||||||
cases := map[string]string{
|
cases := map[string]string{
|
||||||
"image/jpeg": ".jpg",
|
"image/jpeg": ".jpg",
|
||||||
|
"image/jpg": ".jpg",
|
||||||
"image/pjpeg": ".jpg",
|
"image/pjpeg": ".jpg",
|
||||||
"image/png": ".png",
|
"image/png": ".png",
|
||||||
"image/webp": ".webp",
|
"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
|
// TestWriteMediaArtworkFilesAfterScrapeSkipsCloud verifies that cloud media
|
||||||
// never has artwork written anywhere.
|
// never has artwork written anywhere.
|
||||||
func TestWriteMediaArtworkFilesAfterScrapeSkipsCloud(t *testing.T) {
|
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))
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
+217
-54
@@ -46,25 +46,26 @@ type strmSyncState struct {
|
|||||||
rec *model.StrmSyncRecord
|
rec *model.StrmSyncRecord
|
||||||
syncType string
|
syncType string
|
||||||
|
|
||||||
mu sync.Mutex
|
mu sync.Mutex
|
||||||
processed int // 已处理文件计数(用于定期落库进度)
|
processed int // 已处理文件计数(用于定期落库进度)
|
||||||
lastProgressFlush time.Time // 上次进度落库时间
|
lastProgressFlush time.Time // 上次进度落库时间
|
||||||
seenVideo map[string]bool // "v:"+strm 去扩展名相对路径 → 远端存在该视频(供 prune)
|
seenVideo map[string]bool // "v:"+strm 去扩展名相对路径 → 远端存在该视频(供 prune)
|
||||||
seenMeta map[string]bool // "m:"+相对路径 → 远端存在该元数据
|
seenDir map[string]bool // 清洗后的目录相对路径 → 远端存在该目录(供整目录 prune)
|
||||||
remoteMeta map[string][]remoteMetaItem // "m:"+相对路径 → 远端元数据副本列表(多副本聚合,支持择优比对与冗余清理)
|
seenMeta map[string]bool // "m:"+相对路径 → 远端存在该元数据
|
||||||
seenMetaTarget map[string]cloud.FileEntry
|
remoteMeta map[string][]remoteMetaItem // "m:"+相对路径 → 远端元数据副本列表(多副本聚合,支持择优比对与冗余清理)
|
||||||
seenVideoTarget map[string]cloud.FileEntry
|
seenMetaTarget map[string]cloud.FileEntry
|
||||||
|
seenVideoTarget map[string]cloud.FileEntry
|
||||||
// remoteVideos:prefer 模式下同名(去扩展名)视频候选列表,walk 结束后择优写盘
|
// remoteVideos:prefer 模式下同名(去扩展名)视频候选列表,walk 结束后择优写盘
|
||||||
remoteVideos map[string][]remoteVideoCandidate
|
remoteVideos map[string][]remoteVideoCandidate
|
||||||
activeDownloadPaths map[string]bool // 本地已在排队/进行的下载任务路径(内存去重)
|
activeDownloadPaths map[string]bool // 本地已在排队/进行的下载任务路径(内存去重)
|
||||||
activeUploadPaths map[string]bool // 本地已在排队/进行的上传任务路径(内存去重)
|
activeUploadPaths map[string]bool // 本地已在排队/进行的上传任务路径(内存去重)
|
||||||
// recentDoneUploadSizes:近期已成功上传的 local_path → size,缩短「done 但列表未到」窗口内的重复入队
|
// recentDoneUploadSizes:近期已成功上传的 local_path → size,缩短「done 但列表未到」窗口内的重复入队
|
||||||
recentDoneUploadSizes map[string]int64
|
recentDoneUploadSizes map[string]int64
|
||||||
pendingDownloads []*model.StrmDownloadTask
|
pendingDownloads []*model.StrmDownloadTask
|
||||||
pendingUploads []*model.StrmUploadTask
|
pendingUploads []*model.StrmUploadTask
|
||||||
dirCache sync.Map // dirID (string) -> relativePath (string)
|
dirCache sync.Map // dirID (string) -> relativePath (string)
|
||||||
dirPathToID map[string]string // relativePath (string) -> dirID(115 上传父目录寻址用,walk 后构建)
|
dirPathToID map[string]string // relativePath (string) -> dirID(115 上传父目录寻址用,walk 后构建)
|
||||||
dirCacheDirty map[string]string // 待批量落库的目录缓存(dirID → 相对路径),避免逐目录单条 upsert
|
dirCacheDirty map[string]string // 待批量落库的目录缓存(dirID → 相对路径),避免逐目录单条 upsert
|
||||||
|
|
||||||
scanIncomplete atomic.Bool // 远端目录树/文件列表本次扫描不完整 → 禁止增量 prune 误删本地文件
|
scanIncomplete atomic.Bool // 远端目录树/文件列表本次扫描不完整 → 禁止增量 prune 误删本地文件
|
||||||
}
|
}
|
||||||
@@ -281,6 +282,7 @@ func (s *StrmService) runSync(ctx context.Context, p *model.StrmSyncPath, rec *m
|
|||||||
rec: rec,
|
rec: rec,
|
||||||
syncType: rec.SyncType,
|
syncType: rec.SyncType,
|
||||||
seenVideo: map[string]bool{},
|
seenVideo: map[string]bool{},
|
||||||
|
seenDir: map[string]bool{"": true},
|
||||||
seenMeta: map[string]bool{},
|
seenMeta: map[string]bool{},
|
||||||
remoteMeta: map[string][]remoteMetaItem{},
|
remoteMeta: map[string][]remoteMetaItem{},
|
||||||
seenMetaTarget: map[string]cloud.FileEntry{},
|
seenMetaTarget: map[string]cloud.FileEntry{},
|
||||||
@@ -535,36 +537,38 @@ func (st *strmSyncState) walkRemote() error {
|
|||||||
if task.rel != "" {
|
if task.rel != "" {
|
||||||
rel = task.rel + "/" + cleanName
|
rel = task.rel + "/" + cleanName
|
||||||
}
|
}
|
||||||
if entry.IsDir {
|
if entry.IsDir {
|
||||||
st.dirCache.Store(entry.ID, rel)
|
st.markSeenDir(rel)
|
||||||
st.deferDirCacheSave(entry.ID, rel)
|
st.dirCache.Store(entry.ID, rel)
|
||||||
push(dirTask{id: entry.ID, rel: rel})
|
st.deferDirCacheSave(entry.ID, rel)
|
||||||
} else {
|
push(dirTask{id: entry.ID, rel: rel})
|
||||||
st.processRemoteFile(entry, rel)
|
} else {
|
||||||
}
|
st.processRemoteFile(entry, rel)
|
||||||
}
|
}
|
||||||
walkMu.Lock()
|
|
||||||
pending--
|
|
||||||
if pending == 0 {
|
|
||||||
walkCond.Broadcast()
|
|
||||||
}
|
|
||||||
walkMu.Unlock()
|
|
||||||
}
|
}
|
||||||
}); err != nil {
|
walkMu.Lock()
|
||||||
cancel()
|
pending--
|
||||||
|
if pending == 0 {
|
||||||
|
walkCond.Broadcast()
|
||||||
|
}
|
||||||
|
walkMu.Unlock()
|
||||||
}
|
}
|
||||||
}()
|
}); err != nil {
|
||||||
}
|
cancel()
|
||||||
wg.Wait()
|
}
|
||||||
st.flushDirCacheSave()
|
}()
|
||||||
if firstErr != nil {
|
}
|
||||||
return firstErr
|
wg.Wait()
|
||||||
}
|
st.flushDirCacheSave()
|
||||||
|
if firstErr != nil {
|
||||||
|
return firstErr
|
||||||
|
}
|
||||||
return ctx.Err()
|
return ctx.Err()
|
||||||
}
|
}
|
||||||
|
|
||||||
// processRemoteFile 分类处理远端文件:视频生成 STRM,元数据入下载队列。
|
// processRemoteFile 分类处理远端文件:视频生成 STRM,元数据入下载队列。
|
||||||
func (st *strmSyncState) processRemoteFile(entry cloud.FileEntry, rel string) {
|
func (st *strmSyncState) processRemoteFile(entry cloud.FileEntry, rel string) {
|
||||||
|
st.markSeenDir(filepath.ToSlash(filepath.Dir(rel)))
|
||||||
fileName := entry.Name
|
fileName := entry.Name
|
||||||
if st.isExcluded(fileName) {
|
if st.isExcluded(fileName) {
|
||||||
return
|
return
|
||||||
@@ -698,16 +702,16 @@ func (st *strmSyncState) walk115Flat(open115 *cloud115.OpenClient) error {
|
|||||||
if pathCounts[item.Path] > 1 {
|
if pathCounts[item.Path] > 1 {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
st.dirCache.Store(item.DirID, cleanDirRel(item.Path))
|
st.dirCache.Store(item.DirID, cleanDirRel(item.Path))
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 2. 自适应分治拉取文件列表(单目录超 9500 时自动对子目录并发分治扁平化)
|
// 2. 自适应分治拉取文件列表(单目录超 9500 时自动对子目录并发分治扁平化)
|
||||||
allFiles, err := st.fetch115FilesAdaptive(ctx, open115, rootCID)
|
allFiles, err := st.fetch115FilesAdaptive(ctx, open115, rootCID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
if ctx.Err() != nil {
|
if ctx.Err() != nil {
|
||||||
return ctx.Err()
|
return ctx.Err()
|
||||||
@@ -1031,10 +1035,10 @@ func list115DirDirect(ctx context.Context, open115 *cloud115.OpenClient, cid str
|
|||||||
|
|
||||||
// fetch115FilesAdaptive 采用自适应分治策略抓取 115 目录树下的全部文件:
|
// fetch115FilesAdaptive 采用自适应分治策略抓取 115 目录树下的全部文件:
|
||||||
// 115 开放平台扁平搜索对 offset+limit 有 10000 的最大深度限制。
|
// 115 开放平台扁平搜索对 offset+limit 有 10000 的最大深度限制。
|
||||||
// - 若子树文件总数 < 9500,直接使用全速扁平分页批量拉取;
|
// - 若子树文件总数 < 9500,直接使用全速扁平分页批量拉取;
|
||||||
// - 若子树文件总数 >= 9500(大库或超大分类目录),自动分治:仅单层列出该目录的直属子项(cur=1),
|
// - 若子树文件总数 >= 9500(大库或超大分类目录),自动分治:仅单层列出该目录的直属子项(cur=1),
|
||||||
// 直属纯文件直接收集,直属子目录则派发为独立的子树任务继续递归探测与拉取;
|
// 直属纯文件直接收集,直属子目录则派发为独立的子树任务继续递归探测与拉取;
|
||||||
// - 若超大单目录下无子目录或层级过深(>10层),安全回退到 errFallbackToWalkRemote。
|
// - 若超大单目录下无子目录或层级过深(>10层),安全回退到 errFallbackToWalkRemote。
|
||||||
func (st *strmSyncState) fetch115FilesAdaptive(ctx context.Context, open115 *cloud115.OpenClient, rootCID string) ([]cloud115.RemoteFile, error) {
|
func (st *strmSyncState) fetch115FilesAdaptive(ctx context.Context, open115 *cloud115.OpenClient, rootCID string) ([]cloud115.RemoteFile, error) {
|
||||||
var (
|
var (
|
||||||
allFiles []cloud115.RemoteFile
|
allFiles []cloud115.RemoteFile
|
||||||
@@ -1637,6 +1641,10 @@ func (st *strmSyncState) walkLocalSource() error {
|
|||||||
default:
|
default:
|
||||||
}
|
}
|
||||||
if d.IsDir() {
|
if d.IsDir() {
|
||||||
|
rel, relErr := filepath.Rel(srcRoot, path)
|
||||||
|
if relErr == nil {
|
||||||
|
st.markSeenDir(filepath.ToSlash(rel))
|
||||||
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
rel, err := filepath.Rel(srcRoot, path)
|
rel, err := filepath.Rel(srcRoot, path)
|
||||||
@@ -1898,8 +1906,104 @@ func (st *strmSyncState) taskExists(kind, syncPathID, localPath string) bool {
|
|||||||
return count > 0
|
return count > 0
|
||||||
}
|
}
|
||||||
|
|
||||||
// pruneLocal 清理本地多余 .strm(远端已不存在的视频),可选删除空目录。
|
// markSeenDir 记录远端存在的目录及其全部祖先目录。
|
||||||
// 元数据文件(nfo/图片/字幕等)一律保留:本地刮削结果不因网盘端缺失而被删除。
|
func (st *strmSyncState) markSeenDir(rel string) {
|
||||||
|
rel = strings.Trim(filepath.ToSlash(rel), "/")
|
||||||
|
if rel == "." {
|
||||||
|
rel = ""
|
||||||
|
}
|
||||||
|
st.mu.Lock()
|
||||||
|
if st.seenDir == nil {
|
||||||
|
st.seenDir = map[string]bool{"": true}
|
||||||
|
}
|
||||||
|
for {
|
||||||
|
st.seenDir[rel] = true
|
||||||
|
if rel == "" {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
if idx := strings.LastIndexByte(rel, '/'); idx >= 0 {
|
||||||
|
rel = rel[:idx]
|
||||||
|
} else {
|
||||||
|
rel = ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
st.mu.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
// refreshUnseen115Dirs 补查本地存在、但 115 扁平文件列表未覆盖的目录。
|
||||||
|
// 扁平接口不返回空目录;逐层补查这些候选目录可以避免把远端仍存在的空目录误删。
|
||||||
|
func (st *strmSyncState) refreshUnseen115Dirs(localRoot string, dirs []string) error {
|
||||||
|
if st.p.Provider != model.StrmProvider115 || st.provider == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
dirIDs := map[string]string{"": strings.TrimSpace(st.p.RemotePath)}
|
||||||
|
if dirIDs[""] == "" {
|
||||||
|
dirIDs[""] = "0"
|
||||||
|
}
|
||||||
|
st.dirCache.Range(func(key, value any) bool {
|
||||||
|
id, idOK := key.(string)
|
||||||
|
rel, relOK := value.(string)
|
||||||
|
if idOK && relOK && id != "" {
|
||||||
|
dirIDs[cleanDirRel(rel)] = id
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
liveIDs := map[string]string{"": dirIDs[""]}
|
||||||
|
listed := map[string]bool{}
|
||||||
|
|
||||||
|
sort.Strings(dirs)
|
||||||
|
for _, dir := range dirs {
|
||||||
|
rel, err := filepath.Rel(localRoot, dir)
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
rel = cleanDirRel(filepath.ToSlash(rel))
|
||||||
|
st.mu.Lock()
|
||||||
|
seen := st.seenDir[rel]
|
||||||
|
st.mu.Unlock()
|
||||||
|
if seen {
|
||||||
|
if id := dirIDs[rel]; id != "" {
|
||||||
|
liveIDs[rel] = id
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
parentRel := ""
|
||||||
|
if idx := strings.LastIndexByte(rel, '/'); idx >= 0 {
|
||||||
|
parentRel = rel[:idx]
|
||||||
|
}
|
||||||
|
parentID := liveIDs[parentRel]
|
||||||
|
if parentID == "" {
|
||||||
|
continue // 父目录已确认不存在,子目录也必然是本地孤儿
|
||||||
|
}
|
||||||
|
if listed[parentRel] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
entries, err := st.provider.List(st.ctx, parentID)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("核对 115 远端目录 %s 失败:%w", parentRel, err)
|
||||||
|
}
|
||||||
|
listed[parentRel] = true
|
||||||
|
for _, entry := range entries {
|
||||||
|
if !entry.IsDir {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
childRel := cleanEntryName(entry.Name, true)
|
||||||
|
if parentRel != "" {
|
||||||
|
childRel = parentRel + "/" + childRel
|
||||||
|
}
|
||||||
|
st.markSeenDir(childRel)
|
||||||
|
liveIDs[childRel] = entry.ID
|
||||||
|
dirIDs[childRel] = entry.ID
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// pruneLocal 清理本地远端已不存在的内容:
|
||||||
|
// - 整个目录在远端不存在时,递归删除该本地目录(包括元数据);
|
||||||
|
// - 目录仍存在但视频已删除时,仅删除对应的 .strm,保留本地元数据;
|
||||||
|
// - DeleteDir 开启时,最后再清理其余空目录。
|
||||||
func (st *strmSyncState) pruneLocal() error {
|
func (st *strmSyncState) pruneLocal() error {
|
||||||
// 增量同步保护:本次远端扫描不完整(目录详情解析失败 / 文件父路径降级)时,
|
// 增量同步保护:本次远端扫描不完整(目录详情解析失败 / 文件父路径降级)时,
|
||||||
// seenVideo 覆盖不全,按"远端不存在"清理会误删刚下载或已存在的本地 .strm,
|
// seenVideo 覆盖不全,按"远端不存在"清理会误删刚下载或已存在的本地 .strm,
|
||||||
@@ -1911,6 +2015,7 @@ func (st *strmSyncState) pruneLocal() error {
|
|||||||
}
|
}
|
||||||
localRoot := filepath.Clean(st.p.LocalPath)
|
localRoot := filepath.Clean(st.p.LocalPath)
|
||||||
var dirs []string
|
var dirs []string
|
||||||
|
var strmFiles []string
|
||||||
err := filepath.WalkDir(localRoot, func(path string, d os.DirEntry, err error) error {
|
err := filepath.WalkDir(localRoot, func(path string, d os.DirEntry, err error) error {
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil
|
return nil
|
||||||
@@ -1933,13 +2038,74 @@ func (st *strmSyncState) pruneLocal() error {
|
|||||||
}
|
}
|
||||||
rel = filepath.ToSlash(rel)
|
rel = filepath.ToSlash(rel)
|
||||||
ext := strings.ToLower(filepath.Ext(rel))
|
ext := strings.ToLower(filepath.Ext(rel))
|
||||||
remove := false
|
|
||||||
if ext == ".strm" {
|
if ext == ".strm" {
|
||||||
relSansExt := rel[:len(rel)-len(ext)]
|
strmFiles = append(strmFiles, path)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := st.refreshUnseen115Dirs(localRoot, dirs); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// 先从浅到深找出最上层孤儿目录;父目录已判定为孤儿时无需重复处理子目录。
|
||||||
|
sort.Strings(dirs)
|
||||||
|
orphanRoots := make([]string, 0)
|
||||||
|
for _, dir := range dirs {
|
||||||
|
rel, relErr := filepath.Rel(localRoot, dir)
|
||||||
|
if relErr != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
rel = filepath.ToSlash(rel)
|
||||||
|
st.mu.Lock()
|
||||||
|
existsRemotely := st.seenDir[rel]
|
||||||
|
st.mu.Unlock()
|
||||||
|
if existsRemotely {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
underOrphan := false
|
||||||
|
for _, root := range orphanRoots {
|
||||||
|
childRel, childErr := filepath.Rel(root, dir)
|
||||||
|
if childErr == nil && childRel != ".." && !strings.HasPrefix(childRel, ".."+string(filepath.Separator)) {
|
||||||
|
underOrphan = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !underOrphan {
|
||||||
|
orphanRoots = append(orphanRoots, dir)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, dir := range orphanRoots {
|
||||||
|
var fileCount int64
|
||||||
|
_ = filepath.WalkDir(dir, func(_ string, d os.DirEntry, walkErr error) error {
|
||||||
|
if walkErr == nil && !d.IsDir() {
|
||||||
|
fileCount++
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
if err := os.RemoveAll(dir); err == nil {
|
||||||
st.mu.Lock()
|
st.mu.Lock()
|
||||||
remove = !st.seenVideo["v:"+relSansExt]
|
st.rec.Pruned += fileCount
|
||||||
st.mu.Unlock()
|
st.mu.Unlock()
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 对仍存在于远端的目录,按原规则清理失去远端视频来源的单个 .strm。
|
||||||
|
for _, path := range strmFiles {
|
||||||
|
if _, err := os.Stat(path); err != nil {
|
||||||
|
continue // 已随孤儿目录递归删除
|
||||||
|
}
|
||||||
|
rel, relErr := filepath.Rel(localRoot, path)
|
||||||
|
if relErr != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
rel = filepath.ToSlash(rel)
|
||||||
|
relSansExt := strings.TrimSuffix(rel, filepath.Ext(rel))
|
||||||
|
st.mu.Lock()
|
||||||
|
remove := !st.seenVideo["v:"+relSansExt]
|
||||||
|
st.mu.Unlock()
|
||||||
if remove {
|
if remove {
|
||||||
if err := os.Remove(path); err == nil {
|
if err := os.Remove(path); err == nil {
|
||||||
st.mu.Lock()
|
st.mu.Lock()
|
||||||
@@ -1947,11 +2113,8 @@ func (st *strmSyncState) pruneLocal() error {
|
|||||||
st.mu.Unlock()
|
st.mu.Unlock()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return nil
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if st.cfg.DeleteDir {
|
if st.cfg.DeleteDir {
|
||||||
sort.Sort(sort.Reverse(sort.StringSlice(dirs)))
|
sort.Sort(sort.Reverse(sort.StringSlice(dirs)))
|
||||||
for _, dir := range dirs {
|
for _, dir := range dirs {
|
||||||
|
|||||||
@@ -489,14 +489,12 @@ func taskNames(tasks []model.StrmUploadTask) []string {
|
|||||||
return names
|
return names
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestPruneLocalKeepsLocalMeta 验证清理规则:远端已删除的视频 .strm 仍会被清理,
|
// TestPruneLocalRemovesOrphanDirectory 验证远端目录不存在时,会递归删除整个本地
|
||||||
// 但本地元数据一律保留(即使开启"下载元数据"且未开启"上传元数据"、网盘端没有
|
// 目录,包括其中的 strm、NFO、图片等文件。
|
||||||
// 该元数据,也不再删除本地刮削好的 nfo/图片/字幕)。
|
func TestPruneLocalRemovesOrphanDirectory(t *testing.T) {
|
||||||
func TestPruneLocalKeepsLocalMeta(t *testing.T) {
|
|
||||||
svc := testStrmService(t)
|
svc := testStrmService(t)
|
||||||
localDir := t.TempDir()
|
localDir := t.TempDir()
|
||||||
|
|
||||||
// 阿凡达.strm(对应视频已被网盘删除 → 应清理)+ 阿凡达.nfo(网盘没有 → 保留)
|
|
||||||
writeFile(t, filepath.Join(localDir, "电影", "阿凡达.strm"), "http://test.local:8096/x")
|
writeFile(t, filepath.Join(localDir, "电影", "阿凡达.strm"), "http://test.local:8096/x")
|
||||||
writeFile(t, filepath.Join(localDir, "电影", "阿凡达.nfo"), "<local scraped meta/>")
|
writeFile(t, filepath.Join(localDir, "电影", "阿凡达.nfo"), "<local scraped meta/>")
|
||||||
writeFile(t, filepath.Join(localDir, "电影", "poster.jpg"), "local-poster")
|
writeFile(t, filepath.Join(localDir, "电影", "poster.jpg"), "local-poster")
|
||||||
@@ -518,10 +516,40 @@ func TestPruneLocalKeepsLocalMeta(t *testing.T) {
|
|||||||
rec: &model.StrmSyncRecord{},
|
rec: &model.StrmSyncRecord{},
|
||||||
syncType: model.StrmSyncTypeFull,
|
syncType: model.StrmSyncTypeFull,
|
||||||
seenVideo: map[string]bool{},
|
seenVideo: map[string]bool{},
|
||||||
|
seenDir: map[string]bool{"": true},
|
||||||
seenMeta: map[string]bool{},
|
seenMeta: map[string]bool{},
|
||||||
remoteMeta: map[string][]remoteMetaItem{},
|
remoteMeta: map[string][]remoteMetaItem{},
|
||||||
}
|
}
|
||||||
// 本次远端扫描既没有看到视频,也没有看到任何元数据
|
if err := st.pruneLocal(); err != nil {
|
||||||
|
t.Fatalf("pruneLocal failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := os.Stat(filepath.Join(localDir, "电影")); !os.IsNotExist(err) {
|
||||||
|
t.Fatalf("orphan directory should be removed recursively, stat err = %v", err)
|
||||||
|
}
|
||||||
|
if st.rec.Pruned != 3 {
|
||||||
|
t.Fatalf("expected 3 pruned files, got %d", st.rec.Pruned)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestPruneLocalKeepsMetaInExistingDirectory 验证目录仍在远端时,只清理失去
|
||||||
|
// 视频来源的 strm,不删除本地刮削元数据。
|
||||||
|
func TestPruneLocalKeepsMetaInExistingDirectory(t *testing.T) {
|
||||||
|
svc := testStrmService(t)
|
||||||
|
localDir := t.TempDir()
|
||||||
|
writeFile(t, filepath.Join(localDir, "电影", "阿凡达.strm"), "http://test.local:8096/x")
|
||||||
|
writeFile(t, filepath.Join(localDir, "电影", "阿凡达.nfo"), "<local scraped meta/>")
|
||||||
|
|
||||||
|
st := &strmSyncState{
|
||||||
|
s: svc,
|
||||||
|
ctx: context.Background(),
|
||||||
|
p: &model.StrmSyncPath{Base: model.Base{ID: "prune-existing-dir"}, LocalPath: localDir},
|
||||||
|
cfg: &strmPathConfig{},
|
||||||
|
rec: &model.StrmSyncRecord{},
|
||||||
|
syncType: model.StrmSyncTypeFull,
|
||||||
|
seenVideo: map[string]bool{},
|
||||||
|
seenDir: map[string]bool{"": true, "电影": true},
|
||||||
|
}
|
||||||
if err := st.pruneLocal(); err != nil {
|
if err := st.pruneLocal(); err != nil {
|
||||||
t.Fatalf("pruneLocal failed: %v", err)
|
t.Fatalf("pruneLocal failed: %v", err)
|
||||||
}
|
}
|
||||||
@@ -530,10 +558,7 @@ func TestPruneLocalKeepsLocalMeta(t *testing.T) {
|
|||||||
t.Fatalf("orphan .strm should be pruned, stat err = %v", err)
|
t.Fatalf("orphan .strm should be pruned, stat err = %v", err)
|
||||||
}
|
}
|
||||||
if _, err := os.Stat(filepath.Join(localDir, "电影", "阿凡达.nfo")); err != nil {
|
if _, err := os.Stat(filepath.Join(localDir, "电影", "阿凡达.nfo")); err != nil {
|
||||||
t.Fatalf("local meta must be kept even when missing on remote: %v", err)
|
t.Fatalf("local meta in an existing remote directory must be kept: %v", err)
|
||||||
}
|
|
||||||
if _, err := os.Stat(filepath.Join(localDir, "电影", "poster.jpg")); err != nil {
|
|
||||||
t.Fatalf("local poster must be kept even when missing on remote: %v", err)
|
|
||||||
}
|
}
|
||||||
if st.rec.Pruned != 1 {
|
if st.rec.Pruned != 1 {
|
||||||
t.Fatalf("expected 1 pruned (strm only), got %d", st.rec.Pruned)
|
t.Fatalf("expected 1 pruned (strm only), got %d", st.rec.Pruned)
|
||||||
|
|||||||
@@ -94,28 +94,28 @@ func (s *SubtitleService) Discover(ctx context.Context, mediaID string) ([]Subti
|
|||||||
// DiscoverExternalOnly 只返回媒体旁边的外挂字幕文件,不含容器内嵌字幕轨。
|
// DiscoverExternalOnly 只返回媒体旁边的外挂字幕文件,不含容器内嵌字幕轨。
|
||||||
// Emby 字幕接口(/Videos/:id/Subtitles/...)用。
|
// Emby 字幕接口(/Videos/:id/Subtitles/...)用。
|
||||||
func (s *SubtitleService) DiscoverExternalOnly(ctx context.Context, mediaID string) ([]SubtitleTrack, error) {
|
func (s *SubtitleService) DiscoverExternalOnly(ctx context.Context, mediaID string) ([]SubtitleTrack, error) {
|
||||||
tracks, err := s.discover(ctx, mediaID)
|
cacheKey := "external:" + mediaID
|
||||||
|
if tracks, ok := s.cachedDiscovery(cacheKey); ok {
|
||||||
|
return tracks, nil
|
||||||
|
}
|
||||||
|
tracks, err := s.discoverExternalUncached(ctx, mediaID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
out := make([]SubtitleTrack, 0, len(tracks))
|
s.rememberDiscovery(cacheKey, tracks)
|
||||||
for _, track := range tracks {
|
return tracks, nil
|
||||||
if track.Source != "embedded" {
|
|
||||||
out = append(out, track)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return out, nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *SubtitleService) discover(ctx context.Context, mediaID string) ([]SubtitleTrack, error) {
|
func (s *SubtitleService) discover(ctx context.Context, mediaID string) ([]SubtitleTrack, error) {
|
||||||
if tracks, ok := s.cachedDiscovery(mediaID); ok {
|
cacheKey := "all:" + mediaID
|
||||||
|
if tracks, ok := s.cachedDiscovery(cacheKey); ok {
|
||||||
return tracks, nil
|
return tracks, nil
|
||||||
}
|
}
|
||||||
tracks, err := s.discoverUncached(ctx, mediaID)
|
tracks, err := s.discoverUncached(ctx, mediaID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
s.rememberDiscovery(mediaID, tracks)
|
s.rememberDiscovery(cacheKey, tracks)
|
||||||
return tracks, nil
|
return tracks, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -159,6 +159,30 @@ func (s *SubtitleService) discoverUncached(ctx context.Context, mediaID string)
|
|||||||
if m == nil {
|
if m == nil {
|
||||||
return nil, errors.New("media not found")
|
return nil, errors.New("media not found")
|
||||||
}
|
}
|
||||||
|
tracks := discoverExternalSubtitleTracks(m)
|
||||||
|
embedded, err := s.discoverEmbedded(ctx, m)
|
||||||
|
if err != nil {
|
||||||
|
if s.log != nil {
|
||||||
|
s.log.Debug("discover embedded subtitles failed", zap.String("media_id", mediaID), zap.Error(err))
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
tracks = append(tracks, embedded...)
|
||||||
|
}
|
||||||
|
return tracks, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *SubtitleService) discoverExternalUncached(ctx context.Context, mediaID string) ([]SubtitleTrack, error) {
|
||||||
|
m, err := s.repo.Media.FindByID(ctx, mediaID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if m == nil {
|
||||||
|
return nil, errors.New("media not found")
|
||||||
|
}
|
||||||
|
return discoverExternalSubtitleTracks(m), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func discoverExternalSubtitleTracks(m *model.Media) []SubtitleTrack {
|
||||||
dir := filepath.Dir(m.Path)
|
dir := filepath.Dir(m.Path)
|
||||||
bases := mediaSidecarBaseVariants(m.Path)
|
bases := mediaSidecarBaseVariants(m.Path)
|
||||||
if len(bases) == 0 {
|
if len(bases) == 0 {
|
||||||
@@ -214,15 +238,7 @@ func (s *SubtitleService) discoverUncached(ctx context.Context, mediaID string)
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
embedded, err := s.discoverEmbedded(ctx, m)
|
return tracks
|
||||||
if err != nil {
|
|
||||||
if s.log != nil {
|
|
||||||
s.log.Debug("discover embedded subtitles failed", zap.String("media_id", mediaID), zap.Error(err))
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
tracks = append(tracks, embedded...)
|
|
||||||
}
|
|
||||||
return tracks, nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type embeddedSubtitleProbe struct {
|
type embeddedSubtitleProbe struct {
|
||||||
|
|||||||
@@ -2,7 +2,9 @@ package service
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
@@ -48,6 +50,43 @@ func TestSubtitleDiscoverNoTracksReturnsEmptySlice(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestDiscoverExternalOnlyDoesNotResolveOrProbeCloudMedia(t *testing.T) {
|
||||||
|
db, err := gorm.Open(sqlite.Open("file::memory:?cache=shared"), &gorm.Config{})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := db.AutoMigrate(&model.Library{}, &model.Media{}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
media := model.Media{
|
||||||
|
Title: "Cloud Media",
|
||||||
|
Path: "cloud://115/example/video.mkv",
|
||||||
|
STRMURL: "cloud://115/example/video.mkv",
|
||||||
|
}
|
||||||
|
if err := db.Create(&media).Error; err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
svc := NewSubtitleService(&config.Config{}, zap.NewNop(), repository.New(db))
|
||||||
|
resolveCalls := 0
|
||||||
|
svc.SetStrmPlayTargetResolver(func(context.Context, string) (*StrmPlayResult, error) {
|
||||||
|
resolveCalls++
|
||||||
|
return nil, errors.New("resolver must not be called")
|
||||||
|
})
|
||||||
|
|
||||||
|
tracks, err := svc.DiscoverExternalOnly(t.Context(), media.ID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if resolveCalls != 0 {
|
||||||
|
t.Fatalf("STRM resolver called %d times, want 0", resolveCalls)
|
||||||
|
}
|
||||||
|
if len(tracks) != 0 {
|
||||||
|
t.Fatalf("len(tracks) = %d, want 0", len(tracks))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestEmbeddedSubtitleProbeClassifiesTextAndBitmapTracks(t *testing.T) {
|
func TestEmbeddedSubtitleProbeClassifiesTextAndBitmapTracks(t *testing.T) {
|
||||||
var probe embeddedSubtitleProbe
|
var probe embeddedSubtitleProbe
|
||||||
raw := []byte(`{"streams":[
|
raw := []byte(`{"streams":[
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ package service
|
|||||||
// across providers; provider-specific IDs sit side-by-side so the scraper
|
// across providers; provider-specific IDs sit side-by-side so the scraper
|
||||||
// orchestrator can write them all into a single update.
|
// orchestrator can write them all into a single update.
|
||||||
type Match struct {
|
type Match struct {
|
||||||
|
Provider string `json:"provider,omitempty"`
|
||||||
TMDbID int `json:"tmdb_id"`
|
TMDbID int `json:"tmdb_id"`
|
||||||
BangumiID int `json:"bangumi_id"`
|
BangumiID int `json:"bangumi_id"`
|
||||||
DoubanID string `json:"douban_id,omitempty"`
|
DoubanID string `json:"douban_id,omitempty"`
|
||||||
|
|||||||
@@ -13,9 +13,11 @@ export interface SubtitleTrack {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const subtitlesAPI = {
|
export const subtitlesAPI = {
|
||||||
list: (mediaId: string) =>
|
list: (mediaId: string, includeEmbedded = false) =>
|
||||||
api
|
api
|
||||||
.get<{ tracks: SubtitleTrack[] | null }>(`/media/${mediaId}/subtitles`)
|
.get<{ tracks: SubtitleTrack[] | null }>(`/media/${mediaId}/subtitles`, {
|
||||||
|
params: includeEmbedded ? { include_embedded: 'true' } : undefined,
|
||||||
|
})
|
||||||
.then((r) => r.data.tracks ?? []),
|
.then((r) => r.data.tracks ?? []),
|
||||||
|
|
||||||
url: (mediaId: string, path: string) => {
|
url: (mediaId: string, path: string) => {
|
||||||
|
|||||||
@@ -44,8 +44,8 @@ export function ManualScrapeDialog({
|
|||||||
if (!open || !media) return null
|
if (!open || !media) return null
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-ink-900/40 px-4 py-8 backdrop-blur-sm">
|
<div className="fixed inset-0 z-50 flex min-w-0 items-center justify-center overflow-hidden bg-ink-900/40 px-4 py-8 backdrop-blur-sm">
|
||||||
<div className="flex max-h-[86vh] w-full max-w-4xl flex-col overflow-hidden rounded-2xl border border-sand-200 bg-white shadow-2xl">
|
<div className="flex min-w-0 max-h-[86vh] w-full max-w-4xl flex-col overflow-hidden rounded-2xl border border-sand-200 bg-white shadow-2xl">
|
||||||
<ManualScrapeDialogHeader title={scopeLabel || media.title} targetCount={dialog.targetIds.length} onClose={onClose} />
|
<ManualScrapeDialogHeader title={scopeLabel || media.title} targetCount={dialog.targetIds.length} onClose={onClose} />
|
||||||
|
|
||||||
<ManualScrapeSearchControls
|
<ManualScrapeSearchControls
|
||||||
@@ -60,7 +60,7 @@ export function ManualScrapeDialog({
|
|||||||
onEpisodeArtworkChange={dialog.setIncludeEpisodeArtwork}
|
onEpisodeArtworkChange={dialog.setIncludeEpisodeArtwork}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div className="flex-1 overflow-y-auto p-5">
|
<div className="min-w-0 flex-1 overflow-x-hidden overflow-y-auto p-5">
|
||||||
<ManualScrapeCandidateList items={dialog.items} applyingKey={dialog.applyingKey} onApply={dialog.apply} />
|
<ManualScrapeCandidateList items={dialog.items} applyingKey={dialog.applyingKey} onApply={dialog.apply} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -20,14 +20,14 @@ export function ManualScrapeDialogHeader({
|
|||||||
onClose: () => void
|
onClose: () => void
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<div className="flex items-start justify-between gap-4 border-b border-sand-200 px-5 py-4">
|
<div className="flex min-w-0 items-start justify-between gap-4 border-b border-sand-200 px-5 py-4">
|
||||||
<div>
|
<div className="min-w-0">
|
||||||
<h2 className="font-display text-xl font-bold text-ink-600">手动搜索刮削</h2>
|
<h2 className="font-display text-xl font-bold text-ink-600">手动搜索刮削</h2>
|
||||||
<p className="mt-1 text-xs text-sand-500">
|
<p className="mt-1 truncate text-xs text-sand-500">
|
||||||
{title} · {targetCount > 1 ? `将应用到 ${targetCount} 个媒体` : '单个媒体'}
|
{title} · {targetCount > 1 ? `将应用到 ${targetCount} 个媒体` : '单个媒体'}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<button onClick={onClose} className="btn-ghost h-9 w-9 p-0" aria-label="关闭">
|
<button onClick={onClose} className="btn-ghost h-9 w-9 shrink-0 p-0" aria-label="关闭">
|
||||||
<X size={16} />
|
<X size={16} />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -58,7 +58,7 @@ export function ManualScrapeSearchControls({
|
|||||||
onEpisodeArtworkChange,
|
onEpisodeArtworkChange,
|
||||||
}: ManualScrapeSearchControlsProps) {
|
}: ManualScrapeSearchControlsProps) {
|
||||||
return (
|
return (
|
||||||
<div className="grid gap-4 border-b border-sand-200 bg-sand-50/40 p-5">
|
<div className="grid min-w-0 gap-4 border-b border-sand-200 bg-sand-50/40 p-5">
|
||||||
<ManualScrapeQueryBar
|
<ManualScrapeQueryBar
|
||||||
query={query}
|
query={query}
|
||||||
searching={searching}
|
searching={searching}
|
||||||
@@ -170,7 +170,7 @@ export function ManualScrapeCandidateList({
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="grid gap-3">
|
<div className="grid min-w-0 gap-3">
|
||||||
{items.map((item) => {
|
{items.map((item) => {
|
||||||
const key = candidateKey(item)
|
const key = candidateKey(item)
|
||||||
return (
|
return (
|
||||||
@@ -199,7 +199,7 @@ function ManualScrapeCandidateRow({
|
|||||||
onApply: (item: ManualScrapeCandidate) => void
|
onApply: (item: ManualScrapeCandidate) => void
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-4 rounded-xl border border-sand-200 bg-white p-3 shadow-sm sm:flex-row">
|
<div className="flex min-w-0 max-w-full flex-col gap-4 overflow-hidden rounded-xl border border-sand-200 bg-white p-3 shadow-sm sm:flex-row">
|
||||||
<div className="h-28 w-20 shrink-0 overflow-hidden rounded-lg bg-sand-100">
|
<div className="h-28 w-20 shrink-0 overflow-hidden rounded-lg bg-sand-100">
|
||||||
{item.poster_url ? (
|
{item.poster_url ? (
|
||||||
<img src={imageURL(item.poster_url)} alt={item.title} loading="lazy" decoding="async" className="h-full w-full object-cover" referrerPolicy="no-referrer" />
|
<img src={imageURL(item.poster_url)} alt={item.title} loading="lazy" decoding="async" className="h-full w-full object-cover" referrerPolicy="no-referrer" />
|
||||||
@@ -208,14 +208,14 @@ function ManualScrapeCandidateRow({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="min-w-0 flex-1">
|
<div className="min-w-0 flex-1">
|
||||||
<div className="flex flex-wrap items-center gap-2">
|
<div className="flex min-w-0 flex-wrap items-center gap-2">
|
||||||
<h3 className="truncate font-semibold text-ink-600">{item.title}</h3>
|
<h3 className="min-w-0 max-w-full flex-1 truncate font-semibold text-ink-600">{item.title}</h3>
|
||||||
<span className="rounded-full bg-brand-50 px-2 py-0.5 text-[11px] font-bold uppercase text-brand-700">{item.source}</span>
|
<span className="shrink-0 rounded-full bg-brand-50 px-2 py-0.5 text-[11px] font-bold uppercase text-brand-700">{item.source}</span>
|
||||||
{item.nsfw ? <span className="rounded-full bg-rose-50 px-2 py-0.5 text-[11px] font-bold text-rose-600">成人</span> : null}
|
{item.nsfw ? <span className="shrink-0 rounded-full bg-rose-50 px-2 py-0.5 text-[11px] font-bold text-rose-600">成人</span> : null}
|
||||||
{item.year ? <span className="text-xs text-sand-500">{item.year}</span> : null}
|
{item.year ? <span className="shrink-0 text-xs text-sand-500">{item.year}</span> : null}
|
||||||
</div>
|
</div>
|
||||||
<p className="mt-1 line-clamp-2 text-xs leading-relaxed text-ink-50">{item.overview || '暂无简介'}</p>
|
<p className="mt-1 line-clamp-2 text-xs leading-relaxed text-ink-50">{item.overview || '暂无简介'}</p>
|
||||||
<p className="mt-2 text-[11px] font-semibold text-sand-500">{candidateIDText(item)}</p>
|
<p className="mt-2 break-words text-[11px] font-semibold text-sand-500">{candidateIDText(item)}</p>
|
||||||
</div>
|
</div>
|
||||||
<button onClick={() => onApply(item)} disabled={disabled} className="btn-outline h-10 w-full shrink-0 justify-center px-3 text-xs sm:w-auto sm:self-center">
|
<button onClick={() => onApply(item)} disabled={disabled} className="btn-outline h-10 w-full shrink-0 justify-center px-3 text-xs sm:w-auto sm:self-center">
|
||||||
{applying ? <LoaderCircle size={14} className="animate-spin" /> : <Check size={14} />}
|
{applying ? <LoaderCircle size={14} className="animate-spin" /> : <Check size={14} />}
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import { Layers } from 'lucide-react'
|
import { Cloud, Layers } from 'lucide-react'
|
||||||
import { Link } from 'react-router-dom'
|
import { Link } from 'react-router-dom'
|
||||||
|
|
||||||
import type { Media } from '../types'
|
import type { Media } from '../types'
|
||||||
import { mediaVersionLabel, mediaVersionsOf } from '../utils/mediaVersion'
|
import { isStrmMedia, mediaVersionLabel, mediaVersionsOf } from '../utils/mediaVersion'
|
||||||
|
|
||||||
type MediaVersionSwitcherProps = {
|
type MediaVersionSwitcherProps = {
|
||||||
media: Media
|
media: Media
|
||||||
@@ -31,14 +31,30 @@ export function MediaVersionSwitcher({
|
|||||||
<div className="flex flex-wrap gap-2">
|
<div className="flex flex-wrap gap-2">
|
||||||
{versions.map((version) => {
|
{versions.map((version) => {
|
||||||
const active = version.id === media.id
|
const active = version.id === media.id
|
||||||
|
const isStrm = isStrmMedia(version)
|
||||||
const label = mediaVersionLabel(version)
|
const label = mediaVersionLabel(version)
|
||||||
const className = dark
|
const btnClass = dark
|
||||||
? active
|
? active
|
||||||
? 'border-white/40 bg-white/20 text-white'
|
? 'border-white/50 bg-white/20 text-white shadow-sm ring-1 ring-white/20'
|
||||||
: 'border-white/15 bg-white/5 text-white/80 hover:bg-white/15'
|
: 'border-white/15 bg-white/5 text-white/80 hover:bg-white/15 hover:text-white'
|
||||||
: active
|
: active
|
||||||
? 'border-brand-500/40 bg-brand-50 text-[#b07d35]'
|
? 'border-brand-500/40 bg-brand-50 text-[#b07d35] font-bold shadow-sm'
|
||||||
: 'border-gray-200 bg-white text-ink-100 hover:border-brand-500/30 hover:bg-brand-50/40'
|
: 'border-gray-200 bg-white text-ink-100 hover:border-brand-500/30 hover:bg-brand-50/40'
|
||||||
|
|
||||||
|
const content = (
|
||||||
|
<span className="inline-flex items-center gap-1.5">
|
||||||
|
{isStrm && (
|
||||||
|
<Cloud
|
||||||
|
size={12}
|
||||||
|
className={dark ? (active ? 'text-brand-300' : 'text-white/60') : (active ? 'text-brand-500' : 'text-sand-400')}
|
||||||
|
aria-label="云端 / STRM"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<span>{label}</span>
|
||||||
|
{!dark && active ? <span className="opacity-75">· 当前</span> : null}
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
|
||||||
if (mode === 'player') {
|
if (mode === 'player') {
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
@@ -46,10 +62,10 @@ export function MediaVersionSwitcher({
|
|||||||
type="button"
|
type="button"
|
||||||
disabled={active}
|
disabled={active}
|
||||||
onClick={() => onSelect?.(version)}
|
onClick={() => onSelect?.(version)}
|
||||||
className={`rounded-xl border px-3 py-1.5 text-xs font-semibold transition disabled:cursor-default ${className}`}
|
className={`rounded-xl border px-3 py-1.5 text-xs font-semibold transition disabled:cursor-default ${btnClass}`}
|
||||||
title={version.path}
|
title={version.path || version.strm_url}
|
||||||
>
|
>
|
||||||
{label}
|
{content}
|
||||||
</button>
|
</button>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -58,11 +74,10 @@ export function MediaVersionSwitcher({
|
|||||||
key={version.id}
|
key={version.id}
|
||||||
to={`/play/${version.id}`}
|
to={`/play/${version.id}`}
|
||||||
state={{ from: `/media/${media.id}` }}
|
state={{ from: `/media/${media.id}` }}
|
||||||
className={`rounded-xl border px-3 py-1.5 text-xs font-semibold transition ${className}`}
|
className={`rounded-xl border px-3 py-1.5 text-xs font-semibold transition ${btnClass}`}
|
||||||
title={version.path}
|
title={version.path || version.strm_url}
|
||||||
>
|
>
|
||||||
{label}
|
{content}
|
||||||
{active ? ' · 当前' : ''}
|
|
||||||
</Link>
|
</Link>
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { Check, Film, ListVideo, Play, Search, X } from 'lucide-react'
|
|||||||
|
|
||||||
import { imageURL } from '../api/client'
|
import { imageURL } from '../api/client'
|
||||||
import type { Media } from '../types'
|
import type { Media } from '../types'
|
||||||
import { seriesTitleFromPath } from '../utils/groupSeries'
|
import { seasonLabel, seasonSortOrder, seriesTitleFromPath } from '../utils/groupSeries'
|
||||||
|
|
||||||
export type SeasonGroup = {
|
export type SeasonGroup = {
|
||||||
season: number
|
season: number
|
||||||
@@ -42,7 +42,7 @@ export function PlayerPlaylistPanel({
|
|||||||
list.sort((a, b) => (a.episode_num || 0) - (b.episode_num || 0))
|
list.sort((a, b) => (a.episode_num || 0) - (b.episode_num || 0))
|
||||||
}
|
}
|
||||||
return Array.from(seasonsMap.entries())
|
return Array.from(seasonsMap.entries())
|
||||||
.sort(([a], [b]) => a - b)
|
.sort(([a], [b]) => seasonSortOrder(a) - seasonSortOrder(b))
|
||||||
.map(([season, list]) => ({ season, episodes: list }))
|
.map(([season, list]) => ({ season, episodes: list }))
|
||||||
}, [episodes])
|
}, [episodes])
|
||||||
|
|
||||||
@@ -128,7 +128,7 @@ export function PlayerPlaylistPanel({
|
|||||||
: 'bg-white/5 text-white/70 hover:bg-white/10 hover:text-white'
|
: 'bg-white/5 text-white/70 hover:bg-white/10 hover:text-white'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<span>{season === 0 ? '特别篇' : `第 ${season} 季`}</span>
|
<span>{seasonLabel(season)}</span>
|
||||||
<span className="text-[10px] opacity-75">({sesEps.length})</span>
|
<span className="text-[10px] opacity-75">({sesEps.length})</span>
|
||||||
{isPlayingThisSeason && !isSelected && (
|
{isPlayingThisSeason && !isSelected && (
|
||||||
<span className="h-1.5 w-1.5 rounded-full bg-rose-400" />
|
<span className="h-1.5 w-1.5 rounded-full bg-rose-400" />
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { motion } from 'framer-motion'
|
|||||||
import { historyAPI } from '../api/history'
|
import { historyAPI } from '../api/history'
|
||||||
import type { Media } from '../types'
|
import type { Media } from '../types'
|
||||||
import { useAuthStore } from '../stores/auth'
|
import { useAuthStore } from '../stores/auth'
|
||||||
import type { SeriesCard } from '../utils/groupSeries'
|
import { isTheatricalFeature, type SeriesCard } from '../utils/groupSeries'
|
||||||
import {
|
import {
|
||||||
sortMediaList,
|
sortMediaList,
|
||||||
sortSeriesList,
|
sortSeriesList,
|
||||||
@@ -150,6 +150,9 @@ export function LibraryPage() {
|
|||||||
setSelectedSeason,
|
setSelectedSeason,
|
||||||
onClearSeriesState: () => setSeriesMetadataEditOpen(false),
|
onClearSeriesState: () => setSeriesMetadataEditOpen(false),
|
||||||
})
|
})
|
||||||
|
const selectedSeriesScrapeMedia = selectedSeriesEpisodes.find((media) => !isTheatricalFeature(media))
|
||||||
|
?? selectedSeries?.rep
|
||||||
|
?? null
|
||||||
|
|
||||||
const {
|
const {
|
||||||
scraping,
|
scraping,
|
||||||
@@ -287,6 +290,7 @@ export function LibraryPage() {
|
|||||||
onOrganize={handleSeriesOrganize}
|
onOrganize={handleSeriesOrganize}
|
||||||
onDelete={handleSeriesDelete}
|
onDelete={handleSeriesDelete}
|
||||||
onSeasonChange={setSelectedSeason}
|
onSeasonChange={setSelectedSeason}
|
||||||
|
onManualScrapeMedia={setManualMovie}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<LibraryPageDialogs
|
<LibraryPageDialogs
|
||||||
@@ -296,6 +300,7 @@ export function LibraryPage() {
|
|||||||
seriesMetadataEditOpen={seriesMetadataEditOpen}
|
seriesMetadataEditOpen={seriesMetadataEditOpen}
|
||||||
manualMovie={manualMovie}
|
manualMovie={manualMovie}
|
||||||
selectedSeries={selectedSeries}
|
selectedSeries={selectedSeries}
|
||||||
|
selectedSeriesScrapeMedia={selectedSeriesScrapeMedia}
|
||||||
selectedSeriesMediaIDs={selectedSeriesMediaIDs}
|
selectedSeriesMediaIDs={selectedSeriesMediaIDs}
|
||||||
libraryType={library?.type}
|
libraryType={library?.type}
|
||||||
scrapeEpisodeArtwork={scrapeEpisodeArtwork}
|
scrapeEpisodeArtwork={scrapeEpisodeArtwork}
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { ManualScrapeDialog } from '../components/ManualScrapeDialog'
|
|||||||
import { MetadataEditDialog } from '../components/MetadataEditDialog'
|
import { MetadataEditDialog } from '../components/MetadataEditDialog'
|
||||||
import { ScrapeMetadataDialog } from '../components/ScrapeMetadataDialog'
|
import { ScrapeMetadataDialog } from '../components/ScrapeMetadataDialog'
|
||||||
import type { Library, Media } from '../types'
|
import type { Library, Media } from '../types'
|
||||||
import { seriesTitle, type SeriesCard } from '../utils/groupSeries'
|
import { isTheatricalFeature, seriesTitle, type SeriesCard } from '../utils/groupSeries'
|
||||||
|
|
||||||
type LibraryPageDialogsProps = {
|
type LibraryPageDialogsProps = {
|
||||||
scrapeDialogOpen: boolean
|
scrapeDialogOpen: boolean
|
||||||
@@ -11,6 +11,7 @@ type LibraryPageDialogsProps = {
|
|||||||
seriesMetadataEditOpen: boolean
|
seriesMetadataEditOpen: boolean
|
||||||
manualMovie: Media | null
|
manualMovie: Media | null
|
||||||
selectedSeries: SeriesCard | null
|
selectedSeries: SeriesCard | null
|
||||||
|
selectedSeriesScrapeMedia: Media | null
|
||||||
selectedSeriesMediaIDs: string[]
|
selectedSeriesMediaIDs: string[]
|
||||||
libraryType?: string
|
libraryType?: string
|
||||||
scrapeEpisodeArtwork: boolean
|
scrapeEpisodeArtwork: boolean
|
||||||
@@ -29,6 +30,7 @@ export function LibraryPageDialogs({
|
|||||||
seriesMetadataEditOpen,
|
seriesMetadataEditOpen,
|
||||||
manualMovie,
|
manualMovie,
|
||||||
selectedSeries,
|
selectedSeries,
|
||||||
|
selectedSeriesScrapeMedia,
|
||||||
selectedSeriesMediaIDs,
|
selectedSeriesMediaIDs,
|
||||||
libraryType,
|
libraryType,
|
||||||
scrapeEpisodeArtwork,
|
scrapeEpisodeArtwork,
|
||||||
@@ -53,10 +55,10 @@ export function LibraryPageDialogs({
|
|||||||
/>
|
/>
|
||||||
<ManualScrapeDialog
|
<ManualScrapeDialog
|
||||||
open={manualSeriesScrapeOpen}
|
open={manualSeriesScrapeOpen}
|
||||||
media={selectedSeries?.rep ?? null}
|
media={selectedSeriesScrapeMedia}
|
||||||
mediaIds={selectedSeriesMediaIDs}
|
mediaIds={selectedSeriesMediaIDs}
|
||||||
defaultQuery={selectedSeriesTitle}
|
defaultQuery={selectedSeriesTitle}
|
||||||
mediaType={selectedSeries ? scrapeMediaType(libraryType, selectedSeries.rep) : 'tv'}
|
mediaType="tv"
|
||||||
scopeLabel={selectedSeriesTitle || '当前剧集'}
|
scopeLabel={selectedSeriesTitle || '当前剧集'}
|
||||||
episodeArtwork={scrapeEpisodeArtwork}
|
episodeArtwork={scrapeEpisodeArtwork}
|
||||||
onClose={onCloseManualSeriesScrape}
|
onClose={onCloseManualSeriesScrape}
|
||||||
@@ -86,6 +88,9 @@ export function LibraryPageDialogs({
|
|||||||
}
|
}
|
||||||
|
|
||||||
function scrapeMediaType(libraryType: string | undefined, media: Media): string {
|
function scrapeMediaType(libraryType: string | undefined, media: Media): string {
|
||||||
|
if (isTheatricalFeature(media)) {
|
||||||
|
return 'movie'
|
||||||
|
}
|
||||||
if ((media.season_num ?? 0) > 0 || (media.episode_num ?? 0) > 0) {
|
if ((media.season_num ?? 0) > 0 || (media.episode_num ?? 0) > 0) {
|
||||||
return 'tv'
|
return 'tv'
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,11 +6,10 @@ import { imageURL } from '../api/client'
|
|||||||
import { ExternalPlayerButton } from '../components/ExternalPlayerButton'
|
import { ExternalPlayerButton } from '../components/ExternalPlayerButton'
|
||||||
import { MediaFavouriteButton } from '../components/MediaFavouriteButton'
|
import { MediaFavouriteButton } from '../components/MediaFavouriteButton'
|
||||||
import type { Media } from '../types'
|
import type { Media } from '../types'
|
||||||
import { seriesTitle, type SeriesCard } from '../utils/groupSeries'
|
import { isTheatricalFeature, seriesTitle, type SeriesCard } from '../utils/groupSeries'
|
||||||
|
|
||||||
type LibrarySeriesDetailHeaderProps = {
|
type LibrarySeriesDetailHeaderProps = {
|
||||||
series: SeriesCard
|
series: SeriesCard
|
||||||
visibleEpisodes: Media[]
|
|
||||||
allEpisodes: Media[]
|
allEpisodes: Media[]
|
||||||
playbackFrom: string
|
playbackFrom: string
|
||||||
isAdmin: boolean
|
isAdmin: boolean
|
||||||
@@ -31,7 +30,6 @@ type LibrarySeriesDetailHeaderProps = {
|
|||||||
|
|
||||||
export function LibrarySeriesDetailHeader({
|
export function LibrarySeriesDetailHeader({
|
||||||
series,
|
series,
|
||||||
visibleEpisodes,
|
|
||||||
allEpisodes,
|
allEpisodes,
|
||||||
playbackFrom,
|
playbackFrom,
|
||||||
isAdmin,
|
isAdmin,
|
||||||
@@ -49,7 +47,9 @@ export function LibrarySeriesDetailHeader({
|
|||||||
onOrganize,
|
onOrganize,
|
||||||
onDelete,
|
onDelete,
|
||||||
}: LibrarySeriesDetailHeaderProps) {
|
}: LibrarySeriesDetailHeaderProps) {
|
||||||
const firstEpisode = firstPlayableEpisode(visibleEpisodes.length > 0 ? visibleEpisodes : allEpisodes)
|
const tvEpisodes = allEpisodes.filter((media) => !isTheatricalFeature(media))
|
||||||
|
const theatricalCount = allEpisodes.length - tvEpisodes.length
|
||||||
|
const firstEpisode = firstPlayableEpisode(tvEpisodes)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -61,7 +61,9 @@ export function LibrarySeriesDetailHeader({
|
|||||||
<h2 className="truncate font-display text-2xl font-bold text-ink-600">
|
<h2 className="truncate font-display text-2xl font-bold text-ink-600">
|
||||||
{seriesTitle(series.rep)}
|
{seriesTitle(series.rep)}
|
||||||
</h2>
|
</h2>
|
||||||
<span className="text-sm text-sand-500">共 {series.count} 集</span>
|
<span className="text-sm text-sand-500">
|
||||||
|
共 {tvEpisodes.length} 集{theatricalCount > 0 ? ` · ${theatricalCount} 部剧场版` : ''}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex flex-col gap-6 sm:flex-row">
|
<div className="flex flex-col gap-6 sm:flex-row">
|
||||||
@@ -115,7 +117,7 @@ export function LibrarySeriesDetailHeader({
|
|||||||
</button>
|
</button>
|
||||||
<button onClick={onManualScrape} disabled={!!seriesToolBusy} className="btn-outline px-3.5 py-2 text-xs gap-1.5">
|
<button onClick={onManualScrape} disabled={!!seriesToolBusy} className="btn-outline px-3.5 py-2 text-xs gap-1.5">
|
||||||
<Search size={13} className="text-[#c9954a]" />
|
<Search size={13} className="text-[#c9954a]" />
|
||||||
<span>手动匹配整剧</span>
|
<span>{theatricalCount > 0 ? '手动匹配 TV 版' : '手动匹配整剧'}</span>
|
||||||
</button>
|
</button>
|
||||||
<button onClick={onMetadataEdit} disabled={!!seriesToolBusy} className="btn-outline px-3.5 py-2 text-xs gap-1.5">
|
<button onClick={onMetadataEdit} disabled={!!seriesToolBusy} className="btn-outline px-3.5 py-2 text-xs gap-1.5">
|
||||||
<Pencil size={13} />
|
<Pencil size={13} />
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ type LibrarySeriesDetailSectionProps = {
|
|||||||
onOrganize: () => void
|
onOrganize: () => void
|
||||||
onDelete: () => void
|
onDelete: () => void
|
||||||
onSeasonChange: (season: number) => void
|
onSeasonChange: (season: number) => void
|
||||||
|
onManualScrapeMedia: (media: Media) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
export function LibrarySeriesDetailSection({
|
export function LibrarySeriesDetailSection({
|
||||||
@@ -58,6 +59,7 @@ export function LibrarySeriesDetailSection({
|
|||||||
onOrganize,
|
onOrganize,
|
||||||
onDelete,
|
onDelete,
|
||||||
onSeasonChange,
|
onSeasonChange,
|
||||||
|
onManualScrapeMedia,
|
||||||
}: LibrarySeriesDetailSectionProps) {
|
}: LibrarySeriesDetailSectionProps) {
|
||||||
return (
|
return (
|
||||||
<AnimatePresence mode="wait">
|
<AnimatePresence mode="wait">
|
||||||
@@ -70,7 +72,6 @@ export function LibrarySeriesDetailSection({
|
|||||||
>
|
>
|
||||||
<LibrarySeriesDetailHeader
|
<LibrarySeriesDetailHeader
|
||||||
series={selectedSeries}
|
series={selectedSeries}
|
||||||
visibleEpisodes={visibleEpisodes}
|
|
||||||
allEpisodes={allEpisodes}
|
allEpisodes={allEpisodes}
|
||||||
playbackFrom={playbackFrom}
|
playbackFrom={playbackFrom}
|
||||||
isAdmin={isAdmin}
|
isAdmin={isAdmin}
|
||||||
@@ -95,7 +96,9 @@ export function LibrarySeriesDetailSection({
|
|||||||
selectedSeason={selectedSeason}
|
selectedSeason={selectedSeason}
|
||||||
visibleEpisodes={visibleEpisodes}
|
visibleEpisodes={visibleEpisodes}
|
||||||
playbackFrom={playbackFrom}
|
playbackFrom={playbackFrom}
|
||||||
|
isAdmin={isAdmin}
|
||||||
onSeasonChange={onSeasonChange}
|
onSeasonChange={onSeasonChange}
|
||||||
|
onManualScrape={onManualScrapeMedia}
|
||||||
/>
|
/>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
)}
|
)}
|
||||||
@@ -109,7 +112,9 @@ type LibrarySeriesEpisodesPanelProps = {
|
|||||||
selectedSeason: number | null
|
selectedSeason: number | null
|
||||||
visibleEpisodes: Media[]
|
visibleEpisodes: Media[]
|
||||||
playbackFrom: string
|
playbackFrom: string
|
||||||
|
isAdmin: boolean
|
||||||
onSeasonChange: (season: number) => void
|
onSeasonChange: (season: number) => void
|
||||||
|
onManualScrape: (media: Media) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
function LibrarySeriesEpisodesPanel({
|
function LibrarySeriesEpisodesPanel({
|
||||||
@@ -118,7 +123,9 @@ function LibrarySeriesEpisodesPanel({
|
|||||||
selectedSeason,
|
selectedSeason,
|
||||||
visibleEpisodes,
|
visibleEpisodes,
|
||||||
playbackFrom,
|
playbackFrom,
|
||||||
|
isAdmin,
|
||||||
onSeasonChange,
|
onSeasonChange,
|
||||||
|
onManualScrape,
|
||||||
}: LibrarySeriesEpisodesPanelProps) {
|
}: LibrarySeriesEpisodesPanelProps) {
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
@@ -128,7 +135,9 @@ function LibrarySeriesEpisodesPanel({
|
|||||||
selectedSeason={selectedSeason}
|
selectedSeason={selectedSeason}
|
||||||
visibleEpisodes={visibleEpisodes}
|
visibleEpisodes={visibleEpisodes}
|
||||||
playbackFrom={playbackFrom}
|
playbackFrom={playbackFrom}
|
||||||
|
isAdmin={isAdmin}
|
||||||
onSeasonChange={onSeasonChange}
|
onSeasonChange={onSeasonChange}
|
||||||
|
onManualScrape={onManualScrape}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,10 +1,15 @@
|
|||||||
import { Link } from 'react-router-dom'
|
import { Link } from 'react-router-dom'
|
||||||
import { Play } from 'lucide-react'
|
import { Play, Search } from 'lucide-react'
|
||||||
|
|
||||||
import { imageURL } from '../api/client'
|
import { imageURL } from '../api/client'
|
||||||
import { ExternalPlayerButton } from '../components/ExternalPlayerButton'
|
import { ExternalPlayerButton } from '../components/ExternalPlayerButton'
|
||||||
import type { Media } from '../types'
|
import type { Media } from '../types'
|
||||||
import { seriesTitleFromPath } from '../utils/groupSeries'
|
import {
|
||||||
|
isTheatricalFeature,
|
||||||
|
seasonLabel,
|
||||||
|
seriesTitleFromPath,
|
||||||
|
THEATRICAL_SEASON,
|
||||||
|
} from '../utils/groupSeries'
|
||||||
import { formatSize } from './libraryPageModel'
|
import { formatSize } from './libraryPageModel'
|
||||||
|
|
||||||
type SeasonGroup = {
|
type SeasonGroup = {
|
||||||
@@ -18,7 +23,9 @@ type LibrarySeriesEpisodesProps = {
|
|||||||
selectedSeason: number | null
|
selectedSeason: number | null
|
||||||
visibleEpisodes: Media[]
|
visibleEpisodes: Media[]
|
||||||
playbackFrom: string
|
playbackFrom: string
|
||||||
|
isAdmin?: boolean
|
||||||
onSeasonChange: (season: number) => void
|
onSeasonChange: (season: number) => void
|
||||||
|
onManualScrape?: (media: Media) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
export function LibrarySeriesEpisodes({
|
export function LibrarySeriesEpisodes({
|
||||||
@@ -27,7 +34,9 @@ export function LibrarySeriesEpisodes({
|
|||||||
selectedSeason,
|
selectedSeason,
|
||||||
visibleEpisodes,
|
visibleEpisodes,
|
||||||
playbackFrom,
|
playbackFrom,
|
||||||
|
isAdmin = false,
|
||||||
onSeasonChange,
|
onSeasonChange,
|
||||||
|
onManualScrape,
|
||||||
}: LibrarySeriesEpisodesProps) {
|
}: LibrarySeriesEpisodesProps) {
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
@@ -53,18 +62,19 @@ export function LibrarySeriesEpisodes({
|
|||||||
: 'border-sand-200 bg-white text-ink-100 hover:border-brand-200 hover:text-brand-600')
|
: 'border-sand-200 bg-white text-ink-100 hover:border-brand-200 hover:text-brand-600')
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
{season === 0 ? '特别篇' : `第 ${season} 季`} · {episodes.length} 集
|
{seasonLabel(season)} · {episodes.length} {season === THEATRICAL_SEASON ? '部' : '集'}
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<h3 className="mb-3 font-display text-lg font-semibold text-ink-600">
|
<h3 className="mb-3 font-display text-lg font-semibold text-ink-600">
|
||||||
{displaySeason === 0 ? '特别篇' : `第 ${displaySeason} 季`}
|
{seasonLabel(displaySeason)}
|
||||||
</h3>
|
</h3>
|
||||||
<div className="grid grid-cols-1 gap-2.5 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 2xl:grid-cols-5">
|
<div className="grid grid-cols-1 gap-2.5 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 2xl:grid-cols-5">
|
||||||
{visibleEpisodes.map((ep) => {
|
{visibleEpisodes.map((ep) => {
|
||||||
const displayTitle = episodeDisplayTitle(ep, visibleEpisodes)
|
const displayTitle = episodeDisplayTitle(ep, visibleEpisodes)
|
||||||
|
const versionCount = ep.versions?.length ?? 1
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={ep.id}
|
key={ep.id}
|
||||||
@@ -102,10 +112,24 @@ export function LibrarySeriesEpisodes({
|
|||||||
{ep.duration_sec > 0
|
{ep.duration_sec > 0
|
||||||
? `${Math.floor(ep.duration_sec / 60)} 分钟`
|
? `${Math.floor(ep.duration_sec / 60)} 分钟`
|
||||||
: formatSize(ep.size_bytes)}
|
: formatSize(ep.size_bytes)}
|
||||||
|
{versionCount > 1 ? ` · ${versionCount} 版本` : ''}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</Link>
|
</Link>
|
||||||
<ExternalPlayerButton mediaId={ep.id} label="外部" compact />
|
<div className="flex shrink-0 items-center gap-1">
|
||||||
|
{isAdmin && onManualScrape && isTheatricalFeature(ep) && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => onManualScrape(ep)}
|
||||||
|
className="btn-outline !px-2 !py-1.5 text-[11px]"
|
||||||
|
title="手动匹配剧场版"
|
||||||
|
>
|
||||||
|
<Search size={12} />
|
||||||
|
匹配
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<ExternalPlayerButton mediaId={ep.id} label="外部" compact />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
|
|||||||
@@ -237,8 +237,22 @@ export function PlayerPage() {
|
|||||||
// 404 / 无权限等:给出可见错误提示,避免永久「加载中」
|
// 404 / 无权限等:给出可见错误提示,避免永久「加载中」
|
||||||
setLoadError(`无法加载该媒体:${apiErrorMessage(err)}`)
|
setLoadError(`无法加载该媒体:${apiErrorMessage(err)}`)
|
||||||
})
|
})
|
||||||
|
return () => {
|
||||||
|
cancelled = true
|
||||||
|
}
|
||||||
|
}, [id, modeParam, directOnly])
|
||||||
|
|
||||||
|
// Wire up the actual <video> element when we know the mode.
|
||||||
|
// Depend on media.id (not the media object): refreshing duration after
|
||||||
|
// MANIFEST_PARSED must not remount HLS or it storms EnsureJob / DELETE.
|
||||||
|
const mediaId = media?.id
|
||||||
|
|
||||||
|
// 直连播放只发现外挂字幕;只有 HLS 模式需要探测可烧录的内嵌字幕。
|
||||||
|
useEffect(() => {
|
||||||
|
if (!mediaId) return
|
||||||
|
let cancelled = false
|
||||||
subtitlesAPI
|
subtitlesAPI
|
||||||
.list(id)
|
.list(mediaId, mode === 'hls')
|
||||||
.then((tracks) => {
|
.then((tracks) => {
|
||||||
if (cancelled) return
|
if (cancelled) return
|
||||||
const list = tracks ?? []
|
const list = tracks ?? []
|
||||||
@@ -249,16 +263,13 @@ export function PlayerPage() {
|
|||||||
.catch(() => {
|
.catch(() => {
|
||||||
if (cancelled) return
|
if (cancelled) return
|
||||||
setSubs([])
|
setSubs([])
|
||||||
|
setSubtitleIndex(-1)
|
||||||
})
|
})
|
||||||
return () => {
|
return () => {
|
||||||
cancelled = true
|
cancelled = true
|
||||||
}
|
}
|
||||||
}, [id, modeParam, directOnly])
|
}, [mediaId, mode])
|
||||||
|
|
||||||
// Wire up the actual <video> element when we know the mode.
|
|
||||||
// Depend on media.id (not the media object): refreshing duration after
|
|
||||||
// MANIFEST_PARSED must not remount HLS or it storms EnsureJob / DELETE.
|
|
||||||
const mediaId = media?.id
|
|
||||||
const selectedSubtitle = subtitleIndex >= 0 ? subs[subtitleIndex] : undefined
|
const selectedSubtitle = subtitleIndex >= 0 ? subs[subtitleIndex] : undefined
|
||||||
const burnedSubtitleStream =
|
const burnedSubtitleStream =
|
||||||
selectedSubtitle?.delivery === 'burn' ? selectedSubtitle.stream_index : undefined
|
selectedSubtitle?.delivery === 'burn' ? selectedSubtitle.stream_index : undefined
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ export function seriesSourceRoot(episodes: Media[]): string {
|
|||||||
if (!firstPath) return ''
|
if (!firstPath) return ''
|
||||||
const dir = dirname(firstPath)
|
const dir = dirname(firstPath)
|
||||||
const base = basename(dir)
|
const base = basename(dir)
|
||||||
if (/^(?:s\d{1,2}|season[\s._-]*\d{1,2}|第\s*\d{1,2}\s*季|specials?|sp|ova|oad|extra|extras|特别篇|特別篇|番外|特典)$/i.test(base)) {
|
if (/^(?:s\d{1,2}|season[\s._-]*\d{1,2}|第\s*\d{1,2}\s*季|specials?|sp|ovas?|oads?|ovds?|onas?|extras?|bonus(?:es)?|omake|picture[\s._-]*drama|ncop|nced|特别篇|特別篇|番外|特典|画像特典)$/i.test(base)) {
|
||||||
return dirname(dir)
|
return dirname(dir)
|
||||||
}
|
}
|
||||||
return dir
|
return dir
|
||||||
|
|||||||
@@ -1,7 +1,13 @@
|
|||||||
import { useEffect, useMemo } from 'react'
|
import { useEffect, useMemo } from 'react'
|
||||||
|
|
||||||
import type { Media } from '../types'
|
import type { Media } from '../types'
|
||||||
import { getSeriesKey, type SeriesCard } from '../utils/groupSeries'
|
import {
|
||||||
|
getSeriesKey,
|
||||||
|
isTheatricalFeature,
|
||||||
|
seasonSortOrder,
|
||||||
|
specialSectionForMedia,
|
||||||
|
type SeriesCard,
|
||||||
|
} from '../utils/groupSeries'
|
||||||
|
|
||||||
type SeasonEpisodes = {
|
type SeasonEpisodes = {
|
||||||
season: number
|
season: number
|
||||||
@@ -47,7 +53,9 @@ export function useLibrarySeriesSelection({
|
|||||||
: sourceItems.filter((m) => getSeriesKey(m) === selectedSeries.key)
|
: sourceItems.filter((m) => getSeriesKey(m) === selectedSeries.key)
|
||||||
const seasons = new Map<number, Media[]>()
|
const seasons = new Map<number, Media[]>()
|
||||||
for (const ep of eps) {
|
for (const ep of eps) {
|
||||||
const s = ep.episode_num > 0 ? (ep.season_num ?? 0) : (ep.season_num || 1)
|
const specialSection = specialSectionForMedia(ep)
|
||||||
|
const s = specialSection ??
|
||||||
|
(ep.episode_num > 0 ? (ep.season_num ?? 0) : (ep.season_num || 1))
|
||||||
if (!seasons.has(s)) seasons.set(s, [])
|
if (!seasons.has(s)) seasons.set(s, [])
|
||||||
seasons.get(s)!.push(ep)
|
seasons.get(s)!.push(ep)
|
||||||
}
|
}
|
||||||
@@ -55,7 +63,7 @@ export function useLibrarySeriesSelection({
|
|||||||
list.sort((a, b) => (a.episode_num || 0) - (b.episode_num || 0))
|
list.sort((a, b) => (a.episode_num || 0) - (b.episode_num || 0))
|
||||||
}
|
}
|
||||||
return Array.from(seasons.entries())
|
return Array.from(seasons.entries())
|
||||||
.sort(([a], [b]) => a - b)
|
.sort(([a], [b]) => seasonSortOrder(a) - seasonSortOrder(b))
|
||||||
.map(([season, episodes]) => ({ season, episodes }))
|
.map(([season, episodes]) => ({ season, episodes }))
|
||||||
}, [isSeriesLibrary, selectedSeries, items, seriesEpisodeItems])
|
}, [isSeriesLibrary, selectedSeries, items, seriesEpisodeItems])
|
||||||
|
|
||||||
@@ -70,7 +78,7 @@ export function useLibrarySeriesSelection({
|
|||||||
)
|
)
|
||||||
|
|
||||||
const selectedSeriesMediaIDs = useMemo(
|
const selectedSeriesMediaIDs = useMemo(
|
||||||
() => selectedSeriesEpisodes.map((ep) => ep.id),
|
() => selectedSeriesEpisodes.filter((ep) => !isTheatricalFeature(ep)).map((ep) => ep.id),
|
||||||
[selectedSeriesEpisodes],
|
[selectedSeriesEpisodes],
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import { playbackAPI } from '../api/playback'
|
|||||||
import { confirmActionResult } from '../components/confirmAction'
|
import { confirmActionResult } from '../components/confirmAction'
|
||||||
import { useEpisodeArtworkPreference } from '../hooks/useEpisodeArtworkPreference'
|
import { useEpisodeArtworkPreference } from '../hooks/useEpisodeArtworkPreference'
|
||||||
import type { Media } from '../types'
|
import type { Media } from '../types'
|
||||||
|
import { seasonSortOrder } from '../utils/groupSeries'
|
||||||
import { mediaLibraryBackTarget } from './MediaDetailPageModel'
|
import { mediaLibraryBackTarget } from './MediaDetailPageModel'
|
||||||
|
|
||||||
interface MediaDetailPageStateParams {
|
interface MediaDetailPageStateParams {
|
||||||
@@ -77,7 +78,7 @@ export function useMediaDetailPageState({ id, navigate }: MediaDetailPageStatePa
|
|||||||
list.sort((a, b) => (a.episode_num || 0) - (b.episode_num || 0))
|
list.sort((a, b) => (a.episode_num || 0) - (b.episode_num || 0))
|
||||||
}
|
}
|
||||||
return Array.from(seasons.entries())
|
return Array.from(seasons.entries())
|
||||||
.sort(([a], [b]) => a - b)
|
.sort(([a], [b]) => seasonSortOrder(a) - seasonSortOrder(b))
|
||||||
.map(([season, list]) => ({ season, episodes: list }))
|
.map(([season, list]) => ({ season, episodes: list }))
|
||||||
}, [episodes])
|
}, [episodes])
|
||||||
|
|
||||||
|
|||||||
@@ -38,6 +38,18 @@ export type SeriesCard = {
|
|||||||
last_added_at?: string
|
last_added_at?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const THEATRICAL_SEASON = -1
|
||||||
|
export const OVA_SEASON = -2
|
||||||
|
export const OAD_SEASON = -3
|
||||||
|
export const OVD_SEASON = -4
|
||||||
|
export const ONA_SEASON = -5
|
||||||
|
export const EXTRA_SEASON = -6
|
||||||
|
export const BONUS_SEASON = -7
|
||||||
|
export const OMAKE_SEASON = -8
|
||||||
|
export const PICTURE_DRAMA_SEASON = -9
|
||||||
|
export const NCOP_SEASON = -10
|
||||||
|
export const NCED_SEASON = -11
|
||||||
|
|
||||||
export function getSeriesKey(media: Media): string {
|
export function getSeriesKey(media: Media): string {
|
||||||
return compactSeriesKey(getSeriesRawKey(media))
|
return compactSeriesKey(getSeriesRawKey(media))
|
||||||
}
|
}
|
||||||
@@ -90,16 +102,98 @@ export function isEpisodeLike(media: Media): boolean {
|
|||||||
// 剧集类目录名(电视剧/动漫及其二级分类)。媒体路径落在这些目录下时, 即便
|
// 剧集类目录名(电视剧/动漫及其二级分类)。媒体路径落在这些目录下时, 即便
|
||||||
// 季集号未识别出来, 也应按剧集对待, 跳转到 /library 分类视图而非 /media 单页。
|
// 季集号未识别出来, 也应按剧集对待, 跳转到 /library 分类视图而非 /media 单页。
|
||||||
const EPISODIC_PATH_RE =
|
const EPISODIC_PATH_RE =
|
||||||
/[\\/](?:电视剧|剧集|连续剧|短剧|国产剧|国剧|大陆剧|华语剧|国产电视剧|大陆电视剧|华语电视剧|欧美剧|欧美电视剧|美剧|英剧|日韩剧|日韩电视剧|日剧|韩剧|港剧|台剧|港台剧|泰剧|综艺|纪录片|儿童|动漫|番剧|国漫|日番|韩漫|美漫|欧美动漫|欧美动画|其他动漫|anime|tv|series|shows?|season[\s._-]*\d|s\d{1,2}(?:[\s._-]|[\\/])|special[\s._-]*episodes?|specials?|sp|ovas?|oads?|extras?|bonus(?:es)?|omake|特别篇|特別篇|番外篇?|特典|外传|外傳|总集篇|總集篇)[\\/]/i
|
/[\\/](?:电视剧|剧集|连续剧|短剧|国产剧|国剧|大陆剧|华语剧|国产电视剧|大陆电视剧|华语电视剧|欧美剧|欧美电视剧|美剧|英剧|日韩剧|日韩电视剧|日剧|韩剧|港剧|台剧|港台剧|泰剧|综艺|纪录片|儿童|动漫|番剧|国漫|日番|韩漫|美漫|欧美动漫|欧美动画|其他动漫|anime|tv|series|shows?|season[\s._-]*\d|s\d{1,2}(?:[\s._-]|[\\/])|special[\s._-]*episodes?|specials?|sp|ovas?|oads?|ovds?|onas?|extras?|bonus(?:es)?|omake|picture[\s._-]*drama|ncop|nced|特别篇|特別篇|番外篇?|特典|外传|外傳|总集篇|總集篇|画像特典)[\\/]/i
|
||||||
|
|
||||||
|
const THEATRICAL_TITLE_RE =
|
||||||
|
/(?:剧场版|劇場版|动画电影|動畫電影|电影版|電影版|\bthe\s+movie\b|\bmovie\s*\d{1,2}\b)/i
|
||||||
|
|
||||||
|
const THEATRICAL_FOLDER_NAME_RE =
|
||||||
|
/(?:剧场版|劇場版|动画电影|動畫電影|电影版|電影版)/i
|
||||||
|
|
||||||
|
const THEATRICAL_FOLDER_RE =
|
||||||
|
/[\\/][^\\/]*(?:剧场版|劇場版|动画电影|動畫電影|电影版|電影版)[^\\/]*[\\/]/
|
||||||
|
|
||||||
const SEASON_FOLDER_RE =
|
const SEASON_FOLDER_RE =
|
||||||
/^(?:s\d{1,2}|season[\s._-]*\d{1,2}|第\s*[0-9一二三四五六七八九十百零两]+\s*季|special[\s._-]*episodes?|specials?|sp|ovas?|oads?|extras?|bonus(?:es)?|omake|特别篇|特別篇|番外篇?|特典|外传|外傳|总集篇|總集篇)$/i
|
/^(?:s\d{1,2}|season[\s._-]*\d{1,2}|第\s*[0-9一二三四五六七八九十百零两]+\s*季|special[\s._-]*episodes?|specials?|sp|ovas?|oads?|ovds?|onas?|extras?|bonus(?:es)?|omake|picture[\s._-]*drama|ncop|nced|特别篇|特別篇|番外篇?|特典|外传|外傳|总集篇|總集篇|画像特典|剧场版|劇場版|动画电影|動畫電影)$/i
|
||||||
|
|
||||||
export function pathLooksEpisodic(media: Media): boolean {
|
export function pathLooksEpisodic(media: Media): boolean {
|
||||||
const path = (media.path || media.display_library_path || media.library_path || '')
|
const path = (media.path || media.display_library_path || media.library_path || '')
|
||||||
return EPISODIC_PATH_RE.test(path)
|
return EPISODIC_PATH_RE.test(path)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function isTheatricalFeature(media: Media): boolean {
|
||||||
|
const path = media.path || ''
|
||||||
|
if (SERIES_FILE_EPISODE_RE.test(path)) return false
|
||||||
|
return THEATRICAL_TITLE_RE.test(`${media.title || ''} ${path}`) || THEATRICAL_FOLDER_RE.test(path)
|
||||||
|
}
|
||||||
|
|
||||||
|
const SPECIAL_SECTION_PATTERNS: Array<[number, RegExp]> = [
|
||||||
|
[OVA_SEASON, /(?:^|[^a-z0-9])ovas?(?:[\s._-]*\d+)?(?:[^a-z0-9]|$)/i],
|
||||||
|
[OAD_SEASON, /(?:^|[^a-z0-9])oads?(?:[\s._-]*\d+)?(?:[^a-z0-9]|$)/i],
|
||||||
|
[OVD_SEASON, /(?:^|[^a-z0-9])ovds?(?:[\s._-]*\d+)?(?:[^a-z0-9]|$)/i],
|
||||||
|
[ONA_SEASON, /(?:^|[^a-z0-9])onas?(?:[\s._-]*\d+)?(?:[^a-z0-9]|$)/i],
|
||||||
|
[PICTURE_DRAMA_SEASON, /(?:^|[^a-z0-9])(?:picture[\s._-]*drama|画像特典)(?:[^a-z0-9]|$)/i],
|
||||||
|
[NCOP_SEASON, /(?:^|[^a-z0-9])ncop(?:\d+)?(?:[^a-z0-9]|$)/i],
|
||||||
|
[NCED_SEASON, /(?:^|[^a-z0-9])nced(?:\d+)?(?:[^a-z0-9]|$)/i],
|
||||||
|
[EXTRA_SEASON, /(?:^|[^a-z0-9])extras?(?:[\s._-]*\d+)?(?:[^a-z0-9]|$)/i],
|
||||||
|
[BONUS_SEASON, /(?:^|[^a-z0-9])bonus(?:es)?(?:[\s._-]*\d+)?(?:[^a-z0-9]|$)/i],
|
||||||
|
[OMAKE_SEASON, /(?:^|[^a-z0-9])omake(?:[\s._-]*\d+)?(?:[^a-z0-9]|$)/i],
|
||||||
|
]
|
||||||
|
|
||||||
|
export function specialSectionForMedia(media: Media): number | null {
|
||||||
|
if (isTheatricalFeature(media)) return THEATRICAL_SEASON
|
||||||
|
const parts = (media.path || '').split(/[\\/]+/).filter(Boolean)
|
||||||
|
for (const part of parts) {
|
||||||
|
for (const [section, pattern] of SPECIAL_SECTION_PATTERNS) {
|
||||||
|
if (pattern.test(part)) return section
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 季按钮显示顺序:第一季、第二季 … 第 XX 季 → 特别篇 → 剧场版 → OVA → OAD → 其余特典。
|
||||||
|
*
|
||||||
|
* 正片季(季号 > 0)按季号升序排最前;特殊分区(0 / 负数季)按固定优先级排在后面。
|
||||||
|
* 各季分组页(剧集库 / 详情页 / 播放器选集)共用这一顺序,避免三处各排各的。
|
||||||
|
*/
|
||||||
|
export function seasonSortOrder(season: number): number {
|
||||||
|
if (season > 0) return season
|
||||||
|
switch (season) {
|
||||||
|
case 0: return 1000 // 特别篇紧随正片
|
||||||
|
case THEATRICAL_SEASON: return 1001 // 剧场版
|
||||||
|
case OVA_SEASON: return 1002
|
||||||
|
case OAD_SEASON: return 1003
|
||||||
|
case OVD_SEASON: return 1004
|
||||||
|
case ONA_SEASON: return 1005
|
||||||
|
case EXTRA_SEASON: return 1006
|
||||||
|
case BONUS_SEASON: return 1007
|
||||||
|
case OMAKE_SEASON: return 1008
|
||||||
|
case PICTURE_DRAMA_SEASON: return 1009
|
||||||
|
case NCOP_SEASON: return 1010
|
||||||
|
case NCED_SEASON: return 1011
|
||||||
|
default: return 2000 // 未知季兜底,排最后
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function seasonLabel(season: number): string {
|
||||||
|
switch (season) {
|
||||||
|
case 0: return '特别篇'
|
||||||
|
case THEATRICAL_SEASON: return '剧场版'
|
||||||
|
case OVA_SEASON: return 'OVA'
|
||||||
|
case OAD_SEASON: return 'OAD'
|
||||||
|
case OVD_SEASON: return 'OVD'
|
||||||
|
case ONA_SEASON: return 'ONA'
|
||||||
|
case EXTRA_SEASON: return 'Extra'
|
||||||
|
case BONUS_SEASON: return 'Bonus'
|
||||||
|
case OMAKE_SEASON: return 'Omake'
|
||||||
|
case PICTURE_DRAMA_SEASON: return 'Picture Drama'
|
||||||
|
case NCOP_SEASON: return 'NCOP'
|
||||||
|
case NCED_SEASON: return 'NCED'
|
||||||
|
default: return `第 ${season} 季`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export function isSeriesCard(card: SeriesCard): boolean {
|
export function isSeriesCard(card: SeriesCard): boolean {
|
||||||
return (
|
return (
|
||||||
card.count > 1 ||
|
card.count > 1 ||
|
||||||
@@ -156,10 +250,10 @@ function normalizeTitle(value?: string): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const SERIES_SPECIAL_CODE_RE =
|
const SERIES_SPECIAL_CODE_RE =
|
||||||
/\s*[[((【]?\s*(?:s0+\s*e?\s*\d+|season\s*0+(?:\s*episode)?\s*\d*|special(?:\s*episode)?s?\s*\d*|sp\s*\d*|ovas?\s*\d*|oads?\s*\d*|extras?\s*\d*|bonus(?:es)?\s*\d*|omake\s*\d*)\s*[\]))】]?$/i
|
/\s*[[((【]?\s*(?:s0+\s*e?\s*\d+|season\s*0+(?:\s*episode)?\s*\d*|special(?:\s*episode)?s?\s*\d*|sp\s*\d*|ovas?\s*\d*|oads?\s*\d*|ovds?\s*\d*|onas?\s*\d*|extras?\s*\d*|bonus(?:es)?\s*\d*|omake\s*\d*|picture[\s._-]*drama\s*\d*|ncop\s*\d*|nced\s*\d*)\s*[\]))】]?$/i
|
||||||
|
|
||||||
const SERIES_SPECIAL_CJK_RE =
|
const SERIES_SPECIAL_CJK_RE =
|
||||||
/\s*[[((【]?\s*(?:特别篇|特別篇|番外篇?|特典|外传|外傳|总集篇|總集篇)(?:\s*第?\s*[0-9一二三四五六七八九十百零两]+(?:[集话話期])?)?\s*[\]))】]?$/i
|
/\s*[[((【]?\s*(?:特别篇|特別篇|番外篇?|特典|外传|外傳|总集篇|總集篇|画像特典)(?:\s*第?\s*[0-9一二三四五六七八九十百零两]+(?:[集话話期])?)?\s*[\]))】]?$/i
|
||||||
|
|
||||||
function normalizePathSeriesTitle(value?: string): string {
|
function normalizePathSeriesTitle(value?: string): string {
|
||||||
const title = normalizeTitle(value)
|
const title = normalizeTitle(value)
|
||||||
@@ -257,16 +351,20 @@ function seriesDirectoryNameFromPath(path?: string): string {
|
|||||||
if (parts.length < 2) return ''
|
if (parts.length < 2) return ''
|
||||||
let dirIndex = parts.length - 2
|
let dirIndex = parts.length - 2
|
||||||
const lastPart = parts[parts.length - 1]
|
const lastPart = parts[parts.length - 1]
|
||||||
if (!seriesPathPartLooksLikeFile(lastPart) && !SEASON_FOLDER_RE.test(lastPart)) {
|
if (!seriesPathPartLooksLikeFile(lastPart) && !isSeriesContainerFolder(lastPart)) {
|
||||||
dirIndex = parts.length - 1
|
dirIndex = parts.length - 1
|
||||||
}
|
}
|
||||||
while (dirIndex >= 0 && SEASON_FOLDER_RE.test(parts[dirIndex])) {
|
while (dirIndex >= 0 && isSeriesContainerFolder(parts[dirIndex])) {
|
||||||
dirIndex -= 1
|
dirIndex -= 1
|
||||||
}
|
}
|
||||||
if (dirIndex < 0) return ''
|
if (dirIndex < 0) return ''
|
||||||
return parts[dirIndex]
|
return parts[dirIndex]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isSeriesContainerFolder(name: string): boolean {
|
||||||
|
return SEASON_FOLDER_RE.test(name) || THEATRICAL_FOLDER_NAME_RE.test(name)
|
||||||
|
}
|
||||||
|
|
||||||
function seriesExternalIDFromPath(path?: string): string {
|
function seriesExternalIDFromPath(path?: string): string {
|
||||||
const part = seriesDirectoryNameFromPath(path)
|
const part = seriesDirectoryNameFromPath(path)
|
||||||
if (!part) return ''
|
if (!part) return ''
|
||||||
|
|||||||
+134
-25
@@ -1,33 +1,62 @@
|
|||||||
import type { Media } from '../types'
|
import type { Media } from '../types'
|
||||||
|
|
||||||
/** 从路径 / strm URL 推断容器扩展名(mkv/mp4…)。 */
|
const VIDEO_EXTENSIONS = new Set([
|
||||||
|
'mkv', 'mp4', 'm4v', 'avi', 'mov', 'webm', 'flv', 'wmv', 'ts', 'm2ts', 'mts', 'vob', 'rmvb', 'rm', 'iso',
|
||||||
|
])
|
||||||
|
|
||||||
|
export function isStrmMedia(media?: Media | null): boolean {
|
||||||
|
if (!media) return false
|
||||||
|
const path = (media.path || '').toLowerCase()
|
||||||
|
const strmUrl = (media.strm_url || '').toLowerCase()
|
||||||
|
return path.endsWith('.strm') || path.startsWith('cloud://') || Boolean(strmUrl.trim())
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 从路径 / strm URL 推断真实视频容器扩展名(mkv/mp4…)。 */
|
||||||
export function mediaVersionContainer(media: Media): string {
|
export function mediaVersionContainer(media: Media): string {
|
||||||
|
const explicit = (media.container || '').trim().toLowerCase().replace(/^\./, '')
|
||||||
|
if (explicit && explicit !== 'strm' && VIDEO_EXTENSIONS.has(explicit)) {
|
||||||
|
return explicit
|
||||||
|
}
|
||||||
|
|
||||||
const path = (media.path || '').replace(/\\/g, '/')
|
const path = (media.path || '').replace(/\\/g, '/')
|
||||||
const base = path.split('/').pop() || ''
|
const base = path.split('/').pop() || ''
|
||||||
const ext = base.includes('.') ? base.slice(base.lastIndexOf('.')).toLowerCase() : ''
|
const ext = base.includes('.') ? base.slice(base.lastIndexOf('.')).toLowerCase() : ''
|
||||||
let name = ext ? base.slice(0, -ext.length) : base
|
const name = ext ? base.slice(0, -ext.length) : base
|
||||||
|
|
||||||
if (ext === '.strm') {
|
if (ext === '.strm') {
|
||||||
const second = name.includes('.') ? name.slice(name.lastIndexOf('.')).toLowerCase() : ''
|
const second = name.includes('.') ? name.slice(name.lastIndexOf('.')).toLowerCase().replace(/^\./, '') : ''
|
||||||
if (second && second !== '.strm') {
|
if (second && VIDEO_EXTENSIONS.has(second)) {
|
||||||
return second.replace(/^\./, '')
|
return second
|
||||||
}
|
}
|
||||||
|
|
||||||
const strm = (media.strm_url || '').toLowerCase()
|
const strm = (media.strm_url || '').toLowerCase()
|
||||||
const marker = '/video.'
|
if (strm) {
|
||||||
const idx = strm.lastIndexOf(marker)
|
// 1. /video.mkv 或 /video.mp4 模式
|
||||||
if (idx >= 0) {
|
const marker = '/video.'
|
||||||
let rest = strm.slice(idx + marker.length)
|
const idx = strm.lastIndexOf(marker)
|
||||||
const end = rest.search(/[?#&/]/)
|
if (idx >= 0) {
|
||||||
if (end >= 0) rest = rest.slice(0, end)
|
let rest = strm.slice(idx + marker.length)
|
||||||
rest = rest.replace(/^\./, '').trim()
|
const end = rest.search(/[?#&/]/)
|
||||||
if (rest) return rest
|
if (end >= 0) rest = rest.slice(0, end)
|
||||||
|
rest = rest.replace(/^\./, '').trim()
|
||||||
|
if (rest && VIDEO_EXTENSIONS.has(rest)) return rest
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. 从 query 参数(如 ref=/movies/abc.mkv 或 path=...)或 pathname 里匹配扩展名
|
||||||
|
const match = strm.match(/\.(mkv|mp4|m4v|avi|mov|webm|flv|wmv|ts|m2ts|iso)(?=[?#&/]|$)/i)
|
||||||
|
if (match?.[1]) {
|
||||||
|
return match[1].toLowerCase()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return 'strm'
|
return 'strm'
|
||||||
}
|
}
|
||||||
return ext.replace(/^\./, '')
|
|
||||||
|
const cleanExt = ext.replace(/^\./, '')
|
||||||
|
return VIDEO_EXTENSIONS.has(cleanExt) ? cleanExt : cleanExt
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatSize(bytes: number): string {
|
function formatSize(bytes: number): string {
|
||||||
if (!bytes || bytes < 1024) return bytes ? `${bytes} B` : ''
|
if (!bytes || bytes < 1024) return ''
|
||||||
const units = ['KB', 'MB', 'GB', 'TB']
|
const units = ['KB', 'MB', 'GB', 'TB']
|
||||||
let value = bytes
|
let value = bytes
|
||||||
let unit = -1
|
let unit = -1
|
||||||
@@ -38,19 +67,99 @@ function formatSize(bytes: number): string {
|
|||||||
return `${value.toFixed(1)} ${units[unit]}`
|
return `${value.toFixed(1)} ${units[unit]}`
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 版本切换展示名:分辨率 · 容器 · 编码 · 体积,缺省回退文件名。 */
|
/** 从文件名/路径中提取质量或规格标签(如 4K, 2160p, REMUX, HDR, 1080p, 60fps 等) */
|
||||||
|
function extractQualityKeywords(text: string): string[] {
|
||||||
|
const result: string[] = []
|
||||||
|
const upper = text.toUpperCase()
|
||||||
|
|
||||||
|
// 分辨率
|
||||||
|
if (/\b(4K|2160P|UHD)\b/i.test(upper)) result.push('4K')
|
||||||
|
else if (/\b(1080P|FHD)\b/i.test(upper)) result.push('1080p')
|
||||||
|
else if (/\b(720P|HD)\b/i.test(upper)) result.push('720p')
|
||||||
|
|
||||||
|
// 格式/版本规格
|
||||||
|
if (/\bREMUX\b/i.test(upper)) result.push('REMUX')
|
||||||
|
else if (/\b(BLURAY|BLU-RAY|BD)\b/i.test(upper)) result.push('BluRay')
|
||||||
|
else if (/\b(WEB-?DL|WEBRIP)\b/i.test(upper)) result.push('WEB-DL')
|
||||||
|
|
||||||
|
// 动态范围/画质技术
|
||||||
|
if (/\b(DV|DOVI|DOLBY[\s._-]?VISION)\b/i.test(upper)) result.push('DV')
|
||||||
|
if (/\b(HDR10\+|HDR10PLUS)\b/i.test(upper)) result.push('HDR10+')
|
||||||
|
else if (/\bHDR\b/i.test(upper) && !result.includes('DV')) result.push('HDR')
|
||||||
|
|
||||||
|
// 帧率
|
||||||
|
if (/\b(60FPS|120FPS)\b/i.test(upper)) {
|
||||||
|
const fpsMatch = upper.match(/\b(60FPS|120FPS)\b/i)
|
||||||
|
if (fpsMatch) result.push(fpsMatch[1])
|
||||||
|
}
|
||||||
|
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 清理文件名以用作干净的兜底展示 */
|
||||||
|
function cleanFallbackFilename(raw: string): string {
|
||||||
|
let name = raw.replace(/\\/g, '/').split('/').pop() || ''
|
||||||
|
// 去除 .strm 及前缀扩展名
|
||||||
|
name = name.replace(/\.strm$/i, '')
|
||||||
|
name = name.replace(/\.(mkv|mp4|m4v|avi|mov|webm|flv|wmv|ts|iso)$/i, '')
|
||||||
|
// 替换连续的点或下划线为空格
|
||||||
|
name = name.replace(/[._]+/g, ' ').trim()
|
||||||
|
return name
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 版本切换展示名:分辨率 · 容器 · 编码 · 体积 / 规格 / 来源标签。 */
|
||||||
export function mediaVersionLabel(media: Media): string {
|
export function mediaVersionLabel(media: Media): string {
|
||||||
|
const isStrm = isStrmMedia(media)
|
||||||
const parts: string[] = []
|
const parts: string[] = []
|
||||||
if (media.height > 0) parts.push(`${media.height}p`)
|
|
||||||
else if (media.width > 0) parts.push(`${media.width}w`)
|
// 1. 分辨率(优先从媒体元数据获取,其次从路径中提取)
|
||||||
|
if (media.height > 0) {
|
||||||
|
if (media.height >= 2100) parts.push('4K')
|
||||||
|
else parts.push(`${media.height}p`)
|
||||||
|
} else if (media.width > 0) {
|
||||||
|
if (media.width >= 3800) parts.push('4K')
|
||||||
|
else parts.push(`${media.width}w`)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. 容器
|
||||||
const container = mediaVersionContainer(media)
|
const container = mediaVersionContainer(media)
|
||||||
if (container && container !== 'strm') parts.push(container.toUpperCase())
|
if (container && container !== 'strm') {
|
||||||
if (media.video_codec?.trim()) parts.push(media.video_codec.trim().toUpperCase())
|
parts.push(container.toUpperCase())
|
||||||
const size = formatSize(media.size_bytes)
|
}
|
||||||
if (size) parts.push(size)
|
|
||||||
if (parts.length > 0) return parts.join(' · ')
|
// 3. 视频编码
|
||||||
const base = (media.path || '').replace(/\\/g, '/').split('/').pop()
|
if (media.video_codec?.trim()) {
|
||||||
return base || media.title || media.id
|
parts.push(media.video_codec.trim().toUpperCase())
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. 从文件名/路径补充规格特征(针对 STRM 缺乏探测数据时的关键画质提示)
|
||||||
|
const pathKeywords = extractQualityKeywords(`${media.path || ''} ${media.strm_url || ''}`)
|
||||||
|
for (const kw of pathKeywords) {
|
||||||
|
// 避免重复(如已经有了 4K / 1080p)
|
||||||
|
if (!parts.some((p) => p.toUpperCase() === kw.toUpperCase())) {
|
||||||
|
parts.push(kw)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 5. 文件大小:过滤掉本地 STRM 占位小文件(< 1MB 的 STRM 索引通常不是真实视频大小)
|
||||||
|
const isPlaceholderSize = isStrm && media.size_bytes > 0 && media.size_bytes < 1024 * 1024
|
||||||
|
if (media.size_bytes > 0 && !isPlaceholderSize) {
|
||||||
|
const size = formatSize(media.size_bytes)
|
||||||
|
if (size) parts.push(size)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 6. 如果仍然没有解析出足够特征,标记来源(云盘 / STRM)
|
||||||
|
if (isStrm && !parts.some((p) => ['4K', '1080P', '720P', 'REMUX', 'WEB-DL', 'MKV', 'MP4'].includes(p.toUpperCase()))) {
|
||||||
|
parts.push('云端直链')
|
||||||
|
}
|
||||||
|
|
||||||
|
if (parts.length > 0) {
|
||||||
|
return parts.join(' · ')
|
||||||
|
}
|
||||||
|
|
||||||
|
// 兜底:使用清理后的文件名
|
||||||
|
const cleaned = cleanFallbackFilename(media.path || '')
|
||||||
|
return cleaned || media.title || media.id
|
||||||
}
|
}
|
||||||
|
|
||||||
export function mediaVersionsOf(media: Media | null | undefined): Media[] {
|
export function mediaVersionsOf(media: Media | null | undefined): Media[] {
|
||||||
|
|||||||
Reference in New Issue
Block a user