Compare commits

..

1 Commits

Author SHA1 Message Date
truewhile ef8b017c63 Merge pull request #19 from truewhile/main
拉取最新代码
2026-09-02 17:13:21 +08:00
246 changed files with 2705 additions and 9419 deletions
-39
View File
@@ -264,42 +264,3 @@ jobs:
if [ -f "$TAR" ]; then
for i in 1 2 3; do gh release upload "$RELEASE_TAG" "$TAR" --clobber && break || sleep 5; done
fi
deploy:
name: Deploy to Server
needs: [version-and-publish]
runs-on: ubuntu-latest
steps:
- name: Deploy via SSH
uses: appleboy/ssh-action@v1.0.3
with:
host: ${{ secrets.SERVER_HOST }}
username: ${{ secrets.SERVER_USER }}
password: ${{ secrets.SERVER_PASSWORD }}
port: ${{ secrets.SERVER_PORT }}
script: |
set -e
echo "==== 开始部署 MeBox ===="
cd /root/dockerData/mebox
# 判断 compose 命令版本兼容性(docker compose 或 docker-compose)
if docker compose version >/dev/null 2>&1; then
COMPOSE_CMD="docker compose"
elif command -v docker-compose >/dev/null 2>&1; then
COMPOSE_CMD="docker-compose"
else
echo "错误: 未找到 docker compose 或 docker-compose"
exit 1
fi
echo "正在拉取最新镜像..."
$COMPOSE_CMD pull
echo "正在重启服务..."
$COMPOSE_CMD up -d
echo "清理旧的无用镜像..."
docker image prune -f
echo "==== 部署完成并已启动 ===="
-38
View File
@@ -61,41 +61,3 @@ jobs:
VERSION=${{ env.RELEASE_VERSION }}
cache-from: type=gha
cache-to: type=gha,mode=max
deploy:
name: Deploy to Server
needs: [docker]
runs-on: ubuntu-latest
steps:
- name: Deploy via SSH
uses: appleboy/ssh-action@v1.0.3
with:
host: ${{ secrets.SERVER_HOST }}
username: ${{ secrets.SERVER_USER }}
password: ${{ secrets.SERVER_PASSWORD }}
port: ${{ secrets.SERVER_PORT }}
script: |
set -e
echo "==== 开始部署 MeBox ===="
cd /root/dockerData/mebox
if docker compose version >/dev/null 2>&1; then
COMPOSE_CMD="docker compose"
elif command -v docker-compose >/dev/null 2>&1; then
COMPOSE_CMD="docker-compose"
else
echo "错误: 未找到 docker compose 或 docker-compose"
exit 1
fi
echo "正在拉取最新镜像..."
$COMPOSE_CMD pull
echo "正在重启服务..."
$COMPOSE_CMD up -d
echo "清理旧的无用镜像..."
docker image prune -f
echo "==== 部署完成并已启动 ===="
+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, storage stats, DLNA casting, settings and logs |
| **Operations** | Unified task queue, recycle bin, storage stats, DLNA casting, settings and logs |
### Tech stack
+1 -1
View File
@@ -1 +1 @@
0.0.108
0.0.81
+1 -2
View File
@@ -23,7 +23,6 @@ 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"
)
@@ -122,7 +121,7 @@ func main() {
)
}
}()
helper.Go(logger, "services.boot", services.Boot)
go services.Boot()
// Graceful shutdown.
stop := make(chan os.Signal, 1)
+1 -2
View File
@@ -53,7 +53,6 @@ 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()
@@ -70,7 +69,7 @@ func serveSPA(r *gin.Engine, root fs.FS) {
r.GET(rootFile, serveFSFile(root, name))
r.HEAD(rootFile, serveFSFile(root, name))
}
r.NoRoute(middleware.GzipStatic(), func(c *gin.Context) {
r.NoRoute(func(c *gin.Context) {
path := c.Request.URL.Path
if shouldBypassSPAFallback(path) {
c.Status(http.StatusNotFound)
+5 -16
View File
@@ -130,35 +130,26 @@ func (m *serverManager) Shutdown(ctx context.Context) error {
// desiredPair 根据当前配置计算目标监听形态:nil 表示明文 HTTP,非 nil 表示 TLS。
// 证书/私钥按"路径优先、内容兜底"解析,并校验是否匹配。
func (m *serverManager) desiredPair() (*tlsPair, error) {
// 与 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 {
if m.cfg == nil || !m.cfg.App.HTTPSEnabled {
return nil, nil
}
certPEM, err := service.ResolveSSLMaterial(cert, certPath, "证书")
certPEM, err := service.ResolveSSLMaterial(m.cfg.App.SSLCert, m.cfg.App.SSLCertPath, "证书")
if err != nil {
return nil, err
}
keyPEM, err := service.ResolveSSLMaterial(key, keyPath, "私钥")
keyPEM, err := service.ResolveSSLMaterial(m.cfg.App.SSLKey, m.cfg.App.SSLKeyPath, "私钥")
if err != nil {
return nil, err
}
if err := service.ValidateSSLKeyPair(certPEM, keyPEM); err != nil {
return nil, err
}
pairCert, err := tls.X509KeyPair([]byte(certPEM), []byte(keyPEM))
cert, err := tls.X509KeyPair([]byte(certPEM), []byte(keyPEM))
if err != nil {
return nil, fmt.Errorf("SSL 证书/私钥无效:%v", err)
}
return &tlsPair{
cert: pairCert,
cert: cert,
certPEM: certPEM,
keyPEM: keyPEM,
version: certPEM + "\x00" + keyPEM,
@@ -180,8 +171,6 @@ 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) != ""
}
+20 -27
View File
@@ -5,20 +5,19 @@ 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-contrib/gzip v1.2.6
github.com/gin-gonic/gin v1.12.0
github.com/gin-gonic/gin v1.9.1
github.com/glebarez/sqlite v1.11.0
github.com/golang-jwt/jwt/v5 v5.2.2
github.com/golang-jwt/jwt/v5 v5.2.0
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.11.1
github.com/stretchr/testify v1.9.0
github.com/ulikunitz/xz v0.5.12
go.uber.org/zap v1.27.0
golang.org/x/crypto v0.48.0
golang.org/x/sys v0.41.0
golang.org/x/crypto v0.21.0
golang.org/x/sys v0.20.0
golang.org/x/time v0.15.0
gopkg.in/yaml.v3 v3.0.1
gorm.io/driver/postgres v1.5.7
@@ -26,23 +25,20 @@ require (
)
require (
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/bytedance/sonic v1.9.1 // indirect
github.com/cespare/xxhash/v2 v2.2.0 // indirect
github.com/cloudwego/base64x v0.1.6 // indirect
github.com/chenzhuoyu/base64x v0.0.0-20221115062448-fe3a3abad311 // 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.12 // indirect
github.com/gin-contrib/sse v1.1.0 // indirect
github.com/gabriel-vasile/mimetype v1.4.2 // indirect
github.com/gin-contrib/sse v0.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.30.1 // indirect
github.com/goccy/go-json v0.10.5 // indirect
github.com/goccy/go-yaml v1.19.2 // indirect
github.com/go-playground/validator/v10 v10.14.0 // indirect
github.com/goccy/go-json v0.10.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
@@ -50,19 +46,17 @@ 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.3.0 // indirect
github.com/leodido/go-urn v1.4.0 // indirect
github.com/klauspost/cpuid/v2 v2.2.4 // indirect
github.com/leodido/go-urn v1.2.4 // 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.20 // indirect
github.com/mattn/go-isatty v0.0.19 // 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.2.4 // indirect
github.com/pelletier/go-toml/v2 v2.1.0 // 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
@@ -75,15 +69,14 @@ 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.3.1 // indirect
github.com/ugorji/go/codec v1.2.11 // 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.22.0 // indirect
golang.org/x/arch v0.3.0 // indirect
golang.org/x/exp v0.0.0-20230905200255-921286631fa9 // 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
golang.org/x/net v0.21.0 // indirect
golang.org/x/text v0.20.0 // indirect
google.golang.org/protobuf v1.31.0 // indirect
gopkg.in/ini.v1 v1.67.0 // indirect
modernc.org/libc v1.22.5 // indirect
modernc.org/mathutil v1.5.0 // indirect
+55 -62
View File
@@ -4,16 +4,14 @@ github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs=
github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c=
github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA=
github.com/bsm/gomega v1.27.10/go.mod h1:JyEr/xRbxbtgWNi8tIEVPUYZ5Dzef52k01W3YH0H+O0=
github.com/bytedance/gopkg v0.1.3 h1:TPBSwH8RsouGCBcMBktLt1AymVo2TVsBVCY4b6TnZ/M=
github.com/bytedance/gopkg v0.1.3/go.mod h1:576VvJ+eJgyCzdjS+c4+77QF3p7ubbtiKARP3TxducM=
github.com/bytedance/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/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/cespare/xxhash/v2 v2.2.0 h1:DC2CZ1Ep5Y4k3ZQ899DldepgrayRUGE6BBZ/cd9Cj44=
github.com/cespare/xxhash/v2 v2.2.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/cloudwego/base64x v0.1.6 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI2M=
github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gEHfghB2IPU=
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/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=
@@ -26,14 +24,12 @@ 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.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/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/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=
@@ -46,17 +42,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.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/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/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
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=
@@ -78,20 +74,21 @@ 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.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y=
github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
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/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.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
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/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.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-isatty v0.0.19 h1:JITubQf0MOLdlGRuRq+jtsDlekdYPia9ZFsB8h/APPA=
github.com/mattn/go-isatty v0.0.19/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=
@@ -99,24 +96,20 @@ 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.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4=
github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
github.com/pelletier/go-toml/v2 v2.1.0 h1:FnwAJ4oYMvbT/34k9zzHuZNrhlz48GB3/s6at6/MHO4=
github.com/pelletier/go-toml/v2 v2.1.0/go.mod h1:tJU2Z3ZkXwnxa4DPO899bsyIoywizdUvyaeZurnPPDc=
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.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ=
github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog=
github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8=
github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs=
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=
@@ -140,15 +133,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.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/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/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=
@@ -157,44 +150,43 @@ 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.3.1 h1:waO7eEiFDwidsBN6agj1vJQ4AG7lh2yqXyOXqhgQuyY=
github.com/ugorji/go/codec v1.3.1/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4=
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/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.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/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/exp v0.0.0-20230905200255-921286631fa9 h1:GoHiUyI/Tp2nVkLI2mCxVkOjsbSXD66ic0XW0js0R9g=
golang.org/x/exp v0.0.0-20230905200255-921286631fa9/go.mod h1:S2oDrQGGwySpoQPVqRShND87VCbxmc6bL1Yd2oYrm6k=
golang.org/x/net v0.51.0 h1:94R/GTO7mt3/4wIKpcR5gkGmRLOuE/2hNGeWq/GBIFo=
golang.org/x/net v0.51.0/go.mod h1:aamm+2QF5ogm02fjy5Bb7CQ0WMt1/WVM7FtyaTLlA9Y=
golang.org/x/net v0.21.0 h1:AQyQV4dYCvJ7vGmJyKki9+PBdyvhkSd8EIx/qb0AYv4=
golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44=
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.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/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/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.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE=
google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
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=
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=
@@ -215,3 +207,4 @@ modernc.org/memory v1.5.0 h1:N+/8c5rE6EqugZwHii4IFsaJ7MUhoWX07J5tC/iI5Ds=
modernc.org/memory v1.5.0/go.mod h1:PkUhL0Mugw21sHPeskwZW4D6VscE/GQJOnIpCnW6pSU=
modernc.org/sqlite v1.23.1 h1:nrSBg4aRQQwq59JpvGEQ15tNxoO5pX/kUjcRNwSAGQM=
modernc.org/sqlite v1.23.1/go.mod h1:OrDj17Mggn6MhE+iPbBNf7RGKODDE9NFT0f3EwDzJqk=
rsc.io/pdf v0.1.1/go.mod h1:n8OzWcQ6Sp37PL01nO98y4iUCRdTGarVfzxY20ICaU4=
+2 -14
View File
@@ -12,7 +12,6 @@ import (
"os"
"path/filepath"
"strings"
"sync"
"github.com/spf13/viper"
)
@@ -20,12 +19,6 @@ import (
// EnvPrefix 是所有环境变量驱动的覆盖使用的前缀。
const EnvPrefix = "MeBox"
// RuntimeMu 保护运行时热更新配置字段的并发读写:ApplyRuntimeSetting 在
// HTTP goroutine 中写字段,serverManager 的证书轮询等后台协程在无锁读取
// 同一批字段。string 是双字结构,无锁并发读写可读到撕裂的 header。
// 写方在 ApplyRuntimeSetting 内 Lock,读方(cmd/server)在轮询处 RLock。
var RuntimeMu sync.RWMutex
// Load 从默认值 / 文件 / 环境读取配置。
//
// 即使没有文件也始终返回可用的 Config。
@@ -52,13 +45,8 @@ func Load() (*Config, error) {
}
s := viper.New()
s.SetConfigFile(filepath.Join("config", e.Name()))
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())
if err := s.ReadInConfig(); err == nil {
_ = 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 != 90 {
t.Fatalf("expected default media cache ttl 90, got %d", cfg.Cache.MediaTTLSeconds)
if cfg.Cache.MediaTTLSeconds != 15 {
t.Fatalf("expected default media cache ttl 15, 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", 90)
v.SetDefault("cache.media_ttl_seconds", 15)
v.SetDefault("search.backend", "")
v.SetDefault("search.opensearch_url", "")
+3 -9
View File
@@ -51,7 +51,7 @@ func (c *Config) normalize() error {
c.Cache.RedisPrefix = "mebox"
}
if c.Cache.MediaTTLSeconds < 1 {
c.Cache.MediaTTLSeconds = 90
c.Cache.MediaTTLSeconds = 15
}
c.Search.Backend = strings.ToLower(strings.TrimSpace(c.Search.Backend))
if c.Search.Index == "" {
@@ -68,14 +68,8 @@ func (c *Config) normalize() error {
return fmt.Errorf("generate jwt secret: %w", err)
}
c.Secrets.JWTSecret = hex.EncodeToString(buf)
// 持久化失败(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)
}
_ = os.MkdirAll(c.App.DataDir, 0o750)
_ = os.WriteFile(path, []byte(c.Secrets.JWTSecret), 0o600)
}
}
return nil
+2 -9
View File
@@ -34,15 +34,8 @@ func SaveDatabaseConfig(dbType, dsn string) error {
return fmt.Errorf("marshal 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)
if err := os.WriteFile(configPath, out, 0644); err != nil {
return fmt.Errorf("write config.yaml: %w", err)
}
return nil
}
-9
View File
@@ -6,7 +6,6 @@ import (
"errors"
"fmt"
"strings"
"time"
"github.com/glebarez/sqlite"
"go.uber.org/zap"
@@ -74,14 +73,6 @@ 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 err // meta 表创建失败必须上抛,不能静默掩盖
return nil
}
version := currentMediaSearchIndexVersion(db)
if version != mediaSearchIndexSchemaVersion {
+5 -45
View File
@@ -8,11 +8,6 @@ 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
}
@@ -42,27 +37,6 @@ 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
@@ -86,16 +60,8 @@ 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`,
@@ -132,19 +98,13 @@ func ensureEmbyMountsCompatibility(db *gorm.DB) error {
return err
}
}
// 针对已有数据:只给 sort_order=0/NULL 的行按创建时间补号(从现有
// 最大值之后递增),不能整表重排——此前无条件按 created_at 从 0 重新
// 编号,会把用户自定义的顺序覆盖掉。
// 针对已有数据:如果存在多个 sort_order=0/NULL 的记录,按创建时间顺序赋予稳定递增的序号
var zeroCount int64
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
if err := db.Model(&model.EmbyMount{}).Where("sort_order = 0 OR sort_order IS NULL").Count(&zeroCount).Error; err == nil && zeroCount > 1 {
var mounts []model.EmbyMount
if err := db.Where("sort_order = 0 OR sort_order IS NULL").Order("created_at asc, id asc").Find(&mounts).Error; err == nil {
if err := db.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 = ?", maxOrder+1+i, m.ID).Error
_ = db.Exec("UPDATE emby_mounts SET sort_order = ? WHERE id = ?", i, m.ID).Error
}
}
}
@@ -1,84 +0,0 @@
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")
}
}
+16 -31
View File
@@ -50,43 +50,28 @@ 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)
}
var primaryKeySet map[string]struct{}
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()
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)
}
// 分页流式读取:此前整表一次性 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
}
if filtered.Len() == 0 {
continue
}
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
}
+24 -76
View File
@@ -5,21 +5,12 @@ 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
@@ -31,18 +22,15 @@ func installSQLiteWriteGate(db *gorm.DB) {
if tx.Statement != nil && tx.Statement.Context != nil {
ctx = tx.Statement.Context
}
holder, err := gate.Lock(ctx)
if err != nil {
if err := gate.Lock(ctx); err != nil {
_ = tx.AddError(err)
return
}
tx.InstanceSet(lockedKey, holder)
tx.InstanceSet(lockedKey, struct{}{})
}
unlock := func(tx *gorm.DB) {
if holder, ok := tx.InstanceGet(lockedKey); ok {
if h, ok := holder.(*sqliteGateHolder); ok {
gate.Unlock(h)
}
if _, ok := tx.InstanceGet(lockedKey); ok {
gate.Unlock()
}
}
rawLock := func(tx *gorm.DB) {
@@ -76,76 +64,38 @@ func isReadOnlySQL(sql string) bool {
return false
}
// sqliteWriteGate serializes in-process SQLite writes. 所有权令牌(而非裸
// 信号量)保证只有持有者本人能释放;持有超时按泄漏自动回收,避免一次
// panic 让进程的 SQLite 写入半永久性瘫痪。
// sqliteWriteGate serializes in-process SQLite writes while respecting the
// statement context, so request cancellation can break out of a queued write.
type sqliteWriteGate struct {
mu sync.Mutex
cond *sync.Cond
owner *sqliteGateHolder
ch chan struct{}
}
type sqliteGateHolder struct {
id uint64
acquired time.Time
}
var sqliteGateHolderSeq atomic.Uint64
func newSQLiteWriteGate() *sqliteWriteGate {
g := &sqliteWriteGate{}
g.cond = sync.NewCond(&g.mu)
return g
return &sqliteWriteGate{ch: make(chan struct{}, 1)}
}
func (g *sqliteWriteGate) Lock(ctx context.Context) (*sqliteGateHolder, error) {
g.mu.Lock()
defer g.mu.Unlock()
func (g *sqliteWriteGate) Lock(ctx context.Context) error {
select {
case g.ch <- struct{}{}:
return nil
default:
}
if ctx == nil {
ctx = context.Background()
}
// 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()
select {
case g.ch <- struct{}{}:
return nil
case <-ctx.Done():
return ctx.Err()
}
}
func (g *sqliteWriteGate) Unlock(h *sqliteGateHolder) {
g.mu.Lock()
defer g.mu.Unlock()
if h == nil || g.owner != h {
return
func (g *sqliteWriteGate) Unlock() {
select {
case <-g.ch:
default:
}
g.owner = nil
g.cond.Broadcast()
}
func buildSQLiteDSN(cfg *config.Config) string {
@@ -154,9 +104,7 @@ func buildSQLiteDSN(cfg *config.Config) string {
// keep as-is to respect user-provided relative paths.
dbPath = filepath.Clean(dbPath)
}
// _txlock=immediate:事务以写锁开始。此前 deferred BEGIN 在并发事务
// 升级写锁时会绕过 busy_timeout 直接报 SQLITE_BUSY。
dsn := dbPath + "?_txlock=immediate&_pragma=foreign_keys(1)"
dsn := dbPath + "?_pragma=foreign_keys(1)"
if cfg.Database.WALMode {
dsn += "&_pragma=journal_mode(WAL)&_pragma=synchronous(NORMAL)"
}
+4 -20
View File
@@ -10,7 +10,6 @@ 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"
)
@@ -87,19 +86,10 @@ 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 httpsEnabled {
if _, err := service.ResolveSSLKeyPair(cert, certPath, keyMaterial, keyPath); err != nil {
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 {
return fmt.Errorf("启用 HTTPS 失败:%v", err)
}
}
@@ -107,7 +97,7 @@ func applyHTTPSetting(svc *service.Container, key, value string) error {
if err := validateSSLMaterialSource(key, value); err != nil {
return err
}
if !httpsEnabled {
if !svc.Cfg.App.HTTPSEnabled {
return nil
}
if !httpsPairReady(svc) {
@@ -154,13 +144,7 @@ func validateSSLMaterialSource(key, value string) error {
// httpsPairReady 判断基于当前配置解析出的证书/私钥是否完整且匹配。
func httpsPairReady(svc *service.Container) bool {
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)
_, err := service.ResolveSSLKeyPair(svc.Cfg.App.SSLCert, svc.Cfg.App.SSLCertPath, svc.Cfg.App.SSLKey, svc.Cfg.App.SSLKeyPath)
return err == nil
}
-39
View File
@@ -3,8 +3,6 @@ package handler
import (
"net/http"
"net/url"
"strings"
"github.com/gin-gonic/gin"
@@ -35,21 +33,6 @@ 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
@@ -57,25 +40,3 @@ 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
}
+13 -16
View File
@@ -132,25 +132,22 @@ func embyMeHandler(svc *service.Container) gin.HandlerFunc {
func embyGetUserByIDHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
uid := embyUserID(c)
if uid == "" {
embyError(c, http.StatusUnauthorized, "not authenticated")
return
}
// 只返回调用者自己的用户对象:客户端误传其他 userId 时回退到
// 调用者自身(保留旧行为的兼容语义),但绝不返回他人数据。
u, err := svc.Emby.FindUser(c.Request.Context(), uid)
u, err := svc.Emby.FindUser(c.Request.Context(), c.Param("userId"))
if err == nil && u != nil {
c.JSON(http.StatusOK, u)
return
}
c.JSON(http.StatusOK, embyFallbackUser(uid))
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")))
}
}
// embyFallbackUser 是查库失败时的最后兜底(保持客户端可渲染)。
// Policy 必须是最小权限:不声明管理员/删除内容/控制他人等能力,
// 实际权限始终由服务端各路由的校验决定。
func embyFallbackUser(id string) gin.H {
if strings.TrimSpace(id) == "" {
id = "mebox-user"
@@ -164,10 +161,10 @@ func embyFallbackUser(id string) gin.H {
"HasConfiguredEasyPassword": false,
"EnableAutoLogin": false,
"Policy": gin.H{
"IsAdministrator": false,
"EnableContentDeletion": false,
"EnableRemoteControlOfOtherUsers": false,
"EnableSharedDeviceControl": false,
"IsAdministrator": true,
"EnableContentDeletion": true,
"EnableRemoteControlOfOtherUsers": true,
"EnableSharedDeviceControl": true,
"EnableRemoteAccess": true,
"EnableAllDevices": true,
"EnableAllChannels": true,
-2
View File
@@ -9,14 +9,12 @@ 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)
+40 -172
View File
@@ -11,10 +11,8 @@ 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"
)
@@ -60,37 +58,17 @@ 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)
}
}
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{}{}
}
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 len(targetSet) > 0 {
filtered := libs[:0]
for _, lib := range libs {
if _, ok := targetSet[lib.ID]; ok {
filtered = append(filtered, lib)
}
}
libs = filtered
}
libs = filtered
}
withPreview := c.Query("with_preview") == "1" || c.Query("with_preview") == "true"
limit := 10
if withPreview {
@@ -111,50 +89,23 @@ 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 {
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})
}
} else {
for _, l := range libs {
out = append(out, webLibraryPayload{Library: l})
}
// 远程 Emby 挂载库追加在本地库之后(非管理员视图仍受 allowed_library_ids 约束)。
}
// 远程 Emby 挂载库追加在本地库之后。
if svc.EmbyRemote != nil {
if views, err := svc.EmbyRemote.RemoteLibraries(ctx); err == nil {
visibility := mediaVisibilityForRequest(c, svc)
allowedViews := make([]service.RemoteLibraryView, 0, len(views))
for _, v := range views {
if !includeHidden && !service.LibraryVisibleForUser(ctx, svc.Repo, v.Library, visibility) {
continue
}
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 := make([]webLibraryPayload, len(views))
for i, v := range views {
remotePayloads[i] = webLibraryPayload{Library: v.Library, IsRemoteEmby: true, RemoteSource: v.AccountName}
}
if withPreview && len(allowedViews) > 0 {
if withPreview && len(views) > 0 {
const maxRemotePreviewWorkers = 6
sem := make(chan struct{}, maxRemotePreviewWorkers)
var wg sync.WaitGroup
for i, v := range allowedViews {
for i, v := range views {
i, v := i, v
wg.Add(1)
go func() {
@@ -165,20 +116,18 @@ func listLibrariesHandler(svc *service.Container) gin.HandlerFunc {
case <-ctx.Done():
return
}
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
}
})
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()
@@ -202,12 +151,6 @@ 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
}
@@ -368,11 +311,7 @@ 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(), "")
// goroutine 内的 panic 无法被 gin.Recovery 捕获,会直接崩掉进程:
// 与其他调用点一致先判空。
if svc.Watcher != nil {
go func() { _ = svc.Watcher.Refresh(context.Background()) }()
}
go func() { _ = svc.Watcher.Refresh(context.Background()) }()
c.Status(http.StatusNoContent)
}
}
@@ -391,10 +330,6 @@ 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)
@@ -462,10 +397,6 @@ 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()})
@@ -521,114 +452,55 @@ func updateMediaMetadataHandler(svc *service.Container) gin.HandlerFunc {
}
}
func paginateSlice[T any](items []T, page, size int) []T {
if page < 1 {
page = 1
}
if size <= 0 {
size = 50
}
if len(items) == 0 {
return []T{}
}
start := (page - 1) * size
if start >= len(items) {
return []T{}
}
end := start + size
if end > len(items) {
end = len(items)
}
return items[start:end]
}
func searchMediaHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
ctx := c.Request.Context()
q := c.Query("q")
visibility := mediaVisibilityForRequest(c, svc)
groupVersions := c.DefaultQuery("group_versions", "1") != "0"
fetchRemote := func(limit int) []model.Media {
if svc.EmbyRemote == nil || strings.TrimSpace(q) == "" {
return nil
}
remoteItems, _ := svc.EmbyRemote.RemoteSearchMedia(ctx, q, limit, visibility)
return remoteItems
}
if c.Query("page") != "" || c.Query("page_size") != "" {
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
size, _ := strconv.Atoi(c.DefaultQuery("page_size", "50"))
if !groupVersions {
localItems, _, err := svc.Media.SearchMediaVisiblePage(ctx, q, 1, 50000, visibility)
items, total, err := svc.Media.SearchMediaVisiblePage(c.Request.Context(), q, page, size, mediaVisibilityForRequest(c, svc))
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
remoteItems := fetchRemote(size * 2)
all := append(localItems, remoteItems...)
paged := paginateSlice(all, page, size)
c.JSON(http.StatusOK, gin.H{
"items": paged,
"total": len(all),
"items": items,
"total": total,
"page": page,
"page_size": size,
})
return
}
localItems, err := svc.Media.SearchMediaVisible(ctx, q, 50000, visibility)
items, total, err := svc.Media.SearchMediaVisiblePageGrouped(c.Request.Context(), q, page, size, mediaVisibilityForRequest(c, svc))
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
remoteItems := fetchRemote(size * 2)
all := append(localItems, remoteItems...)
grouped := service.GroupMediaVersions(all)
paged := service.PaginateMediaItems(grouped, page, size)
c.JSON(http.StatusOK, gin.H{
"items": paged,
"total": len(grouped),
"items": items,
"total": total,
"page": page,
"page_size": size,
})
return
}
limit, _ := strconv.Atoi(c.DefaultQuery("limit", "50"))
if limit <= 0 {
limit = 50
}
if !groupVersions {
localItems, err := svc.Media.SearchMediaVisible(ctx, q, limit, visibility)
items, err := svc.Media.SearchMediaVisible(c.Request.Context(), q, limit, mediaVisibilityForRequest(c, svc))
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
remoteItems := fetchRemote(limit)
all := append(localItems, remoteItems...)
if len(all) > limit {
all = all[:limit]
}
if all == nil {
all = []model.Media{}
}
c.JSON(http.StatusOK, gin.H{"items": all})
c.JSON(http.StatusOK, gin.H{"items": items})
return
}
localItems, err := svc.Media.SearchMediaVisible(ctx, q, 50000, visibility)
items, err := svc.Media.SearchMediaVisibleGrouped(c.Request.Context(), q, limit, mediaVisibilityForRequest(c, svc))
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
remoteItems := fetchRemote(limit)
all := append(localItems, remoteItems...)
grouped := service.GroupMediaVersions(all)
items := service.FirstMediaItems(grouped, limit)
if items == nil {
items = []service.MediaItem{}
}
c.JSON(http.StatusOK, gin.H{"items": items})
}
}
@@ -648,10 +520,6 @@ 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
@@ -1,35 +0,0 @@
// 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
@@ -1,23 +0,0 @@
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)
}
}
}
+19 -16
View File
@@ -13,6 +13,7 @@ import (
"github.com/gin-gonic/gin"
"github.com/truewhile/MeBox/internal/middleware"
"github.com/truewhile/MeBox/internal/model"
"github.com/truewhile/MeBox/internal/service"
)
@@ -22,18 +23,18 @@ import (
func addMediaFavoriteHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
uid, _ := c.Get(middleware.CtxUserID)
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 {
// 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 {
c.JSON(http.StatusOK, gin.H{"favourite": true})
return
}
if err := svc.Playback.SetFavourite(c.Request.Context(), userID, mediaID, true); err != nil {
// 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 {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
@@ -45,7 +46,9 @@ 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.Playback.SetFavourite(c.Request.Context(), toString(uid), c.Param("id"), false); err != nil {
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 {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
@@ -57,12 +60,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)
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})
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})
}
}
+7 -18
View File
@@ -7,7 +7,6 @@ import (
"github.com/gin-gonic/gin"
"github.com/truewhile/MeBox/internal/helper"
"github.com/truewhile/MeBox/internal/service"
)
@@ -42,11 +41,7 @@ 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()
var res *service.ScanResult
var err error
helper.Run(svc.Log, "scan.library", func() {
res, err = svc.Scan.ScanLibrary(context.Background(), libraryID)
})
res, err := svc.Scan.ScanLibrary(context.Background(), libraryID)
if err != nil {
finishHTTPTask(task, err, "scan", "手动扫描入库失败", scanTaskMetrics(res), scanTaskDetails(res, 20))
return
@@ -80,11 +75,7 @@ func scanLibraryRootHandler(svc *service.Container) gin.HandlerFunc {
task := startScanHTTPTask(svc, "手动扫描媒体库路径", id, rootID)
go func(libraryID, libraryRootID string, task *service.TaskHandle, finish func()) {
defer finish()
var res *service.ScanResult
var err error
helper.Run(svc.Log, "scan.libraryRoot", func() {
res, err = svc.Scan.ScanLibraryRoot(context.Background(), libraryID, libraryRootID)
})
res, err := svc.Scan.ScanLibraryRoot(context.Background(), libraryID, libraryRootID)
if err != nil {
finishHTTPTask(task, err, "scan", "手动扫描路径失败", scanTaskMetrics(res), scanTaskDetails(res, 20))
return
@@ -119,13 +110,11 @@ func queueLibraryRootScan(svc *service.Container, libraryID, rootID string) {
}
go func() {
defer finish()
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)
})
if strings.TrimSpace(rootID) == "" {
_, _ = svc.Scan.ScanLibrary(context.Background(), libraryID)
return
}
_, _ = svc.Scan.ScanLibraryRoot(context.Background(), libraryID, rootID)
}()
}
+6 -183
View File
@@ -104,16 +104,11 @@ 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)
}
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)
}
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)
}
}
func TestGetLibraryAllowsEmptyLibrary(t *testing.T) {
gin.SetMode(gin.TestMode)
@@ -441,180 +436,8 @@ func TestEmptyLibraryListsReturnEmptyArraysNotNull(t *testing.T) {
if strings.Contains(body, `"items":null`) {
t.Fatalf("%s: empty library returned items:null (crashes frontend): %s", tc.name, body)
}
if !strings.Contains(body, `"items":[]`) {
t.Fatalf("%s: expected items:[] for empty library, got %s", tc.name, body)
}
}
}
func TestSearchMediaHandlerIncludesEmbyRemote(t *testing.T) {
gin.SetMode(gin.TestMode)
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
q := r.URL.Query()
if q.Get("SearchTerm") == "碧蓝之海" {
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]any{
"TotalRecordCount": 1,
"Items": []map[string]any{
{
"Id": "156030",
"Name": "碧蓝之海",
"Type": "Series",
"ProductionYear": 2018,
},
},
})
return
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]any{
"TotalRecordCount": 0,
"Items": []map[string]any{},
})
}))
defer server.Close()
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
if err != nil {
t.Fatal(err)
}
if err := db.AutoMigrate(&model.Library{}, &model.Media{}, &model.StrmAccount{}, &model.EmbyMount{}, &model.Setting{}, &model.User{}, &model.PlayProfile{}); err != nil {
t.Fatal(err)
}
repos := repository.New(db)
adminUser := model.User{
Base: model.Base{ID: "user-1"},
Username: "admin",
Role: "admin",
}
_ = repos.DB.Create(&adminUser).Error
localLib := model.Library{Name: "本地电影", Path: "/media/movies", Type: "movie", Enabled: true}
if err := repos.Library.Create(t.Context(), &localLib); err != nil {
t.Fatal(err)
}
localMedia := model.Media{
Base: model.Base{ID: "local-1"},
LibraryID: localLib.ID,
Title: "流浪地球",
Year: 2019,
}
if err := repos.DB.Create(&localMedia).Error; err != nil {
t.Fatal(err)
}
rawCfg, _ := json.Marshal(map[string]string{"url": server.URL, "token": "fake-token"})
acct := model.StrmAccount{
Base: model.Base{ID: "acct-1"},
Name: "远程Emby",
Provider: model.StrmProviderEmbyRemote,
Config: string(rawCfg),
Enabled: true,
}
if err := repos.StrmAccount.Create(t.Context(), &acct); err != nil {
t.Fatal(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.Fatal(err)
}
crypto := service.NewCryptoService("", zap.NewNop())
remoteSvc := service.NewEmbyRemoteService(&config.Config{}, zap.NewNop(), repos, crypto)
mediaSvc := service.NewMediaService(&config.Config{}, zap.NewNop(), repos)
svc := &service.Container{
Repo: repos,
Media: mediaSvc,
EmbyRemote: remoteSvc,
}
// 1. 搜索远程挂载媒体(碧蓝之海)
{
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Set(middleware.CtxUserID, "user-1")
c.Set(middleware.CtxUserRole, "admin")
c.Request = httptest.NewRequest(http.MethodGet, "/api/media?q=碧蓝之海&limit=8", nil)
searchMediaHandler(svc)(c)
if w.Code != http.StatusOK {
t.Fatalf("search status=%d, body=%s", w.Code, w.Body.String())
}
var res struct {
Items []service.MediaItem `json:"items"`
}
if err := json.Unmarshal(w.Body.Bytes(), &res); err != nil {
t.Fatal(err)
}
if len(res.Items) != 1 {
t.Fatalf("expected 1 item, got %d", len(res.Items))
}
if res.Items[0].Title != "碧蓝之海" {
t.Fatalf("expected Title '碧蓝之海', got %q", res.Items[0].Title)
}
expectedID := service.EncodeEmbyRemoteID("mount-1", "156030")
if res.Items[0].ID != expectedID {
t.Fatalf("expected ID %q, got %q", expectedID, res.Items[0].ID)
}
}
// 2. 搜索本地媒体(流浪地球)
{
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Set(middleware.CtxUserID, "user-1")
c.Set(middleware.CtxUserRole, "admin")
c.Request = httptest.NewRequest(http.MethodGet, "/api/media?q=流浪地球&limit=8", nil)
searchMediaHandler(svc)(c)
if w.Code != http.StatusOK {
t.Fatalf("search status=%d, body=%s", w.Code, w.Body.String())
}
var res struct {
Items []service.MediaItem `json:"items"`
}
if err := json.Unmarshal(w.Body.Bytes(), &res); err != nil {
t.Fatal(err)
}
if len(res.Items) != 1 {
t.Fatalf("expected 1 item, got %d", len(res.Items))
}
if res.Items[0].Title != "流浪地球" {
t.Fatalf("expected Title '流浪地球', got %q", res.Items[0].Title)
}
}
// 3. 搜索不存在的媒体
{
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Set(middleware.CtxUserID, "user-1")
c.Set(middleware.CtxUserRole, "admin")
c.Request = httptest.NewRequest(http.MethodGet, "/api/media?q=不存在的影片&limit=8", nil)
searchMediaHandler(svc)(c)
if w.Code != http.StatusOK {
t.Fatalf("search status=%d, body=%s", w.Code, w.Body.String())
}
var res struct {
Items []service.MediaItem `json:"items"`
}
if err := json.Unmarshal(w.Body.Bytes(), &res); err != nil {
t.Fatal(err)
}
if len(res.Items) != 0 {
t.Fatalf("expected 0 items, got %d", len(res.Items))
}
if strings.Contains(w.Body.String(), `"items":null`) {
t.Fatalf("expected items:[], got null: %s", w.Body.String())
if !strings.Contains(body, `"items":[]`) {
t.Fatalf("%s: expected items:[] for empty library, got %s", tc.name, body)
}
}
}
-49
View File
@@ -1,49 +0,0 @@
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})
}
}
+3 -36
View File
@@ -2,11 +2,9 @@
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"
@@ -159,25 +157,6 @@ 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
@@ -185,12 +164,8 @@ 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"), uid, req.MediaID, isAdmin,
c.Request.Context(), c.Param("id"), req.MediaID,
); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
@@ -201,12 +176,8 @@ 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"), uid, c.Param("media_id"), isAdmin,
c.Request.Context(), c.Param("id"), c.Param("media_id"),
); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
@@ -217,12 +188,8 @@ 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"), uid, isAdmin,
c.Request.Context(), c.Param("id"),
); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
+1 -8
View File
@@ -27,9 +27,6 @@ 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{}).
@@ -47,12 +44,8 @@ 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 = ?", pid, c.Param("item_id")).
Where("playlist_id = ? AND id = ?", c.Param("id"), c.Param("item_id")).
Delete(&model.PlaylistItem{}).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
+40
View File
@@ -0,0 +1,40 @@
// 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,7 +37,6 @@ 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))
@@ -88,7 +87,6 @@ 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))
@@ -99,7 +97,6 @@ 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)
registerAuthedRealtimeRoutes(authed, svc)
registerAuthedRecycleAndRealtimeRoutes(authed, svc)
registerAuthedUISurfaceRoutes(authed, svc)
registerAuthedSearchRoutes(authed, svc)
registerAuthedSystemExtraRoutes(authed, svc)
@@ -10,8 +10,6 @@ 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))
@@ -50,6 +48,8 @@ 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 registerAuthedRealtimeRoutes(authed *gin.RouterGroup, svc *service.Container) {
func registerAuthedRecycleAndRealtimeRoutes(authed *gin.RouterGroup, svc *service.Container) {
authed.GET("/ws", wsHandler(svc))
authed.GET("/events", sseHandler(svc))
}
@@ -25,8 +25,6 @@ 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,17 +101,6 @@ 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())
+2 -23
View File
@@ -7,7 +7,6 @@ package handler
import (
"net/http"
"strconv"
"strings"
"github.com/gin-gonic/gin"
@@ -17,26 +16,16 @@ import (
// searchUnifiedHandler is the basic /api/search endpoint.
func searchUnifiedHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
ctx := c.Request.Context()
q := c.Query("q")
limit, _ := strconv.Atoi(c.DefaultQuery("limit", "30"))
if limit <= 0 || limit > 200 {
limit = 30
}
visibility := mediaVisibilityForRequest(c, svc)
items, err := svc.Media.SearchMediaVisible(ctx, q, limit, visibility)
items, err := svc.Media.SearchMediaVisible(c.Request.Context(), q, limit, mediaVisibilityForRequest(c, svc))
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
if svc.EmbyRemote != nil && strings.TrimSpace(q) != "" {
if remoteItems, _ := svc.EmbyRemote.RemoteSearchMedia(ctx, q, limit, visibility); len(remoteItems) > 0 {
items = append(items, remoteItems...)
if len(items) > limit {
items = items[:limit]
}
}
}
c.JSON(http.StatusOK, gin.H{"items": items, "total": len(items)})
}
}
@@ -47,26 +36,16 @@ func searchUnifiedHandler(svc *service.Container) gin.HandlerFunc {
// them back. This keeps API parity without a giant query builder.
func searchAdvancedHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
ctx := c.Request.Context()
q := c.Query("q")
limit, _ := strconv.Atoi(c.DefaultQuery("limit", "30"))
if limit <= 0 || limit > 200 {
limit = 30
}
visibility := mediaVisibilityForRequest(c, svc)
items, err := svc.Media.SearchMediaVisible(ctx, q, limit, visibility)
items, err := svc.Media.SearchMediaVisible(c.Request.Context(), q, limit, mediaVisibilityForRequest(c, svc))
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
if svc.EmbyRemote != nil && strings.TrimSpace(q) != "" {
if remoteItems, _ := svc.EmbyRemote.RemoteSearchMedia(ctx, q, limit, visibility); len(remoteItems) > 0 {
items = append(items, remoteItems...)
if len(items) > limit {
items = items[:limit]
}
}
}
c.JSON(http.StatusOK, gin.H{
"items": items,
"filters": gin.H{
-12
View File
@@ -74,10 +74,6 @@ 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)
@@ -169,10 +165,6 @@ 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)
@@ -215,10 +207,6 @@ 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,14 +14,9 @@ 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).
@@ -40,14 +35,8 @@ 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
@@ -120,13 +109,3 @@ 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,17 +526,6 @@ 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())
@@ -548,17 +537,6 @@ 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())
+1 -6
View File
@@ -39,16 +39,11 @@ func listSystemConfigHandler(svc *service.Container) gin.HandlerFunc {
}
func isSecretKey(k string) bool {
for _, suffix := range []string{".token", ".secret", ".password", ".api_key", ".cookie", ".pin"} {
for _, suffix := range []string{".token", ".secret", ".password", ".api_key", ".cookie"} {
if endsWith(k, suffix) {
return true
}
}
// 非后缀型敏感键:可触发服务端任意命令的更新命令等。
switch k {
case "system.update.command":
return true
}
return false
}
+17 -22
View File
@@ -8,7 +8,6 @@ package handler
import (
"net/http"
"runtime"
"strings"
"time"
"github.com/gin-gonic/gin"
@@ -23,30 +22,26 @@ import (
// without threading state through the container.
var startedAt = time.Now()
func systemInfoHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
directOnly := false
if svc.Repo != nil && svc.Repo.Setting != nil {
if v, err := svc.Repo.Setting.Get(c.Request.Context(), service.PlaybackDirectOnlySettingKey); err == nil {
directOnly = service.ParseBoolSetting(v, false)
}
func systemInfoHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
directOnly := false
if svc.Repo != nil && svc.Repo.Setting != nil {
if v, err := svc.Repo.Setting.Get(c.Request.Context(), service.PlaybackDirectOnlySettingKey); err == nil {
directOnly = service.ParseBoolSetting(v, false)
}
version := svc.Version
if strings.TrimSpace(version) == "" {
version = "dev"
}
c.JSON(http.StatusOK, gin.H{
"name": "MeBox",
"version": version,
"go": runtime.Version(),
"os": runtime.GOOS,
"arch": runtime.GOARCH,
"data_dir": svc.Cfg.App.DataDir,
"cache_dir": svc.Cfg.Cache.CacheDir,
"direct_play_only": directOnly,
})
}
c.JSON(http.StatusOK, gin.H{
"name": "MeBox",
"version": "0.1.0",
"go": runtime.Version(),
"os": runtime.GOOS,
"arch": runtime.GOARCH,
"data_dir": svc.Cfg.App.DataDir,
"cache_dir": svc.Cfg.Cache.CacheDir,
"direct_play_only": directOnly,
})
}
}
func systemStatusHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
+4 -17
View File
@@ -9,8 +9,6 @@ package handler
import (
"encoding/json"
"net/http"
"net/url"
"strings"
"time"
"github.com/gin-gonic/gin"
@@ -23,21 +21,10 @@ import (
var wsUpgrader = websocket.Upgrader{
ReadBufferSize: 1024,
WriteBufferSize: 1024,
// 同源校验:浏览器跨站页面虽读不到 ?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)
},
// 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 },
}
func wsHandler(svc *service.Container) gin.HandlerFunc {
-53
View File
@@ -1,53 +0,0 @@
// 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
@@ -1,95 +0,0 @@
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,19 +51,6 @@ 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
@@ -1,47 +0,0 @@
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
@@ -1,84 +0,0 @@
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)
}
}
+1 -16
View File
@@ -16,8 +16,6 @@ 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
@@ -27,27 +25,14 @@ 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 {
select {
case <-rl.stop:
return
case <-ticker.C:
}
time.Sleep(5 * time.Minute)
rl.mu.Lock()
now := time.Now()
for ip, times := range rl.requests {
+8 -15
View File
@@ -5,22 +5,15 @@ import (
"time"
)
// 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 {
// ApiConfig 存储第三方 API 密钥和配置信息。
// APIKey 字段在 JSON 序列化时隐藏(json:"-"),通过加密存储。
type ApiConfig struct {
Base
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"`
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"`
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"`
+21 -6
View File
@@ -1,8 +1,23 @@
package model
// 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 结构体(字段取并集),此处不再定义重复模型。
// 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"`
}
+2 -2
View File
@@ -26,7 +26,7 @@ type LibraryRoot struct {
// Media 是单个可播放项。剧集链接到 SeriesID;电影 SeriesID == ""。
type Media struct {
Base
LibraryID string `gorm:"index;size:36;index:idx_media_library_release,priority:1" json:"library_id"`
LibraryID string `gorm:"index;size:36" 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:idx_media_library_release,priority:2" json:"release_date,omitempty"`
ReleaseDate string `gorm:"size:10;index" 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,6 +46,7 @@ func AllModels() []interface{} {
&APIConfig{},
&UserPermission{},
&RefreshToken{},
&ApiConfig{},
&PlayProfile{},
&RegistrationCode{},
&SignIn{},
+2 -4
View File
@@ -3,12 +3,10 @@ package model
import "time"
// PlaybackHistory 记录当前播放位置以支持续播。
// (user_id, media_id) 唯一:播放进度每几秒上报一次,唯一索引保证并发上报
// 不会插入重复行(否则续播列表会出现重复卡片),也让 upsert 单语句完成。
type PlaybackHistory struct {
Base
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"`
UserID string `gorm:"index;size:36;not null" json:"user_id"`
MediaID string `gorm:"index;size:128;not null" json:"media_id"`
PositionMs int64 `json:"position_ms"`
DurationMs int64 `json:"duration_ms"`
WatchedAt time.Time `json:"watched_at"`
-23
View File
@@ -25,9 +25,6 @@ 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.
@@ -62,30 +59,10 @@ 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()
}
+19 -32
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,40 +32,27 @@ 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.
// 显式 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
})
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
}
// 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,
@@ -77,13 +64,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,16 +125,3 @@ 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
}
+19 -18
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,24 +13,25 @@ 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 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.
// Upsert atomically inserts/updates the resume position.
func (r *HistoryRepository) Upsert(ctx context.Context, h *model.PlaybackHistory) 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
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
}
// ListByUser returns the most recent history rows for the user.
@@ -1,79 +0,0 @@
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 when
// removing a library or virtual mount so indexed rows are dropped immediately.
// PurgeByLibrary permanently removes media tied to a library. Used for virtual
// cloud mounts where "remove mount" must not populate the recycle bin.
func (r *MediaRepository) PurgeByLibrary(ctx context.Context, libraryID string) error {
return r.db.WithContext(ctx).Unscoped().Where("library_id = ?", libraryID).Delete(&model.Media{}).Error
}
+31 -86
View File
@@ -22,110 +22,37 @@ import (
// 显式写入)。这两个问题都让 EnrichLibrary(WHERE scrape_status='pending')
// 永远捞不到数据。
func (r *MediaRepository) Upsert(ctx context.Context, m *model.Media) error {
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
return withSQLiteBusyRetry(ctx, func() error {
return r.upsert(ctx, m)
})
}
func (r *MediaRepository) upsert(ctx context.Context, m *model.Media) error {
existing, created, err := r.findOrCreateMediaByPath(ctx, m)
if err != nil {
return err
}
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
r.indexMediaBestEffort(ctx, *m)
return nil
}
updates := mediaUpsertUpdates(existing, *m)
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
return r.applyMediaUpsertUpdates(ctx, m, existing, updates)
}
func (r *MediaRepository) findOrCreateMediaByPath(ctx context.Context, db *gorm.DB, m *model.Media) (model.Media, bool, error) {
func (r *MediaRepository) findOrCreateMediaByPath(ctx context.Context, m *model.Media) (model.Media, bool, error) {
var existing model.Media
err := db.WithContext(ctx).Unscoped().Where("path = ?", m.Path).First(&existing).Error
err := r.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 := db.WithContext(ctx).Create(m).Error; createErr == nil {
if createErr := r.db.WithContext(ctx).Create(m).Error; createErr == nil {
return *m, true, nil
} else if retryErr := db.WithContext(ctx).Unscoped().Where("path = ?", m.Path).First(&existing).Error; retryErr != nil {
} else if retryErr := r.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 {
@@ -310,6 +237,24 @@ 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
+3 -11
View File
@@ -105,17 +105,11 @@ 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 "+likeOp+" ? ESCAPE '\\' OR original_name "+likeOp+" ? ESCAPE '\\' OR path "+likeOp+" ? ESCAPE '\\' OR genres "+likeOp+" ? ESCAPE '\\')",
"(title LIKE ? ESCAPE '\\' OR original_name LIKE ? ESCAPE '\\' OR path LIKE ? ESCAPE '\\' OR genres LIKE ? ESCAPE '\\')",
like, like, like, like,
)
}
@@ -126,7 +120,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 "+likeOp+" ? ESCAPE '\\' THEN 2 WHEN original_name "+likeOp+" ? ESCAPE '\\' THEN 3 ELSE 4 END, created_at desc",
"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",
exact, exact, prefix, prefix,
))
} else {
@@ -265,9 +259,7 @@ func (r *MediaRepository) searchIndexEnabled(ctx context.Context) bool {
}
r.searchIndexOnce.Do(func() {
var count int64
// 用 Background 探测:sync.Once 只执行一次,若借用调用方的
// ctx 且恰好被取消,FTS 会被永久误判为不可用。
err := r.db.WithContext(context.Background()).
err := r.db.WithContext(ctx).
Raw(`SELECT COUNT(*) FROM sqlite_master WHERE name = 'media_search_fts'`).
Scan(&count).Error
r.searchIndexAvailable = err == nil && count > 0
+2 -36
View File
@@ -3,7 +3,6 @@ package repository
import (
"context"
"errors"
"time"
"gorm.io/gorm"
@@ -45,43 +44,10 @@ 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).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
})
return r.db.WithContext(ctx).Where("user_id = ?", p.UserID).
Assign(*p).FirstOrCreate(p).Error
})
}
@@ -52,40 +52,6 @@ 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
@@ -270,16 +236,6 @@ 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,39 +302,6 @@ 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
@@ -435,17 +402,6 @@ 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
@@ -653,36 +609,6 @@ 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
@@ -783,17 +709,6 @@ 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,
+1 -4
View File
@@ -12,7 +12,6 @@ import (
"go.uber.org/zap"
"github.com/truewhile/MeBox/internal/helper"
"github.com/truewhile/MeBox/internal/model"
"github.com/truewhile/MeBox/internal/repository"
)
@@ -51,8 +50,6 @@ func (a *AuditService) RecordBestEffort(userID, action, target, ip, detail strin
go func() {
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()
helper.Run(a.log, "audit.record", func() {
a.Record(ctx, userID, action, target, ip, detail)
})
a.Record(ctx, userID, action, target, ip, detail)
}()
}
+3 -6
View File
@@ -13,7 +13,6 @@ 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"
)
@@ -177,11 +176,9 @@ func (s *AuthService) touchLoginBestEffort(userID string) {
go func() {
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()
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))
}
})
if err := s.repo.User.TouchLogin(ctx, userID); err != nil && s.log != nil {
s.log.Debug("touch login delayed", zap.String("user_id", userID), zap.Error(err))
}
}()
}
+1 -10
View File
@@ -8,8 +8,6 @@ import (
"net/url"
"path"
"strings"
"sync"
"time"
)
// cloudDrive2Provider bridges CloudDrive2 through its WebDAV endpoint.
@@ -24,18 +22,11 @@ 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 {
+2 -3
View File
@@ -36,10 +36,9 @@ func (p *cloudDrive2Provider) List(ctx context.Context, dir string) ([]FileEntry
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return nil, p.decorateDAVStatusError(resp, target)
}
// 流式解码:超大目录(如上万条目的网盘目录)响应可能远超旧 4MB 截断上限,
// 直接 xml.Unmarshal 会截断报错;这里用 LimitReader(64MB) + Decoder 边读边解
body, _ := io.ReadAll(io.LimitReader(resp.Body, 4<<20))
var multi cloudDAVMultiStatus
if err := xml.NewDecoder(io.LimitReader(resp.Body, 64<<20)).Decode(&multi); err != nil {
if err := xml.Unmarshal(body, &multi); err != nil {
return nil, fmt.Errorf("%s: decode webdav: %w", p.name, err)
}
basePath := strings.TrimRight(p.base.EscapedPath(), "/")
+18 -46
View File
@@ -131,13 +131,10 @@ func (p *cloudDrive2Provider) openListAPIMove(ctx context.Context, source, targe
}
func (p *cloudDrive2Provider) openListAPIPost(ctx context.Context, apiPath string, payload any, action string) error {
_, 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 {
token, err := p.openListAPIToken(ctx)
if err != nil {
return err
}
body, _ := json.Marshal(payload)
req, err := http.NewRequestWithContext(ctx, http.MethodPost, p.openListAPIURL(apiPath), bytes.NewReader(body))
if err != nil {
@@ -154,9 +151,6 @@ func (p *cloudDrive2Provider) openListAPIPostWithToken(ctx context.Context, apiP
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)
}
@@ -206,43 +200,27 @@ func (p *cloudDrive2Provider) PutFile(ctx context.Context, remotePath string, r
}
// openListAPIPutFile 通过 OpenList /api/fs/form 上传(QMediaSync 同款契约:
// PUT + multipart + File-Path 头)。使用 io.Pipe + multipart.Writer 边写边发,
// 避免把整个文件读进内存。
// PUT + multipart + File-Path 头)。
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)
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)
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)
if err != nil {
// 关闭读端以释放仍在等待写入的后台 goroutine(其 Write 会立即失败返回)
_ = pr.Close()
return err
}
req.Header.Set("Authorization", token)
@@ -252,15 +230,9 @@ 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)
}
+7 -74
View File
@@ -4,50 +4,18 @@ import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"strings"
"time"
)
// 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
func (p *cloudDrive2Provider) listOpenListAPI(ctx context.Context, dir string) ([]FileEntry, error) {
token, err := p.openListAPIToken(ctx)
if err != nil {
return zero, err
return nil, 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)
@@ -77,9 +45,6 @@ func (p *cloudDrive2Provider) listOpenListAPIWithToken(ctx context.Context, dir,
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)
}
@@ -120,12 +85,10 @@ func (p *cloudDrive2Provider) listOpenListAPIWithToken(ctx context.Context, dir,
}
func (p *cloudDrive2Provider) resolveOpenListAPIDirect(ctx context.Context, fileRef string) (*DirectLink, error) {
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) {
token, err := p.openListAPIToken(ctx)
if err != nil {
return nil, err
}
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 {
@@ -142,9 +105,6 @@ func (p *cloudDrive2Provider) resolveOpenListAPIDirectWithToken(ctx context.Cont
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)
}
@@ -203,10 +163,6 @@ 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
@@ -214,30 +170,6 @@ 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,
@@ -272,6 +204,7 @@ func (p *cloudDrive2Provider) openListAPILogin(ctx context.Context) (string, err
if token == "" {
return "", fmt.Errorf("%s: api login returned empty token", p.name)
}
p.token = token
return token, nil
}
+5 -8
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.CurrentAccessToken()) == "" {
if strings.TrimSpace(p.c.AccessToken) == "" {
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.ModifiedAt(),
MTime: f.Utime,
PickCode: f.PickCode,
})
}
@@ -127,15 +127,12 @@ func (p *openAPI115Provider) PutFileNamed(ctx context.Context, parentCID, fileNa
if err := tmp.Close(); err != nil {
return fmt.Errorf("115: 关闭临时文件失败:%w", err)
}
// 重命名为目标文件名,保证上传到 115 后保留原始文件名。
// 重命名失败必须 fail fast:静默用随机临时名上传会导致 115 上的文件名
// 变成 mebox-upload-xxx,破坏元数据文件名契约。
// 重命名为目标文件名,保证上传到 115 后保留原始文件名
if fileName != "" && fileName != filepath.Base(tmpPath) {
namedPath := filepath.Join(filepath.Dir(tmpPath), fileName)
if err := os.Rename(tmpPath, namedPath); err != nil {
return fmt.Errorf("115: 重命名临时文件为 %s 失败:%w", fileName, err)
if err := os.Rename(tmpPath, namedPath); err == nil {
tmpPath = namedPath
}
tmpPath = namedPath
}
_, err = p.c.Upload(ctx, tmpPath, parentCID, "", "")
if err != nil {
+16 -95
View File
@@ -23,14 +23,9 @@ type OpenClient struct {
RefreshTokenStr string
executor *QueueExecutor
// OnTokenRefreshed 在 access_token 刷新成功后回调(参数为新令牌对),
// 供上层持久化新令牌使用;nil 安全,且在 tokenMu 释放后调用以避免死锁。
OnTokenRefreshed func(accessToken, refreshToken string)
// tokenMu 保护 AccessToken / RefreshTokenStr 的并发读写:业务请求中途
// access_token 失效时自动刷新重试,多 goroutine(同步列表 + 下载队列)
// 并发下只允许一次刷新进行。
tokenMu sync.RWMutex
// tokenMu 保护令牌刷新:业务请求中途 access_token 失效时自动刷新重试,
// 多 goroutine(同步列表 + 下载队列)并发下只允许一次刷新进行。
tokenMu sync.Mutex
}
// default115HTTPClient 创建带有防 405 重定向保护的 http.Client。
@@ -62,40 +57,12 @@ 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
@@ -224,14 +191,6 @@ 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
}
@@ -283,11 +242,8 @@ 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 {
// RLock 读取令牌,避免与刷新流程的写入产生数据竞争
if accessToken := c.currentAccessToken(); accessToken != "" {
req.Header.Set("Authorization", "Bearer "+accessToken)
}
if access && c.AccessToken != "" {
req.Header.Set("Authorization", "Bearer "+c.AccessToken)
}
return req, nil
}
@@ -305,57 +261,33 @@ 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()
// 在已持有写锁内读取当前 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
defer c.tokenMu.Unlock()
for attempt := 0; attempt < refreshAttempts; attempt++ {
token, err := c.doRefreshToken(refreshToken)
token, err := c.RefreshToken(c.RefreshTokenStr)
if err == nil {
c.setAuthTokenLocked(token.AccessToken, token.RefreshToken)
return token, true
c.SetAuthToken(token.AccessToken, token.RefreshToken)
return true
}
if IsRefreshTokenDead(err) {
c.setAuthTokenLocked("", "")
return nil, false
c.SetAuthToken("", "")
return false
}
// 可恢复失败:退避后重试。ctx 取消时立即放弃。
if attempt < refreshAttempts-1 {
select {
case <-ctx.Done():
return nil, false
return false
case <-time.After(refreshBackoff(attempt)):
}
}
}
return nil, false
return false
}
// refreshAttempts 是刷新 access_token 失败时的最大尝试次数(含首次)。
@@ -380,14 +312,7 @@ 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
@@ -399,16 +324,12 @@ func openList[T any](raw json.RawMessage) ([]T, error) {
return nil, fmt.Errorf("115: data 既不是对象也不是数组")
}
// openFirstList 取 data 的第一个元素;data 为空(null/空数组)时返回显式错误,
// 避免调用方拿到 (nil, nil) 后解引用空指针。
// openFirstList 取 data 的第一个元素。
func openFirstList[T any](raw json.RawMessage) (*T, error) {
items, err := openList[T](raw)
if err != nil {
if err != nil || len(items) == 0 {
return nil, err
}
if len(items) == 0 {
return nil, fmt.Errorf("115: data 为空数组")
}
return &items[0], nil
}
+1 -6
View File
@@ -317,18 +317,13 @@ 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 := oauthHTTPClient.Do(req)
resp, err := http.DefaultClient.Do(req)
if err != nil {
return nil, err
}
+12 -31
View File
@@ -300,11 +300,6 @@ 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
}
@@ -357,10 +352,6 @@ 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
}
@@ -368,30 +359,11 @@ func (c *OpenClient) GetToken(qrCode *QrCodeDataReturn) (*TokenData, error) {
// RefreshToken 刷新访问令牌。
func (c *OpenClient) RefreshToken(refreshToken string) (*TokenData, error) {
if refreshToken == "" {
refreshToken = c.currentRefreshToken()
refreshToken = c.RefreshTokenStr
}
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 {
@@ -401,9 +373,18 @@ func (c *OpenClient) doRefreshToken(refreshToken string) (*TokenData, error) {
return nil, err
}
if !resp.State {
return nil, NewOpenAPIResponseError(resp.Code, resp.Errno, resp.Message, resp.Error, "115 开放平台刷新访问凭证失败")
apiErr := NewOpenAPIResponseError(resp.Code, resp.Errno, resp.Message, resp.Error, "115 开放平台刷新访问凭证失败")
if IsRefreshTokenDead(apiErr) {
c.SetAuthToken("", "")
}
return nil, apiErr
}
return openFirstList[TokenData](resp.Data)
token, err := openFirstList[TokenData](resp.Data)
if err != nil {
return nil, err
}
c.SetAuthToken(token.AccessToken, token.RefreshToken)
return token, nil
}
// ─── 用户信息 ──────────────────────────────────────────────────────────────────
+37 -76
View File
@@ -10,7 +10,6 @@ import (
"errors"
"fmt"
"io"
"log"
"os"
"sort"
@@ -107,23 +106,14 @@ 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 结果。
// 任一失败路径(分片上传失败 / callback 校验失败 / Complete 失败 / 文件打开失败等)
// 都会经 defer 统一 AbortMultipartUpload 丢弃本次 Initiate 出的 multipart
// (abort 失败仅记日志),避免 OSS 分片永久泄漏;成功路径不 Abort。
func (u *OSSMultipartUploader) UploadFileWithResult(ctx context.Context, input OSSMultipartUploadInput) (result OSSMultipartUploadResult, err error) {
func (u *OSSMultipartUploader) UploadFileWithResult(ctx context.Context, input OSSMultipartUploadInput) (OSSMultipartUploadResult, 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 {
@@ -134,45 +124,28 @@ func (u *OSSMultipartUploader) UploadFileWithResult(ctx context.Context, input O
}
uploadId := input.UploadId
// ownUploadId 标记 uploadId 是否为本调用 Initiate 出来的:仅自建的
// multipart 在失败时由本函数 Abort;调用方显式传入的 uploadId(断点续传)
// 失败后保留现场,由调用方决定重试或清理。
ownUploadId := uploadId == ""
if ownUploadId {
initResult, initErr := u.client.InitiateMultipartUpload(ctx, &oss.InitiateMultipartUploadRequest{
if uploadId == "" {
initResult, err := 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 initErr != nil {
return OSSMultipartUploadResult{}, fmt.Errorf("初始化 OSS multipart 失败:%w", initErr)
if err != nil {
return OSSMultipartUploadResult{}, fmt.Errorf("初始化 OSS multipart 失败:%w", err)
}
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]UploadedPart)
if existingParts, listErr := u.ListUploadedParts(ctx, input.Bucket, input.Object, uploadId); listErr == nil {
existingPartMap := make(map[int32]int64)
existingParts, err := u.ListUploadedParts(ctx, input.Bucket, input.Object, uploadId)
if err == nil {
for _, part := range existingParts {
existingPartMap[part.PartNumber] = part
existingPartMap[part.PartNumber] = part.Size
}
}
@@ -191,20 +164,13 @@ func (u *OSSMultipartUploader) UploadFileWithResult(ctx context.Context, input O
if length < 0 {
length = 0
}
// 断点续传:分片已完整上传(大小一致即代表分片大小未变)时直接复用
// ListParts 返回的 ETag,跳过重传,也不再重复累加统计
if existing, ok := existingPartMap[int32(partNumber)]; ok && existing.Size == length && existing.ETag != "" {
if existingSize, ok := existingPartMap[int32(partNumber)]; ok && existingSize == length {
uploadedBytes += length
uploadedParts++
completeParts = append(completeParts, oss.UploadPart{
PartNumber: int32(partNumber),
ETag: oss.Ptr(existing.ETag),
})
continue
}
etag, uploadErr := u.uploadPartWithRetry(ctx, input, uploadId, int32(partNumber), file, offset, length)
if uploadErr != nil {
return OSSMultipartUploadResult{}, uploadErr
etag, err := u.uploadPartWithRetry(ctx, input, uploadId, int32(partNumber), file, offset, length)
if err != nil {
return OSSMultipartUploadResult{}, err
}
uploadedBytes += length
uploadedParts++
@@ -258,34 +224,29 @@ func (u *OSSMultipartUploader) UploadFileWithResult(ctx context.Context, input O
}, nil
}
// 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
// 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})
}
return parts, nil
}
+2 -6
View File
@@ -262,10 +262,7 @@ 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: 签名验证后失败")
@@ -274,8 +271,7 @@ func (c *OpenClient) Upload(ctx context.Context, filePath, parentCID, signKey, s
case UploadInitStatusNeedUpload:
// 真实上传:OSS multipart
default:
// 未知状态不能当成功返回(会静默丢文件),显式报错便于排查
return nil, fmt.Errorf("115: 未知的 upload/init 状态 %d", status)
return &UploadCompleteResult{FileId: initResult.FileId, PickCode: initResult.PickCode}, nil
}
if initResult.Bucket == "" || initResult.Object == "" {
+3 -15
View File
@@ -1,26 +1,14 @@
package cloud115
import (
"crypto/rand"
"fmt"
"math/big"
)
import "math/rand"
const randCharset = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
// RandomString 生成指定长度的密码学安全随机字符串(PKCE code_verifier、
// OAuth state 等安全敏感场景)。必须使用 crypto/rand:math/rand 未播种时
// 序列可预测,会造成 PKCE 防御失效。
// RandomString 生成指定长度的随机字符串(PKCE code_verifier 等)。
func RandomString(length int) string {
b := make([]byte, length)
max := big.NewInt(int64(len(randCharset)))
for i := range b {
n, err := rand.Int(rand.Reader, max)
if err != nil {
// 仅在系统熵源不可用时发生;静默降级为弱随机不可接受,直接暴露
panic(fmt.Errorf("115: 生成安全随机字符串失败:%w", err))
}
b[i] = randCharset[n.Int64()]
b[i] = randCharset[rand.Intn(len(randCharset))]
}
return string(b)
}
+1 -1
View File
@@ -77,7 +77,7 @@ func (c *CryptoService) Encrypt(plain string) string {
// Decrypt returns the plaintext for an encrypted value. Plaintext rows
// (no prefix) are returned unchanged.
func (c *CryptoService) Decrypt(value string) string {
if c == nil || value == "" || c.aead == nil {
if value == "" || c.aead == nil {
return value
}
if !strings.HasPrefix(value, encPrefix) {
+2 -6
View File
@@ -36,10 +36,6 @@ 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}
@@ -157,7 +153,7 @@ func (d *DLNAService) fetchDescription(ctx context.Context, location string) (*D
if err != nil {
return nil, err
}
resp, err := dlnaHTTPClient.Do(req)
resp, err := http.DefaultClient.Do(req)
if err != nil {
return nil, err
}
@@ -271,7 +267,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 := dlnaHTTPClient.Do(req)
resp, err := http.DefaultClient.Do(req)
if err != nil {
return err
}
+28 -84
View File
@@ -18,7 +18,6 @@ import (
"time"
"github.com/truewhile/MeBox/internal/config"
"github.com/truewhile/MeBox/internal/model"
"github.com/truewhile/MeBox/internal/repository"
"go.uber.org/zap"
)
@@ -149,17 +148,11 @@ 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
@@ -178,9 +171,6 @@ 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))
@@ -268,101 +258,55 @@ func (e *EmbyService) aggregatedSearch(ctx context.Context, p ItemsParams) (map[
if err != nil {
return nil, err
}
type remoteReply struct {
acct *model.StrmAccount
envelope map[string]any
type remoteResult struct {
items []any
}
mounts, aerr := e.remote.ListMounts(ctx)
replies := make([]*remoteReply, 0, len(mounts))
results := make([]remoteResult, 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
}
// 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
// 按挂载逐个搜索:搜索结果归属明确(伪装 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))
}
fetched[job.idx] = &remoteReply{acct: job.acct, envelope: remote}
}(job)
}
wg.Wait()
for _, r := range fetched {
if r != nil {
replies = append(replies, r)
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})
}
}
}
items := make([]any, 0, len(localItemsAsAny(local))+len(replies)*p.Limit)
items := make([]any, 0, len(localItemsAsAny(local))+len(results)*p.Limit)
items = append(items, localItemsAsAny(local)...)
for _, reply := range replies {
if err := e.mergeRemoteUserData(ctx, p.UserID, reply.envelope); err != nil {
return nil, err
}
items = append(items, remoteItemsAsAny(reply.envelope)...)
for _, res := range results {
items = append(items, res.items...)
}
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(ctx, &rows[i]))
key = stableEmbyID(embyVirtualSeriesPrefix, rows[i].LibraryID, e.seriesNameForMedia(&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 90
return 15
}
return e.cfg.Cache.MediaTTLSeconds
}
+9 -170
View File
@@ -21,9 +21,6 @@ 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
@@ -31,14 +28,6 @@ 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 {
@@ -98,8 +87,7 @@ func (e *EmbyService) Item(ctx context.Context, mediaID, userID string) (map[str
pos = h.PositionMs
}
}
// 单条目 payload 内部对库类型/series 标题有多次查找,挂请求级缓存合并。
return e.itemPayload(e.withPayloadCache(ctx), m, fav, pos), nil
return e.itemPayload(ctx, m, fav, pos), nil
}
// LatestItems 最近添加,全库或指定库。远程媒体库(parentID 带前缀)直接透传远程。
@@ -113,9 +101,6 @@ 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
@@ -178,7 +163,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(ctx, rows)
groups := e.seriesGroupsFromMedia(rows)
sortSeriesGroups(groups, ItemsParams{SortBy: "premieredate", SortOrder: "Descending"})
if len(groups) > limit {
groups = groups[:limit]
@@ -195,137 +180,6 @@ 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 {
@@ -338,12 +192,10 @@ 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").Limit(200).Find(&hist).Error; err != nil {
Order("watched_at desc").Find(&hist).Error; err != nil {
return nil, err
}
if len(hist) == 0 {
@@ -369,31 +221,18 @@ func (e *EmbyService) resumableItems(ctx context.Context, p ItemsParams) (map[st
}
}
// 分页前置:凑满 StartIndex+Limit 条即停,不再为「总数」逐条发远程
// 详情 GET(此前每条远程记录一次串行 GET,远程慢时请求挂起数分钟)。
// 总数用候选行数(本地过滤后 + 远程候选),对继续观看行的翻页语义
// 足够准确。
needed := p.StartIndex + p.Limit
items := make([]map[string]any, 0, p.Limit)
localTotal, remoteTotal := 0, 0
items := make([]map[string]any, 0, len(hist))
for _, h := range hist {
if m, ok := byID[h.MediaID]; ok {
if p.ParentID != "" && m.LibraryID != p.ParentID && m.SeriesID != p.ParentID {
continue
}
localTotal++
if produced := len(items); produced < needed {
items = append(items, e.itemPayload(ctx, m, false, h.PositionMs))
}
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 {
@@ -414,7 +253,7 @@ func (e *EmbyService) resumableItems(ctx context.Context, p ItemsParams) (map[st
items = append(items, item)
}
total := int64(localTotal + remoteTotal)
total := int64(len(items))
if p.StartIndex >= len(items) {
return map[string]any{"Items": []map[string]any{}, "TotalRecordCount": total, "StartIndex": p.StartIndex}, nil
}
@@ -431,9 +270,9 @@ func (e *EmbyService) itemPayload(ctx context.Context, m *model.Media, fav bool,
seasonID := ""
if e.mediaShouldBeEpisode(ctx, m) {
itemType = "Episode"
seriesID = e.seriesIDForMedia(ctx, m)
seriesName = e.seriesNameForMedia(ctx, m)
seasonID = e.seasonIDForMedia(ctx, m)
seriesID = e.seriesIDForMedia(m)
seriesName = e.seriesNameForMedia(m)
seasonID = e.seasonIDForMedia(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", "datelastmediaadded", "datelastcontentadded", "communityrating":
case "sortname", "name", "premieredate", "productionyear", "datecreated", "communityrating":
return key
case "dateplayed":
if resumeFilter {
+4 -7
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", "datelastmediaadded", "datelastcontentadded":
order = "media.created_at"
orderIncludesDirection = false
case "datecreated":
order = "media.created_at"
orderIncludesDirection = false
case "dateplayed":
order = "resume.watched_at"
orderIncludesDirection = false
@@ -149,9 +149,6 @@ 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{}
@@ -243,7 +240,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(ctx, rows)
groups := e.seriesGroupsFromMedia(rows)
sortSeriesGroups(groups, p)
total := len(groups)
items := make([]map[string]any, 0, minInt(p.Limit, len(groups)))
-70
View File
@@ -1,70 +0,0 @@
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")
}
}
+7 -8
View File
@@ -65,7 +65,7 @@ func (e *EmbyService) movieLibraryItems(ctx context.Context, p ItemsParams) (map
return nil, err
}
}
seriesGroups := e.seriesGroupsFromMedia(ctx, episodicRows)
seriesGroups := e.seriesGroupsFromMedia(episodicRows)
// 真正的电影 -> Movie 项(剔除剧集结构行)。
movieQ := apply(e.repo.DB.WithContext(ctx).Model(&model.Media{}))
@@ -135,11 +135,10 @@ func (e *EmbyService) libraryIsEpisodic(ctx context.Context, libraryID string) (
if strings.TrimSpace(libraryID) == "" {
return false, nil
}
// 走请求级缓存(若有),避免同一请求内对同一库重复查表。
if typ, ok, err := e.payloadLibraryType(ctx, libraryID); err != nil {
if lib, err := e.repo.Library.FindByID(ctx, libraryID); err != nil {
return false, err
} else if ok {
return embyLibraryTypeIsEpisodic(typ), nil
} else if lib != nil {
return embyLibraryTypeIsEpisodic(lib.Type), nil
}
var count int64
err := e.repo.DB.WithContext(ctx).Model(&model.Media{}).
@@ -152,11 +151,11 @@ func (e *EmbyService) mediaBelongsToEpisodicLibrary(ctx context.Context, m *mode
if e == nil || m == nil || strings.TrimSpace(m.LibraryID) == "" {
return false
}
typ, ok, err := e.payloadLibraryType(ctx, m.LibraryID)
if err != nil || !ok {
lib, err := e.repo.Library.FindByID(ctx, m.LibraryID)
if err != nil || lib == nil {
return false
}
return embyLibraryTypeIsEpisodic(typ)
return embyLibraryTypeIsEpisodic(lib.Type)
}
func (e *EmbyService) mediaShouldBeEpisode(ctx context.Context, m *model.Media) bool {
-175
View File
@@ -1,175 +0,0 @@
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,9 +24,6 @@ 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
+24 -124
View File
@@ -26,7 +26,6 @@ import (
"regexp"
"strconv"
"strings"
"sync"
"time"
"go.uber.org/zap"
@@ -74,7 +73,6 @@ type EmbyRemoteService struct {
repo *repository.Container
crypto *CryptoService
http *http.Client
stream *http.Client // 流式代理专用(视频/字幕),无整体 Timeout
cache *RuntimeCacheService
}
@@ -89,12 +87,6 @@ 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},
},
}
}
@@ -324,65 +316,6 @@ 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{}
@@ -497,28 +430,19 @@ func (r *EmbyRemoteService) ensureTokenOnLine(ctx context.Context, acct *model.S
return 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 {
// persistToken 把认证得到的 token / user id 加密写回账号配置(下次请求免登录)。
func (r *EmbyRemoteService) persistToken(ctx context.Context, acct *model.StrmAccount, cfg *EmbyRemoteConfig) error {
if acct == 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)
}
if mutate != nil {
mutate(raw)
raw["api_key"] = r.crypto.Encrypt(cfg.Token)
raw["remote_user_id"] = cfg.RemoteUserID
if strings.TrimSpace(raw["username"]) == "" {
raw["username"] = cfg.Username
}
data, err := json.Marshal(raw)
if err != nil {
@@ -528,20 +452,6 @@ func (r *EmbyRemoteService) updateAccountConfig(ctx context.Context, acct *model
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 {
@@ -559,10 +469,6 @@ 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 请求失败")
@@ -592,28 +498,22 @@ func (r *EmbyRemoteService) doGetOnLine(ctx context.Context, acct *model.StrmAcc
if err != nil {
return fmt.Errorf("请求远程 Emby 失败: %w", err)
}
// 读 8MB+1 以区分"刚好 8MB"与"被截断":截断的 JSON 会让
// Unmarshal 报 unexpected end,难以定位;这里显式报错。
data, readErr := io.ReadAll(io.LimitReader(resp.Body, (8<<20)+1))
data, readErr := io.ReadAll(io.LimitReader(resp.Body, 8<<20))
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 = ""
if err := r.ensureTokenOnLine(ctx, acct, cfg); err != nil {
return fmt.Errorf("认证重试失败: %w", err)
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)
}
master.Token = cfg.Token
master.RemoteUserID = cfg.RemoteUserID
continue
}
if resp.StatusCode >= 300 {
@@ -653,7 +553,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.remoteConfigWithToken(ctx, acct)
cfg, err := r.configOf(acct)
if err != nil {
return nil, err
}
@@ -688,7 +588,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.remoteConfigWithToken(ctx, acct)
cfg, err := r.configOf(acct)
if err != nil {
return nil, err
}
@@ -733,7 +633,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.remoteConfigWithToken(ctx, acct)
cfg, err := r.configOf(acct)
if err != nil {
return nil, err
}
@@ -766,7 +666,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.remoteConfigWithToken(ctx, acct)
cfg, err := r.configOf(acct)
if err != nil {
return nil, err
}
@@ -781,7 +681,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.remoteConfigWithToken(ctx, acct)
cfg, err := r.configOf(acct)
if err != nil {
return nil, err
}
@@ -802,7 +702,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.remoteConfigWithToken(ctx, acct)
cfg, err := r.configOf(acct)
if err != nil {
return nil, err
}
@@ -988,7 +888,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.stream.Do(upstream)
resp, err := r.http.Do(upstream)
if err != nil {
return fmt.Errorf("连接远程 Emby 视频流失败: %w", err)
}
@@ -1059,7 +959,7 @@ func (r *EmbyRemoteService) proxySubtitleOnLine(ctx context.Context, w http.Resp
return err
}
upstream.Header.Set("X-Emby-Token", cfg.Token)
resp, err := r.stream.Do(upstream)
resp, err := r.http.Do(upstream)
if err != nil {
return fmt.Errorf("连接远程 Emby 字幕流失败: %w", err)
}

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