Compare commits

...

42 Commits

Author SHA1 Message Date
github-actions[bot] d00b14df55 chore: bump version to 0.0.108 [skip ci] 2026-09-05 04:34:31 +00:00
truewhile 1407b9b5c4 优化,排查项目问题 2026-09-05 12:34:17 +08:00
github-actions[bot] 7fa05391e1 chore: bump version to 0.0.107 [skip ci] 2026-09-04 17:15:28 +00:00
truewhile 4790f7753e bug处理 2026-09-05 01:15:11 +08:00
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
github-actions[bot] 205c71b0ad chore: bump version to 0.0.95 [skip ci] 2026-09-03 04:46:22 +00:00
truewhile 5474784107 优化 2026-09-03 12:46:02 +08:00
github-actions[bot] fb92373f0d chore: bump version to 0.0.94 [skip ci] 2026-09-03 02:39:57 +00:00
truewhile 2b99f5f108 fix: enforce user library ACL on mounted Emby libraries (#25)
Mounted Emby libraries were always appended to web/Emby library lists and
detail/play routes without checking allowed_library_ids, so restricted
users could still see and open them. Filter remotes with the same
visibility policy as local libraries across list/detail/series/stream and
Emby Views/Items/search/playback, and label mounts in the admin ACL UI.

Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: truewhile <truewhile@users.noreply.github.com>
2026-09-03 10:39:44 +08:00
github-actions[bot] 9ab32c10ca chore: bump version to 0.0.93 [skip ci] 2026-09-03 02:26:16 +00:00
truewhile e872223b4e feat: honor pinned libraries in Emby client Views order (#24)
Sort /Users/.../Views so libraries the user pinned on the web appear
first for Infuse/Emby/Jellyfin clients. Unpinned libraries keep their
existing sort_order / remote-mount order.

Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: truewhile <truewhile@users.noreply.github.com>
2026-09-03 10:26:04 +08:00
github-actions[bot] c7441e06d4 chore: bump version to 0.0.92 [skip ci] 2026-09-03 02:13:21 +00:00
truewhile 936be24328 fix: stop pinned-library toggle from saving an empty list (#23)
The pin hook captured the optimistic ID list inside a React setState
updater. That updater can run after the following lines, so save ran with
optimistic=[] and overwrote server pins — matching the flash then wipe
when pinning a mounted Emby library after a local pin.

Compute previous/optimistic synchronously from a ref before calling setState.

Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: truewhile <truewhile@users.noreply.github.com>
2026-09-03 10:13:04 +08:00
github-actions[bot] b7f2760728 chore: bump version to 0.0.91 [skip ci] 2026-09-03 01:41:06 +00:00
truewhile ea732e5bd7 fix: allow pinning mounted Emby libraries without wiping local pins (#22)
accessibleLibraryIDSet only consulted the local libraries table, so
embyremote~ mount IDs were stripped on every GET/PUT. Saving a pin that
included a remote library could then persist a filtered list and clear
existing local pins from the client state. Include enabled Emby mounts in
the accessible set, and refuse pin toggles until the server list has
loaded successfully.

Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: truewhile <truewhile@users.noreply.github.com>
2026-09-03 09:40:52 +08:00
github-actions[bot] 4e02692aeb chore: bump version to 0.0.90 [skip ci] 2026-09-03 00:50:58 +00:00
truewhile e8c710c261 fix: sync mounted Emby favourites across web and Emby clients (#21)
Unify favourite writes through SyncUserFavorite so remote mounted media is
stored locally and proxied to the upstream Emby server. Emby clients now
query favourites from the local table (including remote pseudo IDs), and
remote item payloads merge local IsFavorite state.

Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: truewhile <truewhile@users.noreply.github.com>
2026-09-03 08:50:46 +08:00
github-actions[bot] 27d2b79e1f chore: bump version to 0.0.89 [skip ci] 2026-09-03 00:47:22 +00:00
truewhile 1e291df647 feat: persist pinned libraries per user with cross-device sync (#20)
Store pinned library IDs on the user record and expose GET/PUT
/me/pinned-libraries endpoints. The web client now loads and saves pins
through the API, migrates legacy localStorage data once, and applies
pinned ordering on both the libraries and home pages.

Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: truewhile <truewhile@users.noreply.github.com>
2026-09-03 08:47:06 +08:00
github-actions[bot] 1025e3c693 chore: bump version to 0.0.88 [skip ci] 2026-09-02 15:05:55 +00:00
truewhile ba27d48d10 优化 2026-09-02 23:05:38 +08:00
232 changed files with 8296 additions and 2705 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.87
0.0.108
+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)
+16 -5
View File
@@ -130,26 +130,35 @@ func (m *serverManager) Shutdown(ctx context.Context) error {
// desiredPair 根据当前配置计算目标监听形态:nil 表示明文 HTTP,非 nil 表示 TLS。
// 证书/私钥按"路径优先、内容兜底"解析,并校验是否匹配。
func (m *serverManager) desiredPair() (*tlsPair, error) {
if m.cfg == nil || !m.cfg.App.HTTPSEnabled {
// 与 ApplyRuntimeSetting 的写锁配对:HTTPS 相关字段可能被运行时设置
// 热更新,无锁读存在数据竞争(string 撕裂)。
config.RuntimeMu.RLock()
httpsEnabled := m.cfg != nil && m.cfg.App.HTTPSEnabled
cert := m.cfg.App.SSLCert
certPath := m.cfg.App.SSLCertPath
key := m.cfg.App.SSLKey
keyPath := m.cfg.App.SSLKeyPath
config.RuntimeMu.RUnlock()
if !httpsEnabled {
return nil, nil
}
certPEM, err := service.ResolveSSLMaterial(m.cfg.App.SSLCert, m.cfg.App.SSLCertPath, "证书")
certPEM, err := service.ResolveSSLMaterial(cert, certPath, "证书")
if err != nil {
return nil, err
}
keyPEM, err := service.ResolveSSLMaterial(m.cfg.App.SSLKey, m.cfg.App.SSLKeyPath, "私钥")
keyPEM, err := service.ResolveSSLMaterial(key, keyPath, "私钥")
if err != nil {
return nil, err
}
if err := service.ValidateSSLKeyPair(certPEM, keyPEM); err != nil {
return nil, err
}
cert, err := tls.X509KeyPair([]byte(certPEM), []byte(keyPEM))
pairCert, err := tls.X509KeyPair([]byte(certPEM), []byte(keyPEM))
if err != nil {
return nil, fmt.Errorf("SSL 证书/私钥无效:%v", err)
}
return &tlsPair{
cert: cert,
cert: pairCert,
certPEM: certPEM,
keyPEM: keyPEM,
version: certPEM + "\x00" + keyPEM,
@@ -171,6 +180,8 @@ func (m *serverManager) maybeStartAutoReloadLocked() {
// pathBased 是否至少有一侧证书/私钥通过文件路径配置。
func (m *serverManager) pathBased() bool {
config.RuntimeMu.RLock()
defer config.RuntimeMu.RUnlock()
return strings.TrimSpace(m.cfg.App.SSLCertPath) != "" || strings.TrimSpace(m.cfg.App.SSLKeyPath) != ""
}
+27 -20
View File
@@ -5,19 +5,20 @@ 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/golang-jwt/jwt/v5 v5.2.2
github.com/google/uuid v1.6.0
github.com/gorilla/websocket v1.5.3
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
+62 -55
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/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/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.2 h1:Rl4B7itRWVtYIHFrSNd7vhTiz9UpLdi6gZhZ3wEeDy8=
github.com/golang-jwt/jwt/v5 v5.2.2/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
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=
+14 -2
View File
@@ -12,6 +12,7 @@ import (
"os"
"path/filepath"
"strings"
"sync"
"github.com/spf13/viper"
)
@@ -19,6 +20,12 @@ import (
// EnvPrefix 是所有环境变量驱动的覆盖使用的前缀。
const EnvPrefix = "MeBox"
// RuntimeMu 保护运行时热更新配置字段的并发读写:ApplyRuntimeSetting 在
// HTTP goroutine 中写字段,serverManager 的证书轮询等后台协程在无锁读取
// 同一批字段。string 是双字结构,无锁并发读写可读到撕裂的 header。
// 写方在 ApplyRuntimeSetting 内 Lock,读方(cmd/server)在轮询处 RLock。
var RuntimeMu sync.RWMutex
// Load 从默认值 / 文件 / 环境读取配置。
//
// 即使没有文件也始终返回可用的 Config。
@@ -45,8 +52,13 @@ func Load() (*Config, error) {
}
s := viper.New()
s.SetConfigFile(filepath.Join("config", e.Name()))
if err := s.ReadInConfig(); err == nil {
_ = v.MergeConfigMap(s.AllSettings())
if err := s.ReadInConfig(); err != nil {
// 分片解析失败不能静默吞掉:database.yaml 语法错误会让
// database.dsn 缺失 → type=auto 静默回退 SQLite,新数据
// 全部写进一个空库而用户无感知。
fmt.Fprintf(os.Stderr, "warning: parse config/%s failed: %v\n", e.Name(), err)
} else {
v.MergeConfigMap(s.AllSettings())
}
}
}
+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", "")
+9 -3
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 == "" {
@@ -68,8 +68,14 @@ func (c *Config) normalize() error {
return fmt.Errorf("generate jwt secret: %w", err)
}
c.Secrets.JWTSecret = hex.EncodeToString(buf)
_ = os.MkdirAll(c.App.DataDir, 0o750)
_ = os.WriteFile(path, []byte(c.Secrets.JWTSecret), 0o600)
// 持久化失败(DataDir 只读/权限异常)会导致每次重启重新生成
// 密钥、全部会话静默失效、多实例各持不同 secret——必须让
// 操作员感知。
if mkErr := os.MkdirAll(c.App.DataDir, 0o750); mkErr != nil {
fmt.Fprintf(os.Stderr, "warning: persist jwt secret failed (mkdir): %v\n", mkErr)
} else if wErr := os.WriteFile(path, []byte(c.Secrets.JWTSecret), 0o600); wErr != nil {
fmt.Fprintf(os.Stderr, "warning: persist jwt secret failed (write): %v\n", wErr)
}
}
}
return nil
+9 -2
View File
@@ -34,8 +34,15 @@ func SaveDatabaseConfig(dbType, dsn string) error {
return fmt.Errorf("marshal config.yaml: %w", err)
}
if err := os.WriteFile(configPath, out, 0644); err != nil {
return fmt.Errorf("write config.yaml: %w", err)
// 原子写:临时文件 + rename,避免进程崩溃/断电留下截断的 config.yaml
// (下次启动会硬失败);DSN 含数据库密码,权限收窄到 0600。
tmp := configPath + ".tmp"
if err := os.WriteFile(tmp, out, 0o600); err != nil {
return fmt.Errorf("write config.yaml.tmp: %w", err)
}
if err := os.Rename(tmp, configPath); err != nil {
_ = os.Remove(tmp)
return fmt.Errorf("replace config.yaml: %w", err)
}
return nil
}
+9
View File
@@ -6,6 +6,7 @@ import (
"errors"
"fmt"
"strings"
"time"
"github.com/glebarez/sqlite"
"go.uber.org/zap"
@@ -73,6 +74,14 @@ func configureConnectionPool(db *gorm.DB, cfg *config.Config) error {
if cfg.Database.MaxIdleConns > 0 {
sqlDB.SetMaxIdleConns(cfg.Database.MaxIdleConns)
}
// 连接生命周期:默认 0 意味着 Postgres 重启/故障切换后的陈旧连接
// 永不过期,首次复用才报错,运行期断连恢复慢且可能批量报错。
if isPostgres(db) {
sqlDB.SetConnMaxLifetime(time.Hour)
sqlDB.SetConnMaxIdleTime(10 * time.Minute)
} else if isSQLite(db) {
sqlDB.SetConnMaxLifetime(24 * time.Hour)
}
return nil
}
+1 -1
View File
@@ -9,7 +9,7 @@ const mediaSearchIndexSchemaVersion = 2
func ensureMediaSearchIndex(db *gorm.DB) error {
if err := ensureMediaSearchMetaTable(db); err != nil {
return nil
return err // meta 表创建失败必须上抛,不能静默掩盖
}
version := currentMediaSearchIndexVersion(db)
if version != mediaSearchIndexSchemaVersion {
+45 -5
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`,
@@ -98,13 +132,19 @@ func ensureEmbyMountsCompatibility(db *gorm.DB) error {
return err
}
}
// 针对已有数据:如果存在多个 sort_order=0/NULL 的记录,按创建时间顺序赋予稳定递增的序号
// 针对已有数据:只给 sort_order=0/NULL 的行按创建时间补号(从现有
// 最大值之后递增),不能整表重排——此前无条件按 created_at 从 0 重新
// 编号,会把用户自定义的顺序覆盖掉。
var zeroCount int64
if err := db.Model(&model.EmbyMount{}).Where("sort_order = 0 OR sort_order IS NULL").Count(&zeroCount).Error; err == nil && zeroCount > 1 {
if err := db.Model(&model.EmbyMount{}).Where("sort_order = 0 OR sort_order IS NULL").Count(&zeroCount).Error; err == nil && zeroCount > 0 {
// max 只统计非 0 行:sort_order=0 与 NULL 同样视为“未分配”,
// 全部为 0 时从 0 开始编号(与迁移前的初始化语义一致)。
var maxOrder int
_ = db.Raw("SELECT COALESCE(MAX(sort_order), -1) FROM emby_mounts WHERE sort_order > 0").Scan(&maxOrder).Error
var mounts []model.EmbyMount
if err := db.Order("created_at asc, id asc").Find(&mounts).Error; err == nil {
if err := db.Where("sort_order = 0 OR sort_order IS NULL").Order("created_at asc, id asc").Find(&mounts).Error; err == nil {
for i, m := range mounts {
_ = db.Exec("UPDATE emby_mounts SET sort_order = ? WHERE id = ?", i, m.ID).Error
_ = db.Exec("UPDATE emby_mounts SET sort_order = ? WHERE id = ?", maxOrder+1+i, m.ID).Error
}
}
}
@@ -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")
}
}
+31 -16
View File
@@ -50,28 +50,43 @@ func copyModelTables(src, target *gorm.DB, batchSize int) (map[string]int64, int
if modelType.Kind() != reflect.Ptr {
return tableCounts, totalCopied, fmt.Errorf("model %T is not a pointer", m)
}
sliceType := reflect.SliceOf(modelType.Elem())
slicePtr := reflect.New(sliceType)
if err := src.Unscoped().Find(slicePtr.Interface()).Error; err != nil {
return tableCounts, totalCopied, fmt.Errorf("read sqlite table %s: %w", table, err)
}
filtered := slicePtr.Elem()
var primaryKeySet map[string]struct{}
if targetCount > 0 {
primaryKeySet, err := targetPrimaryKeySet(target, table, primaryColumns)
primaryKeySet, err = targetPrimaryKeySet(target, table, primaryColumns)
if err != nil {
return tableCounts, totalCopied, err
}
filtered = filterRowsMissingInTarget(target, table, primaryColumns, filtered, primaryKeySet)
}
if filtered.Len() == 0 {
continue
// 分页流式读取:此前整表一次性 Find 进内存,media 表几十万行、
// 每行含 overview/genres 等长文本时可达数百 MB,迁移过程有 OOM
// 风险。源库在迁移期间是静态的,offset 分页安全。
const readBatch = 1000
copiedForTable := int64(0)
for offset := 0; ; offset += readBatch {
batchPtr := reflect.New(reflect.SliceOf(modelType.Elem()))
if err := src.Unscoped().Limit(readBatch).Offset(offset).Find(batchPtr.Interface()).Error; err != nil {
return tableCounts, totalCopied, fmt.Errorf("read sqlite table %s: %w", table, err)
}
batch := batchPtr.Elem()
if batch.Len() == 0 {
break
}
filtered := batch
if primaryKeySet != nil {
filtered = filterRowsMissingInTarget(target, table, primaryColumns, batch, primaryKeySet)
}
if filtered.Len() > 0 {
filteredPtr := reflect.New(filtered.Type())
filteredPtr.Elem().Set(filtered)
if err := target.Clauses(clause.OnConflict{DoNothing: true}).CreateInBatches(filteredPtr.Interface(), batchSize).Error; err != nil {
return tableCounts, totalCopied, fmt.Errorf("copy sqlite table %s: %w", table, err)
}
copiedForTable += int64(filtered.Len())
}
if batch.Len() < readBatch {
break
}
}
filteredPtr := reflect.New(filtered.Type())
filteredPtr.Elem().Set(filtered)
if err := target.Clauses(clause.OnConflict{DoNothing: true}).CreateInBatches(filteredPtr.Interface(), batchSize).Error; err != nil {
return tableCounts, totalCopied, fmt.Errorf("copy sqlite table %s: %w", table, err)
}
copiedForTable := int64(filtered.Len())
tableCounts[table] = copiedForTable
totalCopied += copiedForTable
}
+76 -24
View File
@@ -5,12 +5,21 @@ import (
"fmt"
"path/filepath"
"strings"
"sync"
"sync/atomic"
"time"
"gorm.io/gorm"
"github.com/truewhile/MeBox/internal/config"
)
// sqliteGateHoldLimit 是写闸持有者的最长合法持有时长。语句级写闸在 SQL
// 执行 panic 时 After 回调不会运行,令牌会泄漏并让后续所有写入永久等锁;
// 超过该时长的持有者按泄漏强制回收(60s 内单条写语句远未到,正常写路径
// 不受影响)。
const sqliteGateHoldLimit = 60 * time.Second
func installSQLiteWriteGate(db *gorm.DB) {
if db == nil {
return
@@ -22,15 +31,18 @@ func installSQLiteWriteGate(db *gorm.DB) {
if tx.Statement != nil && tx.Statement.Context != nil {
ctx = tx.Statement.Context
}
if err := gate.Lock(ctx); err != nil {
holder, err := gate.Lock(ctx)
if err != nil {
_ = tx.AddError(err)
return
}
tx.InstanceSet(lockedKey, struct{}{})
tx.InstanceSet(lockedKey, holder)
}
unlock := func(tx *gorm.DB) {
if _, ok := tx.InstanceGet(lockedKey); ok {
gate.Unlock()
if holder, ok := tx.InstanceGet(lockedKey); ok {
if h, ok := holder.(*sqliteGateHolder); ok {
gate.Unlock(h)
}
}
}
rawLock := func(tx *gorm.DB) {
@@ -64,38 +76,76 @@ func isReadOnlySQL(sql string) bool {
return false
}
// sqliteWriteGate serializes in-process SQLite writes while respecting the
// statement context, so request cancellation can break out of a queued write.
// sqliteWriteGate serializes in-process SQLite writes. 所有权令牌(而非裸
// 信号量)保证只有持有者本人能释放;持有超时按泄漏自动回收,避免一次
// panic 让进程的 SQLite 写入半永久性瘫痪。
type sqliteWriteGate struct {
ch chan struct{}
mu sync.Mutex
cond *sync.Cond
owner *sqliteGateHolder
}
type sqliteGateHolder struct {
id uint64
acquired time.Time
}
var sqliteGateHolderSeq atomic.Uint64
func newSQLiteWriteGate() *sqliteWriteGate {
return &sqliteWriteGate{ch: make(chan struct{}, 1)}
g := &sqliteWriteGate{}
g.cond = sync.NewCond(&g.mu)
return g
}
func (g *sqliteWriteGate) Lock(ctx context.Context) error {
select {
case g.ch <- struct{}{}:
return nil
default:
}
func (g *sqliteWriteGate) Lock(ctx context.Context) (*sqliteGateHolder, error) {
g.mu.Lock()
defer g.mu.Unlock()
if ctx == nil {
ctx = context.Background()
}
select {
case g.ch <- struct{}{}:
return nil
case <-ctx.Done():
return ctx.Err()
// ctx 取消时唤醒等待者(cond 无法感知 ctx,用旁路 goroutine 广播)。
if done := ctx.Done(); done != nil {
stop := make(chan struct{})
defer close(stop)
go func() {
select {
case <-done:
g.cond.Broadcast()
case <-stop:
}
}()
}
for {
if g.owner == nil {
holder := &sqliteGateHolder{
id: sqliteGateHolderSeq.Add(1),
acquired: time.Now(),
}
g.owner = holder
return holder, nil
}
if ctx.Err() != nil {
return nil, ctx.Err()
}
if time.Since(g.owner.acquired) > sqliteGateHoldLimit {
// 持有者疑似 panic 泄漏(After 回调未执行):强制回收。
g.owner = nil
g.cond.Broadcast()
continue
}
g.cond.Wait()
}
}
func (g *sqliteWriteGate) Unlock() {
select {
case <-g.ch:
default:
func (g *sqliteWriteGate) Unlock(h *sqliteGateHolder) {
g.mu.Lock()
defer g.mu.Unlock()
if h == nil || g.owner != h {
return
}
g.owner = nil
g.cond.Broadcast()
}
func buildSQLiteDSN(cfg *config.Config) string {
@@ -104,7 +154,9 @@ func buildSQLiteDSN(cfg *config.Config) string {
// keep as-is to respect user-provided relative paths.
dbPath = filepath.Clean(dbPath)
}
dsn := dbPath + "?_pragma=foreign_keys(1)"
// _txlock=immediate:事务以写锁开始。此前 deferred BEGIN 在并发事务
// 升级写锁时会绕过 busy_timeout 直接报 SQLITE_BUSY。
dsn := dbPath + "?_txlock=immediate&_pragma=foreign_keys(1)"
if cfg.Database.WALMode {
dsn += "&_pragma=journal_mode(WAL)&_pragma=synchronous(NORMAL)"
}
+20 -4
View File
@@ -10,6 +10,7 @@ import (
"github.com/gin-gonic/gin"
"go.uber.org/zap"
"github.com/truewhile/MeBox/internal/config"
"github.com/truewhile/MeBox/internal/model"
"github.com/truewhile/MeBox/internal/service"
)
@@ -86,10 +87,19 @@ func applyHTTPSetting(svc *service.Container, key, value string) error {
svc.Log.Warn("https setting saved but not applied yet", zap.String("key", key), zap.String("reason", reason))
}
}
config.RuntimeMu.RLock()
httpsEnabled := svc.Cfg.App.HTTPSEnabled
cert := svc.Cfg.App.SSLCert
certPath := svc.Cfg.App.SSLCertPath
keyMaterial := svc.Cfg.App.SSLKey
keyPath := svc.Cfg.App.SSLKeyPath
config.RuntimeMu.RUnlock()
switch key {
case "https.enabled":
if svc.Cfg.App.HTTPSEnabled {
if _, err := service.ResolveSSLKeyPair(svc.Cfg.App.SSLCert, svc.Cfg.App.SSLCertPath, svc.Cfg.App.SSLKey, svc.Cfg.App.SSLKeyPath); err != nil {
if httpsEnabled {
if _, err := service.ResolveSSLKeyPair(cert, certPath, keyMaterial, keyPath); err != nil {
return fmt.Errorf("启用 HTTPS 失败:%v", err)
}
}
@@ -97,7 +107,7 @@ func applyHTTPSetting(svc *service.Container, key, value string) error {
if err := validateSSLMaterialSource(key, value); err != nil {
return err
}
if !svc.Cfg.App.HTTPSEnabled {
if !httpsEnabled {
return nil
}
if !httpsPairReady(svc) {
@@ -144,7 +154,13 @@ func validateSSLMaterialSource(key, value string) error {
// httpsPairReady 判断基于当前配置解析出的证书/私钥是否完整且匹配。
func httpsPairReady(svc *service.Container) bool {
_, err := service.ResolveSSLKeyPair(svc.Cfg.App.SSLCert, svc.Cfg.App.SSLCertPath, svc.Cfg.App.SSLKey, svc.Cfg.App.SSLKeyPath)
config.RuntimeMu.RLock()
cert := svc.Cfg.App.SSLCert
certPath := svc.Cfg.App.SSLCertPath
keyMaterial := svc.Cfg.App.SSLKey
keyPath := svc.Cfg.App.SSLKeyPath
config.RuntimeMu.RUnlock()
_, err := service.ResolveSSLKeyPair(cert, certPath, keyMaterial, keyPath)
return err == nil
}
+39
View File
@@ -3,6 +3,8 @@ package handler
import (
"net/http"
"net/url"
"strings"
"github.com/gin-gonic/gin"
@@ -33,6 +35,21 @@ func dlnaCastHandler(svc *service.Container) gin.HandlerFunc {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
// SSRF 防护:control_url 必须命中本服务发现到的真实渲染设备,
// 防止登录用户借 cast 接口向任意内网地址发起 POST。
// 优先用 30s 缓存;未命中时强制重扫一次再校验(设备可能刚上线)。
devices, err := svc.DLNA.Discover(c.Request.Context(), false)
if err == nil && !dlnaControlURLKnown(devices, req.ControlURL) {
devices, err = svc.DLNA.Discover(c.Request.Context(), true)
}
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
if !dlnaControlURLKnown(devices, req.ControlURL) {
c.JSON(http.StatusBadRequest, gin.H{"error": "unknown DLNA device: control_url must come from /api/dlna discovery"})
return
}
if err := svc.DLNA.Cast(c.Request.Context(), req.ControlURL, req.MediaURL); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
@@ -40,3 +57,25 @@ func dlnaCastHandler(svc *service.Container) gin.HandlerFunc {
c.Status(http.StatusNoContent)
}
}
// dlnaControlURLKnown 判断 control_url 是否属于发现列表中的设备。
// 按解析后的 host:port+path 精确比对,容忍大小写与尾斜杠差异。
func dlnaControlURLKnown(devices []service.DLNADevice, controlURL string) bool {
want, err := url.Parse(strings.TrimSpace(controlURL))
if err != nil || want.Host == "" {
return false
}
for _, dev := range devices {
for _, candidate := range []string{dev.ControlURL, dev.Location} {
u, err := url.Parse(strings.TrimSpace(candidate))
if err != nil || u.Host == "" {
continue
}
if strings.EqualFold(u.Host, want.Host) &&
strings.EqualFold(strings.TrimRight(u.Path, "/"), strings.TrimRight(want.Path, "/")) {
return true
}
}
}
return false
}
+16 -13
View File
@@ -132,22 +132,25 @@ func embyMeHandler(svc *service.Container) gin.HandlerFunc {
func embyGetUserByIDHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
u, err := svc.Emby.FindUser(c.Request.Context(), c.Param("userId"))
uid := embyUserID(c)
if uid == "" {
embyError(c, http.StatusUnauthorized, "not authenticated")
return
}
// 只返回调用者自己的用户对象:客户端误传其他 userId 时回退到
// 调用者自身(保留旧行为的兼容语义),但绝不返回他人数据。
u, err := svc.Emby.FindUser(c.Request.Context(), uid)
if err == nil && u != nil {
c.JSON(http.StatusOK, u)
return
}
if authUID := embyUserID(c); authUID != "" && authUID != c.Param("userId") {
u, err = svc.Emby.FindUser(c.Request.Context(), authUID)
if err == nil && u != nil {
c.JSON(http.StatusOK, u)
return
}
}
c.JSON(http.StatusOK, embyFallbackUser(c.Param("userId")))
c.JSON(http.StatusOK, embyFallbackUser(uid))
}
}
// embyFallbackUser 是查库失败时的最后兜底(保持客户端可渲染)。
// Policy 必须是最小权限:不声明管理员/删除内容/控制他人等能力,
// 实际权限始终由服务端各路由的校验决定。
func embyFallbackUser(id string) gin.H {
if strings.TrimSpace(id) == "" {
id = "mebox-user"
@@ -161,10 +164,10 @@ func embyFallbackUser(id string) gin.H {
"HasConfiguredEasyPassword": false,
"EnableAutoLogin": false,
"Policy": gin.H{
"IsAdministrator": true,
"EnableContentDeletion": true,
"EnableRemoteControlOfOtherUsers": true,
"EnableSharedDeviceControl": true,
"IsAdministrator": false,
"EnableContentDeletion": false,
"EnableRemoteControlOfOtherUsers": false,
"EnableSharedDeviceControl": false,
"EnableRemoteAccess": true,
"EnableAllDevices": true,
"EnableAllChannels": true,
+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)
+104 -31
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,23 +111,50 @@ 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 挂载库追加在本地库之后。
// 远程 Emby 挂载库追加在本地库之后(非管理员视图仍受 allowed_library_ids 约束)。
if svc.EmbyRemote != nil {
if views, err := svc.EmbyRemote.RemoteLibraries(ctx); err == nil {
remotePayloads := make([]webLibraryPayload, len(views))
for i, v := range views {
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)
}
remotePayloads := make([]webLibraryPayload, len(allowedViews))
for i, v := range allowedViews {
remotePayloads[i] = webLibraryPayload{Library: v.Library, IsRemoteEmby: true, RemoteSource: v.AccountName}
}
if withPreview && len(views) > 0 {
if withPreview && len(allowedViews) > 0 {
const maxRemotePreviewWorkers = 6
sem := make(chan struct{}, maxRemotePreviewWorkers)
var wg sync.WaitGroup
for i, v := range views {
for i, v := range allowedViews {
i, v := i, v
wg.Add(1)
go func() {
@@ -116,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()
@@ -151,6 +202,12 @@ func getLibraryHandler(svc *service.Container) gin.HandlerFunc {
c.JSON(http.StatusNotFound, gin.H{"error": "not found"})
return
}
role, _ := c.Get(middleware.CtxUserRole)
includeHidden := role == "admin" && (c.Query("include_hidden") == "1" || c.Query("include_hidden") == "true" || c.Query("all") == "1")
if !includeHidden && !service.LibraryVisibleForUser(ctx, svc.Repo, view.Library, mediaVisibilityForRequest(c, svc)) {
c.JSON(http.StatusNotFound, gin.H{"error": "not found"})
return
}
c.JSON(http.StatusOK, webLibraryPayload{Library: view.Library, IsRemoteEmby: true, RemoteSource: view.AccountName})
return
}
@@ -311,7 +368,11 @@ func deleteLibraryHandler(svc *service.Container) gin.HandlerFunc {
}
uid, _ := c.Get("ctx_user_id")
svc.Audit.Record(c.Request.Context(), toString(uid), "library.delete", id, c.ClientIP(), "")
go func() { _ = svc.Watcher.Refresh(context.Background()) }()
// goroutine 内的 panic 无法被 gin.Recovery 捕获,会直接崩掉进程:
// 与其他调用点一致先判空。
if svc.Watcher != nil {
go func() { _ = svc.Watcher.Refresh(context.Background()) }()
}
c.Status(http.StatusNoContent)
}
}
@@ -330,6 +391,10 @@ func listMediaHandler(svc *service.Container) gin.HandlerFunc {
c.JSON(http.StatusNotFound, gin.H{"error": "not found"})
return
}
if !service.EmbyMountLibraryAllowed(mediaVisibilityForRequest(c, svc), mount) {
c.JSON(http.StatusNotFound, gin.H{"error": "not found"})
return
}
itemTypes := ""
if view, err := svc.EmbyRemote.RemoteLibraryByID(ctx, mountID, remoteID); err == nil && view != nil {
itemTypes = remoteLibraryItemTypes(view.CollectionType)
@@ -397,6 +462,10 @@ func getMediaHandler(svc *service.Container) gin.HandlerFunc {
c.JSON(http.StatusNotFound, gin.H{"error": "not found"})
return
}
if !service.EmbyMountLibraryAllowed(mediaVisibilityForRequest(c, svc), mount) {
c.JSON(http.StatusNotFound, gin.H{"error": "not found"})
return
}
m, err := svc.EmbyRemote.RemoteMediaDetail(ctx, mount, acct, remoteID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
@@ -579,6 +648,10 @@ func streamHandler(svc *service.Container) gin.HandlerFunc {
c.JSON(http.StatusNotFound, gin.H{"error": "not found"})
return
}
if !service.EmbyMountLibraryAllowed(mediaVisibilityForRequest(c, svc), mount) {
c.JSON(http.StatusNotFound, gin.H{"error": "not found"})
return
}
if mount.ProxyPlay {
if err := svc.Emby.ProxyRemoteVideoStream(ctx, c.Writer, c.Request, mountID, remoteID); err != nil {
if !c.Writer.Written() {
+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)
}
}
}
+16 -19
View File
@@ -13,7 +13,6 @@ import (
"github.com/gin-gonic/gin"
"github.com/truewhile/MeBox/internal/middleware"
"github.com/truewhile/MeBox/internal/model"
"github.com/truewhile/MeBox/internal/service"
)
@@ -23,18 +22,18 @@ import (
func addMediaFavoriteHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
uid, _ := c.Get(middleware.CtxUserID)
// Check current state.
var existing model.Favorite
err := svc.Repo.DB.WithContext(c.Request.Context()).
Where("user_id = ? AND media_id = ?", uid, c.Param("id")).
First(&existing).Error
if err == nil {
userID := toString(uid)
mediaID := c.Param("id")
favorite, err := service.IsUserFavorite(c.Request.Context(), svc.Repo, userID, mediaID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
if favorite {
c.JSON(http.StatusOK, gin.H{"favourite": true})
return
}
// Otherwise create.
fav := &model.Favorite{UserID: toString(uid), MediaID: c.Param("id")}
if err := svc.Repo.DB.WithContext(c.Request.Context()).Create(fav).Error; err != nil {
if err := svc.Playback.SetFavourite(c.Request.Context(), userID, mediaID, true); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
@@ -46,9 +45,7 @@ func addMediaFavoriteHandler(svc *service.Container) gin.HandlerFunc {
func removeMediaFavoriteHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
uid, _ := c.Get(middleware.CtxUserID)
if err := svc.Repo.DB.WithContext(c.Request.Context()).
Where("user_id = ? AND media_id = ?", uid, c.Param("id")).
Delete(&model.Favorite{}).Error; err != nil {
if err := svc.Playback.SetFavourite(c.Request.Context(), toString(uid), c.Param("id"), false); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
@@ -60,12 +57,12 @@ func removeMediaFavoriteHandler(svc *service.Container) gin.HandlerFunc {
func getMediaFavoriteStatusHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
uid, _ := c.Get(middleware.CtxUserID)
var n int64
_ = svc.Repo.DB.WithContext(c.Request.Context()).
Model(&model.Favorite{}).
Where("user_id = ? AND media_id = ?", uid, c.Param("id")).
Count(&n).Error
c.JSON(http.StatusOK, gin.H{"favourite": n > 0})
favorite, err := service.IsUserFavorite(c.Request.Context(), svc.Repo, toString(uid), c.Param("id"))
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"favourite": favorite})
}
}
+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)
+49
View File
@@ -0,0 +1,49 @@
package handler
import (
"net/http"
"github.com/gin-gonic/gin"
"github.com/truewhile/MeBox/internal/middleware"
"github.com/truewhile/MeBox/internal/service"
)
type pinnedLibrariesReq struct {
LibraryIDs []string `json:"library_ids"`
}
func getPinnedLibrariesHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
uid, _ := c.Get(middleware.CtxUserID)
ids, err := svc.Profile.GetPinnedLibraryIDs(c.Request.Context(), uid.(string))
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
if ids == nil {
ids = []string{}
}
c.JSON(http.StatusOK, gin.H{"library_ids": ids})
}
}
func setPinnedLibrariesHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
var req pinnedLibrariesReq
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
uid, _ := c.Get(middleware.CtxUserID)
ids, err := svc.Profile.SetPinnedLibraryIDs(c.Request.Context(), uid.(string), req.LibraryIDs)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
if ids == nil {
ids = []string{}
}
c.JSON(http.StatusOK, gin.H{"library_ids": ids})
}
}
+36 -3
View File
@@ -2,9 +2,11 @@
package handler
import (
"errors"
"net/http"
"github.com/gin-gonic/gin"
"gorm.io/gorm"
"github.com/truewhile/MeBox/internal/middleware"
"github.com/truewhile/MeBox/internal/service"
@@ -157,6 +159,25 @@ type playlistItemReq struct {
MediaID string `json:"media_id" binding:"required"`
}
// playlistWriteGuard 校验当前用户对播放列表的写权限(属主或 admin)。
// 校验失败时已写入错误响应,调用方直接 return。
func playlistWriteGuard(c *gin.Context, svc *service.Container, playlistID string) (string, bool, bool) {
uid, _ := c.Get(middleware.CtxUserID)
role, _ := c.Get(middleware.CtxUserRole)
isAdmin := role == "admin"
if err := svc.Playback.EnsurePlaylistOwner(c.Request.Context(), playlistID, uid.(string), isAdmin); err != nil {
if errors.Is(err, service.ErrPlaylistForbidden) {
c.JSON(http.StatusForbidden, gin.H{"error": "forbidden"})
} else if errors.Is(err, gorm.ErrRecordNotFound) {
c.JSON(http.StatusNotFound, gin.H{"error": "playlist not found"})
} else {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
}
return "", isAdmin, false
}
return uid.(string), isAdmin, true
}
func addPlaylistItemHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
var req playlistItemReq
@@ -164,8 +185,12 @@ func addPlaylistItemHandler(svc *service.Container) gin.HandlerFunc {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
uid, isAdmin, ok := playlistWriteGuard(c, svc, c.Param("id"))
if !ok {
return
}
if err := svc.Playback.AddToPlaylist(
c.Request.Context(), c.Param("id"), req.MediaID,
c.Request.Context(), c.Param("id"), uid, req.MediaID, isAdmin,
); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
@@ -176,8 +201,12 @@ func addPlaylistItemHandler(svc *service.Container) gin.HandlerFunc {
func removePlaylistItemHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
uid, isAdmin, ok := playlistWriteGuard(c, svc, c.Param("id"))
if !ok {
return
}
if err := svc.Playback.RemoveFromPlaylist(
c.Request.Context(), c.Param("id"), c.Param("media_id"),
c.Request.Context(), c.Param("id"), uid, c.Param("media_id"), isAdmin,
); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
@@ -188,8 +217,12 @@ func removePlaylistItemHandler(svc *service.Container) gin.HandlerFunc {
func deletePlaylistHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
uid, isAdmin, ok := playlistWriteGuard(c, svc, c.Param("id"))
if !ok {
return
}
if err := svc.Playback.DeletePlaylist(
c.Request.Context(), c.Param("id"),
c.Request.Context(), c.Param("id"), uid, isAdmin,
); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
+8 -1
View File
@@ -27,6 +27,9 @@ func reorderPlaylistHandler(svc *service.Container) gin.HandlerFunc {
return
}
pid := c.Param("id")
if _, _, ok := playlistWriteGuard(c, svc, pid); !ok {
return
}
for i, mid := range req.Order {
if err := svc.Repo.DB.WithContext(c.Request.Context()).
Model(&model.PlaylistItem{}).
@@ -44,8 +47,12 @@ func reorderPlaylistHandler(svc *service.Container) gin.HandlerFunc {
// /playlists/:id/items/:item_id (vs. the existing /:media_id variant).
func deletePlaylistItemByIDHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
pid := c.Param("id")
if _, _, ok := playlistWriteGuard(c, svc, pid); !ok {
return
}
if err := svc.Repo.DB.WithContext(c.Request.Context()).
Where("playlist_id = ? AND id = ?", c.Param("id"), c.Param("item_id")).
Where("playlist_id = ? AND id = ?", pid, c.Param("item_id")).
Delete(&model.PlaylistItem{}).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
-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)
}
}
+3
View File
@@ -37,6 +37,7 @@ func registerAdminScraperRoutes(admin *gin.RouterGroup, svc *service.Container)
admin.POST("/scraper/queue/clear-done", clearDoneScrapeTasksHandler(svc))
admin.POST("/scraper/queue/clear-finished", clearFinishedScrapeTasksHandler(svc))
admin.POST("/scraper/queue/clear-canceled", clearCanceledScrapeTasksHandler(svc))
admin.POST("/scraper/queue/clear-failed", clearFailedScrapeTasksHandler(svc))
admin.POST("/scraper/queue/retry-failed", retryAllFailedScrapeTasksHandler(svc))
admin.POST("/scraper/queue/cancel-pending", cancelPendingScrapeTasksHandler(svc))
admin.POST("/scraper/queue/enqueue-library/:id", enqueueLibraryScrapeHandler(svc))
@@ -87,6 +88,7 @@ func registerAdminStrmRoutes(admin *gin.RouterGroup, svc *service.Container) {
admin.POST("/strm/downloads/clear-done", clearDoneDownloadsHandler(svc))
admin.POST("/strm/downloads/clear-finished", clearFinishedDownloadsHandler(svc))
admin.POST("/strm/downloads/clear-canceled", clearCanceledDownloadsHandler(svc))
admin.POST("/strm/downloads/clear-failed", clearFailedDownloadsHandler(svc))
admin.POST("/strm/downloads/retry-failed", retryAllFailedDownloadsHandler(svc))
admin.POST("/strm/downloads/cancel-pending", cancelPendingDownloadsHandler(svc))
admin.GET("/strm/uploads", uploadQueueHandler(svc))
@@ -97,6 +99,7 @@ func registerAdminStrmRoutes(admin *gin.RouterGroup, svc *service.Container) {
admin.POST("/strm/uploads/clear-done", clearDoneUploadsHandler(svc))
admin.POST("/strm/uploads/clear-finished", clearFinishedUploadsHandler(svc))
admin.POST("/strm/uploads/clear-canceled", clearCanceledUploadsHandler(svc))
admin.POST("/strm/uploads/clear-failed", clearFailedUploadsHandler(svc))
admin.POST("/strm/uploads/retry-failed", retryAllFailedUploadsHandler(svc))
admin.POST("/strm/uploads/cancel-pending", cancelPendingUploadsHandler(svc))
}
+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)
@@ -10,6 +10,8 @@ import (
func registerAuthedUserAndLicenseRoutes(authed *gin.RouterGroup, svc *service.Container) {
authed.GET("/me", meHandler(svc))
authed.PATCH("/me", updateProfileHandler(svc))
authed.GET("/me/pinned-libraries", getPinnedLibrariesHandler(svc))
authed.PUT("/me/pinned-libraries", setPinnedLibrariesHandler(svc))
authed.POST("/me/password", changePasswordHandler(svc))
authed.POST("/me/logout", logoutHandler(svc))
@@ -48,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))
}
@@ -25,6 +25,8 @@ func TestAuthenticatedRouteSurfacesAreRegistered(t *testing.T) {
for _, want := range []string{
"GET /api/me",
"GET /api/me/pinned-libraries",
"PUT /api/me/pinned-libraries",
"GET /api/auth/permissions",
"GET /api/libraries",
"GET /api/media",
+11
View File
@@ -101,6 +101,17 @@ func clearCanceledScrapeTasksHandler(svc *service.Container) gin.HandlerFunc {
}
}
func clearFailedScrapeTasksHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
n, err := svc.Scraper.ClearFailedScrapeTasks(c.Request.Context())
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"deleted": n})
}
}
func retryAllFailedScrapeTasksHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
n, err := svc.Scraper.RetryAllFailedScrapeTasks(c.Request.Context())
+12
View File
@@ -74,6 +74,10 @@ func listLibrarySeriesHandler(svc *service.Container) gin.HandlerFunc {
c.JSON(http.StatusNotFound, gin.H{"error": "not found"})
return
}
if !service.EmbyMountLibraryAllowed(mediaVisibilityForRequest(c, svc), mount) {
c.JSON(http.StatusNotFound, gin.H{"error": "not found"})
return
}
cards, err := svc.EmbyRemote.RemoteSeriesCards(ctx, mount, acct, remoteID)
if err != nil {
writeInternalOrCanceled(c, err)
@@ -165,6 +169,10 @@ func listLibrarySeriesEpisodesHandler(svc *service.Container) gin.HandlerFunc {
c.JSON(http.StatusNotFound, gin.H{"error": "not found"})
return
}
if !service.EmbyMountLibraryAllowed(mediaVisibilityForRequest(c, svc), mount) {
c.JSON(http.StatusNotFound, gin.H{"error": "not found"})
return
}
items, err := svc.EmbyRemote.RemoteEpisodes(ctx, mount, acct, remoteSeriesID)
if err != nil {
writeInternalOrCanceled(c, err)
@@ -207,6 +215,10 @@ func listMediaEpisodesHandler(svc *service.Container) gin.HandlerFunc {
c.JSON(http.StatusNotFound, gin.H{"error": "not found"})
return
}
if !service.EmbyMountLibraryAllowed(mediaVisibilityForRequest(c, svc), mount) {
c.JSON(http.StatusNotFound, gin.H{"error": "not found"})
return
}
items, err := svc.EmbyRemote.RemoteEpisodes(ctx, mount, acct, remoteID)
if err != nil {
writeInternalOrCanceled(c, err)
+21
View File
@@ -14,9 +14,14 @@ import (
)
// statsUserHandler returns a watch-time summary for one user.
// 观看统计是隐私数据:仅允许本人或管理员查询。
func statsUserHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
uid := c.Param("id")
if !statsCallerAllowed(c, uid) {
c.JSON(http.StatusForbidden, gin.H{"error": "forbidden"})
return
}
var watched int64
_ = svc.Repo.DB.Model(&model.PlaybackHistory{}).
Where("user_id = ?", uid).
@@ -35,8 +40,14 @@ func statsUserHandler(svc *service.Container) gin.HandlerFunc {
}
// statsTopUsersHandler returns the most active users by play count.
// 全员排行含用户名与精确时长,仅管理员可查。
func statsTopUsersHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
role, _ := c.Get(middleware.CtxUserRole)
if role != "admin" {
c.JSON(http.StatusForbidden, gin.H{"error": "forbidden"})
return
}
limit, _ := strconv.Atoi(c.DefaultQuery("limit", "10"))
if limit <= 0 || limit > 50 {
limit = 10
@@ -109,3 +120,13 @@ func statsPlayHandler(svc *service.Container) gin.HandlerFunc {
c.JSON(http.StatusOK, gin.H{"ok": true})
}
}
// statsCallerAllowed 判断当前调用者是否允许查看 uid 的观看统计。
func statsCallerAllowed(c *gin.Context, uid string) bool {
role, _ := c.Get(middleware.CtxUserRole)
if role == "admin" {
return true
}
caller, _ := c.Get(middleware.CtxUserID)
return toString(caller) == uid
}
+22
View File
@@ -526,6 +526,17 @@ func clearCanceledDownloadsHandler(svc *service.Container) gin.HandlerFunc {
}
}
func clearFailedDownloadsHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
n, err := svc.Strm.ClearFailedDownloadTasks(c.Request.Context())
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"deleted": n})
}
}
func clearCanceledUploadsHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
n, err := svc.Strm.ClearCanceledUploadTasks(c.Request.Context())
@@ -537,6 +548,17 @@ func clearCanceledUploadsHandler(svc *service.Container) gin.HandlerFunc {
}
}
func clearFailedUploadsHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
n, err := svc.Strm.ClearFailedUploadTasks(c.Request.Context())
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"deleted": n})
}
}
func clearDoneUploadsHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
n, err := svc.Strm.ClearDoneUploadTasks(c.Request.Context())
+6 -1
View File
@@ -39,11 +39,16 @@ func listSystemConfigHandler(svc *service.Container) gin.HandlerFunc {
}
func isSecretKey(k string) bool {
for _, suffix := range []string{".token", ".secret", ".password", ".api_key", ".cookie"} {
for _, suffix := range []string{".token", ".secret", ".password", ".api_key", ".cookie", ".pin"} {
if endsWith(k, suffix) {
return true
}
}
// 非后缀型敏感键:可触发服务端任意命令的更新命令等。
switch k {
case "system.update.command":
return true
}
return false
}
+17 -4
View File
@@ -9,6 +9,8 @@ package handler
import (
"encoding/json"
"net/http"
"net/url"
"strings"
"time"
"github.com/gin-gonic/gin"
@@ -21,10 +23,21 @@ import (
var wsUpgrader = websocket.Upgrader{
ReadBufferSize: 1024,
WriteBufferSize: 1024,
// Allow any origin: the AuthRequired middleware already validated the
// JWT before we got here, and we never serve sensitive cross-domain
// state through the socket.
CheckOrigin: func(_ *http.Request) bool { return true },
// 同源校验:浏览器跨站页面虽读不到 ?token=,但可能借 cookie 通道
// (extractToken 接受 msgo_access_token cookie)发起跨站 WebSocket
// 劫持。放行同源与非浏览器客户端(不发 Origin 头的 App/脚本),
// 拒绝跨站 Origin。
CheckOrigin: func(r *http.Request) bool {
origin := strings.TrimSpace(r.Header.Get("Origin"))
if origin == "" {
return true
}
u, err := url.Parse(origin)
if err != nil || u.Host == "" {
return false
}
return strings.EqualFold(u.Host, r.Host)
},
}
func wsHandler(svc *service.Container) gin.HandlerFunc {
+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)
}
+13
View File
@@ -51,6 +51,19 @@ func EmbyAuthRequired(secret string) gin.HandlerFunc {
return
}
// 用途限定令牌(如 external_play,签发给外链播放器且绑定单一
// media)只允许走 /api/stream|/hls|/cloud/play,绝不能作为全功能
// 凭据访问 Emby 兼容面;否则外链 URL 一旦泄漏,持有者可获得
// 该用户最长 24h 的全部 Emby API 权限。
if strings.TrimSpace(claims.Purpose) != "" {
c.JSON(http.StatusUnauthorized, gin.H{
"Code": 40101,
"Message": "Invalid token",
})
c.Abort()
return
}
c.Set(EmbyCtxUserID, claims.UserID)
c.Set(CtxUserID, claims.UserID)
c.Set(CtxUserRole, claims.Role)
+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)
}
}
+16 -1
View File
@@ -16,6 +16,8 @@ type RateLimiter struct {
window time.Duration
max int
requests map[string][]time.Time
stop chan struct{}
stopped sync.Once
}
// NewRateLimiter creates a rate limiter allowing max requests per window
@@ -25,14 +27,27 @@ func NewRateLimiter(max int, window time.Duration) *RateLimiter {
window: window,
max: max,
requests: make(map[string][]time.Time),
stop: make(chan struct{}),
}
go rl.cleanup()
return rl
}
// Close 停止后台清理 goroutine:清理循环此前无停止机制,每建一个实例
// 就永久滞留一条 goroutine(测试场景会随实例创建不断累积)。
func (rl *RateLimiter) Close() {
rl.stopped.Do(func() { close(rl.stop) })
}
func (rl *RateLimiter) cleanup() {
ticker := time.NewTicker(5 * time.Minute)
defer ticker.Stop()
for {
time.Sleep(5 * time.Minute)
select {
case <-rl.stop:
return
case <-ticker.C:
}
rl.mu.Lock()
now := time.Now()
for ip, times := range rl.requests {
+15 -8
View File
@@ -5,15 +5,22 @@ import (
"time"
)
// ApiConfig 存储第三方 API 密钥和配置信息。
// APIKey 字段在 JSON 序列化时隐藏(json:"-"),通过加密存储。
type ApiConfig struct {
// APIConfig 存储第三方 API 密钥和配置信息。
// APIKey 字段在 JSON 序列化时隐藏(json:"-"),通过加密存储(AES-GCM 密文,
// base64 后常超 512 字符,因此必须是 text 而非 varchar(512))。
//
// NOTE: 历史上曾有 APIConfig / ApiConfig 两个结构体映射到同一张 api_configs
// 表,AutoMigrate 每次启动互相改列(provider/api_key 长度来回切换),且
// varchar(512) 收窄会让长密文入库后下一次启动迁移直接失败。现已合并为本
// 结构体,字段取两者并集,请勿再拆分。
type APIConfig struct {
Base
Provider string `gorm:"size:64;uniqueIndex;not null" json:"provider"`
APIKey string `gorm:"size:512" json:"-"`
BaseURL string `gorm:"size:512" json:"base_url,omitempty"`
Extra string `gorm:"type:text" json:"extra,omitempty"`
Enabled bool `gorm:"default:true" json:"enabled"`
Provider string `gorm:"uniqueIndex;size:64;not null" json:"provider"`
APIKey string `gorm:"type:text" json:"-"` // ciphertext (never serialised)
BaseURL string `gorm:"size:512" json:"base_url,omitempty"`
Extra string `gorm:"type:text" json:"extra,omitempty"` // free-form JSON
Enabled bool `gorm:"default:true" json:"enabled"`
Description string `gorm:"size:255" json:"description,omitempty"`
LastTestedAt *time.Time `json:"last_tested_at,omitempty"`
TestResult string `gorm:"size:32" json:"test_result,omitempty"`
+6 -21
View File
@@ -1,23 +1,8 @@
package model
// APIConfig stores third-party data-source configuration. The api_key
// column is encrypted with AES-GCM (see internal/service/crypto.go) so an
// SQLite leak does not expose third-party credentials.
//
// Provider values mirror the original Python project:
//
// tmdb — themoviedb.org
// bangumi — bgm.tv
// thetvdb — thetvdb.com
// fanart — fanart.tv
// douban — douban.com (cookie)
// openai — OpenAI / DeepSeek / Qwen / Ollama (compatible)
type APIConfig struct {
Base
Provider string `gorm:"uniqueIndex;size:32;not null" json:"provider"`
APIKey string `gorm:"type:text" json:"-"` // ciphertext (never serialised)
BaseURL string `gorm:"size:512" json:"base_url,omitempty"`
Extra string `gorm:"type:text" json:"extra,omitempty"` // free-form JSON
Enabled bool `gorm:"default:true" json:"enabled"`
Description string `gorm:"size:255" json:"description,omitempty"`
}
// NOTE: 原 APIConfig(provider varchar(32) / api_key text)与 api_config.go
// 里的 ApiConfig(provider varchar(64) / api_key varchar(512))映射到同一张
// api_configs 表,AutoMigrate 每次启动互相改列;且 api_key 被收窄成
// varchar(512) 后,成人区/豆瓣等存的长 AES-GCM Cookie 密文一旦入库,下次
// 启动迁移即失败、服务无法启动。两者已合并为 api_config.go 中唯一的
// APIConfig 结构体(字段取并集),此处不再定义重复模型。
+2 -2
View File
@@ -26,7 +26,7 @@ type LibraryRoot struct {
// Media 是单个可播放项。剧集链接到 SeriesID;电影 SeriesID == ""。
type Media struct {
Base
LibraryID string `gorm:"index;size:36" json:"library_id"`
LibraryID string `gorm:"index;size:36;index:idx_media_library_release,priority:1" json:"library_id"`
LibraryRootID string `gorm:"index;size:36" json:"library_root_id,omitempty"`
SeriesID string `gorm:"index;size:128" json:"series_id,omitempty"`
Title string `gorm:"size:255;not null" json:"title"`
@@ -46,7 +46,7 @@ type Media struct {
Overview string `gorm:"type:text" json:"overview,omitempty"`
Rating float32 `json:"rating"`
Year int `json:"year"`
ReleaseDate string `gorm:"size:10;index" json:"release_date,omitempty"`
ReleaseDate string `gorm:"size:10;index:idx_media_library_release,priority:2" json:"release_date,omitempty"`
SeasonNum int `json:"season_num"`
EpisodeNum int `json:"episode_num"`
ScrapeStatus string `gorm:"size:16;default:pending" json:"scrape_status"`
-1
View File
@@ -46,7 +46,6 @@ func AllModels() []interface{} {
&APIConfig{},
&UserPermission{},
&RefreshToken{},
&ApiConfig{},
&PlayProfile{},
&RegistrationCode{},
&SignIn{},
+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"`
+23
View File
@@ -25,6 +25,9 @@ type User struct {
// 为空时代表不限制(全库可访问)。
AllowedLibraryIDs string `gorm:"type:text" json:"-"`
AllowedLibraryList []string `gorm:"-" json:"allowed_library_ids,omitempty"`
// PinnedLibraryIDs 存储用户置顶的媒体库 ID 列表(JSON 字符串),顺序即置顶优先级。
PinnedLibraryIDs string `gorm:"type:text" json:"-"`
PinnedLibraryList []string `gorm:"-" json:"pinned_library_ids,omitempty"`
// ExpiredAt is the account expiry time. Nil means the account never
// expires. When set and in the past, the account is treated as expired
// (login blocked) until an admin or a redemption code renews it.
@@ -59,10 +62,30 @@ func (u *User) DecodeAllowedLibraryIDs() []string {
return out
}
// DecodePinnedLibraryIDs 解析 PinnedLibraryIDs 字段。
func (u *User) DecodePinnedLibraryIDs() []string {
if u == nil || strings.TrimSpace(u.PinnedLibraryIDs) == "" {
return nil
}
var ids []string
if err := json.Unmarshal([]byte(u.PinnedLibraryIDs), &ids); err != nil {
return nil
}
var out []string
for _, id := range ids {
trimmed := strings.TrimSpace(id)
if trimmed != "" {
out = append(out, trimmed)
}
}
return out
}
// PopulateComputedFields 填充非 DB 虚拟计算字段(如 AllowedLibraryList)。
func (u *User) PopulateComputedFields() {
if u == nil {
return
}
u.AllowedLibraryList = u.DecodeAllowedLibraryIDs()
u.PinnedLibraryList = u.DecodePinnedLibraryIDs()
}
+32 -19
View File
@@ -10,17 +10,17 @@ import (
"github.com/truewhile/MeBox/internal/model"
)
// ApiConfigRepository persists model.ApiConfig records.
// ApiConfigRepository persists model.APIConfig records.
type ApiConfigRepository struct{ db *gorm.DB }
// Create inserts a new API config record.
func (r *ApiConfigRepository) Create(ctx context.Context, c *model.ApiConfig) error {
func (r *ApiConfigRepository) Create(ctx context.Context, c *model.APIConfig) error {
return r.db.WithContext(ctx).Create(c).Error
}
// FindByProvider returns the API config for a provider, or (nil, nil).
func (r *ApiConfigRepository) FindByProvider(ctx context.Context, provider string) (*model.ApiConfig, error) {
var c model.ApiConfig
func (r *ApiConfigRepository) FindByProvider(ctx context.Context, provider string) (*model.APIConfig, error) {
var c model.APIConfig
err := r.db.WithContext(ctx).Where("provider = ?", provider).First(&c).Error
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, nil
@@ -32,27 +32,40 @@ func (r *ApiConfigRepository) FindByProvider(ctx context.Context, provider strin
}
// List returns all API configs.
func (r *ApiConfigRepository) List(ctx context.Context) ([]model.ApiConfig, error) {
var rows []model.ApiConfig
func (r *ApiConfigRepository) List(ctx context.Context) ([]model.APIConfig, error) {
var rows []model.APIConfig
err := r.db.WithContext(ctx).Order("provider asc").Find(&rows).Error
return rows, err
}
// Upsert creates or updates an API config.
func (r *ApiConfigRepository) Upsert(ctx context.Context, c *model.ApiConfig) error {
return r.db.WithContext(ctx).Where("provider = ?", c.Provider).
Assign(model.ApiConfig{
Base: model.Base{UpdatedAt: time.Now()},
APIKey: c.APIKey,
BaseURL: c.BaseURL,
Extra: c.Extra,
Enabled: c.Enabled,
}).FirstOrCreate(c).Error
// 显式 map 更新:Assign(struct) 会跳过零值字段,导致 Enabled=false、
// 清空 BaseURL/Extra 等撤销操作静默失效。
func (r *ApiConfigRepository) Upsert(ctx context.Context, c *model.APIConfig) error {
return r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
var existing model.APIConfig
err := tx.Where("provider = ?", c.Provider).First(&existing).Error
if errors.Is(err, gorm.ErrRecordNotFound) {
return tx.Create(c).Error
}
if err != nil {
return err
}
c.ID = existing.ID
c.CreatedAt = existing.CreatedAt
return tx.Model(&existing).Updates(map[string]any{
"api_key": c.APIKey,
"base_url": c.BaseURL,
"extra": c.Extra,
"enabled": c.Enabled,
"updated_at": time.Now(),
}).Error
})
}
// Update updates an API config.
func (r *ApiConfigRepository) Update(ctx context.Context, c *model.ApiConfig) error {
return r.db.WithContext(ctx).Model(&model.ApiConfig{}).
func (r *ApiConfigRepository) Update(ctx context.Context, c *model.APIConfig) error {
return r.db.WithContext(ctx).Model(&model.APIConfig{}).
Where("provider = ?", c.Provider).Updates(map[string]any{
"api_key": c.APIKey,
"base_url": c.BaseURL,
@@ -64,13 +77,13 @@ func (r *ApiConfigRepository) Update(ctx context.Context, c *model.ApiConfig) er
// Delete 物理删除 API 配置。
func (r *ApiConfigRepository) Delete(ctx context.Context, provider string) error {
return r.db.WithContext(ctx).Unscoped().Where("provider = ?", provider).Delete(&model.ApiConfig{}).Error
return r.db.WithContext(ctx).Unscoped().Where("provider = ?", provider).Delete(&model.APIConfig{}).Error
}
// UpdateTestResult 更新测试结果。
func (r *ApiConfigRepository) UpdateTestResult(ctx context.Context, provider, result string) error {
now := time.Now()
return r.db.WithContext(ctx).Model(&model.ApiConfig{}).
return r.db.WithContext(ctx).Model(&model.APIConfig{}).
Where("provider = ?", provider).Updates(map[string]any{
"test_result": result,
"last_tested_at": &now,
@@ -125,3 +125,16 @@ func (r *EmbyMountRepository) DeleteByAccountID(ctx context.Context, accountID s
})
return count, err
}
// DeleteOrphans 清理账号已不存在的挂载(老版本删除账号未级联的历史残留)。
func (r *EmbyMountRepository) DeleteOrphans(ctx context.Context) (int64, error) {
var count int64
err := withSQLiteBusyRetry(ctx, func() error {
res := r.db.WithContext(ctx).
Where("account_id NOT IN (SELECT id FROM strm_accounts)").
Delete(&model.EmbyMount{})
count = res.RowsAffected
return res.Error
})
return count, err
}
+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
}
+86 -31
View File
@@ -22,37 +22,110 @@ import (
// 显式写入)。这两个问题都让 EnrichLibrary(WHERE scrape_status='pending')
// 永远捞不到数据。
func (r *MediaRepository) Upsert(ctx context.Context, m *model.Media) error {
return withSQLiteBusyRetry(ctx, func() error {
return r.upsert(ctx, m)
var indexIDs []string
err := withSQLiteBusyRetry(ctx, func() error {
id, uerr := r.upsertWithDB(ctx, r.db, m)
if uerr != nil {
return uerr
}
indexIDs = append(indexIDs[:0], id)
return nil
})
}
func (r *MediaRepository) upsert(ctx context.Context, m *model.Media) error {
existing, created, err := r.findOrCreateMediaByPath(ctx, m)
if err != nil {
return err
}
if created {
r.indexMediaBestEffort(ctx, *m)
r.indexByIDBestEffort(ctx, indexIDs)
return nil
}
// UpsertBatch 在单个事务里逐条执行 Upsert:扫描一批只提交(fsync)一次,
// 而不是每条一个隐式事务。任一条目落库失败不影响批内已成功的条目——
// 事务回滚后由调用方退回逐条 Upsert 兜底。
//
// OpenSearch 索引同步(HTTP,4s 超时)必须在事务提交之后统一执行:放在
// 事务内会把 SQLite 写锁挂起在网络 IO 上,且批内用非事务连接回读只能
// 拿到提交前的旧版本数据,把陈旧内容写进索引。
func (r *MediaRepository) UpsertBatch(ctx context.Context, items []*model.Media) error {
if len(items) == 0 {
return nil
}
indexIDs := make([]string, 0, len(items))
err := withSQLiteBusyRetry(ctx, func() error {
indexIDs = indexIDs[:0]
return r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
for _, m := range items {
if m == nil {
continue
}
id, err := r.upsertWithDB(ctx, tx, m)
if err != nil {
return err
}
if id != "" {
indexIDs = append(indexIDs, id)
}
}
return nil
})
})
if err != nil {
return err
}
r.indexByIDBestEffort(ctx, indexIDs)
return nil
}
// indexByIDBestEffort 在事务提交后按 ID 回读最新行并同步搜索索引。
func (r *MediaRepository) indexByIDBestEffort(ctx context.Context, ids []string) {
for _, id := range ids {
if id == "" {
continue
}
if fresh, err := r.FindByID(ctx, id); err == nil && fresh != nil {
r.indexMediaBestEffort(ctx, *fresh)
}
}
}
// upsertWithDB 落库(新建或更新),返回需要重建索引的媒体 ID(无则空串)。
func (r *MediaRepository) upsertWithDB(ctx context.Context, db *gorm.DB, m *model.Media) (string, error) {
existing, created, err := r.findOrCreateMediaByPath(ctx, db, m)
if err != nil {
return "", err
}
if created {
return m.ID, nil
}
updates := mediaUpsertUpdates(existing, *m)
return r.applyMediaUpsertUpdates(ctx, m, existing, updates)
if len(updates) == 0 {
*m = existing
return "", nil
}
if err := db.WithContext(ctx).Unscoped().Model(&model.Media{}).
Where("id = ?", existing.ID).Updates(updates).Error; err != nil {
return "", err
}
// 回写 ID / 不可变字段,让 caller 拿到完整的现有行。
*m = existing
return existing.ID, nil
}
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
} else {
// 并发插入竞态:重查已命中既有行,直接走更新分支。
return existing, false, nil
}
}
if err != nil {
@@ -237,24 +310,6 @@ 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 {
if len(updates) == 0 {
*m = existing
return nil
}
if err := r.db.WithContext(ctx).Unscoped().Model(&model.Media{}).
Where("id = ?", existing.ID).Updates(updates).Error; err != nil {
return err
}
// 回写 ID / 不可变字段,让 caller 拿到完整的现有行。
*m = existing
if fresh, err := r.FindByID(ctx, existing.ID); err == nil && fresh != nil {
*m = *fresh
r.indexMediaBestEffort(ctx, *fresh)
}
return nil
}
func setIfChanged[T comparable](updates map[string]any, key string, current, next T) {
if current != next {
updates[key] = next
+11 -3
View File
@@ -105,11 +105,17 @@ func (r *MediaRepository) searchFilteredLIKE(ctx context.Context, query string,
var total int64
q := r.db.WithContext(ctx).Model(&model.Media{})
q = applyMediaQueryFilter(q, filter)
// SQLite 的 LIKE 对 ASCII 不区分大小写;Postgres 的 LIKE 区分大小写,
// 需用 ILIKE 保持两端搜索行为一致。
likeOp := "LIKE"
if r.db.Dialector != nil && r.db.Dialector.Name() == "postgres" {
likeOp = "ILIKE"
}
terms := mediaSearchTerms(query)
for _, term := range terms {
like := "%" + escapeLike(term) + "%"
q = q.Where(
"(title LIKE ? ESCAPE '\\' OR original_name LIKE ? ESCAPE '\\' OR path LIKE ? ESCAPE '\\' OR genres LIKE ? ESCAPE '\\')",
"(title "+likeOp+" ? ESCAPE '\\' OR original_name "+likeOp+" ? ESCAPE '\\' OR path "+likeOp+" ? ESCAPE '\\' OR genres "+likeOp+" ? ESCAPE '\\')",
like, like, like, like,
)
}
@@ -120,7 +126,7 @@ func (r *MediaRepository) searchFilteredLIKE(ctx context.Context, query string,
prefix := escapeLike(query) + "%"
exact := query
q = q.Order(gorm.Expr(
"CASE WHEN title = ? THEN 0 WHEN original_name = ? THEN 1 WHEN title LIKE ? ESCAPE '\\' THEN 2 WHEN original_name LIKE ? ESCAPE '\\' THEN 3 ELSE 4 END, created_at desc",
"CASE WHEN title = ? THEN 0 WHEN original_name = ? THEN 1 WHEN title "+likeOp+" ? ESCAPE '\\' THEN 2 WHEN original_name "+likeOp+" ? ESCAPE '\\' THEN 3 ELSE 4 END, created_at desc",
exact, exact, prefix, prefix,
))
} else {
@@ -259,7 +265,9 @@ func (r *MediaRepository) searchIndexEnabled(ctx context.Context) bool {
}
r.searchIndexOnce.Do(func() {
var count int64
err := r.db.WithContext(ctx).
// 用 Background 探测:sync.Once 只执行一次,若借用调用方的
// ctx 且恰好被取消,FTS 会被永久误判为不可用。
err := r.db.WithContext(context.Background()).
Raw(`SELECT COUNT(*) FROM sqlite_master WHERE name = 'media_search_fts'`).
Scan(&count).Error
r.searchIndexAvailable = err == nil && count > 0
+36 -2
View File
@@ -3,6 +3,7 @@ package repository
import (
"context"
"errors"
"time"
"gorm.io/gorm"
@@ -44,10 +45,43 @@ func (r *PermissionRepository) Update(ctx context.Context, userID string, update
}
// Upsert creates or updates a permission record.
// 显式 map 更新:Assign(struct) 会被 GORM 跳过零值字段,导致权限
// "撤销"(false)保存后静默失效且无法重置。
func (r *PermissionRepository) Upsert(ctx context.Context, p *model.UserPermission) error {
return withSQLiteBusyRetry(ctx, func() error {
return r.db.WithContext(ctx).Where("user_id = ?", p.UserID).
Assign(*p).FirstOrCreate(p).Error
return r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
var existing model.UserPermission
err := tx.Where("user_id = ?", p.UserID).First(&existing).Error
if errors.Is(err, gorm.ErrRecordNotFound) {
return tx.Create(p).Error
}
if err != nil {
return err
}
p.ID = existing.ID
p.CreatedAt = existing.CreatedAt
return tx.Model(&existing).Updates(map[string]any{
"can_view_dashboard": p.CanViewDashboard,
"can_play_media": p.CanPlayMedia,
"can_cast": p.CanCast,
"can_external_player": p.CanExternalPlayer,
"can_favorite": p.CanFavorite,
"can_view_history": p.CanViewHistory,
"can_edit_media": p.CanEditMedia,
"can_rescrape": p.CanRescrape,
"can_use_ai": p.CanUseAI,
"can_capture_frames": p.CanCaptureFrames,
"can_manage_downloads": p.CanManageDownloads,
"can_manage_subscriptions": p.CanManageSubscriptions,
"can_manage_sites": p.CanManageSites,
"can_use_ai_assistant": p.CanUseAIAssistant,
"can_manage_users": p.CanManageUsers,
"can_manage_files": p.CanManageFiles,
"can_manage_strm": p.CanManageStrm,
"can_access_settings": p.CanAccessSettings,
"updated_at": time.Now(),
}).Error
})
})
}
@@ -52,6 +52,40 @@ func (r *ScrapeTaskRepository) FindActiveByMediaID(ctx context.Context, mediaID
return &t, err
}
// FindActiveByMediaIDs 批量查询仍处于 pending/running 的任务媒体 ID 集合,
// 供整库入队时去重(防止同一媒体被重复入队并被并发双刮)。
func (r *ScrapeTaskRepository) FindActiveByMediaIDs(ctx context.Context, mediaIDs []string) (map[string]bool, error) {
out := make(map[string]bool, len(mediaIDs))
if len(mediaIDs) == 0 {
return out, nil
}
var rows []model.ScrapeTask
err := r.db.WithContext(ctx).
Select("media_id").
Where("media_id IN ? AND status IN ?", mediaIDs, []string{model.ScrapeTaskPending, model.ScrapeTaskRunning}).
Find(&rows).Error
if err != nil {
return nil, err
}
for _, r := range rows {
out[r.MediaID] = true
}
return out, nil
}
// ResetRunningToPending 启动自愈:进程中断遗留的 running 任务重置为 pending,
// 否则任务永久卡死(ClaimPending 只认 pending,重试按钮也拒绝 running)。
func (r *ScrapeTaskRepository) ResetRunningToPending(ctx context.Context) (int64, error) {
res := r.db.WithContext(ctx).Model(&model.ScrapeTask{}).
Where("status = ?", model.ScrapeTaskRunning).
Updates(map[string]any{
"status": model.ScrapeTaskPending,
"error": "服务重启,任务已重置",
"started_at": nil,
})
return res.RowsAffected, res.Error
}
func (r *ScrapeTaskRepository) List(ctx context.Context, status string, page, pageSize int) ([]model.ScrapeTask, int64, error) {
if page < 1 {
page = 1
@@ -236,6 +270,16 @@ func (r *ScrapeTaskRepository) ClearCanceled(ctx context.Context) (int64, error)
return count, err
}
func (r *ScrapeTaskRepository) ClearFailed(ctx context.Context) (int64, error) {
var count int64
err := withSQLiteBusyRetry(ctx, func() error {
res := r.db.WithContext(ctx).Unscoped().Where("status = ?", model.ScrapeTaskFailed).Delete(&model.ScrapeTask{})
count = res.RowsAffected
return res.Error
})
return count, err
}
func (r *ScrapeTaskRepository) RetryAllFailed(ctx context.Context) (int64, error) {
var count int64
err := withSQLiteBusyRetry(ctx, func() error {
+85
View File
@@ -302,6 +302,39 @@ func (r *StrmDownloadTaskRepository) Update(ctx context.Context, t *model.StrmDo
})
}
// UpdateIfRunning 仅当任务在 DB 中仍为 running 时写入给定字段。
// 返回 false 表示任务已被外部改变状态(如用户取消),收尾不得覆盖。
func (r *StrmDownloadTaskRepository) UpdateIfRunning(ctx context.Context, id string, updates map[string]any) (bool, error) {
var ok bool
err := withSQLiteBusyRetry(ctx, func() error {
updates["updated_at"] = time.Now()
res := r.db.WithContext(ctx).Model(&model.StrmDownloadTask{}).
Where("id = ? AND status = ?", id, model.StrmTaskRunning).Updates(updates)
ok = res.RowsAffected > 0
return res.Error
})
return ok, err
}
// ResetRunningToPending 启动自愈:进程中断遗留的 running 任务全部重置为
// pending(清空退避时间以便立即可被认领),否则任务永久卡死且会阻塞
// 该文件的重复下载。
func (r *StrmDownloadTaskRepository) ResetRunningToPending(ctx context.Context) (int64, error) {
var n int64
err := withSQLiteBusyRetry(ctx, func() error {
res := r.db.WithContext(ctx).Model(&model.StrmDownloadTask{}).
Where("status = ?", model.StrmTaskRunning).
Updates(map[string]any{
"status": model.StrmTaskPending,
"error": "服务重启,任务已重置",
"started_at": nil,
})
n = res.RowsAffected
return res.Error
})
return n, err
}
func (r *StrmDownloadTaskRepository) Delete(ctx context.Context, id string) error {
return withSQLiteBusyRetry(ctx, func() error {
return r.db.WithContext(ctx).Unscoped().Where("id = ?", id).Delete(&model.StrmDownloadTask{}).Error
@@ -402,6 +435,17 @@ func (r *StrmDownloadTaskRepository) ClearCanceled(ctx context.Context) (int64,
return count, err
}
// ClearFailed 清空全部已失败下载任务。
func (r *StrmDownloadTaskRepository) ClearFailed(ctx context.Context) (int64, error) {
var count int64
err := withSQLiteBusyRetry(ctx, func() error {
res := r.db.WithContext(ctx).Unscoped().Where("status = ?", model.StrmTaskFailed).Delete(&model.StrmDownloadTask{})
count = res.RowsAffected
return res.Error
})
return count, err
}
// RetryAllFailed 把所有失败任务重置回待处理,清空错误与重试计数。
func (r *StrmDownloadTaskRepository) RetryAllFailed(ctx context.Context) (int64, error) {
var count int64
@@ -609,6 +653,36 @@ func (r *StrmUploadTaskRepository) Update(ctx context.Context, t *model.StrmUplo
})
}
// UpdateIfRunning 仅当任务在 DB 中仍为 running 时写入给定字段。
func (r *StrmUploadTaskRepository) UpdateIfRunning(ctx context.Context, id string, updates map[string]any) (bool, error) {
var ok bool
err := withSQLiteBusyRetry(ctx, func() error {
updates["updated_at"] = time.Now()
res := r.db.WithContext(ctx).Model(&model.StrmUploadTask{}).
Where("id = ? AND status = ?", id, model.StrmTaskRunning).Updates(updates)
ok = res.RowsAffected > 0
return res.Error
})
return ok, err
}
// ResetRunningToPending 启动自愈:进程中断遗留的 running 任务全部重置为 pending。
func (r *StrmUploadTaskRepository) ResetRunningToPending(ctx context.Context) (int64, error) {
var n int64
err := withSQLiteBusyRetry(ctx, func() error {
res := r.db.WithContext(ctx).Model(&model.StrmUploadTask{}).
Where("status = ?", model.StrmTaskRunning).
Updates(map[string]any{
"status": model.StrmTaskPending,
"error": "服务重启,任务已重置",
"started_at": nil,
})
n = res.RowsAffected
return res.Error
})
return n, err
}
func (r *StrmUploadTaskRepository) Delete(ctx context.Context, id string) error {
return withSQLiteBusyRetry(ctx, func() error {
return r.db.WithContext(ctx).Unscoped().Where("id = ?", id).Delete(&model.StrmUploadTask{}).Error
@@ -709,6 +783,17 @@ func (r *StrmUploadTaskRepository) ClearCanceled(ctx context.Context) (int64, er
return count, err
}
// ClearFailed 清空全部已失败上传任务。
func (r *StrmUploadTaskRepository) ClearFailed(ctx context.Context) (int64, error) {
var count int64
err := withSQLiteBusyRetry(ctx, func() error {
res := r.db.WithContext(ctx).Unscoped().Where("status = ?", model.StrmTaskFailed).Delete(&model.StrmUploadTask{})
count = res.RowsAffected
return res.Error
})
return count, err
}
// RetryAllFailed 把所有失败任务重置回待处理,清空错误与重试计数。
func (r *StrmUploadTaskRepository) RetryAllFailed(ctx context.Context) (int64, error) {
var count int64
@@ -20,7 +20,7 @@ func TestAdultProviderRouting(t *testing.T) {
if err != nil {
t.Fatalf("failed to open sqlite: %v", err)
}
_ = db.AutoMigrate(&model.Setting{}, &model.ApiConfig{})
_ = db.AutoMigrate(&model.Setting{}, &model.APIConfig{})
repos := repository.New(db)
+6 -6
View File
@@ -40,7 +40,7 @@ func (s *ApiConfigService) TestConnection(ctx context.Context, provider string)
}
// testTMDb 测试 TMDb API 连接。
func (s *ApiConfigService) testTMDb(cfg *model.ApiConfig) (string, error) {
func (s *ApiConfigService) testTMDb(cfg *model.APIConfig) (string, error) {
if cfg.APIKey == "" {
return "error", errors.New("API key is required")
}
@@ -74,7 +74,7 @@ func (s *ApiConfigService) testTMDb(cfg *model.ApiConfig) (string, error) {
}
// testOpenAI 测试 OpenAI API 连接。
func (s *ApiConfigService) testOpenAI(cfg *model.ApiConfig) (string, error) {
func (s *ApiConfigService) testOpenAI(cfg *model.APIConfig) (string, error) {
if cfg.APIKey == "" {
return "error", errors.New("API key is required")
}
@@ -108,7 +108,7 @@ func (s *ApiConfigService) testOpenAI(cfg *model.ApiConfig) (string, error) {
}
// testDeepSeek 测试 DeepSeek API 连接。
func (s *ApiConfigService) testDeepSeek(cfg *model.ApiConfig) (string, error) {
func (s *ApiConfigService) testDeepSeek(cfg *model.APIConfig) (string, error) {
if cfg.APIKey == "" {
return "error", errors.New("API key is required")
}
@@ -142,7 +142,7 @@ func (s *ApiConfigService) testDeepSeek(cfg *model.ApiConfig) (string, error) {
}
// testSiliconFlow 测试 SiliconFlow API 连接。
func (s *ApiConfigService) testSiliconFlow(cfg *model.ApiConfig) (string, error) {
func (s *ApiConfigService) testSiliconFlow(cfg *model.APIConfig) (string, error) {
if cfg.APIKey == "" {
return "error", errors.New("API key is required")
}
@@ -176,7 +176,7 @@ func (s *ApiConfigService) testSiliconFlow(cfg *model.ApiConfig) (string, error)
}
// testAdult 测试 Adult (JavDB/JavBus) 刮削数据源连接与年龄验证。
func (s *ApiConfigService) testAdult(ctx context.Context, cfg *model.ApiConfig) (string, error) {
func (s *ApiConfigService) testAdult(ctx context.Context, cfg *model.APIConfig) (string, error) {
bases := []string{}
if cfg.BaseURL != "" {
bases = append(bases, adultConfiguredBases(cfg.BaseURL)...)
@@ -245,7 +245,7 @@ func (s *ApiConfigService) testAdult(ctx context.Context, cfg *model.ApiConfig)
}
// testMetaTube 测试 MetaTube Server 连接与 Token。
func (s *ApiConfigService) testMetaTube(ctx context.Context, cfg *model.ApiConfig) (string, error) {
func (s *ApiConfigService) testMetaTube(ctx context.Context, cfg *model.APIConfig) (string, error) {
serverURL := strings.TrimRight(strings.TrimSpace(cfg.BaseURL), "/")
if serverURL == "" {
serverURL = "http://127.0.0.1:7700"
+3 -3
View File
@@ -9,7 +9,7 @@ import (
)
// GetEffectiveConfig 获取生效的 API 配置(数据库配置优先于配置文件)。
func (s *ApiConfigService) GetEffectiveConfig(ctx context.Context, provider string) (*model.ApiConfig, error) {
func (s *ApiConfigService) GetEffectiveConfig(ctx context.Context, provider string) (*model.APIConfig, error) {
// 首先尝试从数据库获取
cfg, err := s.GetByProvider(ctx, provider)
if err == nil && cfg != nil {
@@ -21,7 +21,7 @@ func (s *ApiConfigService) GetEffectiveConfig(ctx context.Context, provider stri
}
// getConfigFromFile 从配置文件获取 API 配置。
func (s *ApiConfigService) getConfigFromFile(provider string) (*model.ApiConfig, error) {
func (s *ApiConfigService) getConfigFromFile(provider string) (*model.APIConfig, error) {
var apiKey string
var hasKey bool
@@ -44,7 +44,7 @@ func (s *ApiConfigService) getConfigFromFile(provider string) (*model.ApiConfig,
return nil, ErrApiConfigNotFound
}
return &model.ApiConfig{
return &model.APIConfig{
Provider: provider,
APIKey: apiKey,
Enabled: true,
+5 -5
View File
@@ -33,7 +33,7 @@ var (
)
// GetByProvider 获取指定提供者的 API 配置。
func (s *ApiConfigService) GetByProvider(ctx context.Context, provider string) (*model.ApiConfig, error) {
func (s *ApiConfigService) GetByProvider(ctx context.Context, provider string) (*model.APIConfig, error) {
cfg, err := s.repo.ApiConfig.FindByProvider(ctx, provider)
if err != nil {
return nil, err
@@ -49,7 +49,7 @@ func (s *ApiConfigService) GetByProvider(ctx context.Context, provider string) (
}
// List 返回所有 API 配置。
func (s *ApiConfigService) List(ctx context.Context) ([]model.ApiConfig, error) {
func (s *ApiConfigService) List(ctx context.Context) ([]model.APIConfig, error) {
configs, err := s.repo.ApiConfig.List(ctx)
if err != nil {
return nil, err
@@ -69,7 +69,7 @@ func (s *ApiConfigService) GetProviders() []model.ApiProvider {
}
// Upsert 创建或更新 API 配置,自动加密敏感字段。
func (s *ApiConfigService) Upsert(ctx context.Context, provider string, apiKey, baseURL, extra string, enabled bool) (*model.ApiConfig, error) {
func (s *ApiConfigService) Upsert(ctx context.Context, provider string, apiKey, baseURL, extra string, enabled bool) (*model.APIConfig, error) {
// 验证提供者是否有效
if !s.isValidProvider(provider) {
return nil, ErrInvalidProvider
@@ -81,7 +81,7 @@ func (s *ApiConfigService) Upsert(ctx context.Context, provider string, apiKey,
encryptedKey = s.crypto.Encrypt(apiKey)
}
cfg := &model.ApiConfig{
cfg := &model.APIConfig{
Provider: provider,
APIKey: encryptedKey,
BaseURL: baseURL,
@@ -112,7 +112,7 @@ func (s *ApiConfigService) Update(ctx context.Context, provider string, apiKey,
encryptedKey = s.crypto.Encrypt(apiKey)
}
cfg := &model.ApiConfig{
cfg := &model.APIConfig{
Provider: provider,
APIKey: encryptedKey,
BaseURL: baseURL,
+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))
}
})
}()
}
+10 -1
View File
@@ -8,6 +8,8 @@ import (
"net/url"
"path"
"strings"
"sync"
"time"
)
// cloudDrive2Provider bridges CloudDrive2 through its WebDAV endpoint.
@@ -22,11 +24,18 @@ type cloudDrive2Provider struct {
base *url.URL
username string
password string
token string
token string // 配置的静态令牌(构造后只读)
ua string
apiBase *url.URL
client *http.Client
proxy bool
// tokenMu / loginToken / loginTokenSeen 保护 OpenList 用户名密码登录的
// token 缓存:多 worker 并发时单飞登录,缓存有效期内直接复用,
// 401 时清缓存重登(见 clouddrive2_openlist.go 的 openListAPIToken)。
tokenMu sync.Mutex
loginToken string
loginTokenSeen time.Time
}
func newCloudDrive2(cfg map[string]any, client *http.Client) *cloudDrive2Provider {
+3 -2
View File
@@ -36,9 +36,10 @@ func (p *cloudDrive2Provider) List(ctx context.Context, dir string) ([]FileEntry
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return nil, p.decorateDAVStatusError(resp, target)
}
body, _ := io.ReadAll(io.LimitReader(resp.Body, 4<<20))
// 流式解码:超大目录(如上万条目的网盘目录)响应可能远超旧 4MB 截断上限,
// 直接 xml.Unmarshal 会截断报错;这里用 LimitReader(64MB) + Decoder 边读边解
var multi cloudDAVMultiStatus
if err := xml.Unmarshal(body, &multi); err != nil {
if err := xml.NewDecoder(io.LimitReader(resp.Body, 64<<20)).Decode(&multi); err != nil {
return nil, fmt.Errorf("%s: decode webdav: %w", p.name, err)
}
basePath := strings.TrimRight(p.base.EscapedPath(), "/")
+46 -18
View File
@@ -131,10 +131,13 @@ func (p *cloudDrive2Provider) openListAPIMove(ctx context.Context, source, targe
}
func (p *cloudDrive2Provider) openListAPIPost(ctx context.Context, apiPath string, payload any, action string) error {
token, err := p.openListAPIToken(ctx)
if err != nil {
return err
}
_, err := doWithOpenListAPIToken(ctx, p, func(token string) (struct{}, error) {
return struct{}{}, p.openListAPIPostWithToken(ctx, apiPath, payload, action, token)
})
return err
}
func (p *cloudDrive2Provider) openListAPIPostWithToken(ctx context.Context, apiPath string, payload any, action, token string) error {
body, _ := json.Marshal(payload)
req, err := http.NewRequestWithContext(ctx, http.MethodPost, p.openListAPIURL(apiPath), bytes.NewReader(body))
if err != nil {
@@ -151,6 +154,9 @@ func (p *cloudDrive2Provider) openListAPIPost(ctx context.Context, apiPath strin
return decorateDAVTransportError(p.name, p.openListAPIURL(apiPath), err)
}
defer resp.Body.Close()
if resp.StatusCode == http.StatusUnauthorized {
return errOpenListAPITokenExpired
}
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return fmt.Errorf("%s: api %s returned http %d", p.name, action, resp.StatusCode)
}
@@ -200,27 +206,43 @@ func (p *cloudDrive2Provider) PutFile(ctx context.Context, remotePath string, r
}
// openListAPIPutFile 通过 OpenList /api/fs/form 上传(QMediaSync 同款契约:
// PUT + multipart + File-Path 头)。
// PUT + multipart + File-Path 头)。使用 io.Pipe + multipart.Writer 边写边发,
// 避免把整个文件读进内存。
func (p *cloudDrive2Provider) openListAPIPutFile(ctx context.Context, remotePath string, r io.Reader) error {
token, err := p.openListAPIToken(ctx)
if err != nil {
return err
}
encodedPath := openListPathEscape(remotePath)
body := &bytes.Buffer{}
writer := multipart.NewWriter(body)
formFile, err := writer.CreateFormFile("file", path.Base(remotePath))
if err != nil {
return err
}
if _, err := io.Copy(formFile, r); err != nil {
return err
}
if err := writer.Close(); err != nil {
return err
}
req, err := http.NewRequestWithContext(ctx, http.MethodPut, p.openListAPIURL("/api/fs/form"), body)
pr, pw := io.Pipe()
writer := multipart.NewWriter(pw)
go func() {
var writeErr error
defer func() {
// 读源失败必须传给 pipe 写端,让 HTTP 请求以失败收场而不是静默截断
if writeErr != nil {
_ = pw.CloseWithError(writeErr)
return
}
_ = pw.Close()
}()
formFile, err := writer.CreateFormFile("file", path.Base(remotePath))
if err != nil {
writeErr = err
return
}
if _, err := io.Copy(formFile, r); err != nil {
writeErr = err
return
}
writeErr = writer.Close()
}()
req, err := http.NewRequestWithContext(ctx, http.MethodPut, p.openListAPIURL("/api/fs/form"), pr)
if err != nil {
// 关闭读端以释放仍在等待写入的后台 goroutine(其 Write 会立即失败返回)
_ = pr.Close()
return err
}
req.Header.Set("Authorization", token)
@@ -230,9 +252,15 @@ func (p *cloudDrive2Provider) openListAPIPutFile(ctx context.Context, remotePath
req.Header.Set("Overwrite", "true")
resp, err := p.client.Do(req)
if err != nil {
// 传输层失败(含提前断开)时 net/http 会关闭请求 body,解除后台 goroutine 阻塞
return decorateDAVTransportError(p.name, p.openListAPIURL("/api/fs/form"), err)
}
defer resp.Body.Close()
if resp.StatusCode == http.StatusUnauthorized {
// 流式 body 无法重放,不能自动重试:清除登录 token 缓存让下次上传重新登录,
// 本次返回明确错误交由调用方重试
p.invalidateOpenListAPIToken()
}
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return p.openListAPIStatusError("upload", remotePath, resp.StatusCode)
}
+74 -7
View File
@@ -4,18 +4,50 @@ import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"strings"
"time"
)
func (p *cloudDrive2Provider) listOpenListAPI(ctx context.Context, dir string) ([]FileEntry, error) {
// errOpenListAPITokenExpired 标记 OpenList 返回 401(登录 token 已失效):
// 调用方收到后应清缓存重登一次再重试原请求。
var errOpenListAPITokenExpired = errors.New("openlist api token expired")
// openListAPITokenCacheTTL 登录 token 缓存有效期(OpenList 默认签发 48h JWT,
// 这里保守取 30 分钟,过期自动重新登录)。
const openListAPITokenCacheTTL = 30 * time.Minute
// doWithOpenListAPIToken 获取 OpenList API token 后执行 fn;若请求命中 401
// (登录 token 失效)则清缓存重登一次并重试,避免一次 token 轮换导致整批请求失败。
func doWithOpenListAPIToken[T any](ctx context.Context, p *cloudDrive2Provider, fn func(token string) (T, error)) (T, error) {
var zero T
token, err := p.openListAPIToken(ctx)
if err != nil {
return nil, err
return zero, err
}
result, err := fn(token)
if err == nil || !errors.Is(err, errOpenListAPITokenExpired) {
return result, err
}
p.invalidateOpenListAPIToken()
token, err = p.openListAPIToken(ctx)
if err != nil {
return zero, err
}
return fn(token)
}
func (p *cloudDrive2Provider) listOpenListAPI(ctx context.Context, dir string) ([]FileEntry, error) {
return doWithOpenListAPIToken(ctx, p, func(token string) ([]FileEntry, error) {
return p.listOpenListAPIWithToken(ctx, dir, token)
})
}
func (p *cloudDrive2Provider) listOpenListAPIWithToken(ctx context.Context, dir, token string) ([]FileEntry, error) {
const pageSize = 500
target := normalizeCloudDAVPath(dir)
out := make([]FileEntry, 0, pageSize)
@@ -45,6 +77,9 @@ func (p *cloudDrive2Provider) listOpenListAPI(ctx context.Context, dir string) (
var decoded openListListResponse
decodeErr := json.NewDecoder(io.LimitReader(resp.Body, 32<<20)).Decode(&decoded)
resp.Body.Close()
if resp.StatusCode == http.StatusUnauthorized {
return nil, errOpenListAPITokenExpired
}
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return nil, p.openListAPIStatusError("list", target, resp.StatusCode)
}
@@ -85,10 +120,12 @@ func (p *cloudDrive2Provider) listOpenListAPI(ctx context.Context, dir string) (
}
func (p *cloudDrive2Provider) resolveOpenListAPIDirect(ctx context.Context, fileRef string) (*DirectLink, error) {
token, err := p.openListAPIToken(ctx)
if err != nil {
return nil, err
}
return doWithOpenListAPIToken(ctx, p, func(token string) (*DirectLink, error) {
return p.resolveOpenListAPIDirectWithToken(ctx, fileRef, token)
})
}
func (p *cloudDrive2Provider) resolveOpenListAPIDirectWithToken(ctx context.Context, fileRef, token string) (*DirectLink, error) {
payload, _ := json.Marshal(map[string]string{"path": normalizeCloudDAVPath(fileRef), "password": ""})
req, err := http.NewRequestWithContext(ctx, http.MethodPost, p.openListAPIURL("/api/fs/get"), bytes.NewReader(payload))
if err != nil {
@@ -105,6 +142,9 @@ func (p *cloudDrive2Provider) resolveOpenListAPIDirect(ctx context.Context, file
return nil, decorateDAVTransportError(p.name, p.openListAPIURL("/api/fs/get"), err)
}
defer resp.Body.Close()
if resp.StatusCode == http.StatusUnauthorized {
return nil, errOpenListAPITokenExpired
}
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return nil, p.openListAPIStatusError("get", fileRef, resp.StatusCode)
}
@@ -163,6 +203,10 @@ func (p *cloudDrive2Provider) hasOpenListAPICredentials() bool {
return strings.TrimSpace(p.token) != "" || (strings.TrimSpace(p.username) != "" && p.password != "")
}
// openListAPIToken 返回 OpenList API 访问令牌:
// - 配置了静态 token 时直接使用(构造后只读,无并发问题);
// - 否则用用户名密码登录,并在缓存有效期内单飞复用——8 个同步 worker 并发时
// 只会有一个 goroutine 真正执行登录,避免登录风暴;登录 token 的写入受 tokenMu 保护。
func (p *cloudDrive2Provider) openListAPIToken(ctx context.Context) (string, error) {
if token := strings.TrimSpace(p.token); token != "" {
return token, nil
@@ -170,6 +214,30 @@ func (p *cloudDrive2Provider) openListAPIToken(ctx context.Context) (string, err
if strings.TrimSpace(p.username) == "" || p.password == "" {
return "", nil
}
p.tokenMu.Lock()
defer p.tokenMu.Unlock()
if p.loginToken != "" && time.Since(p.loginTokenSeen) < openListAPITokenCacheTTL {
return p.loginToken, nil
}
token, err := p.openListAPILogin(ctx)
if err != nil {
return "", err
}
p.loginToken = token
p.loginTokenSeen = time.Now()
return token, nil
}
// invalidateOpenListAPIToken 清除登录 token 缓存(收到 401 时调用,下次请求重新登录)。
func (p *cloudDrive2Provider) invalidateOpenListAPIToken() {
p.tokenMu.Lock()
p.loginToken = ""
p.loginTokenSeen = time.Time{}
p.tokenMu.Unlock()
}
// openListAPILogin 调用 OpenList /api/auth/login 换取登录 token。
func (p *cloudDrive2Provider) openListAPILogin(ctx context.Context) (string, error) {
payload, _ := json.Marshal(map[string]string{
"username": p.username,
"password": p.password,
@@ -204,7 +272,6 @@ func (p *cloudDrive2Provider) openListAPIToken(ctx context.Context) (string, err
if token == "" {
return "", fmt.Errorf("%s: api login returned empty token", p.name)
}
p.token = token
return token, nil
}
+8 -5
View File
@@ -48,7 +48,7 @@ func (p *openAPI115Provider) Ping(ctx context.Context) error {
if strings.TrimSpace(p.c.AppID) == "" {
return fmt.Errorf("115: 缺少开放平台应用 ID,请重新授权")
}
if strings.TrimSpace(p.c.AccessToken) == "" {
if strings.TrimSpace(p.c.CurrentAccessToken()) == "" {
return fmt.Errorf("115: 缺少访问令牌,请重新授权")
}
_, _, err := p.c.GetFsList(ctx, "0", 0, 1)
@@ -70,7 +70,7 @@ func (p *openAPI115Provider) List(ctx context.Context, dirID string) ([]FileEntr
Name: f.FileName,
IsDir: f.Category == cloud115.TypeDir,
Size: f.FileSize,
MTime: f.Utime,
MTime: f.ModifiedAt(),
PickCode: f.PickCode,
})
}
@@ -127,12 +127,15 @@ func (p *openAPI115Provider) PutFileNamed(ctx context.Context, parentCID, fileNa
if err := tmp.Close(); err != nil {
return fmt.Errorf("115: 关闭临时文件失败:%w", err)
}
// 重命名为目标文件名,保证上传到 115 后保留原始文件名
// 重命名为目标文件名,保证上传到 115 后保留原始文件名。
// 重命名失败必须 fail fast:静默用随机临时名上传会导致 115 上的文件名
// 变成 mebox-upload-xxx,破坏元数据文件名契约。
if fileName != "" && fileName != filepath.Base(tmpPath) {
namedPath := filepath.Join(filepath.Dir(tmpPath), fileName)
if err := os.Rename(tmpPath, namedPath); err == nil {
tmpPath = namedPath
if err := os.Rename(tmpPath, namedPath); err != nil {
return fmt.Errorf("115: 重命名临时文件为 %s 失败:%w", fileName, err)
}
tmpPath = namedPath
}
_, err = p.c.Upload(ctx, tmpPath, parentCID, "", "")
if err != nil {
+95 -16
View File
@@ -23,9 +23,14 @@ type OpenClient struct {
RefreshTokenStr string
executor *QueueExecutor
// tokenMu 保护令牌刷新:业务请求中途 access_token 失效时自动刷新重试,
// 多 goroutine(同步列表 + 下载队列)并发下只允许一次刷新进行。
tokenMu sync.Mutex
// OnTokenRefreshed 在 access_token 刷新成功后回调(参数为新令牌对),
// 供上层持久化新令牌使用;nil 安全,且在 tokenMu 释放后调用以避免死锁。
OnTokenRefreshed func(accessToken, refreshToken string)
// tokenMu 保护 AccessToken / RefreshTokenStr 的并发读写:业务请求中途
// access_token 失效时自动刷新重试,多 goroutine(同步列表 + 下载队列)
// 并发下只允许一次刷新进行。
tokenMu sync.RWMutex
}
// default115HTTPClient 创建带有防 405 重定向保护的 http.Client。
@@ -57,12 +62,40 @@ func NewOpenClient(appID, accessToken, refreshToken string) *OpenClient {
}
}
// SetAuthToken 更新认证令牌。
// SetAuthToken 更新认证令牌(并发安全)。
func (c *OpenClient) SetAuthToken(accessToken, refreshToken string) {
c.tokenMu.Lock()
c.setAuthTokenLocked(accessToken, refreshToken)
c.tokenMu.Unlock()
}
// setAuthTokenLocked 无锁更新令牌,调用方必须已持有 tokenMu 写锁
// (tryRefreshTokenLocked 等已持锁流程内部使用,避免重入死锁)。
func (c *OpenClient) setAuthTokenLocked(accessToken, refreshToken string) {
c.AccessToken = accessToken
c.RefreshTokenStr = refreshToken
}
// currentAccessToken 返回当前 access_token(并发安全)。
func (c *OpenClient) currentAccessToken() string {
c.tokenMu.RLock()
defer c.tokenMu.RUnlock()
return c.AccessToken
}
// currentRefreshToken 返回当前 refresh_token(并发安全)。
func (c *OpenClient) currentRefreshToken() string {
c.tokenMu.RLock()
defer c.tokenMu.RUnlock()
return c.RefreshTokenStr
}
// CurrentAccessToken 返回当前 access_token 快照(并发安全),
// 供上层在无锁环境下安全读取(如 Ping 时探测令牌是否存在)。
func (c *OpenClient) CurrentAccessToken() string {
return c.currentAccessToken()
}
// RespState 兼容 115 不同端点返回的 state 类型(proapi 返回布尔、passport 返回数字)。
type RespState bool
@@ -191,6 +224,14 @@ func (c *OpenClient) doJSON(ctx context.Context, method, rawURL string, form map
if access {
// 刷新失败(或已刷新仍失败)时返回明确错误
lastErr = NewOpenAPIResponseError(base.Code, base.Errno, base.Message, base.Error, "115: access_token 校验失败且刷新未成功")
} else {
// 未携带令牌的请求(登录/刷新流程)命中 token 类错误码:
// 必须返回显式 error,避免调用方把 (resp, nil) 当作成功处理
msg := base.Message
if msg == "" {
msg = base.Error
}
lastErr = fmt.Errorf("115: 认证失败(code=%d): %s", base.Code, msg)
}
return &base, lastErr
}
@@ -242,8 +283,11 @@ func (c *OpenClient) buildRequestWithUA(ctx context.Context, method, rawURL stri
if method == http.MethodPost && len(form) > 0 {
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
}
if access && c.AccessToken != "" {
req.Header.Set("Authorization", "Bearer "+c.AccessToken)
if access {
// RLock 读取令牌,避免与刷新流程的写入产生数据竞争
if accessToken := c.currentAccessToken(); accessToken != "" {
req.Header.Set("Authorization", "Bearer "+accessToken)
}
}
return req, nil
}
@@ -261,33 +305,57 @@ func (c *OpenClient) doAuthJSONWithUA(ctx context.Context, method, rawURL string
// tryRefreshTokenLocked 并发安全地刷新 access_token;成功返回 true(调用方
// 应使用内存中的新 token 重试原请求)。
//
// 拿到写锁后在锁内读取 oldAccess,与持锁期间的当前值对比:若已被其他
// goroutine 刷新过则直接复用新 token,避免并发请求连环轮转消耗 115 的
// 一次性 refresh_token。全程持写锁读写 token 字段,无 TOCTOU 窗口。
//
// 对"refresh_token 本身已失效/被吊销"(IsRefreshTokenDead,如 40140114/116/119/120)
// 这类不可恢复的错误直接放弃并清空内存 token(提示需重新授权)。
// 对其它失败(网络瞬时抖动、刷新接口可重试错误码等)做指数退避重试几次再放弃,
// 避免同步长任务中途 token 到期时恰好撞上一个短暂的刷新失败就整体失败。
func (c *OpenClient) tryRefreshTokenLocked(ctx context.Context) bool {
c.tokenMu.Lock()
defer c.tokenMu.Unlock()
// 在已持有写锁内读取当前 token 作为"刷新前快照",消除双重加锁窗口:
// 若在拿锁期间已有其他 goroutine 完成刷新,refreshTokenWhileLocked
// 内的 c.AccessToken != oldAccess 判断会立即命中并返回复用。
oldAccess := c.AccessToken
newToken, ok := c.refreshTokenWhileLocked(ctx, oldAccess)
c.tokenMu.Unlock()
// 回调必须在 tokenMu 释放后调用,避免上层在回调内访问客户端时死锁
if ok && newToken != nil && c.OnTokenRefreshed != nil {
c.OnTokenRefreshed(newToken.AccessToken, newToken.RefreshToken)
}
return ok
}
// refreshTokenWhileLocked 在已持有 tokenMu 写锁的前提下执行刷新。
// 返回 (新令牌, 是否成功);命中"他人已刷新"捷径时新令牌为 nil。
func (c *OpenClient) refreshTokenWhileLocked(ctx context.Context, oldAccess string) (*TokenData, bool) {
if c.AccessToken != oldAccess {
// 其他 goroutine 刚刷新过:直接复用内存中的新 token 重试原请求
return nil, true
}
refreshToken := c.RefreshTokenStr
for attempt := 0; attempt < refreshAttempts; attempt++ {
token, err := c.RefreshToken(c.RefreshTokenStr)
token, err := c.doRefreshToken(refreshToken)
if err == nil {
c.SetAuthToken(token.AccessToken, token.RefreshToken)
return true
c.setAuthTokenLocked(token.AccessToken, token.RefreshToken)
return token, true
}
if IsRefreshTokenDead(err) {
c.SetAuthToken("", "")
return false
c.setAuthTokenLocked("", "")
return nil, false
}
// 可恢复失败:退避后重试。ctx 取消时立即放弃。
if attempt < refreshAttempts-1 {
select {
case <-ctx.Done():
return false
return nil, false
case <-time.After(refreshBackoff(attempt)):
}
}
}
return false
return nil, false
}
// refreshAttempts 是刷新 access_token 失败时的最大尝试次数(含首次)。
@@ -312,7 +380,14 @@ func isTokenCode(code int) bool {
}
// openList 解析 data 为对象或数组(StructOrArray 语义)。
// 115 部分接口在鉴权/业务异常时会返回 data:null 或 data:{},此时若直接
// 反序列化会得到零值元素 + nil error,调用方会把空数据当成功处理;
// 这里对 null/空对象显式报错。
func openList[T any](raw json.RawMessage) ([]T, error) {
trimmed := bytes.TrimSpace(raw)
if len(trimmed) == 0 || bytes.Equal(trimmed, []byte("null")) || bytes.Equal(trimmed, []byte("{}")) {
return nil, fmt.Errorf("115: data 为空(%s)", string(trimmed))
}
var single T
if err := json.Unmarshal(raw, &single); err == nil {
return []T{single}, nil
@@ -324,12 +399,16 @@ func openList[T any](raw json.RawMessage) ([]T, error) {
return nil, fmt.Errorf("115: data 既不是对象也不是数组")
}
// openFirstList 取 data 的第一个元素。
// openFirstList 取 data 的第一个元素;data 为空(null/空数组)时返回显式错误,
// 避免调用方拿到 (nil, nil) 后解引用空指针。
func openFirstList[T any](raw json.RawMessage) (*T, error) {
items, err := openList[T](raw)
if err != nil || len(items) == 0 {
if err != nil {
return nil, err
}
if len(items) == 0 {
return nil, fmt.Errorf("115: data 为空数组")
}
return &items[0], nil
}
+6 -1
View File
@@ -317,13 +317,18 @@ func appendCallbackParams(rawURL string, params url.Values) (string, error) {
return callbackURL.String(), nil
}
// oauthHTTPClient 是 OAuth 授权服务专用 HTTP 客户端。http.DefaultClient 无超时,
// 授权服务无响应时会永久阻塞授权/轮询协程,这里统一 30s 超时(ctx 仍经
// NewRequestWithContext 传导,可提前取消)。
var oauthHTTPClient = &http.Client{Timeout: 30 * time.Second}
func httpGetJSON(ctx context.Context, endpoint string) (map[string]any, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
if err != nil {
return nil, err
}
req.Header.Set("User-Agent", DefaultUA)
resp, err := http.DefaultClient.Do(req)
resp, err := oauthHTTPClient.Do(req)
if err != nil {
return nil, err
}
+31 -12
View File
@@ -300,6 +300,11 @@ func (c *OpenClient) GetQrCode() (*QrCodeDataReturn, error) {
if err != nil {
return nil, err
}
// 关键字段缺失时显式报错:空 uid/sign 会导致后续扫码轮询必然失败,
// 不能把残缺响应当成功返回给界面。
if code.Uid == "" || code.Sign == "" {
return nil, fmt.Errorf("115: 设备码响应缺少 uid/sign,无法发起扫码授权")
}
return &QrCodeDataReturn{QrCodeData: *code, CodeVerifier: codeVerifier}, nil
}
@@ -352,6 +357,10 @@ func (c *OpenClient) GetToken(qrCode *QrCodeDataReturn) (*TokenData, error) {
if err != nil {
return nil, err
}
// 空凭证绝不能 SetAuthToken 后当成功返回:界面会显示"授权成功"但账号不可用
if token.AccessToken == "" || token.RefreshToken == "" {
return nil, fmt.Errorf("115: 设备码换 token 返回空凭证(access_token/refresh_token 缺失)")
}
c.SetAuthToken(token.AccessToken, token.RefreshToken)
return token, nil
}
@@ -359,11 +368,30 @@ func (c *OpenClient) GetToken(qrCode *QrCodeDataReturn) (*TokenData, error) {
// RefreshToken 刷新访问令牌。
func (c *OpenClient) RefreshToken(refreshToken string) (*TokenData, error) {
if refreshToken == "" {
refreshToken = c.RefreshTokenStr
refreshToken = c.currentRefreshToken()
}
if refreshToken == "" {
return nil, fmt.Errorf("没有可用的 refresh_token")
}
token, err := c.doRefreshToken(refreshToken)
if err != nil {
// refresh_token 已失效时清空内存令牌(提示需重新授权)
if IsRefreshTokenDead(err) {
c.SetAuthToken("", "")
}
return nil, err
}
if token.AccessToken == "" || token.RefreshToken == "" {
return nil, fmt.Errorf("115: 刷新返回空凭证(access_token/refresh_token 缺失)")
}
c.SetAuthToken(token.AccessToken, token.RefreshToken)
return token, nil
}
// doRefreshToken 调用 115 刷新接口换取新令牌,不修改客户端内存状态;
// 拆出无状态方法供 tryRefreshTokenLocked(已持 tokenMu 写锁)复用,
// 避免在持锁期间重入 SetAuthToken 造成死锁。
func (c *OpenClient) doRefreshToken(refreshToken string) (*TokenData, error) {
params := map[string]string{"refresh_token": refreshToken}
resp, err := c.doJSON(context.Background(), "POST", PassportAPIBase+"/open/refreshToken", params, false, 0)
if err != nil && resp == nil {
@@ -373,18 +401,9 @@ func (c *OpenClient) RefreshToken(refreshToken string) (*TokenData, error) {
return nil, err
}
if !resp.State {
apiErr := NewOpenAPIResponseError(resp.Code, resp.Errno, resp.Message, resp.Error, "115 开放平台刷新访问凭证失败")
if IsRefreshTokenDead(apiErr) {
c.SetAuthToken("", "")
}
return nil, apiErr
return nil, NewOpenAPIResponseError(resp.Code, resp.Errno, resp.Message, resp.Error, "115 开放平台刷新访问凭证失败")
}
token, err := openFirstList[TokenData](resp.Data)
if err != nil {
return nil, err
}
c.SetAuthToken(token.AccessToken, token.RefreshToken)
return token, nil
return openFirstList[TokenData](resp.Data)
}
// ─── 用户信息 ──────────────────────────────────────────────────────────────────
+76 -37
View File
@@ -10,6 +10,7 @@ import (
"errors"
"fmt"
"io"
"log"
"os"
"sort"
@@ -106,14 +107,23 @@ func (u *OSSMultipartUploader) UploadFile(ctx context.Context, input OSSMultipar
return result.CallbackResult, nil
}
// UploadedPart 是 OSS 已上传分片的定位信息(断点续传时复用 ETag 用)。
type UploadedPart struct {
PartNumber int32
Size int64
ETag string
}
// UploadFileWithResult 上传文件并返回 multipart 结果。
func (u *OSSMultipartUploader) UploadFileWithResult(ctx context.Context, input OSSMultipartUploadInput) (OSSMultipartUploadResult, error) {
// 任一失败路径(分片上传失败 / callback 校验失败 / Complete 失败 / 文件打开失败等)
// 都会经 defer 统一 AbortMultipartUpload 丢弃本次 Initiate 出的 multipart
// (abort 失败仅记日志),避免 OSS 分片永久泄漏;成功路径不 Abort。
func (u *OSSMultipartUploader) UploadFileWithResult(ctx context.Context, input OSSMultipartUploadInput) (result OSSMultipartUploadResult, err error) {
if input.PartRetryMax <= 0 {
input.PartRetryMax = 3
}
partSize := input.PartSize
totalParts := 0
var err error
if partSize <= 0 {
partSize, totalParts, err = CalculateMultipartPartSize(input.FileSize)
if err != nil {
@@ -124,28 +134,45 @@ func (u *OSSMultipartUploader) UploadFileWithResult(ctx context.Context, input O
}
uploadId := input.UploadId
if uploadId == "" {
initResult, err := u.client.InitiateMultipartUpload(ctx, &oss.InitiateMultipartUploadRequest{
// ownUploadId 标记 uploadId 是否为本调用 Initiate 出来的:仅自建的
// multipart 在失败时由本函数 Abort;调用方显式传入的 uploadId(断点续传)
// 失败后保留现场,由调用方决定重试或清理。
ownUploadId := uploadId == ""
if ownUploadId {
initResult, initErr := u.client.InitiateMultipartUpload(ctx, &oss.InitiateMultipartUploadRequest{
Bucket: oss.Ptr(input.Bucket),
Key: oss.Ptr(input.Object),
RequestCommon: oss.RequestCommon{
Parameters: map[string]string{"sequential": "1"},
},
})
if err != nil {
return OSSMultipartUploadResult{}, fmt.Errorf("初始化 OSS multipart 失败:%w", err)
if initErr != nil {
return OSSMultipartUploadResult{}, fmt.Errorf("初始化 OSS multipart 失败:%w", initErr)
}
if initResult.UploadId == nil || *initResult.UploadId == "" {
return OSSMultipartUploadResult{}, fmt.Errorf("初始化 OSS multipart 返回空 upload_id")
}
uploadId = *initResult.UploadId
}
defer func() {
if err == nil || !ownUploadId || uploadId == "" {
return
}
// 失败路径统一 Abort 丢弃已上传分片;ctx 可能已取消,脱离其取消信号尽力清理
abortCtx := context.WithoutCancel(ctx)
if _, abortErr := u.client.AbortMultipartUpload(abortCtx, &oss.AbortMultipartUploadRequest{
Bucket: oss.Ptr(input.Bucket),
Key: oss.Ptr(input.Object),
UploadId: oss.Ptr(uploadId),
}); abortErr != nil {
log.Printf("115: 中止 OSS multipart 失败(upload_id=%s,可能残留分片):%v", uploadId, abortErr)
}
}()
existingPartMap := make(map[int32]int64)
existingParts, err := u.ListUploadedParts(ctx, input.Bucket, input.Object, uploadId)
if err == nil {
existingPartMap := make(map[int32]UploadedPart)
if existingParts, listErr := u.ListUploadedParts(ctx, input.Bucket, input.Object, uploadId); listErr == nil {
for _, part := range existingParts {
existingPartMap[part.PartNumber] = part.Size
existingPartMap[part.PartNumber] = part
}
}
@@ -164,13 +191,20 @@ func (u *OSSMultipartUploader) UploadFileWithResult(ctx context.Context, input O
if length < 0 {
length = 0
}
if existingSize, ok := existingPartMap[int32(partNumber)]; ok && existingSize == length {
// 断点续传:分片已完整上传(大小一致即代表分片大小未变)时直接复用
// ListParts 返回的 ETag,跳过重传,也不再重复累加统计
if existing, ok := existingPartMap[int32(partNumber)]; ok && existing.Size == length && existing.ETag != "" {
uploadedBytes += length
uploadedParts++
completeParts = append(completeParts, oss.UploadPart{
PartNumber: int32(partNumber),
ETag: oss.Ptr(existing.ETag),
})
continue
}
etag, err := u.uploadPartWithRetry(ctx, input, uploadId, int32(partNumber), file, offset, length)
if err != nil {
return OSSMultipartUploadResult{}, err
etag, uploadErr := u.uploadPartWithRetry(ctx, input, uploadId, int32(partNumber), file, offset, length)
if uploadErr != nil {
return OSSMultipartUploadResult{}, uploadErr
}
uploadedBytes += length
uploadedParts++
@@ -224,29 +258,34 @@ func (u *OSSMultipartUploader) UploadFileWithResult(ctx context.Context, input O
}, nil
}
// ListUploadedParts 查询 OSS 已上传分片。
func (u *OSSMultipartUploader) ListUploadedParts(ctx context.Context, bucket, object, uploadId string) ([]struct {
PartNumber int32
Size int64
}, error) {
parts := []struct {
PartNumber int32
Size int64
}{}
result, err := u.client.ListParts(ctx, &oss.ListPartsRequest{
Bucket: oss.Ptr(bucket),
Key: oss.Ptr(object),
UploadId: oss.Ptr(uploadId),
MaxParts: 1000,
})
if err != nil {
return nil, fmt.Errorf("查询 OSS 已上传分片失败:%w", err)
}
for _, part := range result.Parts {
parts = append(parts, struct {
PartNumber int32
Size int64
}{PartNumber: part.PartNumber, Size: part.Size})
// ListUploadedParts 查询 OSS 已上传分片(MaxParts 上限 1000,超过时按
// NextPartNumberMarker 自动翻页取全量,否则断点续传只能看到前 1000 片)。
func (u *OSSMultipartUploader) ListUploadedParts(ctx context.Context, bucket, object, uploadId string) ([]UploadedPart, error) {
parts := []UploadedPart{}
var marker int32
for {
result, err := u.client.ListParts(ctx, &oss.ListPartsRequest{
Bucket: oss.Ptr(bucket),
Key: oss.Ptr(object),
UploadId: oss.Ptr(uploadId),
MaxParts: 1000,
PartNumberMarker: marker,
})
if err != nil {
return nil, fmt.Errorf("查询 OSS 已上传分片失败:%w", err)
}
for _, part := range result.Parts {
etag := ""
if part.ETag != nil {
etag = *part.ETag
}
parts = append(parts, UploadedPart{PartNumber: part.PartNumber, Size: part.Size, ETag: etag})
}
if !result.IsTruncated || result.NextPartNumberMarker <= marker {
// 防御:marker 不前进时终止循环,避免异常响应导致死循环
break
}
marker = result.NextPartNumberMarker
}
return parts, nil
}
+6 -2
View File
@@ -262,7 +262,10 @@ func (c *OpenClient) Upload(ctx context.Context, filePath, parentCID, signKey, s
}
switch status {
case UploadInitStatusRapidUploaded:
// 秒传成功
// 秒传成功:必须带远端文件定位信息,否则视为异常响应
if initResult.FileId == "" || initResult.PickCode == "" {
return nil, fmt.Errorf("115: 秒传成功但缺少 file_id/pick_code(status=%d)", status)
}
return &UploadCompleteResult{FileId: initResult.FileId, PickCode: initResult.PickCode}, nil
case UploadInitStatusSignFailed:
return nil, fmt.Errorf("115: 签名验证后失败")
@@ -271,7 +274,8 @@ func (c *OpenClient) Upload(ctx context.Context, filePath, parentCID, signKey, s
case UploadInitStatusNeedUpload:
// 真实上传:OSS multipart
default:
return &UploadCompleteResult{FileId: initResult.FileId, PickCode: initResult.PickCode}, nil
// 未知状态不能当成功返回(会静默丢文件),显式报错便于排查
return nil, fmt.Errorf("115: 未知的 upload/init 状态 %d", status)
}
if initResult.Bucket == "" || initResult.Object == "" {
+15 -3
View File
@@ -1,14 +1,26 @@
package cloud115
import "math/rand"
import (
"crypto/rand"
"fmt"
"math/big"
)
const randCharset = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
// RandomString 生成指定长度的随机字符串(PKCE code_verifier 等)。
// RandomString 生成指定长度的密码学安全随机字符串(PKCE code_verifier、
// OAuth state 等安全敏感场景)。必须使用 crypto/rand:math/rand 未播种时
// 序列可预测,会造成 PKCE 防御失效。
func RandomString(length int) string {
b := make([]byte, length)
max := big.NewInt(int64(len(randCharset)))
for i := range b {
b[i] = randCharset[rand.Intn(len(randCharset))]
n, err := rand.Int(rand.Reader, max)
if err != nil {
// 仅在系统熵源不可用时发生;静默降级为弱随机不可接受,直接暴露
panic(fmt.Errorf("115: 生成安全随机字符串失败:%w", err))
}
b[i] = randCharset[n.Int64()]
}
return string(b)
}
+6 -2
View File
@@ -36,6 +36,10 @@ type DLNAService struct {
cachedAt time.Time
}
// dlnaHTTPClient 是 DLNA 专用 HTTP 客户端:SSDP 描述拉取与 SOAP 投递
// 都应快速失败,不占用全局 DefaultClient,也不无限悬挂。
var dlnaHTTPClient = &http.Client{Timeout: 15 * time.Second}
// NewDLNAService is the constructor.
func NewDLNAService(log *zap.Logger) *DLNAService {
return &DLNAService{log: log}
@@ -153,7 +157,7 @@ func (d *DLNAService) fetchDescription(ctx context.Context, location string) (*D
if err != nil {
return nil, err
}
resp, err := http.DefaultClient.Do(req)
resp, err := dlnaHTTPClient.Do(req)
if err != nil {
return nil, err
}
@@ -267,7 +271,7 @@ func (d *DLNAService) soap(ctx context.Context, controlURL, action, envelope str
req.Header.Set("Content-Type", `text/xml; charset="utf-8"`)
req.Header.Set("SOAPAction",
fmt.Sprintf(`"urn:schemas-upnp-org:service:AVTransport:1#%s"`, action))
resp, err := http.DefaultClient.Do(req)
resp, err := dlnaHTTPClient.Do(req)
if err != nil {
return err
}
+84 -28
View File
@@ -18,6 +18,7 @@ import (
"time"
"github.com/truewhile/MeBox/internal/config"
"github.com/truewhile/MeBox/internal/model"
"github.com/truewhile/MeBox/internal/repository"
"go.uber.org/zap"
)
@@ -148,11 +149,17 @@ func (e *EmbyService) Items(ctx context.Context, p ItemsParams) (map[string]any,
if e.remote != nil {
// 远程目录浏览:ParentId 带远程前缀 → 完整转发给远程 Emby 承接分页。
if IsEmbyRemoteID(p.ParentID) {
if containsEmbyFilter(p.Filters, "IsFavorite") {
return e.favoriteItems(ctx, p)
}
mountID, _, _ := DecodeEmbyRemoteID(p.ParentID)
mount, acct, _ := e.remote.ResolveMount(ctx, mountID)
if mount == nil || acct == nil {
return emptyItemsEnvelope(p.StartIndex), nil
}
if !EmbyMountLibraryAllowed(e.mediaVisibility(ctx, p.UserID), mount) {
return emptyItemsEnvelope(p.StartIndex), nil
}
out, err := e.remote.RemoteItems(ctx, mount, acct, p)
if err != nil {
return nil, err
@@ -171,6 +178,9 @@ func (e *EmbyService) Items(ctx context.Context, p ItemsParams) (map[string]any,
if containsEmbyFilter(p.Filters, "IsResumable") {
return e.resumableItems(ctx, p)
}
if containsEmbyFilter(p.Filters, "IsFavorite") {
return e.favoriteItems(ctx, p)
}
if len(p.IDs) > 0 {
items := make([]map[string]any, 0, len(p.IDs))
@@ -258,55 +268,101 @@ func (e *EmbyService) aggregatedSearch(ctx context.Context, p ItemsParams) (map[
if err != nil {
return nil, err
}
type remoteResult struct {
items []any
type remoteReply struct {
acct *model.StrmAccount
envelope map[string]any
}
mounts, aerr := e.remote.ListMounts(ctx)
results := make([]remoteResult, 0, len(mounts))
replies := make([]*remoteReply, 0, len(mounts))
if aerr == nil {
type mountSearchJob struct {
idx int
mount *model.EmbyMount
acct *model.StrmAccount
}
jobs := make([]*mountSearchJob, 0, len(mounts))
for i := range mounts {
m := mounts[i]
if !m.Enabled {
continue
}
if !EmbyMountLibraryAllowed(e.mediaVisibility(ctx, p.UserID), &m) {
continue
}
acct := e.remote.AccountByID(ctx, m.AccountID)
if acct == nil {
continue
}
// 按挂载逐个搜索:搜索结果归属明确(伪装 ID 正确),也天然只搜已
// 挂载的媒体库。
searchParams := p
searchParams.ParentID = "" // RemoteSearchMount 内部设 ParentId
remote, rerr := e.remote.RemoteSearchMount(ctx, &m, acct, p)
if rerr != nil {
if e.log != nil {
e.log.Warn("remote emby search failed",
zap.String("account", acct.Name), zap.Error(rerr))
// idx 使用 jobs 内的序号(而非 mounts 下标):fetched 按
// len(jobs) 分配,必须与 jobs 下标对齐,否则越界 panic。
jobs = append(jobs, &mountSearchJob{idx: len(jobs), mount: &mounts[i], acct: acct})
}
// 并发搜索各挂载(限并发 + 单挂载超时):串行时每挂载最多
// 15s×线路数,多挂载下首屏延迟被成倍放大。结果按挂载顺序合并。
sem := make(chan struct{}, 4)
var wg sync.WaitGroup
fetched := make([]*remoteReply, len(jobs))
for _, job := range jobs {
wg.Add(1)
go func(job *mountSearchJob) {
defer wg.Done()
sem <- struct{}{}
defer func() { <-sem }()
sctx, cancel := context.WithTimeout(ctx, 8*time.Second)
defer cancel()
sp := p
// 远程只取首页:此前每个远程各自按 StartIndex 分页,拼接后
// 又被 sliceSearchItems 再切一次——分页被二次偏移,远程结果
// 首屏不可见、翻页错位。合并后由 sliceSearchItems 单点分页。
sp.StartIndex = 0
sp.ParentID = "" // RemoteSearchMount 内部设 ParentId
remote, rerr := e.remote.RemoteSearchMount(sctx, job.mount, job.acct, sp)
if rerr != nil {
if e.log != nil {
e.log.Warn("remote emby search failed",
zap.String("account", job.acct.Name), zap.Error(rerr))
}
return
}
continue
}
if err := e.mergeRemoteUserData(ctx, p.UserID, remote); err != nil {
return nil, err
}
if raw, ok := remote["Items"].([]any); ok {
results = append(results, remoteResult{items: raw})
} else if rawMap, ok := remote["Items"].([]map[string]any); ok {
converted := make([]any, 0, len(rawMap))
for _, m := range rawMap {
converted = append(converted, any(m))
}
results = append(results, remoteResult{items: converted})
fetched[job.idx] = &remoteReply{acct: job.acct, envelope: remote}
}(job)
}
wg.Wait()
for _, r := range fetched {
if r != nil {
replies = append(replies, r)
}
}
}
items := make([]any, 0, len(localItemsAsAny(local))+len(results)*p.Limit)
items := make([]any, 0, len(localItemsAsAny(local))+len(replies)*p.Limit)
items = append(items, localItemsAsAny(local)...)
for _, res := range results {
items = append(items, res.items...)
for _, reply := range replies {
if err := e.mergeRemoteUserData(ctx, p.UserID, reply.envelope); err != nil {
return nil, err
}
items = append(items, remoteItemsAsAny(reply.envelope)...)
}
return sliceSearchItems(items, p), nil
}
// remoteItemsAsAny 提取远程载荷的 Items 列表(兼容 []any 与 []map 形态)。
func remoteItemsAsAny(envelope map[string]any) []any {
if envelope == nil {
return nil
}
if raw, ok := envelope["Items"].([]any); ok {
return raw
}
if rawMap, ok := envelope["Items"].([]map[string]any); ok {
converted := make([]any, 0, len(rawMap))
for _, m := range rawMap {
converted = append(converted, any(m))
}
return converted
}
return nil
}
func localItemsAsAny(envelope map[string]any) []any {
if envelope == nil {
return nil
+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
}
+170 -9
View File
@@ -21,6 +21,9 @@ func (e *EmbyService) Item(ctx context.Context, mediaID, userID string) (map[str
if mount == nil || acct == nil {
return nil, nil
}
if !EmbyMountLibraryAllowed(e.mediaVisibility(ctx, userID), mount) {
return nil, nil
}
out, err := e.remote.RemoteItem(ctx, mount, acct, remoteID)
if err != nil || out == nil {
return out, err
@@ -28,6 +31,14 @@ func (e *EmbyService) Item(ctx context.Context, mediaID, userID string) (map[str
if err := e.mergeRemoteUserData(ctx, userID, out); err != nil {
return nil, err
}
if favorite, _ := IsUserFavorite(ctx, e.repo, userID, mediaID); favorite {
userData, _ := out["UserData"].(map[string]any)
if userData == nil {
userData = map[string]any{}
out["UserData"] = userData
}
userData["IsFavorite"] = true
}
return out, nil
}
if lib, err := e.repo.Library.FindByID(ctx, mediaID); err != nil {
@@ -87,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 带前缀)直接透传远程。
@@ -101,6 +113,9 @@ func (e *EmbyService) LatestItems(ctx context.Context, userID, parentID string,
if mount == nil || acct == nil {
return nil, nil
}
if !EmbyMountLibraryAllowed(e.mediaVisibility(ctx, userID), mount) {
return nil, nil
}
out, err := e.remote.RemoteLatest(ctx, mount, acct, remoteParent, limit)
if err != nil {
return nil, err
@@ -163,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]
@@ -180,6 +195,137 @@ func (e *EmbyService) ResumeItems(ctx context.Context, userID string, limit int)
return e.resumableItems(ctx, ItemsParams{UserID: userID, Limit: limit})
}
// favoriteItems returns favourited media for Emby clients, including mounted
// remote items stored only in the local favourites table.
func (e *EmbyService) favoriteItems(ctx context.Context, p ItemsParams) (map[string]any, error) {
if p.Limit <= 0 || p.Limit > 500 {
p.Limit = 50
}
if p.StartIndex < 0 {
p.StartIndex = 0
}
if strings.TrimSpace(p.UserID) == "" {
return map[string]any{"Items": []any{}, "TotalRecordCount": int64(0), "StartIndex": p.StartIndex}, nil
}
var favs []model.Favorite
if err := e.repo.DB.WithContext(ctx).
Where("user_id = ?", p.UserID).
Order("created_at desc").
Find(&favs).Error; err != nil {
return nil, err
}
if len(favs) == 0 {
return map[string]any{"Items": []any{}, "TotalRecordCount": int64(0), "StartIndex": p.StartIndex}, nil
}
localIDs := make([]string, 0, len(favs))
for _, fav := range favs {
if !IsEmbyRemoteID(fav.MediaID) {
localIDs = append(localIDs, fav.MediaID)
}
}
byID := map[string]*model.Media{}
if len(localIDs) > 0 {
var medias []model.Media
q := e.repo.DB.WithContext(ctx).Where("id IN ?", localIDs)
q = e.applyUserMediaVisibility(ctx, q, p.UserID)
if err := q.Find(&medias).Error; err != nil {
return nil, err
}
for i := range medias {
byID[medias[i].ID] = &medias[i]
}
}
items := make([]map[string]any, 0, len(favs))
for _, fav := range favs {
if m, ok := byID[fav.MediaID]; ok {
if !favoriteMatchesParent(ctx, e, p.ParentID, fav.MediaID, m.LibraryID, m.SeriesID, nil) {
continue
}
if p.SearchTerm != "" {
needle := strings.ToLower(p.SearchTerm)
if !strings.Contains(strings.ToLower(m.Title), needle) &&
!strings.Contains(strings.ToLower(m.OriginalName), needle) {
continue
}
}
items = append(items, e.itemPayload(ctx, m, true, 0))
continue
}
if e.remote == nil || !IsEmbyRemoteID(fav.MediaID) {
continue
}
mountID, remoteID, _ := DecodeEmbyRemoteID(fav.MediaID)
mount, acct, err := e.remote.ResolveMount(ctx, mountID)
if err != nil || mount == nil || acct == nil {
continue
}
item, err := e.remote.RemoteItem(ctx, mount, acct, remoteID)
if err != nil || item == nil {
continue
}
if !favoriteMatchesParent(ctx, e, p.ParentID, fav.MediaID, "", "", item) {
continue
}
if p.SearchTerm != "" {
needle := strings.ToLower(p.SearchTerm)
name, _ := item["Name"].(string)
orig, _ := item["OriginalTitle"].(string)
if !strings.Contains(strings.ToLower(name), needle) &&
!strings.Contains(strings.ToLower(orig), needle) {
continue
}
}
userData, _ := item["UserData"].(map[string]any)
if userData == nil {
userData = map[string]any{}
item["UserData"] = userData
}
userData["IsFavorite"] = true
items = append(items, item)
}
total := int64(len(items))
if p.StartIndex >= len(items) {
return map[string]any{"Items": []map[string]any{}, "TotalRecordCount": total, "StartIndex": p.StartIndex}, nil
}
end := minInt(p.StartIndex+p.Limit, len(items))
return map[string]any{"Items": items[p.StartIndex:end], "TotalRecordCount": total, "StartIndex": p.StartIndex}, nil
}
func favoriteMatchesParent(ctx context.Context, e *EmbyService, parentID, mediaID, libraryID, seriesID string, remoteItem map[string]any) bool {
if parentID == "" {
return true
}
if libraryID != "" {
if libraryID == parentID || seriesID == parentID {
return true
}
for _, id := range e.mergedLibraryIDs(ctx, parentID) {
if id == libraryID {
return true
}
}
return false
}
if remoteItem == nil {
return false
}
itemParent, _ := remoteItem["ParentId"].(string)
itemSeries, _ := remoteItem["SeriesId"].(string)
if itemParent == parentID || itemSeries == parentID || mediaID == parentID {
return true
}
if !IsEmbyRemoteID(parentID) {
return false
}
wantMountID, _, _ := DecodeEmbyRemoteID(parentID)
gotMountID, _, _ := DecodeEmbyRemoteID(mediaID)
return wantMountID != "" && gotMountID == wantMountID
}
// resumableItems 返回未完成播放进度的媒体(包含本地媒体与挂载的远程媒体),支持分页。
func (e *EmbyService) resumableItems(ctx context.Context, p ItemsParams) (map[string]any, error) {
if p.Limit <= 0 || p.Limit > 100 {
@@ -192,10 +338,12 @@ func (e *EmbyService) resumableItems(ctx context.Context, p ItemsParams) (map[st
return map[string]any{"Items": []any{}, "TotalRecordCount": int64(0), "StartIndex": p.StartIndex}, nil
}
// 历史记录限行:此前无上限全量加载,远程条目多时既拖慢 SQL 也放大
// 下面的远程详情请求量。
var hist []model.PlaybackHistory
if err := e.repo.DB.WithContext(ctx).
Where("user_id = ? AND completed = ? AND position_ms > 0", p.UserID, false).
Order("watched_at desc").Find(&hist).Error; err != nil {
Order("watched_at desc").Limit(200).Find(&hist).Error; err != nil {
return nil, err
}
if len(hist) == 0 {
@@ -221,18 +369,31 @@ func (e *EmbyService) resumableItems(ctx context.Context, p ItemsParams) (map[st
}
}
items := make([]map[string]any, 0, len(hist))
// 分页前置:凑满 StartIndex+Limit 条即停,不再为「总数」逐条发远程
// 详情 GET(此前每条远程记录一次串行 GET,远程慢时请求挂起数分钟)。
// 总数用候选行数(本地过滤后 + 远程候选),对继续观看行的翻页语义
// 足够准确。
needed := p.StartIndex + p.Limit
items := make([]map[string]any, 0, p.Limit)
localTotal, remoteTotal := 0, 0
for _, h := range hist {
if m, ok := byID[h.MediaID]; ok {
if p.ParentID != "" && m.LibraryID != p.ParentID && m.SeriesID != p.ParentID {
continue
}
items = append(items, e.itemPayload(ctx, m, false, h.PositionMs))
localTotal++
if produced := len(items); produced < needed {
items = append(items, e.itemPayload(ctx, m, false, h.PositionMs))
}
continue
}
if e.remote == nil || !IsEmbyRemoteID(h.MediaID) {
continue
}
remoteTotal++
if len(items) >= needed {
continue
}
mountID, remoteID, _ := DecodeEmbyRemoteID(h.MediaID)
mount, acct, err := e.remote.ResolveMount(ctx, mountID)
if err != nil || mount == nil || acct == nil {
@@ -253,7 +414,7 @@ func (e *EmbyService) resumableItems(ctx context.Context, p ItemsParams) (map[st
items = append(items, item)
}
total := int64(len(items))
total := int64(localTotal + remoteTotal)
if p.StartIndex >= len(items) {
return map[string]any{"Items": []map[string]any{}, "TotalRecordCount": total, "StartIndex": p.StartIndex}, nil
}
@@ -270,9 +431,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 != "" {
+1 -1
View File
@@ -63,7 +63,7 @@ func primarySupportedEmbySort(sortBy string, resumeFilter bool) string {
for _, part := range strings.Split(sortBy, ",") {
key := strings.ToLower(strings.TrimSpace(part))
switch key {
case "sortname", "name", "premieredate", "productionyear", "datecreated", "communityrating":
case "sortname", "name", "premieredate", "productionyear", "datecreated", "datelastmediaadded", "datelastcontentadded", "communityrating":
return key
case "dateplayed":
if resumeFilter {
+7 -4
View File
@@ -75,9 +75,9 @@ func (e *EmbyService) mediaItems(ctx context.Context, p ItemsParams) (map[string
orderIncludesDirection = false
case "premieredate", "productionyear":
order = mediaReleaseOrderSQL(desc)
case "datecreated":
order = "media.created_at"
orderIncludesDirection = false
case "datecreated", "datelastmediaadded", "datelastcontentadded":
order = "media.created_at"
orderIncludesDirection = false
case "dateplayed":
order = "resume.watched_at"
orderIncludesDirection = false
@@ -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)))
+70
View File
@@ -0,0 +1,70 @@
package service
import (
"encoding/json"
"testing"
"github.com/truewhile/MeBox/internal/model"
)
func TestViewsHidesDisallowedMountedEmbyLibraries(t *testing.T) {
svc := newTestEmbyService(t)
if err := svc.repo.DB.AutoMigrate(&model.EmbyMount{}); err != nil {
t.Fatal(err)
}
local := model.Library{Name: "Local", Path: "/media/local", Type: "movie", Enabled: true}
if err := svc.repo.Library.Create(t.Context(), &local); err != nil {
t.Fatal(err)
}
mount := &model.EmbyMount{
AccountID: "acct-1",
RemoteViewID: "view-1",
RemoteViewName: "Remote Movies",
Enabled: true,
}
if err := svc.repo.EmbyMount.Create(t.Context(), mount); err != nil {
t.Fatal(err)
}
remoteID := EncodeEmbyRemoteID(mount.ID, mount.RemoteViewID)
user := &model.User{Username: "viewer", PasswordHash: "hash", Role: "user"}
allowed, err := json.Marshal([]string{local.ID})
if err != nil {
t.Fatal(err)
}
user.AllowedLibraryIDs = string(allowed)
if err := svc.repo.User.Create(t.Context(), user); err != nil {
t.Fatal(err)
}
// Without a live remote service, remoteViews is empty; assert helper ACL instead
// and that local Views still honor the allow-list.
views, err := svc.Views(t.Context(), user.ID)
if err != nil {
t.Fatalf("Views: %v", err)
}
items := views["Items"].([]map[string]any)
for _, item := range items {
if id, _ := item["Id"].(string); id == remoteID {
t.Fatalf("disallowed remote library should not appear in Views: %#v", item)
}
}
if !EmbyMountLibraryAllowed(MediaVisibility{AllowedLibraryIDs: []string{local.ID, remoteID}}, mount) {
t.Fatal("expected remote library allowed when listed")
}
if EmbyMountLibraryAllowed(MediaVisibility{AllowedLibraryIDs: []string{local.ID}}, mount) {
t.Fatal("expected remote library denied when not listed")
}
}
func TestLibraryIDAllowed(t *testing.T) {
if !LibraryIDAllowed(MediaVisibility{}, "any") {
t.Fatal("empty allow-list should allow all")
}
if LibraryIDAllowed(MediaVisibility{AllowedLibraryIDs: []string{"a"}}, "b") {
t.Fatal("missing id should be denied")
}
if !LibraryIDAllowed(MediaVisibility{AllowedLibraryIDs: []string{"a", "b"}}, "b") {
t.Fatal("listed id should be allowed")
}
}
+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
}
+3
View File
@@ -24,6 +24,9 @@ func (e *EmbyService) PlaybackInfo(ctx context.Context, mediaID, userID string)
if err != nil {
return nil, ErrEmbyRemoteNotFound
}
if !EmbyMountLibraryAllowed(e.mediaVisibility(ctx, userID), mount) {
return nil, ErrEmbyRemoteNotFound
}
out, err := e.remote.RemotePlaybackInfo(ctx, mount, acct, remoteID, userID)
if err != nil {
return nil, err
+124 -24
View File
@@ -26,6 +26,7 @@ import (
"regexp"
"strconv"
"strings"
"sync"
"time"
"go.uber.org/zap"
@@ -73,6 +74,7 @@ type EmbyRemoteService struct {
repo *repository.Container
crypto *CryptoService
http *http.Client
stream *http.Client // 流式代理专用(视频/字幕),无整体 Timeout
cache *RuntimeCacheService
}
@@ -87,6 +89,12 @@ func NewEmbyRemoteService(cfg *config.Config, log *zap.Logger, repo *repository.
Timeout: embyRemoteHTTPTimeout,
Transport: &embyRemoteTransport{base: http.DefaultTransport},
},
// 流式代理必须用无整体 Timeout 的 client:http.Client.Timeout
// 覆盖整个响应体读取过程,15s 的常规超时会让代理播放播到
// 15 秒整被掐断。生命周期由请求 ctx 控制。
stream: &http.Client{
Transport: &embyRemoteTransport{base: http.DefaultTransport},
},
}
}
@@ -316,6 +324,65 @@ func (r *EmbyRemoteService) AutoSeedMounts(ctx context.Context) {
}
}
// remoteConfigWithToken 解密账号配置并确保已有可用凭据(首次请求自动认证并
// 回写 token 与 remote_user_id,等价于管理端「测试连接」),保证后续构造的
// /Users/{userId} 路径使用远程真实用户 GUID,而不是未认证兜底的 "0"。
func (r *EmbyRemoteService) remoteConfigWithToken(ctx context.Context, acct *model.StrmAccount) (*EmbyRemoteConfig, error) {
cfg, err := r.configOf(acct)
if err != nil {
return nil, err
}
if err := r.ensureToken(ctx, acct, cfg); err != nil {
return nil, err
}
// api_key 直连(未配用户名/密码)的账号认证步骤不会回填用户 ID;此时用
// api_key 拉一次用户列表取真实用户 ID 并回写,避免 /Users/{uid} 请求路径
// 落回兜底 "0" 被远程 Emby 拒绝(Unrecognized Guid format)。
if strings.TrimSpace(cfg.RemoteUserID) == "" {
r.resolveRemoteUserID(ctx, acct, cfg)
}
return cfg, nil
}
// resolveRemoteUserID 用已有 api_key 拉远程用户列表,把首个用户 ID 回写账号
// 配置(取不到时静默跳过,保持兜底行为不变)。
func (r *EmbyRemoteService) resolveRemoteUserID(ctx context.Context, acct *model.StrmAccount, cfg *EmbyRemoteConfig) {
if acct == nil || cfg == nil || strings.TrimSpace(cfg.Token) == "" || strings.TrimSpace(cfg.RemoteUserID) != "" {
return
}
q := url.Values{"api_key": {cfg.Token}}
var users []map[string]any
if err := r.doGet(ctx, acct, cfg, "/Users", q, &users); err != nil || len(users) == 0 {
return
}
uid := strings.TrimSpace(remoteItemString(users[0], "Id"))
if uid == "" {
return
}
cfg.RemoteUserID = uid
_ = r.updateAccountConfig(ctx, acct, func(raw map[string]string) {
raw["remote_user_id"] = uid
})
}
// CleanupOrphanMounts 清理账号已删除的残留挂载(老版本删除账号未级联),
// 避免挂载计数/列表出现永远清不掉的孤儿数据。
func (r *EmbyRemoteService) CleanupOrphanMounts(ctx context.Context) {
n, err := r.repo.EmbyMount.DeleteOrphans(ctx)
if err != nil {
if r.log != nil {
r.log.Warn("cleanup orphan emby mounts failed", zap.Error(err))
}
return
}
if n > 0 {
r.invalidateRemoteMediaCache(ctx)
if r.log != nil {
r.log.Info("cleaned up orphan emby mounts", zap.Int64("mounts", n))
}
}
}
// configOf 解密账号配置。
func (r *EmbyRemoteService) configOf(acct *model.StrmAccount) (*EmbyRemoteConfig, error) {
raw := map[string]string{}
@@ -430,19 +497,28 @@ func (r *EmbyRemoteService) ensureTokenOnLine(ctx context.Context, acct *model.S
return nil
}
// persistToken 把认证得到的 token / user id 加密写回账号配置(下次请求免登录)。
func (r *EmbyRemoteService) persistToken(ctx context.Context, acct *model.StrmAccount, cfg *EmbyRemoteConfig) error {
if acct == nil {
// acctCfgMu 序列化对账号 Config 的读-改-写。并发请求若各自基于请求开始
// 时的快照做整包覆盖,会互相丢失更新(刚持久化的 token / active_line 被
// 旧快照覆盖回去)。
var acctCfgMu sync.Mutex
// updateAccountConfig 在互斥下重读账号最新 Config,应用 mutate 后写回,
// 并同步调用方持有的 acct 快照。
func (r *EmbyRemoteService) updateAccountConfig(ctx context.Context, acct *model.StrmAccount, mutate func(raw map[string]string)) error {
if acct == nil || r.repo == nil {
return nil
}
acctCfgMu.Lock()
defer acctCfgMu.Unlock()
raw := map[string]string{}
if fresh, err := r.repo.StrmAccount.FindByID(ctx, acct.ID); err == nil && fresh != nil {
acct.Config = fresh.Config // 以 DB 最新值为基线,避免覆盖并发写入
}
if strings.TrimSpace(acct.Config) != "" {
_ = json.Unmarshal([]byte(acct.Config), &raw)
}
raw["api_key"] = r.crypto.Encrypt(cfg.Token)
raw["remote_user_id"] = cfg.RemoteUserID
if strings.TrimSpace(raw["username"]) == "" {
raw["username"] = cfg.Username
if mutate != nil {
mutate(raw)
}
data, err := json.Marshal(raw)
if err != nil {
@@ -452,6 +528,20 @@ func (r *EmbyRemoteService) persistToken(ctx context.Context, acct *model.StrmAc
return r.repo.StrmAccount.Update(ctx, acct)
}
// persistToken 把认证得到的 token / user id 加密写回账号配置(下次请求免登录)。
func (r *EmbyRemoteService) persistToken(ctx context.Context, acct *model.StrmAccount, cfg *EmbyRemoteConfig) error {
if acct == nil {
return nil
}
return r.updateAccountConfig(ctx, acct, func(raw map[string]string) {
raw["api_key"] = r.crypto.Encrypt(cfg.Token)
raw["remote_user_id"] = cfg.RemoteUserID
if strings.TrimSpace(raw["username"]) == "" {
raw["username"] = cfg.Username
}
})
}
// doGet 向远程 Emby 发起带 api_key 的 GET,把响应 JSON 解码到 out。
// 401 时自动重认证一次再重试(凭据过期场景)。连接失败时按线路优先级自动切换。
func (r *EmbyRemoteService) doGet(ctx context.Context, acct *model.StrmAccount, cfg *EmbyRemoteConfig, path string, q url.Values, out any) error {
@@ -469,6 +559,10 @@ func (r *EmbyRemoteService) doGet(ctx context.Context, acct *model.StrmAccount,
}
}
if lastErr != nil {
if r.log != nil && acct != nil {
r.log.Warn("remote emby request failed",
zap.String("account", acct.Name), zap.String("path", path), zap.Error(lastErr))
}
return lastErr
}
return errors.New("远程 Emby 请求失败")
@@ -498,22 +592,28 @@ func (r *EmbyRemoteService) doGetOnLine(ctx context.Context, acct *model.StrmAcc
if err != nil {
return fmt.Errorf("请求远程 Emby 失败: %w", err)
}
data, readErr := io.ReadAll(io.LimitReader(resp.Body, 8<<20))
// 读 8MB+1 以区分"刚好 8MB"与"被截断":截断的 JSON 会让
// Unmarshal 报 unexpected end,难以定位;这里显式报错。
data, readErr := io.ReadAll(io.LimitReader(resp.Body, (8<<20)+1))
resp.Body.Close()
if readErr != nil {
return readErr
}
if len(data) > 8<<20 {
return fmt.Errorf("远程 Emby 响应超过 8MB 上限(路径 %s):请减小分页或 Fields 字段", path)
}
if resp.StatusCode == http.StatusUnauthorized && attempt == 0 {
// 401:只清当前线路的内存 token 并立即重认证;不在此时删除
// DB 里的 api_key——①外层还会按线路故障转移(其他线路可能
// 存有自己的 token);②纯 api_key 账号删除后无法再认证,一次
// 线路误报就会把账号“砖化”。重认证成功后 persistToken 会用
// 新 token 覆盖 api_key。
cfg.Token = ""
master.Token = ""
if acct != nil {
raw := map[string]string{}
_ = json.Unmarshal([]byte(acct.Config), &raw)
delete(raw, "api_key")
enc, _ := json.Marshal(raw)
acct.Config = string(enc)
_ = r.repo.StrmAccount.Update(ctx, acct)
if err := r.ensureTokenOnLine(ctx, acct, cfg); err != nil {
return fmt.Errorf("认证重试失败: %w", err)
}
master.Token = cfg.Token
master.RemoteUserID = cfg.RemoteUserID
continue
}
if resp.StatusCode >= 300 {
@@ -553,7 +653,7 @@ func (r *EmbyRemoteService) ProxyPlayOf(acct *model.StrmAccount) (bool, error) {
// RemoteViews 拉取远程媒体库(View)列表,返回远程原始 view map(未重写)。
func (r *EmbyRemoteService) RemoteViews(ctx context.Context, acct *model.StrmAccount) ([]map[string]any, error) {
cfg, err := r.configOf(acct)
cfg, err := r.remoteConfigWithToken(ctx, acct)
if err != nil {
return nil, err
}
@@ -588,7 +688,7 @@ func (r *EmbyRemoteService) remoteUserID(cfg *EmbyRemoteConfig) string {
// RemoteItems 向远程 Emby 转发 /Items 浏览/搜索请求,返回重写后的响应载荷。
// p 的分页/排序/过滤参数原样转发,分页语义完全由远程承接。
func (r *EmbyRemoteService) RemoteItems(ctx context.Context, mount *model.EmbyMount, acct *model.StrmAccount, p ItemsParams) (map[string]any, error) {
cfg, err := r.configOf(acct)
cfg, err := r.remoteConfigWithToken(ctx, acct)
if err != nil {
return nil, err
}
@@ -633,7 +733,7 @@ func (r *EmbyRemoteService) RemoteItems(ctx context.Context, mount *model.EmbyMo
// RemoteSearchMount 对单个挂载的媒体库执行全局搜索(ParentId=挂载的远程库,
// Recursive 返回库内全部命中),结果归属明确可直接伪装。
func (r *EmbyRemoteService) RemoteSearchMount(ctx context.Context, mount *model.EmbyMount, acct *model.StrmAccount, p ItemsParams) (map[string]any, error) {
cfg, err := r.configOf(acct)
cfg, err := r.remoteConfigWithToken(ctx, acct)
if err != nil {
return nil, err
}
@@ -666,7 +766,7 @@ func (r *EmbyRemoteService) RemoteSearchMount(ctx context.Context, mount *model.
// RemoteItem 拉取远程单条目详情(含响应的重写)。
func (r *EmbyRemoteService) RemoteItem(ctx context.Context, mount *model.EmbyMount, acct *model.StrmAccount, remoteID string) (map[string]any, error) {
cfg, err := r.configOf(acct)
cfg, err := r.remoteConfigWithToken(ctx, acct)
if err != nil {
return nil, err
}
@@ -681,7 +781,7 @@ func (r *EmbyRemoteService) RemoteItem(ctx context.Context, mount *model.EmbyMou
// RemoteLatest 拉取远程「最近添加」(用于 /Items/Latest 聚合)。
func (r *EmbyRemoteService) RemoteLatest(ctx context.Context, mount *model.EmbyMount, acct *model.StrmAccount, parentID string, limit int) ([]map[string]any, error) {
cfg, err := r.configOf(acct)
cfg, err := r.remoteConfigWithToken(ctx, acct)
if err != nil {
return nil, err
}
@@ -702,7 +802,7 @@ func (r *EmbyRemoteService) RemoteLatest(ctx context.Context, mount *model.EmbyM
// 播放 URL:不代理=指向远程绝对地址(播放字节不过 MeBox);代理=指向 MeBox
// 本地 /Videos/{encodedID} 端点(由 ProxyVideoStream 反代)。
func (r *EmbyRemoteService) RemotePlaybackInfo(ctx context.Context, mount *model.EmbyMount, acct *model.StrmAccount, remoteID, userID string) (map[string]any, error) {
cfg, err := r.configOf(acct)
cfg, err := r.remoteConfigWithToken(ctx, acct)
if err != nil {
return nil, err
}
@@ -888,7 +988,7 @@ func (r *EmbyRemoteService) proxyVideoStreamOnLine(ctx context.Context, w http.R
if rangeHeader := req.Header.Get("Range"); rangeHeader != "" {
upstream.Header.Set("Range", rangeHeader)
}
resp, err := r.http.Do(upstream)
resp, err := r.stream.Do(upstream)
if err != nil {
return fmt.Errorf("连接远程 Emby 视频流失败: %w", err)
}
@@ -959,7 +1059,7 @@ func (r *EmbyRemoteService) proxySubtitleOnLine(ctx context.Context, w http.Resp
return err
}
upstream.Header.Set("X-Emby-Token", cfg.Token)
resp, err := r.http.Do(upstream)
resp, err := r.stream.Do(upstream)
if err != nil {
return fmt.Errorf("连接远程 Emby 字幕流失败: %w", err)
}
+9 -15
View File
@@ -124,10 +124,11 @@ func isEmbyLineFailoverError(err error) bool {
return false
}
msg := strings.ToLower(err.Error())
// 注意:认证类错误(重认证失败 / 缺少凭据)不在此排除——401 后清空
// 内存 token 重认证失败时应继续按线路故障转移,其他线路可能存有
// 自己的 token。仅“登录失败”(密码错误)是账号级问题,无需换线。
if strings.Contains(msg, "登录失败") ||
strings.Contains(msg, "未返回 accesstoken") ||
strings.Contains(msg, "缺少 emby 凭据") ||
strings.Contains(msg, "认证重试失败") {
strings.Contains(msg, "未返回 accesstoken") {
return false
}
var urlErr *url.Error
@@ -147,20 +148,13 @@ func (r *EmbyRemoteService) persistActiveLine(ctx context.Context, acct *model.S
if acct == nil || cfg == nil || lineIndex < 0 || lineIndex >= len(cfg.Lines) {
return nil
}
raw := map[string]string{}
if strings.TrimSpace(acct.Config) != "" {
_ = json.Unmarshal([]byte(acct.Config), &raw)
}
raw["active_line"] = strconv.Itoa(lineIndex)
raw["url"] = cfg.Lines[lineIndex].URL
data, err := json.Marshal(raw)
if err != nil {
return err
}
acct.Config = string(data)
err := r.updateAccountConfig(ctx, acct, func(raw map[string]string) {
raw["active_line"] = strconv.Itoa(lineIndex)
raw["url"] = cfg.Lines[lineIndex].URL
})
cfg.ActiveLine = lineIndex
cfg.BaseURL = normalizeEmbyRemoteURL(cfg.Lines[lineIndex].URL)
return r.repo.StrmAccount.Update(ctx, acct)
return err
}
func (r *EmbyRemoteService) adoptWorkingLine(ctx context.Context, acct *model.StrmAccount, cfg *EmbyRemoteConfig, lineIndex int) {
+158 -92
View File
@@ -18,6 +18,7 @@ import (
"go.uber.org/zap"
"github.com/truewhile/MeBox/internal/helper"
"github.com/truewhile/MeBox/internal/model"
)
@@ -57,7 +58,7 @@ func (r *EmbyRemoteService) RemoteLibraries(ctx context.Context) ([]RemoteLibrar
acctData[m.AccountID] = nil
continue
}
cfg, cfgErr := r.configOf(acct)
cfg, cfgErr := r.remoteConfigWithToken(ctx, acct)
if cfgErr != nil {
acctData[m.AccountID] = nil
continue
@@ -198,6 +199,9 @@ func (r *EmbyRemoteService) MapRemoteItemToMedia(ctx context.Context, mount *mod
media.CreatedAt = date
media.UpdatedAt = date
}
if date, ok := parseEmbyRemoteDate(remoteItemString(item, "DateLastMediaAdded")); ok {
media.UpdatedAt = date
}
// 只有远程明确存在图片标签才下发图片 URL。
if remoteItemHasImageTag(item, "Primary") {
media.PosterURL = r.remoteItemImageURL(cfg, remoteID, "Primary")
@@ -301,28 +305,28 @@ func (r *EmbyRemoteService) MapRemoteItemToMedia(ctx context.Context, mount *mod
media.SeasonNum = 0
media.EpisodeNum = 0
}
if mount != nil && strings.TrimSpace(mount.RemoteViewID) != "" {
libID := EncodeEmbyRemoteID(mount.ID, mount.RemoteViewID)
media.DisplayLibraryID = libID
media.LibraryID = libID
libName := strings.TrimSpace(mount.Name)
if libName == "" {
libName = strings.TrimSpace(mount.RemoteViewName)
}
if libName == "" && acct != nil {
libName = acct.Name
} else if acct != nil && acct.Name != "" && !strings.Contains(libName, acct.Name) {
libName = acct.Name + " · " + libName
}
media.LibraryName = libName
media.DisplayLibraryName = libName
if mount != nil && strings.TrimSpace(mount.RemoteViewID) != "" {
libID := EncodeEmbyRemoteID(mount.ID, mount.RemoteViewID)
media.DisplayLibraryID = libID
media.LibraryID = libID
libName := strings.TrimSpace(mount.Name)
if libName == "" {
libName = strings.TrimSpace(mount.RemoteViewName)
}
if libName == "" && acct != nil {
libName = acct.Name
} else if acct != nil && acct.Name != "" && !strings.Contains(libName, acct.Name) {
libName = acct.Name + " · " + libName
}
media.LibraryName = libName
media.DisplayLibraryName = libName
}
return media
}
// RemoteLibraryMedia 拉远程库直属条目(电影库=Movie,剧集库=Series),映射分页。
func (r *EmbyRemoteService) RemoteLibraryMedia(ctx context.Context, mount *model.EmbyMount, acct *model.StrmAccount, remoteViewID string, itemTypes string, offset, limit int) ([]model.Media, int64, error) {
cfg, err := r.configOf(acct)
cfg, err := r.remoteConfigWithToken(ctx, acct)
if err != nil {
return nil, 0, err
}
@@ -367,37 +371,52 @@ func (r *EmbyRemoteService) RemoteLibraryMedia(ctx context.Context, mount *model
// RemoteMediaDetail 拉远程单条目映射为 Media(网页详情页)。
func (r *EmbyRemoteService) RemoteMediaDetail(ctx context.Context, mount *model.EmbyMount, acct *model.StrmAccount, remoteID string) (*model.Media, error) {
cfg, err := r.configOf(acct)
m, _, err := r.remoteMediaDetailRaw(ctx, mount, acct, remoteID)
return m, err
}
// remoteMediaDetailRaw 拉取远程条目详情,同时返回原始载荷(ID 已伪装),
// 供调用方免二次请求读取 Type / SeriesId 等字段。
func (r *EmbyRemoteService) remoteMediaDetailRaw(ctx context.Context, mount *model.EmbyMount, acct *model.StrmAccount, remoteID string) (*model.Media, map[string]any, error) {
cfg, err := r.remoteConfigWithToken(ctx, acct)
if err != nil {
return nil, err
return nil, nil, err
}
path := "/Users/" + url.PathEscape(r.remoteUserID(cfg)) + "/Items/" + url.PathEscape(remoteID)
path += "?Fields=Overview,Genres,ProviderIds,People,Studios,Path,MediaStreams,MediaSources,DateCreated,PremiereDate,ProductionYear,CommunityRating,CriticRating"
var out map[string]any
if err := r.doGet(ctx, acct, cfg, path, nil, &out); err != nil {
return nil, err
return nil, nil, err
}
RewriteEmbyRemoteIDs(out, mount.ID)
m := r.MapRemoteItemToMedia(ctx, mount, acct, cfg, out)
return &m, nil
return &m, out, nil
}
// RemoteEpisodes 拉远程条目下的集列表(Series/Season/Folder→子集;Episode→同系列;
// Movie→自身单条),按季/集排序,与本地 ListMediaEpisodes 行为一致。
func (r *EmbyRemoteService) RemoteEpisodes(ctx context.Context, mount *model.EmbyMount, acct *model.StrmAccount, remoteID string) ([]model.Media, error) {
detail, err := r.RemoteMediaDetail(ctx, mount, acct, remoteID)
detail, rawDetail, err := r.remoteMediaDetailRaw(ctx, mount, acct, remoteID)
if err != nil {
return nil, err
}
// 用远程详情载荷精判类型(Episode→同系列;Series/Season/Folder→子集;Movie→单条)。
itemType := r.remoteItemType(ctx, acct, remoteID)
// Type/SeriesId 都在详情载荷里现成可用,不再为判定类型/系列额外发起
// 两次重复的远程全量 GET(远程慢时页面延迟直接×3)。
itemType := remoteItemString(rawDetail, "Type")
if itemType == "" {
itemType = remoteItemTypeOf(detail)
}
var parentID string
switch itemType {
case "Episode":
parentID = r.remoteItemSeriesID(ctx, acct, remoteID)
parentID = remoteItemString(rawDetail, "SeriesId")
if _, rid, ok := DecodeEmbyRemoteID(parentID); ok {
parentID = rid // 载荷 ID 已伪装,远程查询需要原始 ID
}
if parentID == "" {
parentID = r.remoteItemSeriesID(ctx, acct, remoteID)
}
if parentID == "" {
parentID = remoteID
}
@@ -423,7 +442,7 @@ func (r *EmbyRemoteService) RemoteEpisodes(ctx context.Context, mount *model.Emb
}
func (r *EmbyRemoteService) remoteEpisodesOf(ctx context.Context, mount *model.EmbyMount, acct *model.StrmAccount, parentID string) ([]model.Media, int64, error) {
cfg, err := r.configOf(acct)
cfg, err := r.remoteConfigWithToken(ctx, acct)
if err != nil {
return nil, 0, err
}
@@ -431,28 +450,47 @@ func (r *EmbyRemoteService) remoteEpisodesOf(ctx context.Context, mount *model.E
q.Set("ParentId", parentID)
q.Set("IncludeItemTypes", "Episode")
q.Set("Recursive", "true")
q.Set("StartIndex", "0")
q.Set("Limit", "500")
q.Set("Fields", "Overview,Genres,ProviderIds,Path,SeriesPrimaryImage,MediaStreams,MediaSources,DateCreated,PremiereDate,ProductionYear,CommunityRating,CriticRating")
var body struct {
Items []map[string]any `json:"Items"`
TotalRecordCount int64 `json:"TotalRecordCount"`
items := make([]model.Media, 0, 64)
total := int64(0)
// 每页 200 循环拉全:MediaStreams/MediaSources 重字段下单页 500 条
// 已贴近 8MB 截断上限;单次大页超限会静默解析失败。
const episodePageSize = 200
for startIndex := 0; ; startIndex += episodePageSize {
q.Set("StartIndex", strconv.Itoa(startIndex))
q.Set("Limit", strconv.Itoa(episodePageSize))
var body struct {
Items []map[string]any `json:"Items"`
TotalRecordCount int64 `json:"TotalRecordCount"`
}
if err := r.doGet(ctx, acct, cfg, "/Users/"+url.PathEscape(r.remoteUserID(cfg))+"/Items", q, &body); err != nil {
return nil, 0, err
}
total = body.TotalRecordCount
if len(body.Items) == 0 {
break
}
for _, it := range body.Items {
RewriteEmbyRemoteIDs(it, mount.ID)
m := r.MapRemoteItemToMedia(ctx, mount, acct, cfg, it)
items = append(items, m)
}
if len(body.Items) < episodePageSize {
break
}
}
if err := r.doGet(ctx, acct, cfg, "/Users/"+url.PathEscape(r.remoteUserID(cfg))+"/Items", q, &body); err != nil {
return nil, 0, err
}
items := make([]model.Media, 0, len(body.Items))
for _, it := range body.Items {
RewriteEmbyRemoteIDs(it, mount.ID)
m := r.MapRemoteItemToMedia(ctx, mount, acct, cfg, it)
items = append(items, m)
}
return items, body.TotalRecordCount, nil
return items, total, nil
}
// RemoteSeriesCards 远程剧集库的系列卡片(ChildCount 作为集数)。
//
// 远程 Emby 的 Series DTO 不会返回 DateLastMediaAdded 字段(即使请求 Fields
// 也缺失),但其服务端排序支持 SortBy=DateLastContentAdded——即客户端"上次
// 添加集日期"排序。因此这里直接按该键倒序分页拉全量,返回的卡片顺序与对方
// Emby 客户端选择"上次添加集日期"完全一致;LastAddedAt 在远程提供字段时
// 才填充,否则保持 nil(前端对无该值的卡片维持服务器顺序,不再回退加入日期)。
func (r *EmbyRemoteService) RemoteSeriesCards(ctx context.Context, mount *model.EmbyMount, acct *model.StrmAccount, remoteViewID string) ([]SeriesCard, error) {
cfg, err := r.configOf(acct)
cfg, err := r.remoteConfigWithToken(ctx, acct)
if err != nil {
return nil, err
}
@@ -465,28 +503,46 @@ func (r *EmbyRemoteService) RemoteSeriesCards(ctx context.Context, mount *model.
q.Set("ParentId", remoteViewID)
q.Set("IncludeItemTypes", "Series")
q.Set("Recursive", "false")
q.Set("StartIndex", "0")
q.Set("Limit", "1000")
q.Set("Fields", "Overview,Genres,ProviderIds,Path,RecursiveItemCount,SeriesPrimaryImage,DateCreated,PremiereDate,ProductionYear,CommunityRating,CriticRating")
q.Set("SortBy", "DateLastContentAdded")
q.Set("SortOrder", "Descending")
// 每页 200:Fields 带全量重字段(Overview/MediaStreams 等)时单页 1000
// 条的载荷会超过 doGet 的 8MB 截断上限,JSON 被静默截断直接解析失败。
q.Set("Limit", "200")
q.Set("Fields", "Overview,Genres,ProviderIds,Path,RecursiveItemCount,SeriesPrimaryImage,DateCreated,DateLastMediaAdded,PremiereDate,ProductionYear,CommunityRating,CriticRating")
var body struct {
Items []map[string]any `json:"Items"`
Items []map[string]any `json:"Items"`
TotalRecordCount int64 `json:"TotalRecordCount"`
}
if err := r.doGet(ctx, acct, cfg, "/Users/"+url.PathEscape(r.remoteUserID(cfg))+"/Items", q, &body); err != nil {
return nil, err
}
cards := make([]SeriesCard, 0, len(body.Items))
for _, it := range body.Items {
RewriteEmbyRemoteIDs(it, mount.ID)
m := r.MapRemoteItemToMedia(ctx, mount, acct, cfg, it)
// 集数优先用递归条目数(ChildCount 只算直属 Season 文件夹数)。
count := remoteItemInt(it, "RecursiveItemCount")
if count == 0 {
count = remoteItemInt(it, "ChildCount")
cards := make([]SeriesCard, 0)
for startIndex := 0; ; startIndex += 200 {
q.Set("StartIndex", strconv.Itoa(startIndex))
body.Items = nil
if err := r.doGet(ctx, acct, cfg, "/Users/"+url.PathEscape(r.remoteUserID(cfg))+"/Items", q, &body); err != nil {
return nil, err
}
if count == 0 {
count = 1
if len(body.Items) == 0 {
break
}
for _, it := range body.Items {
RewriteEmbyRemoteIDs(it, mount.ID)
m := r.MapRemoteItemToMedia(ctx, mount, acct, cfg, it)
// 集数优先用递归条目数(ChildCount 只算直属 Season 文件夹数)。
count := remoteItemInt(it, "RecursiveItemCount")
if count == 0 {
count = remoteItemInt(it, "ChildCount")
}
if count == 0 {
count = 1
}
var lastAdded *time.Time
if date, ok := parseEmbyRemoteDate(remoteItemString(it, "DateLastMediaAdded")); ok {
lastAdded = &date
}
cards = append(cards, SeriesCard{Key: m.ID, Rep: m, LinkMedia: m, Count: count, LastAddedAt: lastAdded})
}
if int64(len(cards)) >= body.TotalRecordCount || len(body.Items) < 1000 {
break
}
cards = append(cards, SeriesCard{Key: m.ID, Rep: m, LinkMedia: m, Count: count})
}
if r.cache != nil {
r.cache.SetJSON(ctx, cacheKey, cards, r.remoteMediaCacheTTL())
@@ -496,7 +552,7 @@ func (r *EmbyRemoteService) RemoteSeriesCards(ctx context.Context, mount *model.
// RemoteLatestCards 远程库最新条目(首页预览卡片),映射 SeriesCard。
func (r *EmbyRemoteService) RemoteLatestCards(ctx context.Context, mount *model.EmbyMount, acct *model.StrmAccount, remoteViewID string, limit int) ([]SeriesCard, error) {
cfg, err := r.configOf(acct)
cfg, err := r.remoteConfigWithToken(ctx, acct)
if err != nil {
return nil, err
}
@@ -512,13 +568,21 @@ func (r *EmbyRemoteService) RemoteLatestCards(ctx context.Context, mount *model.
cards := make([]SeriesCard, 0, len(items))
for _, it := range items {
m := r.MapRemoteItemToMedia(ctx, mount, acct, cfg, it)
cards = append(cards, SeriesCard{Key: m.ID, Rep: m, LinkMedia: m, Count: 0})
}
if r.cache != nil {
r.cache.SetJSON(ctx, cacheKey, cards, r.remoteMediaCacheTTL())
var lastAdded *time.Time
if !m.UpdatedAt.IsZero() {
t := m.UpdatedAt
lastAdded = &t
} else if !m.CreatedAt.IsZero() {
t := m.CreatedAt
lastAdded = &t
}
return cards, nil
cards = append(cards, SeriesCard{Key: m.ID, Rep: m, LinkMedia: m, Count: 0, LastAddedAt: lastAdded})
}
if r.cache != nil {
r.cache.SetJSON(ctx, cacheKey, cards, r.remoteMediaCacheTTL())
}
return cards, nil
}
// RemoteSearchMedia 在全部启用的挂载库中并发搜索影视条目(Movie,Series),
// 并将远程结果映射为 model.Media。遵循当前用户的 MediaVisibility 权限规则。
@@ -579,7 +643,7 @@ func (r *EmbyRemoteService) RemoteSearchMedia(ctx context.Context, query string,
if acct == nil {
continue
}
cfg, cfgErr := r.configOf(acct)
cfg, cfgErr := r.remoteConfigWithToken(ctx, acct)
if cfgErr != nil {
continue
}
@@ -609,32 +673,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,DateLastMediaAdded,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()
@@ -661,7 +727,7 @@ func (r *EmbyRemoteService) WebStreamURL(ctx context.Context, acct *model.StrmAc
// remoteItemType 轻量查询远程条目 Type(避免依赖映射载荷)。
func (r *EmbyRemoteService) remoteItemType(ctx context.Context, acct *model.StrmAccount, remoteID string) string {
cfg, err := r.configOf(acct)
cfg, err := r.remoteConfigWithToken(ctx, acct)
if err != nil {
return ""
}
@@ -674,7 +740,7 @@ func (r *EmbyRemoteService) remoteItemType(ctx context.Context, acct *model.Strm
// remoteItemSeriesID 轻量查询 Episode 的 SeriesId。
func (r *EmbyRemoteService) remoteItemSeriesID(ctx context.Context, acct *model.StrmAccount, remoteID string) string {
cfg, err := r.configOf(acct)
cfg, err := r.remoteConfigWithToken(ctx, acct)
if err != nil {
return ""
}
+222 -3
View File
@@ -5,6 +5,8 @@ import (
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"sync/atomic"
"testing"
"time"
@@ -68,10 +70,227 @@ func TestMapRemoteItemToMediaCriticRatingFallback(t *testing.T) {
if media.Rating != 9.2 {
t.Fatalf("Rating = %f, want 9.2 from CriticRating", media.Rating)
}
if media.Year != 2022 {
t.Fatalf("Year = %d, want 2022 from PremiereDate", media.Year)
}
if media.Year != 2022 {
t.Fatalf("Year = %d, want 2022 from PremiereDate", media.Year)
}
}
func TestMapRemoteItemToMediaDateLastMediaAdded(t *testing.T) {
svc := &EmbyRemoteService{}
mount := &model.EmbyMount{Base: model.Base{ID: "mount-1"}}
acct := &model.StrmAccount{Base: model.Base{ID: "acct-1"}}
cfg := &EmbyRemoteConfig{BaseURL: "http://localhost:8096"}
item := map[string]any{
"Id": "series-1",
"Name": "测试剧集",
"DateCreated": "2023-01-01T00:00:00.0000000Z",
"DateLastMediaAdded": "2024-05-20T10:00:00.0000000Z",
}
media := svc.MapRemoteItemToMedia(context.Background(), mount, acct, cfg, item)
expectedCreated, _ := time.Parse(time.RFC3339, "2023-01-01T00:00:00Z")
expectedLastAdded, _ := time.Parse(time.RFC3339, "2024-05-20T10:00:00Z")
if !media.CreatedAt.Equal(expectedCreated) {
t.Fatalf("CreatedAt = %v, want %v", media.CreatedAt, expectedCreated)
}
if !media.UpdatedAt.Equal(expectedLastAdded) {
t.Fatalf("UpdatedAt = %v, want %v", media.UpdatedAt, expectedLastAdded)
}
}
func TestRemoteSeriesCardsAutoAuthOnFirstBrowse(t *testing.T) {
// 模拟远程 Emby:未认证兜底用户 ID "0" 被拒绝(与真实服务器一致),
// 只有认证拿到的真实用户 GUID 才能浏览。
var zeroUserHits atomic.Int32
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method == http.MethodPost && strings.HasSuffix(r.URL.Path, "/AuthenticateByName") {
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]any{
"AccessToken": "real-token",
"User": map[string]any{"Id": "real-user-guid"},
})
return
}
if r.URL.Path == "/emby/Users/0/Items" {
zeroUserHits.Add(1)
w.WriteHeader(http.StatusInternalServerError)
_, _ = w.Write([]byte("Unrecognized Guid format."))
return
}
if r.URL.Path == "/emby/Users/real-user-guid/Items" {
q := r.URL.Query()
if q.Get("ParentId") != "view-1" || q.Get("IncludeItemTypes") != "Series" {
w.WriteHeader(http.StatusBadRequest)
return
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]any{
"TotalRecordCount": 1,
"Items": []map[string]any{
{
"Id": "series-100",
"Name": "测试剧",
"Type": "Series",
"ProductionYear": 2024,
"RecursiveItemCount": 12,
"ChildCount": 2,
},
},
})
return
}
w.WriteHeader(http.StatusNotFound)
}))
defer server.Close()
db := newServiceTestDB(t, &model.StrmAccount{}, &model.EmbyMount{})
repos := repository.New(db)
svc := NewEmbyRemoteService(&config.Config{}, zap.NewNop(), repos, NewCryptoService("", zap.NewNop()))
// 账号只配置了用户名/密码,从未「测试连接」:无 api_key、无 remote_user_id。
rawConfig, _ := json.Marshal(map[string]string{
"url": server.URL,
"username": "user",
"password": "pass",
})
acct := &model.StrmAccount{
Base: model.Base{ID: "acct-1"},
Name: "test-emby",
Provider: model.StrmProviderEmbyRemote,
Config: string(rawConfig),
Enabled: true,
}
if err := repos.StrmAccount.Create(t.Context(), acct); err != nil {
t.Fatalf("create account: %v", err)
}
mount := &model.EmbyMount{
Base: model.Base{ID: "mount-1"},
AccountID: acct.ID,
RemoteViewID: "view-1",
RemoteViewName: "剧集库",
CollectionType: "tvshows",
Enabled: true,
}
if err := repos.EmbyMount.Create(t.Context(), mount); err != nil {
t.Fatalf("create mount: %v", err)
}
cards, err := svc.RemoteSeriesCards(t.Context(), mount, acct, "view-1")
if err != nil {
t.Fatalf("RemoteSeriesCards on first browse failed: %v", err)
}
if len(cards) != 1 {
t.Fatalf("cards = %d, want 1", len(cards))
}
if cards[0].Rep.Title != "测试剧" {
t.Fatalf("title = %q, want 测试剧", cards[0].Rep.Title)
}
if cards[0].Count != 12 {
t.Fatalf("count = %d, want 12 (RecursiveItemCount)", cards[0].Count)
}
if n := zeroUserHits.Load(); n != 0 {
t.Fatalf("request hit /Users/0/Items %d time(s), want 0 (must use real user id)", n)
}
// 首次浏览自动认证应把 token 与 remote_user_id 回写账号配置(等价于测试连接)。
stored := map[string]string{}
if err := json.Unmarshal([]byte(acct.Config), &stored); err != nil {
t.Fatalf("decode account config: %v", err)
}
if stored["api_key"] == "" {
t.Fatalf("account config missing api_key after first browse: %v", stored)
}
if stored["remote_user_id"] != "real-user-guid" {
t.Fatalf("remote_user_id = %q, want real-user-guid (config %v)", stored["remote_user_id"], stored)
}
// 第二次浏览不再需要认证步骤,直接命中真实用户 ID。
if _, err := svc.RemoteSeriesCards(t.Context(), mount, acct, "view-1"); err != nil {
t.Fatalf("RemoteSeriesCards second browse failed: %v", err)
}
}
func TestRemoteSeriesCardsResolveUserIDFromAPIKey(t *testing.T) {
// api_key 直连场景:账号只填了 token(无用户名/密码),从未回写过
// remote_user_id。首次浏览应通过 /Users 列表解析出真实用户 GUID。
var zeroUserHits atomic.Int32
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/emby/Users" {
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode([]map[string]any{
{"Id": "real-user-guid", "Name": "admin"},
})
return
}
if r.URL.Path == "/emby/Users/0/Items" {
zeroUserHits.Add(1)
w.WriteHeader(http.StatusInternalServerError)
_, _ = w.Write([]byte("Unrecognized Guid format."))
return
}
if r.URL.Path == "/emby/Users/real-user-guid/Items" {
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]any{
"TotalRecordCount": 1,
"Items": []map[string]any{
{"Id": "series-200", "Name": "API剧", "Type": "Series", "RecursiveItemCount": 8},
},
})
return
}
w.WriteHeader(http.StatusNotFound)
}))
defer server.Close()
db := newServiceTestDB(t, &model.StrmAccount{}, &model.EmbyMount{})
repos := repository.New(db)
svc := NewEmbyRemoteService(&config.Config{}, zap.NewNop(), repos, NewCryptoService("", zap.NewNop()))
rawConfig, _ := json.Marshal(map[string]string{
"url": server.URL,
"token": "api-key-only",
})
acct := &model.StrmAccount{
Base: model.Base{ID: "acct-2"},
Name: "api-key-emby",
Provider: model.StrmProviderEmbyRemote,
Config: string(rawConfig),
Enabled: true,
}
if err := repos.StrmAccount.Create(t.Context(), acct); err != nil {
t.Fatalf("create account: %v", err)
}
mount := &model.EmbyMount{
Base: model.Base{ID: "mount-2"},
AccountID: acct.ID,
RemoteViewID: "view-2",
RemoteViewName: "剧集库",
CollectionType: "tvshows",
Enabled: true,
}
if err := repos.EmbyMount.Create(t.Context(), mount); err != nil {
t.Fatalf("create mount: %v", err)
}
cards, err := svc.RemoteSeriesCards(t.Context(), mount, acct, "view-2")
if err != nil {
t.Fatalf("RemoteSeriesCards with api_key only failed: %v", err)
}
if len(cards) != 1 || cards[0].Rep.Title != "API剧" {
t.Fatalf("cards = %#v, want 1 card 测试剧", cards)
}
if n := zeroUserHits.Load(); n != 0 {
t.Fatalf("request hit /Users/0/Items %d time(s), want 0", n)
}
stored := map[string]string{}
if err := json.Unmarshal([]byte(acct.Config), &stored); err != nil {
t.Fatalf("decode account config: %v", err)
}
if stored["remote_user_id"] != "real-user-guid" {
t.Fatalf("remote_user_id = %q, want real-user-guid (config %v)", stored["remote_user_id"], stored)
}
}
func TestRemoteSearchMedia(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+124 -46
View File
@@ -10,19 +10,20 @@ import (
)
type embySeriesGroup struct {
ID string
LibraryID string
Name string
PosterURL string
BackdropURL string
Overview string
Rating float32
Year int
ReleaseDate string
TMDbID int
BangumiID int
CreatedAt time.Time
Episodes []model.Media
ID string
LibraryID string
Name string
PosterURL string
BackdropURL string
Overview string
Rating float32
Year int
ReleaseDate string
TMDbID int
BangumiID int
CreatedAt time.Time
DateLastMediaAdded time.Time
Episodes []model.Media
}
type embySeasonGroup struct {
@@ -49,11 +50,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
@@ -63,19 +68,20 @@ func (e *EmbyService) findSeriesGroup(ctx context.Context, id, userID string) (e
if series, err := e.repo.Series.FindByID(ctx, id); err != nil {
return embySeriesGroup{}, false, err
} else if series != nil {
return embySeriesGroup{
ID: series.ID,
LibraryID: series.LibraryID,
Name: series.Title,
PosterURL: series.PosterURL,
BackdropURL: series.BackdropURL,
Overview: series.Overview,
Rating: series.Rating,
Year: series.Year,
TMDbID: series.TMDbID,
BangumiID: series.BangumiID,
CreatedAt: series.CreatedAt,
}, true, nil
return embySeriesGroup{
ID: series.ID,
LibraryID: series.LibraryID,
Name: series.Title,
PosterURL: series.PosterURL,
BackdropURL: series.BackdropURL,
Overview: series.Overview,
Rating: series.Rating,
Year: series.Year,
TMDbID: series.TMDbID,
BangumiID: series.BangumiID,
CreatedAt: series.CreatedAt,
DateLastMediaAdded: series.CreatedAt,
}, true, nil
}
}
return embySeriesGroup{}, false, nil
@@ -88,9 +94,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 +113,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,30 +124,93 @@ 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),
Year: row.Year,
ReleaseDate: row.ReleaseDate,
TMDbID: row.TMDbID,
BangumiID: row.BangumiID,
CreatedAt: row.CreatedAt,
group = &embySeriesGroup{
ID: seriesID,
LibraryID: row.LibraryID,
Name: e.seriesNameForMedia(ctx, &row),
Year: row.Year,
ReleaseDate: row.ReleaseDate,
TMDbID: row.TMDbID,
BangumiID: row.BangumiID,
CreatedAt: row.CreatedAt,
DateLastMediaAdded: row.CreatedAt,
}
byID[seriesID] = group
order = append(order, seriesID)
}
if row.CreatedAt.Before(group.CreatedAt) || group.CreatedAt.IsZero() {
group.CreatedAt = row.CreatedAt
}
if row.CreatedAt.After(group.DateLastMediaAdded) {
group.DateLastMediaAdded = row.CreatedAt
}
byID[seriesID] = group
order = append(order, seriesID)
}
if row.CreatedAt.After(group.CreatedAt) {
group.CreatedAt = row.CreatedAt
}
if strings.TrimSpace(row.ReleaseDate) != "" && mediaReleaseSortTime(row).After(embySeriesReleaseSortTime(*group)) {
group.ReleaseDate = row.ReleaseDate
if row.Year > 0 {
+81 -4
View File
@@ -429,10 +429,87 @@ func TestInferSeriesNameFromPath(t *testing.T) {
want: "间谍过家家",
},
}
for _, tc := range tests {
got := inferSeriesNameFromPath(tc.path)
if got != tc.want {
t.Errorf("inferSeriesNameFromPath(%q) = %q, want %q", tc.path, got, tc.want)
for _, tc := range tests {
got := inferSeriesNameFromPath(tc.path)
if got != tc.want {
t.Errorf("inferSeriesNameFromPath(%q) = %q, want %q", tc.path, got, tc.want)
}
}
}
func TestEmbySeriesSortByDateLastMediaAdded(t *testing.T) {
svc := newTestEmbyService(t)
lib := model.Library{Name: "测试剧库", Path: `/media/tv`, Type: "tv", Enabled: true}
if err := svc.repo.Library.Create(t.Context(), &lib); err != nil {
t.Fatalf("create library: %v", err)
}
t0 := time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC)
tOld := time.Date(2025, 6, 1, 0, 0, 0, 0, time.UTC)
tNew := time.Date(2026, 8, 1, 0, 0, 0, 0, time.UTC)
// Series A: 较早创建,但最近添加了新一集 (Last episode at tNew)
// Series B: 较晚创建,但最后一集在 tOld
rows := []model.Media{
{
Base: model.Base{ID: "showA-ep01", CreatedAt: t0, UpdatedAt: t0},
LibraryID: lib.ID,
Title: "剧集A",
Path: `/media/tv/剧集A/Season 01/剧集A.S01E01.mkv`,
SeasonNum: 1,
EpisodeNum: 1,
},
{
Base: model.Base{ID: "showA-ep02", CreatedAt: tNew, UpdatedAt: tNew},
LibraryID: lib.ID,
Title: "剧集A",
Path: `/media/tv/剧集A/Season 01/剧集A.S01E02.mkv`,
SeasonNum: 1,
EpisodeNum: 2,
},
{
Base: model.Base{ID: "showB-ep01", CreatedAt: tOld.Add(-24 * time.Hour), UpdatedAt: tOld.Add(-24 * time.Hour)},
LibraryID: lib.ID,
Title: "剧集B",
Path: `/media/tv/剧集B/Season 01/剧集B.S01E01.mkv`,
SeasonNum: 1,
EpisodeNum: 1,
},
{
Base: model.Base{ID: "showB-ep02", CreatedAt: tOld, UpdatedAt: tOld},
LibraryID: lib.ID,
Title: "剧集B",
Path: `/media/tv/剧集B/Season 01/剧集B.S01E02.mkv`,
SeasonNum: 1,
EpisodeNum: 2,
},
}
for _, m := range rows {
if err := svc.repo.DB.Create(&m).Error; err != nil {
t.Fatalf("create media: %v", err)
}
}
// 降序排序:剧集A最后一集在 tNew,剧集B最后一集在 tOld,剧集A应排在第一位
res, err := svc.Items(t.Context(), ItemsParams{
ParentID: lib.ID,
SortBy: "DateLastMediaAdded",
SortOrder: "Descending",
Limit: 10,
})
if err != nil {
t.Fatalf("items DateLastMediaAdded: %v", err)
}
items := res["Items"].([]map[string]any)
if len(items) != 2 {
t.Fatalf("items count = %d, want 2", len(items))
}
if items[0]["Name"] != "剧集A" {
t.Fatalf("first item = %v, want 剧集A (last episode at tNew)", items[0]["Name"])
}
if items[1]["Name"] != "剧集B" {
t.Fatalf("second item = %v, want 剧集B", items[1]["Name"])
}
if items[0]["DateLastMediaAdded"] != tNew {
t.Fatalf("DateLastMediaAdded = %v, want %v", items[0]["DateLastMediaAdded"], tNew)
}
}

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