mirror of
https://github.com/truewhile/MeBox.git
synced 2026-09-28 11:16:37 +08:00
Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 0e592105c4 | |||
| f20c9c3234 | |||
| 38b85bb487 |
@@ -488,7 +488,7 @@ func getMediaHandler(svc *service.Container) gin.HandlerFunc {
|
||||
c.JSON(http.StatusOK, m)
|
||||
return
|
||||
}
|
||||
m, err := svc.Media.GetMedia(ctx, id)
|
||||
m, err := svc.Media.GetMediaItem(ctx, id)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
@@ -497,7 +497,7 @@ func getMediaHandler(svc *service.Container) gin.HandlerFunc {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "not found"})
|
||||
return
|
||||
}
|
||||
if !mediaVisibleForRequest(c, svc, m) {
|
||||
if !mediaVisibleForRequest(c, svc, &m.Media) {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "not found"})
|
||||
return
|
||||
}
|
||||
|
||||
@@ -45,8 +45,8 @@ func playbackInfoHandler(svc *service.Container) gin.HandlerFunc {
|
||||
token := externalPlaybackToken(c, svc, m.ID, m.DurationSec)
|
||||
profileQuery := externalProfileQuery(c)
|
||||
hlsURL := "/api/hls/" + m.ID + "/index.m3u8?token=" + url.QueryEscape(token) + profileQuery
|
||||
if service.IsEmbyRemoteID(m.ID) || service.IsStrmMediaRow(m) {
|
||||
// Emby 远程挂载与 STRM 媒体一样,默认直连播放,不提供转码地址
|
||||
if service.IsEmbyRemoteID(m.ID) {
|
||||
// 远程 Emby 挂载没有本地文件,不能提供转码地址。STRM 默认直连,直连失败时可走 HLS。
|
||||
hlsURL = ""
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
|
||||
@@ -413,7 +413,7 @@ func newPlaybackScopeTestRouter(t *testing.T) (*gin.Engine, *service.Container,
|
||||
return router, svc, cfg.Secrets.JWTSecret
|
||||
}
|
||||
|
||||
func TestPlaybackInfoForSTRMMediaDisablesHLS(t *testing.T) {
|
||||
func TestPlaybackInfoForSTRMMediaIncludesHLS(t *testing.T) {
|
||||
router, _, secret := newPlaybackScopeTestRouter(t)
|
||||
loginToken := signedTestToken(t, secret)
|
||||
|
||||
@@ -435,8 +435,8 @@ func TestPlaybackInfoForSTRMMediaDisablesHLS(t *testing.T) {
|
||||
if payload.StreamURL == "" {
|
||||
t.Fatalf("expected non-empty stream_url")
|
||||
}
|
||||
if payload.HlsURL != "" {
|
||||
t.Fatalf("expected empty hls_url for STRM media, got %q", payload.HlsURL)
|
||||
if payload.HlsURL == "" || !strings.Contains(payload.HlsURL, "/api/hls/media-1/") {
|
||||
t.Fatalf("expected strm hls_url, got %q", payload.HlsURL)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -14,7 +14,7 @@ import (
|
||||
func hlsPlaylistHandler(svc *service.Container) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
id := c.Param("id")
|
||||
// 远程 Emby 挂载媒体与 STRM 一样,默认直连播放,不进行转码。
|
||||
// 远程 Emby 挂载没有本地文件,不能转码。STRM 允许直连失败后走 HLS。
|
||||
if svc.EmbyRemote != nil && service.IsEmbyRemoteID(id) {
|
||||
c.JSON(http.StatusConflict, gin.H{"error": "transcode disabled"})
|
||||
return
|
||||
|
||||
@@ -232,6 +232,7 @@ type strmSyncPathReq struct {
|
||||
DownloadMeta *bool `json:"download_meta"`
|
||||
UploadMeta *bool `json:"upload_meta"`
|
||||
DeleteDir *bool `json:"delete_dir"`
|
||||
KeepExt *bool `json:"keep_ext"`
|
||||
Cron string `json:"cron"`
|
||||
EnableCron *bool `json:"enable_cron"`
|
||||
SyncMode string `json:"sync_mode"`
|
||||
@@ -703,6 +704,7 @@ func strmSyncPathFromReq(req strmSyncPathReq) *model.StrmSyncPath {
|
||||
DownloadMeta: boolValue(req.DownloadMeta, true),
|
||||
UploadMeta: boolValue(req.UploadMeta, false),
|
||||
DeleteDir: boolValue(req.DeleteDir, false),
|
||||
KeepExt: boolValue(req.KeepExt, false),
|
||||
Cron: strings.TrimSpace(req.Cron),
|
||||
EnableCron: boolValue(req.EnableCron, false),
|
||||
SyncMode: strings.TrimSpace(req.SyncMode),
|
||||
|
||||
@@ -51,6 +51,9 @@ type StrmSyncPath struct {
|
||||
DownloadMeta bool `gorm:"default:true" json:"download_meta"` // 同步时下载元数据文件(nfo/图片/字幕)
|
||||
UploadMeta bool `json:"upload_meta"` // 同步时把本地元数据上传到远端
|
||||
DeleteDir bool `json:"delete_dir"` // 清理多余文件时删除空目录
|
||||
// KeepExt=true 时为每个视频生成 name.mkv.strm / name.mp4.strm(保留全部版本);
|
||||
// false(默认)时同名不同扩展只择优生成一条 name.strm,避免互相覆盖与来回抖动。
|
||||
KeepExt bool `json:"keep_ext"`
|
||||
Cron string `gorm:"size:128" json:"cron"` // 5 段 cron 表达式(可选)
|
||||
EnableCron bool `json:"enable_cron"` // 是否按 Cron 定时同步
|
||||
SyncMode string `gorm:"size:32;default:'incremental'" json:"sync_mode"` // 默认同步模式:incremental / full
|
||||
|
||||
@@ -110,6 +110,7 @@ func (r *StrmSyncPathRepository) Update(ctx context.Context, p *model.StrmSyncPa
|
||||
"download_meta": p.DownloadMeta,
|
||||
"upload_meta": p.UploadMeta,
|
||||
"delete_dir": p.DeleteDir,
|
||||
"keep_ext": p.KeepExt,
|
||||
"cron": p.Cron,
|
||||
"enable_cron": p.EnableCron,
|
||||
"sync_mode": p.SyncMode,
|
||||
|
||||
@@ -369,7 +369,10 @@ func (s *DanmakuService) searchTerms(ctx context.Context, mediaID string) (danma
|
||||
} else if name := strings.TrimSpace(m.Title); name != "" {
|
||||
term.name = name
|
||||
} else {
|
||||
term.name = strings.TrimSuffix(filepath.Base(m.Path), filepath.Ext(m.Path))
|
||||
term.name = mediaSidecarBase(m.Path)
|
||||
if term.name == "" {
|
||||
term.name = strings.TrimSuffix(filepath.Base(m.Path), filepath.Ext(m.Path))
|
||||
}
|
||||
}
|
||||
if m.EpisodeNum > 0 {
|
||||
term.episode = strconv.Itoa(m.EpisodeNum)
|
||||
@@ -527,23 +530,7 @@ func (s *DanmakuService) hashCachePut(stamp, hash string) {
|
||||
// ("xxx.mkv.strm") — so a second strip removes a real video extension only
|
||||
// (filepath.Ext would misread names like "xxx.第01话" as having an extension).
|
||||
func danmakuMatchFileName(path string) string {
|
||||
if path == "" {
|
||||
return ""
|
||||
}
|
||||
clean := strings.ReplaceAll(path, "\\", "/")
|
||||
if idx := strings.LastIndex(clean, "/"); idx >= 0 {
|
||||
clean = clean[idx+1:]
|
||||
}
|
||||
base := filepath.Base(clean)
|
||||
if ext := filepath.Ext(base); ext != "" {
|
||||
base = strings.TrimSuffix(base, ext)
|
||||
}
|
||||
if second := strings.ToLower(filepath.Ext(base)); second != "" {
|
||||
if _, ok := videoExtensions[second]; ok && second != ".strm" {
|
||||
base = strings.TrimSuffix(base, filepath.Ext(base))
|
||||
}
|
||||
}
|
||||
return strings.TrimSpace(base)
|
||||
return mediaSidecarBase(path)
|
||||
}
|
||||
|
||||
// mediaHash returns the dandanplay match hash (MD5 of the first 16MB of the
|
||||
|
||||
@@ -538,21 +538,35 @@ func (e *EmbyService) resolveMediaPeople(ctx context.Context, m *model.Media) []
|
||||
return []map[string]any{}
|
||||
}
|
||||
dir := filepath.Dir(m.Path)
|
||||
ext := filepath.Ext(m.Path)
|
||||
base := strings.TrimSuffix(m.Path, ext)
|
||||
candidates := []string{
|
||||
base + ".nfo",
|
||||
filepath.Join(dir, "movie.nfo"),
|
||||
filepath.Join(dir, "tvshow.nfo"),
|
||||
candidates := make([]string, 0, 6)
|
||||
seenPath := map[string]struct{}{}
|
||||
add := func(path string) {
|
||||
path = strings.TrimSpace(path)
|
||||
if path == "" {
|
||||
return
|
||||
}
|
||||
key := strings.ToLower(filepath.Clean(path))
|
||||
if _, ok := seenPath[key]; ok {
|
||||
return
|
||||
}
|
||||
seenPath[key] = struct{}{}
|
||||
candidates = append(candidates, path)
|
||||
}
|
||||
// 共享词干优先(竞女01.mkv.strm → 竞女01.nfo),并兼容旧的单层剥扩展命名。
|
||||
add(nfoPath(m.Path))
|
||||
for _, base := range mediaSidecarBaseVariants(m.Path) {
|
||||
add(filepath.Join(dir, base+".nfo"))
|
||||
}
|
||||
add(filepath.Join(dir, "movie.nfo"))
|
||||
add(filepath.Join(dir, "tvshow.nfo"))
|
||||
|
||||
people := make([]map[string]any, 0)
|
||||
seen := make(map[string]bool)
|
||||
|
||||
for _, p := range candidates {
|
||||
if fi, err := os.Stat(p); err == nil && !fi.IsDir() {
|
||||
doc, ok, err := decodeNFOFile(p)
|
||||
if err == nil && ok && doc != nil {
|
||||
doc, _, err := decodeNFOFile(p)
|
||||
if err == nil && doc != nil {
|
||||
for _, d := range doc.Directors {
|
||||
name := strings.TrimSpace(d)
|
||||
if name == "" {
|
||||
|
||||
@@ -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 {
|
||||
return map[string]any{
|
||||
"Id": m.ID,
|
||||
"Name": m.Title,
|
||||
"Name": MediaVersionLabel(*m),
|
||||
"Path": embyMediaSourcePath(m),
|
||||
"Container": container,
|
||||
"Size": m.SizeBytes,
|
||||
|
||||
@@ -41,7 +41,10 @@ var (
|
||||
// ParseEpisode tries to extract (season, episode) from an arbitrary filename.
|
||||
// Returns (0, 0) when nothing recognisable is found.
|
||||
func ParseEpisode(path string) (season, episode int) {
|
||||
name := strings.TrimSuffix(filepath.Base(path), filepath.Ext(path))
|
||||
name := mediaSidecarBase(path)
|
||||
if name == "" {
|
||||
name = strings.TrimSuffix(filepath.Base(path), filepath.Ext(path))
|
||||
}
|
||||
|
||||
if m := patSEnE.FindStringSubmatch(name); len(m) == 3 {
|
||||
season = mustAtoi(m[1])
|
||||
@@ -96,7 +99,10 @@ type episodeRef struct {
|
||||
}
|
||||
|
||||
func episodeRefsFromTitle(path string) []episodeRef {
|
||||
name := strings.TrimSuffix(filepath.Base(path), filepath.Ext(path))
|
||||
name := mediaSidecarBase(path)
|
||||
if name == "" {
|
||||
name = strings.TrimSuffix(filepath.Base(path), filepath.Ext(path))
|
||||
}
|
||||
if refs := parseSEpisodeRange(name); len(refs) > 0 {
|
||||
return refs
|
||||
}
|
||||
|
||||
@@ -54,39 +54,59 @@ func mergeArtworkMetadata(meta *LocalMetadata, mediaPath, showBaseDir string) {
|
||||
}
|
||||
|
||||
func localPosterCandidates(mediaPath string) []string {
|
||||
base := strings.TrimSuffix(filepath.Base(mediaPath), filepath.Ext(mediaPath))
|
||||
names := []string{
|
||||
base + "-poster",
|
||||
base + ".poster",
|
||||
"poster",
|
||||
"folder",
|
||||
"cover",
|
||||
"movie",
|
||||
"show",
|
||||
base + "-cover",
|
||||
base + ".cover",
|
||||
base,
|
||||
base + "-thumb",
|
||||
base + ".thumb",
|
||||
"thumb",
|
||||
names := make([]string, 0, 24)
|
||||
seen := map[string]struct{}{}
|
||||
add := func(name string) {
|
||||
name = strings.TrimSpace(name)
|
||||
if name == "" {
|
||||
return
|
||||
}
|
||||
key := strings.ToLower(name)
|
||||
if _, ok := seen[key]; ok {
|
||||
return
|
||||
}
|
||||
seen[key] = struct{}{}
|
||||
names = append(names, name)
|
||||
}
|
||||
for _, base := range mediaSidecarBaseVariants(mediaPath) {
|
||||
add(base + "-poster")
|
||||
add(base + ".poster")
|
||||
add(base + "-cover")
|
||||
add(base + ".cover")
|
||||
add(base)
|
||||
add(base + "-thumb")
|
||||
add(base + ".thumb")
|
||||
}
|
||||
for _, name := range []string{"poster", "folder", "cover", "movie", "show", "thumb"} {
|
||||
add(name)
|
||||
}
|
||||
return append(adultArtworkNameCandidates(mediaPath, "poster"), names...)
|
||||
}
|
||||
|
||||
func localBackdropCandidates(mediaPath string) []string {
|
||||
base := strings.TrimSuffix(filepath.Base(mediaPath), filepath.Ext(mediaPath))
|
||||
names := []string{
|
||||
base + "-fanart",
|
||||
base + ".fanart",
|
||||
base + "-backdrop",
|
||||
base + ".backdrop",
|
||||
base + "-background",
|
||||
"fanart",
|
||||
"backdrop",
|
||||
"background",
|
||||
"landscape",
|
||||
"banner",
|
||||
"clearart",
|
||||
names := make([]string, 0, 24)
|
||||
seen := map[string]struct{}{}
|
||||
add := func(name string) {
|
||||
name = strings.TrimSpace(name)
|
||||
if name == "" {
|
||||
return
|
||||
}
|
||||
key := strings.ToLower(name)
|
||||
if _, ok := seen[key]; ok {
|
||||
return
|
||||
}
|
||||
seen[key] = struct{}{}
|
||||
names = append(names, name)
|
||||
}
|
||||
for _, base := range mediaSidecarBaseVariants(mediaPath) {
|
||||
add(base + "-fanart")
|
||||
add(base + ".fanart")
|
||||
add(base + "-backdrop")
|
||||
add(base + ".backdrop")
|
||||
add(base + "-background")
|
||||
}
|
||||
for _, name := range []string{"fanart", "backdrop", "background", "landscape", "banner", "clearart"} {
|
||||
add(name)
|
||||
}
|
||||
return append(adultArtworkNameCandidates(mediaPath, "backdrop"), names...)
|
||||
}
|
||||
|
||||
@@ -28,13 +28,12 @@ func ReadLocalMetadata(mediaPath, libraryRoot string, seriesLike bool) (*LocalMe
|
||||
|
||||
func findMovieNFO(mediaPath, libraryRoot string) (*nfoDocument, string, error) {
|
||||
mediaDir := filepath.Dir(mediaPath)
|
||||
base := strings.TrimSuffix(filepath.Base(mediaPath), filepath.Ext(mediaPath))
|
||||
adultCode := AdultCodeFromMediaPath(mediaPath)
|
||||
names := []string{
|
||||
base + ".nfo",
|
||||
"movie.nfo",
|
||||
filepath.Base(mediaDir) + ".nfo",
|
||||
names := make([]string, 0, 8)
|
||||
for _, base := range mediaSidecarBaseVariants(mediaPath) {
|
||||
names = append(names, base+".nfo")
|
||||
}
|
||||
names = append(names, "movie.nfo", filepath.Base(mediaDir)+".nfo")
|
||||
if adultCode != "" {
|
||||
names = append([]string{adultCode + ".nfo", strings.ReplaceAll(adultCode, "-", "") + ".nfo"}, names...)
|
||||
}
|
||||
|
||||
@@ -2,6 +2,8 @@ package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"go.uber.org/zap"
|
||||
@@ -111,3 +113,63 @@ func (s *MediaService) GetMedia(ctx context.Context, id string) (*model.Media, e
|
||||
*media = items[0]
|
||||
return media, nil
|
||||
}
|
||||
|
||||
// GetMediaItem 返回媒体详情,并附带同片多版本列表(用于详情页/播放器切换)。
|
||||
func (s *MediaService) GetMediaItem(ctx context.Context, id string) (*MediaItem, error) {
|
||||
media, err := s.GetMedia(ctx, id)
|
||||
if err != nil || media == nil {
|
||||
return nil, err
|
||||
}
|
||||
versions, err := s.listVersionSiblings(ctx, media)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
item := &MediaItem{Media: *media}
|
||||
if len(versions) > 1 {
|
||||
item.Versions = versions
|
||||
}
|
||||
return item, nil
|
||||
}
|
||||
|
||||
// listVersionSiblings 查找与当前条目同属一个版本组的全部媒体(含自身)。
|
||||
func (s *MediaService) listVersionSiblings(ctx context.Context, media *model.Media) ([]model.Media, error) {
|
||||
if media == nil || strings.TrimSpace(media.ID) == "" {
|
||||
return nil, nil
|
||||
}
|
||||
key := mediaVersionGroupKey(*media)
|
||||
if key == "" {
|
||||
return []model.Media{*media}, nil
|
||||
}
|
||||
libraryIDs, err := MergedLibraryIDsForLibrary(ctx, s.repo, media.LibraryID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(libraryIDs) == 0 {
|
||||
libraryIDs = []string{media.LibraryID}
|
||||
}
|
||||
filter := repository.MediaQueryFilter{IncludeNSFW: true}
|
||||
candidates, err := s.repo.Media.ListByLibrariesFilteredNoCount(ctx, libraryIDs, 0, 5000, filter)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
s.attachLibraryMetadata(ctx, candidates)
|
||||
matched := make([]model.Media, 0, 4)
|
||||
for _, row := range candidates {
|
||||
if mediaVersionGroupKey(row) == key {
|
||||
matched = append(matched, row)
|
||||
}
|
||||
}
|
||||
if len(matched) == 0 {
|
||||
return []model.Media{*media}, nil
|
||||
}
|
||||
sort.SliceStable(matched, func(i, j int) bool {
|
||||
if matched[i].ID == media.ID {
|
||||
return true
|
||||
}
|
||||
if matched[j].ID == media.ID {
|
||||
return false
|
||||
}
|
||||
return betterMediaVersion(matched[i], matched[j])
|
||||
})
|
||||
return matched, nil
|
||||
}
|
||||
|
||||
@@ -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 (
|
||||
"fmt"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
@@ -179,6 +180,7 @@ func mediaVersionGroupKey(m model.Media) string {
|
||||
}
|
||||
return "thetvdb:" + strings.ToLower(strings.TrimSpace(m.TheTVDBID))
|
||||
}
|
||||
|
||||
title := firstNonEmpty(m.OriginalName, m.Title)
|
||||
titleYear := 0
|
||||
if title == "" {
|
||||
@@ -187,6 +189,10 @@ func mediaVersionGroupKey(m model.Media) string {
|
||||
title, titleYear = mediaVersionTitleKey(title)
|
||||
}
|
||||
if title == "" {
|
||||
// 无标题时退回同目录词干(覆盖 keep_ext 的 name.mkv.strm / name.mp4.strm)
|
||||
if stemKey := mediaVersionStemGroupKey(m, libKey); stemKey != "" {
|
||||
return stemKey
|
||||
}
|
||||
return ""
|
||||
}
|
||||
year := m.Year
|
||||
@@ -202,6 +208,114 @@ func mediaVersionGroupKey(m model.Media) string {
|
||||
return fmt.Sprintf("movie:%s:%d", title, year)
|
||||
}
|
||||
|
||||
// mediaVersionStemGroupKey 按「库 + 父目录 + 文件词干」折叠多版本
|
||||
// (如 竞女01.mkv.strm 与 竞女01.mp4.strm)。
|
||||
func mediaVersionStemGroupKey(m model.Media, libKey string) string {
|
||||
path := strings.ReplaceAll(strings.TrimSpace(m.Path), "\\", "/")
|
||||
if path == "" || strings.HasPrefix(strings.ToLower(path), "cloud://") {
|
||||
return ""
|
||||
}
|
||||
dir := ""
|
||||
base := path
|
||||
if idx := strings.LastIndex(path, "/"); idx >= 0 {
|
||||
dir = path[:idx]
|
||||
base = path[idx+1:]
|
||||
}
|
||||
stem := mediaVersionFileStem(base)
|
||||
if stem == "" {
|
||||
return ""
|
||||
}
|
||||
stem = normalizeMediaVersionText(stem)
|
||||
if stem == "" {
|
||||
return ""
|
||||
}
|
||||
if libKey == "" {
|
||||
libKey = "_"
|
||||
}
|
||||
return fmt.Sprintf("stem:%s:%s:%s", libKey, strings.ToLower(dir), stem)
|
||||
}
|
||||
|
||||
// mediaVersionFileStem 去掉最终扩展名;若为 .strm 且前一层是视频扩展,再剥一层。
|
||||
func mediaVersionFileStem(name string) string {
|
||||
return mediaFileStem(name)
|
||||
}
|
||||
|
||||
// MediaVersionLabel 生成版本切换展示名(分辨率 / 容器 / 编码 / 体积 / 文件名)。
|
||||
func MediaVersionLabel(m model.Media) string {
|
||||
parts := make([]string, 0, 4)
|
||||
if m.Height > 0 {
|
||||
parts = append(parts, fmt.Sprintf("%dp", m.Height))
|
||||
} else if m.Width > 0 {
|
||||
parts = append(parts, fmt.Sprintf("%dw", m.Width))
|
||||
}
|
||||
container := strings.Trim(strings.ToLower(strings.TrimSpace(m.Container)), ". ")
|
||||
if container == "" || container == "strm" {
|
||||
container = mediaVersionContainerFromPath(m.Path, m.STRMURL)
|
||||
}
|
||||
if container != "" && container != "strm" {
|
||||
parts = append(parts, strings.ToUpper(container))
|
||||
}
|
||||
if codec := strings.TrimSpace(m.VideoCodec); codec != "" {
|
||||
parts = append(parts, strings.ToUpper(codec))
|
||||
}
|
||||
if m.SizeBytes > 0 {
|
||||
parts = append(parts, formatMediaSize(m.SizeBytes))
|
||||
}
|
||||
if len(parts) > 0 {
|
||||
return strings.Join(parts, " · ")
|
||||
}
|
||||
base := filepath.Base(strings.ReplaceAll(strings.TrimSpace(m.Path), "\\", "/"))
|
||||
if base == "" || base == "." {
|
||||
return firstNonEmpty(m.Title, m.OriginalName, m.ID)
|
||||
}
|
||||
return base
|
||||
}
|
||||
|
||||
func mediaVersionContainerFromPath(path, strmURL string) string {
|
||||
base := filepath.Base(strings.ReplaceAll(strings.TrimSpace(path), "\\", "/"))
|
||||
ext := strings.ToLower(filepath.Ext(base))
|
||||
name := strings.TrimSuffix(base, ext)
|
||||
if ext == ".strm" {
|
||||
if second := strings.ToLower(filepath.Ext(name)); second != "" {
|
||||
if _, ok := videoExtensions[second]; ok {
|
||||
return strings.TrimPrefix(second, ".")
|
||||
}
|
||||
}
|
||||
// 从 strm 播放 URL 的 /video.mkv 推断
|
||||
u := strings.ToLower(strmURL)
|
||||
if idx := strings.LastIndex(u, "/video."); idx >= 0 {
|
||||
rest := u[idx+len("/video."):]
|
||||
if end := strings.IndexAny(rest, "?#&/"); end >= 0 {
|
||||
rest = rest[:end]
|
||||
}
|
||||
rest = strings.Trim(rest, ".")
|
||||
if rest != "" {
|
||||
return rest
|
||||
}
|
||||
}
|
||||
return "strm"
|
||||
}
|
||||
if ext != "" {
|
||||
if _, ok := videoExtensions[ext]; ok {
|
||||
return strings.TrimPrefix(ext, ".")
|
||||
}
|
||||
}
|
||||
return strings.TrimPrefix(ext, ".")
|
||||
}
|
||||
|
||||
func formatMediaSize(bytes int64) string {
|
||||
if bytes < 1024 {
|
||||
return fmt.Sprintf("%d B", bytes)
|
||||
}
|
||||
const unit = 1024
|
||||
div, exp := int64(unit), 0
|
||||
for n := bytes / unit; n >= unit; n /= unit {
|
||||
div *= unit
|
||||
exp++
|
||||
}
|
||||
return fmt.Sprintf("%.1f %cB", float64(bytes)/float64(div), "KMGTPE"[exp])
|
||||
}
|
||||
|
||||
func mediaVersionTitleKey(value string) (string, int) {
|
||||
cleaned, year := CleanQuery(value)
|
||||
if strings.TrimSpace(cleaned) == "" {
|
||||
@@ -232,6 +346,10 @@ func normalizeMediaVersionText(value string) string {
|
||||
if _, noise := noiseTokenSet[field]; noise {
|
||||
continue
|
||||
}
|
||||
// 去掉视频容器词干残留(keep_ext / 旧标题「竞女01 mkv」)
|
||||
if _, ok := videoExtensions["."+field]; ok {
|
||||
continue
|
||||
}
|
||||
out = append(out, field)
|
||||
}
|
||||
return strings.Join(out, " ")
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
@@ -264,3 +265,43 @@ func TestGroupMediaVersionsDoesNotMergeAcrossDifferentLibrariesForMovies(t *test
|
||||
t.Fatalf("expected 2 separate groups for movies in different libraries, got %d", len(grouped))
|
||||
}
|
||||
}
|
||||
|
||||
func TestGroupMediaVersionsMergesKeepExtStrmVariants(t *testing.T) {
|
||||
mkv := model.Media{
|
||||
LibraryID: "movies",
|
||||
Title: "竞女01",
|
||||
Path: "/strm/竞女01.mkv.strm",
|
||||
SizeBytes: 500,
|
||||
STRMURL: "/api/strm/play/cloud115/video.mkv?pickcode=a",
|
||||
}
|
||||
mp4 := model.Media{
|
||||
LibraryID: "movies",
|
||||
Title: "竞女01",
|
||||
Path: "/strm/竞女01.mp4.strm",
|
||||
SizeBytes: 100,
|
||||
STRMURL: "/api/strm/play/cloud115/video.mp4?pickcode=b",
|
||||
}
|
||||
grouped := groupMediaVersions([]model.Media{mkv, mp4})
|
||||
if len(grouped) != 1 {
|
||||
t.Fatalf("grouped len = %d, want 1", len(grouped))
|
||||
}
|
||||
if len(grouped[0].Versions) != 2 {
|
||||
t.Fatalf("versions len = %d, want 2", len(grouped[0].Versions))
|
||||
}
|
||||
if grouped[0].Path != mkv.Path {
|
||||
t.Fatalf("primary should be larger mkv, got %q", grouped[0].Path)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMediaVersionLabelUsesContainerAndSize(t *testing.T) {
|
||||
label := MediaVersionLabel(model.Media{
|
||||
Title: "竞女01",
|
||||
Path: "/strm/竞女01.mkv.strm",
|
||||
Height: 1080,
|
||||
SizeBytes: 1024 * 1024 * 1200,
|
||||
STRMURL: "/api/strm/play/cloud115/video.mkv?pickcode=a",
|
||||
})
|
||||
if !strings.Contains(label, "1080p") || !strings.Contains(strings.ToUpper(label), "MKV") {
|
||||
t.Fatalf("unexpected label %q", label)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -115,7 +115,10 @@ func (s *NFOService) ExportLibrary(ctx context.Context, libraryID string) (int,
|
||||
|
||||
func nfoPath(media string) string {
|
||||
dir := filepath.Dir(media)
|
||||
base := strings.TrimSuffix(filepath.Base(media), filepath.Ext(media))
|
||||
base := mediaSidecarBase(media)
|
||||
if base == "" {
|
||||
base = strings.TrimSuffix(filepath.Base(media), filepath.Ext(media))
|
||||
}
|
||||
return filepath.Join(dir, fmt.Sprintf("%s.nfo", base))
|
||||
}
|
||||
|
||||
|
||||
@@ -278,16 +278,21 @@ func randomSuffix() string {
|
||||
// moveStagedArtwork renames artwork sidecars staged alongside `stage` into
|
||||
// their final names next to `dst`.
|
||||
func moveStagedArtwork(stage, dst string) {
|
||||
stageBase := strings.TrimSuffix(filepath.Base(stage), filepath.Ext(stage))
|
||||
dstBase := strings.TrimSuffix(filepath.Base(dst), filepath.Ext(dst))
|
||||
stageBases := mediaSidecarBaseVariants(stage)
|
||||
dstBase := mediaSidecarBase(dst)
|
||||
if dstBase == "" {
|
||||
dstBase = strings.TrimSuffix(filepath.Base(dst), filepath.Ext(dst))
|
||||
}
|
||||
stageDir := filepath.Dir(stage)
|
||||
for _, suffix := range artworkSidecarSuffixes {
|
||||
for _, ext := range artworkSidecarExtensions {
|
||||
srcPath := filepath.Join(stageDir, stageBase+suffix+ext)
|
||||
if _, err := os.Stat(srcPath); err != nil {
|
||||
continue
|
||||
for _, stageBase := range stageBases {
|
||||
for _, suffix := range artworkSidecarSuffixes {
|
||||
for _, ext := range artworkSidecarExtensions {
|
||||
srcPath := filepath.Join(stageDir, stageBase+suffix+ext)
|
||||
if _, err := os.Stat(srcPath); err != nil {
|
||||
continue
|
||||
}
|
||||
_ = os.Rename(srcPath, filepath.Join(stageDir, dstBase+suffix+ext))
|
||||
}
|
||||
_ = os.Rename(srcPath, filepath.Join(stageDir, dstBase+suffix+ext))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -306,12 +311,13 @@ func moveSidecarRename(from, to string) {
|
||||
// removeStagedArtwork removes artwork sidecars that were staged alongside
|
||||
// `stage`, used when a replace fails and its staged outputs must be cleaned up.
|
||||
func removeStagedArtwork(stage string) {
|
||||
stageBase := strings.TrimSuffix(filepath.Base(stage), filepath.Ext(stage))
|
||||
stageBases := mediaSidecarBaseVariants(stage)
|
||||
stageDir := filepath.Dir(stage)
|
||||
for _, suffix := range artworkSidecarSuffixes {
|
||||
for _, ext := range artworkSidecarExtensions {
|
||||
path := filepath.Join(stageDir, stageBase+suffix+ext)
|
||||
_ = os.Remove(path)
|
||||
for _, stageBase := range stageBases {
|
||||
for _, suffix := range artworkSidecarSuffixes {
|
||||
for _, ext := range artworkSidecarExtensions {
|
||||
_ = os.Remove(filepath.Join(stageDir, stageBase+suffix+ext))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -51,23 +51,25 @@ var artworkSidecarExtensions = []string{".jpg", ".jpeg", ".png", ".webp", ".gif"
|
||||
// behind in the old folder; this keeps artwork with the organized file.
|
||||
func transferSidecarArtwork(srcMedia, dstMedia string, mode TransferMode) error {
|
||||
srcDir := filepath.Dir(srcMedia)
|
||||
base := strings.TrimSuffix(filepath.Base(srcMedia), filepath.Ext(srcMedia))
|
||||
if base == "" || base == "." {
|
||||
bases := mediaSidecarBaseVariants(srcMedia)
|
||||
if len(bases) == 0 {
|
||||
return nil
|
||||
}
|
||||
// Find every existing sidecar by probing suffix + extension combinations.
|
||||
sources := make([]string, 0, len(artworkSidecarSuffixes)*len(artworkSidecarExtensions))
|
||||
sources := make([]string, 0, len(artworkSidecarSuffixes)*len(artworkSidecarExtensions)*len(bases))
|
||||
seen := map[string]struct{}{}
|
||||
for _, suffix := range artworkSidecarSuffixes {
|
||||
for _, ext := range artworkSidecarExtensions {
|
||||
path := filepath.Join(srcDir, base+suffix+ext)
|
||||
key := strings.ToLower(filepath.Clean(path))
|
||||
if _, ok := seen[key]; ok {
|
||||
continue
|
||||
}
|
||||
seen[key] = struct{}{}
|
||||
if _, err := os.Stat(path); err == nil {
|
||||
sources = append(sources, path)
|
||||
for _, base := range bases {
|
||||
for _, suffix := range artworkSidecarSuffixes {
|
||||
for _, ext := range artworkSidecarExtensions {
|
||||
path := filepath.Join(srcDir, base+suffix+ext)
|
||||
key := strings.ToLower(filepath.Clean(path))
|
||||
if _, ok := seen[key]; ok {
|
||||
continue
|
||||
}
|
||||
seen[key] = struct{}{}
|
||||
if _, err := os.Stat(path); err == nil {
|
||||
sources = append(sources, path)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -78,15 +80,36 @@ func transferSidecarArtwork(srcMedia, dstMedia string, mode TransferMode) error
|
||||
if err := os.MkdirAll(dstDir, 0o755); err != nil { // #nosec G301 -- sidecar media directories must remain readable by NAS/player users.
|
||||
return err
|
||||
}
|
||||
var firstErr error
|
||||
dstBase := mediaSidecarBase(dstMedia)
|
||||
if dstBase == "" {
|
||||
dstBase = strings.TrimSuffix(filepath.Base(dstMedia), filepath.Ext(dstMedia))
|
||||
}
|
||||
for _, src := range sources {
|
||||
dst := filepath.Join(dstDir, filepath.Base(src))
|
||||
if _, err := os.Stat(dst); err == nil {
|
||||
continue // never clobber an existing artwork at the destination
|
||||
name := filepath.Base(src)
|
||||
// Remap any legacy "Title.mkv-poster.jpg" onto the shared destination stem.
|
||||
suffix := ""
|
||||
lowerName := strings.ToLower(name)
|
||||
for _, base := range bases {
|
||||
prefix := strings.ToLower(base)
|
||||
if strings.HasPrefix(lowerName, prefix) {
|
||||
suffix = name[len(base):]
|
||||
break
|
||||
}
|
||||
}
|
||||
if err := transferFile(src, dst, mode); err != nil && firstErr == nil {
|
||||
firstErr = err
|
||||
dstName := name
|
||||
if suffix != "" && dstBase != "" {
|
||||
dstName = dstBase + suffix
|
||||
}
|
||||
dst := filepath.Join(dstDir, dstName)
|
||||
if strings.EqualFold(filepath.Clean(src), filepath.Clean(dst)) {
|
||||
continue
|
||||
}
|
||||
if _, err := os.Stat(dst); err == nil {
|
||||
continue
|
||||
}
|
||||
if err := transferFile(src, dst, mode); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return firstErr
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -138,7 +138,10 @@ type localScanMediaInput struct {
|
||||
func (s *ScannerService) buildLocalScanMedia(in localScanMediaInput) *model.Media {
|
||||
title, year := CleanQueryWithRecognition(context.Background(), s.repo, in.path)
|
||||
if title == "" {
|
||||
title = strings.TrimSuffix(filepath.Base(in.path), in.ext)
|
||||
title = mediaSidecarBase(in.path)
|
||||
if title == "" {
|
||||
title = strings.TrimSuffix(filepath.Base(in.path), in.ext)
|
||||
}
|
||||
}
|
||||
title, year = preferISOParentScrapeIdentity(in.path, in.lib.Path, title, year)
|
||||
|
||||
|
||||
@@ -75,6 +75,12 @@ func CleanQuery(raw string) (title string, year int) {
|
||||
base = strings.TrimSpace(raw)
|
||||
}
|
||||
name := strings.TrimSuffix(base, filepath.Ext(base))
|
||||
// .strm 可能保留视频扩展名(name.mkv.strm),再剥一层真实视频扩展,
|
||||
// 避免「竞女01.mkv」与「竞女01.mp4」被当成不同标题。
|
||||
name = mediaFileStem(base)
|
||||
if name == "" {
|
||||
name = strings.TrimSuffix(base, filepath.Ext(base))
|
||||
}
|
||||
lower := strings.ToLower(name)
|
||||
|
||||
if m := yearPattern.FindStringSubmatch(lower); len(m) >= 2 {
|
||||
|
||||
@@ -38,9 +38,9 @@ func (s *ScraperService) writeMediaArtworkFilesAfterScrape(ctx context.Context,
|
||||
if dir == "" || dir == "." {
|
||||
return
|
||||
}
|
||||
// Scope sidecar names by the media file's base name (e.g. A.mp4 -> A-poster.jpg)
|
||||
// so that multiple movies sharing one directory (A.mp4 + B.mp4) never clash.
|
||||
base := strings.TrimSuffix(filepath.Base(refreshed.Path), filepath.Ext(refreshed.Path))
|
||||
// Scope sidecar names by the shared media stem (e.g. A.mkv.strm / A.mp4.strm -> A-poster.jpg)
|
||||
// so multi-version files in one folder share artwork and never diverge by container.
|
||||
base := mediaSidecarBase(refreshed.Path)
|
||||
if base == "" || base == "." {
|
||||
return
|
||||
}
|
||||
|
||||
@@ -121,6 +121,8 @@ func (b *serviceContainerBuilder) initContentServices() {
|
||||
b.c.FFTools = NewFFmpegToolsService(b.cfg, b.log, b.repos)
|
||||
// 弹幕 hash 识别需要把 strm 指向解析成可拉取的直链/本地路径。
|
||||
b.c.Danmaku.SetStrmResolver(b.c.Strm.ResolvePlay)
|
||||
// STRM 直连失败后的 HLS 转码:把 .strm 解析成 ffmpeg 可读取的本地路径或 HTTP 直链。
|
||||
b.c.Transcoder.SetStrmPlayTargetResolver(b.c.Strm.ResolvePlayTarget)
|
||||
// 弹幕识别需要把远程 Emby 条目解析为 Media 元数据及可拉取前 16MB 的直链 URL。
|
||||
if b.c.EmbyRemote != nil {
|
||||
b.c.Danmaku.SetRemoteMediaResolver(func(ctx context.Context, encodedID string) (*model.Media, string, error) {
|
||||
|
||||
@@ -87,7 +87,7 @@ func playableSTRMTarget(ctx context.Context, repo *repository.Container, raw str
|
||||
}
|
||||
|
||||
// IsStrmMediaRow 判断媒体行是否为 .strm(远程直链)媒体:STRMURL 非空、
|
||||
// container=strm 或路径以 .strm 结尾。strm 媒体只能直连播放,禁止转码。
|
||||
// container=strm 或路径以 .strm 结尾。网页播放默认直连,失败后可转码。
|
||||
func IsStrmMediaRow(m *model.Media) bool {
|
||||
if m == nil {
|
||||
return false
|
||||
|
||||
@@ -42,6 +42,7 @@ const (
|
||||
StrmSettingDownloadMeta = "strm.download_meta"
|
||||
StrmSettingUploadMeta = "strm.upload_meta"
|
||||
StrmSettingDeleteDir = "strm.delete_dir"
|
||||
StrmSettingKeepExt = "strm.keep_ext"
|
||||
StrmSettingDownloadThreads = "strm.download_threads"
|
||||
StrmSettingUploadThreads = "strm.upload_threads"
|
||||
)
|
||||
@@ -69,6 +70,7 @@ var StrmSettingDefs = map[string]struct {
|
||||
StrmSettingDownloadMeta: {Default: "true", Label: "下载元数据", Kind: "bool", Help: "同步时把远端 nfo/图片/字幕下载到本地输出目录"},
|
||||
StrmSettingUploadMeta: {Default: "false", Label: "上传元数据", Kind: "bool", Help: "同步时把本地元数据上传到远端;本地与网盘元数据不同时以本地为准覆盖(需网盘支持写入)"},
|
||||
StrmSettingDeleteDir: {Default: "false", Label: "清理空目录", Kind: "bool", Help: "清理远端已删除的多余 .strm/元数据后,删除空目录"},
|
||||
StrmSettingKeepExt: {Default: "false", Label: "保留视频扩展名(多版本)", Kind: "bool", Help: "关闭(默认):同名不同扩展(如 竞女01.mkv / 竞女01.mp4)按体积→mtime→扩展名优先级择优生成一条 name.strm;开启:分别生成 name.mkv.strm / name.mp4.strm,保留全部版本供播放切换"},
|
||||
Strm115RelayKeySetting: {Default: "", Label: "115 中继授权共享密钥", Kind: "text", Help: "QMediaSync/MQFamily 中继授权的共享 AES 密钥(OAUTH_RELAY_ENCRYPTION_KEY);不配置则中继授权不可用"},
|
||||
StrmSettingDownloadThreads: {Default: "6", Label: "下载队列线程数", Kind: "number", Help: "OpenList/CloudDrive2 元数据下载并发数(115 独立限速为 3)"},
|
||||
StrmSettingUploadThreads: {Default: "2", Label: "上传队列线程数", Kind: "number", Help: "元数据上传并发数"},
|
||||
@@ -731,6 +733,7 @@ func (s *StrmService) strmEffectiveConfig(ctx context.Context, p *model.StrmSync
|
||||
cfg.DownloadMeta = p.DownloadMeta
|
||||
cfg.UploadMeta = p.UploadMeta
|
||||
cfg.DeleteDir = p.DeleteDir
|
||||
cfg.KeepExt = p.KeepExt
|
||||
cfg.BaseURL = strings.TrimRight(cfg.BaseURL, "/")
|
||||
return cfg, nil
|
||||
}
|
||||
@@ -964,6 +967,7 @@ type strmPathConfig struct {
|
||||
DownloadMeta bool
|
||||
UploadMeta bool
|
||||
DeleteDir bool
|
||||
KeepExt bool
|
||||
}
|
||||
|
||||
// ─── 本地目录浏览(添加同步目录用,兼容 Windows/Linux) ─────────────────────────
|
||||
|
||||
+138
-58
@@ -49,11 +49,13 @@ type strmSyncState struct {
|
||||
mu sync.Mutex
|
||||
processed int // 已处理文件计数(用于定期落库进度)
|
||||
lastProgressFlush time.Time // 上次进度落库时间
|
||||
seenVideo map[string]bool // "v:"+去掉扩展名的相对路径 → 远端存在该视频
|
||||
seenVideo map[string]bool // "v:"+strm 去扩展名相对路径 → 远端存在该视频(供 prune)
|
||||
seenMeta map[string]bool // "m:"+相对路径 → 远端存在该元数据
|
||||
remoteMeta map[string][]remoteMetaItem // "m:"+相对路径 → 远端元数据副本列表(多副本聚合,支持择优比对与冗余清理)
|
||||
seenMetaTarget map[string]cloud.FileEntry
|
||||
seenVideoTarget map[string]cloud.FileEntry
|
||||
// remoteVideos:prefer 模式下同名(去扩展名)视频候选列表,walk 结束后择优写盘
|
||||
remoteVideos map[string][]remoteVideoCandidate
|
||||
activeDownloadPaths map[string]bool // 本地已在排队/进行的下载任务路径(内存去重)
|
||||
activeUploadPaths map[string]bool // 本地已在排队/进行的上传任务路径(内存去重)
|
||||
// recentDoneUploadSizes:近期已成功上传的 local_path → size,缩短「done 但列表未到」窗口内的重复入队
|
||||
@@ -67,6 +69,13 @@ type strmSyncState struct {
|
||||
scanIncomplete atomic.Bool // 远端目录树/文件列表本次扫描不完整 → 禁止增量 prune 误删本地文件
|
||||
}
|
||||
|
||||
// remoteVideoCandidate 记录同名视频的一个远端副本(不同扩展名/体积)。
|
||||
type remoteVideoCandidate struct {
|
||||
entry cloud.FileEntry
|
||||
rel string
|
||||
ext string
|
||||
}
|
||||
|
||||
// StartSync 启动一次同步(异步执行,同一目录同时只允许一个任务)。
|
||||
// syncType 支持 "incremental"(默认增量)和 "full"(全量同步)。
|
||||
func (s *StrmService) StartSync(ctx context.Context, pathID string, syncType ...string) error {
|
||||
@@ -276,6 +285,7 @@ func (s *StrmService) runSync(ctx context.Context, p *model.StrmSyncPath, rec *m
|
||||
remoteMeta: map[string][]remoteMetaItem{},
|
||||
seenMetaTarget: map[string]cloud.FileEntry{},
|
||||
seenVideoTarget: map[string]cloud.FileEntry{},
|
||||
remoteVideos: map[string][]remoteVideoCandidate{},
|
||||
}
|
||||
if p.Provider != model.StrmProviderLocal {
|
||||
acct, err := s.repo.StrmAccount.FindByID(ctx, p.AccountID)
|
||||
@@ -386,6 +396,7 @@ func (st *strmSyncState) run() error {
|
||||
return err
|
||||
}
|
||||
}
|
||||
st.flushPreferredVideos()
|
||||
st.flushPendingDownloads()
|
||||
st.flushProgress()
|
||||
if st.cfg.UploadMeta && st.provider != nil {
|
||||
@@ -1195,21 +1206,123 @@ func (st *strmSyncState) fetch115FilesAdaptive(ctx context.Context, open115 *clo
|
||||
return allFiles, nil
|
||||
}
|
||||
|
||||
// handleVideo 生成/更新 .strm 文件。
|
||||
// handleVideo 收集/生成 .strm 文件。
|
||||
// KeepExt=true:每个视频写 name.ext.strm,保留全部版本。
|
||||
// KeepExt=false(默认):同名候选先入队,walk 结束后按体积→mtime→扩展名优先级择优写一条 name.strm。
|
||||
func (st *strmSyncState) handleVideo(entry cloud.FileEntry, rel, ext string) {
|
||||
relSansExt := rel[:len(rel)-len(ext)]
|
||||
st.mu.Lock()
|
||||
if st.seenVideo["v:"+relSansExt] {
|
||||
st.mu.Unlock()
|
||||
if st.cfg.KeepExt {
|
||||
st.writeVideoStrm(entry, rel, ext, rel+".strm", true)
|
||||
return
|
||||
}
|
||||
st.seenVideo["v:"+relSansExt] = true
|
||||
st.mu.Lock()
|
||||
if st.remoteVideos == nil {
|
||||
st.remoteVideos = map[string][]remoteVideoCandidate{}
|
||||
}
|
||||
st.remoteVideos[relSansExt] = append(st.remoteVideos[relSansExt], remoteVideoCandidate{
|
||||
entry: entry,
|
||||
rel: rel,
|
||||
ext: ext,
|
||||
})
|
||||
st.mu.Unlock()
|
||||
st.touchProgress()
|
||||
}
|
||||
|
||||
// flushPreferredVideos 在 prefer 模式下对同名多版本择优写盘,并记录冲突日志。
|
||||
func (st *strmSyncState) flushPreferredVideos() {
|
||||
if st.cfg.KeepExt {
|
||||
return
|
||||
}
|
||||
st.mu.Lock()
|
||||
pending := st.remoteVideos
|
||||
st.remoteVideos = map[string][]remoteVideoCandidate{}
|
||||
st.mu.Unlock()
|
||||
for base, cands := range pending {
|
||||
if len(cands) == 0 {
|
||||
continue
|
||||
}
|
||||
winnerIdx := 0
|
||||
for i := 1; i < len(cands); i++ {
|
||||
if betterStrmVideoCandidate(cands[i], cands[winnerIdx], st.cfg.VideoExt) {
|
||||
winnerIdx = i
|
||||
}
|
||||
}
|
||||
winner := cands[winnerIdx]
|
||||
if len(cands) > 1 {
|
||||
skippedRels := make([]string, 0, len(cands)-1)
|
||||
skippedRefs := make([]string, 0, len(cands)-1)
|
||||
for i, c := range cands {
|
||||
if i == winnerIdx {
|
||||
continue
|
||||
}
|
||||
skippedRels = append(skippedRels, c.rel)
|
||||
ref := strings.TrimSpace(c.entry.PickCode)
|
||||
if ref == "" {
|
||||
ref = strings.TrimSpace(c.entry.ID)
|
||||
}
|
||||
if ref != "" {
|
||||
skippedRefs = append(skippedRefs, ref)
|
||||
}
|
||||
}
|
||||
st.s.log.Info("strm multi-version prefer",
|
||||
zap.String("base", base),
|
||||
zap.String("winner", winner.rel),
|
||||
zap.String("winner_ref", firstNonEmpty(winner.entry.PickCode, winner.entry.ID)),
|
||||
zap.Int64("winner_size", winner.entry.Size),
|
||||
zap.Strings("skipped", skippedRels),
|
||||
zap.Strings("skipped_refs", skippedRefs),
|
||||
)
|
||||
st.mu.Lock()
|
||||
st.rec.Skipped += int64(len(cands) - 1)
|
||||
st.mu.Unlock()
|
||||
}
|
||||
// 候选已在 handleVideo 计入 Total,此处不再重复 touchProgress
|
||||
st.writeVideoStrm(winner.entry, winner.rel, winner.ext, base+".strm", false)
|
||||
}
|
||||
}
|
||||
|
||||
// betterStrmVideoCandidate 决定 prefer 模式下的赢家:体积更大 → mtime 更新 → video_ext 列表更靠前。
|
||||
func betterStrmVideoCandidate(candidate, current remoteVideoCandidate, videoExtOrder []string) bool {
|
||||
if candidate.entry.Size != current.entry.Size {
|
||||
return candidate.entry.Size > current.entry.Size
|
||||
}
|
||||
if candidate.entry.MTime != current.entry.MTime {
|
||||
return candidate.entry.MTime > current.entry.MTime
|
||||
}
|
||||
return strmExtPriority(candidate.ext, videoExtOrder) < strmExtPriority(current.ext, videoExtOrder)
|
||||
}
|
||||
|
||||
func strmExtPriority(ext string, order []string) int {
|
||||
ext = strings.TrimPrefix(strings.ToLower(strings.TrimSpace(ext)), ".")
|
||||
for i, item := range order {
|
||||
if strings.TrimPrefix(strings.ToLower(strings.TrimSpace(item)), ".") == ext {
|
||||
return i
|
||||
}
|
||||
}
|
||||
return len(order) + 1
|
||||
}
|
||||
|
||||
// writeVideoStrm 把单个视频写成目标 .strm(targetRel 相对本地输出根)。
|
||||
// countProgress 控制是否计入 Total(prefer 模式下候选已在收集阶段计数)。
|
||||
func (st *strmSyncState) writeVideoStrm(entry cloud.FileEntry, rel, ext, targetRel string, countProgress bool) {
|
||||
seenKey := "v:" + strings.TrimSuffix(targetRel, ".strm")
|
||||
st.mu.Lock()
|
||||
if st.seenVideo[seenKey] {
|
||||
st.mu.Unlock()
|
||||
if countProgress {
|
||||
st.touchProgress()
|
||||
}
|
||||
return
|
||||
}
|
||||
st.seenVideo[seenKey] = true
|
||||
st.mu.Unlock()
|
||||
|
||||
targetRel := relSansExt + ".strm"
|
||||
target, err := joinLocalRel(st.p.LocalPath, targetRel)
|
||||
if err != nil {
|
||||
st.s.log.Warn("strm target path out of root", zap.String("rel", targetRel), zap.Error(err))
|
||||
if countProgress {
|
||||
st.touchProgress()
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
@@ -1219,28 +1332,35 @@ func (st *strmSyncState) handleVideo(entry cloud.FileEntry, rel, ext string) {
|
||||
}
|
||||
if _, exists := st.seenVideoTarget[target]; exists {
|
||||
st.mu.Unlock()
|
||||
st.touchProgress()
|
||||
if countProgress {
|
||||
st.touchProgress()
|
||||
}
|
||||
return
|
||||
}
|
||||
st.seenVideoTarget[target] = entry
|
||||
st.mu.Unlock()
|
||||
|
||||
done := func() {
|
||||
if countProgress {
|
||||
st.touchProgress()
|
||||
}
|
||||
}
|
||||
|
||||
// 增量同步模式快速检查:本地 strm 文件存在、非空且修改时间与远端 mtime 一致,直接跳过无需读磁盘
|
||||
if st.syncType == model.StrmSyncTypeIncremental && entry.MTime > 0 {
|
||||
if info, err := os.Stat(target); err == nil && info.Size() > 0 && info.ModTime().Unix() == entry.MTime {
|
||||
st.mu.Lock()
|
||||
st.rec.Skipped++
|
||||
st.mu.Unlock()
|
||||
st.touchProgress()
|
||||
done()
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
content, err := st.strmContent(entry, rel, ext)
|
||||
if err != nil {
|
||||
// 并发 worker 下 rec.Message 无锁写会有数据竞争,这里仅记录日志;
|
||||
// 最终同步结果的 message 由 finishSync 统一填充。
|
||||
st.s.log.Warn("build strm content failed", zap.String("file", rel), zap.Error(err))
|
||||
done()
|
||||
return
|
||||
}
|
||||
existing := ""
|
||||
@@ -1248,7 +1368,6 @@ func (st *strmSyncState) handleVideo(entry cloud.FileEntry, rel, ext string) {
|
||||
existing = string(data)
|
||||
}
|
||||
if existing == content {
|
||||
// 对齐本地 strm 修改时间为远端 mtime,便于后续秒级比对
|
||||
if entry.MTime > 0 {
|
||||
mTime := time.Unix(entry.MTime, 0)
|
||||
_ = os.Chtimes(target, mTime, mTime)
|
||||
@@ -1256,21 +1375,24 @@ func (st *strmSyncState) handleVideo(entry cloud.FileEntry, rel, ext string) {
|
||||
st.mu.Lock()
|
||||
st.rec.Skipped++
|
||||
st.mu.Unlock()
|
||||
st.touchProgress()
|
||||
done()
|
||||
return
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Dir(target), 0o755); err != nil {
|
||||
st.s.log.Warn("mkdir strm dir failed", zap.String("dir", filepath.Dir(target)), zap.Error(err))
|
||||
done()
|
||||
return
|
||||
}
|
||||
tmp := target + ".tmp"
|
||||
if err := os.WriteFile(tmp, []byte(content), 0o644); err != nil {
|
||||
st.s.log.Warn("write strm tmp failed", zap.String("file", target), zap.Error(err))
|
||||
done()
|
||||
return
|
||||
}
|
||||
if err := os.Rename(tmp, target); err != nil {
|
||||
_ = os.Remove(tmp)
|
||||
st.s.log.Warn("rename strm failed", zap.String("file", target), zap.Error(err))
|
||||
done()
|
||||
return
|
||||
}
|
||||
if entry.MTime > 0 {
|
||||
@@ -1280,7 +1402,7 @@ func (st *strmSyncState) handleVideo(entry cloud.FileEntry, rel, ext string) {
|
||||
st.mu.Lock()
|
||||
st.rec.NewStrm++
|
||||
st.mu.Unlock()
|
||||
st.touchProgress()
|
||||
done()
|
||||
}
|
||||
|
||||
// strmContent 构建 strm 文件内容(一行指向本服务播放端点的 URL)。
|
||||
@@ -1534,50 +1656,8 @@ func (st *strmSyncState) walkLocalSource() error {
|
||||
st.touchProgress()
|
||||
return nil
|
||||
}
|
||||
relSansExt := rel[:len(rel)-len(ext)]
|
||||
st.mu.Lock()
|
||||
st.seenVideo["v:"+relSansExt] = true
|
||||
st.mu.Unlock()
|
||||
content, err := st.strmContent(cloud.FileEntry{Name: filepath.Base(rel), Size: info.Size()}, rel, ext)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
target, err := joinLocalRel(st.p.LocalPath, relSansExt+".strm")
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
mTime := info.ModTime()
|
||||
if st.syncType == model.StrmSyncTypeIncremental {
|
||||
if tInfo, err := os.Stat(target); err == nil && tInfo.Size() > 0 && tInfo.ModTime().Unix() == mTime.Unix() {
|
||||
st.mu.Lock()
|
||||
st.rec.Skipped++
|
||||
st.mu.Unlock()
|
||||
st.touchProgress()
|
||||
return nil
|
||||
}
|
||||
}
|
||||
if data, err := os.ReadFile(target); err == nil && string(data) == content {
|
||||
_ = os.Chtimes(target, mTime, mTime)
|
||||
st.mu.Lock()
|
||||
st.rec.Skipped++
|
||||
st.mu.Unlock()
|
||||
st.touchProgress()
|
||||
return nil
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Dir(target), 0o755); err != nil {
|
||||
return nil
|
||||
}
|
||||
tmp := target + ".tmp"
|
||||
if err := os.WriteFile(tmp, []byte(content), 0o644); err == nil {
|
||||
_ = os.Rename(tmp, target)
|
||||
_ = os.Chtimes(target, mTime, mTime)
|
||||
} else {
|
||||
_ = os.Remove(tmp)
|
||||
}
|
||||
st.mu.Lock()
|
||||
st.rec.NewStrm++
|
||||
st.mu.Unlock()
|
||||
st.touchProgress()
|
||||
entry := cloud.FileEntry{Name: filepath.Base(rel), Size: info.Size(), MTime: info.ModTime().Unix()}
|
||||
st.handleVideo(entry, rel, ext)
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1570,3 +1570,84 @@ func TestScanLocalMetaForUploadRequeuesWhenRecentDoneSizeChanged(t *testing.T) {
|
||||
t.Fatal("expected a pending upload task for resized local file")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleVideoPreferPicksLargestAndSkipsOthers(t *testing.T) {
|
||||
svc := testStrmService(t)
|
||||
local := t.TempDir()
|
||||
p := syncPathRecord(t, svc, model.StrmProviderLocal, t.TempDir(), local, true)
|
||||
p.KeepExt = false
|
||||
st := &strmSyncState{
|
||||
s: svc,
|
||||
ctx: context.Background(),
|
||||
p: p,
|
||||
cfg: &strmPathConfig{BaseURL: "http://test.local:8096", VideoExt: csvSplit(StrmDefaultVideoExt), AddPath: 1, KeepExt: false},
|
||||
rec: &model.StrmSyncRecord{},
|
||||
syncType: model.StrmSyncTypeFull,
|
||||
seenVideo: map[string]bool{},
|
||||
seenVideoTarget: map[string]cloud.FileEntry{},
|
||||
remoteVideos: map[string][]remoteVideoCandidate{},
|
||||
}
|
||||
st.handleVideo(cloud.FileEntry{ID: "1", Name: "竞女01.mp4", Size: 100, PickCode: "pc-mp4", MTime: 1000}, "竞女01.mp4", ".mp4")
|
||||
st.handleVideo(cloud.FileEntry{ID: "2", Name: "竞女01.mkv", Size: 500, PickCode: "pc-mkv", MTime: 900}, "竞女01.mkv", ".mkv")
|
||||
st.flushPreferredVideos()
|
||||
|
||||
preferPath := filepath.Join(local, "竞女01.strm")
|
||||
if _, err := os.Stat(preferPath); err != nil {
|
||||
t.Fatalf("expected prefer strm at %s: %v", preferPath, err)
|
||||
}
|
||||
data, err := os.ReadFile(preferPath)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
content := string(data)
|
||||
if !strings.Contains(content, "video.mkv") {
|
||||
t.Fatalf("prefer should pick larger mkv, content=%s", content)
|
||||
}
|
||||
if !strings.Contains(content, "%E7%AB%9E%E5%A5%B301.mkv") && !strings.Contains(content, "竞女01.mkv") {
|
||||
t.Fatalf("prefer strm should point at winner path, content=%s", content)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(local, "竞女01.mkv.strm")); !os.IsNotExist(err) {
|
||||
t.Fatal("prefer mode must not write keep_ext names")
|
||||
}
|
||||
if st.rec.NewStrm != 1 {
|
||||
t.Fatalf("NewStrm=%d want 1", st.rec.NewStrm)
|
||||
}
|
||||
if st.rec.Skipped < 1 {
|
||||
t.Fatalf("Skipped=%d want >=1 for loser", st.rec.Skipped)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleVideoKeepExtWritesAllVersions(t *testing.T) {
|
||||
svc := testStrmService(t)
|
||||
local := t.TempDir()
|
||||
p := syncPathRecord(t, svc, model.StrmProviderLocal, t.TempDir(), local, true)
|
||||
p.KeepExt = true
|
||||
st := &strmSyncState{
|
||||
s: svc,
|
||||
ctx: context.Background(),
|
||||
p: p,
|
||||
cfg: &strmPathConfig{BaseURL: "http://test.local:8096", VideoExt: csvSplit(StrmDefaultVideoExt), AddPath: 1, KeepExt: true},
|
||||
rec: &model.StrmSyncRecord{},
|
||||
syncType: model.StrmSyncTypeFull,
|
||||
seenVideo: map[string]bool{},
|
||||
seenVideoTarget: map[string]cloud.FileEntry{},
|
||||
remoteVideos: map[string][]remoteVideoCandidate{},
|
||||
}
|
||||
st.handleVideo(cloud.FileEntry{ID: "1", Name: "竞女01.mp4", Size: 100, PickCode: "pc-mp4"}, "竞女01.mp4", ".mp4")
|
||||
st.handleVideo(cloud.FileEntry{ID: "2", Name: "竞女01.mkv", Size: 500, PickCode: "pc-mkv"}, "竞女01.mkv", ".mkv")
|
||||
st.flushPreferredVideos()
|
||||
|
||||
mkvPath := filepath.Join(local, "竞女01.mkv.strm")
|
||||
mp4Path := filepath.Join(local, "竞女01.mp4.strm")
|
||||
for _, path := range []string{mkvPath, mp4Path} {
|
||||
if _, err := os.Stat(path); err != nil {
|
||||
t.Fatalf("expected keep_ext strm %s: %v", path, err)
|
||||
}
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(local, "竞女01.strm")); !os.IsNotExist(err) {
|
||||
t.Fatal("keep_ext mode must not write stripped name.strm")
|
||||
}
|
||||
if st.rec.NewStrm != 2 {
|
||||
t.Fatalf("NewStrm=%d want 2", st.rec.NewStrm)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -142,7 +142,10 @@ func (s *SubtitleService) discoverUncached(ctx context.Context, mediaID string)
|
||||
return nil, errors.New("media not found")
|
||||
}
|
||||
dir := filepath.Dir(m.Path)
|
||||
base := strings.TrimSuffix(filepath.Base(m.Path), filepath.Ext(m.Path))
|
||||
bases := mediaSidecarBaseVariants(m.Path)
|
||||
if len(bases) == 0 {
|
||||
bases = []string{strings.TrimSuffix(filepath.Base(m.Path), filepath.Ext(m.Path))}
|
||||
}
|
||||
|
||||
candidates := make([]string, 0, 16)
|
||||
candidates = append(candidates, dir)
|
||||
@@ -166,13 +169,23 @@ func (s *SubtitleService) discoverUncached(ctx context.Context, mediaID string)
|
||||
continue
|
||||
}
|
||||
fullName := strings.TrimSuffix(e.Name(), ext)
|
||||
if !strings.HasPrefix(strings.ToLower(fullName), strings.ToLower(base)) &&
|
||||
c == dir {
|
||||
// In the same directory we require a basename match;
|
||||
// inside subs/ subdirs we accept anything.
|
||||
continue
|
||||
matchedBase := ""
|
||||
if c == dir {
|
||||
for _, base := range bases {
|
||||
if strings.HasPrefix(strings.ToLower(fullName), strings.ToLower(base)) {
|
||||
matchedBase = base
|
||||
break
|
||||
}
|
||||
}
|
||||
if matchedBase == "" {
|
||||
// In the same directory we require a basename match;
|
||||
// inside subs/ subdirs we accept anything.
|
||||
continue
|
||||
}
|
||||
} else if len(bases) > 0 {
|
||||
matchedBase = bases[0]
|
||||
}
|
||||
lang := detectLang(fullName, base)
|
||||
lang := detectLang(fullName, matchedBase)
|
||||
tracks = append(tracks, SubtitleTrack{
|
||||
Lang: lang,
|
||||
Label: lang,
|
||||
|
||||
@@ -25,8 +25,10 @@ package service
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
@@ -34,6 +36,7 @@ import (
|
||||
|
||||
"github.com/truewhile/MeBox/internal/config"
|
||||
"github.com/truewhile/MeBox/internal/helper"
|
||||
"github.com/truewhile/MeBox/internal/model"
|
||||
"github.com/truewhile/MeBox/internal/repository"
|
||||
)
|
||||
|
||||
@@ -44,8 +47,9 @@ type TranscoderService struct {
|
||||
repo *repository.Container
|
||||
hub *Hub
|
||||
|
||||
mu sync.Mutex
|
||||
jobs map[string]*hlsJob
|
||||
mu sync.Mutex
|
||||
jobs map[string]*hlsJob
|
||||
strmResolve func(ctx context.Context, raw string) (*StrmPlayResult, error)
|
||||
}
|
||||
|
||||
// hlsJob holds the live state of one ffmpeg run.
|
||||
@@ -102,14 +106,17 @@ func (t *TranscoderService) EnsureJob(ctx context.Context, mediaID string) (stri
|
||||
if m == nil {
|
||||
return "", ErrMediaNotFound
|
||||
}
|
||||
// .strm 媒体(STRMURL 或 container=strm / *.strm 路径)的内容是远程
|
||||
// 直链文本,ffmpeg 无法读取,转码必然失败且白白消耗资源。直接拒绝
|
||||
// 转码,迫使播放器走 /api/stream 302 直连播放。
|
||||
if isStrmMediaRow(m) {
|
||||
return "", ErrTranscodeDisabled
|
||||
t.mu.Lock()
|
||||
if _, ok := t.jobs[mediaID]; ok {
|
||||
t.touchJobLocked(mediaID)
|
||||
t.mu.Unlock()
|
||||
return t.PlaylistPath(mediaID), nil
|
||||
}
|
||||
if _, err := os.Stat(m.Path); err != nil {
|
||||
return "", ErrMediaNotFound
|
||||
t.mu.Unlock()
|
||||
|
||||
input, err := t.resolveTranscodeInput(ctx, m)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if _, err := t.resolveFFmpegPath(); err != nil {
|
||||
return "", err
|
||||
@@ -145,6 +152,72 @@ func (t *TranscoderService) EnsureJob(ctx context.Context, mediaID string) (stri
|
||||
t.mu.Unlock()
|
||||
|
||||
helper.Go(t.log, "transcoder.monitorIdle", func() { t.monitorIdle(jobCtx, job) })
|
||||
helper.Go(t.log, "transcoder.ffmpeg", func() { t.runFFmpeg(jobCtx, job, m.Path) })
|
||||
helper.Go(t.log, "transcoder.ffmpeg", func() { t.runFFmpeg(jobCtx, job, input) })
|
||||
return t.PlaylistPath(mediaID), nil
|
||||
}
|
||||
|
||||
// SetStrmPlayTargetResolver wires STRM URL resolution so ffmpeg can transcode
|
||||
// remote .strm media (HTTP 直链 or local source path) after direct play fails.
|
||||
func (t *TranscoderService) SetStrmPlayTargetResolver(resolve func(ctx context.Context, raw string) (*StrmPlayResult, error)) {
|
||||
if t == nil {
|
||||
return
|
||||
}
|
||||
t.strmResolve = resolve
|
||||
}
|
||||
|
||||
func (t *TranscoderService) resolveTranscodeInput(ctx context.Context, m *model.Media) (transcodeInput, error) {
|
||||
if m == nil {
|
||||
return transcodeInput{}, ErrMediaNotFound
|
||||
}
|
||||
if !isStrmMediaRow(m) {
|
||||
if _, err := os.Stat(m.Path); err != nil {
|
||||
return transcodeInput{}, ErrMediaNotFound
|
||||
}
|
||||
return transcodeInput{Source: m.Path}, nil
|
||||
}
|
||||
raw := strings.TrimSpace(m.STRMURL)
|
||||
if raw == "" && strings.HasSuffix(strings.ToLower(strings.TrimSpace(m.Path)), ".strm") {
|
||||
parsed, err := readLocalSTRMTarget(m.Path)
|
||||
if err != nil || strings.TrimSpace(parsed) == "" {
|
||||
return transcodeInput{}, fmt.Errorf("strm play target missing")
|
||||
}
|
||||
raw = parsed
|
||||
}
|
||||
if raw == "" {
|
||||
return transcodeInput{}, fmt.Errorf("strm play target missing")
|
||||
}
|
||||
if t != nil && t.strmResolve != nil {
|
||||
src, err := t.strmResolve(ctx, raw)
|
||||
if err != nil {
|
||||
return transcodeInput{}, err
|
||||
}
|
||||
return transcodeInputFromPlayResult(src)
|
||||
}
|
||||
if isHTTPPlaybackTarget(raw) {
|
||||
return transcodeInput{Source: raw}, nil
|
||||
}
|
||||
return transcodeInput{}, fmt.Errorf("strm transcode source unavailable")
|
||||
}
|
||||
|
||||
func transcodeInputFromPlayResult(src *StrmPlayResult) (transcodeInput, error) {
|
||||
if src == nil {
|
||||
return transcodeInput{}, fmt.Errorf("strm transcode source unavailable")
|
||||
}
|
||||
if path := strings.TrimSpace(src.LocalPath); path != "" {
|
||||
if _, err := os.Stat(path); err != nil {
|
||||
return transcodeInput{}, ErrMediaNotFound
|
||||
}
|
||||
return transcodeInput{Source: path}, nil
|
||||
}
|
||||
if url := strings.TrimSpace(src.RedirectURL); url != "" {
|
||||
in := transcodeInput{Source: url}
|
||||
if src.Link != nil {
|
||||
in.Headers = src.Link.Headers
|
||||
}
|
||||
return in, nil
|
||||
}
|
||||
if src.Link != nil && strings.TrimSpace(src.Link.URL) != "" {
|
||||
return transcodeInput{Source: src.Link.URL, Headers: src.Link.Headers}, nil
|
||||
}
|
||||
return transcodeInput{}, fmt.Errorf("strm transcode source unavailable")
|
||||
}
|
||||
|
||||
@@ -2,11 +2,19 @@ package service
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/url"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/truewhile/MeBox/internal/config"
|
||||
)
|
||||
|
||||
type transcodeInput struct {
|
||||
Source string
|
||||
Headers map[string]string
|
||||
}
|
||||
|
||||
type ffmpegArgSettings struct {
|
||||
encoder string
|
||||
bitrate string
|
||||
@@ -31,11 +39,15 @@ type ffmpegVideoPlan struct {
|
||||
// encoder. The function is package-level so the unit test can pin its
|
||||
// behaviour without spawning a real ffmpeg process.
|
||||
func buildFFmpegArgs(cfg *config.Config, source, playlist, segments string) []string {
|
||||
return buildFFmpegArgsForInput(cfg, transcodeInput{Source: source}, playlist, segments)
|
||||
}
|
||||
|
||||
func buildFFmpegArgsForInput(cfg *config.Config, input transcodeInput, playlist, segments string) []string {
|
||||
settings := ffmpegArgSettingsFromConfig(cfg)
|
||||
video := ffmpegVideoPlanForSettings(settings)
|
||||
|
||||
args := baseFFmpegArgs(video.preInput, settings.realtime)
|
||||
args = appendInputAndVideoArgs(args, source, settings, video)
|
||||
args = appendInputAndVideoArgs(args, input, settings, video)
|
||||
args = appendOutputHLSArgs(args, settings, segments, playlist)
|
||||
return args
|
||||
}
|
||||
@@ -111,8 +123,9 @@ func baseFFmpegArgs(preInput string, realtime bool) []string {
|
||||
return args
|
||||
}
|
||||
|
||||
func appendInputAndVideoArgs(args []string, source string, settings ffmpegArgSettings, video ffmpegVideoPlan) []string {
|
||||
args = append(args, "-i", source, "-map", "0:v:0?", "-map", "0:a:0?", "-vf", video.filter, "-c:v", video.codec)
|
||||
func appendInputAndVideoArgs(args []string, input transcodeInput, settings ffmpegArgSettings, video ffmpegVideoPlan) []string {
|
||||
args = append(args, ffmpegHTTPInputArgs(input)...)
|
||||
args = append(args, "-i", input.Source, "-map", "0:v:0?", "-map", "0:a:0?", "-vf", video.filter, "-c:v", video.codec)
|
||||
if settings.threads > 0 && video.codec == "libx264" {
|
||||
args = append(args, "-threads", strconv.Itoa(settings.threads))
|
||||
}
|
||||
@@ -164,3 +177,43 @@ func splitNonEmptyArgs(s string) []string {
|
||||
flush()
|
||||
return out
|
||||
}
|
||||
|
||||
func ffmpegHTTPInputArgs(input transcodeInput) []string {
|
||||
if !isHTTPSource(input.Source) {
|
||||
return nil
|
||||
}
|
||||
args := []string{"-reconnect", "1", "-reconnect_streamed", "1", "-reconnect_delay_max", "2"}
|
||||
if len(input.Headers) == 0 {
|
||||
return args
|
||||
}
|
||||
keys := make([]string, 0, len(input.Headers))
|
||||
for key := range input.Headers {
|
||||
keys = append(keys, key)
|
||||
}
|
||||
sort.Strings(keys)
|
||||
lines := make([]string, 0, len(keys))
|
||||
for _, key := range keys {
|
||||
value := strings.TrimSpace(input.Headers[key])
|
||||
if strings.TrimSpace(key) == "" || value == "" {
|
||||
continue
|
||||
}
|
||||
lines = append(lines, key+": "+value)
|
||||
}
|
||||
if len(lines) == 0 {
|
||||
return args
|
||||
}
|
||||
return append(args, "-headers", strings.Join(lines, "\r\n")+"\r\n")
|
||||
}
|
||||
|
||||
func isHTTPSource(source string) bool {
|
||||
u, err := url.Parse(strings.TrimSpace(source))
|
||||
if err != nil || u == nil {
|
||||
return false
|
||||
}
|
||||
switch strings.ToLower(strings.TrimSpace(u.Scheme)) {
|
||||
case "http", "https":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,7 +13,7 @@ import (
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
func (t *TranscoderService) runFFmpeg(ctx context.Context, job *hlsJob, source string) {
|
||||
func (t *TranscoderService) runFFmpeg(ctx context.Context, job *hlsJob, input transcodeInput) {
|
||||
bin, err := t.resolveFFmpegPath()
|
||||
if err != nil {
|
||||
t.log.Warn("ffmpeg unavailable", zap.String("media_id", job.mediaID), zap.Error(err))
|
||||
@@ -31,7 +31,7 @@ func (t *TranscoderService) runFFmpeg(ctx context.Context, job *hlsJob, source s
|
||||
playlist := filepath.Join(job.outputDir, "index.m3u8")
|
||||
segments := filepath.Join(job.outputDir, "seg_%05d.ts")
|
||||
|
||||
args := buildFFmpegArgs(t.cfg, source, playlist, segments)
|
||||
args := buildFFmpegArgsForInput(t.cfg, input, playlist, segments)
|
||||
|
||||
cmd := exec.CommandContext(ctx, bin, args...) // #nosec G204 -- bin is resolved by resolveFFmpegPath and args are passed without a shell.
|
||||
cmd.Stderr = os.Stderr
|
||||
@@ -39,7 +39,7 @@ func (t *TranscoderService) runFFmpeg(ctx context.Context, job *hlsJob, source s
|
||||
t.log.Info("transcode started",
|
||||
zap.String("media_id", job.mediaID),
|
||||
zap.String("encoder", job.encoder),
|
||||
zap.String("source", source),
|
||||
zap.String("source", input.Source),
|
||||
)
|
||||
t.hub.Publish("transcode", map[string]any{
|
||||
"media_id": job.mediaID,
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/truewhile/MeBox/internal/config"
|
||||
"github.com/truewhile/MeBox/internal/model"
|
||||
"github.com/truewhile/MeBox/internal/service/cloud"
|
||||
)
|
||||
|
||||
func TestBuildFFmpegArgs(t *testing.T) {
|
||||
@@ -107,3 +110,86 @@ func TestHasFFmpegListEntry(t *testing.T) {
|
||||
t.Fatal("must match whole ffmpeg list entries only")
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveTranscodeInputHTTPSTRM(t *testing.T) {
|
||||
svc := &TranscoderService{}
|
||||
got, err := svc.resolveTranscodeInput(context.Background(), &model.Media{
|
||||
Container: "strm",
|
||||
STRMURL: "https://cdn.example.com/a.wmv",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got.Source != "https://cdn.example.com/a.wmv" {
|
||||
t.Fatalf("source = %q", got.Source)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveTranscodeInputUsesResolver(t *testing.T) {
|
||||
svc := &TranscoderService{}
|
||||
svc.SetStrmPlayTargetResolver(func(_ context.Context, raw string) (*StrmPlayResult, error) {
|
||||
if raw != "/api/strm/play/cloud115/a.wmv?acct=1&pickcode=x" {
|
||||
t.Fatalf("raw = %q", raw)
|
||||
}
|
||||
return &StrmPlayResult{
|
||||
RedirectURL: "https://cdn.example.com/a.wmv",
|
||||
Link: &cloud.DirectLink{
|
||||
URL: "https://cdn.example.com/a.wmv",
|
||||
Headers: map[string]string{"User-Agent": "Mozilla/5.0"},
|
||||
},
|
||||
}, nil
|
||||
})
|
||||
got, err := svc.resolveTranscodeInput(context.Background(), &model.Media{
|
||||
Container: "strm",
|
||||
STRMURL: "/api/strm/play/cloud115/a.wmv?acct=1&pickcode=x",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got.Source != "https://cdn.example.com/a.wmv" {
|
||||
t.Fatalf("source = %q", got.Source)
|
||||
}
|
||||
if got.Headers["User-Agent"] != "Mozilla/5.0" {
|
||||
t.Fatalf("headers = %#v", got.Headers)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveTranscodeInputRejectsUnresolvedRelativeSTRM(t *testing.T) {
|
||||
svc := &TranscoderService{}
|
||||
_, err := svc.resolveTranscodeInput(context.Background(), &model.Media{
|
||||
Container: "strm",
|
||||
STRMURL: "/api/strm/play/cloud115/a.wmv?acct=1&pickcode=x",
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected unresolved relative strm to fail")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildFFmpegArgsHTTPInputReconnect(t *testing.T) {
|
||||
cfg := &config.Config{}
|
||||
cfg.Transcoder.MaxHeight = 720
|
||||
cfg.Transcoder.SegmentSeconds = 4
|
||||
args := buildFFmpegArgsForInput(cfg, transcodeInput{
|
||||
Source: "https://cdn.example.com/a.wmv",
|
||||
Headers: map[string]string{"User-Agent": "MeBox", "Referer": "https://cdn.example.com/"},
|
||||
}, "/o/x.m3u8", "/o/seg_%05d.ts")
|
||||
joined := strings.Join(args, " ")
|
||||
if !strings.Contains(joined, "-reconnect") || !strings.Contains(joined, "-headers") {
|
||||
t.Fatalf("expected http reconnect/headers, got: %s", joined)
|
||||
}
|
||||
if !strings.Contains(joined, "User-Agent: MeBox") || !strings.Contains(joined, "Referer: https://cdn.example.com/") {
|
||||
t.Fatalf("expected request headers, got: %s", joined)
|
||||
}
|
||||
idxI, idxH := -1, -1
|
||||
for i, arg := range args {
|
||||
if arg == "-i" && idxI < 0 {
|
||||
idxI = i
|
||||
}
|
||||
if arg == "-headers" {
|
||||
idxH = i
|
||||
}
|
||||
}
|
||||
if idxI < 0 || idxH < 0 || idxH > idxI {
|
||||
t.Fatalf("http flags must come before -i, args=%v", args)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 { ManualScrapeDialog } from '../components/ManualScrapeDialog'
|
||||
import { MediaVersionSwitcher } from '../components/MediaVersionSwitcher'
|
||||
import { MetadataEditDialog } from '../components/MetadataEditDialog'
|
||||
import { OrganizeMediaDialog } from '../components/OrganizeMediaDialog'
|
||||
import type { Media } from '../types'
|
||||
@@ -163,6 +164,7 @@ export function MediaDetailMainContent({
|
||||
onToggleFavourite={onToggleFavourite}
|
||||
playTargetId={playTargetId}
|
||||
/>
|
||||
<MediaVersionSwitcher media={media} mode="detail" />
|
||||
{isAdmin && (
|
||||
<MediaDetailAdminPanel
|
||||
media={media}
|
||||
|
||||
@@ -18,6 +18,8 @@ import { PlayerTopBar } from './PlayerTopBar'
|
||||
import { PlayerVideoStage } from './PlayerVideoStage'
|
||||
import { PlayerDanmakuPanel } from '../components/PlayerDanmakuPanel'
|
||||
import { PlayerPlaylistPanel } from '../components/PlayerPlaylistPanel'
|
||||
import { MediaVersionSwitcher } from '../components/MediaVersionSwitcher'
|
||||
import { mediaVersionsOf } from '../utils/mediaVersion'
|
||||
|
||||
// Fullscreen, dark-themed video page.
|
||||
//
|
||||
@@ -26,7 +28,8 @@ import { PlayerPlaylistPanel } from '../components/PlayerPlaylistPanel'
|
||||
//
|
||||
// We pick a sensible default based on the source codec: H.264 + AAC in
|
||||
// MP4 / WebM containers play directly; everything else (HEVC, MKV, AV1,
|
||||
// AC3 audio, …) gets routed through ffmpeg → HLS.
|
||||
// AC3 audio, …) gets routed through ffmpeg → HLS. STRM / 云盘直链默认直连,
|
||||
// 浏览器播不了时再切 HLS。远程 Emby 挂载只能直连。
|
||||
//
|
||||
// External subtitles next to the source file are auto-discovered and
|
||||
// attached as <track> elements.
|
||||
@@ -206,7 +209,7 @@ export function PlayerPage() {
|
||||
setMedia(m)
|
||||
const isDirect = isDirectStreamMedia(m)
|
||||
const auto = pickPlayerMode(m)
|
||||
// 直连解码模式以及 STRM / Emby 挂载等直连媒体,忽略 ?mode=hls,始终 direct play。
|
||||
// 直连解码模式以及远程 Emby 挂载忽略 ?mode=hls。STRM 默认直连,但允许手动/失败后切 HLS。
|
||||
setMode(directOnly || isDirect ? 'direct' : (modeParam ?? auto))
|
||||
setPlayerError('')
|
||||
setLoadError('')
|
||||
@@ -420,6 +423,15 @@ export function PlayerPage() {
|
||||
[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(() => {
|
||||
if (prevEpisode) {
|
||||
playEpisode(prevEpisode)
|
||||
@@ -528,12 +540,9 @@ export function PlayerPage() {
|
||||
// 浏览器对 <video src> 的错误描述非常有限,把详细原因
|
||||
// 转给开发者控制台 + 一条 toast;常见原因是 codec 不支持。
|
||||
if (mode === 'direct') {
|
||||
if (isRemoteEmbyID(media?.id)) {
|
||||
if (isRemoteEmbyID(media?.id) || isDirectStreamMedia(media)) {
|
||||
setPlayerError('直接播放失败。该媒体为远程 Emby 挂载直连播放(不进行转码);当前浏览器可能不支持该视频编码或音频格式,建议使用外部播放器(如 PotPlayer / VLC / IINA)播放。')
|
||||
toast.error('直接播放失败,建议使用外部播放器')
|
||||
} else if (isDirectStreamMedia(media)) {
|
||||
setPlayerError('直接播放失败。该媒体为 STRM 远程直连播放(不进行转码);当前浏览器可能不支持该视频编码或音频格式,建议使用外部播放器播放。')
|
||||
toast.error('直接播放失败,建议使用外部播放器')
|
||||
} else if (directOnly) {
|
||||
setPlayerError('直接播放失败。当前为「客户端直连解码」模式,宿主机不转码;请使用支持该编码/封装的播放器(如 Infuse / VLC / Emby 客户端)播放,或关闭直连解码模式。')
|
||||
toast.error('直接播放失败(客户端直连解码模式)')
|
||||
@@ -564,17 +573,18 @@ export function PlayerPage() {
|
||||
<PlayerTopBar
|
||||
directOnly={directOnly}
|
||||
isDirectStream={isDirectStream}
|
||||
directStreamLabel={
|
||||
isRemoteEmbyID(media?.id)
|
||||
? 'Emby 直连播放'
|
||||
: isDirectStream
|
||||
? 'STRM 直连播放'
|
||||
: undefined
|
||||
}
|
||||
directStreamLabel={isRemoteEmbyID(media?.id) ? 'Emby 直连播放' : undefined}
|
||||
mode={mode}
|
||||
onBack={goBack}
|
||||
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
|
||||
media={media}
|
||||
loadError={loadError}
|
||||
|
||||
@@ -509,6 +509,12 @@ const SETTING_DEFS: SettingDef[] = [
|
||||
{ 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.download_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,
|
||||
upload_meta: existing?.upload_meta ?? false,
|
||||
delete_dir: existing?.delete_dir ?? false,
|
||||
keep_ext: existing?.keep_ext ?? false,
|
||||
cron: existing?.cron ?? '',
|
||||
enable_cron: existing?.enable_cron ?? false,
|
||||
sync_mode: existing?.sync_mode ?? 'incremental',
|
||||
@@ -628,6 +635,19 @@ export function StrmSyncPathDialog({
|
||||
// 兼容两类历史数据:新版保存的 local_path 末段是目录名,旧版是目录 ID。
|
||||
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]) =>
|
||||
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.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.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.enabled ?? true} onChange={(v) => set('enabled', v)} />
|
||||
</div>
|
||||
|
||||
@@ -8,13 +8,18 @@ const directVideoCodecs = ['h264', 'avc', 'avc1']
|
||||
const directAudioCodecs = ['aac', 'mp3', 'opus']
|
||||
|
||||
/**
|
||||
* 判断媒体是否为远程直链或挂载直连流媒体(STRM 或 Emby 远程挂载)。
|
||||
* 这类媒体服务端 302 重定向到直链或进行原生流中继,本地不具备原始文件,
|
||||
* 无法也不应该进行 ffmpeg 转码,恒走直连播放。
|
||||
* 远程 Emby 挂载:本地没有原始文件,网页端只能直连,不能转码。
|
||||
*/
|
||||
export function isDirectStreamMedia(media?: Media | null): boolean {
|
||||
if (!media) return false
|
||||
if (isRemoteEmbyID(media.id)) return true
|
||||
return isRemoteEmbyID(media.id)
|
||||
}
|
||||
|
||||
/**
|
||||
* STRM / 云盘直链:默认仍走直连;浏览器解不了时再回退 HLS 转码。
|
||||
*/
|
||||
export function isStrmMedia(media?: Media | null): boolean {
|
||||
if (!media) return false
|
||||
const container = (media.container ?? '').toLowerCase()
|
||||
return container.includes('strm') || String(media.strm_url ?? '').trim() !== ''
|
||||
}
|
||||
@@ -24,8 +29,8 @@ export function pickPlayerMode(media: Media): PlayerMode {
|
||||
}
|
||||
|
||||
export function needsTranscodeForBrowser(media: Media): boolean {
|
||||
// Emby 远程挂载与 .strm 媒体一样,均为直连流,无法进行本地转码,恒走 direct play。
|
||||
if (isDirectStreamMedia(media)) return false
|
||||
// Emby 远程挂载无法本地转码。STRM 先直连,失败后再由播放器切 HLS。
|
||||
if (isDirectStreamMedia(media) || isStrmMedia(media)) return false
|
||||
|
||||
const container = (media.container ?? '').toLowerCase()
|
||||
const videoCodec = (media.video_codec ?? '').toLowerCase()
|
||||
|
||||
@@ -70,6 +70,7 @@ export interface StrmSyncPath {
|
||||
download_meta: boolean
|
||||
upload_meta: boolean
|
||||
delete_dir: boolean
|
||||
keep_ext: boolean
|
||||
cron: string
|
||||
enable_cron: boolean
|
||||
sync_mode?: 'incremental' | 'full'
|
||||
@@ -98,6 +99,7 @@ export interface StrmSyncPathInput {
|
||||
download_meta?: boolean
|
||||
upload_meta?: boolean
|
||||
delete_dir?: boolean
|
||||
keep_ext?: boolean
|
||||
cron?: string
|
||||
enable_cron?: boolean
|
||||
sync_mode?: 'incremental' | 'full'
|
||||
@@ -167,6 +169,7 @@ export interface StrmSettingsMap {
|
||||
'strm.download_meta': string
|
||||
'strm.upload_meta': string
|
||||
'strm.delete_dir': string
|
||||
'strm.keep_ext': string
|
||||
'strm.download_threads': string
|
||||
'strm.upload_threads': string
|
||||
[key: string]: string
|
||||
|
||||
@@ -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