mirror of
https://github.com/truewhile/MeBox.git
synced 2026-09-28 11:16:37 +08:00
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:
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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),
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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 == "" {
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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...)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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...)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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, " ")
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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,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}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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]
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user