Music Pipeline Refactor

This commit is contained in:
2026-05-07 18:17:40 +02:00
parent 6ab5f05c41
commit bba0d2fb45
11 changed files with 1333 additions and 732 deletions
+24
View File
@@ -61,8 +61,17 @@ paths:
build: build build: build
music: music:
# Legacy shorthand — automatically normalized into a dataset.
prefixes: prefixes:
envi/music/westgate: mus_wg_ 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: Resolved configuration can be inspected without scanning or building:
@@ -151,6 +160,21 @@ autogen:
repo_env: SOW_ASSETS_REPO 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, Generated and machine-readable artifacts remain JSON, including HAK manifests,
topdata datasets, caches, credits inventories, build reports, and canonical GFF topdata datasets, caches, credits inventories, build reports, and canonical GFF
documents. Nested metadata such as `topdata/templates/config.json` is not root documents. Nested metadata such as `topdata/templates/config.json` is not root
+47
View File
@@ -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
}
+292
View File
@@ -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, "<br>", "\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", "<br>")
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
}
+62
View File
@@ -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
}
+189
View File
@@ -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)
}
+404
View File
@@ -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, "<br>") {
t.Fatal("expected newline to be replaced with <br>")
}
}
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)
}
}
+172
View File
@@ -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
}
+29 -712
View File
@@ -1,49 +1,18 @@
package pipeline package pipeline
import ( import (
"crypto/sha1"
"encoding/json" "encoding/json"
"fmt" "fmt"
"os" "os"
"os/exec"
"path/filepath" "path/filepath"
"regexp"
"sort" "sort"
"strings" "strings"
"gitea.westgate.pw/ShadowsOverWestgate/sow-tools/internal/erf" "gitea.westgate.pw/ShadowsOverWestgate/sow-tools/internal/erf"
"gitea.westgate.pw/ShadowsOverWestgate/sow-tools/internal/music"
"gitea.westgate.pw/ShadowsOverWestgate/sow-tools/internal/project" "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 { type preparedMusicAssets struct {
Generated []assetResource Generated []assetResource
SkipSourceRel map[string]struct{} SkipSourceRel map[string]struct{}
@@ -52,45 +21,6 @@ type preparedMusicAssets struct {
Summary CreditsRefreshSummary 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) { func prepareMusicAssets(p *project.Project) (*preparedMusicAssets, error) {
usedNames := make(map[string]struct{}) usedNames := make(map[string]struct{})
dirSources := make(map[string][]string) dirSources := make(map[string][]string)
@@ -98,7 +28,7 @@ func prepareMusicAssets(p *project.Project) (*preparedMusicAssets, error) {
rel = filepath.ToSlash(rel) rel = filepath.ToSlash(rel)
ext := strings.ToLower(filepath.Ext(rel)) ext := strings.ToLower(filepath.Ext(rel))
name := strings.ToLower(strings.TrimSuffix(filepath.Base(rel), 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)) dir := filepath.ToSlash(filepath.Dir(rel))
dirSources[dir] = append(dirSources[dir], rel) dirSources[dir] = append(dirSources[dir], rel)
continue continue
@@ -127,15 +57,15 @@ func prepareMusicAssets(p *project.Project) (*preparedMusicAssets, error) {
} }
sort.Strings(dirs) sort.Strings(dirs)
generatedByDir := make(map[string][]creditsEntry) generatedByDir := make(map[string][]music.CreditsEntry)
stageRoot := p.MusicStageDir() stageRoot := p.MusicStageDir()
result.TempRoots = append(result.TempRoots, stageRoot) result.TempRoots = append(result.TempRoots, stageRoot)
ffmpegPath, err := resolveFFmpegBinary() ffmpegPath, err := music.ResolveFFmpeg()
if err != nil { if err != nil {
return nil, err return nil, err
} }
ffprobePath, err := resolveFFprobeBinary() ffprobePath, err := music.ResolveFFprobe()
if err != nil { if err != nil {
return nil, err return nil, err
} }
@@ -143,56 +73,56 @@ func prepareMusicAssets(p *project.Project) (*preparedMusicAssets, error) {
for _, dir := range dirs { for _, dir := range dirs {
sources := append([]string(nil), dirSources[dir]...) sources := append([]string(nil), dirSources[dir]...)
sort.Strings(sources) 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) overlayPath := filepath.Join(p.AssetsDir(), filepath.FromSlash(dir), p.EffectiveConfig().Music.CreditsOverlay)
overlay, err := parseCreditsOverlay(overlayPath) overlay, err := music.ParseCreditsOverlay(overlayPath)
if err != nil { if err != nil {
return nil, fmt.Errorf("parse credits overlay %s: %w", overlayPath, err) 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 { for _, rel := range sources {
base := filepath.Base(rel) base := filepath.Base(rel)
manual := overlay.match(base, "") manual := overlay.Match(base, "")
outputFile := "" outputFile := ""
switch { switch {
case manual != nil && strings.TrimSpace(manual.OutputFile) != "": case manual != nil && strings.TrimSpace(manual.OutputFile) != "":
outputFile = strings.ToLower(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) 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) return nil, fmt.Errorf("manual credits output for %s: %w", rel, err)
} }
default: default:
stem, err := generateMusicStem(base, prefix, usedNames) stem, err := music.GenerateStem(base, prefix, usedNames)
if err != nil { if err != nil {
return nil, fmt.Errorf("generate music name for %s: %w", rel, err) return nil, fmt.Errorf("generate music name for %s: %w", rel, err)
} }
outputFile = stem + ".bmu" outputFile = stem + ".bmu"
} }
outputRel := filepath.ToSlash(filepath.Join(dir, outputFile)) 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 { if err != nil {
return nil, fmt.Errorf("probe music metadata for %s: %w", rel, err) return nil, fmt.Errorf("probe music metadata for %s: %w", rel, err)
} }
entry := creditsEntry{ entry := music.CreditsEntry{
Artist: metadata.Artist, 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, OutputFile: outputFile,
OriginalFile: base, OriginalFile: base,
Album: metadata.Album, Album: metadata.Album,
Date: metadata.Date, Date: metadata.Date,
Rights: firstNonEmpty(metadata.Rights, joinNonEmpty("; ", metadata.License, metadata.Copyright)), Rights: music.FirstNonEmpty(metadata.Rights, music.JoinNonEmpty("; ", metadata.License, metadata.Copyright)),
Notes: firstNonEmpty(metadata.Notes, metadata.Comment), Notes: music.FirstNonEmpty(metadata.Notes, metadata.Comment),
} }
applyCreditsOverlay(&entry, manual) music.ApplyCreditsOverlay(&entry, manual)
stagePath := filepath.Join(stageRoot, filepath.FromSlash(outputRel)) stagePath := filepath.Join(stageRoot, filepath.FromSlash(outputRel))
if err := os.MkdirAll(filepath.Dir(stagePath), 0o755); err != nil { if err := os.MkdirAll(filepath.Dir(stagePath), 0o755); err != nil {
return nil, fmt.Errorf("create music stage dir: %w", err) 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) return nil, fmt.Errorf("convert music %s: %w", rel, err)
} }
resourceInfo, err := assetResourceFromPath(stagePath, outputRel, lfsAssetInfo{}, false) resourceInfo, err := assetResourceFromPath(stagePath, outputRel, lfsAssetInfo{}, false)
@@ -213,7 +143,7 @@ func prepareMusicAssets(p *project.Project) (*preparedMusicAssets, error) {
result.SkipSourceRel[rel] = struct{}{} result.SkipSourceRel[rel] = struct{}{}
entries = append(entries, entry) entries = append(entries, entry)
} }
if err := validateOverlayEntries(dir, overlay, entries); err != nil { if err := music.ValidateOverlayEntries(dir, overlay, entries); err != nil {
return nil, err return nil, err
} }
generatedByDir[dir] = entries generatedByDir[dir] = entries
@@ -238,9 +168,9 @@ type creditsArtifactWriteResult struct {
Summary CreditsRefreshSummary 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() creditsRoot := p.CreditsDir()
sources := make([]creditsSource, 0) sources := make([]music.CreditsSource, 0)
desiredFiles := make(map[string][]byte) desiredFiles := make(map[string][]byte)
summary := CreditsRefreshSummary{} summary := CreditsRefreshSummary{}
if generatedByDir != nil { if generatedByDir != nil {
@@ -250,7 +180,7 @@ func writeCreditsArtifacts(p *project.Project, generatedByDir map[string][]credi
} }
sort.Strings(dirs) sort.Strings(dirs)
for _, dir := range 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 { sort.Slice(entries, func(i, j int) bool {
if strings.ToLower(entries[i].Artist) != strings.ToLower(entries[j].Artist) { if strings.ToLower(entries[i].Artist) != strings.ToLower(entries[j].Artist) {
return 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) return strings.ToLower(entries[i].OutputFile) < strings.ToLower(entries[j].OutputFile)
}) })
outputPath := filepath.Join(creditsRoot, filepath.FromSlash(dir), p.EffectiveConfig().Music.CreditsOverlay) 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) summary.GeneratedPaths = append(summary.GeneratedPaths, outputPath)
for _, entry := range entries { for _, entry := range entries {
summary.Mappings = append(summary.Mappings, MusicFileMapping{ summary.Mappings = append(summary.Mappings, MusicFileMapping{
@@ -269,7 +199,7 @@ func writeCreditsArtifacts(p *project.Project, generatedByDir map[string][]credi
Output: entry.OutputFile, Output: entry.OutputFile,
}) })
} }
sources = append(sources, creditsSource{ sources = append(sources, music.CreditsSource{
Path: filepath.ToSlash(outputPath), Path: filepath.ToSlash(outputPath),
Kind: "generated_music", Kind: "generated_music",
Entries: entries, Entries: entries,
@@ -287,7 +217,7 @@ func writeCreditsArtifacts(p *project.Project, generatedByDir map[string][]credi
if strings.ToLower(d.Name()) != "credits.md" { if strings.ToLower(d.Name()) != "credits.md" {
return nil return nil
} }
entries, err := parseCreditsMarkdown(path) entries, err := music.ParseCreditsMarkdown(path)
if err != nil { if err != nil {
return fmt.Errorf("parse credits %s: %w", path, err) 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 { if err != nil {
return err return err
} }
sources = append(sources, creditsSource{ sources = append(sources, music.CreditsSource{
Path: filepath.ToSlash(rel), Path: filepath.ToSlash(rel),
Kind: "authored", Kind: "authored",
Entries: entries, 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 }) sort.Slice(sources, func(i, j int) bool { return sources[i].Path < sources[j].Path })
inventoryPath := filepath.Join(creditsRoot, "credits.json") inventoryPath := filepath.Join(creditsRoot, "credits.json")
payload, err := json.MarshalIndent(creditsInventory{Sources: sources}, "", " ") payload, err := json.MarshalIndent(music.CreditsInventory{Sources: sources}, "", " ")
if err != nil { if err != nil {
return creditsArtifactWriteResult{}, fmt.Errorf("marshal credits inventory: %w", err) 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 { if err := os.MkdirAll(creditsRoot, 0o755); err != nil {
return creditsArtifactWriteResult{}, fmt.Errorf("create credits dir: %w", err) return creditsArtifactWriteResult{}, fmt.Errorf("create credits dir: %w", err)
} }
changedFiles, err := syncGeneratedCreditsArtifacts(creditsRoot, desiredFiles) changedFiles, err := music.SyncGeneratedCreditsArtifacts(creditsRoot, desiredFiles)
if err != nil { if err != nil {
return creditsArtifactWriteResult{}, err return creditsArtifactWriteResult{}, err
} }
@@ -335,53 +265,6 @@ func writeCreditsArtifacts(p *project.Project, generatedByDir map[string][]credi
}, nil }, 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 { func cleanupPreparedMusicAssets(prepared *preparedMusicAssets) error {
if prepared == nil { if prepared == nil {
return nil return nil
@@ -396,569 +279,3 @@ func cleanupPreparedMusicAssets(prepared *preparedMusicAssets) error {
} }
return nil 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, "<br>", "\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", "<br>")
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
}
-9
View File
@@ -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")
}
}
+59 -5
View File
@@ -108,6 +108,14 @@ type EffectiveMusicConfig struct {
CreditsRoot string `json:"credits_root" yaml:"credits_root"` CreditsRoot string `json:"credits_root" yaml:"credits_root"`
CreditsOverlay string `json:"credits_overlay" yaml:"credits_overlay"` CreditsOverlay string `json:"credits_overlay" yaml:"credits_overlay"`
MaxStemLength int `json:"max_stem_length" yaml:"max_stem_length"` 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 { type EffectiveTopDataConfig struct {
@@ -209,6 +217,51 @@ func (p *Project) EffectiveConfig() EffectiveConfig {
extract.CleanupStale = &defaultCleanup 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{ effective := EffectiveConfig{
ConfigSource: p.ConfigSource, ConfigSource: p.ConfigSource,
Module: p.Config.Module, Module: p.Config.Module,
@@ -218,11 +271,12 @@ func (p *Project) EffectiveConfig() EffectiveConfig {
Inventory: inventory, Inventory: inventory,
Scripts: scripts, Scripts: scripts,
Music: EffectiveMusicConfig{ Music: EffectiveMusicConfig{
Prefixes: cloneStringMap(p.Config.Music.Prefixes), Prefixes: musicPrefixes,
StageRoot: expandPathTemplate(DefaultMusicStageRoot, paths), StageRoot: musicStageRoot,
CreditsRoot: expandPathTemplate(DefaultCreditsRoot, paths), CreditsRoot: musicCreditsRoot,
CreditsOverlay: DefaultCreditsOverlayFile, CreditsOverlay: musicCreditsOverlay,
MaxStemLength: 16, MaxStemLength: musicMaxStemLength,
Datasets: effectiveDatasets,
}, },
TopData: top, TopData: top,
Extract: extract, Extract: extract,
+50 -1
View File
@@ -121,7 +121,23 @@ type HAKConfig struct {
} }
type MusicConfig 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 { type TopDataConfig struct {
@@ -871,6 +887,25 @@ func normalizeConfig(cfg *Config) {
for i := range cfg.Autogen.Consumers { for i := range cfg.Autogen.Consumers {
cfg.Autogen.Consumers[i].Include = normalizeStringSlice(cfg.Autogen.Consumers[i].Include) 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 { 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)) 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 return failures
} }