Compare commits

...

3 Commits

Author SHA1 Message Date
truewhile 3aa8662c45 优化,日志脱敏 2026-09-14 15:12:42 +08:00
truewhile 5c85478883 1 2026-09-14 12:33:59 +08:00
truewhile fd54d42db7 优化 2026-09-14 00:02:46 +08:00
56 changed files with 1796 additions and 1204 deletions
+1 -1
View File
@@ -115,7 +115,7 @@ jobs:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '20'
node-version-file: '.nvmrc'
cache: 'npm'
cache-dependency-path: web/package-lock.json
- name: Install
+2 -2
View File
@@ -48,7 +48,7 @@ jobs:
# before the Go toolchain touches the web package.
- uses: actions/setup-node@v4
with:
node-version: '20'
node-version-file: '.nvmrc'
cache: 'npm'
cache-dependency-path: web/package-lock.json
- name: Build SPA
@@ -130,4 +130,4 @@ jobs:
build-args: |
VERSION=${{ steps.version.outputs.full_version }}
cache-from: type=gha
cache-to: type=gha,mode=max
cache-to: type=gha,mode=max
+2 -2
View File
@@ -22,7 +22,7 @@ jobs:
# before the Go toolchain touches the `web` package.
- uses: actions/setup-node@v4
with:
node-version: '20'
node-version-file: '.nvmrc'
cache: 'npm'
cache-dependency-path: web/package-lock.json
- name: Build SPA
@@ -51,7 +51,7 @@ jobs:
- uses: actions/setup-node@v4
with:
node-version: '20'
node-version-file: '.nvmrc'
cache: 'npm'
cache-dependency-path: web/package-lock.json
+1
View File
@@ -0,0 +1 @@
20.19.0
+2 -2
View File
@@ -2,7 +2,7 @@
# =============================================================================
# Multi-architecture build for MeBox.
#
# Stage 1 (frontend) : Node 20 -> static SPA bundle
# Stage 1 (frontend) : Node 20.19+ -> static SPA bundle
# Stage 2 (backend) : Go 1.25 -> single static binary (CGO_ENABLED=0)
# Stage 3 (runtime) : Alpine 3.23 -> ffmpeg + tzdata + non-root user
#
@@ -15,7 +15,7 @@
# =============================================================================
# ---- Stage 1: frontend (always build on the host architecture) -------------
FROM --platform=$BUILDPLATFORM node:20-alpine AS frontend
FROM --platform=$BUILDPLATFORM node:20.19-alpine AS frontend
ARG NPM_CONFIG_REGISTRY=https://registry.npmjs.org/
WORKDIR /app/web
COPY web/package*.json ./
+6
View File
@@ -199,6 +199,11 @@ environment:
**硬链接失败(cross-device link)?**
硬链接要求源与目标在同一文件系统/子卷;跨盘、跨 btrfs 子卷或网盘挂载时请改用复制或软链接。
**日志保留时间太短?**
默认应用日志为 `20MB x 5`,容器 stdout 日志为 `20m x 3`。排障时可在 compose 中调大
`MEBOX_LOGGING_MAX_SIZE_MB`、`MEBOX_LOGGING_MAX_BACKUPS` 与服务的 `logging.options.max-size/max-file`。
**第三方播放器连不上?**
确认地址为 `http://IP:18080`,使用 MeBox 用户账号;反代部署需正确配置外部 URL 与 HTTPS 头。
@@ -207,6 +212,7 @@ environment:
## 开发构建
后端通过 `go:embed` 嵌入 `web/dist`,**编译前必须先构建前端**。
前端构建要求 Node.js `20.19+` 或 `22.12+`。
```bash
npm --prefix web ci
+10 -10
View File
@@ -81,8 +81,8 @@ services:
MEBOX_LOGGING_LEVEL: info
MEBOX_LOGGING_FORMAT: console
MEBOX_LOGGING_OUTPUT_PATH: /data/logs
MEBOX_LOGGING_MAX_SIZE_MB: "50"
MEBOX_LOGGING_MAX_BACKUPS: "20"
MEBOX_LOGGING_MAX_SIZE_MB: "20"
MEBOX_LOGGING_MAX_BACKUPS: "5"
MEBOX_LOGGING_MAX_AGE_DAYS: "30"
MEBOX_DATABASE_TYPE: postgres
@@ -124,8 +124,8 @@ services:
logging:
driver: json-file
options:
max-size: "50m"
max-file: "10"
max-size: "20m"
max-file: "3"
postgres:
image: postgres:16-alpine
@@ -147,8 +147,8 @@ services:
logging:
driver: json-file
options:
max-size: "50m"
max-file: "10"
max-size: "20m"
max-file: "3"
redis:
image: redis:7-alpine
@@ -172,8 +172,8 @@ services:
logging:
driver: json-file
options:
max-size: "50m"
max-file: "10"
max-size: "20m"
max-file: "3"
opensearch:
image: opensearchproject/opensearch:2
@@ -196,5 +196,5 @@ services:
logging:
driver: json-file
options:
max-size: "50m"
max-file: "10"
max-size: "20m"
max-file: "3"
+4 -4
View File
@@ -64,8 +64,8 @@ services:
MEBOX_LOGGING_LEVEL: info
MEBOX_LOGGING_FORMAT: console
MEBOX_LOGGING_OUTPUT_PATH: /data/logs
MEBOX_LOGGING_MAX_SIZE_MB: "50"
MEBOX_LOGGING_MAX_BACKUPS: "20"
MEBOX_LOGGING_MAX_SIZE_MB: "20"
MEBOX_LOGGING_MAX_BACKUPS: "5"
MEBOX_LOGGING_MAX_AGE_DAYS: "30"
extra_hosts:
@@ -82,5 +82,5 @@ services:
logging:
driver: json-file
options:
max-size: "50m"
max-file: "10"
max-size: "20m"
max-file: "3"
+8 -8
View File
@@ -76,8 +76,8 @@ services:
MEBOX_LOGGING_LEVEL: info
MEBOX_LOGGING_FORMAT: console
MEBOX_LOGGING_OUTPUT_PATH: /data/logs
MEBOX_LOGGING_MAX_SIZE_MB: "50"
MEBOX_LOGGING_MAX_BACKUPS: "20"
MEBOX_LOGGING_MAX_SIZE_MB: "20"
MEBOX_LOGGING_MAX_BACKUPS: "5"
MEBOX_LOGGING_MAX_AGE_DAYS: "30"
MEBOX_DATABASE_TYPE: postgres
@@ -113,8 +113,8 @@ services:
logging:
driver: json-file
options:
max-size: "50m"
max-file: "10"
max-size: "20m"
max-file: "3"
postgres:
image: postgres:16-alpine
@@ -136,8 +136,8 @@ services:
logging:
driver: json-file
options:
max-size: "50m"
max-file: "10"
max-size: "20m"
max-file: "3"
redis:
image: redis:7-alpine
@@ -161,5 +161,5 @@ services:
logging:
driver: json-file
options:
max-size: "50m"
max-file: "10"
max-size: "20m"
max-file: "3"
+6 -6
View File
@@ -92,8 +92,8 @@ services:
MEBOX_LOGGING_LEVEL: info
MEBOX_LOGGING_FORMAT: console
MEBOX_LOGGING_OUTPUT_PATH: /data/logs
MEBOX_LOGGING_MAX_SIZE_MB: "50"
MEBOX_LOGGING_MAX_BACKUPS: "20"
MEBOX_LOGGING_MAX_SIZE_MB: "20"
MEBOX_LOGGING_MAX_BACKUPS: "5"
MEBOX_LOGGING_MAX_AGE_DAYS: "30"
# 轻量模式默认只使用 PostgreSQL,适合大多数 NAS。
@@ -141,8 +141,8 @@ services:
logging:
driver: json-file
options:
max-size: "50m"
max-file: "10"
max-size: "20m"
max-file: "3"
postgres:
image: postgres:16-alpine
@@ -166,5 +166,5 @@ services:
logging:
driver: json-file
options:
max-size: "50m"
max-file: "10"
max-size: "20m"
max-file: "3"
+8 -8
View File
@@ -12,16 +12,16 @@ require (
github.com/google/uuid v1.6.0
github.com/gorilla/websocket v1.5.3
github.com/metatube-community/metatube-sdk-go v1.4.0
github.com/redis/go-redis/v9 v9.7.0
github.com/redis/go-redis/v9 v9.7.3
github.com/shirou/gopsutil/v3 v3.24.5
github.com/spf13/viper v1.18.2
github.com/stretchr/testify v1.11.1
github.com/ulikunitz/xz v0.5.12
github.com/ulikunitz/xz v0.5.15
go.uber.org/zap v1.27.0
golang.org/x/crypto v0.49.0
golang.org/x/image v0.37.0
golang.org/x/sync v0.20.0
golang.org/x/sys v0.42.0
golang.org/x/image v0.45.0
golang.org/x/sync v0.22.0
golang.org/x/sys v0.47.0
golang.org/x/time v0.15.0
gopkg.in/yaml.v3 v3.0.1
gorm.io/driver/postgres v1.6.0
@@ -51,7 +51,7 @@ require (
github.com/hashicorp/hcl v1.0.0 // indirect
github.com/jackc/pgpassfile v1.0.0 // indirect
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
github.com/jackc/pgx/v5 v5.8.0 // indirect
github.com/jackc/pgx/v5 v5.9.2 // indirect
github.com/jackc/puddle/v2 v2.2.2 // indirect
github.com/jinzhu/inflection v1.0.0 // indirect
github.com/jinzhu/now v1.1.5 // indirect
@@ -69,7 +69,7 @@ require (
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/quic-go/quic-go v0.59.1 // 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
@@ -89,7 +89,7 @@ require (
golang.org/x/arch v0.25.0 // indirect
golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546 // indirect
golang.org/x/net v0.52.0 // indirect
golang.org/x/text v0.35.0 // indirect
golang.org/x/text v0.41.0 // indirect
google.golang.org/protobuf v1.36.11 // indirect
gopkg.in/ini.v1 v1.67.0 // indirect
modernc.org/libc v1.70.0 // indirect
+20 -20
View File
@@ -77,8 +77,8 @@ github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsI
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
github.com/jackc/pgx/v5 v5.8.0 h1:TYPDoleBBme0xGSAX3/+NujXXtpZn9HBONkQC7IEZSo=
github.com/jackc/pgx/v5 v5.8.0/go.mod h1:QVeDInX2m9VyzvNeiCJVjCkNFqzsNb43204HshNSZKw=
github.com/jackc/pgx/v5 v5.9.2 h1:3ZhOzMWnR4yJ+RW1XImIPsD1aNSz4T4fyP7zlQb56hw=
github.com/jackc/pgx/v5 v5.9.2/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4=
github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E=
@@ -121,10 +121,10 @@ github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c h1:ncq/mPwQF
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/quic-go/quic-go v0.59.1 h1:0Gmua0HW1Tv7ANR7hUYwRyD0MG5OJfgvYSZasGZzBic=
github.com/quic-go/quic-go v0.59.1/go.mod h1:upnsH4Ju1YkqpLXC305eW3yDZ4NfnNbmQRCMWS58IKU=
github.com/redis/go-redis/v9 v9.7.3 h1:YpPyAayJV+XErNsatSElgRZZVCwXX9QzkKYNvO7x0wM=
github.com/redis/go-redis/v9 v9.7.3/go.mod h1:bGUrSggJ9X9GUmZpZNEOQKaANxSGgOEBRltRTZHSvrA=
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.11.0 h1:cWPaGQEPrBb5/AsnsZesgZZ9yb1OQ+GOISoDNXVBh4M=
@@ -171,8 +171,8 @@ github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS
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/ulikunitz/xz v0.5.12 h1:37Nm15o69RwBkXM0J6A5OlE67RZTfzUxTj8fB3dfcsc=
github.com/ulikunitz/xz v0.5.12/go.mod h1:nbz6k7qbPmH4IRqmfOplQw/tblSgqTqBwxkY0oWt/14=
github.com/ulikunitz/xz v0.5.15 h1:9DNdB5s+SgV3bQ2ApL10xRc35ck0DuIX/isZvIk+ubY=
github.com/ulikunitz/xz v0.5.15/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=
@@ -192,28 +192,28 @@ golang.org/x/crypto v0.49.0/go.mod h1:ErX4dUh2UM+CFYiXZRTcMpEcN8b/1gxEuv3nODoYtC
golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546 h1:mgKeJMpvi0yx/sU5GsxQ7p6s2wtOnGAHZWCHUM4KGzY=
golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546/go.mod h1:j/pmGrbnkbPtQfxEe5D0VQhZC6qKbfKifgD0oM7sR70=
golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
golang.org/x/image v0.37.0 h1:ZiRjArKI8GwxZOoEtUfhrBtaCN+4b/7709dlT6SSnQA=
golang.org/x/image v0.37.0/go.mod h1:/3f6vaXC+6CEanU4KJxbcUZyEePbyKbaLoDOe4ehFYY=
golang.org/x/mod v0.33.0 h1:tHFzIWbBifEmbwtGz65eaWyGiGZatSrT9prnU8DbVL8=
golang.org/x/mod v0.33.0/go.mod h1:swjeQEj+6r7fODbD2cqrnje9PnziFuw4bmLbBZFrQ5w=
golang.org/x/image v0.45.0 h1:FMb1nTbH5H9vF55SriQHgFw5GnNL9Jg6L25BwXKzhB0=
golang.org/x/image v0.45.0/go.mod h1:n62x/7RqlwXDvGsSU4u6IUTUf6KghUZ9Bt7cG/T9Fx4=
golang.org/x/mod v0.38.0 h1:MECBjubtXD7yj4HrhIUcywNaGeNVUdfVnxmPajOk4yk=
golang.org/x/mod v0.38.0/go.mod h1:V6Xz0pq8TQ3dGqVQ1FVHuelZpAL0uNhSkk9ogYP3c40=
golang.org/x/net v0.52.0 h1:He/TN1l0e4mmR3QqHMT2Xab3Aj3L9qjbhRm78/6jrW0=
golang.org/x/net v0.52.0/go.mod h1:R1MAz7uMZxVMualyPXb+VaqGSa3LIaUqk0eEt3w36Sw=
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek=
golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201204225414-ed752295db88/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.11.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo=
golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8=
golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA=
golang.org/x/text v0.41.0 h1:vz/seA0lnX87Othu2f/0L24RcgrXD9/YFTSuGjj3rH8=
golang.org/x/text v0.41.0/go.mod h1:jvf1O8ajNzZqhSrQBPbutR/EB83Cc0CFrezNQIwbb5M=
golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U=
golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno=
golang.org/x/tools v0.43.0 h1:12BdW9CeB3Z+J/I/wj34VMl8X+fEXBxVR90JeMX5E7s=
golang.org/x/tools v0.43.0/go.mod h1:uHkMso649BX2cZK6+RpuIPXS3ho2hZo4FVwfoy1vIk0=
golang.org/x/tools v0.48.0 h1:3+hClM1aLL5mjMKm5ovokw9epgRXPuu2tILgismM6RE=
golang.org/x/tools v0.48.0/go.mod h1:08xX0orndb/F7jJxGDicx061tyd5pcMto75YMAXr6lk=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
+6 -1
View File
@@ -127,5 +127,10 @@ func (z zapStdLogger) Printf(format string, args ...interface{}) {
if z.log == nil {
return
}
z.log.Sugar().Infof(format, args...)
message := fmt.Sprintf(format, args...)
if strings.Contains(strings.ToLower(message), "context canceled") {
z.log.Debug(message)
return
}
z.log.Info(message)
}
+5
View File
@@ -99,6 +99,11 @@ func ensurePerformanceIndexes(db *gorm.DB) error {
`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`,
`CREATE INDEX IF NOT EXISTS idx_media_library_series_active ON media(library_id, series_id, season_num, episode_num) WHERE deleted_at IS NULL`,
`CREATE INDEX IF NOT EXISTS idx_media_library_tmdb_active ON media(library_id, tm_db_id) WHERE deleted_at IS NULL`,
`CREATE INDEX IF NOT EXISTS idx_media_library_bangumi_active ON media(library_id, bangumi_id) WHERE deleted_at IS NULL`,
`CREATE INDEX IF NOT EXISTS idx_media_library_douban_active ON media(library_id, douban_id) WHERE deleted_at IS NULL`,
`CREATE INDEX IF NOT EXISTS idx_media_library_thetvdb_active ON media(library_id, thetvdb_id) WHERE deleted_at IS NULL`,
`CREATE INDEX IF NOT EXISTS idx_favorites_user_media_active ON favorites(user_id, media_id) WHERE deleted_at IS NULL`,
`CREATE INDEX IF NOT EXISTS idx_playback_histories_user_media_active ON playback_histories(user_id, media_id, watched_at DESC) WHERE deleted_at IS NULL`,
`CREATE INDEX IF NOT EXISTS idx_playback_histories_resume_active ON playback_histories(user_id, completed, watched_at DESC) WHERE deleted_at IS NULL`,
+1
View File
@@ -19,6 +19,7 @@ func Register(r *gin.Engine, cfg *config.Config, log *zap.Logger, svc *service.C
api.Use(middleware.GzipAPI())
{
api.GET("/health", healthCheck)
api.HEAD("/health", healthCheck)
api.GET("/version", versionInfo)
api.GET("/public/ui-config", publicUIConfigHandler(svc))
+10 -6
View File
@@ -33,10 +33,10 @@ type createLibraryReq struct {
// 统一结构(远程库附加 is_remote_emby / remote_source 只读标记)。
type webLibraryPayload struct {
model.Library
IsRemoteEmby bool `json:"is_remote_emby,omitempty"`
RemoteSource string `json:"remote_source,omitempty"`
Total int64 `json:"total,omitempty"`
Cards []service.SeriesCard `json:"cards,omitempty"`
IsRemoteEmby bool `json:"is_remote_emby,omitempty"`
RemoteSource string `json:"remote_source,omitempty"`
Total int64 `json:"total,omitempty"`
Cards []service.SeriesCardView `json:"cards,omitempty"`
}
// remoteLibraryItemTypes 远程库内容拉取时按 CollectionType 过滤直属条目,
@@ -115,7 +115,11 @@ func listLibrariesHandler(svc *service.Container) gin.HandlerFunc {
return
}
for _, p := range previews {
out = append(out, webLibraryPayload{Library: p.Library, Total: p.Total, Cards: p.Cards})
out = append(out, webLibraryPayload{
Library: p.Library,
Total: p.Total,
Cards: service.NewSeriesCardViews(p.Cards),
})
}
} else {
visibility := mediaVisibilityForRequest(c, svc)
@@ -202,7 +206,7 @@ func listLibrariesHandler(svc *service.Container) gin.HandlerFunc {
remotePayloads[i].Total = total
}
if cards, err := svc.EmbyRemote.RemoteLatestCards(ctx, tmpMount, acct, v.RemoteID, limit); err == nil {
remotePayloads[i].Cards = cards
remotePayloads[i].Cards = service.NewSeriesCardViews(cards)
}
})
}()
+49
View File
@@ -43,6 +43,7 @@ type MediaQueryFilter struct {
IncludeNSFW bool
AllowedLibraryIDs []string
HiddenLibraryIDs []string
SeriesID string
}
func applyMediaQueryFilter(q *gorm.DB, filter MediaQueryFilter) *gorm.DB {
@@ -55,6 +56,9 @@ func applyMediaQueryFilter(q *gorm.DB, filter MediaQueryFilter) *gorm.DB {
if len(filter.AllowedLibraryIDs) > 0 {
q = q.Where("library_id IN ?", filter.AllowedLibraryIDs)
}
if seriesID := strings.TrimSpace(filter.SeriesID); seriesID != "" {
q = q.Where("series_id = ?", seriesID)
}
return q
}
@@ -120,6 +124,47 @@ func (r *MediaRepository) ListAllByLibrariesFilteredNoCount(ctx context.Context,
return items, err
}
// ListVersionCandidates loads a bounded candidate set for version grouping
// using the strongest identity stored on the row. Returning ok=false keeps the
// caller's full-library fallback for rows without external IDs or SeriesID.
func (r *MediaRepository) ListVersionCandidates(ctx context.Context, libraryIDs []string, media model.Media, limit int) ([]model.Media, bool, error) {
items := make([]model.Media, 0)
if len(libraryIDs) == 0 {
return items, false, nil
}
if limit <= 0 {
limit = 5000
}
q := r.db.WithContext(ctx).Model(&model.Media{})
if len(libraryIDs) == 1 {
q = q.Where("library_id = ?", libraryIDs[0])
} else {
q = q.Where("library_id IN ?", libraryIDs)
}
found := true
switch {
case strings.TrimSpace(media.SeriesID) != "":
q = q.Where("series_id = ?", strings.TrimSpace(media.SeriesID))
case media.TMDbID > 0:
q = q.Where("tm_db_id = ?", media.TMDbID)
case media.BangumiID > 0:
q = q.Where("bangumi_id = ?", media.BangumiID)
case strings.TrimSpace(media.DoubanID) != "":
q = q.Where("douban_id = ?", strings.TrimSpace(media.DoubanID))
case strings.TrimSpace(media.TheTVDBID) != "":
q = q.Where("thetvdb_id = ?", strings.TrimSpace(media.TheTVDBID))
default:
found = false
}
if !found {
return items, false, nil
}
err := q.Order("release_date DESC, year DESC, updated_at DESC, created_at DESC, id DESC").
Limit(limit).
Find(&items).Error
return items, true, err
}
func (r *MediaRepository) listByLibrariesFiltered(ctx context.Context, libraryIDs []string, offset, limit int, filter MediaQueryFilter, withCount bool) ([]model.Media, int64, error) {
var items []model.Media
var total int64
@@ -216,6 +261,10 @@ func mediaQueryFilterSQL(filter MediaQueryFilter) (string, []interface{}) {
parts = append(parts, "library_id IN ?")
args = append(args, filter.AllowedLibraryIDs)
}
if seriesID := strings.TrimSpace(filter.SeriesID); seriesID != "" {
parts = append(parts, "series_id = ?")
args = append(args, seriesID)
}
return strings.Join(parts, " AND "), args
}
@@ -146,6 +146,9 @@ func applyQualifiedMediaQueryFilter(q *gorm.DB, filter MediaQueryFilter) *gorm.D
if len(filter.AllowedLibraryIDs) > 0 {
q = q.Where("media.library_id IN ?", filter.AllowedLibraryIDs)
}
if seriesID := strings.TrimSpace(filter.SeriesID); seriesID != "" {
q = q.Where("media.series_id = ?", seriesID)
}
return q
}
+8 -4
View File
@@ -1423,7 +1423,9 @@ func (s *DanmakuService) fetchCommentWithFallback(ctx context.Context, primary,
}
lastErr = err
if i < len(bases)-1 {
s.log.Warn("danmaku comment fetch failed on configured source, falling back to official", zap.String("source", base), zap.Error(err))
s.log.Warn("danmaku comment fetch failed on configured source, falling back to official",
zap.String("source", redactSensitiveURL(base)),
zap.Error(redactSensitiveError(err)))
}
}
return "", "auto", lastErr
@@ -1439,7 +1441,9 @@ func (s *DanmakuService) searchCandidatesWithSource(ctx context.Context, configu
if err == nil {
return candidates, configured, nil
}
s.log.Warn("danmaku search failed on configured source, falling back to official", zap.String("source", configured), zap.Error(err))
s.log.Warn("danmaku search failed on configured source, falling back to official",
zap.String("source", redactSensitiveURL(configured)),
zap.Error(redactSensitiveError(err)))
}
candidates, err := s.searchCandidates(ctx, official, name, episode)
return candidates, official, err
@@ -1573,9 +1577,9 @@ func (s *DanmakuService) lookupConfiguredEpisodes(ctx context.Context, configure
candidates, err := s.searchCandidates(ctx, configured, m.AnimeTitle, episodeNum)
if err != nil {
s.log.Debug("danmaku configured lookup failed",
zap.String("source", configured),
zap.String("source", redactSensitiveURL(configured)),
zap.String("anime", m.AnimeTitle),
zap.Error(err))
zap.Error(redactSensitiveError(err)))
return nil, false
}
matched := matchDanmakuEpisodes(candidates, episodeNum, m.EpisodeTitle)
+2 -1
View File
@@ -69,7 +69,8 @@ type EmbyService struct {
// latestFlight collapses the homepage stampede: clients request Latest
// for every library at once, and a shared expiry used to rebuild each
// library in parallel.
latestFlight singleflight.Group
latestFlight singleflight.Group
latestRefresh sync.Map
tmdb *TMDbProvider
adult *AdultProvider
+49 -12
View File
@@ -130,11 +130,27 @@ func (e *EmbyService) LatestItems(ctx context.Context, userID, parentID string,
limit = 20
}
cacheKey := e.embyLatestCacheKey(userID, parentID, limit)
if items, ok := e.cachedLatestItems(ctx, cacheKey); ok {
if items, stale, ok := e.cachedLatestItemsWithStale(ctx, cacheKey); ok {
if stale {
e.refreshLatestItemsAsync(cacheKey, userID, parentID, limit)
}
return items, nil
}
value, err := e.loadLatestItemsCached(ctx, userID, parentID, limit)
if err != nil {
return nil, err
}
if value.Items == nil {
return []map[string]any{}, nil
}
return value.Items, nil
}
func (e *EmbyService) loadLatestItemsCached(ctx context.Context, userID, parentID string, limit int) (embyLatestCacheValue, error) {
cacheKey := e.embyLatestCacheKey(userID, parentID, limit)
// 一个客户端断开不应取消正在为其他客户端填充的共享重建。
loadCtx := context.WithoutCancel(ctx)
loadCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), 45*time.Second)
defer cancel()
v, err, _ := e.latestFlight.Do(cacheKey, func() (any, error) {
if items, ok := e.cachedLatestItems(loadCtx, cacheKey); ok {
return embyLatestCacheValue{Items: items}, nil
@@ -144,34 +160,55 @@ func (e *EmbyService) LatestItems(ctx context.Context, userID, parentID string,
return nil, err
}
if e.cache != nil {
e.cache.SetJSON(loadCtx, cacheKey, value, time.Duration(e.embyLatestCacheTTLSeconds())*time.Second)
freshTTL := time.Duration(e.embyLatestCacheTTLSeconds()) * time.Second
e.cache.SetJSONWithStale(loadCtx, cacheKey, value, freshTTL, freshTTL+30*time.Minute)
}
e.rememberArtworkRefs(value.Artwork)
return value, nil
})
if err != nil {
return nil, err
return embyLatestCacheValue{}, err
}
cached, _ := v.(embyLatestCacheValue)
if cached.Items == nil {
return []map[string]any{}, nil
return cached, nil
}
func (e *EmbyService) refreshLatestItemsAsync(cacheKey, userID, parentID string, limit int) {
if e == nil || e.cache == nil {
return
}
return cached.Items, nil
if _, loaded := e.latestRefresh.LoadOrStore(cacheKey, struct{}{}); loaded {
return
}
go func() {
defer e.latestRefresh.Delete(cacheKey)
if _, err := e.loadLatestItemsCached(context.Background(), userID, parentID, limit); err != nil && e.log != nil {
e.log.Debug("background refresh of latest items failed",
zap.String("parent_id", parentID),
zap.Error(redactSensitiveError(err)))
}
}()
}
func (e *EmbyService) cachedLatestItems(ctx context.Context, cacheKey string) ([]map[string]any, bool) {
items, stale, ok := e.cachedLatestItemsWithStale(ctx, cacheKey)
return items, ok && !stale
}
func (e *EmbyService) cachedLatestItemsWithStale(ctx context.Context, cacheKey string) ([]map[string]any, bool, bool) {
if e == nil || e.cache == nil {
return nil, false
return nil, false, false
}
var cached embyLatestCacheValue
if !e.cache.GetJSON(ctx, cacheKey, &cached) {
return nil, false
found, stale := e.cache.GetJSONStale(ctx, cacheKey, &cached)
if !found {
return nil, false, false
}
e.rememberArtworkRefs(cached.Artwork)
if cached.Items == nil {
return []map[string]any{}, true
return []map[string]any{}, stale, true
}
return cached.Items, true
return cached.Items, stale, true
}
func (e *EmbyService) loadLatestItems(ctx context.Context, userID, parentID string, limit int) (embyLatestCacheValue, error) {
+17 -9
View File
@@ -510,12 +510,12 @@ func (r *EmbyRemoteService) ensureTokenOnLine(ctx context.Context, acct *model.S
req.Header.Set("X-Emby-Authorization", `MediaBrowser Client="MeBox", Device="MeBox-Federated", DeviceId="mebox-federated", Version="1.0"`)
resp, err := r.http.Do(req)
if err != nil {
return fmt.Errorf("连接远程 Emby 失败: %w", err)
return redactSensitiveError(fmt.Errorf("连接远程 Emby 失败: %w", err))
}
defer resp.Body.Close()
if resp.StatusCode >= 300 {
data, _ := io.ReadAll(io.LimitReader(resp.Body, 512))
return fmt.Errorf("远程 Emby 登录失败(%d): %s", resp.StatusCode, strings.TrimSpace(string(data)))
return redactSensitiveError(fmt.Errorf("远程 Emby 登录失败(%d): %s", resp.StatusCode, strings.TrimSpace(string(data))))
}
var login struct {
AccessToken string `json:"AccessToken"`
@@ -599,8 +599,16 @@ 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))
fields := []zap.Field{
zap.String("account", acct.Name),
zap.String("path", path),
zap.Error(redactSensitiveError(lastErr)),
}
if errors.Is(lastErr, context.Canceled) {
r.log.Debug("remote emby request canceled", fields...)
} else {
r.log.Warn("remote emby request failed", fields...)
}
}
return lastErr
}
@@ -629,7 +637,7 @@ func (r *EmbyRemoteService) doGetOnLine(ctx context.Context, acct *model.StrmAcc
req.Header.Set("X-Emby-Token", cfg.Token)
resp, err := r.http.Do(req)
if err != nil {
return fmt.Errorf("请求远程 Emby 失败: %w", err)
return redactSensitiveError(fmt.Errorf("请求远程 Emby 失败: %w", err))
}
// 读 8MB+1 以区分"刚好 8MB"与"被截断":截断的 JSON 会让
// Unmarshal 报 unexpected end,难以定位;这里显式报错。
@@ -656,7 +664,7 @@ func (r *EmbyRemoteService) doGetOnLine(ctx context.Context, acct *model.StrmAcc
continue
}
if resp.StatusCode >= 300 {
return fmt.Errorf("远程 Emby 请求失败(%d): %s", resp.StatusCode, strings.TrimSpace(string(data)))
return redactSensitiveError(fmt.Errorf("远程 Emby 请求失败(%d): %s", resp.StatusCode, strings.TrimSpace(string(data))))
}
if out == nil {
return nil
@@ -1121,7 +1129,7 @@ func (r *EmbyRemoteService) proxyVideoStreamOnLine(ctx context.Context, w http.R
defer resp.Body.Close()
if resp.StatusCode >= 400 {
data, _ := io.ReadAll(io.LimitReader(resp.Body, 256))
return fmt.Errorf("远程 Emby 视频流失败(%d): %s", resp.StatusCode, strings.TrimSpace(string(data)))
return redactSensitiveError(fmt.Errorf("远程 Emby 视频流失败(%d): %s", resp.StatusCode, strings.TrimSpace(string(data))))
}
for _, header := range []string{"Content-Type", "Content-Length", "Content-Range", "Accept-Ranges", "ETag", "Cache-Control"} {
if value := resp.Header.Get(header); value != "" {
@@ -1281,12 +1289,12 @@ func (r *EmbyRemoteService) doMutateOnLine(ctx context.Context, cfg *EmbyRemoteC
req.Header.Set("X-Emby-Token", cfg.Token)
resp, err := r.http.Do(req)
if err != nil {
return fmt.Errorf("请求远程 Emby 失败: %w", err)
return redactSensitiveError(fmt.Errorf("请求远程 Emby 失败: %w", err))
}
defer resp.Body.Close()
if resp.StatusCode >= 300 {
data, _ := io.ReadAll(io.LimitReader(resp.Body, 256))
return fmt.Errorf("远程 Emby 状态同步失败(%d): %s", resp.StatusCode, strings.TrimSpace(string(data)))
return redactSensitiveError(fmt.Errorf("远程 Emby 状态同步失败(%d): %s", resp.StatusCode, strings.TrimSpace(string(data))))
}
return nil
}
+9 -5
View File
@@ -180,18 +180,20 @@ func downloadFFmpegArchive(ctx context.Context, log *zap.Logger, urls []string,
var lastErr error
for i, u := range urls {
if i > 0 && log != nil {
log.Warn("ffmpeg 主下载源不可用,切换备用源", zap.String("url", u))
log.Warn("ffmpeg 主下载源不可用,切换备用源", zap.String("url", redactSensitiveURL(u)))
}
if err := downloadFFmpegFile(ctx, log, u, dest); err != nil {
lastErr = err
if log != nil {
log.Warn("ffmpeg 下载失败", zap.String("url", u), zap.Error(err))
log.Warn("ffmpeg 下载失败",
zap.String("url", redactSensitiveURL(u)),
zap.Error(redactSensitiveError(err)))
}
continue
}
return nil
}
return fmt.Errorf("所有下载源均失败:%v", lastErr)
return redactSensitiveError(fmt.Errorf("所有下载源均失败:%v", lastErr))
}
// downloadFFmpegFile 下载单个归档文件(最多 10 分钟,限制大小上限)。
@@ -221,10 +223,12 @@ func downloadFFmpegFile(ctx context.Context, log *zap.Logger, url, dest string)
return err
}
if n > 500<<20 {
return fmt.Errorf("归档文件过大(>500MB): %s", url)
return fmt.Errorf("归档文件过大(>500MB): %s", redactSensitiveURL(url))
}
if log != nil {
log.Info("ffmpeg 归档下载完成", zap.String("url", url), zap.Int64("bytes", n))
log.Info("ffmpeg 归档下载完成",
zap.String("url", redactSensitiveURL(url)),
zap.Int64("bytes", n))
}
return nil
}
+7 -5
View File
@@ -24,17 +24,19 @@ import (
"time"
"go.uber.org/zap"
"golang.org/x/sync/singleflight"
"github.com/truewhile/MeBox/internal/config"
)
// ImageProxy fetches and caches remote images on behalf of the browser.
type ImageProxy struct {
cfg *config.Config
log *zap.Logger
client *http.Client
cacheDir string
mu sync.Mutex
cfg *config.Config
log *zap.Logger
client *http.Client
cacheDir string
mu sync.Mutex
fetchGroup singleflight.Group
// resizeSem bounds concurrent decode/resize jobs. Emby TV clients request
// poster grids in bursts; letting every request decode a source image at
+37 -3
View File
@@ -95,7 +95,7 @@ func (p *ImageProxy) serveRemoteImage(ctx context.Context, w http.ResponseWriter
}
// No negative caching: a previously failed fetch is retried on every
// subsequent request, so the image recovers as soon as upstream does.
data, ctype, contentLength, err := p.fetchAndCacheRemoteImage(ctx, raw, host, cachePath, failPath)
data, ctype, contentLength, err := p.fetchAndCacheRemoteImageShared(ctx, raw, host, cachePath, failPath)
if err != nil {
if forceRefresh && p.serveCachedImage(w, r, key, cachePath, opts) {
return nil
@@ -196,14 +196,48 @@ func (p *ImageProxy) fetchAndCacheRemoteImage(ctx context.Context, raw, host, ca
p.writeImageCache(cachePath, failPath, "img-*.tmp", data)
return data, ctype, contentLength, nil
}
p.log.Warn("imageproxy: curl fallback failed", zap.String("host", host), zap.Error(err))
logImageFetchError(p.log, "imageproxy: curl fallback failed", host, "curl", err)
lastErr = err
}
p.markImageFetchFailed(failPath)
if lastErr == nil {
lastErr = errors.New("upstream image fetch failed")
}
return nil, "", "", lastErr
return nil, "", "", redactSensitiveError(lastErr)
}
type sharedRemoteImageResult struct {
data []byte
contentType string
contentLength string
}
// fetchAndCacheRemoteImageShared coalesces concurrent requests for the same
// upstream image. A poster can appear in the hero, a shelf and the detail page
// at the same time; without this guard every resize variant may fetch the same
// original before the first cache write finishes.
func (p *ImageProxy) fetchAndCacheRemoteImageShared(ctx context.Context, raw, host, cachePath, failPath string) ([]byte, string, string, error) {
value, err, _ := p.fetchGroup.Do(cachePath, func() (any, error) {
loadCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), 45*time.Second)
defer cancel()
data, contentType, contentLength, err := p.fetchAndCacheRemoteImage(loadCtx, raw, host, cachePath, failPath)
if err != nil {
return nil, err
}
return sharedRemoteImageResult{
data: data,
contentType: contentType,
contentLength: contentLength,
}, nil
})
if err != nil {
return nil, "", "", err
}
result, ok := value.(sharedRemoteImageResult)
if !ok {
return nil, "", "", errors.New("upstream image fetch failed")
}
return result.data, result.contentType, result.contentLength, nil
}
// Fetch pulls a remote image and returns bytes plus Content-Type using cache.
+24 -8
View File
@@ -48,14 +48,14 @@ func (p *ImageProxy) canUseExternalImageFallback() bool {
func (p *ImageProxy) fetchRemoteImageOnce(ctx context.Context, raw, host string, candidate remoteImageFetchClient) ([]byte, string, string, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, raw, nil)
if err != nil {
p.log.Warn("imageproxy: build request failed", zap.String("url", raw), zap.Error(err))
p.log.Warn("imageproxy: build request failed", zap.String("url", redactSensitiveURL(raw)), zap.Error(redactSensitiveError(err)))
return nil, "", "", errImageProxyRequestSetup
}
applyRemoteImageHeaders(req, host, raw)
resp, err := candidate.client.Do(req)
if err != nil {
p.log.Warn("imageproxy: upstream fetch failed", zap.String("host", host), zap.String("client", candidate.name), zap.Error(err))
logImageFetchError(p.log, "imageproxy: upstream fetch failed", host, candidate.name, err)
return nil, "", "", err
}
defer resp.Body.Close()
@@ -65,7 +65,7 @@ func (p *ImageProxy) fetchRemoteImageOnce(ctx context.Context, raw, host string,
}
data, err := io.ReadAll(io.LimitReader(resp.Body, 32<<20))
if err != nil || len(data) == 0 {
p.log.Warn("imageproxy: read upstream body failed", zap.String("host", host), zap.String("client", candidate.name), zap.Error(err))
p.log.Warn("imageproxy: read upstream body failed", zap.String("host", host), zap.String("client", candidate.name), zap.Error(redactSensitiveError(err)))
if err == nil {
err = errors.New("upstream image body is empty")
}
@@ -79,6 +79,22 @@ func (p *ImageProxy) fetchRemoteImageOnce(ctx context.Context, raw, host string,
return data, ctype, resp.Header.Get("Content-Length"), nil
}
func logImageFetchError(log *zap.Logger, message, host, client string, err error) {
if log == nil || err == nil {
return
}
fields := []zap.Field{
zap.String("host", host),
zap.String("client", client),
zap.Error(redactSensitiveError(err)),
}
if errors.Is(err, context.Canceled) {
log.Debug(message, fields...)
return
}
log.Warn(message, fields...)
}
func applyRemoteImageHeaders(req *http.Request, host, raw string) {
req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/125.0 Safari/537.36")
req.Header.Set("Accept", "image/avif,image/webp,image/apng,image/svg+xml,image/*,*/*;q=0.8")
@@ -162,9 +178,9 @@ func fetchRemoteImageWithCurl(ctx context.Context, raw, host string) ([]byte, st
"--header", "Cache-Control: no-cache",
"--header", "Pragma: no-cache",
}
if referer := remoteImageReferer(host, raw); referer != "" {
args = append(args, "--referer", referer)
}
if referer := remoteImageReferer(host, raw); referer != "" {
args = append(args, "--referer", referer)
}
if cookie := remoteImageCookie(host); cookie != "" {
args = append(args, "--cookie", cookie)
}
@@ -188,9 +204,9 @@ func fetchRemoteImageWithCurl(ctx context.Context, raw, host string) ([]byte, st
if waitErr != nil {
message := strings.TrimSpace(stderr.String())
if message != "" {
return nil, "", "", errors.New(message)
return nil, "", "", redactSensitiveError(errors.New(message))
}
return nil, "", "", waitErr
return nil, "", "", redactSensitiveError(waitErr)
}
if len(data) == 0 {
return nil, "", "", errors.New("curl image body is empty")
@@ -101,7 +101,21 @@ func localBackdropCandidates(mediaPath string) []string {
seen[key] = struct{}{}
names = append(names, name)
}
_, episode := ParseEpisode(mediaPath)
for _, base := range mediaSidecarBaseVariants(mediaPath) {
// Jellyfin/Emby commonly stores an episode still beside the video as
// "<episode>-thumb.jpg" or "<episode>.jpg". Episode-specific artwork
// must win over a series-level fanart.jpg in the parent folder,
// otherwise a rescan flattens every episode to the same backdrop.
add(base + "-thumb")
add(base + ".thumb")
if episode > 0 {
add(base + "-still")
add(base + ".still")
add(base + "-scene")
add(base + ".scene")
add(base)
}
add(base + "-fanart")
add(base + ".fanart")
add(base + "-backdrop")
+2 -2
View File
@@ -236,8 +236,8 @@ func TestReadLocalVarietyMetadataUsesLocalArtwork(t *testing.T) {
if got.PosterURL != showPoster {
t.Fatalf("PosterURL = %q, want show poster %q, not episode thumb %q", got.PosterURL, showPoster, episodeThumb)
}
if got.BackdropURL != backdrop {
t.Fatalf("BackdropURL = %q, want %q", got.BackdropURL, backdrop)
if got.BackdropURL != episodeThumb {
t.Fatalf("BackdropURL = %q, want episode thumb %q, not series backdrop %q", got.BackdropURL, episodeThumb, backdrop)
}
}
+89
View File
@@ -0,0 +1,89 @@
package service
import (
"errors"
"net/url"
"regexp"
"strings"
)
const sensitiveLogKeyPattern = `api[_-]?key|apikey|access[_-]?token|refresh[_-]?token|id[_-]?token|token|password|passwd|pwd|authorization|client[_-]?secret|secret|signature|sig|x[_-]?emby[_-]?token|x[_-]?api[_-]?key|x[_-]?amz[_-]?signature|x[_-]?amz[_-]?credential|x[_-]?amz[_-]?security[_-]?token|awsaccesskeyid|session[_-]?id`
var (
sensitiveLogQueryRE = regexp.MustCompile(`(?i)([?&;])(` + sensitiveLogKeyPattern + `)=([^&#\s"']+)`)
sensitiveLogJSONRE = regexp.MustCompile(`(?i)("(?:` + sensitiveLogKeyPattern + `)"\s*:\s*")((?:[^"\\]|\\.)*)(")`)
sensitiveLogAssignmentRE = regexp.MustCompile(`(?im)((?:^|[\s,{])(?:` + sensitiveLogKeyPattern + `)\s*[:=]\s*(?:(?:bearer|basic)\s+)?)([^\s,;"'}\]]+)`)
)
// redactSensitiveURL removes credentials from URLs before they reach logs or
// error messages. Query values are replaced with REDACTED and URL userinfo
// passwords are removed.
func redactSensitiveURL(raw string) string {
trimmed := strings.TrimSpace(raw)
u, err := url.Parse(trimmed)
if err != nil || u.Scheme == "" || u.Host == "" {
return redactSensitiveText(raw)
}
if u.User != nil {
if _, hasPassword := u.User.Password(); hasPassword {
u.User = url.UserPassword(u.User.Username(), "REDACTED")
}
}
query := u.Query()
changed := false
for key := range query {
if isSensitiveLogKey(key) {
query.Set(key, "REDACTED")
changed = true
}
}
if changed {
u.RawQuery = query.Encode()
}
return u.String()
}
func redactSensitiveText(value string) string {
value = sensitiveLogQueryRE.ReplaceAllString(value, `${1}${2}=REDACTED`)
value = sensitiveLogJSONRE.ReplaceAllString(value, `${1}REDACTED${3}`)
return sensitiveLogAssignmentRE.ReplaceAllString(value, `${1}REDACTED`)
}
type logRedactedError struct {
err error
}
func (e logRedactedError) Error() string {
return redactSensitiveText(e.err.Error())
}
func (e logRedactedError) Unwrap() error {
return e.err
}
// redactSensitiveError wraps an error with a redacted Error string while
// preserving errors.Is and errors.As behavior.
func redactSensitiveError(err error) error {
if err == nil {
return nil
}
var alreadyRedacted logRedactedError
if errors.As(err, &alreadyRedacted) {
return err
}
return logRedactedError{err: err}
}
func isSensitiveLogKey(key string) bool {
normalized := strings.ToLower(strings.TrimSpace(key))
normalized = strings.ReplaceAll(normalized, "-", "_")
switch normalized {
case "api_key", "apikey", "access_token", "refresh_token", "id_token", "token",
"password", "passwd", "pwd", "authorization", "client_secret", "secret",
"signature", "sig", "x_emby_token", "x_api_key", "x_amz_signature",
"x_amz_credential", "x_amz_security_token", "awsaccesskeyid", "session_id":
return true
default:
return false
}
}
+52
View File
@@ -0,0 +1,52 @@
package service
import (
"context"
"errors"
"strings"
"testing"
)
func TestRedactSensitiveURL(t *testing.T) {
raw := "https://user:secret@media.example/emby/Items/1/Images/primary?api_key=abc123&X-Emby-Token=xyz&X-Amz-Signature=sig123&quality=90"
got := redactSensitiveURL(raw)
for _, secret := range []string{"secret", "abc123", "xyz", "sig123"} {
if strings.Contains(got, secret) {
t.Fatalf("redacted URL still contains %q: %s", secret, got)
}
}
if !strings.Contains(got, "quality=90") {
t.Fatalf("non-sensitive query value was removed: %s", got)
}
}
func TestRedactSensitiveTextCoversJSONAndHeaders(t *testing.T) {
raw := `{"AccessToken":"json-token","quality":"90"} Authorization: Bearer bearer-token; X-Emby-Token=header-token`
got := redactSensitiveText(raw)
for _, secret := range []string{"json-token", "bearer-token", "header-token"} {
if strings.Contains(got, secret) {
t.Fatalf("redacted text still contains %q: %s", secret, got)
}
}
if !strings.Contains(got, `"quality":"90"`) {
t.Fatalf("non-sensitive JSON value was removed: %s", got)
}
}
func TestRedactSensitiveErrorPreservesWrapping(t *testing.T) {
base := errors.New(`Get "https://media.example/item?token=secret&quality=90": context canceled`)
err := redactSensitiveError(context.Canceled)
if !errors.Is(err, context.Canceled) {
t.Fatal("redacted error lost context.Canceled")
}
if strings.Contains(err.Error(), "secret") {
t.Fatalf("redacted error leaked token: %s", err.Error())
}
redactedBase := redactSensitiveError(base)
if strings.Contains(redactedBase.Error(), "secret") {
t.Fatalf("redacted base error leaked token: %s", redactedBase.Error())
}
if !errors.Is(redactedBase, base) {
t.Fatal("redacted error lost original error identity")
}
}
+7 -4
View File
@@ -3,6 +3,7 @@ package service
import (
"go.uber.org/zap"
"golang.org/x/sync/singleflight"
"github.com/truewhile/MeBox/internal/config"
"github.com/truewhile/MeBox/internal/model"
@@ -11,10 +12,12 @@ import (
// MediaService offers high-level CRUD over libraries and media items.
type MediaService struct {
cfg *config.Config
log *zap.Logger
repo *repository.Container
cache *RuntimeCacheService
cfg *config.Config
log *zap.Logger
repo *repository.Container
cache *RuntimeCacheService
groupedMediaFlight singleflight.Group
libraryRowsFlight singleflight.Group
}
type MediaVisibility struct {
+4
View File
@@ -31,6 +31,7 @@ func (s *MediaService) mediaListCacheKey(libraryID string, libraryIDs []string,
fmt.Sprintf("%d:%d:%t", page, pageSize, filter.IncludeNSFW),
strings.Join(allowed, ","),
strings.Join(hidden, ","),
filter.SeriesID,
}, "|")))
return "media:list:" + hex.EncodeToString(sum[:])
}
@@ -51,6 +52,7 @@ func (s *MediaService) libraryPreviewCacheKey(libraries []model.Library, cardLim
fmt.Sprintf("%d:%t:%t", cardLimit, filter.IncludeNSFW, includeCounts),
strings.Join(allowed, ","),
strings.Join(hidden, ","),
filter.SeriesID,
}, "|")))
return "media:preview:" + hex.EncodeToString(sum[:])
}
@@ -82,6 +84,7 @@ func (s *MediaService) libraryCountCacheKey(libraryIDs []string, filter reposito
fmt.Sprintf("%t", filter.IncludeNSFW),
strings.Join(allowed, ","),
strings.Join(hidden, ","),
filter.SeriesID,
}, "|")))
return "media:lib-counts:" + hex.EncodeToString(sum[:])
}
@@ -134,6 +137,7 @@ func (s *MediaService) groupedItemsCacheKey(libraryID string, libraryIDs []strin
fmt.Sprintf("%t", filter.IncludeNSFW),
strings.Join(allowed, ","),
strings.Join(hidden, ","),
filter.SeriesID,
})
}
+43 -21
View File
@@ -14,10 +14,6 @@ type LibraryPreviewItem struct {
Cards []SeriesCard `json:"cards"`
}
type libraryPreviewCacheValue struct {
Items []LibraryPreviewItem `json:"items"`
}
// ListLibraries returns every library configured on the server.
func (s *MediaService) ListLibraries(ctx context.Context) ([]model.Library, error) {
return s.repo.Library.List(ctx)
@@ -68,27 +64,34 @@ func (s *MediaService) listLibrariesWithPreview(ctx context.Context, libraries [
if len(libraries) == 0 {
return out, nil
}
visibility = ExpandMediaVisibilityForMergedCloudLibraries(ctx, s.repo, visibility)
filter := repository.MediaQueryFilter{
IncludeNSFW: visibility.IncludeNSFW,
AllowedLibraryIDs: visibility.AllowedLibraryIDs,
HiddenLibraryIDs: visibility.HiddenLibraryIDs,
}
cacheKey := s.libraryPreviewCacheKey(libraries, cardLimit, filter, includeCounts)
var cached libraryPreviewCacheValue
if s.cache != nil && s.cache.GetJSON(ctx, cacheKey, &cached) {
return cached.Items, nil
}
libIDs := make([]string, 0, len(libraries))
pending := make([]model.Library, 0, len(libraries))
pendingSet := make(map[string]struct{}, len(libraries))
for i, lib := range libraries {
out[i] = LibraryPreviewItem{
Library: lib,
Total: 0,
Cards: []SeriesCard{},
}
libIDs = append(libIDs, lib.ID)
itemKey := s.libraryPreviewCacheKey([]model.Library{lib}, cardLimit, filter, includeCounts)
if s.cache != nil && s.cache.GetJSON(ctx, itemKey, &out[i]) {
continue
}
pending = append(pending, lib)
pendingSet[lib.ID] = struct{}{}
}
if len(pending) == 0 {
return out, nil
}
libIDs := make([]string, len(pending))
for i := range pending {
libIDs[i] = pending[i].ID
}
if includeCounts {
@@ -97,9 +100,10 @@ func (s *MediaService) listLibrariesWithPreview(ctx context.Context, libraries [
return nil, err
}
for i := range out {
if total, ok := counts[out[i].ID]; ok {
out[i].Total = total
if _, pendingItem := pendingSet[out[i].ID]; !pendingItem {
continue
}
out[i].Total = counts[out[i].ID]
}
}
@@ -107,8 +111,16 @@ func (s *MediaService) listLibrariesWithPreview(ctx context.Context, libraries [
// library with a very long series cannot turn a homepage request into a full
// 50k-row scan merely to find another distinct card.
fetchCount := cardLimit * 12
if fetchCount < 120 {
fetchCount = 120
minFetchCount := 120
if cardLimit <= 2 {
minFetchCount = 24
} else if cardLimit <= 4 {
// 首页/媒体库入口的马赛克只需要少量代表图,没必要为暂时不会
// 展示的横向货架扫描一整批 120 行候选。
minFetchCount = 48
}
if fetchCount < minFetchCount {
fetchCount = minFetchCount
} else if fetchCount > 400 {
fetchCount = 400
}
@@ -120,6 +132,9 @@ func (s *MediaService) listLibrariesWithPreview(ctx context.Context, libraries [
allPreviewItems := make([]model.Media, 0, len(libIDs)*fetchCount)
for i := range out {
if _, pendingItem := pendingSet[out[i].ID]; !pendingItem {
continue
}
items := recentByLibrary[out[i].ID]
if len(items) == 0 {
continue
@@ -129,8 +144,15 @@ func (s *MediaService) listLibrariesWithPreview(ctx context.Context, libraries [
s.attachLibraryMetadata(ctx, allPreviewItems)
for i := range out {
if _, pendingItem := pendingSet[out[i].ID]; !pendingItem {
continue
}
items := recentByLibrary[out[i].ID]
if len(items) == 0 {
if s.cache != nil {
itemKey := s.libraryPreviewCacheKey([]model.Library{out[i].Library}, cardLimit, filter, includeCounts)
s.cache.SetJSON(ctx, itemKey, out[i], s.derivedReadCacheTTL())
}
continue
}
cards := groupMediaSeriesCards(items)
@@ -141,10 +163,10 @@ func (s *MediaService) listLibrariesWithPreview(ctx context.Context, libraries [
cards = []SeriesCard{}
}
out[i].Cards = cards
}
if s.cache != nil {
s.cache.SetJSON(ctx, cacheKey, libraryPreviewCacheValue{Items: out}, s.derivedReadCacheTTL())
if s.cache != nil {
itemKey := s.libraryPreviewCacheKey([]model.Library{out[i].Library}, cardLimit, filter, includeCounts)
s.cache.SetJSON(ctx, itemKey, out[i], s.derivedReadCacheTTL())
}
}
return out, nil
+29
View File
@@ -180,3 +180,32 @@ func TestListLibraryPreviewsSkipsTotals(t *testing.T) {
t.Fatalf("preview-only result = %#v, want one card and no count", previews)
}
}
func TestListLibraryPreviewsCachesEachLibraryIndependently(t *testing.T) {
db := newServiceTestDB(t, &model.Library{}, &model.Media{})
repos := repository.New(db)
lib := model.Library{Name: "电影", Path: "/media/movies", Type: "movie", Enabled: true}
if err := repos.Library.Create(t.Context(), &lib); err != nil {
t.Fatal(err)
}
if err := repos.DB.Create(&model.Media{
LibraryID: lib.ID,
Title: "预览电影",
Path: "/media/movies/预览电影.mkv",
}).Error; err != nil {
t.Fatal(err)
}
svc := NewMediaService(&config.Config{}, zap.NewNop(), repos).
SetRuntimeCache(NewRuntimeCacheService(&config.Config{}, zap.NewNop()))
visibility := MediaVisibility{IncludeNSFW: true}
if _, err := svc.ListLibraryPreviews(t.Context(), []model.Library{lib}, visibility, 10); err != nil {
t.Fatal(err)
}
filter := repository.MediaQueryFilter{IncludeNSFW: true}
key := svc.libraryPreviewCacheKey([]model.Library{lib}, 10, filter, false)
var cached LibraryPreviewItem
if !svc.cache.GetJSON(t.Context(), key, &cached) || len(cached.Cards) != 1 {
t.Fatalf("per-library preview cache = %#v, want one cached card", cached)
}
}
+28 -11
View File
@@ -77,22 +77,33 @@ func (s *MediaService) GroupedMediaVisible(ctx context.Context, libraryID string
HiddenLibraryIDs: visibility.HiddenLibraryIDs,
}
itemsCacheKey := s.groupedItemsCacheKey(libraryID, libraryIDs, filter)
if s.cache != nil {
if cachedObj, ok := s.cache.GetObject(itemsCacheKey); ok {
if cached, ok := cachedObj.([]MediaItem); ok {
return cached, nil
value, err, _ := s.groupedMediaFlight.Do(itemsCacheKey, func() (any, error) {
if s.cache != nil {
if cachedObj, ok := s.cache.GetObject(itemsCacheKey); ok {
if cached, ok := cachedObj.([]MediaItem); ok {
return cached, nil
}
}
}
}
items, err := s.listMediaVisibleForGrouping(ctx, libraryID, visibility)
loadCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), 2*time.Minute)
defer cancel()
items, err := s.listMediaVisibleForGrouping(loadCtx, libraryID, visibility)
if err != nil {
return nil, err
}
grouped := groupMediaVersions(items)
if s.cache != nil && len(grouped) > 0 {
s.cache.SetObject(itemsCacheKey, grouped, s.mediaObjectTTL())
}
return grouped, nil
})
if err != nil {
return nil, err
}
grouped := groupMediaVersions(items)
if s.cache != nil && len(grouped) > 0 {
s.cache.SetObject(itemsCacheKey, grouped, s.mediaObjectTTL())
if grouped, ok := value.([]MediaItem); ok {
return grouped, nil
}
return grouped, nil
return nil, nil
}
func (s *MediaService) listMediaVisibleForGrouping(ctx context.Context, libraryID string, visibility MediaVisibility) ([]model.Media, error) {
@@ -168,10 +179,16 @@ func (s *MediaService) listVersionSiblings(ctx context.Context, media *model.Med
libraryIDs = []string{media.LibraryID}
}
filter := repository.MediaQueryFilter{IncludeNSFW: true}
candidates, err := s.repo.Media.ListByLibrariesFilteredNoCount(ctx, libraryIDs, 0, 5000, filter)
candidates, narrowed, err := s.repo.Media.ListVersionCandidates(ctx, libraryIDs, *media, 5000)
if err != nil {
return nil, err
}
if !narrowed {
candidates, err = s.repo.Media.ListByLibrariesFilteredNoCount(ctx, libraryIDs, 0, 5000, filter)
if err != nil {
return nil, err
}
}
s.attachLibraryMetadata(ctx, candidates)
matched := make([]model.Media, 0, 4)
for _, row := range candidates {
+95 -22
View File
@@ -36,6 +36,35 @@ type SeriesCard struct {
LastAddedAt *time.Time `json:"last_added_at,omitempty"`
}
// SeriesCardView is the compact payload used by homepage and library preview
// endpoints. LinkMedia is only needed to resolve the target library ID, so
// sending the full media row twice roughly doubles the preview JSON for no UI
// benefit.
type SeriesCardView struct {
Key string `json:"key"`
Rep model.Media `json:"rep"`
LinkLibraryID string `json:"linkLibraryId,omitempty"`
Count int `json:"count"`
LastAddedAt *time.Time `json:"last_added_at,omitempty"`
}
func NewSeriesCardViews(cards []SeriesCard) []SeriesCardView {
if len(cards) == 0 {
return []SeriesCardView{}
}
out := make([]SeriesCardView, len(cards))
for i, card := range cards {
out[i] = SeriesCardView{
Key: card.Key,
Rep: card.Rep,
LinkLibraryID: mediaTargetLibraryID(card.LinkMedia),
Count: card.Count,
LastAddedAt: card.LastAddedAt,
}
}
return out
}
type seriesCardGroup struct {
card SeriesCard
latest time.Time
@@ -45,34 +74,45 @@ type seriesCardGroup struct {
func (s *MediaService) libraryRowsWithIndex(ctx context.Context, libraryID string, visibility MediaVisibility) (*libraryRowsCacheValue, error) {
visibility = ExpandMediaVisibilityForMergedCloudLibraries(ctx, s.repo, visibility)
cacheKey := s.libraryRowsCacheKey(libraryID, visibility)
if s.cache != nil {
if obj, ok := s.cache.GetObject(cacheKey); ok {
if cached, ok := obj.(*libraryRowsCacheValue); ok {
return cached, nil
value, err, _ := s.libraryRowsFlight.Do(cacheKey, func() (any, error) {
if s.cache != nil {
if obj, ok := s.cache.GetObject(cacheKey); ok {
if cached, ok := obj.(*libraryRowsCacheValue); ok {
return cached, nil
}
}
}
}
rows, _, err := s.listAllMediaVisible(ctx, libraryID, visibility)
loadCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), 2*time.Minute)
defer cancel()
rows, _, err := s.listAllMediaVisible(loadCtx, libraryID, visibility)
if err != nil {
return nil, err
}
// listAllMediaVisible 走 ListMediaVisible,行已带库元数据(resolver 的
// key 计算依赖 DisplayLibraryPath/ID)。
resolver, keys := resolveMediaSeriesKeys(rows)
episodes := make(map[string][]model.Media, len(rows)/4+1)
for i, row := range rows {
k := keys[i]
if k == "" {
continue
}
episodes[k] = append(episodes[k], row)
}
cards := groupMediaSeriesCardsByKeys(rows, keys)
value := &libraryRowsCacheValue{Rows: rows, Resolver: resolver, Episodes: episodes, Cards: cards}
if s.cache != nil {
s.cache.SetObject(cacheKey, value, s.derivedReadCacheTTL())
}
return value, nil
})
if err != nil {
return nil, err
}
// listAllMediaVisible 走 ListMediaVisible,行已带库元数据(resolver 的
// key 计算依赖 DisplayLibraryPath/ID)。
resolver, keys := resolveMediaSeriesKeys(rows)
episodes := make(map[string][]model.Media, len(rows)/4+1)
for i, row := range rows {
k := keys[i]
if k == "" {
continue
}
episodes[k] = append(episodes[k], row)
if cached, ok := value.(*libraryRowsCacheValue); ok {
return cached, nil
}
cards := groupMediaSeriesCardsByKeys(rows, keys)
value := &libraryRowsCacheValue{Rows: rows, Resolver: resolver, Episodes: episodes, Cards: cards}
if s.cache != nil {
s.cache.SetObject(cacheKey, value, s.derivedReadCacheTTL())
}
return value, nil
return nil, nil
}
func (s *MediaService) ListLibrarySeriesCards(ctx context.Context, libraryID string, visibility MediaVisibility) ([]SeriesCard, int64, error) {
@@ -179,6 +219,30 @@ func (s *MediaService) ListMediaEpisodes(ctx context.Context, mediaID string, vi
if target.LibraryID == "" {
return []model.Media{*target}, nil
}
// 电影、音乐等单条目库不需要为了返回自身而加载整库。详情页会并行
// 请求 /media/:id/episodes,未短路时每次冷缓存都会触发一次全库分组。
if lib, err := s.repo.Library.FindByID(ctx, target.LibraryID); err == nil && lib != nil &&
libraryUsesSingleMediaRows(lib.Type) &&
strings.TrimSpace(target.SeriesID) == "" &&
!mediaLooksEpisodicForGrouping(*target) {
return []model.Media{*target}, nil
}
if seriesID := strings.TrimSpace(target.SeriesID); seriesID != "" {
libraryIDs, err := MergedLibraryIDsForLibrary(ctx, s.repo, target.LibraryID)
if err == nil && len(libraryIDs) > 0 {
rows, queryErr := s.repo.Media.ListByLibrariesFilteredNoCount(ctx, libraryIDs, 0, maxMediaSearchLimit, repository.MediaQueryFilter{
IncludeNSFW: visibility.IncludeNSFW,
AllowedLibraryIDs: visibility.AllowedLibraryIDs,
HiddenLibraryIDs: visibility.HiddenLibraryIDs,
SeriesID: seriesID,
})
if queryErr == nil && len(rows) > 1 {
s.attachLibraryMetadata(ctx, rows)
sortEpisodesForDisplay(rows)
return rows, nil
}
}
}
cache, err := s.libraryRowsWithIndex(ctx, target.LibraryID, visibility)
if err != nil {
return nil, err
@@ -253,6 +317,15 @@ func (s *MediaService) ListMediaEpisodes(ctx context.Context, mediaID string, vi
return out, nil
}
func libraryUsesSingleMediaRows(libraryType string) bool {
switch strings.ToLower(strings.TrimSpace(libraryType)) {
case "movie", "movies", "music", "adult":
return true
default:
return false
}
}
func (s *MediaService) listAllMediaVisible(ctx context.Context, libraryID string, visibility MediaVisibility) ([]model.Media, int64, error) {
visibility = ExpandMediaVisibilityForMergedCloudLibraries(ctx, s.repo, visibility)
libraryIDs, err := MergedLibraryIDsForLibrary(ctx, s.repo, libraryID)
+83
View File
@@ -594,6 +594,89 @@ func TestListMediaEpisodesKeepsIndependentMoviesSeparate(t *testing.T) {
}
}
func TestListMediaEpisodesMovieTypeSkipsFullLibraryIndex(t *testing.T) {
db := newServiceTestDB(t, &model.Library{}, &model.Media{})
repos := repository.New(db)
lib := model.Library{Base: model.Base{ID: "lib-movie-fast"}, Name: "电影", Type: "movie", Enabled: true}
if err := repos.DB.Create(&lib).Error; err != nil {
t.Fatal(err)
}
movie := model.Media{
Base: model.Base{ID: "movie-fast"},
LibraryID: lib.ID,
Title: "电影",
Path: "/media/movies/电影/电影.mkv",
}
if err := repos.DB.Create(&movie).Error; err != nil {
t.Fatal(err)
}
svc := NewMediaService(&config.Config{}, zap.NewNop(), repos).
SetRuntimeCache(NewRuntimeCacheService(&config.Config{}, zap.NewNop()))
eps, err := svc.ListMediaEpisodes(t.Context(), movie.ID, MediaVisibility{IncludeNSFW: true})
if err != nil {
t.Fatal(err)
}
if len(eps) != 1 || eps[0].ID != movie.ID {
t.Fatalf("ListMediaEpisodes got %#v, want the movie itself", eps)
}
if _, ok := svc.cache.GetObject(svc.libraryRowsCacheKey(lib.ID, MediaVisibility{IncludeNSFW: true})); ok {
t.Fatal("movie detail should not build a full-library episode index")
}
}
func TestListMediaEpisodesUsesSeriesIDFastPath(t *testing.T) {
db := newServiceTestDB(t, &model.Library{}, &model.Media{})
repos := repository.New(db)
lib := model.Library{Base: model.Base{ID: "lib-series-fast"}, Name: "剧集", Type: "tv", Enabled: true}
if err := repos.DB.Create(&lib).Error; err != nil {
t.Fatal(err)
}
rows := []model.Media{
{
Base: model.Base{ID: "episode-2"},
LibraryID: lib.ID,
SeriesID: "series-fast",
Title: "示例剧",
Path: "/media/tv/示例剧/Season 1/示例剧.S01E02.mkv",
SeasonNum: 1,
EpisodeNum: 2,
},
{
Base: model.Base{ID: "episode-1"},
LibraryID: lib.ID,
SeriesID: "series-fast",
Title: "示例剧",
Path: "/media/tv/示例剧/Season 1/示例剧.S01E01.mkv",
SeasonNum: 1,
EpisodeNum: 1,
},
{
Base: model.Base{ID: "other-series"},
LibraryID: lib.ID,
SeriesID: "other",
Title: "其他剧",
Path: "/media/tv/其他剧/Season 1/其他剧.S01E01.mkv",
},
}
if err := repos.DB.Create(&rows).Error; err != nil {
t.Fatal(err)
}
svc := NewMediaService(&config.Config{}, zap.NewNop(), repos).
SetRuntimeCache(NewRuntimeCacheService(&config.Config{}, zap.NewNop()))
eps, err := svc.ListMediaEpisodes(t.Context(), "episode-1", MediaVisibility{IncludeNSFW: true})
if err != nil {
t.Fatal(err)
}
if len(eps) != 2 || eps[0].ID != "episode-1" || eps[1].ID != "episode-2" {
t.Fatalf("SeriesID fast path got %#v, want both episodes in order", eps)
}
if _, ok := svc.cache.GetObject(svc.libraryRowsCacheKey(lib.ID, MediaVisibility{IncludeNSFW: true})); ok {
t.Fatal("SeriesID detail fast path should not build a full-library episode index")
}
}
func TestListLibrarySeriesCardsCachesPrecomputedCards(t *testing.T) {
db := newServiceTestDB(t, &model.Library{}, &model.Media{})
repos := repository.New(db)
+96 -17
View File
@@ -25,7 +25,10 @@ const (
type RuntimeCacheService struct {
log *zap.Logger
client *redis.Client
prefix string
// redisGet is separated from client so cache ordering can be tested without
// requiring a live Redis instance.
redisGet func(ctx context.Context, key string) ([]byte, error)
prefix string
mu sync.RWMutex
memory map[string]runtimeCacheItem
@@ -36,10 +39,11 @@ type RuntimeCacheService struct {
}
type runtimeCacheItem struct {
raw []byte
expiresAt time.Time
lastUsed time.Time
size int64
raw []byte
expiresAt time.Time
staleUntil time.Time
lastUsed time.Time
size int64
}
// runtimeObjectItem 直存 Go 对象,跳过 JSON 编解码。热点路径(整库行、
@@ -95,6 +99,9 @@ func NewRuntimeCacheService(cfg *config.Config, log *zap.Logger) *RuntimeCacheSe
return c
}
c.client = client
c.redisGet = func(ctx context.Context, key string) ([]byte, error) {
return client.Get(ctx, key).Bytes()
}
if log != nil {
log.Info("redis runtime cache enabled with in-process L1", zap.String("addr", opts.Addr), zap.String("prefix", c.prefix))
}
@@ -120,8 +127,8 @@ func (c *RuntimeCacheService) GetJSON(ctx context.Context, key string, out any)
if raw, ok := c.getMemory(fullKey); ok {
return json.Unmarshal(raw, out) == nil
}
if c.client != nil {
raw, err := c.client.Get(ctx, fullKey).Bytes()
if c.redisGet != nil {
raw, err := c.redisGet(ctx, fullKey)
if err == nil {
if json.Unmarshal(raw, out) != nil {
return false
@@ -133,6 +140,36 @@ func (c *RuntimeCacheService) GetJSON(ctx context.Context, key string, out any)
return false
}
// GetJSONStale returns a fresh value when available and otherwise a retained
// stale value stored with SetJSONWithStale. The stale flag lets
// callers serve immediately while refreshing in the background.
func (c *RuntimeCacheService) GetJSONStale(ctx context.Context, key string, out any) (found bool, stale bool) {
if !c.Enabled() || strings.TrimSpace(key) == "" || out == nil {
return false, false
}
fullKey := c.key(key)
raw, ok, isStale := c.getMemoryWithStale(fullKey)
if ok && !isStale {
return json.Unmarshal(raw, out) == nil, false
}
// A stale L1 entry must not hide a newer value written by another
// instance. Prefer Redis whenever the local copy is stale, then fall back
// to it only when Redis is unavailable or its fresh value has expired.
if c.redisGet != nil {
redisRaw, err := c.redisGet(ctx, fullKey)
if err == nil {
if json.Unmarshal(redisRaw, out) == nil {
c.setMemoryOwned(fullKey, redisRaw, 2*time.Second)
return true, false
}
}
}
if ok {
return json.Unmarshal(raw, out) == nil, isStale
}
return false, false
}
func (c *RuntimeCacheService) SetJSON(ctx context.Context, key string, value any, ttl time.Duration) {
if !c.Enabled() || strings.TrimSpace(key) == "" || value == nil || ttl <= 0 {
return
@@ -148,6 +185,27 @@ func (c *RuntimeCacheService) SetJSON(ctx context.Context, key string, value any
}
}
// SetJSONWithStale stores a fresh value for freshTTL and keeps an in-process
// stale copy for staleTTL. Redis keeps only the fresh window so multi-instance
// deployments retain the existing consistency semantics.
func (c *RuntimeCacheService) SetJSONWithStale(ctx context.Context, key string, value any, freshTTL, staleTTL time.Duration) {
if !c.Enabled() || strings.TrimSpace(key) == "" || value == nil || freshTTL <= 0 {
return
}
if staleTTL < freshTTL {
staleTTL = freshTTL
}
raw, err := json.Marshal(value)
if err != nil {
return
}
fullKey := c.key(key)
c.setMemoryBytesWithStale(fullKey, raw, freshTTL, staleTTL, true)
if c.client != nil {
_ = c.client.Set(ctx, fullKey, raw, freshTTL).Err()
}
}
// GetObject 返回缓存中的对象。返回值不可变:调用方需要修改时必须先自行拷贝。
func (c *RuntimeCacheService) GetObject(key string) (any, bool) {
if !c.Enabled() || strings.TrimSpace(key) == "" {
@@ -255,20 +313,29 @@ func (c *RuntimeCacheService) key(key string) string {
}
func (c *RuntimeCacheService) getMemory(key string) ([]byte, bool) {
raw, ok, stale := c.getMemoryWithStale(key)
return raw, ok && !stale
}
func (c *RuntimeCacheService) getMemoryWithStale(key string) ([]byte, bool, bool) {
now := time.Now()
c.mu.Lock()
defer c.mu.Unlock()
item, ok := c.memory[key]
if !ok {
return nil, false
return nil, false, false
}
if !now.Before(item.expiresAt) {
staleUntil := item.staleUntil
if staleUntil.IsZero() {
staleUntil = item.expiresAt
}
if !now.Before(staleUntil) {
c.removeMemoryLocked(key)
return nil, false
return nil, false, false
}
item.lastUsed = now
c.memory[key] = item
return item.raw, true
return item.raw, true, !now.Before(item.expiresAt)
}
func (c *RuntimeCacheService) setMemory(key string, raw []byte, ttl time.Duration) {
@@ -280,9 +347,16 @@ func (c *RuntimeCacheService) setMemoryOwned(key string, raw []byte, ttl time.Du
}
func (c *RuntimeCacheService) setMemoryBytes(key string, raw []byte, ttl time.Duration, owned bool) {
if ttl <= 0 || len(raw) == 0 {
c.setMemoryBytesWithStale(key, raw, ttl, ttl, owned)
}
func (c *RuntimeCacheService) setMemoryBytesWithStale(key string, raw []byte, freshTTL, staleTTL time.Duration, owned bool) {
if freshTTL <= 0 || len(raw) == 0 {
return
}
if staleTTL < freshTTL {
staleTTL = freshTTL
}
size := int64(len(key)+len(raw)) + runtimeCacheEntryOverheadBytes
now := time.Now()
c.mu.Lock()
@@ -298,10 +372,11 @@ func (c *RuntimeCacheService) setMemoryBytes(key string, raw []byte, ttl time.Du
raw = append([]byte(nil), raw...)
}
c.memory[key] = runtimeCacheItem{
raw: raw,
expiresAt: now.Add(ttl),
lastUsed: now,
size: size,
raw: raw,
expiresAt: now.Add(freshTTL),
staleUntil: now.Add(staleTTL),
lastUsed: now,
size: size,
}
c.bytesUsed += size
}
@@ -334,7 +409,11 @@ func (c *RuntimeCacheService) entryCountLocked() int {
func (c *RuntimeCacheService) evictExpiredLocked(now time.Time) {
for key, item := range c.memory {
if !now.Before(item.expiresAt) {
staleUntil := item.staleUntil
if staleUntil.IsZero() {
staleUntil = item.expiresAt
}
if !now.Before(staleUntil) {
c.removeMemoryLocked(key)
}
}
+71
View File
@@ -116,3 +116,74 @@ func TestRuntimeCacheSetMaxSizeEvictsImmediately(t *testing.T) {
t.Fatal("newest entry should remain after lowering cache limit")
}
}
func TestRuntimeCacheReturnsStaleValueAfterFreshTTL(t *testing.T) {
cache := newRuntimeCacheForTest(t, 1)
key := "latest:stale"
cache.SetJSONWithStale(context.Background(), key, "cached-value", time.Minute, time.Hour)
fullKey := cache.key(key)
cache.mu.Lock()
item := cache.memory[fullKey]
item.expiresAt = time.Now().Add(-time.Second)
cache.memory[fullKey] = item
cache.mu.Unlock()
var fresh string
if cache.GetJSON(context.Background(), key, &fresh) {
t.Fatal("expired fresh entry must not be returned by GetJSON")
}
var stale string
found, isStale := cache.GetJSONStale(context.Background(), key, &stale)
if !found || !isStale {
t.Fatalf("GetJSONStale found=%t stale=%t, want true/true", found, isStale)
}
if stale != "cached-value" {
t.Fatalf("stale value=%q, want cached-value", stale)
}
}
func TestRuntimeCacheDoesNotReturnOrdinaryEntryAsStale(t *testing.T) {
cache := newRuntimeCacheForTest(t, 1)
key := "latest:ordinary"
cache.SetJSON(context.Background(), key, "cached-value", time.Minute)
fullKey := cache.key(key)
cache.mu.Lock()
item := cache.memory[fullKey]
expiredAt := time.Now().Add(-time.Second)
item.expiresAt = expiredAt
item.staleUntil = expiredAt
cache.memory[fullKey] = item
cache.mu.Unlock()
var out string
if found, isStale := cache.GetJSONStale(context.Background(), key, &out); found || isStale {
t.Fatalf("ordinary expired entry found=%t stale=%t, want false/false", found, isStale)
}
}
func TestRuntimeCacheStalePrefersFreshRedisValue(t *testing.T) {
cache := newRuntimeCacheForTest(t, 1)
key := "latest:redis-fresh"
cache.SetJSONWithStale(context.Background(), key, "local-stale", time.Minute, time.Hour)
fullKey := cache.key(key)
cache.mu.Lock()
item := cache.memory[fullKey]
item.expiresAt = time.Now().Add(-time.Second)
cache.memory[fullKey] = item
cache.mu.Unlock()
cache.redisGet = func(context.Context, string) ([]byte, error) {
return []byte(`"redis-fresh"`), nil
}
var out string
found, stale := cache.GetJSONStale(context.Background(), key, &out)
if !found || stale {
t.Fatalf("GetJSONStale found=%t stale=%t, want true/false", found, stale)
}
if out != "redis-fresh" {
t.Fatalf("value=%q, want redis-fresh", out)
}
}
@@ -268,6 +268,74 @@ func TestScanLibraryRefreshesStaleNoMatchDerivedMetadata(t *testing.T) {
}
}
func TestScanLibraryPrefersEpisodeArtworkOverSeriesFanart(t *testing.T) {
for _, tc := range []struct {
name string
episodeArt string
}{
{name: "thumb suffix", episodeArt: "Show - S01E01-thumb.jpg"},
{name: "video stem", episodeArt: "Show - S01E01.jpg"},
} {
t.Run(tc.name, func(t *testing.T) {
root := t.TempDir()
seasonDir := filepath.Join(root, "Show", "Season 01")
if err := os.MkdirAll(seasonDir, 0o755); err != nil {
t.Fatal(err)
}
mediaPath := filepath.Join(seasonDir, "Show - S01E01.mkv")
if err := os.WriteFile(mediaPath, []byte("x"), 0o644); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(nfoPath(mediaPath), []byte(
`<episodedetails><title>第一集</title><season>1</season><episode>1</episode></episodedetails>`,
), 0o644); err != nil {
t.Fatal(err)
}
episodeArt := filepath.Join(seasonDir, tc.episodeArt)
if err := os.WriteFile(episodeArt, []byte("episode still"), 0o644); err != nil {
t.Fatal(err)
}
seriesFanart := filepath.Join(root, "Show", "fanart.jpg")
if err := os.WriteFile(seriesFanart, []byte("series backdrop"), 0o644); err != nil {
t.Fatal(err)
}
db := newServiceTestDB(t, &model.Library{}, &model.Media{}, &model.Setting{})
repos := repository.New(db)
lib := model.Library{Name: "TV", Path: root, Type: "tv", Enabled: true}
if err := repos.Library.Create(t.Context(), &lib); err != nil {
t.Fatal(err)
}
existing := model.Media{
LibraryID: lib.ID,
Title: "Show",
Path: mediaPath,
SizeBytes: 1,
SeasonNum: 1,
EpisodeNum: 1,
BackdropURL: "https://image.example/episode-still.jpg",
ScrapeStatus: "matched",
}
if err := repos.DB.Create(&existing).Error; err != nil {
t.Fatal(err)
}
scanner := NewScannerService(&config.Config{}, zap.NewNop(), repos, NewHub(zap.NewNop()), nil, nil)
if _, err := scanner.ScanLibrary(t.Context(), lib.ID); err != nil {
t.Fatal(err)
}
var media model.Media
if err := db.First(&media, "id = ?", existing.ID).Error; err != nil {
t.Fatal(err)
}
if media.BackdropURL != episodeArt {
t.Fatalf("backdrop_url = %q, want episode artwork %q (series fanart %q must not win)", media.BackdropURL, episodeArt, seriesFanart)
}
})
}
}
func TestScanLibraryPrunesMissingMedia(t *testing.T) {
root := t.TempDir()
mediaPath := filepath.Join(root, "Show.S02E03.mkv")
+11 -9
View File
@@ -40,9 +40,9 @@ func (s *ScraperService) prepareScrapedArtworkURL(ctx context.Context, mediaID,
s.log.Warn("MetaTube artwork processing failed; using local face-aware crop",
zap.String("media_id", mediaID),
zap.String("field", field),
zap.String("candidate", candidate),
zap.String("source", originalSource),
zap.Error(err))
zap.String("candidate", redactSensitiveURL(candidate)),
zap.String("source", redactSensitiveURL(originalSource)),
zap.Error(redactSensitiveError(err)))
if current != "" && isHTTPish(current) {
return originalSource, current
}
@@ -53,16 +53,16 @@ func (s *ScraperService) prepareScrapedArtworkURL(ctx context.Context, mediaID,
s.log.Warn("scrape artwork prefetch failed; keeping existing artwork",
zap.String("media_id", mediaID),
zap.String("field", field),
zap.String("candidate", candidate),
zap.String("existing", current),
zap.Error(err))
zap.String("candidate", redactSensitiveURL(candidate)),
zap.String("existing", redactSensitiveURL(current)),
zap.Error(redactSensitiveError(err)))
return current, ""
}
s.log.Warn("scrape artwork prefetch failed; keeping new artwork URL for retry",
zap.String("media_id", mediaID),
zap.String("field", field),
zap.String("candidate", candidate),
zap.Error(err))
zap.String("candidate", redactSensitiveURL(candidate)),
zap.Error(redactSensitiveError(err)))
return candidate, ""
}
if current != "" && isHTTPish(current) {
@@ -98,7 +98,9 @@ func (s *ScraperService) removeCachedScrapedArtwork(urls ...string) {
}
seen[raw] = struct{}{}
if err := s.images.RemoveCached(raw); err != nil {
s.log.Debug("remove old scraped artwork cache failed", zap.String("url", raw), zap.Error(err))
s.log.Debug("remove old scraped artwork cache failed",
zap.String("url", redactSensitiveURL(raw)),
zap.Error(redactSensitiveError(err)))
}
}
}
@@ -117,8 +117,8 @@ func (s *ScraperService) downloadArtworkToPathWithOptions(ctx context.Context, d
if err != nil || len(data) == 0 {
s.log.Warn("scrape artwork download failed",
zap.String("name", name),
zap.String("url", raw),
zap.Error(err))
zap.String("url", redactSensitiveURL(raw)),
zap.Error(redactSensitiveError(err)))
return ""
}
if !isImageContentType(ctype) || isTransparentPlaceholderData(data) {
+3 -1
View File
@@ -88,7 +88,9 @@ func (b *serviceContainerBuilder) configureMediaSearchBackend() {
}
b.repos.Media.SetSearchBackend(searchBackend)
if b.log != nil {
b.log.Info("opensearch media search enabled", zap.String("index", b.cfg.Search.Index), zap.String("url", b.cfg.Search.OpenSearchURL))
b.log.Info("opensearch media search enabled",
zap.String("index", b.cfg.Search.Index),
zap.String("url", redactSensitiveURL(b.cfg.Search.OpenSearchURL)))
}
}
+700 -960
View File
File diff suppressed because it is too large Load Diff
+7 -4
View File
@@ -3,6 +3,9 @@
"private": true,
"version": "0.1.0",
"type": "module",
"engines": {
"node": "^20.19.0 || >=22.12.0"
},
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
@@ -10,7 +13,7 @@
"lint": "eslint ."
},
"dependencies": {
"axios": "^1.7.2",
"axios": "^1.20.0",
"clsx": "^2.1.1",
"danmu": "^0.20.0",
"framer-motion": "^12.38.0",
@@ -22,7 +25,7 @@
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-hot-toast": "^2.6.0",
"react-router-dom": "^6.23.1",
"react-router-dom": "^7.18.3",
"react-virtuoso": "^4.18.12",
"zustand": "^4.5.2"
},
@@ -31,7 +34,7 @@
"@types/qrcode": "^1.5.6",
"@types/react": "^18.3.3",
"@types/react-dom": "^18.3.0",
"@vitejs/plugin-react": "^4.3.0",
"@vitejs/plugin-react": "^6.1.1",
"autoprefixer": "^10.4.19",
"eslint": "^10.4.1",
"eslint-plugin-react-hooks": "^7.1.1",
@@ -41,6 +44,6 @@
"tailwindcss": "^3.4.4",
"typescript": "^5.4.5",
"typescript-eslint": "^8.60.1",
"vite": "^5.3.1"
"vite": "^8.3.0"
}
}
+5
View File
@@ -64,6 +64,11 @@ export const playbackAPI = {
listFavouriteIDs: () =>
api.get<{ ids: string[] }>('/favourites', { params: { ids: 1 } }).then((r) => r.data.ids ?? []),
favouriteStatus: (mediaId: string) =>
api
.get<{ favourite: boolean }>(`/media/${encodeURIComponent(mediaId)}/favorite/status`)
.then((r) => r.data.favourite),
listPlaylists: () =>
api.get<{ items: Playlist[] }>('/playlists').then((r) => r.data.items),
+18 -4
View File
@@ -112,16 +112,25 @@ export function HomePage() {
try {
const rows = await libraryAPI.listPreviews(batch, limit)
loaded = true
const accepted = rows.filter(
(row) => (fetchedPreviewLimitsRef.current.get(row.id) ?? 0) < limit,
)
accepted.forEach((row) => {
fetchedPreviewLimitsRef.current.set(
row.id,
Math.max(fetchedPreviewLimitsRef.current.get(row.id) ?? 0, limit),
)
})
setLibraryData((prev) => {
const next = { ...prev }
for (const row of rows) {
for (const row of accepted) {
next[row.id] = {
cards: row.cards ?? [],
items: [],
total: row.total ?? 0,
}
}
return next
return accepted.length > 0 ? next : prev
})
} catch {
// 单个批次失败不影响其他批次;导航回来时会重试。
@@ -155,8 +164,13 @@ export function HomePage() {
.slice(0, 20)
.filter((l) => !l.cover_url)
.map((l) => l.id)
const initialTargets = Array.from(new Set([...carouselLibIds, ...topRowLibIds, ...topGridLibIds]))
void fetchPreviews(initialTargets, 10)
const shelfTargets = Array.from(new Set([...carouselLibIds, ...topRowLibIds]))
const shelfTargetSet = new Set(shelfTargets)
const gridTargets = topGridLibIds.filter((id) => !shelfTargetSet.has(id))
// 入口网格只需要 2 张封面,横向货架才需要 10 张。分开请求可以避免
// 为暂时不会出现的货架预取完整卡片窗口。
void fetchPreviews(shelfTargets, 10)
void fetchPreviews(gridTargets, 2)
}, [sortedLibraries, fetchPreviews])
// 4. 媒体库展示行渐进流式加载:默认先检视前 3 个库,随向下滚动逐步检视后续库
+2 -2
View File
@@ -359,13 +359,13 @@ export function HomeLibrariesSection({
libraries: Library[]
libraryData?: Record<string, { cards: SeriesCard[]; items: Media[]; total: number }>
libraryCounts: Record<string, number>
onNeedPreviews?: (ids: string[]) => void
onNeedPreviews?: (ids: string[], limit?: number) => void
}) {
const PAGE_SIZE = 20
const [currentPage, setCurrentPage] = useState(1)
const totalPages = Math.max(1, Math.ceil(libraries.length / PAGE_SIZE))
const effectivePage = Math.min(currentPage, totalPages)
const queuePreview = useLazyPreviewBatch(onNeedPreviews)
const queuePreview = useLazyPreviewBatch((ids) => onNeedPreviews?.(ids, 2))
const pagedLibraries = useMemo<Library[]>(() => {
const start = (effectivePage - 1) * PAGE_SIZE
+31 -10
View File
@@ -26,37 +26,58 @@ export function LibrariesPage() {
const [repairEpisodeArtwork, setRepairEpisodeArtwork] = useEpisodeArtworkPreference()
const [repairMsg, setRepairMsg] = useState('')
const fetchedLibIdsRef = useRef<Set<string>>(new Set())
const fetchingRef = useRef<Set<string>>(new Set())
// 缓存每个库已加载到的预览数量:入口网格只需要 2 张,横向货架需要 10 张。
const fetchedPreviewLimitsRef = useRef<Map<string, number>>(new Map())
const fetchingPreviewLimitsRef = useRef<Map<string, number>>(new Map())
const fetchPreviews = useCallback(async (ids: string[]) => {
const targets = ids.filter((id) => !fetchedLibIdsRef.current.has(id) && !fetchingRef.current.has(id))
const fetchPreviews = useCallback(async (ids: string[], limit = 10) => {
const targets = ids.filter(
(id) =>
(fetchedPreviewLimitsRef.current.get(id) ?? 0) < limit &&
(fetchingPreviewLimitsRef.current.get(id) ?? 0) < limit,
)
if (targets.length === 0) return
targets.forEach((id) => fetchingRef.current.add(id))
targets.forEach((id) => fetchingPreviewLimitsRef.current.set(id, limit))
const batches = partitionPreviewIDs(targets)
await Promise.allSettled(
batches.map(async (batch) => {
let loaded = false
try {
const rows = await libraryAPI.listPreviews(batch, 10)
const rows = await libraryAPI.listPreviews(batch, limit)
loaded = true
const accepted = rows.filter(
(row) => (fetchedPreviewLimitsRef.current.get(row.id) ?? 0) < limit,
)
accepted.forEach((row) => {
fetchedPreviewLimitsRef.current.set(
row.id,
Math.max(fetchedPreviewLimitsRef.current.get(row.id) ?? 0, limit),
)
})
setLibraryData((prev) => {
const next = { ...prev }
for (const row of rows) {
for (const row of accepted) {
next[row.id] = {
cards: row.cards ?? [],
total: row.total ?? 0,
}
}
return next
return accepted.length > 0 ? next : prev
})
} catch {
// 单个批次失败不影响其他批次。
} finally {
batch.forEach((id) => {
if (loaded) fetchedLibIdsRef.current.add(id)
fetchingRef.current.delete(id)
if (loaded) {
fetchedPreviewLimitsRef.current.set(
id,
Math.max(fetchedPreviewLimitsRef.current.get(id) ?? 0, limit),
)
}
if (fetchingPreviewLimitsRef.current.get(id) === limit) {
fetchingPreviewLimitsRef.current.delete(id)
}
})
}
}),
+4 -4
View File
@@ -112,10 +112,10 @@ export function LibrariesContent({
previews: LibraryPreview[]
pinnedIds: string[]
onTogglePin: (libraryId: string) => void
onNeedPreviews?: (ids: string[]) => void
onNeedPreviews?: (ids: string[], limit?: number) => void
}) {
const pinnedCount = previews.filter((preview) => isLibraryPinned(preview.library.id, pinnedIds)).length
const queuePreview = useLazyPreviewBatch(onNeedPreviews)
const queuePreview = useLazyPreviewBatch((ids) => onNeedPreviews?.(ids, 2))
// 下方媒体库货架支持向下滑动渐进流式加载:默认先展示前 3 个库货架,
// 随着用户向下滑动接近底部,通过 IntersectionObserver 动态解锁后续媒体库货架。
@@ -126,7 +126,7 @@ export function LibrariesContent({
useEffect(() => {
const currentTargets = previews.slice(0, visibleCount).map((p) => p.library.id)
onNeedPreviews?.(currentTargets)
onNeedPreviews?.(currentTargets, 10)
}, [previews, visibleCount, onNeedPreviews])
// 底部哨兵监听与滚动双保险(触底解锁后续媒体库货架)
@@ -196,7 +196,7 @@ export function LibrariesContent({
useEffect(() => {
const ids = pagedPreviews.filter((preview) => !preview.library.cover_url).map((preview) => preview.library.id)
if (ids.length > 0) {
onNeedPreviews?.(ids)
onNeedPreviews?.(ids, 2)
}
}, [pagedPreviews, onNeedPreviews])
+7 -1
View File
@@ -87,8 +87,14 @@ export function LibrarySeriesEpisodes({
>
{ep.backdrop_url || ep.poster_url ? (
<img
src={imageURL(ep.backdrop_url || ep.poster_url || '', ep.updated_at)}
src={imageURL(ep.backdrop_url || ep.poster_url || '', ep.updated_at, {
maxWidth: 320,
maxHeight: 180,
quality: 76,
})}
alt=""
loading="lazy"
decoding="async"
className="h-full w-full object-cover transition-transform duration-300 group-hover:scale-105"
referrerPolicy="no-referrer"
/>
+14 -2
View File
@@ -14,8 +14,14 @@ export function MediaDetailBackdrop({ media }: MediaDetailArtworkProps) {
<div className="absolute inset-0 h-[480px] z-0 overflow-hidden">
{media.backdrop_url || media.poster_url ? (
<img
src={imageURL(media.backdrop_url || media.poster_url || '', media.updated_at)}
src={imageURL(media.backdrop_url || media.poster_url || '', media.updated_at, {
maxWidth: 1920,
maxHeight: 1080,
quality: 76,
})}
alt=""
decoding="async"
fetchPriority="low"
className="w-full h-full object-cover opacity-[0.04] scale-110 blur-2xl"
referrerPolicy="no-referrer"
/>
@@ -36,8 +42,14 @@ export function MediaDetailPoster({ media }: MediaDetailArtworkProps) {
>
{media.poster_url ? (
<img
src={imageURL(media.poster_url, media.updated_at)}
src={imageURL(media.poster_url, media.updated_at, {
maxWidth: 560,
maxHeight: 840,
quality: 84,
})}
alt={media.title}
decoding="async"
fetchPriority="high"
className="h-full w-full object-cover transition-transform duration-500 group-hover:scale-105"
referrerPolicy="no-referrer"
/>
+1 -1
View File
@@ -33,7 +33,7 @@ export function libraryArtworkItems(cards: SeriesCard[] = []): Array<{ src: stri
version: card.rep.updated_at,
}))
.filter((item) => Boolean(item.src))
.slice(0, 4)
.slice(0, 2)
}
export function getLibraryArtworks(
+2 -3
View File
@@ -148,7 +148,7 @@ function useMediaDetailRefresh({
// 三个请求并行发出;详情一到就解锁整页渲染,收藏状态与分集列表
// 到达后各自补齐(原先完全串行,首屏要排队等满三个往返)。
const nextMediaPromise = mediaAPI.get(id)
const favouritesPromise = playbackAPI.listFavourites().catch(() => [])
const favouritePromise = playbackAPI.favouriteStatus(id).catch(() => false)
const episodesPromise = mediaAPI
.getEpisodes(id)
.then((r) => r.items ?? [])
@@ -158,8 +158,7 @@ function useMediaDetailRefresh({
setMedia(nextMedia)
setLoading(false)
const favourites = await favouritesPromise
setFavourite(favourites.some((item) => item.id === nextMedia.id))
setFavourite(await favouritePromise)
const episodes = await episodesPromise
setEpisodes(episodes)
+10 -6
View File
@@ -33,7 +33,8 @@ import type { Media } from '../types'
export type SeriesCard = {
key: string
rep: Media
linkMedia: Media
linkMedia?: Media
linkLibraryId?: string
count: number
last_added_at?: string
}
@@ -220,12 +221,13 @@ export function seasonLabel(season: number): string {
}
export function isSeriesCard(card: SeriesCard): boolean {
const linkMedia = card.linkMedia ?? card.rep
return (
card.count > 1 ||
isEpisodeLike(card.rep) ||
isEpisodeLike(card.linkMedia) ||
isEpisodeLike(linkMedia) ||
pathLooksEpisodic(card.rep) ||
pathLooksEpisodic(card.linkMedia)
pathLooksEpisodic(linkMedia)
)
}
@@ -474,10 +476,11 @@ export function groupSeries(items: Media[] = []): SeriesCard[] {
}
// Repeated movie IDs represent alternate locations/encodes, not
// episodes. Fold the versions but keep the card in movie mode.
if (isEpisodeLike(m) || pathLooksEpisodic(m) || isEpisodeLike(g.linkMedia) || pathLooksEpisodic(g.linkMedia)) {
const currentLinkMedia = g.linkMedia ?? g.rep
if (isEpisodeLike(m) || pathLooksEpisodic(m) || isEpisodeLike(currentLinkMedia) || pathLooksEpisodic(currentLinkMedia)) {
g.count += 1
}
if (betterSeriesLinkMedia(m, g.linkMedia)) {
if (betterSeriesLinkMedia(m, currentLinkMedia)) {
g.linkMedia = m
}
if (betterSeriesRepresentative(m, g.rep)) {
@@ -534,7 +537,8 @@ function mediaParentLooksLikeCollection(path?: string): boolean {
export function seriesCardLink(card: SeriesCard): string {
if (isSeriesCard(card)) {
return `/library/${targetLibraryID(card.linkMedia)}?series=${encodeURIComponent(card.key)}`
const targetLibrary = card.linkLibraryId || targetLibraryID(card.linkMedia ?? card.rep)
return `/library/${targetLibrary}?series=${encodeURIComponent(card.key)}`
}
return `/media/${card.rep.id}`
}
+1 -1
View File
@@ -8,7 +8,7 @@ export default defineConfig({
plugins: [react()],
resolve: {
alias: {
'@': path.resolve(__dirname, './src'),
'@': path.resolve(import.meta.dirname, './src'),
},
},
server: {