Compare commits

..

51 Commits

Author SHA1 Message Date
truewhile 0ab4693b92 优化 2026-10-29 22:11:28 +08:00
truewhile db516ded61 优化 2026-10-29 00:09:00 +08:00
truewhile 416d4c8c3b 优化 2026-10-27 22:14:40 +08:00
truewhile 78cfe0aba2 优化 2026-10-24 13:51:25 +08:00
truewhile 60ed34d5b1 优化 2026-10-24 00:22:10 +08:00
truewhile 31e775b9ba 优化 2026-10-24 00:12:20 +08:00
truewhile bf35bd3d02 优化 2026-10-23 23:11:05 +08:00
truewhile 0a84031d06 fix(player): 方向键不再唤出控制栏,仅续期隐藏计时器
方向键调整进度/音量时,若控制栏隐藏则保持隐藏,若可见则只刷新自动隐藏计时器,避免连续按键中途消失。
2026-10-22 17:11:02 +08:00
truewhile 9ccf60427a 优化 2026-10-22 15:14:00 +08:00
truewhile 54b0e33968 优化 2026-10-22 00:44:37 +08:00
truewhile 74487359bf 优 2026-10-21 13:21:13 +08:00
truewhile 55854a0adb 优化播放逻辑 2026-10-21 12:02:11 +08:00
truewhile 121a0a3cc5 优化 2026-10-21 10:11:47 +08:00
truewhile 3057664a48 优化 2026-10-21 09:19:14 +08:00
truewhile 7793d50906 优化 2026-10-21 00:06:16 +08:00
truewhile 5ad71b9421 优化查询 2026-10-20 23:29:51 +08:00
truewhile 5615806c2c Merge pull request #49 from truewhile/feat/media-maintenance-and-diagnostics
完善媒体维护、备份恢复与播放诊断
2026-10-20 22:04:10 +08:00
jason jia 1e8fe2396e feat: 完善媒体维护、备份恢复与播放诊断 2026-10-20 14:01:42 +00:00
truewhile e34e81cce0 Merge pull request #48 from truewhile/fix/media-access-cache-search
修复实时事件权限和媒体缓存隔离,移除大库列表截断并缓存排序索引,批量同步 OpenSearch 并处理逐项失败。
2026-10-20 20:40:49 +08:00
jason jia 3cf898756b fix media event access, cache isolation and indexing 2026-10-20 12:39:13 +00:00
truewhile 0b68397265 Merge pull request #46 from truewhile/fix/introdb-api-v4
迁移 TheIntroDB 接口到 v4
2026-10-19 22:26:33 +08:00
truewhile 90bfac3b09 迁移 TheIntroDB 接口到 v4
v1 已删除、v2/v3 已弃用,仅保留只读的 GET /media。本次把 base URL 切到 v4,并适配响应以 tvdb_id 为身份(tmdb_id 字段已移除)。请求仍用 tmdb_id 寻址,v4 仍接受并由服务端解析季集映射。
2026-10-19 22:23:06 +08:00
truewhile f1974a179e 优化 2026-10-19 21:38:55 +08:00
truewhile f2357200be 优化 2026-10-19 21:11:35 +08:00
truewhile ca9a80b862 优化 2026-10-18 22:30:02 +08:00
truewhile 4f19e2210e feat: 字幕轨名称可读化
外挂字幕原来直接拿文件名的语言后缀当名称(chs、chs[BilibiliID_danmu]),
内嵌字幕没有 title 标签时也是裸语言码(chi)。改成统一映射成中文名:
chi/zh -> 中文,chs -> 简体中文,jpn -> 日本語,并把弹幕转存的
[BilibiliID_danmu] 文件标注为「弹幕」,避免和对白字幕混淆。

认不出来的标记保持原样(含 und),Emby 客户端的「字幕 (ASS)」兜底不变。
2026-10-18 17:01:08 +08:00
truewhile 91667c400c bug处理 2026-10-18 15:37:31 +08:00
truewhile 0b8438d798 Merge pull request #45 from truewhile/feature/android-mobile-api
优化
2026-10-18 10:45:05 +08:00
truewhile 2faedae0bf 优化 2026-10-18 10:43:43 +08:00
truewhile fd57d2e59b Merge pull request #43 from truewhile/feature/android-mobile-api
优化
2026-10-18 02:20:26 +08:00
truewhile 8356f5c3c7 优化 2026-10-18 02:19:33 +08:00
truewhile 5d137ab6db Merge pull request #42 from truewhile/feature/android-mobile-api
Feature/android mobile api
2026-10-17 23:25:30 +08:00
truewhile e2133cd76f app兼容接口开发 2026-10-17 23:24:48 +08:00
truewhile 1c45483e1f feat(mobile): add v1 server info and device auth sessions 2026-10-17 12:27:28 +08:00
truewhile 40982f6675 Merge pull request #41 from truewhile/fix/database-playback-stability
修复数据库兼容性与 HLS 播放生命周期问题
2026-10-17 09:31:04 +08:00
jason jia 27dcfc2fe9 fix(playback): reuse completed HLS output and preserve resume positions 2026-10-17 01:30:04 +00:00
jason jia 984b98e61b fix: preserve database compatibility and harden request lifecycles 2026-10-17 01:29:51 +00:00
truewhile 5e496b35f1 Merge pull request #40 from truewhile/fix/search-library-grouping
fix(search): 按媒体库区分同名番剧搜索结果
2026-10-16 23:52:34 +08:00
jason jia 91eb0dadf6 fix(search): keep same series separate across libraries 2026-10-16 15:50:55 +00:00
truewhile 3d7d661199 Merge pull request #39 from truewhile/work
优化媒体搜索页面与返回状态保留
2026-10-16 22:58:45 +08:00
jason jia 8686d8cae6 优化媒体搜索页面与返回状态保留 2026-10-16 14:57:30 +00:00
truewhile 43cfeabea1 bug处理 2026-10-16 14:54:06 +08:00
truewhile d112e8e4db 优化 2026-10-13 23:35:06 +08:00
truewhile 6604691c19 优化 2026-10-13 22:16:14 +08:00
truewhile 23937d8137 优化,bug处理 2026-10-12 23:43:40 +08:00
truewhile 6864a9c7f1 优化 2026-10-12 23:01:46 +08:00
truewhile 079b3ff063 优化兼容性 2026-10-11 23:44:11 +08:00
truewhile 3c30c64a39 优化 2026-10-11 21:44:20 +08:00
truewhile 6186c8b848 优化 2026-10-11 21:29:19 +08:00
truewhile 0db8698fce 优化 2026-10-10 13:49:13 +08:00
truewhile 42703c6760 优化 2026-10-10 12:00:33 +08:00
317 changed files with 22338 additions and 1656 deletions
+34 -1
View File
@@ -40,6 +40,39 @@ jobs:
- name: go test
run: go test ./...
database-compatibility:
name: SQLite / PostgreSQL ${{ matrix.postgres }}
runs-on: ubuntu-latest
strategy:
matrix:
postgres: [16, 17]
services:
postgres:
image: postgres:${{ matrix.postgres }}-alpine
env:
POSTGRES_USER: mebox
POSTGRES_PASSWORD: mebox-test-only
POSTGRES_DB: mebox_test
ports:
- 5432:5432
options: >-
--health-cmd "pg_isready -U mebox -d mebox_test"
--health-interval 5s
--health-timeout 5s
--health-retries 10
env:
MEBOX_TEST_POSTGRES_DSN: postgres://mebox:mebox-test-only@127.0.0.1:5432/mebox_test?sslmode=disable
steps:
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
with:
go-version: '1.26'
cache: true
- name: Database regression tests
run: go test ./internal/database ./internal/repository -count=1
frontend:
name: Frontend (Node)
runs-on: ubuntu-latest
@@ -78,7 +111,7 @@ jobs:
ci-success:
name: CI Success
runs-on: ubuntu-latest
needs: [backend, frontend, smoke]
needs: [backend, frontend, database-compatibility, smoke]
if: success()
steps:
- run: echo "CI passed, ready for release"
+9
View File
@@ -84,6 +84,15 @@ go test ./internal/service -run "TestOrganize|TestSubscription|TestMTeam" -count
go test ./cmd/server -count=1
```
数据库兼容测试默认覆盖 SQLite。连接专用测试 PostgreSQL 后可运行真实的双数据库回归:
```bash
MEBOX_TEST_POSTGRES_DSN='postgres://mebox:test-password@127.0.0.1:5432/mebox_test?sslmode=disable' \
go test ./internal/database ./internal/repository -count=1
```
测试账号需要创建、删除 schema 的权限;每个用例使用独立的临时 schema。CI 会分别在 PostgreSQL 16、17 上执行这些检查。
### 代码要求
- Go 代码使用 `gofmt`。
+6 -3
View File
@@ -84,12 +84,15 @@ ENV MEBOX_APP_PORT=8080 \
EXPOSE 8080
HEALTHCHECK --interval=30s --timeout=5s --start-period=15s --retries=3 \
CMD busybox wget -q --spider http://127.0.0.1:8080/api/health || exit 1
# 探测协议不能写死:开启 HTTPS 后 8080 只接受 TLS。脚本按数据目录里的
# .http_scheme 选择协议并在失败时回退,无需用户改配置。
HEALTHCHECK --interval=30s --timeout=10s --start-period=30s --retries=3 \
CMD ["/usr/local/bin/mebox-healthcheck"]
# Tiny entrypoint that lets us run as a NAS host UID/GID via PUID/PGID without
# rewriting /etc/passwd or /etc/group on every container start.
COPY docker-entrypoint.sh /entrypoint.sh
RUN chmod +x /entrypoint.sh
COPY docker-healthcheck.sh /usr/local/bin/mebox-healthcheck
RUN chmod +x /entrypoint.sh /usr/local/bin/mebox-healthcheck
CMD ["/entrypoint.sh"]
+12 -1
View File
@@ -79,6 +79,9 @@ func (a *application) start() error {
}
}
if err := database.ApplyPendingSQLiteRestore(context.Background(), a.cfg, a.logger); err != nil {
return fmt.Errorf("apply pending database restore: %w", err)
}
db, err := database.Open(a.cfg, a.logger)
if err != nil {
a.logger.Error("database open failed", zap.Error(err))
@@ -88,7 +91,7 @@ func (a *application) start() error {
a.logger.Error("database not ready", zap.Error(err))
return fmt.Errorf("database not ready: %w", err)
}
if err := database.AutoMigrate(db); err != nil {
if err := database.AutoMigrateWithConfig(db, a.cfg, a.logger); err != nil {
a.logger.Error("auto-migrate failed", zap.Error(err))
return fmt.Errorf("auto-migrate failed: %w", err)
}
@@ -110,6 +113,14 @@ func (a *application) start() error {
a.logger.Info("polluted episode metadata cleanup completed", zap.Int("media_count", cleaned))
}
// 一次性删除旧版解析误写的「整集片头/回顾」区间(end_ms <= start_ms),
// 否则升级后播放器仍会一开播就弹跳过按钮。
if cleaned, err := a.services.NormalizeImpossibleSegments(context.Background()); err != nil {
a.logger.Warn("impossible skip segment cleanup failed", zap.Error(err))
} else if cleaned > 0 {
a.logger.Info("impossible skip segments removed", zap.Int("rows", cleaned))
}
if err := a.services.Auth.SeedAdmin(context.Background()); err != nil {
a.logger.Warn("seed admin failed", zap.Error(err))
}
+129
View File
@@ -0,0 +1,129 @@
package main
import (
"bufio"
"net"
"sync"
"time"
)
// 开启 HTTPS 后,同一个端口会同时面对两类连接:
//
// - TLS 客户端(浏览器、播放器)——正常提供 HTTPS 服务;
// - 明确访问 http:// 的客户端——以前只会撞出一句
// "Client sent an HTTP request to an HTTPS server.",用户看不懂。
//
// 一个端口上只有 TLS 监听时无法区分两者,所以在 Accept 之后先偷看第一个字节:
// TLS 握手的记录类型固定是 0x16(handshake),而 HTTP 请求行必然是可见字符。
// 据此把连接分流成两路,明文那一路交给只做跳转的 http.Server,于是 http:// 会被
// 自动重定向到 https://(见 server_manager.go 的 httpsRedirectHandler)。
const tlsRecordHandshake = 0x16
// defaultPeekTimeout 限制"偷看首字节"阶段的等待,避免只连不发的半开连接长期占住
// goroutine。分流器按实例持有该值,便于测试用更短的超时。
const defaultPeekTimeout = 10 * time.Second
// splitListener 是监听分流器:Accept 到底层连接后按首字节投递给对应的一路。
type splitListener struct {
ln net.Listener
tlsCh chan net.Conn
plainCh chan net.Conn
quit chan struct{}
once sync.Once
peek time.Duration
}
func newSplitListener(ln net.Listener) *splitListener {
return newSplitListenerWithTimeout(ln, defaultPeekTimeout)
}
func newSplitListenerWithTimeout(ln net.Listener, peek time.Duration) *splitListener {
s := &splitListener{
ln: ln,
tlsCh: make(chan net.Conn),
plainCh: make(chan net.Conn),
quit: make(chan struct{}),
peek: peek,
}
go s.acceptLoop()
return s
}
// tlsListener 返回只收到 TLS 连接的 listener。
func (s *splitListener) tlsListener() net.Listener {
return &channelListener{addr: s.ln.Addr(), conns: s.tlsCh, quit: s.quit}
}
// plainListener 返回只收到明文 HTTP 连接的 listener。
func (s *splitListener) plainListener() net.Listener {
return &channelListener{addr: s.ln.Addr(), conns: s.plainCh, quit: s.quit}
}
func (s *splitListener) acceptLoop() {
for {
conn, err := s.ln.Accept()
if err != nil {
// 底层监听关闭(换证、关停)后两路 Accept 一起返回 net.ErrClosed,
// 由持有它们的 http.Server 自行收尾。
s.stop()
return
}
go s.dispatch(conn)
}
}
func (s *splitListener) dispatch(conn net.Conn) {
br := bufio.NewReader(conn)
_ = conn.SetReadDeadline(time.Now().Add(s.peek))
first, err := br.Peek(1)
_ = conn.SetReadDeadline(time.Time{})
if err != nil {
_ = conn.Close()
return
}
peeked := &peekedConn{Conn: conn, r: br}
ch := s.plainCh
if first[0] == tlsRecordHandshake {
ch = s.tlsCh
}
select {
case ch <- peeked: // 交给对应的 http.Server,由它负责读写与关闭
case <-s.quit:
_ = conn.Close()
}
}
func (s *splitListener) stop() {
s.once.Do(func() { close(s.quit) })
}
// channelListener 把一路 channel 包装成 net.Listener,供 http.Server.Serve 使用。
type channelListener struct {
addr net.Addr
conns <-chan net.Conn
quit <-chan struct{}
}
func (l *channelListener) Accept() (net.Conn, error) {
select {
case conn := <-l.conns:
return conn, nil
case <-l.quit:
return nil, net.ErrClosed
}
}
// Close 是空实现:真正要释放的端口在 splitListener 的底层监听上,它一旦关闭,
// 这里的 Accept 就会返回 net.ErrClosed,http.Server 随之退出。
func (l *channelListener) Close() error { return nil }
func (l *channelListener) Addr() net.Addr { return l.addr }
// peekedConn 把已经读进 bufio 缓冲的首字节还给上层读取者。
type peekedConn struct {
net.Conn
r *bufio.Reader
}
func (c *peekedConn) Read(p []byte) (int, error) { return c.r.Read(p) }
+140
View File
@@ -0,0 +1,140 @@
package main
import (
"io"
"net"
"net/http"
"testing"
"time"
)
func TestSplitListenerRoutesByFirstByte(t *testing.T) {
raw, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatal(err)
}
split := newSplitListener(raw)
addr := raw.Addr().String()
// TLS:首字节 0x16。分流器偷看过的字节必须还能被上层读到,否则握手会失败。
handshake := []byte{0x16, 0x03, 0x01, 0x00, 0x2c}
tlsClient, err := net.Dial("tcp", addr)
if err != nil {
t.Fatal(err)
}
defer func() { _ = tlsClient.Close() }()
if _, err := tlsClient.Write(handshake); err != nil {
t.Fatal(err)
}
tlsConn := <-split.tlsCh
defer func() { _ = tlsConn.Close() }()
got := make([]byte, len(handshake))
if _, err := io.ReadFull(tlsConn, got); err != nil {
t.Fatalf("read peeked tls bytes: %v", err)
}
if string(got) != string(handshake) {
t.Fatalf("tls bytes = %v, want %v", got, handshake)
}
// 明文 HTTP:请求行走另一路,同样要读到偷看过的首字节。
request := "GET /api/health HTTP/1.1\r\nHost: example.com\r\n\r\n"
plainClient, err := net.Dial("tcp", addr)
if err != nil {
t.Fatal(err)
}
defer func() { _ = plainClient.Close() }()
if _, err := plainClient.Write([]byte(request)); err != nil {
t.Fatal(err)
}
plainConn := <-split.plainCh
defer func() { _ = plainConn.Close() }()
head := make([]byte, len("GET /"))
if _, err := io.ReadFull(plainConn, head); err != nil {
t.Fatalf("read peeked plain bytes: %v", err)
}
if string(head) != request[:len(head)] {
t.Fatalf("plain bytes = %q, want prefix of %q", head, request)
}
// 底层监听关闭后两路 Accept 都要返回 net.ErrClosed,http.Server 才能收尾。
_ = raw.Close()
for _, ln := range []net.Listener{split.tlsListener(), split.plainListener()} {
deadline := time.Now().Add(3 * time.Second)
for {
_, err := ln.Accept()
if err == net.ErrClosed {
break
}
if time.Now().After(deadline) {
t.Fatalf("Accept after close = %v, want net.ErrClosed", err)
}
time.Sleep(10 * time.Millisecond)
}
}
}
// 只连不发的半开连接不应被分给任何一路,而应在偷看超时后被丢弃。
func TestSplitListenerDropsIdleConnection(t *testing.T) {
raw, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatal(err)
}
defer func() { _ = raw.Close() }()
split := newSplitListenerWithTimeout(raw, 200*time.Millisecond)
conn, err := net.Dial("tcp", raw.Addr().String())
if err != nil {
t.Fatal(err)
}
defer func() { _ = conn.Close() }()
select {
case c := <-split.plainCh:
_ = c.Close()
t.Fatal("idle connection should not be dispatched")
case c := <-split.tlsCh:
_ = c.Close()
t.Fatal("idle connection should not be dispatched")
case <-time.After(time.Second):
}
}
func TestHTTPSRedirectTarget(t *testing.T) {
cases := []struct {
name string
host string
rawURL string
want string
wantErr bool
}{
{name: "keeps host port path and query", host: "10.0.0.2:8080", rawURL: "/library?tab=1&q=a%20b", want: "https://10.0.0.2:8080/library?tab=1&q=a%20b"},
{name: "root path", host: "nas.local:18080", rawURL: "/", want: "https://nas.local:18080/"},
{name: "keeps escaping of user input", host: "10.0.0.2", rawURL: "/a%2Fb", want: "https://10.0.0.2/a%2Fb"},
{name: "absolute form request line", host: "10.0.0.2:8080", rawURL: "http://10.0.0.2:8080/api/x?y=1", want: "https://10.0.0.2:8080/api/x?y=1"},
{name: "rejects empty host", host: "", rawURL: "/", wantErr: true},
{name: "rejects host with slash", host: "evil.com/path", rawURL: "/", wantErr: true},
{name: "rejects userinfo host", host: "user@evil.com", rawURL: "/", wantErr: true},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
req, err := http.NewRequest(http.MethodGet, tc.rawURL, nil)
if err != nil {
t.Fatal(err)
}
req.Host = tc.host
if host := redirectHostHeader(req); host == "" {
if !tc.wantErr {
t.Fatalf("redirectHostHeader(%q) rejected, want accepted", tc.host)
}
return
}
if tc.wantErr {
t.Fatalf("redirectHostHeader(%q) accepted, want rejected", tc.host)
}
if got := httpsRedirectURL(tc.host, req); got != tc.want {
t.Fatalf("redirect target = %q, want %q", got, tc.want)
}
})
}
}
+126 -18
View File
@@ -7,6 +7,8 @@ import (
"fmt"
"net"
"net/http"
"os"
"path/filepath"
"strings"
"sync"
"time"
@@ -17,6 +19,14 @@ import (
"github.com/truewhile/MeBox/internal/service"
)
// httpSchemeFileName 是数据目录下记录当前监听协议的小文件(内容为 http 或 https)。
//
// 容器健康检查无法读取后台保存的 HTTPS 设置(它存在数据库里),而同一端口在开启
// HTTPS 后只接受 TLS。若健康检查固定用 http:// 探测,每轮都会撞出
// "client sent an HTTP request to an HTTPS server",既刷日志又让容器永远 unhealthy。
// 因此由服务进程在每次成功绑定监听后写下真实协议,供 docker-healthcheck.sh 选择。
const httpSchemeFileName = ".http_scheme"
// tlsPair 记录当前正在服务的证书,用于判断是否需要重新绑定监听。
type tlsPair struct {
cert tls.Certificate
@@ -35,6 +45,9 @@ type tlsPair struct {
// - 旧服务器随后优雅退出,正在进行的播放/请求不会被立刻掐断。
//
// 任何校验失败都会中止切换并保留旧监听,保证用户不会被锁在服务外面。
//
// 开启 HTTPS 时端口上会同时接两类连接:TLS 走主服务器,明文 HTTP 走
// redirectSrv 并跳转到 HTTPS(见 protocol_mux.go)。
type serverManager struct {
cfg *config.Config
log *zap.Logger
@@ -43,6 +56,7 @@ type serverManager struct {
mu sync.Mutex
srv *http.Server
redirectSrv *http.Server
ln net.Listener
pair *tlsPair
stopCh chan struct{}
@@ -92,11 +106,11 @@ func (m *serverManager) Reload() error {
return nil
}
oldSrv, oldLn, oldPair := m.srv, m.ln, m.pair
oldSrv, oldRedirect, oldLn, oldPair := m.srv, m.redirectSrv, m.ln, m.pair
if oldLn != nil {
_ = oldLn.Close() // 释放端口后再绑定新监听
}
m.srv, m.ln, m.pair = nil, nil, nil
m.srv, m.redirectSrv, m.ln, m.pair = nil, nil, nil, nil
firstErr := m.bind(pair)
if firstErr != nil {
@@ -107,6 +121,7 @@ func (m *serverManager) Reload() error {
}
// 新监听已就绪,让旧服务器在新连接切换到新监听后优雅退出。
m.drain(oldSrv)
m.drain(oldRedirect)
m.logServerReady()
m.maybeStartAutoReloadLocked()
return firstErr
@@ -120,11 +135,19 @@ func (m *serverManager) Shutdown(ctx context.Context) error {
close(m.stopCh)
}
m.mu.Lock()
defer m.mu.Unlock()
if m.srv == nil {
return nil
targets := []*http.Server{m.srv, m.redirectSrv}
m.mu.Unlock()
var firstErr error
for _, srv := range targets {
if srv == nil {
continue
}
if err := srv.Shutdown(ctx); err != nil && firstErr == nil {
firstErr = err
}
}
return m.srv.Shutdown(ctx)
return firstErr
}
// desiredPair 根据当前配置计算目标监听形态:nil 表示明文 HTTP,非 nil 表示 TLS。
@@ -217,25 +240,107 @@ func (m *serverManager) pairEquals(pair *tlsPair) bool {
return pair.version == m.pair.version
}
// bind 创建并按需启用 TLS 的监听,异步开始服务。
// bind 创建监听,异步开始服务。
//
// 未开启 HTTPS 时端口只服务明文 HTTP;开启后同一个端口两种流量都要接:TLS 连接
// 交给主服务器,明文 HTTP 连接交给只做跳转的 redirect 服务器,让 http:// 自动
// 跳到 https://,而不是甩给用户一句 "Client sent an HTTP request to an HTTPS
// server."。
func (m *serverManager) bind(pair *tlsPair) error {
ln, err := net.Listen("tcp", m.addr)
if err != nil {
return fmt.Errorf("listen %s: %w", m.addr, err)
}
srv := &http.Server{
Handler: m.handler,
if pair == nil {
srv := m.newHTTPServer(m.handler)
m.srv, m.redirectSrv, m.ln, m.pair = srv, nil, ln, nil
m.writeSchemeState(nil)
go m.serve(srv, ln)
return nil
}
split := newSplitListener(ln)
srv := m.newHTTPServer(m.handler)
tlsLn := tls.NewListener(split.tlsListener(), &tls.Config{
Certificates: []tls.Certificate{pair.cert},
MinVersion: tls.VersionTLS12,
})
redirect := m.newHTTPServer(m.httpsRedirectHandler())
m.srv, m.redirectSrv, m.ln, m.pair = srv, redirect, ln, pair
m.writeSchemeState(pair)
go m.serve(srv, tlsLn)
go m.serve(redirect, split.plainListener())
return nil
}
func (m *serverManager) newHTTPServer(handler http.Handler) *http.Server {
return &http.Server{
Handler: handler,
ReadHeaderTimeout: 15 * time.Second,
}
if pair != nil {
ln = tls.NewListener(ln, &tls.Config{
Certificates: []tls.Certificate{pair.cert},
MinVersion: tls.VersionTLS12,
})
}
// httpsRedirectHandler 处理"开着 HTTPS 却用 http:// 访问"的请求:跳转到同端口、
// 同路径的 https://。
//
// 用 302/307 而不是 301/308:HTTPS 开关可以在后台随时改,永久跳转会被浏览器长期
// 缓存,等用户关掉 HTTPS 之后 http 访问会被带到已经不提供 TLS 的端口上打不开。
func (m *serverManager) httpsRedirectHandler() http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
host := redirectHostHeader(r)
if host == "" {
// 没有可用的 Host(HTTP/1.0 或畸形请求头)时宁可明说,也不跳到错的地方。
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
w.WriteHeader(http.StatusBadRequest)
fmt.Fprintf(w, "HTTPS is enabled; open https://<host>:%d instead.\n", m.cfg.App.Port)
return
}
status := http.StatusFound
if r.Method != http.MethodGet && r.Method != http.MethodHead {
status = http.StatusTemporaryRedirect // 保住方法与请求体
}
w.Header().Set("Location", httpsRedirectURL(host, r))
w.WriteHeader(status)
})
}
// httpsRedirectURL 拼出同端口同路径的 HTTPS 地址。
func httpsRedirectURL(host string, r *http.Request) string {
target := "https://" + host + r.URL.EscapedPath()
if r.URL.RawQuery != "" {
target += "?" + r.URL.RawQuery
}
return target
}
// redirectHostHeader 取重定向目标主机。HTTP/1.1 里 Host 是唯一的主机来源(端口映射
// 到容器 8080 时它同时也是 HTTPS 端口),这里只做字符过滤,避免把请求头里的怪字符
// 带进 Location 造成任意跳转。
func redirectHostHeader(r *http.Request) string {
host := strings.TrimSpace(r.Host)
if host == "" || strings.ContainsAny(host, " \t\r\n/\\@?#") {
return ""
}
return host
}
// writeSchemeState 把当前实际监听的协议写入数据目录,供容器健康检查按协议探测。
// 写失败只降级为告警:健康检查脚本在状态文件缺失时会回退试另一种协议。
func (m *serverManager) writeSchemeState(pair *tlsPair) {
dataDir := strings.TrimSpace(m.cfg.App.DataDir)
if dataDir == "" {
return
}
scheme := "http"
if pair != nil {
scheme = "https"
}
path := filepath.Join(dataDir, httpSchemeFileName)
if err := os.WriteFile(path, []byte(scheme+"\n"), 0o644); err != nil { // #nosec G304 -- path is DataDir + fixed filename.
m.log.Warn("write http scheme state failed", zap.String("path", path), zap.Error(err))
}
m.srv, m.ln, m.pair = srv, ln, pair
go m.serve(srv, ln)
return nil
}
func (m *serverManager) serve(s *http.Server, ln net.Listener) {
@@ -250,6 +355,9 @@ func (m *serverManager) drain(s *http.Server) {
if s == nil {
return
}
// Stop reusing old connections before Reload returns. Asynchronous Shutdown
// alone leaves a window where a client receives the previous HTTP/TLS mode.
s.SetKeepAlivesEnabled(false)
go func(s *http.Server) {
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel()
@@ -271,7 +379,7 @@ func (m *serverManager) logServerReady() {
zap.String("listen", m.addr),
)
if m.pair != nil {
m.log.Info("HTTPS is enabled; plain HTTP is no longer served on this port",
m.log.Info("HTTPS is enabled; plain HTTP requests on this port are redirected to HTTPS",
zap.String("addr", m.addr),
)
}
+139
View File
@@ -1,13 +1,17 @@
package main
import (
"context"
"crypto/ecdsa"
"crypto/elliptic"
"crypto/rand"
"crypto/tls"
"crypto/x509"
"crypto/x509/pkix"
"encoding/pem"
"io"
"math/big"
"net"
"net/http"
"os"
"path/filepath"
@@ -32,6 +36,8 @@ func makeTestPairPEM(t *testing.T) (certPEM, keyPEM string) {
NotBefore: time.Now().Add(-time.Hour),
NotAfter: time.Now().Add(24 * time.Hour),
DNSNames: []string{"localhost"},
// 带上回环 IP,HTTPS 客户端就能在校验证书的前提下连 127.0.0.1:port。
IPAddresses: []net.IP{net.ParseIP("127.0.0.1")},
}
der, err := x509.CreateCertificate(rand.Reader, tpl, tpl, &priv.PublicKey, priv)
if err != nil {
@@ -105,3 +111,136 @@ func TestDesiredPairModes(t *testing.T) {
t.Fatal("version should change after files replaced")
}
}
// 健康检查依赖该文件判断该用 http 还是 https 探测,内容与当前监听协议必须一致,
// 否则容器会一直显示 unhealthy 并刷 TLS handshake error 日志。
func TestWriteSchemeState(t *testing.T) {
m := newTestServerManager(t)
m.cfg.App.DataDir = t.TempDir()
path := filepath.Join(m.cfg.App.DataDir, httpSchemeFileName)
m.writeSchemeState(nil)
if data, err := os.ReadFile(path); err != nil || strings.TrimSpace(string(data)) != "http" {
t.Fatalf("plain HTTP state: data=%q err=%v", data, err)
}
m.writeSchemeState(&tlsPair{})
if data, err := os.ReadFile(path); err != nil || strings.TrimSpace(string(data)) != "https" {
t.Fatalf("HTTPS state: data=%q err=%v", data, err)
}
// DataDir 为空时不应写到工作目录(测试/裸跑二进制时会污染仓库)。
m.cfg.App.DataDir = ""
_ = os.Remove(path)
m.writeSchemeState(nil)
if _, err := os.Stat(httpSchemeFileName); !os.IsNotExist(err) {
t.Fatalf("empty DataDir should not create %s in cwd: %v", httpSchemeFileName, err)
}
}
// 开启 HTTPS 后同一个端口要同时接 TLS 与明文 HTTP:明文请求跳到 HTTPS,而不是
// 给用户看 "Client sent an HTTP request to an HTTPS server."。
func TestServerManagerRedirectsPlainHTTPToHTTPS(t *testing.T) {
certPEM, keyPEM := makeTestPairPEM(t)
cfg := &config.Config{}
cfg.App.Port = 8080
cfg.App.DataDir = t.TempDir()
cfg.App.HTTPSEnabled = true
cfg.App.SSLCert, cfg.App.SSLKey = certPEM, keyPEM
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
_, _ = io.WriteString(w, "secure:"+r.URL.Path)
})
m := newServerManager(cfg, zap.NewNop(), handler)
m.addr = "127.0.0.1:0" // 端口 0 交给内核分配,测试之间不会撞端口
if err := m.Start(); err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = m.Shutdown(context.Background()) })
addr := m.ln.Addr().String()
// 端口 0 只在第一次分配时用;热切换要绑回同一个端口(生产里端口本来就是固定的)。
m.addr = addr
schemeFile := filepath.Join(cfg.App.DataDir, httpSchemeFileName)
if data, err := os.ReadFile(schemeFile); err != nil || strings.TrimSpace(string(data)) != "https" {
t.Fatalf("scheme state = %q err=%v, want https", data, err)
}
plain := &http.Client{
Timeout: 10 * time.Second,
CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse },
}
resp, err := plain.Get("http://" + addr + "/library?tab=1")
if err != nil {
t.Fatal(err)
}
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode != http.StatusFound {
t.Fatalf("plain GET status = %d, want %d", resp.StatusCode, http.StatusFound)
}
if got, want := resp.Header.Get("Location"), "https://"+addr+"/library?tab=1"; got != want {
t.Fatalf("Location = %q, want %q", got, want)
}
postResp, err := plain.Post("http://"+addr+"/api/me/login", "application/json", strings.NewReader("{}"))
if err != nil {
t.Fatal(err)
}
defer func() { _ = postResp.Body.Close() }()
if postResp.StatusCode != http.StatusTemporaryRedirect {
t.Fatalf("plain POST status = %d, want %d", postResp.StatusCode, http.StatusTemporaryRedirect)
}
if got, want := postResp.Header.Get("Location"), "https://"+addr+"/api/me/login"; got != want {
t.Fatalf("POST Location = %q, want %q", got, want)
}
// 同一端口上的 TLS 连接照常拿到正常响应(校验证书,不做跳过校验)。
pool := x509.NewCertPool()
if !pool.AppendCertsFromPEM([]byte(certPEM)) {
t.Fatal("append test certificate to pool failed")
}
secure := &http.Client{
Timeout: 10 * time.Second,
Transport: &http.Transport{
TLSClientConfig: &tls.Config{RootCAs: pool, MinVersion: tls.VersionTLS12},
},
}
secureResp, err := secure.Get("https://" + addr + "/library")
if err != nil {
t.Fatal(err)
}
defer func() { _ = secureResp.Body.Close() }()
body, err := io.ReadAll(secureResp.Body)
if err != nil {
t.Fatal(err)
}
if secureResp.StatusCode != http.StatusOK || string(body) != "secure:/library" {
t.Fatalf("https response = %d %q, want 200 %q", secureResp.StatusCode, body, "secure:/library")
}
// 关掉 HTTPS 后同一端口回到纯明文服务,不再有多余的跳转服务器。
cfg.App.HTTPSEnabled = false
if err := m.Reload(); err != nil {
t.Fatal(err)
}
if m.redirectSrv != nil {
t.Fatal("redirect server should be released when HTTPS is off")
}
if data, err := os.ReadFile(schemeFile); err != nil || strings.TrimSpace(string(data)) != "http" {
t.Fatalf("scheme state = %q err=%v, want http", data, err)
}
plainResp, err := plain.Get("http://" + addr + "/library")
if err != nil {
t.Fatal(err)
}
defer func() { _ = plainResp.Body.Close() }()
plainBody, err := io.ReadAll(plainResp.Body)
if err != nil {
t.Fatal(err)
}
if plainResp.StatusCode != http.StatusOK || string(plainBody) != "secure:/library" {
t.Fatalf("plain response after disabling https = %d %q", plainResp.StatusCode, plainBody)
}
}
BIN
View File
Binary file not shown.
+4 -2
View File
@@ -62,7 +62,7 @@ services:
# NAS 示例:source: /vol1/1000/Media
# Windows Docker Desktop 示例:source: D:/Media
# create_host_path=false 可以避免路径写错时 Docker 自动创建空文件夹。
- ./media: /media
- ./media:/media
environment:
TZ: Asia/Shanghai
@@ -105,7 +105,9 @@ services:
MEBOX_TRANSCODER_IDLE_TIMEOUT_SECONDS: "120"
healthcheck:
test: ["CMD-SHELL", "busybox wget -qO- http://127.0.0.1:8080/api/health || exit 1"]
# 不能写死 http://:开启 HTTPS 后 8080 只接受 TLS,固定明文探测会
# 每轮刷一条 TLS handshake error 并让容器一直 unhealthy。
test: ["CMD", "/usr/local/bin/mebox-healthcheck"]
interval: 30s
timeout: 10s
retries: 5
+3 -1
View File
@@ -115,7 +115,9 @@ services:
MEBOX_TRANSCODER_IDLE_TIMEOUT_SECONDS: "120"
healthcheck:
test: ["CMD-SHELL", "busybox wget -qO- http://127.0.0.1:8080/api/health || exit 1"]
# 不能写死 http://:开启 HTTPS 后 8080 只接受 TLS,固定明文探测会
# 每轮刷一条 TLS handshake error 并让容器一直 unhealthy。
test: ["CMD", "/usr/local/bin/mebox-healthcheck"]
interval: 30s
timeout: 10s
retries: 5
+3 -1
View File
@@ -73,7 +73,9 @@ services:
- "host.docker.internal:host-gateway"
healthcheck:
test: ["CMD-SHELL", "busybox wget -qO- http://127.0.0.1:8080/api/health || exit 1"]
# 不能写死 http://:开启 HTTPS 后 8080 只接受 TLS,固定明文探测会
# 每轮刷一条 TLS handshake error 并让容器一直 unhealthy。
test: ["CMD", "/usr/local/bin/mebox-healthcheck"]
interval: 30s
timeout: 10s
retries: 5
+4 -2
View File
@@ -44,7 +44,7 @@ services:
# NAS 示例:source: /vol1/1000/Media
# Windows Docker Desktop 示例:source: D:/Media
# create_host_path=false 可以避免路径写错时 Docker 自动创建空文件夹。
- ./media: /media
- ./media:/media
environment:
TZ: Asia/Shanghai
@@ -87,7 +87,9 @@ services:
MEBOX_TRANSCODER_IDLE_TIMEOUT_SECONDS: "120"
healthcheck:
test: ["CMD-SHELL", "busybox wget -qO- http://127.0.0.1:8080/api/health || exit 1"]
# 不能写死 http://:开启 HTTPS 后 8080 只接受 TLS,固定明文探测会
# 每轮刷一条 TLS handshake error 并让容器一直 unhealthy。
test: ["CMD", "/usr/local/bin/mebox-healthcheck"]
interval: 30s
timeout: 10s
retries: 5
+3 -1
View File
@@ -131,7 +131,9 @@ services:
MEBOX_TRANSCODER_IDLE_TIMEOUT_SECONDS: "120"
healthcheck:
test: ["CMD-SHELL", "busybox wget -qO- http://127.0.0.1:8080/api/health || exit 1"]
# 不能写死 http://:开启 HTTPS 后 8080 只接受 TLS,固定明文探测会
# 每轮刷一条 TLS handshake error 并让容器一直 unhealthy。
test: ["CMD", "/usr/local/bin/mebox-healthcheck"]
interval: 30s
timeout: 10s
retries: 5
+39
View File
@@ -0,0 +1,39 @@
#!/bin/sh
# MeBox 容器健康检查。
#
# 背景:MeBox 只监听一个端口,且开启 HTTPS 后该端口只接受 TLS(明文 HTTP 不再
# 服务)。健康检查若固定用 http:// 探测,每轮都会被 TLS 监听拒绝并在服务日志里
# 留下一条 "client sent an HTTP request to an HTTPS server",同时容器状态永远
# 是 unhealthy。反过来固定用 https:// 探测,则在未开启 HTTPS 的部署上产生
# "first record does not look like a TLS handshake"。
#
# 当前协议由服务进程写入 $MEBOX_APP_DATA_DIR/.http_scheme(见
# cmd/server/server_manager.go 的 writeSchemeState)。脚本优先按该文件选择协议,
# 文件缺失或该协议探测失败时再试另一种,避免状态文件过期导致误报不健康。
set -u
port="${MEBOX_APP_PORT:-8080}"
data_dir="${MEBOX_APP_DATA_DIR:-/data}"
scheme_file="$data_dir/.http_scheme"
scheme=""
if [ -r "$scheme_file" ]; then
scheme="$(tr -d '[:space:]' <"$scheme_file")"
fi
if [ "$scheme" = "https" ]; then
first="https"
second="http"
else
first="http"
second="https"
fi
for proto in "$first" "$second"; do
# --no-check-certificate:用户常用自签证书或 IP 访问,校验域名必然会失败。
if busybox wget -q --spider -T 3 --no-check-certificate \
"$proto://127.0.0.1:$port/api/health" >/dev/null 2>&1; then
exit 0
fi
done
exit 1
+91
View File
@@ -0,0 +1,91 @@
# MeBox Mobile API 接入与部署说明
更新日期:2026-10-17
适用版本:后端 `feature/android-mobile-api` 分支;App `0.1.0`(versionCode 1)。
## 1. 路径与版本
- 安卓契约挂载在 `/api/v1`,与既有 `/api`、Emby/Jellyfin 入口并行。
- 健康检查:`GET /api/health`。
- 实例身份:`GET /api/v1/server-info`(匿名,`Cache-Control: no-store`)。
- 契约字段与错误码见同目录 `openapi.yaml`。
`server-info.capabilities` 必须反映真实运行能力。当前分支只有
`device_sessions=true`;播放会话、阅读 v1 进度、批量正文与移动端转码仍为
`false`,App 不得据此进入对应流程。
## 2. 连接与传输安全
- 公网必须使用 HTTPS;App 默认使用系统信任链,不提供全局跳过证书校验。
- 局域网 HTTP 需要用户在 App 内明确确认风险;App 只对当前配置的服务器来源
使用明文,不会把 MeBox `Authorization` 发往其它主机。
- 首版只支持根路径部署。`https://host/mebox` 这类子路径会被 App 明确拒绝,
不会静默截断为根路径。
- 反代需要保留 `X-Request-ID`(可选);服务端会回传同值或新生成值,用于
日志对账。
## 3. 反向代理检查项
以下项用于媒体阶段(M3/M4)回归,当前登录闭环不依赖它们,但仍建议提前配置:
| 项目 | 要求 |
| --- | --- |
| Range / 206 | 透传客户端 `Range`,不要缓存完整响应后改写状态码 |
| 流式响应 | 关闭代理缓冲(如 nginx `proxy_buffering off`) |
| HLS | `.m3u8` 与分片路径不要被改写或强制压缩 |
| WebSocket | 升级头透传,读超时大于心跳间隔 |
| 超时 | 登录/普通 API 30 秒内完成;转码起播超时单独配置 |
目前只验证了普通 JSON 端点;上表其余项尚未在本分支验收。
## 4. 认证与会话
- `POST /api/v1/auth/login` 创建 `auth_sessions` 记录,访问令牌 JWT 增加 `sid`。
- 旧 `/api` 中间件同样校验 `sid`:设备被踢出或退出后,未过期的 JWT 也会立即
返回 401,不能借旧入口绕过。
- 没有 `sid` 的历史令牌继续按原逻辑访问旧 API;v1 认证端点拒绝这类令牌,
App 需要重新登录。
- `POST /api/v1/auth/logout` 幂等;会话撤销会同时撤销其名下刷新令牌。
- 设备管理中的踢出操作会同步撤销该设备认证会话。
### 刷新轮换边界
- 同一个刷新令牌的并发请求共享一次轮换;60 秒宽限期内重复提交返回同一结果。
- 轮换结果仍保存在进程内。若新令牌已签发、旧令牌已撤销,但客户端没收到响应
且服务器随后重启,旧令牌可能无法恢复;此时 App 应要求重新登录。首版接受
该边界,未实现持久化幂等轮换。
- 会话被撤销时,刷新返回 `401 SESSION_REVOKED`,优先于宽限期复用。
## 5. 错误契约
统一响应:
```json
{
"code": "SESSION_REVOKED",
"message": "auth session revoked or expired",
"request_id": "8a3bade4-cbb3-485b-8e34-2471e4940fc3",
"retryable": false
}
```
- 401 由 `code` 区分 `TOKEN_EXPIRED`、`SESSION_REVOKED`、`INVALID_CREDENTIALS`。
- 403 表示权限或账号状态,不触发刷新。
- 429 带 `Retry-After`,客户端必须等待。
- 5xx / `SERVICE_UNAVAILABLE` / `retryable=true` 表示可安全重试。
## 6. 数据迁移与回滚
- 新表 `auth_sessions` 与 `refresh_tokens.auth_session_id` 由现有 AutoMigrate
创建,属于增量变更,不清库。
- SQLite 已验证;PostgreSQL 迁移需要在有实例的环境中单独执行
`go test ./internal/database ./internal/repository -count=1` 后才能宣称通过。
- 回滚到旧版本不会删除新表;旧程序忽略新增字段。回滚前应先确认不需要再
依赖 `sid` 即时撤销能力。
## 7. 已知限制
- 单实例部署;未实现多副本共享会话。
- 外部播放令牌尚未继承 `sid`(属于 M3 播放会话范围),当前不能按设备即时撤销。
- 反代子路径、自签名证书信任、多服务器配置均未支持。
- App 目前只开放连接、登录、会话恢复与退出;媒体与阅读入口未实现。
+389
View File
@@ -0,0 +1,389 @@
openapi: 3.0.3
info:
title: MeBox Mobile API
version: 1.0.0
description: |
MeBox 安卓客户端的稳定接入契约。所有路径以 `/api` 为前缀挂载,
本文档描述 `/api/v1` 部分;既有 `/api` 与 Emby/Jellyfin 入口保持兼容。
约定:
- JSON 字段使用 snake_case;
- 时间点为 RFC 3339 UTC 字符串,播放位置与时长使用整数毫秒;
- 集合为空时返回 `[]`,未知字段客户端必须忽略;
- 错误响应统一为 `code`、`message`、`request_id`、`retryable`;
- 能力开关只有在接口实现且当前可运行时才为 true。
servers:
- url: /api
security:
- bearerAuth: []
tags:
- name: server
- name: auth
paths:
/v1/server-info:
get:
tags: [server]
summary: 匿名读取实例身份与移动端能力
security: []
responses:
'200':
description: 实例可连接
headers:
Cache-Control:
schema:
type: string
description: 固定为 no-store
content:
application/json:
schema:
$ref: '#/components/schemas/ServerInfo'
'503':
description: 暂时无法读取实例身份
content:
application/json:
schema:
$ref: '#/components/schemas/MobileError'
/v1/auth/login:
post:
tags: [auth]
summary: 账号密码登录并创建认证会话
security: []
parameters:
- $ref: '#/components/parameters/RequestId'
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/LoginRequest'
responses:
'200':
description: 登录成功
content:
application/json:
schema:
$ref: '#/components/schemas/AuthResponse'
'400':
$ref: '#/components/responses/InvalidArgument'
'401':
$ref: '#/components/responses/InvalidCredentials'
'403':
description: 账号被禁用或已到期
content:
application/json:
schema:
$ref: '#/components/schemas/MobileError'
'429':
$ref: '#/components/responses/RateLimited'
'503':
$ref: '#/components/responses/ServiceUnavailable'
/v1/auth/refresh:
post:
tags: [auth]
summary: 使用刷新令牌轮换令牌对
security: []
description: |
刷新令牌必须由 v1 登录签发并关联认证会话。同一 refresh token 的
并发提交只会真正轮换一次;轮换后 60 秒内重复提交返回同一次结果。
会话被撤销后刷新返回 SESSION_REVOKED。
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/RefreshRequest'
responses:
'200':
description: 刷新成功,新令牌继续归属同一 auth_session_id
content:
application/json:
schema:
$ref: '#/components/schemas/AuthResponse'
'400':
$ref: '#/components/responses/InvalidArgument'
'401':
description: 刷新令牌无效、过期或会话已撤销
content:
application/json:
schema:
$ref: '#/components/schemas/MobileError'
'503':
$ref: '#/components/responses/ServiceUnavailable'
/v1/auth/session:
get:
tags: [auth]
summary: 读取当前用户与认证会话
responses:
'200':
description: 会话有效
content:
application/json:
schema:
$ref: '#/components/schemas/SessionResponse'
'401':
$ref: '#/components/responses/SessionInvalid'
'503':
$ref: '#/components/responses/ServiceUnavailable'
/v1/auth/logout:
post:
tags: [auth]
summary: 撤销当前认证会话
description: 幂等;重复退出同样返回 200。
responses:
'200':
description: 已退出(本机与远端)
content:
application/json:
schema:
type: object
properties:
status:
type: string
example: ok
'401':
$ref: '#/components/responses/SessionInvalid'
'503':
$ref: '#/components/responses/ServiceUnavailable'
components:
securitySchemes:
bearerAuth:
type: http
scheme: bearer
bearerFormat: JWT
parameters:
RequestId:
name: X-Request-ID
in: header
required: false
description: |
可选的诊断 ID(8—128 位 ASCII 字母、数字或 `-_.:`)。服务器会在
响应头与错误体回传同一个值;非法值会被替换为新 UUID。该值不参与鉴权。
schema:
type: string
responses:
InvalidArgument:
description: 请求字段缺失或格式错误
content:
application/json:
schema:
$ref: '#/components/schemas/MobileError'
InvalidCredentials:
description: 用户名或密码错误
content:
application/json:
schema:
$ref: '#/components/schemas/MobileError'
SessionInvalid:
description: 访问令牌无效、过期或认证会话已撤销
content:
application/json:
schema:
$ref: '#/components/schemas/MobileError'
RateLimited:
description: 请求过于频繁
headers:
Retry-After:
schema:
type: integer
description: 建议等待秒数
content:
application/json:
schema:
$ref: '#/components/schemas/MobileError'
ServiceUnavailable:
description: 暂时性服务/存储错误,可安全重试
content:
application/json:
schema:
$ref: '#/components/schemas/MobileError'
schemas:
ServerInfo:
type: object
required:
- name
- server_id
- server_version
- api_version
- min_supported_app_version_code
- capabilities
properties:
name:
type: string
example: MeBox
server_id:
type: string
format: uuid
description: 实例持久身份;备份克隆成独立实例时必须显式重置。
server_version:
type: string
example: MeBox-v0.1.16
api_version:
type: string
example: v1
min_supported_app_version_code:
type: integer
minimum: 1
capabilities:
$ref: '#/components/schemas/ServerCapabilities'
ServerCapabilities:
type: object
required:
- device_sessions
- playback_sessions
- reader_progress_v1
- reader_content_batch
- local_transcode
properties:
device_sessions:
type: boolean
description: M1 起为 true
playback_sessions:
type: boolean
description: 播放会话接口未实现前必须为 false
reader_progress_v1:
type: boolean
description: v1 阅读锚点/版本冲突未实现前必须为 false
reader_content_batch:
type: boolean
description: v1 批量正文与资源引用未实现前必须为 false
local_transcode:
type: boolean
description: 移动端可用的本地转码会话未实现前必须为 false
MobileUser:
type: object
required: [id, username, role, tier, is_active, force_password_reset]
properties:
id:
type: string
username:
type: string
nickname:
type: string
role:
type: string
enum: [admin, user]
tier:
type: string
example: free
is_active:
type: boolean
force_password_reset:
type: boolean
expires_at:
type: string
format: date-time
nullable: true
TokenPair:
type: object
required: [access_token, refresh_token, expires_in, token_type]
properties:
access_token:
type: string
refresh_token:
type: string
expires_in:
type: integer
description: 访问令牌有效秒数
token_type:
type: string
example: Bearer
LoginRequest:
type: object
required: [username, password, device_id]
properties:
username:
type: string
password:
type: string
device_id:
type: string
maxLength: 128
description: App 安装随机生成,不使用 IMEI/MAC 等硬件标识。
device_name:
type: string
maxLength: 128
client_name:
type: string
maxLength: 64
example: MeBox Android
client_version:
type: string
maxLength: 64
RefreshRequest:
type: object
required: [refresh_token]
properties:
refresh_token:
type: string
AuthResponse:
type: object
required: [auth_session_id, user, tokens]
properties:
auth_session_id:
type: string
format: uuid
user:
$ref: '#/components/schemas/MobileUser'
tokens:
$ref: '#/components/schemas/TokenPair'
SessionDevice:
type: object
properties:
device_id:
type: string
device_name:
type: string
client_name:
type: string
client_version:
type: string
last_used_at:
type: string
format: date-time
expires_at:
type: string
format: date-time
SessionResponse:
type: object
required: [auth_session_id, user]
properties:
auth_session_id:
type: string
format: uuid
user:
$ref: '#/components/schemas/MobileUser'
session:
$ref: '#/components/schemas/SessionDevice'
MobileError:
type: object
required: [code, message, request_id, retryable]
properties:
code:
type: string
enum:
- INVALID_ARGUMENT
- INVALID_CREDENTIALS
- TOKEN_EXPIRED
- SESSION_REVOKED
- ACCOUNT_DISABLED
- ACCOUNT_EXPIRED
- PERMISSION_DENIED
- PROFILE_PIN_REQUIRED
- RATE_LIMITED
- UPSTREAM_UNAVAILABLE
- TRANSCODE_BUSY
- PLAYBACK_SESSION_NOT_FOUND
- RESOURCE_EXPIRED
- PROGRESS_CONFLICT
- SERVICE_UNAVAILABLE
message:
type: string
request_id:
type: string
retryable:
type: boolean
retry_after_sec:
type: integer
minimum: 0
details:
description: 可选的稳定结构化细节,未定义时客户端必须忽略。
+11
View File
@@ -126,11 +126,22 @@ web/src/
| P4 漫画/图片源 ✅ | 图片列表绝对化 + 经签名代理(带书源 Referer 防盗链头)+ 漫画阅读器(上下滚动/左右单页双模式、图片懒加载与加载失败占位、点击分区翻页/呼菜单、菜单进度条按图片序号、进度按图片序号记忆、下一章预取)+ imageStyle 透传 | 漫画源可看 |
| P4.5 书源登录 ✅ | `jsLib` 一次装载(对应 legado SharedJsScope)+ `source.*` 会话方法(getVariable/setVariable/getLoginInfo/putLoginInfo/getLoginHeader/putLoginHeader/get/put)+ `cookie.*`(getCookie/setCookie/replaceCookie/removeCookie/getKey,按 eTLD+1 隔离)+ 请求自动携带 Cookie 与 loginHeader + `loginUrl`/`loginUi`(解析表单 → 按钮 action 拼在 loginUrl 后执行 → result 为表单值)+ `loginCheckJs`(会话失效自动重登/重取)+ 源变量落库 + 登录信息/Cookie 加密存储 + 服务端 toast/startBrowser 回传前端 + 登录面板与变量编辑器 | 登录类书源可登录、可留存登录态 |
| P4.6 本地书籍 ✅ | `POST /api/reader/local/books` 上传 TXT / EPUB(上限 64MB)→ 落盘 `data/reader/local/<bookID>.<txt\|epub>` + 解析目录入 `reader_chapters` + 落库为 `origin=""`、`is_local=true` 的书架条目;TXT 自动识别 BOM/UTF-8/GBK/Big5/UTF-16 并按 legado 默认 TXT 目录规则切章(RE2 无 lookbehind,改行首锚定 + 句子启发式过滤),章定位信息存字节区间 `start:end`,读章只读该区间;EPUB 走 container.xml → OPF spine,标题优先取 NCX/NAV,正文去标签与实体;移出书架同步删落盘文件;同名重复导入覆盖更新并尽量保留进度 | 本地书可上传、可读、可删 |
| P4.10 书源按用户独立 ✅ | 书源从全局唯一改为按用户独立:`ReaderBookSource` 增加 `user_id`,唯一键改为 `(user_id, source_url)`;导入/列表/启停/删除/搜索全部按当前用户限定;老库迁移自动删旧唯一索引、按「谁书架上有这本书」回填归属并给多引用用户复制副本、清理软删历史行;删除改为物理删除(软删会占住唯一键导致「删掉再导入」失败);正文缓存键随之改为「书源行 ID + 书本地址」,不同用户改过规则的同名书源不再共享缓存 | 用户的启停/规则/变量/删除互不影响 |
| P4.7 正文持久缓存 ✅ | 服务端正文持久缓存(`reader_content_cache.go`:书源侧产物 + 索引落库 + 内容落盘 `cache_dir/reader-content/`、TTL/容量 LRU、目录刷新 remap、换源/移出书架清理、读穿透 singleflight)+ 批量取正文接口 `POST /api/reader/books/:id/content-batch`(对应 CacheBook 协议)+ 前端窗口预取(默认 3 章) | 慢源连续翻章不再每章等待;重复阅读零网络 |
| P4.8 图片/字体兼容 ✅ | `coverDecodeJs`(封面解密代理,含搜索结果未入库场景)/ `ruleContent.imageDecode`(正文图片解密)+ 图片 URL 尾部 `,{headers}` options 逐图应用(options 优先于书源 header 与 Cookie)+ `java.queryTTF` / `queryBase64TTF` / `replaceFont`(sfnt cmap 0/4/6、loca 短/长、glyf 简单+复合字形) | 加密图与字体混淆正文可读 |
| P4.9 规则稳定性 ✅ | `@put` 回写书源变量(修复跨请求丢失);`book.getVariable/putVariable` 真正注入书籍上下文并落库(对应 Book.upVariable);全部书源 `regexp2` 统一 3s 匹配超时;解压输出 32MB 上限;非 UTF-8 TXT 托管副本按 UTF-8 落盘(外部引用读时解码);换源单事务;JS `cache` 按书源命名空间隔离并落盘;HLS 双端识别(服务端 `#EXTM3U` 嗅探 + `hls` 标志下发) | 与阅读 App 行为对齐,坏源不再拖死服务 |
| P5 体验完善 | 换源(ChangeBookSourceDialog 四档排序)、追更(定时刷新目录 + 缓存清理)、发现页(exploreUrl 标签条)、阅读器高级设置(页眉页脚提示、点击区域自定义)、书源编辑器六 Tab、备份导出 | 完整体验 |
### 实施记录:书源会话状态仍是跨用户共享(下一步)
书源本身已按用户独立,但 `ReaderSourceState`(Cookie / 登录信息 / loginHeader / 源变量)
仍以 `source_url` 为唯一键、跨用户共享一份。两个用户导入同一书源时:
- 一方登录后,另一方复用同一份 Cookie;一方的登出会清掉另一方的登录态。
彻底隔离需要给状态表也加 `user_id`(或按书源行 ID 组织),涉及登录面板与变量读写
的一批调用点,留作单独一轮处理。
### 实施记录:关于「宽松 @put 触发 panic」的复核
前期评估曾报告 `sourcerule.go` 的宽松 `@put` 回退正则含 RE2 不支持的反向引用、可能 panic。
+1
View File
@@ -36,6 +36,7 @@
- 「更新目录」(对应 legado `menu_update_toc`):`POST /reader/shelf/refresh-toc` 并发重抓书架内网络书籍的目录,覆盖章节缓存;末章标题变化时刷新 `latest_chapter_time`,用于「最近更新」排序与更新时间展示。本地书籍与无书源信息的书跳过。返回 `total/updated/unchanged/failed` 四档——抓到但章数没变(完结书)记 `unchanged`「已是最新」,只有抓取或写入真的失败才记 `failed`,避免把正常结果报成「更新失败」;失败会打 Warn 日志(含书名与书源)。
- 未实现(与 legado 的差距):书籍二级分组网格(进入分组后的封面网格)、导出/导入书架、离线下载管理界面(服务端批量缓存接口与窗口预取已可用,见下)。
- 章节预取:阅读页对当前章之后默认 3 章走 `POST /reader/books/:id/content-batch` 批量抓取(服务端并发 + 持久缓存),翻到后续章直接命中;单章串行预取在高延迟书源上赶不上连续翻章。
- 书源按用户独立:书源管理(列表/导入/启停/删除)与搜索都只作用于当前用户自己的书源。同一份书源不同用户可以各导入一份,各自启停、排序、改规则与变量,互不可见;Web 端无需额外交互,接口按登录用户自动隔离。
**书架分组(`/reader/book-groups`,仿影视模块的媒体库标签)**
- 按用户存服务端:`reader_book_groups` 表每个用户一条,`groups` 列是 `[{name, book_ids}]` 的 JSON。结构、语义与影视的媒体库标签(`User.LibraryTags`)同构——组名 → 成员 ID、整份替换、一个成员只归一个组、组内顺序即展示顺序。
@@ -0,0 +1,46 @@
# Playback Correctness and Isolation Implementation Plan
**Goal:** Fix the five playback defects from the 2026-10-21 review and make playback capability queries side-effect-free, without replacing the player or adding dependencies.
**Scope:** The user approved implementing the reviewed fixes. Work in the current clean checkout; do not commit, push, deploy, or alter unrelated files.
## Design and invariants
- Keep existing media IDs, stream routes, direct playback and external-player URLs compatible.
- Add optional `session_id` to local HLS requests. Authenticated handlers supply the actual user identity; a client parameter never supplies the owner. New web playback sessions use random IDs. Legacy clients are isolated by user and, where available, Emby play-session/device identity.
- Keep transcoder media lookup/logging separate from its internal task key. A task key incorporates user, media and playback session. Each actual ffmpeg run writes to a fresh generation directory, so delayed segment requests never read a replacement run's output.
- Stop only the authenticated caller's playback session. Check media visibility and scoped playback tokens on stop, just as on playlist/segment requests. Segment and playlist queries must match the running generation.
- Cloud child requests resolve their real session media first, compare query media, enforce token scope and visibility against the real media, and reject mismatches before upstream fetch.
- Treat player business positions as absolute source seconds. Only local HLS converts to/from a relative ffmpeg timeline.
- Invalidate outstanding cloud-transcode requests on media/unmount, explicit source/quality changes, and newer requests. Guard both fulfilled and rejected responses.
- Select local fallbacks only from available local qualities. Keep the existing 115-first preference when a cloud quality is available.
- `PlaybackInfo` only reads capabilities/status. Explicit `StartTranscode` requests enqueue cloud work. Retain pending/ready response contracts, polling, and retries.
## Tasks
1. Add regression tests for local-scope isolation, stale generations, stop authorization, cloud media binding, pure capability reads, timeline conversion, available-local selection and stale async results. Run tests before implementation and verify the intended failures.
2. Add tested playback helpers; route skip/undo/progress/quality/subtitle calculations through the common absolute timeline; guard cloud transcode results and fix local fallback choice.
3. Extend existing transcoder/stream methods with an optional scope, preserve legacy internal calls, isolate task keys/output directories, and pass caller scope from web and Emby handlers. Bind web local HLS URLs and stop requests to captured session IDs.
4. Enforce real-media authorization on cloud child requests and authenticated ownership on local stop.
5. Split read-only cloud capability lookup from explicit cloud enqueue. Update existing cloud tests to use the explicit start operation and test no push during reads.
6. Run focused and full Go tests, frontend tests, TypeScript/Vite build, lint diagnostics and review the diff for security, lifecycle and API compatibility. Fix regressions before reporting completion.
## Verification commands
- `node --experimental-strip-types --test src/pages/hlsPlayback.test.ts src/pages/directPlaySeek.test.ts src/pages/playerPageModel.test.ts src/pages/playbackQuality.test.ts src/pages/playbackRequestGuard.test.ts src/utils/skipSegments.test.ts`
- `go test ./internal/service ./internal/handler ./internal/middleware -count=1`
- `go test ./...`
- `npm run build` and targeted ESLint for changed frontend files.
## Out of scope
New remux/audio-only ffmpeg pipelines, speculative browser codec policy changes, player replacement, broad UI refactors, performance improvement claims without measurements, and production deployment.
## Verification outcome
- Six frontend playback regression scripts pass; targeted ESLint and the TypeScript/Vite production build pass. The existing bundle-size warning remains.
- Playback/stream/transcode/progress tests in service, handler and middleware pass, including scoped output reads, stale generation rejection, scoped stops, device-to-segment propagation, read-only capability queries and concurrent cloud start deduplication.
- Whole-repository tests pass with only `TestQueueLibraryRootScanImportsNewLibraryMedia` and `TestSQLiteRestoreStagesWithoutChangingLiveDatabase` excluded. Both were run independently and reproduce Windows file-lock / SQLite errors in unmodified scan/database code. They are not silently disabled in source.
- FFmpeg is not installed in this environment, so the existing real-transcode lifecycle test skips. Actual video, upstream 115 and browser end-to-end playback remain unverified.
- Clients with no playback-session or device identifier retain per-user legacy isolation; new web requests always carry an independent session ID.
- No commit, push or deployment was performed.
+3 -2
View File
@@ -33,6 +33,7 @@ func setDefaults(v *viper.Viper) {
v.SetDefault("database.cache_size", -40000)
v.SetDefault("database.max_open_conns", defaultDatabaseMaxOpenConns)
v.SetDefault("database.max_idle_conns", defaultDatabaseMaxIdleConns)
v.SetDefault("database.postgres_trgm_index", true)
v.SetDefault("secrets.jwt_secret", "")
@@ -46,14 +47,14 @@ func setDefaults(v *viper.Viper) {
v.SetDefault("cache.cache_dir", "./cache")
v.SetDefault("cache.images_max_size_mb", 2000)
v.SetDefault("cache.images_originals_max_size_mb", 256)
v.SetDefault("cache.images_originals_ttl_hours", 168)
v.SetDefault("cache.memory_max_size_mb", DefaultCacheMemoryMaxSizeMB)
v.SetDefault("cache.redis_url", "")
v.SetDefault("cache.redis_prefix", "mebox")
v.SetDefault("cache.media_ttl_seconds", 90)
v.SetDefault("cache.emby_latest_ttl_seconds", 300)
v.SetDefault("cache.emby_latest_ttl_seconds", 900)
v.SetDefault("cache.reader_content_max_size_mb", 1024)
v.SetDefault("cache.reader_content_ttl_hours", 168)
v.SetDefault("cache.reader_files_ttl_hours", 720)
v.SetDefault("search.backend", "")
v.SetDefault("search.opensearch_url", "")
+3 -3
View File
@@ -50,9 +50,6 @@ func (c *Config) normalize() error {
if c.Cache.ImagesOriginalsMaxSizeMB < 0 {
c.Cache.ImagesOriginalsMaxSizeMB = 0
}
if c.Cache.ImagesOriginalsTTLHours < 0 {
c.Cache.ImagesOriginalsTTLHours = 0
}
if c.Cache.MemoryMaxSizeMB <= 0 {
c.Cache.MemoryMaxSizeMB = DefaultCacheMemoryMaxSizeMB
}
@@ -68,6 +65,9 @@ func (c *Config) normalize() error {
if c.Cache.ReaderContentTTLHours < 0 {
c.Cache.ReaderContentTTLHours = 0
}
if c.Cache.ReaderFilesTTLHours < 0 {
c.Cache.ReaderFilesTTLHours = 0
}
c.Search.Backend = strings.ToLower(strings.TrimSpace(c.Search.Backend))
if c.Search.Index == "" {
c.Search.Index = "mebox_media"
+11 -5
View File
@@ -50,7 +50,8 @@ type AppConfig struct {
DataDir string `mapstructure:"data_dir"`
WebDir string `mapstructure:"web_dir"`
// HTTPSEnabled 是否仅通过 HTTPS 提供访问。启用时必须同时配置
// SSLCert / SSLKey(或 SSLCertPath / SSLKeyPath),保存后服务会热切换到 HTTPS。
// SSLCert / SSLKey(或 SSLCertPath / SSLKeyPath),保存后服务会热切换到 HTTPS;
// 同一端口上的明文 HTTP 请求会被跳转到 HTTPS,而不是直接拒绝。
HTTPSEnabled bool `mapstructure:"https_enabled"`
// SSLCert 是 PEM 编码的 SSL 证书内容。
SSLCert string `mapstructure:"ssl_cert"`
@@ -86,6 +87,10 @@ type DatabaseConfig struct {
CacheSize int `mapstructure:"cache_size"`
MaxOpenConns int `mapstructure:"max_open_conns"`
MaxIdleConns int `mapstructure:"max_idle_conns"`
// PostgresTrgmIndex 决定是否用 pg_trgm 的 GIN 三元组索引替代 SQLite 的
// FTS5 全文索引。开启后 ILIKE '%term%' 搜索能走索引而不是整表扫描,代价是
// media 表写入时要多维护 4 个 GIN 索引。仅对 PostgreSQL 生效。
PostgresTrgmIndex bool `mapstructure:"postgres_trgm_index"`
}
// SecretsConfig 保存 JWT / 第三方 API 密钥(不要提交值)。
@@ -120,9 +125,6 @@ type CacheConfig struct {
// ImagesOriginalsMaxSizeMB 限制 cache/images 下原图子配额(MB)。原图只是
// 生成缩略图的原料,丢了可以重新回源;0 表示不单独限制(只受总量约束)。
ImagesOriginalsMaxSizeMB int `mapstructure:"images_originals_max_size_mb"`
// ImagesOriginalsTTLHours 是原图的保留时长(小时),超过即删除;0 表示
// 不按时间淘汰。缩短它能让原图缓存保持在一个滚动窗口内。
ImagesOriginalsTTLHours int `mapstructure:"images_originals_ttl_hours"`
// MemoryMaxSizeMB 限制进程内 L1 缓存总字节数,JSON/对象缓存共用该预算。
MemoryMaxSizeMB int `mapstructure:"memory_max_size_mb"`
RedisURL string `mapstructure:"redis_url"`
@@ -131,12 +133,16 @@ type CacheConfig struct {
// EmbyLatestTTLSeconds 是 Emby「最新添加」(Items/Latest) 的缓存时长。
// 客户端刷新首页时会并发请求全部媒体库的 Latest(生产环境观察到 73 个
// 并发),缓存过短会让这批请求同时穿透并各自重建 payload,在低配主机
// 上造成秒级延迟。默认 300 秒,新入库内容最迟 5 分钟后出现在最新列表。
// 上造成秒级延迟。默认 900 秒;过期后仍会先返回陈旧副本并在后台重建,
// 新入库内容最迟 15 分钟后出现在最新列表(扫描完成会主动失效缓存)。
EmbyLatestTTLSeconds int `mapstructure:"emby_latest_ttl_seconds"`
// ReaderContentMaxSizeMB 阅读正文持久缓存的总容量(MB),0 表示不限制。
ReaderContentMaxSizeMB int `mapstructure:"reader_content_max_size_mb"`
// ReaderContentTTLHours 阅读正文缓存的保留时长(小时),0 表示不过期。
ReaderContentTTLHours int `mapstructure:"reader_content_ttl_hours"`
// ReaderFilesTTLHours 书源文件缓存(java.cacheFile / downloadFile)的保留时长(小时)。
// 0 表示使用内置兜底值(30 天)。
ReaderFilesTTLHours int `mapstructure:"reader_files_ttl_hours"`
}
type SearchConfig struct {
+238
View File
@@ -0,0 +1,238 @@
package database
import (
"context"
"fmt"
"os"
"path/filepath"
"strings"
"sync"
"testing"
"time"
"github.com/google/uuid"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/stdlib"
"gorm.io/driver/postgres"
"gorm.io/gorm"
"gorm.io/gorm/logger"
"github.com/truewhile/MeBox/internal/config"
"github.com/truewhile/MeBox/internal/model"
"github.com/truewhile/MeBox/internal/repository"
)
// PostgreSQL tests use a disposable schema, never the caller's existing tables.
// Set MEBOX_TEST_POSTGRES_DSN to include a real PostgreSQL server in local runs.
func compatibilityDB(t *testing.T, dialect string) *gorm.DB {
t.Helper()
var db *gorm.DB
if dialect == "sqlite" {
cfg := &config.Config{}
cfg.Database.Type = dialect
cfg.Database.DBPath = filepath.Join(t.TempDir(), "test.db")
cfg.Database.WALMode = true
cfg.Database.BusyTimeout = 5000
cfg.Database.MaxOpenConns = 8
var err error
db, err = Open(cfg, nil)
if err != nil {
t.Fatal(err)
}
} else {
dsn := os.Getenv("MEBOX_TEST_POSTGRES_DSN")
if dsn == "" {
t.Skip("set MEBOX_TEST_POSTGRES_DSN to run PostgreSQL integration tests")
}
pgcfg, err := pgx.ParseConfig(dsn)
if err != nil {
t.Fatal("invalid PostgreSQL test configuration")
}
admin := stdlib.OpenDB(*pgcfg)
t.Cleanup(func() { _ = admin.Close() })
schemaName := "mebox_test_" + strings.ReplaceAll(uuid.NewString(), "-", "")
if _, err := admin.Exec("CREATE SCHEMA " + quoteIdent(schemaName)); err != nil {
t.Fatal(err)
}
t.Cleanup(func() {
if _, err := admin.Exec("DROP SCHEMA " + quoteIdent(schemaName) + " CASCADE"); err != nil {
t.Error(err)
}
})
pgcfg.RuntimeParams["search_path"] = schemaName + ",public"
conn := stdlib.OpenDB(*pgcfg)
t.Cleanup(func() { _ = conn.Close() })
db, err = gorm.Open(postgres.New(postgres.Config{Conn: conn}), &gorm.Config{})
if err != nil {
t.Fatal(err)
}
}
db.Logger = logger.Default.LogMode(logger.Silent)
if conn, err := db.DB(); err == nil {
t.Cleanup(func() { _ = conn.Close() })
}
if err := AutoMigrate(db); err != nil {
t.Fatal(err)
}
return db
}
func TestDatabaseCompatibility(t *testing.T) {
for _, dialect := range []string{"sqlite", "postgres"} {
t.Run(dialect, func(t *testing.T) {
t.Run("long_paths_and_restart", func(t *testing.T) {
db := compatibilityDB(t, dialect)
// Distinct CJK characters prevent TOAST compression from hiding
// the PostgreSQL btree entry size limit (> 2704 bytes).
var path strings.Builder
path.WriteString("/media/")
for i := range 980 {
path.WriteRune(rune(0x4e00 + i))
}
path.WriteString(".mkv")
media := model.Media{Path: path.String(), Title: "Long path"}
if err := db.Create(&media).Error; err != nil {
t.Fatalf("insert long media path: %v", err)
}
if err := db.Create(&model.Media{Path: media.Path}).Error; err == nil {
t.Fatal("duplicate path accepted")
}
if err := AutoMigrate(db); err != nil {
t.Fatalf("migrate existing database: %v", err)
}
var found model.Media
if err := db.Where("path = ?", media.Path).First(&found).Error; err != nil {
t.Fatalf("lookup long path: %v", err)
}
})
if dialect == "postgres" {
t.Run("upgrade_legacy_path_indexes", func(t *testing.T) {
db := compatibilityDB(t, dialect)
for _, unique := range []string{"UNIQUE ", ""} {
if err := db.Exec("DROP INDEX idx_media_path").Error; err != nil {
t.Fatal(err)
}
if err := db.Exec("CREATE " + unique + "INDEX idx_media_path ON media(path)").Error; err != nil {
t.Fatal(err)
}
for range 2 {
if err := ensureMediaPathIndex(db); err != nil {
t.Fatal(err)
}
}
isUnique, method, found, err := postgresIndexProperties(db, "idx_media_path")
if err != nil || !found || isUnique || method != "hash" {
t.Fatalf("legacy index not upgraded: unique=%v method=%s found=%v err=%v", isUnique, method, found, err)
}
}
})
}
t.Run("search_ranking", func(t *testing.T) {
db := compatibilityDB(t, dialect)
// Exercise LIKE fallback in both engines.
if dialect == "sqlite" {
resetMediaSearchIndex(db)
}
for i, title := range []string{"The Alpha story", "Alpha sequel", "Alpha"} {
row := model.Media{Base: model.Base{ID: fmt.Sprintf("m-%d", i), CreatedAt: time.Now().Add(time.Duration(i) * time.Minute)}, Title: title, Path: fmt.Sprintf("/media/%d.mkv", i)}
if err := db.Create(&row).Error; err != nil {
t.Fatal(err)
}
}
items, total, err := repository.New(db).Media.SearchFilteredPage(t.Context(), "Alpha", 0, 10, repository.MediaQueryFilter{IncludeNSFW: true})
if err != nil {
t.Fatal(err)
}
if total != 3 || len(items) != 3 || items[0].Title != "Alpha" || items[1].Title != "Alpha sequel" {
t.Fatalf("unexpected search ranking: total=%d, items=%v", total, items)
}
})
t.Run("concurrent_media_upsert", func(t *testing.T) {
db := compatibilityDB(t, dialect)
repo := repository.New(db).Media
const workers = 8
// Synchronize the initial lookups to force the insert race.
var lookups sync.WaitGroup
lookups.Add(workers)
if dialect == "postgres" {
if err := db.Callback().Query().After("gorm:after_query").Register("test:insert_race", func(tx *gorm.DB) {
if tx.Statement.Table == "media" && tx.Error == gorm.ErrRecordNotFound {
lookups.Done()
lookups.Wait()
}
}); err != nil {
t.Fatal(err)
}
}
ctx, cancel := context.WithTimeout(t.Context(), 15*time.Second)
defer cancel()
errs := make(chan error, workers)
for range workers {
go func() { errs <- repo.Upsert(ctx, &model.Media{Path: "/media/shared.mkv", Title: "Shared"}) }()
}
for range workers {
if err := <-errs; err != nil {
t.Errorf("concurrent upsert: %v", err)
}
}
var count int64
if err := db.Model(&model.Media{}).Count(&count).Error; err != nil || count != 1 {
t.Fatalf("media count=%d, err=%v", count, err)
}
})
t.Run("search_filters", func(t *testing.T) {
db := compatibilityDB(t, dialect)
rows := []model.Media{
{Base: model.Base{ID: "keep"}, LibraryID: "lib-1", Genres: "Action", Year: 2020, Rating: 8},
{Base: model.Base{ID: "other-library"}, LibraryID: "lib-2", Genres: "Action", Year: 2020, Rating: 8},
{Base: model.Base{ID: "other-genre"}, LibraryID: "lib-1", Genres: "Comedy", Year: 2020, Rating: 8},
{Base: model.Base{ID: "too-old"}, LibraryID: "lib-1", Genres: "Action", Year: 1990, Rating: 8},
{Base: model.Base{ID: "too-new"}, LibraryID: "lib-1", Genres: "Action", Year: 2026, Rating: 8},
{Base: model.Base{ID: "low-rating"}, LibraryID: "lib-1", Genres: "Action", Year: 2020, Rating: 2},
{Base: model.Base{ID: "watched"}, LibraryID: "lib-1", Genres: "Action", Year: 2020, Rating: 8},
}
for i := range rows {
rows[i].Title = "Alpha " + rows[i].ID
rows[i].Path = "/media/" + rows[i].ID + ".mkv"
if err := db.Create(&rows[i]).Error; err != nil {
t.Fatal(err)
}
}
if err := db.Create(&model.PlaybackHistory{UserID: "viewer", MediaID: "watched", Completed: true}).Error; err != nil {
t.Fatal(err)
}
items, total, err := repository.New(db).Media.SearchFilteredPage(t.Context(), "alpha", 0, 10, repository.MediaQueryFilter{
IncludeNSFW: true, LibraryID: "lib-1", Genres: []string{"Action"}, YearMin: 2000, YearMax: 2025, RatingMin: 5,
UnwatchedOnly: true, UnwatchedUserID: "viewer",
})
if err != nil {
t.Fatal(err)
}
if total != 1 || len(items) != 1 || items[0].ID != "keep" {
t.Fatalf("search ignored filters: total=%d, items=%v", total, items)
}
})
t.Run("migration_preserves_zero_values", func(t *testing.T) {
src := compatibilityDB(t, "sqlite")
dst := compatibilityDB(t, dialect)
user := model.User{Username: "disabled", PasswordHash: "hash"}
if err := src.Create(&user).Error; err != nil {
t.Fatal(err)
}
if err := src.Model(&user).Updates(map[string]any{"is_active": false, "hide_adult": false, "player_volume": 0, "danmaku_enabled": false}).Error; err != nil {
t.Fatal(err)
}
if _, _, err := copyModelTables(src, dst, 500); err != nil {
t.Fatal(err)
}
var got model.User
if err := dst.First(&got, "id = ?", user.ID).Error; err != nil {
t.Fatal(err)
}
if got.IsActive || got.HideAdult || got.PlayerVolume != 0 || got.DanmakuEnabled {
t.Fatalf("migration replaced stored zero values: active=%v hide_adult=%v volume=%v danmaku=%v", got.IsActive, got.HideAdult, got.PlayerVolume, got.DanmakuEnabled)
}
})
})
}
}
+1 -1
View File
@@ -165,7 +165,7 @@ func MigrateCurrentToPostgres(src *gorm.DB, targetDSN string, batchSize int, log
}
// 1. 初始化目标库 Schema、类型与索引
if err := AutoMigrate(targetDB); err != nil {
if err := AutoMigrateWithConfig(targetDB, nil, log); err != nil {
return nil, fmt.Errorf("auto migrate target PostgreSQL: %w", err)
}
+50
View File
@@ -0,0 +1,50 @@
package database
import (
"fmt"
"reflect"
"gorm.io/gorm"
)
// InsertPreservingValues copies persisted model rows without applying creation
// hooks or defaults. Struct CreateInBatches treats false, 0 and empty strings as
// missing values, which would reactivate disabled accounts during migration or
// restore. Column maps preserve the exact values already stored in the source.
func InsertPreservingValues(db *gorm.DB, rows any, batchSize int) error {
stmt := &gorm.Statement{DB: db}
if err := stmt.Parse(rows); err != nil {
return err
}
slice := reflect.Indirect(reflect.ValueOf(rows))
if slice.Kind() != reflect.Slice {
return fmt.Errorf("persisted rows must be a slice, got %T", rows)
}
if len(stmt.Schema.DBNames) == 0 {
return fmt.Errorf("persisted rows have no database columns")
}
values := make([]map[string]any, 0, slice.Len())
for i := 0; i < slice.Len(); i++ {
row := make(map[string]any, len(stmt.Schema.DBNames))
for _, name := range stmt.Schema.DBNames {
field := stmt.Schema.FieldsByDBName[name]
if field.Creatable {
row[name], _ = field.ValueOf(db.Statement.Context, slice.Index(i))
}
}
values = append(values, row)
}
if len(values) == 0 {
return nil
}
// Bound batches by each engine's bind-parameter limit.
parameterLimit := 65535
if isSQLite(db) {
parameterLimit = 32766
}
maxBatch := parameterLimit / len(stmt.Schema.DBNames)
if batchSize <= 0 || batchSize > maxBatch {
batchSize = maxBatch
}
return db.Table(stmt.Schema.Table).CreateInBatches(values, batchSize).Error
}
@@ -0,0 +1,141 @@
package database
import (
"path/filepath"
"testing"
"time"
"github.com/glebarez/sqlite"
"gorm.io/gorm"
"github.com/truewhile/MeBox/internal/model"
)
// legacyReaderSource 旧版书源结构(全局唯一,没有 user_id)。
type legacyReaderSource struct {
ID string `gorm:"primaryKey"`
CreatedAt time.Time
UpdatedAt time.Time
DeletedAt gorm.DeletedAt `gorm:"index"`
Name string
SourceURL string `gorm:"type:varchar(512);uniqueIndex"`
RawJSON string
Enabled bool
EnabledExplore bool
Variables string
Header string
}
func (legacyReaderSource) TableName() string { return "reader_book_sources" }
// 迁移路径:老库(书源全局唯一、无 user_id)升级后
// 1. 旧唯一索引被删除,两个用户可以各持一份同 URL 的书源副本;
// 2. 书源按「谁书架上有这本书」回填归属,多个引用者各得一份副本;
// 3. 软删的历史书源行被清理,删除后重新导入不再撞唯一键。
func TestReaderSourceMigrationBackfill(t *testing.T) {
db, err := gorm.Open(sqlite.Open(filepath.Join(t.TempDir(), "legacy.db")), &gorm.Config{})
if err != nil {
t.Fatal(err)
}
// Windows 上 sqlite 文件句柄会阻塞 TempDir 清理,测试结束前先关连接。
t.Cleanup(func() {
if sqlDB, err := db.DB(); err == nil {
_ = sqlDB.Close()
}
})
// 造老结构:只有书源表(带旧唯一索引),书籍表用当前模型建。
if err := db.AutoMigrate(&legacyReaderSource{}, &model.ReaderBook{}); err != nil {
t.Fatal(err)
}
shared := &legacyReaderSource{ID: "legacy-1", Name: "共享源", SourceURL: "https://shared.example.com", Enabled: true}
if err := db.Create(shared).Error; err != nil {
t.Fatal(err)
}
softDeleted := &legacyReaderSource{ID: "legacy-2", Name: "已删源", SourceURL: "https://gone.example.com"}
if err := db.Create(softDeleted).Error; err != nil {
t.Fatal(err)
}
if err := db.Delete(softDeleted).Error; err != nil { // 旧版 DeleteSource 的软删
t.Fatal(err)
}
// u1 两本、u2 一本都引用共享源;孤儿源无人引用。
books := []model.ReaderBook{
{UserID: "u1", Origin: "https://shared.example.com", BookURL: "https://shared.example.com/b/1", Name: "书1"},
{UserID: "u1", Origin: "https://shared.example.com", BookURL: "https://shared.example.com/b/2", Name: "书2"},
{UserID: "u2", Origin: "https://shared.example.com", BookURL: "https://shared.example.com/b/1", Name: "书1"},
{UserID: "u2", Origin: "https://orphan.example.com", BookURL: "https://orphan.example.com/b/1", Name: "孤儿"},
}
for i := range books {
if err := db.Create(&books[i]).Error; err != nil {
t.Fatal(err)
}
}
// 迁移。
if err := dropLegacyReaderSourceUniqueIndex(db); err != nil {
t.Fatalf("删旧索引失败: %v", err)
}
if err := db.AutoMigrate(model.AllModels()...); err != nil {
t.Fatalf("AutoMigrate 失败: %v", err)
}
if err := backfillReaderSourceOwnership(db); err != nil {
t.Fatalf("回填失败: %v", err)
}
// 1. 旧唯一索引已删除:两个用户同 URL 各自一份。
var rows []model.ReaderBookSource
if err := db.Order("user_id ASC").Find(&rows).Error; err != nil {
t.Fatal(err)
}
byUser := map[string]model.ReaderBookSource{}
for _, r := range rows {
if r.SourceURL == "https://shared.example.com" {
byUser[r.UserID] = r
}
}
if len(byUser) != 2 {
t.Fatalf("共享源应有两份副本,实际 %d 份: %+v", len(byUser), byUser)
}
if byUser["u1"].ID == "" || byUser["u2"].ID == "" || byUser["u1"].ID == byUser["u2"].ID {
t.Fatalf("两份副本应有不同行 ID: u1=%q u2=%q", byUser["u1"].ID, byUser["u2"].ID)
}
if byUser["u1"].RawJSON != shared.RawJSON || byUser["u1"].Name != "共享源" {
t.Fatal("副本应保留原书源内容")
}
// 引用最多的用户保留原行(u1 有两本书)。
if byUser["u1"].ID != "legacy-1" {
t.Fatalf("原行应归引用最多的用户,实际 %q", byUser["u1"].ID)
}
// 2. 软删的历史行被清理。
var softLeft int64
if err := db.Unscoped().Model(&model.ReaderBookSource{}).
Where("source_url = ?", "https://gone.example.com").Count(&softLeft).Error; err != nil {
t.Fatal(err)
}
if softLeft != 0 {
t.Fatalf("软删的历史书源应被清理,剩余 %d 行", softLeft)
}
// 3. 无人引用的书源保持无归属(不会凭空分配给谁)。
var orphans int64
if err := db.Model(&model.ReaderBookSource{}).
Where("source_url = ? AND user_id <> ''", "https://orphan.example.com").Count(&orphans).Error; err != nil {
t.Fatal(err)
}
if orphans != 0 {
t.Fatal("无人引用的书源不应被分配归属")
}
// 4. 回填是幂等的(再次执行不产生重复副本)。
if err := backfillReaderSourceOwnership(db); err != nil {
t.Fatal(err)
}
var total int64
if err := db.Model(&model.ReaderBookSource{}).Where("source_url = ?", "https://shared.example.com").Count(&total).Error; err != nil {
t.Fatal(err)
}
if total != 2 {
t.Fatalf("重复回填不应新增副本,实际 %d 份", total)
}
}
+293 -9
View File
@@ -1,27 +1,52 @@
package database
import (
"fmt"
"strings"
"go.uber.org/zap"
"gorm.io/gorm"
"github.com/truewhile/MeBox/internal/config"
"github.com/truewhile/MeBox/internal/model"
)
// AutoMigrate creates tables for every model registered in the model package.
func AutoMigrate(db *gorm.DB) error {
return AutoMigrateWithConfig(db, nil, nil)
}
// AutoMigrateWithConfig 与 AutoMigrate 相同,另外接受配置与日志:
// 配置用来决定可选的 PostgreSQL 搜索索引,日志用来记录耗时步骤
// (首次在已有大库上重建索引可能需要数十秒)。两者都可以为 nil。
func AutoMigrateWithConfig(db *gorm.DB, cfg *config.Config, log *zap.Logger) error {
// 必须先于 AutoMigrate:旧库中可能已有重复的 (user_id, media_id) 历史行,
// 不去重会导致唯一索引 uniq_user_history 创建失败。
if err := dedupePlaybackHistories(db); err != nil {
return err
}
// 书源从「全局唯一」改成「按用户独立」:先删掉 source_url 上的旧唯一索引。
// AutoMigrate 只会按索引名创建新索引,不会删除已改名的旧唯一索引,留着它
// 第二个用户导入同一书源就会撞唯一约束。
if err := dropLegacyReaderSourceUniqueIndex(db); err != nil {
return err
}
if err := db.AutoMigrate(model.AllModels()...); err != nil {
return err
}
// 回填书源归属:老库的书源没有 user_id,按「谁书架上有这本书」分配或复制副本。
if err := backfillReaderSourceOwnership(db); err != nil {
return err
}
if err := ensurePostgresColumnCompatibility(db); err != nil {
return err
}
if err := ensurePerformanceIndexes(db); err != nil {
return err
}
if err := ensureMediaPathIndex(db); err != nil {
return err
}
if err := ensureLibraryRootsCompatibility(db); err != nil {
return err
}
@@ -34,6 +59,113 @@ func AutoMigrate(db *gorm.DB) error {
}
return ensureSQLiteQueryOptimizer(db)
}
return ensurePostgresSearchIndexes(db, cfg, log)
}
// ensureMediaPathIndex 建立 media.path 的唯一约束与等值查找索引。
//
// 模型上不再用 gorm:"uniqueIndex"(见 model.Media.Path 的注释):PostgreSQL 的
// btree 单条索引项上限是 2704 字节,varchar(1024) 在中文路径下可达 3072 字节,
// 对整列建唯一索引会让这类行插入时报
//
// index row size ... exceeds btree version 4 maximum 2704
//
// 所以 PostgreSQL 改用 md5(path) 的表达式唯一索引(定长 32 字节,永远塞得下),
// 同时用 hash 索引 idx_media_path 加速 path = ? 等值查找;非唯一 btree
// 也有索引项长度限制,不能继续对完整 path 建 btree。SQLite 沿用整列唯一索引。
func ensureMediaPathIndex(db *gorm.DB) error {
if db == nil || !db.Migrator().HasTable("media") {
return nil
}
if !isPostgres(db) {
return db.Exec(`CREATE UNIQUE INDEX IF NOT EXISTS idx_media_path ON media(path)`).Error
}
// 先建哈希唯一索引:此时旧索引还在,唯一性不会出现空窗。
if err := db.Exec(`CREATE UNIQUE INDEX IF NOT EXISTS idx_media_path_hash ON media(md5(path))`).Error; err != nil {
return err
}
unique, method, found, err := postgresIndexProperties(db, "idx_media_path")
if err != nil {
return err
}
if found && (unique || method != "hash") {
if err := db.Exec(`DROP INDEX IF EXISTS idx_media_path`).Error; err != nil {
return err
}
}
return db.Exec(`CREATE INDEX IF NOT EXISTS idx_media_path ON media USING hash(path)`).Error
}
// postgresIndexProperties 返回当前 schema 下索引的唯一性与访问方法。
func postgresIndexProperties(db *gorm.DB, indexName string) (unique bool, method string, found bool, err error) {
// 用指针接 bool:没有匹配行时 Scan 不报错,nil 即代表索引不存在。
var row struct {
IsUnique *bool
Method string
}
if err := db.Raw(`
SELECT i.indisunique AS is_unique, a.amname AS method
FROM pg_class c
JOIN pg_index i ON i.indexrelid = c.oid
JOIN pg_namespace n ON n.oid = c.relnamespace
JOIN pg_am a ON a.oid = c.relam
WHERE n.nspname = current_schema() AND c.relname = ?
`, indexName).Scan(&row).Error; err != nil {
return false, "", false, err
}
if row.IsUnique == nil {
return false, "", false, nil
}
return *row.IsUnique, row.Method, true, nil
}
// ensurePostgresSearchIndexes 给 PostgreSQL 补上 SQLite FTS5 的替代品。
//
// 全文检索只在 SQLite 下存在(FTS5 虚表 + 触发器),PostgreSQL 下
// media_search_repository 会退回 title/original_name/path/genres 的 ILIKE
// 全表扫描。pg_trgm 的 GIN 三元组索引能让这类 '%term%' 模式(≥3 字符)走索引,
// 把搜索从整表扫描降到索引扫描。
//
// 之所以可关闭:media 表写入非常频繁(每次扫描批量入库),多 4 个 GIN 索引会
// 明显放大写入成本。默认开启以对齐 SQLite 的搜索能力,写入压力大的部署可以关掉。
//
// 整个过程是尽力而为:没有建扩展的权限、Postgres 版本不支持等情况下只记一条
// 警告并继续启动,绝不让一个搜索加速项把服务挡在门外。
func ensurePostgresSearchIndexes(db *gorm.DB, cfg *config.Config, log *zap.Logger) error {
if !isPostgres(db) {
return nil
}
if cfg != nil && !cfg.Database.PostgresTrgmIndex {
if log != nil {
log.Info("postgres trigram search index disabled; media search falls back to sequential scan")
}
return nil
}
if err := db.Exec(`CREATE EXTENSION IF NOT EXISTS pg_trgm`).Error; err != nil {
if log != nil {
log.Warn("create pg_trgm extension failed; media search falls back to sequential scan", zap.Error(err))
}
return nil
}
// 首次在已有大库上执行会重建索引(服务尚未开始接受请求),之后每次启动都因
// IF NOT EXISTS 直接跳过。
if log != nil {
log.Info("ensuring postgres trigram search indexes")
}
for _, stmt := range []string{
`CREATE INDEX IF NOT EXISTS idx_media_title_trgm ON media USING gin (title gin_trgm_ops)`,
`CREATE INDEX IF NOT EXISTS idx_media_original_name_trgm ON media USING gin (original_name gin_trgm_ops)`,
`CREATE INDEX IF NOT EXISTS idx_media_path_trgm ON media USING gin (path gin_trgm_ops)`,
`CREATE INDEX IF NOT EXISTS idx_media_genres_trgm ON media USING gin (genres gin_trgm_ops)`,
} {
if err := db.Exec(stmt).Error; err != nil {
if log != nil {
log.Warn("create trigram index failed; media search may fall back to sequential scan",
zap.String("statement", stmt), zap.Error(err))
}
return nil
}
}
return nil
}
@@ -42,6 +174,105 @@ func ensureSQLiteQueryOptimizer(db *gorm.DB) error {
return db.Exec("ANALYZE").Error
}
// dropLegacyReaderSourceUniqueIndex 删除书源表上旧版 source_url 唯一索引。
//
// 书源改为按用户独立后唯一键是 (user_id, source_url);SQLite 的 RenameTable 与
// AutoMigrate 都不会删除按旧名创建的索引(实测旧唯一索引在改名后依然生效),
// 必须显式删除,否则第二个用户导入同一书源会撞唯一约束。
func dropLegacyReaderSourceUniqueIndex(db *gorm.DB) error {
if !db.Migrator().HasTable(&model.ReaderBookSource{}) {
return nil
}
for _, name := range []string{"idx_reader_book_sources_source_url", "reader_book_sources_source_url_key"} {
if db.Migrator().HasIndex(&model.ReaderBookSource{}, name) {
if err := db.Migrator().DropIndex(&model.ReaderBookSource{}, name); err != nil {
return err
}
}
}
// 旧版 DeleteSource 是软删:这些历史行会继续占用 (user_id, source_url) 唯一键,
// 让「删掉再导入同一书源」失败。书源是可重新导入的数据,这里一次性清掉。
return db.Unscoped().Where("deleted_at IS NOT NULL").Delete(&model.ReaderBookSource{}).Error
}
// backfillReaderSourceOwnership 给老库的书源补上归属用户。
//
// 书源原先全局唯一,现在按用户独立:按「谁的书架上有这本书」把书源分配给该用户;
// 同一书源被多个用户引用时,给每个用户复制一份副本(各自启停/排序/改规则),
// 这样谁都不会因为别人删源而失去书源。
//
// 没有 user_id 且无人引用的书源保持原样(user_id 为空)——它不再出现在任何人的
// 列表里,但数据仍在,需要时可以人工认领。
func backfillReaderSourceOwnership(db *gorm.DB) error {
if !db.Migrator().HasTable(&model.ReaderBookSource{}) {
return nil
}
var pending []model.ReaderBookSource
if err := db.Where("user_id = '' OR user_id IS NULL").Find(&pending).Error; err != nil {
return err
}
for i := range pending {
src := pending[i]
if err := assignOrCloneReaderSource(db, &src); err != nil {
return err
}
}
return nil
}
func assignOrCloneReaderSource(db *gorm.DB, src *model.ReaderBookSource) error {
if src == nil || src.SourceURL == "" {
return nil
}
// 引用该书源的书籍分布(去重到用户)。
type ownerCount struct {
UserID string
N int64
}
var owners []ownerCount
if err := db.Model(&model.ReaderBook{}).
Select("user_id, COUNT(*) AS n").
Where("origin = ?", src.SourceURL).
Group("user_id").Order("n DESC").Scan(&owners).Error; err != nil {
return err
}
if len(owners) == 0 {
return nil // 无人引用:保持无归属
}
// 第一个用户(引用最多)直接接手这一行。
owner := owners[0].UserID
if owner == "" {
return nil
}
if err := db.Model(&model.ReaderBookSource{}).
Where("id = ?", src.ID).
Updates(map[string]any{"user_id": owner}).Error; err != nil {
return err
}
// 其余用户各复制一份副本(新行 ID,其余字段全量拷贝)。
for _, o := range owners[1:] {
if o.UserID == "" || o.UserID == owner {
continue
}
var exists int64
if err := db.Model(&model.ReaderBookSource{}).
Where("user_id = ? AND source_url = ?", o.UserID, src.SourceURL).
Count(&exists).Error; err != nil {
return err
}
if exists > 0 {
continue
}
clone := *src
clone.ID = ""
clone.UserID = o.UserID
if err := db.Create(&clone).Error; err != nil {
return err
}
}
return nil
}
// 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
@@ -67,16 +298,35 @@ func ensurePostgresColumnCompatibility(db *gorm.DB) error {
if !isPostgres(db) {
return nil
}
statements := []string{
`ALTER TABLE media ALTER COLUMN container TYPE varchar(128)`,
`ALTER TABLE media ALTER COLUMN genres TYPE text`,
`ALTER TABLE media ALTER COLUMN series_id TYPE varchar(128)`,
`ALTER TABLE media ALTER COLUMN duplicate_of TYPE varchar(128)`,
`ALTER TABLE playback_histories ALTER COLUMN media_id TYPE varchar(128)`,
`ALTER TABLE favorites ALTER COLUMN media_id TYPE varchar(128)`,
`ALTER TABLE playlist_items ALTER COLUMN media_id TYPE varchar(128)`,
// 只在类型确实不匹配时才 ALTER。
//
// PostgreSQL 的 ALTER COLUMN ... TYPE 只要类型声明与目标不同就会整表重写并
// 重建相关索引,执行期间持有 ACCESS EXCLUSIVE 锁。此前这里每次启动都无条件
// 发这 7 条语句:类型其实早已一致,但几十万行的 media 表仍然被反复重写,
// 启动会卡住数分钟,且期间这张表连读都被阻塞。
changes := []struct {
table string
column string
target string
}{
{"media", "container", "varchar(128)"},
{"media", "genres", "text"},
{"media", "series_id", "varchar(128)"},
{"media", "duplicate_of", "varchar(128)"},
{"playback_histories", "media_id", "varchar(128)"},
{"favorites", "media_id", "varchar(128)"},
{"playlist_items", "media_id", "varchar(128)"},
}
for _, stmt := range statements {
for _, change := range changes {
current, found, err := postgresColumnType(db, change.table, change.column)
if err != nil {
return err
}
if !found || current == change.target {
continue
}
stmt := "ALTER TABLE " + quoteIdent(change.table) +
" ALTER COLUMN " + quoteIdent(change.column) + " TYPE " + change.target
if err := db.Exec(stmt).Error; err != nil {
return err
}
@@ -84,6 +334,35 @@ func ensurePostgresColumnCompatibility(db *gorm.DB) error {
return nil
}
// postgresColumnType 返回列在当前 schema 下的规范化类型描述,例如
// "varchar(128)" / "text"。列不存在时 found 为 false。
func postgresColumnType(db *gorm.DB, table, column string) (value string, found bool, err error) {
var row struct {
DataType string
MaxLength *int64
}
if err := db.Raw(`
SELECT data_type AS data_type, character_maximum_length AS max_length
FROM information_schema.columns
WHERE table_schema = current_schema() AND table_name = ? AND column_name = ?
`, table, column).Scan(&row).Error; err != nil {
return "", false, fmt.Errorf("inspect postgres column %s.%s: %w", table, column, err)
}
dataType := strings.ToLower(strings.TrimSpace(row.DataType))
if dataType == "" {
return "", false, nil
}
// information_schema 用的是 SQL 标准名,映射回 GORM 标签里的写法再比较。
switch dataType {
case "character varying", "varchar":
dataType = "varchar"
}
if row.MaxLength != nil && *row.MaxLength > 0 {
dataType = fmt.Sprintf("%s(%d)", dataType, *row.MaxLength)
}
return dataType, true, nil
}
func ensurePerformanceIndexes(db *gorm.DB) error {
statements := []string{
// 完整多级排序索引:媒体库分页与首页预览的 ORDER BY
@@ -97,6 +376,11 @@ func ensurePerformanceIndexes(db *gorm.DB) error {
`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_episode_active ON media(library_id, season_num, episode_num, created_at DESC) WHERE deleted_at IS NULL`,
// 虚拟剧集分组索引:series_id 为空的剧集行要按
// (season_num, episode_num, episode_fraction, created_at) 排序后做内存
// 分组。没有它 SQLite 只能全表扫描再建临时 B-tree,而 Emby 的
// 卡片/海报查询每次只关心其中一部剧(生产环境实测单次 1.3s)。
`CREATE INDEX IF NOT EXISTS idx_media_virtual_series_group ON media(season_num, episode_num, episode_fraction, created_at) WHERE deleted_at IS NULL AND (series_id IS NULL OR series_id = '')`,
`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`,
@@ -0,0 +1,54 @@
package database
import (
"path/filepath"
"strings"
"testing"
"github.com/glebarez/sqlite"
"gorm.io/gorm"
"gorm.io/gorm/logger"
"github.com/truewhile/MeBox/internal/model"
)
// TestAutoMigrateCreatesMediaPathUniqueIndex 守住 media.path 的唯一约束。
//
// 模型上不再声明 gorm:"uniqueIndex"(PostgreSQL 的 btree 单条索引项上限是
// 2704 字节,而 varchar(1024) 在中文路径下可达 3072 字节,对整列建唯一索引会让
// 这类行插入直接失败),改由 AutoMigrate 里的 ensureMediaPathIndex 按方言显式
// 建立。一旦这个调用被漏掉,扫描会静默产生重复的媒体行(同一文件被入库多次)。
func TestAutoMigrateCreatesMediaPathUniqueIndex(t *testing.T) {
db, err := gorm.Open(sqlite.Open(filepath.Join(t.TempDir(), "mebox.db")), &gorm.Config{
Logger: logger.Default.LogMode(logger.Silent),
})
if err != nil {
t.Fatal(err)
}
// 必须在 TempDir 清理之前关掉连接,否则 Windows 上删除库文件会失败。
// t.Cleanup 是后进先出:这里注册的关闭早于 t.TempDir 注册的删除。
if sqlDB, err := db.DB(); err == nil {
t.Cleanup(func() { _ = sqlDB.Close() })
}
if err := AutoMigrate(db); err != nil {
t.Fatalf("AutoMigrate: %v", err)
}
var definition string
if err := db.Raw(`SELECT COALESCE(sql, '') FROM sqlite_master WHERE type = 'index' AND name = 'idx_media_path'`).Scan(&definition).Error; err != nil {
t.Fatal(err)
}
if definition == "" {
t.Fatal("AutoMigrate did not create idx_media_path")
}
if !strings.Contains(strings.ToUpper(definition), "UNIQUE") {
t.Fatalf("idx_media_path is not unique: %q", definition)
}
if err := db.Create(&model.Media{Base: model.Base{ID: "m-1"}, Path: "/media/a.mkv"}).Error; err != nil {
t.Fatal(err)
}
if err := db.Create(&model.Media{Base: model.Base{ID: "m-2"}, Path: "/media/a.mkv"}).Error; err == nil {
t.Fatal("a duplicate media path was accepted; the unique constraint is not enforced")
}
}
+1 -1
View File
@@ -78,7 +78,7 @@ func copyModelTables(src, target *gorm.DB, batchSize int) (map[string]int64, int
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 {
if err := InsertPreservingValues(target.Clauses(clause.OnConflict{DoNothing: true}), filteredPtr.Interface(), batchSize); err != nil {
return tableCounts, totalCopied, fmt.Errorf("copy sqlite table %s: %w", table, err)
}
copiedForTable += int64(filtered.Len())
+189
View File
@@ -0,0 +1,189 @@
package database
import (
"context"
"database/sql"
"errors"
"fmt"
"io"
"net/url"
"os"
"path/filepath"
"strings"
"sync"
"go.uber.org/zap"
"github.com/truewhile/MeBox/internal/config"
)
var sqliteRestoreMu sync.Mutex
func sqliteRestorePath(cfg *config.Config) (string, error) {
if cfg == nil || strings.TrimSpace(cfg.Database.DBPath) == "" || cfg.Database.DBPath == ":memory:" || strings.HasPrefix(cfg.Database.DBPath, "file:") {
return "", errors.New("恢复需要配置有效的 SQLite 数据库文件路径")
}
return filepath.Abs(cfg.Database.DBPath)
}
func sqliteRestoreDSN(path, mode string) string {
u := url.URL{Scheme: "file", Path: filepath.ToSlash(path)}
q := url.Values{"mode": {mode}, "_pragma": {"busy_timeout(5000)"}}
u.RawQuery = q.Encode()
return u.String()
}
func validateSQLiteRestore(ctx context.Context, path string) error {
info, err := os.Lstat(path)
if err != nil {
return err
}
if !info.Mode().IsRegular() {
return errors.New("备份必须是常规文件")
}
db, err := sql.Open("sqlite", sqliteRestoreDSN(path, "ro"))
if err != nil {
return err
}
defer db.Close()
var result string
if err := db.QueryRowContext(ctx, "PRAGMA quick_check").Scan(&result); err != nil {
return fmt.Errorf("SQLite 备份校验失败: %w", err)
}
if result != "ok" {
return fmt.Errorf("SQLite 备份校验失败: %s", result)
}
var tables int
if err := db.QueryRowContext(ctx, "SELECT COUNT(*) FROM sqlite_master WHERE type = 'table' AND name IN ('users', 'media', 'settings')").Scan(&tables); err != nil {
return err
}
if tables == 0 {
return errors.New("备份不包含 MeBox 数据表")
}
return nil
}
// StageSQLiteRestore leaves the live database and its WAL untouched. The
// validated copy is applied on the next startup, before any database is opened.
func StageSQLiteRestore(ctx context.Context, cfg *config.Config, source string) error {
sqliteRestoreMu.Lock()
defer sqliteRestoreMu.Unlock()
dbPath, err := sqliteRestorePath(cfg)
if err != nil {
return err
}
pending := dbPath + ".restore-pending"
if _, err := os.Lstat(pending); err == nil {
return errors.New("已有等待恢复的备份,请重启 MeBox 后再操作")
} else if !errors.Is(err, os.ErrNotExist) {
return err
}
if err := validateSQLiteRestore(ctx, source); err != nil {
return err
}
in, err := os.Open(source)
if err != nil {
return err
}
defer in.Close()
out, err := os.CreateTemp(filepath.Dir(dbPath), ".mebox-restore-*")
if err != nil {
return err
}
tmp := out.Name()
defer os.Remove(tmp)
if _, err := io.Copy(out, in); err != nil {
_ = out.Close()
return err
}
if err := out.Sync(); err != nil {
_ = out.Close()
return err
}
if err := out.Close(); err != nil {
return err
}
if err := validateSQLiteRestore(ctx, tmp); err != nil {
return err
}
return os.Rename(tmp, pending)
}
// ApplyPendingSQLiteRestore must run before database.Open and before workers or
// HTTP listeners start. The previous database is retained alongside the file.
func ApplyPendingSQLiteRestore(ctx context.Context, cfg *config.Config, log *zap.Logger) error {
if cfg == nil {
return nil
}
dialect := normalizeDatabaseType(cfg.Database.Type)
if dialect == "auto" {
dialect = effectiveAutoDatabaseType(cfg)
}
if dialect != "sqlite" {
return nil
}
sqliteRestoreMu.Lock()
defer sqliteRestoreMu.Unlock()
dbPath, err := sqliteRestorePath(cfg)
if err != nil {
return err
}
pending := dbPath + ".restore-pending"
if _, err := os.Lstat(pending); errors.Is(err, os.ErrNotExist) {
return nil
} else if err != nil {
return err
}
if err := validateSQLiteRestore(ctx, pending); err != nil {
return err
}
previous := ""
if _, err := os.Lstat(dbPath); err == nil {
// A clean checkpoint prevents an old WAL from being replayed into the
// replacement database. A busy checkpoint leaves all files untouched.
live, err := sql.Open("sqlite", sqliteRestoreDSN(dbPath, "rw"))
if err != nil {
return err
}
var busy, pages, copied int
err = live.QueryRowContext(ctx, "PRAGMA wal_checkpoint(TRUNCATE)").Scan(&busy, &pages, &copied)
closeErr := live.Close()
if err != nil {
return err
}
if closeErr != nil {
return closeErr
}
if busy != 0 {
return errors.New("SQLite 数据库仍被占用,恢复已保留等待下次启动")
}
old, err := os.CreateTemp(filepath.Dir(dbPath), filepath.Base(dbPath)+".before-restore-*")
if err != nil {
return err
}
previous = old.Name()
if err := old.Close(); err != nil {
return err
}
if err := os.Remove(previous); err != nil {
return err
}
if err := os.Rename(dbPath, previous); err != nil {
return err
}
} else if !errors.Is(err, os.ErrNotExist) {
return err
}
if err := os.Rename(pending, dbPath); err != nil {
if previous != "" {
if rollbackErr := os.Rename(previous, dbPath); rollbackErr != nil {
return fmt.Errorf("恢复失败: %w;回退失败: %v,原数据库保留于 %s", err, rollbackErr, previous)
}
}
return err
}
if log != nil {
log.Warn("SQLite backup restored before startup", zap.String("previous_database", previous))
}
return nil
}
+104
View File
@@ -0,0 +1,104 @@
package database
import (
"database/sql"
"os"
"path/filepath"
"testing"
"github.com/truewhile/MeBox/internal/config"
)
func TestSQLiteRestoreStagesWithoutChangingLiveDatabase(t *testing.T) {
root := t.TempDir()
cfg := &config.Config{}
cfg.Database.Type = "sqlite"
cfg.Database.DBPath = filepath.Join(root, "live.db")
live, err := sql.Open("sqlite", sqliteRestoreDSN(cfg.Database.DBPath, "rwc"))
if err != nil {
t.Fatal(err)
}
defer live.Close()
for _, statement := range []string{
"PRAGMA journal_mode=WAL", "CREATE TABLE settings (key TEXT PRIMARY KEY, value TEXT)",
"INSERT INTO settings VALUES ('state', 'backup')",
} {
if _, err := live.Exec(statement); err != nil {
t.Fatal(err)
}
}
source := filepath.Join(root, "backup.db")
if _, err := live.Exec("VACUUM INTO ?", source); err != nil {
t.Fatal(err)
}
if _, err := live.Exec("UPDATE settings SET value = 'live'"); err != nil {
t.Fatal(err)
}
if err := StageSQLiteRestore(t.Context(), cfg, source); err != nil {
t.Fatal(err)
}
var value string
if err := live.QueryRow("SELECT value FROM settings").Scan(&value); err != nil || value != "live" {
t.Fatalf("staging changed live database: value=%q err=%v", value, err)
}
if _, err := live.Exec("UPDATE settings SET value = 'after-stage'"); err != nil {
t.Fatalf("database could not write after staging: %v", err)
}
if err := StageSQLiteRestore(t.Context(), cfg, source); err == nil {
t.Fatal("second staging should not replace the pending restore")
}
if err := os.Remove(source); err != nil {
t.Fatal(err)
}
if err := live.Close(); err != nil {
t.Fatal(err)
}
if err := ApplyPendingSQLiteRestore(t.Context(), cfg, nil); err != nil {
t.Fatal(err)
}
restored, err := sql.Open("sqlite", sqliteRestoreDSN(cfg.Database.DBPath, "ro"))
if err != nil {
t.Fatal(err)
}
defer restored.Close()
if err := restored.QueryRow("SELECT value FROM settings").Scan(&value); err != nil || value != "backup" {
t.Fatalf("restore failed: value=%q err=%v", value, err)
}
previous, err := filepath.Glob(cfg.Database.DBPath + ".before-restore-*")
if err != nil || len(previous) != 1 {
t.Fatalf("previous database not preserved: files=%v err=%v", previous, err)
}
old, err := sql.Open("sqlite", sqliteRestoreDSN(previous[0], "ro"))
if err != nil {
t.Fatal(err)
}
defer old.Close()
if err := old.QueryRow("SELECT value FROM settings").Scan(&value); err != nil || value != "after-stage" {
t.Fatalf("old database lost latest writes: value=%q err=%v", value, err)
}
if err := ApplyPendingSQLiteRestore(t.Context(), cfg, nil); err != nil {
t.Fatalf("repeat startup: %v", err)
}
}
func TestSQLiteRestoreRejectsCorruptAndSymlinkBackups(t *testing.T) {
root := t.TempDir()
cfg := &config.Config{}
cfg.Database.DBPath = filepath.Join(root, "live.db")
source := filepath.Join(root, "corrupt.db")
if err := os.WriteFile(source, []byte("not a SQLite database"), 0o600); err != nil {
t.Fatal(err)
}
if err := StageSQLiteRestore(t.Context(), cfg, source); err == nil {
t.Fatal("corrupt backup accepted")
}
link := filepath.Join(root, "link.db")
if err := os.Symlink(source, link); err == nil {
if err := StageSQLiteRestore(t.Context(), cfg, link); err == nil {
t.Fatal("symlink backup accepted")
}
}
if _, err := os.Stat(cfg.Database.DBPath + ".restore-pending"); !os.IsNotExist(err) {
t.Fatalf("failed validation left pending data: %v", err)
}
}
+15 -15
View File
@@ -104,19 +104,18 @@ func (g *sqliteWriteGate) Lock(ctx context.Context) (*sqliteGateHolder, error) {
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:
}
}()
// 广播也必须持有同一把锁,否则取消可能落在检查与 Wait 之间而丢失。
wake := func() {
g.mu.Lock()
defer g.mu.Unlock()
g.cond.Broadcast()
}
stopCancel := context.AfterFunc(ctx, wake)
defer stopCancel()
for {
if ctx.Err() != nil {
return nil, ctx.Err()
}
if g.owner == nil {
holder := &sqliteGateHolder{
id: sqliteGateHolderSeq.Add(1),
@@ -125,16 +124,17 @@ func (g *sqliteWriteGate) Lock(ctx context.Context) (*sqliteGateHolder, error) {
g.owner = holder
return holder, nil
}
if ctx.Err() != nil {
return nil, ctx.Err()
}
if time.Since(g.owner.acquired) > sqliteGateHoldLimit {
remaining := sqliteGateHoldLimit - time.Since(g.owner.acquired)
if remaining <= 0 {
// 持有者疑似 panic 泄漏(After 回调未执行):强制回收。
g.owner = nil
g.cond.Broadcast()
continue
}
// 无后续写入或取消时,也要在租约到期后主动唤醒等待者。
timer := time.AfterFunc(remaining, wake)
g.cond.Wait()
timer.Stop()
}
}
+79
View File
@@ -0,0 +1,79 @@
package database
import (
"context"
"errors"
"testing"
"time"
)
func TestSQLiteWriteGateRejectsCancelledContext(t *testing.T) {
ctx, cancel := context.WithCancel(t.Context())
cancel()
gate := newSQLiteWriteGate()
holder, err := gate.Lock(ctx)
defer gate.Unlock(holder)
if !errors.Is(err, context.Canceled) {
t.Fatalf("Lock error=%v, want context.Canceled", err)
}
}
func TestSQLiteWriteGateWakesForExpiredHolder(t *testing.T) {
gate := newSQLiteWriteGate()
holder, err := gate.Lock(t.Context())
if err != nil {
t.Fatal(err)
}
defer gate.Unlock(holder)
// Make the lease expire shortly, without waiting a full minute.
gate.mu.Lock()
holder.acquired = time.Now().Add(-sqliteGateHoldLimit + 20*time.Millisecond)
gate.mu.Unlock()
done := make(chan *sqliteGateHolder, 1)
go func() {
next, _ := gate.Lock(t.Context())
done <- next
}()
select {
case next := <-done:
gate.Unlock(holder) // A stale holder must not unlock its successor.
gate.mu.Lock()
owner := gate.owner
gate.mu.Unlock()
gate.Unlock(next)
if next == nil || owner != next {
t.Fatal("expired holder released the new owner's lock")
}
case <-time.After(time.Second):
gate.Unlock(holder)
gate.Unlock(<-done)
t.Fatal("waiter was not woken when the holder expired")
}
}
func TestSQLiteWriteGateWakesForCancellation(t *testing.T) {
gate := newSQLiteWriteGate()
holder, err := gate.Lock(t.Context())
if err != nil {
t.Fatal(err)
}
defer gate.Unlock(holder)
ctx, cancel := context.WithCancel(t.Context())
done := make(chan error, 1)
go func() {
next, err := gate.Lock(ctx)
gate.Unlock(next)
done <- err
}()
cancel()
select {
case err := <-done:
if !errors.Is(err, context.Canceled) {
t.Fatalf("Lock error=%v, want context.Canceled", err)
}
case <-time.After(time.Second):
gate.Unlock(holder)
<-done
t.Fatal("waiter was not woken for cancellation")
}
}
+1 -1
View File
@@ -87,7 +87,7 @@ func updateSettingHandler(svc *service.Container) gin.HandlerFunc {
if req.Key == "transcode.hw_enabled" || req.Key == "transcode.hw_accel" || req.Key == "transcoder.hardware_accel" || req.Key == "transcoder.encoder" {
svc.Transcoder.StopAll()
}
if req.Key == "cache.images_max_size_mb" || req.Key == "cache.images_originals_max_size_mb" || req.Key == "cache.images_originals_ttl_hours" {
if req.Key == "cache.images_max_size_mb" || req.Key == "cache.images_originals_max_size_mb" {
if svc.Scheduler != nil {
_ = svc.Scheduler.RunNowAsync(c.Request.Context(), "image_cache_cleanup")
}
+18
View File
@@ -0,0 +1,18 @@
package handler
import (
"net/http"
"github.com/gin-gonic/gin"
"github.com/truewhile/MeBox/internal/service"
)
func backgroundTasksHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
if svc == nil || svc.Tasks == nil {
c.JSON(http.StatusOK, service.TaskSnapshot{Active: []service.BackgroundTask{}, Recent: []service.BackgroundTask{}})
return
}
c.JSON(http.StatusOK, svc.Tasks.Snapshot())
}
}
+18 -1
View File
@@ -3,6 +3,7 @@ package handler
import (
"net/http"
"path/filepath"
"github.com/gin-gonic/gin"
@@ -41,12 +42,28 @@ func deleteBackupHandler(svc *service.Container) gin.HandlerFunc {
}
}
func downloadBackupHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
filename := c.Query("filename")
path, err := svc.Backup.ResolvePath(filename)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
c.FileAttachment(path, filepath.Base(path))
}
}
func restoreBackupHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
if err := svc.Backup.Restore(c.Request.Context(), c.Query("filename")); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"message": "restored — restart the server to apply"})
message := "数据库已恢复,请重启 MeBox 以刷新运行时配置"
if svc.Repo.DB.Dialector.Name() == "sqlite" {
message = "备份已校验并暂存,请重启 MeBox 后应用恢复"
}
c.JSON(http.StatusOK, gin.H{"message": message, "restart_required": true})
}
}
+24 -2
View File
@@ -124,10 +124,32 @@ func cloud115HLSSessionHandler(svc *service.Container) gin.HandlerFunc {
c.JSON(http.StatusBadRequest, gin.H{"error": "missing media_id"})
return
}
if !enforceScopedPlaybackToken(c, mediaID) {
if c.Request.Context().Err() != nil {
c.AbortWithStatus(499)
return
}
err := svc.Cloud115.HLSProxy().ServeChild(
actualMediaID, err := svc.Cloud115.HLSProxy().SessionMediaID(c.Param("session"), c.Param("key"))
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"code": "hls_session_not_found", "error": "hls session not found"})
return
}
if mediaID != actualMediaID {
c.JSON(http.StatusForbidden, gin.H{"error": "playback session media mismatch"})
return
}
if !enforceScopedPlaybackToken(c, actualMediaID) {
return
}
if svc.Repo == nil || svc.Repo.Media == nil {
c.JSON(http.StatusNotFound, gin.H{"error": "media not found"})
return
}
m, findErr := svc.Repo.Media.FindByID(c.Request.Context(), actualMediaID)
if findErr != nil || m == nil || !mediaVisibleForRequest(c, svc, m) {
c.JSON(http.StatusNotFound, gin.H{"error": "media not found"})
return
}
err = svc.Cloud115.HLSProxy().ServeChild(
c.Request.Context(),
c.Writer,
c.Request,
+113
View File
@@ -0,0 +1,113 @@
package handler
import (
"bytes"
"context"
"encoding/json"
"net/http"
"testing"
"github.com/truewhile/MeBox/internal/model"
)
// 客户端刷新首页时会用 IncludeItemTypes=Movie,Series,Video 一次取「最新」行。
// 电影库必须返回电影,而不是因为请求里带了 Series 就整行判空。
func TestEmbyItemsMixedTypesKeepsMoviesInMovieLibrary(t *testing.T) {
router, svc, _ := newEmbyDiscoveryEnv(t)
if err := svc.Repo.DB.AutoMigrate(&model.Series{}, &model.PlayProfile{}); err != nil {
t.Fatalf("migrate: %v", err)
}
libID := seedEmbyLibrary(t, svc, "movie")
if err := svc.Repo.DB.WithContext(context.Background()).Create(&model.Media{
Base: model.Base{ID: "movie-1"},
LibraryID: libID,
Title: "Movie One",
Path: "/media/movie/one.mp4",
Year: 2024,
ReleaseDate: "2024-01-01",
}).Error; err != nil {
t.Fatalf("create movie: %v", err)
}
path := "/emby/Items?ParentId=" + libID +
"&IncludeItemTypes=Movie,Series,Video&Recursive=true" +
"&SortBy=DateCreated,SortName&SortOrder=Descending&Limit=12&StartIndex=0"
w := embyGet(t, router, path, signedTestToken(t, "test-secret"))
if w.Code != http.StatusOK {
t.Fatalf("unexpected status: %d body=%s", w.Code, w.Body.String())
}
items := decodeItemsEnvelope(t, w.Body.Bytes())
if len(items) != 1 {
t.Fatalf("expected the movie to be returned, got %d items: %s", len(items), w.Body.String())
}
if items[0]["Id"] != "movie-1" || items[0]["Type"] != "Movie" {
t.Fatalf("unexpected item payload: %#v", items[0])
}
}
// 同一组混合类型在剧集库上仍要折叠成剧集卡片,修复不能把这条路径弄回单集。
func TestEmbyItemsMixedTypesKeepsSeriesCardsInTvLibrary(t *testing.T) {
router, svc, _ := newEmbyDiscoveryEnv(t)
if err := svc.Repo.DB.AutoMigrate(&model.Series{}, &model.PlayProfile{}); err != nil {
t.Fatalf("migrate: %v", err)
}
libID := seedEmbyLibrary(t, svc, "tv")
for _, ep := range []struct {
id string
num int
path string
}{
{id: "ep-1", num: 1, path: "/media/tv/show/S01E01.mkv"},
{id: "ep-2", num: 2, path: "/media/tv/show/S01E02.mkv"},
} {
if err := svc.Repo.DB.WithContext(context.Background()).Create(&model.Media{
Base: model.Base{ID: ep.id},
LibraryID: libID,
SeriesID: "series-1",
Title: "Show",
Path: ep.path,
SeasonNum: 1,
EpisodeNum: ep.num,
Year: 2024,
}).Error; err != nil {
t.Fatalf("create episode: %v", err)
}
}
path := "/emby/Items?ParentId=" + libID +
"&IncludeItemTypes=Movie,Series,Video&Recursive=true" +
"&SortBy=DateCreated,SortName&SortOrder=Descending&Limit=12&StartIndex=0"
w := embyGet(t, router, path, signedTestToken(t, "test-secret"))
if w.Code != http.StatusOK {
t.Fatalf("unexpected status: %d body=%s", w.Code, w.Body.String())
}
items := decodeItemsEnvelope(t, w.Body.Bytes())
if len(items) != 1 {
t.Fatalf("expected one series card, got %d items: %s", len(items), w.Body.String())
}
if items[0]["Type"] != "Series" {
t.Fatalf("expected Series card, got %#v", items[0])
}
}
// Emby 的 SpecialFeatures 响应是 BaseItemDto[],客户端按 List 强转;
// 返回 {"Items":[]} 会直接抛 type cast 异常。
func TestEmbySpecialFeaturesReturnsBareArray(t *testing.T) {
router, _, _ := newEmbyDiscoveryEnv(t)
w := embyGet(t, router, "/emby/Items/movie-1/SpecialFeatures", signedTestToken(t, "test-secret"))
if w.Code != http.StatusOK {
t.Fatalf("unexpected status: %d body=%s", w.Code, w.Body.String())
}
if trimmed := bytes.TrimSpace(w.Body.Bytes()); len(trimmed) == 0 || trimmed[0] != '[' {
t.Fatalf("SpecialFeatures must be a JSON array, got %s", w.Body.String())
}
var items []any
if err := json.Unmarshal(w.Body.Bytes(), &items); err != nil {
t.Fatalf("decode array: %v body=%s", err, w.Body.String())
}
if len(items) != 0 {
t.Fatalf("expected empty array, got %d entries", len(items))
}
}
+19
View File
@@ -50,6 +50,25 @@ func embyServeImage(c *gin.Context, svc *service.Container, id, imageType, tag s
embyServePlaceholderImage(c)
return
}
// 媒体库主图:按 Emby 的做法,用库内几张成员海报拼一张封面(folder
// image)。没有自定义封面时才生成;自定义封面继续走下面的代理路径。
if svc.ImageProxy != nil && strings.EqualFold(strings.TrimSpace(imageType), "Primary") {
if sources, lib := service.LibraryCoverSourcesForLibrary(ctx, svc.Repo, id); lib != nil &&
strings.TrimSpace(lib.CoverURL) == "" && len(sources) > 0 {
width, height := libraryCoverRequestSize(c)
if data, err := svc.ImageProxy.LibraryCoverImage(ctx, id, sources, width, height); err == nil && len(data) > 0 {
c.Header("Cache-Control", "public, max-age=300, stale-while-revalidate=86400")
c.Header("Content-Type", "image/jpeg")
c.Header("Content-Length", strconv.Itoa(len(data)))
if c.Request.Method == http.MethodHead {
c.Status(http.StatusOK)
return
}
c.Data(http.StatusOK, "image/jpeg", data)
return
}
}
}
// PersonImageURL handles TMDb/remote people first and falls back to regular
// media/library artwork, so the official /Items/{personId}/Images route
// works for synthetic person IDs as well as normal item IDs.
+6
View File
@@ -86,6 +86,12 @@ func embySegmentItems(m *model.Media, rows []model.MediaSegment, want map[string
}
endMs := row.EndMs
if endMs <= 0 {
// 只有片尾类允许「延续到片尾」。片头/回顾的结束点缺失意味着这段区间
// 本身不可信(数据源用 0 表示「这段不存在」),补成片长会让客户端把
// 整集标成片头,所以直接丢弃。
if row.Kind != model.SegmentKindCredits && row.Kind != model.SegmentKindPreview {
continue
}
if durationMs <= 0 {
continue
}
+26
View File
@@ -197,6 +197,32 @@ func deleteEmbyMountHandler(svc *service.Container) gin.HandlerFunc {
}
}
// setEmbyMountsEnabledHandler 一键启用/停用某账号下的全部挂载。
//
// 远端整站不可用时(如 Cloudflare 522),管理员可以用它把该账号的挂载
// 全部从媒体库摘掉,首页立刻只返回本地库;远端恢复后再一键启用。
func setEmbyMountsEnabledHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
var req struct {
Enabled *bool `json:"enabled" binding:"required"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
if svc.EmbyRemote == nil {
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "emby remote service not available"})
return
}
affected, err := svc.EmbyRemote.SetMountsEnabled(c.Request.Context(), c.Param("id"), *req.Enabled)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"ok": true, "affected": affected})
}
}
type reorderEmbyMountsReq struct {
IDs []string `json:"ids" binding:"required"`
}
@@ -0,0 +1,108 @@
package handler
import (
"bytes"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"github.com/gin-gonic/gin"
"github.com/glebarez/sqlite"
"go.uber.org/zap"
"gorm.io/gorm"
"github.com/truewhile/MeBox/internal/database"
"github.com/truewhile/MeBox/internal/model"
"github.com/truewhile/MeBox/internal/repository"
"github.com/truewhile/MeBox/internal/service"
)
// TestSetEmbyMountsEnabledHandler 覆盖「一键停用/启用某账号下全部挂载」:
// 只影响目标账号,且配置保留(不是删除)。
func TestSetEmbyMountsEnabledHandler(t *testing.T) {
gin.SetMode(gin.TestMode)
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
if err != nil {
t.Fatal(err)
}
if err := database.AutoMigrate(db); err != nil {
t.Fatal(err)
}
repos := repository.New(db)
ctx := t.Context()
m1 := &model.EmbyMount{AccountID: "acct-1", RemoteViewID: "view-1", Enabled: true}
m2 := &model.EmbyMount{AccountID: "acct-1", RemoteViewID: "view-2", Enabled: true}
other := &model.EmbyMount{AccountID: "acct-2", RemoteViewID: "view-3", Enabled: true}
for _, m := range []*model.EmbyMount{m1, m2, other} {
if err := repos.EmbyMount.Create(ctx, m); err != nil {
t.Fatalf("create mount: %v", err)
}
}
svc := &service.Container{
Repo: repos,
EmbyRemote: service.NewEmbyRemoteService(nil, zap.NewNop(), repos, nil),
}
router := gin.New()
router.PUT("/admin/emby/accounts/:id/mounts/enabled", setEmbyMountsEnabledHandler(svc))
call := func(body map[string]any) *httptest.ResponseRecorder {
raw, _ := json.Marshal(body)
req := httptest.NewRequest(http.MethodPut, "/admin/emby/accounts/acct-1/mounts/enabled", bytes.NewReader(raw))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
return w
}
w := call(map[string]any{"enabled": false})
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String())
}
var resp struct {
OK bool `json:"ok"`
Affected int64 `json:"affected"`
}
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
t.Fatalf("decode response: %v", err)
}
if !resp.OK || resp.Affected != 2 {
t.Fatalf("expected ok with affected=2, got %+v", resp)
}
rows, err := repos.EmbyMount.List(ctx)
if err != nil {
t.Fatal(err)
}
for _, row := range rows {
want := row.AccountID != "acct-1" // 目标账号停用,其他账号不受影响
if row.Enabled != want {
t.Fatalf("mount %s enabled=%v, want %v", row.ID, row.Enabled, want)
}
}
// 重新启用:挂载记录仍在,只是开关回 true。
w = call(map[string]any{"enabled": true})
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String())
}
rows, err = repos.EmbyMount.List(ctx)
if err != nil {
t.Fatal(err)
}
if len(rows) != 3 {
t.Fatalf("expected mounts to be preserved, got %d rows", len(rows))
}
for _, row := range rows {
if !row.Enabled {
t.Fatalf("mount %s should be enabled again", row.ID)
}
}
// 缺少 enabled 字段必须拒绝,避免误把全部挂载停掉。
if w := call(map[string]any{}); w.Code != http.StatusBadRequest {
t.Fatalf("expected 400 for missing enabled, got %d: %s", w.Code, w.Body.String())
}
}
+10 -2
View File
@@ -418,7 +418,11 @@ func embyVideoHLSPlaylistHandler(svc *service.Container) gin.HandlerFunc {
c.Status(http.StatusNotFound)
return
}
err = svc.Stream.ServeHLSPlaylist(c.Writer, c.Request, c.Param("id"))
scope, ok := localHLSPlaybackScope(c)
if !ok {
return
}
err = svc.Stream.ServeHLSPlaylist(c.Writer, c.Request, c.Param("id"), scope)
if errors.Is(err, service.ErrTranscodeDisabled) {
c.JSON(http.StatusConflict, gin.H{"error": "transcode disabled"})
return
@@ -444,7 +448,11 @@ func embyVideoHLSSegmentHandler(svc *service.Container) gin.HandlerFunc {
c.Status(http.StatusNotFound)
return
}
if err := svc.Stream.ServeHLSSegment(c.Writer, c.Request, c.Param("id"), c.Param("seg")); err != nil {
scope, ok := localHLSPlaybackScope(c)
if !ok {
return
}
if err := svc.Stream.ServeHLSSegment(c.Writer, c.Request, c.Param("id"), c.Param("seg"), scope); err != nil {
c.Status(http.StatusNotFound)
}
}
+4 -2
View File
@@ -212,9 +212,11 @@ func registerEmbyAuthenticatedItemRoutes(auth *gin.RouterGroup, svc *service.Con
auth.GET("/Items/:id/Similar", embySimilarHandler(svc))
auth.GET("/Items/:id/ThumbnailSet", embyEmptyItemsHandler(svc))
auth.GET("/Items/:id/ThemeMedia", embyThemeMediaHandler(svc))
auth.GET("/Users/:userId/Items/:id/SpecialFeatures", embyEmptyItemsHandler(svc))
// SpecialFeatures 在 Emby 官方 schema 里是 BaseItemDto[](裸数组),
// 返回 {"Items":[]} 会让按 List 强转的客户端抛 type cast 异常。
auth.GET("/Users/:userId/Items/:id/SpecialFeatures", embyEmptyArrayHandler(svc))
auth.GET("/Users/:userId/Items/:id/Intros", embyEmptyItemsHandler(svc))
auth.GET("/Items/:id/SpecialFeatures", embyEmptyItemsHandler(svc))
auth.GET("/Items/:id/SpecialFeatures", embyEmptyArrayHandler(svc))
auth.GET("/Items/:id/Intros", embyEmptyItemsHandler(svc))
auth.GET("/api/danmu/:id/raw", embyDanmuRawHandler(svc))
}
+2 -2
View File
@@ -55,9 +55,9 @@ func registerLowercaseEmbyItemRoutes(auth *gin.RouterGroup, svc *service.Contain
auth.GET("/items/:id/similar", embySimilarHandler(svc))
auth.GET("/items/:id/thumbnailset", embyEmptyItemsHandler(svc))
auth.GET("/items/:id/thememedia", embyThemeMediaHandler(svc))
auth.GET("/users/:userId/items/:id/specialfeatures", embyEmptyItemsHandler(svc))
auth.GET("/users/:userId/items/:id/specialfeatures", embyEmptyArrayHandler(svc))
auth.GET("/users/:userId/items/:id/intros", embyEmptyItemsHandler(svc))
auth.GET("/items/:id/specialfeatures", embyEmptyItemsHandler(svc))
auth.GET("/items/:id/specialfeatures", embyEmptyArrayHandler(svc))
auth.GET("/items/:id/intros", embyEmptyItemsHandler(svc))
}
+2
View File
@@ -46,6 +46,8 @@ func Register(r *gin.Engine, cfg *config.Config, log *zap.Logger, svc *service.C
registerPublicAuthRoutes(api, svc, log)
registerMobileRoutes(api, svc, log)
registerAuthenticatedRoutes(api, cfg, svc)
registerAdminRoutes(api, cfg, svc)
+37
View File
@@ -0,0 +1,37 @@
package handler
import (
"net/http"
"net/http/httptest"
"testing"
"go.uber.org/zap"
"github.com/truewhile/MeBox/internal/middleware"
"github.com/truewhile/MeBox/internal/model"
"github.com/truewhile/MeBox/internal/service"
)
func TestStopHLSChecksMediaVisibility(t *testing.T) {
router, svc, secret := newPlaybackScopeTestRouter(t)
svc.Transcoder = service.NewTranscoderService(svc.Cfg, zap.NewNop(), svc.Repo, nil)
router.DELETE("/api/hls/:id", middleware.AuthRequired(secret), stopTranscodeHandler(svc))
req := httptest.NewRequest(http.MethodDelete, "/api/hls/nonexistent?session_id=player-a", nil)
req.Header.Set("Authorization", "Bearer "+signedTestToken(t, secret))
rec := httptest.NewRecorder()
router.ServeHTTP(rec, req)
if rec.Code != http.StatusNotFound {
t.Fatalf("stop of a nonexistent/invisible media: status=%d body=%s, want 404", rec.Code, rec.Body.String())
}
profile := &model.PlayProfile{UserID: "user-1", Name: "restricted", IsDefault: true, AllowedLibraryIDs: `["other-library"]`}
if err := svc.Repo.DB.Create(profile).Error; err != nil {
t.Fatal(err)
}
req = httptest.NewRequest(http.MethodDelete, "/api/hls/media-1?session_id=player-a", nil)
req.Header.Set("Authorization", "Bearer "+signedTestToken(t, secret))
rec = httptest.NewRecorder()
router.ServeHTTP(rec, req)
if rec.Code != http.StatusNotFound {
t.Fatalf("invisible existing media can be stopped: %d %s", rec.Code, rec.Body.String())
}
}
+100
View File
@@ -0,0 +1,100 @@
package handler
import (
"context"
"crypto/sha256"
"encoding/hex"
"net/http"
"strconv"
"strings"
"time"
"github.com/gin-gonic/gin"
"github.com/truewhile/MeBox/internal/service"
)
// libraryCoverHandler 输出媒体库封面图(Emby 风格的成员海报拼图)。
//
// 之前 Web 端是拿两张成员海报在前端用 CSS 拼格子,Emby 客户端则直接拿一张
// 评分最高的海报,结果同一个库在不同客户端长得完全不一样。现在服务端生成
// 一张 16:9 的拼图(Emby 的 folder image 做法),Emby 客户端、Web 和 App
// 都指向这一份,外观统一。
func libraryCoverHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
id := strings.TrimSpace(c.Param("id"))
if id == "" || svc == nil || svc.ImageProxy == nil {
c.JSON(http.StatusNotFound, gin.H{"error": "cover unavailable"})
return
}
ctx, cancel := context.WithTimeout(c.Request.Context(), 25*time.Second)
defer cancel()
sources, lib := service.LibraryCoverSourcesForLibrary(ctx, svc.Repo, id)
if lib == nil {
c.JSON(http.StatusNotFound, gin.H{"error": "library not found"})
return
}
if strings.TrimSpace(lib.CoverURL) != "" {
// 管理员自定义封面优先,保持既有行为(代理该地址)。
if err := svc.ImageProxy.Serve(ctx, c.Writer, c.Request, lib.CoverURL); err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "custom cover unavailable"})
}
return
}
if len(sources) == 0 {
c.JSON(http.StatusNotFound, gin.H{"error": "no library items with artwork"})
return
}
width, height := libraryCoverRequestSize(c)
data, err := svc.ImageProxy.LibraryCoverImage(ctx, id, sources, width, height)
if err != nil || len(data) == 0 {
c.JSON(http.StatusNotFound, gin.H{"error": "cover generation failed"})
return
}
writeLibraryCoverImage(c, data, http.StatusOK)
}
}
// libraryCoverRequestSize 解析请求的封面尺寸:Web 用 w/h,Emby 用
// maxWidth/maxHeight,两种都兼容。服务端统一输出 16:9 画布(与该库在 Emby
// 里上报的 PrimaryImageAspectRatio 一致),客户端传的尺寸只当作「不超过」的
// 边界使用。
func libraryCoverRequestSize(c *gin.Context) (int, int) {
return firstPositiveQueryIntParam(c, "maxWidth", "maxwidth", "width", "w"),
firstPositiveQueryIntParam(c, "maxHeight", "maxheight", "height", "h")
}
func firstPositiveQueryIntParam(c *gin.Context, names ...string) int {
for _, name := range names {
raw := strings.TrimSpace(c.Query(name))
if raw == "" {
continue
}
if n, err := strconv.Atoi(raw); err == nil && n > 0 {
return n
}
if f, err := strconv.ParseFloat(raw, 64); err == nil && f > 0 {
return int(f)
}
}
return 0
}
func writeLibraryCoverImage(c *gin.Context, data []byte, status int) {
sum := sha256.Sum256(data)
etag := `"` + hex.EncodeToString(sum[:8]) + `"`
c.Header("ETag", etag)
c.Header("Cache-Control", "public, max-age=300, stale-while-revalidate=86400")
if match := strings.TrimSpace(c.GetHeader("If-None-Match")); match != "" && match == etag {
c.Status(http.StatusNotModified)
return
}
c.Header("Content-Type", "image/jpeg")
c.Header("Content-Length", strconv.Itoa(len(data)))
if c.Request.Method == http.MethodHead {
c.Status(status)
return
}
c.Data(status, "image/jpeg", data)
}
@@ -0,0 +1,118 @@
package handler
import (
"bytes"
"image/color"
"image/jpeg"
"net/http"
"net/http/httptest"
"net/url"
"path/filepath"
"testing"
"time"
"github.com/gin-gonic/gin"
"github.com/golang-jwt/jwt/v5"
"github.com/glebarez/sqlite"
"go.uber.org/zap"
"gorm.io/gorm"
"github.com/truewhile/MeBox/internal/config"
"github.com/truewhile/MeBox/internal/middleware"
"github.com/truewhile/MeBox/internal/model"
"github.com/truewhile/MeBox/internal/repository"
"github.com/truewhile/MeBox/internal/service"
)
func coverTestToken(t *testing.T, secret, userID string) string {
t.Helper()
token := jwt.NewWithClaims(jwt.SigningMethodHS256, middleware.Claims{
UserID: userID,
Role: "admin",
RegisteredClaims: jwt.RegisteredClaims{
ExpiresAt: jwt.NewNumericDate(time.Now().Add(time.Hour)),
IssuedAt: jwt.NewNumericDate(time.Now()),
Subject: userID,
},
})
signed, err := token.SignedString([]byte(secret))
if err != nil {
t.Fatalf("sign token: %v", err)
}
return signed
}
// TestLibraryCoverRouteServesImageThroughFullRouter 走完整的 handler.Register
// 路由表请求封面:既验证路由挂载正确,也验证鉴权链(AuthRequired +
// activeUserRequired)不会把它挡成 404。
func TestLibraryCoverRouteServesImageThroughFullRouter(t *testing.T) {
gin.SetMode(gin.TestMode)
const secret = "test-secret"
poster := coverTestJPEG(t, color.RGBA{R: 200, G: 120, B: 60, A: 255})
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "image/jpeg")
_, _ = w.Write(poster)
}))
defer upstream.Close()
parsed, _ := url.Parse(upstream.URL)
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
if err != nil {
t.Fatalf("open db: %v", err)
}
if err := db.AutoMigrate(&model.Library{}, &model.Media{}, &model.User{}); err != nil {
t.Fatalf("migrate: %v", err)
}
repos := repository.New(db)
user := model.User{Base: model.Base{ID: "user-1"}, Username: "viewer", IsActive: true}
if err := db.Create(&user).Error; err != nil {
t.Fatalf("create user: %v", err)
}
lib := model.Library{Name: "电影", Path: "/media/movies", Type: "movie", Enabled: true}
if err := repos.Library.Create(t.Context(), &lib); err != nil {
t.Fatalf("create library: %v", err)
}
for i := 0; i < 4; i++ {
row := model.Media{
Base: model.Base{ID: "m" + string(rune('a'+i))},
LibraryID: lib.ID,
Title: "Movie " + string(rune('A'+i)),
Path: "/media/movies/m" + string(rune('a'+i)) + ".mkv",
PosterURL: upstream.URL + "/p" + string(rune('1'+i)) + ".jpg",
}
if err := db.Create(&row).Error; err != nil {
t.Fatalf("create media: %v", err)
}
}
cfg := &config.Config{
Cache: config.CacheConfig{CacheDir: filepath.Join(t.TempDir(), "cache")},
Secrets: config.SecretsConfig{JWTSecret: secret},
}
proxy := service.NewImageProxy(cfg, zap.NewNop())
proxy.SetAllowedRemoteHostsProvider(func() []string { return []string{parsed.Host} })
svc := &service.Container{Repo: repos, ImageProxy: proxy}
engine := gin.New()
Register(engine, cfg, zap.NewNop(), svc)
token := coverTestToken(t, secret, user.ID)
req := httptest.NewRequest(http.MethodGet, "/api/libraries/"+lib.ID+"/cover?w=480&h=270&api_key="+token, nil)
w := httptest.NewRecorder()
engine.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("status = %d body=%s", w.Code, w.Body.String())
}
if ct := w.Header().Get("Content-Type"); ct != "image/jpeg" {
t.Fatalf("content type = %q, want image/jpeg", ct)
}
img, err := jpeg.Decode(bytes.NewReader(w.Body.Bytes()))
if err != nil {
t.Fatalf("decode cover: %v", err)
}
if img.Bounds().Dx() != 480 || img.Bounds().Dy() != 270 {
t.Fatalf("cover size = %v, want 480x270", img.Bounds())
}
}
+228
View File
@@ -0,0 +1,228 @@
package handler
import (
"bytes"
"image"
"image/color"
"image/jpeg"
"net/http"
"net/http/httptest"
"net/url"
"path/filepath"
"testing"
"github.com/gin-gonic/gin"
"github.com/glebarez/sqlite"
"go.uber.org/zap"
"gorm.io/gorm"
"github.com/truewhile/MeBox/internal/config"
"github.com/truewhile/MeBox/internal/model"
"github.com/truewhile/MeBox/internal/repository"
"github.com/truewhile/MeBox/internal/service"
)
func coverTestJPEG(t *testing.T, c color.RGBA) []byte {
t.Helper()
img := image.NewRGBA(image.Rect(0, 0, 160, 240))
for y := 0; y < 240; y++ {
for x := 0; x < 160; x++ {
img.Set(x, y, c)
}
}
var buf bytes.Buffer
if err := jpeg.Encode(&buf, img, &jpeg.Options{Quality: 90}); err != nil {
t.Fatalf("encode jpeg: %v", err)
}
return buf.Bytes()
}
// newLibraryCoverTestRouter 构造一个带真实图片代理的路由:成员海报来自
// 本地 httptest 服务,走的是与生产一致的 ImageProxy 抓取路径。
func newLibraryCoverTestRouter(t *testing.T) (*gin.Engine, *repository.Container, *httptest.Server) {
t.Helper()
gin.SetMode(gin.TestMode)
poster := coverTestJPEG(t, color.RGBA{R: 200, G: 120, B: 60, A: 255})
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "image/jpeg")
_, _ = w.Write(poster)
}))
t.Cleanup(upstream.Close)
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
if err != nil {
t.Fatalf("open db: %v", err)
}
if err := db.AutoMigrate(&model.Library{}, &model.Media{}); err != nil {
t.Fatalf("migrate: %v", err)
}
repos := repository.New(db)
parsed, err := url.Parse(upstream.URL)
if err != nil {
t.Fatalf("parse upstream: %v", err)
}
cfg := &config.Config{Cache: config.CacheConfig{CacheDir: filepath.Join(t.TempDir(), "cache")}}
proxy := service.NewImageProxy(cfg, zap.NewNop())
proxy.SetAllowedRemoteHostsProvider(func() []string { return []string{parsed.Host} })
svc := &service.Container{Repo: repos, ImageProxy: proxy}
router := gin.New()
router.GET("/libraries/:id/cover", libraryCoverHandler(svc))
router.HEAD("/libraries/:id/cover", libraryCoverHandler(svc))
return router, repos, upstream
}
func seedCoverLibrary(t *testing.T, repos *repository.Container, posterBase string, coverURL string) model.Library {
t.Helper()
lib := model.Library{Name: "电影", Path: "/media/movies", Type: "movie", Enabled: true, CoverURL: coverURL}
if err := repos.Library.Create(t.Context(), &lib); err != nil {
t.Fatalf("create library: %v", err)
}
for i := 0; i < 4; i++ {
row := model.Media{
Base: model.Base{ID: "m" + string(rune('a'+i))},
LibraryID: lib.ID,
Title: "Movie " + string(rune('A'+i)),
Path: "/media/movies/m" + string(rune('a'+i)) + ".mkv",
PosterURL: posterBase + "/p" + string(rune('1'+i)) + ".jpg",
}
if err := repos.DB.Create(&row).Error; err != nil {
t.Fatalf("create media: %v", err)
}
}
return lib
}
// TestLibraryCoverHandlerServesGeneratedCollage 覆盖 Web/App 的封面接口:
// 返回的是服务端拼好的 16:9 图片,而不是单张成员海报。
func TestLibraryCoverHandlerServesGeneratedCollage(t *testing.T) {
router, repos, upstream := newLibraryCoverTestRouter(t)
lib := seedCoverLibrary(t, repos, upstream.URL, "")
req := httptest.NewRequest(http.MethodGet, "/libraries/"+lib.ID+"/cover?w=480&h=270", nil)
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("status = %d body=%s", w.Code, w.Body.String())
}
if ct := w.Header().Get("Content-Type"); ct != "image/jpeg" {
t.Fatalf("content type = %q, want image/jpeg", ct)
}
img, err := jpeg.Decode(bytes.NewReader(w.Body.Bytes()))
if err != nil {
t.Fatalf("decode cover: %v", err)
}
if img.Bounds().Dx() != 480 || img.Bounds().Dy() != 270 {
t.Fatalf("cover size = %v, want 480x270", img.Bounds())
}
if etag := w.Header().Get("ETag"); etag == "" {
t.Fatal("expected an ETag so clients can revalidate")
}
// 条件请求应拿到 304。
req = httptest.NewRequest(http.MethodGet, "/libraries/"+lib.ID+"/cover?w=480&h=270", nil)
req.Header.Set("If-None-Match", w.Header().Get("ETag"))
rec := httptest.NewRecorder()
router.ServeHTTP(rec, req)
if rec.Code != http.StatusNotModified {
t.Fatalf("conditional request status = %d, want 304", rec.Code)
}
}
func TestLibraryCoverHandlerUnknownLibraryReturns404(t *testing.T) {
router, _, _ := newLibraryCoverTestRouter(t)
req := httptest.NewRequest(http.MethodGet, "/libraries/missing/cover", nil)
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
if w.Code != http.StatusNotFound {
t.Fatalf("status = %d, want 404", w.Code)
}
}
// TestLibraryCoverHandlerServesCustomCover 管理员填了 cover_url 时必须继续
// 用自定义封面,不能在不知不觉中换成拼图。
func TestLibraryCoverHandlerServesCustomCover(t *testing.T) {
router, repos, upstream := newLibraryCoverTestRouter(t)
lib := seedCoverLibrary(t, repos, upstream.URL, upstream.URL+"/custom.jpg")
req := httptest.NewRequest(http.MethodGet, "/libraries/"+lib.ID+"/cover", nil)
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("status = %d body=%s", w.Code, w.Body.String())
}
if _, err := jpeg.Decode(bytes.NewReader(w.Body.Bytes())); err != nil {
t.Fatalf("custom cover should be an image: %v", err)
}
}
// TestEmbyLibraryPrimaryUsesGeneratedCollage 保证 Emby 客户端请求库主图时
// 拿到的也是拼图(与 Web/App 同一份生成逻辑)。
func TestEmbyLibraryPrimaryUsesGeneratedCollage(t *testing.T) {
gin.SetMode(gin.TestMode)
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
if err != nil {
t.Fatalf("open db: %v", err)
}
if err := db.AutoMigrate(&model.Library{}, &model.Media{}); err != nil {
t.Fatalf("migrate: %v", err)
}
poster := coverTestJPEG(t, color.RGBA{G: 180, R: 90, B: 60, A: 255})
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "image/jpeg")
_, _ = w.Write(poster)
}))
defer upstream.Close()
parsed, _ := url.Parse(upstream.URL)
repos := repository.New(db)
lib := model.Library{Name: "剧集", Path: "/media/tv", Type: "tv", Enabled: true}
if err := repos.Library.Create(t.Context(), &lib); err != nil {
t.Fatalf("create library: %v", err)
}
for i := 0; i < 4; i++ {
row := model.Media{
Base: model.Base{ID: "e" + string(rune('a'+i))},
LibraryID: lib.ID,
Title: "Show " + string(rune('A'+i)),
Path: "/media/tv/show" + string(rune('a'+i)) + "/S01E01.mkv",
SeasonNum: 1, EpisodeNum: 1,
PosterURL: upstream.URL + "/p" + string(rune('1'+i)) + ".jpg",
}
if err := repos.DB.Create(&row).Error; err != nil {
t.Fatalf("create media: %v", err)
}
}
cfg := &config.Config{Cache: config.CacheConfig{CacheDir: filepath.Join(t.TempDir(), "cache")}}
proxy := service.NewImageProxy(cfg, zap.NewNop())
proxy.SetAllowedRemoteHostsProvider(func() []string { return []string{parsed.Host} })
svc := &service.Container{
Repo: repos,
Emby: service.NewEmbyService(cfg, zap.NewNop(), repos),
ImageProxy: proxy,
}
router := gin.New()
registerEmbyRoutes(router, "test-secret", svc)
req := httptest.NewRequest(http.MethodGet, "/Items/"+lib.ID+"/Images/Primary?maxWidth=400&maxHeight=300", nil)
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("status = %d body=%s", w.Code, w.Body.String())
}
img, err := jpeg.Decode(bytes.NewReader(w.Body.Bytes()))
if err != nil {
t.Fatalf("decode emby library cover: %v", err)
}
// 请求的是 400×300 的盒子,拼图按 16:9 装进去 → 400×225。
if img.Bounds().Dx() != 400 || img.Bounds().Dy() != 225 {
t.Fatalf("cover size = %v, want 400x225 (16:9 inside the requested box)", img.Bounds())
}
}
+298 -119
View File
@@ -4,8 +4,11 @@ package handler
import (
"context"
"errors"
"fmt"
"math"
"net/http"
"net/url"
"sort"
"strconv"
"strings"
"sync"
@@ -38,8 +41,32 @@ type webLibraryPayload struct {
RemoteSource string `json:"remote_source,omitempty"`
Total int64 `json:"total,omitempty"`
Cards []service.SeriesCardView `json:"cards,omitempty"`
// CoverImageURL 是客户端可直接使用的「有效封面」:管理员自定义的
// cover_url 优先,否则指向服务端生成的成员海报拼图接口。Web / App 都
// 读这一个字段,避免各自用卡片在前端拼格子、长得还不一样。
CoverImageURL string `json:"cover_image_url,omitempty"`
}
// effectiveLibraryCoverURL 计算库封面的有效地址。远程挂载库的封面由上游
// Emby 决定,这里返回空串,调用方继续用卡片拼图兜底。
func effectiveLibraryCoverURL(lib model.Library, isRemote bool) string {
if isRemote {
return ""
}
if raw := strings.TrimSpace(lib.CoverURL); raw != "" {
return raw
}
if strings.TrimSpace(lib.ID) == "" {
return ""
}
return "/api/libraries/" + url.PathEscape(lib.ID) + "/cover"
}
// remoteLibraryPreviewBudget 限制 /api/libraries?with_preview=1 里远程 Emby
// 库预览(总条数 + 最新卡片)的总耗时。这些预览只用于首页卡片,值得为它
// 排队等待的量很小;此前它跟随请求上下文,上游超时会让整页等 15 秒以上。
var remoteLibraryPreviewBudget = 3 * time.Second
// remoteLibraryItemTypes 远程库内容拉取时按 CollectionType 过滤直属条目,
// 避免电影库里的合集文件夹(Folder) 漏出为电影卡片。
func remoteLibraryItemTypes(collectionType string) string {
@@ -117,9 +144,10 @@ func listLibrariesHandler(svc *service.Container) gin.HandlerFunc {
}
for _, p := range previews {
out = append(out, webLibraryPayload{
Library: p.Library,
Total: p.Total,
Cards: service.NewSeriesCardViews(p.Cards),
Library: p.Library,
Total: p.Total,
Cards: service.NewSeriesCardViews(p.Cards),
CoverImageURL: effectiveLibraryCoverURL(p.Library, false),
})
}
} else {
@@ -138,7 +166,7 @@ func listLibrariesHandler(svc *service.Container) gin.HandlerFunc {
if counts != nil {
total = counts[l.ID]
}
out = append(out, webLibraryPayload{Library: l, Total: total})
out = append(out, webLibraryPayload{Library: l, Total: total, CoverImageURL: effectiveLibraryCoverURL(l, false)})
}
}
// 精确指定目标库时,如果目标全是本地库,就不必枚举远程挂载。
@@ -176,6 +204,12 @@ func listLibrariesHandler(svc *service.Container) gin.HandlerFunc {
remotePayloads[i] = webLibraryPayload{Library: v.Library, IsRemoteEmby: true, RemoteSource: v.AccountName}
}
if withPreview && len(allowedViews) > 0 {
// 远程库预览只是首页卡片上的「最新几条」,绝不能拖住整个
// 库列表:上游 Emby 一旦超时,每个远程库都要等满
// embyRemoteHTTPTimeout(15s),串起来就是 17–30 秒。
// 给整段预览一个总预算,超时就只返回库名,卡片留给下一次刷新。
previewCtx, cancelPreview := context.WithTimeout(ctx, remoteLibraryPreviewBudget)
defer cancelPreview()
const maxRemotePreviewWorkers = 6
sem := make(chan struct{}, maxRemotePreviewWorkers)
var wg sync.WaitGroup
@@ -187,11 +221,11 @@ func listLibrariesHandler(svc *service.Container) gin.HandlerFunc {
select {
case sem <- struct{}{}:
defer func() { <-sem }()
case <-ctx.Done():
case <-previewCtx.Done():
return
}
helper.Run(svc.Log, "media.remotePreview", func() {
acct := svc.EmbyRemote.AccountByID(ctx, v.AccountID)
acct := svc.EmbyRemote.AccountByID(previewCtx, v.AccountID)
if acct == nil {
return
}
@@ -203,10 +237,10 @@ func listLibrariesHandler(svc *service.Container) gin.HandlerFunc {
Name: v.Library.Name,
}
itemTypes := remoteLibraryItemTypes(v.CollectionType)
if _, total, err := svc.EmbyRemote.RemoteLibraryMedia(ctx, tmpMount, acct, v.RemoteID, itemTypes, 0, 1); err == nil {
if _, total, err := svc.EmbyRemote.RemoteLibraryMedia(previewCtx, 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 {
if cards, err := svc.EmbyRemote.RemoteLatestCards(previewCtx, tmpMount, acct, v.RemoteID, limit); err == nil {
remotePayloads[i].Cards = service.NewSeriesCardViews(cards)
}
})
@@ -259,9 +293,9 @@ func getLibraryHandler(svc *service.Container) gin.HandlerFunc {
c.JSON(http.StatusNotFound, gin.H{"error": "not found"})
return
}
c.JSON(http.StatusOK, webLibraryPayload{Library: libs[0]})
c.JSON(http.StatusOK, webLibraryPayload{Library: libs[0], CoverImageURL: effectiveLibraryCoverURL(libs[0], false)})
} else {
c.JSON(http.StatusOK, webLibraryPayload{Library: *lib})
c.JSON(http.StatusOK, webLibraryPayload{Library: *lib, CoverImageURL: effectiveLibraryCoverURL(*lib, false)})
}
}
}
@@ -556,19 +590,17 @@ func listMediaHandler(svc *service.Container) gin.HandlerFunc {
})
return
}
grouped, err := svc.Media.GroupedMediaVisibleFiltered(ctx, id, mediaVisibilityForRequest(c, svc), filters)
items, total, err := svc.Media.ListMediaVisibleSortedPage(ctx, id, page, size, mediaVisibilityForRequest(c, svc), filters, sortSpec, history)
if err != nil {
writeInternalOrCanceled(c, err)
return
}
grouped = service.SortMediaItems(grouped, sortSpec.Field, sortSpec.Order, history)
items := service.PaginateMediaItems(grouped, page, size)
if items == nil {
items = []service.MediaItem{}
}
c.JSON(http.StatusOK, gin.H{
"items": items,
"total": len(grouped),
"total": total,
"page": page,
"page_size": size,
})
@@ -656,6 +688,9 @@ func paginateSlice[T any](items []T, page, size int) []T {
if len(items) == 0 {
return []T{}
}
if page-1 > len(items)/size {
return []T{}
}
start := (page - 1) * size
if start >= len(items) {
return []T{}
@@ -670,127 +705,257 @@ func paginateSlice[T any](items []T, page, size int) []T {
func searchMediaHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
ctx := c.Request.Context()
q := c.Query("q")
query := strings.TrimSpace(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
filter := service.MediaSearchFilter{
LibraryID: strings.TrimSpace(c.Query("library_id")),
YearMin: boundedSearchYear(c.Query("year_min")),
YearMax: boundedSearchYear(c.Query("year_max")),
}
if c.DefaultQuery("group_series", "0") != "0" {
localItems, err := svc.Media.SearchMediaVisible(ctx, q, 50000, visibility)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
for _, genre := range c.QueryArray("genre") {
if genre = strings.TrimSpace(genre); genre != "" && len(filter.Genres) < 8 {
filter.Genres = append(filter.Genres, genre)
}
remoteItems := fetchRemote(50000)
all := service.GroupMediaSeriesItems(append(localItems, remoteItems...))
if c.Query("page") != "" || c.Query("page_size") != "" {
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
size, _ := strconv.Atoi(c.DefaultQuery("page_size", "50"))
paged := paginateSlice(all, page, size)
c.JSON(http.StatusOK, gin.H{
"items": paged,
"total": len(all),
"page": page,
"page_size": size,
})
return
}
limit, _ := strconv.Atoi(c.DefaultQuery("limit", "50"))
if limit <= 0 {
limit = 50
}
if len(all) > limit {
all = all[:limit]
}
c.JSON(http.StatusOK, gin.H{"items": all})
return
}
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)
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),
"page": page,
"page_size": size,
})
return
}
localItems, err := svc.Media.SearchMediaVisible(ctx, q, 50000, visibility)
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),
"page": page,
"page_size": size,
})
return
if filter.YearMin > 0 && filter.YearMax > 0 && filter.YearMin > filter.YearMax {
filter.YearMin, filter.YearMax = filter.YearMax, filter.YearMin
}
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
if page < 1 {
page = 1
}
size, _ := strconv.Atoi(c.DefaultQuery("page_size", "50"))
if size < 1 {
size = 50
} else if size > 2000 {
size = 2000
}
limit, _ := strconv.Atoi(c.DefaultQuery("limit", "50"))
if limit <= 0 {
if limit < 1 {
limit = 50
} else if limit > 50000 {
limit = 50000
}
if !groupVersions {
localItems, err := svc.Media.SearchMediaVisible(ctx, q, limit, visibility)
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})
return
}
localItems, err := svc.Media.SearchMediaVisible(ctx, q, 50000, visibility)
paginated := c.Query("page") != "" || c.Query("page_size") != ""
sortBy := normalizeMediaSearchSort(c.Query("sort"))
// 远程搜索与本地查询并行发起:本地结果不再排在远程超时之后才返回。
remoteCh := startRemoteMediaSearch(ctx, svc, query, visibility, filter)
// Sorting and grouping must operate on the same candidate set before
// either a page size or the caller's output limit is applied.
items, err := svc.Media.SearchMediaVisibleFiltered(ctx, query, 50000, visibility, filter)
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{}
if remoteCh != nil {
for _, item := range <-remoteCh {
items = append(items, item)
}
}
c.JSON(http.StatusOK, gin.H{"items": items})
if c.DefaultQuery("group_series", "0") != "0" {
items = service.GroupMediaSeriesItems(items)
sortSearchMedia(items, sortBy)
writeMediaSearchResults(c, items, page, size, limit, paginated)
return
}
if c.DefaultQuery("group_versions", "1") != "0" {
grouped := service.GroupMediaVersions(items)
if sortBy != "" {
sort.SliceStable(grouped, func(i, j int) bool {
return mediaSearchSortLess(grouped[i].Media, grouped[j].Media, sortBy)
})
}
writeMediaSearchResults(c, grouped, page, size, limit, paginated)
return
}
sortSearchMedia(items, sortBy)
writeMediaSearchResults(c, items, page, size, limit, paginated)
}
}
const (
// remoteSearchItemLimit 是每个远程 Emby 挂载点返回的条目上限。此前这里传
// 50000(等于让上游做一次全库查询),上游稍慢就会一路跑到 5 秒超时,把每次
// 搜索都拖成秒级;搜索场景只需要少量候选,多出来的结果也会被分页丢掉。
remoteSearchItemLimit = 100
// remoteSearchBudget 限制远程结果最多给一次请求增加多少延迟。
remoteSearchBudget = 2 * time.Second
// remoteSearchCacheTTL 让同一关键词的翻页与重复搜索复用远程结果,避免每翻
// 一页都重新打一遍远程挂载点。
remoteSearchCacheTTL = 30 * time.Second
// remoteSearchFailureCacheTTL 用于上游超时/失败时的短缓存:部分结果只保留
// 几秒,避免反复吃满 remoteSearchBudget,又不至于长期压住后续重试。
remoteSearchFailureCacheTTL = 5 * time.Second
)
// startRemoteMediaSearch 并发发起远程搜索,返回一个只投递一次结果的通道。
// 不满足远程搜索条件时返回 nil。通道带缓冲,调用方即使提前返回也不会泄漏
// goroutine;请求结束时请求上下文被取消,远程调用会随之退出。
func startRemoteMediaSearch(
ctx context.Context,
svc *service.Container,
query string,
visibility service.MediaVisibility,
filter service.MediaSearchFilter,
) <-chan []model.Media {
if svc == nil || svc.EmbyRemote == nil || query == "" {
return nil
}
if filter.LibraryID != "" && !service.IsEmbyRemoteID(filter.LibraryID) {
return nil
}
results := make(chan []model.Media, 1)
go func() {
results <- remoteMediaSearchItems(ctx, svc, query, visibility, filter)
}()
return results
}
func remoteMediaSearchItems(
ctx context.Context,
svc *service.Container,
query string,
visibility service.MediaVisibility,
filter service.MediaSearchFilter,
) []model.Media {
cacheKey := remoteSearchCacheKey(query, visibility)
if svc.Cache != nil {
if cached, ok := svc.Cache.GetObject(cacheKey); ok {
if rows, ok := cached.([]model.Media); ok {
return filterRemoteSearchItems(rows, filter)
}
}
}
searchCtx, cancel := context.WithTimeout(ctx, remoteSearchBudget)
defer cancel()
remote, _ := svc.EmbyRemote.RemoteSearchMedia(searchCtx, query, remoteSearchItemLimit, visibility)
if svc.Cache != nil {
// 空结果也要缓存:否则查不到片名时每次都会重新等到 remoteSearchBudget。
if remote == nil {
remote = []model.Media{}
}
ttl := remoteSearchCacheTTL
if searchCtx.Err() != nil {
ttl = remoteSearchFailureCacheTTL
}
svc.Cache.SetObject(cacheKey, remote, ttl)
}
return filterRemoteSearchItems(remote, filter)
}
func filterRemoteSearchItems(remote []model.Media, filter service.MediaSearchFilter) []model.Media {
if len(remote) == 0 {
return nil
}
out := make([]model.Media, 0, len(remote))
for _, item := range remote {
if remoteSearchMatchesFilter(item, filter) {
out = append(out, item)
}
}
return out
}
// remoteSearchCacheKey 按可见范围隔离远程搜索结果,避免把某个用户可见挂载
// 点的结果复用给可见范围不同的用户。
func remoteSearchCacheKey(query string, visibility service.MediaVisibility) string {
allowed := append([]string(nil), visibility.AllowedLibraryIDs...)
hidden := append([]string(nil), visibility.HiddenLibraryIDs...)
sort.Strings(allowed)
sort.Strings(hidden)
return fmt.Sprintf("media:obj:remote-search:%t|%s|%s|%s",
visibility.IncludeNSFW, strings.Join(allowed, ","), strings.Join(hidden, ","), query)
}
func writeMediaSearchResults[T any](c *gin.Context, items []T, page, size, limit int, paginated bool) {
if items == nil {
items = []T{}
}
if paginated {
c.JSON(http.StatusOK, gin.H{
"items": paginateSlice(items, page, size),
"total": len(items), "page": page, "page_size": size,
})
return
}
if len(items) > limit {
items = items[:limit]
}
c.JSON(http.StatusOK, gin.H{"items": items})
}
func remoteSearchMatchesFilter(item model.Media, filter service.MediaSearchFilter) bool {
if filter.LibraryID != "" && item.LibraryID != filter.LibraryID && item.DisplayLibraryID != filter.LibraryID {
return false
}
if filter.YearMin > 0 && item.Year < filter.YearMin || filter.YearMax > 0 && item.Year > filter.YearMax {
return false
}
if len(filter.Genres) == 0 {
return true
}
normalize := func(value string) string {
return strings.ToLower(strings.ReplaceAll(strings.TrimSpace(value), " ", ""))
}
for _, genre := range strings.Split(strings.ReplaceAll(item.Genres, ",", ","), ",") {
for _, wanted := range filter.Genres {
if normalize(genre) == normalize(wanted) {
return true
}
}
}
return false
}
func normalizeMediaSearchSort(value string) string {
value = strings.TrimSpace(value)
switch value {
case "title_asc", "year_desc", "rating_desc":
return value
default:
return ""
}
}
func sortSearchMedia(items []model.Media, sortBy string) {
if sortBy == "" {
return
}
sort.SliceStable(items, func(i, j int) bool {
return mediaSearchSortLess(items[i], items[j], sortBy)
})
}
func mediaSearchSortLess(a, b model.Media, sortBy string) bool {
switch sortBy {
case "title_asc":
left, right := strings.ToLower(strings.TrimSpace(a.Title)), strings.ToLower(strings.TrimSpace(b.Title))
if left != right {
return left < right
}
case "year_desc":
if a.Year != b.Year {
return a.Year > b.Year
}
case "rating_desc":
if a.Rating != b.Rating {
return a.Rating > b.Rating
}
}
if !a.CreatedAt.Equal(b.CreatedAt) {
return a.CreatedAt.After(b.CreatedAt)
}
return a.ID < b.ID
}
func boundedSearchYear(value string) int {
year, err := strconv.Atoi(strings.TrimSpace(value))
if err != nil || year < 1870 || year > 2200 {
return 0
}
return year
}
func streamHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
ctx := c.Request.Context()
@@ -810,6 +975,20 @@ func streamHandler(svc *service.Container) gin.HandlerFunc {
c.JSON(http.StatusNotFound, gin.H{"error": "not found"})
return
}
// 远程条目可能是「整部剧/季/目录」:这类条目自身没有媒体源,远程 Emby 对它
// 们的 /Videos/{id}/stream 会直接回 500(Unable to cast object of type
// 'Series' to type 'IHasMediaSources')。网页端、外部播放器、第三方客户端都
// 可能把这种 ID 送进来,这里统一改投第一集:302 让客户端自己跟过去,播放器
// 原有的 token / proxy 查询参数一并保留。
if playable := svc.EmbyRemote.PlayableRemoteID(ctx, acct, remoteID); playable != "" && playable != remoteID {
target := "/api/stream/" + service.EncodeEmbyRemoteID(mountID, playable)
if rawQuery := c.Request.URL.RawQuery; rawQuery != "" {
target += "?" + rawQuery
}
setRedirectNoStoreHeaders(c)
c.Redirect(http.StatusFound, target)
return
}
// 远程 Emby 条目的同源转发:VR 全景需要浏览器读帧(见 stream_proxy.go),
// 与挂载账号的 proxy_play 开关等价。
if mount.ProxyPlay || wantSameOriginProxy(c) {
@@ -0,0 +1,136 @@
package handler
import (
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"github.com/gin-gonic/gin"
"github.com/glebarez/sqlite"
"go.uber.org/zap"
"gorm.io/gorm"
"github.com/truewhile/MeBox/internal/config"
"github.com/truewhile/MeBox/internal/middleware"
"github.com/truewhile/MeBox/internal/model"
"github.com/truewhile/MeBox/internal/repository"
"github.com/truewhile/MeBox/internal/service"
)
// TestListLibrariesRemotePreviewIsBounded 锁定远程库预览的总预算:
// 上游 Emby 卡住时,/api/libraries?with_preview=1 必须按时返回远程库条目
// (只是没有卡片),而不是跟着上游一起等十几秒。线上实测这个接口因此出现过
// 17.4 秒、30 秒(前端放弃)的响应,设置页也跟着整页空转。
func TestListLibrariesRemotePreviewIsBounded(t *testing.T) {
gin.SetMode(gin.TestMode)
oldBudget := remoteLibraryPreviewBudget
remoteLibraryPreviewBudget = 200 * time.Millisecond
t.Cleanup(func() { remoteLibraryPreviewBudget = oldBudget })
// Views / 用户列表正常返回,Preview(Items 查询)永远不返回:模拟上游半挂。
release := make(chan struct{})
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
path := r.URL.Path
switch {
case strings.HasSuffix(path, "/Views"):
_ = json.NewEncoder(w).Encode(map[string]any{
"Items": []map[string]any{{
"Id": "view-1", "Name": "远程动画", "CollectionType": "tvshows",
}},
})
return
case strings.HasSuffix(path, "/Users"):
// api_key 账号要先解析出远程用户 Id,否则请求路径会落到兜底 "0"。
_ = json.NewEncoder(w).Encode([]map[string]any{{"Id": "uid-1", "Name": "admin"}})
return
case r.Method == http.MethodPost:
_ = json.NewEncoder(w).Encode(map[string]any{
"AccessToken": "fake-token",
"User": map[string]any{"Id": "uid-1"},
})
return
}
select {
case <-release:
case <-r.Context().Done():
case <-time.After(30 * time.Second):
}
w.WriteHeader(http.StatusGatewayTimeout)
}))
defer upstream.Close()
defer close(release)
// 唯一库名 + cache=shared:预览协程会并发读库(账号解析)。
dsn := fmt.Sprintf("file:%s?mode=memory&cache=shared", strings.ReplaceAll(t.Name(), "/", "_"))
db, err := gorm.Open(sqlite.Open(dsn), &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)
if err := db.Create(&model.User{Base: model.Base{ID: "admin-1"}, Username: "admin", Role: "admin"}).Error; err != nil {
t.Fatal(err)
}
rawConfig, _ := json.Marshal(map[string]string{"url": upstream.URL, "token": "fake-token"})
acct := model.StrmAccount{
Base: model.Base{ID: "acct-1"}, Name: "远程Emby", Provider: model.StrmProviderEmbyRemote,
Config: string(rawConfig), 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)
}
svc := &service.Container{
Repo: repos,
Media: service.NewMediaService(&config.Config{}, zap.NewNop(), repos),
EmbyRemote: service.NewEmbyRemoteService(&config.Config{}, zap.NewNop(), repos, service.NewCryptoService("", zap.NewNop())),
Cache: service.NewRuntimeCacheService(&config.Config{}, zap.NewNop()),
}
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Set(middleware.CtxUserID, "admin-1")
c.Set(middleware.CtxUserRole, "admin")
c.Request = httptest.NewRequest(http.MethodGet, "/api/libraries?with_preview=1&include_hidden=1", nil)
start := time.Now()
listLibrariesHandler(svc)(c)
elapsed := time.Since(start)
if w.Code != http.StatusOK {
t.Fatalf("status=%d body=%s", w.Code, w.Body.String())
}
if elapsed > 2*time.Second {
t.Fatalf("request took %v with a hung upstream; the preview budget must bound it", elapsed)
}
var payload []webLibraryPayload
if err := json.Unmarshal(w.Body.Bytes(), &payload); err != nil {
t.Fatalf("decode response: %v (%s)", err, w.Body.String())
}
var remote *webLibraryPayload
for i := range payload {
if payload[i].IsRemoteEmby {
remote = &payload[i]
break
}
}
if remote == nil {
t.Fatalf("remote library missing from the bounded response: %#v", payload)
}
if len(remote.Cards) != 0 || remote.Total != 0 {
t.Fatalf("expected the preview to be skipped on timeout, got total=%d cards=%d", remote.Total, len(remote.Cards))
}
}
+37
View File
@@ -0,0 +1,37 @@
package handler
import (
"net/http"
"strconv"
"github.com/gin-gonic/gin"
"github.com/truewhile/MeBox/internal/service"
)
func mediaQualityHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "50"))
if page < 1 {
page = 1
} else if page > 1000000 {
page = 1000000
}
if pageSize < 1 {
pageSize = 50
} else if pageSize > 100 {
pageSize = 100
}
issue := c.DefaultQuery("issue", "unmatched")
if issue != "unmatched" && issue != "poster" && issue != "missing" {
c.JSON(http.StatusBadRequest, gin.H{"error": "未知检查分类"})
return
}
items, total, err := svc.Media.QualityIssues(c.Request.Context(), issue, page, pageSize)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"items": items, "total": total, "page": page, "page_size": pageSize})
}
}
@@ -0,0 +1,88 @@
package handler
import (
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"strconv"
"testing"
"github.com/gin-gonic/gin"
"github.com/glebarez/sqlite"
"go.uber.org/zap"
"gorm.io/gorm"
"github.com/truewhile/MeBox/internal/config"
"github.com/truewhile/MeBox/internal/middleware"
"github.com/truewhile/MeBox/internal/model"
"github.com/truewhile/MeBox/internal/repository"
"github.com/truewhile/MeBox/internal/service"
)
func TestSearchMediaSortLimitFiltersAndDeepPagination(t *testing.T) {
gin.SetMode(gin.TestMode)
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
if err != nil {
t.Fatal(err)
}
sqlDB, _ := db.DB()
defer sqlDB.Close()
if err := db.AutoMigrate(&model.Library{}, &model.Media{}, &model.User{}, &model.Setting{}, &model.PlayProfile{}); err != nil {
t.Fatal(err)
}
repos := repository.New(db)
lib := model.Library{Name: "Review", Path: "/review", Type: "movie", Enabled: true}
if err := repos.Library.Create(t.Context(), &lib); err != nil {
t.Fatal(err)
}
rows := make([]model.Media, 2001)
for i := range rows {
rows[i] = model.Media{
Base: model.Base{ID: fmt.Sprintf("review-%04d", i)}, LibraryID: lib.ID,
Title: fmt.Sprintf("Review %04d", i), Path: fmt.Sprintf("/review/%04d.mp4", i), Year: 2020, Genres: "Comedy",
}
}
rows[2000].Year = 2025
rows[2000].Genres = "Drama"
if err := db.CreateInBatches(rows, 100).Error; err != nil {
t.Fatal(err)
}
svc := &service.Container{Repo: repos, Media: service.NewMediaService(&config.Config{}, zap.NewNop(), repos)}
cases := []struct {
name, query, expectedID string
count, total, page, size int
}{
{name: "sorted output limit", query: "sort=year_desc&limit=1", expectedID: "review-2000", count: 1},
{name: "page past old 2000 cap", query: "sort=title_asc&page=2001&page_size=1", expectedID: "review-2000", count: 1, total: 2001, page: 2001, size: 1},
{name: "genre filtering", query: "genre=Drama&library_id=" + lib.ID, expectedID: "review-2000", count: 1},
{name: "normalized page", query: "page=0&page_size=0", count: 50, total: 2001, page: 1, size: 50},
{name: "bounded page size", query: "page=1&page_size=99999", count: 2000, total: 2001, page: 1, size: 2000},
{name: "huge page does not overflow", query: "page=" + strconv.Itoa(int(^uint(0)>>1)) + "&page_size=2000", count: 0, total: 2001, page: int(^uint(0) >> 1), size: 2000},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
w := httptest.NewRecorder()
ctx, _ := gin.CreateTestContext(w)
ctx.Set(middleware.CtxUserID, "admin")
ctx.Set(middleware.CtxUserRole, "admin")
ctx.Request = httptest.NewRequest(http.MethodGet, "/api/media?q=Review&group_versions=0&"+tc.query, nil)
searchMediaHandler(svc)(ctx)
var response struct {
Items []model.Media `json:"items"`
Total int `json:"total"`
Page int `json:"page"`
Size int `json:"page_size"`
}
if w.Code != http.StatusOK || json.Unmarshal(w.Body.Bytes(), &response) != nil {
t.Fatalf("search failed: status=%d body=%s", w.Code, w.Body.String())
}
if len(response.Items) != tc.count || response.Total != tc.total || response.Page != tc.page || response.Size != tc.size {
t.Fatalf("response count=%d total=%d page=%d size=%d, want %#v", len(response.Items), response.Total, response.Page, response.Size, tc)
}
if tc.expectedID != "" && response.Items[0].ID != tc.expectedID {
t.Fatalf("first ID=%q want %q", response.Items[0].ID, tc.expectedID)
}
})
}
}
@@ -0,0 +1,153 @@
package handler
import (
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"strings"
"sync"
"testing"
"github.com/gin-gonic/gin"
"github.com/glebarez/sqlite"
"go.uber.org/zap"
"gorm.io/gorm"
"github.com/truewhile/MeBox/internal/config"
"github.com/truewhile/MeBox/internal/middleware"
"github.com/truewhile/MeBox/internal/model"
"github.com/truewhile/MeBox/internal/repository"
"github.com/truewhile/MeBox/internal/service"
)
// TestSearchMediaRemoteSearchBoundedAndCached 防止远程搜索再次退化:
// 1. 每个挂载点的 Limit 必须是 remoteSearchItemLimit,而不是历史全库查询的 50000;
// 2. 同一关键词的重复搜索应命中缓存,不再打上游;
// 3. 指定本地媒体库时不应枚举远程挂载点。
func TestSearchMediaRemoteSearchBoundedAndCached(t *testing.T) {
gin.SetMode(gin.TestMode)
var mu sync.Mutex
searchCalls := 0
searchLimits := make([]string, 0, 4)
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if term := r.URL.Query().Get("SearchTerm"); term != "" {
mu.Lock()
searchCalls++
searchLimits = append(searchLimits, r.URL.Query().Get("Limit"))
mu.Unlock()
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]any{
"TotalRecordCount": 1,
"Items": []map[string]any{{
"Id": "remote-1",
"Name": "远方剧集",
"Type": "Series",
"ProductionYear": 2024,
}},
})
}))
defer upstream.Close()
// 唯一库名 + cache=shared:远程搜索与本地查询会并发读库,
// 而 :memory: 下每个连接各自一个空库,并发连接会读到「no such table」。
dsn := fmt.Sprintf("file:%s?mode=memory&cache=shared", strings.ReplaceAll(t.Name(), "/", "_"))
db, err := gorm.Open(sqlite.Open(dsn), &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)
if err := repos.DB.Create(&model.User{Base: model.Base{ID: "user-1"}, Username: "admin", Role: "admin"}).Error; err != nil {
t.Fatal(err)
}
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 := db.Create(&model.Media{
Base: model.Base{ID: "local-1"}, LibraryID: lib.ID, Title: "流浪地球", Path: "/media/movies/流浪地球.mkv",
}).Error; err != nil {
t.Fatal(err)
}
rawConfig, _ := json.Marshal(map[string]string{"url": upstream.URL, "token": "fake-token"})
acct := model.StrmAccount{
Base: model.Base{ID: "acct"}, Name: "远程Emby", Provider: model.StrmProviderEmbyRemote,
Config: string(rawConfig), 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)
}
svc := &service.Container{
Repo: repos,
Media: service.NewMediaService(&config.Config{}, zap.NewNop(), repos),
EmbyRemote: service.NewEmbyRemoteService(&config.Config{}, zap.NewNop(), repos, service.NewCryptoService("", zap.NewNop())),
Cache: service.NewRuntimeCacheService(&config.Config{}, zap.NewNop()),
}
search := func(params string) []service.MediaItem {
t.Helper()
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?"+params, nil)
searchMediaHandler(svc)(c)
if w.Code != http.StatusOK {
t.Fatalf("search status=%d, body=%s", w.Code, w.Body.String())
}
var response struct {
Items []service.MediaItem `json:"items"`
}
if err := json.Unmarshal(w.Body.Bytes(), &response); err != nil {
t.Fatal(err)
}
return response.Items
}
callCount := func() int {
mu.Lock()
defer mu.Unlock()
return searchCalls
}
if items := search("q=远方&page=1&page_size=48"); len(items) != 1 || items[0].Title != "远方剧集" {
t.Fatalf("expected remote result, got %#v", items)
}
if calls := callCount(); calls != 1 {
t.Fatalf("expected exactly one upstream search, got %d", calls)
}
mu.Lock()
limit := searchLimits[0]
mu.Unlock()
if limit != "100" {
t.Fatalf("remote search Limit=%q, want bounded 100 (50000 means a full-library query)", limit)
}
// 同一关键词再来一次(例如刷新/重复搜索)应复用缓存,不再请求上游。
if items := search("q=远方&page=1&page_size=48"); len(items) != 1 || items[0].Title != "远方剧集" {
t.Fatalf("cached search lost the remote result: %#v", items)
}
if calls := callCount(); calls != 1 {
t.Fatalf("expected cached remote search to skip upstream, got %d calls", calls)
}
// 指定本地媒体库时不应枚举远程挂载点。
if items := search("q=流浪&page=1&page_size=48&library_id=" + lib.ID); len(items) != 1 || items[0].ID != "local-1" {
t.Fatalf("expected local-only result, got %#v", items)
}
if calls := callCount(); calls != 1 {
t.Fatalf("local library filter must not hit upstream, got %d calls", calls)
}
}
+122
View File
@@ -0,0 +1,122 @@
package handler
import (
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"net/url"
"strings"
"testing"
"github.com/gin-gonic/gin"
"github.com/glebarez/sqlite"
"go.uber.org/zap"
"gorm.io/gorm"
"github.com/truewhile/MeBox/internal/config"
"github.com/truewhile/MeBox/internal/middleware"
"github.com/truewhile/MeBox/internal/model"
"github.com/truewhile/MeBox/internal/repository"
"github.com/truewhile/MeBox/internal/service"
)
func TestSearchMediaSeriesKeepsRemoteLibrariesSeparate(t *testing.T) {
gin.SetMode(gin.TestMode)
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]any{
"TotalRecordCount": 1,
"Items": []map[string]any{{
"Id": "same-series", "Name": "龙珠改", "Type": "Series",
"ProviderIds": map[string]string{"Tmdb": "61709"},
"ProductionYear": 2024, "Genres": []string{"Science Fiction"},
}},
})
}))
defer upstream.Close()
// 唯一库名 + cache=shared:远程搜索与本地查询会并发读库,
// 而 :memory: 下每个连接各自一个空库,并发连接会读到「no such table」。
dsn := fmt.Sprintf("file:%s?mode=memory&cache=shared", strings.ReplaceAll(t.Name(), "/", "_"))
db, err := gorm.Open(sqlite.Open(dsn), &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)
lib := model.Library{Name: "本地番剧", Path: "/media/anime", Type: "anime", Enabled: true}
if err := repos.Library.Create(t.Context(), &lib); err != nil {
t.Fatal(err)
}
rows := []model.Media{
{Base: model.Base{ID: "local-1"}, LibraryID: lib.ID, Title: "龙珠改", Path: "/media/anime/龙珠改/Season 1/ep1.mkv", SeasonNum: 1, EpisodeNum: 1, TMDbID: 61709},
{Base: model.Base{ID: "local-2"}, LibraryID: lib.ID, Title: "龙珠改", Path: "/media/anime/龙珠改/Season 1/ep2.mkv", SeasonNum: 1, EpisodeNum: 2, TMDbID: 61709},
}
if err := db.Create(&rows).Error; err != nil {
t.Fatal(err)
}
rawConfig, _ := json.Marshal(map[string]string{"url": upstream.URL, "token": "fake-token"})
acct := model.StrmAccount{Base: model.Base{ID: "account"}, Name: "Emby", Provider: model.StrmProviderEmbyRemote, Config: string(rawConfig), Enabled: true}
if err := repos.StrmAccount.Create(t.Context(), &acct); err != nil {
t.Fatal(err)
}
expectedIDs := map[string]string{lib.ID: "local-1"}
for _, suffix := range []string{"a", "b"} {
mount := model.EmbyMount{Base: model.Base{ID: "mount-" + suffix}, AccountID: acct.ID, RemoteViewID: "view-" + suffix, RemoteViewName: "番剧" + suffix, CollectionType: "tvshows", Enabled: true}
if err := repos.EmbyMount.Create(t.Context(), &mount); err != nil {
t.Fatal(err)
}
expectedIDs[service.EncodeEmbyRemoteID(mount.ID, mount.RemoteViewID)] = service.EncodeEmbyRemoteID(mount.ID, "same-series")
}
svc := &service.Container{
Repo: repos,
Media: service.NewMediaService(&config.Config{}, zap.NewNop(), repos),
EmbyRemote: service.NewEmbyRemoteService(&config.Config{}, zap.NewNop(), repos, service.NewCryptoService("", zap.NewNop())),
}
request := func(params string) ([]model.Media, int) {
t.Helper()
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Set(middleware.CtxUserID, "admin")
c.Set(middleware.CtxUserRole, "admin")
c.Request = httptest.NewRequest(http.MethodGet, "/api/media?q=龙珠改&group_series=1&"+params, nil)
searchMediaHandler(svc)(c)
if w.Code != http.StatusOK {
t.Fatalf("search status=%d, body=%s", w.Code, w.Body.String())
}
var response struct {
Items []model.Media `json:"items"`
Total int `json:"total"`
}
if err := json.Unmarshal(w.Body.Bytes(), &response); err != nil {
t.Fatal(err)
}
return response.Items, response.Total
}
items, _ := request("limit=8")
if len(items) != len(expectedIDs) {
t.Fatalf("got %d results, want one local series and two remote series: %#v", len(items), items)
}
seen := make(map[string]bool)
for page := 1; page <= len(expectedIDs); page++ {
items, total := request(fmt.Sprintf("page=%d&page_size=1", page))
if total != len(expectedIDs) || len(items) != 1 {
t.Fatalf("page %d: total=%d items=%#v, want total=3 and one item", page, total, items)
}
item := items[0]
if expectedIDs[item.DisplayLibraryID] != item.ID || seen[item.DisplayLibraryID] || item.DisplayLibraryName == "" {
t.Fatalf("page %d lost or repeated a library's series identity: %#v", page, item)
}
seen[item.DisplayLibraryID] = true
}
remoteLibraryID := service.EncodeEmbyRemoteID("mount-a", "view-a")
items, total := request("page=1&page_size=8&library_id=" + url.QueryEscape(remoteLibraryID) + "&year_min=2020&genre=Science+Fiction")
if total != 1 || len(items) != 1 || items[0].DisplayLibraryID != remoteLibraryID {
t.Fatalf("remote library/year/genre filters lost results: total=%d items=%#v", total, items)
}
}
+7 -2
View File
@@ -26,7 +26,9 @@ import (
)
func TestQueueLibraryRootScanImportsNewLibraryMedia(t *testing.T) {
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
// The scanner and polling loop use separate connections; :memory: gives
// each connection its own empty database.
db, err := gorm.Open(sqlite.Open(filepath.Join(t.TempDir(), "scan.db")+"?_pragma=journal_mode(WAL)&_pragma=busy_timeout(5000)"), &gorm.Config{})
if err != nil {
t.Fatal(err)
}
@@ -557,7 +559,10 @@ func TestSearchMediaHandlerIncludesEmbyRemote(t *testing.T) {
}))
defer server.Close()
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
// 唯一库名 + cache=shared:远程搜索与本地查询会并发读库,
// 而 :memory: 下每个连接各自一个空库,并发连接会读到「no such table」。
dsn := fmt.Sprintf("file:%s?mode=memory&cache=shared", strings.ReplaceAll(t.Name(), "/", "_"))
db, err := gorm.Open(sqlite.Open(dsn), &gorm.Config{})
if err != nil {
t.Fatal(err)
}
+395
View File
@@ -0,0 +1,395 @@
// Package handler — 移动端 /api/v1 认证端点。
package handler
import (
"context"
"errors"
"net/http"
"strings"
"time"
"github.com/gin-gonic/gin"
"github.com/golang-jwt/jwt/v5"
"go.uber.org/zap"
"github.com/truewhile/MeBox/internal/middleware"
"github.com/truewhile/MeBox/internal/model"
"github.com/truewhile/MeBox/internal/repository"
"github.com/truewhile/MeBox/internal/service"
)
// mobileUserPayload 是 v1 返回的用户摘要。这里显式挑选字段,避免把仅服务端
// 使用的偏好和内部状态带进新契约。
type mobileUserPayload struct {
ID string `json:"id"`
Username string `json:"username"`
Nickname string `json:"nickname,omitempty"`
Role string `json:"role"`
Tier string `json:"tier"`
IsActive bool `json:"is_active"`
ForcePasswordReset bool `json:"force_password_reset"`
ExpiresAt *time.Time `json:"expires_at,omitempty"`
}
func toMobileUserPayload(u *model.User) *mobileUserPayload {
if u == nil {
return nil
}
return &mobileUserPayload{
ID: u.ID,
Username: u.Username,
Nickname: u.Nickname,
Role: u.Role,
Tier: u.Tier,
IsActive: u.IsActive,
ForcePasswordReset: u.ForcePasswordReset,
ExpiresAt: u.ExpiredAt,
}
}
type mobileSessionPayload struct {
AuthSessionID string `json:"auth_session_id"`
DeviceID string `json:"device_id"`
DeviceName string `json:"device_name,omitempty"`
ClientName string `json:"client_name,omitempty"`
ClientVersion string `json:"client_version,omitempty"`
CreatedAt time.Time `json:"created_at"`
LastUsedAt time.Time `json:"last_used_at"`
ExpiresAt time.Time `json:"expires_at"`
}
func toMobileSessionPayload(s *model.AuthSession) *mobileSessionPayload {
if s == nil {
return nil
}
return &mobileSessionPayload{
AuthSessionID: s.ID,
DeviceID: s.DeviceID,
DeviceName: s.DeviceName,
ClientName: s.ClientName,
ClientVersion: s.ClientVersion,
CreatedAt: s.CreatedAt,
LastUsedAt: s.LastUsedAt,
ExpiresAt: s.ExpiresAt,
}
}
type mobileLoginRequest struct {
Username string `json:"username" binding:"required"`
Password string `json:"password" binding:"required"`
DeviceID string `json:"device_id" binding:"required"`
DeviceName string `json:"device_name"`
ClientName string `json:"client_name"`
ClientVersion string `json:"client_version"`
}
type mobileRefreshRequest struct {
RefreshToken string `json:"refresh_token" binding:"required"`
}
// mobileAuthRequired 是 v1 专用认证中间件:要求有效 JWT + 有效设备会话,
// 并统一输出 v1 错误契约。
func mobileAuthRequired(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
raw := middleware.ExtractToken(c)
if raw == "" {
writeMobileError(c, http.StatusUnauthorized, mobileCodeInvalidCredentials,
"access token required", false, 0)
return
}
claims, err := svc.Token.ValidateAccessToken(raw)
if err != nil {
if errors.Is(err, jwt.ErrTokenExpired) {
writeMobileError(c, http.StatusUnauthorized, mobileCodeTokenExpired,
"access token expired", false, 0)
return
}
writeMobileError(c, http.StatusUnauthorized, mobileCodeInvalidCredentials,
"invalid access token", false, 0)
return
}
if strings.TrimSpace(claims.Purpose) != "" {
writeMobileError(c, http.StatusForbidden, mobileCodePermissionDenied,
"token scope denied for this endpoint", false, 0)
return
}
sessionID := strings.TrimSpace(claims.Sid)
if sessionID == "" {
// v1 客户端必须使用登录时创建的会话;旧网页令牌不进入移动端契约。
writeMobileError(c, http.StatusUnauthorized, mobileCodeInvalidCredentials,
"auth session required; sign in again", false, 0)
return
}
session, err := svc.AuthSessions.Validate(c.Request.Context(), sessionID, claims.UserID)
if err != nil {
if service.IsTransientDatabaseLock(err) {
writeMobileError(c, http.StatusServiceUnavailable, mobileCodeServiceUnavailable,
"auth session check temporarily unavailable", true, 2)
return
}
writeMobileError(c, http.StatusUnauthorized, mobileCodeSessionRevoked,
"auth session revoked or expired", false, 0)
return
}
c.Set(middleware.CtxUserID, claims.UserID)
c.Set(middleware.CtxUserRole, claims.Role)
c.Set(middleware.CtxUserTier, claims.Tier)
c.Set(middleware.CtxAuthSessionID, sessionID)
c.Set(mobileCtxAuthSession, session)
// 每个请求写一次 last_used_at 会在 SQLite 上造成不必要的写锁竞争;
// 一分钟内的重复请求不更新,足以支撑设备列表的“最近活动”。
if time.Since(session.LastUsedAt) > time.Minute {
svc.AuthSessions.Touch(c.Request.Context(), sessionID)
}
c.Next()
}
}
// mobileLogoutAuthRequired 只校验 JWT 与 sid,不要求会话仍然有效。
// 退出必须幂等:同一个令牌重复调用 logout 也要返回成功,而不是在第二次
// 被会话校验拦成 401。
func mobileLogoutAuthRequired(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
raw := middleware.ExtractToken(c)
if raw == "" {
writeMobileError(c, http.StatusUnauthorized, mobileCodeInvalidCredentials,
"access token required", false, 0)
return
}
claims, err := svc.Token.ValidateAccessToken(raw)
if err != nil {
if errors.Is(err, jwt.ErrTokenExpired) {
writeMobileError(c, http.StatusUnauthorized, mobileCodeTokenExpired,
"access token expired", false, 0)
return
}
writeMobileError(c, http.StatusUnauthorized, mobileCodeInvalidCredentials,
"invalid access token", false, 0)
return
}
sessionID := strings.TrimSpace(claims.Sid)
if sessionID == "" {
writeMobileError(c, http.StatusUnauthorized, mobileCodeInvalidCredentials,
"auth session required; sign in again", false, 0)
return
}
c.Set(middleware.CtxUserID, claims.UserID)
c.Set(middleware.CtxAuthSessionID, sessionID)
c.Next()
}
}
func mobileLoginHandler(svc *service.Container, log *zap.Logger) gin.HandlerFunc {
return func(c *gin.Context) {
var req mobileLoginRequest
if err := c.ShouldBindJSON(&req); err != nil {
writeMobileError(c, http.StatusBadRequest, mobileCodeInvalidArgument,
"username, password and device_id are required", false, 0)
return
}
req.Username = strings.TrimSpace(req.Username)
req.DeviceID = strings.TrimSpace(req.DeviceID)
if req.Username == "" || req.DeviceID == "" || len(req.DeviceID) > 128 {
writeMobileError(c, http.StatusBadRequest, mobileCodeInvalidArgument,
"invalid username or device_id", false, 0)
return
}
ctx := c.Request.Context()
user, err := svc.Auth.Authenticate(ctx, req.Username, req.Password)
if err != nil {
writeMobileAuthError(c, log, err)
return
}
deviceName := strings.TrimSpace(req.DeviceName)
clientName := strings.TrimSpace(req.ClientName)
if clientName == "" {
clientName = "MeBox Android"
}
session, err := svc.AuthSessions.Create(ctx, service.CreateAuthSessionInput{
UserID: user.ID,
DeviceID: req.DeviceID,
DeviceName: deviceName,
ClientName: clientName,
ClientVersion: req.ClientVersion,
})
if err != nil {
if isTransientServiceError(err) {
writeMobileError(c, http.StatusServiceUnavailable, mobileCodeServiceUnavailable,
"cannot create auth session right now", true, 2)
return
}
writeMobileError(c, http.StatusBadRequest, mobileCodeInvalidArgument,
"invalid device information", false, 0)
return
}
tokens, err := svc.Token.IssuePairForSession(ctx, user.ID, user.Role, user.Tier, session.ID)
if err != nil {
// 令牌签发失败时回收会话,避免留下永远无法使用的会话记录。
_ = svc.AuthSessions.Revoke(ctx, session.ID)
writeMobileAuthError(c, log, err)
return
}
if svc.Device != nil {
svc.Device.RecordLogin(ctx, user.ID, session.DeviceID, deviceName, clientName, c.ClientIP())
}
if svc.Audit != nil {
svc.Audit.RecordBestEffort(user.ID, "auth.mobile.login", user.Username, c.ClientIP(), "")
}
c.JSON(http.StatusOK, gin.H{
"auth_session_id": session.ID,
"user": toMobileUserPayload(user),
"tokens": tokens,
})
}
}
func mobileRefreshHandler(svc *service.Container, log *zap.Logger) gin.HandlerFunc {
return func(c *gin.Context) {
var req mobileRefreshRequest
if err := c.ShouldBindJSON(&req); err != nil {
writeMobileError(c, http.StatusBadRequest, mobileCodeInvalidArgument,
"refresh_token required", false, 0)
return
}
ctx := c.Request.Context()
rt, err := svc.Repo.RefreshToken.FindByHash(ctx, repository.HashToken(req.RefreshToken))
if err != nil {
if isTransientServiceError(err) {
writeMobileError(c, http.StatusServiceUnavailable, mobileCodeServiceUnavailable,
"token store temporarily unavailable", true, 2)
return
}
writeMobileAuthError(c, log, err)
return
}
// 只有 v1 登录签发的刷新令牌才带会话;旧网页令牌必须重新登录。
// 注意这里不能在 rt.Revoked 时提前返回:轮换宽限期内的幂等复用
// 由 TokenService 判定。
if rt == nil || strings.TrimSpace(rt.AuthSessionID) == "" {
writeMobileError(c, http.StatusUnauthorized, mobileCodeInvalidCredentials,
"refresh token is not bound to a device session; sign in again", false, 0)
return
}
if _, err := svc.AuthSessions.Validate(ctx, rt.AuthSessionID, rt.UserID); err != nil {
if isTransientServiceError(err) {
writeMobileError(c, http.StatusServiceUnavailable, mobileCodeServiceUnavailable,
"auth session check temporarily unavailable", true, 2)
return
}
writeMobileError(c, http.StatusUnauthorized, mobileCodeSessionRevoked,
"auth session revoked or expired", false, 0)
return
}
tokens, err := svc.Auth.RefreshTokens(ctx, req.RefreshToken)
if err != nil {
writeMobileAuthError(c, log, err)
return
}
user, err := svc.Repo.User.FindByID(ctx, rt.UserID)
if err != nil {
writeMobileAuthError(c, log, err)
return
}
if user == nil {
writeMobileError(c, http.StatusUnauthorized, mobileCodeInvalidCredentials,
"user not found", false, 0)
return
}
c.JSON(http.StatusOK, gin.H{
"auth_session_id": rt.AuthSessionID,
"user": toMobileUserPayload(user),
"tokens": tokens,
})
}
}
func mobileSessionHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
ctx := c.Request.Context()
userID := c.GetString(middleware.CtxUserID)
user, err := svc.Repo.User.FindByID(ctx, userID)
if err != nil {
writeMobileAuthError(c, svc.Log, err)
return
}
if user == nil {
writeMobileError(c, http.StatusUnauthorized, mobileCodeInvalidCredentials,
"user not found", false, 0)
return
}
session, _ := c.Get(mobileCtxAuthSession)
sessionModel, _ := session.(*model.AuthSession)
c.JSON(http.StatusOK, gin.H{
"auth_session_id": c.GetString(middleware.CtxAuthSessionID),
"user": toMobileUserPayload(user),
"session": toMobileSessionPayload(sessionModel),
})
}
}
func mobileLogoutHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
sessionID := c.GetString(middleware.CtxAuthSessionID)
if err := svc.AuthSessions.Revoke(c.Request.Context(), sessionID); err != nil {
if isTransientServiceError(err) {
writeMobileError(c, http.StatusServiceUnavailable, mobileCodeServiceUnavailable,
"cannot revoke session right now", true, 2)
return
}
writeMobileError(c, http.StatusInternalServerError, mobileCodeServiceUnavailable,
"logout failed", false, 0)
return
}
c.JSON(http.StatusOK, gin.H{"status": "ok"})
}
}
// writeMobileAuthError 把 service 层错误映射为稳定错误码。
// 临时性存储/基础设施错误必须返回可重试的 503,不能伪装成凭证失效。
func writeMobileAuthError(c *gin.Context, log *zap.Logger, err error) {
switch {
case errors.Is(err, service.ErrInvalidCredentials), errors.Is(err, service.ErrInvalidRefreshToken):
writeMobileError(c, http.StatusUnauthorized, mobileCodeInvalidCredentials,
"invalid username, password or refresh token", false, 0)
case errors.Is(err, service.ErrTokenExpired):
writeMobileError(c, http.StatusUnauthorized, mobileCodeTokenExpired,
"refresh token expired", false, 0)
case errors.Is(err, service.ErrTokenRevoked),
errors.Is(err, service.ErrAuthSessionRevoked),
errors.Is(err, service.ErrAuthSessionExpired),
errors.Is(err, service.ErrAuthSessionRequired):
writeMobileError(c, http.StatusUnauthorized, mobileCodeSessionRevoked,
"auth session revoked or expired", false, 0)
case errors.Is(err, service.ErrUserInactive):
writeMobileError(c, http.StatusForbidden, mobileCodeAccountDisabled,
"account is disabled", false, 0)
case errors.Is(err, service.ErrUserExpired):
writeMobileError(c, http.StatusForbidden, mobileCodeAccountExpired,
"account has expired", false, 0)
case isTransientServiceError(err):
writeMobileError(c, http.StatusServiceUnavailable, mobileCodeServiceUnavailable,
"service temporarily unavailable, please retry", true, 2)
default:
if log != nil {
log.Warn("mobile auth request failed", zap.Error(err))
}
writeMobileError(c, http.StatusServiceUnavailable, mobileCodeServiceUnavailable,
"service temporarily unavailable, please retry", true, 2)
}
}
func isTransientServiceError(err error) bool {
if err == nil {
return false
}
if service.IsTransientDatabaseLock(err) {
return true
}
if errors.Is(err, context.DeadlineExceeded) || errors.Is(err, context.Canceled) {
return true
}
return false
}
+434
View File
@@ -0,0 +1,434 @@
package handler
import (
"bytes"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"time"
"github.com/gin-gonic/gin"
"github.com/glebarez/sqlite"
"go.uber.org/zap"
"golang.org/x/crypto/bcrypt"
"gorm.io/gorm"
"gorm.io/gorm/logger"
"github.com/truewhile/MeBox/internal/config"
"github.com/truewhile/MeBox/internal/model"
"github.com/truewhile/MeBox/internal/repository"
"github.com/truewhile/MeBox/internal/service"
)
const mobileTestSecret = "mobile-test-secret"
func newMobileTestEnv(t *testing.T) (*gin.Engine, *service.Container) {
t.Helper()
gin.SetMode(gin.TestMode)
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{
Logger: logger.Default.LogMode(logger.Silent),
})
if err != nil {
t.Fatal(err)
}
sqlDB, err := db.DB()
if err != nil {
t.Fatal(err)
}
// :memory: 只有单连接能看到同一份数据;handler 测试足够,且避免
// shared cache 名字在并行测试间串库。
sqlDB.SetMaxOpenConns(1)
t.Cleanup(func() { _ = sqlDB.Close() })
if err := db.AutoMigrate(
&model.User{}, &model.RefreshToken{}, &model.AuthSession{}, &model.Setting{},
&model.UserDevice{}, &model.UserPermission{},
); err != nil {
t.Fatal(err)
}
repos := repository.New(db)
cfg := &config.Config{Secrets: config.SecretsConfig{JWTSecret: mobileTestSecret}}
svc := service.NewWithVersion(cfg, zap.NewNop(), repos, "MeBox-test")
router := gin.New()
Register(router, cfg, zap.NewNop(), svc)
return router, svc
}
func seedMobileUser(t *testing.T, svc *service.Container, username, password string) *model.User {
t.Helper()
hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.MinCost)
if err != nil {
t.Fatal(err)
}
u := &model.User{Username: username, PasswordHash: string(hash), Role: "user", Tier: "free", IsActive: true}
if err := svc.Repo.User.Create(t.Context(), u); err != nil {
t.Fatal(err)
}
return u
}
func mobileJSONRequest(t *testing.T, router *gin.Engine, method, path, token string, body any, headers map[string]string) *httptest.ResponseRecorder {
t.Helper()
var reader *bytes.Reader
if body != nil {
raw, err := json.Marshal(body)
if err != nil {
t.Fatal(err)
}
reader = bytes.NewReader(raw)
} else {
reader = bytes.NewReader(nil)
}
req := httptest.NewRequest(method, path, reader)
req.Header.Set("Content-Type", "application/json")
if token != "" {
req.Header.Set("Authorization", "Bearer "+token)
}
for k, v := range headers {
req.Header.Set(k, v)
}
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
return w
}
func decodeMobileJSON(t *testing.T, w *httptest.ResponseRecorder, out any) {
t.Helper()
if err := json.Unmarshal(w.Body.Bytes(), out); err != nil {
t.Fatalf("decode body %q: %v", w.Body.String(), err)
}
}
func TestMobileServerInfoAnonymousAndCacheable(t *testing.T) {
router, _ := newMobileTestEnv(t)
w := mobileJSONRequest(t, router, http.MethodGet, "/api/v1/server-info", "", nil, nil)
if w.Code != http.StatusOK {
t.Fatalf("status = %d body=%s", w.Code, w.Body.String())
}
if got := w.Header().Get("Cache-Control"); got != "no-store" {
t.Fatalf("Cache-Control = %q, want no-store", got)
}
var info service.ServerInfo
decodeMobileJSON(t, w, &info)
if info.ServerID == "" || info.ServerVersion != "MeBox-test" || info.APIVersion != "v1" {
t.Fatalf("unexpected server info: %+v", info)
}
if !info.Capabilities.DeviceSessions {
t.Fatalf("device_sessions capability should be true: %+v", info.Capabilities)
}
if info.Capabilities.PlaybackSessions || info.Capabilities.ReaderProgressV1 {
t.Fatalf("unimplemented capabilities must stay false: %+v", info.Capabilities)
}
if info.MinSupportedAppVersionCode < 1 {
t.Fatalf("min_supported_app_version_code = %d", info.MinSupportedAppVersionCode)
}
// 匿名访问不应要求 Authorization,也不应记录 cookie。
if len(w.Result().Cookies()) != 0 {
t.Fatalf("server-info should not set cookies")
}
}
func TestMobileAuthLifecycle(t *testing.T) {
router, svc := newMobileTestEnv(t)
user := seedMobileUser(t, svc, "alice", "secret-123")
// 1. 错误密码:401 + 稳定错误码 + request_id。
w := mobileJSONRequest(t, router, http.MethodPost, "/api/v1/auth/login", "", map[string]any{
"username": "alice", "password": "wrong", "device_id": "device-1",
}, map[string]string{"X-Request-ID": "mobile-test-0001"})
if w.Code != http.StatusUnauthorized {
t.Fatalf("wrong password status = %d body=%s", w.Code, w.Body.String())
}
var authErr mobileErrorBody
decodeMobileJSON(t, w, &authErr)
if authErr.Code != mobileCodeInvalidCredentials || authErr.RequestID != "mobile-test-0001" {
t.Fatalf("unexpected auth error: %+v", authErr)
}
if authErr.Retryable {
t.Fatalf("invalid credentials must not be retryable: %+v", authErr)
}
// 2. 缺少 device_id:400 INVALID_ARGUMENT。
w = mobileJSONRequest(t, router, http.MethodPost, "/api/v1/auth/login", "", map[string]any{
"username": "alice", "password": "secret-123",
}, nil)
if w.Code != http.StatusBadRequest {
t.Fatalf("missing device id status = %d body=%s", w.Code, w.Body.String())
}
decodeMobileJSON(t, w, &authErr)
if authErr.Code != mobileCodeInvalidArgument {
t.Fatalf("code = %q, want %q", authErr.Code, mobileCodeInvalidArgument)
}
// 3. 正常登录。
w = mobileJSONRequest(t, router, http.MethodPost, "/api/v1/auth/login", "", map[string]any{
"username": "alice", "password": "secret-123", "device_id": "device-1",
"device_name": "Pixel 9", "client_name": "MeBox Android", "client_version": "1.0.0",
}, nil)
if w.Code != http.StatusOK {
t.Fatalf("login status = %d body=%s", w.Code, w.Body.String())
}
var loginResp struct {
AuthSessionID string `json:"auth_session_id"`
User mobileUserPayload `json:"user"`
Tokens service.TokenPair `json:"tokens"`
}
decodeMobileJSON(t, w, &loginResp)
if loginResp.AuthSessionID == "" || loginResp.Tokens.AccessToken == "" || loginResp.Tokens.RefreshToken == "" {
t.Fatalf("incomplete login response: %+v", loginResp)
}
if loginResp.User.ID != user.ID || loginResp.User.Username != "alice" {
t.Fatalf("unexpected user payload: %+v", loginResp.User)
}
claims, err := svc.Token.ValidateAccessToken(loginResp.Tokens.AccessToken)
if err != nil {
t.Fatalf("validate access token: %v", err)
}
if claims.Sid != loginResp.AuthSessionID {
t.Fatalf("token sid = %q, want %q", claims.Sid, loginResp.AuthSessionID)
}
rt, err := svc.Repo.RefreshToken.FindByHash(t.Context(), repository.HashToken(loginResp.Tokens.RefreshToken))
if err != nil || rt == nil || rt.AuthSessionID != loginResp.AuthSessionID {
t.Fatalf("refresh token not bound to session: %+v err=%v", rt, err)
}
// 4. 会话读取。
w = mobileJSONRequest(t, router, http.MethodGet, "/api/v1/auth/session", loginResp.Tokens.AccessToken, nil, nil)
if w.Code != http.StatusOK {
t.Fatalf("session status = %d body=%s", w.Code, w.Body.String())
}
var sessionResp struct {
AuthSessionID string `json:"auth_session_id"`
User mobileUserPayload `json:"user"`
Session *mobileSessionPayload `json:"session"`
}
decodeMobileJSON(t, w, &sessionResp)
if sessionResp.AuthSessionID != loginResp.AuthSessionID || sessionResp.Session == nil {
t.Fatalf("unexpected session response: %+v", sessionResp)
}
if sessionResp.Session.DeviceID != "device-1" || sessionResp.Session.DeviceName != "Pixel 9" {
t.Fatalf("unexpected session payload: %+v", sessionResp.Session)
}
// 5. 刷新:新令牌继续归属同一会话,旧刷新令牌被撤销。
w = mobileJSONRequest(t, router, http.MethodPost, "/api/v1/auth/refresh", "", map[string]any{
"refresh_token": loginResp.Tokens.RefreshToken,
}, nil)
if w.Code != http.StatusOK {
t.Fatalf("refresh status = %d body=%s", w.Code, w.Body.String())
}
var refreshResp struct {
AuthSessionID string `json:"auth_session_id"`
Tokens service.TokenPair `json:"tokens"`
}
decodeMobileJSON(t, w, &refreshResp)
if refreshResp.AuthSessionID != loginResp.AuthSessionID || refreshResp.Tokens.AccessToken == "" {
t.Fatalf("unexpected refresh response: %+v", refreshResp)
}
if refreshResp.Tokens.RefreshToken == loginResp.Tokens.RefreshToken {
t.Fatal("refresh token should rotate")
}
oldRT, err := svc.Repo.RefreshToken.FindByHash(t.Context(), repository.HashToken(loginResp.Tokens.RefreshToken))
if err != nil || oldRT == nil || !oldRT.Revoked {
t.Fatalf("old refresh token should be revoked: %+v err=%v", oldRT, err)
}
// 6. 用刷新后的访问令牌访问旧 API,确认 sid 会话校验在旧入口同样生效。
w = mobileJSONRequest(t, router, http.MethodGet, "/api/me", refreshResp.Tokens.AccessToken, nil, nil)
if w.Code != http.StatusOK {
t.Fatalf("old api with fresh token status = %d body=%s", w.Code, w.Body.String())
}
// 7. 退出:成功且可重复。
for i := 0; i < 2; i++ {
w = mobileJSONRequest(t, router, http.MethodPost, "/api/v1/auth/logout", refreshResp.Tokens.AccessToken, nil, nil)
if w.Code != http.StatusOK {
t.Fatalf("logout #%d status = %d body=%s", i+1, w.Code, w.Body.String())
}
}
// 8. 会话撤销后:v1 会话接口 401 SESSION_REVOKED。
w = mobileJSONRequest(t, router, http.MethodGet, "/api/v1/auth/session", refreshResp.Tokens.AccessToken, nil, nil)
if w.Code != http.StatusUnauthorized {
t.Fatalf("session after logout status = %d body=%s", w.Code, w.Body.String())
}
decodeMobileJSON(t, w, &authErr)
if authErr.Code != mobileCodeSessionRevoked {
t.Fatalf("session after logout code = %q, want %q", authErr.Code, mobileCodeSessionRevoked)
}
// 9. 被踢后的未过期 JWT 不能绕过旧 API。
w = mobileJSONRequest(t, router, http.MethodGet, "/api/me", refreshResp.Tokens.AccessToken, nil, nil)
if w.Code != http.StatusUnauthorized {
t.Fatalf("old api after logout status = %d body=%s", w.Code, w.Body.String())
}
// 10. 刷新令牌随会话一起撤销。
w = mobileJSONRequest(t, router, http.MethodPost, "/api/v1/auth/refresh", "", map[string]any{
"refresh_token": refreshResp.Tokens.RefreshToken,
}, nil)
if w.Code != http.StatusUnauthorized {
t.Fatalf("refresh after logout status = %d body=%s", w.Code, w.Body.String())
}
decodeMobileJSON(t, w, &authErr)
if authErr.Code != mobileCodeSessionRevoked {
t.Fatalf("refresh after logout code = %q, want %q", authErr.Code, mobileCodeSessionRevoked)
}
}
func TestMobileV1RejectsLegacyTokenWithoutSid(t *testing.T) {
router, svc := newMobileTestEnv(t)
user := seedMobileUser(t, svc, "legacy", "secret-123")
pair, err := svc.Token.IssuePair(t.Context(), user.ID, user.Role, user.Tier)
if err != nil {
t.Fatal(err)
}
if claims, err := svc.Token.ValidateAccessToken(pair.AccessToken); err != nil || claims.Sid != "" {
t.Fatalf("legacy token unexpectedly has sid: %+v err=%v", claims, err)
}
w := mobileJSONRequest(t, router, http.MethodGet, "/api/v1/auth/session", pair.AccessToken, nil, nil)
if w.Code != http.StatusUnauthorized {
t.Fatalf("legacy token status = %d body=%s", w.Code, w.Body.String())
}
var body mobileErrorBody
decodeMobileJSON(t, w, &body)
if body.Code != mobileCodeInvalidCredentials {
t.Fatalf("legacy token code = %q, want %q", body.Code, mobileCodeInvalidCredentials)
}
// 旧令牌仍可访问旧 API(兼容路径不能被新协议破坏)。
w = mobileJSONRequest(t, router, http.MethodGet, "/api/me", pair.AccessToken, nil, nil)
if w.Code != http.StatusOK {
t.Fatalf("legacy token on old api status = %d body=%s", w.Code, w.Body.String())
}
}
func TestMobileRefreshRejectsUnboundLegacyToken(t *testing.T) {
router, svc := newMobileTestEnv(t)
user := seedMobileUser(t, svc, "bob", "secret-123")
pair, err := svc.Token.IssuePair(t.Context(), user.ID, user.Role, user.Tier)
if err != nil {
t.Fatal(err)
}
w := mobileJSONRequest(t, router, http.MethodPost, "/api/v1/auth/refresh", "", map[string]any{
"refresh_token": pair.RefreshToken,
}, nil)
if w.Code != http.StatusUnauthorized {
t.Fatalf("legacy refresh status = %d body=%s", w.Code, w.Body.String())
}
var body mobileErrorBody
decodeMobileJSON(t, w, &body)
if body.Code != mobileCodeInvalidCredentials {
t.Fatalf("legacy refresh code = %q, want %q", body.Code, mobileCodeInvalidCredentials)
}
}
func TestMobileDeviceKickRevokesSession(t *testing.T) {
router, svc := newMobileTestEnv(t)
user := seedMobileUser(t, svc, "carol", "secret-123")
w := mobileJSONRequest(t, router, http.MethodPost, "/api/v1/auth/login", "", map[string]any{
"username": "carol", "password": "secret-123", "device_id": "device-kick",
"device_name": "Kick Me", "client_name": "MeBox Android",
}, nil)
if w.Code != http.StatusOK {
t.Fatalf("login status = %d body=%s", w.Code, w.Body.String())
}
var loginResp struct {
AuthSessionID string `json:"auth_session_id"`
Tokens service.TokenPair `json:"tokens"`
}
decodeMobileJSON(t, w, &loginResp)
if err := svc.Device.KickDevice(t.Context(), user.ID, "device-kick"); err != nil {
t.Fatalf("kick device: %v", err)
}
session, err := svc.Repo.AuthSession.FindByID(t.Context(), loginResp.AuthSessionID)
if err != nil || session == nil || !session.Revoked {
t.Fatalf("session should be revoked after device kick: %+v err=%v", session, err)
}
w = mobileJSONRequest(t, router, http.MethodGet, "/api/v1/auth/session", loginResp.Tokens.AccessToken, nil, nil)
if w.Code != http.StatusUnauthorized {
t.Fatalf("session after kick status = %d body=%s", w.Code, w.Body.String())
}
}
func TestMobileKickOneDeviceKeepsOtherSessions(t *testing.T) {
router, svc := newMobileTestEnv(t)
seedMobileUser(t, svc, "erin", "secret-123")
login := func(deviceID string) string {
w := mobileJSONRequest(t, router, http.MethodPost, "/api/v1/auth/login", "", map[string]any{
"username": "erin", "password": "secret-123", "device_id": deviceID,
"device_name": deviceID, "client_name": "MeBox Android",
}, nil)
if w.Code != http.StatusOK {
t.Fatalf("login %s status = %d body=%s", deviceID, w.Code, w.Body.String())
}
var resp struct {
Tokens service.TokenPair `json:"tokens"`
}
decodeMobileJSON(t, w, &resp)
return resp.Tokens.AccessToken
}
tokenA := login("device-a")
tokenB := login("device-b")
if err := svc.Device.KickDevice(t.Context(), svcUserID(t, svc, "erin"), "device-a"); err != nil {
t.Fatalf("kick device a: %v", err)
}
w := mobileJSONRequest(t, router, http.MethodGet, "/api/v1/auth/session", tokenA, nil, nil)
if w.Code != http.StatusUnauthorized {
t.Fatalf("kicked session status = %d body=%s", w.Code, w.Body.String())
}
w = mobileJSONRequest(t, router, http.MethodGet, "/api/v1/auth/session", tokenB, nil, nil)
if w.Code != http.StatusOK {
t.Fatalf("other device status = %d body=%s", w.Code, w.Body.String())
}
}
func svcUserID(t *testing.T, svc *service.Container, username string) string {
t.Helper()
u, err := svc.Repo.User.FindByUsername(t.Context(), username)
if err != nil || u == nil {
t.Fatalf("find user %s: %v", username, err)
}
return u.ID
}
func TestMobileAuthDefaultsAndSessionExpiryShape(t *testing.T) {
router, svc := newMobileTestEnv(t)
seedMobileUser(t, svc, "dave", "secret-123")
w := mobileJSONRequest(t, router, http.MethodPost, "/api/v1/auth/login", "", map[string]any{
"username": "dave", "password": "secret-123", "device_id": "device-default",
}, nil)
if w.Code != http.StatusOK {
t.Fatalf("login status = %d body=%s", w.Code, w.Body.String())
}
var loginResp struct {
AuthSessionID string `json:"auth_session_id"`
Tokens service.TokenPair `json:"tokens"`
}
decodeMobileJSON(t, w, &loginResp)
session, err := svc.Repo.AuthSession.FindByID(t.Context(), loginResp.AuthSessionID)
if err != nil || session == nil {
t.Fatalf("load session: %v", err)
}
if session.ClientName != "MeBox Android" {
t.Fatalf("client_name default = %q", session.ClientName)
}
if session.ExpiresAt.Before(time.Now()) {
t.Fatalf("session expiry in the past: %v", session.ExpiresAt)
}
if loginResp.Tokens.TokenType != "Bearer" || loginResp.Tokens.ExpiresIn <= 0 {
t.Fatalf("unexpected token metadata: %+v", loginResp.Tokens)
}
}
+129
View File
@@ -0,0 +1,129 @@
package handler
import (
"net/http"
"strconv"
"strings"
"unicode"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
"github.com/truewhile/MeBox/internal/middleware"
)
// 移动端 v1 错误码。新增错误码必须同步 OpenAPI 与 App 的 ApiErrorMapper。
const (
mobileCodeInvalidArgument = "INVALID_ARGUMENT"
mobileCodeInvalidCredentials = "INVALID_CREDENTIALS"
mobileCodeTokenExpired = "TOKEN_EXPIRED"
mobileCodeSessionRevoked = "SESSION_REVOKED"
mobileCodeAccountDisabled = "ACCOUNT_DISABLED"
mobileCodeAccountExpired = "ACCOUNT_EXPIRED"
mobileCodePermissionDenied = "PERMISSION_DENIED"
mobileCodeProfilePINRequired = "PROFILE_PIN_REQUIRED"
mobileCodeRateLimited = "RATE_LIMITED"
mobileCodeUpstreamUnavailable = "UPSTREAM_UNAVAILABLE"
mobileCodeTranscodeBusy = "TRANSCODE_BUSY"
mobileCodePlaybackSessionAbsent = "PLAYBACK_SESSION_NOT_FOUND"
mobileCodeResourceExpired = "RESOURCE_EXPIRED"
mobileCodeProgressConflict = "PROGRESS_CONFLICT"
mobileCodeServiceUnavailable = "SERVICE_UNAVAILABLE"
)
const (
mobileCtxRequestID = "mobile_request_id"
mobileCtxAuthSession = "mobile_auth_session"
mobileRequestIDHeader = "X-Request-ID"
)
// mobileErrorBody 是 v1 统一错误响应。字段直接位于顶层,便于客户端按
// code 分流,不需要再判断后端历史包装层。
type mobileErrorBody struct {
Code string `json:"code"`
Message string `json:"message"`
RequestID string `json:"request_id"`
Retryable bool `json:"retryable"`
RetryAfterSec int `json:"retry_after_sec,omitempty"`
Details any `json:"details,omitempty"`
}
// mobileRequestIDMiddleware 为每个 v1 请求绑定可追踪的请求 ID。
// 合法客户端值会沿用(便于 App 与服务端日志对齐),非法或过长则由服务器
// 重新生成;该值仅用于诊断,绝不参与鉴权。
func mobileRequestIDMiddleware() gin.HandlerFunc {
return func(c *gin.Context) {
id := strings.TrimSpace(c.GetHeader(mobileRequestIDHeader))
if !validRequestID(id) {
id = uuid.NewString()
}
c.Set(mobileCtxRequestID, id)
c.Header(mobileRequestIDHeader, id)
c.Next()
}
}
func validRequestID(id string) bool {
if len(id) < 8 || len(id) > 128 {
return false
}
for _, r := range id {
if r > unicode.MaxASCII {
return false
}
if unicode.IsLetter(r) || unicode.IsDigit(r) {
continue
}
switch r {
case '-', '_', '.', ':':
continue
default:
return false
}
}
return true
}
func currentMobileRequestID(c *gin.Context) string {
if c == nil {
return ""
}
if v, ok := c.Get(mobileCtxRequestID); ok {
if s, ok := v.(string); ok && s != "" {
return s
}
}
return ""
}
// writeMobileError 输出统一错误体。request_id 缺失时现场生成,保证所有
// v1 错误都可用于对账,而不是只在注册了中间件的路由上才有。
func writeMobileError(c *gin.Context, status int, code, message string, retryable bool, retryAfterSec int) {
requestID := currentMobileRequestID(c)
if requestID == "" {
requestID = uuid.NewString()
c.Header(mobileRequestIDHeader, requestID)
}
if retryAfterSec > 0 {
c.Header("Retry-After", strconv.Itoa(retryAfterSec))
}
c.AbortWithStatusJSON(status, mobileErrorBody{
Code: code,
Message: message,
RequestID: requestID,
Retryable: retryable,
RetryAfterSec: retryAfterSec,
})
}
// mobileRateLimit 与旧端点共用 RateLimiter,但返回 v1 错误契约和 Retry-After。
func mobileRateLimit(limiter *middleware.RateLimiter) gin.HandlerFunc {
return func(c *gin.Context) {
if !limiter.Allow(c.ClientIP()) {
writeMobileError(c, http.StatusTooManyRequests, mobileCodeRateLimited,
"too many requests, please try again later", true, 60)
return
}
c.Next()
}
}
+81
View File
@@ -0,0 +1,81 @@
package handler
import (
"context"
"errors"
"fmt"
"net/http"
"time"
"github.com/gin-gonic/gin"
"go.uber.org/zap"
"github.com/truewhile/MeBox/internal/middleware"
"github.com/truewhile/MeBox/internal/service"
)
// registerMobileRoutes 挂载 /api/v1 移动端稳定契约。
//
// 既有 /api 与 Emby 入口保持不变;v1 只做适配和会话语义补强,不复制媒体
// 或书源业务逻辑。
func registerMobileRoutes(api *gin.RouterGroup, svc *service.Container, log *zap.Logger) {
if svc == nil || svc.ServerIdentity == nil || svc.AuthSessions == nil {
// 最小容器(例如旧测试桩)没有身份/会话服务时,不暴露不可用的 v1。
return
}
wireAuthSessionValidator(svc)
v1 := api.Group("/v1")
v1.Use(mobileRequestIDMiddleware())
v1.GET("/server-info", mobileServerInfoHandler(svc, log))
authLimiter := middleware.NewRateLimiter(30, time.Minute)
v1.POST("/auth/login", mobileRateLimit(authLimiter), mobileLoginHandler(svc, log))
v1.POST("/auth/refresh", mobileRefreshHandler(svc, log))
authed := v1.Group("/auth")
{
authed.GET("/session", mobileAuthRequired(svc), mobileSessionHandler(svc))
authed.POST("/logout", mobileLogoutAuthRequired(svc), mobileLogoutHandler(svc))
}
}
// wireAuthSessionValidator 让旧 /api 中间件也能校验新令牌的 sid。
// service 层错误在这里映射为 middleware 的两个哨兵,避免包依赖反转。
func wireAuthSessionValidator(svc *service.Container) {
middleware.SetSessionValidator(func(ctx context.Context, sessionID, userID string) error {
if svc == nil || svc.AuthSessions == nil {
return nil
}
_, err := svc.AuthSessions.Validate(ctx, sessionID, userID)
if err == nil {
return nil
}
if errors.Is(err, service.ErrAuthSessionRevoked) ||
errors.Is(err, service.ErrAuthSessionExpired) ||
errors.Is(err, service.ErrAuthSessionRequired) {
return middleware.ErrSessionRevoked
}
return fmt.Errorf("%w: %v", middleware.ErrSessionUnavailable, err)
})
}
func mobileServerInfoHandler(svc *service.Container, log *zap.Logger) gin.HandlerFunc {
return func(c *gin.Context) {
ctx, cancel := context.WithTimeout(c.Request.Context(), 5*time.Second)
defer cancel()
info, err := svc.ServerIdentity.Info(ctx, service.CurrentServerCapabilities(svc))
if err != nil {
if log != nil {
log.Warn("mobile server info failed", zap.Error(err))
}
writeMobileError(c, http.StatusServiceUnavailable, mobileCodeServiceUnavailable,
"server identity temporarily unavailable", true, 2)
return
}
// 身份信息随配置变化且不应被中间缓存:同一地址可能切换部署实例。
c.Header("Cache-Control", "no-store")
c.JSON(http.StatusOK, info)
}
}
+44 -2
View File
@@ -51,11 +51,53 @@ func playbackInfoHandler(svc *service.Container) gin.HandlerFunc {
// 远程 Emby 挂载没有本地文件,不能提供转码地址。STRM 默认直连,直连失败时可走 HLS。
hlsURL = ""
}
c.JSON(http.StatusOK, gin.H{
// 起播路径原本还要各发一次 /playback/{id}/resume 和
// /media/{id}/subtitles。把这两份数据并进播放地址响应后,移动端起播
// 只剩一个请求 —— 跨境链路上每多一条连接就多一次 DNS/TLS 建连,
// 实测单次建连可到数秒。老客户端不读这两个字段,行为不变。
uid, _ := c.Get(middleware.CtxUserID)
resumePayload := gin.H{"position_ms": int64(0), "duration_ms": int64(0), "completed": false}
if svc.Playback != nil {
if row, err := svc.Playback.GetProgress(c.Request.Context(), toString(uid), m.ID); err == nil && row != nil {
resumePayload = gin.H{
"position_ms": row.PositionMs,
"duration_ms": row.DurationMs,
"completed": row.Completed,
}
}
}
// 直连播放只需要外挂字幕;内嵌字幕由播放器从容器里解。
subtitles := []service.SubtitleTrack{}
if svc.Subtitle != nil && !(svc.EmbyRemote != nil && service.IsEmbyRemoteID(m.ID)) {
if discovered, err := svc.Subtitle.DiscoverExternalOnly(c.Request.Context(), m.ID); err == nil && discovered != nil {
subtitles = discovered
}
}
// 能在服务端换链就把最终直链一并返回:客户端直接连 CDN,省掉
// /api/stream 的 302 往返。跨境/高延迟链路上,这一跳等于一次完整的
// DNS + TCP + TLS,实测能到数秒,正好压在起播关键路径上。
// 换链失败、本地文件或需要服务端加头的网盘保持原样,客户端仍走
// stream_url 跳转,行为与改动前一致。
directURL, hasDirectURL := "", false
if svc.Stream != nil {
// `?refresh=1`:客户端手上的直链已失效(CDN 403),要求丢掉缓存
// 重新换链,别把同一条死链再发一次。
if strings.EqualFold(strings.TrimSpace(c.Query("refresh")), "1") {
service.InvalidateMediaLinkCache(m)
}
directURL, hasDirectURL = svc.Stream.ResolveDirectPlayURL(c.Request, m)
}
payload := gin.H{
"media": m,
"stream_url": "/api/stream/" + m.ID + "?token=" + url.QueryEscape(token) + profileQuery,
"hls_url": hlsURL,
})
"resume": resumePayload,
"subtitles": subtitles,
}
if hasDirectURL {
payload["direct_url"] = directURL
}
c.JSON(http.StatusOK, payload)
}
}
+65
View File
@@ -22,6 +22,7 @@ import (
"github.com/truewhile/MeBox/internal/model"
"github.com/truewhile/MeBox/internal/repository"
"github.com/truewhile/MeBox/internal/service"
"github.com/truewhile/MeBox/internal/service/cloud"
)
func TestExternalURLUsesMediaScopedPlaybackToken(t *testing.T) {
@@ -435,6 +436,10 @@ func TestPlaybackInfoForSTRMMediaIncludesHLS(t *testing.T) {
var payload struct {
StreamURL string `json:"stream_url"`
HlsURL string `json:"hls_url"`
// 移动端起播只打这一个接口:resume / subtitles 必须始终存在,
// 哪怕是默认值,否则客户端会退回额外两个请求。
Resume *struct{ PositionMs int64 `json:"position_ms"` } `json:"resume"`
Subtitles *[]struct{} `json:"subtitles"`
}
if err := json.Unmarshal(w.Body.Bytes(), &payload); err != nil {
t.Fatalf("decode: %v", err)
@@ -445,6 +450,66 @@ func TestPlaybackInfoForSTRMMediaIncludesHLS(t *testing.T) {
if payload.HlsURL == "" || !strings.Contains(payload.HlsURL, "/api/hls/media-1/") {
t.Fatalf("expected strm hls_url, got %q", payload.HlsURL)
}
if payload.Resume == nil {
t.Fatalf("playback info must always carry resume (got body=%s)", w.Body.String())
}
if payload.Subtitles == nil {
t.Fatalf("playback info must always carry subtitles (got body=%s)", w.Body.String())
}
}
// 服务端能换到最终直链时,播放地址响应要把直链一并给客户端:移动端起播就能
// 跳过 /api/stream 的 302 往返(跨境链路上等于一次完整 DNS+TCP+TLS)。
func TestPlaybackInfoCarriesPrefetchedDirectURL(t *testing.T) {
router, svc, secret := newPlaybackScopeTestRouter(t)
loginToken := signedTestToken(t, secret)
const direct = "https://cdnfhnfile.115cdn.net/637b/Movie.mkv?t=1&k=sig"
var gotUA string
svc.Stream.SetStrmPlayTargetResolver(func(_ context.Context, _, userAgent string) (*service.StrmPlayResult, error) {
gotUA = userAgent
return &service.StrmPlayResult{
RedirectURL: direct,
Link: &cloud.DirectLink{URL: direct, Headers: map[string]string{"User-Agent": userAgent}},
}, nil
})
if err := svc.Repo.DB.Create(&model.Media{
Base: model.Base{ID: "media-direct"},
LibraryID: "lib-1",
Title: "Direct",
Path: "cloud://cloud115/Movie.mkv",
Container: "strm",
DurationSec: 3600,
STRMURL: "/api/strm/play/cloud115/video.mkv?acct=a1&pickcode=pc1",
}).Error; err != nil {
t.Fatal(err)
}
req := httptest.NewRequest(http.MethodGet, "http://nas.local/api/playback/media-direct/info", nil)
req.Header.Set("Authorization", "Bearer "+loginToken)
req.Header.Set("User-Agent", "MeBox-Android/0.1.0")
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("status = %d body=%s", w.Code, w.Body.String())
}
var payload struct {
DirectURL string `json:"direct_url"`
StreamURL string `json:"stream_url"`
}
if err := json.Unmarshal(w.Body.Bytes(), &payload); err != nil {
t.Fatalf("decode: %v", err)
}
if payload.DirectURL != direct {
t.Fatalf("direct_url = %q, want %q (body=%s)", payload.DirectURL, direct, w.Body.String())
}
if !strings.Contains(payload.StreamURL, "/api/stream/media-direct") {
t.Fatalf("stream_url must stay as the fallback, got %q", payload.StreamURL)
}
// 115 直链绑定换取时的 UA:预置直链必须按播放器的 UA 换,否则播放会 403。
if gotUA != "MeBox-Android/0.1.0" {
t.Fatalf("resolver UA = %q, want the client UA", gotUA)
}
}
func TestSubtitleListOnlyProbesEmbeddedTracksForHLS(t *testing.T) {
+72 -1
View File
@@ -1,13 +1,18 @@
// Package handler — 片头/片尾片段接口。
//
// GET /playback/:id/segments
// GET /playback/:id/segments
// PUT /playback/:id/segments {kind, position_ms, clear}
//
// 单独开一个接口而不是塞进 /media/:id/playback,有两个原因:一是抓取外部数据
// 可能要几秒,不能拖慢决定能否起播的那个请求;二是片段与播放来源(本地 / 云盘 /
// 远程 Emby)无关,独立出来对所有媒体一致。
//
// 写入接口只处理「手动标记」:用户播放时把当前位置标成片头结束 / 片尾开始。
// 外部数据永远由提供方刷新,不接受客户端写入。
package handler
import (
"errors"
"net/http"
"github.com/gin-gonic/gin"
@@ -42,6 +47,72 @@ func playbackSegmentsHandler(svc *service.Container) gin.HandlerFunc {
}
}
// playbackSegmentsMarkRequest 是手动标记的入参。
type playbackSegmentsMarkRequest struct {
// Kind 只接受 intro / credits。
Kind string `json:"kind" binding:"required"`
// PositionMs 是当前播放位置:标片头时表示片头结束点,标片尾时表示片尾开始点。
PositionMs int64 `json:"position_ms"`
// Clear 为 true 时删除该类型的标记。
Clear bool `json:"clear"`
}
// playbackSegmentsMarkHandler 写入或清除一条手动标记,返回更新后的完整片段列表。
//
// 返回整份列表而不是只回 204:播放器需要立刻反映合并后的结果(手动标记优先级
// 最高),避免自己再发一次 GET 造成竞态。
func playbackSegmentsMarkHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
if svc.Segments == nil {
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "segments unavailable"})
return
}
m, err := findMediaForPlaybackEndpoint(c, svc, c.Param("id"))
if err != nil || m == nil || !mediaVisibleForRequest(c, svc, m) {
c.JSON(http.StatusNotFound, gin.H{"error": "media not found"})
return
}
// 远程 Emby 挂载是上游库的投影,本地标记没有可落地的地方。
if service.IsEmbyRemoteID(m.ID) {
c.JSON(http.StatusBadRequest, gin.H{"error": "remote media cannot be marked"})
return
}
var req playbackSegmentsMarkRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid body"})
return
}
err = svc.Segments.SetManualMark(c.Request.Context(), m, service.ManualMarkRequest{
Kind: req.Kind,
PositionMs: req.PositionMs,
Clear: req.Clear,
})
if errors.Is(err, service.ErrInvalidManualMark) {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid skip mark"})
return
}
if err != nil {
if svc.Log != nil {
svc.Log.Warn("save manual skip mark failed",
zap.String("media_id", m.ID), zap.Error(err))
}
c.JSON(http.StatusInternalServerError, gin.H{"error": "save failed"})
return
}
rows, listErr := svc.Segments.ListForPlayback(c.Request.Context(), m)
if listErr != nil {
if svc.Log != nil {
svc.Log.Debug("list media segments after mark failed",
zap.String("media_id", m.ID), zap.Error(listErr))
}
}
c.JSON(http.StatusOK, gin.H{
"segments": service.ToSegmentViews(rows),
"auto_skip": resolveAutoSkipFlag(c, svc),
})
}
}
// resolveAutoSkipFlag reads the「自动跳过片头」switch off whichever profile is
// currently in effect. It reuses selectedPlayProfile so the server agrees with
// the UI about which profile is active (explicit header first, then the user's
+445
View File
@@ -0,0 +1,445 @@
package handler
import (
"fmt"
"net/http"
"net/http/httptest"
"strconv"
"strings"
"sync"
"sync/atomic"
"testing"
"github.com/gin-gonic/gin"
"github.com/truewhile/MeBox/internal/model"
"github.com/truewhile/MeBox/internal/service"
"github.com/truewhile/MeBox/internal/service/reader"
)
// 媒体代理在「上游签名票被拒」时的行为契约。
//
// 背景(真实故障):聆阅·懒人听书源的音频直链是第三方中转现签的腾讯 CDN 票,
// 票被 CDN 判掉时上游回的是**空的 401**(无 Content-Type、无 body)。旧行为把它
// 原样透传给播放器,播放器重试的还是同一个 URL,于是 App 日志里连续 4~8 个
// ERROR_CODE_IO_BAD_HTTP_STATUS (2004),表现为「这本书怎么都播不了」。
//
// 现在的契约:
// - 能换票 → 用新票重拉,直接返回媒体本体(第一个请求就被治好);
// - 换不到 → 502 + 明确文案,而不是把 401 丢给播放器;
// - 非音轨地址(漫画图片防盗链等)→ 维持原样透传,不改变既有行为。
func TestReaderMediaPayloadRejected(t *testing.T) {
cases := []struct {
name string
head string
contentType string
want bool
}{
{"HTML 错误页", "<!DOCTYPE html><html><body>403</body></html>", "text/html; charset=utf-8", true},
{"JSON 报错", `{"code":403,"message":"expired"}`, "application/json", true},
{"数组形态报错", `[{"error":"bad ticket"}]`, "application/json; charset=utf-8", true},
{"纯文本报错", "ticket expired", "text/plain", false},
{"正常 m4a", "\x00\x00\x00\x20ftypmp42", "audio/mp4", false},
{"正常 mp3", "ID3\x04\x00", "audio/mpeg", false},
{"正常图片", "\xff\xd8\xff\xe0", "image/jpeg", false},
{"播放列表", "#EXTM3U\n#EXTINF:10,", "application/vnd.apple.mpegurl", false},
{"空体", "", "text/html", false},
{"HTML 类型但二进制体", "\x00\x00\x00\x20ftypmp42", "text/html", false},
}
for _, c := range cases {
got := readerMediaPayloadRejected([]byte(c.head), c.contentType)
if got != c.want {
t.Errorf("%s: readerMediaPayloadRejected(%q, %q) = %v,期望 %v",
c.name, c.head, c.contentType, got, c.want)
}
}
}
// TestReaderMediaProxyHealsStaleTicket 票被上游拒绝时代理自己换票重拉,
// 第一个请求就返回媒体本体(不必等播放器重试)。
func TestReaderMediaProxyHealsStaleTicket(t *testing.T) {
svc, srv, book := prepareMediaTicketBook(t)
router := mediaRouterForTest(svc)
signed := primeTicketBook(t, svc, book.ID)
// 让中转再签一张新票:章节缓存里那张就此变成「旧票」。
srv.rotate()
code, body := getReaderMedia(t, router, signed)
if code != http.StatusOK {
t.Fatalf("换票重试后 status=%d body=%s", code, body)
}
if !strings.Contains(body, "fake-audio-bytes") {
t.Fatalf("响应体不是媒体: %q", body)
}
if got := srv.contentHits(); got < 2 {
t.Fatalf("应当回源重取章节,实际请求 %d 次", got)
}
}
// TestReaderMediaProxyFailsClearlyWhenRefreshImpossible 换不到票时给 502 + 明确文案,
// 而不是把 401 透传给播放器(那样播放器只会对着必然失败的地址反复重试)。
func TestReaderMediaProxyFailsClearlyWhenRefreshImpossible(t *testing.T) {
svc, srv, book := prepareMediaTicketBook(t)
router := mediaRouterForTest(svc)
signed := primeTicketBook(t, svc, book.ID)
srv.rotate()
srv.stopIssuing()
code, body := getReaderMedia(t, router, signed)
if code != http.StatusBadGateway {
t.Fatalf("换票失败应当返回 502,实际 %d body=%s", code, body)
}
if !strings.Contains(body, "签名失效") {
t.Fatalf("错误文案应说明是音源签名失效: %s", body)
}
}
// TestReaderMediaProxyPassesThroughNonTrackError 漫画图片这类不可换票的地址,
// 上游 4xx 仍按原状态透传(既有行为不变)。
func TestReaderMediaProxyPassesThroughNonTrackError(t *testing.T) {
svc, srv, book := prepareMediaTicketBook(t)
router := mediaRouterForTest(svc)
// 书源域名下的图片地址:不在任何音轨缓存里。
imgURL := srv.URL + "/img/forbidden.jpg"
signed := svc.Reader.ProxyURL(book.ID, imgURL)
code, body := getReaderMedia(t, router, signed)
if code == http.StatusBadGateway || code == http.StatusUnauthorized {
t.Fatalf("非音轨地址不该走换票分支,实际 %d body=%s", code, body)
}
if code != http.StatusForbidden {
t.Fatalf("图片 403 应原样透传,实际 %d body=%s", code, body)
}
if got := srv.contentHits(); got != 0 {
t.Fatalf("图片失败不该触发回源取章节,实际 %d 次", got)
}
}
// TestReaderMediaProxyRejectsNonMediaPayload 上游用 200 回一段 HTML/JSON 错误页时,
// 代理直接判失败,不把网页喂给播放器(否则报「格式不支持」)。
func TestReaderMediaProxyRejectsNonMediaPayload(t *testing.T) {
svc, srv, book := prepareMediaTicketBook(t)
router := mediaRouterForTest(svc)
signed := primeTicketBook(t, svc, book.ID)
srv.serveErrorPage()
code, body := getReaderMedia(t, router, signed)
if code != http.StatusBadGateway {
t.Fatalf("非媒体内容应当返回 502,实际 %d body=%.160s", code, body)
}
if !strings.Contains(body, "非媒体内容") {
t.Fatalf("错误文案应说明上游返回了非媒体内容: %s", body)
}
}
// ─── 上游抖动:连接被 reset 时的重试与截断可见化 ─────────────────────────
// TestReaderMediaProbeRetriesConnectionReset 上游在响应体中途 RST 连接时,
// 代理要退避重试,而不是第一次读失败就回 502。
//
// 真实故障:腾讯 CDN 的跨境边缘在并发/长传输时会直接 RST,同一章重试一次
// 往往就好。以前这里读 body 失败即判死,把一次可恢复的抖动变成「这本书播不了」。
func TestReaderMediaProbeRetriesConnectionReset(t *testing.T) {
var attempts int32
const bodyBytes = 200 << 10
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
n := atomic.AddInt32(&attempts, 1)
if n == 1 {
// 第一次:声明长度,写一小段后直接断开连接(模拟 RST/提前 EOF)。
w.Header().Set("Content-Type", "audio/mp4")
w.Header().Set("Content-Length", strconv.Itoa(bodyBytes))
_, _ = w.Write([]byte("\x00\x00\x00\x20ftypmp42"))
w.(http.Flusher).Flush()
if hj, ok := w.(http.Hijacker); ok {
if conn, _, err := hj.Hijack(); err == nil {
_ = conn.Close()
}
}
return
}
w.Header().Set("Content-Type", "audio/mp4")
w.Header().Set("Content-Length", strconv.Itoa(bodyBytes))
payload := make([]byte, 8<<10)
for written := 0; written < bodyBytes; written += len(payload) {
if _, err := w.Write(payload); err != nil {
return
}
}
}))
defer srv.Close()
svc, _, book := prepareMediaTicketBookWithUpstream(t, srv.URL)
router := mediaRouterForTest(svc)
signed := svc.Reader.ProxyURL(book.ID, srv.URL+"/media")
code, body := getReaderMedia(t, router, signed)
if code != http.StatusOK {
t.Fatalf("连接被 reset 后应当重试成功,实际 status=%d body=%.200s", code, body)
}
if int32(len(body)) != bodyBytes {
t.Fatalf("交付字节 = %d,期望 %d", len(body), bodyBytes)
}
if got := atomic.LoadInt32(&attempts); got < 2 {
t.Fatalf("应当重试过,实际上游请求 %d 次", got)
}
}
// TestReaderMediaAbortsWhenUpstreamTruncates 上游中途断开且重试都失败时,
// 代理必须让客户端看到「传输不完整」,不能返回一个长度正确却内容缺失的响应。
//
// 以前这里是 `_, _ = io.Copy(...)`,错误被丢掉,客户端拿到一个正常结束的
// 半截文件 —— 播放器只能报「格式不支持」(3003),根因被彻底掩盖。
func TestReaderMediaAbortsWhenUpstreamTruncates(t *testing.T) {
const declared = 400 << 10
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "audio/mp4")
w.Header().Set("Content-Length", strconv.Itoa(declared))
// 先写够探测用的前缀,再断开:探测能过,透传阶段才断。
payload := make([]byte, 8<<10)
for written := 0; written < 128<<10; written += len(payload) {
if _, err := w.Write(payload); err != nil {
return
}
}
w.(http.Flusher).Flush()
if hj, ok := w.(http.Hijacker); ok {
if conn, _, err := hj.Hijack(); err == nil {
_ = conn.Close()
}
}
}))
defer srv.Close()
svc, _, book := prepareMediaTicketBookWithUpstream(t, srv.URL)
router := mediaRouterForTest(svc)
signed := svc.Reader.ProxyURL(book.ID, srv.URL+"/media")
w := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, signed, nil)
// panic(http.ErrAbortHandler) 由 net/http 捕获;httptest 下会向上冒泡,
// 这里断言它确实发生了(即「没有静默地把半截文件当成功」)。
defer func() {
recovered := recover()
if recovered == nil {
t.Fatalf("上游截断时必须中止响应(panic(http.ErrAbortHandler)),实际正常返回了 %d 字节",
w.Body.Len())
}
if recovered != http.ErrAbortHandler {
t.Fatalf("中止响应时应使用 http.ErrAbortHandler,实际 %v", recovered)
}
}()
router.ServeHTTP(w, req)
if w.Body.Len() >= declared {
t.Fatalf("不应交付完整长度的响应体(上游只写了 128KB)")
}
}
func mediaRouterForTest(svc *service.Container) *gin.Engine {
gin.SetMode(gin.TestMode)
r := gin.New()
// /api/reader/media 挂在公共 api 组(签名鉴权,不需要 JWT),单独注册即可。
r.GET("/api/reader/media", readerMediaProxyHandler(svc))
return r
}
func getReaderMedia(t *testing.T, router *gin.Engine, signedPath string) (int, string) {
t.Helper()
w := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, signedPath, nil)
router.ServeHTTP(w, req)
return w.Code, w.Body.String()
}
// primeTicketBook 先读一次章节(签出第一张票并落缓存),返回该音轨的签名代理路径。
func primeTicketBook(t *testing.T, svc *service.Container, bookID string) string {
t.Helper()
out, err := svc.Reader.GetContentForBook(t.Context(), "u1", bookID, 0)
if err != nil {
t.Fatalf("首次取章节失败: %v", err)
}
if len(out.Tracks) != 1 {
t.Fatalf("音轨数 = %d,期望 1", len(out.Tracks))
}
return out.Tracks[0]
}
// ─── 模拟「中转现签 CDN 直链」的上游 ───────────────────────────────────────
// mediaTicketServer:
// - /book/1 详情页
// - /content 章节正文,每次请求签一张新票
// - /media 媒体本体,只认最新签出的票;旧票回**空的 401**(与腾讯 CDN 一致)
// - /img/* 图片资源,固定 403(模拟防盗链)
type mediaTicketServer struct {
*httptest.Server
mu sync.Mutex
issued []string
hits int
noToken bool
errorPage bool
}
func newMediaTicketServer(t *testing.T) *mediaTicketServer {
t.Helper()
s := &mediaTicketServer{}
s.Server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch {
case strings.HasPrefix(r.URL.Path, "/book/1"):
w.Header().Set("Content-Type", "text/html; charset=utf-8")
_, _ = w.Write([]byte(`<html><body><h1>票源之书</h1></body></html>`))
case r.URL.Path == "/content":
s.mu.Lock()
s.hits++
n := s.hits
noToken := s.noToken
s.mu.Unlock()
w.Header().Set("Content-Type", "application/json; charset=utf-8")
if noToken {
_, _ = w.Write([]byte(`{"data":{"url":""}}`))
return
}
token := s.newTokenLocked(fmt.Sprintf("ticket-%d", n))
_, _ = w.Write([]byte(fmt.Sprintf(`{"data":{"url":"%s/media?t=%s"}}`, s.URL, token)))
case r.URL.Path == "/media":
if s.errorPageNow() {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
_, _ = w.Write([]byte(`<html><body>ticket expired</body></html>`))
return
}
token := r.URL.Query().Get("t")
s.mu.Lock()
valid := len(s.issued) > 0 && s.issued[len(s.issued)-1] == token
s.mu.Unlock()
if !valid {
// 与上游一致:空 body、无 Content-Type。
w.WriteHeader(http.StatusUnauthorized)
return
}
w.Header().Set("Content-Type", "audio/mp4")
_, _ = w.Write([]byte("\x00\x00\x00\x20ftypmp42fake-audio-bytes"))
case strings.HasPrefix(r.URL.Path, "/img/"):
w.WriteHeader(http.StatusForbidden)
default:
http.NotFound(w, r)
}
}))
t.Cleanup(s.Close)
return s
}
// newTokenLocked 签一张新票(调用方需持有或随后获取锁;这里内部加锁)。
func (s *mediaTicketServer) newTokenLocked(token string) string {
s.mu.Lock()
defer s.mu.Unlock()
s.issued = append(s.issued, token)
return token
}
// rotate 模拟「中转又签了一张新票」:章节缓存里那张就此失效。
func (s *mediaTicketServer) rotate() {
s.mu.Lock()
s.issued = append(s.issued, fmt.Sprintf("rotated-%d", len(s.issued)))
s.mu.Unlock()
}
// stopIssuing 让书源此后给不出播放地址(模拟中转本身挂了)。
func (s *mediaTicketServer) stopIssuing() {
s.mu.Lock()
s.noToken = true
s.mu.Unlock()
}
// serveErrorPage 让媒体端点改用 200 + HTML 错误页(直链失效的另一种形态)。
func (s *mediaTicketServer) serveErrorPage() {
s.mu.Lock()
s.errorPage = true
s.mu.Unlock()
}
func (s *mediaTicketServer) errorPageNow() bool {
s.mu.Lock()
defer s.mu.Unlock()
return s.errorPage
}
func (s *mediaTicketServer) contentHits() int {
s.mu.Lock()
defer s.mu.Unlock()
return s.hits
}
// prepareMediaTicketBookWithUpstream 用指定的上游地址建一本书(上游由调用方提供)。
func prepareMediaTicketBookWithUpstream(t *testing.T, upstream string) (*service.Container, *mediaTicketServer, *model.ReaderBook) {
t.Helper()
svc := newReaderHandlerContainer(t)
svc.Cfg.Cache.CacheDir = t.TempDir()
// 占位 ticket server:只用来满足清理钩子,不参与请求。
srv := newMediaTicketServer(t)
srcJSON := fmt.Sprintf(`{
"bookSourceName": "上游直连测试",
"bookSourceType": 1,
"bookSourceUrl": %q,
"ruleContent": {"content": "$.data.url"}
}`, upstream)
if _, err := svc.Reader.ImportSources(t.Context(), "u1", srcJSON); err != nil {
t.Fatalf("导入书源失败: %v", err)
}
srcs, err := svc.Reader.ListSources(t.Context(), "u1")
if err != nil || len(srcs) == 0 {
t.Fatalf("读不到书源: %v", err)
}
book, err := svc.Reader.AddBook(t.Context(), "u1", reader.SearchOrigin{
SourceID: srcs[0].ID,
Origin: upstream,
OriginName: "上游直连测试",
OriginType: 1,
BookURL: upstream + "/book/1",
}, "上游直连之书", "某作者", "")
if err != nil {
t.Fatalf("加入书架失败: %v", err)
}
return svc, srv, book
}
func prepareMediaTicketBook(t *testing.T) (*service.Container, *mediaTicketServer, *model.ReaderBook) {
t.Helper()
svc := newReaderHandlerContainer(t)
// 章节缓存必须落在磁盘上:换票要凭落盘载荷找回「这个地址属于哪一章」。
// newReaderHandlerContainer 只给空 cfg,这里补一个临时缓存目录。
svc.Cfg.Cache.CacheDir = t.TempDir()
srv := newMediaTicketServer(t)
srcJSON := fmt.Sprintf(`{
"bookSourceName": "票源测试",
"bookSourceType": 1,
"bookSourceUrl": %q,
"ruleContent": {"content": "$.data.url"}
}`, srv.URL)
if _, err := svc.Reader.ImportSources(t.Context(), "u1", srcJSON); err != nil {
t.Fatalf("导入书源失败: %v", err)
}
srcs, err := svc.Reader.ListSources(t.Context(), "u1")
if err != nil || len(srcs) == 0 {
t.Fatalf("读不到书源: %v", err)
}
book, err := svc.Reader.AddBook(t.Context(), "u1", reader.SearchOrigin{
SourceID: srcs[0].ID,
Origin: srv.URL,
OriginName: "票源测试",
OriginType: 1,
BookURL: srv.URL + "/book/1",
}, "票源之书", "某作者", "")
if err != nil {
t.Fatalf("加入书架失败: %v", err)
}
if err := svc.Reader.SaveChapters(t.Context(), book.ID, []reader.ChapterInput{
{Index: 0, Title: "第一章", URL: srv.URL + "/content"},
}); err != nil {
t.Fatalf("保存目录失败: %v", err)
}
return svc, srv, book
}
+265 -23
View File
@@ -2,11 +2,16 @@
package handler
import (
"bytes"
"context"
"errors"
"fmt"
"io"
"net/http"
"os"
"strconv"
"strings"
"time"
"github.com/gin-gonic/gin"
@@ -90,7 +95,8 @@ func registerReaderRoutes(authed *gin.RouterGroup, svc *service.Container) {
func readerListSourcesHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
sources, err := svc.Reader.ListSources(c.Request.Context())
userID := c.GetString(middleware.CtxUserID)
sources, err := svc.Reader.ListSources(c.Request.Context(), userID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
@@ -108,7 +114,8 @@ func readerImportSourcesHandler(svc *service.Container) gin.HandlerFunc {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
imported, err := svc.Reader.ImportSources(c.Request.Context(), body.Text)
userID := c.GetString(middleware.CtxUserID)
imported, err := svc.Reader.ImportSources(c.Request.Context(), userID, body.Text)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
@@ -126,7 +133,8 @@ func readerUpdateSourceHandler(svc *service.Container) gin.HandlerFunc {
c.JSON(http.StatusBadRequest, gin.H{"error": "enabled 字段必填"})
return
}
if err := svc.Reader.UpdateSourceEnabled(c.Request.Context(), c.Param("id"), *body.Enabled); err != nil {
userID := c.GetString(middleware.CtxUserID)
if err := svc.Reader.UpdateSourceEnabled(c.Request.Context(), userID, c.Param("id"), *body.Enabled); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
@@ -136,7 +144,8 @@ func readerUpdateSourceHandler(svc *service.Container) gin.HandlerFunc {
func readerDeleteSourceHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
if err := svc.Reader.DeleteSource(c.Request.Context(), c.Param("id")); err != nil {
userID := c.GetString(middleware.CtxUserID)
if err := svc.Reader.DeleteSource(c.Request.Context(), userID, c.Param("id")); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
@@ -145,15 +154,16 @@ func readerDeleteSourceHandler(svc *service.Container) gin.HandlerFunc {
}
func readerDebugSourceHandler(svc *service.Container) gin.HandlerFunc {
var body struct {
Key string `json:"key" binding:"required"`
}
return func(c *gin.Context) {
var body struct {
Key string `json:"key" binding:"required"`
}
if err := c.ShouldBindJSON(&body); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
logs, err := svc.Reader.Debug(c.Request.Context(), c.Param("id"), body.Key)
userID := c.GetString(middleware.CtxUserID)
logs, err := svc.Reader.Debug(c.Request.Context(), userID, c.Param("id"), body.Key)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
@@ -318,7 +328,8 @@ func readerBrowserAssetHandler(svc *service.Container) gin.HandlerFunc {
func readerSourceLogoutHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
if err := svc.Reader.ClearSourceLogin(c.Request.Context(), c.Param("id")); err != nil {
userID := c.GetString(middleware.CtxUserID)
if err := svc.Reader.ClearSourceLogin(c.Request.Context(), userID, c.Param("id")); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
@@ -335,7 +346,8 @@ func readerSetSourceVariableHandler(svc *service.Container) gin.HandlerFunc {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
if err := svc.Reader.SetSourceVariable(c.Request.Context(), c.Param("id"), body.Variable); err != nil {
userID := c.GetString(middleware.CtxUserID)
if err := svc.Reader.SetSourceVariable(c.Request.Context(), userID, c.Param("id"), body.Variable); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
@@ -352,7 +364,8 @@ func readerSetSourceLoginInfoHandler(svc *service.Container) gin.HandlerFunc {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
if err := svc.Reader.SetSourceLoginInfo(c.Request.Context(), c.Param("id"), body.Fields); err != nil {
userID := c.GetString(middleware.CtxUserID)
if err := svc.Reader.SetSourceLoginInfo(c.Request.Context(), userID, c.Param("id"), body.Fields); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
@@ -373,7 +386,8 @@ func readerSearchHandler(svc *service.Container) gin.HandlerFunc {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
books, skipped, err := svc.Reader.Search(c.Request.Context(), body.Key, body.SourceIDs, body.Page)
userID := c.GetString(middleware.CtxUserID)
books, skipped, err := svc.Reader.Search(c.Request.Context(), userID, body.Key, body.SourceIDs, body.Page)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
@@ -394,6 +408,10 @@ func readerBookInfoHandler(svc *service.Container) gin.HandlerFunc {
}
// 声明了封面解密的书源:详情封面也要走解密代理(尚未入库,按书源 URL 签)。
if info != nil {
// 顺手修复书架里被上游限流写坏的封面(存成接口地址的那种),先按原始
// 地址写回,再做下发时的解密代理改写。
userID := c.GetString(middleware.CtxUserID)
svc.Reader.RepairShelfCover(c.Request.Context(), userID, c.Query("source_url"), c.Query("book_url"), info.CoverURL)
info.CoverURL = svc.Reader.RewriteBookCover(c.Request.Context(), "", c.Query("source_url"), info.CoverURL)
}
c.JSON(http.StatusOK, info)
@@ -860,12 +878,18 @@ func readerMediaProxyHandler(svc *service.Container) gin.HandlerFunc {
// 图片地址可自带 ",{headers:{...}}" 选项段:拆分后 headers 逐图应用,
// 代理地址里只保留不带选项的地址(见 ProxyURL)。
rawURL, mediaOptions, _ := readerrule.ParseMediaOptions(rawURL)
resp, err := svc.Reader.FetchMediaWithOptions(c.Request.Context(), book, rawURL, c.GetHeader("Range"), mediaOptions.Headers)
// 只有 200 的 GET 需要嗅探响应体(判播放列表 / 判错误页);
// 206 的体是二进制分片、HEAD 没有体,读了反而要额外拼回去。
probeBody := c.Request.Method == http.MethodGet
media, err := fetchReaderMedia(c.Request.Context(), svc, book, rawURL, c.GetHeader("Range"), mediaOptions.Headers, probeBody)
if err != nil {
c.JSON(http.StatusBadGateway, gin.H{"error": "媒体拉取失败: " + err.Error()})
return
}
resp := media.resp
defer resp.Body.Close()
// 换过票时用新地址:m3u8 里的相对分片要按新地址解析。
rawURL = media.url
ct := resp.Header.Get("Content-Type")
// 书源声明了 imageDecode 时,图片字节需要服务端二次解密。
@@ -876,6 +900,7 @@ func readerMediaProxyHandler(svc *service.Container) gin.HandlerFunc {
c.JSON(http.StatusBadGateway, gin.H{"error": "图片过大或读取失败"})
return
}
sniffed = append(append([]byte(nil), media.head...), sniffed...)
decoded, decErr := svc.Reader.DecodeImageBytes(c.Request.Context(), book, rawURL, false, sniffed)
if decErr != nil {
c.JSON(http.StatusBadGateway, gin.H{"error": "图片解密失败: " + decErr.Error()})
@@ -888,20 +913,21 @@ func readerMediaProxyHandler(svc *service.Container) gin.HandlerFunc {
c.Data(resp.StatusCode, contentType, decoded)
return
}
// m3u8 判定不能只看 Content-Type 或 URL 后缀:上游经常把播放列表标成
// application/octet-stream,或者地址是 /index.m3u8?token=...(后缀判断不命中)。
// 先读一小段用 #EXTM3U 标记确认;不是播放列表就把这段拼回响应体继续流式透传。
// 200 的 GET:media.head 已经读过一小段,先用它判播放列表与错误页。
//
// 只嗅探 200 的 GET:206 的体是二进制分片、HEAD 没有体,都不需要判断。
if c.Request.Method == http.MethodGet && resp.StatusCode == http.StatusOK {
head, readErr := io.ReadAll(io.LimitReader(resp.Body, 64<<10))
if readErr == nil && reader.BodyIsPlaylist(head, ct) {
// m3u8 判定不能只看 Content-Type 或 URL 后缀:上游经常把播放列表标成
// application/octet-stream,或者地址是 /index.m3u8?token=...(后缀判断不命中),
// 所以以 #EXTM3U 标记为准。
if probeBody && resp.StatusCode == http.StatusOK {
head := media.head
if reader.BodyIsPlaylist(head, ct) {
rest, _ := io.ReadAll(io.LimitReader(resp.Body, 4<<20))
rewritten := svc.Reader.RewritePlaylist(bookID, rawURL, string(head)+string(rest))
c.Data(http.StatusOK, "application/vnd.apple.mpegurl", []byte(rewritten))
return
}
// 不是播放列表:透传时 Content-Length 可能因为已经读过一部分而失配,
// 走到这里说明 head 不是错误页(fetchReaderMedia 已经把错误页换票重试过了),
// 原样拼回去继续流式透传。Content-Length 因为已读过一部分会失配,
// 直接省略交给 net/http 按 chunked 处理,避免少写/多写导致截断。
for _, h := range []string{"Content-Type", "Content-Range", "Accept-Ranges"} {
if v := resp.Header.Get(h); v != "" {
@@ -912,7 +938,7 @@ func readerMediaProxyHandler(svc *service.Container) gin.HandlerFunc {
if len(head) > 0 {
_, _ = c.Writer.Write(head)
}
_, _ = io.Copy(c.Writer, resp.Body)
streamReaderMediaBody(c.Writer, resp.Body)
return
}
// 流式透传(含 206 Partial Content,支持音频拖动进度)
@@ -922,13 +948,229 @@ func readerMediaProxyHandler(svc *service.Container) gin.HandlerFunc {
}
}
c.Status(resp.StatusCode)
_, _ = io.Copy(c.Writer, resp.Body)
streamReaderMediaBody(c.Writer, resp.Body)
}
}
// streamReaderMediaBody 把上游响应体写到客户端,并在上游中途断开时**主动掐断**连接。
//
// 这是修复「播放器拿到半首歌」的关键:以前这里 `_, _ = io.Copy(...)` 把错误直接
// 丢掉,上游(腾讯 CDN 边缘)在跨境链路上 RST 连接时,客户端收到的是一个正常
// 结束、但字节数不足的响应 —— 播放器无从判断,只能报
// ERROR_CODE_PARSING_CONTAINER_UNSUPPORTED(看起来像「格式不支持」,实际是被截断)。
//
// 现在改成:上游没读完就中止这个响应,客户端因此拿到「传输不完整」而不是「正常结束」,
// 播放器可以按常规的重试/重连逻辑处理,而不是误判成格式问题。
func streamReaderMediaBody(w io.Writer, body io.Reader) {
if _, err := io.Copy(w, body); err != nil {
// panic(http.ErrAbortHandler) 是 net/http 约定的「异常中止」:不再补写
// chunked 结尾,直接关闭连接,客户端据此判定响应不完整。
panic(http.ErrAbortHandler)
}
}
// readerImageDecodeMaxBytes 需要服务端解密的单张图片上限。
const readerImageDecodeMaxBytes = 32 << 20
// readerMediaProbeBytes 媒体代理嗅探上游响应体用的前缀长度。
//
// 这个前缀同时用于三件事:判定 HLS 播放列表(#EXTM3U)、判定「其实是错误页」、
// 以及把已读出的字节原样拼回响应体继续流式透传。
const readerMediaProbeBytes = 64 << 10
// readerMediaResult 一次媒体拉取的结果。
type readerMediaResult struct {
resp *http.Response
// url 是本次真正请求的媒体地址(换过票时是新票),m3u8 重写要用它做 base。
url string
// head 是已从响应体读出的前缀(仅 200 的 GET 才有),调用方写响应时要先写它。
head []byte
// rejected 表示 200 的响应体其实是错误页(HTML/JSON 报错),不能喂给播放器。
rejected bool
}
// fetchReaderMedia 拉取媒体,并在「签名票失效」时换票重试一次。
//
// 听书源的播放地址是第三方中转现签的 CDN 直链(vkey/uid 带时效),票被上游判掉时
// 有两种表现,这里都要兜住:
//
// ① 空的 401:没有 Content-Type、没有 body(腾讯 CDN 对无效 vkey/uid 的固定回答);
// ② 200 + HTML/JSON 错误页:中转自己出错时把它的报错页当成媒体回给我们。
//
// 旧行为是两种都原样透传给播放器:① 变成连续多个 401(播放器重试的是同一个
// 必然失败的地址),② 变成 ERROR_CODE_PARSING_CONTAINER_UNSUPPORTED(19 个
// extractor 一个都不认)。两者的根因都是「这张票已经不能用了」,所以统一处理成
// 回源换一张新票再拉:
//
// - 换票成功 → 用新地址重拉,新票同时写回章节缓存,后续请求自然有效;
// - 地址不属于可换票的音轨(漫画图片防盗链的 403 等)→ 原样透传上游状态,
// 不改变原有行为;
// - 该换票但换不到、或换票后依然不可用 → 返回错误,由调用方给出明确文案,
// 而不是让播放器对着必然失败的地址反复重试。
func fetchReaderMedia(
ctx context.Context,
svc *service.Container,
book *model.ReaderBook,
rawURL, rangeHeader string,
options map[string]string,
probeBody bool,
) (*readerMediaResult, error) {
// 上游 CDN 在并发/跨境链路上偶发 reset 连接(实测同一章重试即可成功),
// 探测阶段读到一半断开不能直接判死,先按退避重试几次。
out, refreshable, err := fetchReaderMediaWithRetry(ctx, svc, book, rawURL, rangeHeader, options, probeBody)
if err != nil || !refreshable {
return out, err
}
fresh, canRefresh, refreshErr := svc.Reader.RefreshMediaURL(ctx, book, rawURL)
if !canRefresh {
// 换不动(漫画图片防盗链的 403 等):原样透传上游状态,保持既有行为。
// 但有两类失败例外——它们透传给播放器毫无意义(播放器只会一直重试
// 同一个必然失败的地址,或者把网页当成音频),改成明确错误让前端提示。
switch {
case out.rejected:
_ = out.resp.Body.Close()
return nil, fmt.Errorf("音源返回了非媒体内容(%s),请稍后重试或换源",
strings.TrimSpace(out.resp.Header.Get("Content-Type")))
case out.resp.StatusCode == http.StatusUnauthorized:
_, _ = io.Copy(io.Discard, io.LimitReader(out.resp.Body, 1<<16))
_ = out.resp.Body.Close()
return nil, fmt.Errorf("音源鉴权被拒(上游 401),请稍后重试或换源")
}
return out, nil
}
// 换得动就必须先释放这张失败响应的连接,再发新请求。
_, _ = io.Copy(io.Discard, io.LimitReader(out.resp.Body, 1<<16))
_ = out.resp.Body.Close()
if refreshErr != nil {
return nil, fmt.Errorf("音源签名失效且重新取票失败: %w", refreshErr)
}
retried, _, retryErr := fetchReaderMediaOnce(ctx, svc, book, fresh, rangeHeader, options, probeBody)
if retryErr != nil {
return nil, retryErr
}
if retried.rejected {
_ = retried.resp.Body.Close()
return nil, fmt.Errorf("音源换票后仍返回非媒体内容,请稍后重试或换源")
}
if reader.MediaNeedsRefresh(retried.resp.StatusCode) {
return nil, fmt.Errorf("音源换票后仍被拒绝(上游 %d),请稍后重试或换源", retried.resp.StatusCode)
}
return retried, nil
}
// readerMediaProbeAttempts 探测阶段遇到连接中断(reset/EOF)的最大尝试次数。
//
// 上游 CDN(腾讯 CDN 的跨境边缘)在并发或长传输时会直接 RST 连接,同一章
// 重试一次往往就好。以前这里读 body 失败就直接回 502,把一次可恢复的抖动
// 变成了「这本书播不了」。
const readerMediaProbeAttempts = 3
// readerMediaProbeRetryDelay 探测重试的退避基数。
const readerMediaProbeRetryDelay = 300 * time.Millisecond
// readerMediaProbeError 探测阶段的可重试错误(连接被 reset / 提前 EOF)。
type readerMediaProbeError struct{ err error }
func (e *readerMediaProbeError) Error() string { return e.err.Error() }
func (e *readerMediaProbeError) Unwrap() error { return e.err }
// fetchReaderMediaWithRetry 探测阶段按退避重试,只对「连接中断」类错误重试:
// 上游明确回了 4xx 时重试同一张票没有意义(那是换票流程的事)。
func fetchReaderMediaWithRetry(
ctx context.Context,
svc *service.Container,
book *model.ReaderBook,
rawURL, rangeHeader string,
options map[string]string,
probeBody bool,
) (*readerMediaResult, bool, error) {
var lastErr error
for attempt := 1; attempt <= readerMediaProbeAttempts; attempt++ {
if attempt > 1 {
select {
case <-ctx.Done():
return nil, false, ctx.Err()
case <-time.After(readerMediaProbeRetryDelay * time.Duration(attempt-1)):
}
}
out, refreshable, err := fetchReaderMediaOnce(ctx, svc, book, rawURL, rangeHeader, options, probeBody)
if err == nil {
return out, refreshable, nil
}
var probeErr *readerMediaProbeError
if !errors.As(err, &probeErr) {
return nil, false, err
}
lastErr = err
}
return nil, false, lastErr
}
// fetchReaderMediaOnce 发一次媒体请求并做可用性判定。
//
// 返回的 refreshable 表示「这次失败值得换一张签名票再试」:
// 上游 401/403/410,或 200 但响应体其实是错误页(详见 fetchReaderMedia 的说明)。
// rejected 表示「200 但内容不是媒体」——调用方据此在无法换票时报明确错误,
// 而不是把网页喂给播放器。
func fetchReaderMediaOnce(
ctx context.Context,
svc *service.Container,
book *model.ReaderBook,
rawURL, rangeHeader string,
options map[string]string,
probeBody bool,
) (*readerMediaResult, bool, error) {
resp, err := svc.Reader.FetchMediaWithOptions(ctx, book, rawURL, rangeHeader, options)
if err != nil {
return nil, false, err
}
if reader.MediaNeedsRefresh(resp.StatusCode) {
return &readerMediaResult{resp: resp, url: rawURL}, true, nil
}
if !probeBody || resp.StatusCode != http.StatusOK {
return &readerMediaResult{resp: resp, url: rawURL}, false, nil
}
// 先读一小段判断是不是错误页/播放列表;不是就原样拼回去继续流式透传。
head, readErr := io.ReadAll(io.LimitReader(resp.Body, readerMediaProbeBytes))
if readErr != nil {
_ = resp.Body.Close()
// 连接被 reset / 提前 EOF 属于上游抖动,交给上层退避重试。
return nil, false, &readerMediaProbeError{err: fmt.Errorf("读取媒体响应失败: %w", readErr)}
}
rejected := readerMediaPayloadRejected(head, resp.Header.Get("Content-Type"))
return &readerMediaResult{resp: resp, url: rawURL, head: head, rejected: rejected}, rejected, nil
}
// readerMediaPayloadRejected 判断上游 200 响应体其实是错误页而不是媒体。
//
// 直链失效的另一个变种:CDN/中转不返回 4xx,而是回一段 HTML 错误页或 JSON
// 报错(Content-Type 是 text/html、application/json)。这类字节喂给播放器,
// ExoPlayer 的 19 个 extractor 全都不认,报出来的是
// ERROR_CODE_PARSING_CONTAINER_UNSUPPORTED —— 看起来像「格式不支持」,
// 实际是「拿到了一张网页」。这里按 Content-Type + 首字符识别出来,直接判失败。
func readerMediaPayloadRejected(head []byte, contentType string) bool {
if len(head) == 0 {
return false
}
lower := strings.ToLower(contentType)
errish := strings.Contains(lower, "html") ||
strings.Contains(lower, "json") ||
strings.HasPrefix(lower, "text/")
if !errish {
return false
}
trimmed := bytes.TrimSpace(head)
if len(trimmed) == 0 {
return false
}
switch trimmed[0] {
case '<', '{', '[':
return true
default:
return false
}
}
// isMediaPlaylistRequest 判断响应是否可能已是 HLS 播放列表。
// 播放列表太小、不该走图片解密分支(书源的 imageDecode 只会用于图片)。
func isMediaPlaylistRequest(resp *http.Response) bool {
+4
View File
@@ -13,6 +13,8 @@ func registerAdminRoutes(api *gin.RouterGroup, cfg *config.Config, svc *service.
admin := api.Group("/admin")
admin.Use(middleware.AuthRequired(cfg.Secrets.JWTSecret), middleware.AdminRequired())
registerAdminUserRoutes(admin, svc)
admin.GET("/tasks", backgroundTasksHandler(svc))
admin.GET("/quality", mediaQualityHandler(svc))
registerAdminPermissionRoutes(admin, svc)
registerAdminSystemRoutes(admin, svc)
registerAdminBackupRoutes(admin, svc)
@@ -50,6 +52,7 @@ func registerAdminStrmRoutes(admin *gin.RouterGroup, svc *service.Container) {
// Emby 挂载管理:远程 Emby 媒体库挂载(账号复用 strm/accounts)
admin.GET("/emby/accounts/:id/views", embyAccountViewsHandler(svc))
admin.POST("/emby/accounts/:id/full-mount", fullMountEmbyAccountHandler(svc))
admin.PUT("/emby/accounts/:id/mounts/enabled", setEmbyMountsEnabledHandler(svc))
admin.GET("/emby/mounts", listEmbyMountsHandler(svc))
admin.POST("/emby/mounts", createEmbyMountsHandler(svc))
admin.PUT("/emby/mounts/reorder", reorderEmbyMountsHandler(svc))
@@ -142,6 +145,7 @@ func registerAdminSystemRoutes(admin *gin.RouterGroup, svc *service.Container) {
func registerAdminBackupRoutes(admin *gin.RouterGroup, svc *service.Container) {
admin.GET("/backups", listBackupsHandler(svc))
admin.POST("/backups", createBackupHandler(svc))
admin.GET("/backups/download", downloadBackupHandler(svc))
admin.DELETE("/backups", deleteBackupHandler(svc))
admin.POST("/backups/restore", restoreBackupHandler(svc))
}
@@ -36,6 +36,9 @@ func registerAuthedLibraryRoutes(authed *gin.RouterGroup, svc *service.Container
authed.GET("/libraries", listLibrariesHandler(svc))
authed.POST("/libraries", middleware.AdminRequired(), createLibraryHandler(svc))
authed.GET("/libraries/:id", getLibraryHandler(svc))
// 服务端生成的媒体库封面(成员海报拼图),Web / App / Emby 共用同一份。
authed.GET("/libraries/:id/cover", libraryCoverHandler(svc))
authed.HEAD("/libraries/:id/cover", libraryCoverHandler(svc))
authed.PATCH("/libraries/:id", middleware.AdminRequired(), updateLibraryHandler(svc))
authed.PUT("/libraries/reorder", middleware.AdminRequired(), reorderLibrariesHandler(svc))
authed.DELETE("/libraries/:id", middleware.AdminRequired(), deleteLibraryHandler(svc))
@@ -74,6 +74,7 @@ func registerAuthedPlaybackExtraRoutes(authed *gin.RouterGroup, svc *service.Con
authed.GET("/playback/:id/info", playbackInfoHandler(svc))
authed.GET("/playback/:id/resume", playbackResumeHandler(svc))
authed.GET("/playback/:id/segments", playbackSegmentsHandler(svc))
authed.PUT("/playback/:id/segments", playbackSegmentsMarkHandler(svc))
authed.POST("/playback/:id/progress", playbackProgressHandler(svc))
authed.GET("/playback/:id/external-players", externalPlayersHandler(svc))
authed.GET("/playback/:id/external-url", externalURLHandler(svc))
@@ -21,6 +21,8 @@ func registerAuthedDLNARoutes(authed *gin.RouterGroup, svc *service.Container) {
}
func registerAuthedRealtimeRoutes(authed *gin.RouterGroup, svc *service.Container) {
authed.GET("/ws", wsHandler(svc))
authed.GET("/events", sseHandler(svc))
// These streams carry operator tasks, including filesystem paths and scan
// errors. Their payloads are not scoped to an individual user's libraries.
authed.GET("/ws", middleware.AdminRequired(), wsHandler(svc))
authed.GET("/events", middleware.AdminRequired(), sseHandler(svc))
}
@@ -0,0 +1,200 @@
package handler
import (
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"github.com/gin-gonic/gin"
"github.com/glebarez/sqlite"
"go.uber.org/zap"
"gorm.io/gorm"
"github.com/truewhile/MeBox/internal/config"
"github.com/truewhile/MeBox/internal/model"
"github.com/truewhile/MeBox/internal/repository"
"github.com/truewhile/MeBox/internal/service"
)
// 首页轮播/剧集卡片交给播放器的是「整部剧」的伪装 ID,它自身没有媒体源:远程 Emby
// 对它的取流请求会回 500(Unable to cast 'Series' to 'IHasMediaSources')。这里锁定
// 播放接口的行为——改投第一集,且客户端原有的查询参数(token / profile / proxy)
// 必须原样带过去,否则播放器跟过去之后会掉鉴权。
func TestStreamRedirectsRemoteContainerToPlayableEpisode(t *testing.T) {
gin.SetMode(gin.TestMode)
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
switch r.URL.Path {
case "/emby/Users/uid-1/Items/series-100":
_ = json.NewEncoder(w).Encode(map[string]any{
"Id": "series-100", "Name": "某部新番", "Type": "Series", "IsFolder": true,
})
case "/emby/Users/uid-1/Items":
_ = json.NewEncoder(w).Encode(map[string]any{
"Items": []map[string]any{
map[string]any{
"Id": "ep-1", "Name": "第1集", "Type": "Episode", "SeriesId": "series-100",
"ParentIndexNumber": 1, "IndexNumber": 1,
"MediaSources": []any{map[string]any{"Id": "ep-1-src"}},
},
},
})
default:
w.WriteHeader(http.StatusNotFound)
}
}))
defer upstream.Close()
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
if err != nil {
t.Fatal(err)
}
if err := db.AutoMigrate(
&model.User{}, &model.UserPermission{}, &model.Setting{}, &model.Library{}, &model.Media{},
&model.StrmAccount{}, &model.EmbyMount{},
); err != nil {
t.Fatal(err)
}
repos := repository.New(db)
cfg := &config.Config{}
cfg.Secrets.JWTSecret = "stream-redirect-secret"
log := zap.NewNop()
remote := service.NewEmbyRemoteService(cfg, log, repos, service.NewCryptoService("", log))
svc := &service.Container{Cfg: cfg, Repo: repos, EmbyRemote: remote}
rawConfig, _ := json.Marshal(map[string]string{
"url": upstream.URL,
"api_key": "test-api-key",
"remote_user_id": "uid-1",
})
if err := repos.StrmAccount.Create(t.Context(), &model.StrmAccount{
Base: model.Base{ID: "acct-1"},
Name: "远程 Emby",
Provider: model.StrmProviderEmbyRemote,
Config: string(rawConfig),
Enabled: true,
}); err != nil {
t.Fatal(err)
}
if err := repos.EmbyMount.Create(t.Context(), &model.EmbyMount{
Base: model.Base{ID: "mount-1"},
AccountID: "acct-1",
RemoteViewID: "view-1",
RemoteViewName: "新番连载",
CollectionType: "tvshows",
Enabled: true,
}); err != nil {
t.Fatal(err)
}
router := gin.New()
router.GET("/api/stream/:id", streamHandler(svc))
req := httptest.NewRequest(
http.MethodGet,
"/api/stream/"+service.EncodeEmbyRemoteID("mount-1", "series-100")+"?token=tok-1&profile=p-1",
nil,
)
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
if w.Code != http.StatusFound {
t.Fatalf("status = %d body=%s, want 302", w.Code, w.Body.String())
}
want := "/api/stream/" + service.EncodeEmbyRemoteID("mount-1", "ep-1") + "?token=tok-1&profile=p-1"
if got := w.Header().Get("Location"); got != want {
t.Fatalf("Location = %q, want %q", got, want)
}
}
// 本身可播的远程条目(单集/电影)不能被改写,必须照原样进入原来的代理/直连分支。
func TestStreamKeepsPlayableRemoteItem(t *testing.T) {
gin.SetMode(gin.TestMode)
var streamCalls int
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
switch r.URL.Path {
case "/emby/Users/uid-1/Items/ep-1":
_ = json.NewEncoder(w).Encode(map[string]any{
"Id": "ep-1", "Name": "第1集", "Type": "Episode", "SeriesId": "series-100",
"ParentIndexNumber": 1, "IndexNumber": 1,
"MediaSources": []any{map[string]any{"Id": "ep-1-src"}},
})
case "/emby/Videos/ep-1/stream":
streamCalls++
w.Header().Set("Content-Type", "video/mp4")
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte("bytes"))
default:
w.WriteHeader(http.StatusNotFound)
}
}))
defer upstream.Close()
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
if err != nil {
t.Fatal(err)
}
if err := db.AutoMigrate(
&model.User{}, &model.UserPermission{}, &model.Setting{}, &model.Library{}, &model.Media{},
&model.StrmAccount{}, &model.EmbyMount{},
); err != nil {
t.Fatal(err)
}
repos := repository.New(db)
cfg := &config.Config{}
cfg.Secrets.JWTSecret = "stream-keep-secret"
log := zap.NewNop()
remote := service.NewEmbyRemoteService(cfg, log, repos, service.NewCryptoService("", log))
emby := service.NewEmbyService(cfg, log, repos).SetEmbyRemote(remote)
svc := &service.Container{Cfg: cfg, Repo: repos, EmbyRemote: remote, Emby: emby}
rawConfig, _ := json.Marshal(map[string]string{
"url": upstream.URL,
"api_key": "test-api-key",
"remote_user_id": "uid-1",
})
if err := repos.StrmAccount.Create(t.Context(), &model.StrmAccount{
Base: model.Base{ID: "acct-1"},
Name: "远程 Emby",
Provider: model.StrmProviderEmbyRemote,
Config: string(rawConfig),
Enabled: true,
}); err != nil {
t.Fatal(err)
}
// proxy_play=true:直接走反向代理分支,便于断言确实取到了流。
if err := repos.EmbyMount.Create(t.Context(), &model.EmbyMount{
Base: model.Base{ID: "mount-1"},
AccountID: "acct-1",
RemoteViewID: "view-1",
RemoteViewName: "新番连载",
CollectionType: "tvshows",
ProxyPlay: true,
Enabled: true,
}); err != nil {
t.Fatal(err)
}
router := gin.New()
router.GET("/api/stream/:id", streamHandler(svc))
req := httptest.NewRequest(http.MethodGet, "/api/stream/"+service.EncodeEmbyRemoteID("mount-1", "ep-1"), nil)
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("status = %d body=%s, want 200", w.Code, w.Body.String())
}
if got := w.Body.String(); got != "bytes" {
t.Fatalf("body = %q, want the proxied stream", got)
}
if streamCalls != 1 {
t.Fatalf("upstream stream calls = %d, want 1", streamCalls)
}
}
+43 -3
View File
@@ -27,7 +27,15 @@ func hlsPlaylistHandler(svc *service.Container) gin.HandlerFunc {
if !enforceScopedPlaybackToken(c, m.ID) {
return
}
err = svc.Stream.ServeHLSPlaylist(c.Writer, c.Request, c.Param("id"))
scope, ok := localHLSPlaybackScope(c)
if !ok {
return
}
err = svc.Stream.ServeHLSPlaylist(c.Writer, c.Request, c.Param("id"), scope)
if errors.Is(err, service.ErrHLSGenerationSuperseded) {
c.JSON(http.StatusConflict, gin.H{"code": "hls_generation_superseded", "error": err.Error()})
return
}
if errors.Is(err, service.ErrMediaNotFound) {
c.JSON(http.StatusNotFound, gin.H{"error": "not found"})
return
@@ -62,7 +70,11 @@ func hlsSegmentHandler(svc *service.Container) gin.HandlerFunc {
if !enforceScopedPlaybackToken(c, m.ID) {
return
}
err = svc.Stream.ServeHLSSegment(c.Writer, c.Request, c.Param("id"), c.Param("seg"))
scope, ok := localHLSPlaybackScope(c)
if !ok {
return
}
err = svc.Stream.ServeHLSSegment(c.Writer, c.Request, c.Param("id"), c.Param("seg"), scope)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": err.Error()})
return
@@ -72,11 +84,39 @@ func hlsSegmentHandler(svc *service.Container) gin.HandlerFunc {
func stopTranscodeHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
svc.Transcoder.StopJob(c.Param("id"))
m, err := findMediaForPlaybackEndpoint(c, svc, c.Param("id"))
if err != nil || m == nil || !mediaVisibleForRequest(c, svc, m) {
c.JSON(http.StatusNotFound, gin.H{"error": "media not found"})
return
}
if !enforceScopedPlaybackToken(c, m.ID) {
return
}
scope, ok := localHLSPlaybackScope(c)
if !ok {
return
}
if svc.Transcoder != nil {
svc.Transcoder.StopPlayback(m.ID, scope)
}
c.Status(http.StatusNoContent)
}
}
func localHLSPlaybackScope(c *gin.Context) (service.HLSPlaybackScope, bool) {
userID := currentUserID(c)
if userID == "" {
c.JSON(http.StatusUnauthorized, gin.H{"error": "missing playback owner"})
return service.HLSPlaybackScope{}, false
}
scope, err := service.HLSPlaybackScopeForRequest(c.Request, userID)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return service.HLSPlaybackScope{}, false
}
return scope, true
}
func imageProxyHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
raw := c.Query("url")
+14
View File
@@ -27,6 +27,10 @@ import (
// historyListHandler returns the caller's history rows joined with the
// matching media in a single response.
//
// include_media=0 只返回历史行本身。客户端判断「继续播放」指向哪一集时只
// 需要 media_id/position_ms;带上整条 media 会让 300 条的响应从 ~40KB 涨到
// ~420KB,手机链路上光传输就要十几秒,详情页的续播解析会被它拖住。
func historyListHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
uid, _ := c.Get(middleware.CtxUserID)
@@ -34,6 +38,12 @@ func historyListHandler(svc *service.Container) gin.HandlerFunc {
if limit <= 0 || limit > 500 {
limit = 50
}
includeMedia := true
if raw := strings.TrimSpace(c.Query("include_media")); raw != "" {
if parsed, err := strconv.ParseBool(raw); err == nil {
includeMedia = parsed
}
}
items, err := svc.Playback.RecentHistory(c.Request.Context(), toString(uid), limit)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
@@ -42,7 +52,11 @@ func historyListHandler(svc *service.Container) gin.HandlerFunc {
visibility := mediaVisibilityForRequest(c, svc)
filtered := make([]service.HistoryItem, 0, len(items))
for _, item := range items {
// 可见性仍然按 media 判断,只是决定要不要把它写进响应。
if item.Media == nil || visibility.Allows(item.Media) {
if !includeMedia {
item.Media = nil
}
filtered = append(filtered, item)
}
}
+72
View File
@@ -103,3 +103,75 @@ func TestHistoryContinueRemovesMissingMedia(t *testing.T) {
t.Fatalf("stale history rows remaining = %d, want 0", staleCount)
}
}
// include_media=0 只是不把 media 写进响应,行数和可见性判断都不变。
func TestHistoryListCanOmitEmbeddedMedia(t *testing.T) {
gin.SetMode(gin.TestMode)
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
if err != nil {
t.Fatal(err)
}
if err := db.AutoMigrate(&model.PlaybackHistory{}, &model.Media{}); err != nil {
t.Fatal(err)
}
media := model.Media{
Base: model.Base{ID: "media-1"},
Title: "影片",
Path: "/media/one.mkv",
}
if err := db.Create(&media).Error; err != nil {
t.Fatal(err)
}
if err := db.Create(&model.PlaybackHistory{
Base: model.Base{ID: "history-1"},
UserID: "user-1",
MediaID: media.ID,
PositionMs: 30_000,
DurationMs: 120_000,
WatchedAt: time.Now(),
}).Error; err != nil {
t.Fatal(err)
}
repos := repository.New(db)
svc := &service.Container{
Log: zap.NewNop(),
Repo: repos,
Playback: service.NewPlaybackService(zap.NewNop(), repos),
}
router := gin.New()
router.Use(func(c *gin.Context) {
c.Set(middleware.CtxUserID, "user-1")
c.Next()
})
router.GET("/watch-history", historyListHandler(svc))
type listRow struct {
MediaID string `json:"media_id"`
Media *model.Media `json:"media"`
}
full := httptest.NewRecorder()
router.ServeHTTP(full, httptest.NewRequest(http.MethodGet, "/watch-history?limit=10", nil))
var fullRows []listRow
if err := json.Unmarshal(full.Body.Bytes(), &fullRows); err != nil {
t.Fatal(err)
}
if len(fullRows) != 1 || fullRows[0].Media == nil || fullRows[0].Media.ID != media.ID {
t.Fatalf("default response should embed media: %s", full.Body.String())
}
lean := httptest.NewRecorder()
router.ServeHTTP(lean, httptest.NewRequest(http.MethodGet, "/watch-history?limit=10&include_media=0", nil))
var leanRows []listRow
if err := json.Unmarshal(lean.Body.Bytes(), &leanRows); err != nil {
t.Fatal(err)
}
if len(leanRows) != 1 || leanRows[0].MediaID != media.ID {
t.Fatalf("lean response should keep the row: %s", lean.Body.String())
}
if leanRows[0].Media != nil {
t.Fatalf("include_media=0 must not embed media: %s", lean.Body.String())
}
}
+8 -5
View File
@@ -1,9 +1,7 @@
// Package handler — WebSocket endpoint.
//
// Clients connect to /api/ws?token=... (the token is the same JWT used for
// REST calls). The first message they send is a JSON {"action":"subscribe",
// "topics":["scan","scrape","transcode"]}. Subsequent server-pushed events
// arrive as {"topic":"...","payload":{...}}.
// Administrators connect to /api/ws?token=... with their REST access token.
// Operator events arrive as {"topic":"...","payload":{...}}.
package handler
import (
@@ -52,8 +50,11 @@ func wsHandler(svc *service.Container) gin.HandlerFunc {
sub := svc.WSHub.Subscribe(id, nil)
defer svc.WSHub.Unsubscribe(id)
// Reader: accept ping + subscription updates.
// Reading processes WebSocket control frames and detects disconnects.
done := make(chan struct{})
conn.SetReadLimit(64 << 10)
go func() {
defer close(done)
for {
if _, _, err := conn.NextReader(); err != nil {
_ = conn.Close()
@@ -67,6 +68,8 @@ func wsHandler(svc *service.Container) gin.HandlerFunc {
defer ticker.Stop()
for {
select {
case <-done:
return
case ev, ok := <-sub.Out:
if !ok {
return
+93
View File
@@ -0,0 +1,93 @@
package handler
import (
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"github.com/gin-gonic/gin"
"github.com/golang-jwt/jwt/v5"
"github.com/gorilla/websocket"
"go.uber.org/zap"
"github.com/truewhile/MeBox/internal/middleware"
"github.com/truewhile/MeBox/internal/service"
)
func realtimeTestRouter(hub *service.Hub) *gin.Engine {
gin.SetMode(gin.TestMode)
router := gin.New()
authed := router.Group("/api")
authed.Use(middleware.AuthRequired("realtime-test"))
registerAuthedRealtimeRoutes(authed, &service.Container{WSHub: hub})
return router
}
func realtimeTestToken(t *testing.T, role string) string {
t.Helper()
token, err := jwt.NewWithClaims(jwt.SigningMethodHS256, middleware.Claims{
UserID: "viewer", Role: role,
RegisteredClaims: jwt.RegisteredClaims{ExpiresAt: jwt.NewNumericDate(time.Now().Add(time.Hour))},
}).SignedString([]byte("realtime-test"))
if err != nil {
t.Fatal(err)
}
return token
}
func TestRealtimeStreamsRejectOrdinaryUsers(t *testing.T) {
router := realtimeTestRouter(nil)
for _, path := range []string{"/api/ws", "/api/events"} {
for _, role := range []string{"", "user"} {
req := httptest.NewRequest(http.MethodGet, path, nil)
want := http.StatusUnauthorized
if role != "" {
req.Header.Set("Authorization", "Bearer "+realtimeTestToken(t, role))
want = http.StatusForbidden
}
recorder := httptest.NewRecorder()
router.ServeHTTP(recorder, req)
if recorder.Code != want {
t.Fatalf("%s role %q: status %d, want %d", path, role, recorder.Code, want)
}
}
}
}
func TestAdminWebSocketReceivesTaskEvents(t *testing.T) {
hub := service.NewHub(zap.NewNop())
go hub.Run()
defer hub.Stop()
server := httptest.NewServer(realtimeTestRouter(hub))
defer server.Close()
url := "ws" + strings.TrimPrefix(server.URL, "http") + "/api/ws?token=" + realtimeTestToken(t, "admin")
conn, _, err := websocket.DefaultDialer.Dial(url, nil)
if err != nil {
t.Fatal(err)
}
defer conn.Close()
stop := make(chan struct{})
defer close(stop)
go func() {
ticker := time.NewTicker(10 * time.Millisecond)
defer ticker.Stop()
for {
select {
case <-stop:
return
case <-ticker.C:
hub.Publish("task", service.BackgroundTask{ID: "task-1", SourcePath: "/media/private"})
}
}
}()
_ = conn.SetReadDeadline(time.Now().Add(2 * time.Second))
var event service.Event
if err := conn.ReadJSON(&event); err != nil {
t.Fatal(err)
}
if event.Topic != "task" || event.Payload.(map[string]any)["source_path"] != "/media/private" {
t.Fatalf("event = %#v", event)
}
}
+43 -4
View File
@@ -7,6 +7,7 @@ import (
"compress/flate"
"compress/gzip"
"compress/zlib"
"crypto/tls"
"io"
"net/http"
"net/url"
@@ -88,10 +89,51 @@ func StripAcceptEncoding(h http.Header) {
//
// When useProxy is false, the client is created without proxy plumbing so
// the request goes out direct, matching the user's checkbox intent.
//
// 注意 http.Client.Timeout 是「整个请求」的上限,包含读完响应体。书源的
// 搜索/目录/正文都是小响应,30 秒足够;但同一个 client 也被用来流转发
// 音频/视频(见 NewMediaHTTPClient),那里必须换一个没有整体超时的 client。
func NewSiteHTTPClient(timeoutSeconds int, useProxy bool) *http.Client {
if timeoutSeconds <= 0 {
timeoutSeconds = 15
}
return &http.Client{
Timeout: time.Duration(timeoutSeconds) * time.Second,
Transport: newSiteTransport(useProxy),
}
}
// NewMediaHTTPClient 拉取并转发媒体流(音频/视频/大图)的客户端。
//
// 为什么不能用 NewSiteHTTPClient:http.Client.Timeout 会覆盖「读完 body」这个
// 阶段。一个 4MB 的 m4a 在跨境链路上很容易超过 30 秒,一旦超时,net/http 会
// 直接掐断响应体,而调用方的 io.Copy 只是正常返回 —— 表现为**静默截断**
// (播放器拿到半首歌,或判「格式不支持」),或者上游慢到 Do 本身报错 → 502。
//
// 为什么强制 HTTP/1.1:实测腾讯 CDN(vb.stream.tencentmusic.com)的 h2 边缘在
// 长连接大文件上不按预期回 WINDOW_UPDATE,Go 的 http2 Transport 初始连接窗口
// 是 1MiB,于是下载**稳定卡在 1MiB/2MiB/3MiB 整数倍**处断开;同一 URL 用
// HTTP/1.1(wget、curl --http1.1)从头到尾都完整。因此媒体通路固定走 h1.1:
// 音视频本来就没有「多路复用省请求」的收益,反而是流控 bug 的受害者。
func NewMediaHTTPClient(useProxy bool) *http.Client {
tr := newSiteTransport(useProxy)
// 等响应头最多 90 秒:跨境 CDN 首包偶发要几十秒,但绝不能无上限挂着。
tr.ResponseHeaderTimeout = 90 * time.Second
// 媒体是长连接大流量,连接池开大一些,避免并发取图/拖动进度时互相排队。
tr.MaxIdleConns = 32
tr.MaxIdleConnsPerHost = 16
// 关闭 HTTP/2:见上面的流控说明。
tr.ForceAttemptHTTP2 = false
tr.TLSNextProto = map[string]func(string, *tls.Conn) http.RoundTripper{}
// 不复用连接:实测腾讯 CDN 的边缘在「同一条 keep-alive 连接上连续取多个
// 大对象」时会直接 RST 第二个请求的响应体(容器内 wget/curl 每次新建连接
// 就完全正常)。音视频请求本来就稀疏,为省一次 TLS 握手去冒截断风险不划算。
tr.DisableKeepAlives = true
return &http.Client{Transport: tr}
}
// newSiteTransport 书源请求共用的 Transport(媒体与规则请求配置一致的部分)。
func newSiteTransport(useProxy bool) *http.Transport {
tr := &http.Transport{
MaxIdleConns: 16,
MaxIdleConnsPerHost: 4,
@@ -104,10 +146,7 @@ func NewSiteHTTPClient(timeoutSeconds int, useProxy bool) *http.Client {
return ProxyFromEnvironmentOrSystem(r)
}
}
return &http.Client{
Timeout: time.Duration(timeoutSeconds) * time.Second,
Transport: tr,
}
return tr
}
// HTTPHeaderPresets returns a map of realistic browser HTTP headers.
+20
View File
@@ -17,6 +17,8 @@ type Claims struct {
Tier string `json:"tier,omitempty"`
Purpose string `json:"purpose,omitempty"`
MediaID string `json:"media_id,omitempty"`
// Sid 是设备认证会话 ID;旧令牌为空,继续走兼容路径。
Sid string `json:"sid,omitempty"`
jwt.RegisteredClaims
}
@@ -44,6 +46,19 @@ func AuthRequired(secret string) gin.HandlerFunc {
c.AbortWithStatusJSON(http.StatusForbidden, gin.H{"code": 40304, "message": "token scope denied"})
return
}
// 新协议令牌携带 sid:旧 API 也必须检查会话是否已被撤销,否则
// 设备被踢后仍能用未过期的 JWT 访问 /api/*。
if sid := strings.TrimSpace(claims.Sid); sid != "" {
if err := ValidateAuthSession(c.Request.Context(), sid, claims.UserID); err != nil {
if errors.Is(err, ErrSessionUnavailable) {
c.AbortWithStatusJSON(http.StatusServiceUnavailable, gin.H{"code": 50303, "message": "auth session unavailable"})
return
}
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"code": 40104, "message": "auth session revoked"})
return
}
c.Set(CtxAuthSessionID, sid)
}
syncAccessTokenCookie(c, raw, claims)
c.Set(CtxUserID, claims.UserID)
c.Set(CtxUserRole, claims.Role)
@@ -151,3 +166,8 @@ func extractToken(c *gin.Context) string {
}
return ""
}
// ExtractToken 暴露令牌提取逻辑给需要自定义错误契约的移动端中间件。
func ExtractToken(c *gin.Context) string {
return extractToken(c)
}
+48
View File
@@ -0,0 +1,48 @@
package middleware
import (
"context"
"errors"
"strings"
"sync"
)
// 会话校验错误。service 层把自己的错误映射到这两个哨兵,避免 middleware
// 依赖 service 包(auth 服务已经依赖 middleware 的上下文键)。
var (
// ErrSessionRevoked 表示会话已撤销、已过期或不存在:客户端应重新登录。
ErrSessionRevoked = errors.New("auth session revoked")
// ErrSessionUnavailable 表示暂时无法完成会话校验:客户端应稍后重试。
ErrSessionUnavailable = errors.New("auth session unavailable")
)
// SessionValidator 校验认证会话是否仍属于该用户。
type SessionValidator func(ctx context.Context, sessionID, userID string) error
var (
sessionValidatorMu sync.RWMutex
sessionValidator SessionValidator
)
// SetSessionValidator 注册全局会话校验器;传 nil 表示关闭校验(旧装配路径)。
func SetSessionValidator(fn SessionValidator) {
sessionValidatorMu.Lock()
defer sessionValidatorMu.Unlock()
sessionValidator = fn
}
// ValidateAuthSession 使用已注册的校验器检查 sid。未注册校验器时放行,
// 以保持旧测试和嵌入式调用的兼容行为。
func ValidateAuthSession(ctx context.Context, sessionID, userID string) error {
sessionID = strings.TrimSpace(sessionID)
if sessionID == "" {
return nil
}
sessionValidatorMu.RLock()
fn := sessionValidator
sessionValidatorMu.RUnlock()
if fn == nil {
return nil
}
return fn(ctx, sessionID, userID)
}
+12
View File
@@ -17,12 +17,24 @@ var gzipExcludedExtensions = []string{
// apiGzipExcludedPrefixes 大文件流式传输(Range 语义)与 WS/SSE 长连接
// 不参与 gzip:压缩会破坏 Range / 逐块推送语义。
//
// 阅读媒体代理(/api/reader/media、/api/reader/local/audio、
// /api/reader/audio/transcode)同样必须排除,原因有两条:
// - 它们透传上游的 206 Partial Content。gin-contrib/gzip 在压缩时会删掉
// Content-Length,却把 Content-Range 原样留下,客户端看到的是「声明
// N 字节、实际收到 M 字节的 gzip 流」,自己发 Accept-Encoding 的播放器
// (ExoPlayer 不透明处理)直接判 ERROR_CODE_PARSING_CONTAINER_UNSUPPORTED。
// - 音视频本来就是压缩格式,再 gzip 一遍只烧 CPU(3.5MB 的 m4a 压完还是
// 3.5MB 量级),对带宽没有收益。
var apiGzipExcludedPrefixes = []string{
"/api/stream/",
"/api/hls/",
"/api/img",
"/api/subtitles/",
"/api/strm/play/",
"/api/reader/media",
"/api/reader/local/audio",
"/api/reader/audio/transcode",
"/api/ws",
"/api/events",
}
+46
View File
@@ -70,6 +70,52 @@ func TestGzipAPIExcludesStreamPath(t *testing.T) {
}
}
// TestGzipAPIExcludesReaderMedia 阅读媒体代理必须排除在 gzip 之外。
//
// 它会透传 CDN 的 206 Partial Content。gin-contrib/gzip 压缩时会删掉
// Content-Length 却保留 Content-Range,客户端看到「声明 N 字节、实收 M 字节」,
// 自己发 Accept-Encoding 的播放器(ExoPlayer)直接判
// ERROR_CODE_PARSING_CONTAINER_UNSUPPORTED,表现为「音频格式不支持」。
func TestGzipAPIExcludesReaderMedia(t *testing.T) {
gin.SetMode(gin.TestMode)
router := gin.New()
api := router.Group("/api")
api.Use(GzipAPI())
// 用 206 + Content-Range 模拟真实的音频分段响应。
segment := func(c *gin.Context) {
c.Header("Content-Range", "bytes 0-99/100")
c.Data(http.StatusPartialContent, "audio/mp4", []byte(strings.Repeat("x", 4096)))
}
for _, path := range []string{
"/reader/media",
"/reader/local/audio",
"/reader/audio/transcode",
} {
api.GET(path, segment)
}
for _, path := range []string{
"/reader/media",
"/reader/local/audio",
"/reader/audio/transcode",
} {
req := httptest.NewRequest(http.MethodGet, "/api"+path+"?b=book&u=x&s=y", nil)
req.Header.Set("Accept-Encoding", "gzip")
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
if got := w.Header().Get("Content-Encoding"); got == "gzip" {
t.Errorf("%s 不该被 gzip(Range 语义会被破坏)", path)
}
if got := w.Header().Get("Content-Range"); got != "bytes 0-99/100" {
t.Errorf("%s 的 Content-Range 被破坏: %q", path, got)
}
if w.Body.Len() != 4096 {
t.Errorf("%s 响应体长度 = %d,期望 4096", path, w.Body.Len())
}
}
}
func TestGzipStaticCompressesAssets(t *testing.T) {
router := newGzipTestRouter()
+2
View File
@@ -9,6 +9,8 @@ const (
CtxUserTier = "ctx_user_tier"
CtxTokenPurpose = "ctx_token_purpose"
CtxTokenMediaID = "ctx_token_media_id"
// CtxAuthSessionID 保存已校验的设备认证会话 ID(新协议令牌才有)。
CtxAuthSessionID = "ctx_auth_session_id"
// AccessTokenCookieName carries the web access token for browser-managed
// resource requests such as <img>, which cannot attach Authorization.
+49
View File
@@ -0,0 +1,49 @@
package model
import (
"time"
"github.com/google/uuid"
"gorm.io/gorm"
)
// AuthSession 是可撤销的设备认证会话。
//
// 会话与刷新令牌的关系是 1:N:一次登录创建一个会话,之后每次轮换刷新
// 令牌都继续归属同一会话。访问令牌通过 JWT 的 sid 字段引用会话,因此
// 服务端可以在 JWT 尚未过期时立即撤销某台设备的访问能力。
//
// 旧客户端没有会话关联(sid 为空),继续走既有兼容路径;本模型不强制
// 把历史刷新令牌绑定到任何设备。
type AuthSession struct {
ID string `gorm:"primaryKey;size:36" json:"id"`
UserID string `gorm:"index;size:36;not null" json:"user_id"`
DeviceID string `gorm:"index:idx_auth_session_device,priority:1;size:128;not null" json:"device_id"`
DeviceName string `gorm:"size:128" json:"device_name,omitempty"`
ClientName string `gorm:"size:64" json:"client_name,omitempty"`
ClientVersion string `gorm:"size:64" json:"client_version,omitempty"`
Revoked bool `gorm:"not null;default:false" json:"revoked"`
RevokedAt *time.Time `json:"revoked_at,omitempty"`
LastUsedAt time.Time `json:"last_used_at"`
ExpiresAt time.Time `gorm:"index" json:"expires_at"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
// BeforeCreate 生成 UUID 主键。
func (s *AuthSession) BeforeCreate(_ *gorm.DB) error {
if s.ID == "" {
s.ID = uuid.NewString()
}
return nil
}
// IsExpired 判断会话是否超过最长有效期。
func (s *AuthSession) IsExpired() bool {
return s != nil && !s.ExpiresAt.IsZero() && time.Now().After(s.ExpiresAt)
}
// IsValid 判断会话当前是否可继续使用。
func (s *AuthSession) IsValid() bool {
return s != nil && !s.Revoked && !s.IsExpired()
}
+23
View File
@@ -0,0 +1,23 @@
package model
import "time"
// BackgroundTaskRecord persists the operator-facing lifecycle of scan,
// organize, and other long-running application tasks.
type BackgroundTaskRecord struct {
ID string `gorm:"primaryKey;size:36" json:"id"`
Kind string `gorm:"index;size:32;not null" json:"kind"`
Name string `gorm:"size:255;not null" json:"name"`
Status string `gorm:"index;size:24;not null" json:"status"`
Stage string `gorm:"size:64" json:"stage"`
SourcePath string `gorm:"size:2048" json:"source_path"`
DestPath string `gorm:"size:2048" json:"dest_path"`
Message string `gorm:"type:text" json:"message"`
Error string `gorm:"type:text" json:"error"`
Details string `gorm:"type:text" json:"details"`
Metrics string `gorm:"type:text" json:"metrics"`
Items string `gorm:"type:text" json:"items"`
StartedAt time.Time `gorm:"index" json:"started_at"`
UpdatedAt time.Time `json:"updated_at"`
FinishedAt *time.Time `json:"finished_at,omitempty"`
}
+16 -7
View File
@@ -1,5 +1,7 @@
package model
import "time"
// Library 表示一个逻辑媒体库。Path 保留为兼容字段,指向第一条 LibraryRoot。
type Library struct {
Base
@@ -26,13 +28,20 @@ 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"`
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"`
OriginalName string `gorm:"size:255" json:"original_name,omitempty"`
EpisodeTitle string `gorm:"size:255" json:"episode_title,omitempty"`
Path string `gorm:"uniqueIndex;size:1024;not null" json:"path"`
AvailabilityState string `gorm:"index;size:16;not null;default:available" json:"availability_state"`
MissingSince *time.Time `json:"missing_since,omitempty"`
LastSeenAt *time.Time `json:"last_seen_at,omitempty"`
LibraryID string `gorm:"index;size:36;index:idx_media_library_release,priority:1" json:"library_id"`
LibraryRootID string `gorm:"index;size:36" json:"library_root_id,omitempty"`
SeriesID string `gorm:"index;size:128" json:"series_id,omitempty"`
Title string `gorm:"size:255;not null" json:"title"`
OriginalName string `gorm:"size:255" json:"original_name,omitempty"`
EpisodeTitle string `gorm:"size:255" json:"episode_title,omitempty"`
// Path 的唯一性由 database 包按方言显式建索引,这里刻意不用 uniqueIndex:
// PostgreSQL 的 btree 单条索引项上限 2704 字节,而 varchar(1024) 在中文路径
// 下可达 3072 字节,直接对整列建唯一索引会让长路径的扫描插入直接失败
// (SQLite 没有这个限制)。见 database.ensureMediaPathIndex。
Path string `gorm:"size:1024;not null" json:"path"`
RelativePath string `gorm:"size:1024" json:"relative_path,omitempty"`
SizeBytes int64 `json:"size_bytes"`
DurationSec int `json:"duration_sec"`
+2
View File
@@ -49,6 +49,7 @@ func AllModels() []interface{} {
&APIConfig{},
&UserPermission{},
&RefreshToken{},
&AuthSession{},
&PlayProfile{},
&RegistrationCode{},
&SignIn{},
@@ -60,6 +61,7 @@ func AllModels() []interface{} {
&StrmUploadTask{},
&StrmDirCache{},
&ScrapeTask{},
&BackgroundTaskRecord{},
&EmbyMount{},
&ReaderBookSource{},
&ReaderSourceState{},
+8 -1
View File
@@ -8,12 +8,19 @@ import (
)
// ReaderBookSource 书源:原始 JSON 全量存储 + 常用字段冗余列出便于筛选排序。
//
// 书源按用户独立:同一份书源不同用户可以各存一份(各自启停、排序、修改规则与变量),
// 互不可见。唯一键是 (user_id, source_url);跨用户的「同一本书」靠
// ReaderBook.Origin(书源 URL)而不是书源行 ID 关联。
type ReaderBookSource struct {
Base
// UserID 归属用户。历史数据迁移时会回填;值为空表示「旧版全局书源」,
// 只在没有任何用户引用时才会出现。
UserID string `gorm:"type:varchar(36);uniqueIndex:uniq_reader_source_user_url;index" json:"user_id"`
Name string `gorm:"type:varchar(255);index" json:"name"`
GroupName string `gorm:"type:varchar(255);index" json:"group"`
Type int `gorm:"default:0" json:"type"` // 0文本 1音频 2图片 3文件 4视频
SourceURL string `gorm:"type:varchar(512);index" json:"source_url"`
SourceURL string `gorm:"type:varchar(512);uniqueIndex:uniq_reader_source_user_url;index" json:"source_url"`
RawJSON string `gorm:"type:text" json:"-"`
Enabled bool `gorm:"default:true" json:"enabled"`
EnabledExplore bool `gorm:"default:true" json:"enabled_explore"`
+8 -6
View File
@@ -11,12 +11,14 @@ import (
// RefreshToken 用于双令牌认证机制中的刷新令牌。
// 存储时使用 SHA256 哈希,原始令牌不存储。
type RefreshToken struct {
ID string `gorm:"primaryKey;size:36" json:"id"`
UserID string `gorm:"index;size:36;not null" json:"user_id"`
TokenHash string `gorm:"uniqueIndex;size:128;not null" json:"-"`
ExpiresAt time.Time `gorm:"index" json:"expires_at"`
CreatedAt time.Time `json:"created_at"`
Revoked bool `gorm:"default:false" json:"revoked"`
ID string `gorm:"primaryKey;size:36" json:"id"`
UserID string `gorm:"index;size:36;not null" json:"user_id"`
TokenHash string `gorm:"uniqueIndex;size:128;not null" json:"-"`
// AuthSessionID 关联移动端设备会话。旧数据为空,继续按既有兼容路径使用。
AuthSessionID string `gorm:"index;size:36" json:"auth_session_id,omitempty"`
ExpiresAt time.Time `gorm:"index" json:"expires_at"`
CreatedAt time.Time `json:"created_at"`
Revoked bool `gorm:"default:false" json:"revoked"`
}
// BeforeCreate 生成 UUID。
@@ -0,0 +1,101 @@
package repository
import (
"context"
"errors"
"time"
"gorm.io/gorm"
"github.com/truewhile/MeBox/internal/model"
)
// AuthSessionRepository persists model.AuthSession records.
type AuthSessionRepository struct{ db *gorm.DB }
// Create inserts a new authentication session.
func (r *AuthSessionRepository) Create(ctx context.Context, s *model.AuthSession) error {
return withSQLiteBusyRetry(ctx, func() error {
return r.db.WithContext(ctx).Create(s).Error
})
}
// FindByID returns the session or (nil, nil) when absent.
func (r *AuthSessionRepository) FindByID(ctx context.Context, id string) (*model.AuthSession, error) {
if id == "" {
return nil, nil
}
var s model.AuthSession
err := withSQLiteBusyRetry(ctx, func() error {
s = model.AuthSession{}
return r.db.WithContext(ctx).Where("id = ?", id).First(&s).Error
})
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, nil
}
if err != nil {
return nil, err
}
return &s, nil
}
// TouchLastUsed 更新会话最近使用时间。
func (r *AuthSessionRepository) TouchLastUsed(ctx context.Context, id string, at time.Time) error {
return withSQLiteBusyRetry(ctx, func() error {
return r.db.WithContext(ctx).Model(&model.AuthSession{}).
Where("id = ? AND revoked = ?", id, false).
Update("last_used_at", at).Error
})
}
// Revoke 撤销单个会话(幂等)。
func (r *AuthSessionRepository) Revoke(ctx context.Context, id string, at time.Time) error {
return withSQLiteBusyRetry(ctx, func() error {
return r.db.WithContext(ctx).Model(&model.AuthSession{}).
Where("id = ? AND revoked = ?", id, false).
Updates(map[string]any{"revoked": true, "revoked_at": at}).Error
})
}
// RevokeByUser 撤销某用户的全部会话(幂等)。
func (r *AuthSessionRepository) RevokeByUser(ctx context.Context, userID string, at time.Time) error {
return withSQLiteBusyRetry(ctx, func() error {
return r.db.WithContext(ctx).Model(&model.AuthSession{}).
Where("user_id = ? AND revoked = ?", userID, false).
Updates(map[string]any{"revoked": true, "revoked_at": at}).Error
})
}
// RevokeByDevice 撤销某用户某设备关联的全部会话(幂等)。
func (r *AuthSessionRepository) RevokeByDevice(ctx context.Context, userID, deviceID string, at time.Time) error {
return withSQLiteBusyRetry(ctx, func() error {
return r.db.WithContext(ctx).Model(&model.AuthSession{}).
Where("user_id = ? AND device_id = ? AND revoked = ?", userID, deviceID, false).
Updates(map[string]any{"revoked": true, "revoked_at": at}).Error
})
}
// ListActiveByUser 返回某用户当前有效(未撤销、未过期)的会话。
func (r *AuthSessionRepository) ListActiveByUser(ctx context.Context, userID string) ([]model.AuthSession, error) {
var rows []model.AuthSession
err := withSQLiteBusyRetry(ctx, func() error {
rows = nil
return r.db.WithContext(ctx).
Where("user_id = ? AND revoked = ? AND expires_at > ?", userID, false, time.Now()).
Order("last_used_at desc, created_at desc").
Find(&rows).Error
})
return rows, err
}
// ListByDevice 返回某用户某设备的全部会话,用于踢出设备时逐个撤销。
func (r *AuthSessionRepository) ListByDevice(ctx context.Context, userID, deviceID string) ([]model.AuthSession, error) {
var rows []model.AuthSession
err := withSQLiteBusyRetry(ctx, func() error {
rows = nil
return r.db.WithContext(ctx).
Where("user_id = ? AND device_id = ?", userID, deviceID).
Find(&rows).Error
})
return rows, err
}
+21
View File
@@ -0,0 +1,21 @@
package repository
import "gorm.io/gorm"
// LikeOperator 返回当前方言下「与 SQLite LIKE 行为一致」的模式匹配运算符。
//
// SQLite 的 LIKE 对 ASCII 不区分大小写(默认编译选项),PostgreSQL 的 LIKE 区分
// 大小写、只有 ILIKE 才不区分。同一个搜索词在两种方言下必须给出同样的结果集,
// 否则换库之后「inception」就再也搜不到「Inception」。
//
// 供构造原始 SQL 片段的调用方(含 service 层)使用。
func LikeOperator(db *gorm.DB) string {
return likeOperator(db)
}
func likeOperator(db *gorm.DB) string {
if db != nil && db.Dialector != nil && db.Dialector.Name() == "postgres" {
return "ILIKE"
}
return "LIKE"
}
+54
View File
@@ -0,0 +1,54 @@
package repository
import (
"strings"
"testing"
"gorm.io/driver/postgres"
"gorm.io/gorm"
)
// TestSegmentPrewarmQueryBindsBooleanFound 守住 PostgreSQL 下片段预取查询的合法性。
//
// media_segment_fetches.found 是布尔列,而 PostgreSQL 没有 boolean = integer
// 运算符:写成 f.found = 0 / = 1 会让计划任务里的片段预取固定报
//
// operator does not exist: boolean = integer (SQLSTATE 42883)
//
// SQLite 一直能跑,所以这个错只在 PostgreSQL 上暴露(表现为日志里周期性出现
// 该错误、片头片尾数据永远补不上)。这里断言语句里只能出现占位符绑定。
func TestSegmentPrewarmQueryBindsBooleanFound(t *testing.T) {
query := segmentPrewarmCandidatesQuery
lower := strings.ToLower(query)
for _, forbidden := range []string{"found = 0", "found = 1", "found=0", "found=1", "found = true", "found = false"} {
if strings.Contains(lower, forbidden) {
t.Fatalf("预取查询把布尔列与字面量比较(%q),PostgreSQL 会直接报错:\n%s", forbidden, query)
}
}
if got := strings.Count(query, "f.found = ?"); got != 2 {
t.Fatalf("expected 2 placeholder-bound found comparisons, got %d:\n%s", got, query)
}
}
// TestLikeOperatorMatchesSQLiteCaseInsensitivity 守住搜索大小写行为的方言差异。
//
// SQLite 的 LIKE 对 ASCII 不区分大小写,PostgreSQL 的 LIKE 区分大小写:同一个
// 搜索词在两种方言下必须给出同样的结果集,PostgreSQL 必须走 ILIKE。
func TestLikeOperatorMatchesSQLiteCaseInsensitivity(t *testing.T) {
pg, err := gorm.Open(postgres.New(postgres.Config{
DSN: "postgres://mebox:mebox@127.0.0.1:5432/mebox?sslmode=disable",
}), &gorm.Config{
DryRun: true,
DisableAutomaticPing: true,
SkipDefaultTransaction: true,
})
if err != nil {
t.Fatalf("open dry-run postgres: %v", err)
}
if got := LikeOperator(pg); got != "ILIKE" {
t.Fatalf("postgres LikeOperator = %q, want ILIKE", got)
}
if got := LikeOperator(nil); got != "LIKE" {
t.Fatalf("nil LikeOperator = %q, want LIKE", got)
}
}
@@ -3,6 +3,7 @@ package repository
import (
"context"
"errors"
"strings"
"time"
"gorm.io/gorm"
@@ -89,6 +90,22 @@ func (r *EmbyMountRepository) SetSortOrder(ctx context.Context, ids []string) er
})
}
// SetEnabledByAccountID 批量设置账号下全部挂载的启用状态。
func (r *EmbyMountRepository) SetEnabledByAccountID(ctx context.Context, accountID string, enabled bool) (int64, error) {
var affected int64
if strings.TrimSpace(accountID) == "" {
return 0, nil
}
err := withSQLiteBusyRetry(ctx, func() error {
res := r.db.WithContext(ctx).Model(&model.EmbyMount{}).
Where("account_id = ?", accountID).
Updates(map[string]any{"enabled": enabled, "updated_at": time.Now()})
affected = res.RowsAffected
return res.Error
})
return affected, err
}
func (r *EmbyMountRepository) CountByAccountID(ctx context.Context, accountID string) (int64, error) {
var count int64
err := r.db.WithContext(ctx).Model(&model.EmbyMount{}).Where("account_id = ?", accountID).Count(&count).Error
+17 -3
View File
@@ -33,6 +33,20 @@ func (r *HistoryRepository) UpsertProgress(ctx context.Context, h *model.Playbac
return r.upsert(ctx, h, true)
}
// historyColumn 生成「当前表」限定的列引用。
//
// PostgreSQL 的 ON CONFLICT DO UPDATE ... WHERE 里,未限定的列名会同时可解析到
// 目标表和本次插入的行,于是报
//
// column reference "session_started_at_ms" is ambiguous (SQLSTATE 42702)
//
// 结果是 POST /api/history 一律 500,观看进度与续播完全存不下来。SQLite 不做这项
// 检查,所以这个错只在 PostgreSQL 上暴露。clause.CurrentTable 由 GORM 在构建语句
// 时替换成真实表名,两种方言都能正确渲染。
func historyColumn(name string) clause.Column {
return clause.Column{Table: clause.CurrentTable, Name: name}
}
func (r *HistoryRepository) upsert(ctx context.Context, h *model.PlaybackHistory, versioned bool) error {
updates := playbackHistoryAssignments(h)
if versioned {
@@ -49,16 +63,16 @@ func (r *HistoryRepository) upsert(ctx context.Context, h *model.PlaybackHistory
onConflict.Where = clause.Where{Exprs: []clause.Expression{
clause.Or(
clause.Lt{
Column: clause.Column{Name: "session_started_at_ms"},
Column: historyColumn("session_started_at_ms"),
Value: h.SessionStartedAtMs,
},
clause.And(
clause.Eq{
Column: clause.Column{Name: "session_started_at_ms"},
Column: historyColumn("session_started_at_ms"),
Value: h.SessionStartedAtMs,
},
clause.Lte{
Column: clause.Column{Name: "sequence"},
Column: historyColumn("sequence"),
Value: h.Sequence,
},
),
@@ -0,0 +1,65 @@
package repository
import (
"context"
"strings"
"testing"
"github.com/truewhile/MeBox/internal/model"
)
// TestHistoryProgressUpsertQualifiesConflictWhereColumns 守住 PostgreSQL 下进度上报
// 的 upsert 语句合法性。
//
// ON CONFLICT DO UPDATE 的 WHERE 里如果不给列名加表限定,PostgreSQL 会因为这些列
// 同时出现在本次插入的行里而拒绝该语句:
//
// column reference "session_started_at_ms" is ambiguous (SQLSTATE 42702)
//
// 后果是 POST /api/history 全部 500,观看进度与续播完全存不下来。SQLite 不做这项
// 检查(旧写法在 SQLite 下一直正常),所以只能在 PostgreSQL 方言下断言。
func TestHistoryProgressUpsertQualifiesConflictWhereColumns(t *testing.T) {
repos, rec := newPostgresDryRunRepos(t)
ctx := context.Background()
if err := repos.History.UpsertProgress(ctx, &model.PlaybackHistory{
UserID: "u-1",
MediaID: "m-1",
PositionMs: 1_000,
DurationMs: 2_000,
SessionID: "session-1",
SessionStartedAtMs: 111,
Sequence: 2,
}); err != nil {
t.Fatalf("build upsert: %v", err)
}
sql := rec.all()
if strings.TrimSpace(sql) == "" {
t.Fatal("no SQL captured from UpsertProgress")
}
if !strings.Contains(sql, "ON CONFLICT") {
t.Fatalf("expected an ON CONFLICT upsert, got:\n%s", sql)
}
// 只看 ON CONFLICT 之后的部分:SET 的赋值目标和 CASE 里的 duration_ms 引用
// 属于另一套语法规则,不在这里断言。
conflict := sql[strings.Index(sql, "ON CONFLICT"):]
whereIdx := strings.Index(conflict, " WHERE ")
if whereIdx < 0 {
t.Fatalf("ON CONFLICT clause has no DO UPDATE ... WHERE guard:\n%s", sql)
}
where := conflict[whereIdx:]
for _, want := range []string{
`"playback_histories"."session_started_at_ms"`,
`"playback_histories"."sequence"`,
} {
if !strings.Contains(where, want) {
t.Fatalf("DO UPDATE WHERE is missing the table-qualified %s; it is ambiguous on PostgreSQL:\n%s", want, sql)
}
}
if strings.Contains(where, `("session_started_at_ms"`) || strings.Contains(where, ` AND "sequence"`) {
t.Fatalf("DO UPDATE WHERE still references unqualified columns:\n%s", sql)
}
}
+19 -21
View File
@@ -63,38 +63,38 @@ type MediaQueryFilter struct {
func applyMediaQueryFilter(q *gorm.DB, filter MediaQueryFilter) *gorm.DB {
if !filter.IncludeNSFW {
q = q.Where("nsfw = ?", false)
q = q.Where("media.nsfw = ?", false)
}
if len(filter.HiddenLibraryIDs) > 0 {
q = q.Where("library_id NOT IN ?", filter.HiddenLibraryIDs)
q = q.Where("media.library_id NOT IN ?", filter.HiddenLibraryIDs)
}
if len(filter.AllowedLibraryIDs) > 0 {
q = q.Where("library_id IN ?", filter.AllowedLibraryIDs)
q = q.Where("media.library_id IN ?", filter.AllowedLibraryIDs)
}
if libraryID := strings.TrimSpace(filter.LibraryID); libraryID != "" {
q = q.Where("library_id = ?", libraryID)
q = q.Where("media.library_id = ?", libraryID)
}
if seriesID := strings.TrimSpace(filter.SeriesID); seriesID != "" {
q = q.Where("series_id = ?", seriesID)
q = q.Where("media.series_id = ?", seriesID)
}
if len(filter.Genres) > 0 {
q = q.Where(genreMatchClause(filter.Genres), genreMatchArgs(filter.Genres)...)
}
if filter.YearMin > 0 {
q = q.Where("year >= ?", filter.YearMin)
q = q.Where("media.year >= ?", filter.YearMin)
}
if filter.YearMax > 0 {
q = q.Where("year <= ?", filter.YearMax)
q = q.Where("media.year <= ?", filter.YearMax)
}
if filter.RatingMin > 0 {
q = q.Where("rating >= ?", filter.RatingMin)
q = q.Where("media.rating >= ?", filter.RatingMin)
}
if filter.UnwatchedOnly {
userID := strings.TrimSpace(filter.UnwatchedUserID)
// 没有用户上下文时忽略该条件:否则会把整个库筛成空,看起来像「坏了」。
if userID != "" {
q = q.Where(
"id NOT IN (SELECT media_id FROM playback_histories WHERE user_id = ? AND completed = ?)",
"media.id NOT IN (SELECT media_id FROM playback_histories WHERE user_id = ? AND completed = ?)",
userID, true,
)
}
@@ -113,7 +113,7 @@ func applyMediaQueryFilter(q *gorm.DB, filter MediaQueryFilter) *gorm.DB {
func genreMatchClause(genres []string) string {
clauses := make([]string, 0, len(genres))
for range genres {
clauses = append(clauses, "',' || REPLACE(REPLACE(TRIM(genres), ' ', ''), ',', ',') || ',' LIKE ?")
clauses = append(clauses, "',' || REPLACE(REPLACE(TRIM(media.genres), ' ', ''), ',', ',') || ',' LIKE ?")
}
return "(" + strings.Join(clauses, " OR ") + ")"
}
@@ -122,7 +122,7 @@ func genreMatchClause(genres []string) string {
//
// 必须与 genreMatchClause 的列端处理完全对称:
// - TRIM → TrimSpace
// - REPLACE(…, ' ', '') → ReplaceAll(…, " ", "") ← 多词类型(如 "Science Fiction")
// - REPLACE(…, ' ', ”) → ReplaceAll(…, " ", "") ← 多词类型(如 "Science Fiction")
// - REPLACE(…, ',', ',') → ReplaceAll(…, ",", ",")
func genreMatchArgs(genres []string) []any {
args := make([]any, 0, len(genres))
@@ -178,14 +178,6 @@ func (r *MediaRepository) YearRange(ctx context.Context, filter MediaQueryFilter
return min, max, nil
}
func (r *MediaRepository) indexMediaBestEffort(ctx context.Context, media model.Media) {
backend, ok := r.searchBackend.(MediaSearchSyncBackend)
if !ok {
return
}
_ = backend.IndexMedia(ctx, []model.Media{media})
}
// FindByID returns the media row or (nil, nil).
func (r *MediaRepository) FindByID(ctx context.Context, id string) (*model.Media, error) {
var m model.Media
@@ -245,10 +237,16 @@ func (r *MediaRepository) ListSeasonSiblings(ctx context.Context, m *model.Media
} else {
libraryID := strings.TrimSpace(m.LibraryID)
title := strings.TrimSpace(m.Title)
if libraryID == "" || title == "" || m.TMDbID <= 0 {
if libraryID == "" || title == "" {
return nil, nil
}
query = query.Where("library_id = ? AND title = ? AND tm_db_id = ?", libraryID, title, m.TMDbID)
// 没有 series_id 也没有 TMDb id 时,退到「同库 + 同标题」:手动标记的按季
// 传播是用户显式操作,同库同名已经足够可靠;而在缺 id 时直接放弃会让这个
// 功能在未刮削的库上完全不生效。
query = query.Where("library_id = ? AND title = ?", libraryID, title)
if m.TMDbID > 0 {
query = query.Where("tm_db_id = ?", m.TMDbID)
}
}
var rows []model.Media
if err := query.Find(&rows).Error; err != nil {
+102 -15
View File
@@ -5,8 +5,10 @@ import (
"errors"
"path/filepath"
"strings"
"time"
"gorm.io/gorm"
"gorm.io/gorm/clause"
"github.com/truewhile/MeBox/internal/model"
)
@@ -95,12 +97,34 @@ func (r *MediaRepository) UpsertBatchWithAliases(ctx context.Context, items []Me
// indexByIDBestEffort 在事务提交后按 ID 回读最新行并同步搜索索引。
func (r *MediaRepository) indexByIDBestEffort(ctx context.Context, ids []string) {
backend, ok := r.searchBackend.(MediaSearchSyncBackend)
if !ok {
return
}
unique := make([]string, 0, len(ids))
seen := make(map[string]struct{}, len(ids))
for _, id := range ids {
if id == "" {
continue
}
if fresh, err := r.FindByID(ctx, id); err == nil && fresh != nil {
r.indexMediaBestEffort(ctx, *fresh)
if _, exists := seen[id]; !exists {
seen[id] = struct{}{}
unique = append(unique, id)
}
}
// Read committed rows and submit one bulk per bounded batch. Keep network
// work outside the transaction and below SQLite's parameter limit.
const batchSize = 500
for start := 0; start < len(unique); start += batchSize {
batch := unique[start:min(start+batchSize, len(unique))]
rows, err := r.ListMediaByIDs(ctx, batch)
if err == nil {
err = backend.IndexMedia(ctx, rows)
}
if err != nil {
// The primary write has committed; do not report a failed upsert and
// make scanners repeat it. Surface the separate index failure instead.
r.db.Logger.Error(ctx, "media saved but search index sync failed for %d items: %v", len(batch), err)
}
}
}
@@ -115,7 +139,12 @@ func (r *MediaRepository) upsertWithDB(ctx context.Context, db *gorm.DB, m *mode
return m.ID, nil
}
updates := mediaUpsertUpdates(existing, *m)
// 孤儿行回收:原库已被删除时(DeleteLibrary 硬删库行;扫描与删除并发时扫描也会
// 把行写回已删库),这条行会钉死在不存在的 library_id 上。media.path 是全局唯一
// 索引,重扫既插不进同路径新行,又因 LibraryID 非空而拒绝迁移归属,于是新库/重建库
// 永远查不到这些条目(表现为"扫描明明有文件,库却是空的")。这里显式识别并允许迁移。
reclaimLibrary := r.mediaRowLibraryIsOrphaned(ctx, db, existing.LibraryID, m.LibraryID, m.Path)
updates := mediaUpsertUpdates(existing, *m, reclaimLibrary)
if adopted {
updates["path"] = m.Path
if existing.DeletedAt.Valid {
@@ -135,10 +164,52 @@ func (r *MediaRepository) upsertWithDB(ctx context.Context, db *gorm.DB, m *mode
existing.Path = m.Path
existing.DeletedAt = gorm.DeletedAt{}
}
if _, ok := updates["availability_state"]; ok {
existing.AvailabilityState = "available"
existing.MissingSince = nil
now := updates["last_seen_at"].(time.Time)
existing.LastSeenAt = &now
}
*m = existing
return existing.ID, nil
}
// mediaRowLibraryIsOrphaned reports whether a media row is still pinned to a
// library that no longer exists.
//
// 删除媒体库会硬删库行(见 MediaService.DeleteLibrary),但扫描与删除并发时,
// 已在途的扫描仍可能把这批文件写回已删库的 library_id;历史数据里也可能留下
// 指向早已消失的库的行。因为 media.path 是全局唯一索引,这些行一旦存在:
// 1. 重扫无法插入同路径的新行(唯一约束冲突);
// 2. addMediaPlacementUpdates 又因为 existing.LibraryID 非空而拒绝迁移归属。
//
// 结果就是新库 / 重建的同名库永远查不到这些条目,扫描报告有文件而库显示为空。
// 这里把"原库已不存在"这种情况单独判定出来,交给扫描把归属迁移到当前库。
func (r *MediaRepository) mediaRowLibraryIsOrphaned(ctx context.Context, db *gorm.DB, existingLibraryID, incomingLibraryID, incomingPath string) bool {
existingLibraryID = strings.TrimSpace(existingLibraryID)
incomingLibraryID = strings.TrimSpace(incomingLibraryID)
// 归属未变或原行为空(后者走既有的回填分支)时无需查库。
if existingLibraryID == "" || existingLibraryID == incomingLibraryID {
return false
}
// 云盘媒体的归属迁移由 addMediaPlacementUpdates 无条件处理,无需再查库。
if strings.HasPrefix(strings.ToLower(strings.TrimSpace(incomingPath)), "cloud://") {
return false
}
if db == nil || !db.Migrator().HasTable(&model.Library{}) {
return false
}
var count int64
// 刻意不加 Unscoped:软删除的库同样不再出现在库里,挂在它下面的媒体行
// 也属于"查不到"的孤儿,一并允许回收。查询失败时保守处理:宁可保留旧
// 归属,也不要把仍有效的库误判成孤儿。
if err := db.WithContext(ctx).Model(&model.Library{}).
Where("id = ?", existingLibraryID).Count(&count).Error; err != nil {
return false
}
return count == 0
}
func (r *MediaRepository) findOrCreateMediaByPath(ctx context.Context, db *gorm.DB, m *model.Media, aliasPaths []string) (model.Media, bool, bool, error) {
var existing model.Media
err := db.WithContext(ctx).Unscoped().Where("path = ?", m.Path).First(&existing).Error
@@ -152,14 +223,23 @@ func (r *MediaRepository) findOrCreateMediaByPath(ctx context.Context, db *gorm.
if m.ScrapeStatus == "" {
m.ScrapeStatus = "pending"
}
if createErr := db.WithContext(ctx).Create(m).Error; createErr == nil {
return *m, true, false, nil
} else if retryErr := db.WithContext(ctx).Unscoped().Where("path = ?", m.Path).First(&existing).Error; retryErr != nil {
return model.Media{}, false, false, createErr
} else {
// 并发插入竞态:重查已命中既有行,直接走更新分支。
return existing, false, false, nil
now := time.Now()
m.AvailabilityState = "available"
m.MissingSince = nil
m.LastSeenAt = &now
// PostgreSQL 唯一键异常会中止整个事务,之后重查也会失败。
// DO NOTHING 保持事务可用,并兼容 path 的表达式唯一索引。
result := db.WithContext(ctx).Clauses(clause.OnConflict{DoNothing: true}).Create(m)
if result.Error != nil {
return model.Media{}, false, false, result.Error
}
if result.RowsAffected > 0 {
return *m, true, false, nil
}
if err := db.WithContext(ctx).Unscoped().Where("path = ?", m.Path).First(&existing).Error; err != nil {
return model.Media{}, false, false, err
}
return existing, false, false, nil
}
if err != nil {
return model.Media{}, false, false, err
@@ -194,14 +274,19 @@ func (r *MediaRepository) findMediaByAlias(ctx context.Context, db *gorm.DB, cur
return existing, err
}
func mediaUpsertUpdates(existing, incoming model.Media) map[string]any {
func mediaUpsertUpdates(existing, incoming model.Media, reclaimLibrary bool) map[string]any {
updates := map[string]any{}
if existing.AvailabilityState != "available" || existing.MissingSince != nil {
updates["availability_state"] = "available"
updates["missing_since"] = nil
updates["last_seen_at"] = time.Now()
}
addMediaFileScanUpdates(updates, existing, incoming)
addMediaTitleUpdates(updates, existing, incoming)
addMediaExternalIDUpdates(updates, existing, incoming)
addMatchedMediaMetadataUpdates(updates, existing, incoming)
addMediaArtworkUpdates(updates, existing, incoming)
addMediaPlacementUpdates(updates, existing, incoming)
addMediaPlacementUpdates(updates, existing, incoming, reclaimLibrary)
addMediaSTRMUpdate(updates, existing, incoming)
return updates
}
@@ -308,13 +393,15 @@ func addMediaArtworkUpdates(updates map[string]any, existing, incoming model.Med
}
}
func addMediaPlacementUpdates(updates map[string]any, existing, incoming model.Media) {
func addMediaPlacementUpdates(updates map[string]any, existing, incoming model.Media, reclaimLibrary bool) {
// 云盘媒体:同一 cloud:// 文件可能先被父目录库扫描入库,之后用户按二级
// 分类重新挂载/扫描到更精确的分类库。此时让 library_id 迁移到当前扫描库,
// 否则媒体被钉死在旧库、新分类库里看不到(表现为"媒体部分消失")。
// 本地媒体物理位置固定:仅在原 library_id 为空时回填,不迁移。
// 本地媒体物理位置固定:仅在原 library_id 为空、或原库已不存在(孤儿行,
// reclaimLibrary)时才回填/迁移,既避免把仍属于其他库的本地媒体抢走,
// 又能让扫描回收被删除库遗留的行。
if isCloudMediaPath := strings.HasPrefix(strings.ToLower(strings.TrimSpace(incoming.Path)), "cloud://"); incoming.LibraryID != "" && incoming.LibraryID != existing.LibraryID {
if isCloudMediaPath || existing.LibraryID == "" {
if isCloudMediaPath || existing.LibraryID == "" || reclaimLibrary {
updates["library_id"] = incoming.LibraryID
}
}
+6 -24
View File
@@ -6,6 +6,7 @@ import (
"unicode"
"gorm.io/gorm"
"gorm.io/gorm/clause"
"github.com/truewhile/MeBox/internal/model"
)
@@ -86,7 +87,7 @@ func (r *MediaRepository) searchFilteredFTS(ctx context.Context, query string, o
Joins("JOIN media_search_fts ON media_search_fts.rowid = media.rowid").
Where("media.deleted_at IS NULL").
Where("media_search_fts MATCH ?", ftsQuery)
q = applyQualifiedMediaQueryFilter(q, filter)
q = applyMediaQueryFilter(q, filter)
if err := q.Count(&total).Error; err != nil {
return nil, 0, false
}
@@ -106,11 +107,8 @@ func (r *MediaRepository) searchFilteredLIKE(ctx context.Context, query string,
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"
}
// 需用 ILIKE 保持两端搜索行为一致(见 dialect.go 的 likeOperator)。
likeOp := likeOperator(r.db)
terms := mediaSearchTerms(query)
for _, term := range terms {
like := "%" + escapeLike(term) + "%"
@@ -125,10 +123,10 @@ func (r *MediaRepository) searchFilteredLIKE(ctx context.Context, query string,
if query != "" {
prefix := escapeLike(query) + "%"
exact := query
q = q.Order(gorm.Expr(
q = q.Order(clause.OrderBy{Expression: 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",
exact, exact, prefix, prefix,
))
)})
} else {
q = q.Order("created_at desc")
}
@@ -136,22 +134,6 @@ func (r *MediaRepository) searchFilteredLIKE(ctx context.Context, query string,
return items, total, err
}
func applyQualifiedMediaQueryFilter(q *gorm.DB, filter MediaQueryFilter) *gorm.DB {
if !filter.IncludeNSFW {
q = q.Where("media.nsfw = ?", false)
}
if len(filter.HiddenLibraryIDs) > 0 {
q = q.Where("media.library_id NOT IN ?", filter.HiddenLibraryIDs)
}
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
}
func mediaFTSQuery(query string) string {
terms := mediaSearchTerms(query)
if len(terms) == 0 {
+47 -18
View File
@@ -56,6 +56,23 @@ func (r *MediaSegmentRepository) ReplaceForMedia(ctx context.Context, mediaID, s
})
}
// ReplaceForMediaSourceKind swaps the segments of one (media, source, kind)
// triple in a single transaction. Used by manual marking, where the user sets
// one boundary at a time and must not disturb the other kinds.
func (r *MediaSegmentRepository) ReplaceForMediaSourceKind(ctx context.Context, mediaID, source, kind string, rows []model.MediaSegment) error {
return r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
if err := tx.Unscoped().
Where("media_id = ? AND source = ? AND kind = ?", mediaID, source, kind).
Delete(&model.MediaSegment{}).Error; err != nil {
return err
}
if len(rows) == 0 {
return nil
}
return tx.Create(&rows).Error
})
}
// GetFetch returns the fetch ledger row for (media, source), or (nil, nil).
func (r *MediaSegmentRepository) GetFetch(ctx context.Context, mediaID, source string) (*model.MediaSegmentFetch, error) {
var row model.MediaSegmentFetch
@@ -86,21 +103,15 @@ func (r *MediaSegmentRepository) UpsertFetch(ctx context.Context, row *model.Med
return r.db.WithContext(ctx).Clauses(onConflict).Create(row).Error
}
// ListPrewarmCandidates returns media that should be refreshed from source:
// no ledger, expired miss (fetched_at < missBefore), or expired hit (fetched_at < hitBefore).
// Recently played items come first so hot titles recover coverage sooner.
func (r *MediaSegmentRepository) ListPrewarmCandidates(
ctx context.Context,
source string,
missBefore, hitBefore time.Time,
limit int,
) ([]model.Media, error) {
if r == nil || limit <= 0 {
return nil, nil
}
rows := make([]model.Media, 0, limit)
// 可查询:有 TMDb,且是剧集(有季集)或电影(无季集)。
err := r.db.WithContext(ctx).Raw(`
// segmentPrewarmCandidatesQuery 是片段预取候选查询。
//
// 抽成包级常量是为了让方言相关的回归测试能直接断言语句文本:DryRun 模式下
// GORM 不会把 Raw 语句交给 logger,无法从执行路径上捕获生成后的 SQL。
//
// 注意 found 必须用占位符绑定布尔值,不能写 f.found = 0 / = 1:
// PostgreSQL 没有 boolean = integer 运算符,那样写会固定报 SQLSTATE 42883
// (SQLite 宽容,所以只在 PostgreSQL 上暴露)。
const segmentPrewarmCandidatesQuery = `
SELECT m.*
FROM media m
LEFT JOIN media_segment_fetches f
@@ -119,11 +130,29 @@ WHERE m.deleted_at IS NULL
)
AND (
f.id IS NULL
OR (f.found = 0 AND f.fetched_at < ?)
OR (f.found = 1 AND f.fetched_at < ?)
OR (f.found = ? AND f.fetched_at < ?)
OR (f.found = ? AND f.fetched_at < ?)
)
ORDER BY ph.last_played DESC
LIMIT ?
`, source, missBefore, hitBefore, limit).Scan(&rows).Error
`
// ListPrewarmCandidates returns media that should be refreshed from source:
// no ledger, expired miss (fetched_at < missBefore), or expired hit (fetched_at < hitBefore).
// Recently played items come first so hot titles recover coverage sooner.
func (r *MediaSegmentRepository) ListPrewarmCandidates(
ctx context.Context,
source string,
missBefore, hitBefore time.Time,
limit int,
) ([]model.Media, error) {
if r == nil || limit <= 0 {
return nil, nil
}
rows := make([]model.Media, 0, limit)
// 可查询:有 TMDb,且是剧集(有季集)或电影(无季集)。
err := r.db.WithContext(ctx).
Raw(segmentPrewarmCandidatesQuery, source, false, missBefore, true, hitBefore, limit).
Scan(&rows).Error
return rows, err
}
+53
View File
@@ -0,0 +1,53 @@
package repository
import (
"context"
"github.com/truewhile/MeBox/internal/model"
)
// MediaSeriesIndexColumns 是解析剧集归组 key 所需的全部列。
//
// 剧集归组(首页「最近添加」、媒体库卡片)必须看到整库每一行才能算出正确的
// key 和集数,因此不能只用 SQL 顶端的几行。但整行扫描会把 overview、genres、
// strm_url 这些大文本列一并读进来再丢掉:7k 行规模下驱动层的逐列 bind 占了
// 绝大部分耗时(实测 ~60%)。这里只取归组真正用到的列,卡片代表行在选出
// 前 N 个剧集后按 id 再补一次完整行。
const MediaSeriesIndexColumns = "" +
"id, created_at, updated_at, library_id, library_root_id, series_id, " +
"title, original_name, path, poster_url, backdrop_url, " +
"year, release_date, season_num, episode_num, episode_fraction, " +
"tm_db_id, bangumi_id, douban_id, thetvdb_id, " +
"width, height, size_bytes, video_codec, audio_codec, container, nsfw"
// ListMediaSeriesIndex 按 created_at 倒序返回可见媒体的「归组投影」。
//
// 与 SearchFiltered 的区别:
// - 不做 COUNT(*)(调用方不需要总数,7k 行时这一条 SQL 白跑一遍全表);
// - 只 SELECT 归组所需列,避免大文本列的解码与内存拷贝。
func (r *MediaRepository) ListMediaSeriesIndex(ctx context.Context, limit int, filter MediaQueryFilter) ([]model.Media, error) {
var items []model.Media
q := r.db.WithContext(ctx).Model(&model.Media{}).
Select(MediaSeriesIndexColumns).
Order("created_at desc")
q = applyMediaQueryFilter(q, filter)
if limit > 0 {
q = q.Limit(limit)
}
if err := q.Find(&items).Error; err != nil {
return nil, err
}
return items, nil
}
// ListMediaByIDs 按 ID 批量取完整行(用于补齐剧集卡片的代表行)。
func (r *MediaRepository) ListMediaByIDs(ctx context.Context, ids []string) ([]model.Media, error) {
if len(ids) == 0 {
return nil, nil
}
var items []model.Media
if err := r.db.WithContext(ctx).Where("id IN ?", ids).Find(&items).Error; err != nil {
return nil, err
}
return items, nil
}

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