mirror of
https://github.com/truewhile/MeBox.git
synced 2026-11-15 20:34:23 +08:00
Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| d112e8e4db | |||
| 6604691c19 | |||
| 23937d8137 | |||
| 6864a9c7f1 |
+6
-3
@@ -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"]
|
||||
|
||||
@@ -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) }
|
||||
@@ -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
@@ -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),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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"`
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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),
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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;瞬时中断应忽略或静默重试一次。
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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',
|
||||
},
|
||||
{
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user