From bba0d2fb455f11801f8946e79c45306080f0c189 Mon Sep 17 00:00:00 2001 From: vickydotbat Date: Thu, 7 May 2026 18:17:40 +0200 Subject: [PATCH] Music Pipeline Refactor --- README.md | 24 + internal/music/config.go | 47 ++ internal/music/credits.go | 292 ++++++++++++ internal/music/metadata.go | 62 +++ internal/music/music.go | 189 ++++++++ internal/music/music_test.go | 404 ++++++++++++++++ internal/music/naming.go | 172 +++++++ internal/pipeline/music.go | 741 ++--------------------------- internal/pipeline/pipeline_test.go | 9 - internal/project/effective.go | 74 ++- internal/project/project.go | 51 +- 11 files changed, 1333 insertions(+), 732 deletions(-) create mode 100644 internal/music/config.go create mode 100644 internal/music/credits.go create mode 100644 internal/music/metadata.go create mode 100644 internal/music/music.go create mode 100644 internal/music/music_test.go create mode 100644 internal/music/naming.go diff --git a/README.md b/README.md index 838e136..16f9ae1 100644 --- a/README.md +++ b/README.md @@ -61,8 +61,17 @@ paths: build: build music: + # Legacy shorthand — automatically normalized into a dataset. prefixes: envi/music/westgate: mus_wg_ + + # Explicit dataset config (replaces prefixes above). + defaults: + max_stem_length: 16 + datasets: + westgate: + source: envi/music/westgate + prefix: mus_wg_ ``` Resolved configuration can be inspected without scanning or building: @@ -151,6 +160,21 @@ autogen: repo_env: SOW_ASSETS_REPO ``` +Music defaults (used when not explicitly configured): + +```yaml +music: + defaults: + stage_root: "{paths.cache}/music" + credits_root: "{paths.cache}/credits" + credits_overlay: CREDITS.md + max_stem_length: 16 +``` + +Music datasets define individual music collections with independent sources and +naming prefixes. The legacy `music.prefixes` shorthand is automatically normalized +into a dataset. + Generated and machine-readable artifacts remain JSON, including HAK manifests, topdata datasets, caches, credits inventories, build reports, and canonical GFF documents. Nested metadata such as `topdata/templates/config.json` is not root diff --git a/internal/music/config.go b/internal/music/config.go new file mode 100644 index 0000000..0423f0f --- /dev/null +++ b/internal/music/config.go @@ -0,0 +1,47 @@ +package music + +import ( + "fmt" + "path/filepath" + "strings" +) + +type DatasetConfig struct { + Source string `json:"source" yaml:"source"` + Prefix string `json:"prefix,omitempty" yaml:"prefix,omitempty"` + StageRoot string `json:"stage_root,omitempty" yaml:"stage_root,omitempty"` + CreditsRoot string `json:"credits_root,omitempty" yaml:"credits_root,omitempty"` +} + +type DefaultsConfig struct { + StageRoot string `json:"stage_root,omitempty" yaml:"stage_root,omitempty"` + CreditsRoot string `json:"credits_root,omitempty" yaml:"credits_root,omitempty"` + CreditsOverlay string `json:"credits_overlay,omitempty" yaml:"credits_overlay,omitempty"` + MaxStemLength *int `json:"max_stem_length,omitempty" yaml:"max_stem_length,omitempty"` +} + +func ValidateDatasetSource(field, source string) error { + trimmed := strings.TrimSpace(source) + if trimmed == "" { + return fmt.Errorf("%s.source is required", field) + } + if strings.Contains(trimmed, "\x00") { + return fmt.Errorf("%s.source must not contain NUL bytes", field) + } + clean := filepath.Clean(filepath.FromSlash(trimmed)) + if clean == ".." || strings.HasPrefix(clean, ".."+string(filepath.Separator)) { + return fmt.Errorf("%s.source must not escape project root", field) + } + return nil +} + +func ValidateDatasetID(id string) error { + trimmed := strings.TrimSpace(id) + if trimmed == "" { + return fmt.Errorf("dataset id must not be empty") + } + if strings.Contains(trimmed, "/") || strings.Contains(trimmed, "\\") { + return fmt.Errorf("dataset id %q must not contain path separators", trimmed) + } + return nil +} diff --git a/internal/music/credits.go b/internal/music/credits.go new file mode 100644 index 0000000..b76f6ba --- /dev/null +++ b/internal/music/credits.go @@ -0,0 +1,292 @@ +package music + +import ( + "fmt" + "os" + "path/filepath" + "strings" +) + +func ParseCreditsOverlay(path string) (CreditsOverlay, error) { + entries, err := ParseCreditsMarkdown(path) + if err != nil { + return CreditsOverlay{}, err + } + overlay := CreditsOverlay{ + ByOriginal: make(map[string]CreditsEntry), + ByOutput: make(map[string]CreditsEntry), + Entries: entries, + } + for _, entry := range entries { + if entry.OriginalFile != "" { + key := strings.ToLower(strings.TrimSpace(entry.OriginalFile)) + if _, exists := overlay.ByOriginal[key]; exists { + return CreditsOverlay{}, fmt.Errorf("duplicate manual credits row for original file %s", entry.OriginalFile) + } + overlay.ByOriginal[key] = entry + } + if entry.OutputFile != "" { + key := strings.ToLower(strings.TrimSpace(entry.OutputFile)) + if _, exists := overlay.ByOutput[key]; exists { + return CreditsOverlay{}, fmt.Errorf("duplicate manual credits row for output file %s", entry.OutputFile) + } + overlay.ByOutput[key] = entry + } + } + return overlay, nil +} + +func (o CreditsOverlay) Match(originalFile, outputFile string) *CreditsEntry { + if entry, ok := o.ByOutput[strings.ToLower(strings.TrimSpace(outputFile))]; ok { + entryCopy := entry + return &entryCopy + } + if entry, ok := o.ByOriginal[strings.ToLower(strings.TrimSpace(originalFile))]; ok { + entryCopy := entry + return &entryCopy + } + return nil +} + +func ValidateOverlayEntries(dir string, overlay CreditsOverlay, generated []CreditsEntry) error { + if len(overlay.Entries) == 0 { + return nil + } + validOriginal := make(map[string]struct{}, len(generated)) + validOutput := make(map[string]struct{}, len(generated)) + for _, entry := range generated { + validOriginal[strings.ToLower(entry.OriginalFile)] = struct{}{} + validOutput[strings.ToLower(entry.OutputFile)] = struct{}{} + } + for _, entry := range overlay.Entries { + if entry.OriginalFile != "" { + if _, ok := validOriginal[strings.ToLower(entry.OriginalFile)]; !ok { + return fmt.Errorf("manual credits entry in %s references unknown original file %s", dir, entry.OriginalFile) + } + } + if entry.OutputFile != "" { + if _, ok := validOutput[strings.ToLower(entry.OutputFile)]; !ok { + return fmt.Errorf("manual credits entry in %s references unknown output file %s", dir, entry.OutputFile) + } + } + } + return nil +} + +func ParseCreditsMarkdown(path string) ([]CreditsEntry, error) { + raw, err := os.ReadFile(path) + if err != nil { + if os.IsNotExist(err) { + return nil, nil + } + return nil, err + } + lines := strings.Split(strings.ReplaceAll(string(raw), "\r\n", "\n"), "\n") + header := []string(nil) + entries := make([]CreditsEntry, 0) + for _, line := range lines { + line = strings.TrimSpace(line) + if !strings.HasPrefix(line, "|") { + continue + } + cells := ParseMarkdownTableRow(line) + if len(cells) == 0 { + continue + } + if header == nil { + header = make([]string, len(cells)) + for i, cell := range cells { + header[i] = NormalizeCreditsHeader(cell) + } + continue + } + if IsMarkdownSeparatorRow(cells) { + continue + } + entry := CreditsEntry{} + for i, key := range header { + if i >= len(cells) { + continue + } + value := CleanCreditsCell(cells[i]) + switch key { + case "artist": + entry.Artist = value + case "title": + entry.Title = value + case "output_file": + entry.OutputFile = value + case "original_file": + entry.OriginalFile = value + case "album": + entry.Album = value + case "date": + entry.Date = value + case "rights": + entry.Rights = value + case "notes": + entry.Notes = value + } + } + if entry.Artist == "" && entry.Title == "" && entry.OutputFile == "" && entry.OriginalFile == "" { + continue + } + entries = append(entries, entry) + } + return entries, nil +} + +func ParseMarkdownTableRow(line string) []string { + trimmed := strings.TrimSpace(line) + trimmed = strings.TrimPrefix(trimmed, "|") + trimmed = strings.TrimSuffix(trimmed, "|") + parts := strings.Split(trimmed, "|") + cells := make([]string, 0, len(parts)) + for _, part := range parts { + cells = append(cells, strings.TrimSpace(strings.ReplaceAll(part, `\|`, "|"))) + } + return cells +} + +func IsMarkdownSeparatorRow(cells []string) bool { + if len(cells) == 0 { + return false + } + for _, cell := range cells { + cell = strings.TrimSpace(cell) + cell = strings.ReplaceAll(cell, "-", "") + cell = strings.ReplaceAll(cell, ":", "") + if cell != "" { + return false + } + } + return true +} + +func NormalizeCreditsHeader(value string) string { + switch strings.ToLower(CleanCreditsCell(value)) { + case "artist": + return "artist" + case "title": + return "title" + case "output file": + return "output_file" + case "original file": + return "original_file" + case "album": + return "album" + case "date": + return "date" + case "license / copyright": + return "rights" + case "notes": + return "notes" + default: + return "" + } +} + +func CleanCreditsCell(value string) string { + value = strings.TrimSpace(value) + value = strings.Trim(value, "`") + value = strings.ReplaceAll(value, "
", "\n") + return strings.TrimSpace(value) +} + +func RenderCreditsMarkdown(entries []CreditsEntry) string { + var builder strings.Builder + builder.WriteString(CreditsHeader) + for _, entry := range entries { + builder.WriteString("| ") + builder.WriteString(EscapeMarkdownCell(entry.Artist)) + builder.WriteString(" | ") + builder.WriteString(EscapeMarkdownCell(entry.Title)) + builder.WriteString(" | ") + builder.WriteString(EscapeMarkdownCell(WrapCodeCell(entry.OutputFile))) + builder.WriteString(" | ") + builder.WriteString(EscapeMarkdownCell(WrapCodeCell(entry.OriginalFile))) + builder.WriteString(" | ") + builder.WriteString(EscapeMarkdownCell(entry.Album)) + builder.WriteString(" | ") + builder.WriteString(EscapeMarkdownCell(entry.Date)) + builder.WriteString(" | ") + builder.WriteString(EscapeMarkdownCell(entry.Rights)) + builder.WriteString(" | ") + builder.WriteString(EscapeMarkdownCell(entry.Notes)) + builder.WriteString(" |\n") + } + return builder.String() +} + +func WrapCodeCell(value string) string { + if strings.TrimSpace(value) == "" { + return "" + } + return "`" + value + "`" +} + +func EscapeMarkdownCell(value string) string { + value = strings.ReplaceAll(value, `\`, `\\`) + value = strings.ReplaceAll(value, "|", `\|`) + value = strings.ReplaceAll(value, "\n", "
") + return value +} + +func ApplyCreditsOverlay(entry *CreditsEntry, overlay *CreditsEntry) { + if overlay == nil { + return + } + entry.Artist = FirstNonEmpty(overlay.Artist, entry.Artist) + entry.Title = FirstNonEmpty(overlay.Title, entry.Title) + entry.Album = FirstNonEmpty(overlay.Album, entry.Album) + entry.Date = FirstNonEmpty(overlay.Date, entry.Date) + entry.Rights = FirstNonEmpty(overlay.Rights, entry.Rights) + entry.Notes = FirstNonEmpty(overlay.Notes, entry.Notes) +} + +func SyncGeneratedCreditsArtifacts(root string, desiredFiles map[string][]byte) (int, error) { + changedFiles := 0 + existingFiles := make(map[string]struct{}) + if err := filepath.WalkDir(root, func(path string, d os.DirEntry, walkErr error) error { + if walkErr != nil { + if os.IsNotExist(walkErr) { + return nil + } + return walkErr + } + if d.IsDir() { + return nil + } + existingFiles[path] = struct{}{} + return nil + }); err != nil { + return 0, fmt.Errorf("scan credits dir: %w", err) + } + + for path, content := range desiredFiles { + current, err := os.ReadFile(path) + if err != nil && !os.IsNotExist(err) { + return 0, fmt.Errorf("read credits artifact %s: %w", path, err) + } + if err == nil && string(current) == string(content) { + delete(existingFiles, path) + continue + } + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + return 0, fmt.Errorf("create generated credits dir: %w", err) + } + if err := os.WriteFile(path, content, 0o644); err != nil { + return 0, fmt.Errorf("write credits artifact %s: %w", path, err) + } + changedFiles++ + delete(existingFiles, path) + } + + for path := range existingFiles { + if err := os.Remove(path); err != nil && !os.IsNotExist(err) { + return 0, fmt.Errorf("remove stale credits artifact %s: %w", path, err) + } + changedFiles++ + } + return changedFiles, nil +} diff --git a/internal/music/metadata.go b/internal/music/metadata.go new file mode 100644 index 0000000..5114c36 --- /dev/null +++ b/internal/music/metadata.go @@ -0,0 +1,62 @@ +package music + +import ( + "encoding/json" + "fmt" + "os/exec" + "strings" +) + +func ProbeMetadata(ffprobePath, sourcePath string) (Metadata, error) { + cmd := exec.Command(ffprobePath, "-v", "quiet", "-print_format", "json", "-show_format", sourcePath) + output, err := cmd.Output() + if err != nil { + return Metadata{}, err + } + var payload struct { + Format struct { + Tags map[string]string `json:"tags"` + } `json:"format"` + } + if err := json.Unmarshal(output, &payload); err != nil { + return Metadata{}, err + } + tags := make(map[string]string, len(payload.Format.Tags)) + for key, value := range payload.Format.Tags { + tags[strings.ToLower(strings.TrimSpace(key))] = strings.TrimSpace(value) + } + return Metadata{ + Title: FirstNonEmpty(tags["title"]), + Artist: FirstNonEmpty(tags["artist"], tags["album_artist"], tags["composer"]), + Album: FirstNonEmpty(tags["album"]), + Date: FirstNonEmpty(tags["date"], tags["year"]), + Comment: FirstNonEmpty(tags["comment"], tags["description"]), + Copyright: FirstNonEmpty(tags["copyright"]), + License: FirstNonEmpty(tags["license"], tags["license_url"]), + }, nil +} + +func ConvertSource(ffmpegPath, sourcePath, outputPath string) error { + cmd := exec.Command( + ffmpegPath, "-y", + "-i", sourcePath, + "-vn", + "-map_metadata", "-1", + "-ar", "44100", + "-ac", "2", + "-b:a", "192k", + "-codec:a", "libmp3lame", + "-write_xing", "0", + "-f", "mp3", + outputPath, + ) + output, err := cmd.CombinedOutput() + if err != nil { + message := strings.TrimSpace(string(output)) + if message == "" { + message = err.Error() + } + return fmt.Errorf("%s", message) + } + return nil +} diff --git a/internal/music/music.go b/internal/music/music.go new file mode 100644 index 0000000..000715b --- /dev/null +++ b/internal/music/music.go @@ -0,0 +1,189 @@ +package music + +import ( + "os" + "os/exec" + "path/filepath" + "regexp" + "sort" + "strings" +) + +const ( + MaxStemLen = 16 + CreditsHeader = "# NWN Music Pack Credits\n\nOriginal rights remain with the respective composers and licensors.\n\n## Processed Files\n\n| Artist | Title | Output File | Original File | Album | Date | License / Copyright | Notes |\n|---|---|---|---|---|---|---|---|\n" +) + +var ( + ConvertExtensions = map[string]struct{}{ + ".mp3": {}, + ".ogg": {}, + } + PassthroughExtensions = map[string]struct{}{ + ".bmu": {}, + ".wav": {}, + } + DropWords = map[string]struct{}{ + "mp3": {}, "wav": {}, "flac": {}, "ogg": {}, "m4a": {}, + "music": {}, "track": {}, "audio": {}, "song": {}, "soundtrack": {}, + "final": {}, "version": {}, "ver": {}, "v": {}, "mix": {}, "master": {}, "remaster": {}, + "free": {}, "royalty": {}, "copyright": {}, "background": {}, "bgm": {}, + "loop": {}, "loops": {}, "loopable": {}, "seamless": {}, + "official": {}, "download": {}, "preview": {}, + "the": {}, "a": {}, "an": {}, "and": {}, "of": {}, "to": {}, "in": {}, "for": {}, "with": {}, "by": {}, + } + BracketRE = regexp.MustCompile(`\[[^\]]*\]|\([^)]*\)|\{[^}]*\}`) + SplitRE = regexp.MustCompile(`[^A-Za-z0-9]+`) + YearRE = regexp.MustCompile(`^\d{4,}$`) + VowelRE = regexp.MustCompile(`[aeiou]`) +) + +type Metadata struct { + Title string + Artist string + Album string + Date string + Rights string + Notes string + Comment string + Copyright string + License string +} + +type CreditsEntry struct { + Artist string `json:"artist"` + Title string `json:"title"` + OutputFile string `json:"output_file"` + OriginalFile string `json:"original_file"` + Album string `json:"album"` + Date string `json:"date"` + Rights string `json:"rights"` + Notes string `json:"notes"` +} + +type CreditsSource struct { + Path string `json:"path"` + Kind string `json:"kind"` + Entries []CreditsEntry `json:"entries"` +} + +type CreditsInventory struct { + Sources []CreditsSource `json:"sources"` +} + +type CreditsOverlay struct { + ByOriginal map[string]CreditsEntry + ByOutput map[string]CreditsEntry + Entries []CreditsEntry +} + +func ResolveFFmpeg() (string, error) { + if configured := strings.TrimSpace(os.Getenv("SOW_FFMPEG")); configured != "" { + return configured, nil + } + path, err := exec.LookPath("ffmpeg") + if err != nil { + return "", err + } + return path, nil +} + +func ResolveFFprobe() (string, error) { + if configured := strings.TrimSpace(os.Getenv("SOW_FFPROBE")); configured != "" { + return configured, nil + } + path, err := exec.LookPath("ffprobe") + if err != nil { + return "", err + } + return path, nil +} + +func IsMusicAssetPath(rel string) bool { + rel = filepath.ToSlash(strings.TrimSpace(rel)) + return rel == "envi/music" || strings.HasPrefix(rel, "envi/music/") +} + +func PrefixForDir(prefixes map[string]string, dir string) string { + dir = TrimSlashes(filepath.ToSlash(dir)) + bestPrefix := "" + bestLen := -1 + keys := make([]string, 0, len(prefixes)) + for key := range prefixes { + keys = append(keys, key) + } + sort.Strings(keys) + for _, key := range keys { + value := prefixes[key] + normalizedKey := TrimSlashes(filepath.ToSlash(key)) + if normalizedKey == "" { + continue + } + if dir != normalizedKey && !strings.HasPrefix(dir, normalizedKey+"/") { + continue + } + if len(normalizedKey) > bestLen { + bestLen = len(normalizedKey) + bestPrefix = SanitizePrefix(value) + } + } + return bestPrefix +} + +func SanitizePrefix(prefix string) string { + prefix = strings.ToLower(strings.TrimSpace(prefix)) + var builder strings.Builder + for _, r := range prefix { + switch { + case r >= 'a' && r <= 'z': + builder.WriteRune(r) + case r >= '0' && r <= '9': + builder.WriteRune(r) + case r == '_': + builder.WriteRune(r) + } + } + result := builder.String() + if strings.TrimSpace(prefix) != "" && strings.HasSuffix(strings.TrimSpace(prefix), "_") && !strings.HasSuffix(result, "_") { + result += "_" + } + return result +} + +func TrimSlashes(value string) string { + return strings.Trim(strings.TrimSpace(value), "/") +} + +func Min(a, b int) int { + if a < b { + return a + } + return b +} + +func Max(a, b int) int { + if a > b { + return a + } + return b +} + +func FirstNonEmpty(values ...string) string { + for _, value := range values { + if strings.TrimSpace(value) != "" { + return strings.TrimSpace(value) + } + } + return "" +} + +func JoinNonEmpty(sep string, values ...string) string { + parts := make([]string, 0, len(values)) + for _, value := range values { + value = strings.TrimSpace(value) + if value != "" { + parts = append(parts, value) + } + } + return strings.Join(parts, sep) +} diff --git a/internal/music/music_test.go b/internal/music/music_test.go new file mode 100644 index 0000000..f4d7ecd --- /dev/null +++ b/internal/music/music_test.go @@ -0,0 +1,404 @@ +package music + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +func TestUniqueNameHandlesShortStemCollisions(t *testing.T) { + used := map[string]struct{}{ + "mus_wg_mystc": {}, + } + + got := UniqueName("mus_wg_mystc", used) + if got != "mus_wg_mystc_1" { + t.Fatalf("unexpected collision result: got %q want %q", got, "mus_wg_mystc_1") + } +} + +func TestUniqueNameNoCollision(t *testing.T) { + used := map[string]struct{}{} + got := UniqueName("new_stem", used) + if got != "new_stem" { + t.Fatalf("expected 'new_stem', got %q", got) + } +} + +func TestUniqueNameMultipleCollisions(t *testing.T) { + used := map[string]struct{}{ + "stem": {}, + "stem_1": {}, + "stem_2": {}, + } + got := UniqueName("stem", used) + if got != "stem_3" { + t.Fatalf("expected 'stem_3', got %q", got) + } +} + +func TestGenerateStem(t *testing.T) { + used := map[string]struct{}{ + "existing": {}, + } + stem, err := GenerateStem("My Cool Track.mp3", "mus_", used) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if stem == "" { + t.Fatal("expected non-empty stem") + } + if len(stem) > MaxStemLen { + t.Fatalf("stem %q exceeds max length %d", stem, MaxStemLen) + } + if _, exists := used[stem]; !exists { + t.Fatal("expected stem to be registered in used set") + } +} + +func TestGenerateStemPrefixTooLong(t *testing.T) { + _, err := GenerateStem("test.mp3", "this_prefix_is_way_too_long_for_stem_", nil) + if err == nil { + t.Fatal("expected error for too-long prefix") + } +} + +func TestSlugWords(t *testing.T) { + words := SlugWords("My Cool Music Track (Official) [HD]") + if len(words) == 0 { + t.Fatal("expected non-empty words") + } +} + +func TestSlugWordsStripsBrackets(t *testing.T) { + words := SlugWords("Song [Explicit] (Remix)") + for _, w := range words { + if w == "explicit" || w == "remix" { + t.Fatalf("bracket content should be removed, got word %q", w) + } + } +} + +func TestSanitizePrefix(t *testing.T) { + if got := SanitizePrefix("Mus_WG_"); got != "mus_wg_" { + t.Fatalf("unexpected prefix: %q", got) + } + if got := SanitizePrefix(" Test "); got != "test" { + t.Fatalf("unexpected prefix: %q", got) + } + if got := SanitizePrefix(""); got != "" { + t.Fatalf("expected empty prefix, got %q", got) + } +} + +func TestReserveName(t *testing.T) { + used := map[string]struct{}{} + if err := ReserveName("testname", used); err != nil { + t.Fatalf("unexpected error: %v", err) + } + if _, exists := used["testname"]; !exists { + t.Fatal("expected name to be reserved") + } + if err := ReserveName("testname", used); err == nil { + t.Fatal("expected error for duplicate name") + } +} + +func TestValidateManualOutputFile(t *testing.T) { + if err := ValidateManualOutputFile("test.bmu"); err != nil { + t.Fatalf("unexpected error: %v", err) + } + if err := ValidateManualOutputFile("test.txt"); err == nil { + t.Fatal("expected error for non-bmu extension") + } + if err := ValidateManualOutputFile(".bmu"); err == nil { + t.Fatal("expected error for empty stem") + } +} + +func TestParseCreditsMarkdownMissingFile(t *testing.T) { + entries, err := ParseCreditsMarkdown("nonexistent.md") + if err != nil { + t.Fatalf("unexpected error for missing file: %v", err) + } + if entries != nil { + t.Fatalf("expected nil entries for missing file, got %v", entries) + } +} + +func TestParseCreditsMarkdownRealContent(t *testing.T) { + content := "# Header\n\n| Artist | Title | Output File | Original File | Album | Date | License / Copyright | Notes |\n|---|---|---|---|---|---|---|---|\n| Test Artist | Test Title | `output.bmu` | `original.mp3` | Test Album | 2024 | MIT | test |\n" + path := filepath.Join(t.TempDir(), "CREDITS.md") + if err := os.WriteFile(path, []byte(content), 0o644); err != nil { + t.Fatalf("write test credits: %v", err) + } + + entries, err := ParseCreditsMarkdown(path) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(entries) != 1 { + t.Fatalf("expected 1 entry, got %d", len(entries)) + } + if entries[0].Artist != "Test Artist" { + t.Fatalf("expected 'Test Artist', got %q", entries[0].Artist) + } + if entries[0].Title != "Test Title" { + t.Fatalf("expected 'Test Title', got %q", entries[0].Title) + } + if entries[0].OutputFile != "output.bmu" { + t.Fatalf("expected 'output.bmu', got %q", entries[0].OutputFile) + } +} + +func TestRenderCreditsMarkdown(t *testing.T) { + entries := []CreditsEntry{ + { + Artist: "Test Artist", + Title: "Test Title", + OutputFile: "output.bmu", + OriginalFile: "original.mp3", + Album: "Test Album", + Date: "2024", + Rights: "MIT", + Notes: "test note", + }, + } + + result := RenderCreditsMarkdown(entries) + if !strings.Contains(result, "Test Artist") { + t.Fatal("expected rendered output to contain artist") + } + if !strings.Contains(result, "output.bmu") { + t.Fatal("expected rendered output to contain output file") + } +} + +func TestParseCreditsOverlay(t *testing.T) { + content := "# Header\n\n| Artist | Title | Output File | Original File |\n|---|---|---|---|\n| Overlay Artist | Ovr Title | `override.bmu` | `original.mp3` |\n" + path := filepath.Join(t.TempDir(), "CREDITS.md") + if err := os.WriteFile(path, []byte(content), 0o644); err != nil { + t.Fatalf("write test overlay: %v", err) + } + + overlay, err := ParseCreditsOverlay(path) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(overlay.Entries) != 1 { + t.Fatalf("expected 1 overlay entry, got %d", len(overlay.Entries)) + } + + matched := overlay.Match("original.mp3", "") + if matched == nil { + t.Fatal("expected overlay match by original file") + } + if matched.Artist != "Overlay Artist" { + t.Fatalf("expected 'Overlay Artist', got %q", matched.Artist) + } +} + +func TestApplyCreditsOverlay(t *testing.T) { + entry := &CreditsEntry{ + Artist: "Original Artist", + Title: "Original Title", + } + overlay := &CreditsEntry{ + Artist: "Overlay Artist", + } + + ApplyCreditsOverlay(entry, overlay) + if entry.Artist != "Overlay Artist" { + t.Fatalf("expected 'Overlay Artist', got %q", entry.Artist) + } + if entry.Title != "Original Title" { + t.Fatalf("expected 'Original Title' preserved, got %q", entry.Title) + } + + ApplyCreditsOverlay(entry, nil) + if entry.Artist != "Overlay Artist" { + t.Fatalf("expected nil overlay to be no-op, got %q", entry.Artist) + } +} + +func TestValidateOverlayEntries(t *testing.T) { + generated := []CreditsEntry{ + {OriginalFile: "track1.mp3", OutputFile: "track1.bmu"}, + } + overlay := CreditsOverlay{ + Entries: []CreditsEntry{ + {OriginalFile: "track1.mp3"}, + }, + } + if err := ValidateOverlayEntries("test", overlay, generated); err != nil { + t.Fatalf("unexpected validation error: %v", err) + } + + badOverlay := CreditsOverlay{ + Entries: []CreditsEntry{ + {OriginalFile: "nonexistent.mp3"}, + }, + } + if err := ValidateOverlayEntries("test", badOverlay, generated); err == nil { + t.Fatal("expected validation error for unknown original file") + } +} + +func TestIsMusicAssetPath(t *testing.T) { + if !IsMusicAssetPath("envi/music") { + t.Fatal("expected 'envi/music' to be music path") + } + if !IsMusicAssetPath("envi/music/westgate") { + t.Fatal("expected 'envi/music/westgate' to be music path") + } + if IsMusicAssetPath("envi/textures") { + t.Fatal("expected 'envi/textures' not to be music path") + } + if IsMusicAssetPath("") { + t.Fatal("expected empty string not to be music path") + } +} + +func TestFirstNonEmpty(t *testing.T) { + if got := FirstNonEmpty("", "hello", "world"); got != "hello" { + t.Fatalf("expected 'hello', got %q", got) + } + if got := FirstNonEmpty("", "", ""); got != "" { + t.Fatalf("expected empty, got %q", got) + } + if got := FirstNonEmpty("first"); got != "first" { + t.Fatalf("expected 'first', got %q", got) + } +} + +func TestJoinNonEmpty(t *testing.T) { + if got := JoinNonEmpty(", ", "a", "", "b"); got != "a, b" { + t.Fatalf("expected 'a, b', got %q", got) + } + if got := JoinNonEmpty(", "); got != "" { + t.Fatalf("expected empty, got %q", got) + } +} + +func TestTrimSlashes(t *testing.T) { + if got := TrimSlashes("/foo/bar/"); got != "foo/bar" { + t.Fatalf("expected 'foo/bar', got %q", got) + } + if got := TrimSlashes("///"); got != "" { + t.Fatalf("expected empty, got %q", got) + } +} + +func TestEscapeMarkdownCell(t *testing.T) { + result := EscapeMarkdownCell("a|b\nc") + if !strings.Contains(result, `\|`) { + t.Fatal("expected pipe to be escaped") + } + if !strings.Contains(result, "
") { + t.Fatal("expected newline to be replaced with
") + } +} + +func TestWrapCodeCell(t *testing.T) { + if got := WrapCodeCell("test.bmu"); got != "`test.bmu`" { + t.Fatalf("expected '`test.bmu`', got %q", got) + } + if got := WrapCodeCell(""); got != "" { + t.Fatalf("expected empty string, got %q", got) + } +} + +func TestSyncGeneratedCreditsArtifactsWritesNewFiles(t *testing.T) { + root := t.TempDir() + desired := map[string][]byte{ + filepath.Join(root, "test.md"): []byte("content"), + } + changed, err := SyncGeneratedCreditsArtifacts(root, desired) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if changed != 1 { + t.Fatalf("expected 1 changed file, got %d", changed) + } +} + +func TestSyncGeneratedCreditsArtifactsNoChanges(t *testing.T) { + root := t.TempDir() + path := filepath.Join(root, "test.md") + if err := os.WriteFile(path, []byte("content"), 0o644); err != nil { + t.Fatalf("write file: %v", err) + } + desired := map[string][]byte{ + path: []byte("content"), + } + changed, err := SyncGeneratedCreditsArtifacts(root, desired) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if changed != 0 { + t.Fatalf("expected 0 changed files, got %d", changed) + } +} + +func TestSyncGeneratedCreditsArtifactsRemovesStale(t *testing.T) { + root := t.TempDir() + stalePath := filepath.Join(root, "stale.md") + if err := os.WriteFile(stalePath, []byte("stale"), 0o644); err != nil { + t.Fatalf("write stale file: %v", err) + } + desired := map[string][]byte{} + changed, err := SyncGeneratedCreditsArtifacts(root, desired) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if changed != 1 { + t.Fatalf("expected 1 removed file, got %d", changed) + } +} + +func TestPrefixForDir(t *testing.T) { + prefixes := map[string]string{ + "envi/music/westgate": "mus_wg_", + } + if got := PrefixForDir(prefixes, "envi/music/westgate"); got != "mus_wg_" { + t.Fatalf("expected 'mus_wg_', got %q", got) + } + if got := PrefixForDir(prefixes, "envi/music/other"); got != "" { + t.Fatalf("expected empty prefix, got %q", got) + } + if got := PrefixForDir(nil, "envi/music"); got != "" { + t.Fatalf("expected empty prefix for nil map, got %q", got) + } +} + +func TestMaxMin(t *testing.T) { + if got := Min(3, 5); got != 3 { + t.Fatalf("Min(3,5) = %d, want 3", got) + } + if got := Max(3, 5); got != 5 { + t.Fatalf("Max(3,5) = %d, want 5", got) + } +} + +func TestResolveFFmpegEnv(t *testing.T) { + t.Setenv("SOW_FFMPEG", "/custom/ffmpeg") + path, err := ResolveFFmpeg() + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if path != "/custom/ffmpeg" { + t.Fatalf("expected '/custom/ffmpeg', got %q", path) + } +} + +func TestResolveFFprobeEnv(t *testing.T) { + t.Setenv("SOW_FFPROBE", "/custom/ffprobe") + path, err := ResolveFFprobe() + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if path != "/custom/ffprobe" { + t.Fatalf("expected '/custom/ffprobe', got %q", path) + } +} diff --git a/internal/music/naming.go b/internal/music/naming.go new file mode 100644 index 0000000..2874bc7 --- /dev/null +++ b/internal/music/naming.go @@ -0,0 +1,172 @@ +package music + +import ( + "crypto/sha1" + "fmt" + "path/filepath" + "strings" +) + +func GenerateStem(fileName, prefix string, used map[string]struct{}) (string, error) { + maxCore := MaxStemLen - len(prefix) + if maxCore < 3 { + return "", fmt.Errorf("prefix too long: %q", prefix) + } + base := strings.TrimSuffix(fileName, filepath.Ext(fileName)) + words := SlugWords(base) + core := MakeMusicCore(words, maxCore) + if core == "" { + sum := sha1.Sum([]byte(fileName)) + core = fmt.Sprintf("%x", sum[:])[:maxCore] + } + stem := strings.TrimRight((prefix + core)[:Min(MaxStemLen, len(prefix)+len(core))], "_") + if stem == "" { + return "", fmt.Errorf("could not derive music stem for %s", fileName) + } + return UniqueName(stem, used), nil +} + +func SlugWords(value string) []string { + value = BracketRE.ReplaceAllString(value, " ") + value = strings.ReplaceAll(value, "&", " and ") + value = strings.ReplaceAll(value, "'", "") + value = strings.ReplaceAll(value, "\u2019", "") + value = strings.ToLower(value) + var ascii strings.Builder + for _, r := range value { + switch { + case r >= 'a' && r <= 'z': + ascii.WriteRune(r) + case r >= '0' && r <= '9': + ascii.WriteRune(r) + default: + ascii.WriteRune(' ') + } + } + rawWords := SplitRE.Split(ascii.String(), -1) + words := make([]string, 0, len(rawWords)) + for _, word := range rawWords { + if word == "" { + continue + } + if _, drop := DropWords[word]; drop { + continue + } + if YearRE.MatchString(word) { + continue + } + words = append(words, word) + } + return words +} + +func MakeMusicCore(words []string, maxCore int) string { + core := "" + for _, word := range words { + candidate := word + if core != "" { + candidate = core + "_" + word + } + if len(candidate) <= maxCore { + core = candidate + continue + } + break + } + if core != "" { + return core + } + abbrev := make([]string, 0, Min(4, len(words))) + for _, word := range words { + if len(abbrev) == 4 { + break + } + abbrev = append(abbrev, AbbreviateWord(word, 5)) + } + for _, word := range abbrev { + candidate := word + if core != "" { + candidate = core + "_" + word + } + if len(candidate) <= maxCore { + core = candidate + continue + } + break + } + if core != "" { + return core + } + if len(words) > 0 { + return strings.Trim(words[0][:Min(maxCore, len(words[0]))], "_") + } + return "" +} + +func AbbreviateWord(word string, maxLen int) string { + if len(word) <= maxLen { + return word + } + consonants := VowelRE.ReplaceAllString(word, "") + candidate := word[:1] + if len(consonants) > 1 { + candidate += consonants[1:] + } + if len(candidate) >= 3 { + return candidate[:Min(maxLen, len(candidate))] + } + return word[:Min(maxLen, len(word))] +} + +func UniqueName(stem string, used map[string]struct{}) string { + if _, exists := used[stem]; !exists { + used[stem] = struct{}{} + return stem + } + for index := 1; ; index++ { + suffix := fmt.Sprintf("_%d", index) + prefixLen := Min(len(stem), Max(0, MaxStemLen-len(suffix))) + candidate := strings.TrimRight(stem[:prefixLen], "_") + suffix + if _, exists := used[candidate]; exists { + continue + } + used[candidate] = struct{}{} + return candidate + } +} + +func ReserveName(stem string, used map[string]struct{}) error { + stem = strings.ToLower(strings.TrimSpace(stem)) + if stem == "" { + return fmt.Errorf("output filename is empty") + } + if _, exists := used[stem]; exists { + return fmt.Errorf("output filename %s collides with an existing asset", stem) + } + used[stem] = struct{}{} + return nil +} + +func ValidateManualOutputFile(name string) error { + name = strings.ToLower(strings.TrimSpace(name)) + if !strings.HasSuffix(name, ".bmu") { + return fmt.Errorf("output file must end with .bmu") + } + stem := strings.TrimSuffix(name, ".bmu") + if stem == "" { + return fmt.Errorf("output file stem is empty") + } + if len(stem) > MaxStemLen { + return fmt.Errorf("output file stem %q exceeds %d characters", stem, MaxStemLen) + } + for _, r := range stem { + switch { + case r >= 'a' && r <= 'z': + case r >= '0' && r <= '9': + case r == '_': + default: + return fmt.Errorf("output file stem %q contains unsupported character %q", stem, string(r)) + } + } + return nil +} diff --git a/internal/pipeline/music.go b/internal/pipeline/music.go index 9a80f49..b6e4152 100644 --- a/internal/pipeline/music.go +++ b/internal/pipeline/music.go @@ -1,49 +1,18 @@ package pipeline import ( - "crypto/sha1" "encoding/json" "fmt" "os" - "os/exec" "path/filepath" - "regexp" "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" ) -const ( - maxMusicStemLen = 16 - musicCreditsHeader = "# NWN Music Pack Credits\n\nOriginal rights remain with the respective composers and licensors.\n\n## Processed Files\n\n| Artist | Title | Output File | Original File | Album | Date | License / Copyright | Notes |\n|---|---|---|---|---|---|---|---|\n" -) - -var ( - musicConvertExtensions = map[string]struct{}{ - ".mp3": {}, - ".ogg": {}, - } - musicPassthroughExtensions = map[string]struct{}{ - ".bmu": {}, - ".wav": {}, - } - musicDropWords = map[string]struct{}{ - "mp3": {}, "wav": {}, "flac": {}, "ogg": {}, "m4a": {}, - "music": {}, "track": {}, "audio": {}, "song": {}, "soundtrack": {}, - "final": {}, "version": {}, "ver": {}, "v": {}, "mix": {}, "master": {}, "remaster": {}, - "free": {}, "royalty": {}, "copyright": {}, "background": {}, "bgm": {}, - "loop": {}, "loops": {}, "loopable": {}, "seamless": {}, - "official": {}, "download": {}, "preview": {}, - "the": {}, "a": {}, "an": {}, "and": {}, "of": {}, "to": {}, "in": {}, "for": {}, "with": {}, "by": {}, - } - musicBracketRE = regexp.MustCompile(`\[[^\]]*\]|\([^)]*\)|\{[^}]*\}`) - musicSplitRE = regexp.MustCompile(`[^A-Za-z0-9]+`) - musicYearRE = regexp.MustCompile(`^\d{4,}$`) - musicVowelRE = regexp.MustCompile(`[aeiou]`) -) - type preparedMusicAssets struct { Generated []assetResource SkipSourceRel map[string]struct{} @@ -52,45 +21,6 @@ type preparedMusicAssets struct { Summary CreditsRefreshSummary } -type musicMetadata struct { - Title string - Artist string - Album string - Date string - Rights string - Notes string - Comment string - Copyright string - License string -} - -type creditsEntry struct { - Artist string `json:"artist"` - Title string `json:"title"` - OutputFile string `json:"output_file"` - OriginalFile string `json:"original_file"` - Album string `json:"album"` - Date string `json:"date"` - Rights string `json:"rights"` - Notes string `json:"notes"` -} - -type creditsSource struct { - Path string `json:"path"` - Kind string `json:"kind"` - Entries []creditsEntry `json:"entries"` -} - -type creditsInventory struct { - Sources []creditsSource `json:"sources"` -} - -type creditsOverlay struct { - ByOriginal map[string]creditsEntry - ByOutput map[string]creditsEntry - Entries []creditsEntry -} - func prepareMusicAssets(p *project.Project) (*preparedMusicAssets, error) { usedNames := make(map[string]struct{}) dirSources := make(map[string][]string) @@ -98,7 +28,7 @@ func prepareMusicAssets(p *project.Project) (*preparedMusicAssets, error) { rel = filepath.ToSlash(rel) ext := strings.ToLower(filepath.Ext(rel)) name := strings.ToLower(strings.TrimSuffix(filepath.Base(rel), filepath.Ext(rel))) - if _, convertible := musicConvertExtensions[ext]; convertible && isMusicAssetPath(rel) { + if _, convertible := music.ConvertExtensions[ext]; convertible && music.IsMusicAssetPath(rel) { dir := filepath.ToSlash(filepath.Dir(rel)) dirSources[dir] = append(dirSources[dir], rel) continue @@ -127,15 +57,15 @@ func prepareMusicAssets(p *project.Project) (*preparedMusicAssets, error) { } sort.Strings(dirs) - generatedByDir := make(map[string][]creditsEntry) + generatedByDir := make(map[string][]music.CreditsEntry) stageRoot := p.MusicStageDir() result.TempRoots = append(result.TempRoots, stageRoot) - ffmpegPath, err := resolveFFmpegBinary() + ffmpegPath, err := music.ResolveFFmpeg() if err != nil { return nil, err } - ffprobePath, err := resolveFFprobeBinary() + ffprobePath, err := music.ResolveFFprobe() if err != nil { return nil, err } @@ -143,56 +73,56 @@ func prepareMusicAssets(p *project.Project) (*preparedMusicAssets, error) { for _, dir := range dirs { sources := append([]string(nil), dirSources[dir]...) sort.Strings(sources) - prefix := musicPrefixForDir(p, dir) + prefix := music.PrefixForDir(p.EffectiveConfig().Music.Prefixes, dir) overlayPath := filepath.Join(p.AssetsDir(), filepath.FromSlash(dir), p.EffectiveConfig().Music.CreditsOverlay) - overlay, err := parseCreditsOverlay(overlayPath) + overlay, err := music.ParseCreditsOverlay(overlayPath) if err != nil { return nil, fmt.Errorf("parse credits overlay %s: %w", overlayPath, err) } - entries := make([]creditsEntry, 0, len(sources)) + entries := make([]music.CreditsEntry, 0, len(sources)) for _, rel := range sources { base := filepath.Base(rel) - manual := overlay.match(base, "") + manual := overlay.Match(base, "") outputFile := "" switch { case manual != nil && strings.TrimSpace(manual.OutputFile) != "": outputFile = strings.ToLower(strings.TrimSpace(manual.OutputFile)) - if err := validateManualOutputFile(outputFile); err != nil { + if err := music.ValidateManualOutputFile(outputFile); err != nil { return nil, fmt.Errorf("manual credits output for %s: %w", rel, err) } - if err := reserveMusicName(strings.TrimSuffix(outputFile, filepath.Ext(outputFile)), usedNames); err != nil { + 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 := generateMusicStem(base, prefix, usedNames) + 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 := probeMusicMetadata(ffprobePath, filepath.Join(p.AssetsDir(), filepath.FromSlash(rel))) + 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 := creditsEntry{ + entry := music.CreditsEntry{ Artist: metadata.Artist, - Title: firstNonEmpty(metadata.Title, strings.TrimSuffix(base, filepath.Ext(base))), + Title: music.FirstNonEmpty(metadata.Title, strings.TrimSuffix(base, filepath.Ext(base))), OutputFile: outputFile, OriginalFile: base, Album: metadata.Album, Date: metadata.Date, - Rights: firstNonEmpty(metadata.Rights, joinNonEmpty("; ", metadata.License, metadata.Copyright)), - Notes: firstNonEmpty(metadata.Notes, metadata.Comment), + Rights: music.FirstNonEmpty(metadata.Rights, music.JoinNonEmpty("; ", metadata.License, metadata.Copyright)), + Notes: music.FirstNonEmpty(metadata.Notes, metadata.Comment), } - applyCreditsOverlay(&entry, manual) + 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 := convertMusicSource(ffmpegPath, filepath.Join(p.AssetsDir(), filepath.FromSlash(rel)), stagePath); err != nil { + 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) @@ -213,7 +143,7 @@ func prepareMusicAssets(p *project.Project) (*preparedMusicAssets, error) { result.SkipSourceRel[rel] = struct{}{} entries = append(entries, entry) } - if err := validateOverlayEntries(dir, overlay, entries); err != nil { + if err := music.ValidateOverlayEntries(dir, overlay, entries); err != nil { return nil, err } generatedByDir[dir] = entries @@ -238,9 +168,9 @@ type creditsArtifactWriteResult struct { Summary CreditsRefreshSummary } -func writeCreditsArtifacts(p *project.Project, generatedByDir map[string][]creditsEntry) (creditsArtifactWriteResult, error) { +func writeCreditsArtifacts(p *project.Project, generatedByDir map[string][]music.CreditsEntry) (creditsArtifactWriteResult, error) { creditsRoot := p.CreditsDir() - sources := make([]creditsSource, 0) + sources := make([]music.CreditsSource, 0) desiredFiles := make(map[string][]byte) summary := CreditsRefreshSummary{} if generatedByDir != nil { @@ -250,7 +180,7 @@ func writeCreditsArtifacts(p *project.Project, generatedByDir map[string][]credi } sort.Strings(dirs) for _, dir := range dirs { - entries := append([]creditsEntry(nil), generatedByDir[dir]...) + 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) @@ -261,7 +191,7 @@ func writeCreditsArtifacts(p *project.Project, generatedByDir map[string][]credi 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(renderCreditsMarkdown(entries)) + desiredFiles[outputPath] = []byte(music.RenderCreditsMarkdown(entries)) summary.GeneratedPaths = append(summary.GeneratedPaths, outputPath) for _, entry := range entries { summary.Mappings = append(summary.Mappings, MusicFileMapping{ @@ -269,7 +199,7 @@ func writeCreditsArtifacts(p *project.Project, generatedByDir map[string][]credi Output: entry.OutputFile, }) } - sources = append(sources, creditsSource{ + sources = append(sources, music.CreditsSource{ Path: filepath.ToSlash(outputPath), Kind: "generated_music", Entries: entries, @@ -287,7 +217,7 @@ func writeCreditsArtifacts(p *project.Project, generatedByDir map[string][]credi if strings.ToLower(d.Name()) != "credits.md" { return nil } - entries, err := parseCreditsMarkdown(path) + entries, err := music.ParseCreditsMarkdown(path) if err != nil { return fmt.Errorf("parse credits %s: %w", path, err) } @@ -295,7 +225,7 @@ func writeCreditsArtifacts(p *project.Project, generatedByDir map[string][]credi if err != nil { return err } - sources = append(sources, creditsSource{ + sources = append(sources, music.CreditsSource{ Path: filepath.ToSlash(rel), Kind: "authored", Entries: entries, @@ -308,7 +238,7 @@ func writeCreditsArtifacts(p *project.Project, generatedByDir map[string][]credi 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(creditsInventory{Sources: sources}, "", " ") + payload, err := json.MarshalIndent(music.CreditsInventory{Sources: sources}, "", " ") if err != nil { return creditsArtifactWriteResult{}, fmt.Errorf("marshal credits inventory: %w", err) } @@ -317,7 +247,7 @@ func writeCreditsArtifacts(p *project.Project, generatedByDir map[string][]credi if err := os.MkdirAll(creditsRoot, 0o755); err != nil { return creditsArtifactWriteResult{}, fmt.Errorf("create credits dir: %w", err) } - changedFiles, err := syncGeneratedCreditsArtifacts(creditsRoot, desiredFiles) + changedFiles, err := music.SyncGeneratedCreditsArtifacts(creditsRoot, desiredFiles) if err != nil { return creditsArtifactWriteResult{}, err } @@ -335,53 +265,6 @@ func writeCreditsArtifacts(p *project.Project, generatedByDir map[string][]credi }, nil } -func syncGeneratedCreditsArtifacts(root string, desiredFiles map[string][]byte) (int, error) { - changedFiles := 0 - existingFiles := make(map[string]struct{}) - if err := filepath.WalkDir(root, func(path string, d os.DirEntry, walkErr error) error { - if walkErr != nil { - if os.IsNotExist(walkErr) { - return nil - } - return walkErr - } - if d.IsDir() { - return nil - } - existingFiles[path] = struct{}{} - return nil - }); err != nil { - return 0, fmt.Errorf("scan credits dir: %w", err) - } - - for path, content := range desiredFiles { - current, err := os.ReadFile(path) - if err != nil && !os.IsNotExist(err) { - return 0, fmt.Errorf("read credits artifact %s: %w", path, err) - } - if err == nil && string(current) == string(content) { - delete(existingFiles, path) - continue - } - if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { - return 0, fmt.Errorf("create generated credits dir: %w", err) - } - if err := os.WriteFile(path, content, 0o644); err != nil { - return 0, fmt.Errorf("write credits artifact %s: %w", path, err) - } - changedFiles++ - delete(existingFiles, path) - } - - for path := range existingFiles { - if err := os.Remove(path); err != nil && !os.IsNotExist(err) { - return 0, fmt.Errorf("remove stale credits artifact %s: %w", path, err) - } - changedFiles++ - } - return changedFiles, nil -} - func cleanupPreparedMusicAssets(prepared *preparedMusicAssets) error { if prepared == nil { return nil @@ -396,569 +279,3 @@ func cleanupPreparedMusicAssets(prepared *preparedMusicAssets) error { } return nil } - -func parseCreditsOverlay(path string) (creditsOverlay, error) { - entries, err := parseCreditsMarkdown(path) - if err != nil { - return creditsOverlay{}, err - } - overlay := creditsOverlay{ - ByOriginal: make(map[string]creditsEntry), - ByOutput: make(map[string]creditsEntry), - Entries: entries, - } - for _, entry := range entries { - if entry.OriginalFile != "" { - key := strings.ToLower(strings.TrimSpace(entry.OriginalFile)) - if _, exists := overlay.ByOriginal[key]; exists { - return creditsOverlay{}, fmt.Errorf("duplicate manual credits row for original file %s", entry.OriginalFile) - } - overlay.ByOriginal[key] = entry - } - if entry.OutputFile != "" { - key := strings.ToLower(strings.TrimSpace(entry.OutputFile)) - if _, exists := overlay.ByOutput[key]; exists { - return creditsOverlay{}, fmt.Errorf("duplicate manual credits row for output file %s", entry.OutputFile) - } - overlay.ByOutput[key] = entry - } - } - return overlay, nil -} - -func (o creditsOverlay) match(originalFile, outputFile string) *creditsEntry { - if entry, ok := o.ByOutput[strings.ToLower(strings.TrimSpace(outputFile))]; ok { - entryCopy := entry - return &entryCopy - } - if entry, ok := o.ByOriginal[strings.ToLower(strings.TrimSpace(originalFile))]; ok { - entryCopy := entry - return &entryCopy - } - return nil -} - -func validateOverlayEntries(dir string, overlay creditsOverlay, generated []creditsEntry) error { - if len(overlay.Entries) == 0 { - return nil - } - validOriginal := make(map[string]struct{}, len(generated)) - validOutput := make(map[string]struct{}, len(generated)) - for _, entry := range generated { - validOriginal[strings.ToLower(entry.OriginalFile)] = struct{}{} - validOutput[strings.ToLower(entry.OutputFile)] = struct{}{} - } - for _, entry := range overlay.Entries { - if entry.OriginalFile != "" { - if _, ok := validOriginal[strings.ToLower(entry.OriginalFile)]; !ok { - return fmt.Errorf("manual credits entry in %s references unknown original file %s", dir, entry.OriginalFile) - } - } - if entry.OutputFile != "" { - if _, ok := validOutput[strings.ToLower(entry.OutputFile)]; !ok { - return fmt.Errorf("manual credits entry in %s references unknown output file %s", dir, entry.OutputFile) - } - } - } - return nil -} - -func parseCreditsMarkdown(path string) ([]creditsEntry, error) { - raw, err := os.ReadFile(path) - if err != nil { - if os.IsNotExist(err) { - return nil, nil - } - return nil, err - } - lines := strings.Split(strings.ReplaceAll(string(raw), "\r\n", "\n"), "\n") - header := []string(nil) - entries := make([]creditsEntry, 0) - for _, line := range lines { - line = strings.TrimSpace(line) - if !strings.HasPrefix(line, "|") { - continue - } - cells := parseMarkdownTableRow(line) - if len(cells) == 0 { - continue - } - if header == nil { - header = make([]string, len(cells)) - for i, cell := range cells { - header[i] = normalizeCreditsHeader(cell) - } - continue - } - if isMarkdownSeparatorRow(cells) { - continue - } - entry := creditsEntry{} - for i, key := range header { - if i >= len(cells) { - continue - } - value := cleanCreditsCell(cells[i]) - switch key { - case "artist": - entry.Artist = value - case "title": - entry.Title = value - case "output_file": - entry.OutputFile = value - case "original_file": - entry.OriginalFile = value - case "album": - entry.Album = value - case "date": - entry.Date = value - case "rights": - entry.Rights = value - case "notes": - entry.Notes = value - } - } - if entry.Artist == "" && entry.Title == "" && entry.OutputFile == "" && entry.OriginalFile == "" { - continue - } - entries = append(entries, entry) - } - return entries, nil -} - -func parseMarkdownTableRow(line string) []string { - trimmed := strings.TrimSpace(line) - trimmed = strings.TrimPrefix(trimmed, "|") - trimmed = strings.TrimSuffix(trimmed, "|") - parts := strings.Split(trimmed, "|") - cells := make([]string, 0, len(parts)) - for _, part := range parts { - cells = append(cells, strings.TrimSpace(strings.ReplaceAll(part, `\|`, "|"))) - } - return cells -} - -func isMarkdownSeparatorRow(cells []string) bool { - if len(cells) == 0 { - return false - } - for _, cell := range cells { - cell = strings.TrimSpace(cell) - cell = strings.ReplaceAll(cell, "-", "") - cell = strings.ReplaceAll(cell, ":", "") - if cell != "" { - return false - } - } - return true -} - -func normalizeCreditsHeader(value string) string { - switch strings.ToLower(cleanCreditsCell(value)) { - case "artist": - return "artist" - case "title": - return "title" - case "output file": - return "output_file" - case "original file": - return "original_file" - case "album": - return "album" - case "date": - return "date" - case "license / copyright": - return "rights" - case "notes": - return "notes" - default: - return "" - } -} - -func cleanCreditsCell(value string) string { - value = strings.TrimSpace(value) - value = strings.Trim(value, "`") - value = strings.ReplaceAll(value, "
", "\n") - return strings.TrimSpace(value) -} - -func renderCreditsMarkdown(entries []creditsEntry) string { - var builder strings.Builder - builder.WriteString(musicCreditsHeader) - for _, entry := range entries { - builder.WriteString("| ") - builder.WriteString(escapeMarkdownCell(entry.Artist)) - builder.WriteString(" | ") - builder.WriteString(escapeMarkdownCell(entry.Title)) - builder.WriteString(" | ") - builder.WriteString(escapeMarkdownCell(wrapCodeCell(entry.OutputFile))) - builder.WriteString(" | ") - builder.WriteString(escapeMarkdownCell(wrapCodeCell(entry.OriginalFile))) - builder.WriteString(" | ") - builder.WriteString(escapeMarkdownCell(entry.Album)) - builder.WriteString(" | ") - builder.WriteString(escapeMarkdownCell(entry.Date)) - builder.WriteString(" | ") - builder.WriteString(escapeMarkdownCell(entry.Rights)) - builder.WriteString(" | ") - builder.WriteString(escapeMarkdownCell(entry.Notes)) - builder.WriteString(" |\n") - } - return builder.String() -} - -func wrapCodeCell(value string) string { - if strings.TrimSpace(value) == "" { - return "" - } - return "`" + value + "`" -} - -func escapeMarkdownCell(value string) string { - value = strings.ReplaceAll(value, `\`, `\\`) - value = strings.ReplaceAll(value, "|", `\|`) - value = strings.ReplaceAll(value, "\n", "
") - return value -} - -func applyCreditsOverlay(entry *creditsEntry, overlay *creditsEntry) { - if overlay == nil { - return - } - entry.Artist = firstNonEmpty(overlay.Artist, entry.Artist) - entry.Title = firstNonEmpty(overlay.Title, entry.Title) - entry.Album = firstNonEmpty(overlay.Album, entry.Album) - entry.Date = firstNonEmpty(overlay.Date, entry.Date) - entry.Rights = firstNonEmpty(overlay.Rights, entry.Rights) - entry.Notes = firstNonEmpty(overlay.Notes, entry.Notes) -} - -func firstNonEmpty(values ...string) string { - for _, value := range values { - if strings.TrimSpace(value) != "" { - return strings.TrimSpace(value) - } - } - return "" -} - -func joinNonEmpty(sep string, values ...string) string { - parts := make([]string, 0, len(values)) - for _, value := range values { - value = strings.TrimSpace(value) - if value != "" { - parts = append(parts, value) - } - } - return strings.Join(parts, sep) -} - -func resolveFFmpegBinary() (string, error) { - if configured := strings.TrimSpace(os.Getenv("SOW_FFMPEG")); configured != "" { - return configured, nil - } - path, err := exec.LookPath("ffmpeg") - if err != nil { - return "", fmt.Errorf("ffmpeg not found; install ffmpeg or set SOW_FFMPEG") - } - return path, nil -} - -func resolveFFprobeBinary() (string, error) { - if configured := strings.TrimSpace(os.Getenv("SOW_FFPROBE")); configured != "" { - return configured, nil - } - path, err := exec.LookPath("ffprobe") - if err != nil { - return "", fmt.Errorf("ffprobe not found; install ffprobe or set SOW_FFPROBE") - } - return path, nil -} - -func probeMusicMetadata(ffprobePath, sourcePath string) (musicMetadata, error) { - cmd := exec.Command(ffprobePath, "-v", "quiet", "-print_format", "json", "-show_format", sourcePath) - output, err := cmd.Output() - if err != nil { - return musicMetadata{}, err - } - var payload struct { - Format struct { - Tags map[string]string `json:"tags"` - } `json:"format"` - } - if err := json.Unmarshal(output, &payload); err != nil { - return musicMetadata{}, err - } - tags := make(map[string]string, len(payload.Format.Tags)) - for key, value := range payload.Format.Tags { - tags[strings.ToLower(strings.TrimSpace(key))] = strings.TrimSpace(value) - } - return musicMetadata{ - Title: firstNonEmpty(tags["title"]), - Artist: firstNonEmpty(tags["artist"], tags["album_artist"], tags["composer"]), - Album: firstNonEmpty(tags["album"]), - Date: firstNonEmpty(tags["date"], tags["year"]), - Comment: firstNonEmpty(tags["comment"], tags["description"]), - Copyright: firstNonEmpty(tags["copyright"]), - License: firstNonEmpty(tags["license"], tags["license_url"]), - }, nil -} - -func convertMusicSource(ffmpegPath, sourcePath, outputPath string) error { - cmd := exec.Command( - ffmpegPath, "-y", - "-i", sourcePath, - "-vn", - "-map_metadata", "-1", - "-ar", "44100", - "-ac", "2", - "-b:a", "192k", - "-codec:a", "libmp3lame", - "-write_xing", "0", - "-f", "mp3", - outputPath, - ) - output, err := cmd.CombinedOutput() - if err != nil { - message := strings.TrimSpace(string(output)) - if message == "" { - message = err.Error() - } - return fmt.Errorf("%s", message) - } - return nil -} - -func isMusicAssetPath(rel string) bool { - rel = filepath.ToSlash(strings.TrimSpace(rel)) - return rel == "envi/music" || strings.HasPrefix(rel, "envi/music/") -} - -func musicPrefixForDir(p *project.Project, dir string) string { - dir = trimSlashes(filepath.ToSlash(dir)) - bestPrefix := "" - bestLen := -1 - keys := make([]string, 0, len(p.Config.Music.Prefixes)) - for key := range p.Config.Music.Prefixes { - keys = append(keys, key) - } - sort.Strings(keys) - for _, key := range keys { - value := p.Config.Music.Prefixes[key] - normalizedKey := trimSlashes(filepath.ToSlash(key)) - if normalizedKey == "" { - continue - } - if dir != normalizedKey && !strings.HasPrefix(dir, normalizedKey+"/") { - continue - } - if len(normalizedKey) > bestLen { - bestLen = len(normalizedKey) - bestPrefix = sanitizePrefix(value) - } - } - return bestPrefix -} - -func sanitizePrefix(prefix string) string { - prefix = strings.ToLower(strings.TrimSpace(prefix)) - var builder strings.Builder - for _, r := range prefix { - switch { - case r >= 'a' && r <= 'z': - builder.WriteRune(r) - case r >= '0' && r <= '9': - builder.WriteRune(r) - case r == '_': - builder.WriteRune(r) - } - } - result := builder.String() - if strings.TrimSpace(prefix) != "" && strings.HasSuffix(strings.TrimSpace(prefix), "_") && !strings.HasSuffix(result, "_") { - result += "_" - } - return result -} - -func trimSlashes(value string) string { - return strings.Trim(strings.TrimSpace(value), "/") -} - -func generateMusicStem(fileName, prefix string, used map[string]struct{}) (string, error) { - maxCore := maxMusicStemLen - len(prefix) - if maxCore < 3 { - return "", fmt.Errorf("prefix too long: %q", prefix) - } - base := strings.TrimSuffix(fileName, filepath.Ext(fileName)) - words := slugWords(base) - core := makeMusicCore(words, maxCore) - if core == "" { - sum := sha1.Sum([]byte(fileName)) - core = fmt.Sprintf("%x", sum[:])[:maxCore] - } - stem := strings.TrimRight((prefix + core)[:min(maxMusicStemLen, len(prefix)+len(core))], "_") - if stem == "" { - return "", fmt.Errorf("could not derive music stem for %s", fileName) - } - return uniqueMusicName(stem, used), nil -} - -func slugWords(value string) []string { - value = musicBracketRE.ReplaceAllString(value, " ") - value = strings.ReplaceAll(value, "&", " and ") - value = strings.ReplaceAll(value, "'", "") - value = strings.ReplaceAll(value, "’", "") - value = strings.ToLower(value) - var ascii strings.Builder - for _, r := range value { - switch { - case r >= 'a' && r <= 'z': - ascii.WriteRune(r) - case r >= '0' && r <= '9': - ascii.WriteRune(r) - default: - ascii.WriteRune(' ') - } - } - rawWords := musicSplitRE.Split(ascii.String(), -1) - words := make([]string, 0, len(rawWords)) - for _, word := range rawWords { - if word == "" { - continue - } - if _, drop := musicDropWords[word]; drop { - continue - } - if musicYearRE.MatchString(word) { - continue - } - words = append(words, word) - } - return words -} - -func makeMusicCore(words []string, maxCore int) string { - core := "" - for _, word := range words { - candidate := word - if core != "" { - candidate = core + "_" + word - } - if len(candidate) <= maxCore { - core = candidate - continue - } - break - } - if core != "" { - return core - } - abbrev := make([]string, 0, min(4, len(words))) - for _, word := range words { - if len(abbrev) == 4 { - break - } - abbrev = append(abbrev, abbreviateMusicWord(word, 5)) - } - for _, word := range abbrev { - candidate := word - if core != "" { - candidate = core + "_" + word - } - if len(candidate) <= maxCore { - core = candidate - continue - } - break - } - if core != "" { - return core - } - if len(words) > 0 { - return strings.Trim(words[0][:min(maxCore, len(words[0]))], "_") - } - return "" -} - -func abbreviateMusicWord(word string, maxLen int) string { - if len(word) <= maxLen { - return word - } - consonants := musicVowelRE.ReplaceAllString(word, "") - candidate := word[:1] - if len(consonants) > 1 { - candidate += consonants[1:] - } - if len(candidate) >= 3 { - return candidate[:min(maxLen, len(candidate))] - } - return word[:min(maxLen, len(word))] -} - -func uniqueMusicName(stem string, used map[string]struct{}) string { - if _, exists := used[stem]; !exists { - used[stem] = struct{}{} - return stem - } - for index := 1; ; index++ { - suffix := fmt.Sprintf("_%d", index) - prefixLen := min(len(stem), max(0, maxMusicStemLen-len(suffix))) - candidate := strings.TrimRight(stem[:prefixLen], "_") + suffix - if _, exists := used[candidate]; exists { - continue - } - used[candidate] = struct{}{} - return candidate - } -} - -func reserveMusicName(stem string, used map[string]struct{}) error { - stem = strings.ToLower(strings.TrimSpace(stem)) - if stem == "" { - return fmt.Errorf("output filename is empty") - } - if _, exists := used[stem]; exists { - return fmt.Errorf("output filename %s collides with an existing asset", stem) - } - used[stem] = struct{}{} - return nil -} - -func validateManualOutputFile(name string) error { - name = strings.ToLower(strings.TrimSpace(name)) - if !strings.HasSuffix(name, ".bmu") { - return fmt.Errorf("output file must end with .bmu") - } - stem := strings.TrimSuffix(name, ".bmu") - if stem == "" { - return fmt.Errorf("output file stem is empty") - } - if len(stem) > maxMusicStemLen { - return fmt.Errorf("output file stem %q exceeds %d characters", stem, maxMusicStemLen) - } - for _, r := range stem { - switch { - case r >= 'a' && r <= 'z': - case r >= '0' && r <= '9': - case r == '_': - default: - return fmt.Errorf("output file stem %q contains unsupported character %q", stem, string(r)) - } - } - return nil -} - -func min(a, b int) int { - if a < b { - return a - } - return b -} - -func max(a, b int) int { - if a > b { - return a - } - return b -} diff --git a/internal/pipeline/pipeline_test.go b/internal/pipeline/pipeline_test.go index 1d25a94..203873a 100644 --- a/internal/pipeline/pipeline_test.go +++ b/internal/pipeline/pipeline_test.go @@ -2769,13 +2769,4 @@ func TestBuildHAKsConvertsMusicSourcesAndWritesCreditsArtifacts(t *testing.T) { } } -func TestUniqueMusicNameHandlesShortStemCollisions(t *testing.T) { - used := map[string]struct{}{ - "mus_wg_mystc": {}, - } - got := uniqueMusicName("mus_wg_mystc", used) - if got != "mus_wg_mystc_1" { - t.Fatalf("unexpected collision result: got %q want %q", got, "mus_wg_mystc_1") - } -} diff --git a/internal/project/effective.go b/internal/project/effective.go index cdba63e..9c2bfb3 100644 --- a/internal/project/effective.go +++ b/internal/project/effective.go @@ -103,11 +103,19 @@ type EffectiveScriptCompilerEnvConfig struct { } type EffectiveMusicConfig struct { - Prefixes map[string]string `json:"prefixes" yaml:"prefixes"` - StageRoot string `json:"stage_root" yaml:"stage_root"` - CreditsRoot string `json:"credits_root" yaml:"credits_root"` - CreditsOverlay string `json:"credits_overlay" yaml:"credits_overlay"` - MaxStemLength int `json:"max_stem_length" yaml:"max_stem_length"` + Prefixes map[string]string `json:"prefixes" yaml:"prefixes"` + StageRoot string `json:"stage_root" yaml:"stage_root"` + CreditsRoot string `json:"credits_root" yaml:"credits_root"` + CreditsOverlay string `json:"credits_overlay" yaml:"credits_overlay"` + MaxStemLength int `json:"max_stem_length" yaml:"max_stem_length"` + Datasets map[string]EffectiveMusicDataset `json:"datasets,omitempty" yaml:"datasets,omitempty"` +} + +type EffectiveMusicDataset struct { + Source string `json:"source" yaml:"source"` + Prefix string `json:"prefix,omitempty" yaml:"prefix,omitempty"` + StageRoot string `json:"stage_root,omitempty" yaml:"stage_root,omitempty"` + CreditsRoot string `json:"credits_root,omitempty" yaml:"credits_root,omitempty"` } type EffectiveTopDataConfig struct { @@ -209,6 +217,51 @@ func (p *Project) EffectiveConfig() EffectiveConfig { extract.CleanupStale = &defaultCleanup } + musicStageRoot := expandPathTemplate(DefaultMusicStageRoot, paths) + musicCreditsRoot := expandPathTemplate(DefaultCreditsRoot, paths) + musicCreditsOverlay := DefaultCreditsOverlayFile + musicMaxStemLength := 16 + if d := p.Config.Music.Defaults; d != nil { + if d.StageRoot != "" { + musicStageRoot = expandPathTemplate(d.StageRoot, paths) + } + if d.CreditsRoot != "" { + musicCreditsRoot = expandPathTemplate(d.CreditsRoot, paths) + } + if d.CreditsOverlay != "" { + musicCreditsOverlay = d.CreditsOverlay + } + if d.MaxStemLength != nil { + musicMaxStemLength = *d.MaxStemLength + } + } + musicPrefixes := cloneStringMap(p.Config.Music.Prefixes) + if len(musicPrefixes) == 0 { + musicPrefixes = make(map[string]string, len(p.Config.Music.Datasets)) + for _, ds := range p.Config.Music.Datasets { + if ds.Source != "" && ds.Prefix != "" { + musicPrefixes[ds.Source] = ds.Prefix + } + } + } + effectiveDatasets := make(map[string]EffectiveMusicDataset, len(p.Config.Music.Datasets)) + for id, ds := range p.Config.Music.Datasets { + dsStageRoot := ds.StageRoot + if dsStageRoot == "" { + dsStageRoot = musicStageRoot + } + dsCreditsRoot := ds.CreditsRoot + if dsCreditsRoot == "" { + dsCreditsRoot = musicCreditsRoot + } + effectiveDatasets[id] = EffectiveMusicDataset{ + Source: ds.Source, + Prefix: ds.Prefix, + StageRoot: dsStageRoot, + CreditsRoot: dsCreditsRoot, + } + } + effective := EffectiveConfig{ ConfigSource: p.ConfigSource, Module: p.Config.Module, @@ -218,11 +271,12 @@ func (p *Project) EffectiveConfig() EffectiveConfig { Inventory: inventory, Scripts: scripts, Music: EffectiveMusicConfig{ - Prefixes: cloneStringMap(p.Config.Music.Prefixes), - StageRoot: expandPathTemplate(DefaultMusicStageRoot, paths), - CreditsRoot: expandPathTemplate(DefaultCreditsRoot, paths), - CreditsOverlay: DefaultCreditsOverlayFile, - MaxStemLength: 16, + Prefixes: musicPrefixes, + StageRoot: musicStageRoot, + CreditsRoot: musicCreditsRoot, + CreditsOverlay: musicCreditsOverlay, + MaxStemLength: musicMaxStemLength, + Datasets: effectiveDatasets, }, TopData: top, Extract: extract, diff --git a/internal/project/project.go b/internal/project/project.go index d7ed92b..0a24741 100644 --- a/internal/project/project.go +++ b/internal/project/project.go @@ -121,7 +121,23 @@ type HAKConfig struct { } type MusicConfig struct { - Prefixes map[string]string `json:"prefixes" yaml:"prefixes"` + Prefixes map[string]string `json:"prefixes,omitempty" yaml:"prefixes,omitempty"` + Defaults *MusicDefaultsConfig `json:"defaults,omitempty" yaml:"defaults,omitempty"` + Datasets map[string]MusicDatasetConfig `json:"datasets,omitempty" yaml:"datasets,omitempty"` +} + +type MusicDefaultsConfig struct { + StageRoot string `json:"stage_root,omitempty" yaml:"stage_root,omitempty"` + CreditsRoot string `json:"credits_root,omitempty" yaml:"credits_root,omitempty"` + CreditsOverlay string `json:"credits_overlay,omitempty" yaml:"credits_overlay,omitempty"` + MaxStemLength *int `json:"max_stem_length,omitempty" yaml:"max_stem_length,omitempty"` +} + +type MusicDatasetConfig struct { + Source string `json:"source" yaml:"source"` + Prefix string `json:"prefix,omitempty" yaml:"prefix,omitempty"` + StageRoot string `json:"stage_root,omitempty" yaml:"stage_root,omitempty"` + CreditsRoot string `json:"credits_root,omitempty" yaml:"credits_root,omitempty"` } type TopDataConfig struct { @@ -871,6 +887,25 @@ func normalizeConfig(cfg *Config) { for i := range cfg.Autogen.Consumers { cfg.Autogen.Consumers[i].Include = normalizeStringSlice(cfg.Autogen.Consumers[i].Include) } + normalizeMusicConfig(&cfg.Music) +} + +func normalizeMusicConfig(music *MusicConfig) { + if len(music.Prefixes) > 0 && len(music.Datasets) == 0 { + datasets := make(map[string]MusicDatasetConfig, len(music.Prefixes)) + for dir, prefix := range music.Prefixes { + source := trimConfigSlashes(filepath.ToSlash(strings.TrimSpace(dir))) + if source == "" { + continue + } + key := strings.ReplaceAll(source, "/", "_") + datasets[key] = MusicDatasetConfig{ + Source: source, + Prefix: strings.TrimSpace(prefix), + } + } + music.Datasets = datasets + } } func normalizeStringSlice(input []string) []string { @@ -994,6 +1029,20 @@ func validateMusicConfig(cfg MusicConfig) []error { failures = append(failures, fmt.Errorf("music.prefixes[%q] must not be empty", root)) } } + seenSources := map[string]string{} + for id, ds := range cfg.Datasets { + if strings.TrimSpace(id) == "" { + failures = append(failures, errors.New("music.datasets contains an empty key")) + } + source := trimConfigSlashes(filepath.ToSlash(strings.TrimSpace(ds.Source))) + if source == "" { + failures = append(failures, fmt.Errorf("music.datasets[%q].source is required", id)) + } else if prior, exists := seenSources[source]; exists { + failures = append(failures, fmt.Errorf("music.datasets[%q].source %q conflicts with dataset %q", id, ds.Source, prior)) + } else { + seenSources[source] = id + } + } return failures }