feat: transfer STRM tree subtitle links

This commit is contained in:
ShukeBta
2026-07-02 22:41:37 +08:00
parent ccd9756b6f
commit e7d988ebf8
8 changed files with 291 additions and 71 deletions
+24 -22
View File
@@ -109,17 +109,18 @@ type generateSTRMReq struct {
}
type generateSTRMTreeReq struct {
Provider string `json:"provider"`
TreeText string `json:"tree_text"`
Paths []string `json:"paths"`
SourceRoot string `json:"source_root"`
OutputPrefix string `json:"output_prefix"`
OutputDir string `json:"output_dir"`
BaseURL string `json:"base_url"`
Overwrite bool `json:"overwrite"`
Cleanup bool `json:"cleanup"`
DryRun bool `json:"dry_run"`
BatchLimit int `json:"batch_limit"`
Provider string `json:"provider"`
TreeText string `json:"tree_text"`
Paths []string `json:"paths"`
SourceRoot string `json:"source_root"`
OutputPrefix string `json:"output_prefix"`
OutputDir string `json:"output_dir"`
BaseURL string `json:"base_url"`
Overwrite bool `json:"overwrite"`
Cleanup bool `json:"cleanup"`
DryRun bool `json:"dry_run"`
BatchLimit int `json:"batch_limit"`
TransferSubtitles bool `json:"transfer_subtitles"`
}
func generateSTRMHandler(svc *service.Container) gin.HandlerFunc {
@@ -182,17 +183,18 @@ func generateSTRMFromTreeHandler(svc *service.Container) gin.HandlerFunc {
baseURL = strings.TrimRight(absoluteRequestURL(c, "/"), "/")
}
res, err := strmSvc.GenerateFromTree(c.Request.Context(), service.GenerateSTRMTreeOptions{
Provider: req.Provider,
TreeText: req.TreeText,
Paths: req.Paths,
SourceRoot: req.SourceRoot,
OutputPrefix: req.OutputPrefix,
OutputDir: req.OutputDir,
BaseURL: baseURL,
Overwrite: req.Overwrite,
Cleanup: req.Cleanup,
DryRun: req.DryRun,
BatchLimit: req.BatchLimit,
Provider: req.Provider,
TreeText: req.TreeText,
Paths: req.Paths,
SourceRoot: req.SourceRoot,
OutputPrefix: req.OutputPrefix,
OutputDir: req.OutputDir,
BaseURL: baseURL,
Overwrite: req.Overwrite,
Cleanup: req.Cleanup,
DryRun: req.DryRun,
BatchLimit: req.BatchLimit,
TransferSubtitles: req.TransferSubtitles,
})
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
+119 -47
View File
@@ -10,31 +10,49 @@ import (
)
type GenerateSTRMTreeOptions struct {
Provider string `json:"provider"`
TreeText string `json:"tree_text,omitempty"`
Paths []string `json:"paths,omitempty"`
SourceRoot string `json:"source_root,omitempty"`
OutputPrefix string `json:"output_prefix,omitempty"`
OutputDir string `json:"output_dir"`
BaseURL string `json:"base_url,omitempty"`
Overwrite bool `json:"overwrite"`
Cleanup bool `json:"cleanup"`
DryRun bool `json:"dry_run"`
BatchLimit int `json:"batch_limit,omitempty"`
Provider string `json:"provider"`
TreeText string `json:"tree_text,omitempty"`
Paths []string `json:"paths,omitempty"`
SourceRoot string `json:"source_root,omitempty"`
OutputPrefix string `json:"output_prefix,omitempty"`
OutputDir string `json:"output_dir"`
BaseURL string `json:"base_url,omitempty"`
Overwrite bool `json:"overwrite"`
Cleanup bool `json:"cleanup"`
DryRun bool `json:"dry_run"`
BatchLimit int `json:"batch_limit,omitempty"`
TransferSubtitles bool `json:"transfer_subtitles,omitempty"`
}
type strmTreeSource struct {
Provider string
Path string
RefPath string
Kind string
}
const (
strmTreeSourceKindVideo = "video"
strmTreeSourceKindSubtitle = "subtitle"
)
type strmTreeSourceCollection struct {
sources []strmTreeSource
ignored []string
ignoredCount int
}
type strmTreeSourceCollector struct {
fallbackProvider string
transferSubtitles bool
sources []strmTreeSource
subtitles []strmTreeSource
ignored []string
ignoredCount int
seen map[string]struct{}
seenIgnored map[string]struct{}
}
func (s *STRMService) GenerateFromTree(ctx context.Context, opts GenerateSTRMTreeOptions) (*GenerateSTRMResult, error) {
provider := normalizeSTRMTreeProvider(opts.Provider)
if provider == "" {
@@ -97,6 +115,9 @@ func strmTreeBatchLimitReached(result *GenerateSTRMResult, limit int) bool {
func generateTreeSTRMItem(outputDir string, source strmTreeSource, opts GenerateSTRMTreeOptions) GenerateSTRMItem {
relSource := strmTreeRelativeSource(source.Path, opts.SourceRoot)
relPath, err := strmTreeOutputRelativePath(relSource)
if source.Kind == strmTreeSourceKindSubtitle {
relPath, err = strmTreeOutputSubtitleLinkRelativePath(relSource)
}
item := GenerateSTRMItem{Title: strings.TrimSuffix(path.Base(source.Path), path.Ext(source.Path))}
if err != nil {
item.Action = "error"
@@ -141,49 +162,100 @@ func generateTreeSTRMItem(outputDir string, source strmTreeSource, opts Generate
}
func collectSTRMTreeSources(opts GenerateSTRMTreeOptions) strmTreeSourceCollection {
fallbackProvider := normalizeSTRMTreeProvider(opts.Provider)
out := make([]strmTreeSource, 0, len(opts.Paths))
seen := map[string]struct{}{}
ignored := make([]string, 0)
ignoredCount := 0
seenIgnored := map[string]struct{}{}
addIgnored := func(value string) {
if ignoredPath, ok := strmTreeIgnoredFileLikeSource(value); ok {
key := strings.ToLower(ignoredPath)
if _, exists := seenIgnored[key]; exists {
return
}
seenIgnored[key] = struct{}{}
ignoredCount++
if len(ignored) < strmTreeIgnoredItemSampleLimit {
ignored = append(ignored, ignoredPath)
}
}
}
add := func(value string) {
source := normalizeSTRMTreeSourceWithProvider(value, fallbackProvider)
if source.Provider == "" || source.Path == "" || !strmTreeSourceIsVideo(source.Path) {
addIgnored(value)
return
}
key := strings.ToLower(source.Provider) + "\x00" + strings.ToLower(source.Path) + "\x00" + strings.ToLower(source.cloudRefPath())
if _, ok := seen[key]; ok {
return
}
seen[key] = struct{}{}
out = append(out, source)
}
collector := newSTRMTreeSourceCollector(opts)
for _, value := range opts.Paths {
add(value)
collector.add(value)
}
treeSources, treeIgnored := parseSTRMTreeTextWithIgnored(opts.TreeText)
for _, value := range treeSources {
add(value)
collector.add(value)
}
for _, value := range treeIgnored {
addIgnored(value)
collector.addIgnoredOrSubtitle(value)
}
return strmTreeSourceCollection{sources: out, ignored: ignored, ignoredCount: ignoredCount}
collector.finalizeSubtitles()
return collector.collection()
}
func newSTRMTreeSourceCollector(opts GenerateSTRMTreeOptions) *strmTreeSourceCollector {
return &strmTreeSourceCollector{
fallbackProvider: normalizeSTRMTreeProvider(opts.Provider),
transferSubtitles: opts.TransferSubtitles,
sources: make([]strmTreeSource, 0, len(opts.Paths)),
subtitles: make([]strmTreeSource, 0),
ignored: make([]string, 0),
seen: map[string]struct{}{},
seenIgnored: map[string]struct{}{},
}
}
func (c *strmTreeSourceCollector) add(value string) {
source := normalizeSTRMTreeSourceWithProvider(value, c.fallbackProvider)
if source.Provider == "" || source.Path == "" || !strmTreeSourceIsVideo(source.Path) {
c.addIgnoredOrSubtitle(value)
return
}
source.Kind = strmTreeSourceKindVideo
c.addSource(source)
}
func (c *strmTreeSourceCollector) addIgnoredOrSubtitle(value string) {
if c.transferSubtitles && c.addSubtitleCandidate(value) {
return
}
c.addIgnored(value)
}
func (c *strmTreeSourceCollector) addSource(source strmTreeSource) {
if source.Kind == "" {
source.Kind = strmTreeSourceKindVideo
}
key := strings.ToLower(source.Provider) + "\x00" + strings.ToLower(source.Kind) + "\x00" + strings.ToLower(source.Path) + "\x00" + strings.ToLower(source.cloudRefPath())
if _, ok := c.seen[key]; ok {
return
}
c.seen[key] = struct{}{}
c.sources = append(c.sources, source)
}
func (c *strmTreeSourceCollector) addSubtitleCandidate(value string) bool {
source := normalizeSTRMTreeSubtitleSourceWithProvider(value, c.fallbackProvider)
if source.Provider == "" || source.Path == "" {
return false
}
c.subtitles = append(c.subtitles, source)
return true
}
func (c *strmTreeSourceCollector) addIgnored(value string) {
if ignoredPath, ok := strmTreeIgnoredFileLikeSource(value); ok {
key := strings.ToLower(ignoredPath)
if _, exists := c.seenIgnored[key]; exists {
return
}
c.seenIgnored[key] = struct{}{}
c.ignoredCount++
if len(c.ignored) < strmTreeIgnoredItemSampleLimit {
c.ignored = append(c.ignored, ignoredPath)
}
}
}
func (c *strmTreeSourceCollector) finalizeSubtitles() {
if !c.transferSubtitles {
return
}
for _, source := range c.subtitles {
if strmTreeSubtitleMatchesVideo(source, c.sources) {
c.addSource(source)
continue
}
c.addIgnored(source.Path)
}
}
func (c *strmTreeSourceCollector) collection() strmTreeSourceCollection {
return strmTreeSourceCollection{sources: c.sources, ignored: c.ignored, ignoredCount: c.ignoredCount}
}
func (s strmTreeSource) cloudRefPath() string {
+45
View File
@@ -121,6 +121,51 @@ func TestGenerateSTRMFromTreeReportsIgnoredFileLikeRows(t *testing.T) {
}
}
func TestGenerateSTRMFromTreeTransfersMatchingSubtitleLinks(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{
"/Movies/A.mkv",
"/Movies/A.zh.srt",
"/Movies/Orphan.srt",
},
TreeText: strings.Join([]string{
"电视剧",
"└── Show.Name.2026",
" ├── Show.S01E01.mp4",
" ├── Show.S01E01.ass",
" └── Show.S01E02.srt",
}, "\n"),
OutputDir: outDir,
BaseURL: "https://media.example.com",
TransferSubtitles: true,
})
if err != nil {
t.Fatal(err)
}
if res.Generated != 4 || res.Ignored != 2 || len(res.Errors) != 0 {
t.Fatalf("result = %#v, want two video links, two matching subtitle links, and two orphan subtitles ignored", res)
}
movieSubtitle := readSTRM(t, filepath.Join(outDir, "Movies", "A.zh.srt.strm"))
if !strings.HasPrefix(movieSubtitle, "https://media.example.com/api/cloud/play/openlist?") ||
!strings.Contains(movieSubtitle, "ref=%2FMovies%2FA.zh.srt") {
t.Fatalf("subtitle link = %q, want cloud play URL for subtitle source", movieSubtitle)
}
showSubtitle := readSTRM(t, filepath.Join(outDir, "电视剧", "Show.Name.2026", "Show.S01E01.ass.strm"))
if !strings.Contains(showSubtitle, "ref=%2F%E7%94%B5%E8%A7%86%E5%89%A7%2FShow.Name.2026%2FShow.S01E01.ass") {
t.Fatalf("tree subtitle link = %q, want full tree subtitle ref", showSubtitle)
}
if _, err := os.Stat(filepath.Join(outDir, "Movies", "Orphan.srt.strm")); !os.IsNotExist(err) {
t.Fatalf("orphan subtitle should not generate a link, stat err=%v", err)
}
if _, err := os.Stat(filepath.Join(outDir, "电视剧", "Show.Name.2026", "Show.S01E02.srt.strm")); !os.IsNotExist(err) {
t.Fatalf("subtitle without matching episode should not generate a link, stat err=%v", err)
}
}
func TestGenerateSTRMFromTreeLimitsIgnoredItemSamples(t *testing.T) {
outDir := filepath.Join(t.TempDir(), "strm")
paths := make([]string, 0, 25)
+45
View File
@@ -31,6 +31,15 @@ var strmTreeKnownNonVideoExtensions = map[string]struct{}{
".webp": {},
}
var strmTreeSubtitleExtensions = map[string]struct{}{
".ass": {},
".idx": {},
".srt": {},
".ssa": {},
".sub": {},
".vtt": {},
}
func normalizeSTRMTreeProvider(raw string) string {
switch strings.ToLower(strings.TrimSpace(raw)) {
case "115", "115pan", "pan115", "cloud115":
@@ -109,6 +118,30 @@ func normalizeSTRMTreeSourceWithProvider(raw, fallbackProvider string) strmTreeS
return strmTreeSource{Provider: provider, Path: source}
}
func normalizeSTRMTreeSubtitleSourceWithProvider(raw, fallbackProvider string) strmTreeSource {
provider := normalizeSTRMTreeProvider(fallbackProvider)
value := strings.TrimSpace(strings.Trim(raw, `"'`))
if value == "" {
return strmTreeSource{}
}
if info, ok := ParseCloudLibraryMount(value); ok {
source, sourceOK := strmTreeSubtitleFileLikeSource(info.DisplayDir)
if !sourceOK {
return strmTreeSource{}
}
ref, refOK := strmTreeSubtitleFileLikeSource(info.ScanDir)
if !refOK {
ref = source
}
return strmTreeSource{Provider: normalizeSTRMTreeProvider(info.Provider), Path: source, RefPath: ref, Kind: strmTreeSourceKindSubtitle}
}
source, ok := strmTreeSubtitleFileLikeSource(value)
if !ok {
return strmTreeSource{}
}
return strmTreeSource{Provider: provider, Path: source, Kind: strmTreeSourceKindSubtitle}
}
func strmTreeCloudPlaySourceFromURL(parsed *url.URL) (string, string) {
if parsed == nil {
return "", ""
@@ -241,6 +274,18 @@ func strmTreeIgnoredFileLikeSource(raw string) (string, bool) {
return "", false
}
func strmTreeSubtitleFileLikeSource(raw string) (string, bool) {
source, ok := strmTreeIgnoredFileLikeSource(raw)
if !ok {
return "", false
}
ext := strings.ToLower(path.Ext(source))
if _, ok := strmTreeSubtitleExtensions[ext]; !ok {
return "", false
}
return source, true
}
func cleanSTRMTreePathDecorations(value string) string {
if value == "" {
return ""
+47 -2
View File
@@ -29,6 +29,14 @@ func strmTreeCloudRef(source, sourceRoot string) string {
}
func strmTreeOutputRelativePath(source string) (string, error) {
return strmTreeOutputRelativePathWithLinkExtension(source, videoExtensions, ".strm", false)
}
func strmTreeOutputSubtitleLinkRelativePath(source string) (string, error) {
return strmTreeOutputRelativePathWithLinkExtension(source, strmTreeSubtitleExtensions, ".strm", true)
}
func strmTreeOutputRelativePathWithLinkExtension(source string, allowedExtensions map[string]struct{}, linkExtension string, appendLinkExtension bool) (string, error) {
parts := strings.Split(strings.Trim(strings.ReplaceAll(source, "\\", "/"), "/"), "/")
if len(parts) == 0 {
return "", errors.New("empty source path")
@@ -41,10 +49,16 @@ func strmTreeOutputRelativePath(source string) (string, error) {
}
if i == len(parts)-1 {
ext := strings.ToLower(path.Ext(part))
if _, ok := videoExtensions[ext]; !ok {
if _, ok := allowedExtensions[ext]; !ok {
return "", fmt.Errorf("unsupported media extension %q", ext)
}
part = strings.TrimSuffix(part, path.Ext(part)) + ".strm"
if linkExtension != "" {
if appendLinkExtension {
part += linkExtension
} else {
part = strings.TrimSuffix(part, path.Ext(part)) + linkExtension
}
}
}
safe := sanitizeFilename(part)
if safe == "" {
@@ -55,6 +69,37 @@ func strmTreeOutputRelativePath(source string) (string, error) {
return filepath.Join(out...), nil
}
func strmTreeSubtitleMatchesVideo(subtitle strmTreeSource, videos []strmTreeSource) bool {
subDir, subBase := strmTreeDirAndBase(subtitle.Path)
if subBase == "" {
return false
}
for _, video := range videos {
if video.Kind != "" && video.Kind != strmTreeSourceKindVideo {
continue
}
if !strings.EqualFold(subtitle.Provider, video.Provider) {
continue
}
videoDir, videoBase := strmTreeDirAndBase(video.Path)
if !strings.EqualFold(subDir, videoDir) || videoBase == "" {
continue
}
if strings.EqualFold(subBase, videoBase) || strings.HasPrefix(strings.ToLower(subBase), strings.ToLower(videoBase)+".") {
return true
}
}
return false
}
func strmTreeDirAndBase(source string) (string, string) {
source = normalizeSTRMTreeSource(source)
dir := path.Dir(source)
name := path.Base(source)
base := strings.TrimSuffix(name, path.Ext(name))
return strings.ToLower(strings.Trim(dir, "/")), strings.ToLower(base)
}
func strmTreeOutputPrefixPath(prefix string) (string, error) {
parts := strings.Split(strings.Trim(strings.ReplaceAll(prefix, "\\", "/"), "/"), "/")
out := make([]string, 0, len(parts))
+1
View File
@@ -46,6 +46,7 @@ export type GenerateSTRMTreeInput = {
cleanup?: boolean
dry_run?: boolean
batch_limit?: number
transfer_subtitles?: boolean
}
export const strmAPI = {
@@ -52,7 +52,9 @@ export function StrmTreeGenerateSection({
setProvider,
setSourceRoot,
setTreeText,
setTransferSubtitles,
sourceRoot,
transferSubtitles,
treeText,
}: StrmTreeGenerateSectionProps) {
const batchLimitEnabled = Number.parseInt(batchLimit, 10) > 0
@@ -179,6 +181,10 @@ export function StrmTreeGenerateSection({
<input type="checkbox" checked={overwrite} onChange={(e) => setOverwrite(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">
<input type="checkbox" checked={transferSubtitles} onChange={(e) => setTransferSubtitles(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">
<input
type="checkbox"
+4
View File
@@ -17,6 +17,7 @@ export function useStrmTreeGenerateForm() {
const [pathsText, setPathsText] = useState('')
const [overwrite, setOverwrite] = useState(false)
const [cleanup, setCleanup] = useState(false)
const [transferSubtitles, setTransferSubtitles] = useState(true)
const [runningMode, setRunningMode] = useState<'generate' | 'preview' | null>(null)
const [result, setResult] = useState<GenerateSTRMResult | null>(null)
@@ -69,6 +70,7 @@ export function useStrmTreeGenerateForm() {
cleanup: parsedBatchLimit > 0 ? false : cleanup,
dry_run: dryRun,
batch_limit: parsedBatchLimit || undefined,
transfer_subtitles: transferSubtitles,
})
setResult(next)
if (dryRun) {
@@ -117,7 +119,9 @@ export function useStrmTreeGenerateForm() {
setProvider,
setSourceRoot,
setTreeText,
setTransferSubtitles,
sourceRoot,
transferSubtitles,
treeText,
}
}