diff --git a/internal/service/stream_file.go b/internal/service/stream_file.go index 27390c5..ba53cde 100644 --- a/internal/service/stream_file.go +++ b/internal/service/stream_file.go @@ -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) diff --git a/internal/service/stream_normalize_test.go b/internal/service/stream_normalize_test.go index c0fe0a9..7fd4f65 100644 --- a/internal/service/stream_normalize_test.go +++ b/internal/service/stream_normalize_test.go @@ -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) + } +} diff --git a/internal/service/stream_redirect.go b/internal/service/stream_redirect.go index eaa24d3..b982a90 100644 --- a/internal/service/stream_redirect.go +++ b/internal/service/stream_redirect.go @@ -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() diff --git a/internal/service/stream_test.go b/internal/service/stream_test.go index 6678c35..0e20d31 100644 --- a/internal/service/stream_test.go +++ b/internal/service/stream_test.go @@ -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 { diff --git a/internal/service/strm_play.go b/internal/service/strm_play.go index 8220ed8..7df1d3d 100644 --- a/internal/service/strm_play.go +++ b/internal/service/strm_play.go @@ -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) diff --git a/internal/service/strm_sync_test.go b/internal/service/strm_sync_test.go index 70d549b..935a462 100644 --- a/internal/service/strm_sync_test.go +++ b/internal/service/strm_sync_test.go @@ -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) } } diff --git a/web/src/components/MediaVersionSwitcher.tsx b/web/src/components/MediaVersionSwitcher.tsx index 7cbdeee..4fc80ce 100644 --- a/web/src/components/MediaVersionSwitcher.tsx +++ b/web/src/components/MediaVersionSwitcher.tsx @@ -6,26 +6,23 @@ import { isStrmMedia, mediaVersionLabel, mediaVersionsOf } from '../utils/mediaV type MediaVersionSwitcherProps = { media: Media - /** 详情页:用 Link 跳转播放;播放页:回调切换 */ - mode?: 'detail' | 'player' - onSelect?: (version: Media) => void className?: string } -export function MediaVersionSwitcher({ - media, - mode = 'detail', - onSelect, - className = '', -}: MediaVersionSwitcherProps) { +/** + * 详情页的版本切换。 + * + * 播放器内不再单独浮出一块版本条:多版本切换统一收在「选集」面板里 + * (见 PlayerPlaylistPanel),这里只负责详情页的同片多版本跳转。 + */ +export function MediaVersionSwitcher({ media, className = '' }: MediaVersionSwitcherProps) { const versions = mediaVersionsOf(media) if (versions.length <= 1) return null - const dark = mode === 'player' return (
-
- +
+ 版本({versions.length})
@@ -33,42 +30,10 @@ export function MediaVersionSwitcher({ const active = version.id === media.id const isStrm = isStrmMedia(version) const label = mediaVersionLabel(version) - const btnClass = dark - ? active - ? 'border-white/50 bg-white/20 text-white shadow-sm ring-1 ring-white/20' - : 'border-white/15 bg-white/5 text-white/80 hover:bg-white/15 hover:text-white' - : active - ? 'border-brand-500/40 bg-brand-50 text-[#b07d35] font-bold shadow-sm' - : 'border-gray-200 bg-white text-ink-100 hover:border-brand-500/30 hover:bg-brand-50/40' + const btnClass = active + ? 'border-brand-500/40 bg-brand-50 text-[#b07d35] font-bold shadow-sm' + : 'border-gray-200 bg-white text-ink-100 hover:border-brand-500/30 hover:bg-brand-50/40' - const content = ( - - {isStrm && ( - - )} - {label} - {!dark && active ? · 当前 : null} - - ) - - if (mode === 'player') { - return ( - - ) - } return ( - {content} + + {isStrm && ( + + )} + {label} + {active ? · 当前 : null} + ) })} diff --git a/web/src/components/PlayerControls.tsx b/web/src/components/PlayerControls.tsx index 3e22c81..0e57bd3 100644 --- a/web/src/components/PlayerControls.tsx +++ b/web/src/components/PlayerControls.tsx @@ -5,7 +5,7 @@ import { CaptionsOff, Check, FastForward, - Gauge, + Layers, ListVideo, Loader2, Lock, @@ -17,6 +17,7 @@ import { Play, Rewind, Rotate3d, + Settings2, SkipBack, SkipForward, Timer, @@ -39,21 +40,36 @@ import { type SubtitlePosition, type SubtitleStylePreset, } from '../utils/subtitleDisplay' +import { + PLAYER_ICON_BUTTON, + PLAYER_MENU_DIVIDER, + PLAYER_MENU_ITEM, + PLAYER_MENU_LABEL, + PLAYER_POPOVER, + PLAYER_RANGE_OVERLAY, + PLAYER_TEXT_BUTTON, + PLAYER_TRACK, + PLAYER_TRACK_FILL, +} from './playerTheme' -// PlayerControls — custom bottom control bar replacing the native