feat(strm): 同名多版本择优 / keep_ext,并支持合并展示与播放切换 (#30)

* feat(strm): 同名多版本择优/keep_ext,并支持合并展示与播放切换

默认对同名不同扩展(如 .mkv/.mp4)按体积→mtime→扩展名优先级择优生成一条 strm,冲突打日志;开启 keep_ext 则分别生成 name.ext.strm。媒体详情与播放器可切换版本,列表继续合并同片多版本,Emby MediaSources 名称区分容器/分辨率。

Co-authored-by: truewhile <truewhile@users.noreply.github.com>

* 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 <truewhile@users.noreply.github.com>

* fix(emby): People NFO 按共享词干匹配 keep_ext strm

resolveMediaPeople 改为查找竞女01.nfo(并兼容旧的竞女01.mkv.nfo),
同时修正把 decodeNFOFile 的 partial 误当 ok 导致完整 NFO 被跳过的问题。

Co-authored-by: truewhile <truewhile@users.noreply.github.com>

---------

Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: truewhile <truewhile@users.noreply.github.com>
This commit is contained in:
truewhile
2026-09-07 13:56:30 +08:00
committed by GitHub
parent 6bf8a6878a
commit 38b85bb487
32 changed files with 1045 additions and 167 deletions
+2 -2
View File
@@ -488,7 +488,7 @@ func getMediaHandler(svc *service.Container) gin.HandlerFunc {
c.JSON(http.StatusOK, m) c.JSON(http.StatusOK, m)
return return
} }
m, err := svc.Media.GetMedia(ctx, id) m, err := svc.Media.GetMediaItem(ctx, id)
if err != nil { if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return return
@@ -497,7 +497,7 @@ func getMediaHandler(svc *service.Container) gin.HandlerFunc {
c.JSON(http.StatusNotFound, gin.H{"error": "not found"}) c.JSON(http.StatusNotFound, gin.H{"error": "not found"})
return return
} }
if !mediaVisibleForRequest(c, svc, m) { if !mediaVisibleForRequest(c, svc, &m.Media) {
c.JSON(http.StatusNotFound, gin.H{"error": "not found"}) c.JSON(http.StatusNotFound, gin.H{"error": "not found"})
return return
} }
+2
View File
@@ -232,6 +232,7 @@ type strmSyncPathReq struct {
DownloadMeta *bool `json:"download_meta"` DownloadMeta *bool `json:"download_meta"`
UploadMeta *bool `json:"upload_meta"` UploadMeta *bool `json:"upload_meta"`
DeleteDir *bool `json:"delete_dir"` DeleteDir *bool `json:"delete_dir"`
KeepExt *bool `json:"keep_ext"`
Cron string `json:"cron"` Cron string `json:"cron"`
EnableCron *bool `json:"enable_cron"` EnableCron *bool `json:"enable_cron"`
SyncMode string `json:"sync_mode"` SyncMode string `json:"sync_mode"`
@@ -703,6 +704,7 @@ func strmSyncPathFromReq(req strmSyncPathReq) *model.StrmSyncPath {
DownloadMeta: boolValue(req.DownloadMeta, true), DownloadMeta: boolValue(req.DownloadMeta, true),
UploadMeta: boolValue(req.UploadMeta, false), UploadMeta: boolValue(req.UploadMeta, false),
DeleteDir: boolValue(req.DeleteDir, false), DeleteDir: boolValue(req.DeleteDir, false),
KeepExt: boolValue(req.KeepExt, false),
Cron: strings.TrimSpace(req.Cron), Cron: strings.TrimSpace(req.Cron),
EnableCron: boolValue(req.EnableCron, false), EnableCron: boolValue(req.EnableCron, false),
SyncMode: strings.TrimSpace(req.SyncMode), SyncMode: strings.TrimSpace(req.SyncMode),
+3
View File
@@ -51,6 +51,9 @@ type StrmSyncPath struct {
DownloadMeta bool `gorm:"default:true" json:"download_meta"` // 同步时下载元数据文件(nfo/图片/字幕) DownloadMeta bool `gorm:"default:true" json:"download_meta"` // 同步时下载元数据文件(nfo/图片/字幕)
UploadMeta bool `json:"upload_meta"` // 同步时把本地元数据上传到远端 UploadMeta bool `json:"upload_meta"` // 同步时把本地元数据上传到远端
DeleteDir bool `json:"delete_dir"` // 清理多余文件时删除空目录 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 表达式(可选) Cron string `gorm:"size:128" json:"cron"` // 5 段 cron 表达式(可选)
EnableCron bool `json:"enable_cron"` // 是否按 Cron 定时同步 EnableCron bool `json:"enable_cron"` // 是否按 Cron 定时同步
SyncMode string `gorm:"size:32;default:'incremental'" json:"sync_mode"` // 默认同步模式:incremental / full SyncMode string `gorm:"size:32;default:'incremental'" json:"sync_mode"` // 默认同步模式:incremental / full
+1
View File
@@ -110,6 +110,7 @@ func (r *StrmSyncPathRepository) Update(ctx context.Context, p *model.StrmSyncPa
"download_meta": p.DownloadMeta, "download_meta": p.DownloadMeta,
"upload_meta": p.UploadMeta, "upload_meta": p.UploadMeta,
"delete_dir": p.DeleteDir, "delete_dir": p.DeleteDir,
"keep_ext": p.KeepExt,
"cron": p.Cron, "cron": p.Cron,
"enable_cron": p.EnableCron, "enable_cron": p.EnableCron,
"sync_mode": p.SyncMode, "sync_mode": p.SyncMode,
+5 -18
View File
@@ -369,7 +369,10 @@ func (s *DanmakuService) searchTerms(ctx context.Context, mediaID string) (danma
} else if name := strings.TrimSpace(m.Title); name != "" { } else if name := strings.TrimSpace(m.Title); name != "" {
term.name = name term.name = name
} else { } 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 { if m.EpisodeNum > 0 {
term.episode = strconv.Itoa(m.EpisodeNum) 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 // ("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). // (filepath.Ext would misread names like "xxx.第01话" as having an extension).
func danmakuMatchFileName(path string) string { func danmakuMatchFileName(path string) string {
if path == "" { return mediaSidecarBase(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)
} }
// mediaHash returns the dandanplay match hash (MD5 of the first 16MB of the // mediaHash returns the dandanplay match hash (MD5 of the first 16MB of the
+22 -8
View File
@@ -538,21 +538,35 @@ func (e *EmbyService) resolveMediaPeople(ctx context.Context, m *model.Media) []
return []map[string]any{} return []map[string]any{}
} }
dir := filepath.Dir(m.Path) dir := filepath.Dir(m.Path)
ext := filepath.Ext(m.Path) candidates := make([]string, 0, 6)
base := strings.TrimSuffix(m.Path, ext) seenPath := map[string]struct{}{}
candidates := []string{ add := func(path string) {
base + ".nfo", path = strings.TrimSpace(path)
filepath.Join(dir, "movie.nfo"), if path == "" {
filepath.Join(dir, "tvshow.nfo"), 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) people := make([]map[string]any, 0)
seen := make(map[string]bool) seen := make(map[string]bool)
for _, p := range candidates { for _, p := range candidates {
if fi, err := os.Stat(p); err == nil && !fi.IsDir() { if fi, err := os.Stat(p); err == nil && !fi.IsDir() {
doc, ok, err := decodeNFOFile(p) doc, _, err := decodeNFOFile(p)
if err == nil && ok && doc != nil { if err == nil && doc != nil {
for _, d := range doc.Directors { for _, d := range doc.Directors {
name := strings.TrimSpace(d) name := strings.TrimSpace(d)
if name == "" { if name == "" {
+57
View File
@@ -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 := `<movie>
<title>竞女01</title>
<director>导演甲</director>
<actor><name>演员乙</name><role>主角</role></actor>
</movie>`
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(`<movie><director>旧导演</director></movie>`), 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)
}
}
+1 -1
View File
@@ -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 { 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{ return map[string]any{
"Id": m.ID, "Id": m.ID,
"Name": m.Title, "Name": MediaVersionLabel(*m),
"Path": embyMediaSourcePath(m), "Path": embyMediaSourcePath(m),
"Container": container, "Container": container,
"Size": m.SizeBytes, "Size": m.SizeBytes,
+8 -2
View File
@@ -41,7 +41,10 @@ var (
// ParseEpisode tries to extract (season, episode) from an arbitrary filename. // ParseEpisode tries to extract (season, episode) from an arbitrary filename.
// Returns (0, 0) when nothing recognisable is found. // Returns (0, 0) when nothing recognisable is found.
func ParseEpisode(path string) (season, episode int) { 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 { if m := patSEnE.FindStringSubmatch(name); len(m) == 3 {
season = mustAtoi(m[1]) season = mustAtoi(m[1])
@@ -96,7 +99,10 @@ type episodeRef struct {
} }
func episodeRefsFromTitle(path string) []episodeRef { 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 { if refs := parseSEpisodeRange(name); len(refs) > 0 {
return refs return refs
} }
+48 -28
View File
@@ -54,39 +54,59 @@ func mergeArtworkMetadata(meta *LocalMetadata, mediaPath, showBaseDir string) {
} }
func localPosterCandidates(mediaPath string) []string { func localPosterCandidates(mediaPath string) []string {
base := strings.TrimSuffix(filepath.Base(mediaPath), filepath.Ext(mediaPath)) names := make([]string, 0, 24)
names := []string{ seen := map[string]struct{}{}
base + "-poster", add := func(name string) {
base + ".poster", name = strings.TrimSpace(name)
"poster", if name == "" {
"folder", return
"cover", }
"movie", key := strings.ToLower(name)
"show", if _, ok := seen[key]; ok {
base + "-cover", return
base + ".cover", }
base, seen[key] = struct{}{}
base + "-thumb", names = append(names, name)
base + ".thumb", }
"thumb", 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...) return append(adultArtworkNameCandidates(mediaPath, "poster"), names...)
} }
func localBackdropCandidates(mediaPath string) []string { func localBackdropCandidates(mediaPath string) []string {
base := strings.TrimSuffix(filepath.Base(mediaPath), filepath.Ext(mediaPath)) names := make([]string, 0, 24)
names := []string{ seen := map[string]struct{}{}
base + "-fanart", add := func(name string) {
base + ".fanart", name = strings.TrimSpace(name)
base + "-backdrop", if name == "" {
base + ".backdrop", return
base + "-background", }
"fanart", key := strings.ToLower(name)
"backdrop", if _, ok := seen[key]; ok {
"background", return
"landscape", }
"banner", seen[key] = struct{}{}
"clearart", 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...) return append(adultArtworkNameCandidates(mediaPath, "backdrop"), names...)
} }
+4 -5
View File
@@ -28,13 +28,12 @@ func ReadLocalMetadata(mediaPath, libraryRoot string, seriesLike bool) (*LocalMe
func findMovieNFO(mediaPath, libraryRoot string) (*nfoDocument, string, error) { func findMovieNFO(mediaPath, libraryRoot string) (*nfoDocument, string, error) {
mediaDir := filepath.Dir(mediaPath) mediaDir := filepath.Dir(mediaPath)
base := strings.TrimSuffix(filepath.Base(mediaPath), filepath.Ext(mediaPath))
adultCode := AdultCodeFromMediaPath(mediaPath) adultCode := AdultCodeFromMediaPath(mediaPath)
names := []string{ names := make([]string, 0, 8)
base + ".nfo", for _, base := range mediaSidecarBaseVariants(mediaPath) {
"movie.nfo", names = append(names, base+".nfo")
filepath.Base(mediaDir) + ".nfo",
} }
names = append(names, "movie.nfo", filepath.Base(mediaDir)+".nfo")
if adultCode != "" { if adultCode != "" {
names = append([]string{adultCode + ".nfo", strings.ReplaceAll(adultCode, "-", "") + ".nfo"}, names...) names = append([]string{adultCode + ".nfo", strings.ReplaceAll(adultCode, "-", "") + ".nfo"}, names...)
} }
+62
View File
@@ -2,6 +2,8 @@ package service
import ( import (
"context" "context"
"sort"
"strings"
"time" "time"
"go.uber.org/zap" "go.uber.org/zap"
@@ -111,3 +113,63 @@ func (s *MediaService) GetMedia(ctx context.Context, id string) (*model.Media, e
*media = items[0] *media = items[0]
return media, nil 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
}
+62
View File
@@ -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
}
+107
View File
@@ -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, `<movie><title>竞女01</title></movie>`)
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)
}
}
+118
View File
@@ -2,6 +2,7 @@ package service
import ( import (
"fmt" "fmt"
"path/filepath"
"sort" "sort"
"strings" "strings"
@@ -179,6 +180,7 @@ func mediaVersionGroupKey(m model.Media) string {
} }
return "thetvdb:" + strings.ToLower(strings.TrimSpace(m.TheTVDBID)) return "thetvdb:" + strings.ToLower(strings.TrimSpace(m.TheTVDBID))
} }
title := firstNonEmpty(m.OriginalName, m.Title) title := firstNonEmpty(m.OriginalName, m.Title)
titleYear := 0 titleYear := 0
if title == "" { if title == "" {
@@ -187,6 +189,10 @@ func mediaVersionGroupKey(m model.Media) string {
title, titleYear = mediaVersionTitleKey(title) title, titleYear = mediaVersionTitleKey(title)
} }
if title == "" { if title == "" {
// 无标题时退回同目录词干(覆盖 keep_ext 的 name.mkv.strm / name.mp4.strm)
if stemKey := mediaVersionStemGroupKey(m, libKey); stemKey != "" {
return stemKey
}
return "" return ""
} }
year := m.Year year := m.Year
@@ -202,6 +208,114 @@ func mediaVersionGroupKey(m model.Media) string {
return fmt.Sprintf("movie:%s:%d", title, year) 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) { func mediaVersionTitleKey(value string) (string, int) {
cleaned, year := CleanQuery(value) cleaned, year := CleanQuery(value)
if strings.TrimSpace(cleaned) == "" { if strings.TrimSpace(cleaned) == "" {
@@ -232,6 +346,10 @@ func normalizeMediaVersionText(value string) string {
if _, noise := noiseTokenSet[field]; noise { if _, noise := noiseTokenSet[field]; noise {
continue continue
} }
// 去掉视频容器词干残留(keep_ext / 旧标题「竞女01 mkv」)
if _, ok := videoExtensions["."+field]; ok {
continue
}
out = append(out, field) out = append(out, field)
} }
return strings.Join(out, " ") return strings.Join(out, " ")
+41
View File
@@ -1,6 +1,7 @@
package service package service
import ( import (
"strings"
"testing" "testing"
"time" "time"
@@ -264,3 +265,43 @@ func TestGroupMediaVersionsDoesNotMergeAcrossDifferentLibrariesForMovies(t *test
t.Fatalf("expected 2 separate groups for movies in different libraries, got %d", len(grouped)) 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)
}
}
+4 -1
View File
@@ -115,7 +115,10 @@ func (s *NFOService) ExportLibrary(ctx context.Context, libraryID string) (int,
func nfoPath(media string) string { func nfoPath(media string) string {
dir := filepath.Dir(media) 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)) return filepath.Join(dir, fmt.Sprintf("%s.nfo", base))
} }
@@ -278,16 +278,21 @@ func randomSuffix() string {
// moveStagedArtwork renames artwork sidecars staged alongside `stage` into // moveStagedArtwork renames artwork sidecars staged alongside `stage` into
// their final names next to `dst`. // their final names next to `dst`.
func moveStagedArtwork(stage, dst string) { func moveStagedArtwork(stage, dst string) {
stageBase := strings.TrimSuffix(filepath.Base(stage), filepath.Ext(stage)) stageBases := mediaSidecarBaseVariants(stage)
dstBase := strings.TrimSuffix(filepath.Base(dst), filepath.Ext(dst)) dstBase := mediaSidecarBase(dst)
if dstBase == "" {
dstBase = strings.TrimSuffix(filepath.Base(dst), filepath.Ext(dst))
}
stageDir := filepath.Dir(stage) stageDir := filepath.Dir(stage)
for _, suffix := range artworkSidecarSuffixes { for _, stageBase := range stageBases {
for _, ext := range artworkSidecarExtensions { for _, suffix := range artworkSidecarSuffixes {
srcPath := filepath.Join(stageDir, stageBase+suffix+ext) for _, ext := range artworkSidecarExtensions {
if _, err := os.Stat(srcPath); err != nil { srcPath := filepath.Join(stageDir, stageBase+suffix+ext)
continue 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 // removeStagedArtwork removes artwork sidecars that were staged alongside
// `stage`, used when a replace fails and its staged outputs must be cleaned up. // `stage`, used when a replace fails and its staged outputs must be cleaned up.
func removeStagedArtwork(stage string) { func removeStagedArtwork(stage string) {
stageBase := strings.TrimSuffix(filepath.Base(stage), filepath.Ext(stage)) stageBases := mediaSidecarBaseVariants(stage)
stageDir := filepath.Dir(stage) stageDir := filepath.Dir(stage)
for _, suffix := range artworkSidecarSuffixes { for _, stageBase := range stageBases {
for _, ext := range artworkSidecarExtensions { for _, suffix := range artworkSidecarSuffixes {
path := filepath.Join(stageDir, stageBase+suffix+ext) for _, ext := range artworkSidecarExtensions {
_ = os.Remove(path) _ = os.Remove(filepath.Join(stageDir, stageBase+suffix+ext))
}
} }
} }
} }
+43 -20
View File
@@ -51,23 +51,25 @@ var artworkSidecarExtensions = []string{".jpg", ".jpeg", ".png", ".webp", ".gif"
// behind in the old folder; this keeps artwork with the organized file. // behind in the old folder; this keeps artwork with the organized file.
func transferSidecarArtwork(srcMedia, dstMedia string, mode TransferMode) error { func transferSidecarArtwork(srcMedia, dstMedia string, mode TransferMode) error {
srcDir := filepath.Dir(srcMedia) srcDir := filepath.Dir(srcMedia)
base := strings.TrimSuffix(filepath.Base(srcMedia), filepath.Ext(srcMedia)) bases := mediaSidecarBaseVariants(srcMedia)
if base == "" || base == "." { if len(bases) == 0 {
return nil return nil
} }
// Find every existing sidecar by probing suffix + extension combinations. // 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{}{} seen := map[string]struct{}{}
for _, suffix := range artworkSidecarSuffixes { for _, base := range bases {
for _, ext := range artworkSidecarExtensions { for _, suffix := range artworkSidecarSuffixes {
path := filepath.Join(srcDir, base+suffix+ext) for _, ext := range artworkSidecarExtensions {
key := strings.ToLower(filepath.Clean(path)) path := filepath.Join(srcDir, base+suffix+ext)
if _, ok := seen[key]; ok { key := strings.ToLower(filepath.Clean(path))
continue if _, ok := seen[key]; ok {
} continue
seen[key] = struct{}{} }
if _, err := os.Stat(path); err == nil { seen[key] = struct{}{}
sources = append(sources, path) 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. if err := os.MkdirAll(dstDir, 0o755); err != nil { // #nosec G301 -- sidecar media directories must remain readable by NAS/player users.
return err return err
} }
var firstErr error dstBase := mediaSidecarBase(dstMedia)
if dstBase == "" {
dstBase = strings.TrimSuffix(filepath.Base(dstMedia), filepath.Ext(dstMedia))
}
for _, src := range sources { for _, src := range sources {
dst := filepath.Join(dstDir, filepath.Base(src)) name := filepath.Base(src)
if _, err := os.Stat(dst); err == nil { // Remap any legacy "Title.mkv-poster.jpg" onto the shared destination stem.
continue // never clobber an existing artwork at the destination 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 { dstName := name
firstErr = err 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
} }
+4 -1
View File
@@ -138,7 +138,10 @@ type localScanMediaInput struct {
func (s *ScannerService) buildLocalScanMedia(in localScanMediaInput) *model.Media { func (s *ScannerService) buildLocalScanMedia(in localScanMediaInput) *model.Media {
title, year := CleanQueryWithRecognition(context.Background(), s.repo, in.path) title, year := CleanQueryWithRecognition(context.Background(), s.repo, in.path)
if title == "" { 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) title, year = preferISOParentScrapeIdentity(in.path, in.lib.Path, title, year)
+6
View File
@@ -75,6 +75,12 @@ func CleanQuery(raw string) (title string, year int) {
base = strings.TrimSpace(raw) base = strings.TrimSpace(raw)
} }
name := strings.TrimSuffix(base, filepath.Ext(base)) 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) lower := strings.ToLower(name)
if m := yearPattern.FindStringSubmatch(lower); len(m) >= 2 { if m := yearPattern.FindStringSubmatch(lower); len(m) >= 2 {
@@ -38,9 +38,9 @@ func (s *ScraperService) writeMediaArtworkFilesAfterScrape(ctx context.Context,
if dir == "" || dir == "." { if dir == "" || dir == "." {
return return
} }
// Scope sidecar names by the media file's base name (e.g. A.mp4 -> A-poster.jpg) // Scope sidecar names by the shared media stem (e.g. A.mkv.strm / A.mp4.strm -> A-poster.jpg)
// so that multiple movies sharing one directory (A.mp4 + B.mp4) never clash. // so multi-version files in one folder share artwork and never diverge by container.
base := strings.TrimSuffix(filepath.Base(refreshed.Path), filepath.Ext(refreshed.Path)) base := mediaSidecarBase(refreshed.Path)
if base == "" || base == "." { if base == "" || base == "." {
return return
} }
+4
View File
@@ -42,6 +42,7 @@ const (
StrmSettingDownloadMeta = "strm.download_meta" StrmSettingDownloadMeta = "strm.download_meta"
StrmSettingUploadMeta = "strm.upload_meta" StrmSettingUploadMeta = "strm.upload_meta"
StrmSettingDeleteDir = "strm.delete_dir" StrmSettingDeleteDir = "strm.delete_dir"
StrmSettingKeepExt = "strm.keep_ext"
StrmSettingDownloadThreads = "strm.download_threads" StrmSettingDownloadThreads = "strm.download_threads"
StrmSettingUploadThreads = "strm.upload_threads" StrmSettingUploadThreads = "strm.upload_threads"
) )
@@ -69,6 +70,7 @@ var StrmSettingDefs = map[string]struct {
StrmSettingDownloadMeta: {Default: "true", Label: "下载元数据", Kind: "bool", Help: "同步时把远端 nfo/图片/字幕下载到本地输出目录"}, StrmSettingDownloadMeta: {Default: "true", Label: "下载元数据", Kind: "bool", Help: "同步时把远端 nfo/图片/字幕下载到本地输出目录"},
StrmSettingUploadMeta: {Default: "false", Label: "上传元数据", Kind: "bool", Help: "同步时把本地元数据上传到远端;本地与网盘元数据不同时以本地为准覆盖(需网盘支持写入)"}, StrmSettingUploadMeta: {Default: "false", Label: "上传元数据", Kind: "bool", Help: "同步时把本地元数据上传到远端;本地与网盘元数据不同时以本地为准覆盖(需网盘支持写入)"},
StrmSettingDeleteDir: {Default: "false", Label: "清理空目录", Kind: "bool", Help: "清理远端已删除的多余 .strm/元数据后,删除空目录"}, 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);不配置则中继授权不可用"}, 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)"}, StrmSettingDownloadThreads: {Default: "6", Label: "下载队列线程数", Kind: "number", Help: "OpenList/CloudDrive2 元数据下载并发数(115 独立限速为 3)"},
StrmSettingUploadThreads: {Default: "2", Label: "上传队列线程数", Kind: "number", Help: "元数据上传并发数"}, 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.DownloadMeta = p.DownloadMeta
cfg.UploadMeta = p.UploadMeta cfg.UploadMeta = p.UploadMeta
cfg.DeleteDir = p.DeleteDir cfg.DeleteDir = p.DeleteDir
cfg.KeepExt = p.KeepExt
cfg.BaseURL = strings.TrimRight(cfg.BaseURL, "/") cfg.BaseURL = strings.TrimRight(cfg.BaseURL, "/")
return cfg, nil return cfg, nil
} }
@@ -964,6 +967,7 @@ type strmPathConfig struct {
DownloadMeta bool DownloadMeta bool
UploadMeta bool UploadMeta bool
DeleteDir bool DeleteDir bool
KeepExt bool
} }
// ─── 本地目录浏览(添加同步目录用,兼容 Windows/Linux) ───────────────────────── // ─── 本地目录浏览(添加同步目录用,兼容 Windows/Linux) ─────────────────────────
+138 -58
View File
@@ -49,11 +49,13 @@ type strmSyncState struct {
mu sync.Mutex mu sync.Mutex
processed int // 已处理文件计数(用于定期落库进度) processed int // 已处理文件计数(用于定期落库进度)
lastProgressFlush time.Time // 上次进度落库时间 lastProgressFlush time.Time // 上次进度落库时间
seenVideo map[string]bool // "v:"+去掉扩展名的相对路径 → 远端存在该视频 seenVideo map[string]bool // "v:"+strm 去扩展名相对路径 → 远端存在该视频(供 prune)
seenMeta map[string]bool // "m:"+相对路径 → 远端存在该元数据 seenMeta map[string]bool // "m:"+相对路径 → 远端存在该元数据
remoteMeta map[string][]remoteMetaItem // "m:"+相对路径 → 远端元数据副本列表(多副本聚合,支持择优比对与冗余清理) remoteMeta map[string][]remoteMetaItem // "m:"+相对路径 → 远端元数据副本列表(多副本聚合,支持择优比对与冗余清理)
seenMetaTarget map[string]cloud.FileEntry seenMetaTarget map[string]cloud.FileEntry
seenVideoTarget map[string]cloud.FileEntry seenVideoTarget map[string]cloud.FileEntry
// remoteVideos:prefer 模式下同名(去扩展名)视频候选列表,walk 结束后择优写盘
remoteVideos map[string][]remoteVideoCandidate
activeDownloadPaths map[string]bool // 本地已在排队/进行的下载任务路径(内存去重) activeDownloadPaths map[string]bool // 本地已在排队/进行的下载任务路径(内存去重)
activeUploadPaths map[string]bool // 本地已在排队/进行的上传任务路径(内存去重) activeUploadPaths map[string]bool // 本地已在排队/进行的上传任务路径(内存去重)
// recentDoneUploadSizes:近期已成功上传的 local_path → size,缩短「done 但列表未到」窗口内的重复入队 // recentDoneUploadSizes:近期已成功上传的 local_path → size,缩短「done 但列表未到」窗口内的重复入队
@@ -67,6 +69,13 @@ type strmSyncState struct {
scanIncomplete atomic.Bool // 远端目录树/文件列表本次扫描不完整 → 禁止增量 prune 误删本地文件 scanIncomplete atomic.Bool // 远端目录树/文件列表本次扫描不完整 → 禁止增量 prune 误删本地文件
} }
// remoteVideoCandidate 记录同名视频的一个远端副本(不同扩展名/体积)。
type remoteVideoCandidate struct {
entry cloud.FileEntry
rel string
ext string
}
// StartSync 启动一次同步(异步执行,同一目录同时只允许一个任务)。 // StartSync 启动一次同步(异步执行,同一目录同时只允许一个任务)。
// syncType 支持 "incremental"(默认增量)和 "full"(全量同步)。 // syncType 支持 "incremental"(默认增量)和 "full"(全量同步)。
func (s *StrmService) StartSync(ctx context.Context, pathID string, syncType ...string) error { 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{}, remoteMeta: map[string][]remoteMetaItem{},
seenMetaTarget: map[string]cloud.FileEntry{}, seenMetaTarget: map[string]cloud.FileEntry{},
seenVideoTarget: map[string]cloud.FileEntry{}, seenVideoTarget: map[string]cloud.FileEntry{},
remoteVideos: map[string][]remoteVideoCandidate{},
} }
if p.Provider != model.StrmProviderLocal { if p.Provider != model.StrmProviderLocal {
acct, err := s.repo.StrmAccount.FindByID(ctx, p.AccountID) acct, err := s.repo.StrmAccount.FindByID(ctx, p.AccountID)
@@ -386,6 +396,7 @@ func (st *strmSyncState) run() error {
return err return err
} }
} }
st.flushPreferredVideos()
st.flushPendingDownloads() st.flushPendingDownloads()
st.flushProgress() st.flushProgress()
if st.cfg.UploadMeta && st.provider != nil { if st.cfg.UploadMeta && st.provider != nil {
@@ -1195,21 +1206,123 @@ func (st *strmSyncState) fetch115FilesAdaptive(ctx context.Context, open115 *clo
return allFiles, nil 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) { func (st *strmSyncState) handleVideo(entry cloud.FileEntry, rel, ext string) {
relSansExt := rel[:len(rel)-len(ext)] relSansExt := rel[:len(rel)-len(ext)]
st.mu.Lock() if st.cfg.KeepExt {
if st.seenVideo["v:"+relSansExt] { st.writeVideoStrm(entry, rel, ext, rel+".strm", true)
st.mu.Unlock()
return 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() st.mu.Unlock()
targetRel := relSansExt + ".strm"
target, err := joinLocalRel(st.p.LocalPath, targetRel) target, err := joinLocalRel(st.p.LocalPath, targetRel)
if err != nil { if err != nil {
st.s.log.Warn("strm target path out of root", zap.String("rel", targetRel), zap.Error(err)) st.s.log.Warn("strm target path out of root", zap.String("rel", targetRel), zap.Error(err))
if countProgress {
st.touchProgress()
}
return return
} }
@@ -1219,28 +1332,35 @@ func (st *strmSyncState) handleVideo(entry cloud.FileEntry, rel, ext string) {
} }
if _, exists := st.seenVideoTarget[target]; exists { if _, exists := st.seenVideoTarget[target]; exists {
st.mu.Unlock() st.mu.Unlock()
st.touchProgress() if countProgress {
st.touchProgress()
}
return return
} }
st.seenVideoTarget[target] = entry st.seenVideoTarget[target] = entry
st.mu.Unlock() st.mu.Unlock()
done := func() {
if countProgress {
st.touchProgress()
}
}
// 增量同步模式快速检查:本地 strm 文件存在、非空且修改时间与远端 mtime 一致,直接跳过无需读磁盘 // 增量同步模式快速检查:本地 strm 文件存在、非空且修改时间与远端 mtime 一致,直接跳过无需读磁盘
if st.syncType == model.StrmSyncTypeIncremental && entry.MTime > 0 { if st.syncType == model.StrmSyncTypeIncremental && entry.MTime > 0 {
if info, err := os.Stat(target); err == nil && info.Size() > 0 && info.ModTime().Unix() == entry.MTime { if info, err := os.Stat(target); err == nil && info.Size() > 0 && info.ModTime().Unix() == entry.MTime {
st.mu.Lock() st.mu.Lock()
st.rec.Skipped++ st.rec.Skipped++
st.mu.Unlock() st.mu.Unlock()
st.touchProgress() done()
return return
} }
} }
content, err := st.strmContent(entry, rel, ext) content, err := st.strmContent(entry, rel, ext)
if err != nil { if err != nil {
// 并发 worker 下 rec.Message 无锁写会有数据竞争,这里仅记录日志;
// 最终同步结果的 message 由 finishSync 统一填充。
st.s.log.Warn("build strm content failed", zap.String("file", rel), zap.Error(err)) st.s.log.Warn("build strm content failed", zap.String("file", rel), zap.Error(err))
done()
return return
} }
existing := "" existing := ""
@@ -1248,7 +1368,6 @@ func (st *strmSyncState) handleVideo(entry cloud.FileEntry, rel, ext string) {
existing = string(data) existing = string(data)
} }
if existing == content { if existing == content {
// 对齐本地 strm 修改时间为远端 mtime,便于后续秒级比对
if entry.MTime > 0 { if entry.MTime > 0 {
mTime := time.Unix(entry.MTime, 0) mTime := time.Unix(entry.MTime, 0)
_ = os.Chtimes(target, mTime, mTime) _ = os.Chtimes(target, mTime, mTime)
@@ -1256,21 +1375,24 @@ func (st *strmSyncState) handleVideo(entry cloud.FileEntry, rel, ext string) {
st.mu.Lock() st.mu.Lock()
st.rec.Skipped++ st.rec.Skipped++
st.mu.Unlock() st.mu.Unlock()
st.touchProgress() done()
return return
} }
if err := os.MkdirAll(filepath.Dir(target), 0o755); err != nil { 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)) st.s.log.Warn("mkdir strm dir failed", zap.String("dir", filepath.Dir(target)), zap.Error(err))
done()
return return
} }
tmp := target + ".tmp" tmp := target + ".tmp"
if err := os.WriteFile(tmp, []byte(content), 0o644); err != nil { 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)) st.s.log.Warn("write strm tmp failed", zap.String("file", target), zap.Error(err))
done()
return return
} }
if err := os.Rename(tmp, target); err != nil { if err := os.Rename(tmp, target); err != nil {
_ = os.Remove(tmp) _ = os.Remove(tmp)
st.s.log.Warn("rename strm failed", zap.String("file", target), zap.Error(err)) st.s.log.Warn("rename strm failed", zap.String("file", target), zap.Error(err))
done()
return return
} }
if entry.MTime > 0 { if entry.MTime > 0 {
@@ -1280,7 +1402,7 @@ func (st *strmSyncState) handleVideo(entry cloud.FileEntry, rel, ext string) {
st.mu.Lock() st.mu.Lock()
st.rec.NewStrm++ st.rec.NewStrm++
st.mu.Unlock() st.mu.Unlock()
st.touchProgress() done()
} }
// strmContent 构建 strm 文件内容(一行指向本服务播放端点的 URL)。 // strmContent 构建 strm 文件内容(一行指向本服务播放端点的 URL)。
@@ -1534,50 +1656,8 @@ func (st *strmSyncState) walkLocalSource() error {
st.touchProgress() st.touchProgress()
return nil return nil
} }
relSansExt := rel[:len(rel)-len(ext)] entry := cloud.FileEntry{Name: filepath.Base(rel), Size: info.Size(), MTime: info.ModTime().Unix()}
st.mu.Lock() st.handleVideo(entry, rel, ext)
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()
return nil return nil
}) })
} }
+81
View File
@@ -1570,3 +1570,84 @@ func TestScanLocalMetaForUploadRequeuesWhenRecentDoneSizeChanged(t *testing.T) {
t.Fatal("expected a pending upload task for resized local file") 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)
}
}
+20 -7
View File
@@ -142,7 +142,10 @@ func (s *SubtitleService) discoverUncached(ctx context.Context, mediaID string)
return nil, errors.New("media not found") return nil, errors.New("media not found")
} }
dir := filepath.Dir(m.Path) 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 := make([]string, 0, 16)
candidates = append(candidates, dir) candidates = append(candidates, dir)
@@ -166,13 +169,23 @@ func (s *SubtitleService) discoverUncached(ctx context.Context, mediaID string)
continue continue
} }
fullName := strings.TrimSuffix(e.Name(), ext) fullName := strings.TrimSuffix(e.Name(), ext)
if !strings.HasPrefix(strings.ToLower(fullName), strings.ToLower(base)) && matchedBase := ""
c == dir { if c == dir {
// In the same directory we require a basename match; for _, base := range bases {
// inside subs/ subdirs we accept anything. if strings.HasPrefix(strings.ToLower(fullName), strings.ToLower(base)) {
continue 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{ tracks = append(tracks, SubtitleTrack{
Lang: lang, Lang: lang,
Label: lang, Label: lang,
@@ -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 (
<div className={`space-y-2 ${className}`.trim()}>
<div className={`flex items-center gap-2 text-sm font-semibold ${dark ? 'text-white/90' : 'text-ink-600'}`}>
<Layers size={14} className={dark ? 'text-white/60' : 'text-sand-500'} />
<span>版本({versions.length})</span>
</div>
<div className="flex flex-wrap gap-2">
{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 (
<button
key={version.id}
type="button"
disabled={active}
onClick={() => onSelect?.(version)}
className={`rounded-xl border px-3 py-1.5 text-xs font-semibold transition disabled:cursor-default ${className}`}
title={version.path}
>
{label}
</button>
)
}
return (
<Link
key={version.id}
to={`/play/${version.id}`}
state={{ from: `/media/${media.id}` }}
className={`rounded-xl border px-3 py-1.5 text-xs font-semibold transition ${className}`}
title={version.path}
>
{label}
{active ? ' · 当前' : ''}
</Link>
)
})}
</div>
</div>
)
}
@@ -5,6 +5,7 @@ import { PageBackButton } from '../components/PageBackButton'
import { ExternalPlayerButton } from '../components/ExternalPlayerButton' import { ExternalPlayerButton } from '../components/ExternalPlayerButton'
import { ManualScrapeDialog } from '../components/ManualScrapeDialog' import { ManualScrapeDialog } from '../components/ManualScrapeDialog'
import { MediaVersionSwitcher } from '../components/MediaVersionSwitcher'
import { MetadataEditDialog } from '../components/MetadataEditDialog' import { MetadataEditDialog } from '../components/MetadataEditDialog'
import { OrganizeMediaDialog } from '../components/OrganizeMediaDialog' import { OrganizeMediaDialog } from '../components/OrganizeMediaDialog'
import type { Media } from '../types' import type { Media } from '../types'
@@ -163,6 +164,7 @@ export function MediaDetailMainContent({
onToggleFavourite={onToggleFavourite} onToggleFavourite={onToggleFavourite}
playTargetId={playTargetId} playTargetId={playTargetId}
/> />
<MediaVersionSwitcher media={media} mode="detail" />
{isAdmin && ( {isAdmin && (
<MediaDetailAdminPanel <MediaDetailAdminPanel
media={media} media={media}
+18
View File
@@ -18,6 +18,8 @@ import { PlayerTopBar } from './PlayerTopBar'
import { PlayerVideoStage } from './PlayerVideoStage' import { PlayerVideoStage } from './PlayerVideoStage'
import { PlayerDanmakuPanel } from '../components/PlayerDanmakuPanel' import { PlayerDanmakuPanel } from '../components/PlayerDanmakuPanel'
import { PlayerPlaylistPanel } from '../components/PlayerPlaylistPanel' import { PlayerPlaylistPanel } from '../components/PlayerPlaylistPanel'
import { MediaVersionSwitcher } from '../components/MediaVersionSwitcher'
import { mediaVersionsOf } from '../utils/mediaVersion'
// Fullscreen, dark-themed video page. // Fullscreen, dark-themed video page.
// //
@@ -420,6 +422,15 @@ export function PlayerPage() {
[navigate, location.search, location.state], [navigate, location.search, location.state],
) )
const versionList = useMemo(() => mediaVersionsOf(media), [media])
const switchVersion = useCallback(
(version: Media) => {
if (!version?.id || version.id === media?.id) return
playEpisode(version)
},
[media?.id, playEpisode],
)
const handlePrevEpisode = useCallback(() => { const handlePrevEpisode = useCallback(() => {
if (prevEpisode) { if (prevEpisode) {
playEpisode(prevEpisode) playEpisode(prevEpisode)
@@ -575,6 +586,13 @@ export function PlayerPage() {
onBack={goBack} onBack={goBack}
onToggleMode={toggleMode} onToggleMode={toggleMode}
/> />
{versionList.length > 1 && (
<div className="pointer-events-none absolute inset-x-0 top-16 z-20 flex justify-center px-4 sm:top-20">
<div className="pointer-events-auto max-w-3xl rounded-2xl border border-white/15 bg-black/70 px-3 py-2 shadow-xl backdrop-blur">
<MediaVersionSwitcher media={media!} mode="player" onSelect={switchVersion} className="text-white" />
</div>
</div>
)}
<PlayerVideoStage <PlayerVideoStage
media={media} media={media}
loadError={loadError} loadError={loadError}
+25
View File
@@ -509,6 +509,12 @@ const SETTING_DEFS: SettingDef[] = [
{ value: '3', label: '不带 path' }, { value: '3', label: '不带 path' },
], ],
}, },
{
key: 'strm.keep_ext',
label: '保留视频扩展名(多版本)',
type: 'toggle',
hint: '关闭(默认):同名不同扩展(如 竞女01.mkv / 竞女01.mp4)择优生成一条 name.strm;开启:分别生成 name.mkv.strm / name.mp4.strm,保留全部版本供播放切换',
},
{ key: 'strm.115_relay_key', label: '115 中继授权共享密钥', type: 'text', hint: 'QMediaSync/MQFamily 中继授权的共享 AES 密钥;不配置则中继授权不可用' }, { key: 'strm.115_relay_key', label: '115 中继授权共享密钥', type: 'text', hint: 'QMediaSync/MQFamily 中继授权的共享 AES 密钥;不配置则中继授权不可用' },
{ key: 'strm.download_threads', label: '下载队列线程数', type: 'number', hint: '元数据下载并发数' }, { key: 'strm.download_threads', label: '下载队列线程数', type: 'number', hint: '元数据下载并发数' },
{ key: 'strm.upload_threads', label: '上传队列线程数', type: 'number', hint: '元数据上传并发数' }, { key: 'strm.upload_threads', label: '上传队列线程数', type: 'number', hint: '元数据上传并发数' },
@@ -610,6 +616,7 @@ export function StrmSyncPathDialog({
download_meta: existing?.download_meta ?? true, download_meta: existing?.download_meta ?? true,
upload_meta: existing?.upload_meta ?? false, upload_meta: existing?.upload_meta ?? false,
delete_dir: existing?.delete_dir ?? false, delete_dir: existing?.delete_dir ?? false,
keep_ext: existing?.keep_ext ?? false,
cron: existing?.cron ?? '', cron: existing?.cron ?? '',
enable_cron: existing?.enable_cron ?? false, enable_cron: existing?.enable_cron ?? false,
sync_mode: existing?.sync_mode ?? 'incremental', sync_mode: existing?.sync_mode ?? 'incremental',
@@ -628,6 +635,19 @@ export function StrmSyncPathDialog({
// 兼容两类历史数据:新版保存的 local_path 末段是目录名,旧版是目录 ID。 // 兼容两类历史数据:新版保存的 local_path 末段是目录名,旧版是目录 ID。
const prevRemoteTailRef = useRef(existing ? initRemoteTail(existing) : '') const prevRemoteTailRef = useRef(existing ? initRemoteTail(existing) : '')
useEffect(() => {
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 = <K extends keyof StrmSyncPathInput>(key: K, value: StrmSyncPathInput[K]) => const set = <K extends keyof StrmSyncPathInput>(key: K, value: StrmSyncPathInput[K]) =>
setForm((f) => ({ ...f, [key]: value })) setForm((f) => ({ ...f, [key]: value }))
@@ -917,6 +937,11 @@ export function StrmSyncPathDialog({
<ToggleRow label="下载元数据" checked={form.download_meta ?? true} onChange={(v) => set('download_meta', v)} /> <ToggleRow label="下载元数据" checked={form.download_meta ?? true} onChange={(v) => set('download_meta', v)} />
<ToggleRow label="上传元数据" checked={form.upload_meta ?? false} onChange={(v) => set('upload_meta', v)} /> <ToggleRow label="上传元数据" checked={form.upload_meta ?? false} onChange={(v) => set('upload_meta', v)} />
<ToggleRow label="清理空目录" checked={form.delete_dir ?? false} onChange={(v) => set('delete_dir', v)} /> <ToggleRow label="清理空目录" checked={form.delete_dir ?? false} onChange={(v) => set('delete_dir', v)} />
<ToggleRow
label="保留视频扩展名(多版本)"
checked={form.keep_ext ?? false}
onChange={(v) => set('keep_ext', v)}
/>
<ToggleRow label="启用定时同步" checked={form.enable_cron ?? false} onChange={(v) => set('enable_cron', v)} /> <ToggleRow label="启用定时同步" checked={form.enable_cron ?? false} onChange={(v) => set('enable_cron', v)} />
<ToggleRow label="启用该目录" checked={form.enabled ?? true} onChange={(v) => set('enabled', v)} /> <ToggleRow label="启用该目录" checked={form.enabled ?? true} onChange={(v) => set('enabled', v)} />
</div> </div>
+3
View File
@@ -70,6 +70,7 @@ export interface StrmSyncPath {
download_meta: boolean download_meta: boolean
upload_meta: boolean upload_meta: boolean
delete_dir: boolean delete_dir: boolean
keep_ext: boolean
cron: string cron: string
enable_cron: boolean enable_cron: boolean
sync_mode?: 'incremental' | 'full' sync_mode?: 'incremental' | 'full'
@@ -98,6 +99,7 @@ export interface StrmSyncPathInput {
download_meta?: boolean download_meta?: boolean
upload_meta?: boolean upload_meta?: boolean
delete_dir?: boolean delete_dir?: boolean
keep_ext?: boolean
cron?: string cron?: string
enable_cron?: boolean enable_cron?: boolean
sync_mode?: 'incremental' | 'full' sync_mode?: 'incremental' | 'full'
@@ -167,6 +169,7 @@ export interface StrmSettingsMap {
'strm.download_meta': string 'strm.download_meta': string
'strm.upload_meta': string 'strm.upload_meta': string
'strm.delete_dir': string 'strm.delete_dir': string
'strm.keep_ext': string
'strm.download_threads': string 'strm.download_threads': string
'strm.upload_threads': string 'strm.upload_threads': string
[key: string]: string [key: string]: string
+60
View File
@@ -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]
}