mirror of
https://github.com/truewhile/MeBox.git
synced 2026-10-03 12:26:36 +08:00
优化界面,优化多版本切换逻辑
This commit is contained in:
@@ -33,8 +33,9 @@ func (s *StreamService) ServeFileWithCloudMode(w http.ResponseWriter, r *http.Re
|
||||
if !cloudPlaybackModeEnabled(r.Context(), s.repo, cloudMode) {
|
||||
return ErrCloudPlaybackDisabled
|
||||
}
|
||||
// 云盘播放 URL 先规范化为相对路径,免疫扫描时固化的旧 host。
|
||||
target := normalizeCloudPlayTarget(strmURL)
|
||||
// 云盘播放 URL 先规范化为相对路径,免疫扫描时固化的旧 host;
|
||||
// 指向别的 MeBox 实例的地址保持原样,按第三方直链透传。
|
||||
target := normalizeCloudPlayTarget(r.Context(), s.repo, s.cfg, r, strmURL)
|
||||
target = withAuthTokenForInternalRedirect(target, r, PublicServerURL(r.Context(), s.repo, s.cfg))
|
||||
setCloudRedirectNoStore(w)
|
||||
http.Redirect(w, r, absoluteInternalRedirect(target, r), http.StatusFound)
|
||||
|
||||
@@ -1,19 +1,28 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"testing"
|
||||
|
||||
"github.com/truewhile/MeBox/internal/model"
|
||||
)
|
||||
|
||||
// TestNormalizeCloudPlayTarget 验证存库的云盘播放 URL(可能携带扫描时的
|
||||
// 旧 host)被规范化为相对路径,使 302 始终基于当前请求地址构造。
|
||||
// TestNormalizeCloudPlayTarget 验证存库的云盘播放 URL(可能携带扫描时的旧 host)
|
||||
// 被规范化为相对路径,使 302 始终基于当前请求地址构造。
|
||||
func TestNormalizeCloudPlayTarget(t *testing.T) {
|
||||
ref := "/电影/某部影片 (2024)/movie.mkv"
|
||||
stale := "http://192.168.1.4:9011/api/cloud/play/openlist?ref=" + url.QueryEscape(ref)
|
||||
got := normalizeCloudPlayTarget(stale)
|
||||
req := httptest.NewRequest(http.MethodGet, "http://nas.local:18080/api/stream/media-1", nil)
|
||||
ctx := context.Background()
|
||||
|
||||
// 相对路径:本来就是本机形态,按 provider+ref 重建(保持相对)。
|
||||
relative := "/api/cloud/play/openlist?ref=" + url.QueryEscape(ref)
|
||||
got := normalizeCloudPlayTarget(ctx, nil, nil, req, relative)
|
||||
want := BuildRelativeCloudPlayURL("openlist", ref)
|
||||
if got != want {
|
||||
t.Fatalf("normalizeCloudPlayTarget = %q, want %q", got, want)
|
||||
t.Fatalf("normalizeCloudPlayTarget(relative) = %q, want %q", got, want)
|
||||
}
|
||||
parsed, err := url.Parse(got)
|
||||
if err != nil {
|
||||
@@ -26,16 +35,83 @@ func TestNormalizeCloudPlayTarget(t *testing.T) {
|
||||
t.Fatalf("ref round-trip failed: %q", parsed.Query().Get("ref"))
|
||||
}
|
||||
|
||||
strmStale := "http://bwg.linkmy.fun:1314/api/strm/play/cloud115/video.mkv?acct=abc&pickcode=123"
|
||||
gotStrm := normalizeCloudPlayTarget(strmStale)
|
||||
// 绝对地址但 host 就是当前请求 host:宿主切换过(开发机扫描 → 部署)
|
||||
// 之后仍要能当作本机地址处理。
|
||||
sameHost := "http://nas.local:18080/api/strm/play/cloud115/video.mkv?acct=abc&pickcode=123"
|
||||
gotStrm := normalizeCloudPlayTarget(ctx, nil, nil, req, sameHost)
|
||||
wantStrm := "/api/strm/play/cloud115/video.mkv?acct=abc&pickcode=123"
|
||||
if gotStrm != wantStrm {
|
||||
t.Fatalf("normalizeCloudPlayTarget(strm) = %q, want %q", gotStrm, wantStrm)
|
||||
t.Fatalf("normalizeCloudPlayTarget(same host) = %q, want %q", gotStrm, wantStrm)
|
||||
}
|
||||
|
||||
// 非云盘播放 URL 保持原样(WebDAV/直链等)。
|
||||
passthrough := "https://dav.example.com/media/file.mkv"
|
||||
if got := normalizeCloudPlayTarget(passthrough); got != passthrough {
|
||||
if got := normalizeCloudPlayTarget(ctx, nil, nil, req, passthrough); got != passthrough {
|
||||
t.Fatalf("non-cloud target should pass through, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestNormalizeCloudPlayTargetKeepsForeignInstanceURL 验证别的 MeBox /
|
||||
// MediaStationGo 实例生成的 .strm 内容不被本机账号解析,而是按第三方直链透传。
|
||||
func TestNormalizeCloudPlayTargetKeepsForeignInstanceURL(t *testing.T) {
|
||||
svc := testStrmService(t)
|
||||
ctx := context.Background()
|
||||
// 本机自己的 115 账号(strm.base_url 由 testStrmService 设为 http://test.local:8096)。
|
||||
own := &model.StrmAccount{
|
||||
Base: model.Base{ID: "acct-own-115"},
|
||||
Name: "own",
|
||||
Provider: model.StrmProvider115,
|
||||
Enabled: true,
|
||||
}
|
||||
if err := svc.repo.StrmAccount.Create(ctx, own); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
req := httptest.NewRequest(http.MethodGet, "http://mebox.local/api/stream/media-1", nil)
|
||||
|
||||
// host 不认识 + acct 不是本机账号 → 另一个实例的 .strm,原样透传。
|
||||
foreign := "http://other-mebox.example:18080/api/strm/play/cloud115/video.mkv?acct=acct-other&pickcode=xyz"
|
||||
if got := normalizeCloudPlayTarget(ctx, svc.repo, nil, req, foreign); got != foreign {
|
||||
t.Fatalf("foreign instance url should pass through, got %q", got)
|
||||
}
|
||||
|
||||
// host 不认识但 acct 是本机账号 → 本机换了域名/IP 的老 .strm,仍要认领。
|
||||
staleOwn := "http://192.168.1.4:9011/api/strm/play/cloud115/video.mkv?acct=acct-own-115&pickcode=123"
|
||||
wantStale := "/api/strm/play/cloud115/video.mkv?acct=acct-own-115&pickcode=123"
|
||||
if got := normalizeCloudPlayTarget(ctx, svc.repo, nil, req, staleOwn); got != wantStale {
|
||||
t.Fatalf("own acct on stale host = %q, want %q", got, wantStale)
|
||||
}
|
||||
|
||||
// acct 撞上本机账号 ID 但账号类型与路径 provider 不一致:不算本机。
|
||||
wrongProvider := "http://other-mebox.example/api/strm/play/openlist/video.mkv?acct=acct-own-115&pickcode=123"
|
||||
if got := normalizeCloudPlayTarget(ctx, svc.repo, nil, req, wrongProvider); got != wrongProvider {
|
||||
t.Fatalf("provider mismatch should pass through, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestNormalizeCloudPlayTargetLegacyCloudURL 旧格式 /api/cloud/play(不带 acct)
|
||||
// 无法凭账号 ID 判断归属:本机配了该类型账号就按本机处理(保住老固化地址的可
|
||||
// 播放性),完全没配才透传。
|
||||
func TestNormalizeCloudPlayTargetLegacyCloudURL(t *testing.T) {
|
||||
svc := testStrmService(t)
|
||||
ctx := context.Background()
|
||||
req := httptest.NewRequest(http.MethodGet, "http://mebox.local/api/stream/media-1", nil)
|
||||
ref := "/Movies/Movie.mkv"
|
||||
legacy := "http://old-host:9011/api/cloud/play/openlist?ref=" + url.QueryEscape(ref)
|
||||
|
||||
// 本机没有 openlist 账号 → 不是本机地址。
|
||||
if got := normalizeCloudPlayTarget(ctx, svc.repo, nil, req, legacy); got != legacy {
|
||||
t.Fatalf("legacy url without local provider should pass through, got %q", got)
|
||||
}
|
||||
|
||||
if err := svc.repo.StrmAccount.Create(ctx, &model.StrmAccount{
|
||||
Base: model.Base{ID: "acct-openlist"},
|
||||
Name: "openlist",
|
||||
Provider: model.StrmProviderOpenList,
|
||||
Enabled: true,
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := normalizeCloudPlayTarget(ctx, svc.repo, nil, req, legacy); got != BuildRelativeCloudPlayURL("openlist", ref) {
|
||||
t.Fatalf("legacy url with local provider = %q, want relative rebuild", got)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,9 +1,13 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
|
||||
"github.com/truewhile/MeBox/internal/config"
|
||||
"github.com/truewhile/MeBox/internal/repository"
|
||||
)
|
||||
|
||||
// normalizeCloudPlayTarget 把存库的云盘播放 URL 规范化为相对路径。
|
||||
@@ -11,28 +15,197 @@ import (
|
||||
// STRMURL 是扫描时根据当时的 server_url/请求地址生成并固化进数据库的。
|
||||
// 在 Windows 开发机上扫描、再部署到 Docker(或更换了内网 IP/域名)后,
|
||||
// 这些绝对 URL 会指向已失效的旧地址,第三方播放器跟随 302 就会拿到
|
||||
// 连接失败/404。这里只要能从 URL 中解析出 provider+ref,就重建为相对
|
||||
// /api/cloud/play 路径,由 absoluteInternalRedirect 基于「当前请求」补全
|
||||
// host,从而对历史脏数据免疫。
|
||||
func normalizeCloudPlayTarget(raw string) string {
|
||||
// 连接失败/404。所以只要确认这个地址「是本机自己生成的」,就从 URL 中解析出
|
||||
// provider+ref,重建为相对 /api/cloud/play 或 /api/strm/play 路径,由
|
||||
// absoluteInternalRedirect 基于「当前请求」补全 host,从而对历史脏数据免疫。
|
||||
//
|
||||
// 反过来,指向**别的 MeBox / MediaStationGo 实例**的地址不能按本机账号解析:
|
||||
// 别人 .strm 里的 acct 是他那台机器的账号 ID,拿到本机来查只会得到
|
||||
// 「网盘账号不存在」。这种地址按普通第三方直链原样透传,让客户端跟着 302 去
|
||||
// 对方实例取流(/api/strm/play 是公开端点,不需要本机凭据),或直接去对方的
|
||||
// CDN 直链。归属判断见 isInternalPlaybackTarget。
|
||||
func normalizeCloudPlayTarget(ctx context.Context, repo *repository.Container, cfg *config.Config, r *http.Request, raw string) string {
|
||||
raw = strings.TrimSpace(raw)
|
||||
if raw == "" {
|
||||
return raw
|
||||
}
|
||||
if !isInternalPlaybackTarget(ctx, repo, cfg, r, raw) {
|
||||
return raw
|
||||
}
|
||||
if typ, ref, ok := parseCloudMediaPlaybackURL(raw); ok {
|
||||
return BuildRelativeCloudPlayURL(typ, ref)
|
||||
}
|
||||
if u, err := url.Parse(raw); err == nil {
|
||||
path := strings.ToLower(u.Path)
|
||||
if strings.HasPrefix(path, "/api/strm/play/") || strings.HasPrefix(path, "/api/cloud/play/") || strings.HasPrefix(path, "/api/stream/") {
|
||||
u.Scheme = ""
|
||||
u.Host = ""
|
||||
return u.String()
|
||||
}
|
||||
u, err := url.Parse(raw)
|
||||
if err != nil {
|
||||
return raw
|
||||
}
|
||||
path := strings.ToLower(u.Path)
|
||||
if strings.HasPrefix(path, "/api/strm/play/") || strings.HasPrefix(path, "/api/cloud/play/") || strings.HasPrefix(path, "/api/stream/") {
|
||||
u.Scheme = ""
|
||||
u.Host = ""
|
||||
return u.String()
|
||||
}
|
||||
return raw
|
||||
}
|
||||
|
||||
// isInternalPlaybackTarget 判断播放地址能否按「本机自己的云盘播放地址」处理
|
||||
// (相对化 + 用本机账号解析)。判断顺序:
|
||||
//
|
||||
// 1. 相对路径一定是本机存库的常规形态;
|
||||
// 2. 绝对地址的 host 与本机配置的 strm.base_url、各同步目录覆盖的 base_url、
|
||||
// 当前请求 host 之一相同 → 就是本机(老 .strm 里固化的旧 host 属于这一类);
|
||||
// 3. host 对不上时,再看地址里带的网盘账号 ID 是不是本机账号:MeBox /
|
||||
// MediaStationGo 会把本机账号 ID 写进 acct,而账号 ID 由各实例自行生成,
|
||||
// 跨实例几乎不可能撞号。这条用于「换了域名/IP 之后」认领自己的老 .strm。
|
||||
//
|
||||
// 三条都不满足(典型是另一台 MeBox 生成的 .strm)→ 视为第三方直链。
|
||||
func isInternalPlaybackTarget(ctx context.Context, repo *repository.Container, cfg *config.Config, r *http.Request, raw string) bool {
|
||||
u, err := url.Parse(strings.TrimSpace(raw))
|
||||
if err != nil || u == nil {
|
||||
return false
|
||||
}
|
||||
if u.Host == "" {
|
||||
return true
|
||||
}
|
||||
if !isPlaybackAPIPath(u.Path) {
|
||||
return false
|
||||
}
|
||||
if matchesLocalPlaybackHost(ctx, repo, cfg, r, u) {
|
||||
return true
|
||||
}
|
||||
return localAccountOwnsPlaybackTarget(ctx, repo, u)
|
||||
}
|
||||
|
||||
// isPlaybackAPIPath 判断路径是不是本服务自己的播放端点。
|
||||
func isPlaybackAPIPath(path string) bool {
|
||||
lower := strings.ToLower(strings.TrimSpace(path))
|
||||
for _, prefix := range []string{"/api/strm/play/", "/api/cloud/play/", "/api/stream/"} {
|
||||
if strings.HasPrefix(lower, prefix) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// matchesLocalPlaybackHost 判断绝对播放地址的 host 是否就是本机。
|
||||
func matchesLocalPlaybackHost(ctx context.Context, repo *repository.Container, cfg *config.Config, r *http.Request, u *url.URL) bool {
|
||||
if u == nil {
|
||||
return false
|
||||
}
|
||||
target := canonicalHostPort(u)
|
||||
if target == "" {
|
||||
return false
|
||||
}
|
||||
for _, base := range localPlaybackBaseURLs(ctx, repo, cfg, r) {
|
||||
parsed, err := url.Parse(base)
|
||||
if err != nil || parsed == nil {
|
||||
continue
|
||||
}
|
||||
if canonicalHostPort(parsed) == target {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// localPlaybackBaseURLs 汇总本机的播放基地址:管理员配置的公网地址 /
|
||||
// strm.base_url、每条 STRM 同步目录单独覆盖的 base_url,以及当前请求的 host
|
||||
// (含反向代理头)。请求 host 也要算进来:没有配 base_url 时 .strm 里固化的
|
||||
// 就是请求地址。
|
||||
func localPlaybackBaseURLs(ctx context.Context, repo *repository.Container, cfg *config.Config, r *http.Request) []string {
|
||||
bases := make([]string, 0, 4)
|
||||
if base := PublicServerURL(ctx, repo, cfg); base != "" {
|
||||
bases = append(bases, base)
|
||||
}
|
||||
if repo != nil && repo.StrmSyncPath != nil {
|
||||
if paths, err := repo.StrmSyncPath.List(ctx); err == nil {
|
||||
for i := range paths {
|
||||
if override := strings.TrimSpace(paths[i].StrmBaseURL); override != "" {
|
||||
bases = append(bases, override)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if r != nil {
|
||||
if host := strings.TrimSpace(r.Host); host != "" {
|
||||
bases = append(bases, "//"+host)
|
||||
}
|
||||
for _, header := range []string{"X-Forwarded-Host", "X-Original-Host"} {
|
||||
if host := strings.TrimSpace(r.Header.Get(header)); host != "" {
|
||||
bases = append(bases, "//"+host)
|
||||
}
|
||||
}
|
||||
}
|
||||
return bases
|
||||
}
|
||||
|
||||
// canonicalHostPort 归一化 host[:port]:小写、忽略默认端口(http 80 / https 443)。
|
||||
func canonicalHostPort(u *url.URL) string {
|
||||
if u == nil {
|
||||
return ""
|
||||
}
|
||||
host := strings.ToLower(strings.TrimSpace(u.Hostname()))
|
||||
if host == "" {
|
||||
return ""
|
||||
}
|
||||
port := strings.TrimSpace(u.Port())
|
||||
if port == "" {
|
||||
return host
|
||||
}
|
||||
scheme := strings.ToLower(strings.TrimSpace(u.Scheme))
|
||||
if (scheme == "http" || scheme == "") && port == "80" {
|
||||
return host
|
||||
}
|
||||
if scheme == "https" && port == "443" {
|
||||
return host
|
||||
}
|
||||
return host + ":" + port
|
||||
}
|
||||
|
||||
// localAccountOwnsPlaybackTarget 用「地址里带的网盘账号是不是本机的」判断播放
|
||||
// 地址归属。账号 ID 跨实例不会撞号,因此比 host 更可靠:
|
||||
//
|
||||
// - /api/strm/play/{provider}/video{ext}?acct=…:acct 能在本机查到、且账号类型
|
||||
// 与路径里的 provider 一致,才认作本机地址;
|
||||
// - /api/cloud/play/{provider}?ref=…(旧格式,不带 acct):只能退化为「本机是否
|
||||
// 配置了该类型账号」,配置了才按本机处理,保持老版本固化地址的可播放性。
|
||||
func localAccountOwnsPlaybackTarget(ctx context.Context, repo *repository.Container, u *url.URL) bool {
|
||||
if repo == nil || repo.StrmAccount == nil || u == nil {
|
||||
return false
|
||||
}
|
||||
segments := strings.Split(strings.Trim(strings.TrimSpace(u.Path), "/"), "/")
|
||||
if len(segments) < 4 || !strings.EqualFold(segments[0], "api") || !strings.EqualFold(segments[2], "play") {
|
||||
return false
|
||||
}
|
||||
provider := strings.TrimSpace(segments[3])
|
||||
if provider == "" {
|
||||
return false
|
||||
}
|
||||
switch strings.ToLower(segments[1]) {
|
||||
case "strm":
|
||||
accountID := strings.TrimSpace(u.Query().Get("acct"))
|
||||
if accountID == "" {
|
||||
return false
|
||||
}
|
||||
account, err := repo.StrmAccount.FindByID(ctx, accountID)
|
||||
if err != nil || account == nil {
|
||||
return false
|
||||
}
|
||||
return strings.EqualFold(strings.TrimSpace(account.Provider), provider)
|
||||
case "cloud":
|
||||
accounts, err := repo.StrmAccount.List(ctx)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
for i := range accounts {
|
||||
if strings.EqualFold(strings.TrimSpace(accounts[i].Provider), provider) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// BuildRelativeCloudPlayURL 构造相对的云盘播放 API 路径。
|
||||
func BuildRelativeCloudPlayURL(typ, ref string) string {
|
||||
return "/api/cloud/play/" + url.PathEscape(strings.TrimSpace(typ)) + "?" + url.Values{"ref": []string{ref}}.Encode()
|
||||
|
||||
@@ -216,6 +216,78 @@ func TestServeFileRedirectsLocalSTRMFileTargetByDefault(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// 别的 MeBox / MediaStationGo 实例生成的 .strm:里面的 acct 是对方实例的账号,
|
||||
// 本机不能拿自己的账号去解析,直接把 302 透传给客户端,由客户端去对方实例取流。
|
||||
func TestServeFilePassesThroughForeignInstanceSTRMURL(t *testing.T) {
|
||||
repos := newStreamTestRepo(t)
|
||||
target := "http://other-mebox.example:18080/api/strm/play/cloud115/video.mkv?acct=other-acct&pickcode=xyz"
|
||||
if err := repos.DB.Create(&model.Media{
|
||||
Base: model.Base{ID: "foreign-strm"},
|
||||
Title: "Foreign STRM",
|
||||
Path: "D:/media/Foreign.strm",
|
||||
Container: "strm",
|
||||
STRMURL: target,
|
||||
}).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/foreign-strm?token=jwt123", nil)
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
if err := svc.ServeFile(w, req, "foreign-strm"); err != nil {
|
||||
t.Fatalf("foreign instance strm url should be passed through: %v", err)
|
||||
}
|
||||
if w.Code != http.StatusFound {
|
||||
t.Fatalf("status = %d, want 302", w.Code)
|
||||
}
|
||||
loc := w.Header().Get("Location")
|
||||
if loc != target {
|
||||
t.Fatalf("Location = %q, want untouched %q", loc, target)
|
||||
}
|
||||
if strings.Contains(loc, "jwt123") {
|
||||
t.Fatalf("foreign instance url must not receive our auth token, got %q", loc)
|
||||
}
|
||||
}
|
||||
|
||||
// 本机自己生成的 .strm 在换了域名/IP 之后仍要认领:host 对不上,但 acct 是本机
|
||||
// 网盘账号,于是按当前请求 host 相对化,保持可播放。
|
||||
func TestServeFileRealignsOwnSTRMURLOtherHost(t *testing.T) {
|
||||
repos := repository.New(newServiceTestDB(t, &model.Media{}, &model.Setting{}, &model.StrmAccount{}))
|
||||
if err := repos.StrmAccount.Create(t.Context(), &model.StrmAccount{
|
||||
Base: model.Base{ID: "own-acct"},
|
||||
Name: "own",
|
||||
Provider: model.StrmProvider115,
|
||||
Enabled: true,
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := repos.DB.Create(&model.Media{
|
||||
Base: model.Base{ID: "own-strm"},
|
||||
Title: "Own STRM",
|
||||
Path: "D:/media/Own.strm",
|
||||
Container: "strm",
|
||||
STRMURL: "http://old-host:9011/api/strm/play/cloud115/video.mkv?acct=own-acct&pickcode=123",
|
||||
}).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/own-strm?token=jwt123", nil)
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
if err := svc.ServeFile(w, req, "own-strm"); err != nil {
|
||||
t.Fatalf("own strm url on a stale host should still play: %v", 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/strm/play/cloud115/video.mkv?") ||
|
||||
!strings.Contains(loc, "acct=own-acct") ||
|
||||
!strings.Contains(loc, "pickcode=123") {
|
||||
t.Fatalf("own strm url should be realigned to current host, got %q", loc)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCloudPlaybackModeUsesExplicitModeBeforeLegacySTRMFlag(t *testing.T) {
|
||||
repos := newStreamTestRepo(t)
|
||||
if got := CloudPlaybackMode(t.Context(), repos); got != CloudPlaybackModeRedirectProxy {
|
||||
|
||||
@@ -121,7 +121,7 @@ func (s *StrmService) resolveLocalPlay(ctx context.Context, rawPath string) (*St
|
||||
// 支持:
|
||||
// - /api/strm/play/{provider}/video{ext}?acct=..&pickcode=.. (常规格式,含账号)
|
||||
// - /api/cloud/play/{type}?ref=.. (旧格式,无账号 → 取该类型第一个启用账号)
|
||||
// - 绝对 http(s) 链接(直接透传)
|
||||
// - 绝对 http(s) 链接(直接透传,包含别的 MeBox / MediaStationGo 实例的播放端点)
|
||||
// - 其余协议(webdav:// 等)返回错误,由调用方决定是否静默跳过
|
||||
func (s *StrmService) ResolvePlayTarget(ctx context.Context, raw string) (*StrmPlayResult, error) {
|
||||
raw = strings.TrimSpace(raw)
|
||||
@@ -132,6 +132,17 @@ func (s *StrmService) ResolvePlayTarget(ctx context.Context, raw string) (*StrmP
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("解析播放目标失败: %w", err)
|
||||
}
|
||||
// 别的 MeBox / MediaStationGo 实例的播放端点:本机没有对应账号,按普通外部
|
||||
// 直链处理——客户端与 ffmpeg 直接跟随 302 去对方实例取流(/api/strm/play 是
|
||||
// 公开端点,不需要本机凭据),不能拿本机账号去查别人的 pickcode。
|
||||
if isPlaybackAPIPath(u.Path) && !s.isLocalPlaybackTarget(ctx, raw) {
|
||||
switch strings.ToLower(strings.TrimSpace(u.Scheme)) {
|
||||
case "http", "https":
|
||||
return &StrmPlayResult{RedirectURL: raw}, nil
|
||||
default:
|
||||
return nil, fmt.Errorf("不支持的播放目标协议: %s", u.Scheme)
|
||||
}
|
||||
}
|
||||
lowerPath := strings.ToLower(u.Path)
|
||||
switch {
|
||||
case strings.HasPrefix(lowerPath, "/api/strm/play/"):
|
||||
@@ -156,6 +167,16 @@ func (s *StrmService) ResolvePlayTarget(ctx context.Context, raw string) (*StrmP
|
||||
}
|
||||
}
|
||||
|
||||
// isLocalPlaybackTarget 报告播放地址是否属于本机。这里没有 HTTP 请求上下文,
|
||||
// 「本机」由 strm.base_url / 各同步目录覆盖的 base_url / 本机网盘账号共同界定
|
||||
// (见 isInternalPlaybackTarget)。
|
||||
func (s *StrmService) isLocalPlaybackTarget(ctx context.Context, raw string) bool {
|
||||
if s == nil {
|
||||
return true
|
||||
}
|
||||
return isInternalPlaybackTarget(ctx, s.repo, s.cfg, nil, raw)
|
||||
}
|
||||
|
||||
// firstEnabledAccountOf 返回指定提供方第一个凭据可用的启用账号。
|
||||
func (s *StrmService) firstEnabledAccountOf(ctx context.Context, provider string) (*model.StrmAccount, error) {
|
||||
accounts, err := s.repo.StrmAccount.List(ctx)
|
||||
|
||||
@@ -746,7 +746,10 @@ func TestHandleMetaSha1Identity(t *testing.T) {
|
||||
|
||||
// fakeRemoteProvider 是 walkRemote 并发遍历的假提供方:返回一棵固定目录树,
|
||||
// 并记录每个目录被 List 的次数,用于验证并发遍历无漏目录、无重复目录。
|
||||
// walkRemote 会并发调用 List,因此计数必须加锁:此前直接写 map 会偶发
|
||||
// "fatal error: concurrent map writes",把整个测试进程带走。
|
||||
type fakeRemoteProvider struct {
|
||||
mu sync.Mutex
|
||||
listed map[string]int
|
||||
}
|
||||
|
||||
@@ -756,11 +759,20 @@ func (f *fakeRemoteProvider) Resolve(context.Context, string) (*cloud.DirectLink
|
||||
return &cloud.DirectLink{URL: "http://cdn/x.mkv"}, nil
|
||||
}
|
||||
|
||||
// listedCount 返回某个目录被 List 的次数。
|
||||
func (f *fakeRemoteProvider) listedCount(dirID string) int {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
return f.listed[dirID]
|
||||
}
|
||||
|
||||
func (f *fakeRemoteProvider) List(_ context.Context, dirID string) ([]cloud.FileEntry, error) {
|
||||
f.mu.Lock()
|
||||
if f.listed == nil {
|
||||
f.listed = map[string]int{}
|
||||
}
|
||||
f.listed[dirID]++
|
||||
f.mu.Unlock()
|
||||
switch dirID {
|
||||
case "root":
|
||||
return []cloud.FileEntry{
|
||||
@@ -828,8 +840,8 @@ func TestWalkRemoteConcurrent(t *testing.T) {
|
||||
st.flushPreferredVideos()
|
||||
|
||||
for _, dir := range []string{"root", "a", "a1", "b"} {
|
||||
if provider.listed[dir] != 1 {
|
||||
t.Errorf("目录 %s 被列出 %d 次,期望 1 次", dir, provider.listed[dir])
|
||||
if count := provider.listedCount(dir); count != 1 {
|
||||
t.Errorf("目录 %s 被列出 %d 次,期望 1 次", dir, count)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user