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]
+}