Compare commits

...

22 Commits

Author SHA1 Message Date
github-actions[bot] a311438aa1 chore: bump version to 0.0.106 [skip ci] 2026-09-04 03:56:49 +00:00
truewhile 203abd106a 优化 2026-09-04 11:56:29 +08:00
github-actions[bot] cc40169616 chore: bump version to 0.0.105 [skip ci] 2026-09-04 01:06:30 +00:00
truewhile cd720ae879 优化 2026-09-04 09:06:15 +08:00
github-actions[bot] fc84291346 chore: bump version to 0.0.104 [skip ci] 2026-09-03 15:12:00 +00:00
truewhile c37e936f48 bug 2026-09-03 23:11:42 +08:00
github-actions[bot] 389cb99bcf chore: bump version to 0.0.103 [skip ci] 2026-09-03 14:48:19 +00:00
truewhile 1b611a6181 bug处理 2026-09-03 22:48:02 +08:00
github-actions[bot] 25c03f2b0d chore: bump version to 0.0.102 [skip ci] 2026-09-03 13:18:35 +00:00
truewhile a56b1801f9 bug处理 2026-09-03 21:18:20 +08:00
github-actions[bot] 99c755dc29 chore: bump version to 0.0.101 [skip ci] 2026-09-03 12:51:41 +00:00
truewhile 086c0307c3 bug处理 2026-09-03 20:51:24 +08:00
github-actions[bot] 51d0f5010e chore: bump version to 0.0.100 [skip ci] 2026-09-03 12:20:20 +00:00
truewhile b9dd09a5d2 bug处理 2026-09-03 20:19:31 +08:00
github-actions[bot] e79f393969 chore: bump version to 0.0.99 [skip ci] 2026-09-03 11:37:58 +00:00
truewhile e422ecce53 优化 2026-09-03 19:37:29 +08:00
github-actions[bot] d0e29a0d8d chore: bump version to 0.0.98 [skip ci] 2026-09-03 09:43:17 +00:00
truewhile a7eeb8fbeb 优化 2026-09-03 17:42:52 +08:00
github-actions[bot] 64906d659d chore: bump version to 0.0.97 [skip ci] 2026-09-03 08:53:39 +00:00
truewhile c702a68cfb 优化 2026-09-03 16:53:12 +08:00
github-actions[bot] 4150d03852 chore: bump version to 0.0.96 [skip ci] 2026-09-03 06:13:05 +00:00
truewhile a79bbb80a8 Remove unused recycle bin and add optional local file delete (#26)
Soft-delete restore/purge/list and the recycle_purge job are gone.
Admin delete now removes library index rows, with an optional
delete_files query that also deletes local media + sidecar NFO.
UI copy is renamed from 移入回收站 to 删除, with a confirm checkbox.

Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: truewhile <truewhile@users.noreply.github.com>
2026-09-03 14:12:52 +08:00
133 changed files with 3993 additions and 1872 deletions
+1 -1
View File
@@ -51,7 +51,7 @@
| **网盘与 STRM** | OpenList、CloudDrive2、115、WebDAV 等;STRM 同步、上传/下载队列、直链/302 播放 |
| **下载与整理** | qBittorrent 接入、站点搜索与订阅、下载后自动整理、文件管理器(复制/移动/硬链/软链) |
| **用户与权限** | 管理员/普通用户、有效期、成人内容开关、播放配置 PIN、细粒度操作权限 |
| **运维能力** | 统一任务队列、回收站、存储统计、DLNA 投屏、系统设置与日志 |
| **运维能力** | 统一任务队列、存储统计、DLNA 投屏、系统设置与日志 |
### 技术栈
+1 -1
View File
@@ -50,7 +50,7 @@ In practice, MeBox gives you:
| **Cloud & STRM** | OpenList, CloudDrive2, 115, WebDAV; STRM sync; upload/download queues; direct or 302 playback |
| **Downloads & organize** | qBittorrent, site search/subscriptions, post-download organization, file manager |
| **Users & permissions** | Admin/regular users, expiry, NSFW toggle, play-profile PIN, granular permissions |
| **Operations** | Unified task queue, recycle bin, storage stats, DLNA casting, settings and logs |
| **Operations** | Unified task queue, storage stats, DLNA casting, settings and logs |
### Tech stack
+1 -1
View File
@@ -1 +1 @@
0.0.95
0.0.106
+2 -1
View File
@@ -23,6 +23,7 @@ import (
"github.com/truewhile/MeBox/internal/config"
"github.com/truewhile/MeBox/internal/database"
"github.com/truewhile/MeBox/internal/helper"
"github.com/truewhile/MeBox/internal/repository"
"github.com/truewhile/MeBox/internal/service"
)
@@ -121,7 +122,7 @@ func main() {
)
}
}()
go services.Boot()
helper.Go(logger, "services.boot", services.Boot)
// Graceful shutdown.
stop := make(chan os.Signal, 1)
+2 -1
View File
@@ -53,6 +53,7 @@ func buildRouter(cfg *config.Config, logger *zap.Logger, svc *service.Container)
// comes from root, which is either the compiled-in SPA or an on-disk web dir.
func serveSPA(r *gin.Engine, root fs.FS) {
assets := r.Group("/assets")
assets.Use(middleware.GzipStatic())
assets.Use(func(c *gin.Context) {
c.Header("Cache-Control", "public, max-age=31536000, immutable")
c.Next()
@@ -69,7 +70,7 @@ func serveSPA(r *gin.Engine, root fs.FS) {
r.GET(rootFile, serveFSFile(root, name))
r.HEAD(rootFile, serveFSFile(root, name))
}
r.NoRoute(func(c *gin.Context) {
r.NoRoute(middleware.GzipStatic(), func(c *gin.Context) {
path := c.Request.URL.Path
if shouldBypassSPAFallback(path) {
c.Status(http.StatusNotFound)
+26 -19
View File
@@ -5,7 +5,8 @@ go 1.25.0
require (
github.com/aliyun/alibabacloud-oss-go-sdk-v2 v1.5.2
github.com/fsnotify/fsnotify v1.7.0
github.com/gin-gonic/gin v1.9.1
github.com/gin-contrib/gzip v1.2.6
github.com/gin-gonic/gin v1.12.0
github.com/glebarez/sqlite v1.11.0
github.com/golang-jwt/jwt/v5 v5.2.0
github.com/google/uuid v1.6.0
@@ -13,11 +14,11 @@ require (
github.com/redis/go-redis/v9 v9.7.0
github.com/shirou/gopsutil/v3 v3.24.5
github.com/spf13/viper v1.18.2
github.com/stretchr/testify v1.9.0
github.com/stretchr/testify v1.11.1
github.com/ulikunitz/xz v0.5.12
go.uber.org/zap v1.27.0
golang.org/x/crypto v0.21.0
golang.org/x/sys v0.20.0
golang.org/x/crypto v0.48.0
golang.org/x/sys v0.41.0
golang.org/x/time v0.15.0
gopkg.in/yaml.v3 v3.0.1
gorm.io/driver/postgres v1.5.7
@@ -25,20 +26,23 @@ require (
)
require (
github.com/bytedance/sonic v1.9.1 // indirect
github.com/bytedance/gopkg v0.1.3 // indirect
github.com/bytedance/sonic v1.15.0 // indirect
github.com/bytedance/sonic/loader v0.5.0 // indirect
github.com/cespare/xxhash/v2 v2.2.0 // indirect
github.com/chenzhuoyu/base64x v0.0.0-20221115062448-fe3a3abad311 // indirect
github.com/cloudwego/base64x v0.1.6 // indirect
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/gabriel-vasile/mimetype v1.4.2 // indirect
github.com/gin-contrib/sse v0.1.0 // indirect
github.com/gabriel-vasile/mimetype v1.4.12 // indirect
github.com/gin-contrib/sse v1.1.0 // indirect
github.com/glebarez/go-sqlite v1.21.2 // indirect
github.com/go-ole/go-ole v1.2.6 // indirect
github.com/go-playground/locales v0.14.1 // indirect
github.com/go-playground/universal-translator v0.18.1 // indirect
github.com/go-playground/validator/v10 v10.14.0 // indirect
github.com/goccy/go-json v0.10.2 // indirect
github.com/go-playground/validator/v10 v10.30.1 // indirect
github.com/goccy/go-json v0.10.5 // indirect
github.com/goccy/go-yaml v1.19.2 // indirect
github.com/hashicorp/hcl v1.0.0 // indirect
github.com/jackc/pgpassfile v1.0.0 // indirect
github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a // indirect
@@ -46,17 +50,19 @@ require (
github.com/jinzhu/inflection v1.0.0 // indirect
github.com/jinzhu/now v1.1.5 // indirect
github.com/json-iterator/go v1.1.12 // indirect
github.com/klauspost/cpuid/v2 v2.2.4 // indirect
github.com/leodido/go-urn v1.2.4 // indirect
github.com/klauspost/cpuid/v2 v2.3.0 // indirect
github.com/leodido/go-urn v1.4.0 // indirect
github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 // indirect
github.com/magiconair/properties v1.8.7 // indirect
github.com/mattn/go-isatty v0.0.19 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/mitchellh/mapstructure v1.5.0 // indirect
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
github.com/modern-go/reflect2 v1.0.2 // indirect
github.com/pelletier/go-toml/v2 v2.1.0 // indirect
github.com/pelletier/go-toml/v2 v2.2.4 // indirect
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c // indirect
github.com/quic-go/qpack v0.6.0 // indirect
github.com/quic-go/quic-go v0.59.0 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
github.com/sagikazarmark/locafero v0.4.0 // indirect
github.com/sagikazarmark/slog-shim v0.1.0 // indirect
@@ -69,14 +75,15 @@ require (
github.com/tklauser/go-sysconf v0.3.12 // indirect
github.com/tklauser/numcpus v0.6.1 // indirect
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
github.com/ugorji/go/codec v1.2.11 // indirect
github.com/ugorji/go/codec v1.3.1 // indirect
github.com/yusufpapurcu/wmi v1.2.4 // indirect
go.mongodb.org/mongo-driver/v2 v2.5.0 // indirect
go.uber.org/multierr v1.10.0 // indirect
golang.org/x/arch v0.3.0 // indirect
golang.org/x/arch v0.22.0 // indirect
golang.org/x/exp v0.0.0-20230905200255-921286631fa9 // indirect
golang.org/x/net v0.21.0 // indirect
golang.org/x/text v0.20.0 // indirect
google.golang.org/protobuf v1.31.0 // indirect
golang.org/x/net v0.51.0 // indirect
golang.org/x/text v0.35.0 // indirect
google.golang.org/protobuf v1.36.10 // indirect
gopkg.in/ini.v1 v1.67.0 // indirect
modernc.org/libc v1.22.5 // indirect
modernc.org/mathutil v1.5.0 // indirect
+60 -53
View File
@@ -4,14 +4,16 @@ github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs=
github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c=
github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA=
github.com/bsm/gomega v1.27.10/go.mod h1:JyEr/xRbxbtgWNi8tIEVPUYZ5Dzef52k01W3YH0H+O0=
github.com/bytedance/sonic v1.5.0/go.mod h1:ED5hyg4y6t3/9Ku1R6dU/4KyJ48DZ4jPhfY1O2AihPM=
github.com/bytedance/sonic v1.9.1 h1:6iJ6NqdoxCDr6mbY8h18oSO+cShGSMRGCEo7F2h0x8s=
github.com/bytedance/sonic v1.9.1/go.mod h1:i736AoUSYt75HyZLoJW9ERYxcy6eaN6h4BZXU064P/U=
github.com/bytedance/gopkg v0.1.3 h1:TPBSwH8RsouGCBcMBktLt1AymVo2TVsBVCY4b6TnZ/M=
github.com/bytedance/gopkg v0.1.3/go.mod h1:576VvJ+eJgyCzdjS+c4+77QF3p7ubbtiKARP3TxducM=
github.com/bytedance/sonic v1.15.0 h1:/PXeWFaR5ElNcVE84U0dOHjiMHQOwNIx3K4ymzh/uSE=
github.com/bytedance/sonic v1.15.0/go.mod h1:tFkWrPz0/CUCLEF4ri4UkHekCIcdnkqXw9VduqpJh0k=
github.com/bytedance/sonic/loader v0.5.0 h1:gXH3KVnatgY7loH5/TkeVyXPfESoqSBSBEiDd5VjlgE=
github.com/bytedance/sonic/loader v0.5.0/go.mod h1:AR4NYCk5DdzZizZ5djGqQ92eEhCCcdf5x77udYiSJRo=
github.com/cespare/xxhash/v2 v2.2.0 h1:DC2CZ1Ep5Y4k3ZQ899DldepgrayRUGE6BBZ/cd9Cj44=
github.com/cespare/xxhash/v2 v2.2.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/chenzhuoyu/base64x v0.0.0-20211019084208-fb5309c8db06/go.mod h1:DH46F32mSOjUmXrMHnKwZdA8wcEefY7UVqBKYGjpdQY=
github.com/chenzhuoyu/base64x v0.0.0-20221115062448-fe3a3abad311 h1:qSGYFH7+jGhDF8vLC+iwCD4WpbV1EBDSzWkJODFLams=
github.com/chenzhuoyu/base64x v0.0.0-20221115062448-fe3a3abad311/go.mod h1:b583jCggY9gE99b6G5LEC39OIiVsWj+R97kbl5odCEk=
github.com/cloudwego/base64x v0.1.6 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI2M=
github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gEHfghB2IPU=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
@@ -24,12 +26,14 @@ github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHk
github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
github.com/fsnotify/fsnotify v1.7.0 h1:8JEhPFa5W2WU7YfeZzPNqzMP6Lwt7L2715Ggo0nosvA=
github.com/fsnotify/fsnotify v1.7.0/go.mod h1:40Bi/Hjc2AVfZrqy+aj+yEI+/bRxZnMJyTJwOpGvigM=
github.com/gabriel-vasile/mimetype v1.4.2 h1:w5qFW6JKBz9Y393Y4q372O9A7cUSequkh1Q7OhCmWKU=
github.com/gabriel-vasile/mimetype v1.4.2/go.mod h1:zApsH/mKG4w07erKIaJPFiX0Tsq9BFQgN3qGY5GnNgA=
github.com/gin-contrib/sse v0.1.0 h1:Y/yl/+YNO8GZSjAhjMsSuLt29uWRFHdHYUb5lYOV9qE=
github.com/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm+fLHvGI=
github.com/gin-gonic/gin v1.9.1 h1:4idEAncQnU5cB7BeOkPtxjfCSye0AAm1R0RVIqJ+Jmg=
github.com/gin-gonic/gin v1.9.1/go.mod h1:hPrL7YrpYKXt5YId3A/Tnip5kqbEAP+KLuI3SUcPTeU=
github.com/gabriel-vasile/mimetype v1.4.12 h1:e9hWvmLYvtp846tLHam2o++qitpguFiYCKbn0w9jyqw=
github.com/gabriel-vasile/mimetype v1.4.12/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s=
github.com/gin-contrib/gzip v1.2.6 h1:OtN8DplD5DNZCSLAnQ5HxRkD2qZ5VU+JhOrcfJrcRvg=
github.com/gin-contrib/gzip v1.2.6/go.mod h1:BQy8/+JApnRjAVUplSGZiVtD2k8GmIE2e9rYu/hLzzU=
github.com/gin-contrib/sse v1.1.0 h1:n0w2GMuUpWDVp7qSpvze6fAu9iRxJY4Hmj6AmBOU05w=
github.com/gin-contrib/sse v1.1.0/go.mod h1:hxRZ5gVpWMT7Z0B0gSNYqqsSCNIJMjzvm6fqCz9vjwM=
github.com/gin-gonic/gin v1.12.0 h1:b3YAbrZtnf8N//yjKeU2+MQsh2mY5htkZidOM7O0wG8=
github.com/gin-gonic/gin v1.12.0/go.mod h1:VxccKfsSllpKshkBWgVgRniFFAzFb9csfngsqANjnLc=
github.com/glebarez/go-sqlite v1.21.2 h1:3a6LFC4sKahUunAmynQKLZceZCOzUthkRkEAl9gAXWo=
github.com/glebarez/go-sqlite v1.21.2/go.mod h1:sfxdZyhQjTM2Wry3gVYWaW072Ri1WMdWJi0k6+3382k=
github.com/glebarez/sqlite v1.11.0 h1:wSG0irqzP6VurnMEpFGer5Li19RpIRi2qvQz++w0GMw=
@@ -42,17 +46,17 @@ github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/o
github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY=
github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY=
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
github.com/go-playground/validator/v10 v10.14.0 h1:vgvQWe3XCz3gIeFDm/HnTIbj6UGmg/+t63MyGU2n5js=
github.com/go-playground/validator/v10 v10.14.0/go.mod h1:9iXMNT7sEkjXb0I+enO7QXmzG6QCsPWY4zveKFVRSyU=
github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU=
github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I=
github.com/go-playground/validator/v10 v10.30.1 h1:f3zDSN/zOma+w6+1Wswgd9fLkdwy06ntQJp0BBvFG0w=
github.com/go-playground/validator/v10 v10.30.1/go.mod h1:oSuBIQzuJxL//3MelwSLD5hc2Tu889bF0Idm9Dg26cM=
github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4=
github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
github.com/goccy/go-yaml v1.19.2 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM=
github.com/goccy/go-yaml v1.19.2/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=
github.com/golang-jwt/jwt/v5 v5.2.0 h1:d/ix8ftRUorsN+5eMIlF4T6J8CAt9rch3My2winC1Jw=
github.com/golang-jwt/jwt/v5 v5.2.0/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk=
github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
github.com/google/pprof v0.0.0-20221118152302-e6195bd50e26 h1:Xim43kblpZXfIBQsbuBVKCudVG457BR2GZFIz3uw3hQ=
github.com/google/pprof v0.0.0-20221118152302-e6195bd50e26/go.mod h1:dDKJzRmX4S37WGHujM7tX//fmj1uioxKzKxz3lo4HJo=
@@ -74,21 +78,20 @@ github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ=
github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8=
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
github.com/klauspost/cpuid/v2 v2.2.4 h1:acbojRNwl3o09bUq+yDCtZFc1aiwaAAxtcn8YkZXnvk=
github.com/klauspost/cpuid/v2 v2.2.4/go.mod h1:RVVoqg1df56z8g3pUjL/3lE5UfnlrJX8tyFgg4nqhuY=
github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y=
github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/leodido/go-urn v1.2.4 h1:XlAE/cm/ms7TE/VMVoduSpNBoyc2dOxHs5MZSwAN63Q=
github.com/leodido/go-urn v1.2.4/go.mod h1:7ZrI8mTSeBSHl/UaRyKQW1qZeMgak41ANeCNaVckg+4=
github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 h1:6E+4a0GO5zZEnZ81pIr0yLvtUWk2if982qA3F3QD6H4=
github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0/go.mod h1:zJYVVT2jmtg6P3p1VtQj7WsuWi/y4VnjVBn7F8KPB3I=
github.com/magiconair/properties v1.8.7 h1:IeQXZAiQcpL9mgcAe1Nu6cX9LLw6ExEHKjN0VQdvPDY=
github.com/magiconair/properties v1.8.7/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0=
github.com/mattn/go-isatty v0.0.19 h1:JITubQf0MOLdlGRuRq+jtsDlekdYPia9ZFsB8h/APPA=
github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
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/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY=
github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo=
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
@@ -96,20 +99,24 @@ github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
github.com/pelletier/go-toml/v2 v2.1.0 h1:FnwAJ4oYMvbT/34k9zzHuZNrhlz48GB3/s6at6/MHO4=
github.com/pelletier/go-toml/v2 v2.1.0/go.mod h1:tJU2Z3ZkXwnxa4DPO899bsyIoywizdUvyaeZurnPPDc=
github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4=
github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c h1:ncq/mPwQF4JjgDlrVEn3C11VoGHZN7m8qihwgMEtzYw=
github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE=
github.com/quic-go/qpack v0.6.0 h1:g7W+BMYynC1LbYLSqRt8PBg5Tgwxn214ZZR34VIOjz8=
github.com/quic-go/qpack v0.6.0/go.mod h1:lUpLKChi8njB4ty2bFLX2x4gzDqXwUpaO1DP9qMDZII=
github.com/quic-go/quic-go v0.59.0 h1:OLJkp1Mlm/aS7dpKgTc6cnpynnD2Xg7C1pwL6vy/SAw=
github.com/quic-go/quic-go v0.59.0/go.mod h1:upnsH4Ju1YkqpLXC305eW3yDZ4NfnNbmQRCMWS58IKU=
github.com/redis/go-redis/v9 v9.7.0 h1:HhLSs+B6O021gwzl+locl0zEDnyNkxMtf/Z3NNBMa9E=
github.com/redis/go-redis/v9 v9.7.0/go.mod h1:f6zhXITC7JUJIlPEiBOTXxJgPLdZcA93GewI7inzyWw=
github.com/remyoudompheng/bigfft v0.0.0-20200410134404-eec4a21b6bb0/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8=
github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs=
github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ=
github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog=
github.com/sagikazarmark/locafero v0.4.0 h1:HApY1R9zGo4DBgr7dqsTH/JJxLTTsOt7u6keLGt6kNQ=
github.com/sagikazarmark/locafero v0.4.0/go.mod h1:Pe1W6UlPYUk/+wc/6KFhbORCfqzgYEpgQ3O5fPuL3H4=
github.com/sagikazarmark/slog-shim v0.1.0 h1:diDBnUNK9N/354PgrxMywXnAwEr1QZcOr6gto+ugjYE=
@@ -133,15 +140,15 @@ github.com/spf13/viper v1.18.2/go.mod h1:EKmWIqdnk5lOcmR72yw6hS+8OPYcwD0jteitLMV
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
github.com/stretchr/testify v1.8.2/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8=
github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU=
github.com/tklauser/go-sysconf v0.3.12 h1:0QaGUFOdQaIVdPgfITYzaTegZvdCjmYO52cSFAEVmqU=
@@ -150,43 +157,44 @@ github.com/tklauser/numcpus v0.6.1 h1:ng9scYS7az0Bk4OZLvrNXNSAO2Pxr1XXRAPyjhIx+F
github.com/tklauser/numcpus v0.6.1/go.mod h1:1XfjsgE2zo8GVw7POkMbHENHzVg3GzmoZ9fESEdAacY=
github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
github.com/ugorji/go/codec v1.2.11 h1:BMaWp1Bb6fHwEtbplGBGJ498wD+LKlNSl25MjdZY4dU=
github.com/ugorji/go/codec v1.2.11/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg=
github.com/ugorji/go/codec v1.3.1 h1:waO7eEiFDwidsBN6agj1vJQ4AG7lh2yqXyOXqhgQuyY=
github.com/ugorji/go/codec v1.3.1/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4=
github.com/ulikunitz/xz v0.5.12 h1:37Nm15o69RwBkXM0J6A5OlE67RZTfzUxTj8fB3dfcsc=
github.com/ulikunitz/xz v0.5.12/go.mod h1:nbz6k7qbPmH4IRqmfOplQw/tblSgqTqBwxkY0oWt/14=
github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0=
github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0=
go.mongodb.org/mongo-driver/v2 v2.5.0 h1:yXUhImUjjAInNcpTcAlPHiT7bIXhshCTL3jVBkF3xaE=
go.mongodb.org/mongo-driver/v2 v2.5.0/go.mod h1:yOI9kBsufol30iFsl1slpdq1I0eHPzybRWdyYUs8K/0=
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y=
go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU=
go.uber.org/multierr v1.10.0 h1:S0h4aNzvfcFsC3dRF1jLoaov7oRaKqRGC/pUEJ2yvPQ=
go.uber.org/multierr v1.10.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y=
go.uber.org/zap v1.27.0 h1:aJMhYGrd5QSmlpLMr2MftRKl7t8J8PTZPA732ud/XR8=
go.uber.org/zap v1.27.0/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E=
golang.org/x/arch v0.0.0-20210923205945-b76863e36670/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8=
golang.org/x/arch v0.3.0 h1:02VY4/ZcO/gBOH6PUaoiptASxtXU10jazRCP865E97k=
golang.org/x/arch v0.3.0/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8=
golang.org/x/crypto v0.21.0 h1:X31++rzVUdKhX5sWmSOFZxx8UW/ldWx55cbf08iNAMA=
golang.org/x/crypto v0.21.0/go.mod h1:0BP7YvVV9gBbVKyeTG0Gyn+gZm94bibOW5BjDEYAOMs=
golang.org/x/arch v0.22.0 h1:c/Zle32i5ttqRXjdLyyHZESLD/bB90DCU1g9l/0YBDI=
golang.org/x/arch v0.22.0/go.mod h1:dNHoOeKiyja7GTvF9NJS1l3Z2yntpQNzgrjh1cU103A=
golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts=
golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos=
golang.org/x/exp v0.0.0-20230905200255-921286631fa9 h1:GoHiUyI/Tp2nVkLI2mCxVkOjsbSXD66ic0XW0js0R9g=
golang.org/x/exp v0.0.0-20230905200255-921286631fa9/go.mod h1:S2oDrQGGwySpoQPVqRShND87VCbxmc6bL1Yd2oYrm6k=
golang.org/x/net v0.21.0 h1:AQyQV4dYCvJ7vGmJyKki9+PBdyvhkSd8EIx/qb0AYv4=
golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44=
golang.org/x/net v0.51.0 h1:94R/GTO7mt3/4wIKpcR5gkGmRLOuE/2hNGeWq/GBIFo=
golang.org/x/net v0.51.0/go.mod h1:aamm+2QF5ogm02fjy5Bb7CQ0WMt1/WVM7FtyaTLlA9Y=
golang.org/x/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-20220704084225-05e143d24a9e/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.11.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.20.0 h1:Od9JTbYCk261bKm4M/mw7AklTlFYIa0bIp9BgSm1S8Y=
golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/text v0.20.0 h1:gK/Kv2otX8gz+wn7Rmb3vT96ZwuoxnQlY+HlJVj7Qug=
golang.org/x/text v0.20.0/go.mod h1:D4IsuqiFMhST5bX19pQ9ikHC2GsaKyk/oF+pn3ducp4=
golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k=
golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8=
golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA=
golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U=
golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw=
google.golang.org/protobuf v1.31.0 h1:g0LDEJHgrBl9N9r17Ru3sqWhkIx2NB67okBHPwC7hs8=
google.golang.org/protobuf v1.31.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I=
google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE=
google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
@@ -207,4 +215,3 @@ modernc.org/memory v1.5.0 h1:N+/8c5rE6EqugZwHii4IFsaJ7MUhoWX07J5tC/iI5Ds=
modernc.org/memory v1.5.0/go.mod h1:PkUhL0Mugw21sHPeskwZW4D6VscE/GQJOnIpCnW6pSU=
modernc.org/sqlite v1.23.1 h1:nrSBg4aRQQwq59JpvGEQ15tNxoO5pX/kUjcRNwSAGQM=
modernc.org/sqlite v1.23.1/go.mod h1:OrDj17Mggn6MhE+iPbBNf7RGKODDE9NFT0f3EwDzJqk=
rsc.io/pdf v0.1.1/go.mod h1:n8OzWcQ6Sp37PL01nO98y4iUCRdTGarVfzxY20ICaU4=
+2 -2
View File
@@ -43,8 +43,8 @@ func TestLoadDefaults(t *testing.T) {
if cfg.Cache.RedisPrefix != "mebox" {
t.Fatalf("expected default redis prefix, got %q", cfg.Cache.RedisPrefix)
}
if cfg.Cache.MediaTTLSeconds != 15 {
t.Fatalf("expected default media cache ttl 15, got %d", cfg.Cache.MediaTTLSeconds)
if cfg.Cache.MediaTTLSeconds != 90 {
t.Fatalf("expected default media cache ttl 90, got %d", cfg.Cache.MediaTTLSeconds)
}
if cfg.Search.Index != "mebox_media" {
t.Fatalf("expected default search index, got %q", cfg.Search.Index)
+1 -1
View File
@@ -47,7 +47,7 @@ func setDefaults(v *viper.Viper) {
v.SetDefault("cache.cleanup_interval_min", 60)
v.SetDefault("cache.redis_url", "")
v.SetDefault("cache.redis_prefix", "mebox")
v.SetDefault("cache.media_ttl_seconds", 15)
v.SetDefault("cache.media_ttl_seconds", 90)
v.SetDefault("search.backend", "")
v.SetDefault("search.opensearch_url", "")
+1 -1
View File
@@ -51,7 +51,7 @@ func (c *Config) normalize() error {
c.Cache.RedisPrefix = "mebox"
}
if c.Cache.MediaTTLSeconds < 1 {
c.Cache.MediaTTLSeconds = 15
c.Cache.MediaTTLSeconds = 90
}
c.Search.Backend = strings.ToLower(strings.TrimSpace(c.Search.Backend))
if c.Search.Index == "" {
+35 -1
View File
@@ -8,6 +8,11 @@ import (
// AutoMigrate creates tables for every model registered in the model package.
func AutoMigrate(db *gorm.DB) error {
// 必须先于 AutoMigrate:旧库中可能已有重复的 (user_id, media_id) 历史行,
// 不去重会导致唯一索引 uniq_user_history 创建失败。
if err := dedupePlaybackHistories(db); err != nil {
return err
}
if err := db.AutoMigrate(model.AllModels()...); err != nil {
return err
}
@@ -37,6 +42,27 @@ func ensureSQLiteQueryOptimizer(db *gorm.DB) error {
return db.Exec("ANALYZE").Error
}
// dedupePlaybackHistories removes duplicate (user_id, media_id) rows left by
// the former read-then-write upsert, so the uniq_user_history composite unique
// index can be created on existing databases. Keeps the most recent row per
// pair, preferring live rows over soft-deleted ones.
func dedupePlaybackHistories(db *gorm.DB) error {
if !db.Migrator().HasTable("playback_histories") {
return nil
}
return db.Exec(`
DELETE FROM playback_histories WHERE id IN (
SELECT id FROM (
SELECT id, ROW_NUMBER() OVER (
PARTITION BY user_id, media_id
ORDER BY deleted_at IS NULL DESC, watched_at DESC, id DESC
) AS rn
FROM playback_histories
) ranked
WHERE ranked.rn > 1
)`).Error
}
func ensurePostgresColumnCompatibility(db *gorm.DB) error {
if !isPostgres(db) {
return nil
@@ -60,8 +86,16 @@ func ensurePostgresColumnCompatibility(db *gorm.DB) error {
func ensurePerformanceIndexes(db *gorm.DB) error {
statements := []string{
// 完整多级排序索引:媒体库分页与首页预览的 ORDER BY
// (release_date, year, updated_at, created_at, id) DESC 与索引列完全一致,
// LIMIT 分页沿索引顺序直取,免去对整库行做临时 B-tree 排序。
`CREATE INDEX IF NOT EXISTS idx_media_library_recent_active ON media(library_id, release_date DESC, year DESC, updated_at DESC, created_at DESC, id DESC) WHERE deleted_at IS NULL`,
// 计数覆盖索引:首页 CountByLibraries 的 GROUP BY library_id + nsfw 谓词
// 全部落在索引键/部分索引条件上,纯索引扫描即可完成,不回表。
`CREATE INDEX IF NOT EXISTS idx_media_library_nsfw_active ON media(library_id, nsfw) WHERE deleted_at IS NULL`,
// 旧的两键前缀索引被上面的完整排序索引完全覆盖,删除以降低写放大。
`DROP INDEX IF EXISTS idx_media_library_release_active`,
`CREATE INDEX IF NOT EXISTS idx_media_library_created_active ON media(library_id, created_at DESC) WHERE deleted_at IS NULL`,
`CREATE INDEX IF NOT EXISTS idx_media_library_release_active ON media(library_id, release_date DESC, year DESC) WHERE deleted_at IS NULL`,
`CREATE INDEX IF NOT EXISTS idx_media_library_episode_active ON media(library_id, season_num, episode_num, created_at DESC) WHERE deleted_at IS NULL`,
`CREATE INDEX IF NOT EXISTS idx_media_library_root_active ON media(library_id, library_root_id) WHERE deleted_at IS NULL`,
`CREATE INDEX IF NOT EXISTS idx_media_series_active ON media(series_id, season_num, episode_num) WHERE deleted_at IS NULL`,
@@ -0,0 +1,84 @@
package database
import (
"testing"
"time"
"github.com/glebarez/sqlite"
"gorm.io/gorm"
)
// TestAutoMigrateDedupesPlaybackHistories reproduces the upgrade path: a legacy
// database contains duplicate (user_id, media_id) history rows created by the
// old read-then-write upsert. AutoMigrate must merge them before creating the
// uniq_user_history composite unique index, otherwise the upgrade fails.
func TestAutoMigrateDedupesPlaybackHistories(t *testing.T) {
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
if err != nil {
t.Fatal(err)
}
// 旧 schema:无 uniq_user_history 唯一索引。
if err := db.Exec(`CREATE TABLE playback_histories (
id varchar(36) PRIMARY KEY,
created_at datetime,
updated_at datetime,
deleted_at datetime,
user_id varchar(36) NOT NULL,
media_id varchar(128) NOT NULL,
position_ms integer,
duration_ms integer,
watched_at datetime,
completed numeric
)`).Error; err != nil {
t.Fatal(err)
}
base := time.Now()
rows := []struct {
id string
position int64
watchedAt time.Time
}{
{"h-old", 1_000, base.Add(-2 * time.Hour)},
{"h-mid", 2_000, base.Add(-1 * time.Hour)},
{"h-new", 3_000, base},
}
for _, r := range rows {
if err := db.Exec(
`INSERT INTO playback_histories (id, user_id, media_id, position_ms, watched_at, created_at, updated_at)
VALUES (?, 'u-1', 'm-1', ?, ?, ?, ?)`,
r.id, r.position, r.watchedAt, r.watchedAt, r.watchedAt,
).Error; err != nil {
t.Fatal(err)
}
}
if err := AutoMigrate(db); err != nil {
t.Fatalf("auto migrate with duplicate histories: %v", err)
}
var count int64
if err := db.Table("playback_histories").Where("user_id = ? AND media_id = ?", "u-1", "m-1").Count(&count).Error; err != nil {
t.Fatal(err)
}
if count != 1 {
t.Fatalf("expected duplicate rows merged to 1, got %d", count)
}
var position int64
if err := db.Table("playback_histories").
Where("user_id = ? AND media_id = ?", "u-1", "m-1").
Select("position_ms").Scan(&position).Error; err != nil {
t.Fatal(err)
}
if position != 3_000 {
t.Fatalf("dedupe should keep the most recent row, got position_ms=%d", position)
}
// 唯一索引存在时,重复插入同一 (user_id, media_id) 应触发冲突而非新增行。
if err := db.Exec(
`INSERT INTO playback_histories (id, user_id, media_id, position_ms, watched_at, created_at, updated_at)
VALUES ('h-dup', 'u-1', 'm-1', 4_000, ?, ?, ?)`,
base, base, base,
).Error; err == nil {
t.Fatal("insert violating uniq_user_history should fail")
}
}
+2
View File
@@ -9,12 +9,14 @@ import (
"go.uber.org/zap"
"github.com/truewhile/MeBox/internal/config"
"github.com/truewhile/MeBox/internal/middleware"
"github.com/truewhile/MeBox/internal/service"
)
// Register attaches every API route to the engine.
func Register(r *gin.Engine, cfg *config.Config, log *zap.Logger, svc *service.Container) {
api := r.Group("/api")
api.Use(middleware.GzipAPI())
{
api.GET("/health", healthCheck)
api.GET("/version", versionInfo)
+75 -32
View File
@@ -11,8 +11,10 @@ import (
"github.com/gin-gonic/gin"
"github.com/truewhile/MeBox/internal/helper"
"github.com/truewhile/MeBox/internal/middleware"
"github.com/truewhile/MeBox/internal/model"
"github.com/truewhile/MeBox/internal/repository"
"github.com/truewhile/MeBox/internal/service"
)
@@ -58,17 +60,37 @@ func listLibrariesHandler(svc *service.Container) gin.HandlerFunc {
}
role, _ := c.Get(middleware.CtxUserRole)
includeHidden := role == "admin" && (c.Query("include_hidden") == "1" || c.Query("include_hidden") == "true" || c.Query("all") == "1")
if !includeHidden {
libs = service.FilterDisplayCloudLibraries(ctx, svc.Repo, libs)
visibility := mediaVisibilityForRequest(c, svc)
filtered := libs[:0]
for _, lib := range libs {
if service.LibraryVisibleForUser(ctx, svc.Repo, lib, visibility) {
filtered = append(filtered, lib)
if !includeHidden {
libs = service.FilterDisplayCloudLibraries(ctx, svc.Repo, libs)
visibility := mediaVisibilityForRequest(c, svc)
filtered := libs[:0]
for _, lib := range libs {
if service.LibraryVisibleForUser(ctx, svc.Repo, lib, visibility) {
filtered = append(filtered, lib)
}
}
libs = filtered
}
rawIDs := strings.TrimSpace(c.Query("ids"))
var targetSet map[string]struct{}
if rawIDs != "" {
targetSet = make(map[string]struct{})
for _, id := range strings.Split(rawIDs, ",") {
id = strings.TrimSpace(id)
if id != "" {
targetSet[id] = struct{}{}
}
}
}
libs = filtered
}
if len(targetSet) > 0 {
filtered := libs[:0]
for _, lib := range libs {
if _, ok := targetSet[lib.ID]; ok {
filtered = append(filtered, lib)
}
}
libs = filtered
}
withPreview := c.Query("with_preview") == "1" || c.Query("with_preview") == "true"
limit := 10
if withPreview {
@@ -89,22 +111,41 @@ func listLibrariesHandler(svc *service.Container) gin.HandlerFunc {
for _, p := range previews {
out = append(out, webLibraryPayload{Library: p.Library, Total: p.Total, Cards: p.Cards})
}
} else {
for _, l := range libs {
out = append(out, webLibraryPayload{Library: l})
} else {
visibility := mediaVisibilityForRequest(c, svc)
libIDs := make([]string, len(libs))
for i, l := range libs {
libIDs[i] = l.ID
}
counts, _ := svc.Repo.Media.CountByLibraries(ctx, libIDs, repository.MediaQueryFilter{
IncludeNSFW: visibility.IncludeNSFW,
AllowedLibraryIDs: visibility.AllowedLibraryIDs,
HiddenLibraryIDs: visibility.HiddenLibraryIDs,
})
for _, l := range libs {
var total int64
if counts != nil {
total = counts[l.ID]
}
out = append(out, webLibraryPayload{Library: l, Total: total})
}
}
}
// 远程 Emby 挂载库追加在本地库之后(非管理员视图仍受 allowed_library_ids 约束)。
if svc.EmbyRemote != nil {
if views, err := svc.EmbyRemote.RemoteLibraries(ctx); err == nil {
visibility := mediaVisibilityForRequest(c, svc)
allowedViews := make([]service.RemoteLibraryView, 0, len(views))
for _, v := range views {
if !includeHidden && !service.LibraryVisibleForUser(ctx, svc.Repo, v.Library, visibility) {
continue
visibility := mediaVisibilityForRequest(c, svc)
allowedViews := make([]service.RemoteLibraryView, 0, len(views))
for _, v := range views {
if !includeHidden && !service.LibraryVisibleForUser(ctx, svc.Repo, v.Library, visibility) {
continue
}
if len(targetSet) > 0 {
if _, ok := targetSet[v.Library.ID]; !ok {
continue
}
}
allowedViews = append(allowedViews, v)
}
allowedViews = append(allowedViews, v)
}
remotePayloads := make([]webLibraryPayload, len(allowedViews))
for i, v := range allowedViews {
remotePayloads[i] = webLibraryPayload{Library: v.Library, IsRemoteEmby: true, RemoteSource: v.AccountName}
@@ -124,18 +165,20 @@ func listLibrariesHandler(svc *service.Container) gin.HandlerFunc {
case <-ctx.Done():
return
}
acct := svc.EmbyRemote.AccountByID(ctx, v.AccountID)
if acct == nil {
return
}
tmpMount := &model.EmbyMount{Base: model.Base{ID: v.MountID}}
itemTypes := remoteLibraryItemTypes(v.CollectionType)
if _, total, err := svc.EmbyRemote.RemoteLibraryMedia(ctx, tmpMount, acct, v.RemoteID, itemTypes, 0, 1); err == nil {
remotePayloads[i].Total = total
}
if cards, err := svc.EmbyRemote.RemoteLatestCards(ctx, tmpMount, acct, v.RemoteID, limit); err == nil {
remotePayloads[i].Cards = cards
}
helper.Run(svc.Log, "media.remotePreview", func() {
acct := svc.EmbyRemote.AccountByID(ctx, v.AccountID)
if acct == nil {
return
}
tmpMount := &model.EmbyMount{Base: model.Base{ID: v.MountID}}
itemTypes := remoteLibraryItemTypes(v.CollectionType)
if _, total, err := svc.EmbyRemote.RemoteLibraryMedia(ctx, tmpMount, acct, v.RemoteID, itemTypes, 0, 1); err == nil {
remotePayloads[i].Total = total
}
if cards, err := svc.EmbyRemote.RemoteLatestCards(ctx, tmpMount, acct, v.RemoteID, limit); err == nil {
remotePayloads[i].Cards = cards
}
})
}()
}
wg.Wait()
+35
View File
@@ -0,0 +1,35 @@
// Package handler — media delete endpoint.
package handler
import (
"net/http"
"strconv"
"strings"
"github.com/gin-gonic/gin"
"github.com/truewhile/MeBox/internal/service"
)
func deleteMediaHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
deleteFiles := parseDeleteFilesQuery(c.Query("delete_files"))
if err := svc.Media.DeleteMedia(c.Request.Context(), c.Param("id"), deleteFiles); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.Status(http.StatusNoContent)
}
}
func parseDeleteFilesQuery(raw string) bool {
raw = strings.TrimSpace(strings.ToLower(raw))
if raw == "" {
return false
}
if raw == "1" || raw == "true" || raw == "yes" {
return true
}
v, err := strconv.ParseBool(raw)
return err == nil && v
}
+23
View File
@@ -0,0 +1,23 @@
package handler
import "testing"
func TestParseDeleteFilesQuery(t *testing.T) {
cases := []struct {
raw string
want bool
}{
{"", false},
{"0", false},
{"false", false},
{"1", true},
{"true", true},
{"TRUE", true},
{"yes", true},
}
for _, tc := range cases {
if got := parseDeleteFilesQuery(tc.raw); got != tc.want {
t.Fatalf("parseDeleteFilesQuery(%q)=%v, want %v", tc.raw, got, tc.want)
}
}
}
+18 -7
View File
@@ -7,6 +7,7 @@ import (
"github.com/gin-gonic/gin"
"github.com/truewhile/MeBox/internal/helper"
"github.com/truewhile/MeBox/internal/service"
)
@@ -41,7 +42,11 @@ func scanLibraryHandler(svc *service.Container) gin.HandlerFunc {
task := startScanHTTPTask(svc, "手动扫描入库", lib.Name, lib.Path)
go func(libraryID string, task *service.TaskHandle, finish func()) {
defer finish()
res, err := svc.Scan.ScanLibrary(context.Background(), libraryID)
var res *service.ScanResult
var err error
helper.Run(svc.Log, "scan.library", func() {
res, err = svc.Scan.ScanLibrary(context.Background(), libraryID)
})
if err != nil {
finishHTTPTask(task, err, "scan", "手动扫描入库失败", scanTaskMetrics(res), scanTaskDetails(res, 20))
return
@@ -75,7 +80,11 @@ func scanLibraryRootHandler(svc *service.Container) gin.HandlerFunc {
task := startScanHTTPTask(svc, "手动扫描媒体库路径", id, rootID)
go func(libraryID, libraryRootID string, task *service.TaskHandle, finish func()) {
defer finish()
res, err := svc.Scan.ScanLibraryRoot(context.Background(), libraryID, libraryRootID)
var res *service.ScanResult
var err error
helper.Run(svc.Log, "scan.libraryRoot", func() {
res, err = svc.Scan.ScanLibraryRoot(context.Background(), libraryID, libraryRootID)
})
if err != nil {
finishHTTPTask(task, err, "scan", "手动扫描路径失败", scanTaskMetrics(res), scanTaskDetails(res, 20))
return
@@ -110,11 +119,13 @@ func queueLibraryRootScan(svc *service.Container, libraryID, rootID string) {
}
go func() {
defer finish()
if strings.TrimSpace(rootID) == "" {
_, _ = svc.Scan.ScanLibrary(context.Background(), libraryID)
return
}
_, _ = svc.Scan.ScanLibraryRoot(context.Background(), libraryID, rootID)
helper.Run(svc.Log, "scan.queuedRoot", func() {
if strings.TrimSpace(rootID) == "" {
_, _ = svc.Scan.ScanLibrary(context.Background(), libraryID)
return
}
_, _ = svc.Scan.ScanLibraryRoot(context.Background(), libraryID, rootID)
})
}()
}
+9 -4
View File
@@ -104,11 +104,16 @@ func TestListLibrariesHidesAdultDirectoriesUnlessAdminRequestsAll(t *testing.T)
t.Fatalf("watching library list should hide adult directories, got %#v", visible)
}
all := requestLibraries(t, svc, viewer.ID, "admin", "/api/libraries?include_hidden=1")
if len(all) != 2 {
t.Fatalf("admin include_hidden list should keep management access, got %#v", all)
all := requestLibraries(t, svc, viewer.ID, "admin", "/api/libraries?include_hidden=1")
if len(all) != 2 {
t.Fatalf("admin include_hidden list should keep management access, got %#v", all)
}
filtered := requestLibraries(t, svc, viewer.ID, "admin", "/api/libraries?include_hidden=1&ids="+safe.ID)
if len(filtered) != 1 || filtered[0].ID != safe.ID {
t.Fatalf("ids filter should return only requested library, got %#v", filtered)
}
}
}
func TestGetLibraryAllowsEmptyLibrary(t *testing.T) {
gin.SetMode(gin.TestMode)
-40
View File
@@ -1,40 +0,0 @@
// Package handler — recycle bin endpoints.
package handler
import (
"net/http"
"github.com/gin-gonic/gin"
"github.com/truewhile/MeBox/internal/service"
)
func deleteMediaHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
if err := svc.Media.SoftDelete(c.Request.Context(), c.Param("id")); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.Status(http.StatusNoContent)
}
}
func restoreMediaHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
if err := svc.Media.RestoreDeleted(c.Request.Context(), c.Param("id")); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.Status(http.StatusNoContent)
}
}
func purgeMediaHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
if err := svc.Media.PurgeDeleted(c.Request.Context(), c.Param("id")); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.Status(http.StatusNoContent)
}
}
+1 -1
View File
@@ -21,7 +21,7 @@ func registerAuthenticatedRoutes(api *gin.RouterGroup, cfg *config.Config, svc *
registerAuthedCollectionRoutes(authed, svc)
registerAuthedFileRoutes(authed, svc)
registerAuthedDLNARoutes(authed, svc)
registerAuthedRecycleAndRealtimeRoutes(authed, svc)
registerAuthedRealtimeRoutes(authed, svc)
registerAuthedUISurfaceRoutes(authed, svc)
registerAuthedSearchRoutes(authed, svc)
registerAuthedSystemExtraRoutes(authed, svc)
@@ -50,8 +50,6 @@ func registerAuthedMediaRoutes(authed *gin.RouterGroup, svc *service.Container)
authed.POST("/media/scrape/apply", middleware.AdminRequired(), manualScrapeApplyBatchHandler(svc))
authed.POST("/media/:id/probe", middleware.AdminRequired(), reprobeHandler(svc))
authed.DELETE("/media/:id", middleware.AdminRequired(), deleteMediaHandler(svc))
authed.POST("/media/:id/restore", middleware.AdminRequired(), restoreMediaHandler(svc))
authed.DELETE("/media/:id/purge", middleware.AdminRequired(), purgeMediaHandler(svc))
authed.GET("/media/:id/subtitles", listSubtitlesHandler(svc))
authed.GET("/subtitles/:id", serveSubtitleHandler(svc))
authed.POST("/media/:id/nfo", middleware.AdminRequired(), exportNFOHandler(svc))
@@ -20,7 +20,7 @@ func registerAuthedDLNARoutes(authed *gin.RouterGroup, svc *service.Container) {
authed.POST("/dlna/cast", dlnaCastHandler(svc))
}
func registerAuthedRecycleAndRealtimeRoutes(authed *gin.RouterGroup, svc *service.Container) {
func registerAuthedRealtimeRoutes(authed *gin.RouterGroup, svc *service.Container) {
authed.GET("/ws", wsHandler(svc))
authed.GET("/events", sseHandler(svc))
}
+53
View File
@@ -0,0 +1,53 @@
// Package helper provides shared utilities.
package helper
import (
"fmt"
"os"
"runtime/debug"
"go.uber.org/zap"
)
// Go runs fn in a new goroutine and recovers from panics so that a failure in
// a background task (scraper parsing remote responses, cloud-drive sync, ...)
// is logged instead of crashing the whole process. log may be nil.
func Go(log *zap.Logger, name string, fn func()) {
go Run(log, name, fn)
}
// Run executes fn and recovers from panics, logging the task name and stack.
// Use it as the first statement inside goroutines spawned elsewhere, or wrap
// loop bodies so one bad iteration cannot kill a long-running worker.
func Run(log *zap.Logger, name string, fn func()) {
defer func() {
if r := recover(); r != nil {
logPanic(log, name, r)
}
}()
fn()
}
// Recover runs fn and converts a panic into an error so callers can run their
// own deferred cleanup (releasing locks, updating job state) before unwinding.
func Recover(log *zap.Logger, name string, fn func() error) (err error) {
defer func() {
if r := recover(); r != nil {
logPanic(log, name, r)
err = fmt.Errorf("%s panicked: %v", name, r)
}
}()
return fn()
}
func logPanic(log *zap.Logger, name string, r any) {
if log == nil {
fmt.Fprintf(os.Stderr, "background task panicked: task=%s panic=%v\n%s\n", name, r, debug.Stack())
return
}
log.Error("background task panicked",
zap.String("task", name),
zap.Any("panic", r),
zap.ByteString("stack", debug.Stack()),
)
}
+95
View File
@@ -0,0 +1,95 @@
package helper
import (
"errors"
"strings"
"testing"
"time"
"go.uber.org/zap"
"go.uber.org/zap/zaptest/observer"
)
func newObservedLogger(t *testing.T) (*zap.Logger, *observer.ObservedLogs) {
t.Helper()
core, logs := observer.New(zap.ErrorLevel)
return zap.New(core), logs
}
func waitForLogs(t *testing.T, logs *observer.ObservedLogs, n int) []observer.LoggedEntry {
t.Helper()
deadline := time.Now().Add(2 * time.Second)
for time.Now().Before(deadline) {
if entries := logs.All(); len(entries) >= n {
return entries
}
time.Sleep(5 * time.Millisecond)
}
t.Fatalf("timed out waiting for %d log entries, got %d", n, logs.Len())
return nil
}
func TestRunRecoversPanic(t *testing.T) {
log, logs := newObservedLogger(t)
ran := false
Run(log, "unit.panic", func() {
ran = true
panic("boom")
})
if !ran {
t.Fatal("fn should have run before panicking")
}
entries := waitForLogs(t, logs, 1)
if entries[0].Message != "background task panicked" {
t.Fatalf("unexpected message: %s", entries[0].Message)
}
found := false
for _, f := range entries[0].Context {
if f.Key == "task" && f.String == "unit.panic" {
found = true
}
}
if !found {
t.Fatalf("expected task name in log context: %v", entries[0].Context)
}
}
func TestRunNoPanicNoLog(t *testing.T) {
log, logs := newObservedLogger(t)
Run(log, "unit.ok", func() {})
time.Sleep(10 * time.Millisecond)
if logs.Len() != 0 {
t.Fatalf("expected no error log, got %d", logs.Len())
}
}
func TestRecoverConvertsPanicToError(t *testing.T) {
log, _ := newObservedLogger(t)
err := Recover(log, "unit.recover", func() error {
panic("kaboom")
})
if err == nil {
t.Fatal("expected error from recovered panic")
}
if !strings.Contains(err.Error(), "kaboom") {
t.Fatalf("panic value should be in error: %v", err)
}
}
func TestRecoverReturnsFnError(t *testing.T) {
sentinel := errors.New("plain failure")
err := Recover(nil, "unit.err", func() error { return sentinel })
if !errors.Is(err, sentinel) {
t.Fatalf("expected fn error, got %v", err)
}
}
func TestRunWithNilLoggerDoesNotCrash(t *testing.T) {
Run(nil, "unit.nillog", func() { panic("still caught") })
}
func TestGoLogsPanicFromSpawnedGoroutine(t *testing.T) {
log, logs := newObservedLogger(t)
Go(log, "unit.go", func() { panic("async boom") })
waitForLogs(t, logs, 1)
}
+47
View File
@@ -0,0 +1,47 @@
package middleware
import (
"github.com/gin-contrib/gzip"
"github.com/gin-gonic/gin"
)
// gzipExcludedExtensions 已经是压缩格式(图片/字体/媒体/归档)的响应体,
// 再 gzip 只浪费 CPU 不省流量。
var gzipExcludedExtensions = []string{
".png", ".gif", ".jpeg", ".jpg", ".webp", ".avif", ".ico", ".svg",
".woff", ".woff2", ".ttf", ".otf",
".mp4", ".mkv", ".webm", ".ts", ".m4s", ".m3u8",
".mp3", ".flac", ".aac", ".ogg",
".zip", ".gz", ".xz", ".7z", ".rar",
}
// apiGzipExcludedPrefixes 大文件流式传输(Range 语义)与 WS/SSE 长连接
// 不参与 gzip:压缩会破坏 Range / 逐块推送语义。
var apiGzipExcludedPrefixes = []string{
"/api/stream/",
"/api/hls/",
"/api/img",
"/api/subtitles/",
"/api/strm/play/",
"/api/ws",
"/api/events",
}
// GzipAPI 压缩 /api 下的 JSON 响应(媒体列表动辄数 MB,压缩率 85%+)。
// gin-contrib/gzip 的路径排除是前缀匹配,且会自动校验 Accept-Encoding
// 与 Connection: Upgrade(WebSocket 安全)。
func GzipAPI() gin.HandlerFunc {
return gzip.Gzip(
gzip.DefaultCompression,
gzip.WithExcludedExtensions(gzipExcludedExtensions),
gzip.WithExcludedPaths(apiGzipExcludedPrefixes),
)
}
// GzipStatic 压缩 SPA 静态资源(JS/CSS/HTML 是构建产物的大头)。
func GzipStatic() gin.HandlerFunc {
return gzip.Gzip(
gzip.DefaultCompression,
gzip.WithExcludedExtensions(gzipExcludedExtensions),
)
}
+84
View File
@@ -0,0 +1,84 @@
package middleware
import (
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/gin-gonic/gin"
)
func newGzipTestRouter() *gin.Engine {
gin.SetMode(gin.TestMode)
router := gin.New()
api := router.Group("/api")
api.Use(GzipAPI())
api.GET("/libraries", func(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"items": strings.Repeat("mebox", 200)})
})
api.GET("/stream/:id", func(c *gin.Context) {
c.String(http.StatusOK, strings.Repeat("video-bytes", 200))
})
router.GET("/assets/app.js", GzipStatic(), func(c *gin.Context) {
c.Data(http.StatusOK, "text/javascript", []byte(strings.Repeat("console.log(1);", 200)))
})
return router
}
func TestGzipAPICompressesJSONWhenAccepted(t *testing.T) {
router := newGzipTestRouter()
req := httptest.NewRequest(http.MethodGet, "/api/libraries", nil)
req.Header.Set("Accept-Encoding", "gzip")
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("status = %d, want 200", w.Code)
}
if got := w.Header().Get("Content-Encoding"); got != "gzip" {
t.Fatalf("Content-Encoding = %q, want gzip", got)
}
if raw := strings.Repeat("mebox", 200); w.Body.Len() >= len(raw) {
t.Fatalf("body not compressed: len = %d, raw = %d", w.Body.Len(), len(raw))
}
}
func TestGzipAPISkipsWhenNotAccepted(t *testing.T) {
router := newGzipTestRouter()
req := httptest.NewRequest(http.MethodGet, "/api/libraries", nil)
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
if got := w.Header().Get("Content-Encoding"); got == "gzip" {
t.Fatal("Content-Encoding should not be gzip without Accept-Encoding")
}
}
func TestGzipAPIExcludesStreamPath(t *testing.T) {
router := newGzipTestRouter()
req := httptest.NewRequest(http.MethodGet, "/api/stream/abc", nil)
req.Header.Set("Accept-Encoding", "gzip")
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
if got := w.Header().Get("Content-Encoding"); got == "gzip" {
t.Fatal("stream path must not be gzipped (Range semantics)")
}
}
func TestGzipStaticCompressesAssets(t *testing.T) {
router := newGzipTestRouter()
req := httptest.NewRequest(http.MethodGet, "/assets/app.js", nil)
req.Header.Set("Accept-Encoding", "gzip")
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
if got := w.Header().Get("Content-Encoding"); got != "gzip" {
t.Fatalf("Content-Encoding = %q, want gzip for static assets", got)
}
}
+4 -2
View File
@@ -3,10 +3,12 @@ package model
import "time"
// PlaybackHistory 记录当前播放位置以支持续播。
// (user_id, media_id) 唯一:播放进度每几秒上报一次,唯一索引保证并发上报
// 不会插入重复行(否则续播列表会出现重复卡片),也让 upsert 单语句完成。
type PlaybackHistory struct {
Base
UserID string `gorm:"index;size:36;not null" json:"user_id"`
MediaID string `gorm:"index;size:128;not null" json:"media_id"`
UserID string `gorm:"index;size:36;not null;uniqueIndex:uniq_user_history" json:"user_id"`
MediaID string `gorm:"index;size:128;not null;uniqueIndex:uniq_user_history" json:"media_id"`
PositionMs int64 `json:"position_ms"`
DurationMs int64 `json:"duration_ms"`
WatchedAt time.Time `json:"watched_at"`
+18 -19
View File
@@ -2,9 +2,9 @@ package repository
import (
"context"
"errors"
"gorm.io/gorm"
"gorm.io/gorm/clause"
"github.com/truewhile/MeBox/internal/model"
)
@@ -13,25 +13,24 @@ import (
// upserts on (UserID, MediaID) so resume always reads the latest position.
type HistoryRepository struct{ db *gorm.DB }
// Upsert atomically inserts/updates the resume position.
// Upsert atomically inserts/updates the resume position in a single statement,
// relying on the uniq_user_history composite unique index. Concurrent progress
// reports for the same (user, media) can no longer double-insert.
func (r *HistoryRepository) Upsert(ctx context.Context, h *model.PlaybackHistory) error {
var existing model.PlaybackHistory
err := r.db.WithContext(ctx).
Where("user_id = ? AND media_id = ?", h.UserID, h.MediaID).
First(&existing).Error
if errors.Is(err, gorm.ErrRecordNotFound) {
return r.db.WithContext(ctx).Create(h).Error
}
if err != nil {
return err
}
existing.PositionMs = h.PositionMs
if h.DurationMs > 0 {
existing.DurationMs = h.DurationMs
}
existing.WatchedAt = h.WatchedAt
existing.Completed = h.Completed
return r.db.WithContext(ctx).Save(&existing).Error
return r.db.WithContext(ctx).Clauses(clause.OnConflict{
Columns: []clause.Column{{Name: "user_id"}, {Name: "media_id"}},
DoUpdates: clause.Assignments(map[string]any{
"position_ms": h.PositionMs,
// 沿用旧语义:未知时长(0)不覆盖已记录的时长。
"duration_ms": gorm.Expr(
"CASE WHEN ? > 0 THEN ? ELSE playback_histories.duration_ms END",
h.DurationMs, h.DurationMs,
),
"watched_at": h.WatchedAt,
"completed": h.Completed,
"deleted_at": nil,
}),
}).Create(h).Error
}
// ListByUser returns the most recent history rows for the user.
@@ -0,0 +1,79 @@
package repository
import (
"testing"
"time"
"github.com/glebarez/sqlite"
"gorm.io/gorm"
"github.com/truewhile/MeBox/internal/database"
"github.com/truewhile/MeBox/internal/model"
)
func TestHistoryUpsertSingleRowPerUserMedia(t *testing.T) {
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
if err != nil {
t.Fatal(err)
}
if err := database.AutoMigrate(db); err != nil {
t.Fatalf("migrate: %v", err)
}
repos := New(db)
ctx := t.Context()
watched := time.Now()
first := &model.PlaybackHistory{UserID: "u-1", MediaID: "m-1", PositionMs: 30_000, DurationMs: 0, WatchedAt: watched, Completed: false}
if err := repos.History.Upsert(ctx, first); err != nil {
t.Fatalf("first upsert: %v", err)
}
second := &model.PlaybackHistory{UserID: "u-1", MediaID: "m-1", PositionMs: 90_000, DurationMs: 120_000, WatchedAt: watched.Add(time.Minute), Completed: true}
if err := repos.History.Upsert(ctx, second); err != nil {
t.Fatalf("second upsert: %v", err)
}
var count int64
if err := db.Model(&model.PlaybackHistory{}).Where("user_id = ? AND media_id = ?", "u-1", "m-1").Count(&count).Error; err != nil {
t.Fatal(err)
}
if count != 1 {
t.Fatalf("expected 1 history row after upserts, got %d", count)
}
var got model.PlaybackHistory
if err := db.Where("user_id = ? AND media_id = ?", "u-1", "m-1").First(&got).Error; err != nil {
t.Fatal(err)
}
if got.PositionMs != 90_000 || !got.Completed {
t.Fatalf("position/completion not updated: %#v", got)
}
if got.DurationMs != 120_000 {
t.Fatalf("duration should update when known, got %d", got.DurationMs)
}
}
func TestHistoryUpsertKeepsDurationWhenUnknown(t *testing.T) {
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
if err != nil {
t.Fatal(err)
}
if err := database.AutoMigrate(db); err != nil {
t.Fatalf("migrate: %v", err)
}
repos := New(db)
ctx := t.Context()
watched := time.Now()
if err := repos.History.Upsert(ctx, &model.PlaybackHistory{UserID: "u-1", MediaID: "m-2", PositionMs: 10, DurationMs: 600_000, WatchedAt: watched}); err != nil {
t.Fatal(err)
}
if err := repos.History.Upsert(ctx, &model.PlaybackHistory{UserID: "u-1", MediaID: "m-2", PositionMs: 20, DurationMs: 0, WatchedAt: watched.Add(time.Second)}); err != nil {
t.Fatal(err)
}
var got model.PlaybackHistory
if err := db.Where("user_id = ? AND media_id = ?", "u-1", "m-2").First(&got).Error; err != nil {
t.Fatal(err)
}
if got.DurationMs != 600_000 {
t.Fatalf("duration_ms=0 upsert must not clear stored duration, got %d", got.DurationMs)
}
}
+2 -2
View File
@@ -240,8 +240,8 @@ func (r *MediaRepository) DeleteByLibraryRoot(ctx context.Context, libraryID, ro
Delete(&model.Media{}).Error
}
// PurgeByLibrary permanently removes media tied to a library. Used for virtual
// cloud mounts where "remove mount" must not populate the recycle bin.
// PurgeByLibrary permanently removes media tied to a library. Used when
// removing a library or virtual mount so indexed rows are dropped immediately.
func (r *MediaRepository) PurgeByLibrary(ctx context.Context, libraryID string) error {
return r.db.WithContext(ctx).Unscoped().Where("library_id = ?", libraryID).Delete(&model.Media{}).Error
}
+32 -10
View File
@@ -23,12 +23,34 @@ import (
// 永远捞不到数据。
func (r *MediaRepository) Upsert(ctx context.Context, m *model.Media) error {
return withSQLiteBusyRetry(ctx, func() error {
return r.upsert(ctx, m)
return r.upsertWithDB(ctx, r.db, m)
})
}
func (r *MediaRepository) upsert(ctx context.Context, m *model.Media) error {
existing, created, err := r.findOrCreateMediaByPath(ctx, m)
// UpsertBatch 在单个事务里逐条执行 Upsert:扫描一批只提交(fsync)一次,
// 而不是每条一个隐式事务。任一条目落库失败不影响批内已成功的条目——
// 事务回滚后由调用方退回逐条 Upsert 兜底。
func (r *MediaRepository) UpsertBatch(ctx context.Context, items []*model.Media) error {
if len(items) == 0 {
return nil
}
return withSQLiteBusyRetry(ctx, func() error {
return r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
for _, m := range items {
if m == nil {
continue
}
if err := r.upsertWithDB(ctx, tx, m); err != nil {
return err
}
}
return nil
})
})
}
func (r *MediaRepository) upsertWithDB(ctx context.Context, db *gorm.DB, m *model.Media) error {
existing, created, err := r.findOrCreateMediaByPath(ctx, db, m)
if err != nil {
return err
}
@@ -38,20 +60,20 @@ func (r *MediaRepository) upsert(ctx context.Context, m *model.Media) error {
}
updates := mediaUpsertUpdates(existing, *m)
return r.applyMediaUpsertUpdates(ctx, m, existing, updates)
return r.applyMediaUpsertUpdates(ctx, db, m, existing, updates)
}
func (r *MediaRepository) findOrCreateMediaByPath(ctx context.Context, m *model.Media) (model.Media, bool, error) {
func (r *MediaRepository) findOrCreateMediaByPath(ctx context.Context, db *gorm.DB, m *model.Media) (model.Media, bool, error) {
var existing model.Media
err := r.db.WithContext(ctx).Unscoped().Where("path = ?", m.Path).First(&existing).Error
err := db.WithContext(ctx).Unscoped().Where("path = ?", m.Path).First(&existing).Error
if errors.Is(err, gorm.ErrRecordNotFound) {
// 新行:保证 scrape_status 走 GORM default:pending(即留空让数据库填)。
if m.ScrapeStatus == "" {
m.ScrapeStatus = "pending"
}
if createErr := r.db.WithContext(ctx).Create(m).Error; createErr == nil {
if createErr := db.WithContext(ctx).Create(m).Error; createErr == nil {
return *m, true, nil
} else if retryErr := r.db.WithContext(ctx).Unscoped().Where("path = ?", m.Path).First(&existing).Error; retryErr != nil {
} else if retryErr := db.WithContext(ctx).Unscoped().Where("path = ?", m.Path).First(&existing).Error; retryErr != nil {
return model.Media{}, false, createErr
}
}
@@ -237,12 +259,12 @@ func setNonEmptyMediaString(updates map[string]any, key, current, next string) {
}
}
func (r *MediaRepository) applyMediaUpsertUpdates(ctx context.Context, m *model.Media, existing model.Media, updates map[string]any) error {
func (r *MediaRepository) applyMediaUpsertUpdates(ctx context.Context, db *gorm.DB, m *model.Media, existing model.Media, updates map[string]any) error {
if len(updates) == 0 {
*m = existing
return nil
}
if err := r.db.WithContext(ctx).Unscoped().Model(&model.Media{}).
if err := db.WithContext(ctx).Unscoped().Model(&model.Media{}).
Where("id = ?", existing.ID).Updates(updates).Error; err != nil {
return err
}
+4 -1
View File
@@ -12,6 +12,7 @@ import (
"go.uber.org/zap"
"github.com/truewhile/MeBox/internal/helper"
"github.com/truewhile/MeBox/internal/model"
"github.com/truewhile/MeBox/internal/repository"
)
@@ -50,6 +51,8 @@ func (a *AuditService) RecordBestEffort(userID, action, target, ip, detail strin
go func() {
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()
a.Record(ctx, userID, action, target, ip, detail)
helper.Run(a.log, "audit.record", func() {
a.Record(ctx, userID, action, target, ip, detail)
})
}()
}
+6 -3
View File
@@ -13,6 +13,7 @@ import (
"golang.org/x/crypto/bcrypt"
"github.com/truewhile/MeBox/internal/config"
"github.com/truewhile/MeBox/internal/helper"
"github.com/truewhile/MeBox/internal/model"
"github.com/truewhile/MeBox/internal/repository"
)
@@ -176,9 +177,11 @@ func (s *AuthService) touchLoginBestEffort(userID string) {
go func() {
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()
if err := s.repo.User.TouchLogin(ctx, userID); err != nil && s.log != nil {
s.log.Debug("touch login delayed", zap.String("user_id", userID), zap.Error(err))
}
helper.Run(s.log, "auth.touchLogin", func() {
if err := s.repo.User.TouchLogin(ctx, userID); err != nil && s.log != nil {
s.log.Debug("touch login delayed", zap.String("user_id", userID), zap.Error(err))
}
})
}()
}
+1 -1
View File
@@ -54,7 +54,7 @@ func (e *EmbyService) countVisibleSeries(ctx context.Context, userID string) (in
for i := range rows {
key := strings.TrimSpace(rows[i].SeriesID)
if key == "" {
key = stableEmbyID(embyVirtualSeriesPrefix, rows[i].LibraryID, e.seriesNameForMedia(&rows[i]))
key = stableEmbyID(embyVirtualSeriesPrefix, rows[i].LibraryID, e.seriesNameForMedia(ctx, &rows[i]))
}
seen[key] = struct{}{}
}
+1 -1
View File
@@ -49,7 +49,7 @@ func (e *EmbyService) embyLatestCacheKey(userID, parentID string, limit int) str
func (e *EmbyService) mediaCacheTTLSeconds() int {
if e == nil || e.cfg == nil || e.cfg.Cache.MediaTTLSeconds < 1 {
return 15
return 90
}
return e.cfg.Cache.MediaTTLSeconds
}
+6 -5
View File
@@ -98,7 +98,8 @@ func (e *EmbyService) Item(ctx context.Context, mediaID, userID string) (map[str
pos = h.PositionMs
}
}
return e.itemPayload(ctx, m, fav, pos), nil
// 单条目 payload 内部对库类型/series 标题有多次查找,挂请求级缓存合并。
return e.itemPayload(e.withPayloadCache(ctx), m, fav, pos), nil
}
// LatestItems 最近添加,全库或指定库。远程媒体库(parentID 带前缀)直接透传远程。
@@ -177,7 +178,7 @@ func (e *EmbyService) latestSeriesItemsForLibrary(ctx context.Context, userID, l
if err := q.Order(mediaReleaseOrderSQL(true)).Limit(embySeriesGroupingLimit).Find(&rows).Error; err != nil {
return nil, err
}
groups := e.seriesGroupsFromMedia(rows)
groups := e.seriesGroupsFromMedia(ctx, rows)
sortSeriesGroups(groups, ItemsParams{SortBy: "premieredate", SortOrder: "Descending"})
if len(groups) > limit {
groups = groups[:limit]
@@ -415,9 +416,9 @@ func (e *EmbyService) itemPayload(ctx context.Context, m *model.Media, fav bool,
seasonID := ""
if e.mediaShouldBeEpisode(ctx, m) {
itemType = "Episode"
seriesID = e.seriesIDForMedia(m)
seriesName = e.seriesNameForMedia(m)
seasonID = e.seasonIDForMedia(m)
seriesID = e.seriesIDForMedia(ctx, m)
seriesName = e.seriesNameForMedia(ctx, m)
seasonID = e.seasonIDForMedia(ctx, m)
parentID = seasonID
episodeTitle := strings.TrimSpace(m.EpisodeTitle)
if episodeTitle != "" {
+4 -1
View File
@@ -149,6 +149,9 @@ func (e *EmbyService) episodeItems(ctx context.Context, rows []model.Media, p It
}
func (e *EmbyService) payloadsForMedia(ctx context.Context, rows []model.Media, userID string) ([]map[string]any, error) {
// 请求级缓存:库类型与 series 标题整页只查一次,消除逐条目 N+1。
ctx = e.withPayloadCache(ctx)
e.prefetchPayloadCache(ctx, rows)
rows = e.collapseMediaVersionRows(ctx, rows)
userFavs := map[string]bool{}
userPos := map[string]int64{}
@@ -240,7 +243,7 @@ func (e *EmbyService) seriesItemsForLibrary(ctx context.Context, libraryID strin
if err := q.Order(mediaReleaseOrderSQL(true)).Limit(embySeriesGroupingLimit).Find(&rows).Error; err != nil {
return nil, err
}
groups := e.seriesGroupsFromMedia(rows)
groups := e.seriesGroupsFromMedia(ctx, rows)
sortSeriesGroups(groups, p)
total := len(groups)
items := make([]map[string]any, 0, minInt(p.Limit, len(groups)))
+8 -7
View File
@@ -65,7 +65,7 @@ func (e *EmbyService) movieLibraryItems(ctx context.Context, p ItemsParams) (map
return nil, err
}
}
seriesGroups := e.seriesGroupsFromMedia(episodicRows)
seriesGroups := e.seriesGroupsFromMedia(ctx, episodicRows)
// 真正的电影 -> Movie 项(剔除剧集结构行)。
movieQ := apply(e.repo.DB.WithContext(ctx).Model(&model.Media{}))
@@ -135,10 +135,11 @@ func (e *EmbyService) libraryIsEpisodic(ctx context.Context, libraryID string) (
if strings.TrimSpace(libraryID) == "" {
return false, nil
}
if lib, err := e.repo.Library.FindByID(ctx, libraryID); err != nil {
// 走请求级缓存(若有),避免同一请求内对同一库重复查表。
if typ, ok, err := e.payloadLibraryType(ctx, libraryID); err != nil {
return false, err
} else if lib != nil {
return embyLibraryTypeIsEpisodic(lib.Type), nil
} else if ok {
return embyLibraryTypeIsEpisodic(typ), nil
}
var count int64
err := e.repo.DB.WithContext(ctx).Model(&model.Media{}).
@@ -151,11 +152,11 @@ func (e *EmbyService) mediaBelongsToEpisodicLibrary(ctx context.Context, m *mode
if e == nil || m == nil || strings.TrimSpace(m.LibraryID) == "" {
return false
}
lib, err := e.repo.Library.FindByID(ctx, m.LibraryID)
if err != nil || lib == nil {
typ, ok, err := e.payloadLibraryType(ctx, m.LibraryID)
if err != nil || !ok {
return false
}
return embyLibraryTypeIsEpisodic(lib.Type)
return embyLibraryTypeIsEpisodic(typ)
}
func (e *EmbyService) mediaShouldBeEpisode(ctx context.Context, m *model.Media) bool {
+175
View File
@@ -0,0 +1,175 @@
package service
import (
"context"
"errors"
"strings"
"sync"
"gorm.io/gorm"
"github.com/truewhile/MeBox/internal/model"
)
// 请求级 payload 构建缓存:/Items 列表为每行构建 payload 时,
// mediaShouldBeEpisode 需要库类型、剧集 payload 需要 series 标题。
// 一次页面请求内这些值高度重复(同一库、同一部剧),挂在 ctx 上的
// 小缓存可以把每条目 2-3 次 DB 查询降为整个请求各 1 次预取。
type embyPayloadCacheKey struct{}
type embyLibraryTypeEntry struct {
typ string
found bool // 库不存在时 found=false,调用方可退回计数启发式
}
type embyPayloadCache struct {
mu sync.Mutex
libTypes map[string]embyLibraryTypeEntry
series map[string]string // series_id -> title("" 表示不存在/无标题)
}
func (c *embyPayloadCache) libraryType(id string) (embyLibraryTypeEntry, bool) {
c.mu.Lock()
defer c.mu.Unlock()
entry, ok := c.libTypes[id]
return entry, ok
}
func (c *embyPayloadCache) setLibraryType(id string, entry embyLibraryTypeEntry) {
c.mu.Lock()
defer c.mu.Unlock()
c.libTypes[id] = entry
}
func (c *embyPayloadCache) seriesTitle(id string) (string, bool) {
c.mu.Lock()
defer c.mu.Unlock()
title, ok := c.series[id]
return title, ok
}
func (c *embyPayloadCache) setSeriesTitle(id, title string) {
c.mu.Lock()
defer c.mu.Unlock()
c.series[id] = title
}
// withPayloadCache attaches a fresh request-scoped cache if none exists yet.
func (e *EmbyService) withPayloadCache(ctx context.Context) context.Context {
if e == nil || e.repo == nil {
return ctx
}
if ctx.Value(embyPayloadCacheKey{}) != nil {
return ctx
}
return context.WithValue(ctx, embyPayloadCacheKey{}, &embyPayloadCache{
libTypes: map[string]embyLibraryTypeEntry{},
series: map[string]string{},
})
}
// prefetchPayloadCache warms the cache for the given media rows with two bulk
// queries (library types, series titles) instead of per-item lookups.
func (e *EmbyService) prefetchPayloadCache(ctx context.Context, rows []model.Media) {
cache, ok := ctx.Value(embyPayloadCacheKey{}).(*embyPayloadCache)
if !ok || len(rows) == 0 {
return
}
libIDs := make([]string, 0, 8)
seriesIDs := make([]string, 0, 8)
seenLib := map[string]struct{}{}
seenSeries := map[string]struct{}{}
for i := range rows {
row := &rows[i]
if id := strings.TrimSpace(row.LibraryID); id != "" {
if _, done := seenLib[id]; !done {
// 已在缓存中的库不必再查。
if _, hit := cache.libraryType(id); !hit {
seenLib[id] = struct{}{}
libIDs = append(libIDs, id)
}
}
}
if id := strings.TrimSpace(row.SeriesID); id != "" {
if _, done := seenSeries[id]; !done {
if _, hit := cache.seriesTitle(id); !hit {
seenSeries[id] = struct{}{}
seriesIDs = append(seriesIDs, id)
}
}
}
}
if len(libIDs) > 0 {
var libs []model.Library
if err := e.repo.DB.WithContext(ctx).Select("id, type").Where("id IN ?", libIDs).Find(&libs).Error; err == nil {
found := map[string]string{}
for _, lib := range libs {
found[lib.ID] = lib.Type
}
for _, id := range libIDs {
typ, ok := found[id]
cache.setLibraryType(id, embyLibraryTypeEntry{typ: typ, found: ok})
}
}
}
if len(seriesIDs) > 0 {
var series []model.Series
if err := e.repo.DB.WithContext(ctx).Select("id, title").Where("id IN ?", seriesIDs).Find(&series).Error; err == nil {
for _, s := range series {
cache.setSeriesTitle(s.ID, s.Title)
}
}
}
}
// payloadLibraryType resolves a library type through the request cache,
// falling back to a direct lookup when no cache is attached. found=false
// means the library row does not exist (soft-deleted or orphaned id).
func (e *EmbyService) payloadLibraryType(ctx context.Context, libraryID string) (typ string, found bool, err error) {
if cache, ok := ctx.Value(embyPayloadCacheKey{}).(*embyPayloadCache); ok {
if entry, hit := cache.libraryType(libraryID); hit {
return entry.typ, entry.found, nil
}
var lib model.Library
if dbErr := e.repo.DB.WithContext(ctx).Select("id, type").Where("id = ?", libraryID).First(&lib).Error; dbErr != nil {
cache.setLibraryType(libraryID, embyLibraryTypeEntry{})
return "", false, nil
}
cache.setLibraryType(lib.ID, embyLibraryTypeEntry{typ: lib.Type, found: true})
return lib.Type, true, nil
}
var lib model.Library
if err = e.repo.DB.WithContext(ctx).Select("id, type").Where("id = ?", libraryID).First(&lib).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return "", false, nil
}
return "", false, err
}
return lib.Type, true, nil
}
// payloadSeriesTitle resolves a series title through the request cache,
// falling back to a direct lookup when no cache is attached.
func (e *EmbyService) payloadSeriesTitle(ctx context.Context, seriesID string) (string, bool, error) {
if cache, ok := ctx.Value(embyPayloadCacheKey{}).(*embyPayloadCache); ok {
if title, hit := cache.seriesTitle(seriesID); hit {
return title, true, nil
}
var s model.Series
if err := e.repo.DB.WithContext(ctx).Select("id, title").Where("id = ?", seriesID).First(&s).Error; err != nil {
cache.setSeriesTitle(seriesID, "")
return "", true, nil
}
cache.setSeriesTitle(s.ID, s.Title)
return s.Title, true, nil
}
series, err := e.repo.Series.FindByID(ctx, seriesID)
if err != nil {
return "", false, err
}
if series == nil {
return "", false, nil
}
return series.Title, true, nil
}
+27 -24
View File
@@ -18,6 +18,7 @@ import (
"go.uber.org/zap"
"github.com/truewhile/MeBox/internal/helper"
"github.com/truewhile/MeBox/internal/model"
)
@@ -609,32 +610,34 @@ func (r *EmbyRemoteService) RemoteSearchMedia(ctx context.Context, query string,
return
}
q := url.Values{}
q.Set("ParentId", target.mount.RemoteViewID)
q.Set("Recursive", "true")
q.Set("SearchTerm", query)
q.Set("IncludeItemTypes", "Movie,Series")
q.Set("Fields", "Overview,Genres,ProviderIds,Path,SeriesPrimaryImage,MediaStreams,MediaSources,DateCreated,PremiereDate,ProductionYear,CommunityRating,CriticRating")
q.Set("Limit", strconv.Itoa(limit))
q.Set("StartIndex", "0")
helper.Run(r.log, "emby.remoteSearch", func() {
q := url.Values{}
q.Set("ParentId", target.mount.RemoteViewID)
q.Set("Recursive", "true")
q.Set("SearchTerm", query)
q.Set("IncludeItemTypes", "Movie,Series")
q.Set("Fields", "Overview,Genres,ProviderIds,Path,SeriesPrimaryImage,MediaStreams,MediaSources,DateCreated,PremiereDate,ProductionYear,CommunityRating,CriticRating")
q.Set("Limit", strconv.Itoa(limit))
q.Set("StartIndex", "0")
var body struct {
Items []map[string]any `json:"Items"`
}
if err := r.doGet(searchCtx, target.acct, target.cfg, "/Users/"+url.PathEscape(r.remoteUserID(target.cfg))+"/Items", q, &body); err != nil {
if r.log != nil {
r.log.Warn("remote search failed",
zap.String("mount", target.mount.RemoteViewName), zap.Error(err))
var body struct {
Items []map[string]any `json:"Items"`
}
return
}
medias := make([]model.Media, 0, len(body.Items))
for _, it := range body.Items {
RewriteEmbyRemoteIDs(it, target.mount.ID)
m := r.MapRemoteItemToMedia(searchCtx, &target.mount, target.acct, target.cfg, it)
medias = append(medias, m)
}
results[idx] = searchResult{items: medias}
if err := r.doGet(searchCtx, target.acct, target.cfg, "/Users/"+url.PathEscape(r.remoteUserID(target.cfg))+"/Items", q, &body); err != nil {
if r.log != nil {
r.log.Warn("remote search failed",
zap.String("mount", target.mount.RemoteViewName), zap.Error(err))
}
return
}
medias := make([]model.Media, 0, len(body.Items))
for _, it := range body.Items {
RewriteEmbyRemoteIDs(it, target.mount.ID)
m := r.MapRemoteItemToMedia(searchCtx, &target.mount, target.acct, target.cfg, it)
medias = append(medias, m)
}
results[idx] = searchResult{items: medias}
})
}(i, t)
}
wg.Wait()
+78 -6
View File
@@ -49,11 +49,15 @@ func (e *EmbyService) findSeriesGroup(ctx context.Context, id, userID string) (e
q = e.applyUserMediaVisibility(ctx, q, userID)
if !strings.HasPrefix(id, embyVirtualSeriesPrefix) {
q = q.Where("series_id = ?", id)
} else {
// 虚拟 series ID 只可能来自 series_id 为空的媒体:
// 有 series_id 时分组 key 就是 series_id 本身(UUID,不带虚拟前缀)。
q = q.Where("series_id IS NULL OR series_id = ''")
}
if err := q.Order("media.season_num asc, media.episode_num asc, media.created_at asc").Limit(embySeriesGroupingLimit).Find(&rows).Error; err != nil {
return embySeriesGroup{}, false, err
}
for _, group := range e.seriesGroupsFromMedia(rows) {
for _, group := range e.seriesGroupsFromMedia(ctx, rows) {
if group.ID == id {
e.rememberSeriesGroup(group)
return group, true, nil
@@ -88,9 +92,18 @@ func (e *EmbyService) findSeasonGroup(ctx context.Context, id, userID string) (e
if season, ok := e.cachedSeasonGroup(id); ok {
return season, true, nil
}
// 虚拟 Season ID 是 hash(seriesKey, seasonNum),无法反解出 series。
// 常见情况(已刮削、series_id 非空)先用一条小型 DISTINCT 查询枚举候选对,
// 在内存中算哈希匹配,命中后只加载该一部剧的剧集行,避免整库扫描。
if season, ok, err := e.findSeasonGroupBySeriesCandidates(ctx, id, userID); err != nil {
return embySeasonGroup{}, false, err
} else if ok {
return season, true, nil
}
// 回退:未刮削(series_id 为空,虚拟 key 由库名+名称派生)的媒体只能全量分组。
var rows []model.Media
q := e.repo.DB.WithContext(ctx).Model(&model.Media{}).
Where("season_num > 0 OR episode_num > 0")
Where("(series_id IS NULL OR series_id = '') AND (season_num > 0 OR episode_num > 0)")
q = e.applyUserMediaVisibility(ctx, q, userID)
if err := q.
Order("media.season_num asc, media.episode_num asc, media.created_at asc").
@@ -98,7 +111,7 @@ func (e *EmbyService) findSeasonGroup(ctx context.Context, id, userID string) (e
Find(&rows).Error; err != nil {
return embySeasonGroup{}, false, err
}
for _, series := range e.seriesGroupsFromMedia(rows) {
for _, series := range e.seriesGroupsFromMedia(ctx, rows) {
for _, season := range e.seasonsForSeries(series) {
if season.ID == id {
e.rememberSeriesGroup(series)
@@ -109,18 +122,77 @@ func (e *EmbyService) findSeasonGroup(ctx context.Context, id, userID string) (e
return embySeasonGroup{}, false, nil
}
func (e *EmbyService) seriesGroupsFromMedia(rows []model.Media) []embySeriesGroup {
// findSeasonGroupBySeriesCandidates resolves virtual season IDs for media that
// carry a real series_id: enumerate distinct (series_id, season_num) pairs via
// SQL, hash each candidate to find the matching season, then load only that
// one series' episodes.
func (e *EmbyService) findSeasonGroupBySeriesCandidates(ctx context.Context, id, userID string) (embySeasonGroup, bool, error) {
type seasonCandidate struct {
SeriesID string
SeasonNum int
}
var candidates []seasonCandidate
q := e.repo.DB.WithContext(ctx).Model(&model.Media{}).
Select("DISTINCT series_id, season_num").
Where("series_id <> '' AND (season_num > 0 OR episode_num > 0)")
q = e.applyUserMediaVisibility(ctx, q, userID)
if err := q.Find(&candidates).Error; err != nil {
return embySeasonGroup{}, false, err
}
matched := make([]string, 0, 1)
for _, cand := range candidates {
if seasonID(cand.SeriesID, cand.SeasonNum) == id {
matched = append(matched, cand.SeriesID)
}
}
for _, matchedSeries := range matched {
season, ok, err := e.seasonGroupForSeries(ctx, id, matchedSeries, userID)
if err != nil || ok {
return season, ok, err
}
}
return embySeasonGroup{}, false, nil
}
// seasonGroupForSeries rebuilds the season groups of one series (small row
// set) and returns the one matching the virtual season id.
func (e *EmbyService) seasonGroupForSeries(ctx context.Context, id, seriesID, userID string) (embySeasonGroup, bool, error) {
var rows []model.Media
rq := e.repo.DB.WithContext(ctx).Model(&model.Media{}).
Where("series_id = ? AND (season_num > 0 OR episode_num > 0)", seriesID)
rq = e.applyUserMediaVisibility(ctx, rq, userID)
if err := rq.
Order("media.season_num asc, media.episode_num asc, media.created_at asc").
Limit(embySeriesGroupingLimit).
Find(&rows).Error; err != nil {
return embySeasonGroup{}, false, err
}
for _, series := range e.seriesGroupsFromMedia(ctx, rows) {
if series.ID != seriesID {
continue
}
for _, season := range e.seasonsForSeries(series) {
if season.ID == id {
e.rememberSeriesGroup(series)
return season, true, nil
}
}
}
return embySeasonGroup{}, false, nil
}
func (e *EmbyService) seriesGroupsFromMedia(ctx context.Context, rows []model.Media) []embySeriesGroup {
byID := map[string]*embySeriesGroup{}
order := []string{}
for _, row := range rows {
row := row
seriesID := e.seriesIDForMedia(&row)
seriesID := e.seriesIDForMedia(ctx, &row)
group, ok := byID[seriesID]
if !ok {
group = &embySeriesGroup{
ID: seriesID,
LibraryID: row.LibraryID,
Name: e.seriesNameForMedia(&row),
Name: e.seriesNameForMedia(ctx, &row),
Year: row.Year,
ReleaseDate: row.ReleaseDate,
TMDbID: row.TMDbID,
+8 -7
View File
@@ -14,21 +14,22 @@ import (
"github.com/truewhile/MeBox/internal/model"
)
func (e *EmbyService) seriesIDForMedia(m *model.Media) string {
func (e *EmbyService) seriesIDForMedia(ctx context.Context, m *model.Media) string {
if strings.TrimSpace(m.SeriesID) != "" {
return m.SeriesID
}
return stableEmbyID(embyVirtualSeriesPrefix, m.LibraryID, e.seriesNameForMedia(m))
return stableEmbyID(embyVirtualSeriesPrefix, m.LibraryID, e.seriesNameForMedia(ctx, m))
}
func (e *EmbyService) seasonIDForMedia(m *model.Media) string {
return seasonID(e.seriesIDForMedia(m), m.SeasonNum)
func (e *EmbyService) seasonIDForMedia(ctx context.Context, m *model.Media) string {
return seasonID(e.seriesIDForMedia(ctx, m), m.SeasonNum)
}
func (e *EmbyService) seriesNameForMedia(m *model.Media) string {
func (e *EmbyService) seriesNameForMedia(ctx context.Context, m *model.Media) string {
if strings.TrimSpace(m.SeriesID) != "" {
if series, err := e.repo.Series.FindByID(context.Background(), m.SeriesID); err == nil && series != nil && strings.TrimSpace(series.Title) != "" {
return series.Title
// 走请求级缓存;无缓存 ctx 时退化为单次查询。
if title, ok, err := e.payloadSeriesTitle(ctx, m.SeriesID); err == nil && ok && strings.TrimSpace(title) != "" {
return title
}
}
if strings.EqualFold(strings.TrimSpace(m.ScrapeStatus), "matched") && strings.TrimSpace(m.Title) != "" {
+2 -1
View File
@@ -21,6 +21,7 @@ import (
"go.uber.org/zap"
"github.com/truewhile/MeBox/internal/config"
"github.com/truewhile/MeBox/internal/helper"
"github.com/truewhile/MeBox/internal/repository"
)
@@ -152,7 +153,7 @@ func (s *FFmpegToolsService) StartInstall(ctx context.Context) error {
s.mu.Unlock()
s.setMessage("准备下载…")
go s.runInstall()
helper.Go(s.log, "ffmpeg.install", s.runInstall)
return nil
}
+51 -1
View File
@@ -7,6 +7,7 @@ import (
"fmt"
"sort"
"strings"
"time"
"github.com/truewhile/MeBox/internal/model"
"github.com/truewhile/MeBox/internal/repository"
@@ -70,11 +71,60 @@ func (s *MediaService) seriesCardsCacheKey(libraryID string, visibility MediaVis
func (s *MediaService) mediaCacheTTLSeconds() int {
if s == nil || s.cfg == nil || s.cfg.Cache.MediaTTLSeconds < 1 {
return 15
return 90
}
return s.cfg.Cache.MediaTTLSeconds
}
// mediaObjectTTL 对象缓存与字节缓存同 TTL,失效同走 invalidateMediaCache
// 的 DeletePrefix("media:")(对象存储一并清除)。
func (s *MediaService) mediaObjectTTL() time.Duration {
return time.Duration(s.mediaCacheTTLSeconds()) * time.Second
}
func hashObjectCacheKey(parts []string) string {
sum := sha1.Sum([]byte(strings.Join(parts, "|")))
return hex.EncodeToString(sum[:])
}
// mediaGroupedRowsCacheKey 版本分组源行(已挂库元数据)的对象缓存。
func (s *MediaService) mediaGroupedRowsCacheKey(libraryID string, libraryIDs []string, filter repository.MediaQueryFilter) string {
libs := append([]string(nil), libraryIDs...)
sort.Strings(libs)
allowed := append([]string(nil), filter.AllowedLibraryIDs...)
hidden := append([]string(nil), filter.HiddenLibraryIDs...)
sort.Strings(allowed)
sort.Strings(hidden)
return "media:obj:grouped-rows:" + hashObjectCacheKey([]string{
libraryID,
strings.Join(libs, ","),
fmt.Sprintf("%t", filter.IncludeNSFW),
strings.Join(allowed, ","),
strings.Join(hidden, ","),
})
}
// libraryRowsCacheKey 整库可见行 + 预计算剧集索引的对象缓存(剧集卡片/剧集
// 列表共用一次 SQL 加载与一次 key 解析)。
func (s *MediaService) libraryRowsCacheKey(libraryID string, visibility MediaVisibility) string {
allowed := append([]string(nil), visibility.AllowedLibraryIDs...)
hidden := append([]string(nil), visibility.HiddenLibraryIDs...)
sort.Strings(allowed)
sort.Strings(hidden)
return "media:obj:library-rows:" + hashObjectCacheKey([]string{
libraryID,
fmt.Sprintf("%t", visibility.IncludeNSFW),
strings.Join(allowed, ","),
strings.Join(hidden, ","),
})
}
// libraryCardsObjectKey 系列卡片结果的对象缓存(与 seriesCardsCacheKey 同维度,
// 换独立前缀避免与字节缓存 key 冲突)。
func (s *MediaService) libraryCardsObjectKey(libraryID string, visibility MediaVisibility) string {
return s.seriesCardsCacheKey(libraryID, visibility) + ":obj"
}
func (s *MediaService) invalidateMediaCache(ctx context.Context) {
if s != nil && s.cache != nil {
s.cache.DeletePrefix(ctx, "media:")
+59
View File
@@ -0,0 +1,59 @@
package service
import (
"context"
"errors"
"os"
"strings"
"go.uber.org/zap"
"gorm.io/gorm"
"github.com/truewhile/MeBox/internal/model"
)
// DeleteMedia removes a media row from the library index.
// When deleteFiles is true and the path is a local filesystem file, the media
// file and its sidecar NFO are also removed. cloud:// paths are never touched on disk.
func (s *MediaService) DeleteMedia(ctx context.Context, id string, deleteFiles bool) error {
var media model.Media
err := s.repo.DB.WithContext(ctx).Where("id = ?", id).First(&media).Error
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil
}
return err
}
if deleteFiles {
if err := deleteLocalMediaFiles(s.log, media.Path); err != nil {
return err
}
}
err = s.repo.DB.WithContext(ctx).Unscoped().Where("id = ?", id).Delete(&model.Media{}).Error
if err == nil {
s.invalidateMediaCache(ctx)
}
return err
}
func deleteLocalMediaFiles(log *zap.Logger, path string) error {
path = strings.TrimSpace(path)
if path == "" {
return nil
}
if strings.HasPrefix(strings.ToLower(path), "cloud://") {
if log != nil {
log.Info("skip local file delete for cloud media path", zap.String("path", path))
}
return nil
}
if err := removeMediaAndNFO(path); err != nil {
if errors.Is(err, os.ErrNotExist) {
return nil
}
return err
}
return nil
}
+135
View File
@@ -0,0 +1,135 @@
package service
import (
"os"
"path/filepath"
"testing"
"time"
"go.uber.org/zap"
"github.com/truewhile/MeBox/internal/config"
"github.com/truewhile/MeBox/internal/model"
"github.com/truewhile/MeBox/internal/repository"
)
func TestDeleteMediaInvalidatesMediaAndStatsCache(t *testing.T) {
db := newServiceTestDB(t, &model.Media{})
repos := repository.New(db)
media := model.Media{
Base: model.Base{ID: "local-media"},
Title: "Cached Movie",
Path: filepath.Join(t.TempDir(), "Cached Movie.mkv"),
}
if err := repos.DB.Create(&media).Error; err != nil {
t.Fatal(err)
}
cache := NewRuntimeCacheService(&config.Config{}, zap.NewNop())
cache.SetJSON(t.Context(), "media:list:test", map[string]string{"state": "stale"}, time.Minute)
cache.SetJSON(t.Context(), "stats:snapshot:base", map[string]int{"media": 1}, time.Minute)
svc := NewMediaService(&config.Config{}, zap.NewNop(), repos).SetRuntimeCache(cache)
if err := svc.DeleteMedia(t.Context(), media.ID, false); err != nil {
t.Fatal(err)
}
var mediaCache map[string]string
if cache.GetJSON(t.Context(), "media:list:test", &mediaCache) {
t.Fatal("delete should invalidate media cache")
}
var statsCache map[string]int
if cache.GetJSON(t.Context(), "stats:snapshot:base", &statsCache) {
t.Fatal("delete should invalidate stats cache")
}
var count int64
if err := db.Unscoped().Model(&model.Media{}).Where("id = ?", media.ID).Count(&count).Error; err != nil {
t.Fatal(err)
}
if count != 0 {
t.Fatalf("media row still present, count=%d", count)
}
}
func TestDeleteMediaRemovesLocalFilesWhenRequested(t *testing.T) {
db := newServiceTestDB(t, &model.Media{})
repos := repository.New(db)
dir := t.TempDir()
mediaPath := filepath.Join(dir, "Movie.mkv")
nfo := filepath.Join(dir, "Movie.nfo")
if err := os.WriteFile(mediaPath, []byte("video"), 0o644); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(nfo, []byte("nfo"), 0o644); err != nil {
t.Fatal(err)
}
media := model.Media{
Base: model.Base{ID: "local-delete-files"},
Title: "Movie",
Path: mediaPath,
}
if err := repos.DB.Create(&media).Error; err != nil {
t.Fatal(err)
}
svc := NewMediaService(&config.Config{}, zap.NewNop(), repos)
if err := svc.DeleteMedia(t.Context(), media.ID, true); err != nil {
t.Fatal(err)
}
if _, err := os.Stat(mediaPath); !os.IsNotExist(err) {
t.Fatalf("expected media file removed, err=%v", err)
}
if _, err := os.Stat(nfo); !os.IsNotExist(err) {
t.Fatalf("expected nfo removed, err=%v", err)
}
}
func TestDeleteMediaKeepsLocalFilesByDefault(t *testing.T) {
db := newServiceTestDB(t, &model.Media{})
repos := repository.New(db)
dir := t.TempDir()
mediaPath := filepath.Join(dir, "Keep.mkv")
if err := os.WriteFile(mediaPath, []byte("video"), 0o644); err != nil {
t.Fatal(err)
}
media := model.Media{
Base: model.Base{ID: "keep-files"},
Title: "Keep",
Path: mediaPath,
}
if err := repos.DB.Create(&media).Error; err != nil {
t.Fatal(err)
}
svc := NewMediaService(&config.Config{}, zap.NewNop(), repos)
if err := svc.DeleteMedia(t.Context(), media.ID, false); err != nil {
t.Fatal(err)
}
if _, err := os.Stat(mediaPath); err != nil {
t.Fatalf("expected media file kept, err=%v", err)
}
}
func TestDeleteMediaSkipsCloudPaths(t *testing.T) {
db := newServiceTestDB(t, &model.Media{})
repos := repository.New(db)
media := model.Media{
Base: model.Base{ID: "cloud-media"},
Title: "Cloud",
Path: "cloud://openlist/Movie.mkv",
}
if err := repos.DB.Create(&media).Error; err != nil {
t.Fatal(err)
}
svc := NewMediaService(&config.Config{}, zap.NewNop(), repos)
if err := svc.DeleteMedia(t.Context(), media.ID, true); err != nil {
t.Fatal(err)
}
var count int64
if err := db.Unscoped().Model(&model.Media{}).Where("id = ?", media.ID).Count(&count).Error; err != nil {
t.Fatal(err)
}
if count != 0 {
t.Fatalf("cloud media row still present, count=%d", count)
}
}
+15 -8
View File
@@ -100,14 +100,21 @@ func (s *MediaService) ListLibrariesWithPreview(ctx context.Context, libraries [
if len(items) == 0 {
continue
}
cards := groupMediaSeriesCards(items)
if len(cards) > cardLimit {
cards = cards[:cardLimit]
}
if cards == nil {
cards = []SeriesCard{}
}
out[i].Cards = cards
cards := groupMediaSeriesCards(items)
// 如果折叠后的作品部数不足 cardLimit,且该库总记录数大于当前提取的条目数,
// 说明多集剧集折叠占满了提取窗口,调用 ListLibrarySeriesCards 补齐完整的影视部数。
if len(cards) < cardLimit && out[i].Total > int64(len(items)) {
if fullCards, _, err := s.ListLibrarySeriesCards(ctx, out[i].ID, visibility); err == nil && len(fullCards) > 0 {
cards = fullCards
}
}
if len(cards) > cardLimit {
cards = cards[:cardLimit]
}
if cards == nil {
cards = []SeriesCard{}
}
out[i].Cards = cards
}
if s.cache != nil {
+63
View File
@@ -92,3 +92,66 @@ func TestListLibrariesWithPreview(t *testing.T) {
t.Errorf("preview[1].Cards[0].Count = %d, want 12", previews[1].Cards[0].Count)
}
}
func TestListLibrariesWithPreviewLongSeriesCompletion(t *testing.T) {
db := newServiceTestDB(t, &model.Library{}, &model.Media{})
repos := repository.New(db)
lib := model.Library{Name: "剧集库", Path: "/media/tv", Type: "tv", Enabled: true}
if err := repos.Library.Create(t.Context(), &lib); err != nil {
t.Fatal(err)
}
now := time.Date(2026, 7, 2, 12, 0, 0, 0, time.UTC)
var rows []model.Media
// 1. 添加一部更早入库的短剧(2 集),如果按窗口 60 条限制,排在第 70 集之后,无法被初始窗口截取
for i := 1; i <= 2; i++ {
rows = append(rows, model.Media{
Base: model.Base{ID: fmt.Sprintf("short-ep-%02d", i), CreatedAt: now.Add(-time.Duration(100-i) * time.Minute)},
LibraryID: lib.ID,
Title: fmt.Sprintf("经典短剧 第%d集", i),
Path: fmt.Sprintf("/media/tv/经典短剧/Season 01/经典短剧.S01E%02d.mp4", i),
SeasonNum: 1,
EpisodeNum: i,
})
}
// 2. 添加一部最新入库的 70 集长篇连续剧,这 70 篇全在新入库时间线上
for i := 1; i <= 70; i++ {
rows = append(rows, model.Media{
Base: model.Base{ID: fmt.Sprintf("long-ep-%02d", i), CreatedAt: now.Add(time.Duration(i) * time.Minute)},
LibraryID: lib.ID,
Title: fmt.Sprintf("大长篇连续剧 第%d集", i),
Path: fmt.Sprintf("/media/tv/大长篇连续剧/Season 01/大长篇连续剧.S01E%02d.mp4", i),
SeasonNum: 1,
EpisodeNum: i,
})
}
if err := repos.DB.Create(&rows).Error; err != nil {
t.Fatal(err)
}
svc := NewMediaService(&config.Config{}, zap.NewNop(), repos)
// 请求 10 部卡片
previews, err := svc.ListLibrariesWithPreview(t.Context(), []model.Library{lib}, MediaVisibility{IncludeNSFW: true}, 10)
if err != nil {
t.Fatalf("ListLibrariesWithPreview failed: %v", err)
}
if len(previews) != 1 {
t.Fatalf("got %d previews, want 1", len(previews))
}
// 总媒体行数是 72 行
if previews[0].Total != 72 {
t.Errorf("previews[0].Total = %d, want 72", previews[0].Total)
}
// 验证自适应补全机制:必须包含 2 部剧集(长篇 + 短剧),而不是因为长篇 70 集占满窗口只显示 1 部
if len(previews[0].Cards) != 2 {
t.Fatalf("previews[0].Cards count = %d, want 2", len(previews[0].Cards))
}
}
+10 -6
View File
@@ -73,11 +73,15 @@ func (s *MediaService) listMediaVisibleForGrouping(ctx context.Context, libraryI
AllowedLibraryIDs: visibility.AllowedLibraryIDs,
HiddenLibraryIDs: visibility.HiddenLibraryIDs,
}
cacheKey := s.mediaListCacheKey(libraryID, libraryIDs, 0, maxMediaSearchLimit, filter) + ":group-source"
var cached mediaListCacheValue
if s.cache != nil && s.cache.GetJSON(ctx, cacheKey, &cached) {
s.attachLibraryMetadata(ctx, cached.Items)
return cached.Items, nil
cacheKey := s.mediaGroupedRowsCacheKey(libraryID, libraryIDs, filter)
if s.cache != nil {
if cachedObj, ok := s.cache.GetObject(cacheKey); ok {
if cached, ok := cachedObj.([]model.Media); ok {
// 对象缓存中的切片视为不可变;attachLibraryMetadata 会在填充时
// 执行过,命中路径直接返回副本即可(调用方只读)。
return cached, nil
}
}
}
items, total, err := s.repo.Media.ListByLibrariesFiltered(ctx, libraryIDs, 0, maxMediaSearchLimit, filter)
if err != nil {
@@ -91,7 +95,7 @@ func (s *MediaService) listMediaVisibleForGrouping(ctx context.Context, libraryI
}
s.attachLibraryMetadata(ctx, items)
if s.cache != nil {
s.cache.SetJSON(ctx, cacheKey, mediaListCacheValue{Items: items, Total: total}, time.Duration(s.mediaCacheTTLSeconds())*time.Second)
s.cache.SetObject(cacheKey, items, s.mediaObjectTTL())
}
return items, nil
}
-91
View File
@@ -1,91 +0,0 @@
package service
import (
"context"
"gorm.io/gorm"
"github.com/truewhile/MeBox/internal/model"
)
const maxRecycleBinRecords = 200
// SoftDelete 物理删除媒体记录(统一硬删除以降低 SQLite 存储与索引压力)。
func (s *MediaService) SoftDelete(ctx context.Context, id string) error {
err := s.repo.DB.WithContext(ctx).Unscoped().Where("id = ?", id).Delete(&model.Media{}).Error
if err == nil {
s.invalidateMediaCache(ctx)
}
return err
}
// RestoreDeleted unsets DeletedAt for a single media row.
func (s *MediaService) RestoreDeleted(ctx context.Context, id string) error {
err := s.repo.DB.WithContext(ctx).Unscoped().Model(&model.Media{}).
Where("id = ?", id).Update("deleted_at", nil).Error
if err == nil {
s.invalidateMediaCache(ctx)
}
return err
}
// ListRecycleBin returns every soft-deleted row, newest first.
func (s *MediaService) ListRecycleBin(ctx context.Context, limit int) ([]model.Media, error) {
if err := pruneRecycleBinRows(ctx, s.repo.DB, maxRecycleBinRecords); err != nil {
return nil, err
}
if limit <= 0 || limit > maxRecycleBinRecords {
limit = maxRecycleBinRecords
}
var rows []model.Media
err := s.repo.DB.Unscoped().
Where("deleted_at IS NOT NULL").
Order("deleted_at desc").
Limit(limit).
Find(&rows).Error
return rows, err
}
func pruneRecycleBinRows(ctx context.Context, db *gorm.DB, keep int) error {
if db == nil {
return nil
}
if keep <= 0 {
keep = maxRecycleBinRecords
}
var rows []struct {
ID string
}
if err := db.WithContext(ctx).Unscoped().
Model(&model.Media{}).
Select("id").
Where("deleted_at IS NOT NULL").
Order("deleted_at desc").
Limit(100000).
Offset(keep).
Find(&rows).Error; err != nil {
return err
}
if len(rows) == 0 {
return nil
}
ids := make([]string, 0, len(rows))
for _, row := range rows {
if row.ID != "" {
ids = append(ids, row.ID)
}
}
if len(ids) == 0 {
return nil
}
return db.WithContext(ctx).Unscoped().Where("id IN ?", ids).Delete(&model.Media{}).Error
}
// PurgeDeleted permanently removes a soft-deleted row from the database.
func (s *MediaService) PurgeDeleted(ctx context.Context, id string) error {
err := s.repo.DB.WithContext(ctx).Unscoped().Where("id = ?", id).Delete(&model.Media{}).Error
if err == nil {
s.invalidateMediaCache(ctx)
}
return err
}
-88
View File
@@ -1,88 +0,0 @@
package service
import (
"fmt"
"path/filepath"
"testing"
"time"
"go.uber.org/zap"
"gorm.io/gorm"
"github.com/truewhile/MeBox/internal/config"
"github.com/truewhile/MeBox/internal/model"
"github.com/truewhile/MeBox/internal/repository"
)
func TestListRecycleBinPrunesOldRowsOverLimit(t *testing.T) {
db := newServiceTestDB(t, &model.Media{})
repos := repository.New(db)
now := time.Now()
for i := 0; i < maxRecycleBinRecords+5; i++ {
deletedAt := now.Add(time.Duration(i) * time.Second)
media := model.Media{
Base: model.Base{
ID: fmt.Sprintf("media-%03d", i),
DeletedAt: gorm.DeletedAt{Time: deletedAt, Valid: true},
},
Title: fmt.Sprintf("Movie %03d", i),
Path: filepath.Join(t.TempDir(), fmt.Sprintf("Movie %03d.mkv", i)),
}
if err := db.Unscoped().Create(&media).Error; err != nil {
t.Fatal(err)
}
}
svc := NewMediaService(&config.Config{}, zap.NewNop(), repos)
rows, err := svc.ListRecycleBin(t.Context(), 500)
if err != nil {
t.Fatal(err)
}
if len(rows) != maxRecycleBinRecords {
t.Fatalf("recycle rows = %d, want %d", len(rows), maxRecycleBinRecords)
}
var count int64
if err := db.Unscoped().Model(&model.Media{}).Where("deleted_at IS NOT NULL").Count(&count).Error; err != nil {
t.Fatal(err)
}
if count != maxRecycleBinRecords {
t.Fatalf("stored recycle rows = %d, want %d", count, maxRecycleBinRecords)
}
var oldCount int64
if err := db.Unscoped().Model(&model.Media{}).Where("id IN ?", []string{"media-000", "media-001", "media-002", "media-003", "media-004"}).Count(&oldCount).Error; err != nil {
t.Fatal(err)
}
if oldCount != 0 {
t.Fatalf("oldest recycle rows were not pruned, count=%d", oldCount)
}
}
func TestSoftDeleteInvalidatesMediaAndStatsCache(t *testing.T) {
db := newServiceTestDB(t, &model.Media{})
repos := repository.New(db)
media := model.Media{
Base: model.Base{ID: "local-media"},
Title: "Cached Movie",
Path: filepath.Join(t.TempDir(), "Cached Movie.mkv"),
}
if err := repos.DB.Create(&media).Error; err != nil {
t.Fatal(err)
}
cache := NewRuntimeCacheService(&config.Config{}, zap.NewNop())
cache.SetJSON(t.Context(), "media:list:test", map[string]string{"state": "stale"}, time.Minute)
cache.SetJSON(t.Context(), "stats:snapshot:base", map[string]int{"media": 1}, time.Minute)
svc := NewMediaService(&config.Config{}, zap.NewNop(), repos).SetRuntimeCache(cache)
if err := svc.SoftDelete(t.Context(), media.ID); err != nil {
t.Fatal(err)
}
var mediaCache map[string]string
if cache.GetJSON(t.Context(), "media:list:test", &mediaCache) {
t.Fatal("soft delete should invalidate media cache")
}
var statsCache map[string]int
if cache.GetJSON(t.Context(), "stats:snapshot:base", &statsCache) {
t.Fatal("soft delete should invalidate stats cache")
}
}
+114 -33
View File
@@ -17,6 +17,15 @@ type seriesCardsCacheValue struct {
Total int64 `json:"total"`
}
// libraryRowsCacheValue 整库可见行 + 预计算索引的对象缓存。填充时一次性完成
// 整库 SQL 加载与 series key 解析,之后剧集卡片、剧集列表、媒体详情剧集
// 共享这份结果:命中路径零 SQL、零逐行 key 解析。Rows/Episodes 视为不可变。
type libraryRowsCacheValue struct {
Rows []model.Media
Resolver mediaSeriesKeyResolver
Episodes map[string][]model.Media
}
type SeriesCard struct {
Key string `json:"key"`
Rep model.Media `json:"rep"`
@@ -29,24 +38,59 @@ type seriesCardGroup struct {
latest time.Time
}
func (s *MediaService) ListLibrarySeriesCards(ctx context.Context, libraryID string, visibility MediaVisibility) ([]SeriesCard, int64, error) {
visibility = ExpandMediaVisibilityForMergedCloudLibraries(ctx, s.repo, visibility)
cacheKey := s.seriesCardsCacheKey(libraryID, visibility)
var cached seriesCardsCacheValue
if s.cache != nil && s.cache.GetJSON(ctx, cacheKey, &cached) {
return cached.Cards, cached.Total, nil
// libraryRowsWithIndex 返回整库行与预计算剧集索引(带对象缓存)。
func (s *MediaService) libraryRowsWithIndex(ctx context.Context, libraryID string, visibility MediaVisibility) (*libraryRowsCacheValue, error) {
cacheKey := s.libraryRowsCacheKey(libraryID, visibility)
if s.cache != nil {
if obj, ok := s.cache.GetObject(cacheKey); ok {
if cached, ok := obj.(*libraryRowsCacheValue); ok {
return cached, nil
}
}
}
rows, _, err := s.listAllMediaVisible(ctx, libraryID, visibility)
if err != nil {
return nil, err
}
// listAllMediaVisible 走 ListMediaVisible,行已带库元数据(resolver 的
// key 计算依赖 DisplayLibraryPath/ID)。
resolver := newMediaSeriesKeyResolver(rows)
episodes := make(map[string][]model.Media, len(rows)/4+1)
for _, row := range rows {
k := resolver.key(row)
if k == "" {
continue
}
episodes[k] = append(episodes[k], row)
}
value := &libraryRowsCacheValue{Rows: rows, Resolver: resolver, Episodes: episodes}
if s.cache != nil {
s.cache.SetObject(cacheKey, value, s.mediaObjectTTL())
}
return value, nil
}
func (s *MediaService) ListLibrarySeriesCards(ctx context.Context, libraryID string, visibility MediaVisibility) ([]SeriesCard, int64, error) {
visibility = ExpandMediaVisibilityForMergedCloudLibraries(ctx, s.repo, visibility)
cacheKey := s.libraryCardsObjectKey(libraryID, visibility)
if s.cache != nil {
if obj, ok := s.cache.GetObject(cacheKey); ok {
if cached, ok := obj.(*seriesCardsCacheValue); ok {
return cached.Cards, cached.Total, nil
}
}
}
rows, err := s.libraryRowsWithIndex(ctx, libraryID, visibility)
if err != nil {
return nil, 0, err
}
cards := groupMediaSeriesCards(rows)
cards := groupMediaSeriesCards(rows.Rows)
if cards == nil {
cards = []SeriesCard{}
}
total := int64(len(cards))
if s.cache != nil {
s.cache.SetJSON(ctx, cacheKey, seriesCardsCacheValue{Cards: cards, Total: total}, time.Duration(s.mediaCacheTTLSeconds())*time.Second)
s.cache.SetObject(cacheKey, &seriesCardsCacheValue{Cards: cards, Total: total}, s.mediaObjectTTL())
}
return cards, total, nil
}
@@ -72,17 +116,35 @@ func (s *MediaService) ListRecentSeriesCards(ctx context.Context, limit int, vis
}
func (s *MediaService) ListLibrarySeriesEpisodes(ctx context.Context, libraryID, key string, visibility MediaVisibility) ([]model.Media, error) {
rows, _, err := s.listAllMediaVisible(ctx, libraryID, visibility)
rows, err := s.libraryRowsWithIndex(ctx, libraryID, visibility)
if err != nil {
return nil, err
}
// 预计算索引命中:O(1) 查找,拷贝后排序避免改动共享缓存。
if episodes, ok := rows.Episodes[key]; ok && len(episodes) > 0 {
out := make([]model.Media, len(episodes))
copy(out, episodes)
sortEpisodesForDisplay(out)
return out, nil
}
// 兜底:索引未命中(如卡片 key 与行缓存短暂跨代),保持原线性匹配逻辑。
all := rows.Rows
out := make([]model.Media, 0)
resolver := newMediaSeriesKeyResolver(rows)
for _, row := range rows {
if resolver.key(row) == key {
for _, row := range all {
if rows.Resolver.key(row) == key {
out = append(out, row)
}
}
if len(out) == 0 {
return []model.Media{}, nil
}
sortEpisodesForDisplay(out)
return out, nil
}
// sortEpisodesForDisplay 与历史行为一致:季/集号升序,再按入库时间兜底。
func sortEpisodesForDisplay(out []model.Media) {
sort.SliceStable(out, func(i, j int) bool {
if out[i].SeasonNum != out[j].SeasonNum {
return out[i].SeasonNum < out[j].SeasonNum
@@ -92,7 +154,6 @@ func (s *MediaService) ListLibrarySeriesEpisodes(ctx context.Context, libraryID,
}
return out[i].CreatedAt.Before(out[j].CreatedAt)
})
return out, nil
}
func (s *MediaService) ListMediaEpisodes(ctx context.Context, mediaID string, visibility MediaVisibility) ([]model.Media, error) {
@@ -109,39 +170,59 @@ func (s *MediaService) ListMediaEpisodes(ctx context.Context, mediaID string, vi
if target.LibraryID == "" {
return []model.Media{*target}, nil
}
rows, _, err := s.listAllMediaVisible(ctx, target.LibraryID, visibility)
cache, err := s.libraryRowsWithIndex(ctx, target.LibraryID, visibility)
if err != nil {
return nil, err
}
rows := cache.Rows
if len(rows) == 0 {
return []model.Media{*target}, nil
}
resolver := newMediaSeriesKeyResolver(rows)
targetKey := resolver.key(*target)
out := make([]model.Media, 0)
var out []model.Media
targetKey := cache.Resolver.key(*target)
if targetKey != "" {
for _, row := range rows {
if resolver.key(row) == targetKey {
out = append(out, row)
}
// 预计算索引命中时拷贝,避免改动共享缓存。
if episodes, ok := cache.Episodes[targetKey]; ok {
out = make([]model.Media, len(episodes))
copy(out, episodes)
}
}
// 如果没有聚合到多集,尝试同父目录匹配
if len(out) <= 1 && target.Path != "" {
targetDir := filepath.Dir(strings.ReplaceAll(target.Path, "\\", "/"))
dirMatches := make([]model.Media, 0)
for _, row := range rows {
if row.Path != "" && filepath.Dir(strings.ReplaceAll(row.Path, "\\", "/")) == targetDir {
dirMatches = append(dirMatches, row)
// 如果没有聚合到多集,尝试同父目录匹配(排除合集目录和公共分类目录,且同目录文件不能是互不相同的独立电影)
if len(out) <= 1 && target.Path != "" {
targetDir := filepath.Dir(strings.ReplaceAll(target.Path, "\\", "/"))
parentBase := filepath.Base(targetDir)
if !mediaParentLooksLikeCollection(target.Path) && !seriesTitleIsGenericContainer(parentBase, *target) {
targetTitleNorm := normalizeSeriesTitle(target.Title)
targetDirNorm := normalizeSeriesTitle(parentBase)
dirMatches := make([]model.Media, 0)
for _, row := range rows {
if row.Path == "" || filepath.Dir(strings.ReplaceAll(row.Path, "\\", "/")) != targetDir {
continue
}
if row.ID == target.ID {
dirMatches = append(dirMatches, row)
continue
}
rowTitleNorm := normalizeSeriesTitle(row.Title)
allowMatch := false
if isGenericMovieTitle(rowTitleNorm) || isGenericMovieTitle(targetTitleNorm) {
allowMatch = true
} else if rowTitleNorm != "" && rowTitleNorm == targetTitleNorm {
allowMatch = true
} else if rowTitleNorm != "" && targetDirNorm != "" && rowTitleNorm == targetDirNorm {
allowMatch = true
}
if allowMatch {
dirMatches = append(dirMatches, row)
}
}
if len(dirMatches) > 1 {
out = dirMatches
}
}
}
if len(dirMatches) > 1 {
out = dirMatches
}
}
if len(out) == 0 {
out = []model.Media{*target}
+15 -1
View File
@@ -11,6 +11,16 @@ import (
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 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)$`)
func isGenericMovieTitle(title string) bool {
title = strings.TrimSpace(title)
if title == "" {
return true
}
return genericMovieTitleRE.MatchString(title)
}
func mediaSeriesKey(media model.Media) string {
return compactSeriesKey(mediaSeriesRawKey(media))
}
@@ -54,7 +64,11 @@ func mediaSeriesRawKey(media model.Media) string {
return seriesFingerprint("movie-external", fmt.Sprintf("bgm:%d", media.BangumiID))
}
if fromPath != "" && !mediaParentLooksLikeCollection(media.Path) {
return seriesFingerprint("library-path", media.LibraryID, fromPath)
titleNorm := normalizeSeriesTitle(media.Title)
fromPathNorm := normalizeSeriesTitle(fromPath)
if titleNorm == "" || isGenericMovieTitle(titleNorm) || titleNorm == fromPathNorm {
return seriesFingerprint("library-path", media.LibraryID, fromPath)
}
}
return seriesFingerprint("library-title", media.LibraryID, normalizeSeriesTitle(media.Title))
}
+64
View File
@@ -445,3 +445,67 @@ func TestGroupMediaSeriesCardsBridgesReleaseFoldersByMatchedSeriesTitle(t *testi
t.Fatalf("cards=%#v, want one series bridged by matched title", cards)
}
}
func TestGroupMediaSeriesCardsKeepsIndependentMoviesSeparateInSharedSubdirectory(t *testing.T) {
// 同一分类子目录下存放多部不同标题的独立电影,不应被强制折叠成 1 部
items := []model.Media{
{LibraryID: "movies", Title: "老师2024偷窥篇", Path: `/media/小姐姐/国产/nana/老师2024偷窥篇.strm`},
{LibraryID: "movies", Title: "紫光灯下的肉体诱惑", Path: `/media/小姐姐/国产/nana/紫光灯下的肉体诱惑.strm`},
{LibraryID: "movies", Title: "修洗衣机", Path: `/media/小姐姐/国产/nana/修洗衣机.strm`},
}
cards := groupMediaSeriesCards(items)
if len(cards) != 3 {
t.Fatalf("got %d cards, want 3 independent movie cards", len(cards))
}
// 但同一部电影的 CD1 和 CD2 仍应正确折叠为 1 部
cdItems := []model.Media{
{LibraryID: "movies", Title: "cd1", Path: `/media/电影/指环王 (2001)/cd1.mkv`},
{LibraryID: "movies", Title: "cd2", Path: `/media/电影/指环王 (2001)/cd2.mkv`},
}
cdCards := groupMediaSeriesCards(cdItems)
if len(cdCards) != 1 {
t.Fatalf("got %d cards for cd1/cd2, want 1 folded movie card", len(cdCards))
}
}
func TestListMediaEpisodesKeepsIndependentMoviesSeparate(t *testing.T) {
db := newServiceTestDB(t, &model.Library{}, &model.Media{})
repos := repository.New(db)
lib := model.Library{Base: model.Base{ID: "lib-movies"}, Name: "电影", Type: "movies", Enabled: true}
if err := repos.DB.Create(&lib).Error; err != nil {
t.Fatal(err)
}
m1 := model.Media{
Base: model.Base{ID: "m1"},
LibraryID: lib.ID,
Title: "老师2024偷窥篇",
Path: `/media/小姐姐/国产/nana/老师2024偷窥篇.strm`,
}
m2 := model.Media{
Base: model.Base{ID: "m2"},
LibraryID: lib.ID,
Title: "紫光灯下的肉体诱惑",
Path: `/media/小姐姐/国产/nana/紫光灯下的肉体诱惑.strm`,
}
m3 := model.Media{
Base: model.Base{ID: "m3"},
LibraryID: lib.ID,
Title: "修洗衣机",
Path: `/media/小姐姐/国产/nana/修洗衣机.strm`,
}
if err := repos.DB.Create(&[]model.Media{m1, m2, m3}).Error; err != nil {
t.Fatal(err)
}
svc := NewMediaService(&config.Config{}, zap.NewNop(), repos)
eps, err := svc.ListMediaEpisodes(t.Context(), "m1", MediaVisibility{IncludeNSFW: true})
if err != nil {
t.Fatal(err)
}
if len(eps) != 1 || eps[0].ID != "m1" {
t.Fatalf("ListMediaEpisodes got %#v, want exactly m1", eps)
}
}
+61 -1
View File
@@ -20,6 +20,7 @@ type RuntimeCacheService struct {
mu sync.RWMutex
memory map[string]runtimeCacheItem
obj map[string]runtimeObjectItem
limit int
}
@@ -28,8 +29,18 @@ type runtimeCacheItem struct {
expiresAt time.Time
}
// runtimeObjectItem 直存 Go 对象,跳过 JSON 编解码。热点路径(整库行、
// 分组结果)每次请求都要完整反序列化,字节缓存避免了 SQL 却没避免解码;
// 对象缓存命中时零解码零分配。存储的值视为不可变:读取方如需修改必须
// 自行浅拷贝。仅进程内生效(Redis 只支持字节),多实例部署退化为各实例
// 独立缓存,与现有内存 L1 语义一致。
type runtimeObjectItem struct {
value any
expiresAt time.Time
}
func NewRuntimeCacheService(cfg *config.Config, log *zap.Logger) *RuntimeCacheService {
c := &RuntimeCacheService{log: log, memory: map[string]runtimeCacheItem{}, limit: 2048}
c := &RuntimeCacheService{log: log, memory: map[string]runtimeCacheItem{}, obj: map[string]runtimeObjectItem{}, limit: 2048}
if cfg == nil {
return c
}
@@ -112,6 +123,50 @@ func (c *RuntimeCacheService) SetJSON(ctx context.Context, key string, value any
}
}
// GetObject 返回缓存中的对象。返回值不可变:调用方需要修改时必须先自行拷贝。
func (c *RuntimeCacheService) GetObject(key string) (any, bool) {
if !c.Enabled() || strings.TrimSpace(key) == "" {
return nil, false
}
fullKey := c.key(key)
now := time.Now()
c.mu.RLock()
item, ok := c.obj[fullKey]
c.mu.RUnlock()
if !ok {
return nil, false
}
if now.After(item.expiresAt) {
c.mu.Lock()
delete(c.obj, fullKey)
c.mu.Unlock()
return nil, false
}
return item.value, true
}
// SetObject 存入一个此后视为不可变的对象。
func (c *RuntimeCacheService) SetObject(key string, value any, ttl time.Duration) {
if !c.Enabled() || strings.TrimSpace(key) == "" || value == nil || ttl <= 0 {
return
}
fullKey := c.key(key)
now := time.Now()
c.mu.Lock()
defer c.mu.Unlock()
if len(c.obj) >= c.limit {
for k, item := range c.obj {
if now.After(item.expiresAt) || len(c.obj) >= c.limit {
delete(c.obj, k)
}
if len(c.obj) < c.limit {
break
}
}
}
c.obj[fullKey] = runtimeObjectItem{value: value, expiresAt: now.Add(ttl)}
}
func (c *RuntimeCacheService) DeletePrefix(ctx context.Context, prefix string) {
if !c.Enabled() || strings.TrimSpace(prefix) == "" {
return
@@ -190,4 +245,9 @@ func (c *RuntimeCacheService) deleteMemoryPrefix(prefix string) {
delete(c.memory, key)
}
}
for key := range c.obj {
if strings.HasPrefix(key, prefix) {
delete(c.obj, key)
}
}
}
@@ -7,6 +7,7 @@ import (
"go.uber.org/zap"
"github.com/truewhile/MeBox/internal/helper"
"github.com/truewhile/MeBox/internal/model"
)
@@ -45,7 +46,7 @@ func (s *ScannerService) startLocalMediaProbeWorkers() {
s.localMediaProbeOnce.Do(func() {
workers := s.ffprobeWorkerCount()
for i := 0; i < workers; i++ {
go s.localMediaProbeWorker()
helper.Go(s.log, "scanner.probeWorker", s.localMediaProbeWorker)
}
})
}
+36 -1
View File
@@ -63,6 +63,8 @@ func (b *localMediaWriteBatch) Flush() {
return
}
existingPaths := b.existingPaths(items)
upsertItems := make([]*model.Media, 0, len(items))
upsertAfter := make([]func(), 0, len(items))
createItems := make([]localMediaWriteItem, 0, len(items))
createMedia := make([]model.Media, 0, len(items))
for _, item := range items {
@@ -70,12 +72,16 @@ func (b *localMediaWriteBatch) Flush() {
continue
}
if existingPaths[filepath.Clean(item.media.Path)] {
b.upsertExistingItem(item)
// 已存在行:攒起来在一个事务里逐条 upsert(一批一次提交)。
after := item.after
upsertItems = append(upsertItems, item.media)
upsertAfter = append(upsertAfter, after)
continue
}
createItems = append(createItems, item)
createMedia = append(createMedia, *item.media)
}
b.flushUpserts(items, upsertItems, upsertAfter)
if len(createMedia) == 0 {
b.publish()
return
@@ -142,6 +148,35 @@ func (b *localMediaWriteBatch) existingPaths(items []localMediaWriteItem) map[st
return out
}
// flushUpserts 把已存在行的 upsert 攒成一个事务(一次提交/一组 fsync)。
// 整批失败(如单条数据触发约束)时退回逐条 Upsert,只丢真正坏的那几条。
func (b *localMediaWriteBatch) flushUpserts(allItems []localMediaWriteItem, upsertItems []*model.Media, upsertAfter []func()) {
if len(upsertItems) == 0 {
return
}
if err := b.scanner.repo.Media.UpsertBatch(b.ctx, upsertItems); err == nil {
b.res.Updated += len(upsertItems)
for _, after := range upsertAfter {
if after != nil {
after()
}
}
return
} else if b.scanner.log != nil {
b.scanner.log.Warn("batch upsert failed; falling back to per-item upsert",
zap.Int("items", len(upsertItems)))
}
// 兜底:按原始顺序找回每个条目的 path/after(两个切片同序但可能含 nil)。
idx := 0
for _, item := range allItems {
if item.media == nil || idx >= len(upsertItems) || upsertItems[idx] != item.media {
continue
}
idx++
b.upsertExistingItem(item)
}
}
func (b *localMediaWriteBatch) upsertExistingItem(item localMediaWriteItem) {
if item.media == nil {
return
+9 -5
View File
@@ -5,6 +5,8 @@ import (
"time"
"go.uber.org/zap"
"github.com/truewhile/MeBox/internal/helper"
)
func (s *ScannerService) invalidateMediaCache(ctx context.Context) {
@@ -18,10 +20,12 @@ func (s *ScannerService) startAutoScrape(ctx context.Context, libraryID string)
scrapeCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), 30*time.Minute)
go func() {
defer cancel()
_, err := s.scraper.EnrichLibraryDetailedWithOptions(scrapeCtx, libraryID, skipEpisodeArtworkOptions(false))
if err != nil {
s.log.Warn("scraper enrich failed", zap.Error(err))
return
}
// 扫描触发的后台刮削与请求线程无关,panic 只记日志,不能带崩进程。
helper.Run(s.log, "scanner.autoScrape", func() {
_, err := s.scraper.EnrichLibraryDetailedWithOptions(scrapeCtx, libraryID, skipEpisodeArtworkOptions(false))
if err != nil {
s.log.Warn("scraper enrich failed", zap.Error(err))
}
})
}()
}
+2 -9
View File
@@ -8,9 +8,6 @@
// organize_source opt-in — organize the configured staging folder.
// transcode_cleanup every 24 h — purge HLS transcode artefacts
// older than 24 h.
// recycle_purge every 24 h — empty the recycle bin of rows
// soft-deleted more than 30 days
// ago.
//
// Each job runs at most once at a time (an in-flight run blocks the
// next tick). All work happens on a long-lived background context so
@@ -25,6 +22,7 @@ import (
"go.uber.org/zap"
"github.com/truewhile/MeBox/internal/helper"
"github.com/truewhile/MeBox/internal/repository"
)
@@ -128,11 +126,6 @@ func (s *SchedulerService) Start(ctx context.Context) {
interval: 24 * time.Hour,
run: s.jobCleanTranscodeCache,
},
{
name: "recycle_purge",
interval: 24 * time.Hour,
run: s.jobPurgeRecycleBin,
},
{
name: "image_cache_cleanup",
interval: 1 * time.Hour,
@@ -147,7 +140,7 @@ func (s *SchedulerService) Start(ctx context.Context) {
// 首轮等满一个完整周期再跑,平时节奏不变。
initialDelay = j.interval
}
go s.loopWithInitialDelay(ctx, j, initialDelay)
helper.Go(s.log, "scheduler.loop."+j.name, func() { s.loopWithInitialDelay(ctx, j, initialDelay) })
}
}
-26
View File
@@ -8,9 +8,6 @@ import (
"time"
"go.uber.org/zap"
"gorm.io/gorm"
"github.com/truewhile/MeBox/internal/model"
)
// jobScanLibraries re-walks every enabled library.
@@ -181,29 +178,6 @@ func (s *SchedulerService) jobCleanTranscodeCache(ctx context.Context) error {
return walkAndPrune(s.cacheDir+"/hls", cutoff)
}
// jobPurgeRecycleBin permanently deletes media rows soft-deleted >30 days
// ago. The on-disk file is left untouched (delete is operator-driven).
func (s *SchedulerService) jobPurgeRecycleBin(ctx context.Context) error {
cutoff := time.Now().Add(-30 * 24 * time.Hour)
res := s.repo.DB.WithContext(ctx).
Unscoped().
Where("deleted_at IS NOT NULL AND deleted_at < ?", cutoff).
Delete(&model.Media{})
if res.Error != nil && !isMissingTableErr(res.Error) {
return res.Error
}
return pruneRecycleBinRows(ctx, s.repo.DB, maxRecycleBinRecords)
}
// isMissingTableErr lets the test harness ignore "no such table" errors
// that show up before AutoMigrate has run.
func isMissingTableErr(err error) bool {
if err == nil {
return false
}
return err == gorm.ErrInvalidDB
}
// jobCleanImageCache prunes image proxy cache files when disk usage exceeds the configured limit.
func (s *SchedulerService) jobCleanImageCache(ctx context.Context) error {
if s.cacheDir == "" {
+4 -1
View File
@@ -6,6 +6,8 @@ import (
"time"
"go.uber.org/zap"
"github.com/truewhile/MeBox/internal/helper"
)
// JobStatus is a snapshot suitable for the admin UI.
@@ -142,7 +144,8 @@ func (s *SchedulerService) beginRun(j *scheduledJob) error {
}
func (s *SchedulerService) runReserved(ctx context.Context, j *scheduledJob) error {
err := j.run(ctx)
// 任务 panic 转为 error,保证下方 running/lastErr 状态照常清理、调度循环存活。
err := helper.Recover(s.log, "scheduler.job."+j.name, func() error { return j.run(ctx) })
s.mu.Lock()
j.lastRun = s.currentTime()
if err != nil {
+5 -1
View File
@@ -10,6 +10,7 @@ import (
"go.uber.org/zap"
"github.com/truewhile/MeBox/internal/helper"
"github.com/truewhile/MeBox/internal/model"
)
@@ -73,7 +74,10 @@ func (s *ScraperService) queueWorker(ctx context.Context) {
}
defer func() { <-sem }()
s.processScrapeTask(ctx, t)
// 刮削要解析远端元数据响应,单个任务 panic 不应拖垮队列 worker。
helper.Run(s.log, "scraper.task", func() {
s.processScrapeTask(ctx, t)
})
}(&tasks[i])
}
wg.Wait()
+3 -2
View File
@@ -10,6 +10,7 @@ import (
"go.uber.org/zap"
"github.com/truewhile/MeBox/internal/config"
"github.com/truewhile/MeBox/internal/helper"
"github.com/truewhile/MeBox/internal/repository"
)
@@ -96,7 +97,7 @@ func (c *Container) Boot() {
if err := c.APIConfig.SeedDefaults(c.stopCtx); err != nil {
c.Log.Warn("api config seed failed", zap.Error(err))
}
go c.warmMediaSearchIndex(c.stopCtx)
helper.Go(c.Log, "service.warmMediaSearchIndex", func() { c.warmMediaSearchIndex(c.stopCtx) })
// 启动调度器定时任务
c.Scheduler.Start(c.stopCtx)
@@ -119,7 +120,7 @@ func (c *Container) Boot() {
// Mgo 保号规则巡检:默认关闭,由管理员通过 Telegram Bot 命令开启。
// 每天触发一次评估;规则里的窗口可随机,不固定。
if c.Device != nil {
go c.runInactivitySweeper(c.stopCtx)
helper.Go(c.Log, "service.inactivitySweeper", func() { c.runInactivitySweeper(c.stopCtx) })
}
}
+3 -2
View File
@@ -8,6 +8,7 @@ import (
"go.uber.org/zap"
"github.com/truewhile/MeBox/internal/config"
"github.com/truewhile/MeBox/internal/helper"
"github.com/truewhile/MeBox/internal/model"
"github.com/truewhile/MeBox/internal/repository"
)
@@ -47,12 +48,12 @@ func newServiceContainer(cfg *config.Config, log *zap.Logger, repos *repository.
func (b *serviceContainerBuilder) startRealtimeServices() {
b.c.WSHub = NewHub(b.log)
go b.c.WSHub.Run()
helper.Go(b.log, "ws.hub", b.c.WSHub.Run)
b.c.Tasks = NewTaskTrackerService(b.log, b.c.WSHub)
b.c.SystemUpdate = NewSystemUpdateService(b.cfg, b.log, b.repos, b.c.Tasks, b.version)
b.c.SSEHub = NewSSEHub(b.log)
go b.c.SSEHub.Run()
helper.Go(b.log, "sse.hub", b.c.SSEHub.Run)
}
func (b *serviceContainerBuilder) initProviderServices() {
+5 -1
View File
@@ -18,6 +18,7 @@ import (
"go.uber.org/zap"
"github.com/truewhile/MeBox/internal/helper"
"github.com/truewhile/MeBox/internal/model"
"github.com/truewhile/MeBox/internal/service/cloud"
"github.com/truewhile/MeBox/internal/service/cloud115"
@@ -66,7 +67,10 @@ func (s *StrmService) downloadWorker(ctx context.Context) {
return
}
defer s.releaseDownloadSlot(task.Provider)
s.processDownloadTask(ctx, task)
// 单个任务 panic 不应拖垮整个下载 worker。
helper.Run(s.log, "strm.downloadTask", func() {
s.processDownloadTask(ctx, task)
})
}(i)
}
wg.Wait()
+6 -5
View File
@@ -24,6 +24,7 @@ import (
"go.uber.org/zap"
"github.com/truewhile/MeBox/internal/config"
"github.com/truewhile/MeBox/internal/helper"
"github.com/truewhile/MeBox/internal/model"
"github.com/truewhile/MeBox/internal/repository"
"github.com/truewhile/MeBox/internal/service/cloud"
@@ -186,14 +187,14 @@ func (s *StrmService) Start(ctx context.Context) {
uploadThreads = 4
}
for i := 0; i < downloadThreads; i++ {
go s.downloadWorker(ctx)
helper.Go(s.log, "strm.downloadWorker", func() { s.downloadWorker(ctx) })
}
for i := 0; i < uploadThreads; i++ {
go s.uploadWorker(ctx)
helper.Go(s.log, "strm.uploadWorker", func() { s.uploadWorker(ctx) })
}
go s.cronLoop(ctx)
go s.queueCleanupLoop(ctx)
go s.refresh115TokensLoop(ctx)
helper.Go(s.log, "strm.cronLoop", func() { s.cronLoop(ctx) })
helper.Go(s.log, "strm.queueCleanupLoop", func() { s.queueCleanupLoop(ctx) })
helper.Go(s.log, "strm.refresh115TokensLoop", func() { s.refresh115TokensLoop(ctx) })
s.log.Info("strm service started",
zap.Int("download_threads", downloadThreads),
zap.Int("upload_threads", uploadThreads))
+118 -97
View File
@@ -18,6 +18,7 @@ import (
"go.uber.org/zap"
"github.com/truewhile/MeBox/internal/helper"
"github.com/truewhile/MeBox/internal/model"
"github.com/truewhile/MeBox/internal/service/cloud"
"github.com/truewhile/MeBox/internal/service/cloud115"
@@ -105,7 +106,7 @@ func (s *StrmService) StartSync(ctx context.Context, pathID string, syncType ...
p.LastSyncMessage = "同步进行中"
_ = s.repo.StrmSyncPath.Update(ctx, p)
go s.runSync(runCtx, p, rec)
helper.Go(s.log, "strm.sync", func() { s.runSync(runCtx, p, rec) })
return nil
}
@@ -361,38 +362,45 @@ func (st *strmSyncState) walkRemote() error {
wg.Add(1)
go func() {
defer wg.Done()
for task := range queue {
if ctx.Err() != nil {
return
}
entries, err := st.provider.List(ctx, task.id)
if err != nil {
errMu.Lock()
if firstErr == nil {
firstErr = fmt.Errorf("列出远端目录 %s 失败:%w", task.id, err)
// worker 解析远端响应 panic 时取消整个同步,让 closer 与其余
// worker 正常收尾,避免队列与 pending 计数卡死;正常退出不取消。
if err := helper.Recover(st.s.log, "strm.sync.walkRemote", func() error {
for task := range queue {
if ctx.Err() != nil {
return nil
}
errMu.Unlock()
cancel()
return
}
for _, entry := range entries {
cleanName := cleanEntryName(entry.Name, entry.IsDir)
rel := cleanName
if task.rel != "" {
rel = task.rel + "/" + cleanName
}
if entry.IsDir {
pending.Add(1)
select {
case queue <- dirTask{id: entry.ID, rel: rel}:
case <-ctx.Done():
pending.Add(-1)
entries, err := st.provider.List(ctx, task.id)
if err != nil {
errMu.Lock()
if firstErr == nil {
firstErr = fmt.Errorf("列出远端目录 %s 失败:%w", task.id, err)
}
} else {
st.processRemoteFile(entry, rel)
errMu.Unlock()
cancel()
return nil
}
for _, entry := range entries {
cleanName := cleanEntryName(entry.Name, entry.IsDir)
rel := cleanName
if task.rel != "" {
rel = task.rel + "/" + cleanName
}
if entry.IsDir {
pending.Add(1)
select {
case queue <- dirTask{id: entry.ID, rel: rel}:
case <-ctx.Done():
pending.Add(-1)
}
} else {
st.processRemoteFile(entry, rel)
}
}
pending.Add(-1)
}
pending.Add(-1)
return nil
}); err != nil {
cancel()
}
}()
}
@@ -560,22 +568,28 @@ func (st *strmSyncState) walk115Flat(open115 *cloud115.OpenClient) error {
wg.Add(1)
go func() {
defer wg.Done()
for t := range taskCh {
if ctx.Err() != nil {
return
}
files, _, err := open115.GetFsListFlat(ctx, rootCID, t.offset, pageSize)
if err != nil {
errMu.Lock()
if fetchErr == nil {
fetchErr = err
// 分页拉取 panic 时取消整个同步;正常退出不取消。
if err := helper.Recover(st.s.log, "strm.sync.walk115.page", func() error {
for t := range taskCh {
if ctx.Err() != nil {
return nil
}
errMu.Unlock()
return
files, _, err := open115.GetFsListFlat(ctx, rootCID, t.offset, pageSize)
if err != nil {
errMu.Lock()
if fetchErr == nil {
fetchErr = err
}
errMu.Unlock()
return nil
}
filesMu.Lock()
allFiles = append(allFiles, files...)
filesMu.Unlock()
}
filesMu.Lock()
allFiles = append(allFiles, files...)
filesMu.Unlock()
return nil
}); err != nil {
cancel()
}
}()
}
@@ -632,63 +646,70 @@ func (st *strmSyncState) walk115Flat(open115 *cloud115.OpenClient) error {
pwg.Add(1)
go func() {
defer pwg.Done()
for pid := range pidCh {
if ctx.Err() != nil {
return
}
if _, loaded := st.dirCache.Load(pid); loaded {
if n := doneDirs.Add(1); n%20 == 0 || n == int64(totalDirs) {
// 解析目录详情 panic 时中止整个同步(避免带着损坏的相对路径
// 继续执行);正常退出不取消。
if err := helper.Recover(st.s.log, "strm.sync.walk115.dirTree", func() error {
for pid := range pidCh {
if ctx.Err() != nil {
return nil
}
if _, loaded := st.dirCache.Load(pid); loaded {
if n := doneDirs.Add(1); n%20 == 0 || n == int64(totalDirs) {
st.updateSyncMessage(fmt.Sprintf("正在解析目录树 (%d/%d)...", n, totalDirs))
}
continue
}
detail, err := open115.GetFsDetailByCid(ctx, pid)
if err != nil {
// 目录详情解析失败会导致下游文件 rel 无法还原真实父路径,
// seen key 与磁盘路径对不上:增量 prune 会误删本地文件、上传会
// 误传本地未变文件、下载会重复下载。这里不是降级容错,而是
// 直接中止整个同步——宁可本次同步失败,也不带着损坏的相对路径
// 继续执行造成大规模误删/误传/重下(参考用户反馈"云盘没动却重下重传")。
errMu.Lock()
if firstErr == nil {
firstErr = fmt.Errorf("115: 解析目录树失败(file_id=%s):%w", pid, err)
}
errMu.Unlock()
st.scanIncomplete.Store(true)
cancel()
return nil
} else if detail != nil {
// 解析相对路径
relPath := cleanDirRel(detail.RelativePath(rootCID))
st.dirCache.Store(pid, relPath)
_ = st.s.repo.StrmDirCache.Set(ctx, st.p.ID, pid, relPath)
// 顺便解析并缓存 detail.Paths 中包含的中间各层级目录
for _, ancestor := range detail.Paths {
if ancestor.FileId == "0" || ancestor.FileId == rootCID {
continue
}
if _, loaded := st.dirCache.Load(ancestor.FileId); !loaded {
subDetail := &cloud115.RemoteFileDetail{
FileId: ancestor.FileId,
FileName: ancestor.Name,
Paths: nil,
}
for _, p := range detail.Paths {
subDetail.Paths = append(subDetail.Paths, p)
if p.FileId == ancestor.FileId {
break
}
}
ancestorRel := cleanDirRel(subDetail.RelativePath(rootCID))
st.dirCache.Store(ancestor.FileId, ancestorRel)
_ = st.s.repo.StrmDirCache.Set(ctx, st.p.ID, ancestor.FileId, ancestorRel)
}
}
}
if n := doneDirs.Add(1); n%10 == 0 || n == int64(totalDirs) {
st.updateSyncMessage(fmt.Sprintf("正在解析目录树 (%d/%d)...", n, totalDirs))
}
continue
}
detail, err := open115.GetFsDetailByCid(ctx, pid)
if err != nil {
// 目录详情解析失败会导致下游文件 rel 无法还原真实父路径,
// seen key 与磁盘路径对不上:增量 prune 会误删本地文件、上传会
// 误传本地未变文件、下载会重复下载。这里不是降级容错,而是
// 直接中止整个同步——宁可本次同步失败,也不带着损坏的相对路径
// 继续执行造成大规模误删/误传/重下(参考用户反馈"云盘没动却重下重传")。
errMu.Lock()
if firstErr == nil {
firstErr = fmt.Errorf("115: 解析目录树失败(file_id=%s):%w", pid, err)
}
errMu.Unlock()
st.scanIncomplete.Store(true)
cancel()
return
} else if detail != nil {
// 解析相对路径
relPath := cleanDirRel(detail.RelativePath(rootCID))
st.dirCache.Store(pid, relPath)
_ = st.s.repo.StrmDirCache.Set(ctx, st.p.ID, pid, relPath)
// 顺便解析并缓存 detail.Paths 中包含的中间各层级目录
for _, ancestor := range detail.Paths {
if ancestor.FileId == "0" || ancestor.FileId == rootCID {
continue
}
if _, loaded := st.dirCache.Load(ancestor.FileId); !loaded {
subDetail := &cloud115.RemoteFileDetail{
FileId: ancestor.FileId,
FileName: ancestor.Name,
Paths: nil,
}
for _, p := range detail.Paths {
subDetail.Paths = append(subDetail.Paths, p)
if p.FileId == ancestor.FileId {
break
}
}
ancestorRel := cleanDirRel(subDetail.RelativePath(rootCID))
st.dirCache.Store(ancestor.FileId, ancestorRel)
_ = st.s.repo.StrmDirCache.Set(ctx, st.p.ID, ancestor.FileId, ancestorRel)
}
}
}
if n := doneDirs.Add(1); n%10 == 0 || n == int64(totalDirs) {
st.updateSyncMessage(fmt.Sprintf("正在解析目录树 (%d/%d)...", n, totalDirs))
}
return nil
}); err != nil {
cancel()
}
}()
}
+61
View File
@@ -23,6 +23,8 @@ import (
"path/filepath"
"regexp"
"strings"
"sync"
"time"
"go.uber.org/zap"
@@ -35,6 +37,21 @@ type SubtitleService struct {
log *zap.Logger
repo *repository.Container
cfg *config.Config
// 目录发现是 Emby 条目列表的热路径(每个媒体源一次 DB 查询 + 最多 5 次
// os.ReadDir),而字幕文件极少变化:按 media_id 做短 TTL 缓存。
cacheMu sync.Mutex
discovery map[string]subtitleDiscoveryEntry
}
const (
subtitleDiscoveryTTL = 2 * time.Minute
subtitleDiscoveryCacheCap = 4096
)
type subtitleDiscoveryEntry struct {
tracks []SubtitleTrack
expiresAt time.Time
}
// NewSubtitleService is the constructor.
@@ -73,6 +90,50 @@ func (s *SubtitleService) DiscoverExternalOnly(ctx context.Context, mediaID stri
}
func (s *SubtitleService) discover(ctx context.Context, mediaID string) ([]SubtitleTrack, error) {
if tracks, ok := s.cachedDiscovery(mediaID); ok {
return tracks, nil
}
tracks, err := s.discoverUncached(ctx, mediaID)
if err != nil {
return nil, err
}
s.rememberDiscovery(mediaID, tracks)
return tracks, nil
}
func (s *SubtitleService) cachedDiscovery(mediaID string) ([]SubtitleTrack, bool) {
now := time.Now()
s.cacheMu.Lock()
defer s.cacheMu.Unlock()
entry, ok := s.discovery[mediaID]
if !ok {
return nil, false
}
if now.After(entry.expiresAt) {
delete(s.discovery, mediaID)
return nil, false
}
// 返回副本,避免调用方修改缓存内容。
return append([]SubtitleTrack(nil), entry.tracks...), true
}
func (s *SubtitleService) rememberDiscovery(mediaID string, tracks []SubtitleTrack) {
now := time.Now()
s.cacheMu.Lock()
defer s.cacheMu.Unlock()
if s.discovery == nil {
s.discovery = make(map[string]subtitleDiscoveryEntry)
}
if len(s.discovery) >= subtitleDiscoveryCacheCap {
s.discovery = make(map[string]subtitleDiscoveryEntry)
}
s.discovery[mediaID] = subtitleDiscoveryEntry{
tracks: append([]SubtitleTrack(nil), tracks...),
expiresAt: now.Add(subtitleDiscoveryTTL),
}
}
func (s *SubtitleService) discoverUncached(ctx context.Context, mediaID string) ([]SubtitleTrack, error) {
m, err := s.repo.Media.FindByID(ctx, mediaID)
if err != nil {
return nil, err
+2 -1
View File
@@ -11,6 +11,7 @@ import (
"go.uber.org/zap"
"github.com/truewhile/MeBox/internal/config"
"github.com/truewhile/MeBox/internal/helper"
"github.com/truewhile/MeBox/internal/repository"
)
@@ -132,7 +133,7 @@ func (s *SystemUpdateService) Apply(ctx context.Context) (SystemUpdateStatus, er
s.last = &status
s.mu.Unlock()
go s.runUpdate(context.Background(), status, task)
helper.Go(s.log, "system.update", func() { s.runUpdate(context.Background(), status, task) })
return status, nil
}
+8 -4
View File
@@ -7,6 +7,7 @@ import (
"go.uber.org/zap"
"github.com/truewhile/MeBox/internal/helper"
"github.com/truewhile/MeBox/internal/model"
"github.com/truewhile/MeBox/internal/repository"
)
@@ -26,11 +27,14 @@ func (s *TokenService) storeRefreshTokenBestEffort(userID, tokenHash string, exp
go func() {
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()
done <- s.storeRefreshToken(ctx, &model.RefreshToken{
UserID: userID,
TokenHash: tokenHash,
ExpiresAt: expiresAt,
err := helper.Recover(s.log, "token.storeRefreshToken", func() error {
return s.storeRefreshToken(ctx, &model.RefreshToken{
UserID: userID,
TokenHash: tokenHash,
ExpiresAt: expiresAt,
})
})
done <- err
}()
select {
case err := <-done:
+3 -2
View File
@@ -33,6 +33,7 @@ import (
"go.uber.org/zap"
"github.com/truewhile/MeBox/internal/config"
"github.com/truewhile/MeBox/internal/helper"
"github.com/truewhile/MeBox/internal/repository"
)
@@ -143,7 +144,7 @@ func (t *TranscoderService) EnsureJob(ctx context.Context, mediaID string) (stri
t.jobs[mediaID] = job
t.mu.Unlock()
go t.monitorIdle(jobCtx, job)
go t.runFFmpeg(jobCtx, job, m.Path)
helper.Go(t.log, "transcoder.monitorIdle", func() { t.monitorIdle(jobCtx, job) })
helper.Go(t.log, "transcoder.ffmpeg", func() { t.runFFmpeg(jobCtx, job, m.Path) })
return t.PlaylistPath(mediaID), nil
}
+3 -2
View File
@@ -22,6 +22,7 @@ import (
"github.com/fsnotify/fsnotify"
"go.uber.org/zap"
"github.com/truewhile/MeBox/internal/helper"
"github.com/truewhile/MeBox/internal/model"
"github.com/truewhile/MeBox/internal/repository"
)
@@ -69,8 +70,8 @@ func (w *WatcherService) Start(ctx context.Context) error {
if err := w.Refresh(ctx); err != nil {
w.log.Warn("watcher refresh failed", zap.Error(err))
}
go w.loop(ctx)
go w.debouncer(ctx)
helper.Go(w.log, "watcher.loop", func() { w.loop(ctx) })
helper.Go(w.log, "watcher.debouncer", func() { w.debouncer(ctx) })
return nil
}
+2 -6
View File
@@ -8,12 +8,8 @@
<link rel="icon" type="image/png" href="/brand/logo-64.png" />
<link rel="icon" type="image/x-icon" href="/favicon.ico" />
<link rel="apple-touch-icon" href="/brand/logo-192.png" />
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link
href="https://fonts.googleapis.com/css2?family=Cabinet+Grotesk:wght@400;500;600;700;800&family=Geist:wght@300;400;500;600;700&family=JetBrains+Mono:wght@400;500;600&display=swap"
rel="stylesheet"
/>
<!-- 字体已自托管(见 src/index.css 的 @font-face),不再使用 Google Fonts 外链:
自托管环境常无法访问 Google,渲染阻塞外链会拖慢首屏。 -->
<title>MeBox</title>
</head>
<body class="font-body antialiased">
+11
View File
@@ -19,6 +19,7 @@
"react-dom": "^18.3.1",
"react-hot-toast": "^2.6.0",
"react-router-dom": "^6.23.1",
"react-virtuoso": "^4.18.12",
"zustand": "^4.5.2"
},
"devDependencies": {
@@ -3874,6 +3875,16 @@
"react-dom": ">=16.8"
}
},
"node_modules/react-virtuoso": {
"version": "4.18.12",
"resolved": "https://registry.npmjs.org/react-virtuoso/-/react-virtuoso-4.18.12.tgz",
"integrity": "sha512-6c1SnRicSBfG+WnbhcyJUxzDHvvxD3vsux/EpcfVbgB7clyP1Od2r81TAGTgwbL7U2qP889RR4u+CH6WjgpW0g==",
"license": "MIT",
"peerDependencies": {
"react": ">=16 || >=17 || >= 18 || >= 19",
"react-dom": ">=16 || >=17 || >= 18 || >=19"
}
},
"node_modules/read-cache": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz",
+1
View File
@@ -21,6 +21,7 @@
"react-dom": "^18.3.1",
"react-hot-toast": "^2.6.0",
"react-router-dom": "^6.23.1",
"react-virtuoso": "^4.18.12",
"zustand": "^4.5.2"
},
"devDependencies": {
BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 140 KiB

BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 408 KiB

BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 140 KiB

-1
View File
File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 199 KiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 81 KiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 81 KiB

BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 MiB

Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+28 -1
View File
@@ -87,17 +87,31 @@ export interface LibraryWithPreview extends Library {
}
export const libraryAPI = {
list: (options?: { includeHidden?: boolean; withPreview?: boolean; previewLimit?: number }) =>
list: (options?: { includeHidden?: boolean; withPreview?: boolean; previewLimit?: number; ids?: string[] }) =>
api
.get<LibraryWithPreview[]>('/libraries', {
params: {
...(options?.includeHidden ? { include_hidden: 1 } : {}),
...(options?.withPreview ? { with_preview: 1 } : {}),
...(options?.previewLimit ? { preview_limit: options.previewLimit } : {}),
...(options?.ids && options.ids.length > 0 ? { ids: options.ids.join(',') } : {}),
},
})
.then((r) => r.data),
listPreviews: (ids: string[], previewLimit = 10) => {
if (ids.length === 0) return Promise.resolve<LibraryWithPreview[]>([])
return api
.get<LibraryWithPreview[]>('/libraries', {
params: {
with_preview: 1,
preview_limit: previewLimit,
ids: ids.join(','),
},
})
.then((r) => r.data)
},
get: (id: string, options?: { includeHidden?: boolean }) =>
api
.get<Library>(`/libraries/${id}`, {
@@ -225,6 +239,19 @@ export const mediaAPI = {
{ timeout: BATCH_REQUEST_TIMEOUT },
)
.then((r) => r.data),
delete: (id: string, options?: { deleteFiles?: boolean }) =>
api
.delete(`/media/${id}`, {
params: options?.deleteFiles ? { delete_files: true } : undefined,
})
.then((r) => r.data),
exportNFO: (id: string) =>
api.post<{ path: string }>(`/media/${id}/nfo`).then((r) => r.data),
exportLibraryNFO: (id: string) =>
api.post<{ written: number }>(`/libraries/${id}/nfo`).then((r) => r.data),
}
function episodeImageOption(options?: ScrapeOptions): boolean | undefined {
-15
View File
@@ -1,15 +0,0 @@
import { api } from './client'
export const recycleAPI = {
softDelete: (id: string) => api.delete(`/media/${id}`).then((r) => r.data),
restore: (id: string) => api.post(`/media/${id}/restore`).then((r) => r.data),
purge: (id: string) => api.delete(`/media/${id}/purge`).then((r) => r.data),
exportNFO: (id: string) =>
api.post<{ path: string }>(`/media/${id}/nfo`).then((r) => r.data),
exportLibraryNFO: (id: string) =>
api.post<{ written: number }>(`/libraries/${id}/nfo`).then((r) => r.data),
}
+50 -13
View File
@@ -2,25 +2,34 @@
import { lazy, type ReactElement } from 'react'
import { Navigate } from 'react-router-dom'
const HomePage = lazy(() => import('./pages/HomePage').then((m) => ({ default: m.HomePage })))
const LibraryPage = lazy(() => import('./pages/LibraryPage').then((m) => ({ default: m.LibraryPage })))
const LibrariesPage = lazy(() => import('./pages/LibrariesPage').then((m) => ({ default: m.LibrariesPage })))
const FavouritesPage = lazy(() => import('./pages/FavouritesPage').then((m) => ({ default: m.FavouritesPage })))
const PlaylistsPage = lazy(() => import('./pages/PlaylistsPage').then((m) => ({ default: m.PlaylistsPage })))
const PlaylistDetailPage = lazy(() =>
import('./pages/PlaylistDetailPage').then((m) => ({ default: m.PlaylistDetailPage })),
)
const MediaDetailPage = lazy(() => import('./pages/MediaDetailPage').then((m) => ({ default: m.MediaDetailPage })))
const PlayerPage = lazy(() => import('./pages/PlayerPage').then((m) => ({ default: m.PlayerPage })))
const HomePageLoader = () => import('./pages/HomePage').then((m) => ({ default: m.HomePage }))
const LibraryPageLoader = () => import('./pages/LibraryPage').then((m) => ({ default: m.LibraryPage }))
const LibrariesPageLoader = () => import('./pages/LibrariesPage').then((m) => ({ default: m.LibrariesPage }))
const FavouritesPageLoader = () => import('./pages/FavouritesPage').then((m) => ({ default: m.FavouritesPage }))
const PlaylistsPageLoader = () => import('./pages/PlaylistsPage').then((m) => ({ default: m.PlaylistsPage }))
const PlaylistDetailPageLoader = () =>
import('./pages/PlaylistDetailPage').then((m) => ({ default: m.PlaylistDetailPage }))
const MediaDetailPageLoader = () =>
import('./pages/MediaDetailPage').then((m) => ({ default: m.MediaDetailPage }))
const PlayerPageLoader = () => import('./pages/PlayerPage').then((m) => ({ default: m.PlayerPage }))
const WatchHistoryPageLoader = () =>
import('./pages/WatchHistoryPage').then((m) => ({ default: m.WatchHistoryPage }))
const HomePage = lazy(HomePageLoader)
const LibraryPage = lazy(LibraryPageLoader)
const LibrariesPage = lazy(LibrariesPageLoader)
const FavouritesPage = lazy(FavouritesPageLoader)
const PlaylistsPage = lazy(PlaylistsPageLoader)
const PlaylistDetailPage = lazy(PlaylistDetailPageLoader)
const MediaDetailPage = lazy(MediaDetailPageLoader)
const PlayerPage = lazy(PlayerPageLoader)
const AdminPage = lazy(() => import('./pages/AdminPage').then((m) => ({ default: m.AdminPage })))
const ProfilePage = lazy(() => import('./pages/ProfilePage').then((m) => ({ default: m.ProfilePage })))
const DlnaPage = lazy(() => import('./pages/DlnaPage').then((m) => ({ default: m.DlnaPage })))
const FileManagerPage = lazy(() =>
import('./pages/FileManagerPage').then((m) => ({ default: m.FileManagerPage })),
)
const WatchHistoryPage = lazy(() =>
import('./pages/WatchHistoryPage').then((m) => ({ default: m.WatchHistoryPage })),
)
const WatchHistoryPage = lazy(WatchHistoryPageLoader)
const PosterWallPage = lazy(() => import('./pages/PosterWallPage').then((m) => ({ default: m.PosterWallPage })))
const ProfileManagementPage = lazy(() =>
import('./pages/ProfileManagementPage').then((m) => ({ default: m.ProfileManagementPage })),
@@ -37,6 +46,34 @@ const StrmUploadQueuePage = lazy(() =>
const ScraperQueuePage = lazy(() => import('./pages/ScraperQueuePage').then((m) => ({ default: m.ScraperQueuePage })))
const TaskQueuePage = lazy(() => import('./pages/TaskQueuePage').then((m) => ({ default: m.TaskQueuePage })))
// 常用页面的路由 chunk 空闲预取:应用加载完成后浏览器一空闲就把浏览路径
// (库列表/库详情/媒体详情/播放/收藏/历史等)的 chunk 拉下来,
// 首次点击进入时不再出现"加载中…"等 chunk 下载。失败静默(导航时会重试)。
let prefetchStarted = false
export function prefetchCommonRouteChunks() {
if (prefetchStarted || typeof window === 'undefined') return
prefetchStarted = true
const loaders = [
LibrariesPageLoader,
LibraryPageLoader,
MediaDetailPageLoader,
PlayerPageLoader,
FavouritesPageLoader,
WatchHistoryPageLoader,
PlaylistsPageLoader,
]
const run = () => {
for (const load of loaders) {
load().catch(() => undefined)
}
}
if (typeof requestIdleCallback !== 'undefined') {
requestIdleCallback(run, { timeout: 5000 })
} else {
window.setTimeout(run, 2000)
}
}
export type AppRoute = {
path?: string
index?: boolean
+26 -5
View File
@@ -1,3 +1,4 @@
import { useState } from 'react'
import { AlertTriangle } from 'lucide-react'
export type ConfirmOptions = {
@@ -6,6 +7,13 @@ export type ConfirmOptions = {
confirmText?: string
cancelText?: string
danger?: boolean
checkboxLabel?: string
checkboxDefaultChecked?: boolean
}
export type ConfirmResult = {
confirmed: boolean
checked: boolean
}
export function ConfirmDialog({
@@ -13,13 +21,15 @@ export function ConfirmDialog({
onClose,
}: {
options: ConfirmOptions
onClose: (value: boolean) => void
onClose: (value: ConfirmResult) => void
}) {
const danger = options.danger ?? true
const hasCheckbox = Boolean(options.checkboxLabel)
const [checked, setChecked] = useState(Boolean(options.checkboxDefaultChecked))
return (
<div
className="fixed inset-0 z-[100] flex items-center justify-center bg-black/35 p-4 backdrop-blur-sm"
onClick={() => onClose(false)}
onClick={() => onClose({ confirmed: false, checked })}
>
<div
role="dialog"
@@ -40,20 +50,31 @@ export function ConfirmDialog({
<h3 className="font-display text-lg font-bold text-ink-600">
{options.title || '确认操作'}
</h3>
<p className="mt-2 text-sm leading-6 text-ink-50">{options.message}</p>
<p className="mt-2 whitespace-pre-line text-sm leading-6 text-ink-50">{options.message}</p>
{hasCheckbox ? (
<label className="mt-4 flex cursor-pointer items-start gap-2.5 text-sm text-ink-100">
<input
type="checkbox"
className="mt-0.5 h-4 w-4 rounded border-gray-300 text-red-500 focus:ring-red-400"
checked={checked}
onChange={(event) => setChecked(event.target.checked)}
/>
<span>{options.checkboxLabel}</span>
</label>
) : null}
</div>
</div>
<div className="flex justify-end gap-2 border-t border-gray-100 bg-gray-50/80 px-5 py-4">
<button
type="button"
onClick={() => onClose(false)}
onClick={() => onClose({ confirmed: false, checked })}
className="rounded-xl border border-gray-200 bg-white px-4 py-2 text-sm font-semibold text-ink-100 hover:bg-gray-50"
>
{options.cancelText || '取消'}
</button>
<button
type="button"
onClick={() => onClose(true)}
onClick={() => onClose({ confirmed: true, checked })}
className={
'rounded-xl px-4 py-2 text-sm font-semibold text-white shadow-sm transition ' +
(danger ? 'bg-red-500 hover:bg-red-600' : 'bg-brand-500 hover:bg-brand-600')
+9
View File
@@ -1,5 +1,7 @@
import { useEffect } from 'react'
import { useLocation, useNavigate } from 'react-router-dom'
import { prefetchCommonRouteChunks } from '../appRoutes'
import { useAuthStore } from '../stores/auth'
import { usePlayProfileStore } from '../stores/playProfile'
import {
@@ -37,6 +39,13 @@ function isMediaView(pathname: string, search: string): boolean {
export function Layout() {
const navigate = useNavigate()
const location = useLocation()
// 登录后的外壳挂载即开始空闲预取常用页面的路由 chunk,
// 让首次点击进入各页面时不出现"加载中…"等 chunk 下载。
useEffect(() => {
prefetchCommonRouteChunks()
}, [])
const user = useAuthStore((s) => s.user)
const logout = useAuthStore((s) => s.logout)
const activeProfileId = usePlayProfileStore((s) => s.activeProfileId)
+5 -4
View File
@@ -6,6 +6,7 @@ import { ArrowLeft, Film, LoaderCircle, Menu, Search, Star, X } from 'lucide-rea
import { imageURL } from '../api/client'
import { mediaAPI } from '../api/library'
import type { Media, PlayProfile, User } from '../types'
import { favouriteMediaLink } from '../utils/mediaNavigation'
import { resolveHeaderBack } from './layoutNavigation'
import { LayoutThemeToggle } from './LayoutThemeToggle'
import { LayoutUserMenu } from './LayoutUserMenu'
@@ -149,17 +150,17 @@ function LayoutHeaderSearch() {
return () => document.removeEventListener('mousedown', handleClickOutside)
}, [])
const handleSelect = (mediaId: string) => {
const handleSelect = (item: Media) => {
setIsOpen(false)
setQuery('')
navigate(`/media/${mediaId}`)
navigate(favouriteMediaLink(item))
}
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === 'Escape') {
setIsOpen(false)
} else if (e.key === 'Enter' && results.length > 0) {
handleSelect(results[0].id)
handleSelect(results[0])
}
}
@@ -220,7 +221,7 @@ function LayoutHeaderSearch() {
{results.map((item) => (
<button
key={item.id}
onClick={() => handleSelect(item.id)}
onClick={() => handleSelect(item)}
className="flex w-full items-center gap-3 rounded-xl p-2 text-left transition-colors hover:bg-[var(--app-hover)] group"
>
<div className="relative h-12 w-9 shrink-0 overflow-hidden rounded-lg bg-[var(--app-panel-soft)]">
+1 -1
View File
@@ -137,7 +137,7 @@ export function LayoutWorkspace({ routeKey, showMobileBottomNav = false }: Layou
}
return (
<main className={clsx('flex-1 overflow-y-auto px-4 py-6 md:px-8 md:py-10', bottomPad)}>
<main id="app-main-scroll" className={clsx('flex-1 overflow-y-auto px-4 py-6 md:px-8 md:py-10', bottomPad)}>
<div className="max-w-7xl mx-auto">
<AnimatePresence mode="wait">
<motion.div
@@ -202,7 +202,7 @@ function ManualScrapeCandidateRow({
<div className="flex flex-col gap-4 rounded-xl border border-sand-200 bg-white p-3 shadow-sm sm:flex-row">
<div className="h-28 w-20 shrink-0 overflow-hidden rounded-lg bg-sand-100">
{item.poster_url ? (
<img src={imageURL(item.poster_url)} alt={item.title} 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" />
) : (
<div className="flex h-full items-center justify-center text-xs text-sand-500">无海报</div>
)}
+23 -26
View File
@@ -1,12 +1,13 @@
import { useEffect, useRef, useState, type ReactNode } from 'react'
import { memo, useEffect, useRef, useState, type ReactNode } from 'react'
import { Link } from 'react-router-dom'
import { motion } from 'framer-motion'
import { Film, Play, Layers, Star } from 'lucide-react'
import { imageURL } from '../api/client'
import type { Media } from '../types'
export const MediaCard = ({
media, progress, count, rating, linkTo, onClick, actions, compact,
// memo:父级状态变化(如轮播切图、其它卡片操作)不再级联重渲染所有卡片。
// 注意 actions/renderActions 必须引用稳定(用 renderActions 传函数)memo 才生效。
export const MediaCard = memo(function MediaCard({
media, progress, count, rating, linkTo, onClick, actions, renderActions, compact,
}: {
media: Media
progress?: number
@@ -15,25 +16,26 @@ export const MediaCard = ({
linkTo?: string
onClick?: () => void
actions?: ReactNode
renderActions?: (media: Media) => ReactNode
compact?: boolean
}) => {
}) {
const ref = useRef<HTMLDivElement>(null)
const href = linkTo ?? `/media/${media.id}`
const [posterFit, setPosterFit] = useState<'cover' | 'contain'>('cover')
const posterSrc = imageURL(media.poster_url, media.updated_at)
const displayRating = rating ?? media.rating
const versionCount = media.versions?.length ?? 0
// renderActions 延迟到卡片自身渲染时才调用,保证 memo 生效
const actionContent = actions ?? renderActions?.(media)
useEffect(() => {
setPosterFit('cover')
}, [media.poster_url, media.updated_at])
const card = (
<motion.div
<div
ref={ref}
whileHover={{ scale: 1.04, y: -6 }}
transition={{ type: 'spring', stiffness: 280, damping: 22 }}
className="relative overflow-hidden rounded-2xl border border-[var(--app-border)] bg-[var(--app-panel)] shadow-[0_1px_3px_rgba(0,0,0,0.01),0_1px_2px_rgba(0,0,0,0.015)] transition-all duration-300 hover:border-brand-500/40 hover:shadow-[0_12px_32px_var(--app-shadow)]"
className="relative overflow-hidden rounded-2xl border border-[var(--app-border)] bg-[var(--app-panel)] shadow-[0_1px_3px_rgba(0,0,0,0.01),0_1px_2px_rgba(0,0,0,0.015)] transition-all duration-300 hover:scale-[1.04] hover:-translate-y-1.5 hover:border-brand-500/40 hover:shadow-[0_12px_32px_var(--app-shadow)]"
>
{/* Poster Wrapper */}
<div className="relative aspect-[2/3] w-full overflow-hidden bg-[var(--app-panel-soft)]">
@@ -95,16 +97,11 @@ export const MediaCard = ({
</span>
)}
{/* Premium Hover Overlay */}
{/* Premium Hover Overlay(CSS 过渡替代 framer-motion 逐卡动画实例) */}
<div className={`absolute inset-0 bg-gradient-to-t from-[#111827]/90 via-[#111827]/30 to-transparent opacity-0 group-hover:opacity-100 transition-opacity duration-300 flex flex-col justify-end ${
compact ? 'p-3' : 'p-4'
}`}>
<motion.div
initial={{ y: 15, opacity: 0 }}
whileHover={{ y: 0, opacity: 1 }}
transition={{ duration: 0.2 }}
className="space-y-2"
>
<div className="space-y-2 translate-y-4 transition-transform duration-200 group-hover:translate-y-0">
<span className={`inline-flex items-center gap-1.5 rounded-xl bg-brand-500 px-4 py-2 font-bold text-white shadow-md shadow-brand-500/20 ${
compact ? 'py-1.5 text-[11px]' : 'text-xs py-2'
}`}>
@@ -116,15 +113,15 @@ export const MediaCard = ({
{media.overview || "暂无简介内容"}
</p>
)}
</motion.div>
</div>
</div>
{/* Progress Bar overlay */}
{progress !== undefined && progress > 0 && progress < 1 && (
<div className="absolute inset-x-0 bottom-0 h-1.5 bg-[var(--app-hover)]">
<div
className="h-full bg-gradient-to-r from-brand-400 to-brand-500 rounded-r-full transition-all duration-300"
style={{ width: `${Math.round(progress * 100)}%` }}
<div
className="h-full bg-gradient-to-r from-brand-400 to-brand-500 rounded-r-full transition-all duration-300"
style={{ width: `${Math.round(progress * 100)}%` }}
/>
</div>
)}
@@ -148,7 +145,7 @@ export const MediaCard = ({
)}
</div>
</div>
</motion.div>
</div>
)
if (onClick) {
@@ -157,23 +154,23 @@ export const MediaCard = ({
<button type="button" onClick={onClick} className="block w-full text-left">
{card}
</button>
{actions && (
{actionContent && (
<div className="absolute right-2 top-2 z-20 flex flex-wrap justify-end gap-1 opacity-0 transition-opacity group-hover:opacity-100 focus-within:opacity-100">
{actions}
{actionContent}
</div>
)}
</div>
)
}
if (actions) {
if (actionContent) {
return (
<div className="group relative block">
<Link to={href} className="block">
{card}
</Link>
<div className="absolute right-2 top-2 z-20 flex flex-wrap justify-end gap-1 opacity-0 transition-opacity group-hover:opacity-100 focus-within:opacity-100">
{actions}
{actionContent}
</div>
</div>
)
@@ -184,4 +181,4 @@ export const MediaCard = ({
{card}
</Link>
)
}
})
-52
View File
@@ -1,52 +0,0 @@
import { type ReactNode } from 'react'
import { usePermissionStore } from '../stores/permissions'
import { useAuthStore } from '../stores/auth'
interface PermissionGuardProps {
permission: string
children: ReactNode
fallback?: ReactNode
requireSuperUser?: boolean
}
/**
* PermissionGuard 组件用于根据用户权限控制内容显示。
*
* @param permission - 需要的权限键
* @param children - 有权限时显示的内容
* @param fallback - 无权限时显示的内容(可选,默认不显示)
* @param requireSuperUser - 是否要求超级用户(admin/plus)绕过权限检查
*/
export function PermissionGuard({
permission,
children,
fallback = null,
requireSuperUser = false,
}: PermissionGuardProps) {
const { hasPermission, isSuper, permissions, isLoading } = usePermissionStore()
const tier = useAuthStore((state) => state.tier)
const role = useAuthStore((state) => state.user?.role)
// 超级用户(admin 或 plus)默认有所有权限
if (isSuper || tier === 'plus' || role === 'admin') {
return <>{children}</>
}
// 如果 requireSuperUser 为 true 且用户不是超级用户,则不显示
if (requireSuperUser && !isSuper) {
return <>{fallback}</>
}
// 加载中时显示 fallback
if (isLoading && Object.keys(permissions).length === 0) {
return <>{fallback}</>
}
// 检查具体权限
if (hasPermission(permission)) {
return <>{children}</>
}
return <>{fallback}</>
}

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