package pipeline import ( "encoding/json" "fmt" "os" "path/filepath" "sort" "strings" "gitea.westgate.pw/ShadowsOverWestgate/sow-tools/internal/erf" "gitea.westgate.pw/ShadowsOverWestgate/sow-tools/internal/music" "gitea.westgate.pw/ShadowsOverWestgate/sow-tools/internal/project" ) type preparedMusicAssets struct { Generated []assetResource SkipSourceRel map[string]struct{} Artifacts []string TempRoots []string Summary CreditsRefreshSummary } func prepareMusicAssets(p *project.Project) (*preparedMusicAssets, error) { usedNames := make(map[string]struct{}) dirSources := make(map[string][]string) for _, rel := range p.Inventory.AssetFiles { rel = filepath.ToSlash(rel) ext := strings.ToLower(filepath.Ext(rel)) name := strings.ToLower(strings.TrimSuffix(filepath.Base(rel), filepath.Ext(rel))) if _, convertible := music.ConvertExtensions[ext]; convertible && music.IsMusicAssetPath(rel) { dir := filepath.ToSlash(filepath.Dir(rel)) dirSources[dir] = append(dirSources[dir], rel) continue } if name != "" { usedNames[name] = struct{}{} } } result := &preparedMusicAssets{ SkipSourceRel: make(map[string]struct{}), } if len(dirSources) == 0 { writeResult, err := writeCreditsArtifacts(p, nil) if err != nil { return nil, err } result.Artifacts = writeResult.Artifacts result.Summary = writeResult.Summary return result, nil } dirs := make([]string, 0, len(dirSources)) for dir := range dirSources { dirs = append(dirs, dir) } sort.Strings(dirs) generatedByDir := make(map[string][]music.CreditsEntry) stageRoot := p.MusicStageDir() result.TempRoots = append(result.TempRoots, stageRoot) ffmpegPath, err := music.ResolveFFmpeg() if err != nil { return nil, err } ffprobePath, err := music.ResolveFFprobe() if err != nil { return nil, err } for _, dir := range dirs { sources := append([]string(nil), dirSources[dir]...) sort.Strings(sources) prefix := music.PrefixForDir(p.EffectiveConfig().Music.Prefixes, dir) overlayPath := filepath.Join(p.AssetsDir(), filepath.FromSlash(dir), p.EffectiveConfig().Music.CreditsOverlay) overlay, err := music.ParseCreditsOverlay(overlayPath) if err != nil { return nil, fmt.Errorf("parse credits overlay %s: %w", overlayPath, err) } entries := make([]music.CreditsEntry, 0, len(sources)) for _, rel := range sources { base := filepath.Base(rel) manual := overlay.Match(base, "") outputFile := "" switch { case manual != nil && strings.TrimSpace(manual.OutputFile) != "": outputFile = strings.ToLower(strings.TrimSpace(manual.OutputFile)) if err := music.ValidateManualOutputFile(outputFile); err != nil { return nil, fmt.Errorf("manual credits output for %s: %w", rel, err) } if err := music.ReserveName(strings.TrimSuffix(outputFile, filepath.Ext(outputFile)), usedNames); err != nil { return nil, fmt.Errorf("manual credits output for %s: %w", rel, err) } default: stem, err := music.GenerateStem(base, prefix, usedNames) if err != nil { return nil, fmt.Errorf("generate music name for %s: %w", rel, err) } outputFile = stem + ".bmu" } outputRel := filepath.ToSlash(filepath.Join(dir, outputFile)) metadata, err := music.ProbeMetadata(ffprobePath, filepath.Join(p.AssetsDir(), filepath.FromSlash(rel))) if err != nil { return nil, fmt.Errorf("probe music metadata for %s: %w", rel, err) } entry := music.CreditsEntry{ Artist: metadata.Artist, Title: music.FirstNonEmpty(metadata.Title, strings.TrimSuffix(base, filepath.Ext(base))), OutputFile: outputFile, OriginalFile: base, Album: metadata.Album, Date: metadata.Date, Rights: music.FirstNonEmpty(metadata.Rights, music.JoinNonEmpty("; ", metadata.License, metadata.Copyright)), Notes: music.FirstNonEmpty(metadata.Notes, metadata.Comment), } music.ApplyCreditsOverlay(&entry, manual) stagePath := filepath.Join(stageRoot, filepath.FromSlash(outputRel)) if err := os.MkdirAll(filepath.Dir(stagePath), 0o755); err != nil { return nil, fmt.Errorf("create music stage dir: %w", err) } if err := music.ConvertSource(ffmpegPath, filepath.Join(p.AssetsDir(), filepath.FromSlash(rel)), stagePath); err != nil { return nil, fmt.Errorf("convert music %s: %w", rel, err) } resourceInfo, err := assetResourceFromPath(stagePath, outputRel, lfsAssetInfo{}, false) if err != nil { return nil, fmt.Errorf("load converted music %s: %w", outputRel, err) } info, err := os.Stat(stagePath) if err != nil { return nil, fmt.Errorf("stat converted music %s: %w", stagePath, err) } result.Generated = append(result.Generated, assetResource{ Rel: outputRel, Resource: resourceInfo.Resource, Size: erf.ArchiveSize([]erf.Resource{resourceInfo.Resource}), CreatedAt: info.ModTime(), ContentID: resourceInfo.ContentID, }) result.SkipSourceRel[rel] = struct{}{} entries = append(entries, entry) } if err := music.ValidateOverlayEntries(dir, overlay, entries); err != nil { return nil, err } generatedByDir[dir] = entries } writeResult, err := writeCreditsArtifacts(p, generatedByDir) if err != nil { return nil, err } writeResult.Summary.ScannedDirs = append(writeResult.Summary.ScannedDirs, dirs...) writeResult.Summary.TrackCount = len(result.Generated) result.Artifacts = writeResult.Artifacts result.Summary = writeResult.Summary sort.Slice(result.Generated, func(i, j int) bool { return compareResourceKeys(result.Generated[i].Resource, result.Generated[j].Resource) < 0 }) return result, nil } type creditsArtifactWriteResult struct { Artifacts []string Summary CreditsRefreshSummary } func writeCreditsArtifacts(p *project.Project, generatedByDir map[string][]music.CreditsEntry) (creditsArtifactWriteResult, error) { creditsRoot := p.CreditsDir() sources := make([]music.CreditsSource, 0) desiredFiles := make(map[string][]byte) summary := CreditsRefreshSummary{} if generatedByDir != nil { dirs := make([]string, 0, len(generatedByDir)) for dir := range generatedByDir { dirs = append(dirs, dir) } sort.Strings(dirs) for _, dir := range dirs { entries := append([]music.CreditsEntry(nil), generatedByDir[dir]...) sort.Slice(entries, func(i, j int) bool { if strings.ToLower(entries[i].Artist) != strings.ToLower(entries[j].Artist) { return strings.ToLower(entries[i].Artist) < strings.ToLower(entries[j].Artist) } if strings.ToLower(entries[i].Title) != strings.ToLower(entries[j].Title) { return strings.ToLower(entries[i].Title) < strings.ToLower(entries[j].Title) } return strings.ToLower(entries[i].OutputFile) < strings.ToLower(entries[j].OutputFile) }) outputPath := filepath.Join(creditsRoot, filepath.FromSlash(dir), p.EffectiveConfig().Music.CreditsOverlay) desiredFiles[outputPath] = []byte(music.RenderCreditsMarkdown(entries)) summary.GeneratedPaths = append(summary.GeneratedPaths, outputPath) for _, entry := range entries { summary.Mappings = append(summary.Mappings, MusicFileMapping{ Source: entry.OriginalFile, Output: entry.OutputFile, }) } sources = append(sources, music.CreditsSource{ Path: filepath.ToSlash(outputPath), Kind: "generated_music", Entries: entries, }) } } err := filepath.WalkDir(p.AssetsDir(), func(path string, d os.DirEntry, walkErr error) error { if walkErr != nil { return walkErr } if d.IsDir() { return nil } if strings.ToLower(d.Name()) != "credits.md" { return nil } entries, err := music.ParseCreditsMarkdown(path) if err != nil { return fmt.Errorf("parse credits %s: %w", path, err) } rel, err := filepath.Rel(p.Root, path) if err != nil { return err } sources = append(sources, music.CreditsSource{ Path: filepath.ToSlash(rel), Kind: "authored", Entries: entries, }) return nil }) if err != nil { return creditsArtifactWriteResult{}, err } sort.Slice(sources, func(i, j int) bool { return sources[i].Path < sources[j].Path }) inventoryPath := filepath.Join(creditsRoot, "credits.json") payload, err := json.MarshalIndent(music.CreditsInventory{Sources: sources}, "", " ") if err != nil { return creditsArtifactWriteResult{}, fmt.Errorf("marshal credits inventory: %w", err) } payload = append(payload, '\n') desiredFiles[inventoryPath] = payload if err := os.MkdirAll(creditsRoot, 0o755); err != nil { return creditsArtifactWriteResult{}, fmt.Errorf("create credits dir: %w", err) } changedFiles, err := music.SyncGeneratedCreditsArtifacts(creditsRoot, desiredFiles) if err != nil { return creditsArtifactWriteResult{}, err } artifacts := make([]string, 0, len(desiredFiles)) for path := range desiredFiles { artifacts = append(artifacts, path) } sort.Strings(artifacts) summary.ArtifactPaths = append(summary.ArtifactPaths, artifacts...) summary.InventoryPath = inventoryPath summary.ChangedFiles = changedFiles return creditsArtifactWriteResult{ Artifacts: artifacts, Summary: summary, }, nil } func cleanupPreparedMusicAssets(prepared *preparedMusicAssets) error { if prepared == nil { return nil } for _, root := range prepared.TempRoots { if strings.TrimSpace(root) == "" { continue } if err := os.RemoveAll(root); err != nil { return fmt.Errorf("remove temporary music artifact root %s: %w", root, err) } } return nil }