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
}