From 38b85bb4878b1edf99a7e6c356241348e4f65f4e Mon Sep 17 00:00:00 2001 From: truewhile <62226914+truewhile@users.noreply.github.com> Date: Mon, 7 Sep 2026 13:56:30 +0800 Subject: [PATCH] =?UTF-8?q?feat(strm):=20=E5=90=8C=E5=90=8D=E5=A4=9A?= =?UTF-8?q?=E7=89=88=E6=9C=AC=E6=8B=A9=E4=BC=98=20/=20keep=5Fext=EF=BC=8C?= =?UTF-8?q?=E5=B9=B6=E6=94=AF=E6=8C=81=E5=90=88=E5=B9=B6=E5=B1=95=E7=A4=BA?= =?UTF-8?q?=E4=B8=8E=E6=92=AD=E6=94=BE=E5=88=87=E6=8D=A2=20(#30)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(strm): 同名多版本择优/keep_ext,并支持合并展示与播放切换 默认对同名不同扩展(如 .mkv/.mp4)按体积→mtime→扩展名优先级择优生成一条 strm,冲突打日志;开启 keep_ext 则分别生成 name.ext.strm。媒体详情与播放器可切换版本,列表继续合并同片多版本,Emby MediaSources 名称区分容器/分辨率。 Co-authored-by: truewhile * fix(meta): 边车匹配忽略 .mkv.strm 中间扩展,同片共享 NFO/海报/字幕 NFO、海报、字幕与刮削写出统一使用 mediaSidecarBase 词干,使竞女01.mkv.strm 与竞女01.mp4.strm 配对同一套 竞女01.nfo / 竞女01-poster / 竞女01.srt; 仍兼容历史上的 name.mkv.nfo 命名。 Co-authored-by: truewhile * fix(emby): People NFO 按共享词干匹配 keep_ext strm resolveMediaPeople 改为查找竞女01.nfo(并兼容旧的竞女01.mkv.nfo), 同时修正把 decodeNFOFile 的 partial 误当 ok 导致完整 NFO 被跳过的问题。 Co-authored-by: truewhile --------- Co-authored-by: Cursor Agent Co-authored-by: truewhile --- internal/handler/media.go | 4 +- internal/handler/strm.go | 2 + internal/model/strm.go | 3 + internal/repository/strm_repository.go | 1 + internal/service/danmaku_service.go | 23 +- internal/service/emby_items_detail.go | 30 ++- internal/service/emby_people_test.go | 57 +++++ internal/service/emby_playback.go | 2 +- internal/service/episode_parser.go | 10 +- internal/service/local_metadata_artwork.go | 76 ++++--- internal/service/local_metadata_read.go | 9 +- internal/service/media_listing.go | 62 ++++++ internal/service/media_sidecar_base.go | 62 ++++++ internal/service/media_sidecar_base_test.go | 107 ++++++++++ internal/service/media_versions.go | 118 +++++++++++ internal/service/media_versions_test.go | 41 ++++ internal/service/nfo.go | 5 +- .../service/organizer_directory_versions.go | 32 +-- internal/service/organizer_sidecar.go | 63 ++++-- internal/service/scanner_local_ingest.go | 5 +- internal/service/scraper_query_clean.go | 6 + .../service/scraper_write_metadata_files.go | 6 +- internal/service/strm_service.go | 4 + internal/service/strm_sync.go | 196 ++++++++++++------ internal/service/strm_sync_test.go | 81 ++++++++ internal/service/subtitle.go | 27 ++- web/src/components/MediaVersionSwitcher.tsx | 72 +++++++ web/src/pages/MediaDetailPageSections.tsx | 2 + web/src/pages/PlayerPage.tsx | 18 ++ web/src/pages/StrmDialogs.tsx | 25 +++ web/src/types/strm.ts | 3 + web/src/utils/mediaVersion.ts | 60 ++++++ 32 files changed, 1045 insertions(+), 167 deletions(-) create mode 100644 internal/service/emby_people_test.go create mode 100644 internal/service/media_sidecar_base.go create mode 100644 internal/service/media_sidecar_base_test.go create mode 100644 web/src/components/MediaVersionSwitcher.tsx create mode 100644 web/src/utils/mediaVersion.ts diff --git a/internal/handler/media.go b/internal/handler/media.go index 99c52f2..be67f7a 100644 --- a/internal/handler/media.go +++ b/internal/handler/media.go @@ -488,7 +488,7 @@ func getMediaHandler(svc *service.Container) gin.HandlerFunc { c.JSON(http.StatusOK, m) return } - m, err := svc.Media.GetMedia(ctx, id) + m, err := svc.Media.GetMediaItem(ctx, id) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return @@ -497,7 +497,7 @@ func getMediaHandler(svc *service.Container) gin.HandlerFunc { c.JSON(http.StatusNotFound, gin.H{"error": "not found"}) return } - if !mediaVisibleForRequest(c, svc, m) { + if !mediaVisibleForRequest(c, svc, &m.Media) { c.JSON(http.StatusNotFound, gin.H{"error": "not found"}) return } diff --git a/internal/handler/strm.go b/internal/handler/strm.go index 55842db..6a5bf9a 100644 --- a/internal/handler/strm.go +++ b/internal/handler/strm.go @@ -232,6 +232,7 @@ type strmSyncPathReq struct { DownloadMeta *bool `json:"download_meta"` UploadMeta *bool `json:"upload_meta"` DeleteDir *bool `json:"delete_dir"` + KeepExt *bool `json:"keep_ext"` Cron string `json:"cron"` EnableCron *bool `json:"enable_cron"` SyncMode string `json:"sync_mode"` @@ -703,6 +704,7 @@ func strmSyncPathFromReq(req strmSyncPathReq) *model.StrmSyncPath { DownloadMeta: boolValue(req.DownloadMeta, true), UploadMeta: boolValue(req.UploadMeta, false), DeleteDir: boolValue(req.DeleteDir, false), + KeepExt: boolValue(req.KeepExt, false), Cron: strings.TrimSpace(req.Cron), EnableCron: boolValue(req.EnableCron, false), SyncMode: strings.TrimSpace(req.SyncMode), diff --git a/internal/model/strm.go b/internal/model/strm.go index 4f02b1a..0b1e34c 100644 --- a/internal/model/strm.go +++ b/internal/model/strm.go @@ -51,6 +51,9 @@ type StrmSyncPath struct { DownloadMeta bool `gorm:"default:true" json:"download_meta"` // 同步时下载元数据文件(nfo/图片/字幕) UploadMeta bool `json:"upload_meta"` // 同步时把本地元数据上传到远端 DeleteDir bool `json:"delete_dir"` // 清理多余文件时删除空目录 + // KeepExt=true 时为每个视频生成 name.mkv.strm / name.mp4.strm(保留全部版本); + // false(默认)时同名不同扩展只择优生成一条 name.strm,避免互相覆盖与来回抖动。 + KeepExt bool `json:"keep_ext"` Cron string `gorm:"size:128" json:"cron"` // 5 段 cron 表达式(可选) EnableCron bool `json:"enable_cron"` // 是否按 Cron 定时同步 SyncMode string `gorm:"size:32;default:'incremental'" json:"sync_mode"` // 默认同步模式:incremental / full diff --git a/internal/repository/strm_repository.go b/internal/repository/strm_repository.go index 6d0daf1..2cfbb18 100644 --- a/internal/repository/strm_repository.go +++ b/internal/repository/strm_repository.go @@ -110,6 +110,7 @@ func (r *StrmSyncPathRepository) Update(ctx context.Context, p *model.StrmSyncPa "download_meta": p.DownloadMeta, "upload_meta": p.UploadMeta, "delete_dir": p.DeleteDir, + "keep_ext": p.KeepExt, "cron": p.Cron, "enable_cron": p.EnableCron, "sync_mode": p.SyncMode, diff --git a/internal/service/danmaku_service.go b/internal/service/danmaku_service.go index 9a1d928..c9e334b 100644 --- a/internal/service/danmaku_service.go +++ b/internal/service/danmaku_service.go @@ -369,7 +369,10 @@ func (s *DanmakuService) searchTerms(ctx context.Context, mediaID string) (danma } else if name := strings.TrimSpace(m.Title); name != "" { term.name = name } else { - term.name = strings.TrimSuffix(filepath.Base(m.Path), filepath.Ext(m.Path)) + term.name = mediaSidecarBase(m.Path) + if term.name == "" { + term.name = strings.TrimSuffix(filepath.Base(m.Path), filepath.Ext(m.Path)) + } } if m.EpisodeNum > 0 { term.episode = strconv.Itoa(m.EpisodeNum) @@ -527,23 +530,7 @@ func (s *DanmakuService) hashCachePut(stamp, hash string) { // ("xxx.mkv.strm") — so a second strip removes a real video extension only // (filepath.Ext would misread names like "xxx.第01话" as having an extension). func danmakuMatchFileName(path string) string { - if path == "" { - return "" - } - clean := strings.ReplaceAll(path, "\\", "/") - if idx := strings.LastIndex(clean, "/"); idx >= 0 { - clean = clean[idx+1:] - } - base := filepath.Base(clean) - if ext := filepath.Ext(base); ext != "" { - base = strings.TrimSuffix(base, ext) - } - if second := strings.ToLower(filepath.Ext(base)); second != "" { - if _, ok := videoExtensions[second]; ok && second != ".strm" { - base = strings.TrimSuffix(base, filepath.Ext(base)) - } - } - return strings.TrimSpace(base) + return mediaSidecarBase(path) } // mediaHash returns the dandanplay match hash (MD5 of the first 16MB of the diff --git a/internal/service/emby_items_detail.go b/internal/service/emby_items_detail.go index c606710..3cc35a5 100644 --- a/internal/service/emby_items_detail.go +++ b/internal/service/emby_items_detail.go @@ -538,21 +538,35 @@ func (e *EmbyService) resolveMediaPeople(ctx context.Context, m *model.Media) [] return []map[string]any{} } dir := filepath.Dir(m.Path) - ext := filepath.Ext(m.Path) - base := strings.TrimSuffix(m.Path, ext) - candidates := []string{ - base + ".nfo", - filepath.Join(dir, "movie.nfo"), - filepath.Join(dir, "tvshow.nfo"), + candidates := make([]string, 0, 6) + seenPath := map[string]struct{}{} + add := func(path string) { + path = strings.TrimSpace(path) + if path == "" { + return + } + key := strings.ToLower(filepath.Clean(path)) + if _, ok := seenPath[key]; ok { + return + } + seenPath[key] = struct{}{} + candidates = append(candidates, path) } + // 共享词干优先(竞女01.mkv.strm → 竞女01.nfo),并兼容旧的单层剥扩展命名。 + add(nfoPath(m.Path)) + for _, base := range mediaSidecarBaseVariants(m.Path) { + add(filepath.Join(dir, base+".nfo")) + } + add(filepath.Join(dir, "movie.nfo")) + add(filepath.Join(dir, "tvshow.nfo")) people := make([]map[string]any, 0) seen := make(map[string]bool) for _, p := range candidates { if fi, err := os.Stat(p); err == nil && !fi.IsDir() { - doc, ok, err := decodeNFOFile(p) - if err == nil && ok && doc != nil { + doc, _, err := decodeNFOFile(p) + if err == nil && doc != nil { for _, d := range doc.Directors { name := strings.TrimSpace(d) if name == "" { diff --git a/internal/service/emby_people_test.go b/internal/service/emby_people_test.go new file mode 100644 index 0000000..28ebcea --- /dev/null +++ b/internal/service/emby_people_test.go @@ -0,0 +1,57 @@ +package service + +import ( + "os" + "path/filepath" + "testing" + + "github.com/truewhile/MeBox/internal/model" +) + +func TestResolveMediaPeopleUsesSharedStemBesideKeepExtStrm(t *testing.T) { + svc := newTestEmbyService(t) + dir := t.TempDir() + mediaPath := filepath.Join(dir, "竞女01.mkv.strm") + nfoPath := filepath.Join(dir, "竞女01.nfo") + if err := os.WriteFile(mediaPath, []byte("http://example/play"), 0o644); err != nil { + t.Fatal(err) + } + nfo := ` +竞女01 +导演甲 +演员乙主角 +` + if err := os.WriteFile(nfoPath, []byte(nfo), 0o644); err != nil { + t.Fatal(err) + } + + people := svc.resolveMediaPeople(t.Context(), &model.Media{Path: mediaPath, Title: "竞女01"}) + if len(people) != 2 { + t.Fatalf("people=%d want 2: %#v", len(people), people) + } + got := map[string]string{} + for _, p := range people { + got[p["Name"].(string)] = p["Type"].(string) + } + if got["导演甲"] != "Director" || got["演员乙"] != "Actor" { + t.Fatalf("unexpected people: %#v", people) + } +} + +func TestResolveMediaPeopleStillReadsLegacyKeepExtNFO(t *testing.T) { + svc := newTestEmbyService(t) + dir := t.TempDir() + mediaPath := filepath.Join(dir, "竞女01.mkv.strm") + legacyNFO := filepath.Join(dir, "竞女01.mkv.nfo") + if err := os.WriteFile(mediaPath, []byte("http://example/play"), 0o644); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(legacyNFO, []byte(`旧导演`), 0o644); err != nil { + t.Fatal(err) + } + + people := svc.resolveMediaPeople(t.Context(), &model.Media{Path: mediaPath}) + if len(people) != 1 || people[0]["Name"] != "旧导演" { + t.Fatalf("expected legacy keep_ext nfo people, got %#v", people) + } +} diff --git a/internal/service/emby_playback.go b/internal/service/emby_playback.go index 1a48f2d..599ba7f 100644 --- a/internal/service/emby_playback.go +++ b/internal/service/emby_playback.go @@ -321,7 +321,7 @@ func (e *EmbyService) mediaSource(ctx context.Context, m *model.Media, asEmbedde func (e *EmbyService) baseMediaSource(ctx context.Context, m *model.Media, container string, isCloud bool, playURL string, directOnly bool) map[string]any { return map[string]any{ "Id": m.ID, - "Name": m.Title, + "Name": MediaVersionLabel(*m), "Path": embyMediaSourcePath(m), "Container": container, "Size": m.SizeBytes, diff --git a/internal/service/episode_parser.go b/internal/service/episode_parser.go index 499ebdb..c0b28fa 100644 --- a/internal/service/episode_parser.go +++ b/internal/service/episode_parser.go @@ -41,7 +41,10 @@ var ( // ParseEpisode tries to extract (season, episode) from an arbitrary filename. // Returns (0, 0) when nothing recognisable is found. func ParseEpisode(path string) (season, episode int) { - name := strings.TrimSuffix(filepath.Base(path), filepath.Ext(path)) + name := mediaSidecarBase(path) + if name == "" { + name = strings.TrimSuffix(filepath.Base(path), filepath.Ext(path)) + } if m := patSEnE.FindStringSubmatch(name); len(m) == 3 { season = mustAtoi(m[1]) @@ -96,7 +99,10 @@ type episodeRef struct { } func episodeRefsFromTitle(path string) []episodeRef { - name := strings.TrimSuffix(filepath.Base(path), filepath.Ext(path)) + name := mediaSidecarBase(path) + if name == "" { + name = strings.TrimSuffix(filepath.Base(path), filepath.Ext(path)) + } if refs := parseSEpisodeRange(name); len(refs) > 0 { return refs } diff --git a/internal/service/local_metadata_artwork.go b/internal/service/local_metadata_artwork.go index 46f0efa..84a79fc 100644 --- a/internal/service/local_metadata_artwork.go +++ b/internal/service/local_metadata_artwork.go @@ -54,39 +54,59 @@ func mergeArtworkMetadata(meta *LocalMetadata, mediaPath, showBaseDir string) { } func localPosterCandidates(mediaPath string) []string { - base := strings.TrimSuffix(filepath.Base(mediaPath), filepath.Ext(mediaPath)) - names := []string{ - base + "-poster", - base + ".poster", - "poster", - "folder", - "cover", - "movie", - "show", - base + "-cover", - base + ".cover", - base, - base + "-thumb", - base + ".thumb", - "thumb", + names := make([]string, 0, 24) + seen := map[string]struct{}{} + add := func(name string) { + name = strings.TrimSpace(name) + if name == "" { + return + } + key := strings.ToLower(name) + if _, ok := seen[key]; ok { + return + } + seen[key] = struct{}{} + names = append(names, name) + } + for _, base := range mediaSidecarBaseVariants(mediaPath) { + add(base + "-poster") + add(base + ".poster") + add(base + "-cover") + add(base + ".cover") + add(base) + add(base + "-thumb") + add(base + ".thumb") + } + for _, name := range []string{"poster", "folder", "cover", "movie", "show", "thumb"} { + add(name) } return append(adultArtworkNameCandidates(mediaPath, "poster"), names...) } func localBackdropCandidates(mediaPath string) []string { - base := strings.TrimSuffix(filepath.Base(mediaPath), filepath.Ext(mediaPath)) - names := []string{ - base + "-fanart", - base + ".fanart", - base + "-backdrop", - base + ".backdrop", - base + "-background", - "fanart", - "backdrop", - "background", - "landscape", - "banner", - "clearart", + names := make([]string, 0, 24) + seen := map[string]struct{}{} + add := func(name string) { + name = strings.TrimSpace(name) + if name == "" { + return + } + key := strings.ToLower(name) + if _, ok := seen[key]; ok { + return + } + seen[key] = struct{}{} + names = append(names, name) + } + for _, base := range mediaSidecarBaseVariants(mediaPath) { + add(base + "-fanart") + add(base + ".fanart") + add(base + "-backdrop") + add(base + ".backdrop") + add(base + "-background") + } + for _, name := range []string{"fanart", "backdrop", "background", "landscape", "banner", "clearart"} { + add(name) } return append(adultArtworkNameCandidates(mediaPath, "backdrop"), names...) } diff --git a/internal/service/local_metadata_read.go b/internal/service/local_metadata_read.go index ff19ac9..ff91ab9 100644 --- a/internal/service/local_metadata_read.go +++ b/internal/service/local_metadata_read.go @@ -28,13 +28,12 @@ func ReadLocalMetadata(mediaPath, libraryRoot string, seriesLike bool) (*LocalMe func findMovieNFO(mediaPath, libraryRoot string) (*nfoDocument, string, error) { mediaDir := filepath.Dir(mediaPath) - base := strings.TrimSuffix(filepath.Base(mediaPath), filepath.Ext(mediaPath)) adultCode := AdultCodeFromMediaPath(mediaPath) - names := []string{ - base + ".nfo", - "movie.nfo", - filepath.Base(mediaDir) + ".nfo", + names := make([]string, 0, 8) + for _, base := range mediaSidecarBaseVariants(mediaPath) { + names = append(names, base+".nfo") } + names = append(names, "movie.nfo", filepath.Base(mediaDir)+".nfo") if adultCode != "" { names = append([]string{adultCode + ".nfo", strings.ReplaceAll(adultCode, "-", "") + ".nfo"}, names...) } diff --git a/internal/service/media_listing.go b/internal/service/media_listing.go index f44ec31..32ba720 100644 --- a/internal/service/media_listing.go +++ b/internal/service/media_listing.go @@ -2,6 +2,8 @@ package service import ( "context" + "sort" + "strings" "time" "go.uber.org/zap" @@ -111,3 +113,63 @@ func (s *MediaService) GetMedia(ctx context.Context, id string) (*model.Media, e *media = items[0] return media, nil } + +// GetMediaItem 返回媒体详情,并附带同片多版本列表(用于详情页/播放器切换)。 +func (s *MediaService) GetMediaItem(ctx context.Context, id string) (*MediaItem, error) { + media, err := s.GetMedia(ctx, id) + if err != nil || media == nil { + return nil, err + } + versions, err := s.listVersionSiblings(ctx, media) + if err != nil { + return nil, err + } + item := &MediaItem{Media: *media} + if len(versions) > 1 { + item.Versions = versions + } + return item, nil +} + +// listVersionSiblings 查找与当前条目同属一个版本组的全部媒体(含自身)。 +func (s *MediaService) listVersionSiblings(ctx context.Context, media *model.Media) ([]model.Media, error) { + if media == nil || strings.TrimSpace(media.ID) == "" { + return nil, nil + } + key := mediaVersionGroupKey(*media) + if key == "" { + return []model.Media{*media}, nil + } + libraryIDs, err := MergedLibraryIDsForLibrary(ctx, s.repo, media.LibraryID) + if err != nil { + return nil, err + } + if len(libraryIDs) == 0 { + libraryIDs = []string{media.LibraryID} + } + filter := repository.MediaQueryFilter{IncludeNSFW: true} + candidates, err := s.repo.Media.ListByLibrariesFilteredNoCount(ctx, libraryIDs, 0, 5000, filter) + if err != nil { + return nil, err + } + s.attachLibraryMetadata(ctx, candidates) + matched := make([]model.Media, 0, 4) + for _, row := range candidates { + if mediaVersionGroupKey(row) == key { + matched = append(matched, row) + } + } + if len(matched) == 0 { + return []model.Media{*media}, nil + } + sort.SliceStable(matched, func(i, j int) bool { + if matched[i].ID == media.ID { + return true + } + if matched[j].ID == media.ID { + return false + } + return betterMediaVersion(matched[i], matched[j]) + }) + return matched, nil +} diff --git a/internal/service/media_sidecar_base.go b/internal/service/media_sidecar_base.go new file mode 100644 index 0000000..6ea83e8 --- /dev/null +++ b/internal/service/media_sidecar_base.go @@ -0,0 +1,62 @@ +package service + +import ( + "path/filepath" + "strings" +) + +// mediaSidecarBase 返回媒体文件用于配对 NFO/海报/字幕等元数据的共享基名。 +// 同片多版本(含 keep_ext 的 name.mkv.strm / name.mp4.strm)应落到同一词干, +// 从而忽略中间的视频扩展,按「同一影片」匹配边车文件。 +// +// movie.mkv → movie +// movie.strm → movie +// movie.mkv.strm → movie +// Show.S01E01.mp4.strm → Show.S01E01 +func mediaSidecarBase(mediaPath string) string { + clean := strings.ReplaceAll(strings.TrimSpace(mediaPath), "\\", "/") + if clean == "" { + return "" + } + return mediaFileStem(filepath.Base(clean)) +} + +// mediaFileStem 去掉最终扩展名;若为 .strm 且前一层是视频扩展,再剥一层。 +func mediaFileStem(name string) string { + name = strings.TrimSpace(name) + if name == "" || name == "." { + return "" + } + ext := filepath.Ext(name) + stem := strings.TrimSuffix(name, ext) + if strings.EqualFold(ext, ".strm") { + if second := strings.ToLower(filepath.Ext(stem)); second != "" { + if _, ok := videoExtensions[second]; ok && second != ".strm" { + stem = strings.TrimSuffix(stem, filepath.Ext(stem)) + } + } + } + return strings.TrimSpace(stem) +} + +// mediaSidecarBaseVariants 返回匹配用的基名候选:共享词干优先,其次保留单层剥扩展 +// (兼容历史上写成 name.mkv.nfo / name.mkv-poster.jpg 的边车)。 +func mediaSidecarBaseVariants(mediaPath string) []string { + stem := mediaSidecarBase(mediaPath) + single := strings.TrimSuffix(filepath.Base(strings.TrimSpace(mediaPath)), filepath.Ext(mediaPath)) + single = strings.TrimSpace(single) + out := make([]string, 0, 2) + seen := map[string]struct{}{} + for _, item := range []string{stem, single} { + if item == "" || item == "." { + continue + } + key := strings.ToLower(item) + if _, ok := seen[key]; ok { + continue + } + seen[key] = struct{}{} + out = append(out, item) + } + return out +} diff --git a/internal/service/media_sidecar_base_test.go b/internal/service/media_sidecar_base_test.go new file mode 100644 index 0000000..2c02f02 --- /dev/null +++ b/internal/service/media_sidecar_base_test.go @@ -0,0 +1,107 @@ +package service + +import ( + "os" + "path/filepath" + "strings" + "testing" + + "github.com/truewhile/MeBox/internal/model" + "github.com/truewhile/MeBox/internal/repository" +) + +func TestMediaSidecarBaseIgnoresKeepExt(t *testing.T) { + cases := map[string]string{ + "/media/竞女01.mkv.strm": "竞女01", + "/media/竞女01.mp4.strm": "竞女01", + "/media/竞女01.strm": "竞女01", + "/media/竞女01.mkv": "竞女01", + "/lib/Show.S01E02.mkv.strm": "Show.S01E02", + `C:/lib/Show.S01E02.mkv.strm`: "Show.S01E02", + } + for in, want := range cases { + if got := mediaSidecarBase(in); got != want { + t.Fatalf("mediaSidecarBase(%q)=%q want %q", in, got, want) + } + } +} + +func TestNFOPathUsesSharedStemForKeepExt(t *testing.T) { + got := nfoPath("/strm/竞女01.mkv.strm") + want := filepath.Join("/strm", "竞女01.nfo") + if got != want { + t.Fatalf("nfoPath keep_ext = %q want %q", got, want) + } + if nfoPath("/strm/竞女01.mp4.strm") != want { + t.Fatalf("mkv/mp4 versions should share nfo path") + } +} + +func TestFindMovieNFOMatchesSharedStemBesideKeepExtStrm(t *testing.T) { + dir := t.TempDir() + media := filepath.Join(dir, "竞女01.mkv.strm") + nfo := filepath.Join(dir, "竞女01.nfo") + writeFileContent(t, media, "http://example/play") + writeFileContent(t, nfo, `竞女01`) + + doc, path, err := findMovieNFO(media, dir) + if err != nil { + t.Fatal(err) + } + if path != nfo { + t.Fatalf("path=%q want %q", path, nfo) + } + if doc == nil || strings.TrimSpace(doc.Title) != "竞女01" { + t.Fatalf("unexpected nfo doc: %#v", doc) + } +} + +func TestLocalPosterCandidatesIncludeSharedStem(t *testing.T) { + cands := localPosterCandidates("/strm/竞女01.mkv.strm") + found := false + for _, name := range cands { + if name == "竞女01-poster" || name == "竞女01" { + found = true + break + } + } + if !found { + t.Fatalf("expected shared stem poster candidates, got %#v", cands) + } +} + +func TestSubtitleDiscoveryMatchesSharedStemBesideKeepExt(t *testing.T) { + dir := t.TempDir() + mediaPath := filepath.Join(dir, "竞女01.mkv.strm") + subPath := filepath.Join(dir, "竞女01.zh.srt") + writeFileContent(t, mediaPath, "http://example/play") + writeFileContent(t, subPath, "1\n00:00:01,000 --> 00:00:02,000\nhi\n") + + db := newServiceTestDB(t, &model.Media{}) + repos := repository.New(db) + media := model.Media{Title: "竞女01", Path: mediaPath} + if err := repos.Media.Upsert(t.Context(), &media); err != nil { + t.Fatal(err) + } + svc := &SubtitleService{repo: repos} + tracks, err := svc.discoverUncached(t.Context(), media.ID) + if err != nil { + t.Fatal(err) + } + if len(tracks) != 1 { + t.Fatalf("tracks=%d want 1 (%#v)", len(tracks), tracks) + } + if tracks[0].Path != subPath { + t.Fatalf("track path=%q want %q", tracks[0].Path, subPath) + } +} + +func writeFileContent(t *testing.T, path, data string) { + t.Helper() + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(path, []byte(data), 0o644); err != nil { + t.Fatal(err) + } +} diff --git a/internal/service/media_versions.go b/internal/service/media_versions.go index aea4764..5bad02c 100644 --- a/internal/service/media_versions.go +++ b/internal/service/media_versions.go @@ -2,6 +2,7 @@ package service import ( "fmt" + "path/filepath" "sort" "strings" @@ -179,6 +180,7 @@ func mediaVersionGroupKey(m model.Media) string { } return "thetvdb:" + strings.ToLower(strings.TrimSpace(m.TheTVDBID)) } + title := firstNonEmpty(m.OriginalName, m.Title) titleYear := 0 if title == "" { @@ -187,6 +189,10 @@ func mediaVersionGroupKey(m model.Media) string { title, titleYear = mediaVersionTitleKey(title) } if title == "" { + // 无标题时退回同目录词干(覆盖 keep_ext 的 name.mkv.strm / name.mp4.strm) + if stemKey := mediaVersionStemGroupKey(m, libKey); stemKey != "" { + return stemKey + } return "" } year := m.Year @@ -202,6 +208,114 @@ func mediaVersionGroupKey(m model.Media) string { return fmt.Sprintf("movie:%s:%d", title, year) } +// mediaVersionStemGroupKey 按「库 + 父目录 + 文件词干」折叠多版本 +// (如 竞女01.mkv.strm 与 竞女01.mp4.strm)。 +func mediaVersionStemGroupKey(m model.Media, libKey string) string { + path := strings.ReplaceAll(strings.TrimSpace(m.Path), "\\", "/") + if path == "" || strings.HasPrefix(strings.ToLower(path), "cloud://") { + return "" + } + dir := "" + base := path + if idx := strings.LastIndex(path, "/"); idx >= 0 { + dir = path[:idx] + base = path[idx+1:] + } + stem := mediaVersionFileStem(base) + if stem == "" { + return "" + } + stem = normalizeMediaVersionText(stem) + if stem == "" { + return "" + } + if libKey == "" { + libKey = "_" + } + return fmt.Sprintf("stem:%s:%s:%s", libKey, strings.ToLower(dir), stem) +} + +// mediaVersionFileStem 去掉最终扩展名;若为 .strm 且前一层是视频扩展,再剥一层。 +func mediaVersionFileStem(name string) string { + return mediaFileStem(name) +} + +// MediaVersionLabel 生成版本切换展示名(分辨率 / 容器 / 编码 / 体积 / 文件名)。 +func MediaVersionLabel(m model.Media) string { + parts := make([]string, 0, 4) + if m.Height > 0 { + parts = append(parts, fmt.Sprintf("%dp", m.Height)) + } else if m.Width > 0 { + parts = append(parts, fmt.Sprintf("%dw", m.Width)) + } + container := strings.Trim(strings.ToLower(strings.TrimSpace(m.Container)), ". ") + if container == "" || container == "strm" { + container = mediaVersionContainerFromPath(m.Path, m.STRMURL) + } + if container != "" && container != "strm" { + parts = append(parts, strings.ToUpper(container)) + } + if codec := strings.TrimSpace(m.VideoCodec); codec != "" { + parts = append(parts, strings.ToUpper(codec)) + } + if m.SizeBytes > 0 { + parts = append(parts, formatMediaSize(m.SizeBytes)) + } + if len(parts) > 0 { + return strings.Join(parts, " · ") + } + base := filepath.Base(strings.ReplaceAll(strings.TrimSpace(m.Path), "\\", "/")) + if base == "" || base == "." { + return firstNonEmpty(m.Title, m.OriginalName, m.ID) + } + return base +} + +func mediaVersionContainerFromPath(path, strmURL string) string { + base := filepath.Base(strings.ReplaceAll(strings.TrimSpace(path), "\\", "/")) + ext := strings.ToLower(filepath.Ext(base)) + name := strings.TrimSuffix(base, ext) + if ext == ".strm" { + if second := strings.ToLower(filepath.Ext(name)); second != "" { + if _, ok := videoExtensions[second]; ok { + return strings.TrimPrefix(second, ".") + } + } + // 从 strm 播放 URL 的 /video.mkv 推断 + u := strings.ToLower(strmURL) + if idx := strings.LastIndex(u, "/video."); idx >= 0 { + rest := u[idx+len("/video."):] + if end := strings.IndexAny(rest, "?#&/"); end >= 0 { + rest = rest[:end] + } + rest = strings.Trim(rest, ".") + if rest != "" { + return rest + } + } + return "strm" + } + if ext != "" { + if _, ok := videoExtensions[ext]; ok { + return strings.TrimPrefix(ext, ".") + } + } + return strings.TrimPrefix(ext, ".") +} + +func formatMediaSize(bytes int64) string { + if bytes < 1024 { + return fmt.Sprintf("%d B", bytes) + } + const unit = 1024 + div, exp := int64(unit), 0 + for n := bytes / unit; n >= unit; n /= unit { + div *= unit + exp++ + } + return fmt.Sprintf("%.1f %cB", float64(bytes)/float64(div), "KMGTPE"[exp]) +} + func mediaVersionTitleKey(value string) (string, int) { cleaned, year := CleanQuery(value) if strings.TrimSpace(cleaned) == "" { @@ -232,6 +346,10 @@ func normalizeMediaVersionText(value string) string { if _, noise := noiseTokenSet[field]; noise { continue } + // 去掉视频容器词干残留(keep_ext / 旧标题「竞女01 mkv」) + if _, ok := videoExtensions["."+field]; ok { + continue + } out = append(out, field) } return strings.Join(out, " ") diff --git a/internal/service/media_versions_test.go b/internal/service/media_versions_test.go index f93175d..7539e69 100644 --- a/internal/service/media_versions_test.go +++ b/internal/service/media_versions_test.go @@ -1,6 +1,7 @@ package service import ( + "strings" "testing" "time" @@ -264,3 +265,43 @@ func TestGroupMediaVersionsDoesNotMergeAcrossDifferentLibrariesForMovies(t *test t.Fatalf("expected 2 separate groups for movies in different libraries, got %d", len(grouped)) } } + +func TestGroupMediaVersionsMergesKeepExtStrmVariants(t *testing.T) { + mkv := model.Media{ + LibraryID: "movies", + Title: "竞女01", + Path: "/strm/竞女01.mkv.strm", + SizeBytes: 500, + STRMURL: "/api/strm/play/cloud115/video.mkv?pickcode=a", + } + mp4 := model.Media{ + LibraryID: "movies", + Title: "竞女01", + Path: "/strm/竞女01.mp4.strm", + SizeBytes: 100, + STRMURL: "/api/strm/play/cloud115/video.mp4?pickcode=b", + } + grouped := groupMediaVersions([]model.Media{mkv, mp4}) + if len(grouped) != 1 { + t.Fatalf("grouped len = %d, want 1", len(grouped)) + } + if len(grouped[0].Versions) != 2 { + t.Fatalf("versions len = %d, want 2", len(grouped[0].Versions)) + } + if grouped[0].Path != mkv.Path { + t.Fatalf("primary should be larger mkv, got %q", grouped[0].Path) + } +} + +func TestMediaVersionLabelUsesContainerAndSize(t *testing.T) { + label := MediaVersionLabel(model.Media{ + Title: "竞女01", + Path: "/strm/竞女01.mkv.strm", + Height: 1080, + SizeBytes: 1024 * 1024 * 1200, + STRMURL: "/api/strm/play/cloud115/video.mkv?pickcode=a", + }) + if !strings.Contains(label, "1080p") || !strings.Contains(strings.ToUpper(label), "MKV") { + t.Fatalf("unexpected label %q", label) + } +} diff --git a/internal/service/nfo.go b/internal/service/nfo.go index 10905c5..9e9d8a3 100644 --- a/internal/service/nfo.go +++ b/internal/service/nfo.go @@ -115,7 +115,10 @@ func (s *NFOService) ExportLibrary(ctx context.Context, libraryID string) (int, func nfoPath(media string) string { dir := filepath.Dir(media) - base := strings.TrimSuffix(filepath.Base(media), filepath.Ext(media)) + base := mediaSidecarBase(media) + if base == "" { + base = strings.TrimSuffix(filepath.Base(media), filepath.Ext(media)) + } return filepath.Join(dir, fmt.Sprintf("%s.nfo", base)) } diff --git a/internal/service/organizer_directory_versions.go b/internal/service/organizer_directory_versions.go index 1be8b62..757732c 100644 --- a/internal/service/organizer_directory_versions.go +++ b/internal/service/organizer_directory_versions.go @@ -278,16 +278,21 @@ func randomSuffix() string { // moveStagedArtwork renames artwork sidecars staged alongside `stage` into // their final names next to `dst`. func moveStagedArtwork(stage, dst string) { - stageBase := strings.TrimSuffix(filepath.Base(stage), filepath.Ext(stage)) - dstBase := strings.TrimSuffix(filepath.Base(dst), filepath.Ext(dst)) + stageBases := mediaSidecarBaseVariants(stage) + dstBase := mediaSidecarBase(dst) + if dstBase == "" { + dstBase = strings.TrimSuffix(filepath.Base(dst), filepath.Ext(dst)) + } stageDir := filepath.Dir(stage) - for _, suffix := range artworkSidecarSuffixes { - for _, ext := range artworkSidecarExtensions { - srcPath := filepath.Join(stageDir, stageBase+suffix+ext) - if _, err := os.Stat(srcPath); err != nil { - continue + for _, stageBase := range stageBases { + for _, suffix := range artworkSidecarSuffixes { + for _, ext := range artworkSidecarExtensions { + srcPath := filepath.Join(stageDir, stageBase+suffix+ext) + if _, err := os.Stat(srcPath); err != nil { + continue + } + _ = os.Rename(srcPath, filepath.Join(stageDir, dstBase+suffix+ext)) } - _ = os.Rename(srcPath, filepath.Join(stageDir, dstBase+suffix+ext)) } } } @@ -306,12 +311,13 @@ func moveSidecarRename(from, to string) { // removeStagedArtwork removes artwork sidecars that were staged alongside // `stage`, used when a replace fails and its staged outputs must be cleaned up. func removeStagedArtwork(stage string) { - stageBase := strings.TrimSuffix(filepath.Base(stage), filepath.Ext(stage)) + stageBases := mediaSidecarBaseVariants(stage) stageDir := filepath.Dir(stage) - for _, suffix := range artworkSidecarSuffixes { - for _, ext := range artworkSidecarExtensions { - path := filepath.Join(stageDir, stageBase+suffix+ext) - _ = os.Remove(path) + for _, stageBase := range stageBases { + for _, suffix := range artworkSidecarSuffixes { + for _, ext := range artworkSidecarExtensions { + _ = os.Remove(filepath.Join(stageDir, stageBase+suffix+ext)) + } } } } diff --git a/internal/service/organizer_sidecar.go b/internal/service/organizer_sidecar.go index 0d11e53..8b1f153 100644 --- a/internal/service/organizer_sidecar.go +++ b/internal/service/organizer_sidecar.go @@ -51,23 +51,25 @@ var artworkSidecarExtensions = []string{".jpg", ".jpeg", ".png", ".webp", ".gif" // behind in the old folder; this keeps artwork with the organized file. func transferSidecarArtwork(srcMedia, dstMedia string, mode TransferMode) error { srcDir := filepath.Dir(srcMedia) - base := strings.TrimSuffix(filepath.Base(srcMedia), filepath.Ext(srcMedia)) - if base == "" || base == "." { + bases := mediaSidecarBaseVariants(srcMedia) + if len(bases) == 0 { return nil } // Find every existing sidecar by probing suffix + extension combinations. - sources := make([]string, 0, len(artworkSidecarSuffixes)*len(artworkSidecarExtensions)) + sources := make([]string, 0, len(artworkSidecarSuffixes)*len(artworkSidecarExtensions)*len(bases)) seen := map[string]struct{}{} - for _, suffix := range artworkSidecarSuffixes { - for _, ext := range artworkSidecarExtensions { - path := filepath.Join(srcDir, base+suffix+ext) - key := strings.ToLower(filepath.Clean(path)) - if _, ok := seen[key]; ok { - continue - } - seen[key] = struct{}{} - if _, err := os.Stat(path); err == nil { - sources = append(sources, path) + for _, base := range bases { + for _, suffix := range artworkSidecarSuffixes { + for _, ext := range artworkSidecarExtensions { + path := filepath.Join(srcDir, base+suffix+ext) + key := strings.ToLower(filepath.Clean(path)) + if _, ok := seen[key]; ok { + continue + } + seen[key] = struct{}{} + if _, err := os.Stat(path); err == nil { + sources = append(sources, path) + } } } } @@ -78,15 +80,36 @@ func transferSidecarArtwork(srcMedia, dstMedia string, mode TransferMode) error if err := os.MkdirAll(dstDir, 0o755); err != nil { // #nosec G301 -- sidecar media directories must remain readable by NAS/player users. return err } - var firstErr error + dstBase := mediaSidecarBase(dstMedia) + if dstBase == "" { + dstBase = strings.TrimSuffix(filepath.Base(dstMedia), filepath.Ext(dstMedia)) + } for _, src := range sources { - dst := filepath.Join(dstDir, filepath.Base(src)) - if _, err := os.Stat(dst); err == nil { - continue // never clobber an existing artwork at the destination + name := filepath.Base(src) + // Remap any legacy "Title.mkv-poster.jpg" onto the shared destination stem. + suffix := "" + lowerName := strings.ToLower(name) + for _, base := range bases { + prefix := strings.ToLower(base) + if strings.HasPrefix(lowerName, prefix) { + suffix = name[len(base):] + break + } } - if err := transferFile(src, dst, mode); err != nil && firstErr == nil { - firstErr = err + dstName := name + if suffix != "" && dstBase != "" { + dstName = dstBase + suffix + } + dst := filepath.Join(dstDir, dstName) + if strings.EqualFold(filepath.Clean(src), filepath.Clean(dst)) { + continue + } + if _, err := os.Stat(dst); err == nil { + continue + } + if err := transferFile(src, dst, mode); err != nil { + return err } } - return firstErr + return nil } diff --git a/internal/service/scanner_local_ingest.go b/internal/service/scanner_local_ingest.go index 6a05966..c9f8c31 100644 --- a/internal/service/scanner_local_ingest.go +++ b/internal/service/scanner_local_ingest.go @@ -138,7 +138,10 @@ type localScanMediaInput struct { func (s *ScannerService) buildLocalScanMedia(in localScanMediaInput) *model.Media { title, year := CleanQueryWithRecognition(context.Background(), s.repo, in.path) if title == "" { - title = strings.TrimSuffix(filepath.Base(in.path), in.ext) + title = mediaSidecarBase(in.path) + if title == "" { + title = strings.TrimSuffix(filepath.Base(in.path), in.ext) + } } title, year = preferISOParentScrapeIdentity(in.path, in.lib.Path, title, year) diff --git a/internal/service/scraper_query_clean.go b/internal/service/scraper_query_clean.go index 5c3ce1f..9888f99 100644 --- a/internal/service/scraper_query_clean.go +++ b/internal/service/scraper_query_clean.go @@ -75,6 +75,12 @@ func CleanQuery(raw string) (title string, year int) { base = strings.TrimSpace(raw) } name := strings.TrimSuffix(base, filepath.Ext(base)) + // .strm 可能保留视频扩展名(name.mkv.strm),再剥一层真实视频扩展, + // 避免「竞女01.mkv」与「竞女01.mp4」被当成不同标题。 + name = mediaFileStem(base) + if name == "" { + name = strings.TrimSuffix(base, filepath.Ext(base)) + } lower := strings.ToLower(name) if m := yearPattern.FindStringSubmatch(lower); len(m) >= 2 { diff --git a/internal/service/scraper_write_metadata_files.go b/internal/service/scraper_write_metadata_files.go index 2a4b923..70a5143 100644 --- a/internal/service/scraper_write_metadata_files.go +++ b/internal/service/scraper_write_metadata_files.go @@ -38,9 +38,9 @@ func (s *ScraperService) writeMediaArtworkFilesAfterScrape(ctx context.Context, if dir == "" || dir == "." { return } - // Scope sidecar names by the media file's base name (e.g. A.mp4 -> A-poster.jpg) - // so that multiple movies sharing one directory (A.mp4 + B.mp4) never clash. - base := strings.TrimSuffix(filepath.Base(refreshed.Path), filepath.Ext(refreshed.Path)) + // Scope sidecar names by the shared media stem (e.g. A.mkv.strm / A.mp4.strm -> A-poster.jpg) + // so multi-version files in one folder share artwork and never diverge by container. + base := mediaSidecarBase(refreshed.Path) if base == "" || base == "." { return } diff --git a/internal/service/strm_service.go b/internal/service/strm_service.go index e74937c..fb85946 100644 --- a/internal/service/strm_service.go +++ b/internal/service/strm_service.go @@ -42,6 +42,7 @@ const ( StrmSettingDownloadMeta = "strm.download_meta" StrmSettingUploadMeta = "strm.upload_meta" StrmSettingDeleteDir = "strm.delete_dir" + StrmSettingKeepExt = "strm.keep_ext" StrmSettingDownloadThreads = "strm.download_threads" StrmSettingUploadThreads = "strm.upload_threads" ) @@ -69,6 +70,7 @@ var StrmSettingDefs = map[string]struct { StrmSettingDownloadMeta: {Default: "true", Label: "下载元数据", Kind: "bool", Help: "同步时把远端 nfo/图片/字幕下载到本地输出目录"}, StrmSettingUploadMeta: {Default: "false", Label: "上传元数据", Kind: "bool", Help: "同步时把本地元数据上传到远端;本地与网盘元数据不同时以本地为准覆盖(需网盘支持写入)"}, StrmSettingDeleteDir: {Default: "false", Label: "清理空目录", Kind: "bool", Help: "清理远端已删除的多余 .strm/元数据后,删除空目录"}, + StrmSettingKeepExt: {Default: "false", Label: "保留视频扩展名(多版本)", Kind: "bool", Help: "关闭(默认):同名不同扩展(如 竞女01.mkv / 竞女01.mp4)按体积→mtime→扩展名优先级择优生成一条 name.strm;开启:分别生成 name.mkv.strm / name.mp4.strm,保留全部版本供播放切换"}, Strm115RelayKeySetting: {Default: "", Label: "115 中继授权共享密钥", Kind: "text", Help: "QMediaSync/MQFamily 中继授权的共享 AES 密钥(OAUTH_RELAY_ENCRYPTION_KEY);不配置则中继授权不可用"}, StrmSettingDownloadThreads: {Default: "6", Label: "下载队列线程数", Kind: "number", Help: "OpenList/CloudDrive2 元数据下载并发数(115 独立限速为 3)"}, StrmSettingUploadThreads: {Default: "2", Label: "上传队列线程数", Kind: "number", Help: "元数据上传并发数"}, @@ -731,6 +733,7 @@ func (s *StrmService) strmEffectiveConfig(ctx context.Context, p *model.StrmSync cfg.DownloadMeta = p.DownloadMeta cfg.UploadMeta = p.UploadMeta cfg.DeleteDir = p.DeleteDir + cfg.KeepExt = p.KeepExt cfg.BaseURL = strings.TrimRight(cfg.BaseURL, "/") return cfg, nil } @@ -964,6 +967,7 @@ type strmPathConfig struct { DownloadMeta bool UploadMeta bool DeleteDir bool + KeepExt bool } // ─── 本地目录浏览(添加同步目录用,兼容 Windows/Linux) ───────────────────────── diff --git a/internal/service/strm_sync.go b/internal/service/strm_sync.go index a48b0a0..44a0258 100644 --- a/internal/service/strm_sync.go +++ b/internal/service/strm_sync.go @@ -49,11 +49,13 @@ type strmSyncState struct { mu sync.Mutex processed int // 已处理文件计数(用于定期落库进度) lastProgressFlush time.Time // 上次进度落库时间 - seenVideo map[string]bool // "v:"+去掉扩展名的相对路径 → 远端存在该视频 + seenVideo map[string]bool // "v:"+strm 去扩展名相对路径 → 远端存在该视频(供 prune) seenMeta map[string]bool // "m:"+相对路径 → 远端存在该元数据 remoteMeta map[string][]remoteMetaItem // "m:"+相对路径 → 远端元数据副本列表(多副本聚合,支持择优比对与冗余清理) seenMetaTarget map[string]cloud.FileEntry seenVideoTarget map[string]cloud.FileEntry + // remoteVideos:prefer 模式下同名(去扩展名)视频候选列表,walk 结束后择优写盘 + remoteVideos map[string][]remoteVideoCandidate activeDownloadPaths map[string]bool // 本地已在排队/进行的下载任务路径(内存去重) activeUploadPaths map[string]bool // 本地已在排队/进行的上传任务路径(内存去重) // recentDoneUploadSizes:近期已成功上传的 local_path → size,缩短「done 但列表未到」窗口内的重复入队 @@ -67,6 +69,13 @@ type strmSyncState struct { scanIncomplete atomic.Bool // 远端目录树/文件列表本次扫描不完整 → 禁止增量 prune 误删本地文件 } +// remoteVideoCandidate 记录同名视频的一个远端副本(不同扩展名/体积)。 +type remoteVideoCandidate struct { + entry cloud.FileEntry + rel string + ext string +} + // StartSync 启动一次同步(异步执行,同一目录同时只允许一个任务)。 // syncType 支持 "incremental"(默认增量)和 "full"(全量同步)。 func (s *StrmService) StartSync(ctx context.Context, pathID string, syncType ...string) error { @@ -276,6 +285,7 @@ func (s *StrmService) runSync(ctx context.Context, p *model.StrmSyncPath, rec *m remoteMeta: map[string][]remoteMetaItem{}, seenMetaTarget: map[string]cloud.FileEntry{}, seenVideoTarget: map[string]cloud.FileEntry{}, + remoteVideos: map[string][]remoteVideoCandidate{}, } if p.Provider != model.StrmProviderLocal { acct, err := s.repo.StrmAccount.FindByID(ctx, p.AccountID) @@ -386,6 +396,7 @@ func (st *strmSyncState) run() error { return err } } + st.flushPreferredVideos() st.flushPendingDownloads() st.flushProgress() if st.cfg.UploadMeta && st.provider != nil { @@ -1195,21 +1206,123 @@ func (st *strmSyncState) fetch115FilesAdaptive(ctx context.Context, open115 *clo return allFiles, nil } -// handleVideo 生成/更新 .strm 文件。 +// handleVideo 收集/生成 .strm 文件。 +// KeepExt=true:每个视频写 name.ext.strm,保留全部版本。 +// KeepExt=false(默认):同名候选先入队,walk 结束后按体积→mtime→扩展名优先级择优写一条 name.strm。 func (st *strmSyncState) handleVideo(entry cloud.FileEntry, rel, ext string) { relSansExt := rel[:len(rel)-len(ext)] - st.mu.Lock() - if st.seenVideo["v:"+relSansExt] { - st.mu.Unlock() + if st.cfg.KeepExt { + st.writeVideoStrm(entry, rel, ext, rel+".strm", true) return } - st.seenVideo["v:"+relSansExt] = true + st.mu.Lock() + if st.remoteVideos == nil { + st.remoteVideos = map[string][]remoteVideoCandidate{} + } + st.remoteVideos[relSansExt] = append(st.remoteVideos[relSansExt], remoteVideoCandidate{ + entry: entry, + rel: rel, + ext: ext, + }) + st.mu.Unlock() + st.touchProgress() +} + +// flushPreferredVideos 在 prefer 模式下对同名多版本择优写盘,并记录冲突日志。 +func (st *strmSyncState) flushPreferredVideos() { + if st.cfg.KeepExt { + return + } + st.mu.Lock() + pending := st.remoteVideos + st.remoteVideos = map[string][]remoteVideoCandidate{} + st.mu.Unlock() + for base, cands := range pending { + if len(cands) == 0 { + continue + } + winnerIdx := 0 + for i := 1; i < len(cands); i++ { + if betterStrmVideoCandidate(cands[i], cands[winnerIdx], st.cfg.VideoExt) { + winnerIdx = i + } + } + winner := cands[winnerIdx] + if len(cands) > 1 { + skippedRels := make([]string, 0, len(cands)-1) + skippedRefs := make([]string, 0, len(cands)-1) + for i, c := range cands { + if i == winnerIdx { + continue + } + skippedRels = append(skippedRels, c.rel) + ref := strings.TrimSpace(c.entry.PickCode) + if ref == "" { + ref = strings.TrimSpace(c.entry.ID) + } + if ref != "" { + skippedRefs = append(skippedRefs, ref) + } + } + st.s.log.Info("strm multi-version prefer", + zap.String("base", base), + zap.String("winner", winner.rel), + zap.String("winner_ref", firstNonEmpty(winner.entry.PickCode, winner.entry.ID)), + zap.Int64("winner_size", winner.entry.Size), + zap.Strings("skipped", skippedRels), + zap.Strings("skipped_refs", skippedRefs), + ) + st.mu.Lock() + st.rec.Skipped += int64(len(cands) - 1) + st.mu.Unlock() + } + // 候选已在 handleVideo 计入 Total,此处不再重复 touchProgress + st.writeVideoStrm(winner.entry, winner.rel, winner.ext, base+".strm", false) + } +} + +// betterStrmVideoCandidate 决定 prefer 模式下的赢家:体积更大 → mtime 更新 → video_ext 列表更靠前。 +func betterStrmVideoCandidate(candidate, current remoteVideoCandidate, videoExtOrder []string) bool { + if candidate.entry.Size != current.entry.Size { + return candidate.entry.Size > current.entry.Size + } + if candidate.entry.MTime != current.entry.MTime { + return candidate.entry.MTime > current.entry.MTime + } + return strmExtPriority(candidate.ext, videoExtOrder) < strmExtPriority(current.ext, videoExtOrder) +} + +func strmExtPriority(ext string, order []string) int { + ext = strings.TrimPrefix(strings.ToLower(strings.TrimSpace(ext)), ".") + for i, item := range order { + if strings.TrimPrefix(strings.ToLower(strings.TrimSpace(item)), ".") == ext { + return i + } + } + return len(order) + 1 +} + +// writeVideoStrm 把单个视频写成目标 .strm(targetRel 相对本地输出根)。 +// countProgress 控制是否计入 Total(prefer 模式下候选已在收集阶段计数)。 +func (st *strmSyncState) writeVideoStrm(entry cloud.FileEntry, rel, ext, targetRel string, countProgress bool) { + seenKey := "v:" + strings.TrimSuffix(targetRel, ".strm") + st.mu.Lock() + if st.seenVideo[seenKey] { + st.mu.Unlock() + if countProgress { + st.touchProgress() + } + return + } + st.seenVideo[seenKey] = true st.mu.Unlock() - targetRel := relSansExt + ".strm" target, err := joinLocalRel(st.p.LocalPath, targetRel) if err != nil { st.s.log.Warn("strm target path out of root", zap.String("rel", targetRel), zap.Error(err)) + if countProgress { + st.touchProgress() + } return } @@ -1219,28 +1332,35 @@ func (st *strmSyncState) handleVideo(entry cloud.FileEntry, rel, ext string) { } if _, exists := st.seenVideoTarget[target]; exists { st.mu.Unlock() - st.touchProgress() + if countProgress { + st.touchProgress() + } return } st.seenVideoTarget[target] = entry st.mu.Unlock() + done := func() { + if countProgress { + st.touchProgress() + } + } + // 增量同步模式快速检查:本地 strm 文件存在、非空且修改时间与远端 mtime 一致,直接跳过无需读磁盘 if st.syncType == model.StrmSyncTypeIncremental && entry.MTime > 0 { if info, err := os.Stat(target); err == nil && info.Size() > 0 && info.ModTime().Unix() == entry.MTime { st.mu.Lock() st.rec.Skipped++ st.mu.Unlock() - st.touchProgress() + done() return } } content, err := st.strmContent(entry, rel, ext) if err != nil { - // 并发 worker 下 rec.Message 无锁写会有数据竞争,这里仅记录日志; - // 最终同步结果的 message 由 finishSync 统一填充。 st.s.log.Warn("build strm content failed", zap.String("file", rel), zap.Error(err)) + done() return } existing := "" @@ -1248,7 +1368,6 @@ func (st *strmSyncState) handleVideo(entry cloud.FileEntry, rel, ext string) { existing = string(data) } if existing == content { - // 对齐本地 strm 修改时间为远端 mtime,便于后续秒级比对 if entry.MTime > 0 { mTime := time.Unix(entry.MTime, 0) _ = os.Chtimes(target, mTime, mTime) @@ -1256,21 +1375,24 @@ func (st *strmSyncState) handleVideo(entry cloud.FileEntry, rel, ext string) { st.mu.Lock() st.rec.Skipped++ st.mu.Unlock() - st.touchProgress() + done() return } if err := os.MkdirAll(filepath.Dir(target), 0o755); err != nil { st.s.log.Warn("mkdir strm dir failed", zap.String("dir", filepath.Dir(target)), zap.Error(err)) + done() return } tmp := target + ".tmp" if err := os.WriteFile(tmp, []byte(content), 0o644); err != nil { st.s.log.Warn("write strm tmp failed", zap.String("file", target), zap.Error(err)) + done() return } if err := os.Rename(tmp, target); err != nil { _ = os.Remove(tmp) st.s.log.Warn("rename strm failed", zap.String("file", target), zap.Error(err)) + done() return } if entry.MTime > 0 { @@ -1280,7 +1402,7 @@ func (st *strmSyncState) handleVideo(entry cloud.FileEntry, rel, ext string) { st.mu.Lock() st.rec.NewStrm++ st.mu.Unlock() - st.touchProgress() + done() } // strmContent 构建 strm 文件内容(一行指向本服务播放端点的 URL)。 @@ -1534,50 +1656,8 @@ func (st *strmSyncState) walkLocalSource() error { st.touchProgress() return nil } - relSansExt := rel[:len(rel)-len(ext)] - st.mu.Lock() - st.seenVideo["v:"+relSansExt] = true - st.mu.Unlock() - content, err := st.strmContent(cloud.FileEntry{Name: filepath.Base(rel), Size: info.Size()}, rel, ext) - if err != nil { - return nil - } - target, err := joinLocalRel(st.p.LocalPath, relSansExt+".strm") - if err != nil { - return nil - } - mTime := info.ModTime() - if st.syncType == model.StrmSyncTypeIncremental { - if tInfo, err := os.Stat(target); err == nil && tInfo.Size() > 0 && tInfo.ModTime().Unix() == mTime.Unix() { - st.mu.Lock() - st.rec.Skipped++ - st.mu.Unlock() - st.touchProgress() - return nil - } - } - if data, err := os.ReadFile(target); err == nil && string(data) == content { - _ = os.Chtimes(target, mTime, mTime) - st.mu.Lock() - st.rec.Skipped++ - st.mu.Unlock() - st.touchProgress() - return nil - } - if err := os.MkdirAll(filepath.Dir(target), 0o755); err != nil { - return nil - } - tmp := target + ".tmp" - if err := os.WriteFile(tmp, []byte(content), 0o644); err == nil { - _ = os.Rename(tmp, target) - _ = os.Chtimes(target, mTime, mTime) - } else { - _ = os.Remove(tmp) - } - st.mu.Lock() - st.rec.NewStrm++ - st.mu.Unlock() - st.touchProgress() + entry := cloud.FileEntry{Name: filepath.Base(rel), Size: info.Size(), MTime: info.ModTime().Unix()} + st.handleVideo(entry, rel, ext) return nil }) } diff --git a/internal/service/strm_sync_test.go b/internal/service/strm_sync_test.go index 3861c1b..1b016f1 100644 --- a/internal/service/strm_sync_test.go +++ b/internal/service/strm_sync_test.go @@ -1570,3 +1570,84 @@ func TestScanLocalMetaForUploadRequeuesWhenRecentDoneSizeChanged(t *testing.T) { t.Fatal("expected a pending upload task for resized local file") } } + +func TestHandleVideoPreferPicksLargestAndSkipsOthers(t *testing.T) { + svc := testStrmService(t) + local := t.TempDir() + p := syncPathRecord(t, svc, model.StrmProviderLocal, t.TempDir(), local, true) + p.KeepExt = false + st := &strmSyncState{ + s: svc, + ctx: context.Background(), + p: p, + cfg: &strmPathConfig{BaseURL: "http://test.local:8096", VideoExt: csvSplit(StrmDefaultVideoExt), AddPath: 1, KeepExt: false}, + rec: &model.StrmSyncRecord{}, + syncType: model.StrmSyncTypeFull, + seenVideo: map[string]bool{}, + seenVideoTarget: map[string]cloud.FileEntry{}, + remoteVideos: map[string][]remoteVideoCandidate{}, + } + st.handleVideo(cloud.FileEntry{ID: "1", Name: "竞女01.mp4", Size: 100, PickCode: "pc-mp4", MTime: 1000}, "竞女01.mp4", ".mp4") + st.handleVideo(cloud.FileEntry{ID: "2", Name: "竞女01.mkv", Size: 500, PickCode: "pc-mkv", MTime: 900}, "竞女01.mkv", ".mkv") + st.flushPreferredVideos() + + preferPath := filepath.Join(local, "竞女01.strm") + if _, err := os.Stat(preferPath); err != nil { + t.Fatalf("expected prefer strm at %s: %v", preferPath, err) + } + data, err := os.ReadFile(preferPath) + if err != nil { + t.Fatal(err) + } + content := string(data) + if !strings.Contains(content, "video.mkv") { + t.Fatalf("prefer should pick larger mkv, content=%s", content) + } + if !strings.Contains(content, "%E7%AB%9E%E5%A5%B301.mkv") && !strings.Contains(content, "竞女01.mkv") { + t.Fatalf("prefer strm should point at winner path, content=%s", content) + } + if _, err := os.Stat(filepath.Join(local, "竞女01.mkv.strm")); !os.IsNotExist(err) { + t.Fatal("prefer mode must not write keep_ext names") + } + if st.rec.NewStrm != 1 { + t.Fatalf("NewStrm=%d want 1", st.rec.NewStrm) + } + if st.rec.Skipped < 1 { + t.Fatalf("Skipped=%d want >=1 for loser", st.rec.Skipped) + } +} + +func TestHandleVideoKeepExtWritesAllVersions(t *testing.T) { + svc := testStrmService(t) + local := t.TempDir() + p := syncPathRecord(t, svc, model.StrmProviderLocal, t.TempDir(), local, true) + p.KeepExt = true + st := &strmSyncState{ + s: svc, + ctx: context.Background(), + p: p, + cfg: &strmPathConfig{BaseURL: "http://test.local:8096", VideoExt: csvSplit(StrmDefaultVideoExt), AddPath: 1, KeepExt: true}, + rec: &model.StrmSyncRecord{}, + syncType: model.StrmSyncTypeFull, + seenVideo: map[string]bool{}, + seenVideoTarget: map[string]cloud.FileEntry{}, + remoteVideos: map[string][]remoteVideoCandidate{}, + } + st.handleVideo(cloud.FileEntry{ID: "1", Name: "竞女01.mp4", Size: 100, PickCode: "pc-mp4"}, "竞女01.mp4", ".mp4") + st.handleVideo(cloud.FileEntry{ID: "2", Name: "竞女01.mkv", Size: 500, PickCode: "pc-mkv"}, "竞女01.mkv", ".mkv") + st.flushPreferredVideos() + + mkvPath := filepath.Join(local, "竞女01.mkv.strm") + mp4Path := filepath.Join(local, "竞女01.mp4.strm") + for _, path := range []string{mkvPath, mp4Path} { + if _, err := os.Stat(path); err != nil { + t.Fatalf("expected keep_ext strm %s: %v", path, err) + } + } + if _, err := os.Stat(filepath.Join(local, "竞女01.strm")); !os.IsNotExist(err) { + t.Fatal("keep_ext mode must not write stripped name.strm") + } + if st.rec.NewStrm != 2 { + t.Fatalf("NewStrm=%d want 2", st.rec.NewStrm) + } +} diff --git a/internal/service/subtitle.go b/internal/service/subtitle.go index 67a8831..ce9eebf 100644 --- a/internal/service/subtitle.go +++ b/internal/service/subtitle.go @@ -142,7 +142,10 @@ func (s *SubtitleService) discoverUncached(ctx context.Context, mediaID string) return nil, errors.New("media not found") } dir := filepath.Dir(m.Path) - base := strings.TrimSuffix(filepath.Base(m.Path), filepath.Ext(m.Path)) + bases := mediaSidecarBaseVariants(m.Path) + if len(bases) == 0 { + bases = []string{strings.TrimSuffix(filepath.Base(m.Path), filepath.Ext(m.Path))} + } candidates := make([]string, 0, 16) candidates = append(candidates, dir) @@ -166,13 +169,23 @@ func (s *SubtitleService) discoverUncached(ctx context.Context, mediaID string) continue } fullName := strings.TrimSuffix(e.Name(), ext) - if !strings.HasPrefix(strings.ToLower(fullName), strings.ToLower(base)) && - c == dir { - // In the same directory we require a basename match; - // inside subs/ subdirs we accept anything. - continue + matchedBase := "" + if c == dir { + for _, base := range bases { + if strings.HasPrefix(strings.ToLower(fullName), strings.ToLower(base)) { + matchedBase = base + break + } + } + if matchedBase == "" { + // In the same directory we require a basename match; + // inside subs/ subdirs we accept anything. + continue + } + } else if len(bases) > 0 { + matchedBase = bases[0] } - lang := detectLang(fullName, base) + lang := detectLang(fullName, matchedBase) tracks = append(tracks, SubtitleTrack{ Lang: lang, Label: lang, diff --git a/web/src/components/MediaVersionSwitcher.tsx b/web/src/components/MediaVersionSwitcher.tsx new file mode 100644 index 0000000..d6ae415 --- /dev/null +++ b/web/src/components/MediaVersionSwitcher.tsx @@ -0,0 +1,72 @@ +import { Layers } from 'lucide-react' +import { Link } from 'react-router-dom' + +import type { Media } from '../types' +import { mediaVersionLabel, mediaVersionsOf } from '../utils/mediaVersion' + +type MediaVersionSwitcherProps = { + media: Media + /** 详情页:用 Link 跳转播放;播放页:回调切换 */ + mode?: 'detail' | 'player' + onSelect?: (version: Media) => void + className?: string +} + +export function MediaVersionSwitcher({ + media, + mode = 'detail', + onSelect, + className = '', +}: MediaVersionSwitcherProps) { + const versions = mediaVersionsOf(media) + if (versions.length <= 1) return null + const dark = mode === 'player' + + return ( +
+
+ + 版本({versions.length}) +
+
+ {versions.map((version) => { + const active = version.id === media.id + const label = mediaVersionLabel(version) + const className = dark + ? active + ? 'border-white/40 bg-white/20 text-white' + : 'border-white/15 bg-white/5 text-white/80 hover:bg-white/15' + : active + ? 'border-brand-500/40 bg-brand-50 text-[#b07d35]' + : 'border-gray-200 bg-white text-ink-100 hover:border-brand-500/30 hover:bg-brand-50/40' + if (mode === 'player') { + return ( + + ) + } + return ( + + {label} + {active ? ' · 当前' : ''} + + ) + })} +
+
+ ) +} diff --git a/web/src/pages/MediaDetailPageSections.tsx b/web/src/pages/MediaDetailPageSections.tsx index d8a885e..8ff0dd0 100644 --- a/web/src/pages/MediaDetailPageSections.tsx +++ b/web/src/pages/MediaDetailPageSections.tsx @@ -5,6 +5,7 @@ import { PageBackButton } from '../components/PageBackButton' import { ExternalPlayerButton } from '../components/ExternalPlayerButton' import { ManualScrapeDialog } from '../components/ManualScrapeDialog' +import { MediaVersionSwitcher } from '../components/MediaVersionSwitcher' import { MetadataEditDialog } from '../components/MetadataEditDialog' import { OrganizeMediaDialog } from '../components/OrganizeMediaDialog' import type { Media } from '../types' @@ -163,6 +164,7 @@ export function MediaDetailMainContent({ onToggleFavourite={onToggleFavourite} playTargetId={playTargetId} /> + {isAdmin && ( mediaVersionsOf(media), [media]) + const switchVersion = useCallback( + (version: Media) => { + if (!version?.id || version.id === media?.id) return + playEpisode(version) + }, + [media?.id, playEpisode], + ) + const handlePrevEpisode = useCallback(() => { if (prevEpisode) { playEpisode(prevEpisode) @@ -575,6 +586,13 @@ export function PlayerPage() { onBack={goBack} onToggleMode={toggleMode} /> + {versionList.length > 1 && ( +
+
+ +
+
+ )} { + if (existing) return + strmAPI + .getSettings() + .then((settings) => { + const keepExt = settings['strm.keep_ext'] + if (keepExt === 'true' || keepExt === '1') { + setForm((f) => ({ ...f, keep_ext: true })) + } + }) + .catch(() => undefined) + }, [existing]) + const set = (key: K, value: StrmSyncPathInput[K]) => setForm((f) => ({ ...f, [key]: value })) @@ -917,6 +937,11 @@ export function StrmSyncPathDialog({ set('download_meta', v)} /> set('upload_meta', v)} /> set('delete_dir', v)} /> + set('keep_ext', v)} + /> set('enable_cron', v)} /> set('enabled', v)} /> diff --git a/web/src/types/strm.ts b/web/src/types/strm.ts index 5d46973..edd2ffd 100644 --- a/web/src/types/strm.ts +++ b/web/src/types/strm.ts @@ -70,6 +70,7 @@ export interface StrmSyncPath { download_meta: boolean upload_meta: boolean delete_dir: boolean + keep_ext: boolean cron: string enable_cron: boolean sync_mode?: 'incremental' | 'full' @@ -98,6 +99,7 @@ export interface StrmSyncPathInput { download_meta?: boolean upload_meta?: boolean delete_dir?: boolean + keep_ext?: boolean cron?: string enable_cron?: boolean sync_mode?: 'incremental' | 'full' @@ -167,6 +169,7 @@ export interface StrmSettingsMap { 'strm.download_meta': string 'strm.upload_meta': string 'strm.delete_dir': string + 'strm.keep_ext': string 'strm.download_threads': string 'strm.upload_threads': string [key: string]: string diff --git a/web/src/utils/mediaVersion.ts b/web/src/utils/mediaVersion.ts new file mode 100644 index 0000000..dba99d1 --- /dev/null +++ b/web/src/utils/mediaVersion.ts @@ -0,0 +1,60 @@ +import type { Media } from '../types' + +/** 从路径 / strm URL 推断容器扩展名(mkv/mp4…)。 */ +export function mediaVersionContainer(media: Media): string { + const path = (media.path || '').replace(/\\/g, '/') + const base = path.split('/').pop() || '' + const ext = base.includes('.') ? base.slice(base.lastIndexOf('.')).toLowerCase() : '' + let name = ext ? base.slice(0, -ext.length) : base + if (ext === '.strm') { + const second = name.includes('.') ? name.slice(name.lastIndexOf('.')).toLowerCase() : '' + if (second && second !== '.strm') { + return second.replace(/^\./, '') + } + const strm = (media.strm_url || '').toLowerCase() + const marker = '/video.' + const idx = strm.lastIndexOf(marker) + if (idx >= 0) { + let rest = strm.slice(idx + marker.length) + const end = rest.search(/[?#&/]/) + if (end >= 0) rest = rest.slice(0, end) + rest = rest.replace(/^\./, '').trim() + if (rest) return rest + } + return 'strm' + } + return ext.replace(/^\./, '') +} + +function formatSize(bytes: number): string { + if (!bytes || bytes < 1024) return bytes ? `${bytes} B` : '' + const units = ['KB', 'MB', 'GB', 'TB'] + let value = bytes + let unit = -1 + do { + value /= 1024 + unit += 1 + } while (value >= 1024 && unit < units.length - 1) + return `${value.toFixed(1)} ${units[unit]}` +} + +/** 版本切换展示名:分辨率 · 容器 · 编码 · 体积,缺省回退文件名。 */ +export function mediaVersionLabel(media: Media): string { + const parts: string[] = [] + if (media.height > 0) parts.push(`${media.height}p`) + else if (media.width > 0) parts.push(`${media.width}w`) + const container = mediaVersionContainer(media) + if (container && container !== 'strm') parts.push(container.toUpperCase()) + if (media.video_codec?.trim()) parts.push(media.video_codec.trim().toUpperCase()) + const size = formatSize(media.size_bytes) + if (size) parts.push(size) + if (parts.length > 0) return parts.join(' · ') + const base = (media.path || '').replace(/\\/g, '/').split('/').pop() + return base || media.title || media.id +} + +export function mediaVersionsOf(media: Media | null | undefined): Media[] { + if (!media) return [] + if (media.versions && media.versions.length > 1) return media.versions + return [media] +}