Compare commits

..

14 Commits

Author SHA1 Message Date
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
98 changed files with 5620 additions and 568 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"]
+1 -1
View File
@@ -88,7 +88,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)
}
+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)
}
}
+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
+1
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", "")
+6 -1
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 密钥(不要提交值)。
+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
}
+179 -9
View File
@@ -1,13 +1,25 @@
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 {
@@ -32,6 +44,9 @@ func AutoMigrate(db *gorm.DB) error {
if err := ensurePerformanceIndexes(db); err != nil {
return err
}
if err := ensureMediaPathIndex(db); err != nil {
return err
}
if err := ensureLibraryRootsCompatibility(db); err != nil {
return err
}
@@ -44,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
}
@@ -176,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
}
@@ -193,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
@@ -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())
+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")
}
}
+14
View File
@@ -810,6 +810,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) {
+111
View File
@@ -0,0 +1,111 @@
package handler
import (
"encoding/json"
"fmt"
"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/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"},
}},
})
}))
defer upstream.Close()
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
if err != nil {
t.Fatal(err)
}
if err := db.AutoMigrate(&model.Library{}, &model.Media{}, &model.StrmAccount{}, &model.EmbyMount{}, &model.Setting{}, &model.User{}, &model.PlayProfile{}); err != nil {
t.Fatal(err)
}
repos := repository.New(db)
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
}
}
+3 -1
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)
}
@@ -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)
}
}
+11 -7
View File
@@ -26,13 +26,17 @@ 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"`
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"`
+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)
}
}
+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)
}
}
+11 -11
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))
+16 -7
View File
@@ -7,6 +7,7 @@ import (
"strings"
"gorm.io/gorm"
"gorm.io/gorm/clause"
"github.com/truewhile/MeBox/internal/model"
)
@@ -95,6 +96,9 @@ func (r *MediaRepository) UpsertBatchWithAliases(ctx context.Context, items []Me
// indexByIDBestEffort 在事务提交后按 ID 回读最新行并同步搜索索引。
func (r *MediaRepository) indexByIDBestEffort(ctx context.Context, ids []string) {
if _, ok := r.searchBackend.(MediaSearchSyncBackend); !ok {
return
}
for _, id := range ids {
if id == "" {
continue
@@ -193,14 +197,19 @@ 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
// 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
+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 {
+30 -18
View File
@@ -86,21 +86,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 +113,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
}
+35 -4
View File
@@ -6,6 +6,7 @@ import (
"errors"
"gorm.io/gorm"
"gorm.io/gorm/clause"
"github.com/truewhile/MeBox/internal/model"
)
@@ -15,6 +16,17 @@ type ReaderRepository struct {
db *gorm.DB
}
// readerOrderBy 组装方言安全的 ORDER BY。
//
// 用 clause.Column 让 GORM 按当前方言给列名加引号(SQLite 反引号 / PostgreSQL 双引号),
// 而不是把引号硬编码进排序字符串。reader_books.order、reader_chapters.index、
// reader_replace_rules.order 都撞上了 SQL 关键字(order 在 PostgreSQL 里是保留字):
// 之前写成 .Order("`order` ASC"),SQLite 下正常,PostgreSQL 下反引号被词法分析成
// 运算符,直接报 `syntax error at or near "order"`,书架 / 目录 / 替换规则整页 500。
func readerOrderBy(columns ...clause.OrderByColumn) clause.OrderBy {
return clause.OrderBy{Columns: columns}
}
// ListSources 用户的书源列表(书源按用户独立,按 customOrder 排序)。
func (r *ReaderRepository) ListSources(ctx context.Context, userID string) ([]model.ReaderBookSource, error) {
var out []model.ReaderBookSource
@@ -153,7 +165,11 @@ func (r *ReaderRepository) SaveSourceState(ctx context.Context, st *model.Reader
// ListBooks 用户书架(按 order 排序)。
func (r *ReaderRepository) ListBooks(ctx context.Context, userID string) ([]model.ReaderBook, error) {
var out []model.ReaderBook
err := r.db.WithContext(ctx).Where("user_id = ?", userID).Order("`order` ASC, updated_at DESC").Find(&out).Error
err := r.db.WithContext(ctx).Where("user_id = ?", userID).
Order(readerOrderBy(
clause.OrderByColumn{Column: clause.Column{Name: "order"}},
clause.OrderByColumn{Column: clause.Column{Name: "updated_at"}, Desc: true},
)).Find(&out).Error
return out, err
}
@@ -231,7 +247,10 @@ func (r *ReaderRepository) SwitchBookOrigin(ctx context.Context, book *model.Rea
// ListChapters 按序取章节。
func (r *ReaderRepository) ListChapters(ctx context.Context, bookID string) ([]model.ReaderChapter, error) {
var out []model.ReaderChapter
err := r.db.WithContext(ctx).Where("book_id = ?", bookID).Order("`index` ASC").Find(&out).Error
err := r.db.WithContext(ctx).Where("book_id = ?", bookID).
Order(readerOrderBy(
clause.OrderByColumn{Column: clause.Column{Name: "index"}},
)).Find(&out).Error
return out, err
}
@@ -365,9 +384,17 @@ func (r *ReaderRepository) CountChaptersByBook(ctx context.Context, bookIDs []st
}
// GetChapter 取指定章节。
//
// index 在 PostgreSQL 里是保留字,必须让 GORM 按方言加引号(SQLite 反引号 /
// PostgreSQL 双引号)。此前写成硬编码的 "book_id = ? AND `index` = ?",
// PostgreSQL 会把反引号词法分析成运算符并报 syntax error at or near "`"。
func (r *ReaderRepository) GetChapter(ctx context.Context, bookID string, index int) (*model.ReaderChapter, error) {
var out model.ReaderChapter
if err := r.db.WithContext(ctx).First(&out, "book_id = ? AND `index` = ?", bookID, index).Error; err != nil {
err := r.db.WithContext(ctx).
Where(clause.Eq{Column: clause.Column{Name: "book_id"}, Value: bookID}).
Where(clause.Eq{Column: clause.Column{Name: "index"}, Value: index}).
First(&out).Error
if err != nil {
return nil, err
}
return &out, nil
@@ -433,7 +460,11 @@ func (r *ReaderRepository) SaveBookGroups(ctx context.Context, row *model.Reader
// ListReplaceRules 用户替换规则(按 order 排序)。
func (r *ReaderRepository) ListReplaceRules(ctx context.Context, userID string) ([]model.ReaderReplaceRule, error) {
var out []model.ReaderReplaceRule
err := r.db.WithContext(ctx).Where("user_id = ?", userID).Order("`order` DESC, created_at ASC").Find(&out).Error
err := r.db.WithContext(ctx).Where("user_id = ?", userID).
Order(readerOrderBy(
clause.OrderByColumn{Column: clause.Column{Name: "order"}, Desc: true},
clause.OrderByColumn{Column: clause.Column{Name: "created_at"}},
)).Find(&out).Error
return out, err
}
@@ -0,0 +1,116 @@
package repository
import (
"context"
"strings"
"sync"
"testing"
"time"
"gorm.io/driver/postgres"
"gorm.io/gorm"
gormlogger "gorm.io/gorm/logger"
)
// sqlCaptureLogger 只记录 GORM 生成的 SQL,不打印任何日志。
type sqlCaptureLogger struct {
mu sync.Mutex
lines []string
}
func (l *sqlCaptureLogger) LogMode(gormlogger.LogLevel) gormlogger.Interface { return l }
func (l *sqlCaptureLogger) Info(context.Context, string, ...interface{}) {}
func (l *sqlCaptureLogger) Warn(context.Context, string, ...interface{}) {}
func (l *sqlCaptureLogger) Error(context.Context, string, ...interface{}) {}
func (l *sqlCaptureLogger) Trace(_ context.Context, _ time.Time, fc func() (string, int64), _ error) {
sql, _ := fc()
l.mu.Lock()
l.lines = append(l.lines, sql)
l.mu.Unlock()
}
func (l *sqlCaptureLogger) all() string {
l.mu.Lock()
defer l.mu.Unlock()
return strings.Join(l.lines, "\n")
}
// newPostgresDryRunRepos 返回使用 PostgreSQL 方言的 repository 容器:
// DryRun 只构建 SQL 不执行,DisableAutomaticPing 让它无需真实数据库也能离线运行,
// SkipDefaultTransaction 则挡掉 GORM 默认事务包装——那一步会真的去开事务连接,
// 在 DryRun 下同样会拨号(ToSQL 内部也是这么绕开的)。于是可以直接断言生成语句
// 在 PostgreSQL 下是否合法。
func newPostgresDryRunRepos(t *testing.T) (*Container, *sqlCaptureLogger) {
t.Helper()
rec := &sqlCaptureLogger{}
db, 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,
Logger: rec,
})
if err != nil {
t.Fatalf("open dry-run postgres: %v", err)
}
return New(db), rec
}
// TestReaderListsUseDialectQuotedReservedColumns 守住阅读子系统列表查询在
// PostgreSQL 下的合法性。reader_books.order / reader_chapters.index /
// reader_replace_rules.order 都是关键字级列名(order 在 PostgreSQL 里是保留字)。
//
// 排序条件曾经写成硬编码反引号的原始字符串,例如 .Order("`order` ASC"):
// SQLite 正常,PostgreSQL 会把反引号词法分析成运算符,直接报
// `syntax error at or near "order"` (SQLSTATE 42601),书架 / 目录 / 替换规则整页 500。
// 现在改用 clause.Column,由 GORM 按方言加引号(SQLite 反引号 / PostgreSQL 双引号)。
func TestReaderListsUseDialectQuotedReservedColumns(t *testing.T) {
ctx := context.Background()
cases := []struct {
name string
run func(repos *Container) error
wants []string
}{
{
name: "books",
run: func(repos *Container) error { _, err := repos.Reader.ListBooks(ctx, "user-1"); return err },
wants: []string{`"order"`, `"updated_at" DESC`},
},
{
name: "chapters",
run: func(repos *Container) error { _, err := repos.Reader.ListChapters(ctx, "book-1"); return err },
wants: []string{`"index"`},
},
{
name: "replace rules",
run: func(repos *Container) error { _, err := repos.Reader.ListReplaceRules(ctx, "user-1"); return err },
wants: []string{`"order" DESC`, `"created_at"`},
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
repos, rec := newPostgresDryRunRepos(t)
if err := tc.run(repos); err != nil {
t.Fatalf("build query: %v", err)
}
sql := rec.all()
if strings.TrimSpace(sql) == "" {
t.Fatal("no SQL captured from the repository call")
}
if strings.Contains(sql, "`") {
t.Fatalf("SQL uses backtick quoting, which PostgreSQL rejects:\n%s", sql)
}
if !strings.Contains(sql, "ORDER BY") {
t.Fatalf("SQL has no ORDER BY clause:\n%s", sql)
}
for _, want := range tc.wants {
if !strings.Contains(sql, want) {
t.Fatalf("SQL missing %q:\n%s", want, sql)
}
}
})
}
}
+64 -18
View File
@@ -1,9 +1,13 @@
// Package service — database hot backup and restore.
//
// BackupService uses SQLite's "VACUUM INTO" (available since 3.27.0) to
// produce a consistent copy of the database without blocking writers.
// Backup files are stored under {data_dir}/backups/ with a timestamp
// suffix. The admin UI can list / download / delete / restore them.
// Backup files are stored under {data_dir}/backups/ with a timestamp suffix.
// The admin UI can list / download / delete / restore them.
//
// 两种方言的备份形态不同:
// - SQLite:用 "VACUUM INTO"(3.27.0 起可用)产出一个一致的库文件,
// 扩展名 .db;恢复即「替换库文件 + 重启」。
// - PostgreSQL:没有单文件语义,用逻辑备份(见 backup_postgres.go),
// 扩展名 .json.gz;恢复是在单个事务内重建全部表。
package service
import (
@@ -65,18 +69,34 @@ func (b *BackupService) backupFilePath(filename string) (string, error) {
return path, nil
}
// Create produces a new hot backup via "VACUUM INTO".
// isPostgres reports whether the service is backed by PostgreSQL.
//
// PostgreSQL 不支持 SQLite 的 "VACUUM INTO",按文件替换恢复也会写错目标,
// 因此备份/恢复必须按方言分支,不能共用一条路径。
func (b *BackupService) isPostgres() bool {
return b.db != nil && b.db.Dialector != nil && b.db.Dialector.Name() == "postgres"
}
// Create produces a new hot backup. SQLite uses "VACUUM INTO"; PostgreSQL uses
// a logical dump.
func (b *BackupService) Create(ctx context.Context) (*BackupInfo, error) {
dir := b.backupDir()
if err := os.MkdirAll(dir, 0o750); err != nil {
return nil, err
}
ts := time.Now().Format("20060102_150405")
dst := filepath.Join(dir, fmt.Sprintf("mebox_%s.db", ts))
// VACUUM INTO creates a clean, non-WAL copy atomically.
if err := b.db.WithContext(ctx).Exec("VACUUM INTO ?", dst).Error; err != nil {
return nil, fmt.Errorf("VACUUM INTO failed: %w", err)
var (
dst string
err error
)
if b.isPostgres() {
dst, err = b.createPostgresBackup(ctx, ts)
} else {
dst, err = b.createSQLiteBackup(ctx, ts)
}
if err != nil {
return nil, err
}
stat, err := os.Stat(dst)
if err != nil {
@@ -91,6 +111,15 @@ func (b *BackupService) Create(ctx context.Context) (*BackupInfo, error) {
}, nil
}
func (b *BackupService) createSQLiteBackup(ctx context.Context, stamp string) (string, error) {
dst := filepath.Join(b.backupDir(), fmt.Sprintf("mebox_%s.db", stamp))
// VACUUM INTO creates a clean, non-WAL copy atomically.
if err := b.db.WithContext(ctx).Exec("VACUUM INTO ?", dst).Error; err != nil {
return "", fmt.Errorf("VACUUM INTO failed: %w", err)
}
return dst, nil
}
// List returns every backup file in the backup directory, newest first.
func (b *BackupService) List() ([]BackupInfo, error) {
dir := b.backupDir()
@@ -103,7 +132,7 @@ func (b *BackupService) List() ([]BackupInfo, error) {
}
out := make([]BackupInfo, 0, len(entries))
for _, e := range entries {
if e.IsDir() || !strings.HasSuffix(e.Name(), ".db") {
if e.IsDir() || !isBackupFilename(e.Name()) {
continue
}
info, err := e.Info()
@@ -132,9 +161,10 @@ func (b *BackupService) Delete(filename string) error {
return os.Remove(path)
}
// Restore copies a backup over the live database using VACUUM INTO in
// reverse. WARNING: this is destructive — the live DB will be replaced.
// Callers should shut down the server after this call.
// Restore replaces the live database with the given backup.
// WARNING: this is destructive. SQLite restores by swapping the database file
// (the caller should shut down the server afterwards); PostgreSQL restores by
// rebuilding every table inside a single transaction.
func (b *BackupService) Restore(ctx context.Context, filename string) error {
src, err := b.backupFilePath(filename)
if err != nil {
@@ -143,6 +173,19 @@ func (b *BackupService) Restore(ctx context.Context, filename string) error {
if _, err := os.Stat(src); err != nil {
return fmt.Errorf("backup not found: %s", filename)
}
if b.isPostgres() {
if !strings.HasSuffix(filename, postgresBackupSuffix) {
return errors.New("该备份是 SQLite 文件备份,当前运行在 PostgreSQL 上,无法恢复")
}
return b.restorePostgresBackup(ctx, src)
}
if !strings.HasSuffix(filename, ".db") {
return errors.New("该备份是 PostgreSQL 逻辑备份,当前运行在 SQLite 上,无法恢复")
}
return b.restoreSQLiteBackup(filename, src)
}
func (b *BackupService) restoreSQLiteBackup(filename, src string) error {
dbPath, err := filepath.Abs(b.cfg.Database.DBPath)
if err != nil || dbPath == "" {
return errors.New("invalid database path")
@@ -171,7 +214,13 @@ func (b *BackupService) Restore(ctx context.Context, filename string) error {
return nil
}
// isValidBackupFilename rejects path traversal attempts and non-.db files.
// isBackupFilename reports whether name looks like a backup produced by either
// dialect (SQLite ".db" or PostgreSQL ".json.gz").
func isBackupFilename(name string) bool {
return strings.HasSuffix(name, ".db") || strings.HasSuffix(name, postgresBackupSuffix)
}
// isValidBackupFilename rejects path traversal attempts and non-backup files.
func isValidBackupFilename(name string) bool {
if name == "" {
return false
@@ -179,8 +228,5 @@ func isValidBackupFilename(name string) bool {
if strings.ContainsAny(name, "/\\") || strings.Contains(name, "..") {
return false
}
if !strings.HasSuffix(name, ".db") {
return false
}
return true
return isBackupFilename(name)
}
+280
View File
@@ -0,0 +1,280 @@
package service
import (
"compress/gzip"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"os"
"path/filepath"
"reflect"
"time"
"gorm.io/gorm"
"gorm.io/gorm/schema"
"github.com/truewhile/MeBox/internal/database"
"github.com/truewhile/MeBox/internal/model"
)
// PostgreSQL 逻辑备份 / 恢复。
//
// SQLite 的备份是「VACUUM INTO 出一个一致的库文件,恢复时替换文件」,而
// PostgreSQL 既没有 VACUUM INTO,数据也由服务端进程管理,没有可替换的单文件,
// 所以这里实现逻辑备份:把每张表按主键顺序分块序列化成 JSON Lines(gzip 压缩)
// 写入 {data_dir}/backups/。
//
// 分块序列化而不是整表 marshal:media 表在真实媒体库上可达几十万行、每行还带
// overview / path 等长文本,整表进内存会把小内存的 NAS 打爆。
//
// 恢复在单个事务内「先清空全部模型表、再逐块插入」:任何一步失败(包括备份文件
// 读到一半损坏)都整体回滚,不会留下半清空的库。因此恢复不需要重启服务。
const (
postgresBackupSuffix = ".json.gz"
postgresBackupFormat = "mebox-postgres-logical"
postgresBackupVersion = 1
backupDumpChunkSize = 1000
backupRestoreBatchSize = 500
)
type postgresBackupHeader struct {
Format string `json:"format"`
Version int `json:"version"`
CreatedAt time.Time `json:"created_at"`
}
// postgresBackupRow 是「列名 → JSON 值」的一行。
//
// 刻意逐列反射序列化,而不是直接 json.Marshal 模型结构体:模型里有大量带
// json:"-" 的列(Base.DeletedAt、User.AllowedLibraryIDs、APIConfig.APIKey、
// StrmAccount.Config、PlayProfile.PINHash、ReaderBookSource.RawJSON…),走结构体
// 自带的序列化会把这些列整列丢掉——软删除行会在恢复后「复活」,加密后的第三方
// 密钥与 PIN 会全部丢失。
type postgresBackupRow map[string]json.RawMessage
type postgresBackupChunk struct {
Table string `json:"table"`
Rows []postgresBackupRow `json:"rows"`
}
// parseModelSchema 返回模型对应的表名与 GORM schema(逐列反射所需)。
func parseModelSchema(db *gorm.DB, m any) (table string, sch *schema.Schema, err error) {
stmt := &gorm.Statement{DB: db}
if err := stmt.Parse(m); err != nil {
return "", nil, err
}
return stmt.Schema.Table, stmt.Schema, nil
}
// postgresBackupModelSchemas 返回「表名 → 模型 schema / 结构体类型」,用于把备份
// 里的行还原成 GORM 能插入的具体类型。
func postgresBackupModelSchemas(db *gorm.DB) (map[string]*schema.Schema, map[string]reflect.Type, error) {
schemas := make(map[string]*schema.Schema, len(model.AllModels()))
types := make(map[string]reflect.Type, len(model.AllModels()))
for _, m := range model.AllModels() {
table, sch, err := parseModelSchema(db, m)
if err != nil {
return nil, nil, err
}
schemas[table] = sch
types[table] = reflect.TypeOf(m).Elem()
}
return schemas, types, nil
}
// createPostgresBackup 把全库导出成一个 gzip 压缩的 JSON Lines 文件。
func (b *BackupService) createPostgresBackup(ctx context.Context, stamp string) (string, error) {
dst := filepath.Join(b.backupDir(), "mebox_"+stamp+postgresBackupSuffix)
file, err := os.OpenFile(dst, os.O_CREATE|os.O_TRUNC|os.O_WRONLY, 0o600)
if err != nil {
return "", err
}
complete := false
defer func() {
_ = file.Close()
if !complete {
// 半截备份文件比没有备份更危险:留着会让人以为有可用的还原点。
_ = os.Remove(dst)
}
}()
zw := gzip.NewWriter(file)
enc := json.NewEncoder(zw)
if err := enc.Encode(postgresBackupHeader{
Format: postgresBackupFormat,
Version: postgresBackupVersion,
CreatedAt: time.Now(),
}); err != nil {
return "", fmt.Errorf("写入备份头失败: %w", err)
}
for _, m := range model.AllModels() {
table, sch, err := parseModelSchema(b.db, m)
if err != nil {
return "", err
}
if !b.db.Migrator().HasTable(table) {
continue
}
if err := b.dumpPostgresTable(ctx, m, sch, table, enc); err != nil {
return "", err
}
}
if err := zw.Close(); err != nil {
return "", fmt.Errorf("压缩备份失败: %w", err)
}
if err := file.Sync(); err != nil {
return "", fmt.Errorf("写入备份失败: %w", err)
}
complete = true
return dst, nil
}
// dumpPostgresTable 按主键分块导出一张表。FindInBatches 会自己按主键升序游标
// 翻页(WHERE pk > last),不会像 OFFSET 分页那样在大表上做 O(n²) 扫描。
func (b *BackupService) dumpPostgresTable(ctx context.Context, m any, sch *schema.Schema, table string, enc *json.Encoder) error {
// 软删除行也要备份:恢复后应还原成删除前的样子,而不是让它们「复活」。
slicePtr := reflect.New(reflect.SliceOf(reflect.TypeOf(m).Elem()))
err := b.db.WithContext(ctx).Unscoped().
FindInBatches(slicePtr.Interface(), backupDumpChunkSize, func(_ *gorm.DB, _ int) error {
rows, err := encodePostgresBackupRows(ctx, sch, slicePtr.Elem())
if err != nil {
return fmt.Errorf("编码表 %s 失败: %w", table, err)
}
return enc.Encode(postgresBackupChunk{Table: table, Rows: rows})
}).Error
if err != nil {
return fmt.Errorf("导出表 %s 失败: %w", table, err)
}
return nil
}
// backupPersistableFields 返回真正对应数据库列的字段。
//
// GORM 的 Schema.Fields 里还带着 gorm:"-" 的字段(例如 User.AllowedLibraryList、
// LibraryTagList),它们的 DBName 是空字符串。不过滤的话多个「列」会共用空键:
// 编码时互相覆盖,解码时把某个字段的值写到另一个字段上,报出
// 「cannot unmarshal number into Go value of type []string」这类与真实数据无关的错。
func backupPersistableFields(sch *schema.Schema) []*schema.Field {
out := make([]*schema.Field, 0, len(sch.Fields))
for _, field := range sch.Fields {
if field.DBName == "" {
continue
}
out = append(out, field)
}
return out
}
// encodePostgresBackupRows 逐列反射序列化一个批次。
func encodePostgresBackupRows(ctx context.Context, sch *schema.Schema, slice reflect.Value) ([]postgresBackupRow, error) {
fields := backupPersistableFields(sch)
rows := make([]postgresBackupRow, 0, slice.Len())
for i := 0; i < slice.Len(); i++ {
element := slice.Index(i)
row := make(postgresBackupRow, len(fields))
for _, field := range fields {
value := field.ReflectValueOf(ctx, element)
if !value.IsValid() {
continue
}
raw, err := json.Marshal(value.Interface())
if err != nil {
return nil, fmt.Errorf("列 %s: %w", field.DBName, err)
}
row[field.DBName] = raw
}
rows = append(rows, row)
}
return rows, nil
}
// decodePostgresBackupRows 把「列名 → JSON 值」还原成可直接插入的具体切片。
func decodePostgresBackupRows(ctx context.Context, sch *schema.Schema, modelType reflect.Type, rows []postgresBackupRow) (any, error) {
fields := backupPersistableFields(sch)
slice := reflect.MakeSlice(reflect.SliceOf(modelType), len(rows), len(rows))
for i, row := range rows {
element := slice.Index(i)
for _, field := range fields {
raw, ok := row[field.DBName]
if !ok {
continue
}
holder := reflect.New(field.FieldType)
if err := json.Unmarshal(raw, holder.Interface()); err != nil {
return nil, fmt.Errorf("列 %s: %w", field.DBName, err)
}
if err := field.Set(ctx, element, holder.Elem().Interface()); err != nil {
return nil, fmt.Errorf("列 %s: %w", field.DBName, err)
}
}
}
return slice.Interface(), nil
}
// restorePostgresBackup 用逻辑备份重建全库。
func (b *BackupService) restorePostgresBackup(ctx context.Context, src string) error {
file, err := os.Open(src)
if err != nil {
return err
}
defer file.Close()
zr, err := gzip.NewReader(file)
if err != nil {
return fmt.Errorf("备份文件不是有效的 gzip: %w", err)
}
defer zr.Close()
dec := json.NewDecoder(zr)
var header postgresBackupHeader
if err := dec.Decode(&header); err != nil {
return fmt.Errorf("备份头损坏: %w", err)
}
if header.Format != postgresBackupFormat || header.Version != postgresBackupVersion {
return fmt.Errorf("不支持的备份格式 %q (v%d)", header.Format, header.Version)
}
schemas, modelTypes, err := postgresBackupModelSchemas(b.db)
if err != nil {
return err
}
// 恢复必须在单个事务里完成:清空成功后中途失败若不回滚,库就空了。
return b.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
// 逆序清空,保证 library_roots → libraries 这类外键先删子表。
models := model.AllModels()
for i := len(models) - 1; i >= 0; i-- {
m := models[i]
if !tx.Migrator().HasTable(m) {
continue
}
if err := tx.Session(&gorm.Session{AllowGlobalUpdate: true}).Unscoped().Delete(m).Error; err != nil {
return fmt.Errorf("清空表失败: %w", err)
}
}
for {
var chunk postgresBackupChunk
decodeErr := dec.Decode(&chunk)
if errors.Is(decodeErr, io.EOF) {
return nil
}
if decodeErr != nil {
return fmt.Errorf("备份文件损坏: %w", decodeErr)
}
sch, ok := schemas[chunk.Table]
if !ok {
return fmt.Errorf("备份包含未知表 %s,可能来自不兼容的版本", chunk.Table)
}
if len(chunk.Rows) == 0 {
continue
}
rows, err := decodePostgresBackupRows(ctx, sch, modelTypes[chunk.Table], chunk.Rows)
if err != nil {
return fmt.Errorf("表 %s 解码失败: %w", chunk.Table, err)
}
if err := database.InsertPreservingValues(tx.Unscoped(), rows, backupRestoreBatchSize); err != nil {
return fmt.Errorf("恢复表 %s 失败: %w", chunk.Table, err)
}
}
})
}
+226
View File
@@ -0,0 +1,226 @@
package service
import (
"compress/gzip"
"context"
"os"
"path/filepath"
"reflect"
"testing"
"go.uber.org/zap"
"github.com/truewhile/MeBox/internal/config"
"github.com/truewhile/MeBox/internal/model"
)
func newBackupTestService(t *testing.T) (*BackupService, *config.Config) {
t.Helper()
cfg := &config.Config{}
cfg.App.DataDir = t.TempDir()
db := newServiceTestDB(t)
svc := &BackupService{cfg: cfg, log: zap.NewNop(), db: db}
// Create 会先建目录;直接调各方言的 create* 助手时需要它已存在。
if err := os.MkdirAll(svc.backupDir(), 0o750); err != nil {
t.Fatal(err)
}
return svc, cfg
}
// TestPostgresLogicalBackupRoundTrip 验证「备份 → 改库 → 恢复」后整库回到备份时的
// 样子,包括软删除行与 json:"-" 的列。
//
// 逻辑备份/恢复本身是方言无关的 GORM 代码(PostgreSQL 专有的只有文件后缀与
// isPostgres() 分派),所以可以直接在 SQLite 上跑。
func TestPostgresLogicalBackupRoundTrip(t *testing.T) {
svc, _ := newBackupTestService(t)
ctx := context.Background()
db := svc.db
lib := model.Library{Base: model.Base{ID: "lib-1"}, Name: "Movies", Path: "/media/movies", Type: "movie", Enabled: true}
if err := db.Create(&lib).Error; err != nil {
t.Fatal(err)
}
if err := db.Create(&model.LibraryRoot{Base: model.Base{ID: "root-1"}, LibraryID: lib.ID, Path: "/media/movies"}).Error; err != nil {
t.Fatal(err)
}
if err := db.Create(&model.Media{Base: model.Base{ID: "m-keep"}, LibraryID: lib.ID, Title: "Keep", Path: "/media/movies/keep.mkv"}).Error; err != nil {
t.Fatal(err)
}
if err := db.Create(&model.Media{Base: model.Base{ID: "m-gone"}, LibraryID: lib.ID, Title: "Gone", Path: "/media/movies/gone.mkv"}).Error; err != nil {
t.Fatal(err)
}
if err := db.Delete(&model.Media{}, "id = ?", "m-gone").Error; err != nil {
t.Fatal(err)
}
if err := db.Create(&model.Setting{Key: "k", Value: "v"}).Error; err != nil {
t.Fatal(err)
}
// 下面这些列都带 json:"-":走结构体自带的 JSON 序列化会被整列丢掉,
// 恢复之后用户权限、置顶库、加密后的密钥会一起消失。
user := model.User{
Base: model.Base{ID: "u-1"},
Username: "alice",
PasswordHash: "hash",
Role: "user",
AllowedLibraryIDs: `["lib-1"]`,
PinnedLibraryIDs: `["lib-1"]`,
HideAdult: true,
IsActive: true,
DanmakuEnabled: true,
PlayerVr360GuideSeen: true,
}
if err := db.Create(&user).Error; err != nil {
t.Fatal(err)
}
if err := db.Create(&model.APIConfig{Base: model.Base{ID: "cfg-1"}, Provider: "tmdb", APIKey: "ciphertext", Enabled: true}).Error; err != nil {
t.Fatal(err)
}
dump, err := svc.createPostgresBackup(ctx, "20260101_000000")
if err != nil {
t.Fatalf("createPostgresBackup: %v", err)
}
if dump == "" {
t.Fatal("createPostgresBackup returned an empty path")
}
// 破坏当前数据:加一条备份里没有的、硬删一条备份里有的。
if err := db.Create(&model.Media{Base: model.Base{ID: "m-extra"}, LibraryID: lib.ID, Title: "Extra", Path: "/media/movies/extra.mkv"}).Error; err != nil {
t.Fatal(err)
}
if err := db.Unscoped().Delete(&model.Media{}, "id = ?", "m-keep").Error; err != nil {
t.Fatal(err)
}
if err := svc.restorePostgresBackup(ctx, dump); err != nil {
t.Fatalf("restorePostgresBackup: %v", err)
}
var ids []string
if err := db.Unscoped().Model(&model.Media{}).Order("id asc").Pluck("id", &ids).Error; err != nil {
t.Fatal(err)
}
if want := []string{"m-gone", "m-keep"}; !reflect.DeepEqual(ids, want) {
t.Fatalf("media ids after restore = %v, want %v", ids, want)
}
// 软删除状态必须原样还原,而不是被「复活」。
var gone model.Media
if err := db.Unscoped().First(&gone, "id = ?", "m-gone").Error; err != nil {
t.Fatal(err)
}
if !gone.DeletedAt.Valid {
t.Fatal("soft-deleted media was revived by restore")
}
var live int64
if err := db.Model(&model.Media{}).Where("id = ?", "m-keep").Count(&live).Error; err != nil {
t.Fatal(err)
}
if live != 1 {
t.Fatalf("live media count = %d, want 1", live)
}
var value string
if err := db.Model(&model.Setting{}).Where("key = ?", "k").Select("value").Scan(&value).Error; err != nil {
t.Fatal(err)
}
if value != "v" {
t.Fatalf("setting value = %q, want v", value)
}
var libCount int64
if err := db.Model(&model.Library{}).Count(&libCount).Error; err != nil {
t.Fatal(err)
}
if libCount != 1 {
t.Fatalf("library count = %d, want 1", libCount)
}
var rootCount int64
if err := db.Model(&model.LibraryRoot{}).Count(&rootCount).Error; err != nil {
t.Fatal(err)
}
if rootCount != 1 {
t.Fatalf("library root count = %d, want 1", rootCount)
}
// json:"-" 的列必须同样被备份:它们是权限与凭据,丢了比丢一条媒体严重得多。
var restoredUser model.User
if err := db.First(&restoredUser, "id = ?", "u-1").Error; err != nil {
t.Fatal(err)
}
if restoredUser.PasswordHash != "hash" {
t.Fatalf("password hash = %q, want hash", restoredUser.PasswordHash)
}
if restoredUser.AllowedLibraryIDs != `["lib-1"]` {
t.Fatalf(`allowed library ids = %q, want ["lib-1"]`, restoredUser.AllowedLibraryIDs)
}
if restoredUser.PinnedLibraryIDs != `["lib-1"]` {
t.Fatalf(`pinned library ids = %q, want ["lib-1"]`, restoredUser.PinnedLibraryIDs)
}
if !restoredUser.HideAdult || !restoredUser.IsActive || !restoredUser.DanmakuEnabled || !restoredUser.PlayerVr360GuideSeen {
t.Fatalf("boolean columns lost: %+v", restoredUser)
}
var restoredConfig model.APIConfig
if err := db.First(&restoredConfig, "id = ?", "cfg-1").Error; err != nil {
t.Fatal(err)
}
if restoredConfig.APIKey != "ciphertext" {
t.Fatalf("api key = %q, want ciphertext", restoredConfig.APIKey)
}
}
// TestRestorePostgresBackupKeepsDataOnCorruptFile 验证损坏的备份不会在回滚后留下
// 半清空的库:恢复是先清空再插入,必须整体在一个事务里,否则中途失败就把库清掉了。
func TestRestorePostgresBackupKeepsDataOnCorruptFile(t *testing.T) {
svc, _ := newBackupTestService(t)
ctx := context.Background()
if err := svc.db.Create(&model.Setting{Key: "keep", Value: "me"}).Error; err != nil {
t.Fatal(err)
}
corrupt := filepath.Join(t.TempDir(), "broken.json.gz")
file, err := os.Create(corrupt)
if err != nil {
t.Fatal(err)
}
zw := gzip.NewWriter(file)
// 头部完整、正文半截:解码在清空表之后才会失败。
if _, err := zw.Write([]byte(
`{"format":"mebox-postgres-logical","version":1,"created_at":"2026-01-01T00:00:00Z"}` + "\n" +
`{"table":"media","rows":[`)); err != nil {
t.Fatal(err)
}
if err := zw.Close(); err != nil {
t.Fatal(err)
}
if err := file.Close(); err != nil {
t.Fatal(err)
}
if err := svc.restorePostgresBackup(ctx, corrupt); err == nil {
t.Fatal("corrupt backup should fail to restore")
}
var value string
if err := svc.db.Model(&model.Setting{}).Where("key = ?", "keep").Select("value").Scan(&value).Error; err != nil {
t.Fatal(err)
}
if value != "me" {
t.Fatalf("a failed restore wiped the database: settings keep = %q", value)
}
}
// TestRestoreRejectsBackupFromOtherDialect 验证备份文件与当前方言不匹配时给出明确
// 错误,而不是把 SQLite 文件写到 PostgreSQL 部署的库路径上(那是原实现的隐患)。
func TestRestoreRejectsBackupFromOtherDialect(t *testing.T) {
svc, _ := newBackupTestService(t)
pgStyle := filepath.Join(svc.backupDir(), "mebox_20260101_000000.json.gz")
if err := os.WriteFile(pgStyle, []byte("not-gzip"), 0o600); err != nil {
t.Fatal(err)
}
// SQLite 路径下不允许恢复 PostgreSQL 逻辑备份。
if err := svc.Restore(context.Background(), filepath.Base(pgStyle)); err == nil {
t.Fatal("SQLite restore accepted a PostgreSQL logical backup")
}
}
@@ -0,0 +1,34 @@
package service
import (
"testing"
"github.com/truewhile/MeBox/internal/model"
)
func TestPostgresLogicalBackupPreservesZeroValues(t *testing.T) {
svc, _ := newBackupTestService(t)
user := model.User{Username: "disabled", PasswordHash: "hash"}
if err := svc.db.Create(&user).Error; err != nil {
t.Fatal(err)
}
if err := svc.db.Model(&user).Updates(map[string]any{
"is_active": false, "hide_adult": false, "player_volume": 0, "danmaku_enabled": false,
}).Error; err != nil {
t.Fatal(err)
}
dump, err := svc.createPostgresBackup(t.Context(), "zero-values")
if err != nil {
t.Fatal(err)
}
if err := svc.restorePostgresBackup(t.Context(), dump); err != nil {
t.Fatal(err)
}
var got model.User
if err := svc.db.First(&got, "id = ?", user.ID).Error; err != nil {
t.Fatal(err)
}
if got.IsActive || got.HideAdult || got.PlayerVolume != 0 || got.DanmakuEnabled {
t.Fatalf("restore replaced zero values: active=%v hide_adult=%v volume=%v danmaku=%v", got.IsActive, got.HideAdult, got.PlayerVolume, got.DanmakuEnabled)
}
}
@@ -0,0 +1,157 @@
package service
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
"go.uber.org/zap"
"github.com/truewhile/MeBox/internal/config"
"github.com/truewhile/MeBox/internal/model"
"github.com/truewhile/MeBox/internal/repository"
)
// newDanmakuRemoteResolverTest 起一个假的远程 Emby(账号 + 挂载齐备),返回接好
// 弹幕解析器的 builder。
func newDanmakuRemoteResolverTest(t *testing.T, handler http.Handler) *serviceContainerBuilder {
t.Helper()
server := httptest.NewServer(handler)
t.Cleanup(server.Close)
db := newServiceTestDB(t, &model.StrmAccount{}, &model.EmbyMount{})
repos := repository.New(db)
cfg := &config.Config{}
remote := NewEmbyRemoteService(cfg, zap.NewNop(), repos, NewCryptoService("", zap.NewNop()))
remote.SetRuntimeCache(NewRuntimeCacheService(cfg, zap.NewNop()))
rawConfig, _ := json.Marshal(map[string]string{
"url": server.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.Fatalf("create account: %v", 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.Fatalf("create mount: %v", err)
}
return &serviceContainerBuilder{
cfg: cfg,
log: zap.NewNop(),
repos: repos,
c: &Container{Cfg: cfg, Log: zap.NewNop(), Repo: repos, EmbyRemote: remote},
}
}
func danmakuResolverTestEpisode(id string) map[string]any {
return map[string]any{
"Id": id,
"Name": "第 1 集",
"Type": "Episode",
"Path": "/media/anime/PSYREN/" + id + ".mkv",
"MediaSources": []any{
map[string]any{"Id": id + "-src", "Container": "mkv", "Size": int64(1 << 20)},
},
}
}
// 目录类条目(整部剧/季)自身没有媒体源,拿它的 ID 去请求远程 /Videos/{id}/stream 只会
// 回 500,哈希层于是每次都白白跳过。这条用例保证弹幕解析器跟随 /api/stream 的规则先
// 落到第一集:哈希算的是真正要播的那条流。
func TestResolveDanmakuRemoteMediaUsesPlayableChildForContainer(t *testing.T) {
var requested []string
b := newDanmakuRemoteResolverTest(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
requested = append(requested, r.URL.Path)
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": "PSYREN", "Type": "Series", "IsFolder": true,
})
case "/emby/Users/uid-1/Items":
if got := r.URL.Query().Get("ParentId"); got != "series-100" {
t.Errorf("ParentId = %q, want series-100", got)
}
_ = json.NewEncoder(w).Encode(map[string]any{
"Items": []map[string]any{
{"Id": "season-1", "Type": "Season", "IndexNumber": 1},
danmakuResolverTestEpisode("ep-1"),
},
})
case "/emby/Users/uid-1/Items/ep-1":
_ = json.NewEncoder(w).Encode(danmakuResolverTestEpisode("ep-1"))
default:
w.WriteHeader(http.StatusNotFound)
}
}))
media, streamURL, err := b.resolveDanmakuRemoteMedia(
context.Background(), EncodeEmbyRemoteID("mount-1", "series-100"))
if err != nil {
t.Fatalf("resolve failed: %v", err)
}
if media == nil {
t.Fatal("resolve returned no media metadata")
}
if !strings.Contains(streamURL, "/Videos/ep-1/stream") {
t.Fatalf("stream url = %q, want the playable child ep-1", streamURL)
}
if strings.Contains(streamURL, "series-100") {
t.Fatalf("stream url = %q, must not point at the container id (remote Emby answers 500)", streamURL)
}
if !strings.Contains(strings.Join(requested, " "), "/Items/ep-1") {
t.Fatalf("metadata must come from the playable child, requests = %v", requested)
}
}
// 本身可播的条目(单集/电影)不能被改写:直接取它自己的流地址。
func TestResolveDanmakuRemoteMediaKeepsPlayableItem(t *testing.T) {
b := newDanmakuRemoteResolverTest(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
if r.URL.Path != "/emby/Users/uid-1/Items/ep-2" {
t.Errorf("unexpected upstream path %q", r.URL.Path)
}
_ = json.NewEncoder(w).Encode(danmakuResolverTestEpisode("ep-2"))
}))
media, streamURL, err := b.resolveDanmakuRemoteMedia(
context.Background(), EncodeEmbyRemoteID("mount-1", "ep-2"))
if err != nil {
t.Fatalf("resolve failed: %v", err)
}
if media == nil {
t.Fatal("resolve returned no media metadata")
}
if !strings.Contains(streamURL, "/Videos/ep-2/stream") {
t.Fatalf("stream url = %q, want /Videos/ep-2/stream", streamURL)
}
}
// 不是远程伪装 ID 时直接报错,不去猜一个可以拉取的地址。
func TestResolveDanmakuRemoteMediaRejectsPlainID(t *testing.T) {
b := newDanmakuRemoteResolverTest(t, http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
t.Error("plain local ids must not reach the remote Emby")
w.WriteHeader(http.StatusNotFound)
}))
if _, _, err := b.resolveDanmakuRemoteMedia(context.Background(), "local-media-1"); err == nil {
t.Fatal("expected an error for a non remote-emby id")
}
}
@@ -0,0 +1,82 @@
package service
import (
"strings"
"testing"
"github.com/truewhile/MeBox/internal/model"
)
// TestLikelyEpisodicPathSQLNormalisesSeparator 守住剧集路径判定的转义差异。
//
// 此前 embyLikelyEpisodicPathSQL 会额外生成一份反斜杠模式
// (strings.ReplaceAll(pattern, "/", `\`))。PostgreSQL 的 LIKE 默认就把反斜杠
// 当转义符,模式结尾的 `\%` 被解析成「字面百分号」而不是「反斜杠 + 通配符」,
// 那些模式在 PostgreSQL 下永不命中;SQLite 没有默认转义符所以一直正常。
// 现在统一在 SQL 里把分隔符归一化成 '/',模式本身不再含反斜杠。
func TestLikelyEpisodicPathSQLNormalisesSeparator(t *testing.T) {
clause, args := embyLikelyEpisodicPathSQL()
if !strings.Contains(clause, "REPLACE(media.path") {
t.Fatalf("clause no longer normalises the path separator:\n%s", clause)
}
if got := strings.Count(clause, "LIKE ?"); got != len(args) {
t.Fatalf("clause has %d placeholders but %d args:\n%s", got, len(args), clause)
}
for _, arg := range args {
pattern, ok := arg.(string)
if !ok {
t.Fatalf("unexpected pattern type %T", arg)
}
if strings.Contains(pattern, `\`) {
t.Fatalf("pattern %q still contains a backslash; PostgreSQL's LIKE would treat it as an escape character", pattern)
}
if !strings.Contains(pattern, "/") {
t.Fatalf("pattern %q lost its separator", pattern)
}
}
}
// TestFilterLikelyEpisodicPathsHandlesWindowsStylePaths 验证反斜杠路径(Windows
// 宿主机入库的媒体)仍然被判成剧集并从电影库查询里排除。
func TestFilterLikelyEpisodicPathsHandlesWindowsStylePaths(t *testing.T) {
db := newServiceTestDB(t)
episodic := model.Media{
Base: model.Base{ID: "ep-1"},
Path: `D:\Media\国产剧\我的剧\Season 1\我的剧 - S01E01.mkv`,
SeasonNum: 1,
EpisodeNum: 1,
}
movie := model.Media{
Base: model.Base{ID: "mv-1"},
Path: `D:\Media\电影\某电影\某电影.mkv`,
}
if err := db.Create(&episodic).Error; err != nil {
t.Fatal(err)
}
if err := db.Create(&movie).Error; err != nil {
t.Fatal(err)
}
q := filterLikelyEpisodicPathsFromMovieQuery(
db.Model(&model.Media{}).Where("season_num > 0 OR episode_num > 0"),
)
var rows []model.Media
if err := q.Find(&rows).Error; err != nil {
t.Fatal(err)
}
if len(rows) != 0 {
t.Fatalf("windows-style episodic path was not excluded, got %d rows", len(rows))
}
// 反证:电影类型的行必须保留。
var kept []model.Media
keepQ := filterLikelyEpisodicPathsFromMovieQuery(
db.Model(&model.Media{}).Where("path LIKE ?", `D:\Media\电影\%`),
)
if err := keepQ.Find(&kept).Error; err != nil {
t.Fatal(err)
}
if len(kept) != 1 || kept[0].ID != "mv-1" {
t.Fatalf("plain movie row was dropped from the movie query: %+v", kept)
}
}
+11 -2
View File
@@ -7,6 +7,7 @@ import (
"time"
"github.com/truewhile/MeBox/internal/model"
"github.com/truewhile/MeBox/internal/repository"
)
func (e *EmbyService) mediaItems(ctx context.Context, p ItemsParams) (map[string]any, error) {
@@ -21,7 +22,11 @@ func (e *EmbyService) mediaItems(ctx context.Context, p ItemsParams) (map[string
q = q.Where("library_id IN ? OR series_id = ?", e.mergedLibraryIDs(ctx, p.ParentID), p.ParentID)
}
if p.SearchTerm != "" {
q = q.Where("title LIKE ? OR original_name LIKE ?", "%"+p.SearchTerm+"%", "%"+p.SearchTerm+"%")
// ILIKE(PostgreSQL)/ LIKE(SQLite)由方言决定,避免换库后搜索词
// 大小写不匹配就搜不到结果。
likeOp := repository.LikeOperator(e.repo.DB)
term := "%" + p.SearchTerm + "%"
q = q.Where("title "+likeOp+" ? OR original_name "+likeOp+" ?", term, term)
}
if containsEmbyFilter(p.Filters, "IsFavorite") {
if strings.TrimSpace(p.UserID) == "" {
@@ -264,7 +269,11 @@ func (e *EmbyService) seriesItemsForLibrary(ctx context.Context, libraryID strin
q = q.Where("library_id IN ?", e.mergedLibraryIDs(ctx, libraryID))
}
if p.SearchTerm != "" {
q = q.Where("title LIKE ? OR original_name LIKE ?", "%"+p.SearchTerm+"%", "%"+p.SearchTerm+"%")
// ILIKE(PostgreSQL)/ LIKE(SQLite)由方言决定,避免换库后搜索词
// 大小写不匹配就搜不到结果。
likeOp := repository.LikeOperator(e.repo.DB)
term := "%" + p.SearchTerm + "%"
q = q.Where("title "+likeOp+" ? OR original_name "+likeOp+" ?", term, term)
}
if containsEmbyFilter(p.Filters, "IsFavorite") {
if strings.TrimSpace(p.UserID) == "" {
+17 -8
View File
@@ -9,6 +9,7 @@ import (
"gorm.io/gorm"
"github.com/truewhile/MeBox/internal/model"
"github.com/truewhile/MeBox/internal/repository"
)
// movieLibraryHasEpisodicContent 报告电影类型库里是否混入了「剧集结构」内容
@@ -51,7 +52,11 @@ func (e *EmbyService) movieLibraryItems(ctx context.Context, p ItemsParams) (map
q = e.applyUserMediaVisibility(ctx, q, p.UserID)
q = q.Where("library_id IN ?", libIDs)
if p.SearchTerm != "" {
q = q.Where("title LIKE ? OR original_name LIKE ?", "%"+p.SearchTerm+"%", "%"+p.SearchTerm+"%")
// 方言相关的 ILIKE/LIKE:PostgreSQL 的 LIKE 区分大小写,
// 直接用 LIKE 会让客户端搜索的大小写与入库标题不一致时搜不到。
likeOp := repository.LikeOperator(e.repo.DB)
term := "%" + p.SearchTerm + "%"
q = q.Where("title "+likeOp+" ? OR original_name "+likeOp+" ?", term, term)
}
if containsEmbyFilter(p.Filters, "IsFavorite") {
if strings.TrimSpace(p.UserID) == "" {
@@ -266,6 +271,14 @@ func filterLikelyEpisodicPathsFromMovieQuery(q *gorm.DB) *gorm.DB {
return q.Where("NOT ((media.season_num > 0 OR media.episode_num > 0) AND ("+clause+"))", args...)
}
// embyEpisodicPathExpr 把路径分隔符归一化成 '/' 后再小写化,供剧集路径判定使用。
//
// 反斜杠归一化必须放在 SQL 里做,而不是再生成一份反斜杠模式:PostgreSQL 的
// LIKE 默认就把反斜杠当转义符,模式结尾的 `\%` 会被解析成「字面百分号」而不是
// 「反斜杠 + 通配符」,那些模式在 PostgreSQL 下永不命中(SQLite 没有默认转义符,
// 所以只有换库之后才会暴露)。归一化列值后两种方言结果一致。
const embyEpisodicPathExpr = `LOWER(REPLACE(media.path, '\', '/'))`
func embyLikelyEpisodicPathSQL() (string, []any) {
patterns := []string{
"%/season %/%", "%/season.%/%", "%/season-%/%", "%/season_%/%",
@@ -274,15 +287,11 @@ func embyLikelyEpisodicPathSQL() (string, []any) {
"%/电视剧/%", "%/剧集/%", "%/连续剧/%", "%/短剧/%", "%/国产剧/%", "%/国剧/%", "%/大陆剧/%", "%/华语剧/%", "%/国产电视剧/%", "%/大陆电视剧/%", "%/华语电视剧/%", "%/欧美剧/%", "%/欧美电视剧/%", "%/美剧/%", "%/英剧/%", "%/日韩剧/%", "%/日韩电视剧/%", "%/日剧/%", "%/韩剧/%", "%/港剧/%", "%/台剧/%", "%/港台剧/%", "%/泰剧/%",
"%/日番/%", "%/国漫/%", "%/番剧/%", "%/动漫/%", "%/特别篇/%", "%/特別篇/%", "%/番外/%", "%/特典/%",
}
clauses := make([]string, 0, len(patterns)*2)
args := make([]any, 0, len(patterns)*2)
clauses := make([]string, 0, len(patterns))
args := make([]any, 0, len(patterns))
for _, pattern := range patterns {
clauses = append(clauses, "LOWER(media.path) LIKE ?")
clauses = append(clauses, embyEpisodicPathExpr+" LIKE ?")
args = append(args, pattern)
if strings.Contains(pattern, "/") {
clauses = append(clauses, "LOWER(media.path) LIKE ?")
args = append(args, strings.ReplaceAll(pattern, "/", `\`))
}
}
return strings.Join(clauses, " OR "), args
}
+16 -1
View File
@@ -1587,7 +1587,22 @@ func (r *EmbyRemoteService) proxyVideoStreamOnLine(ctx context.Context, w http.R
defer resp.Body.Close()
if resp.StatusCode >= 400 {
data, _ := io.ReadAll(io.LimitReader(resp.Body, 256))
return redactSensitiveError(fmt.Errorf("远程 Emby 视频流失败(%d): %s", resp.StatusCode, strings.TrimSpace(string(data))))
detail := strings.TrimSpace(string(data))
// 远程 Emby 对「整部剧/目录」这类自身没有媒体源的条目会直接 500,正文是
// 一句 .NET 异常(如 Unable to cast object of type 'Series' to type
// 'IHasMediaSources')。原样丢给用户没人看得懂,也没有排查线索——细节进日志,
// 给用户一句能照着做的提示。
if r.log != nil {
r.log.Warn("remote emby video stream rejected",
zap.String("remote_id", remoteID),
zap.Int("status", resp.StatusCode),
zap.String("detail", detail),
)
}
if resp.StatusCode >= 500 {
return redactSensitiveError(fmt.Errorf("远程 Emby 无法播放该条目(HTTP %d):请在详情页选择具体集数后重试", resp.StatusCode))
}
return redactSensitiveError(fmt.Errorf("远程 Emby 视频流失败(%d): %s", resp.StatusCode, detail))
}
for _, header := range []string{"Content-Type", "Content-Length", "Content-Range", "Accept-Ranges", "ETag", "Cache-Control"} {
if value := resp.Header.Get(header); value != "" {
@@ -0,0 +1,160 @@
package service
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"strconv"
"sync/atomic"
"testing"
"go.uber.org/zap"
"github.com/truewhile/MeBox/internal/config"
"github.com/truewhile/MeBox/internal/model"
"github.com/truewhile/MeBox/internal/repository"
)
// newPlayableIDTestService 起一个假的远程 Emby,并建好账号。
func newPlayableIDTestService(t *testing.T, handler http.Handler) (*EmbyRemoteService, *model.StrmAccount) {
t.Helper()
server := httptest.NewServer(handler)
t.Cleanup(server.Close)
db := newServiceTestDB(t, &model.StrmAccount{}, &model.EmbyMount{})
repos := repository.New(db)
cfg := &config.Config{}
remote := NewEmbyRemoteService(cfg, zap.NewNop(), repos, NewCryptoService("", zap.NewNop()))
remote.SetRuntimeCache(NewRuntimeCacheService(cfg, zap.NewNop()))
rawConfig, _ := json.Marshal(map[string]string{
"url": server.URL,
"api_key": "test-api-key",
"remote_user_id": "uid-1",
})
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.Fatalf("create account: %v", err)
}
return remote, acct
}
func playableIDTestEpisode(id string, index, sources int) map[string]any {
item := map[string]any{
"Id": id,
"Name": "第" + strconv.Itoa(index) + "集",
"Type": "Episode",
"SeriesId": "series-100",
"ParentIndexNumber": 1,
"IndexNumber": index,
}
if sources > 0 {
item["MediaSources"] = []any{map[string]any{"Id": id + "-source"}}
}
return item
}
// 整部剧的 ID 自身没有媒体源,远程 Emby 对它的取流请求会回 500
// (Unable to cast 'Series' to 'IHasMediaSources')。这条用例保证我们会换成第一集,
// 而不是把这种 ID 直接交给远程。
func TestPlayableRemoteIDResolvesContainerToFirstEpisode(t *testing.T) {
var itemsCalls int64
remote, acct := newPlayableIDTestService(t, 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/ep-2":
_ = json.NewEncoder(w).Encode(playableIDTestEpisode("ep-2", 2, 1))
case "/emby/Users/uid-1/Items":
atomic.AddInt64(&itemsCalls, 1)
if got := r.URL.Query().Get("ParentId"); got != "series-100" {
t.Errorf("ParentId = %q, want series-100", got)
}
// 故意乱序,并让第一条没有媒体源:只有真按季/集排序、且检查过媒体源,
// 才会选中 ep-1。
_ = json.NewEncoder(w).Encode(map[string]any{
"Items": []map[string]any{
{"Id": "season-1", "Type": "Season", "IndexNumber": 1},
playableIDTestEpisode("ep-3", 3, 1),
playableIDTestEpisode("ep-1", 1, 1),
playableIDTestEpisode("ep-2", 2, 1),
},
})
default:
w.WriteHeader(http.StatusNotFound)
}
}))
ctx := context.Background()
if got := remote.PlayableRemoteID(ctx, acct, "series-100"); got != "ep-1" {
t.Fatalf("series id resolved to %q, want ep-1", got)
}
// 播放器对同一个地址会反复发 Range 请求,解析结果必须命中缓存,不再打远程。
if got := remote.PlayableRemoteID(ctx, acct, "series-100"); got != "ep-1" {
t.Fatalf("cached resolve = %q, want ep-1", got)
}
if n := atomic.LoadInt64(&itemsCalls); n != 1 {
t.Fatalf("episode query issued %d times, want 1 (cached)", n)
}
}
// 本身带媒体源的条目(电影/单集)必须原样返回,不能被改写。
func TestPlayableRemoteIDKeepsPlayableItem(t *testing.T) {
var queries int64
remote, acct := newPlayableIDTestService(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
atomic.AddInt64(&queries, 1)
w.Header().Set("Content-Type", "application/json")
if r.URL.Path != "/emby/Users/uid-1/Items/ep-2" {
t.Errorf("unexpected upstream path %s", r.URL.Path)
}
_ = json.NewEncoder(w).Encode(playableIDTestEpisode("ep-2", 2, 1))
}))
if got := remote.PlayableRemoteID(context.Background(), acct, "ep-2"); got != "ep-2" {
t.Fatalf("playable item = %q, want ep-2", got)
}
if n := atomic.LoadInt64(&queries); n != 1 {
t.Fatalf("upstream queries = %d, want 1", n)
}
}
// 远程不可用/查不到条目时保持原 ID:让原有流程去报错,别把播放地址改成空。
func TestPlayableRemoteIDKeepsOriginalOnFailure(t *testing.T) {
remote, acct := newPlayableIDTestService(t, http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusNotFound)
}))
if got := remote.PlayableRemoteID(context.Background(), acct, "series-100"); got != "series-100" {
t.Fatalf("failure path = %q, want the original id", got)
}
}
// 容器下没有任何带媒体源的子条目时同样返回原 ID(下一层会给友好报错)。
func TestPlayableRemoteIDKeepsContainerWithoutPlayableChild(t *testing.T) {
remote, acct := newPlayableIDTestService(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
if r.URL.Path == "/emby/Users/uid-1/Items" {
_ = json.NewEncoder(w).Encode(map[string]any{
"Items": []map[string]any{
{"Id": "season-1", "Type": "Season", "IndexNumber": 1},
},
})
return
}
_ = json.NewEncoder(w).Encode(map[string]any{"Id": "series-100", "Type": "Series", "IsFolder": true})
}))
if got := remote.PlayableRemoteID(context.Background(), acct, "series-100"); got != "series-100" {
t.Fatalf("container without playable child = %q, want the original id", got)
}
}
@@ -0,0 +1,84 @@
package service
import (
"context"
"net/http"
"net/http/httptest"
"strings"
"testing"
"go.uber.org/zap"
)
// 远程条目自身没有媒体源时(例如把「整部剧」的 ID 当播放目标),远程 Emby 会回
// 500 + 一句 .NET 异常。异常原文不能直接丢给用户,但要能从日志里查得到。
func TestProxyVideoStreamOnLineHidesUpstreamException(t *testing.T) {
const upstreamBody = "Unable to cast object of type 'MediaBrowser.Controller.Entities.TV.Series' to type 'MediaBrowser.Controller.Entities.IHasMediaSources'."
var gotPath, gotStatic, gotRange, gotToken string
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
gotPath = req.URL.Path
gotStatic = req.URL.Query().Get("Static")
gotRange = req.Header.Get("Range")
gotToken = req.Header.Get("X-Emby-Token")
w.WriteHeader(http.StatusInternalServerError)
_, _ = w.Write([]byte(upstreamBody))
}))
defer upstream.Close()
svc := &EmbyRemoteService{stream: upstream.Client(), log: zap.NewNop()}
cfg := &EmbyRemoteConfig{BaseURL: upstream.URL, Token: "tok-1"}
req := httptest.NewRequest(http.MethodGet, "/api/stream/embyremote~acct~222679", nil)
req.Header.Set("Range", "bytes=0-1023")
rec := httptest.NewRecorder()
err := svc.proxyVideoStreamOnLine(context.Background(), rec, req, cfg, "222679")
if err == nil {
t.Fatal("expected an error for upstream 500")
}
if gotPath != "/emby/Videos/222679/stream" {
t.Fatalf("upstream path = %q", gotPath)
}
if gotStatic != "true" {
t.Fatalf("Static = %q, want true", gotStatic)
}
if gotRange != "bytes=0-1023" {
t.Fatalf("Range = %q, want forwarded range", gotRange)
}
if gotToken != "tok-1" {
t.Fatalf("X-Emby-Token = %q", gotToken)
}
if rec.Body.Len() != 0 {
t.Fatalf("nothing should be written to the client on upstream failure, got %q", rec.Body.String())
}
msg := err.Error()
if strings.Contains(msg, "Unable to cast") || strings.Contains(msg, "MediaBrowser") {
t.Fatalf("raw upstream exception must not leak to clients: %q", msg)
}
if !strings.Contains(msg, "无法播放该条目") {
t.Fatalf("error should tell the user what to do, got %q", msg)
}
}
// 4xx(例如远程条目被删除/无权限)保持原有措辞,带着上游说明便于排查。
func TestProxyVideoStreamOnLineKeepsClientErrorDetail(t *testing.T) {
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusNotFound)
_, _ = w.Write([]byte("Item not found"))
}))
defer upstream.Close()
svc := &EmbyRemoteService{stream: upstream.Client(), log: zap.NewNop()}
cfg := &EmbyRemoteConfig{BaseURL: upstream.URL, Token: "tok-1"}
req := httptest.NewRequest(http.MethodGet, "/api/stream/embyremote~acct~404", nil)
err := svc.proxyVideoStreamOnLine(context.Background(), httptest.NewRecorder(), req, cfg, "404")
if err == nil {
t.Fatal("expected an error for upstream 404")
}
if !strings.Contains(err.Error(), "404") || !strings.Contains(err.Error(), "Item not found") {
t.Fatalf("client error detail should be kept, got %q", err.Error())
}
}
+117
View File
@@ -820,6 +820,123 @@ func (r *EmbyRemoteService) remoteItemSeriesID(ctx context.Context, acct *model.
return remoteItemString(out, "SeriesId")
}
// PlayableRemoteID 把「自身没有媒体源」的远程条目换成可直接播放的条目 ID。
//
// 远程 Emby 只为带媒体源的条目提供 /Videos/{id}/stream:拿整部剧/季/目录的 ID 去
// 请求,它会回 500 加一句 .NET 异常(Unable to cast object of type 'Series' to
// type 'IHasMediaSources')。首页轮播、剧集库卡片、外部播放器与第三方客户端都可能
// 把这种 ID 交过来,统一在这里回落到第一集,保证拿到的 ID 真能取到流。
//
// 条目本身可播、或解析失败时返回原 ID(调用方拿到后用原逻辑处理即可,无需区分错误)。
func (r *EmbyRemoteService) PlayableRemoteID(ctx context.Context, acct *model.StrmAccount, remoteID string) string {
remoteID = strings.TrimSpace(remoteID)
if r == nil || acct == nil || remoteID == "" {
return remoteID
}
cfg, err := r.remoteConfigWithToken(ctx, acct)
if err != nil {
return remoteID
}
cacheKey := r.remoteCacheKey("playable-remote-id", acct.ID, remoteID)
if r.cache != nil {
var cached string
// 播放器会对同一个地址发很多次 Range 请求,判定结果必须走缓存,否则每次拖动
// 进度条都要多打两次远程 Emby。缓存比常规媒体缓存长:条目可播性是静态属性。
if r.cache.GetJSON(ctx, cacheKey, &cached) {
if cached == "" {
return remoteID
}
return cached
}
}
resolved := r.resolvePlayableRemoteID(ctx, acct, cfg, remoteID)
// 解析失败不写缓存:远程抖动时下次照样重试,而不是把「播不了」记住十分钟。
if r.cache != nil && resolved != "" {
r.cache.SetJSON(ctx, cacheKey, resolved, playableRemoteIDCacheTTL)
}
if resolved == "" {
return remoteID
}
return resolved
}
// playableRemoteIDCacheTTL 是「条目可播性」判定的缓存时长。
const playableRemoteIDCacheTTL = 10 * time.Minute
// resolvePlayableRemoteID 返回 remoteID 对应的可播条目:本身带媒体源时原样返回,
// 容器条目(整部剧/季/目录)返回排在最前的、确实带媒体源的子条目。
func (r *EmbyRemoteService) resolvePlayableRemoteID(ctx context.Context, acct *model.StrmAccount, cfg *EmbyRemoteConfig, remoteID string) string {
var item map[string]any
if err := r.doGet(ctx, acct, cfg, "/Users/"+url.PathEscape(r.remoteUserID(cfg))+"/Items/"+url.PathEscape(remoteID), nil, &item); err != nil {
return ""
}
if remoteItemHasMediaSources(item) || !remoteItemIsContainer(item) {
return remoteID
}
q := url.Values{}
q.Set("ParentId", remoteID)
q.Set("Recursive", "true")
q.Set("IncludeItemTypes", "Episode,Movie,Video")
q.Set("Fields", "MediaSources")
q.Set("SortBy", "ParentIndexNumber,IndexNumber,SortName")
q.Set("Limit", "50")
var body struct {
Items []map[string]any `json:"Items"`
}
if err := r.doGet(ctx, acct, cfg, "/Users/"+url.PathEscape(r.remoteUserID(cfg))+"/Items", q, &body); err != nil {
return ""
}
// 远程 SortBy 只覆盖它认识的字段(不同 Emby/Jellyfin 版本支持度不一),
// 这里再按季/集排一次,确保拿到的是第一集而不是服务端返回顺序里的第一条。
children := body.Items
sort.SliceStable(children, func(i, j int) bool {
if a, b := remoteItemInt(children[i], "ParentIndexNumber"), remoteItemInt(children[j], "ParentIndexNumber"); a != b {
return a < b
}
if a, b := remoteItemInt(children[i], "IndexNumber"), remoteItemInt(children[j], "IndexNumber"); a != b {
return a < b
}
return remoteItemString(children[i], "SortName") < remoteItemString(children[j], "SortName")
})
for _, child := range children {
if id := remoteItemString(child, "Id"); id != "" && remoteItemHasMediaSources(child) {
return id
}
}
return ""
}
// remoteItemHasMediaSources 该条目是否自带媒体源(即能直接取流)。
func remoteItemHasMediaSources(item map[string]any) bool {
if item == nil {
return false
}
switch sources := item["MediaSources"].(type) {
case []any:
return len(sources) > 0
case []map[string]any:
return len(sources) > 0
}
return false
}
// remoteItemIsContainer 该条目是否是「装别人」的容器:整部剧/季/目录/合集。
func remoteItemIsContainer(item map[string]any) bool {
switch remoteItemString(item, "Type") {
case "Series", "Season", "Folder", "BoxSet", "CollectionFolder", "AggregateFolder", "Playlist":
return true
}
// 类型缺失时退回 IsFolder(Emby 的目录类条目都会带)。
if _, ok := item["Type"]; !ok {
if isFolder, okBool := item["IsFolder"].(bool); okBool {
return isFolder
}
}
return false
}
// ─── 远程 item JSON 取值辅助 ────────────────────────────────────────────────
func anyString(v any) string {
+2
View File
@@ -49,6 +49,8 @@ func TestSafeZipTargetRejectsTraversal(t *testing.T) {
}
func TestFFmpegToolsStatusNoPanic(t *testing.T) {
t.Setenv("PATH", t.TempDir())
t.Chdir(t.TempDir())
svc := NewFFmpegToolsService(&config.Config{}, zap.NewNop(), nil)
st := svc.Status(context.Background())
for _, key := range []string{"installing", "message", "error", "install_dir", "target", "ffmpeg", "ffprobe"} {
+4 -45
View File
@@ -14,14 +14,12 @@ package service
import (
"context"
"errors"
"net"
"net/http"
"path/filepath"
"runtime"
"strings"
"sync"
"syscall"
"time"
"go.uber.org/zap"
@@ -103,53 +101,14 @@ func NewImageProxy(cfg *config.Config, log *zap.Logger) *ImageProxy {
// from image.tmdb.org via their HTTP proxy without extra config. On
// Windows we also honor the current user's system proxy settings.
transport := NewExternalTransport()
if proxyConfiguredForImageFetch() {
// 走本地代理(如 127.0.0.1:7890)时,拨号目标是代理本身,
// 连接层 SSRF 校验会误杀本地回环代理;此时沿用 URL 级校验。
log.Info("image proxy: outbound proxy detected, connection-level SSRF guard disabled")
} else {
// 仅 URL 解析层的 isPrivateHost 可被十进制/十六进制 IP、解析到
// 私网的域名与 DNS rebinding 绕过;在拨号层对最终连接 IP 做二次
// 校验(含重定向后的每条连接)堵住该旁路。
// 用户明确配置的远程挂载源(如内网 Emby)豁免该私网限制。
dialer := &net.Dialer{
Timeout: 15 * time.Second,
Control: func(_, address string, _ syscall.RawConn) error {
host, _, err := net.SplitHostPort(address)
if err != nil {
return err
}
if proxy.isAllowedRemoteHost(host) {
return nil
}
ip := net.ParseIP(host)
if ip == nil {
return errors.New("image proxy: refusing non-IP dial target")
}
if isPrivateIP(ip) {
return errors.New("image proxy: requests to private/internal hosts are not allowed")
}
return nil
},
}
transport.DialContext = dialer.DialContext
}
proxy.guardTransport(transport)
directTransport := NewInternalTransport()
proxy.guardTransport(directTransport)
proxy.client = &http.Client{Timeout: 30 * time.Second, Transport: transport}
proxy.directClient = &http.Client{Timeout: 30 * time.Second, Transport: NewInternalTransport()}
proxy.directClient = &http.Client{Timeout: 30 * time.Second, Transport: directTransport}
return proxy
}
// proxyConfiguredForImageFetch 探测环境变量或系统代理是否会影响图片抓取。
func proxyConfiguredForImageFetch() bool {
req, err := http.NewRequest(http.MethodGet, "https://image.tmdb.org/", nil)
if err != nil {
return false
}
proxy, err := ProxyFromEnvironmentOrSystem(req)
return err == nil && proxy != nil
}
// SetLibraryRootsProvider injects a callback that returns the current set of
// media library root directories. Sidecar posters live under these roots
// (which are arbitrary, user-defined, and not necessarily under the
@@ -0,0 +1,193 @@
package service
import (
"context"
"net"
"net/url"
"sort"
"strings"
"github.com/truewhile/MeBox/internal/repository"
)
// 显式配置的服务地址也要进入图片代理的私网放行名单。
//
// 背景(现场 2026-10-16):MetaTube 刮削后端被配置成 docker 服务名
// `http://mebox-metatube-1:8081`,刮削结果里的 poster_url 就是这个地址。
// 图片代理的连接层 SSRF 校验只放行远程 Emby 挂载主机,于是每一次封面回源都
// 被拒(日志 warn: requests to private/internal hosts are not allowed),
// 前端长期只拿到占位图。
//
// 这里的取舍与 isPrivateHost 的注释一致:私网校验防的是「用户可控 URL 打到
// 内网」,而管理员显式配置的服务地址属于受信输入,和远程 Emby 挂载同级。
func configuredServiceImageHosts(values ...string) []string {
seen := make(map[string]struct{}, len(values))
out := make([]string, 0, len(values))
for _, raw := range values {
host := imageHostFromBaseURL(raw)
if host == "" {
continue
}
if _, ok := seen[host]; ok {
continue
}
seen[host] = struct{}{}
out = append(out, host)
}
sort.Strings(out)
return out
}
// internalServiceImageHosts 只保留指向内网/局域网的服务地址。
//
// 公网域名(image.tmdb.org、api.themoviedb.org 等)必须留在名单之外:放行后
// 图片代理会优先走直连客户端,而 GFW 环境恰恰需要先用 HTTP(S)_PROXY。把它们
// 加进来会让每张海报都白付一次直连失败。
func internalServiceImageHosts(values ...string) []string {
hosts := configuredServiceImageHosts(values...)
out := make([]string, 0, len(hosts))
for _, host := range hosts {
if isInternalServiceHost(host) {
out = append(out, host)
}
}
return out
}
// isInternalServiceHost 判断主机名是否只在内网可达:私网/回环/链路本地 IP、
// 单标签服务名(docker compose 服务名如 mebox-metatube-1),或常见的内部域名后缀。
func isInternalServiceHost(host string) bool {
host = strings.ToLower(strings.TrimSpace(host))
if host == "" {
return false
}
if ip := net.ParseIP(host); ip != nil {
return isPrivateIP(ip)
}
// 单标签名(不含点)不是公网可解析的 FQDN,只可能是容器名/主机名。
if !strings.Contains(host, ".") {
return true
}
for _, suffix := range []string{".local", ".internal", ".lan", ".home.arpa", ".localhost"} {
if strings.HasSuffix(host, suffix) {
return true
}
}
return false
}
// imageHostFromBaseURL 从配置里的 base URL 取出小写主机名(不含端口)。
// 容忍缺少 scheme 的写法(`mebox-metatube-1:8081`),并拒绝非 http/https。
func imageHostFromBaseURL(raw string) string {
raw = strings.TrimSpace(raw)
if raw == "" {
return ""
}
if !strings.Contains(raw, "://") {
raw = "http://" + raw
}
u, err := url.Parse(raw)
if err != nil {
return ""
}
switch strings.ToLower(u.Scheme) {
case "http", "https":
default:
return ""
}
return strings.ToLower(strings.TrimSpace(u.Hostname()))
}
// mergeImageProxyAllowedHosts 合并多组放行主机(远程 Emby 挂载 + 显式配置的
// 服务地址),去重时忽略大小写与端口。
func mergeImageProxyAllowedHosts(groups ...[]string) []string {
seen := make(map[string]struct{})
out := make([]string, 0)
for _, group := range groups {
for _, raw := range group {
host := strings.ToLower(strings.TrimSpace(raw))
if host == "" {
continue
}
if h, _, err := net.SplitHostPort(host); err == nil {
host = strings.ToLower(strings.TrimSpace(h))
}
if host == "" {
continue
}
if _, ok := seen[host]; ok {
continue
}
seen[host] = struct{}{}
out = append(out, host)
}
}
sort.Strings(out)
return out
}
// imageMintingSettingKeys 是「其取值会被拼进图片 URL」的设置项。
//
// 只收录这类设置:放行名单每多一条就是多一处 SSRF 例外,因此 app.server_url、
// strm.base_url 这些只出现在播放地址里的设置不在此列(它们由 streamURL 使用,
// 与 /api/img 无关)。
var imageMintingSettingKeys = []string{
"adult.scraper.metatube_server",
"adult.scraper.builtin_javdb_url",
"adult.scraper.builtin_javbus_url",
}
// imageMintingAPIProviders 是「其 base_url 会被拼进图片 URL」的 api_configs 行。
//
// 同样刻意收窄:api_configs 里每个 provider 都有一行出厂默认值,其中 metatube
// 默认指向 http://127.0.0.1:7700。把整张表都放行会让每次安装都无条件放开回环
// 地址,而只有真正用 MetaTube 刮削的部署才需要这个例外。
var imageMintingAPIProviders = []string{"metatube"}
// configuredServiceImageHostsFromRepo 从持久化配置里收集「会被写进图片 URL」的
// 内网服务地址。只在构造阶段调用一次,所以直接读库、忽略错误——读不到只是少
// 一条放行,不影响启动。
func configuredServiceImageHostsFromRepo(ctx context.Context, repos *repository.Container) []string {
if repos == nil {
return nil
}
values := make([]string, 0, 4)
if repos.Setting != nil {
for _, key := range imageMintingSettingKeys {
if val, err := repos.Setting.Get(ctx, key); err == nil && strings.TrimSpace(val) != "" {
values = append(values, val)
}
}
}
if repos.ApiConfig != nil {
// 用 List 而不是 FindByProvider:后者走 First(),缺行时 gorm 会打一条
// "record not found" 日志,而「没配置 MetaTube」是完全正常的状态。
if rows, err := repos.ApiConfig.List(ctx); err == nil {
for _, row := range rows {
if !imageMintingAPIProvider(row.Provider) {
continue
}
if strings.TrimSpace(row.BaseURL) != "" {
values = append(values, row.BaseURL)
}
// Extra 是逗号/分号分隔的备用源列表(见 api_config.go 的 adult 默认值)。
values = append(values, adultConfiguredBases(row.Extra)...)
}
}
}
return internalServiceImageHosts(values...)
}
func imageMintingAPIProvider(provider string) bool {
provider = strings.ToLower(strings.TrimSpace(provider))
for _, allowed := range imageMintingAPIProviders {
if provider == allowed {
return true
}
}
return false
}
@@ -0,0 +1,249 @@
package service
import (
"context"
"sort"
"testing"
"github.com/glebarez/sqlite"
"gorm.io/gorm"
"github.com/truewhile/MeBox/internal/model"
"github.com/truewhile/MeBox/internal/repository"
)
// 现场(2026-10-16):13 条媒体记录的 poster_url 指向
// http://mebox-metatube-1:8081/v1/images/primary/... ,而图片代理只放行
// 远程 Emby 挂载主机。MetaTube 是用户显式配置的刮削后端(settings 里的
// adult.scraper.metatube_server),它跑在 docker 私网里,于是每一次封面
// 回源都被连接层 SSRF 校验拒掉,日志刷 warn,前端长期只拿到占位图。
//
// 结论:显式配置的服务地址必须和远程 Emby 一样被加入放行名单。
func TestConfiguredServiceImageHostsExtractsHostnames(t *testing.T) {
got := configuredServiceImageHosts(
"http://mebox-metatube-1:8081",
"https://api.themoviedb.org/3",
"http://192.168.1.50:8096/emby",
)
want := []string{"192.168.1.50", "api.themoviedb.org", "mebox-metatube-1"}
if len(got) != len(want) {
t.Fatalf("hosts = %v, want %v", got, want)
}
for i := range want {
if got[i] != want[i] {
t.Fatalf("hosts = %v, want %v", got, want)
}
}
}
func TestConfiguredServiceImageHostsNormalizesAndDedupes(t *testing.T) {
got := configuredServiceImageHosts(
" HTTP://MeBox-MetaTube-1:8081 ",
"http://mebox-metatube-1:8081/other/path",
"mebox-metatube-1:8081",
)
if len(got) != 1 || got[0] != "mebox-metatube-1" {
t.Fatalf("hosts = %v, want exactly [mebox-metatube-1]", got)
}
}
func TestConfiguredServiceImageHostsSkipsUnusableValues(t *testing.T) {
got := configuredServiceImageHosts(
"",
" ",
"not a url at all",
"http://",
"://missing-scheme",
"/relative/path",
"ftp://files.example.com",
)
if len(got) != 0 {
t.Fatalf("hosts = %v, want none", got)
}
}
func TestConfiguredServiceImageHostsPreservesIPv6(t *testing.T) {
got := configuredServiceImageHosts("http://[fd00::5]:8081")
if len(got) != 1 || got[0] != "fd00::5" {
t.Fatalf("hosts = %v, want [fd00::5]", got)
}
}
func TestImageProxyAllowlistCombinesMountsAndConfiguredServices(t *testing.T) {
// 回归:合并后的名单必须同时覆盖远程 Emby 挂载与显式配置的服务,
// 否则两组图片里必有一组被 SSRF 校验挡住。
proxy := newUpstreamImageProxy(t)
proxy.SetAllowedRemoteHostsProvider(func() []string {
return mergeImageProxyAllowedHosts(
[]string{"192.168.1.50"},
configuredServiceImageHosts("http://mebox-metatube-1:8081"),
)
})
for _, host := range []string{"192.168.1.50", "mebox-metatube-1"} {
if !proxy.isAllowedRemoteHost(host) {
t.Fatalf("host %q must bypass the private-IP guard", host)
}
}
if proxy.isAllowedRemoteHost("evil.example.com") {
t.Fatal("unrelated hosts must stay blocked")
}
}
func TestMergeImageProxyAllowedHostsDedupesCaseInsensitively(t *testing.T) {
got := mergeImageProxyAllowedHosts(
[]string{"192.168.1.50:8096", "MeBox-MetaTube-1"},
[]string{"mebox-metatube-1", "", " ", "192.168.1.50"},
)
sort.Strings(got)
want := []string{"192.168.1.50", "mebox-metatube-1"}
if len(got) != len(want) {
t.Fatalf("merged = %v, want %v", got, want)
}
for i := range want {
if got[i] != want[i] {
t.Fatalf("merged = %v, want %v", got, want)
}
}
}
// 公网上游必须留在名单之外:放行后图片代理会优先走直连,而 GFW 环境恰恰要靠
// HTTP(S)_PROXY 才能取到 image.tmdb.org 这类海报。只有内网地址才需要豁免。
func TestInternalServiceImageHostsKeepsOnlyInternalAddresses(t *testing.T) {
got := internalServiceImageHosts(
"http://mebox-metatube-1:8081",
"http://127.0.0.1:7700",
"http://192.168.1.50:8096",
"https://api.themoviedb.org/3",
"https://image.tmdb.org",
"https://api.bgm.tv",
"https://javdb.com",
"http://nas.local:7700",
"http://media.internal",
)
want := []string{
"127.0.0.1",
"192.168.1.50",
"mebox-metatube-1",
"media.internal",
"nas.local",
}
sort.Strings(want)
if len(got) != len(want) {
t.Fatalf("hosts = %v, want %v", got, want)
}
for i := range want {
if got[i] != want[i] {
t.Fatalf("hosts = %v, want %v", got, want)
}
}
}
func TestIsInternalServiceHost(t *testing.T) {
internal := []string{
"mebox-metatube-1", "127.0.0.1", "10.1.2.3", "172.18.0.5",
"192.168.1.50", "fd00::5", "nas.local", "media.internal",
"box.lan", "db.home.arpa",
}
for _, host := range internal {
if !isInternalServiceHost(host) {
t.Errorf("isInternalServiceHost(%q) = false, want true", host)
}
}
external := []string{
"", "api.themoviedb.org", "image.tmdb.org", "www.javbus.com",
"pics.dmm.co.jp", "8.8.8.8", "example.com",
}
for _, host := range external {
if isInternalServiceHost(host) {
t.Errorf("isInternalServiceHost(%q) = true, want false", host)
}
}
}
// 端到端装配:从真实的 settings / api_configs 里读出内网服务地址。
// 现场数据是 settings.adult.scraper.metatube_server = http://mebox-metatube-1:8081,
// 而 api_configs.metatube.base_url 还是出厂默认的 http://127.0.0.1:7700。
func TestConfiguredServiceImageHostsFromRepoReadsSettingsAndAPIConfigs(t *testing.T) {
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
if err != nil {
t.Fatalf("failed to open sqlite: %v", err)
}
if err := db.AutoMigrate(&model.Setting{}, &model.APIConfig{}); err != nil {
t.Fatalf("migrate: %v", err)
}
repos := repository.New(db)
ctx := context.Background()
if err := repos.Setting.Set(ctx, "adult.scraper.metatube_server", "http://mebox-metatube-1:8081"); err != nil {
t.Fatal(err)
}
if err := repos.Setting.Set(ctx, "adult.scraper.builtin_javdb_url", "https://javdb.com"); err != nil {
t.Fatal(err)
}
for _, row := range []model.APIConfig{
{Provider: "metatube", BaseURL: "http://127.0.0.1:7700", Enabled: true},
{Provider: "tmdb", BaseURL: "https://api.themoviedb.org/3", Enabled: true},
{Provider: "openai", BaseURL: "http://192.168.1.77:11434/v1", Enabled: true},
} {
if err := repos.ApiConfig.Create(ctx, &row); err != nil {
t.Fatal(err)
}
}
got := configuredServiceImageHostsFromRepo(ctx, repos)
// javdb.com / api.themoviedb.org 是公网地址,必须留在名单之外(否则图片
// 代理会改走直连,GFW 环境下反而取不到图);openai 的 base_url 虽然指向
// 内网,但它从不产出图片 URL,同样不应成为 SSRF 例外。
want := []string{"127.0.0.1", "mebox-metatube-1"}
sort.Strings(want)
if len(got) != len(want) {
t.Fatalf("hosts = %v, want %v", got, want)
}
for i := range want {
if got[i] != want[i] {
t.Fatalf("hosts = %v, want %v", got, want)
}
}
}
// 放行名单是 SSRF 例外,必须收窄:只有「会产出图片 URL」的配置才入选。
func TestConfiguredServiceImageHostsFromRepoIgnoresUnrelatedInternalServices(t *testing.T) {
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
if err != nil {
t.Fatalf("failed to open sqlite: %v", err)
}
if err := db.AutoMigrate(&model.Setting{}, &model.APIConfig{}); err != nil {
t.Fatalf("migrate: %v", err)
}
repos := repository.New(db)
ctx := context.Background()
// 只配置了内网的 OpenAI 兼容网关与内网下载器,没有任何图片来源。
if err := repos.Setting.Set(ctx, "app.server_url", "http://10.0.0.2:1314"); err != nil {
t.Fatal(err)
}
if err := repos.Setting.Set(ctx, "strm.base_url", "http://10.0.0.2:1314"); err != nil {
t.Fatal(err)
}
for _, row := range []model.APIConfig{
{Provider: "openai", BaseURL: "http://192.168.1.77:11434/v1", Enabled: true},
{Provider: "tmdb", BaseURL: "https://api.themoviedb.org/3", Enabled: true},
} {
if err := repos.ApiConfig.Create(ctx, &row); err != nil {
t.Fatal(err)
}
}
if got := configuredServiceImageHostsFromRepo(ctx, repos); len(got) != 0 {
t.Fatalf("hosts = %v, want none: unrelated internal services must not become SSRF exceptions", got)
}
}
func TestConfiguredServiceImageHostsFromRepoToleratesMissingRepos(t *testing.T) {
if got := configuredServiceImageHostsFromRepo(context.Background(), nil); len(got) != 0 {
t.Fatalf("hosts = %v, want none", got)
}
}
@@ -0,0 +1,112 @@
package service
import (
"net/http"
"net/http/httptest"
"net/url"
"path/filepath"
"testing"
"go.uber.org/zap"
"github.com/truewhile/MeBox/internal/config"
)
// hostAndPortOf 从测试服务器的 URL 里取出主机名与端口。
func hostAndPortOf(t *testing.T, raw string) (string, string) {
t.Helper()
u, err := url.Parse(raw)
if err != nil {
t.Fatalf("parse upstream url: %v", err)
}
return u.Hostname(), u.Port()
}
// 现场(2026-10-16,生产容器内实测):放行名单里明明有 mebox-metatube-1,
// 回源仍然失败于
//
// image proxy: requests to private/internal hosts are not allowed
//
// 原因:连接层校验(dialer 的 Control 回调)拿到的 address 是**解析后的 IP**,
// 而名单里存的是主机名(ConfiguredRemoteHosts 与
// configuredServiceImageHostsFromRepo 都返回 u.Hostname()),两者永远不相等:
// 那段 isAllowedRemoteHost 分支对主机名条目是死代码。
//
// 远程 Emby 挂载用 IP 配置时看不出问题,用主机名(NAS 名、docker 服务名)配置
// 时同样中招。这个用例锁住「按主机名放行」这条路径。
func TestImageProxyDialsAllowlistedHostname(t *testing.T) {
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "image/jpeg")
_, _ = w.Write(testJPEG)
}))
defer upstream.Close()
proxy := NewImageProxy(
&config.Config{Cache: config.CacheConfig{CacheDir: filepath.Join(t.TempDir(), "cache")}},
zap.NewNop(),
)
// 名单里放「主机名」而不是 IP —— 这正是生产里 mebox-metatube-1 的形态。
proxy.SetAllowedRemoteHostsProvider(func() []string { return []string{"localhost"} })
// 直接用该代理自己的 transport 拨号,才能命中连接层校验。
_, port := hostAndPortOf(t, upstream.URL)
resp, err := proxy.client.Get("http://localhost:" + port + "/poster.jpg")
if err != nil {
t.Fatalf("allowlisted hostname must be dialable, got: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
t.Fatalf("status = %d, want 200", resp.StatusCode)
}
}
// 反向约束:未放行的主机名解析到私网地址时仍必须被拒。
// 这是连接层校验存在的理由(DNS rebinding),不能为了修上面那条而拆掉。
func TestImageProxyStillBlocksUnlistedHostnameResolvingToPrivateIP(t *testing.T) {
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
_, _ = w.Write(testJPEG)
}))
defer upstream.Close()
proxy := NewImageProxy(
&config.Config{Cache: config.CacheConfig{CacheDir: filepath.Join(t.TempDir(), "cache")}},
zap.NewNop(),
)
// 名单为空:localhost 解析到回环地址,必须被拦。
proxy.SetAllowedRemoteHostsProvider(func() []string { return nil })
_, port := hostAndPortOf(t, upstream.URL)
for _, client := range []*http.Client{proxy.client, proxy.directClient} {
resp, err := client.Get("http://localhost:" + port + "/poster.jpg")
if err == nil {
resp.Body.Close()
t.Fatal("a non-allowlisted hostname resolving to a private IP must be refused")
}
}
}
func TestImageProxyAllowsSelectedLocalProxy(t *testing.T) {
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "image/jpeg")
_, _ = w.Write(testJPEG)
}))
defer upstream.Close()
proxyURL, err := url.Parse(upstream.URL)
if err != nil {
t.Fatal(err)
}
p := NewImageProxy(&config.Config{}, zap.NewNop())
transport := NewExternalTransport()
transport.Proxy = http.ProxyURL(proxyURL)
p.guardTransport(transport)
t.Cleanup(transport.CloseIdleConnections)
client := &http.Client{Transport: transport}
resp, err := client.Get("http://images.example.com/poster.jpg")
if err != nil {
t.Fatalf("configured local proxy must remain usable: %v", err)
}
resp.Body.Close()
if resp.StatusCode != http.StatusOK {
t.Fatalf("status=%d, want 200", resp.StatusCode)
}
}
@@ -0,0 +1,152 @@
// 在真实 docker 网络里验证图片代理的私网放行名单修复。
//
// 现场(修复前):MetaTube 刮削结果里的 poster_url 指向
// http://mebox-metatube-1:8081/... ,而放行名单只含远程 Emby 挂载主机,
// 于是每一次封面回源都被连接层 SSRF 校验拒掉,前端长期只拿到占位图。
//
// 本文件用与 service_builder.initImageProxy 相同的装配方式复现那条路径。
// 真实回源那一步只在能解析到 MetaTube 服务名的环境里跑(生产容器网络);
// 开发机上会 Skip,因为那属于环境缺失,不是回归。
//
// 在生产机上运行的姿势(无需 Go 工具链):
//
// GOOS=linux GOARCH=amd64 CGO_ENABLED=0 go test -c -o /tmp/verify.test ./internal/service
// scp /tmp/verify.test root@HOST:/tmp/ && ssh root@HOST \
// 'docker cp /tmp/verify.test mebox:/tmp/ && docker exec mebox /tmp/verify.test \
// -test.run TestImageProxyFetchesFromMetaTubeService -test.v'
package service
import (
"context"
"errors"
"net"
"path/filepath"
"strings"
"testing"
"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"
)
// 与生产环境一致的两个取值(取自 10.0.0.2 上真实的 settings / api_configs)。
const (
metaTubeServer = "http://mebox-metatube-1:8081"
metaTubeLegacy = "http://127.0.0.1:7700"
sampleImageURL = "http://mebox-metatube-1:8081/v1/images/primary/JAV321/dsam-15"
)
// newMetaTubeVerificationRepos 建一个带现场取值的临时库(不碰生产数据)。
func newMetaTubeVerificationRepos(t *testing.T) (*repository.Container, *ImageProxy) {
t.Helper()
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
if err != nil {
t.Fatalf("open sqlite: %v", err)
}
if err := db.AutoMigrate(&model.Setting{}, &model.APIConfig{}); err != nil {
t.Fatalf("migrate: %v", err)
}
repos := repository.New(db)
ctx := context.Background()
if err := repos.Setting.Set(ctx, "adult.scraper.metatube_server", metaTubeServer); err != nil {
t.Fatalf("seed setting: %v", err)
}
if err := repos.ApiConfig.Create(ctx, &model.APIConfig{
Provider: "metatube", BaseURL: metaTubeLegacy, Enabled: true,
}); err != nil {
t.Fatalf("seed metatube api config: %v", err)
}
// 一个公网上游,用来确认它没有混进放行名单。
if err := repos.ApiConfig.Create(ctx, &model.APIConfig{
Provider: "tmdb", BaseURL: "https://api.themoviedb.org/3", Enabled: true,
}); err != nil {
t.Fatalf("seed tmdb api config: %v", err)
}
cfg := &config.Config{Cache: config.CacheConfig{CacheDir: filepath.Join(t.TempDir(), "cache")}}
cfg.Secrets.JWTSecret = "verify-secret"
proxy := NewImageProxy(cfg, zap.NewNop())
// 与 service_builder.initImageProxy 相同的装配方式(无远程 Emby 挂载:
// 本次只验证「显式配置的服务地址」这条路径)。
proxy.SetAllowedRemoteHostsProvider(func() []string {
return mergeImageProxyAllowedHosts(
nil,
configuredServiceImageHostsFromRepo(ctx, repos),
)
})
return repos, proxy
}
// TestImageProxyAllowlistIncludesConfiguredMetaTube 确认内网服务地址被放行、
// 公网上游没有被放行。
func TestImageProxyAllowlistIncludesConfiguredMetaTube(t *testing.T) {
repos, proxy := newMetaTubeVerificationRepos(t)
t.Logf("allowlist from config: %v",
configuredServiceImageHostsFromRepo(context.Background(), repos))
for _, host := range []string{"mebox-metatube-1", "127.0.0.1"} {
if !proxy.isAllowedRemoteHost(host) {
t.Fatalf("host %q must be allowlisted (it is an explicitly configured service)", host)
}
}
if proxy.isAllowedRemoteHost("api.themoviedb.org") {
t.Fatal("public upstreams must stay off the allowlist so image fetches keep using the proxy")
}
}
// TestImageProxyReachesMetaTubeService 证明放行名单真的解除了私网拦截。
//
// 现场(修复前)回源必然失败于
//
// dial tcp 172.18.0.5:8081: image proxy: requests to private/internal hosts are not allowed
//
// 也就是连接根本没发生。修复后连接成功,失败只可能来自上游自己的 HTTP 状态
// (MetaTube 冷缓存时对未刮削过的番号返回 500)。
//
// 因此这个用例断言的不是「拿到 200」,而是**「不再是被私网校验拒绝」**:
// 后者才是本次修复的对象,前者取决于 MetaTube 缓存里有没有那条记录。
func TestImageProxyReachesMetaTubeService(t *testing.T) {
_, proxy := newMetaTubeVerificationRepos(t)
data, ctype, err := proxy.Fetch(context.Background(), sampleImageURL)
if err != nil {
if isMetaTubeUnreachable(err) {
t.Skipf("mebox-metatube-1 is not reachable from this environment: %v", err)
}
if isPrivateHostRejection(err) {
t.Fatalf("the allowlist did not take effect: %v", err)
}
// 连接已经建立,只是上游自己返回了错误状态(MetaTube 冷缓存)。
t.Logf("reached metaTube; upstream returned its own error: %v", err)
return
}
t.Logf("fetched %d bytes (content-type=%s)", len(data), ctype)
if len(data) == 0 {
t.Fatal("fetched an empty image body")
}
}
// isPrivateHostRejection 报告错误是否来自连接层的私网校验。
// 这正是本次修复必须消除的那一类失败。
func isPrivateHostRejection(err error) bool {
return err != nil && strings.Contains(err.Error(), "requests to private/internal hosts are not allowed")
}
// isMetaTubeUnreachable 区分「环境里没有这个服务」与「放行名单没生效」。
func isMetaTubeUnreachable(err error) bool {
var dnsErr *net.DNSError
if errors.As(err, &dnsErr) {
return true
}
msg := strings.ToLower(err.Error())
for _, needle := range []string{"no such host", "server misbehaving", "i/o timeout", "connection refused"} {
if strings.Contains(msg, needle) {
return true
}
}
return false
}
+3 -1
View File
@@ -32,7 +32,9 @@ func (p *ImageProxy) remoteImageFetchClients(host string) []remoteImageFetchClie
}
direct := p.directClient
if direct == nil {
direct = &http.Client{Timeout: timeout, Transport: NewInternalTransport()}
transport := NewInternalTransport()
p.guardTransport(transport)
direct = &http.Client{Timeout: timeout, Transport: transport}
}
directCandidate := remoteImageFetchClient{name: "direct", client: direct}
defaultCandidate := remoteImageFetchClient{name: "default", client: client}
+67
View File
@@ -0,0 +1,67 @@
package service
import (
"context"
"errors"
"net"
"net/http"
"net/url"
"strings"
"sync"
"syscall"
"time"
)
// guardTransport checks resolved destination addresses even when a deployment
// has an outbound proxy. Only actual proxy endpoints and explicitly configured
// image services may use private addresses; NO_PROXY and direct fallback
// requests still need the connection-level check against DNS rebinding.
func (p *ImageProxy) guardTransport(transport *http.Transport) {
var proxyEndpoints sync.Map
if selectProxy := transport.Proxy; selectProxy != nil {
transport.Proxy = func(req *http.Request) (*url.URL, error) {
proxyURL, err := selectProxy(req)
if err == nil && proxyURL != nil {
port := proxyURL.Port()
if port == "" {
switch strings.ToLower(proxyURL.Scheme) {
case "https":
port = "443"
case "socks5", "socks5h":
port = "1080"
default:
port = "80"
}
}
proxyEndpoints.Store(net.JoinHostPort(strings.ToLower(proxyURL.Hostname()), port), true)
}
return proxyURL, err
}
}
guardDialer := &net.Dialer{
Timeout: 15 * time.Second,
Control: func(_, address string, _ syscall.RawConn) error {
host, _, err := net.SplitHostPort(address)
if err != nil {
return err
}
ip := net.ParseIP(host)
if ip == nil || isPrivateIP(ip) {
return errors.New("image proxy: requests to private/internal hosts are not allowed")
}
return nil
},
}
trustedDialer := &net.Dialer{Timeout: 15 * time.Second}
transport.DialContext = func(ctx context.Context, network, address string) (net.Conn, error) {
host, _, err := net.SplitHostPort(address)
if err != nil {
return nil, err
}
_, isProxyEndpoint := proxyEndpoints.Load(strings.ToLower(address))
if isProxyEndpoint || p.isAllowedRemoteHost(host) {
return trustedDialer.DialContext(ctx, network, address)
}
return guardDialer.DialContext(ctx, network, address)
}
}
+4 -1
View File
@@ -50,9 +50,12 @@ func LookupLocalAvailability(ctx context.Context, repo *repository.Container, ti
return out
}
like := "%" + query + "%"
// 方言相关的 ILIKE/LIKE:PostgreSQL 下用 LIKE 会因标题大小写与查询不一致而
// 漏判「本地已有该剧」,进而反复提示订阅缺失。
likeOp := repository.LikeOperator(repo.DB)
var rows []model.Media
if err := repo.DB.WithContext(ctx).
Where("title LIKE ? OR original_name LIKE ? OR path LIKE ?", like, like, like).
Where("title "+likeOp+" ? OR original_name "+likeOp+" ? OR path "+likeOp+" ?", like, like, like).
Order("season_num asc, episode_num asc, episode_fraction asc, created_at desc").
Limit(2000).
Find(&rows).Error; err != nil {
+69
View File
@@ -0,0 +1,69 @@
package service
import (
"strings"
"testing"
"github.com/truewhile/MeBox/internal/model"
)
func TestGroupMediaSeriesItemsKeepsLibrariesSeparate(t *testing.T) {
for _, tc := range []struct {
name string
media model.Media
}{
{"remote series with TMDb ID", model.Media{TMDbID: 61709}},
{"remote series with Bangumi ID", model.Media{BangumiID: 61709}},
{"flat episode with TMDb ID", model.Media{SeasonNum: 1, EpisodeNum: 1, TMDbID: 61709}},
{"flat episode with Bangumi ID", model.Media{SeasonNum: 1, EpisodeNum: 1, BangumiID: 61709}},
{"flat episode with Douban ID", model.Media{SeasonNum: 1, EpisodeNum: 1, DoubanID: "61709"}},
{"flat episode with TVDB ID", model.Media{SeasonNum: 1, EpisodeNum: 1, TheTVDBID: "61709"}},
{"shared series ID", model.Media{SeriesID: "same-series"}},
{"same title and directory", model.Media{Path: "/media/anime/龙珠改/Season 1/ep1.mkv", SeasonNum: 1, EpisodeNum: 1}},
} {
t.Run(tc.name, func(t *testing.T) {
first, second := tc.media, tc.media
first.ID, first.LibraryID, first.LibraryName, first.Title = "first", "anime-a", "番剧库甲", "龙珠改"
second.ID, second.LibraryID, second.LibraryName, second.Title = "second", "anime-b", "番剧库乙", "龙珠改"
if strings.HasPrefix(tc.name, "remote") {
first.ID = EncodeEmbyRemoteID("mount-a", "same-id")
second.ID = EncodeEmbyRemoteID("mount-b", "same-id")
}
items := GroupMediaSeriesItems([]model.Media{first, second})
if len(items) != 2 {
t.Fatalf("got %d search results, want one per library: %#v", len(items), items)
}
seen := make(map[string]bool)
for _, item := range items {
seen[item.LibraryID] = true
if item.Title != "龙珠改" || item.LibraryName == "" {
t.Fatalf("search result lost title or library metadata: %#v", item)
}
}
if !seen[first.LibraryID] || !seen[second.LibraryID] {
t.Fatalf("search results lost a library: %#v", items)
}
})
}
}
func TestGroupMediaSeriesItemsFoldsEpisodesWithinDisplayLibrary(t *testing.T) {
items := []model.Media{
{Base: model.Base{ID: "a-2"}, LibraryID: "source-a", DisplayLibraryID: "anime-a", Title: "龙珠改", Path: "/media/anime/龙珠改/Season 1/ep2.mkv", SeasonNum: 1, EpisodeNum: 2, TMDbID: 61709},
{Base: model.Base{ID: "a-1"}, LibraryID: "source-b", DisplayLibraryID: "anime-a", Title: "龙珠改", Path: "/media/anime/龙珠改/Season 1/ep1.mkv", SeasonNum: 1, EpisodeNum: 1, TMDbID: 61709},
{Base: model.Base{ID: "b-2"}, LibraryID: "source-a", DisplayLibraryID: "anime-b", Title: "龙珠改", Path: "/media/anime/龙珠改/Season 1/ep2.mkv", SeasonNum: 1, EpisodeNum: 2, TMDbID: 61709},
{Base: model.Base{ID: "b-1"}, LibraryID: "source-a", DisplayLibraryID: "anime-b", Title: "龙珠改", Path: "/media/anime/龙珠改/Season 1/ep1.mkv", SeasonNum: 1, EpisodeNum: 1, TMDbID: 61709},
}
grouped := GroupMediaSeriesItems(items)
if len(grouped) != 2 {
t.Fatalf("got %d results, want one series per display library: %#v", len(grouped), grouped)
}
seen := make(map[string]bool)
for _, item := range grouped {
if item.EpisodeNum != 1 || seen[item.DisplayLibraryID] {
t.Fatalf("expected the first episode from each display library: %#v", grouped)
}
seen[item.DisplayLibraryID] = true
}
}
+9 -2
View File
@@ -524,9 +524,16 @@ func groupMediaSeriesCardsByKeys(items []model.Media, keys []string) []SeriesCar
// GroupMediaSeriesItems folds episode-level rows into one representative media
// row per series. It is intended for search surfaces where applying a small
// limit before series grouping would otherwise return several episodes from
// the same show.
// the same show. Each display library keeps its own search result.
func GroupMediaSeriesItems(items []model.Media) []model.Media {
cards := groupMediaSeriesCards(items)
_, keys := resolveMediaSeriesKeys(items)
for i, item := range items {
// External IDs and SeriesID can be shared by different libraries. Scope
// search grouping to the same library used by the result's label/link,
// while preserving episode folding within merged display libraries.
keys[i] = seriesFingerprint(mediaTargetLibraryID(item), keys[i])
}
cards := groupMediaSeriesCardsByKeys(items, keys)
if len(cards) == 0 {
return []model.Media{}
}
+46 -22
View File
@@ -159,28 +159,41 @@ func (b *serviceContainerBuilder) initContentServices() {
b.c.Stream.SetStrmPlayTargetResolver(b.c.Strm.ResolvePlayTargetWithUA)
// 弹幕识别需要把远程 Emby 条目解析为 Media 元数据及可拉取前 16MB 的直链 URL。
if b.c.EmbyRemote != nil {
b.c.Danmaku.SetRemoteMediaResolver(func(ctx context.Context, encodedID string) (*model.Media, string, error) {
mountID, remoteID, ok := DecodeEmbyRemoteID(encodedID)
if !ok {
return nil, "", errors.New("invalid emby remote id")
}
mount, acct, err := b.c.EmbyRemote.ResolveMount(ctx, mountID)
if err != nil || mount == nil || acct == nil {
if err != nil {
return nil, "", err
}
return nil, "", errors.New("emby mount or account not found")
}
m, err := b.c.EmbyRemote.RemoteMediaDetail(ctx, mount, acct, remoteID)
if err != nil || m == nil {
return nil, "", err
}
streamURL, _ := b.c.EmbyRemote.WebStreamURL(ctx, acct, remoteID)
return m, streamURL, nil
})
b.c.Danmaku.SetRemoteMediaResolver(b.resolveDanmakuRemoteMedia)
}
}
// resolveDanmakuRemoteMedia 把远程 Emby 伪 ID 解析成弹幕识别要用的 Media 与直链 URL。
//
// 目录类条目(整部剧/季/目录)自身没有媒体源,拿它的 ID 去请求远程
// /Videos/{id}/stream 只会回 500(Unable to cast object of type 'Series' to type
// 'IHasMediaSources'),哈希层于是每次都在这里白白失败、退回按文件名搜索。所以先按
// 与 /api/stream 相同的规则回落到第一集:算出来的哈希对得上真正播放的那条流,元数据
// (时长、文件名)也才是播放中那一集的。
func (b *serviceContainerBuilder) resolveDanmakuRemoteMedia(ctx context.Context, encodedID string) (*model.Media, string, error) {
mountID, remoteID, ok := DecodeEmbyRemoteID(encodedID)
if !ok {
return nil, "", errors.New("invalid emby remote id")
}
mount, acct, err := b.c.EmbyRemote.ResolveMount(ctx, mountID)
if err != nil || mount == nil || acct == nil {
if err != nil {
return nil, "", err
}
return nil, "", errors.New("emby mount or account not found")
}
playable := b.c.EmbyRemote.PlayableRemoteID(ctx, acct, remoteID)
if strings.TrimSpace(playable) == "" {
playable = remoteID
}
m, err := b.c.EmbyRemote.RemoteMediaDetail(ctx, mount, acct, playable)
if err != nil || m == nil {
return nil, "", err
}
streamURL, _ := b.c.EmbyRemote.WebStreamURL(ctx, acct, playable)
return m, streamURL, nil
}
func (b *serviceContainerBuilder) initAccessAndStorageServices() {
b.c.PlayProfiles = NewPlayProfileService(b.log, b.repos)
b.c.Permissions = NewPermissionService(b.log, b.repos)
@@ -240,10 +253,21 @@ func (b *serviceContainerBuilder) initIdentityServices() {
func (b *serviceContainerBuilder) initImageProxy() {
b.c.ImageProxy = NewImageProxy(b.cfg, b.log)
b.c.ImageProxy.SetLibraryRootsProvider(b.libraryRoots)
// 放行名单 = 远程 Emby 挂载主机 + 用户显式配置的内网服务地址。
//
// 只放行挂载主机是不够的:MetaTube 这类刮削后端会把「容器服务名」写进
// poster_url(现场:http://mebox-metatube-1:8081),连接层 SSRF 校验会把
// 每一次封面回源都拒掉,前端长期只拿到占位图。名单本身有 30s 缓存,
// 所以管理员改完设置无需重启即可生效。
b.c.ImageProxy.SetAllowedRemoteHostsProvider(func() []string {
var mounts []string
if b.c.EmbyRemote != nil {
mounts = b.c.EmbyRemote.ConfiguredRemoteHosts(context.Background())
}
services := configuredServiceImageHostsFromRepo(context.Background(), b.repos)
return mergeImageProxyAllowedHosts(mounts, services)
})
if b.c.EmbyRemote != nil {
b.c.ImageProxy.SetAllowedRemoteHostsProvider(func() []string {
return b.c.EmbyRemote.ConfiguredRemoteHosts(context.Background())
})
// 远程 Emby 的图片 URL 会带着签发时的 api_key 长期缓存/下发;轮换后
// URL 里的旧值必然过期。回源时改用账号当前 token,并在上游 401/403 时
// 重新登录一次再重试,封面图不再依赖人工改账号。
+47 -26
View File
@@ -59,6 +59,9 @@ type TranscoderService struct {
availMu sync.Mutex
availCheckedAt time.Time
availValue bool
availKey string
ffmpegMu sync.Mutex
ffmpegCache ffmpegValidationCache
}
// hlsJob holds the live state of one ffmpeg run.
@@ -83,6 +86,11 @@ type hlsJob struct {
quality string
// done is closed when the ffmpeg goroutine fully exits (after process death).
done chan struct{}
// Completed outputs stay reusable until idle eviction without occupying a
// running-process slot. The binary and config are fixed for this generation.
completed bool
ffmpegPath string
config *config.Config
}
var (
@@ -117,24 +125,37 @@ func (t *TranscoderService) Available() bool {
if t == nil || t.cfg == nil {
return false
}
if !t.cfg.Transcoder.Enabled {
cfg := t.transcodeConfig()
if !cfg.Transcoder.Enabled {
return false
}
encoder := ffmpegArgSettingsFromConfig(cfg, nil).encoder
key := cfg.App.FFmpegPath + "\x00" + encoder
t.availMu.Lock()
defer t.availMu.Unlock()
if !t.availCheckedAt.IsZero() && time.Since(t.availCheckedAt) < transcodeAvailabilityTTL {
return t.availValue
if !t.availValue && t.availKey == key && !t.availCheckedAt.IsZero() && time.Since(t.availCheckedAt) < transcodeAvailabilityTTL {
return false
}
_, err := t.resolveFFmpegPath()
_, err := t.resolveFFmpegPathFor(context.Background(), cfg.App.FFmpegPath, encoder)
available := err == nil
if !available && t.log != nil {
t.log.Debug("local HLS renditions unavailable", zap.Error(err))
}
t.availValue = available
t.availCheckedAt = time.Now()
t.availKey = key
return available
}
func (t *TranscoderService) transcodeConfig() *config.Config {
config.RuntimeMu.RLock()
defer config.RuntimeMu.RUnlock()
return &config.Config{
App: config.AppConfig{FFmpegPath: t.cfg.App.FFmpegPath, VAAPIDevice: t.cfg.App.VAAPIDevice},
Transcoder: t.cfg.Transcoder,
}
}
// HLSDir is the per-media directory that holds index.m3u8 + segment files.
func (t *TranscoderService) HLSDir(mediaID string) string {
return filepath.Join(t.cfg.Cache.CacheDir, "hls", mediaID)
@@ -174,7 +195,8 @@ func (t *TranscoderService) EnsureJobFromSubtitle(ctx context.Context, mediaID s
// EnsureJobFromSubtitleQuality is EnsureJobFromSubtitle with an explicit local
// HLS quality profile. Empty qualityID keeps the global transcoder config.
func (t *TranscoderService) EnsureJobFromSubtitleQuality(ctx context.Context, mediaID string, startSec float64, seekGen int64, subtitleStream int, qualityID string) (string, error) {
if !t.cfg.Transcoder.Enabled {
cfg := t.transcodeConfig()
if !cfg.Transcoder.Enabled {
return "", ErrTranscodeDisabled
}
if startSec < 0 {
@@ -203,12 +225,17 @@ func (t *TranscoderService) EnsureJobFromSubtitleQuality(ctx context.Context, me
t.mu.Lock()
if existing, ok := t.jobs[mediaID]; ok {
if sameHLSConfiguration(existing, startSec, subtitleStream, qualityID) {
reusable := true
if existing.completed {
info, err := os.Stat(t.PlaylistPath(mediaID))
reusable = err == nil && info.Size() > 0
}
if reusable && sameHLSConfiguration(existing, startSec, subtitleStream, qualityID) {
t.touchJobLocked(mediaID)
t.mu.Unlock()
return t.PlaylistPath(mediaID), nil
}
if !shouldReplaceHLSJobConfigurationQuality(existing, startSec, seekGen, subtitleStream, qualityID) {
if reusable && !shouldReplaceHLSJobConfigurationQuality(existing, startSec, seekGen, subtitleStream, qualityID) {
t.touchJobLocked(mediaID)
t.mu.Unlock()
return t.PlaylistPath(mediaID), nil
@@ -230,7 +257,13 @@ func (t *TranscoderService) EnsureJobFromSubtitleQuality(ctx context.Context, me
}
input.Quality = quality
t.maybeFillDuration(ctx, m, input)
if _, err := t.resolveFFmpegPath(); err != nil {
settings := ffmpegArgSettingsFromConfig(cfg, quality)
jobEncoder := settings.encoder
if settings.noScale || subtitleStream >= 0 {
jobEncoder = ""
}
bin, err := t.resolveFFmpegPathFor(ctx, cfg.App.FFmpegPath, jobEncoder)
if err != nil {
return "", err
}
@@ -242,29 +275,15 @@ func (t *TranscoderService) EnsureJobFromSubtitleQuality(ctx context.Context, me
}
t.mu.Lock()
if existing, ok := t.jobs[mediaID]; ok {
if sameHLSConfiguration(existing, startSec, subtitleStream, qualityID) {
t.touchJobLocked(mediaID)
t.mu.Unlock()
return t.PlaylistPath(mediaID), nil
}
if !shouldReplaceHLSJobConfigurationQuality(existing, startSec, seekGen, subtitleStream, qualityID) {
t.touchJobLocked(mediaID)
t.mu.Unlock()
return t.PlaylistPath(mediaID), nil
}
prev := t.detachJobLocked(mediaID)
t.mu.Unlock()
waitJobExit(prev, 12*time.Second)
t.mu.Lock()
}
if max := t.maxConcurrent(); max > 0 && len(t.jobs) >= max {
if max := t.maxConcurrent(); max > 0 && t.runningJobCountLocked() >= max {
t.mu.Unlock()
return "", ErrTranscodeBusy
}
jobCtx, cancel := context.WithCancel(context.Background())
jobEncoder := t.effectiveEncoder()
if jobEncoder == "" {
jobEncoder = "software"
}
if subtitleStream >= 0 {
jobEncoder = "software (subtitle burn-in)"
}
@@ -280,6 +299,8 @@ func (t *TranscoderService) EnsureJobFromSubtitleQuality(ctx context.Context, me
subtitleStream: subtitleStream,
quality: qualityID,
done: make(chan struct{}),
ffmpegPath: bin,
config: cfg,
}
t.jobs[mediaID] = job
t.mu.Unlock()
@@ -13,6 +13,8 @@ import (
// be false both when transcoding is disabled by configuration and when no usable
// ffmpeg can be resolved.
func TestTranscoderAvailableGatesOnConfigAndBinary(t *testing.T) {
t.Setenv("PATH", t.TempDir())
t.Chdir(t.TempDir())
cases := []struct {
name string
enabled bool
+61 -29
View File
@@ -11,29 +11,17 @@ import (
"time"
"go.uber.org/zap"
"github.com/truewhile/MeBox/internal/config"
)
func (t *TranscoderService) runFFmpeg(ctx context.Context, job *hlsJob, input transcodeInput) {
bin, err := t.resolveFFmpegPath()
if err != nil {
t.log.Warn("ffmpeg unavailable", zap.String("media_id", job.mediaID), zap.Error(err))
t.mu.Lock()
delete(t.jobs, job.mediaID)
t.mu.Unlock()
t.hub.Publish("transcode", map[string]any{
"media_id": job.mediaID,
"status": "error",
"error": err.Error(),
})
return
}
playlist := filepath.Join(job.outputDir, "index.m3u8")
segments := filepath.Join(job.outputDir, "seg_%05d.ts")
args := buildFFmpegArgsForInput(t.cfg, input, playlist, segments)
args := buildFFmpegArgsForInput(job.config, input, playlist, segments)
cmd := exec.CommandContext(ctx, bin, args...) // #nosec G204 -- bin is resolved by resolveFFmpegPath and args are passed without a shell.
cmd := exec.CommandContext(ctx, job.ffmpegPath, args...) // #nosec G204 -- the binary was validated before creating this job.
setFFmpegSysProcAttr(cmd)
cmd.Stderr = os.Stderr
@@ -50,7 +38,8 @@ func (t *TranscoderService) runFFmpeg(ctx context.Context, job *hlsJob, input tr
"start_sec": input.StartSec,
})
if err := cmd.Run(); err != nil && !errors.Is(ctx.Err(), context.Canceled) {
err := cmd.Run()
if err != nil && !errors.Is(ctx.Err(), context.Canceled) {
t.log.Warn("ffmpeg exited",
zap.String("media_id", job.mediaID),
zap.Float64("start_sec", input.StartSec),
@@ -59,11 +48,18 @@ func (t *TranscoderService) runFFmpeg(ctx context.Context, job *hlsJob, input tr
}
t.mu.Lock()
// Only drop the map entry if we are still the registered generation.
// Only update the map entry if we are still the registered generation.
if cur, ok := t.jobs[job.mediaID]; ok && cur == job {
delete(t.jobs, job.mediaID)
if err == nil && ctx.Err() == nil {
job.completed = true
} else {
delete(t.jobs, job.mediaID)
}
}
t.mu.Unlock()
if err != nil || ctx.Err() != nil {
job.cancel()
}
t.hub.Publish("transcode", map[string]any{
"media_id": job.mediaID,
@@ -73,19 +69,53 @@ func (t *TranscoderService) runFFmpeg(ctx context.Context, job *hlsJob, input tr
}
func (t *TranscoderService) resolveFFmpegPath() (string, error) {
t.mu.Lock()
configuredPath := strings.TrimSpace(t.cfg.App.FFmpegPath)
t.mu.Unlock()
cfg := t.transcodeConfig()
return t.resolveFFmpegPathFor(context.Background(), cfg.App.FFmpegPath, ffmpegArgSettingsFromConfig(cfg, nil).encoder)
}
type ffmpegValidationCache struct {
configuredPath string
path string
encoder string
size int64
modTime time.Time
checkedAt time.Time
}
func (t *TranscoderService) resolveFFmpegPathFor(ctx context.Context, configuredPath, encoder string) (string, error) {
t.ffmpegMu.Lock()
defer t.ffmpegMu.Unlock()
if err := ctx.Err(); err != nil {
return "", err
}
configuredPath = strings.TrimSpace(configuredPath)
cached := t.ffmpegCache
if cached.path != "" && cached.encoder == encoder &&
(configuredPath == cached.configuredPath || configuredPath == cached.path) &&
time.Since(cached.checkedAt) < transcodeAvailabilityTTL {
if info, err := os.Stat(cached.path); err == nil && info.Size() == cached.size && info.ModTime().Equal(cached.modTime) {
return cached.path, nil
}
}
var lastErr error
for _, bin := range executableCandidates(configuredPath, "ffmpeg") {
if err := validateFFmpegForTranscode(context.Background(), bin, t.effectiveEncoder()); err != nil {
if err := validateFFmpegForTranscode(ctx, bin, encoder); err != nil {
if ctx.Err() != nil {
return "", ctx.Err()
}
lastErr = err
continue
}
t.mu.Lock()
t.cfg.App.FFmpegPath = bin
t.mu.Unlock()
if info, err := os.Stat(bin); err == nil {
t.ffmpegCache = ffmpegValidationCache{configuredPath: configuredPath, path: bin, encoder: encoder,
size: info.Size(), modTime: info.ModTime(), checkedAt: time.Now()}
}
config.RuntimeMu.Lock()
if strings.TrimSpace(t.cfg.App.FFmpegPath) == configuredPath {
t.cfg.App.FFmpegPath = bin
}
config.RuntimeMu.Unlock()
return bin, nil
}
if lastErr != nil {
@@ -95,10 +125,11 @@ func (t *TranscoderService) resolveFFmpegPath() (string, error) {
}
func (t *TranscoderService) effectiveEncoder() string {
if !t.cfg.Transcoder.HardwareAccel {
cfg := t.transcodeConfig()
if !cfg.Transcoder.HardwareAccel {
return ""
}
return normalizedHardwareEncoder(t.cfg.Transcoder.Encoder)
return normalizedHardwareEncoder(cfg.Transcoder.Encoder)
}
func normalizedHardwareEncoder(encoder string) string {
@@ -160,6 +191,7 @@ func hasFFmpegListEntry(output, name string) bool {
// HumanFFmpegProfile is exposed for the admin UI / settings view.
func (t *TranscoderService) HumanFFmpegProfile() string {
cfg := t.transcodeConfig()
return fmt.Sprintf("ffmpeg=%s, encoder=%s, output=%s",
t.cfg.App.FFmpegPath, t.cfg.Transcoder.Encoder, filepath.Join(t.cfg.Cache.CacheDir, "hls"))
cfg.App.FFmpegPath, cfg.Transcoder.Encoder, filepath.Join(t.cfg.Cache.CacheDir, "hls"))
}
+65 -14
View File
@@ -6,41 +6,58 @@ import (
"time"
"go.uber.org/zap"
"github.com/truewhile/MeBox/internal/config"
)
// WaitReady blocks (with a deadline) until the playlist file shows up on
// disk for the *current* job generation. Stale playlists left behind by a
// failed RemoveAll / still-exiting ffmpeg must not unblock a mid-file restart.
func (t *TranscoderService) WaitReady(ctx context.Context, mediaID string, timeout time.Duration) bool {
deadline := time.Now().Add(timeout)
timer := time.NewTimer(timeout)
defer timer.Stop()
ticker := time.NewTicker(250 * time.Millisecond)
defer ticker.Stop()
for {
if ctx.Err() != nil {
return false
}
t.mu.Lock()
job, ok := t.jobs[mediaID]
var started time.Time
var completed bool
if ok {
started = job.startedAt
completed = job.completed
}
t.mu.Unlock()
if !ok {
return false // A failed/exited job cannot become ready later.
}
if ok {
if info, err := os.Stat(t.PlaylistPath(mediaID)); err == nil {
// Allow a small clock skew; reject anything older than this job.
if !info.ModTime().Before(started.Add(-2 * time.Second)) {
t.mu.Lock()
if j, exists := t.jobs[mediaID]; exists {
if j, exists := t.jobs[mediaID]; exists && j == job {
j.playlistOK = true
t.mu.Unlock()
return true
}
t.mu.Unlock()
return true
}
}
}
if time.Now().After(deadline) || ctx.Err() != nil {
return false
if completed {
return false // A completed job cannot regenerate a missing playlist.
}
select {
case <-ctx.Done():
return false
case <-time.After(250 * time.Millisecond):
case <-timer.C:
return false
case <-ticker.C:
case <-job.done:
}
}
}
@@ -101,6 +118,9 @@ func (t *TranscoderService) Active() []ActiveJob {
defer t.mu.Unlock()
out := make([]ActiveJob, 0, len(t.jobs))
for _, j := range t.jobs {
if j.completed {
continue
}
out = append(out, ActiveJob{
MediaID: j.mediaID,
Encoder: j.encoder,
@@ -111,7 +131,19 @@ func (t *TranscoderService) Active() []ActiveJob {
return out
}
func (t *TranscoderService) runningJobCountLocked() int {
n := 0
for _, job := range t.jobs {
if !job.completed {
n++
}
}
return n
}
func (t *TranscoderService) maxConcurrent() int {
config.RuntimeMu.RLock()
defer config.RuntimeMu.RUnlock()
if t.cfg.Transcoder.MaxConcurrent <= 0 {
return 1
}
@@ -119,6 +151,8 @@ func (t *TranscoderService) maxConcurrent() int {
}
func (t *TranscoderService) idleTimeout() time.Duration {
config.RuntimeMu.RLock()
defer config.RuntimeMu.RUnlock()
if t.cfg.Transcoder.IdleTimeoutSeconds <= 0 {
return 120 * time.Second
}
@@ -137,21 +171,38 @@ func (t *TranscoderService) monitorIdle(ctx context.Context, job *hlsJob) {
case <-ticker.C:
t.mu.Lock()
current, ok := t.jobs[job.mediaID]
if !ok {
if !ok || current != job {
t.mu.Unlock()
return
}
idleFor := time.Since(current.lastAccess)
t.mu.Unlock()
if idleFor >= timeout {
t.log.Info("transcode idle timeout",
zap.String("media_id", job.mediaID),
zap.Duration("idle_for", idleFor),
zap.Duration("timeout", timeout),
)
t.StopJob(job.mediaID)
if idleFor >= timeout && t.stopIdleJob(job, timeout) {
return
}
}
}
}
func (t *TranscoderService) stopIdleJob(job *hlsJob, timeout time.Duration) bool {
gate := t.mediaStartGate(job.mediaID)
gate.Lock()
defer gate.Unlock()
t.mu.Lock()
current := t.jobs[job.mediaID]
if current != job {
t.mu.Unlock()
return true
}
idleFor := time.Since(job.lastAccess)
if idleFor < timeout {
t.mu.Unlock()
return false
}
t.detachJobLocked(job.mediaID)
t.mu.Unlock()
waitJobExit(job, 12*time.Second)
t.log.Info("transcode idle timeout", zap.String("media_id", job.mediaID),
zap.Duration("idle_for", idleFor), zap.Duration("timeout", timeout))
return true
}
@@ -0,0 +1,228 @@
package service
import (
"context"
"os"
"os/exec"
"path/filepath"
"runtime"
"strings"
"testing"
"time"
"go.uber.org/zap"
"github.com/truewhile/MeBox/internal/config"
"github.com/truewhile/MeBox/internal/model"
"github.com/truewhile/MeBox/internal/repository"
)
func TestHLSCompletedTranscodeStaysReadyAndIsReused(t *testing.T) {
bin, err := exec.LookPath("ffmpeg")
if err != nil {
t.Skip("ffmpeg is required for the real HLS lifecycle test")
}
ctx, cancel := context.WithTimeout(t.Context(), 10*time.Second)
defer cancel()
source := filepath.Join(t.TempDir(), "short.mp4")
cmd := exec.CommandContext(ctx, bin, "-hide_banner", "-loglevel", "error", "-f", "lavfi", "-i", "testsrc=size=64x64:rate=10", "-t", "1", "-c:v", "libx264", "-pix_fmt", "yuv420p", "-threads", "1", source)
if output, err := cmd.CombinedOutput(); err != nil {
t.Fatalf("generate source: %v: %s", err, output)
}
cfg := &config.Config{}
cfg.App.FFmpegPath = bin
cfg.Cache.CacheDir = t.TempDir()
cfg.Transcoder.Enabled = true
cfg.Transcoder.MaxConcurrent = 1
cfg.Transcoder.MaxHeight = 64
cfg.Transcoder.SegmentSeconds = 1
cfg.Transcoder.Threads = 1
db := newServiceTestDB(t)
media := model.Media{Path: source, DurationSec: 1}
if err := db.Create(&media).Error; err != nil {
t.Fatal(err)
}
svc := NewTranscoderService(cfg, zap.NewNop(), repository.New(db), NewHub(zap.NewNop()))
t.Cleanup(svc.StopAll)
playlist, err := svc.EnsureJob(ctx, media.ID)
if err != nil {
t.Fatal(err)
}
svc.mu.Lock()
job := svc.jobs[media.ID]
svc.mu.Unlock()
if job == nil {
t.Fatal("transcode lost its job before the playlist could be read")
}
select {
case <-job.done:
case <-ctx.Done():
t.Fatal("short transcode did not finish")
}
data, err := os.ReadFile(playlist)
if err != nil || !strings.Contains(string(data), "#EXT-X-ENDLIST") {
t.Fatalf("transcode did not produce a complete playlist: err=%v, data=%s", err, data)
}
if !svc.WaitReady(ctx, media.ID, 100*time.Millisecond) {
t.Fatal("completed HLS playlist is incorrectly reported as not ready")
}
if len(svc.Active()) != 0 {
t.Fatal("completed transcode still occupies an active job slot")
}
if _, err := svc.EnsureJob(ctx, media.ID); err != nil {
t.Fatal(err)
}
svc.mu.Lock()
reused := svc.jobs[media.ID] == job
svc.mu.Unlock()
if !reused {
t.Fatal("refreshing a completed playlist started a second transcode")
}
other := model.Media{Path: source + ".second", DurationSec: 1}
if err := os.WriteFile(other.Path, mustReadTestFile(t, source), 0o600); err != nil {
t.Fatal(err)
}
if err := db.Create(&other).Error; err != nil {
t.Fatal(err)
}
if _, err := svc.EnsureJob(ctx, other.ID); err != nil {
t.Fatalf("completed cache must not consume MaxConcurrent: %v", err)
}
}
func mustReadTestFile(t *testing.T, path string) []byte {
t.Helper()
data, err := os.ReadFile(path)
if err != nil {
t.Fatal(err)
}
return data
}
func TestHLSWaitReadyReturnsImmediatelyForMissingJob(t *testing.T) {
svc := NewTranscoderService(&config.Config{}, zap.NewNop(), nil, NewHub(zap.NewNop()))
started := time.Now()
if svc.WaitReady(t.Context(), "failed", time.Second) {
t.Fatal("missing job must not be ready")
}
if elapsed := time.Since(started); elapsed >= 500*time.Millisecond {
t.Fatalf("missing/failed job waited for the readiness deadline: %v", elapsed)
}
}
func countingFFmpeg(t *testing.T) string {
t.Helper()
if runtime.GOOS == "windows" {
t.Skip("test ffmpeg uses a POSIX shell")
}
bin := filepath.Join(t.TempDir(), "ffmpeg")
script := `#!/bin/sh
printf '%s\n' "$*" >> "$0.calls"
case " $* " in
*" -encoders "*) printf ' V..... libx264\n V..... h264_nvenc\n' ;;
*" -muxers "*) printf ' E hls\n' ;;
esac
`
if err := os.WriteFile(bin, []byte(script), 0o755); err != nil {
t.Fatal(err)
}
return bin
}
func TestFFmpegValidationIsCachedAndInvalidatedByEncoder(t *testing.T) {
bin := countingFFmpeg(t)
cfg := &config.Config{}
cfg.App.FFmpegPath = bin
svc := NewTranscoderService(cfg, zap.NewNop(), nil, NewHub(zap.NewNop()))
for range 3 {
if _, err := svc.resolveFFmpegPath(); err != nil {
t.Fatal(err)
}
}
calls := strings.Count(string(mustReadTestFile(t, bin+".calls")), "\n")
if calls != 2 {
t.Fatalf("repeated resolution spawned ffmpeg %d times, want one encoder/muxer probe pair", calls)
}
ApplyRuntimeSetting(cfg, "transcoder.hardware_accel", "true")
ApplyRuntimeSetting(cfg, "transcoder.encoder", "nvenc")
if _, err := svc.resolveFFmpegPath(); err != nil {
t.Fatal(err)
}
calls = strings.Count(string(mustReadTestFile(t, bin+".calls")), "\n")
if calls != 4 {
t.Fatalf("encoder change must revalidate capabilities, calls=%d", calls)
}
changed := time.Now().Add(time.Second)
if err := os.Chtimes(bin, changed, changed); err != nil {
t.Fatal(err)
}
if _, err := svc.resolveFFmpegPath(); err != nil {
t.Fatal(err)
}
calls = strings.Count(string(mustReadTestFile(t, bin+".calls")), "\n")
if calls != 6 {
t.Fatalf("binary replacement must invalidate validation, calls=%d", calls)
}
}
func TestHLSIdleCleanupPreservesReplacementAndRecentActivity(t *testing.T) {
svc := NewTranscoderService(&config.Config{}, zap.NewNop(), nil, NewHub(zap.NewNop()))
done := make(chan struct{})
close(done)
cancelled := false
old := &hlsJob{mediaID: "m", lastAccess: time.Now().Add(-time.Hour)}
current := &hlsJob{mediaID: "m", lastAccess: time.Now(), done: done, cancel: func() { cancelled = true }}
svc.jobs["m"] = current
if !svc.stopIdleJob(old, time.Minute) || cancelled || svc.jobs["m"] != current {
t.Fatal("an old watchdog must leave the replacement generation intact")
}
if svc.stopIdleJob(current, time.Minute) || cancelled {
t.Fatal("recent segment access must prevent idle cancellation")
}
current.lastAccess = time.Now().Add(-time.Hour)
if !svc.stopIdleJob(current, time.Minute) || !cancelled || svc.jobs["m"] != nil {
t.Fatal("an idle generation must release its resources")
}
}
func TestHLSOriginalQualityValidatesSoftwareEncoder(t *testing.T) {
bin := countingFFmpeg(t)
softwareOnly := strings.ReplaceAll(string(mustReadTestFile(t, bin)), ` V..... h264_nvenc\n`, "")
if err := os.WriteFile(bin, []byte(softwareOnly), 0o755); err != nil {
t.Fatal(err)
}
t.Setenv("PATH", t.TempDir())
t.Chdir(t.TempDir())
cfg := &config.Config{}
cfg.App.FFmpegPath = bin
cfg.Transcoder.Enabled = true
cfg.Transcoder.HardwareAccel = true
cfg.Transcoder.Encoder = "nvenc"
cfg.Cache.CacheDir = t.TempDir()
db := newServiceTestDB(t)
source := filepath.Join(t.TempDir(), "movie.mkv")
if err := os.WriteFile(source, []byte("source"), 0o600); err != nil {
t.Fatal(err)
}
media := model.Media{Path: source, DurationSec: 60}
if err := db.Create(&media).Error; err != nil {
t.Fatal(err)
}
svc := NewTranscoderService(cfg, zap.NewNop(), repository.New(db), NewHub(zap.NewNop()))
t.Cleanup(svc.StopAll)
if _, err := svc.EnsureJobFromSubtitleQuality(t.Context(), media.ID, 0, 1, -1, "source"); err != nil {
t.Fatalf("source quality uses software and must not require NVENC: %v", err)
}
svc.mu.Lock()
job := svc.jobs[media.ID]
svc.mu.Unlock()
select {
case <-job.done:
case <-time.After(time.Second):
t.Fatal("fake ffmpeg did not finish")
}
calls := string(mustReadTestFile(t, bin+".calls"))
if strings.Count(calls, "\n") != 3 || !strings.Contains(calls, "-c:v libx264") {
t.Fatalf("startup must probe once and encode with libx264, calls=%s", calls)
}
}
+21 -2
View File
@@ -2,7 +2,7 @@ import axios, { AxiosError, type InternalAxiosRequestConfig } from 'axios'
import { useAuthStore } from '../stores/auth'
import { getActivePlayProfileId, getActivePlayProfilePinToken } from '../stores/playProfile'
import type { RefreshOutcome } from '../utils/authRefresh'
import { createTokenPreflight, type RefreshOutcome } from '../utils/authRefresh'
// Single axios instance used by every API helper. Adds the JWT to outgoing
// requests and refreshes it on 401. Only an explicit server-side rejection of
@@ -48,8 +48,27 @@ function isRefreshRequest(config?: InternalAxiosRequestConfig | null): boolean {
return Boolean(config?.url?.includes('/auth/refresh'))
}
// 请求发出前的令牌预检。本地持久化的 token 往往已经过期,而首屏有多个各自
// 独立的 useEffect 会并发发请求;不预检的话它们会一起拿到 401,控制台刷出
// 一整屏红字(现场:2026-10-16 12:49,7 个请求同时 401),用户还要等拦截器
// 刷新完才看到内容。预检把刷新提到请求之前,且刷新协调器本身是单飞的。
const preflightAccessToken = createTokenPreflight({
readAccessToken: () => useAuthStore.getState().token,
refreshSession: () => useAuthStore.getState().refreshSession(),
})
// Add auth token to requests
api.interceptors.request.use((config) => {
api.interceptors.request.use(async (config) => {
// 刷新请求自己绝不能走预检,否则会递归刷新。
if (!isRefreshRequest(config)) {
try {
await preflightAccessToken()
} catch {
// 会话已被服务端判定失效:直接失败,不发无意义的 401 请求。
// 清空会话与跳转登录页由刷新协调器和 401 拦截器负责,保持一致。
throw new axios.Cancel('session expired')
}
}
const token = useAuthStore.getState().token
if (token) {
config.headers = config.headers ?? {}
+4 -1
View File
@@ -102,7 +102,10 @@ export const danmakuAPI = {
api
.get<DanmakuFetchResult>(`/danmaku/${encodeURIComponent(mediaId)}`, {
params: { kw: options.kw || undefined, episodeId: options.episodeId || undefined },
timeout: 20_000,
// 服务端依次做「16MB 前缀哈希 → 上游匹配 → 评论库」三段串行请求,每段的上游
// 超时都是 20 秒。客户端要是也卡在 20 秒,就会在服务端正要降级返回(哈希超时
// 后改按文件名搜索)时把请求撤掉:用户看不到弹幕,服务端还记一条 404。
timeout: 45_000,
})
.then((r) => r.data),
+2 -2
View File
@@ -42,9 +42,9 @@ function publicOriginHeader() {
}
export const playbackAPI = {
getResume: (mediaId: string) =>
getResume: (mediaId: string, signal?: AbortSignal) =>
api
.get<{ position_ms: number; duration_ms: number; completed: boolean }>(`/playback/${mediaId}/resume`)
.get<{ position_ms: number; duration_ms: number; completed: boolean }>(`/playback/${encodeURIComponent(mediaId)}/resume`, { signal })
.then((r) => r.data),
// 片头/片尾片段:播放开始后再调用,服务端可能需要几秒去外部数据库取数,
+4
View File
@@ -6,6 +6,7 @@ const HomePageLoader = () => import('./pages/HomePage').then((m) => ({ default:
const LibraryPageLoader = () => import('./pages/LibraryPage').then((m) => ({ default: m.LibraryPage }))
const LibrariesPageLoader = () => import('./pages/LibrariesPage').then((m) => ({ default: m.LibrariesPage }))
const FavouritesPageLoader = () => import('./pages/FavouritesPage').then((m) => ({ default: m.FavouritesPage }))
const MediaSearchPageLoader = () => import('./pages/MediaSearchPage').then((m) => ({ default: m.MediaSearchPage }))
const PlaylistsPageLoader = () => import('./pages/PlaylistsPage').then((m) => ({ default: m.PlaylistsPage }))
const PlaylistDetailPageLoader = () =>
import('./pages/PlaylistDetailPage').then((m) => ({ default: m.PlaylistDetailPage }))
@@ -21,6 +22,7 @@ const HomePage = lazy(HomePageLoader)
const LibraryPage = lazy(LibraryPageLoader)
const LibrariesPage = lazy(LibrariesPageLoader)
const FavouritesPage = lazy(FavouritesPageLoader)
const MediaSearchPage = lazy(MediaSearchPageLoader)
const PlaylistsPage = lazy(PlaylistsPageLoader)
const PlaylistDetailPage = lazy(PlaylistDetailPageLoader)
const MediaDetailPage = lazy(MediaDetailPageLoader)
@@ -73,6 +75,7 @@ export function prefetchCommonRouteChunks() {
LibrariesPageLoader,
LibraryPageLoader,
MediaDetailPageLoader,
MediaSearchPageLoader,
FavouritesPageLoader,
WatchHistoryPageLoader,
PlaylistsPageLoader,
@@ -98,6 +101,7 @@ export type AppRoute = {
export const appRoutes: AppRoute[] = [
{ index: true, element: <HomePage /> },
{ path: 'search', element: <MediaSearchPage /> },
{ path: 'libraries', element: <LibrariesPage /> },
{ path: 'library/:id', element: <LibraryPage /> },
{ path: 'favourites', element: <FavouritesPage /> },
+1
View File
@@ -25,6 +25,7 @@ function isMediaView(pathname: string, search: string): boolean {
return (
pathname === '/' ||
pathname === '/search' ||
pathname === '/libraries' ||
pathname.startsWith('/library') ||
pathname.startsWith('/media') ||
+57 -27
View File
@@ -7,7 +7,9 @@ import { ARTWORK, imageURL } from '../api/client'
import { mediaAPI } from '../api/library'
import { readerAPI, type ReaderBook } from '../api/reader'
import type { Media, PlayProfile, User } from '../types'
import { favouriteMediaLink } from '../utils/mediaNavigation'
import { favouriteMediaLink, mediaSearchLink, mediaSearchReturnPath } from '../utils/mediaNavigation'
import { useAuthStore } from '../stores/auth'
import { usePlayProfileStore } from '../stores/playProfile'
import { resolveHeaderBack } from './layoutNavigation'
import { LayoutThemeToggle } from './LayoutThemeToggle'
import { LayoutUserMenu } from './LayoutUserMenu'
@@ -56,7 +58,11 @@ export function LayoutHeader({
pathname = '',
}: LayoutHeaderProps) {
const navigate = useNavigate()
const headerBack = resolveHeaderBack(pathname)
const location = useLocation()
const searchFrom = mediaSearchReturnPath(location.state)
const headerBack = searchFrom && (pathname.startsWith('/library/') || pathname.startsWith('/media/'))
? { to: searchFrom, label: '搜索结果' }
: resolveHeaderBack(pathname)
return (
<header className="relative z-30 flex h-16 shrink-0 items-center justify-between gap-2 border-b border-[var(--app-border)] bg-[var(--app-header-bg)] px-3 backdrop-blur-md sm:h-20 sm:gap-4 sm:px-4 md:px-8">
@@ -295,7 +301,12 @@ function LayoutHeaderBookSearch() {
const SEARCH_PAGE_SIZE = 8
function LayoutHeaderSearch() {
const [query, setQuery] = useState('')
const location = useLocation()
const userId = useAuthStore((s) => s.user?.id)
const profileId = usePlayProfileStore((s) => s.activeProfileId)
const [query, setQuery] = useState(() =>
location.pathname === '/search' ? new URLSearchParams(location.search).get('q') ?? '' : '',
)
const [isOpen, setIsOpen] = useState(false)
const [loading, setLoading] = useState(false)
const [loadingMore, setLoadingMore] = useState(false)
@@ -312,7 +323,6 @@ function LayoutHeaderSearch() {
const resultsRef = useRef<Media[]>([])
const hasMoreRef = useRef(false)
const navigate = useNavigate()
const location = useLocation()
const locationKey = `${location.pathname}${location.search}`
const prevLocationKeyRef = useRef(locationKey)
@@ -392,14 +402,21 @@ function LayoutHeaderSearch() {
}
}, 250)
return () => clearTimeout(timer)
}, [query])
return () => {
clearTimeout(timer)
// 卸载或关键词变化时,已发出的旧请求也不能覆盖当前结果。
searchSeqRef.current += 1
}
}, [query, userId, profileId])
useLayoutEffect(() => {
if (prevLocationKeyRef.current === locationKey) return
prevLocationKeyRef.current = locationKey
dismissSearchRef.current(true)
}, [locationKey])
dismissSearchRef.current(false)
if (location.pathname === '/search') {
setQuery(new URLSearchParams(location.search).get('q') ?? '')
}
}, [locationKey, location.pathname, location.search])
useEffect(() => {
function handlePointerDown(e: PointerEvent) {
@@ -446,28 +463,34 @@ function LayoutHeaderSearch() {
}
}
const handleSelect = (item: Media) => {
const openSearchPage = () => {
if (suppressInputRef.current) return
const to = favouriteMediaLink(item)
dismissSearch(true)
navigate(to)
const trimmed = query.trim()
if (!trimmed) return
dismissSearch(false)
navigate(mediaSearchLink(trimmed))
}
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === 'Escape') {
dismissSearch(false)
} else if (e.key === 'Enter' && results.length > 0) {
handleSelect(results[0])
} else if (e.key === 'Enter' && !e.nativeEvent.isComposing && e.keyCode !== 229) {
e.preventDefault()
openSearchPage()
}
}
return (
<div ref={containerRef} className="relative w-full">
<div className="relative flex items-center">
<Search
size={15}
className="absolute left-3 text-[var(--app-muted)] pointer-events-none transition-colors group-focus-within:text-brand-500 sm:left-3.5 sm:text-[16px]"
/>
<button
type="button"
onClick={openSearchPage}
aria-label="搜索媒体"
className="absolute left-3 rounded text-[var(--app-muted)] transition-colors hover:text-brand-500 sm:left-3.5"
>
<Search size={15} />
</button>
<input
ref={inputRef}
type="text"
@@ -488,6 +511,7 @@ function LayoutHeaderSearch() {
}}
onKeyDown={handleKeyDown}
placeholder="搜索媒体…"
aria-label="搜索媒体关键词"
className="w-full h-9 sm:h-10 pl-8 sm:pl-10 pr-8 sm:pr-9 rounded-xl sm:rounded-2xl border border-[var(--app-border)] bg-[var(--app-panel)] text-xs sm:text-sm text-[var(--app-text)] placeholder:text-[var(--app-muted)] shadow-sm outline-none transition-all duration-200 focus:border-brand-500 focus:ring-2 focus:ring-brand-500/20 focus:bg-[var(--app-panel-elevated)]"
/>
{loading ? (
@@ -498,6 +522,7 @@ function LayoutHeaderSearch() {
onClick={() => {
dismissSearch(true)
}}
aria-label="清空媒体搜索"
className="absolute right-3 text-[var(--app-muted)] hover:text-[var(--app-text)] p-0.5 rounded-lg"
>
<X size={15} />
@@ -538,15 +563,11 @@ function LayoutHeaderSearch() {
) : (
<div className="space-y-1">
{results.map((item) => (
<button
<Link
key={item.id}
type="button"
onPointerDown={(e) => {
if (e.button !== 0) return
e.preventDefault()
handleSelect(item)
}}
onClick={() => handleSelect(item)}
to={favouriteMediaLink(item)}
state={{ from: locationKey }}
onClick={() => dismissSearch(false)}
className="flex w-full items-center gap-3 rounded-xl p-2 text-left transition-colors hover:bg-[var(--app-hover)] group"
>
<div className="relative h-12 w-9 shrink-0 overflow-hidden rounded-lg bg-[var(--app-panel-soft)]">
@@ -590,7 +611,7 @@ function LayoutHeaderSearch() {
)}
</div>
</div>
</button>
</Link>
))}
{loadingMore && (
<div className="flex items-center justify-center gap-2 py-3 text-[10px] text-[var(--app-muted)]">
@@ -605,6 +626,15 @@ function LayoutHeaderSearch() {
)}
</div>
)}
<Link
to={mediaSearchLink(query)}
onClick={() => dismissSearch(false)}
className="sticky -bottom-2 flex items-center justify-center gap-2 border-t border-[var(--app-border)] bg-[var(--app-panel)] px-3 py-3 text-xs font-bold text-brand-500 hover:bg-[var(--app-hover)]"
>
<Search size={14} />
查看全部搜索结果
<span className="hidden text-[10px] font-normal text-[var(--app-muted)] sm:inline">按回车搜索</span>
</Link>
</div>
) : null}
</div>
+8 -1
View File
@@ -10,7 +10,7 @@ const ACTION_OVERLAY_CLASS =
// memo:父级状态变化(如轮播切图、其它卡片操作)不再级联重渲染所有卡片。
// 注意 actions/renderActions 必须引用稳定(用 renderActions 传函数)memo 才生效。
export const MediaCard = memo(function MediaCard({
media, progress, count, rating, linkTo, linkState, onClick, actions, renderActions, compact,
media, progress, count, rating, linkTo, linkState, onClick, actions, renderActions, compact, showLibrary,
}: {
media: Media
progress?: number
@@ -22,6 +22,7 @@ export const MediaCard = memo(function MediaCard({
actions?: ReactNode
renderActions?: (media: Media) => ReactNode
compact?: boolean
showLibrary?: boolean
}) {
const ref = useRef<HTMLDivElement>(null)
const href = linkTo ?? `/media/${media.id}`
@@ -31,6 +32,7 @@ export const MediaCard = memo(function MediaCard({
const blurredPosterSrc = imageURL(media.poster_url, media.updated_at, ARTWORK.posterTiny)
const displayRating = rating ?? media.rating
const versionCount = media.versions?.length ?? 0
const libraryName = media.display_library_name || media.library_name
// renderActions 延迟到卡片自身渲染时才调用,保证 memo 生效
const actionContent = actions ?? renderActions?.(media)
@@ -131,6 +133,11 @@ export const MediaCard = memo(function MediaCard({
}`}>
{media.title}
</p>
{showLibrary && libraryName && (
<p title={libraryName} className="truncate text-[10px] text-[var(--app-muted)] sm:text-xs">
{libraryName}
</p>
)}
{/* 单行、不折行:年份可截断,徽标永不压缩。折行会让同一行里各卡片的
徽标落位高低不一,看起来就是「错位」,所以宁可极窄屏上截断年份。
min-h 统一行高,避免有/无徽标的卡片年份基线差几像素。 */}
+3 -2
View File
@@ -74,7 +74,7 @@ export const MOBILE_BOTTOM_NAV_ITEMS: MobileBottomNavItem[] = [
},
]
const ROOT_MEDIA_PATHS = new Set(['/', '/libraries', '/favourites', '/playlists', '/history'])
const ROOT_MEDIA_PATHS = new Set(['/', '/search', '/libraries', '/favourites', '/playlists', '/history'])
/** True only for the fullscreen player route (`/play` or `/play/:id`), not `/playlists`. */
export function isPlayerRoute(pathname: string): boolean {
@@ -131,7 +131,7 @@ export function resolveHeaderBack(pathname: string): HeaderBackTarget | null {
if (pathname === '/history/stats') {
return { to: '/history', label: '观看历史' }
}
if (pathname === '/playlists' || pathname === '/favourites' || pathname === '/history') {
if (pathname === '/search' || pathname === '/playlists' || pathname === '/favourites' || pathname === '/history') {
return { to: '/', label: '首页' }
}
if (pathname.startsWith('/playlist/')) {
@@ -171,6 +171,7 @@ export function shouldShowMobileBottomNav(pathname: string): boolean {
if (isPlayerRoute(pathname)) return false
return (
pathname === '/' ||
pathname === '/search' ||
pathname === '/libraries' ||
pathname.startsWith('/library') ||
pathname.startsWith('/media') ||
+1
View File
@@ -11,6 +11,7 @@ function check(name: string, condition: boolean) {
}
check('home remembers scroll', shouldRememberScroll('/'))
check('media search remembers scroll', shouldRememberScroll('/search'))
check('library list remembers scroll', shouldRememberScroll('/libraries'))
check('library detail remembers scroll', shouldRememberScroll('/library/lib-1'))
check('library series panel remembers scroll', shouldRememberScroll('/library/lib-1'))
+1
View File
@@ -63,6 +63,7 @@ function writeScrollPosition(key: string, value: number): void {
export function shouldRememberScroll(pathname: string): boolean {
return (
pathname === '/' ||
pathname === '/search' ||
pathname === '/libraries' ||
pathname.startsWith('/library/') ||
pathname.startsWith('/media/')
+3 -28
View File
@@ -1,38 +1,13 @@
import { useEffect, useRef } from 'react'
import { useAuthStore } from '../stores/auth'
import { isAccessTokenStale } from '../utils/authRefresh'
// 前 5 次沿用原快速退避间隔;之后进入 60s 慢速重试并不再放弃,
// 服务重启或网络恢复后仍能自动重连(清理函数可随时取消定时器)。
const FAST_RECONNECT_ATTEMPTS = 5
const SLOW_RECONNECT_INTERVAL = 60_000
// token 过期前 30 秒即视为失效,给刷新留出余量。
const TOKEN_EXPIRY_SKEW_MS = 30_000
// decodeJwtExpiryMs 解析 JWT 的 exp 声明并换算成毫秒时间戳。
// 解析失败(非 JWT / 结构异常)返回 null,调用方据此放行重连。
function decodeJwtExpiryMs(token: string): number | null {
const segment = token.split('.')[1]
if (!segment) return null
try {
// JWT 使用 base64url 编码,需先还原字符集并补齐 padding。
const normalized = segment.replace(/-/g, '+').replace(/_/g, '/')
const padded = normalized.padEnd(Math.ceil(normalized.length / 4) * 4, '=')
const claims = JSON.parse(atob(padded)) as { exp?: unknown }
return typeof claims.exp === 'number' ? claims.exp * 1000 : null
} catch {
return null
}
}
// isTokenExpired 判断 token 是否已过期或即将过期。
function isTokenExpired(token: string): boolean {
const expiry = decodeJwtExpiryMs(token)
if (expiry === null) return false
return Date.now() >= expiry - TOKEN_EXPIRY_SKEW_MS
}
// useWebSocket opens a single connection to /api/ws and dispatches every
// message to the supplied handler. Auto-reconnects with back-off while the
// auth token is present; after the fast retries are exhausted it keeps a
@@ -68,7 +43,7 @@ export function useWebSocket(onEvent: (topic: string, payload: unknown) => void)
const open = () => {
if (closed) return
if (isTokenExpired(token)) {
if (isAccessTokenStale(token)) {
refreshExpiredToken()
return
}
@@ -92,7 +67,7 @@ export function useWebSocket(onEvent: (topic: string, payload: unknown) => void)
ws.onclose = () => {
if (closed) return
// token 过期时不要继续退避重试,交给刷新流程处理。
if (isTokenExpired(token)) {
if (isAccessTokenStale(token)) {
refreshExpiredToken()
return
}
+3 -1
View File
@@ -32,12 +32,14 @@ import { useLibrarySeriesSelection } from './useLibrarySeriesSelection'
import { useLibraryAdminActions } from './useLibraryAdminActions'
import { usePermission } from '../hooks/usePermission'
import { useFavourites } from '../hooks/useFavourites'
import { mediaSearchReturnPath } from '../utils/mediaNavigation'
export function LibraryPage() {
const { id = '' } = useParams()
const [searchParams, setSearchParams] = useSearchParams()
const location = useLocation()
const navigate = useNavigate()
const searchFrom = mediaSearchReturnPath(location.state)
const role = useAuthStore((s) => s.user?.role)
const canFavorite = usePermission('can_favorite')
const { isFavourite, toggleFavourite } = useFavourites()
@@ -358,7 +360,7 @@ export function LibraryPage() {
void handleToggleFavourite(selectedSeries.rep.id)
}}
seriesToolBusy={seriesToolBusy}
onBack={clearSelectedSeries}
onBack={() => searchFrom ? navigate(searchFrom) : clearSelectedSeries()}
onSmartScrape={handleSeriesSmartScrape}
onManualScrape={() => setManualSeriesScrapeOpen(true)}
onMetadataEdit={() => setSeriesMetadataEditOpen(true)}
+182
View File
@@ -0,0 +1,182 @@
import { useEffect, useState } from 'react'
import { useSearchParams } from 'react-router-dom'
import { ChevronLeft, ChevronRight, LoaderCircle, RefreshCw, Search } from 'lucide-react'
import { mediaAPI, type MediaSearchPage as SearchPageData } from '../api/library'
import { MediaCard } from '../components/MediaCard'
import { PageBackButton } from '../components/PageBackButton'
import { normalizeListPosition } from '../hooks/useListPositionMemory'
import { useAuthStore } from '../stores/auth'
import { usePlayProfileStore } from '../stores/playProfile'
import { favouriteMediaLink, mediaSearchLink } from '../utils/mediaNavigation'
const PAGE_SIZE = 48
const CACHE_TTL_MS = 5 * 60_000
// 返回详情前的结果可立即显示;按账号、播放档案、关键词和页码隔离。
const pageCache = new Map<string, { at: number; data: SearchPageData }>()
function readCachedPage(key: string): SearchPageData | null {
const cached = pageCache.get(key)
if (!cached) return null
if (Date.now() - cached.at >= CACHE_TTL_MS) {
pageCache.delete(key)
return null
}
return cached.data
}
export function MediaSearchPage() {
const [params, setParams] = useSearchParams()
const query = (params.get('q') ?? '').trim()
const page = normalizeListPosition(params.get('page'))
const userId = useAuthStore((s) => s.user?.id)
const profileId = usePlayProfileStore((s) => s.activeProfileId)
const cacheKey = JSON.stringify([userId, profileId, query, page])
const changePage = (next: number) => {
const nextParams = new URLSearchParams({ q: query })
if (next > 1) nextParams.set('page', String(next))
setParams(nextParams)
}
return (
<MediaSearchResults
key={cacheKey}
cacheKey={cacheKey}
query={query}
page={page}
onPageChange={changePage}
/>
)
}
function MediaSearchResults({ cacheKey, query, page, onPageChange }: {
cacheKey: string
query: string
page: number
onPageChange: (page: number) => void
}) {
const [data, setData] = useState(() => readCachedPage(cacheKey))
const [loading, setLoading] = useState(() => !!query && !readCachedPage(cacheKey))
const [error, setError] = useState('')
const [revision, setRevision] = useState(0)
useEffect(() => {
if (!query) return
const cached = readCachedPage(cacheKey)
if (cached) {
setData(cached)
setLoading(false)
return
}
let cancelled = false
setLoading(true)
setError('')
mediaAPI.searchPage(query, page, PAGE_SIZE, { groupSeries: true })
.then((result) => {
if (cancelled) return
// 限制缓存大小,避免长时间搜索大量关键词后占用过多内存。
if (pageCache.size >= 20) pageCache.delete(pageCache.keys().next().value!)
pageCache.set(cacheKey, { at: Date.now(), data: result })
setData(result)
})
.catch(() => {
if (!cancelled) setError('搜索失败,请稍后重试')
})
.finally(() => {
if (!cancelled) setLoading(false)
})
return () => { cancelled = true }
}, [cacheKey, query, page, revision])
const refresh = () => {
pageCache.delete(cacheKey)
setRevision((value) => value + 1)
}
const items = data?.items ?? []
const total = data?.total ?? items.length
const totalPages = Math.max(1, Math.ceil(total / PAGE_SIZE))
const from = mediaSearchLink(query, page)
return (
<div className="space-y-6">
<PageBackButton to="/" label="返回首页" compact />
<div className="flex items-start justify-between gap-4">
<div className="min-w-0 space-y-2">
<h1 className="font-display text-2xl font-bold text-[var(--app-text)] sm:text-3xl">搜索结果</h1>
<p className="break-words text-sm text-[var(--app-muted)]" aria-live="polite">
{query
? <>“{query}”{data && !loading ? ` · 共 ${total} 条结果` : ''}</>
: '在顶部搜索框输入媒体名称,按回车开始搜索'}
</p>
</div>
{query && (
<button
type="button"
onClick={refresh}
disabled={loading}
className="inline-flex shrink-0 items-center gap-2 rounded-xl border border-[var(--app-border)] bg-[var(--app-panel)] px-3 py-2 text-xs text-[var(--app-subtle)] hover:bg-[var(--app-hover)] disabled:opacity-50"
>
<RefreshCw size={14} />
刷新结果
</button>
)}
</div>
{loading && (
<div role="status" className="flex items-center justify-center gap-2 py-20 text-sm text-[var(--app-muted)]">
<LoaderCircle size={18} className="animate-spin text-brand-500" />
正在搜索…
</div>
)}
{error && (
<div role="alert" className="rounded-2xl border border-red-400/30 bg-[var(--app-panel)] p-5 text-sm text-red-400">
{error}
<button type="button" onClick={refresh} className="ml-3 underline">重试</button>
</div>
)}
{!loading && !error && query && items.length === 0 && (
<div className="flex flex-col items-center gap-3 rounded-2xl border border-[var(--app-border)] bg-[var(--app-panel)] px-6 py-16 text-center text-[var(--app-muted)]">
<Search size={28} />
<p className="break-all text-sm">未搜索到与 “{query}” 相关的媒体内容</p>
<p className="text-xs">试试其他名称或更简短的关键词</p>
</div>
)}
{!loading && items.length > 0 && (
<div className="grid grid-cols-2 gap-4 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6">
{items.map((item) => (
<MediaCard key={item.id} media={item} showLibrary linkTo={favouriteMediaLink(item)} linkState={{ from }} />
))}
</div>
)}
{!loading && !error && query && (totalPages > 1 || page > 1) && (
<nav aria-label="搜索结果分页" className="flex items-center justify-center gap-4 py-4 text-sm">
<button
type="button"
disabled={page <= 1}
onClick={() => onPageChange(page - 1)}
className="inline-flex items-center gap-1 rounded-xl border border-[var(--app-border)] bg-[var(--app-panel)] px-3 py-2 hover:bg-[var(--app-hover)] disabled:cursor-not-allowed disabled:opacity-40"
>
<ChevronLeft size={16} />上一页
</button>
<span className="text-[var(--app-muted)]">第 {page} / {totalPages} 页</span>
<button
type="button"
disabled={page >= totalPages}
onClick={() => onPageChange(page + 1)}
className="inline-flex items-center gap-1 rounded-xl border border-[var(--app-border)] bg-[var(--app-panel)] px-3 py-2 hover:bg-[var(--app-hover)] disabled:cursor-not-allowed disabled:opacity-40"
>
下一页<ChevronRight size={16} />
</button>
</nav>
)}
</div>
)
}
+242 -127
View File
@@ -20,7 +20,7 @@ import { useAuthStore } from '../stores/auth'
import type { Media, PlaybackInfo, PlaybackQuality, PlaybackSegment, PlaybackSegmentKind } from '../types'
import { getSeriesKey, seriesTitleFromPath } from '../utils/groupSeries'
import { formatEpisodeLabel } from '../utils/episodeNumber'
import { mediaVersionMatches, mediaVersionsOf } from '../utils/mediaVersion'
import { autoPlayEpisodeFor, mediaVersionMatches, mediaVersionsOf } from '../utils/mediaVersion'
import { DEFAULT_PLAYBACK_RATE, normalizePlaybackRate } from '../utils/playbackRate'
import { resolveActiveSkip, skippedNoticeText, toSkipSegments, type SkipPrompt } from '../utils/skipSegments'
import { isRemoteEmbyID } from '../utils/remoteEmby'
@@ -37,8 +37,10 @@ import {
type SubtitleStylePreset,
} from '../utils/subtitleDisplay'
import { pickPlayerMode, needsTranscodeForBrowser, isDirectStreamMedia, isStrmMedia, type PlayerMode } from './playerPageModel'
import { classifyDirectPlayError, DIRECT_SEEK_GRACE_MS } from './directPlayError'
import { classifyDirectPlayError, directPlayFailureCopy, directStallTimeoutMs, DIRECT_SEEK_GRACE_MS } from './directPlayError'
import type { DirectPlayFailureReason } from './directPlayError'
import { canSeekDirectNow, DIRECT_SEEK_WAIT_MS } from './directPlaySeek'
import { applyPendingHlsSeek, attachNativeHls, bindPendingHlsSeek, hlsManifestTimeoutMs, playbackPositionSec } from './hlsPlayback'
import { apiErrorMessage } from './StrmManagePage'
import { PlayerTopBar } from './PlayerTopBar'
import { PlayerVideoStage } from './PlayerVideoStage'
@@ -200,6 +202,13 @@ export function PlayerPage() {
// 必须在切换前先记下来,等直连 loadedmetadata 后再跳。
const pendingDirectStartRef = useRef(0)
const hlsSourceRef = useRef<'cloud' | 'local'>('local')
// 起播看门狗要等到 20 秒后才判定,判定时用 ref 读最新的「直连解码」开关,避免
// 把回调塞进播放 effect 的依赖里反复重建。
const directOnlyRef = useRef(false)
// 当前挂在画面上的「播放失败」横幅文案。这类提示描述的是「刚才那一下没起来」,
// 画面真正播起来之后必须撤掉:上游冷启动慢 10 秒就会先误报一次,而播放随后
// 是正常的,留在画面上就成了「编码不兼容」的假结论。
const transientPlayErrorRef = useRef('')
// 弹幕控制:状态来自 /api/danmaku/config 初始值,用户在面板里实时调整。
const [danmakuOpen, setDanmakuOpen] = useState(false)
@@ -519,6 +528,7 @@ export function PlayerPage() {
directRetryRef.current = false
retryingDirectRef.current = false
directSeekGraceUntilRef.current = 0
transientPlayErrorRef.current = ''
pendingDirectSeekRef.current = null
directSeekWaitCleanupRef.current?.()
directSeekWaitCleanupRef.current = null
@@ -553,6 +563,24 @@ export function PlayerPage() {
}
}, [])
/**
* 挂一条「播放起不来」的横幅。走这个入口的提示都会记下来,一旦画面真的开始
* 播放(playing)就被撤掉;切媒体、切线路时也会清空。信息类提示(例如「可能
* 只有画面没有声音」)不走这里,它们播起来之后依然成立。
*/
const showPlayFailure = useCallback((message: string) => {
transientPlayErrorRef.current = message
setPlayerError(message)
}, [])
const clearPlayFailure = useCallback(() => {
const stale = transientPlayErrorRef.current
if (!stale) return
transientPlayErrorRef.current = ''
// 只撤掉自己挂的那条:期间可能已经换了别的提示。
setPlayerError((prev) => (prev === stale ? '' : prev))
}, [])
const clearDirectSeekWait = useCallback(() => {
directSeekWaitCleanupRef.current?.()
directSeekWaitCleanupRef.current = null
@@ -742,6 +770,7 @@ export function PlayerPage() {
if (cancelled) return
setMedia(m)
setPlayerError('')
transientPlayErrorRef.current = ''
setLoadError('')
const isDirect = isDirectStreamMedia(m)
const auto = pickPlayerMode(m)
@@ -875,6 +904,13 @@ export function PlayerPage() {
// 加载统一播放能力:115 云端清晰度 + 本地 HLS 清晰度。
useEffect(() => {
if (!mediaId) return
// 远程 Emby 条目只存在于远程库里,本地 media 表没有它的伪 ID,问这个接口只会
// 拿到 404(控制台里就是一条红字);而它的播放方式恒为「远程直连、不转码」,
// 也没有任何可切换的档位。播放地址由 /api/stream/:id 负责解析。
if (isRemoteEmbyID(mediaId)) {
setPlaybackInfo(null)
return
}
let cancelled = false
mediaAPI
.playbackInfo(mediaId)
@@ -957,14 +993,15 @@ export function PlayerPage() {
}, [])
const switchToLocalHLS = useCallback(
(position = 0) => {
(position?: number) => {
pendingSeekRef.current = null
setHlsSource('local')
setCloudWaiting(false)
setSelectedQuality((prev) => {
if (playbackInfo?.local_qualities?.some((quality) => quality.id === prev)) return prev
return defaultLocalQualityId(playbackInfo)
})
if (position > 2) setHlsStartSec(position)
if (position !== undefined) setHlsStartSec(Math.max(0, position))
setPlaybackMode('hls')
},
[playbackInfo, setPlaybackMode],
@@ -1041,10 +1078,10 @@ export function PlayerPage() {
: undefined
if (preferred?.source === 'cloud') {
setHlsSource('cloud')
// 云 HLS 是整段时间轴,等待云转码期间也要保留目标位置。
pendingSeekRef.current = position > 2 ? position : null
if (preferred.available) {
setCloudWaiting(false)
// 云 HLS 是整段时间轴:切过去后按当前位置跳一下,别让用户从头看。
pendingSeekRef.current = position > 2 ? position : null
} else {
setCloudWaiting(true)
setCloudWaitMessage(preferred.note || `正在等待 115 转码 ${preferred.label}…`)
@@ -1109,8 +1146,9 @@ export function PlayerPage() {
if (!playbackInfo || !initialHlsRequestRef.current) return
if (directOnly || !mediaId) return
initialHlsRequestRef.current = false
enterHlsPlayback(0, playbackInfo)
}, [directOnly, enterHlsPlayback, mediaId, playbackInfo])
const position = Math.max(resumePosition, playbackPositionSec(mode, hlsSource, hlsStartSec, ref.current?.currentTime || 0))
enterHlsPlayback(position, playbackInfo)
}, [directOnly, enterHlsPlayback, hlsSource, hlsStartSec, mediaId, mode, playbackInfo, resumePosition])
// 直连播放只发现外挂字幕;只有 HLS 模式需要探测可烧录的内嵌字幕。
useEffect(() => {
@@ -1145,13 +1183,122 @@ export function PlayerPage() {
const mediaRef = useRef(media)
useEffect(() => {
mediaRef.current = media
}, [media])
directOnlyRef.current = directOnly
}, [directOnly, media])
/**
* 直连失败后的统一回退:按当前源与设置决定切 115 云转码 / 本地转码 / 明确报错。
* error 分支与起播看门狗(armDirectStallWatchdog)共用同一套判定。
*
* reason:'stall' = 看门狗等到期还没数据;'error' = 媒体元素报了 error(重试过
* 一次仍然失败)。两者文案必须分开——超时的真实原因多半是上游慢,不是编码不兼容。
*/
const fallbackDirectPlay = useCallback((reason: DirectPlayFailureReason = 'error') => {
if (modeRef.current !== 'direct') return
// 宽限期内又触发的迟到 fallback 定时器:直接丢掉。
if (Date.now() < directSeekGraceUntilRef.current) return
const current = ref.current
if (current && current.readyState >= HTMLMediaElement.HAVE_CURRENT_DATA) return
if (isRemoteEmbyID(mediaRef.current?.id) || isDirectStreamMedia(mediaRef.current)) {
const copy = directPlayFailureCopy('remote-emby', reason)
showPlayFailure(copy.banner)
toast.error(copy.toast)
} else if (directOnly) {
const copy = directPlayFailureCopy('direct-only', reason)
showPlayFailure(copy.banner)
toast.error(copy.toast)
} else if (playbackInfo?.provider === 'cloud115') {
const preferred =
findPlaybackQualityById(playbackInfo, selectedQuality) ??
findPlaybackQualityById(playbackInfo, playbackInfo.default_quality)
if (preferred?.source === 'cloud' && preferred.available) {
setCloudWaiting(false)
setHlsSource('cloud')
setPlaybackMode('hls')
} else if (preferred?.source === 'cloud') {
setHlsSource('cloud')
setCloudWaiting(true)
setCloudWaitMessage(preferred.note || `正在等待 115 转码 ${preferred.label}…`)
void startCloudTranscode(Number(preferred.id) || 4)
} else {
switchToLocalHLS(current?.currentTime || 0)
}
} else if (hlsUnavailable) {
showPlayFailure('直接播放失败,且 HLS 转码不可用。请检查文件是否存在,或配置本机 ffmpeg 后使用 HLS 转码播放。')
toast.error('直接播放失败,HLS 转码不可用')
} else {
toast.error('直接播放失败,切换到 HLS 转码')
switchToLocalHLS(current?.currentTime || 0)
}
}, [
directOnly,
hlsUnavailable,
playbackInfo,
selectedQuality,
setPlaybackMode,
showPlayFailure,
startCloudTranscode,
switchToLocalHLS,
])
/**
* 安排一次直连回退判定。STRM/115 直链冷启动比本地文件慢,给足缓冲时间再判失败。
* 只由 handleVideoError(媒体元素真的报了 error)触发,所以原因按 'error' 记。
*/
const scheduleDirectFallback = useCallback(() => {
clearFallbackTimer()
const fallbackDelayMs = isStrmMedia(mediaRef.current) ? 4000 : 1500
fallbackTimerRef.current = setTimeout(() => fallbackDirectPlay('error'), fallbackDelayMs)
}, [clearFallbackTimer, fallbackDirectPlay])
// 看门狗定时器可能比一次渲染活得更久:始终调用最新一版回退逻辑,避免用
// 「起播那一刻」捕获的 playbackInfo(那时可能还是 null)去判定,把本该走
// 115 云转码的条目误判成本地转码。
const fallbackDirectPlayRef = useRef<(reason?: DirectPlayFailureReason) => void>(() => {})
useEffect(() => {
fallbackDirectPlayRef.current = fallbackDirectPlay
}, [fallbackDirectPlay])
/**
* 直连起播看门狗。
*
* 直连没有 hls.js 那种 manifestLoadingTimeOut 兜底:请求挂住、或上游回了不带
* error 事件的响应时,<video> 会一直停在 readyState=0——不出画面、不报错、
* 也不触发 handleVideoError,播放器就静默卡死(表现为「进去不自动播放」,
* 只能退出重进或手动切播放方式)。这里给每次直连起播挂一个兜底定时器:
* 到点仍是 readyState=0(连元数据都没解析到)就按失败处理;只要已经拿到
* metadata 就说明链路是通的,只是慢,交给 playing 事件收尾。
*
* 期限本身要按「失败之后还有没有补救动作」来定:能切 HLS 的源早点换线路对
* 用户有利;远程 Emby / 直连解码没有可退的线路,等不到数据时唯一的产出是
* 一条提示,所以必须容忍跨网上游的冷启动(见 directStallTimeoutMs)。
*/
const armDirectStallWatchdog = useCallback(() => {
clearFallbackTimer()
const currentMedia = mediaRef.current
const canFallback = !isDirectStreamMedia(currentMedia) && !directOnlyRef.current
const timeoutMs = directStallTimeoutMs({
isStrm: isStrmMedia(currentMedia),
canFallback,
})
fallbackTimerRef.current = setTimeout(() => {
fallbackTimerRef.current = null
if (modeRef.current !== 'direct') return
if (Date.now() < directSeekGraceUntilRef.current) return
const current = ref.current
if (current && current.readyState >= HTMLMediaElement.HAVE_METADATA) return
// 这里只可能是「一直没数据」,按超时文案报,不要说成编码不兼容。
fallbackDirectPlayRef.current('stall')
}, timeoutMs)
}, [clearFallbackTimer])
useEffect(() => {
if (!mediaId || !ref.current) return
const currentMedia = mediaRef.current
if (!currentMedia) return
let cancelled = false
let cleanupHlsResume: (() => void) | undefined
let onDirectMetadata: (() => void) | undefined
teardownHls()
const video = ref.current
@@ -1168,13 +1315,14 @@ export function PlayerPage() {
void import('hls.js').then(({ default: HlsCtor }) => {
if (cancelled || !ref.current) return
if (HlsCtor.isSupported()) {
cleanupHlsResume = bindPendingHlsSeek(video, pendingSeekRef)
const hls = new HlsCtor({
enableWorker: true,
lowLatencyMode: false,
// The server waits up to 45s for the first segment. Slow two-core
// hosts and remote STRM sources regularly need more than hls.js's
// 10s default, which otherwise aborts a healthy transcode.
manifestLoadingTimeOut: 60_000,
manifestLoadingTimeOut: hlsManifestTimeoutMs(hlsSource, hlsStartSec),
// 115 云 HLS 的 master.m3u8 每次都要现调 115 接口(实测 0.5–1.3s),
// 还可能撞上 409「正在转码」。只重试 1 次的话,一次抖动就判死刑,
// 直接掉到本地转码。
@@ -1188,15 +1336,7 @@ export function PlayerPage() {
hls.loadSource(url)
hls.attachMedia(video)
hls.on(HlsCtor.Events.MANIFEST_PARSED, () => {
if (pendingSeekRef.current !== null) {
const target = pendingSeekRef.current
pendingSeekRef.current = null
try {
video.currentTime = target
} catch {
// ignore
}
}
applyPendingHlsSeek(video, pendingSeekRef)
void video.play().catch(() => undefined)
// .strm 入库时常缺 duration;转码启动时会补探测,这里刷新一次给进度条总时长。
if (durationSec > 0) return
@@ -1224,10 +1364,12 @@ export function PlayerPage() {
}
setHlsUnavailable(true)
if (playbackProvider === 'cloud115') {
setPlayerError('115 云端和本地转码均不可用,请检查账号授权或稍后重试。')
showPlayFailure('115 云端和本地转码均不可用,请检查账号授权或稍后重试。')
toast.error('115 云端和本地转码均不可用')
return
}
// 这条同时是「为什么可能没声音」的说明,播起来之后依然成立,
// 所以不走 showPlayFailure(不随 playing 撤掉)。
setPlayerError('HLS 转码不可用,正在尝试直接播放原始文件。若出现有画面无声音,通常是 MKV/AC3/EAC3 音轨需要配置本机 ffmpeg 转码为 AAC。')
toast.error('HLS 转码失败,尝试切换到直接播放')
setPlaybackMode('direct')
@@ -1244,18 +1386,17 @@ export function PlayerPage() {
} catch {
// ignore
}
video.src = url
void video.play().catch(() => undefined)
cleanupHlsResume = attachNativeHls(video, url, pendingSeekRef)
} else {
setHlsUnavailable(true)
setPlayerError('当前浏览器不支持 HLS,正在尝试直接播放。')
showPlayFailure('当前浏览器不支持 HLS,正在尝试直接播放。')
toast.error('当前浏览器不支持 HLS,降级到直接播放')
setPlaybackMode('direct')
}
}).catch(() => {
if (cancelled) return
setHlsUnavailable(true)
setPlayerError('HLS 播放组件加载失败,正在尝试直接播放。')
showPlayFailure('HLS 播放组件加载失败,正在尝试直接播放。')
setPlaybackMode('direct')
})
} else {
@@ -1277,41 +1418,48 @@ export function PlayerPage() {
)
pendingDirectStartRef.current = 0
if (resumeAt > 0) {
video.addEventListener(
'loadedmetadata',
() => {
// STRM/115 直链要等 seekable 覆盖目标位置再写 currentTime(否则会被
// 钳回 0),并顺带打开误报宽限期,避免这次跳转被当成播放失败又弹回 HLS。
if (isStrmMedia(currentMedia)) {
seekStrmDirectTo(resumeAt)
return
}
try {
video.currentTime = resumeAt
} catch {
// ignore
}
},
{ once: true },
)
onDirectMetadata = () => {
// STRM/115 直链要等 seekable 覆盖目标位置再写 currentTime(否则会被
// 钳回 0),并顺带打开误报宽限期,避免这次跳转被当成播放失败又弹回 HLS。
if (isStrmMedia(currentMedia)) {
seekStrmDirectTo(resumeAt)
return
}
try {
video.currentTime = resumeAt
} catch {
// ignore
}
}
video.addEventListener('loadedmetadata', onDirectMetadata, { once: true })
}
video.src = url
void video.play().catch(() => undefined)
// 起播兜底:上游不回 error 时会一直停在 readyState=0,见 armDirectStallWatchdog。
armDirectStallWatchdog()
}
if (hlsUnavailable && needsTranscodeForBrowser(currentMedia)) {
setPlayerError('当前正在直连播放原始文件;此封装或音轨浏览器兼容性有限,可能只有画面没有声音。请配置本机 ffmpeg 后切回 HLS 转码播放。')
}
}
const onPlaying = () => clearFallbackTimer()
const onPlaying = () => {
clearFallbackTimer()
// 画面真的动了:之前挂的「起不来」提示一律作废。
clearPlayFailure()
}
video.addEventListener('playing', onPlaying)
return () => {
cancelled = true
video.removeEventListener('playing', onPlaying)
if (onDirectMetadata) video.removeEventListener('loadedmetadata', onDirectMetadata)
cleanupHlsResume?.()
teardownHls()
}
}, [
activeBurnedSubtitleStream,
armDirectStallWatchdog,
clearFallbackTimer,
clearPlayFailure,
cloudReloadNonce,
cloudWaiting,
hlsSource,
@@ -1324,6 +1472,7 @@ export function PlayerPage() {
seekStrmDirectTo,
selectedQuality,
setPlaybackMode,
showPlayFailure,
switchToLocalHLS,
teardownHls,
vr360DirectProxy,
@@ -1352,19 +1501,22 @@ export function PlayerPage() {
if (!id) return
setResumePosition(0)
setInitialSeekDone(false)
const controller = new AbortController()
playbackAPI
.getResume(id)
.getResume(id, controller.signal)
.then((progress) => {
if (controller.signal.aborted) return
if (progress.position_ms > 2000 && !progress.completed) {
setResumePosition(progress.position_ms / 1000)
}
})
.catch(() => undefined)
return () => controller.abort()
}, [id])
useEffect(() => {
if (!resumePosition || initialSeekDone) return
if (mode === 'hls') {
if (mode === 'hls' && hlsSource === 'local') {
// Restart transcode near the resume point instead of seeking a short partial playlist.
if (Math.abs(hlsStartSec - resumePosition) > 2) {
setHlsStartSec(resumePosition)
@@ -1383,6 +1535,7 @@ export function PlayerPage() {
let seekStarted = false
let onPlaying: (() => void) | undefined
let onCanPlay: (() => void) | undefined
let onSeeked: (() => void) | undefined
const notifyResume = () => {
const m = Math.floor(resumePosition / 60)
@@ -1399,7 +1552,7 @@ export function PlayerPage() {
return
}
seekStarted = true
if (isStrmMedia(mediaRef.current)) {
if (mode === 'direct' && isStrmMedia(mediaRef.current)) {
seekStrmDirectTo(resumePosition, {
timeoutMs: 6000,
onSettled: () => {
@@ -1426,7 +1579,8 @@ export function PlayerPage() {
directSeekGraceUntilRef.current = Date.now() + DIRECT_SEEK_GRACE_MS
directRetryRef.current = false
clearFallbackTimer()
const onSeeked = () => {
onSeeked = () => {
if (cancelled) return
setInitialSeekDone(true)
notifyResume()
}
@@ -1439,7 +1593,7 @@ export function PlayerPage() {
}
// STRM/云盘:等真正起播后再续播跳转,避免 302 未完成就 seek。
const deferForStrm = isStrmMedia(mediaRef.current)
const deferForStrm = mode === 'direct' && isStrmMedia(mediaRef.current)
if (deferForStrm) {
onPlaying = () => {
performResumeSeek()
@@ -1462,8 +1616,9 @@ export function PlayerPage() {
cancelled = true
if (onPlaying) video.removeEventListener('playing', onPlaying)
if (onCanPlay) video.removeEventListener('canplay', onCanPlay)
if (onSeeked) video.removeEventListener('seeked', onSeeked)
}
}, [resumePosition, initialSeekDone, mode, hlsStartSec, clearFallbackTimer, seekStrmDirectTo])
}, [resumePosition, initialSeekDone, mode, hlsSource, hlsStartSec, clearFallbackTimer, seekStrmDirectTo])
// 使用 ref 实时同步进度计算所需的状态,避免每次 hlsStartSec 改变都触发 cleanup 并误上报旧进度
const hlsStartSecRef = useRef(hlsStartSec)
@@ -1589,6 +1744,17 @@ export function PlayerPage() {
return playlistEpisodes.findIndex((e) => mediaVersionMatches(e, media.id))
}, [media, playlistEpisodes])
// 用剧集 ID 打开播放器时(首页轮播、剧集库卡片、剧集详情页的播放按钮),先落到第一集:
// 这类条目没有可播文件,直接请求 /api/stream/<剧集ID> 会被远程 Emby 以 500 打回。
useEffect(() => {
const target = autoPlayEpisodeFor(media, id, playlistEpisodes)
if (!target) return
navigate(
{ pathname: `/play/${target.id}`, search: location.search },
{ replace: true, state: location.state },
)
}, [media, id, playlistEpisodes, navigate, location.search, location.state])
const prevEpisode = useMemo(() => {
if (currentEpisodeIndex > 0) {
return playlistEpisodes[currentEpisodeIndex - 1]
@@ -2059,28 +2225,22 @@ export function PlayerPage() {
toast.error('图片字幕需要开启 HLS 转码后才能显示')
return
}
const position = playbackPositionSec(mode, hlsSource, hlsStartSec, ref.current?.currentTime || 0)
if (nextTrack?.delivery === 'burn') {
// PGS 等图片字幕必须走本地 FFmpeg 烧录,不能交给云端 HLS。
switchToLocalHLS(ref.current?.currentTime || 0)
}
const burnChanged =
oldTrack?.delivery === 'burn' || nextTrack?.delivery === 'burn'
if (burnChanged && mode === 'hls' && ref.current) {
setHlsStartSec(hlsStartSec + (ref.current.currentTime || 0))
switchToLocalHLS(position)
} else if (oldTrack?.delivery === 'burn' && mode === 'hls') {
setHlsStartSec(position)
}
setSubtitleIndex(index)
if (nextTrack?.delivery === 'burn' && mode !== 'hls' && !directOnly && !isDirectStream) {
setHlsStartSec(ref.current?.currentTime || 0)
setPlaybackMode('hls')
}
}, [directOnly, hlsStartSec, isDirectStream, mode, setPlaybackMode, subs, subtitleIndex, switchToLocalHLS])
}, [directOnly, hlsSource, hlsStartSec, isDirectStream, mode, subs, subtitleIndex, switchToLocalHLS])
const selectPlaybackQuality = useCallback(
(quality: PlaybackQuality) => {
const video = ref.current
const localTime = video?.currentTime || 0
// 云 HLS 的 currentTime 本来就在绝对时间轴上;本地 HLS 的 t=0 对应 hlsStartSec。
const position = mode === 'hls' && hlsSource === 'local' ? hlsStartSec + localTime : localTime
const position = playbackPositionSec(mode, hlsSource, hlsStartSec, localTime)
if (quality.source === 'original') {
setCloudWaiting(false)
setHlsSource('local')
@@ -2092,12 +2252,13 @@ export function PlayerPage() {
if (quality.source === 'cloud') {
setSelectedQuality(quality.id)
setHlsSource('cloud')
setHlsStartSec(position)
pendingSeekRef.current = position > 2 ? position : null
// 用户手动选了云端档位:这是一次明确的重新尝试,重置自动重连的次数窗口。
cloudReloadsRef.current = []
if (quality.available) {
setCloudWaiting(false)
setCloudWaitMessage('')
if (position > 2) pendingSeekRef.current = position
setPlaybackMode('hls')
} else {
setCloudWaiting(true)
@@ -2117,7 +2278,8 @@ export function PlayerPage() {
toast.error(quality.note || '当前无法进行 HLS 转码,请在「系统设置 → 常规」安装 ffmpeg 后重试')
return
}
if (position > 2) setHlsStartSec(position)
pendingSeekRef.current = null
setHlsStartSec(position)
setPlaybackMode('hls')
},
[hlsSource, hlsStartSec, mode, setPlaybackMode, startCloudTranscode],
@@ -2150,7 +2312,7 @@ export function PlayerPage() {
const handleVideoError = useCallback(() => {
const video = ref.current
if (mode !== 'direct') {
setPlayerError('视频播放失败,请检查文件是否存在,或确认 ffmpeg 已正确配置。')
showPlayFailure('视频播放失败,请检查文件是否存在,或确认 ffmpeg 已正确配置。')
toast.error('视频播放失败,请检查文件是否存在')
return
}
@@ -2174,16 +2336,21 @@ export function PlayerPage() {
directRetryRef.current = true
retryingDirectRef.current = true
try {
// STRM/115 直连:load() 会清空进度从头播。软恢复即可。
if (isStrmMedia(mediaRef.current) || pendingDirectSeekRef.current !== null) {
const pending = pendingDirectSeekRef.current
void video.play().catch(() => undefined)
if (pending !== null && pending > 1) {
seekStrmDirectTo(pending)
}
} else {
video.load()
void video.play().catch(() => undefined)
// 必须真正重新发起请求。媒体元素加载失败后 networkState 已经是
// NETWORK_NO_SOURCE(不是 NETWORK_EMPTY),此时只调 play() 不会重跑资源
// 选取算法:既不会重新请求,也不会再触发一次 error,于是没人去挂 fallback
// 定时器——上游 403/502 一次就把播放器钉死在「direct + 空 src」上,没有
// 错误提示也不会自动播放,只能退出重进或手动切播放方式。
//
// classifyDirectPlayError 只在 readyState < HAVE_CURRENT_DATA 时返回
// 'retry',本来就没有可保住的播放进度,所以 load() 是安全的;需要续播
// 的位置由 seekStrmDirectTo 在 metadata 就绪后补回。
const resumeAt = pendingDirectSeekRef.current
pendingDirectSeekRef.current = null
video.load()
void video.play().catch(() => undefined)
if (resumeAt !== null && resumeAt > 1) {
seekStrmDirectTo(resumeAt)
}
} finally {
retryingDirectRef.current = false
@@ -2191,60 +2358,8 @@ export function PlayerPage() {
return
}
const fallbackDirectPlay = () => {
if (modeRef.current !== 'direct') return
// 宽限期内又触发的迟到 fallback 定时器:直接丢掉。
if (Date.now() < directSeekGraceUntilRef.current) return
const current = ref.current
if (current && current.readyState >= HTMLMediaElement.HAVE_CURRENT_DATA) return
if (isRemoteEmbyID(mediaRef.current?.id) || isDirectStreamMedia(mediaRef.current)) {
setPlayerError('直接播放失败。该媒体为远程 Emby 挂载直连播放(不进行转码);当前浏览器可能不支持该视频编码或音频格式,建议使用外部播放器(如 PotPlayer / VLC / IINA)播放。')
toast.error('直接播放失败,建议使用外部播放器')
} else if (directOnly) {
setPlayerError('直接播放失败。当前为「客户端直连解码」模式,宿主机不转码;请使用支持该编码/封装的播放器(如 Infuse / VLC / Emby 客户端)播放,或关闭直连解码模式。')
toast.error('直接播放失败(客户端直连解码模式)')
} else if (playbackInfo?.provider === 'cloud115') {
const preferred =
findPlaybackQualityById(playbackInfo, selectedQuality) ??
findPlaybackQualityById(playbackInfo, playbackInfo.default_quality)
if (preferred?.source === 'cloud' && preferred.available) {
setCloudWaiting(false)
setHlsSource('cloud')
setPlaybackMode('hls')
} else if (preferred?.source === 'cloud') {
setHlsSource('cloud')
setCloudWaiting(true)
setCloudWaitMessage(preferred.note || `正在等待 115 转码 ${preferred.label}…`)
void startCloudTranscode(Number(preferred.id) || 4)
} else {
switchToLocalHLS(current?.currentTime || 0)
}
} else if (hlsUnavailable) {
setPlayerError('直接播放失败,且 HLS 转码不可用。请检查文件是否存在,或配置本机 ffmpeg 后使用 HLS 转码播放。')
toast.error('直接播放失败,HLS 转码不可用')
} else {
toast.error('直接播放失败,切换到 HLS 转码')
switchToLocalHLS(current?.currentTime || 0)
}
}
clearFallbackTimer()
// STRM/115 直链冷启动比本地文件慢,给足缓冲时间再判定失败。
const fallbackDelayMs = isStrmMedia(mediaRef.current) ? 4000 : 1500
fallbackTimerRef.current = setTimeout(fallbackDirectPlay, fallbackDelayMs)
}, [
clearFallbackTimer,
directOnly,
hlsUnavailable,
mediaId,
mode,
playbackInfo,
seekStrmDirectTo,
selectedQuality,
setPlaybackMode,
startCloudTranscode,
switchToLocalHLS,
])
scheduleDirectFallback()
}, [mediaId, mode, scheduleDirectFallback, seekStrmDirectTo, showPlayFailure])
const changeSubtitlePosition = useCallback((nextPosition: SubtitlePosition) => {
setSubtitlePosition(nextPosition)
+59
View File
@@ -6,6 +6,65 @@ const HAVE_CURRENT_DATA = 2
/** 程序化续播/跳转后忽略直连误报的宽限期(毫秒)。 */
export const DIRECT_SEEK_GRACE_MS = 12_000
/** 直连起播看门狗的等待上限(毫秒)。 */
export const DIRECT_STALL_TIMEOUT_MS = 6_000
/** STRM / 云盘直链要等 302 换链,冷启动比本地文件慢,给长一点的期限。 */
export const DIRECT_STALL_TIMEOUT_STRM_MS = 12_000
/**
* 没有可退线路(远程 Emby 挂载、客户端直连解码)时的等待上限。
*
* 这两种源都不在宿主机转码,等不到数据时没有任何补救动作——唯一的输出是给用户
* 一条提示,所以宁可多等:跨网上游的冷启动实测要 8–11 秒才吐第一段数据,6 秒
* 就判失败只会把「上游慢」写成「浏览器不支持这个编码」,而画面其实几秒后就播上了。
*/
export const DIRECT_STALL_TIMEOUT_NO_FALLBACK_MS = 20_000
/**
* 起播看门狗该等多久才把「还没起来」当成「起不来」。
*
* canFallback=false:直连失败没有可切的线路(远程 Emby / 直连解码),失败提示
* 是唯一产出,所以要容忍慢启动。canFallback=true:还能切 HLS,早点换线路对用户
* 更有利,保持较短期限。
*/
export function directStallTimeoutMs(input: { isStrm?: boolean; canFallback: boolean }): number {
if (!input.canFallback) return DIRECT_STALL_TIMEOUT_NO_FALLBACK_MS
return input.isStrm ? DIRECT_STALL_TIMEOUT_STRM_MS : DIRECT_STALL_TIMEOUT_MS
}
/** 直连起播失败的两类起因:看门狗等超时(上游一直没数据)/ 媒体元素真的报了 error。 */
export type DirectPlayFailureReason = 'stall' | 'error'
/** 直连失败时用户看到的来源:远程 Emby 挂载 / 客户端直连解码。 */
export type DirectPlayFailureKind = 'remote-emby' | 'direct-only'
/**
* 直连失败的横幅与 toast 文案。
*
* 超时和真报错的文案必须分开:超时的真实原因是上游慢,不该断言「浏览器不支持这个
* 编码」(实测最常见的情形就是慢,画面随后自己就播上了)。
*/
export function directPlayFailureCopy(
kind: DirectPlayFailureKind,
reason: DirectPlayFailureReason = 'error',
): { banner: string; toast: string } {
if (reason === 'stall') {
return {
banner:
kind === 'remote-emby'
? '直接播放失败:远程 Emby 挂载的流长时间没有返回数据(该线路不转码,只能直连)。可稍后重试,或用外部播放器(如 PotPlayer / VLC / IINA)播放。'
: '直接播放失败:客户端直连解码模式下宿主机不转码,流长时间没有返回数据。可稍后重试,或关闭直连解码模式改用转码播放。',
toast: '直连流长时间没有响应',
}
}
return {
banner:
kind === 'remote-emby'
? '直接播放失败。该媒体为远程 Emby 挂载直连播放(不进行转码);当前浏览器可能不支持该视频编码或音频格式,建议使用外部播放器(如 PotPlayer / VLC / IINA)播放。'
: '直接播放失败。当前为「客户端直连解码」模式,宿主机不转码;请使用支持该编码/封装的播放器(如 Infuse / VLC / Emby 客户端)播放,或关闭直连解码模式。',
toast: '直接播放失败,建议使用外部播放器',
}
}
/**
* Chromium 常把被中断的直连(换 src、插入 track、302 未完成就 seek)
* 报成 error。真正不兼容应 fallback;瞬时中断应忽略或静默重试一次。
+58
View File
@@ -0,0 +1,58 @@
import { strict as assert } from 'node:assert'
import { applyPendingHlsSeek, attachNativeHls, bindPendingHlsSeek, hlsManifestTimeoutMs, playbackPositionSec } from './hlsPlayback.ts'
class FakeVideo extends EventTarget {
src = ''
rejectSeek = false
position = 0
playCalls = 0
get currentTime() { return this.position }
set currentTime(value: number) {
if (this.rejectSeek) throw new Error('metadata not ready')
this.position = value
}
play() { this.playCalls += 1; return Promise.resolve() }
}
const video = new FakeVideo()
const pending = { current: 420 as number | null }
const detach = attachNativeHls(video, '/cloud/master.m3u8', pending)
assert.equal(video.src, '/cloud/master.m3u8')
assert.equal(video.playCalls, 1)
assert.equal(video.currentTime, 0, 'seek must wait for the new source metadata')
video.rejectSeek = true
video.dispatchEvent(new Event('loadedmetadata'))
assert.equal(pending.current, 420, 'a rejected seek must survive until canplay')
video.rejectSeek = false
video.dispatchEvent(new Event('canplay'))
assert.equal(video.currentTime, 420, 'native HLS must restore the absolute cloud position')
assert.equal(pending.current, null)
pending.current = 600
detach()
video.dispatchEvent(new Event('loadedmetadata'))
assert.equal(video.currentTime, 420, 'a detached source must not apply late metadata events')
assert.equal(pending.current, 600)
const msePending = { current: 120 as number | null }
video.rejectSeek = true
assert.equal(applyPendingHlsSeek(video, msePending), false)
assert.equal(msePending.current, 120)
video.rejectSeek = false
assert.equal(applyPendingHlsSeek(video, msePending), true)
assert.equal(msePending.current, null)
const lateMsePending = { current: 900 as number | null }
video.rejectSeek = true
const detachMse = bindPendingHlsSeek(video, lateMsePending)
assert.equal(applyPendingHlsSeek(video, lateMsePending), false)
video.rejectSeek = false
video.dispatchEvent(new Event('loadedmetadata'))
assert.equal(video.currentTime, 900, 'hls.js seeks rejected during manifest parsing must retry after metadata')
assert.equal(lateMsePending.current, null)
detachMse()
assert.ok(hlsManifestTimeoutMs('local', 1800) > 120_000, 'deep seeks must outlast the server readiness deadline')
assert.equal(hlsManifestTimeoutMs('local', 0), 60_000)
assert.equal(hlsManifestTimeoutMs('cloud', 1800), 60_000)
assert.equal(playbackPositionSec('hls', 'cloud', 420, 600), 600, 'cloud subtitle/quality switches must not add an old local offset')
assert.equal(playbackPositionSec('hls', 'local', 420, 60), 480, 'local subtitle switches preserve the source position')
assert.equal(playbackPositionSec('direct', 'local', 420, 60), 60)
console.log('hlsPlayback.test.ts ok')
+43
View File
@@ -0,0 +1,43 @@
type HlsVideo = Pick<HTMLVideoElement, 'src' | 'currentTime' | 'addEventListener' | 'removeEventListener' | 'play'>
type PendingSeek = { current: number | null }
/** Local mid-file restarts may wait 120 seconds on the server before a playlist exists. */
export function hlsManifestTimeoutMs(source: 'cloud' | 'local', startSec: number): number {
return source === 'local' && startSec > 0.05 ? 135_000 : 60_000
}
/** Keep a pending seek until the media element accepts it; metadata can arrive later. */
export function applyPendingHlsSeek(video: Pick<HlsVideo, 'currentTime'>, pending: PendingSeek): boolean {
if (pending.current === null) return true
if (!Number.isFinite(pending.current) || pending.current < 0) return false
try {
video.currentTime = pending.current
pending.current = null
return true
} catch {
return false
}
}
export function bindPendingHlsSeek(video: HlsVideo, pending: PendingSeek): () => void {
const restore = () => { applyPendingHlsSeek(video, pending) }
video.addEventListener('loadedmetadata', restore)
video.addEventListener('canplay', restore)
return () => {
video.removeEventListener('loadedmetadata', restore)
video.removeEventListener('canplay', restore)
}
}
/** Native HLS needs the same seek restoration as hls.js, with removable listeners. */
export function attachNativeHls(video: HlsVideo, url: string, pending: PendingSeek): () => void {
const detach = bindPendingHlsSeek(video, pending)
video.src = url
void video.play().catch(() => undefined)
return detach
}
/** Only locally transcoded HLS uses a relative timeline starting at startSec. */
export function playbackPositionSec(mode: 'direct' | 'hls', source: 'cloud' | 'local', startSec: number, currentTime: number): number {
return mode === 'hls' && source === 'local' ? startSec + currentTime : currentTime
}
+40 -1
View File
@@ -1,4 +1,11 @@
import { classifyDirectPlayError } from './directPlayError.ts'
import {
classifyDirectPlayError,
directPlayFailureCopy,
directStallTimeoutMs,
DIRECT_STALL_TIMEOUT_MS,
DIRECT_STALL_TIMEOUT_NO_FALLBACK_MS,
DIRECT_STALL_TIMEOUT_STRM_MS,
} from './directPlayError.ts'
function check(name: string, cond: boolean) {
if (!cond) throw new Error(`classifyDirectPlayError: ${name}`)
@@ -96,4 +103,36 @@ check(
}) === 'ignore',
)
// 起播看门狗的期限:没有可退线路(远程 Emby / 直连解码)时必须容忍慢启动,
// 否则上游冷启动的 8–11 秒会被误报成「编码不兼容」。
check('local direct stall waits 6s', directStallTimeoutMs({ canFallback: true }) === DIRECT_STALL_TIMEOUT_MS)
check(
'strm direct stall waits 12s',
directStallTimeoutMs({ isStrm: true, canFallback: true }) === DIRECT_STALL_TIMEOUT_STRM_MS,
)
check(
'no fallback line waits 20s',
directStallTimeoutMs({ canFallback: false }) === DIRECT_STALL_TIMEOUT_NO_FALLBACK_MS,
)
check(
'no fallback line ignores strm tuning',
directStallTimeoutMs({ isStrm: true, canFallback: false }) === DIRECT_STALL_TIMEOUT_NO_FALLBACK_MS,
)
const stallCopy = directPlayFailureCopy('remote-emby', 'stall')
check('stall copy blames the upstream, not the codec', !stallCopy.banner.includes('不支持'))
check('stall copy keeps the failure prefix', stallCopy.banner.startsWith('直接播放失败'))
const decodeCopy = directPlayFailureCopy('remote-emby', 'error')
check('decode copy still mentions unsupported codec', decodeCopy.banner.includes('不支持'))
check(
'copy defaults to the decode wording',
directPlayFailureCopy('remote-emby').banner === decodeCopy.banner,
)
check(
'direct-only copy names the client-decode mode',
directPlayFailureCopy('direct-only', 'stall').banner.includes('客户端直连解码') &&
directPlayFailureCopy('direct-only', 'error').banner.includes('客户端直连解码'),
)
check('stall toast is not the external-player advice', stallCopy.toast !== decodeCopy.toast)
console.log('playerPageModel.test.ts ok')
@@ -35,7 +35,6 @@ export default function ReaderReplacePage() {
.then(setRules)
.catch(() => toast.error('加载替换规则失败'))
}
// eslint-disable-next-line react-hooks/exhaustive-deps
useEffect(load, [])
const save = async () => {
@@ -49,7 +49,6 @@ export default function ReaderSourcesPage() {
.then(setSources)
.catch(() => toast.error('加载书源失败'))
}
// eslint-disable-next-line react-hooks/exhaustive-deps
useEffect(load, [])
// 读取书源文件(选择或拖入),走与粘贴相同的导入链路
+65 -68
View File
@@ -1,4 +1,4 @@
import { memo, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState, type CSSProperties } from 'react'
import { memo, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState, type CSSProperties, type ReactNode } from 'react'
import { useParams } from 'react-router-dom'
import toast from 'react-hot-toast'
import {
@@ -266,6 +266,19 @@ function settleTextDrag(dragEl: HTMLDivElement, turn: 1 | -1, dx: number, stride
})
}
function ReaderMenuButton({ icon, label, onClick }: { icon: ReactNode; label: string; onClick: () => void }) {
return (
<button
type="button"
onClick={onClick}
className="flex flex-1 flex-col items-center gap-1 py-1 opacity-80 hover:opacity-100"
>
{icon}
<span className="text-2xs">{label}</span>
</button>
)
}
export default function ReaderViewPage() {
const { bookId = '' } = useParams()
const settings = useReaderSettingsStore()
@@ -316,6 +329,7 @@ export default function ReaderViewPage() {
const [page, setPage] = useState(0)
const [pageCount, setPageCount] = useState(1)
const [vw, setVw] = useState(0)
const [scrollProgress, setScrollProgress] = useState(0)
// 文本翻页:位移与页脚先走 DOM,React 的 page state 等到过渡结束后再同步。
// 若在同一次点击/触摸里同步 setPage,整页重渲染会堵住下一次绘制,
// 命令式 transform 也要等卡完才开始——点按和滑动都会顿一下。
@@ -338,8 +352,13 @@ export default function ReaderViewPage() {
const paragraphWarmRef = useRef<Uint8Array | null>(null)
/** 当前窗口中心页;-1 表示窗口失效(映射刚重算过),下次应用时重建。 */
const warmCenterRef = useRef(-1)
vwRef.current = vw
pageCountRef.current = pageCount
const currentChapter = chapterIndex !== null ? chapters[chapterIndex] : null
const chapterTitle = currentChapter?.title ?? ''
useLayoutEffect(() => {
vwRef.current = vw
pageCountRef.current = pageCount
chapterTitleRef.current = chapterTitle
}, [vw, pageCount, chapterTitle])
// 音频/漫画媒体状态
const [media, setMedia] = useState<ReaderChapterContent | null>(null)
const [restorePos, setRestorePos] = useState(0) // 音频秒数 / 漫画图片序号
@@ -448,6 +467,7 @@ export default function ReaderViewPage() {
// ── 加载书籍与章节 ──
useEffect(() => {
let cancelled = false
const cache = contentCache.current
;(async () => {
try {
setLoadingStage('book')
@@ -510,7 +530,7 @@ export default function ReaderViewPage() {
})()
return () => {
cancelled = true
contentCache.current.clear()
cache.clear()
}
}, [bookId, reloadKey])
@@ -910,6 +930,16 @@ export default function ReaderViewPage() {
if (menuOpen) flushTextPageReactSync()
}, [menuOpen, flushTextPageReactSync])
// Only refresh the scroll slider while its menu is visible, keeping scroll
// events on the reading path free of whole-page React renders.
useLayoutEffect(() => {
if (!menuOpen || settings.pageMode !== 'scroll') return
const el = scrollRef.current
if (el) {
setScrollProgress(Math.round((el.scrollTop / Math.max(1, el.scrollHeight - el.clientHeight)) * 1000))
}
}, [menuOpen, settings.pageMode, content])
// 滚动模式恢复进度。
//
// 同时清掉 dataset.last:它是「上次保存的滚动百分比」,换章后必须重算。
@@ -1355,9 +1385,6 @@ export default function ReaderViewPage() {
return () => el.removeEventListener('wheel', onWheel)
}, [settings.pageMode, menuOpen, contentType, goPrev, goNext])
const currentChapter = chapterIndex !== null ? chapters[chapterIndex] : null
chapterTitleRef.current = currentChapter?.title ?? ''
// 顶栏高度量一次:菜单打开时正文下移,开头几行不被顶栏压住。
// 用 transform 而不是 padding,避免改变视口高度触发重新分页。
const menuShiftStyle = useMemo(
@@ -1397,11 +1424,7 @@ export default function ReaderViewPage() {
value:
settings.pageMode === 'page'
? page + 1
: Math.round(
((scrollRef.current?.scrollTop ?? 0) /
Math.max(1, (scrollRef.current?.scrollHeight ?? 1) - (scrollRef.current?.clientHeight ?? 1))) *
1000,
),
: scrollProgress,
onChange: (v: number) => {
if (settings.pageMode === 'page') {
commitTextPage(v - 1)
@@ -1569,6 +1592,7 @@ export default function ReaderViewPage() {
const max = el.scrollHeight - el.clientHeight
if (max > 0) {
const pct = el.scrollTop / max
if (menuOpen) setScrollProgress(Math.round(pct * 1000))
if (shouldSaveScrollProgress(pct, el.dataset.last)) {
el.dataset.last = formatScrollPct(pct)
savePos(Math.round(el.scrollTop))
@@ -1753,62 +1777,35 @@ export default function ReaderViewPage() {
用 flex 等分而不是 grid-cols-N:动作数量随内容类型和书源能力变化,
grid 列数得写死成字面量类名,多一个按钮就要再加一档。 */}
<div className="flex pt-1" style={{ color: theme.text }}>
{([
{ icon: <LayoutList size={18} />, label: '目录', action: () => openPanel('toc') },
{ icon: <BookOpen size={18} />, label: '界面', action: () => openPanel('style') },
{
icon: settings.night ? <Sun size={18} /> : <Moon size={18} />,
label: settings.night ? '日间' : '夜间',
action: () => settings.toggleNight(),
},
{
icon: settings.pageMode === 'page' ? <ScrollText size={18} /> : <ListEnd size={18} />,
label: settings.pageMode === 'page' ? '滚动' : '翻页',
action: () => settings.setPageMode(settings.pageMode === 'page' ? 'scroll' : 'page'),
},
// 双页铺开只对漫画的翻页模式有意义;窗口太窄时并排两页没法看,
// 干脆不显示入口(不是灰掉,避免用户以为坏了)。
...(contentType === 'image' && settings.pageMode === 'page' && windowWidth >= COMIC_SPREAD_MIN_WIDTH
? [
{
icon: <Columns2 size={18} />,
label: settings.comicDoublePage ? '单页' : '双页',
action: () => settings.setComicDoublePage(!settings.comicDoublePage),
},
]
: []),
// 全屏:只在浏览器支持元素全屏时出现(iOS Safari 不支持,见上方注释)。
...(fullscreenSupported
? [
{
icon: fullscreen ? <Minimize size={18} /> : <Maximize size={18} />,
label: fullscreen ? '还原' : '全屏',
action: toggleFullscreen,
},
]
: []),
...(canSwitchSource
? [
{
icon: <ArrowLeftRight size={18} />,
label: '换源',
action: () => {
void openSourcePicker()
},
},
]
: []),
]).map((item) => (
<button
key={item.label}
type="button"
onClick={item.action}
className="flex flex-1 flex-col items-center gap-1 py-1 opacity-80 hover:opacity-100"
>
{item.icon}
<span className="text-2xs">{item.label}</span>
</button>
))}
<ReaderMenuButton icon={<LayoutList size={18} />} label="目录" onClick={() => openPanel('toc')} />
<ReaderMenuButton icon={<BookOpen size={18} />} label="界面" onClick={() => openPanel('style')} />
<ReaderMenuButton
icon={settings.night ? <Sun size={18} /> : <Moon size={18} />}
label={settings.night ? '日间' : '夜间'}
onClick={() => settings.toggleNight()}
/>
<ReaderMenuButton
icon={settings.pageMode === 'page' ? <ScrollText size={18} /> : <ListEnd size={18} />}
label={settings.pageMode === 'page' ? '滚动' : '翻页'}
onClick={() => settings.setPageMode(settings.pageMode === 'page' ? 'scroll' : 'page')}
/>
{contentType === 'image' && settings.pageMode === 'page' && windowWidth >= COMIC_SPREAD_MIN_WIDTH && (
<ReaderMenuButton
icon={<Columns2 size={18} />}
label={settings.comicDoublePage ? '单页' : '双页'}
onClick={() => settings.setComicDoublePage(!settings.comicDoublePage)}
/>
)}
{fullscreenSupported && (
<ReaderMenuButton
icon={fullscreen ? <Minimize size={18} /> : <Maximize size={18} />}
label={fullscreen ? '还原' : '全屏'}
onClick={toggleFullscreen}
/>
)}
{canSwitchSource && (
<ReaderMenuButton icon={<ArrowLeftRight size={18} />} label="换源" onClick={() => { void openSourcePicker() }} />
)}
</div>
{/* 界面设置面板(主题 / 字号 / 行距 / 段距 / 漫画图片尺寸) */}
+2 -2
View File
@@ -1,5 +1,5 @@
import type { ReaderBook } from '../../api/reader'
import { formatRelativeTime, sortShelfBooks, unreadChapters } from './bookshelfModel'
import type { ReaderBook } from '../../api/reader.ts'
import { formatRelativeTime, sortShelfBooks, unreadChapters } from './bookshelfModel.ts'
function check(name: string, condition: boolean) {
if (!condition) throw new Error(`bookshelfModel: ${name}`)
+1 -1
View File
@@ -142,7 +142,7 @@ export const generalSettingsGroup: SettingGroup = {
key: 'https.enabled',
label: '启用 HTTPS',
type: 'toggle',
hint: '开启后服务将仅通过 HTTPS 访问,保存后会自动切换到 https 地址,原 http 地址不再提供服务。必须先在下文配置好 SSL 证书与私钥(内容或路径任选其一,两者需匹配)才能启用;首次切换因浏览器以 origin(协议+域名+端口)隔离登录态,需要重新登录一次。',
hint: '开启后服务仅通过 HTTPS 提供访问,用 http:// 打开同一地址会被自动跳转到 https://(临时跳转,随时关掉 HTTPS 都不会被浏览器长期记住);保存后当前页面会自动切换到 https 地址。必须先在下文配置好 SSL 证书与私钥(内容或路径任选其一,两者需匹配)才能启用;首次切换因浏览器以 origin(协议+域名+端口)隔离登录态,需要重新登录一次。',
defaultValue: 'false',
},
{
+146
View File
@@ -5,6 +5,9 @@ import {
RefreshRequestError,
createRefreshCoordinator,
createSingleFlightRunner,
createTokenPreflight,
decodeJwtExpiryMs,
isAccessTokenStale,
refreshFailureOutcome,
type RefreshOutcome,
type RefreshTokens,
@@ -30,6 +33,149 @@ check('timeout is transient', refreshFailureOutcome(408) === 'transient')
check('429 is transient', refreshFailureOutcome(429) === 'transient')
check('unexpected status is transient', refreshFailureOutcome(418) === 'transient')
// ── JWT 过期判定:首屏放行前必须能识别出「已经过期的 access token」 ────────
// 现场(2026-10-16 12:49):刷新页面时本地 token 已过期,React 首屏并发发出
// 7 个受保护请求,全部 401,控制台一次性刷出 7 行红字;约 2 秒后才由拦截器
// 刷新成功并重放。修复要求首屏就认得过期 token,先刷新再放行。
function jwtWithExp(exp: number | string | null): string {
const encode = (value: unknown) =>
Buffer.from(JSON.stringify(value)).toString('base64url')
const claims: Record<string, unknown> = { uid: 'u1' }
if (exp !== null) claims.exp = exp
return `${encode({ alg: 'HS256', typ: 'JWT' })}.${encode(claims)}.signature`
}
const NOW = 1_700_000_000_000
check('jwt expiry is decoded to milliseconds',
decodeJwtExpiryMs(jwtWithExp(NOW / 1000 + 3600)) === NOW + 3600_000)
check('jwt without exp decodes to null', decodeJwtExpiryMs(jwtWithExp(null)) === null)
check('jwt with non-numeric exp decodes to null', decodeJwtExpiryMs(jwtWithExp('soon')) === null)
check('opaque token decodes to null', decodeJwtExpiryMs('not-a-jwt') === null)
check('empty token decodes to null', decodeJwtExpiryMs('') === null)
check('malformed payload decodes to null', decodeJwtExpiryMs('aaa.!!!.ccc') === null)
// 已过期 / 即将过期的 token 必须在首屏就被拦下。
check('expired token is stale', isAccessTokenStale(jwtWithExp(NOW / 1000 - 1), NOW))
check('token expiring inside the skew is stale',
isAccessTokenStale(jwtWithExp(NOW / 1000 + 20), NOW))
check('token outside the skew is fresh',
isAccessTokenStale(jwtWithExp(NOW / 1000 + 3600), NOW) === false)
// 边界:正好落在 skew 上算过期(留出余量,避免刷新途中再次过期)。
check('token exactly at the skew boundary is stale',
isAccessTokenStale(jwtWithExp(NOW / 1000 + 30), NOW))
// 没有 token 或 token 无法解析时不能判定为「过期」:
// 前者由路由守卫处理,后者强行刷新会变成无休止的刷新循环。
check('missing token is not stale', isAccessTokenStale(null, NOW) === false)
check('opaque token is not stale', isAccessTokenStale('opaque', NOW) === false)
// ── 发送前预检:本地 token 已过期就先刷新,再让请求带新令牌出去 ────────────
// 现场(2026-10-16 12:49):刷新页面时本地 token 已过期,React 首屏并发发出
// 7 个受保护请求,全部 401,控制台一次刷出 7 行红字;约 2 秒后才由 401 拦截器
// 刷新成功并重放。修复要求请求在发出前就认得过期 token,先刷新再发送。
async function expectPreflight(
name: string,
run: Promise<void>,
want: 'resolved' | 'rejected',
) {
let outcome: 'resolved' | 'rejected' = 'resolved'
try {
await run
} catch {
outcome = 'rejected'
}
if (outcome !== want) throw new Error(`authRefresh: ${name} = ${outcome}, want ${want}`)
}
function preflightHarness(overrides: {
accessToken?: string | null
refresh?: () => Promise<RefreshOutcome>
}) {
const state = { token: overrides.accessToken === undefined ? 'at-1' : overrides.accessToken }
const calls = { refresh: 0 }
const ensure = createTokenPreflight({
readAccessToken: () => state.token,
refreshSession: async () => {
calls.refresh += 1
if (overrides.refresh) return overrides.refresh()
state.token = jwtWithExp(Date.now() / 1000 + 3600)
return 'refreshed'
},
now: () => NOW,
})
return { state, calls, ensure }
}
{
// 令牌新鲜:不得产生任何刷新请求。
const { calls, ensure } = preflightHarness({ accessToken: jwtWithExp(NOW / 1000 + 3600) })
await expectPreflight('fresh token passes straight through', ensure(), 'resolved')
check('fresh token skips the refresh', calls.refresh === 0)
}
{
// 核心回归:过期令牌必须先刷新,且刷新完成后才返回(调用方据此发请求)。
const { state, calls, ensure } = preflightHarness({ accessToken: jwtWithExp(NOW / 1000 - 60) })
await expectPreflight('stale token refreshes before sending', ensure(), 'resolved')
check('stale token refreshed exactly once', calls.refresh === 1)
check('preflight handed out a fresh token',
!isAccessTokenStale(state.token, NOW))
}
{
// 并发首屏:7 个请求同时预检,只允许刷新一次(协调器本身是单飞,
// 这里确认预检不会绕过它)。
const { calls, ensure } = preflightHarness({ accessToken: jwtWithExp(NOW / 1000 - 60) })
await Promise.all([ensure(), ensure(), ensure(), ensure(), ensure(), ensure(), ensure()])
check('concurrent preflight refreshes once', calls.refresh === 1)
}
{
// 瞬时故障(部署/重启窗口):保留会话,照常发出请求——
// 页面自己会重试,不该把人挡在门外。
const { ensure } = preflightHarness({
accessToken: jwtWithExp(NOW / 1000 - 60),
refresh: async () => 'transient',
})
await expectPreflight('transient failure still sends the request', ensure(), 'resolved')
}
{
// 服务端明确判定凭证失效:不发请求,直接失败,避免刷出无意义的 401。
const { ensure } = preflightHarness({
accessToken: jwtWithExp(NOW / 1000 - 60),
refresh: async () => 'invalid',
})
await expectPreflight('revoked session refuses to send', ensure(), 'rejected')
}
{
// 未登录:交给路由守卫和 401 路径处理,预检不替它们做决定。
const { calls, ensure } = preflightHarness({ accessToken: null })
await expectPreflight('anonymous request is not preflighted', ensure(), 'resolved')
check('anonymous request does not refresh', calls.refresh === 0)
}
{
// 非 JWT 令牌(Emby 等外部客户端签发):读不出 exp,按新鲜处理,
// 绝不能因此陷入刷新循环。
const { calls, ensure } = preflightHarness({ accessToken: 'opaque-token' })
await expectPreflight('opaque token passes through', ensure(), 'resolved')
check('opaque token does not refresh', calls.refresh === 0)
}
{
// 刷新抛出异常(网络不可达):按瞬时故障处理,请求照发。
const { ensure } = preflightHarness({
accessToken: jwtWithExp(NOW / 1000 - 60),
refresh: async () => {
throw new Error('network down')
},
})
await expectPreflight('throwing refresh still sends the request', ensure(), 'resolved')
}
// ── 单飞:并发调用共享同一次执行,结束后可重新执行 ────────────────────────
{
let calls = 0
+78
View File
@@ -15,6 +15,46 @@
/** 一次刷新尝试的结果。 */
export type RefreshOutcome = 'refreshed' | 'invalid' | 'transient'
/**
* access token 过期前多久就视为失效。刷新要留出余量:刚好卡在 exp 上
* 刷新,重放的原请求可能在服务端又已经过期。
*/
export const TOKEN_EXPIRY_SKEW_MS = 30_000
/**
* 解析 JWT 的 exp 声明并换算成毫秒时间戳。
*
* 解析失败(非 JWT、base64url 损坏、缺少 exp、exp 不是数字)一律返回 null:
* 调用方据此放行,绝不能把「读不懂」当成「已过期」,否则不透明的令牌会导致
* 无休止的刷新循环。
*/
export function decodeJwtExpiryMs(token: string | null | undefined): number | null {
if (!token) return null
const segment = token.split('.')[1]
if (!segment) return null
try {
// JWT 使用 base64url 编码,需先还原字符集并补齐 padding。
const normalized = segment.replace(/-/g, '+').replace(/_/g, '/')
const padded = normalized.padEnd(Math.ceil(normalized.length / 4) * 4, '=')
const claims = JSON.parse(atob(padded)) as { exp?: unknown }
return typeof claims.exp === 'number' ? claims.exp * 1000 : null
} catch {
return null
}
}
/**
* token 是否已过期或即将过期。
*
* 首屏放行前用它做一次判断:本地持久化的 token 往往已经过期,直接发请求只
* 会一次性刷出一屏 401。读不出 exp 的令牌按「新鲜」处理(见 decodeJwtExpiryMs)。
*/
export function isAccessTokenStale(token: string | null | undefined, nowMs = Date.now()): boolean {
const expiry = decodeJwtExpiryMs(token)
if (expiry === null) return false
return nowMs >= expiry - TOKEN_EXPIRY_SKEW_MS
}
/** 刷新接口返回的令牌对。 */
export interface RefreshTokens {
token: string
@@ -101,6 +141,44 @@ export function createBrowserExclusiveRunner(
}
}
export interface SessionGateDeps {
/** 读取当前 access token(可能来自持久化存储,往往已经过期)。 */
readAccessToken: () => string | null
/** 刷新会话;语义与 useAuthStore.refreshSession 一致。 */
refreshSession: () => Promise<RefreshOutcome>
/** 可注入的时间源,便于测试。 */
now?: () => number
}
/**
* 构造请求前预检:每个请求发出前调用,必要时先把过期的令牌换新。
*
* 本地持久化的 access token 常常已经过期。此时若直接发请求,首屏那些各自
* 独立的 useEffect 会在同一帧里并发打出受保护请求,全部拿到 401,控制台
* 一次刷出一整屏红字,用户还要等 401 拦截器刷新完才能看到内容
* (现场:2026-10-16 12:49,7 个请求同时 401)。
*
* 预检把「先刷新、再发送」放在请求发出之前:
* - token 新鲜(或无法解析 exp)→ 立即返回;
* - token 已过期 → 等待刷新(刷新协调器自带单飞,并发请求只发一次);
* - 刷新遇到瞬时故障 → 照常发送(页面自己会重试);
* - 服务端明确判定凭证失效 → 抛出,请求根本不发出去。
*
* 未登录(没有 token)直接返回:跳登录页是路由守卫的职责。
*/
export function createTokenPreflight(deps: SessionGateDeps): () => Promise<void> {
return async () => {
const token = deps.readAccessToken()
if (!token) return
const now = deps.now ?? Date.now
if (!isAccessTokenStale(token, now())) return
const outcome = await deps.refreshSession().catch((): RefreshOutcome => 'transient')
if (outcome === 'invalid') {
throw new Error('session expired')
}
}
}
export interface RefreshCoordinatorDeps {
readAccessToken: () => string | null
readRefreshToken: () => string | null
+1 -1
View File
@@ -1,4 +1,4 @@
import { CHAPTER_GROUP_SIZE, buildChapterGroups, chapterGroupIndexOf } from './chapterGroups'
import { CHAPTER_GROUP_SIZE, buildChapterGroups, chapterGroupIndexOf } from './chapterGroups.ts'
// 目录分段分组的纯逻辑回归测试(对齐 utils/readerBookGroups.test.ts 的写法)。
+11
View File
@@ -2,6 +2,17 @@ import type { Media } from '../types'
import { isRemoteEmbyID } from './remoteEmby'
import { getSeriesKey, isEpisodeLike, pathLooksEpisodic } from './groupSeries'
export function mediaSearchLink(query: string, page = 1): string {
const params = new URLSearchParams({ q: query.trim() })
if (page > 1) params.set('page', String(page))
return `/search?${params}`
}
export function mediaSearchReturnPath(state: unknown): string | undefined {
if (!state || typeof state !== 'object' || !('from' in state)) return undefined
return typeof state.from === 'string' && state.from.startsWith('/search?') ? state.from : undefined
}
function pathLooksLikeMovie(media: Media): boolean {
const path = (media.path || media.display_library_path || media.library_path || '').toLowerCase()
return /[\\/](?:movie|movies|film|films|电影)[\\/]/i.test(path)
+22
View File
@@ -1,4 +1,5 @@
import {
autoPlayEpisodeFor,
isStrmMedia,
mediaVersionFileName,
mediaVersionLabel,
@@ -64,4 +65,25 @@ check('strm filename drops outer extension', mediaVersionFileName(cloudRow) ===
check('missing path has no filename', mediaVersionFileName(media({ path: '' })) === '')
check('isStrmMedia detects strm url', isStrmMedia(cloudRow))
// 首页轮播/剧集卡片交给播放器的是「整部剧」ID:选集列表里没有它自己,
// 播放器要自动落到第一集,否则 /api/stream/<剧集ID> 会被远程 Emby 打回 500。
const series = media({ id: 'embyremote~mount-1~222679', title: '某部新番', path: '/anime/某部新番', size_bytes: 0 })
const ep1 = media({ id: 'embyremote~mount-1~222681', path: '/anime/某部新番/Season 1/S1E01.mp4', size_bytes: 299580311 })
const ep2 = media({ id: 'embyremote~mount-1~222683', path: '/anime/某部新番/Season 1/S1E02.mp4', size_bytes: 299580312 })
check('series falls back to its first episode', autoPlayEpisodeFor(series, series.id, [ep1, ep2])?.id === ep1.id)
check('playable episode stays untouched', autoPlayEpisodeFor(ep1, ep1.id, [ep1, ep2]) === null)
check('single-item movie list stays untouched', autoPlayEpisodeFor(ep1, ep1.id, [{ ...ep1 }]) === null)
check('waits until the requested item is loaded', autoPlayEpisodeFor(null, series.id, [ep1]) === null)
check('ignores a response for another id', autoPlayEpisodeFor(ep2, series.id, [ep1]) === null)
check('empty episode list has no fallback', autoPlayEpisodeFor(series, series.id, []) === null)
check(
'entry present as another version stays untouched',
autoPlayEpisodeFor(
media({ id: 'v-4k' }),
'v-4k',
[media({ id: 'v-4k', versions: [local4k, local1080] })],
) === null,
)
console.log('mediaVersion.test.ts: ok')
+26
View File
@@ -195,6 +195,32 @@ export function mediaVersionSourceLabel(media: Media): string {
return isStrmMedia(media) ? '云端直链' : '本地文件'
}
/**
* 挑出「播放器该自动跳转到的那一集」。
*
* 传给 /play/:id 的可能是「整部剧」这类自身没有可播文件的条目——首页轮播、远程 Emby
* 剧集库的卡片、剧集详情页的播放按钮都会把剧集 ID 直接交给播放器。这种条目的选集接口
* 返回的是它下面的剧集,列表里没有它自己;若直接拿剧集 ID 去请求
* /api/stream/<剧集ID>,远程 Emby 会返回 HTTP 500(Unable to cast object of type
* 'Series' to type 'IHasMediaSources'),用户看到的就是「播放失败」。此时换成第一集,
* 播放、进度、上下集都落在真正可播的条目上。
*
* 返回 null 表示无需跳转:条目还没加载完、条目本身可播(含以其它版本形式出现在列表里)、
* 或该条目下没有任何可选剧集。
*/
export function autoPlayEpisodeFor(
media: Media | null | undefined,
requestedId: string,
episodes: Media[],
): Media | null {
if (!media || !requestedId || media.id !== requestedId) return null
if (episodes.length === 0) return null
if (episodes.some((episode) => mediaVersionMatches(episode, media.id))) return null
const first = episodes[0]
if (!first?.id || first.id === media.id) return null
return first
}
/** 版本文件名(去掉目录,并剥掉 .strm 外层扩展名),用作版本选项的次要说明。 */
export function mediaVersionFileName(media: Media): string {
const path = (media.path || '').replace(/\\/g, '/').trim()
+1 -1
View File
@@ -10,7 +10,7 @@ import {
normalizeBookGroups,
reorderBookGroups,
resolveSelectedGroupId,
} from './readerBookGroups'
} from './readerBookGroups.ts'
// 书架分组的纯逻辑回归测试(对齐 utils/libraryTags.test.ts 的写法)。