Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 40 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -468,12 +468,19 @@ The template has access to the following data:

- `.Release` - The full MusicBrainz release object (see [`type Release struct {`](https://github.com/sentriz/wrtag/blob/master/musicbrainz/musicbrainz.go))
- `.Track` - The current track being processed (see [`type Track struct {`](https://github.com/sentriz/wrtag/blob/master/musicbrainz/musicbrainz.go))
- `.TrackNum` - The track number (integer, starting at 1)
- `.TrackNum` - The track number (integer, starting at 1, sequential across all tracks)
- `.Tracks` - The list of tracks in the release
- `.ReleaseDisambiguation` - A string for release and release group disambiguation
- `.IsCompilation` - Boolean indicating if this is a compilation album
- `.Ext` - The file extension for the current track, including the dot (e.g., ".flac")

**Multi-disc fields** (available for organizing multi-disc releases):

- `.DiscNum` - The disc number for this track (integer, 1-indexed)
- `.DiscTitle` - The disc subtitle/title (string, may be empty)
- `.TotalDiscs` - Total number of discs in the release (integer)
- `.Track.Position` - Per-disc track position (1-indexed within each disc)

## Helper functions

In addition to what's provided by Go [text/template](https://pkg.go.dev/text/template), several helper functions are available to format your paths:
Expand Down Expand Up @@ -532,6 +539,35 @@ Including multi-album artist support, release group year, release group and rele
/music/{{ artists .Release.Artists | sort | join "; " | safepath }}/({{ .Release.ReleaseGroup.FirstReleaseDate.Year }}) {{ .Release.Title | safepath }}/{{ pad0 2 .TrackNum }}.{{ len .Tracks | pad0 2 }} {{ .Track.Title | safepath }}{{ .Ext }}
```

### Multi-disc releases

For multi-disc releases, you can organize tracks into disc-specific folders. This format conditionally creates disc subdirectories only when there are multiple discs:

```
/music/{{ artists .Release.Artists | sort | join "; " | safepath }}/{{ .Release.Title | safepath }}{{ if gt .TotalDiscs 1 }}/Disc {{ pad0 2 .DiscNum }}{{ end }}/{{ pad0 2 .Track.Position }} {{ .Track.Title | safepath }}{{ .Ext }}
```

Result for a 2-disc album:
```
/music/Artist/Album/Disc 01/01 Track Name.flac
/music/Artist/Album/Disc 01/02 Another Track.flac
/music/Artist/Album/Disc 02/01 First on Disc 2.flac
```

### Multi-disc with disc titles

If discs have titles (e.g., "Studio" and "Live"), you can include them in the path:

```
/music/{{ artists .Release.Artists | sort | join "; " | safepath }}/{{ .Release.Title | safepath }}{{ if gt .TotalDiscs 1 }}{{ if .DiscTitle }}/{{ .DiscTitle | safepath }}{{ else }}/Disc {{ pad0 2 .DiscNum }}{{ end }}{{ end }}/{{ pad0 2 .Track.Position }} {{ .Track.Title | safepath }}{{ .Ext }}
```

Result:
```
/music/Artist/Album/Studio Sessions/01 Track.flac
/music/Artist/Album/Live in London/01 Track.flac
```

# Addons

Addons can be used to fetch/compute additional metadata after the MusicBrainz match has been applied and the files have been tagged.
Expand Down Expand Up @@ -628,7 +664,9 @@ Example track is [America Is Waiting](https://musicbrainz.org/release/3b28412d-8
| `ARTISTS_CREDIT` | Track artist credit names as multi-valued tag | `Eno`, `Byrne` |
| `GENRE` | Primary genre | `ambient` |
| `GENRES` | Genre list as multi-valued tag | `ambient`, `art rock`, `electronic`, `experimental` |
| `TRACKNUMBER` | Track number | `1` |
| `TRACKNUMBER` | Track number (per-disc for multi-disc releases) | `1` (single-disc) or `1` (multi-disc, track 1 of disc 1) |
| `DISCNUMBER` | Disc number in N/M format (multi-disc only) | `1/2` (disc 1 of 2) or `1` (single-disc) |
| `DISCSUBTITLE` | Disc subtitle/title (if present on multi-disc) | `Live Recordings` or `Bonus Material` |
| `ISRC` | International Standard Recording Code | `GBAAA0500384` |
| `REMIXER` | Concatenated remixers on the recording | `Artist One, Artist Two` |
| `REMIXERS` | multi-valued remixers on the recording | `Artist One`, `Artist Two` |
Expand Down
2 changes: 1 addition & 1 deletion cmd/wrtag/testdata/scripts/copy-new-cover.txtar
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ exec tag write 'kat_moda/3.flac'
exec tag write 'kat_moda/*.flac' musicbrainz_albumid 'e47d04a4-7460-427d-a731-cc82386d85f1'

exec wrtag copy -yes kat_moda
stderr 'moved path.*from.*\.wrtag-cover-tmp'
stderr 'msg=move.*from.*wrtag-cover-tmp'
! stderr 'deleted extra file.*cover'

exec find albums
Expand Down
100 changes: 100 additions & 0 deletions cmd/wrtag/testdata/scripts/multi-disc-tags.txtar
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
# Test multi-disc release tag writing
# Uses the 2-disc Rammstein "Reise, Reise" release (bdc01183-d1c9-378f-a209-a6145fa596c9)
# Verifies:
# - Per-disc track numbering (tracks 1,2,3,4 on disc 1, then 1,2,3,4 on disc 2)
# - Disc numbers in N/M format (1/2, 2/2)
# - Path organization with disc folders

# Set up source files for 2-disc release (8 tracks total: 4 per disc with pregaps)
exec tag write 'reise/d1-00.flac' title 'd1 hidden track'
exec tag write 'reise/d1-01.flac' title 'Reise, Reise'
exec tag write 'reise/d1-02.flac' title 'Mein Teil'
exec tag write 'reise/d1-03.flac' title 'Dalai Lama'

exec tag write 'reise/d2-00.flac' title 'd2 hidden track'
exec tag write 'reise/d2-01.flac' title 'Keine Lust'
exec tag write 'reise/d2-02.flac' title 'Los'
exec tag write 'reise/d2-03.flac' title 'Amerika'

exec tag write 'reise/*.flac' musicbrainz_albumid 'bdc01183-d1c9-378f-a209-a6145fa596c9'
exec tag write 'reise/*.flac' album 'old album name'
exec tag write 'reise/*.flac' albumartist 'old artist'

# Use path format with disc folders
env WRTAG_PATH_FORMAT='albums/{{ .Release.Title }}{{if gt .TotalDiscs 1}}/Disc {{ .DiscNum }}{{end}}/{{ pad0 2 .Track.Position }} {{ .Track.Title }}{{ .Ext }}'

# Process the release
exec wrtag move -yes reise

# Verify folder structure has disc folders
exec find albums/
cmp stdout exp-layout

# Verify disc 1 tags
cd 'albums/Reise, Reise/Disc 1'

# Pregap track (track 0) on disc 1
exec tag check '00*.flac' tracknumber '0'
exec tag check '00*.flac' discnumber '1'
exec tag check '00*.flac' title '[ohne Titel]'

# Regular tracks on disc 1 should have per-disc numbering (1, 2, 3)
exec tag check '01*.flac' tracknumber '1'
exec tag check '01*.flac' discnumber '1'
exec tag check '01*.flac' title 'Reise, Reise'

exec tag check '02*.flac' tracknumber '2'
exec tag check '02*.flac' discnumber '1'
exec tag check '02*.flac' title 'Mein Teil'

exec tag check '03*.flac' tracknumber '3'
exec tag check '03*.flac' discnumber '1'
exec tag check '03*.flac' title 'Dalai Lama'

# Verify all disc 1 tracks have correct album metadata
exec tag check '*.flac' album 'Reise, Reise'
exec tag check '*.flac' albumartist 'Rammstein'
exec tag check '*.flac' artist 'Rammstein'
exec tag check '*.flac' date '2004-09-27'

cd $WORK

# Verify disc 2 tags
cd 'albums/Reise, Reise/Disc 2'

# Pregap track (track 0) on disc 2
exec tag check '00*.flac' tracknumber '0'
exec tag check '00*.flac' discnumber '2'
exec tag check '00*.flac' title '[ohne Titel #2]'

# Regular tracks on disc 2 should RESTART numbering at 1 (per-disc numbering)
exec tag check '01*.flac' tracknumber '1'
exec tag check '01*.flac' discnumber '2'
exec tag check '01*.flac' title 'Keine Lust'

exec tag check '02*.flac' tracknumber '2'
exec tag check '02*.flac' discnumber '2'
exec tag check '02*.flac' title 'Los'

exec tag check '03*.flac' tracknumber '3'
exec tag check '03*.flac' discnumber '2'
exec tag check '03*.flac' title 'Amerika'

# Verify all disc 2 tracks have correct album metadata
exec tag check '*.flac' album 'Reise, Reise'
exec tag check '*.flac' albumartist 'Rammstein'
exec tag check '*.flac' artist 'Rammstein'

-- exp-layout --
albums
albums/Reise, Reise
albums/Reise, Reise/Disc 1
albums/Reise, Reise/Disc 1/00 [ohne Titel].flac
albums/Reise, Reise/Disc 1/01 Reise, Reise.flac
albums/Reise, Reise/Disc 1/02 Mein Teil.flac
albums/Reise, Reise/Disc 1/03 Dalai Lama.flac
albums/Reise, Reise/Disc 2
albums/Reise, Reise/Disc 2/00 [ohne Titel #2].flac
albums/Reise, Reise/Disc 2/01 Keine Lust.flac
albums/Reise, Reise/Disc 2/02 Los.flac
albums/Reise, Reise/Disc 2/03 Amerika.flac
13 changes: 8 additions & 5 deletions cmd/wrtagweb/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -414,7 +414,13 @@ func processJob(ctx context.Context, cfg *wrtag.Config, notifs *notifications.No
ic = wrtag.Always
}

slog.InfoContext(ctx, "processing job", "job_id", job.ID, "operation", job.Operation, "path", job.SourcePath)
searchResult, processErr := wrtag.ProcessDir(ctx, cfg, op, job.SourcePath, ic, job.UseMBID)
if processErr != nil {
slog.ErrorContext(ctx, "job failed", "job_id", job.ID, "err", processErr)
} else {
slog.InfoContext(ctx, "job succeeded", "job_id", job.ID)
}

if searchResult != nil && searchResult.Query.Artist != "" {
researchLinks, err := researchLinkQuerier.Build(researchlink.Query{
Expand All @@ -430,11 +436,8 @@ func processJob(ctx context.Context, cfg *wrtag.Config, notifs *notifications.No
job.ResearchLinks = sqlb.NewJSON(researchLinks)
}

if searchResult != nil && searchResult.Release != nil {
job.DestPath, err = wrtag.DestDir(&cfg.PathFormat, searchResult.Release)
if err != nil {
return fmt.Errorf("gen dest dir: %w", err)
}
if searchResult != nil {
job.DestPath = searchResult.AlbumRootDir
}

job.SearchResult = sqlb.NewJSON(searchResult)
Expand Down
61 changes: 52 additions & 9 deletions musicbrainz/musicbrainz.go
Original file line number Diff line number Diff line change
Expand Up @@ -235,9 +235,14 @@ type Track struct {
ISRCs []string `json:"isrcs"`
} `json:"recording"`
Number string `json:"number"`
Position int `json:"position"`
Position int `json:"position"` // Track position within disc (1-based from MusicBrainz)
Title string `json:"title"`
Artists []ArtistCredit `json:"artist-credit"`

// Disc context fields (not from JSON, populated by FlatTracks)
DiscNumber int `json:"-"` // Disc number (1-based, renumbered after filtering)
DiscTitle string `json:"-"` // Disc title/subtitle from Media.Title
DiscFormat string `json:"-"` // Disc format from Media.Format
}

type Work struct {
Expand Down Expand Up @@ -463,27 +468,65 @@ func IsCompilation(rg ReleaseGroup) bool {
return false
}

func shouldIgnoreMedia(m Media) bool {
// not supported for now
return strings.Contains(m.Format, "DVD") || strings.Contains(m.Format, "Blu-ray")
}

// CountNonFilteredDiscs returns the number of discs after filtering out DVD/Blu-ray media.
// This is used to determine the total disc count for multi-disc releases.
func CountNonFilteredDiscs(media []Media) int {
var count int
for _, m := range media {
if shouldIgnoreMedia(m) {
continue
}
count++
}
return count
}

// FlatTracks flattens media into a single track list, filtering out DVD/Blu-ray discs
// and video tracks. It enriches each track with disc context (DiscNumber, DiscTitle, DiscFormat).
// Disc numbers are renumbered sequentially (1, 2, 3...) after filtering to avoid gaps.
func FlatTracks(media []Media) []Track {
// Pre-allocate based on total track count
var numTracks int
for _, media := range media {
numTracks += len(media.Tracks)
for _, m := range media {
if shouldIgnoreMedia(m) {
continue
}
numTracks += len(m.Tracks)
}

tracks := make([]Track, 0, numTracks)
for _, media := range media {
if strings.Contains(media.Format, "DVD") || strings.Contains(media.Format, "Blu-ray") {
// not supported for now

// Sequential numbering after filtering (not Media.Position)
discNumber := 1
for _, m := range media {
if shouldIgnoreMedia(m) {
continue
}
if media.Pregap != nil {
tracks = append(tracks, *media.Pregap)

if m.Pregap != nil {
pregap := *m.Pregap
pregap.DiscNumber = discNumber
pregap.DiscTitle = m.Title
pregap.DiscFormat = m.Format
tracks = append(tracks, pregap)
}
for _, track := range media.Tracks {

for _, track := range m.Tracks {
if track.Recording.Video {
continue
}
track.DiscNumber = discNumber
track.DiscTitle = m.Title
track.DiscFormat = m.Format
tracks = append(tracks, track)
}

discNumber++
}
return tracks
}
Expand Down
Loading