965 lines
27 KiB
Go
965 lines
27 KiB
Go
package pipeline
|
||
|
||
import (
|
||
"crypto/sha1"
|
||
"encoding/json"
|
||
"fmt"
|
||
"os"
|
||
"os/exec"
|
||
"path/filepath"
|
||
"regexp"
|
||
"sort"
|
||
"strings"
|
||
|
||
"gitea.westgate.pw/ShadowsOverWestgate/sow-tools/internal/erf"
|
||
"gitea.westgate.pw/ShadowsOverWestgate/sow-tools/internal/project"
|
||
)
|
||
|
||
const (
|
||
maxMusicStemLen = 16
|
||
musicCreditsHeader = "# NWN Music Pack Credits\n\nOriginal rights remain with the respective composers and licensors.\n\n## Processed Files\n\n| Artist | Title | Output File | Original File | Album | Date | License / Copyright | Notes |\n|---|---|---|---|---|---|---|---|\n"
|
||
)
|
||
|
||
var (
|
||
musicConvertExtensions = map[string]struct{}{
|
||
".mp3": {},
|
||
".ogg": {},
|
||
}
|
||
musicPassthroughExtensions = map[string]struct{}{
|
||
".bmu": {},
|
||
".wav": {},
|
||
}
|
||
musicDropWords = map[string]struct{}{
|
||
"mp3": {}, "wav": {}, "flac": {}, "ogg": {}, "m4a": {},
|
||
"music": {}, "track": {}, "audio": {}, "song": {}, "soundtrack": {},
|
||
"final": {}, "version": {}, "ver": {}, "v": {}, "mix": {}, "master": {}, "remaster": {},
|
||
"free": {}, "royalty": {}, "copyright": {}, "background": {}, "bgm": {},
|
||
"loop": {}, "loops": {}, "loopable": {}, "seamless": {},
|
||
"official": {}, "download": {}, "preview": {},
|
||
"the": {}, "a": {}, "an": {}, "and": {}, "of": {}, "to": {}, "in": {}, "for": {}, "with": {}, "by": {},
|
||
}
|
||
musicBracketRE = regexp.MustCompile(`\[[^\]]*\]|\([^)]*\)|\{[^}]*\}`)
|
||
musicSplitRE = regexp.MustCompile(`[^A-Za-z0-9]+`)
|
||
musicYearRE = regexp.MustCompile(`^\d{4,}$`)
|
||
musicVowelRE = regexp.MustCompile(`[aeiou]`)
|
||
)
|
||
|
||
type preparedMusicAssets struct {
|
||
Generated []assetResource
|
||
SkipSourceRel map[string]struct{}
|
||
Artifacts []string
|
||
TempRoots []string
|
||
Summary CreditsRefreshSummary
|
||
}
|
||
|
||
type musicMetadata struct {
|
||
Title string
|
||
Artist string
|
||
Album string
|
||
Date string
|
||
Rights string
|
||
Notes string
|
||
Comment string
|
||
Copyright string
|
||
License string
|
||
}
|
||
|
||
type creditsEntry struct {
|
||
Artist string `json:"artist"`
|
||
Title string `json:"title"`
|
||
OutputFile string `json:"output_file"`
|
||
OriginalFile string `json:"original_file"`
|
||
Album string `json:"album"`
|
||
Date string `json:"date"`
|
||
Rights string `json:"rights"`
|
||
Notes string `json:"notes"`
|
||
}
|
||
|
||
type creditsSource struct {
|
||
Path string `json:"path"`
|
||
Kind string `json:"kind"`
|
||
Entries []creditsEntry `json:"entries"`
|
||
}
|
||
|
||
type creditsInventory struct {
|
||
Sources []creditsSource `json:"sources"`
|
||
}
|
||
|
||
type creditsOverlay struct {
|
||
ByOriginal map[string]creditsEntry
|
||
ByOutput map[string]creditsEntry
|
||
Entries []creditsEntry
|
||
}
|
||
|
||
func prepareMusicAssets(p *project.Project) (*preparedMusicAssets, error) {
|
||
usedNames := make(map[string]struct{})
|
||
dirSources := make(map[string][]string)
|
||
for _, rel := range p.Inventory.AssetFiles {
|
||
rel = filepath.ToSlash(rel)
|
||
ext := strings.ToLower(filepath.Ext(rel))
|
||
name := strings.ToLower(strings.TrimSuffix(filepath.Base(rel), filepath.Ext(rel)))
|
||
if _, convertible := musicConvertExtensions[ext]; convertible && isMusicAssetPath(rel) {
|
||
dir := filepath.ToSlash(filepath.Dir(rel))
|
||
dirSources[dir] = append(dirSources[dir], rel)
|
||
continue
|
||
}
|
||
if name != "" {
|
||
usedNames[name] = struct{}{}
|
||
}
|
||
}
|
||
|
||
result := &preparedMusicAssets{
|
||
SkipSourceRel: make(map[string]struct{}),
|
||
}
|
||
if len(dirSources) == 0 {
|
||
writeResult, err := writeCreditsArtifacts(p, nil)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
result.Artifacts = writeResult.Artifacts
|
||
result.Summary = writeResult.Summary
|
||
return result, nil
|
||
}
|
||
|
||
dirs := make([]string, 0, len(dirSources))
|
||
for dir := range dirSources {
|
||
dirs = append(dirs, dir)
|
||
}
|
||
sort.Strings(dirs)
|
||
|
||
generatedByDir := make(map[string][]creditsEntry)
|
||
stageRoot := filepath.Join(p.Root, ".cache", "music")
|
||
result.TempRoots = append(result.TempRoots, stageRoot)
|
||
|
||
ffmpegPath, err := resolveFFmpegBinary()
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
ffprobePath, err := resolveFFprobeBinary()
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
|
||
for _, dir := range dirs {
|
||
sources := append([]string(nil), dirSources[dir]...)
|
||
sort.Strings(sources)
|
||
prefix := musicPrefixForDir(p, dir)
|
||
overlayPath := filepath.Join(p.AssetsDir(), filepath.FromSlash(dir), "CREDITS.md")
|
||
overlay, err := parseCreditsOverlay(overlayPath)
|
||
if err != nil {
|
||
return nil, fmt.Errorf("parse credits overlay %s: %w", overlayPath, err)
|
||
}
|
||
|
||
entries := make([]creditsEntry, 0, len(sources))
|
||
for _, rel := range sources {
|
||
base := filepath.Base(rel)
|
||
manual := overlay.match(base, "")
|
||
outputFile := ""
|
||
switch {
|
||
case manual != nil && strings.TrimSpace(manual.OutputFile) != "":
|
||
outputFile = strings.ToLower(strings.TrimSpace(manual.OutputFile))
|
||
if err := validateManualOutputFile(outputFile); err != nil {
|
||
return nil, fmt.Errorf("manual credits output for %s: %w", rel, err)
|
||
}
|
||
if err := reserveMusicName(strings.TrimSuffix(outputFile, filepath.Ext(outputFile)), usedNames); err != nil {
|
||
return nil, fmt.Errorf("manual credits output for %s: %w", rel, err)
|
||
}
|
||
default:
|
||
stem, err := generateMusicStem(base, prefix, usedNames)
|
||
if err != nil {
|
||
return nil, fmt.Errorf("generate music name for %s: %w", rel, err)
|
||
}
|
||
outputFile = stem + ".bmu"
|
||
}
|
||
outputRel := filepath.ToSlash(filepath.Join(dir, outputFile))
|
||
metadata, err := probeMusicMetadata(ffprobePath, filepath.Join(p.AssetsDir(), filepath.FromSlash(rel)))
|
||
if err != nil {
|
||
return nil, fmt.Errorf("probe music metadata for %s: %w", rel, err)
|
||
}
|
||
entry := creditsEntry{
|
||
Artist: metadata.Artist,
|
||
Title: firstNonEmpty(metadata.Title, strings.TrimSuffix(base, filepath.Ext(base))),
|
||
OutputFile: outputFile,
|
||
OriginalFile: base,
|
||
Album: metadata.Album,
|
||
Date: metadata.Date,
|
||
Rights: firstNonEmpty(metadata.Rights, joinNonEmpty("; ", metadata.License, metadata.Copyright)),
|
||
Notes: firstNonEmpty(metadata.Notes, metadata.Comment),
|
||
}
|
||
applyCreditsOverlay(&entry, manual)
|
||
|
||
stagePath := filepath.Join(stageRoot, filepath.FromSlash(outputRel))
|
||
if err := os.MkdirAll(filepath.Dir(stagePath), 0o755); err != nil {
|
||
return nil, fmt.Errorf("create music stage dir: %w", err)
|
||
}
|
||
if err := convertMusicSource(ffmpegPath, filepath.Join(p.AssetsDir(), filepath.FromSlash(rel)), stagePath); err != nil {
|
||
return nil, fmt.Errorf("convert music %s: %w", rel, err)
|
||
}
|
||
resourceInfo, err := assetResourceFromPath(stagePath, outputRel, lfsAssetInfo{}, false)
|
||
if err != nil {
|
||
return nil, fmt.Errorf("load converted music %s: %w", outputRel, err)
|
||
}
|
||
info, err := os.Stat(stagePath)
|
||
if err != nil {
|
||
return nil, fmt.Errorf("stat converted music %s: %w", stagePath, err)
|
||
}
|
||
result.Generated = append(result.Generated, assetResource{
|
||
Rel: outputRel,
|
||
Resource: resourceInfo.Resource,
|
||
Size: erf.ArchiveSize([]erf.Resource{resourceInfo.Resource}),
|
||
CreatedAt: info.ModTime(),
|
||
ContentID: resourceInfo.ContentID,
|
||
})
|
||
result.SkipSourceRel[rel] = struct{}{}
|
||
entries = append(entries, entry)
|
||
}
|
||
if err := validateOverlayEntries(dir, overlay, entries); err != nil {
|
||
return nil, err
|
||
}
|
||
generatedByDir[dir] = entries
|
||
}
|
||
|
||
writeResult, err := writeCreditsArtifacts(p, generatedByDir)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
writeResult.Summary.ScannedDirs = append(writeResult.Summary.ScannedDirs, dirs...)
|
||
writeResult.Summary.TrackCount = len(result.Generated)
|
||
result.Artifacts = writeResult.Artifacts
|
||
result.Summary = writeResult.Summary
|
||
sort.Slice(result.Generated, func(i, j int) bool {
|
||
return compareResourceKeys(result.Generated[i].Resource, result.Generated[j].Resource) < 0
|
||
})
|
||
return result, nil
|
||
}
|
||
|
||
type creditsArtifactWriteResult struct {
|
||
Artifacts []string
|
||
Summary CreditsRefreshSummary
|
||
}
|
||
|
||
func writeCreditsArtifacts(p *project.Project, generatedByDir map[string][]creditsEntry) (creditsArtifactWriteResult, error) {
|
||
creditsRoot := filepath.Join(p.Root, ".cache", "credits")
|
||
sources := make([]creditsSource, 0)
|
||
desiredFiles := make(map[string][]byte)
|
||
summary := CreditsRefreshSummary{}
|
||
if generatedByDir != nil {
|
||
dirs := make([]string, 0, len(generatedByDir))
|
||
for dir := range generatedByDir {
|
||
dirs = append(dirs, dir)
|
||
}
|
||
sort.Strings(dirs)
|
||
for _, dir := range dirs {
|
||
entries := append([]creditsEntry(nil), generatedByDir[dir]...)
|
||
sort.Slice(entries, func(i, j int) bool {
|
||
if strings.ToLower(entries[i].Artist) != strings.ToLower(entries[j].Artist) {
|
||
return strings.ToLower(entries[i].Artist) < strings.ToLower(entries[j].Artist)
|
||
}
|
||
if strings.ToLower(entries[i].Title) != strings.ToLower(entries[j].Title) {
|
||
return strings.ToLower(entries[i].Title) < strings.ToLower(entries[j].Title)
|
||
}
|
||
return strings.ToLower(entries[i].OutputFile) < strings.ToLower(entries[j].OutputFile)
|
||
})
|
||
outputPath := filepath.Join(creditsRoot, filepath.FromSlash(dir), "CREDITS.md")
|
||
desiredFiles[outputPath] = []byte(renderCreditsMarkdown(entries))
|
||
summary.GeneratedPaths = append(summary.GeneratedPaths, outputPath)
|
||
for _, entry := range entries {
|
||
summary.Mappings = append(summary.Mappings, MusicFileMapping{
|
||
Source: entry.OriginalFile,
|
||
Output: entry.OutputFile,
|
||
})
|
||
}
|
||
sources = append(sources, creditsSource{
|
||
Path: filepath.ToSlash(outputPath),
|
||
Kind: "generated_music",
|
||
Entries: entries,
|
||
})
|
||
}
|
||
}
|
||
|
||
err := filepath.WalkDir(p.AssetsDir(), func(path string, d os.DirEntry, walkErr error) error {
|
||
if walkErr != nil {
|
||
return walkErr
|
||
}
|
||
if d.IsDir() {
|
||
return nil
|
||
}
|
||
if strings.ToLower(d.Name()) != "credits.md" {
|
||
return nil
|
||
}
|
||
entries, err := parseCreditsMarkdown(path)
|
||
if err != nil {
|
||
return fmt.Errorf("parse credits %s: %w", path, err)
|
||
}
|
||
rel, err := filepath.Rel(p.Root, path)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
sources = append(sources, creditsSource{
|
||
Path: filepath.ToSlash(rel),
|
||
Kind: "authored",
|
||
Entries: entries,
|
||
})
|
||
return nil
|
||
})
|
||
if err != nil {
|
||
return creditsArtifactWriteResult{}, err
|
||
}
|
||
|
||
sort.Slice(sources, func(i, j int) bool { return sources[i].Path < sources[j].Path })
|
||
inventoryPath := filepath.Join(creditsRoot, "credits.json")
|
||
payload, err := json.MarshalIndent(creditsInventory{Sources: sources}, "", " ")
|
||
if err != nil {
|
||
return creditsArtifactWriteResult{}, fmt.Errorf("marshal credits inventory: %w", err)
|
||
}
|
||
payload = append(payload, '\n')
|
||
desiredFiles[inventoryPath] = payload
|
||
if err := os.MkdirAll(creditsRoot, 0o755); err != nil {
|
||
return creditsArtifactWriteResult{}, fmt.Errorf("create credits dir: %w", err)
|
||
}
|
||
changedFiles, err := syncGeneratedCreditsArtifacts(creditsRoot, desiredFiles)
|
||
if err != nil {
|
||
return creditsArtifactWriteResult{}, err
|
||
}
|
||
artifacts := make([]string, 0, len(desiredFiles))
|
||
for path := range desiredFiles {
|
||
artifacts = append(artifacts, path)
|
||
}
|
||
sort.Strings(artifacts)
|
||
summary.ArtifactPaths = append(summary.ArtifactPaths, artifacts...)
|
||
summary.InventoryPath = inventoryPath
|
||
summary.ChangedFiles = changedFiles
|
||
return creditsArtifactWriteResult{
|
||
Artifacts: artifacts,
|
||
Summary: summary,
|
||
}, nil
|
||
}
|
||
|
||
func syncGeneratedCreditsArtifacts(root string, desiredFiles map[string][]byte) (int, error) {
|
||
changedFiles := 0
|
||
existingFiles := make(map[string]struct{})
|
||
if err := filepath.WalkDir(root, func(path string, d os.DirEntry, walkErr error) error {
|
||
if walkErr != nil {
|
||
if os.IsNotExist(walkErr) {
|
||
return nil
|
||
}
|
||
return walkErr
|
||
}
|
||
if d.IsDir() {
|
||
return nil
|
||
}
|
||
existingFiles[path] = struct{}{}
|
||
return nil
|
||
}); err != nil {
|
||
return 0, fmt.Errorf("scan credits dir: %w", err)
|
||
}
|
||
|
||
for path, content := range desiredFiles {
|
||
current, err := os.ReadFile(path)
|
||
if err != nil && !os.IsNotExist(err) {
|
||
return 0, fmt.Errorf("read credits artifact %s: %w", path, err)
|
||
}
|
||
if err == nil && string(current) == string(content) {
|
||
delete(existingFiles, path)
|
||
continue
|
||
}
|
||
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
||
return 0, fmt.Errorf("create generated credits dir: %w", err)
|
||
}
|
||
if err := os.WriteFile(path, content, 0o644); err != nil {
|
||
return 0, fmt.Errorf("write credits artifact %s: %w", path, err)
|
||
}
|
||
changedFiles++
|
||
delete(existingFiles, path)
|
||
}
|
||
|
||
for path := range existingFiles {
|
||
if err := os.Remove(path); err != nil && !os.IsNotExist(err) {
|
||
return 0, fmt.Errorf("remove stale credits artifact %s: %w", path, err)
|
||
}
|
||
changedFiles++
|
||
}
|
||
return changedFiles, nil
|
||
}
|
||
|
||
func cleanupPreparedMusicAssets(prepared *preparedMusicAssets) error {
|
||
if prepared == nil {
|
||
return nil
|
||
}
|
||
for _, root := range prepared.TempRoots {
|
||
if strings.TrimSpace(root) == "" {
|
||
continue
|
||
}
|
||
if err := os.RemoveAll(root); err != nil {
|
||
return fmt.Errorf("remove temporary music artifact root %s: %w", root, err)
|
||
}
|
||
}
|
||
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
|
||
}
|