mirror of
https://github.com/truewhile/MeBox.git
synced 2026-10-06 13:26:38 +08:00
feat: transfer STRM tree subtitle links
This commit is contained in:
+24
-22
@@ -109,17 +109,18 @@ type generateSTRMReq struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type generateSTRMTreeReq struct {
|
type generateSTRMTreeReq struct {
|
||||||
Provider string `json:"provider"`
|
Provider string `json:"provider"`
|
||||||
TreeText string `json:"tree_text"`
|
TreeText string `json:"tree_text"`
|
||||||
Paths []string `json:"paths"`
|
Paths []string `json:"paths"`
|
||||||
SourceRoot string `json:"source_root"`
|
SourceRoot string `json:"source_root"`
|
||||||
OutputPrefix string `json:"output_prefix"`
|
OutputPrefix string `json:"output_prefix"`
|
||||||
OutputDir string `json:"output_dir"`
|
OutputDir string `json:"output_dir"`
|
||||||
BaseURL string `json:"base_url"`
|
BaseURL string `json:"base_url"`
|
||||||
Overwrite bool `json:"overwrite"`
|
Overwrite bool `json:"overwrite"`
|
||||||
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"`
|
||||||
|
TransferSubtitles bool `json:"transfer_subtitles"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func generateSTRMHandler(svc *service.Container) gin.HandlerFunc {
|
func generateSTRMHandler(svc *service.Container) gin.HandlerFunc {
|
||||||
@@ -182,17 +183,18 @@ func generateSTRMFromTreeHandler(svc *service.Container) gin.HandlerFunc {
|
|||||||
baseURL = strings.TrimRight(absoluteRequestURL(c, "/"), "/")
|
baseURL = strings.TrimRight(absoluteRequestURL(c, "/"), "/")
|
||||||
}
|
}
|
||||||
res, err := strmSvc.GenerateFromTree(c.Request.Context(), service.GenerateSTRMTreeOptions{
|
res, err := strmSvc.GenerateFromTree(c.Request.Context(), service.GenerateSTRMTreeOptions{
|
||||||
Provider: req.Provider,
|
Provider: req.Provider,
|
||||||
TreeText: req.TreeText,
|
TreeText: req.TreeText,
|
||||||
Paths: req.Paths,
|
Paths: req.Paths,
|
||||||
SourceRoot: req.SourceRoot,
|
SourceRoot: req.SourceRoot,
|
||||||
OutputPrefix: req.OutputPrefix,
|
OutputPrefix: req.OutputPrefix,
|
||||||
OutputDir: req.OutputDir,
|
OutputDir: req.OutputDir,
|
||||||
BaseURL: baseURL,
|
BaseURL: baseURL,
|
||||||
Overwrite: req.Overwrite,
|
Overwrite: req.Overwrite,
|
||||||
Cleanup: req.Cleanup,
|
Cleanup: req.Cleanup,
|
||||||
DryRun: req.DryRun,
|
DryRun: req.DryRun,
|
||||||
BatchLimit: req.BatchLimit,
|
BatchLimit: req.BatchLimit,
|
||||||
|
TransferSubtitles: req.TransferSubtitles,
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
|
|||||||
@@ -10,31 +10,49 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
type GenerateSTRMTreeOptions struct {
|
type GenerateSTRMTreeOptions struct {
|
||||||
Provider string `json:"provider"`
|
Provider string `json:"provider"`
|
||||||
TreeText string `json:"tree_text,omitempty"`
|
TreeText string `json:"tree_text,omitempty"`
|
||||||
Paths []string `json:"paths,omitempty"`
|
Paths []string `json:"paths,omitempty"`
|
||||||
SourceRoot string `json:"source_root,omitempty"`
|
SourceRoot string `json:"source_root,omitempty"`
|
||||||
OutputPrefix string `json:"output_prefix,omitempty"`
|
OutputPrefix string `json:"output_prefix,omitempty"`
|
||||||
OutputDir string `json:"output_dir"`
|
OutputDir string `json:"output_dir"`
|
||||||
BaseURL string `json:"base_url,omitempty"`
|
BaseURL string `json:"base_url,omitempty"`
|
||||||
Overwrite bool `json:"overwrite"`
|
Overwrite bool `json:"overwrite"`
|
||||||
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"`
|
||||||
|
TransferSubtitles bool `json:"transfer_subtitles,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type strmTreeSource struct {
|
type strmTreeSource struct {
|
||||||
Provider string
|
Provider string
|
||||||
Path string
|
Path string
|
||||||
RefPath string
|
RefPath string
|
||||||
|
Kind string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const (
|
||||||
|
strmTreeSourceKindVideo = "video"
|
||||||
|
strmTreeSourceKindSubtitle = "subtitle"
|
||||||
|
)
|
||||||
|
|
||||||
type strmTreeSourceCollection struct {
|
type strmTreeSourceCollection struct {
|
||||||
sources []strmTreeSource
|
sources []strmTreeSource
|
||||||
ignored []string
|
ignored []string
|
||||||
ignoredCount int
|
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) {
|
func (s *STRMService) GenerateFromTree(ctx context.Context, opts GenerateSTRMTreeOptions) (*GenerateSTRMResult, error) {
|
||||||
provider := normalizeSTRMTreeProvider(opts.Provider)
|
provider := normalizeSTRMTreeProvider(opts.Provider)
|
||||||
if provider == "" {
|
if provider == "" {
|
||||||
@@ -97,6 +115,9 @@ func strmTreeBatchLimitReached(result *GenerateSTRMResult, limit int) bool {
|
|||||||
func generateTreeSTRMItem(outputDir string, source strmTreeSource, opts GenerateSTRMTreeOptions) GenerateSTRMItem {
|
func generateTreeSTRMItem(outputDir string, source strmTreeSource, opts GenerateSTRMTreeOptions) GenerateSTRMItem {
|
||||||
relSource := strmTreeRelativeSource(source.Path, opts.SourceRoot)
|
relSource := strmTreeRelativeSource(source.Path, opts.SourceRoot)
|
||||||
relPath, err := strmTreeOutputRelativePath(relSource)
|
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))}
|
item := GenerateSTRMItem{Title: strings.TrimSuffix(path.Base(source.Path), path.Ext(source.Path))}
|
||||||
if err != nil {
|
if err != nil {
|
||||||
item.Action = "error"
|
item.Action = "error"
|
||||||
@@ -141,49 +162,100 @@ func generateTreeSTRMItem(outputDir string, source strmTreeSource, opts Generate
|
|||||||
}
|
}
|
||||||
|
|
||||||
func collectSTRMTreeSources(opts GenerateSTRMTreeOptions) strmTreeSourceCollection {
|
func collectSTRMTreeSources(opts GenerateSTRMTreeOptions) strmTreeSourceCollection {
|
||||||
fallbackProvider := normalizeSTRMTreeProvider(opts.Provider)
|
collector := newSTRMTreeSourceCollector(opts)
|
||||||
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)
|
|
||||||
}
|
|
||||||
for _, value := range opts.Paths {
|
for _, value := range opts.Paths {
|
||||||
add(value)
|
collector.add(value)
|
||||||
}
|
}
|
||||||
treeSources, treeIgnored := parseSTRMTreeTextWithIgnored(opts.TreeText)
|
treeSources, treeIgnored := parseSTRMTreeTextWithIgnored(opts.TreeText)
|
||||||
for _, value := range treeSources {
|
for _, value := range treeSources {
|
||||||
add(value)
|
collector.add(value)
|
||||||
}
|
}
|
||||||
for _, value := range treeIgnored {
|
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 {
|
func (s strmTreeSource) cloudRefPath() string {
|
||||||
|
|||||||
@@ -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) {
|
func TestGenerateSTRMFromTreeLimitsIgnoredItemSamples(t *testing.T) {
|
||||||
outDir := filepath.Join(t.TempDir(), "strm")
|
outDir := filepath.Join(t.TempDir(), "strm")
|
||||||
paths := make([]string, 0, 25)
|
paths := make([]string, 0, 25)
|
||||||
|
|||||||
@@ -31,6 +31,15 @@ var strmTreeKnownNonVideoExtensions = map[string]struct{}{
|
|||||||
".webp": {},
|
".webp": {},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var strmTreeSubtitleExtensions = map[string]struct{}{
|
||||||
|
".ass": {},
|
||||||
|
".idx": {},
|
||||||
|
".srt": {},
|
||||||
|
".ssa": {},
|
||||||
|
".sub": {},
|
||||||
|
".vtt": {},
|
||||||
|
}
|
||||||
|
|
||||||
func normalizeSTRMTreeProvider(raw string) string {
|
func normalizeSTRMTreeProvider(raw string) string {
|
||||||
switch strings.ToLower(strings.TrimSpace(raw)) {
|
switch strings.ToLower(strings.TrimSpace(raw)) {
|
||||||
case "115", "115pan", "pan115", "cloud115":
|
case "115", "115pan", "pan115", "cloud115":
|
||||||
@@ -109,6 +118,30 @@ func normalizeSTRMTreeSourceWithProvider(raw, fallbackProvider string) strmTreeS
|
|||||||
return strmTreeSource{Provider: provider, Path: source}
|
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) {
|
func strmTreeCloudPlaySourceFromURL(parsed *url.URL) (string, string) {
|
||||||
if parsed == nil {
|
if parsed == nil {
|
||||||
return "", ""
|
return "", ""
|
||||||
@@ -241,6 +274,18 @@ func strmTreeIgnoredFileLikeSource(raw string) (string, bool) {
|
|||||||
return "", false
|
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 {
|
func cleanSTRMTreePathDecorations(value string) string {
|
||||||
if value == "" {
|
if value == "" {
|
||||||
return ""
|
return ""
|
||||||
|
|||||||
@@ -29,6 +29,14 @@ func strmTreeCloudRef(source, sourceRoot string) string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func strmTreeOutputRelativePath(source string) (string, error) {
|
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, "\\", "/"), "/"), "/")
|
parts := strings.Split(strings.Trim(strings.ReplaceAll(source, "\\", "/"), "/"), "/")
|
||||||
if len(parts) == 0 {
|
if len(parts) == 0 {
|
||||||
return "", errors.New("empty source path")
|
return "", errors.New("empty source path")
|
||||||
@@ -41,10 +49,16 @@ func strmTreeOutputRelativePath(source string) (string, error) {
|
|||||||
}
|
}
|
||||||
if i == len(parts)-1 {
|
if i == len(parts)-1 {
|
||||||
ext := strings.ToLower(path.Ext(part))
|
ext := strings.ToLower(path.Ext(part))
|
||||||
if _, ok := videoExtensions[ext]; !ok {
|
if _, ok := allowedExtensions[ext]; !ok {
|
||||||
return "", fmt.Errorf("unsupported media extension %q", ext)
|
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)
|
safe := sanitizeFilename(part)
|
||||||
if safe == "" {
|
if safe == "" {
|
||||||
@@ -55,6 +69,37 @@ func strmTreeOutputRelativePath(source string) (string, error) {
|
|||||||
return filepath.Join(out...), nil
|
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) {
|
func strmTreeOutputPrefixPath(prefix string) (string, error) {
|
||||||
parts := strings.Split(strings.Trim(strings.ReplaceAll(prefix, "\\", "/"), "/"), "/")
|
parts := strings.Split(strings.Trim(strings.ReplaceAll(prefix, "\\", "/"), "/"), "/")
|
||||||
out := make([]string, 0, len(parts))
|
out := make([]string, 0, len(parts))
|
||||||
|
|||||||
@@ -46,6 +46,7 @@ export type GenerateSTRMTreeInput = {
|
|||||||
cleanup?: boolean
|
cleanup?: boolean
|
||||||
dry_run?: boolean
|
dry_run?: boolean
|
||||||
batch_limit?: number
|
batch_limit?: number
|
||||||
|
transfer_subtitles?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export const strmAPI = {
|
export const strmAPI = {
|
||||||
|
|||||||
@@ -52,7 +52,9 @@ export function StrmTreeGenerateSection({
|
|||||||
setProvider,
|
setProvider,
|
||||||
setSourceRoot,
|
setSourceRoot,
|
||||||
setTreeText,
|
setTreeText,
|
||||||
|
setTransferSubtitles,
|
||||||
sourceRoot,
|
sourceRoot,
|
||||||
|
transferSubtitles,
|
||||||
treeText,
|
treeText,
|
||||||
}: StrmTreeGenerateSectionProps) {
|
}: StrmTreeGenerateSectionProps) {
|
||||||
const batchLimitEnabled = Number.parseInt(batchLimit, 10) > 0
|
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)} />
|
<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={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">
|
<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
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
|
|||||||
@@ -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 [transferSubtitles, setTransferSubtitles] = useState(true)
|
||||||
const [runningMode, setRunningMode] = useState<'generate' | 'preview' | null>(null)
|
const [runningMode, setRunningMode] = useState<'generate' | 'preview' | null>(null)
|
||||||
const [result, setResult] = useState<GenerateSTRMResult | null>(null)
|
const [result, setResult] = useState<GenerateSTRMResult | null>(null)
|
||||||
|
|
||||||
@@ -69,6 +70,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,
|
||||||
|
transfer_subtitles: transferSubtitles,
|
||||||
})
|
})
|
||||||
setResult(next)
|
setResult(next)
|
||||||
if (dryRun) {
|
if (dryRun) {
|
||||||
@@ -117,7 +119,9 @@ export function useStrmTreeGenerateForm() {
|
|||||||
setProvider,
|
setProvider,
|
||||||
setSourceRoot,
|
setSourceRoot,
|
||||||
setTreeText,
|
setTreeText,
|
||||||
|
setTransferSubtitles,
|
||||||
sourceRoot,
|
sourceRoot,
|
||||||
|
transferSubtitles,
|
||||||
treeText,
|
treeText,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user