From 1ea29cca9e00d77b1b67c3a6539ed2618e504d12 Mon Sep 17 00:00:00 2001 From: truewhile <62226914+truewhile@users.noreply.github.com> Date: Wed, 9 Sep 2026 00:39:00 +0800 Subject: [PATCH] =?UTF-8?q?bug=E5=A4=84=E7=90=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- internal/handler/playback_extra_test.go | 35 ++++++++++++++++ internal/handler/subtitles.go | 8 +++- internal/service/subtitle.go | 54 ++++++++++++++++--------- internal/service/subtitle_test.go | 39 ++++++++++++++++++ web/src/api/subtitles.ts | 6 ++- web/src/pages/PlayerPage.tsx | 23 ++++++++--- 6 files changed, 137 insertions(+), 28 deletions(-) diff --git a/internal/handler/playback_extra_test.go b/internal/handler/playback_extra_test.go index 3bea2fe..a51623d 100644 --- a/internal/handler/playback_extra_test.go +++ b/internal/handler/playback_extra_test.go @@ -1,7 +1,9 @@ package handler import ( + "context" "encoding/json" + "errors" "net/http" "net/http/httptest" "net/url" @@ -359,6 +361,7 @@ func newPlaybackScopeTestRouter(t *testing.T) (*gin.Engine, *service.Container, Auth: auth, Media: service.NewMediaService(cfg, log, repos), Stream: service.NewStreamService(cfg, log, repos, nil), + Subtitle: service.NewSubtitleService(cfg, log, repos), Permissions: permissions, } if err := repos.User.Create(t.Context(), &model.User{ @@ -440,6 +443,38 @@ func TestPlaybackInfoForSTRMMediaIncludesHLS(t *testing.T) { } } +func TestSubtitleListOnlyProbesEmbeddedTracksForHLS(t *testing.T) { + router, svc, secret := newPlaybackScopeTestRouter(t) + loginToken := signedTestToken(t, secret) + resolveCalls := 0 + svc.Subtitle.SetStrmPlayTargetResolver(func(context.Context, string) (*service.StrmPlayResult, error) { + resolveCalls++ + return nil, errors.New("probe resolver called") + }) + + request := func(path string) *httptest.ResponseRecorder { + req := httptest.NewRequest(http.MethodGet, "http://nas.local"+path, nil) + req.Header.Set("Authorization", "Bearer "+loginToken) + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + return w + } + + if w := request("/api/media/media-1/subtitles"); w.Code != http.StatusOK { + t.Fatalf("direct subtitle status = %d body=%s", w.Code, w.Body.String()) + } + if resolveCalls != 0 { + t.Fatalf("direct subtitle request resolved cloud media %d times, want 0", resolveCalls) + } + + if w := request("/api/media/media-1/subtitles?include_embedded=true"); w.Code != http.StatusOK { + t.Fatalf("HLS subtitle status = %d body=%s", w.Code, w.Body.String()) + } + if resolveCalls != 1 { + t.Fatalf("HLS subtitle request resolved cloud media %d times, want 1", resolveCalls) + } +} + func TestHLSPlaylistForRemoteEmbyMediaDisabled(t *testing.T) { router, svc, secret := newPlaybackScopeTestRouter(t) svc.EmbyRemote = &service.EmbyRemoteService{} diff --git a/internal/handler/subtitles.go b/internal/handler/subtitles.go index 1e78a4a..9c0b84d 100644 --- a/internal/handler/subtitles.go +++ b/internal/handler/subtitles.go @@ -16,7 +16,13 @@ func listSubtitlesHandler(svc *service.Container) gin.HandlerFunc { c.JSON(http.StatusOK, gin.H{"tracks": []service.SubtitleTrack{}}) return } - tracks, err := svc.Subtitle.Discover(c.Request.Context(), id) + var tracks []service.SubtitleTrack + var err error + if c.Query("include_embedded") == "true" { + tracks, err = svc.Subtitle.Discover(c.Request.Context(), id) + } else { + tracks, err = svc.Subtitle.DiscoverExternalOnly(c.Request.Context(), id) + } if err != nil { c.JSON(http.StatusNotFound, gin.H{"error": err.Error()}) return diff --git a/internal/service/subtitle.go b/internal/service/subtitle.go index 42763f0..8c3981b 100644 --- a/internal/service/subtitle.go +++ b/internal/service/subtitle.go @@ -94,28 +94,28 @@ func (s *SubtitleService) Discover(ctx context.Context, mediaID string) ([]Subti // DiscoverExternalOnly 只返回媒体旁边的外挂字幕文件,不含容器内嵌字幕轨。 // Emby 字幕接口(/Videos/:id/Subtitles/...)用。 func (s *SubtitleService) DiscoverExternalOnly(ctx context.Context, mediaID string) ([]SubtitleTrack, error) { - tracks, err := s.discover(ctx, mediaID) + cacheKey := "external:" + mediaID + if tracks, ok := s.cachedDiscovery(cacheKey); ok { + return tracks, nil + } + tracks, err := s.discoverExternalUncached(ctx, mediaID) if err != nil { return nil, err } - out := make([]SubtitleTrack, 0, len(tracks)) - for _, track := range tracks { - if track.Source != "embedded" { - out = append(out, track) - } - } - return out, nil + s.rememberDiscovery(cacheKey, tracks) + return tracks, nil } func (s *SubtitleService) discover(ctx context.Context, mediaID string) ([]SubtitleTrack, error) { - if tracks, ok := s.cachedDiscovery(mediaID); ok { + cacheKey := "all:" + mediaID + if tracks, ok := s.cachedDiscovery(cacheKey); ok { return tracks, nil } tracks, err := s.discoverUncached(ctx, mediaID) if err != nil { return nil, err } - s.rememberDiscovery(mediaID, tracks) + s.rememberDiscovery(cacheKey, tracks) return tracks, nil } @@ -159,6 +159,30 @@ func (s *SubtitleService) discoverUncached(ctx context.Context, mediaID string) if m == nil { return nil, errors.New("media not found") } + tracks := discoverExternalSubtitleTracks(m) + embedded, err := s.discoverEmbedded(ctx, m) + if err != nil { + if s.log != nil { + s.log.Debug("discover embedded subtitles failed", zap.String("media_id", mediaID), zap.Error(err)) + } + } else { + tracks = append(tracks, embedded...) + } + return tracks, nil +} + +func (s *SubtitleService) discoverExternalUncached(ctx context.Context, mediaID string) ([]SubtitleTrack, error) { + m, err := s.repo.Media.FindByID(ctx, mediaID) + if err != nil { + return nil, err + } + if m == nil { + return nil, errors.New("media not found") + } + return discoverExternalSubtitleTracks(m), nil +} + +func discoverExternalSubtitleTracks(m *model.Media) []SubtitleTrack { dir := filepath.Dir(m.Path) bases := mediaSidecarBaseVariants(m.Path) if len(bases) == 0 { @@ -214,15 +238,7 @@ func (s *SubtitleService) discoverUncached(ctx context.Context, mediaID string) }) } } - embedded, err := s.discoverEmbedded(ctx, m) - if err != nil { - if s.log != nil { - s.log.Debug("discover embedded subtitles failed", zap.String("media_id", mediaID), zap.Error(err)) - } - } else { - tracks = append(tracks, embedded...) - } - return tracks, nil + return tracks } type embeddedSubtitleProbe struct { diff --git a/internal/service/subtitle_test.go b/internal/service/subtitle_test.go index db8f465..70a12a7 100644 --- a/internal/service/subtitle_test.go +++ b/internal/service/subtitle_test.go @@ -2,7 +2,9 @@ package service import ( "bytes" + "context" "encoding/json" + "errors" "os" "path/filepath" "strings" @@ -48,6 +50,43 @@ func TestSubtitleDiscoverNoTracksReturnsEmptySlice(t *testing.T) { } } +func TestDiscoverExternalOnlyDoesNotResolveOrProbeCloudMedia(t *testing.T) { + db, err := gorm.Open(sqlite.Open("file::memory:?cache=shared"), &gorm.Config{}) + if err != nil { + t.Fatal(err) + } + if err := db.AutoMigrate(&model.Library{}, &model.Media{}); err != nil { + t.Fatal(err) + } + + media := model.Media{ + Title: "Cloud Media", + Path: "cloud://115/example/video.mkv", + STRMURL: "cloud://115/example/video.mkv", + } + if err := db.Create(&media).Error; err != nil { + t.Fatal(err) + } + + svc := NewSubtitleService(&config.Config{}, zap.NewNop(), repository.New(db)) + resolveCalls := 0 + svc.SetStrmPlayTargetResolver(func(context.Context, string) (*StrmPlayResult, error) { + resolveCalls++ + return nil, errors.New("resolver must not be called") + }) + + tracks, err := svc.DiscoverExternalOnly(t.Context(), media.ID) + if err != nil { + t.Fatal(err) + } + if resolveCalls != 0 { + t.Fatalf("STRM resolver called %d times, want 0", resolveCalls) + } + if len(tracks) != 0 { + t.Fatalf("len(tracks) = %d, want 0", len(tracks)) + } +} + func TestEmbeddedSubtitleProbeClassifiesTextAndBitmapTracks(t *testing.T) { var probe embeddedSubtitleProbe raw := []byte(`{"streams":[ diff --git a/web/src/api/subtitles.ts b/web/src/api/subtitles.ts index 57c1a76..ac696ab 100644 --- a/web/src/api/subtitles.ts +++ b/web/src/api/subtitles.ts @@ -13,9 +13,11 @@ export interface SubtitleTrack { } export const subtitlesAPI = { - list: (mediaId: string) => + list: (mediaId: string, includeEmbedded = false) => api - .get<{ tracks: SubtitleTrack[] | null }>(`/media/${mediaId}/subtitles`) + .get<{ tracks: SubtitleTrack[] | null }>(`/media/${mediaId}/subtitles`, { + params: includeEmbedded ? { include_embedded: 'true' } : undefined, + }) .then((r) => r.data.tracks ?? []), url: (mediaId: string, path: string) => { diff --git a/web/src/pages/PlayerPage.tsx b/web/src/pages/PlayerPage.tsx index 1e9e1e0..92bb1c6 100644 --- a/web/src/pages/PlayerPage.tsx +++ b/web/src/pages/PlayerPage.tsx @@ -237,8 +237,22 @@ export function PlayerPage() { // 404 / 无权限等:给出可见错误提示,避免永久「加载中」 setLoadError(`无法加载该媒体:${apiErrorMessage(err)}`) }) + return () => { + cancelled = true + } + }, [id, modeParam, directOnly]) + + // Wire up the actual