mirror of
https://github.com/truewhile/MeBox.git
synced 2026-09-30 03:36:37 +08:00
284 lines
8.7 KiB
Go
284 lines
8.7 KiB
Go
package service
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"os"
|
|
"path"
|
|
"path/filepath"
|
|
"strings"
|
|
)
|
|
|
|
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"`
|
|
RecognizeRename bool `json:"recognize_rename,omitempty"`
|
|
TransferSubtitles bool `json:"transfer_subtitles,omitempty"`
|
|
MissingOnly bool `json:"missing_only,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 == "" {
|
|
return nil, errors.New("provider required")
|
|
}
|
|
outputDir := resolveMappedDestinationPath(strings.TrimSpace(opts.OutputDir))
|
|
if outputDir == "" || outputDir == "." {
|
|
return nil, errors.New("output_dir required")
|
|
}
|
|
if !opts.DryRun {
|
|
if err := os.MkdirAll(outputDir, 0o755); err != nil { // #nosec G301 -- STRM output directories must be readable by media players.
|
|
return nil, err
|
|
}
|
|
}
|
|
result := &GenerateSTRMResult{LibraryID: provider, OutputDir: outputDir}
|
|
collection := collectSTRMTreeSources(opts)
|
|
sources := collection.sources
|
|
result.Total = len(sources)
|
|
result.Ignored = collection.ignoredCount
|
|
result.IgnoredItems = collection.ignored
|
|
existingRefs, err := s.strmTreeExistingCloudRefs(ctx, opts)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
expectedFiles := make(map[string]struct{})
|
|
for i, source := range sources {
|
|
select {
|
|
case <-ctx.Done():
|
|
return result, ctx.Err()
|
|
default:
|
|
}
|
|
item := s.generateTreeSTRMItem(ctx, outputDir, source, opts, existingRefs)
|
|
if item.FilePath != "" && item.Action != "error" {
|
|
expectedFiles[filepath.Clean(item.FilePath)] = struct{}{}
|
|
}
|
|
result.addItem(item)
|
|
if strmTreeBatchLimitReached(result, opts.BatchLimit) {
|
|
result.Remaining = len(sources) - i - 1
|
|
result.BatchLimited = result.Remaining > 0
|
|
break
|
|
}
|
|
}
|
|
if opts.Cleanup && !opts.DryRun && opts.BatchLimit <= 0 && len(expectedFiles) > 0 {
|
|
cleanupDir := outputDir
|
|
if prefix, err := strmTreeOutputPrefixPath(opts.OutputPrefix); err == nil && prefix != "" {
|
|
cleanupDir = filepath.Join(outputDir, prefix)
|
|
}
|
|
cleaned, err := removeStaleSTRMFiles(cleanupDir, expectedFiles)
|
|
result.Cleaned += cleaned
|
|
if err != nil {
|
|
result.Errors = append(result.Errors, err.Error())
|
|
}
|
|
}
|
|
return result, nil
|
|
}
|
|
|
|
func strmTreeBatchLimitReached(result *GenerateSTRMResult, limit int) bool {
|
|
if result == nil || limit <= 0 {
|
|
return false
|
|
}
|
|
return result.Generated+result.Updated+result.Previewed >= limit
|
|
}
|
|
|
|
func (s *STRMService) generateTreeSTRMItem(ctx context.Context, outputDir string, source strmTreeSource, opts GenerateSTRMTreeOptions, existingRefs map[string]struct{}) GenerateSTRMItem {
|
|
relSource := strmTreeRelativeSource(source.Path, opts.SourceRoot)
|
|
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 {
|
|
relPath, err = strmTreeOutputSubtitleLinkRelativePath(relSource)
|
|
}
|
|
item := GenerateSTRMItem{Title: strings.TrimSuffix(path.Base(source.Path), path.Ext(source.Path))}
|
|
if err != nil {
|
|
item.Action = "error"
|
|
item.Reason = err.Error()
|
|
return item
|
|
}
|
|
prefix, err := strmTreeOutputPrefixPath(opts.OutputPrefix)
|
|
if err != nil {
|
|
item.Action = "error"
|
|
item.Reason = err.Error()
|
|
return item
|
|
}
|
|
filePath := filepath.Join(outputDir, prefix, relPath)
|
|
item.FilePath = filePath
|
|
item.URL = absolutizeSTRMURL(BuildRelativeCloudPlayURL(source.Provider, strmTreeCloudRef(source.cloudRefPath(), opts.SourceRoot)), opts.BaseURL)
|
|
if strmTreeSourceAlreadyInLibrary(source, opts, existingRefs) {
|
|
item.Action = "skipped"
|
|
item.Reason = "already in media library"
|
|
return item
|
|
}
|
|
if _, err := os.Stat(filePath); err == nil && !opts.Overwrite {
|
|
item.Action = "skipped"
|
|
item.Reason = "target exists"
|
|
return item
|
|
}
|
|
action := "generated"
|
|
if _, err := os.Stat(filePath); err == nil {
|
|
action = "updated"
|
|
}
|
|
if opts.DryRun {
|
|
item.Action = "preview"
|
|
item.Reason = action
|
|
return item
|
|
}
|
|
if err := os.MkdirAll(filepath.Dir(filePath), 0o755); err != nil { // #nosec G301 -- STRM output directories must be readable by media players.
|
|
item.Action = "error"
|
|
item.Reason = err.Error()
|
|
return item
|
|
}
|
|
if err := os.WriteFile(filePath, []byte(item.URL+"\n"), 0o644); err != nil { // #nosec G306 -- STRM files are media sidecars intended to be readable by players.
|
|
item.Action = "error"
|
|
item.Reason = err.Error()
|
|
return item
|
|
}
|
|
item.Action = action
|
|
return item
|
|
}
|
|
|
|
func collectSTRMTreeSources(opts GenerateSTRMTreeOptions) strmTreeSourceCollection {
|
|
collector := newSTRMTreeSourceCollector(opts)
|
|
for _, value := range opts.Paths {
|
|
collector.add(value)
|
|
}
|
|
treeSources, treeIgnored := parseSTRMTreeTextWithIgnored(opts.TreeText)
|
|
for _, value := range treeSources {
|
|
collector.add(value)
|
|
}
|
|
for _, value := range treeIgnored {
|
|
collector.addIgnoredOrSubtitle(value)
|
|
}
|
|
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 {
|
|
if strings.TrimSpace(s.RefPath) != "" {
|
|
return s.RefPath
|
|
}
|
|
return s.Path
|
|
}
|