package music import ( "fmt" "os" "path/filepath" "strings" ) func ParseCreditsOverlay(path string) (CreditsOverlay, error) { entries, err := ParseCreditsMarkdown(path) if err != nil { return CreditsOverlay{}, err } overlay := CreditsOverlay{ ByOriginal: make(map[string]CreditsEntry), ByOutput: make(map[string]CreditsEntry), Entries: entries, } for _, entry := range entries { if entry.OriginalFile != "" { key := strings.ToLower(strings.TrimSpace(entry.OriginalFile)) if _, exists := overlay.ByOriginal[key]; exists { return CreditsOverlay{}, fmt.Errorf("duplicate manual credits row for original file %s", entry.OriginalFile) } overlay.ByOriginal[key] = entry } if entry.OutputFile != "" { key := strings.ToLower(strings.TrimSpace(entry.OutputFile)) if _, exists := overlay.ByOutput[key]; exists { return CreditsOverlay{}, fmt.Errorf("duplicate manual credits row for output file %s", entry.OutputFile) } overlay.ByOutput[key] = entry } } return overlay, nil } func (o CreditsOverlay) Match(originalFile, outputFile string) *CreditsEntry { if entry, ok := o.ByOutput[strings.ToLower(strings.TrimSpace(outputFile))]; ok { entryCopy := entry return &entryCopy } if entry, ok := o.ByOriginal[strings.ToLower(strings.TrimSpace(originalFile))]; ok { entryCopy := entry return &entryCopy } return nil } func ValidateOverlayEntries(dir string, overlay CreditsOverlay, generated []CreditsEntry) error { if len(overlay.Entries) == 0 { return nil } validOriginal := make(map[string]struct{}, len(generated)) validOutput := make(map[string]struct{}, len(generated)) for _, entry := range generated { validOriginal[strings.ToLower(entry.OriginalFile)] = struct{}{} validOutput[strings.ToLower(entry.OutputFile)] = struct{}{} } for _, entry := range overlay.Entries { if entry.OriginalFile != "" { if _, ok := validOriginal[strings.ToLower(entry.OriginalFile)]; !ok { return fmt.Errorf("manual credits entry in %s references unknown original file %s", dir, entry.OriginalFile) } } if entry.OutputFile != "" { if _, ok := validOutput[strings.ToLower(entry.OutputFile)]; !ok { return fmt.Errorf("manual credits entry in %s references unknown output file %s", dir, entry.OutputFile) } } } return nil } func ParseCreditsMarkdown(path string) ([]CreditsEntry, error) { raw, err := os.ReadFile(path) if err != nil { if os.IsNotExist(err) { return nil, nil } return nil, err } lines := strings.Split(strings.ReplaceAll(string(raw), "\r\n", "\n"), "\n") header := []string(nil) entries := make([]CreditsEntry, 0) for _, line := range lines { line = strings.TrimSpace(line) if !strings.HasPrefix(line, "|") { continue } cells := ParseMarkdownTableRow(line) if len(cells) == 0 { continue } if header == nil { header = make([]string, len(cells)) for i, cell := range cells { header[i] = NormalizeCreditsHeader(cell) } continue } if IsMarkdownSeparatorRow(cells) { continue } entry := CreditsEntry{} for i, key := range header { if i >= len(cells) { continue } value := CleanCreditsCell(cells[i]) switch key { case "artist": entry.Artist = value case "title": entry.Title = value case "output_file": entry.OutputFile = value case "original_file": entry.OriginalFile = value case "album": entry.Album = value case "date": entry.Date = value case "rights": entry.Rights = value case "notes": entry.Notes = value } } if entry.Artist == "" && entry.Title == "" && entry.OutputFile == "" && entry.OriginalFile == "" { continue } entries = append(entries, entry) } return entries, nil } func ParseMarkdownTableRow(line string) []string { trimmed := strings.TrimSpace(line) trimmed = strings.TrimPrefix(trimmed, "|") trimmed = strings.TrimSuffix(trimmed, "|") parts := strings.Split(trimmed, "|") cells := make([]string, 0, len(parts)) for _, part := range parts { cells = append(cells, strings.TrimSpace(strings.ReplaceAll(part, `\|`, "|"))) } return cells } func IsMarkdownSeparatorRow(cells []string) bool { if len(cells) == 0 { return false } for _, cell := range cells { cell = strings.TrimSpace(cell) cell = strings.ReplaceAll(cell, "-", "") cell = strings.ReplaceAll(cell, ":", "") if cell != "" { return false } } return true } func NormalizeCreditsHeader(value string) string { switch strings.ToLower(CleanCreditsCell(value)) { case "artist": return "artist" case "title": return "title" case "output file": return "output_file" case "original file": return "original_file" case "album": return "album" case "date": return "date" case "license / copyright": return "rights" case "notes": return "notes" default: return "" } } func CleanCreditsCell(value string) string { value = strings.TrimSpace(value) value = strings.Trim(value, "`") value = strings.ReplaceAll(value, "
", "\n") return strings.TrimSpace(value) } func RenderCreditsMarkdown(entries []CreditsEntry) string { var builder strings.Builder builder.WriteString(CreditsHeader) for _, entry := range entries { builder.WriteString("| ") builder.WriteString(EscapeMarkdownCell(entry.Artist)) builder.WriteString(" | ") builder.WriteString(EscapeMarkdownCell(entry.Title)) builder.WriteString(" | ") builder.WriteString(EscapeMarkdownCell(WrapCodeCell(entry.OutputFile))) builder.WriteString(" | ") builder.WriteString(EscapeMarkdownCell(WrapCodeCell(entry.OriginalFile))) builder.WriteString(" | ") builder.WriteString(EscapeMarkdownCell(entry.Album)) builder.WriteString(" | ") builder.WriteString(EscapeMarkdownCell(entry.Date)) builder.WriteString(" | ") builder.WriteString(EscapeMarkdownCell(entry.Rights)) builder.WriteString(" | ") builder.WriteString(EscapeMarkdownCell(entry.Notes)) builder.WriteString(" |\n") } return builder.String() } func WrapCodeCell(value string) string { if strings.TrimSpace(value) == "" { return "" } return "`" + value + "`" } func EscapeMarkdownCell(value string) string { value = strings.ReplaceAll(value, `\`, `\\`) value = strings.ReplaceAll(value, "|", `\|`) value = strings.ReplaceAll(value, "\n", "
") return value } func ApplyCreditsOverlay(entry *CreditsEntry, overlay *CreditsEntry) { if overlay == nil { return } entry.Artist = FirstNonEmpty(overlay.Artist, entry.Artist) entry.Title = FirstNonEmpty(overlay.Title, entry.Title) entry.Album = FirstNonEmpty(overlay.Album, entry.Album) entry.Date = FirstNonEmpty(overlay.Date, entry.Date) entry.Rights = FirstNonEmpty(overlay.Rights, entry.Rights) entry.Notes = FirstNonEmpty(overlay.Notes, entry.Notes) } func SyncGeneratedCreditsArtifacts(root string, desiredFiles map[string][]byte) (int, error) { changedFiles := 0 existingFiles := make(map[string]struct{}) if err := filepath.WalkDir(root, func(path string, d os.DirEntry, walkErr error) error { if walkErr != nil { if os.IsNotExist(walkErr) { return nil } return walkErr } if d.IsDir() { return nil } existingFiles[path] = struct{}{} return nil }); err != nil { return 0, fmt.Errorf("scan credits dir: %w", err) } for path, content := range desiredFiles { current, err := os.ReadFile(path) if err != nil && !os.IsNotExist(err) { return 0, fmt.Errorf("read credits artifact %s: %w", path, err) } if err == nil && string(current) == string(content) { delete(existingFiles, path) continue } if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { return 0, fmt.Errorf("create generated credits dir: %w", err) } if err := os.WriteFile(path, content, 0o644); err != nil { return 0, fmt.Errorf("write credits artifact %s: %w", path, err) } changedFiles++ delete(existingFiles, path) } for path := range existingFiles { if err := os.Remove(path); err != nil && !os.IsNotExist(err) { return 0, fmt.Errorf("remove stale credits artifact %s: %w", path, err) } changedFiles++ } return changedFiles, nil }