mirror of
https://github.com/truewhile/MeBox.git
synced 2026-10-06 21:36:37 +08:00
feat: add STRM tree recognize rename option
This commit is contained in:
@@ -123,6 +123,7 @@ type generateSTRMTreeReq struct {
|
|||||||
Cleanup bool `json:"cleanup"`
|
Cleanup bool `json:"cleanup"`
|
||||||
DryRun bool `json:"dry_run"`
|
DryRun bool `json:"dry_run"`
|
||||||
BatchLimit int `json:"batch_limit"`
|
BatchLimit int `json:"batch_limit"`
|
||||||
|
RecognizeRename bool `json:"recognize_rename"`
|
||||||
TransferSubtitles bool `json:"transfer_subtitles"`
|
TransferSubtitles bool `json:"transfer_subtitles"`
|
||||||
MissingOnly bool `json:"missing_only"`
|
MissingOnly bool `json:"missing_only"`
|
||||||
RefreshLibrary bool `json:"refresh_library"`
|
RefreshLibrary bool `json:"refresh_library"`
|
||||||
@@ -226,6 +227,7 @@ func generateSTRMFromTreeHandler(svc *service.Container) gin.HandlerFunc {
|
|||||||
Cleanup: req.Cleanup,
|
Cleanup: req.Cleanup,
|
||||||
DryRun: req.DryRun,
|
DryRun: req.DryRun,
|
||||||
BatchLimit: req.BatchLimit,
|
BatchLimit: req.BatchLimit,
|
||||||
|
RecognizeRename: req.RecognizeRename,
|
||||||
TransferSubtitles: req.TransferSubtitles,
|
TransferSubtitles: req.TransferSubtitles,
|
||||||
MissingOnly: req.MissingOnly,
|
MissingOnly: req.MissingOnly,
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ type GenerateSTRMTreeOptions struct {
|
|||||||
Cleanup bool `json:"cleanup"`
|
Cleanup bool `json:"cleanup"`
|
||||||
DryRun bool `json:"dry_run"`
|
DryRun bool `json:"dry_run"`
|
||||||
BatchLimit int `json:"batch_limit,omitempty"`
|
BatchLimit int `json:"batch_limit,omitempty"`
|
||||||
|
RecognizeRename bool `json:"recognize_rename,omitempty"`
|
||||||
TransferSubtitles bool `json:"transfer_subtitles,omitempty"`
|
TransferSubtitles bool `json:"transfer_subtitles,omitempty"`
|
||||||
MissingOnly bool `json:"missing_only,omitempty"`
|
MissingOnly bool `json:"missing_only,omitempty"`
|
||||||
}
|
}
|
||||||
@@ -85,7 +86,7 @@ func (s *STRMService) GenerateFromTree(ctx context.Context, opts GenerateSTRMTre
|
|||||||
return result, ctx.Err()
|
return result, ctx.Err()
|
||||||
default:
|
default:
|
||||||
}
|
}
|
||||||
item := generateTreeSTRMItem(outputDir, source, opts, existingRefs)
|
item := s.generateTreeSTRMItem(ctx, outputDir, source, opts, existingRefs)
|
||||||
if item.FilePath != "" && item.Action != "error" {
|
if item.FilePath != "" && item.Action != "error" {
|
||||||
expectedFiles[filepath.Clean(item.FilePath)] = struct{}{}
|
expectedFiles[filepath.Clean(item.FilePath)] = struct{}{}
|
||||||
}
|
}
|
||||||
@@ -117,9 +118,15 @@ func strmTreeBatchLimitReached(result *GenerateSTRMResult, limit int) bool {
|
|||||||
return result.Generated+result.Updated+result.Previewed >= limit
|
return result.Generated+result.Updated+result.Previewed >= limit
|
||||||
}
|
}
|
||||||
|
|
||||||
func generateTreeSTRMItem(outputDir string, source strmTreeSource, opts GenerateSTRMTreeOptions, existingRefs map[string]struct{}) GenerateSTRMItem {
|
func (s *STRMService) generateTreeSTRMItem(ctx context.Context, outputDir string, source strmTreeSource, opts GenerateSTRMTreeOptions, existingRefs map[string]struct{}) GenerateSTRMItem {
|
||||||
relSource := strmTreeRelativeSource(source.Path, opts.SourceRoot)
|
relSource := strmTreeRelativeSource(source.Path, opts.SourceRoot)
|
||||||
relPath, err := strmTreeOutputRelativePath(relSource)
|
relPath, err := strmTreeOutputRelativePath(relSource)
|
||||||
|
if opts.RecognizeRename && source.Kind != strmTreeSourceKindSubtitle {
|
||||||
|
if renamed, renameErr := s.strmTreeRecognizedOutputRelativePath(ctx, source.Path, opts.SourceRoot); renameErr == nil && renamed != "" {
|
||||||
|
relPath = renamed
|
||||||
|
err = nil
|
||||||
|
}
|
||||||
|
}
|
||||||
if source.Kind == strmTreeSourceKindSubtitle {
|
if source.Kind == strmTreeSourceKindSubtitle {
|
||||||
relPath, err = strmTreeOutputSubtitleLinkRelativePath(relSource)
|
relPath, err = strmTreeOutputSubtitleLinkRelativePath(relSource)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -44,6 +44,36 @@ func TestGenerateSTRMFromTreePaths(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestGenerateSTRMFromTreeRecognizeRenameOutputPaths(t *testing.T) {
|
||||||
|
outDir := filepath.Join(t.TempDir(), "strm")
|
||||||
|
svc := NewSTRMService(zap.NewNop(), nil, nil)
|
||||||
|
|
||||||
|
res, err := svc.GenerateFromTree(t.Context(), GenerateSTRMTreeOptions{
|
||||||
|
Provider: "openlist",
|
||||||
|
Paths: []string{"/电视剧/国产剧/南部档案/Season 01/Archives.The.Nanyang.Mystery.S01E02.2160p.WEB-DL.mkv", "/电影/Dune.Part.Two.2024.2160p.WEB-DL.mkv"},
|
||||||
|
SourceRoot: "/电视剧",
|
||||||
|
OutputDir: outDir,
|
||||||
|
RecognizeRename: true,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if res.Generated != 2 || len(res.Errors) != 0 {
|
||||||
|
t.Fatalf("result = %#v, want two generated renamed STRM files", res)
|
||||||
|
}
|
||||||
|
episode := readSTRM(t, filepath.Join(outDir, "南部档案", "Season 01", "南部档案 S01E02.strm"))
|
||||||
|
if !strings.Contains(episode, "ref=%2F%E7%94%B5%E8%A7%86%E5%89%A7%2F%E5%9B%BD%E4%BA%A7%E5%89%A7%2F%E5%8D%97%E9%83%A8%E6%A1%A3%E6%A1%88%2FSeason+01%2FArchives.The.Nanyang.Mystery.S01E02.2160p.WEB-DL.mkv") {
|
||||||
|
t.Fatalf("episode strm URL = %q, want original cloud ref preserved", episode)
|
||||||
|
}
|
||||||
|
movie := readSTRM(t, filepath.Join(outDir, "Dune Part Two (2024)", "Dune Part Two (2024).strm"))
|
||||||
|
if !strings.Contains(movie, "ref=%2F%E7%94%B5%E5%BD%B1%2FDune.Part.Two.2024.2160p.WEB-DL.mkv") {
|
||||||
|
t.Fatalf("movie strm URL = %q, want original cloud ref preserved", movie)
|
||||||
|
}
|
||||||
|
if _, err := os.Stat(filepath.Join(outDir, "国产剧", "南部档案", "Season 01", "Archives.The.Nanyang.Mystery.S01E02.2160p.WEB-DL.strm")); !os.IsNotExist(err) {
|
||||||
|
t.Fatalf("recognize rename should not leave original episode output path, stat err=%v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestGenerateSTRMFromTreeSupportsCommonVideoExtensions(t *testing.T) {
|
func TestGenerateSTRMFromTreeSupportsCommonVideoExtensions(t *testing.T) {
|
||||||
outDir := filepath.Join(t.TempDir(), "strm")
|
outDir := filepath.Join(t.TempDir(), "strm")
|
||||||
svc := NewSTRMService(zap.NewNop(), nil, nil)
|
svc := NewSTRMService(zap.NewNop(), nil, nil)
|
||||||
|
|||||||
@@ -1,10 +1,12 @@
|
|||||||
package service
|
package service
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"path"
|
"path"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -32,6 +34,70 @@ func strmTreeOutputRelativePath(source string) (string, error) {
|
|||||||
return strmTreeOutputRelativePathWithLinkExtension(source, videoExtensions, ".strm", false)
|
return strmTreeOutputRelativePathWithLinkExtension(source, videoExtensions, ".strm", false)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *STRMService) strmTreeRecognizedOutputRelativePath(ctx context.Context, source, sourceRoot string) (string, error) {
|
||||||
|
source = normalizeSTRMTreeSource(source)
|
||||||
|
season, episode := ParseEpisode(source)
|
||||||
|
title, year := s.strmTreeCleanQuery(ctx, source)
|
||||||
|
if season > 0 || episode > 0 {
|
||||||
|
show := s.strmTreeRecognizedSeriesTitle(ctx, source, sourceRoot, title)
|
||||||
|
if show == "" {
|
||||||
|
return "", errors.New("empty recognized series title")
|
||||||
|
}
|
||||||
|
if season <= 0 {
|
||||||
|
season = 1
|
||||||
|
}
|
||||||
|
if episode <= 0 {
|
||||||
|
return "", errors.New("missing recognized episode number")
|
||||||
|
}
|
||||||
|
name := fmt.Sprintf("%s S%02dE%02d.strm", show, season, episode)
|
||||||
|
return filepath.Join(sanitizeFilename(show), fmt.Sprintf("Season %02d", season), sanitizeFilename(name)), nil
|
||||||
|
}
|
||||||
|
if title == "" {
|
||||||
|
return "", errors.New("empty recognized movie title")
|
||||||
|
}
|
||||||
|
movie := sanitizeFilename(titleCaseWords(title))
|
||||||
|
if movie == "" {
|
||||||
|
return "", errors.New("empty recognized movie filename")
|
||||||
|
}
|
||||||
|
folder := movie
|
||||||
|
if year > 0 && !strings.Contains(folder, strconv.Itoa(year)) {
|
||||||
|
folder = fmt.Sprintf("%s (%d)", movie, year)
|
||||||
|
}
|
||||||
|
return filepath.Join(sanitizeFilename(folder), sanitizeFilename(folder)+".strm"), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *STRMService) strmTreeRecognizedSeriesTitle(ctx context.Context, source, sourceRoot, fallback string) string {
|
||||||
|
rel := strmTreeRelativeSource(source, sourceRoot)
|
||||||
|
parts := strings.Split(strings.Trim(strings.ReplaceAll(rel, "\\", "/"), "/"), "/")
|
||||||
|
if len(parts) >= 2 {
|
||||||
|
dir := parts[len(parts)-2]
|
||||||
|
if strings.TrimSpace(dir) != "" && !seriesSeasonDirRE.MatchString(dir) {
|
||||||
|
if title, _ := s.strmTreeCleanQuery(ctx, dir); title != "" {
|
||||||
|
return sanitizeFilename(titleCaseWords(title))
|
||||||
|
}
|
||||||
|
return sanitizeFilename(strings.TrimSpace(dir))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(parts) >= 3 {
|
||||||
|
dir := parts[len(parts)-3]
|
||||||
|
if title, _ := s.strmTreeCleanQuery(ctx, dir); title != "" {
|
||||||
|
return sanitizeFilename(titleCaseWords(title))
|
||||||
|
}
|
||||||
|
return sanitizeFilename(strings.TrimSpace(dir))
|
||||||
|
}
|
||||||
|
if fallback != "" {
|
||||||
|
return sanitizeFilename(titleCaseWords(fallback))
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *STRMService) strmTreeCleanQuery(ctx context.Context, raw string) (string, int) {
|
||||||
|
if s == nil {
|
||||||
|
return CleanQuery(raw)
|
||||||
|
}
|
||||||
|
return CleanQueryWithRecognition(ctx, s.repo, raw)
|
||||||
|
}
|
||||||
|
|
||||||
func strmTreeOutputSubtitleLinkRelativePath(source string) (string, error) {
|
func strmTreeOutputSubtitleLinkRelativePath(source string) (string, error) {
|
||||||
return strmTreeOutputRelativePathWithLinkExtension(source, strmTreeSubtitleExtensions, ".strm", true)
|
return strmTreeOutputRelativePathWithLinkExtension(source, strmTreeSubtitleExtensions, ".strm", true)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -49,6 +49,7 @@ export type GenerateSTRMTreeInput = {
|
|||||||
cleanup?: boolean
|
cleanup?: boolean
|
||||||
dry_run?: boolean
|
dry_run?: boolean
|
||||||
batch_limit?: number
|
batch_limit?: number
|
||||||
|
recognize_rename?: boolean
|
||||||
transfer_subtitles?: boolean
|
transfer_subtitles?: boolean
|
||||||
missing_only?: boolean
|
missing_only?: boolean
|
||||||
refresh_library?: boolean
|
refresh_library?: boolean
|
||||||
|
|||||||
@@ -46,6 +46,7 @@ export function StrmTreeGenerateSection({
|
|||||||
overwrite,
|
overwrite,
|
||||||
pathsText,
|
pathsText,
|
||||||
provider,
|
provider,
|
||||||
|
recognizeRename,
|
||||||
refreshLibrary,
|
refreshLibrary,
|
||||||
result,
|
result,
|
||||||
runningMode,
|
runningMode,
|
||||||
@@ -59,6 +60,7 @@ export function StrmTreeGenerateSection({
|
|||||||
setOverwrite,
|
setOverwrite,
|
||||||
setPathsText,
|
setPathsText,
|
||||||
setProvider,
|
setProvider,
|
||||||
|
setRecognizeRename,
|
||||||
setRefreshLibrary,
|
setRefreshLibrary,
|
||||||
setScrapeAfter,
|
setScrapeAfter,
|
||||||
setSourceRoot,
|
setSourceRoot,
|
||||||
@@ -193,6 +195,10 @@ export function StrmTreeGenerateSection({
|
|||||||
<input type="checkbox" checked={overwrite} onChange={(e) => setOverwrite(e.target.checked)} />
|
<input type="checkbox" checked={overwrite} onChange={(e) => setOverwrite(e.target.checked)} />
|
||||||
覆盖已存在
|
覆盖已存在
|
||||||
</label>
|
</label>
|
||||||
|
<label className="flex min-h-10 items-center gap-2 rounded-2xl border border-gray-200 bg-white/70 px-3 py-2 text-sm text-ink-50 md:col-span-4">
|
||||||
|
<input type="checkbox" checked={recognizeRename} onChange={(e) => setRecognizeRename(e.target.checked)} />
|
||||||
|
识别重命名
|
||||||
|
</label>
|
||||||
<label className="flex min-h-10 items-center gap-2 rounded-2xl border border-gray-200 bg-white/70 px-3 py-2 text-sm text-ink-50 md:col-span-4">
|
<label className="flex min-h-10 items-center gap-2 rounded-2xl border border-gray-200 bg-white/70 px-3 py-2 text-sm text-ink-50 md:col-span-4">
|
||||||
<input type="checkbox" checked={transferSubtitles} onChange={(e) => setTransferSubtitles(e.target.checked)} />
|
<input type="checkbox" checked={transferSubtitles} onChange={(e) => setTransferSubtitles(e.target.checked)} />
|
||||||
转移同名字幕链接
|
转移同名字幕链接
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ export function useStrmTreeGenerateForm() {
|
|||||||
const [pathsText, setPathsText] = useState('')
|
const [pathsText, setPathsText] = useState('')
|
||||||
const [overwrite, setOverwrite] = useState(false)
|
const [overwrite, setOverwrite] = useState(false)
|
||||||
const [cleanup, setCleanup] = useState(false)
|
const [cleanup, setCleanup] = useState(false)
|
||||||
|
const [recognizeRename, setRecognizeRename] = useState(false)
|
||||||
const [transferSubtitles, setTransferSubtitles] = useState(true)
|
const [transferSubtitles, setTransferSubtitles] = useState(true)
|
||||||
const [missingOnly, setMissingOnly] = useState(false)
|
const [missingOnly, setMissingOnly] = useState(false)
|
||||||
const [refreshLibrary, setRefreshLibrary] = useState(true)
|
const [refreshLibrary, setRefreshLibrary] = useState(true)
|
||||||
@@ -73,6 +74,7 @@ export function useStrmTreeGenerateForm() {
|
|||||||
cleanup: parsedBatchLimit > 0 ? false : cleanup,
|
cleanup: parsedBatchLimit > 0 ? false : cleanup,
|
||||||
dry_run: dryRun,
|
dry_run: dryRun,
|
||||||
batch_limit: parsedBatchLimit || undefined,
|
batch_limit: parsedBatchLimit || undefined,
|
||||||
|
recognize_rename: recognizeRename,
|
||||||
transfer_subtitles: transferSubtitles,
|
transfer_subtitles: transferSubtitles,
|
||||||
missing_only: missingOnly,
|
missing_only: missingOnly,
|
||||||
refresh_library: refreshLibrary,
|
refresh_library: refreshLibrary,
|
||||||
@@ -121,6 +123,7 @@ export function useStrmTreeGenerateForm() {
|
|||||||
provider,
|
provider,
|
||||||
result,
|
result,
|
||||||
runningMode,
|
runningMode,
|
||||||
|
recognizeRename,
|
||||||
refreshLibrary,
|
refreshLibrary,
|
||||||
scrapeAfter,
|
scrapeAfter,
|
||||||
setBaseURL,
|
setBaseURL,
|
||||||
@@ -132,6 +135,7 @@ export function useStrmTreeGenerateForm() {
|
|||||||
setOverwrite,
|
setOverwrite,
|
||||||
setPathsText,
|
setPathsText,
|
||||||
setProvider,
|
setProvider,
|
||||||
|
setRecognizeRename,
|
||||||
setRefreshLibrary: setRefreshLibraryEnabled,
|
setRefreshLibrary: setRefreshLibraryEnabled,
|
||||||
setScrapeAfter,
|
setScrapeAfter,
|
||||||
setSourceRoot,
|
setSourceRoot,
|
||||||
|
|||||||
Reference in New Issue
Block a user