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 } type MusicBuildOptions struct { DatasetIDs []string Write bool } type musicPrepareOptions struct { datasetIDs []string write bool } type musicSourceGroup struct { DatasetID string Dataset project.EffectiveMusicDataset SourceDir string OutputDir string Sources []string } type musicCreditGroup struct { CreditsRoot string SourceDir string Entries []music.CreditsEntry } func musicRootPath(p *project.Project, path string) string { if strings.TrimSpace(path) == "" { return p.Root } if filepath.IsAbs(path) { return path } return filepath.Join(p.Root, filepath.FromSlash(path)) } func effectiveMusicDatasets(p *project.Project, onlyIDs []string) (map[string]project.EffectiveMusicDataset, error) { effective := p.EffectiveConfig() wanted := map[string]struct{}{} for _, id := range onlyIDs { id = strings.TrimSpace(id) if id != "" { wanted[id] = struct{}{} } } datasets := map[string]project.EffectiveMusicDataset{} for id, dataset := range effective.Music.Datasets { if len(wanted) > 0 { if _, ok := wanted[id]; !ok { continue } delete(wanted, id) } datasets[id] = dataset } if len(wanted) > 0 { unknown := make([]string, 0, len(wanted)) for id := range wanted { unknown = append(unknown, id) } sort.Strings(unknown) return nil, fmt.Errorf("unknown music dataset(s): %s", strings.Join(unknown, ", ")) } if len(datasets) == 0 && len(wanted) == 0 { for _, rel := range p.Inventory.AssetFiles { ext := strings.ToLower(filepath.Ext(rel)) if !music.PathIsUnder("envi/music", rel) { continue } if _, ok := extensionSet(effective.Music.ConvertExtensions)[ext]; ok { datasets["legacy_envi_music"] = project.EffectiveMusicDataset{ Source: "envi/music", Output: "envi/music", StageRoot: effective.Music.StageRoot, CreditsRoot: effective.Music.CreditsRoot, NamingScheme: effective.Music.NamingScheme, OutputExtension: effective.Music.OutputExtension, MaxStemLength: effective.Music.MaxStemLength, PackageMode: "hak_asset", ConvertExtensions: effective.Music.ConvertExtensions, } break } } } return datasets, nil } func extensionSet(values []string) map[string]struct{} { out := make(map[string]struct{}, len(values)) for _, value := range values { value = strings.ToLower(strings.TrimSpace(value)) if value != "" { out[value] = struct{}{} } } return out } func datasetForMusicSource(datasets map[string]project.EffectiveMusicDataset, rel, ext string) (string, project.EffectiveMusicDataset, bool) { bestID := "" var best project.EffectiveMusicDataset bestLen := -1 for id, dataset := range datasets { if _, ok := extensionSet(dataset.ConvertExtensions)[ext]; !ok { continue } if !music.PathIsUnder(dataset.Source, rel) { continue } if len(dataset.Source) > bestLen { bestID = id best = dataset bestLen = len(dataset.Source) } } return bestID, best, bestID != "" } func plannedMusicAsset(outputRel, sourceRel string) (assetResource, error) { extension := strings.TrimPrefix(strings.ToLower(filepath.Ext(outputRel)), ".") resourceType, ok := erf.HAKResourceTypeForExtension(extension) if !ok { return assetResource{}, fmt.Errorf("unsupported generated music resource extension %q", filepath.Ext(outputRel)) } name := strings.ToLower(strings.TrimSuffix(filepath.Base(outputRel), filepath.Ext(outputRel))) return assetResource{ Rel: outputRel, Resource: erf.Resource{ Name: name, Type: resourceType, }, ContentID: "music-plan:" + filepath.ToSlash(sourceRel), }, nil } func BuildMusic(p *project.Project, opts MusicBuildOptions) (BuildResult, error) { prepared, err := prepareMusicAssetsWithOptions(p, musicPrepareOptions{datasetIDs: opts.DatasetIDs, write: opts.Write}) if err != nil { return BuildResult{}, err } if err := cleanupPreparedMusicAssets(prepared); err != nil { return BuildResult{}, err } return BuildResult{ CreditsArtifactPaths: prepared.Artifacts, CreditsSummary: prepared.Summary, HAKAssets: len(prepared.Generated), }, nil } func prepareMusicAssets(p *project.Project) (*preparedMusicAssets, error) { return prepareMusicAssetsWithOptions(p, musicPrepareOptions{write: true}) } func prepareMusicAssetsWithOptions(p *project.Project, opts musicPrepareOptions) (*preparedMusicAssets, error) { usedNames := make(map[string]struct{}) groupsByKey := make(map[string]*musicSourceGroup) datasets, err := effectiveMusicDatasets(p, opts.datasetIDs) if err != nil { return nil, err } 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 datasetID, dataset, ok := datasetForMusicSource(datasets, rel, ext); ok { sourceDir := filepath.ToSlash(filepath.Dir(rel)) relDir, err := filepath.Rel(filepath.FromSlash(dataset.Source), filepath.FromSlash(sourceDir)) if err != nil { return nil, fmt.Errorf("resolve music source directory %s: %w", sourceDir, err) } relDir = filepath.ToSlash(relDir) outputDir := dataset.Output if relDir != "." && relDir != "" { outputDir = filepath.ToSlash(filepath.Join(outputDir, filepath.FromSlash(relDir))) } key := datasetID + "\x00" + sourceDir group := groupsByKey[key] if group == nil { group = &musicSourceGroup{ DatasetID: datasetID, Dataset: dataset, SourceDir: sourceDir, OutputDir: outputDir, } groupsByKey[key] = group } group.Sources = append(group.Sources, rel) continue } if name != "" { usedNames[name] = struct{}{} } } result := &preparedMusicAssets{ SkipSourceRel: make(map[string]struct{}), } if len(groupsByKey) == 0 { if !opts.write { return result, nil } writeResult, err := writeCreditsArtifacts(p, nil) if err != nil { return nil, err } result.Artifacts = writeResult.Artifacts result.Summary = writeResult.Summary return result, nil } groups := make([]*musicSourceGroup, 0, len(groupsByKey)) for _, group := range groupsByKey { sort.Strings(group.Sources) groups = append(groups, group) } sort.Slice(groups, func(i, j int) bool { if groups[i].SourceDir != groups[j].SourceDir { return groups[i].SourceDir < groups[j].SourceDir } return groups[i].DatasetID < groups[j].DatasetID }) creditGroups := make([]musicCreditGroup, 0, len(groups)) for _, group := range groups { result.Summary.ScannedDirs = append(result.Summary.ScannedDirs, group.SourceDir) result.Summary.TrackCount += len(group.Sources) } ffmpegPath := "" ffprobePath := "" if opts.write { var err error ffmpegPath, err = music.ResolveFFmpegWithConfig(p.MusicFFmpegPath()) if err != nil { return nil, err } ffprobePath, err = music.ResolveFFprobeWithConfig(p.MusicFFprobePath()) if err != nil { return nil, err } } for _, group := range groups { stageRoot := musicRootPath(p, group.Dataset.StageRoot) result.TempRoots = append(result.TempRoots, stageRoot) prefix := music.SanitizePrefix(group.Dataset.Prefix) overlayPath := filepath.Join(p.AssetsDir(), filepath.FromSlash(group.SourceDir), 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(group.Sources)) for _, rel := range group.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.ValidateManualOutputFileWithRules(outputFile, group.Dataset.OutputExtension, group.Dataset.MaxStemLength); 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.GenerateStemWithMax(base, prefix, group.Dataset.MaxStemLength, usedNames) if err != nil { return nil, fmt.Errorf("generate music name for %s: %w", rel, err) } outputFile = stem + group.Dataset.OutputExtension } outputRel := filepath.ToSlash(filepath.Join(group.OutputDir, outputFile)) result.Summary.Mappings = append(result.Summary.Mappings, MusicFileMapping{ Source: rel, Output: outputRel, }) result.SkipSourceRel[rel] = struct{}{} if !opts.write { if group.Dataset.PackageMode == "hak_asset" { asset, err := plannedMusicAsset(outputRel, rel) if err != nil { return nil, err } result.Generated = append(result.Generated, asset) } continue } 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) } if group.Dataset.PackageMode == "hak_asset" { 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, }) } entries = append(entries, entry) } if opts.write { if err := music.ValidateOverlayEntries(group.SourceDir, overlay, entries); err != nil { return nil, err } creditGroups = append(creditGroups, musicCreditGroup{ CreditsRoot: group.Dataset.CreditsRoot, SourceDir: group.SourceDir, Entries: entries, }) } } if !opts.write { sort.Slice(result.Generated, func(i, j int) bool { return compareResourceKeys(result.Generated[i].Resource, result.Generated[j].Resource) < 0 }) return result, nil } writeResult, err := writeCreditsArtifacts(p, creditGroups) if err != nil { return nil, err } writeResult.Summary.ScannedDirs = append(writeResult.Summary.ScannedDirs, result.Summary.ScannedDirs...) writeResult.Summary.TrackCount = result.Summary.TrackCount 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, generated []musicCreditGroup) (creditsArtifactWriteResult, error) { defaultCreditsRoot := p.CreditsDir() sources := make([]music.CreditsSource, 0) desiredByRoot := map[string]map[string][]byte{} summary := CreditsRefreshSummary{} if generated != nil { sort.Slice(generated, func(i, j int) bool { if generated[i].SourceDir != generated[j].SourceDir { return generated[i].SourceDir < generated[j].SourceDir } return generated[i].CreditsRoot < generated[j].CreditsRoot }) for _, group := range generated { creditsRoot := musicRootPath(p, group.CreditsRoot) if desiredByRoot[creditsRoot] == nil { desiredByRoot[creditsRoot] = map[string][]byte{} } entries := append([]music.CreditsEntry(nil), group.Entries...) 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(group.SourceDir), p.EffectiveConfig().Music.CreditsOverlay) desiredByRoot[creditsRoot][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 }) payload, err := json.MarshalIndent(music.CreditsInventory{Sources: sources}, "", " ") if err != nil { return creditsArtifactWriteResult{}, fmt.Errorf("marshal credits inventory: %w", err) } payload = append(payload, '\n') if len(desiredByRoot) == 0 { desiredByRoot[defaultCreditsRoot] = map[string][]byte{} } artifacts := make([]string, 0) changedFiles := 0 roots := make([]string, 0, len(desiredByRoot)) for root := range desiredByRoot { roots = append(roots, root) } sort.Strings(roots) for _, creditsRoot := range roots { desiredFiles := desiredByRoot[creditsRoot] inventoryPath := filepath.Join(creditsRoot, "credits.json") desiredFiles[inventoryPath] = payload if err := os.MkdirAll(creditsRoot, 0o755); err != nil { return creditsArtifactWriteResult{}, fmt.Errorf("create credits dir: %w", err) } changed, err := music.SyncGeneratedCreditsArtifacts(creditsRoot, desiredFiles) if err != nil { return creditsArtifactWriteResult{}, err } changedFiles += changed for path := range desiredFiles { artifacts = append(artifacts, path) } if summary.InventoryPath == "" { summary.InventoryPath = inventoryPath } } sort.Strings(artifacts) summary.ArtifactPaths = append(summary.ArtifactPaths, artifacts...) 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 }