Music Pipeline Refactor
This commit is contained in:
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
@@ -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
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -103,11 +103,19 @@ type EffectiveScriptCompilerEnvConfig struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type EffectiveMusicConfig struct {
|
type EffectiveMusicConfig struct {
|
||||||
Prefixes map[string]string `json:"prefixes" yaml:"prefixes"`
|
Prefixes map[string]string `json:"prefixes" yaml:"prefixes"`
|
||||||
StageRoot string `json:"stage_root" yaml:"stage_root"`
|
StageRoot string `json:"stage_root" yaml:"stage_root"`
|
||||||
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,
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user