Files
MeBox/internal/service/stream_test.go
T
ShukeBta 91df7521ce fix: 资源占用/登录稳定性/QB整理入库/第三方播放404 综合修复
资源占用(Docker 部署 CPU/内存长期居高):
- 云盘探测预算改为按尝试扣减,杜绝队列满时对每个文件反复入队
  并刷出数万条 WARN(实测日志 41165 条)
- 探测队列满时给文件挂 30 分钟退避 + 告警限速为每分钟一条
- 扫描时每个文件的海报/背景图由同步下载(单张最长 20s)改为
  后台预取队列,云盘大库扫描不再串行拉图数小时
- PlaybackInfo 的云盘 ffprobe 探测改异步(原同步最长 8s,
  既拖慢起播又放大云盘流量),带单飞去重
- 访问日志跳过 /api/health 与静态资源;logging.level/format
  配置真正生效(此前是死配置)

登录稳定性(经常登录报错):
- refresh token 未及时落库期间,刷新请求可识别「待落库令牌」,
  不再把用户踢回登录页;轮换/登出后取消后台补写,防止旧令牌复活

QB 下载整理入库:
- 新增 download.path_mappings 设置:自定义下载器→本程序路径映射
  (每行 客户端路径=本地路径),并复用 compose 环境变量映射规则
- 应用重启后补整理最近 24h 内完成的种子(此前重启即永久漏掉)
- 下载客户端初始化失败仍注册并惰性重连(容器启动顺序免疫)
- 硬链接跨文件系统(EXDEV)自动降级为复制,保种语义不变

第三方播放器 404:
- 播放处理器不再把所有错误吞成 404:媒体不存在→404,
  云盘解析失败/STRM 关闭→502+原因
- 存库的云盘播放 URL 规范化为相对路径,免疫扫描时固化的旧 host
- 云盘媒体 SupportsDirectPlay=false,强制走带鉴权的 DirectStream
2026-06-12 04:31:34 +00:00

147 lines
4.9 KiB
Go

package service
import (
"net/http"
"net/http/httptest"
"net/url"
"strings"
"testing"
"github.com/glebarez/sqlite"
"go.uber.org/zap"
"gorm.io/gorm"
"github.com/ShukeBta/MediaStationGo/internal/config"
"github.com/ShukeBta/MediaStationGo/internal/model"
"github.com/ShukeBta/MediaStationGo/internal/repository"
)
func TestWithAuthTokenPropagatesToInternalRedirect(t *testing.T) {
// <video src=/api/stream/{id}?token=JWT> follows the 302 to the cloud
// play endpoint, which must stay authenticated.
r := &http.Request{Header: http.Header{}, URL: &url.URL{RawQuery: "token=jwt123&profile=p"}}
got := withAuthToken("/api/cloud/play/cloud115?ref=abc", r)
u, err := url.Parse(got)
if err != nil {
t.Fatalf("parse: %v", err)
}
if u.Query().Get("token") != "jwt123" {
t.Fatalf("token not propagated: %q", got)
}
if u.Query().Get("ref") != "abc" {
t.Fatalf("existing query lost: %q", got)
}
}
func TestWithAuthTokenNeverLeaksToAbsoluteURL(t *testing.T) {
// An absolute external direct link (e.g. cloud CDN) must NOT receive the JWT.
r := &http.Request{Header: http.Header{}, URL: &url.URL{RawQuery: "token=jwt123"}}
got := withAuthToken("https://cdn.115.example/x.mp4?sig=1", r)
if strings.Contains(got, "jwt123") {
t.Fatalf("JWT leaked to external URL: %q", got)
}
if got != "https://cdn.115.example/x.mp4?sig=1" {
t.Fatalf("external URL mutated: %q", got)
}
}
func TestWithAuthTokenPropagatesToSameOriginAbsoluteInternalURL(t *testing.T) {
r := httptest.NewRequest(http.MethodGet, "http://media.example/Videos/m-1/stream?api_key=jwt123", nil)
got := withAuthTokenForInternalRedirect("http://media.example/api/cloud/play/openlist?ref=abc", r, "http://media.example")
u, err := url.Parse(got)
if err != nil {
t.Fatalf("parse: %v", err)
}
if u.Query().Get("token") != "jwt123" || u.Query().Get("ref") != "abc" {
t.Fatalf("same-origin internal URL should keep ref and receive token: %q", got)
}
}
func TestServeFileRedirectsInternalSTRMAsAbsoluteURLWithToken(t *testing.T) {
repos := newStreamTestRepo(t)
if err := repos.DB.Create(&model.Media{
Base: model.Base{ID: "cloud-1"},
Title: "Cloud",
Path: "cloud://openlist/Movie.mkv",
STRMURL: "/api/cloud/play/openlist?ref=movie",
}).Error; err != nil {
t.Fatal(err)
}
svc := NewStreamService(&config.Config{}, zap.NewNop(), repos, nil)
req := httptest.NewRequest(http.MethodGet, "http://nas.local:18080/api/stream/cloud-1?api_key=jwt123", nil)
w := httptest.NewRecorder()
if err := svc.ServeFile(w, req, "cloud-1"); err != nil {
t.Fatal(err)
}
if w.Code != http.StatusFound {
t.Fatalf("status = %d, want 302", w.Code)
}
loc := w.Header().Get("Location")
if !strings.HasPrefix(loc, "http://nas.local:18080/api/cloud/play/openlist?") ||
!strings.Contains(loc, "ref=movie") ||
!strings.Contains(loc, "token=jwt123") {
t.Fatalf("redirect Location should be absolute and tokenized, got %q", loc)
}
}
func TestServeFileHonorsSTRMPlaybackDisabled(t *testing.T) {
repos := newStreamTestRepo(t)
if err := repos.Setting.Set(t.Context(), STRMEnabledSettingKey, "false"); err != nil {
t.Fatal(err)
}
if err := repos.DB.Create(&model.Media{
Base: model.Base{ID: "cloud-1"},
Title: "Cloud",
Path: "cloud://openlist/Movie.mkv",
STRMURL: "/api/cloud/play/openlist?ref=movie",
}).Error; err != nil {
t.Fatal(err)
}
svc := NewStreamService(&config.Config{}, zap.NewNop(), repos, nil)
req := httptest.NewRequest(http.MethodGet, "http://nas.local:18080/api/stream/cloud-1?api_key=jwt123", nil)
w := httptest.NewRecorder()
err := svc.ServeFile(w, req, "cloud-1")
// 云盘媒体在 STRM 播放关闭时返回明确的「云盘播放不可用」错误,
// 而不是和「媒体不存在」混在一起(后者会让播放器显示 404)。
if err != ErrCloudPlaybackUnavailable {
t.Fatalf("disabled STRM should not redirect cloud media, err=%v status=%d location=%q", err, w.Code, w.Header().Get("Location"))
}
if loc := w.Header().Get("Location"); loc != "" {
t.Fatalf("disabled STRM leaked redirect Location %q", loc)
}
}
func newStreamTestRepo(t *testing.T) *repository.Container {
t.Helper()
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
if err != nil {
t.Fatal(err)
}
if err := db.AutoMigrate(&model.Media{}, &model.Setting{}); err != nil {
t.Fatal(err)
}
return repository.New(db)
}
func TestRequestTokenFromBearerHeader(t *testing.T) {
h := http.Header{}
h.Set("Authorization", "Bearer hdrtok")
r := &http.Request{Header: h, URL: &url.URL{}}
if got := requestToken(r); got != "hdrtok" {
t.Fatalf("bearer token not extracted: %q", got)
}
}
func TestAppendQueryToHLSSegments(t *testing.T) {
in := "#EXTM3U\n#EXTINF:4.0,\nseg_00000.ts\n#EXTINF:4.0,\nseg_00001.ts?old=1\n"
got := appendQueryToHLSSegments(in, "token=abc")
if !strings.Contains(got, "seg_00000.ts?token=abc") {
t.Fatalf("missing tokenized segment: %q", got)
}
if !strings.Contains(got, "seg_00001.ts?old=1") {
t.Fatalf("existing query should be preserved: %q", got)
}
}