Compare commits

..

9 Commits

Author SHA1 Message Date
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
52 changed files with 3043 additions and 147 deletions
+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"]
+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)
}
})
}
}
+123 -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) {
@@ -271,7 +376,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
+2 -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"`
+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
}
}
@@ -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)
}
}
@@ -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")
}
}
+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 {
+21 -6
View File
@@ -111,17 +111,20 @@ func NewImageProxy(cfg *config.Config, log *zap.Logger) *ImageProxy {
// 仅 URL 解析层的 isPrivateHost 可被十进制/十六进制 IP、解析到
// 私网的域名与 DNS rebinding 绕过;在拨号层对最终连接 IP 做二次
// 校验(含重定向后的每条连接)堵住该旁路。
// 用户明确配置的远程挂载源(如内网 Emby)豁免该私网限制。
dialer := &net.Dialer{
//
// 用户明确配置的远程挂载源(如内网 Emby)与显式配置的服务(如
// MetaTube)豁免该私网限制。豁免必须在 DialContext 这一层判断:
// Control 回调收到的 address 已经是**解析后的 IP**,而放行名单里
// 存的是主机名(mebox-metatube-1、NAS 名),在 Control 里比较永远
// 不相等——那段分支对主机名条目是死代码,现场表现就是「名单里有
// 这个主机,回源依然被拒」。
guardDialer := &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")
@@ -132,7 +135,19 @@ func NewImageProxy(cfg *config.Config, log *zap.Logger) *ImageProxy {
return nil
},
}
transport.DialContext = dialer.DialContext
// 放行目标的专用拨号器:不再做私网校验,但仍然只连到请求里的那个
// 主机(豁免的是私网限制,不是「任意目标」)。
allowlistedDialer := &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
}
if proxy.isAllowedRemoteHost(host) {
return allowlistedDialer.DialContext(ctx, network, address)
}
return guardDialer.DialContext(ctx, network, address)
}
}
proxy.client = &http.Client{Timeout: 30 * time.Second, Transport: transport}
@@ -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,84 @@
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)
resp, err := proxy.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")
}
}
@@ -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
}
+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 时
// 重新登录一次再重试,封面图不再依赖人工改账号。
+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),
+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>
)
}
+91 -19
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,7 +37,8 @@ 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 { apiErrorMessage } from './StrmManagePage'
import { PlayerTopBar } from './PlayerTopBar'
@@ -200,6 +201,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 +527,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 +562,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 +769,7 @@ export function PlayerPage() {
if (cancelled) return
setMedia(m)
setPlayerError('')
transientPlayErrorRef.current = ''
setLoadError('')
const isDirect = isDirectStreamMedia(m)
const auto = pickPlayerMode(m)
@@ -875,6 +903,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)
@@ -1145,24 +1180,30 @@ 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(() => {
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)) {
setPlayerError('直接播放失败。该媒体为远程 Emby 挂载直连播放(不进行转码);当前浏览器可能不支持该视频编码或音频格式,建议使用外部播放器(如 PotPlayer / VLC / IINA)播放。')
toast.error('直接播放失败,建议使用外部播放器')
const copy = directPlayFailureCopy('remote-emby', reason)
showPlayFailure(copy.banner)
toast.error(copy.toast)
} else if (directOnly) {
setPlayerError('直接播放失败。当前为「客户端直连解码」模式,宿主机不转码;请使用支持该编码/封装的播放器(如 Infuse / VLC / Emby 客户端)播放,或关闭直连解码模式。')
toast.error('直接播放失败(客户端直连解码模式)')
const copy = directPlayFailureCopy('direct-only', reason)
showPlayFailure(copy.banner)
toast.error(copy.toast)
} else if (playbackInfo?.provider === 'cloud115') {
const preferred =
findPlaybackQualityById(playbackInfo, selectedQuality) ??
@@ -1180,7 +1221,7 @@ export function PlayerPage() {
switchToLocalHLS(current?.currentTime || 0)
}
} else if (hlsUnavailable) {
setPlayerError('直接播放失败,且 HLS 转码不可用。请检查文件是否存在,或配置本机 ffmpeg 后使用 HLS 转码播放。')
showPlayFailure('直接播放失败,且 HLS 转码不可用。请检查文件是否存在,或配置本机 ffmpeg 后使用 HLS 转码播放。')
toast.error('直接播放失败,HLS 转码不可用')
} else {
toast.error('直接播放失败,切换到 HLS 转码')
@@ -1192,23 +1233,25 @@ export function PlayerPage() {
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, fallbackDelayMs)
fallbackTimerRef.current = setTimeout(() => fallbackDirectPlay('error'), fallbackDelayMs)
}, [clearFallbackTimer, fallbackDirectPlay])
// 看门狗定时器可能比一次渲染活得更久:始终调用最新一版回退逻辑,避免用
// 「起播那一刻」捕获的 playbackInfo(那时可能还是 null)去判定,把本该走
// 115 云转码的条目误判成本地转码。
const fallbackDirectPlayRef = useRef<() => void>(() => {})
const fallbackDirectPlayRef = useRef<(reason?: DirectPlayFailureReason) => void>(() => {})
useEffect(() => {
fallbackDirectPlayRef.current = fallbackDirectPlay
}, [fallbackDirectPlay])
@@ -1222,17 +1265,27 @@ export function PlayerPage() {
* 只能退出重进或手动切播放方式)。这里给每次直连起播挂一个兜底定时器:
* 到点仍是 readyState=0(连元数据都没解析到)就按失败处理;只要已经拿到
* metadata 就说明链路是通的,只是慢,交给 playing 事件收尾。
*
* 期限本身要按「失败之后还有没有补救动作」来定:能切 HLS 的源早点换线路对
* 用户有利;远程 Emby / 直连解码没有可退的线路,等不到数据时唯一的产出是
* 一条提示,所以必须容忍跨网上游的冷启动(见 directStallTimeoutMs)。
*/
const armDirectStallWatchdog = useCallback(() => {
clearFallbackTimer()
const timeoutMs = isStrmMedia(mediaRef.current) ? 12000 : 6000
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()
// 这里只可能是「一直没数据」,按超时文案报,不要说成编码不兼容。
fallbackDirectPlayRef.current('stall')
}, timeoutMs)
}, [clearFallbackTimer])
@@ -1313,10 +1366,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')
@@ -1337,14 +1392,14 @@ export function PlayerPage() {
void video.play().catch(() => undefined)
} 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 {
@@ -1393,7 +1448,11 @@ export function PlayerPage() {
setPlayerError('当前正在直连播放原始文件;此封装或音轨浏览器兼容性有限,可能只有画面没有声音。请配置本机 ffmpeg 后切回 HLS 转码播放。')
}
}
const onPlaying = () => clearFallbackTimer()
const onPlaying = () => {
clearFallbackTimer()
// 画面真的动了:之前挂的「起不来」提示一律作废。
clearPlayFailure()
}
video.addEventListener('playing', onPlaying)
return () => {
cancelled = true
@@ -1404,6 +1463,7 @@ export function PlayerPage() {
activeBurnedSubtitleStream,
armDirectStallWatchdog,
clearFallbackTimer,
clearPlayFailure,
cloudReloadNonce,
cloudWaiting,
hlsSource,
@@ -1416,6 +1476,7 @@ export function PlayerPage() {
seekStrmDirectTo,
selectedQuality,
setPlaybackMode,
showPlayFailure,
switchToLocalHLS,
teardownHls,
vr360DirectProxy,
@@ -1681,6 +1742,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]
@@ -2242,7 +2314,7 @@ export function PlayerPage() {
const handleVideoError = useCallback(() => {
const video = ref.current
if (mode !== 'direct') {
setPlayerError('视频播放失败,请检查文件是否存在,或确认 ffmpeg 已正确配置。')
showPlayFailure('视频播放失败,请检查文件是否存在,或确认 ffmpeg 已正确配置。')
toast.error('视频播放失败,请检查文件是否存在')
return
}
@@ -2289,7 +2361,7 @@ export function PlayerPage() {
}
scheduleDirectFallback()
}, [mediaId, mode, scheduleDirectFallback, seekStrmDirectTo])
}, [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;瞬时中断应忽略或静默重试一次。
+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')
+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 的写法)。