diff --git a/go_backend/ape_tags.go b/go_backend/ape_tags.go index c654e160..9b6057d6 100644 --- a/go_backend/ape_tags.go +++ b/go_backend/ape_tags.go @@ -5,7 +5,6 @@ import ( "fmt" "io" "os" - "strconv" "strings" ) @@ -367,12 +366,9 @@ func APETagToAudioMetadata(tag *APETag) *AudioMetadata { case "DATE": metadata.Date = value case "TRACK", "TRACKNUMBER": - // APE track format can be "3" or "3/12" - trackNum, _ := strconv.Atoi(strings.Split(value, "/")[0]) - metadata.TrackNumber = trackNum + metadata.TrackNumber, metadata.TotalTracks = parseIndexPair(value) case "DISC", "DISCNUMBER": - discNum, _ := strconv.Atoi(strings.Split(value, "/")[0]) - metadata.DiscNumber = discNum + metadata.DiscNumber, metadata.TotalDiscs = parseIndexPair(value) case "ISRC": metadata.ISRC = value case "LYRICS", "UNSYNCEDLYRICS": @@ -425,10 +421,10 @@ func AudioMetadataToAPEItems(metadata *AudioMetadata) []APETagItem { addItem("Year", metadata.Year) } if metadata.TrackNumber > 0 { - addItem("Track", strconv.Itoa(metadata.TrackNumber)) + addItem("Track", formatIndexValue(metadata.TrackNumber, metadata.TotalTracks)) } if metadata.DiscNumber > 0 { - addItem("Disc", strconv.Itoa(metadata.DiscNumber)) + addItem("Disc", formatIndexValue(metadata.DiscNumber, metadata.TotalDiscs)) } addItem("ISRC", metadata.ISRC) addItem("Lyrics", metadata.Lyrics) @@ -484,9 +480,15 @@ func apeKeysFromFields(fields map[string]string) map[string]struct{} { if _, present := fields["disc_number"]; present { result["DISCNUMBER"] = struct{}{} } + if _, present := fields["disc_total"]; present { + result["DISCNUMBER"] = struct{}{} + } if _, present := fields["track_number"]; present { result["TRACKNUMBER"] = struct{}{} } + if _, present := fields["track_total"]; present { + result["TRACKNUMBER"] = struct{}{} + } if _, present := fields["album_artist"]; present { result["ALBUMARTIST"] = struct{}{} } diff --git a/go_backend/audio_metadata.go b/go_backend/audio_metadata.go index b4515480..52a56d56 100644 --- a/go_backend/audio_metadata.go +++ b/go_backend/audio_metadata.go @@ -21,7 +21,9 @@ type AudioMetadata struct { Year string Date string TrackNumber int + TotalTracks int DiscNumber int + TotalDiscs int ISRC string Lyrics string Label string @@ -173,9 +175,9 @@ func parseID3v22Frames(data []byte, metadata *AudioMetadata, tagUnsync bool) { case "TCO": metadata.Genre = cleanGenre(value) case "TRK": - metadata.TrackNumber = parseTrackNumber(value) + metadata.TrackNumber, metadata.TotalTracks = parseIndexPair(value) case "TPA": - metadata.DiscNumber = parseTrackNumber(value) + metadata.DiscNumber, metadata.TotalDiscs = parseIndexPair(value) case "TCM": metadata.Composer = value case "TPB": @@ -292,9 +294,9 @@ func parseID3v23Frames(data []byte, metadata *AudioMetadata, version byte, tagUn case "TCON": metadata.Genre = cleanGenre(value) case "TRCK": - metadata.TrackNumber = parseTrackNumber(value) + metadata.TrackNumber, metadata.TotalTracks = parseIndexPair(value) case "TPOS": - metadata.DiscNumber = parseTrackNumber(value) + metadata.DiscNumber, metadata.TotalDiscs = parseIndexPair(value) case "TSRC": metadata.ISRC = value case "TCOM": @@ -580,14 +582,28 @@ func cleanGenre(genre string) string { } func parseTrackNumber(s string) int { - s = strings.TrimSpace(s) - if idx := strings.Index(s, "/"); idx > 0 { - s = s[:idx] - } - num, _ := strconv.Atoi(s) + num, _ := parseIndexPair(s) return num } +func parseIndexPair(s string) (int, int) { + s = strings.TrimSpace(s) + if s == "" { + return 0, 0 + } + + first := s + second := "" + if idx := strings.Index(s, "/"); idx > 0 { + first = s[:idx] + second = s[idx+1:] + } + + num, _ := strconv.Atoi(strings.TrimSpace(first)) + total, _ := strconv.Atoi(strings.TrimSpace(second)) + return num, total +} + func removeUnsync(data []byte) []byte { if len(data) == 0 { return data @@ -1037,9 +1053,9 @@ func parseVorbisComments(data []byte, metadata *AudioMetadata) { case "GENRE": metadata.Genre = value case "TRACKNUMBER", "TRACK": - metadata.TrackNumber = parseTrackNumber(value) + metadata.TrackNumber, metadata.TotalTracks = parseIndexPair(value) case "DISCNUMBER", "DISC": - metadata.DiscNumber = parseTrackNumber(value) + metadata.DiscNumber, metadata.TotalDiscs = parseIndexPair(value) case "ISRC": metadata.ISRC = value case "COMPOSER": diff --git a/go_backend/cue_parser.go b/go_backend/cue_parser.go index 4d54420d..4b1b178a 100644 --- a/go_backend/cue_parser.go +++ b/go_backend/cue_parser.go @@ -513,6 +513,11 @@ func scanCueSheetForLibrary(cuePath string, sheet *CueSheet, audioPath, virtualP album = "Unknown Album" } + composer := track.Composer + if composer == "" { + composer = sheet.Composer + } + var duration int if i+1 < len(sheet.Tracks) { nextStart := sheet.Tracks[i+1].StartTime @@ -539,12 +544,15 @@ func scanCueSheetForLibrary(cuePath string, sheet *CueSheet, audioPath, virtualP ScannedAt: scanTime, ISRC: track.ISRC, TrackNumber: track.Number, + TotalTracks: len(sheet.Tracks), DiscNumber: 1, + TotalDiscs: 1, Duration: duration, ReleaseDate: sheet.Date, BitDepth: bitDepth, SampleRate: sampleRate, Genre: sheet.Genre, + Composer: composer, Format: "cue+" + strings.TrimPrefix(audioExt, "."), } diff --git a/go_backend/deezer.go b/go_backend/deezer.go index dbd02ea2..4a6e3f6f 100644 --- a/go_backend/deezer.go +++ b/go_backend/deezer.go @@ -630,6 +630,12 @@ func (c *DeezerClient) GetAlbum(ctx context.Context, albumID string) (*AlbumResp } isrcMap := c.fetchISRCsParallel(ctx, allTracks) + totalDiscs := 0 + for _, track := range allTracks { + if track.DiskNumber > totalDiscs { + totalDiscs = track.DiskNumber + } + } tracks := make([]AlbumTrackMetadata, 0, len(allTracks)) albumType := album.RecordType @@ -658,6 +664,7 @@ func (c *DeezerClient) GetAlbum(ctx context.Context, albumID string) (*AlbumResp TrackNumber: trackNum, TotalTracks: album.NbTracks, DiscNumber: track.DiskNumber, + TotalDiscs: totalDiscs, ExternalURL: track.Link, ISRC: isrc, AlbumID: fmt.Sprintf("deezer:%d", album.ID), diff --git a/go_backend/deezer_download.go b/go_backend/deezer_download.go index 8bbe97d3..8c635b25 100644 --- a/go_backend/deezer_download.go +++ b/go_backend/deezer_download.go @@ -369,10 +369,12 @@ func downloadFromDeezer(req DownloadRequest) (DeezerDownloadResult, error) { TrackNumber: req.TrackNumber, TotalTracks: req.TotalTracks, DiscNumber: req.DiscNumber, + TotalDiscs: req.TotalDiscs, ISRC: req.ISRC, Genre: req.Genre, Label: req.Label, Copyright: req.Copyright, + Composer: req.Composer, } var coverData []byte diff --git a/go_backend/exports.go b/go_backend/exports.go index 5349cce9..6e7deba1 100644 --- a/go_backend/exports.go +++ b/go_backend/exports.go @@ -55,6 +55,7 @@ type DownloadRequest struct { TrackNumber int `json:"track_number"` DiscNumber int `json:"disc_number"` TotalTracks int `json:"total_tracks"` + TotalDiscs int `json:"total_discs,omitempty"` ReleaseDate string `json:"release_date"` ItemID string `json:"item_id"` DurationMS int `json:"duration_ms"` @@ -62,6 +63,7 @@ type DownloadRequest struct { Genre string `json:"genre,omitempty"` Label string `json:"label,omitempty"` Copyright string `json:"copyright,omitempty"` + Composer string `json:"composer,omitempty"` TidalID string `json:"tidal_id,omitempty"` QobuzID string `json:"qobuz_id,omitempty"` DeezerID string `json:"deezer_id,omitempty"` @@ -88,11 +90,14 @@ type DownloadResponse struct { ReleaseDate string `json:"release_date,omitempty"` TrackNumber int `json:"track_number,omitempty"` DiscNumber int `json:"disc_number,omitempty"` + TotalTracks int `json:"total_tracks,omitempty"` + TotalDiscs int `json:"total_discs,omitempty"` ISRC string `json:"isrc,omitempty"` CoverURL string `json:"cover_url,omitempty"` Genre string `json:"genre,omitempty"` Label string `json:"label,omitempty"` Copyright string `json:"copyright,omitempty"` + Composer string `json:"composer,omitempty"` SkipMetadataEnrichment bool `json:"skip_metadata_enrichment,omitempty"` LyricsLRC string `json:"lyrics_lrc,omitempty"` DecryptionKey string `json:"decryption_key,omitempty"` @@ -107,12 +112,15 @@ type DownloadResult struct { Album string ReleaseDate string TrackNumber int + TotalTracks int DiscNumber int + TotalDiscs int ISRC string CoverURL string Genre string Label string Copyright string + Composer string LyricsLRC string DecryptionKey string } @@ -130,11 +138,14 @@ type reEnrichRequest struct { AlbumArtist string `json:"album_artist"` TrackNumber int `json:"track_number"` DiscNumber int `json:"disc_number"` + TotalTracks int `json:"total_tracks,omitempty"` + TotalDiscs int `json:"total_discs,omitempty"` ReleaseDate string `json:"release_date"` ISRC string `json:"isrc"` Genre string `json:"genre"` Label string `json:"label"` Copyright string `json:"copyright"` + Composer string `json:"composer"` DurationMs int64 `json:"duration_ms"` SearchOnline bool `json:"search_online"` UpdateFields []string `json:"update_fields,omitempty"` @@ -183,9 +194,15 @@ func applyReEnrichTrackMetadata(req *reEnrichRequest, track ExtTrackMetadata) { if track.TrackNumber > 0 { req.TrackNumber = track.TrackNumber } + if track.TotalTracks > 0 { + req.TotalTracks = track.TotalTracks + } if track.DiscNumber > 0 { req.DiscNumber = track.DiscNumber } + if track.TotalDiscs > 0 { + req.TotalDiscs = track.TotalDiscs + } } if req.shouldUpdateField("release_info") { if track.ReleaseDate != "" { @@ -213,6 +230,9 @@ func applyReEnrichTrackMetadata(req *reEnrichRequest, track ExtTrackMetadata) { if track.Copyright != "" { req.Copyright = track.Copyright } + if track.Composer != "" { + req.Composer = track.Composer + } } } @@ -225,6 +245,11 @@ func reEnrichDownloadRequest(req reEnrichRequest) DownloadRequest { ISRC: req.ISRC, DurationMS: int(req.DurationMs), ArtistTagMode: req.ArtistTagMode, + TrackNumber: req.TrackNumber, + TotalTracks: req.TotalTracks, + DiscNumber: req.DiscNumber, + TotalDiscs: req.TotalDiscs, + Composer: req.Composer, } } @@ -256,13 +281,16 @@ func buildReEnrichFFmpegMetadata(req *reEnrichRequest, lyricsLRC string) map[str if req.Copyright != "" { metadata["COPYRIGHT"] = req.Copyright } + if req.Composer != "" { + metadata["COMPOSER"] = req.Composer + } } if req.shouldUpdateField("track_info") { if req.TrackNumber > 0 { - metadata["TRACKNUMBER"] = fmt.Sprintf("%d", req.TrackNumber) + metadata["TRACKNUMBER"] = formatIndexValue(req.TrackNumber, req.TotalTracks) } if req.DiscNumber > 0 { - metadata["DISCNUMBER"] = fmt.Sprintf("%d", req.DiscNumber) + metadata["DISCNUMBER"] = formatIndexValue(req.DiscNumber, req.TotalDiscs) } } if req.shouldUpdateField("lyrics") { @@ -367,11 +395,14 @@ func extTrackFromTrackMetadata(track *TrackMetadata, providerID string) *ExtTrac Images: track.Images, ReleaseDate: track.ReleaseDate, TrackNumber: track.TrackNumber, + TotalTracks: track.TotalTracks, DiscNumber: track.DiscNumber, + TotalDiscs: track.TotalDiscs, ISRC: track.ISRC, ProviderID: providerID, DeezerID: deezerID, SpotifyID: track.SpotifyID, + Composer: track.Composer, } } @@ -533,6 +564,11 @@ func buildDownloadSuccessResponse( copyright = req.Copyright } + composer := result.Composer + if composer == "" { + composer = req.Composer + } + coverURL := strings.TrimSpace(result.CoverURL) if coverURL == "" { coverURL = strings.TrimSpace(req.CoverURL) @@ -552,12 +588,15 @@ func buildDownloadSuccessResponse( AlbumArtist: req.AlbumArtist, ReleaseDate: releaseDate, TrackNumber: trackNumber, + TotalTracks: req.TotalTracks, DiscNumber: discNumber, + TotalDiscs: req.TotalDiscs, ISRC: isrc, CoverURL: coverURL, Genre: genre, Label: label, Copyright: copyright, + Composer: composer, LyricsLRC: result.LyricsLRC, DecryptionKey: result.DecryptionKey, } @@ -1005,7 +1044,9 @@ func ReadFileMetadata(filePath string) (string, error) { "album_artist": "", "date": "", "track_number": 0, + "total_tracks": 0, "disc_number": 0, + "total_discs": 0, "isrc": "", "lyrics": "", "genre": "", @@ -1033,7 +1074,9 @@ func ReadFileMetadata(filePath string) (string, error) { result["date"] = oggMeta.Year } result["track_number"] = oggMeta.TrackNumber + result["total_tracks"] = oggMeta.TotalTracks result["disc_number"] = oggMeta.DiscNumber + result["total_discs"] = oggMeta.TotalDiscs result["isrc"] = oggMeta.ISRC result["lyrics"] = oggMeta.Lyrics result["genre"] = oggMeta.Genre @@ -1054,7 +1097,9 @@ func ReadFileMetadata(filePath string) (string, error) { result["album_artist"] = metadata.AlbumArtist result["date"] = metadata.Date result["track_number"] = metadata.TrackNumber + result["total_tracks"] = metadata.TotalTracks result["disc_number"] = metadata.DiscNumber + result["total_discs"] = metadata.TotalDiscs result["isrc"] = metadata.ISRC result["lyrics"] = metadata.Lyrics result["genre"] = metadata.Genre @@ -1088,7 +1133,9 @@ func ReadFileMetadata(filePath string) (string, error) { result["date"] = meta.Year } result["track_number"] = meta.TrackNumber + result["total_tracks"] = meta.TotalTracks result["disc_number"] = meta.DiscNumber + result["total_discs"] = meta.TotalDiscs result["isrc"] = meta.ISRC result["lyrics"] = meta.Lyrics result["genre"] = meta.Genre @@ -1118,7 +1165,9 @@ func ReadFileMetadata(filePath string) (string, error) { result["date"] = meta.Year } result["track_number"] = meta.TrackNumber + result["total_tracks"] = meta.TotalTracks result["disc_number"] = meta.DiscNumber + result["total_discs"] = meta.TotalDiscs result["isrc"] = meta.ISRC result["lyrics"] = meta.Lyrics result["genre"] = meta.Genre @@ -1149,7 +1198,9 @@ func ReadFileMetadata(filePath string) (string, error) { result["date"] = meta.Year } result["track_number"] = meta.TrackNumber + result["total_tracks"] = meta.TotalTracks result["disc_number"] = meta.DiscNumber + result["total_discs"] = meta.TotalDiscs result["isrc"] = meta.ISRC result["lyrics"] = meta.Lyrics result["genre"] = meta.Genre @@ -1182,7 +1233,9 @@ func ReadFileMetadata(filePath string) (string, error) { result["date"] = meta.Year } result["track_number"] = meta.TrackNumber + result["total_tracks"] = meta.TotalTracks result["disc_number"] = meta.DiscNumber + result["total_discs"] = meta.TotalDiscs result["isrc"] = meta.ISRC result["lyrics"] = meta.Lyrics result["genre"] = meta.Genre @@ -1281,13 +1334,21 @@ func EditFileMetadata(filePath, metadataJSON string) (string, error) { // APE/WV/MPC: write APEv2 tags natively if isApeFile { trackNum := 0 + totalTracks := 0 discNum := 0 + totalDiscs := 0 if v, ok := fields["track_number"]; ok && v != "" { fmt.Sscanf(v, "%d", &trackNum) } + if v, ok := fields["track_total"]; ok && v != "" { + fmt.Sscanf(v, "%d", &totalTracks) + } if v, ok := fields["disc_number"]; ok && v != "" { fmt.Sscanf(v, "%d", &discNum) } + if v, ok := fields["disc_total"]; ok && v != "" { + fmt.Sscanf(v, "%d", &totalDiscs) + } meta := &AudioMetadata{ Title: fields["title"], @@ -1296,7 +1357,9 @@ func EditFileMetadata(filePath, metadataJSON string) (string, error) { AlbumArtist: fields["album_artist"], Date: fields["date"], TrackNumber: trackNum, + TotalTracks: totalTracks, DiscNumber: discNum, + TotalDiscs: totalDiscs, ISRC: fields["isrc"], Genre: fields["genre"], Label: fields["label"], @@ -1930,11 +1993,13 @@ func buildDeezerISRCSearchResult(track *TrackMetadata) map[string]interface{} { "track_number": track.TrackNumber, "total_tracks": track.TotalTracks, "disc_number": track.DiscNumber, + "total_discs": track.TotalDiscs, "external_urls": track.ExternalURL, "isrc": track.ISRC, "album_id": track.AlbumID, "artist_id": track.ArtistID, "album_type": track.AlbumType, + "composer": track.Composer, } if deezerID := strings.TrimSpace(strings.TrimPrefix(track.SpotifyID, "deezer:")); deezerID != "" { @@ -2379,7 +2444,9 @@ func ReEnrichFile(requestJSON string) (string, error) { } if req.shouldUpdateField("track_info") { enrichedMeta["track_number"] = req.TrackNumber + enrichedMeta["total_tracks"] = req.TotalTracks enrichedMeta["disc_number"] = req.DiscNumber + enrichedMeta["total_discs"] = req.TotalDiscs } if req.shouldUpdateField("release_info") { enrichedMeta["release_date"] = req.ReleaseDate @@ -2392,6 +2459,7 @@ func ReEnrichFile(requestJSON string) (string, error) { enrichedMeta["genre"] = req.Genre enrichedMeta["label"] = req.Label enrichedMeta["copyright"] = req.Copyright + enrichedMeta["composer"] = req.Composer } if isFlac { @@ -2408,7 +2476,9 @@ func ReEnrichFile(requestJSON string) (string, error) { } if req.shouldUpdateField("track_info") { metadata.TrackNumber = req.TrackNumber + metadata.TotalTracks = req.TotalTracks metadata.DiscNumber = req.DiscNumber + metadata.TotalDiscs = req.TotalDiscs } if req.shouldUpdateField("release_info") { metadata.Date = req.ReleaseDate @@ -2421,6 +2491,7 @@ func ReEnrichFile(requestJSON string) (string, error) { metadata.Genre = req.Genre metadata.Label = req.Label metadata.Copyright = req.Copyright + metadata.Composer = req.Composer } if len(coverDataBytes) > 0 { @@ -2910,11 +2981,14 @@ func CustomSearchWithExtensionJSON(extensionID, query string, optionsJSON string "images": track.ResolvedCoverURL(), "release_date": track.ReleaseDate, "track_number": track.TrackNumber, + "total_tracks": track.TotalTracks, "disc_number": track.DiscNumber, + "total_discs": track.TotalDiscs, "isrc": track.ISRC, "provider_id": track.ProviderID, "item_type": track.ItemType, "album_type": track.AlbumType, + "composer": track.Composer, } } @@ -2981,9 +3055,12 @@ func HandleURLWithExtensionJSON(url string) (string, error) { "images": result.Track.ResolvedCoverURL(), "release_date": result.Track.ReleaseDate, "track_number": result.Track.TrackNumber, + "total_tracks": result.Track.TotalTracks, "disc_number": result.Track.DiscNumber, + "total_discs": result.Track.TotalDiscs, "isrc": result.Track.ISRC, "provider_id": result.Track.ProviderID, + "composer": result.Track.Composer, } } @@ -3000,11 +3077,14 @@ func HandleURLWithExtensionJSON(url string) (string, error) { "images": track.ResolvedCoverURL(), "release_date": track.ReleaseDate, "track_number": track.TrackNumber, + "total_tracks": track.TotalTracks, "disc_number": track.DiscNumber, + "total_discs": track.TotalDiscs, "isrc": track.ISRC, "provider_id": track.ProviderID, "item_type": track.ItemType, "album_type": track.AlbumType, + "composer": track.Composer, } } response["tracks"] = tracks @@ -3090,10 +3170,13 @@ func HandleURLWithExtensionJSON(url string) (string, error) { "images": track.ResolvedCoverURL(), "release_date": track.ReleaseDate, "track_number": track.TrackNumber, + "total_tracks": track.TotalTracks, "disc_number": track.DiscNumber, + "total_discs": track.TotalDiscs, "isrc": track.ISRC, "provider_id": track.ProviderID, "spotify_id": track.SpotifyID, + "composer": track.Composer, } } artistResponse["top_tracks"] = topTracks @@ -3163,11 +3246,14 @@ func GetAlbumWithExtensionJSON(extensionID, albumID string) (string, error) { "cover_url": trackCover, "release_date": track.ReleaseDate, "track_number": trackNum, + "total_tracks": track.TotalTracks, "disc_number": track.DiscNumber, + "total_discs": track.TotalDiscs, "isrc": track.ISRC, "provider_id": track.ProviderID, "item_type": track.ItemType, "album_type": track.AlbumType, + "composer": track.Composer, } } @@ -3264,11 +3350,14 @@ func GetPlaylistWithExtensionJSON(extensionID, playlistID string) (string, error "cover_url": trackCover, "release_date": track.ReleaseDate, "track_number": track.TrackNumber, + "total_tracks": track.TotalTracks, "disc_number": track.DiscNumber, + "total_discs": track.TotalDiscs, "isrc": track.ISRC, "provider_id": track.ProviderID, "item_type": track.ItemType, "album_type": track.AlbumType, + "composer": track.Composer, } } @@ -3375,10 +3464,13 @@ func GetArtistWithExtensionJSON(extensionID, artistID string) (string, error) { "images": track.ResolvedCoverURL(), "release_date": track.ReleaseDate, "track_number": track.TrackNumber, + "total_tracks": track.TotalTracks, "disc_number": track.DiscNumber, + "total_discs": track.TotalDiscs, "isrc": track.ISRC, "provider_id": track.ProviderID, "spotify_id": track.SpotifyID, + "composer": track.Composer, } } response["top_tracks"] = topTracks diff --git a/go_backend/exports_test.go b/go_backend/exports_test.go index 32cce99f..5e6e32b5 100644 --- a/go_backend/exports_test.go +++ b/go_backend/exports_test.go @@ -224,3 +224,47 @@ func TestBuildReEnrichFFmpegMetadataOmitsEmptyFields(t *testing.T) { } } } + +func TestApplyReEnrichTrackMetadataCopiesComposerAndTotals(t *testing.T) { + req := reEnrichRequest{} + + applyReEnrichTrackMetadata(&req, ExtTrackMetadata{ + TrackNumber: 7, + TotalTracks: 12, + DiscNumber: 2, + TotalDiscs: 3, + Composer: "Composer", + }) + + if req.TrackNumber != 7 || req.TotalTracks != 12 { + t.Fatalf("track metadata = %d/%d", req.TrackNumber, req.TotalTracks) + } + if req.DiscNumber != 2 || req.TotalDiscs != 3 { + t.Fatalf("disc metadata = %d/%d", req.DiscNumber, req.TotalDiscs) + } + if req.Composer != "Composer" { + t.Fatalf("composer = %q", req.Composer) + } +} + +func TestBuildReEnrichFFmpegMetadataFormatsTotalsAndComposer(t *testing.T) { + req := reEnrichRequest{ + TrackNumber: 7, + TotalTracks: 12, + DiscNumber: 2, + TotalDiscs: 3, + Composer: "Composer", + } + + metadata := buildReEnrichFFmpegMetadata(&req, "") + + if metadata["TRACKNUMBER"] != "7/12" { + t.Fatalf("TRACKNUMBER = %q", metadata["TRACKNUMBER"]) + } + if metadata["DISCNUMBER"] != "2/3" { + t.Fatalf("DISCNUMBER = %q", metadata["DISCNUMBER"]) + } + if metadata["COMPOSER"] != "Composer" { + t.Fatalf("COMPOSER = %q", metadata["COMPOSER"]) + } +} diff --git a/go_backend/extension_providers.go b/go_backend/extension_providers.go index c33015b7..4573628e 100644 --- a/go_backend/extension_providers.go +++ b/go_backend/extension_providers.go @@ -26,7 +26,9 @@ type ExtTrackMetadata struct { Images string `json:"images,omitempty"` ReleaseDate string `json:"release_date,omitempty"` TrackNumber int `json:"track_number,omitempty"` + TotalTracks int `json:"total_tracks,omitempty"` DiscNumber int `json:"disc_number,omitempty"` + TotalDiscs int `json:"total_discs,omitempty"` ISRC string `json:"isrc,omitempty"` ProviderID string `json:"provider_id"` ItemType string `json:"item_type,omitempty"` @@ -41,6 +43,7 @@ type ExtTrackMetadata struct { Label string `json:"label,omitempty"` Copyright string `json:"copyright,omitempty"` Genre string `json:"genre,omitempty"` + Composer string `json:"composer,omitempty"` } func (t *ExtTrackMetadata) ResolvedCoverURL() string { @@ -775,7 +778,9 @@ func normalizeBuiltInMetadataTrack(track TrackMetadata, providerID string) ExtTr Images: track.Images, ReleaseDate: track.ReleaseDate, TrackNumber: track.TrackNumber, + TotalTracks: track.TotalTracks, DiscNumber: track.DiscNumber, + TotalDiscs: track.TotalDiscs, ISRC: track.ISRC, ProviderID: providerID, SpotifyID: prefixedID, @@ -783,6 +788,7 @@ func normalizeBuiltInMetadataTrack(track TrackMetadata, providerID string) ExtTr TidalID: tidalID, QobuzID: qobuzID, AlbumType: track.AlbumType, + Composer: track.Composer, } } @@ -975,8 +981,11 @@ func DownloadWithExtensionFallback(req DownloadRequest) (*DownloadResponse, erro ISRC: req.ISRC, ReleaseDate: req.ReleaseDate, TrackNumber: req.TrackNumber, + TotalTracks: req.TotalTracks, DiscNumber: req.DiscNumber, + TotalDiscs: req.TotalDiscs, ProviderID: req.Source, + Composer: req.Composer, } enrichedTrack, err := provider.EnrichTrack(trackMeta) @@ -1041,10 +1050,22 @@ func DownloadWithExtensionFallback(req DownloadRequest) (*DownloadResponse, erro GoLog("[DownloadWithExtensionFallback] TrackNumber from enrichment: %d\n", enrichedTrack.TrackNumber) req.TrackNumber = enrichedTrack.TrackNumber } + if enrichedTrack.TotalTracks > 0 && req.TotalTracks == 0 { + GoLog("[DownloadWithExtensionFallback] TotalTracks from enrichment: %d\n", enrichedTrack.TotalTracks) + req.TotalTracks = enrichedTrack.TotalTracks + } if enrichedTrack.DiscNumber > 0 && req.DiscNumber == 0 { GoLog("[DownloadWithExtensionFallback] DiscNumber from enrichment: %d\n", enrichedTrack.DiscNumber) req.DiscNumber = enrichedTrack.DiscNumber } + if enrichedTrack.TotalDiscs > 0 && req.TotalDiscs == 0 { + GoLog("[DownloadWithExtensionFallback] TotalDiscs from enrichment: %d\n", enrichedTrack.TotalDiscs) + req.TotalDiscs = enrichedTrack.TotalDiscs + } + if enrichedTrack.Composer != "" && req.Composer == "" { + GoLog("[DownloadWithExtensionFallback] Composer from enrichment: %s\n", enrichedTrack.Composer) + req.Composer = enrichedTrack.Composer + } } } } @@ -1077,9 +1098,18 @@ func DownloadWithExtensionFallback(req DownloadRequest) (*DownloadResponse, erro if track.TrackNumber > 0 && req.TrackNumber == 0 { req.TrackNumber = track.TrackNumber } + if track.TotalTracks > 0 && req.TotalTracks == 0 { + req.TotalTracks = track.TotalTracks + } if track.DiscNumber > 0 && req.DiscNumber == 0 { req.DiscNumber = track.DiscNumber } + if track.TotalDiscs > 0 && req.TotalDiscs == 0 { + req.TotalDiscs = track.TotalDiscs + } + if track.Composer != "" && req.Composer == "" { + req.Composer = track.Composer + } if track.CoverURL != "" && req.CoverURL == "" { req.CoverURL = track.CoverURL } @@ -1594,12 +1624,15 @@ func buildOutputPath(req DownloadRequest) string { "album_artist": req.AlbumArtist, "track": req.TrackNumber, "track_number": req.TrackNumber, + "total_tracks": req.TotalTracks, "disc": req.DiscNumber, "disc_number": req.DiscNumber, + "total_discs": req.TotalDiscs, "year": extractYear(req.ReleaseDate), "date": req.ReleaseDate, "release_date": req.ReleaseDate, "isrc": req.ISRC, + "composer": req.Composer, } filename := buildFilenameFromTemplate(req.FilenameFormat, metadata) @@ -1644,12 +1677,15 @@ func buildOutputPathForExtension(req DownloadRequest, ext *LoadedExtension) stri "album_artist": req.AlbumArtist, "track": req.TrackNumber, "track_number": req.TrackNumber, + "total_tracks": req.TotalTracks, "disc": req.DiscNumber, "disc_number": req.DiscNumber, + "total_discs": req.TotalDiscs, "year": extractYear(req.ReleaseDate), "date": req.ReleaseDate, "release_date": req.ReleaseDate, "isrc": req.ISRC, + "composer": req.Composer, } filename := buildFilenameFromTemplate(req.FilenameFormat, metadata) diff --git a/go_backend/library_scan.go b/go_backend/library_scan.go index c8ec9f54..673c8870 100644 --- a/go_backend/library_scan.go +++ b/go_backend/library_scan.go @@ -24,13 +24,16 @@ type LibraryScanResult struct { FileModTime int64 `json:"fileModTime,omitempty"` // Unix timestamp in milliseconds ISRC string `json:"isrc,omitempty"` TrackNumber int `json:"trackNumber,omitempty"` + TotalTracks int `json:"totalTracks,omitempty"` DiscNumber int `json:"discNumber,omitempty"` + TotalDiscs int `json:"totalDiscs,omitempty"` Duration int `json:"duration,omitempty"` ReleaseDate string `json:"releaseDate,omitempty"` BitDepth int `json:"bitDepth,omitempty"` SampleRate int `json:"sampleRate,omitempty"` Bitrate int `json:"bitrate,omitempty"` // kbps, for lossy formats (MP3, Opus, Vorbis) Genre string `json:"genre,omitempty"` + Composer string `json:"composer,omitempty"` Label string `json:"label,omitempty"` Copyright string `json:"copyright,omitempty"` Format string `json:"format,omitempty"` @@ -367,9 +370,12 @@ func scanFLACFile(filePath string, result *LibraryScanResult, displayNameHint st result.AlbumArtist = metadata.AlbumArtist result.ISRC = metadata.ISRC result.TrackNumber = metadata.TrackNumber + result.TotalTracks = metadata.TotalTracks result.DiscNumber = metadata.DiscNumber + result.TotalDiscs = metadata.TotalDiscs result.ReleaseDate = metadata.Date result.Genre = metadata.Genre + result.Composer = metadata.Composer result.Label = metadata.Label result.Copyright = metadata.Copyright @@ -401,12 +407,15 @@ func scanM4AFile(filePath string, result *LibraryScanResult, displayNameHint str result.AlbumArtist = metadata.AlbumArtist result.ISRC = metadata.ISRC result.TrackNumber = metadata.TrackNumber + result.TotalTracks = metadata.TotalTracks result.DiscNumber = metadata.DiscNumber + result.TotalDiscs = metadata.TotalDiscs result.ReleaseDate = metadata.Date if result.ReleaseDate == "" { result.ReleaseDate = metadata.Year } result.Genre = metadata.Genre + result.Composer = metadata.Composer result.Label = metadata.Label result.Copyright = metadata.Copyright } @@ -433,7 +442,9 @@ func scanMP3File(filePath string, result *LibraryScanResult, displayNameHint str result.AlbumName = metadata.Album result.AlbumArtist = metadata.AlbumArtist result.TrackNumber = metadata.TrackNumber + result.TotalTracks = metadata.TotalTracks result.DiscNumber = metadata.DiscNumber + result.TotalDiscs = metadata.TotalDiscs result.Genre = metadata.Genre if metadata.Date != "" { result.ReleaseDate = metadata.Date @@ -441,6 +452,7 @@ func scanMP3File(filePath string, result *LibraryScanResult, displayNameHint str result.ReleaseDate = metadata.Year } result.ISRC = metadata.ISRC + result.Composer = metadata.Composer result.Label = metadata.Label result.Copyright = metadata.Copyright @@ -472,9 +484,12 @@ func scanOggFile(filePath string, result *LibraryScanResult, displayNameHint str result.AlbumArtist = metadata.AlbumArtist result.ISRC = metadata.ISRC result.TrackNumber = metadata.TrackNumber + result.TotalTracks = metadata.TotalTracks result.DiscNumber = metadata.DiscNumber + result.TotalDiscs = metadata.TotalDiscs result.Genre = metadata.Genre result.ReleaseDate = metadata.Date + result.Composer = metadata.Composer result.Label = metadata.Label result.Copyright = metadata.Copyright @@ -511,13 +526,16 @@ func scanAPEFile(filePath string, result *LibraryScanResult, displayNameHint str result.AlbumArtist = metadata.AlbumArtist result.ISRC = metadata.ISRC result.TrackNumber = metadata.TrackNumber + result.TotalTracks = metadata.TotalTracks result.DiscNumber = metadata.DiscNumber + result.TotalDiscs = metadata.TotalDiscs result.Genre = metadata.Genre if metadata.Date != "" { result.ReleaseDate = metadata.Date } else { result.ReleaseDate = metadata.Year } + result.Composer = metadata.Composer result.Label = metadata.Label result.Copyright = metadata.Copyright diff --git a/go_backend/metadata.go b/go_backend/metadata.go index 8f6446e3..fb79d330 100644 --- a/go_backend/metadata.go +++ b/go_backend/metadata.go @@ -110,6 +110,7 @@ type Metadata struct { TrackNumber int TotalTracks int DiscNumber int + TotalDiscs int ISRC string Description string Lyrics string @@ -273,23 +274,23 @@ func ReadMetadata(filePath string) (*Metadata, error) { trackNum := getComment(cmt, "TRACKNUMBER") if trackNum != "" { - fmt.Sscanf(trackNum, "%d", &metadata.TrackNumber) + metadata.TrackNumber, metadata.TotalTracks = parseIndexPair(trackNum) } if metadata.TrackNumber == 0 { trackNum = getComment(cmt, "TRACK") if trackNum != "" { - fmt.Sscanf(trackNum, "%d", &metadata.TrackNumber) + metadata.TrackNumber, metadata.TotalTracks = parseIndexPair(trackNum) } } discNum := getComment(cmt, "DISCNUMBER") if discNum != "" { - fmt.Sscanf(discNum, "%d", &metadata.DiscNumber) + metadata.DiscNumber, metadata.TotalDiscs = parseIndexPair(discNum) } if metadata.DiscNumber == 0 { discNum = getComment(cmt, "DISC") if discNum != "" { - fmt.Sscanf(discNum, "%d", &metadata.DiscNumber) + metadata.DiscNumber, metadata.TotalDiscs = parseIndexPair(discNum) } } @@ -403,26 +404,39 @@ func EditFlacFields(filePath string, fields map[string]string) error { removeCommentKey(cmt, "ALBUM_ARTIST") } - // Track/disc numbers: present + empty → clear; present + "0" → clear. - if v, ok := fields["track_number"]; ok { - trackNum := 0 - if v != "" { - fmt.Sscanf(v, "%d", &trackNum) + // Track/disc numbers: present + empty → clear; when only totals are edited, + // preserve the current index number and rewrite the combined value. + if _, ok := fields["track_number"]; ok || fields["track_total"] != "" || hasMapKey(fields, "track_total") { + currentTrackNum, currentTotalTracks := parseIndexPair(getComment(cmt, "TRACKNUMBER")) + if currentTrackNum == 0 && currentTotalTracks == 0 { + currentTrackNum, currentTotalTracks = parseIndexPair(getComment(cmt, "TRACK")) } - if trackNum > 0 { - setOrClearComment(cmt, "TRACKNUMBER", strconv.Itoa(trackNum)) + if v, ok := fields["track_number"]; ok { + currentTrackNum = parsePositiveInt(v) + } + if v, ok := fields["track_total"]; ok { + currentTotalTracks = parsePositiveInt(v) + } + if currentTrackNum > 0 { + setOrClearComment(cmt, "TRACKNUMBER", formatIndexValue(currentTrackNum, currentTotalTracks)) } else { removeCommentKey(cmt, "TRACKNUMBER") } removeCommentKey(cmt, "TRACK") // alias } - if v, ok := fields["disc_number"]; ok { - discNum := 0 - if v != "" { - fmt.Sscanf(v, "%d", &discNum) + if _, ok := fields["disc_number"]; ok || fields["disc_total"] != "" || hasMapKey(fields, "disc_total") { + currentDiscNum, currentTotalDiscs := parseIndexPair(getComment(cmt, "DISCNUMBER")) + if currentDiscNum == 0 && currentTotalDiscs == 0 { + currentDiscNum, currentTotalDiscs = parseIndexPair(getComment(cmt, "DISC")) } - if discNum > 0 { - setOrClearComment(cmt, "DISCNUMBER", strconv.Itoa(discNum)) + if v, ok := fields["disc_number"]; ok { + currentDiscNum = parsePositiveInt(v) + } + if v, ok := fields["disc_total"]; ok { + currentTotalDiscs = parsePositiveInt(v) + } + if currentDiscNum > 0 { + setOrClearComment(cmt, "DISCNUMBER", formatIndexValue(currentDiscNum, currentTotalDiscs)) } else { removeCommentKey(cmt, "DISCNUMBER") } @@ -478,15 +492,11 @@ func writeVorbisMetadata(cmt *flacvorbis.MetaDataBlockVorbisComment, metadata Me setComment(cmt, "DATE", metadata.Date) if metadata.TrackNumber > 0 { - if metadata.TotalTracks > 0 { - setComment(cmt, "TRACKNUMBER", fmt.Sprintf("%d/%d", metadata.TrackNumber, metadata.TotalTracks)) - } else { - setComment(cmt, "TRACKNUMBER", strconv.Itoa(metadata.TrackNumber)) - } + setComment(cmt, "TRACKNUMBER", formatIndexValue(metadata.TrackNumber, metadata.TotalTracks)) } if metadata.DiscNumber > 0 { - setComment(cmt, "DISCNUMBER", strconv.Itoa(metadata.DiscNumber)) + setComment(cmt, "DISCNUMBER", formatIndexValue(metadata.DiscNumber, metadata.TotalDiscs)) } if metadata.ISRC != "" { @@ -953,9 +963,9 @@ func ReadM4ATags(filePath string) (*AudioMetadata, error) { case "\xa9lyr": metadata.Lyrics, _ = readM4ATextValue(f, header, fi.Size()) case "trkn": - metadata.TrackNumber, _ = readM4AIndexValue(f, header, fi.Size()) + metadata.TrackNumber, metadata.TotalTracks, _ = readM4AIndexPair(f, header, fi.Size()) case "disk": - metadata.DiscNumber, _ = readM4AIndexValue(f, header, fi.Size()) + metadata.DiscNumber, metadata.TotalDiscs, _ = readM4AIndexPair(f, header, fi.Size()) case "----": name, value, freeformErr := readM4AFreeformValue(f, header, fi.Size()) if freeformErr == nil { @@ -1150,6 +1160,41 @@ func readM4AIndexValue(f *os.File, parent atomHeader, fileSize int64) (int, erro return int(binary.BigEndian.Uint16(payload[2:4])), nil } +func readM4AIndexPair(f *os.File, parent atomHeader, fileSize int64) (int, int, error) { + payload, err := readM4ADataPayload(f, parent, fileSize) + if err != nil { + return 0, 0, err + } + if len(payload) < 6 { + return 0, 0, fmt.Errorf("index payload too short in %s", parent.typ) + } + return int(binary.BigEndian.Uint16(payload[2:4])), int(binary.BigEndian.Uint16(payload[4:6])), nil +} + +func parsePositiveInt(value string) int { + value = strings.TrimSpace(value) + if value == "" { + return 0 + } + n, _ := strconv.Atoi(value) + return n +} + +func formatIndexValue(number, total int) string { + if number <= 0 { + return "" + } + if total > 0 { + return fmt.Sprintf("%d/%d", number, total) + } + return strconv.Itoa(number) +} + +func hasMapKey(fields map[string]string, key string) bool { + _, ok := fields[key] + return ok +} + func readM4AFreeformValue(f *os.File, parent atomHeader, fileSize int64) (string, string, error) { start := parent.offset + parent.headerSize end := parent.offset + parent.size diff --git a/go_backend/metadata_types.go b/go_backend/metadata_types.go index e2523e96..caac6e22 100644 --- a/go_backend/metadata_types.go +++ b/go_backend/metadata_types.go @@ -23,11 +23,13 @@ type TrackMetadata struct { TrackNumber int `json:"track_number"` TotalTracks int `json:"total_tracks,omitempty"` DiscNumber int `json:"disc_number,omitempty"` + TotalDiscs int `json:"total_discs,omitempty"` ExternalURL string `json:"external_urls"` ISRC string `json:"isrc"` AlbumID string `json:"album_id,omitempty"` ArtistID string `json:"artist_id,omitempty"` AlbumType string `json:"album_type,omitempty"` + Composer string `json:"composer,omitempty"` } type AlbumTrackMetadata struct { @@ -42,11 +44,13 @@ type AlbumTrackMetadata struct { TrackNumber int `json:"track_number"` TotalTracks int `json:"total_tracks,omitempty"` DiscNumber int `json:"disc_number,omitempty"` + TotalDiscs int `json:"total_discs,omitempty"` ExternalURL string `json:"external_urls"` ISRC string `json:"isrc"` AlbumID string `json:"album_id,omitempty"` AlbumURL string `json:"album_url,omitempty"` AlbumType string `json:"album_type,omitempty"` + Composer string `json:"composer,omitempty"` } type AlbumInfoMetadata struct { diff --git a/go_backend/qobuz.go b/go_backend/qobuz.go index 66e3700b..d4bf27ee 100644 --- a/go_backend/qobuz.go +++ b/go_backend/qobuz.go @@ -1030,6 +1030,7 @@ func (q *QobuzDownloader) GetAlbumMetadata(resourceID string) (*AlbumResponsePay } tracks := make([]AlbumTrackMetadata, 0, len(album.Tracks.Items)) + totalDiscs := 0 for i := range album.Tracks.Items { track := &album.Tracks.Items[i] track.Album.ID = album.ID @@ -1041,8 +1042,14 @@ func (q *QobuzDownloader) GetAlbumMetadata(resourceID string) (*AlbumResponsePay Large: album.Image.Large, } track.Album.TracksCount = album.TracksCount + if track.MediaNumber > totalDiscs { + totalDiscs = track.MediaNumber + } tracks = append(tracks, qobuzTrackToAlbumTrackMetadata(track)) } + for i := range tracks { + tracks[i].TotalDiscs = totalDiscs + } return &AlbumResponsePayload{ AlbumInfo: qobuzAlbumToAlbumInfo(album), @@ -2793,10 +2800,12 @@ func downloadFromQobuz(req DownloadRequest) (QobuzDownloadResult, error) { TrackNumber: actualTrackNumber, TotalTracks: req.TotalTracks, DiscNumber: req.DiscNumber, + TotalDiscs: req.TotalDiscs, ISRC: track.ISRC, Genre: req.Genre, Label: req.Label, Copyright: req.Copyright, + Composer: req.Composer, } var coverData []byte diff --git a/go_backend/tidal.go b/go_backend/tidal.go index 9cbc9c22..b818667e 100644 --- a/go_backend/tidal.go +++ b/go_backend/tidal.go @@ -1012,6 +1012,7 @@ func (t *TidalDownloader) GetAlbumMetadata(resourceID string) (*AlbumResponsePay } tracks := make([]AlbumTrackMetadata, 0, len(itemsModule.PagedList.Items)) + totalDiscs := 0 for _, item := range itemsModule.PagedList.Items { track := item.Item track.Album.ID = headerModule.Album.ID @@ -1019,8 +1020,14 @@ func (t *TidalDownloader) GetAlbumMetadata(resourceID string) (*AlbumResponsePay track.Album.Cover = headerModule.Album.Cover track.Album.ReleaseDate = headerModule.Album.ReleaseDate track.Album.URL = headerModule.Album.URL + if track.VolumeNumber > totalDiscs { + totalDiscs = track.VolumeNumber + } tracks = append(tracks, tidalTrackToAlbumTrackMetadata(&track)) } + for i := range tracks { + tracks[i].TotalDiscs = totalDiscs + } return &AlbumResponsePayload{ AlbumInfo: tidalAlbumToAlbumInfo(&headerModule.Album), @@ -2360,10 +2367,12 @@ func downloadFromTidal(req DownloadRequest) (TidalDownloadResult, error) { TrackNumber: actualTrackNumber, TotalTracks: req.TotalTracks, DiscNumber: actualDiscNumber, + TotalDiscs: req.TotalDiscs, ISRC: track.ISRC, Genre: req.Genre, Label: req.Label, Copyright: req.Copyright, + Composer: req.Composer, } var coverData []byte diff --git a/lib/models/track.dart b/lib/models/track.dart index f1ac89e0..bc6549a6 100644 --- a/lib/models/track.dart +++ b/lib/models/track.dart @@ -16,12 +16,14 @@ class Track { final int duration; final int? trackNumber; final int? discNumber; + final int? totalDiscs; final String? releaseDate; final String? deezerId; final ServiceAvailability? availability; final String? source; final String? albumType; final int? totalTracks; + final String? composer; final String? itemType; const Track({ @@ -37,12 +39,14 @@ class Track { required this.duration, this.trackNumber, this.discNumber, + this.totalDiscs, this.releaseDate, this.deezerId, this.availability, this.source, this.albumType, this.totalTracks, + this.composer, this.itemType, }); diff --git a/lib/models/track.g.dart b/lib/models/track.g.dart index 5e361ab6..c889af70 100644 --- a/lib/models/track.g.dart +++ b/lib/models/track.g.dart @@ -19,6 +19,7 @@ Track _$TrackFromJson(Map json) => Track( duration: (json['duration'] as num).toInt(), trackNumber: (json['trackNumber'] as num?)?.toInt(), discNumber: (json['discNumber'] as num?)?.toInt(), + totalDiscs: (json['totalDiscs'] as num?)?.toInt(), releaseDate: json['releaseDate'] as String?, deezerId: json['deezerId'] as String?, availability: json['availability'] == null @@ -29,6 +30,7 @@ Track _$TrackFromJson(Map json) => Track( source: json['source'] as String?, albumType: json['albumType'] as String?, totalTracks: (json['totalTracks'] as num?)?.toInt(), + composer: json['composer'] as String?, itemType: json['itemType'] as String?, ); @@ -45,12 +47,14 @@ Map _$TrackToJson(Track instance) => { 'duration': instance.duration, 'trackNumber': instance.trackNumber, 'discNumber': instance.discNumber, + 'totalDiscs': instance.totalDiscs, 'releaseDate': instance.releaseDate, 'deezerId': instance.deezerId, 'availability': instance.availability, 'source': instance.source, 'albumType': instance.albumType, 'totalTracks': instance.totalTracks, + 'composer': instance.composer, 'itemType': instance.itemType, }; diff --git a/lib/providers/download_queue_provider.dart b/lib/providers/download_queue_provider.dart index 4b0570af..22ab0a5d 100644 --- a/lib/providers/download_queue_provider.dart +++ b/lib/providers/download_queue_provider.dart @@ -3251,11 +3251,13 @@ class DownloadQueueNotifier extends Notifier { isrc: backendIsrc ?? baseTrack.isrc, trackNumber: backendTrackNum ?? baseTrack.trackNumber, discNumber: backendDiscNum ?? baseTrack.discNumber, + totalDiscs: baseTrack.totalDiscs, releaseDate: backendYear ?? baseTrack.releaseDate, deezerId: baseTrack.deezerId, availability: baseTrack.availability, albumType: baseTrack.albumType, totalTracks: baseTrack.totalTracks, + composer: baseTrack.composer, source: baseTrack.source, ); } @@ -3329,17 +3331,25 @@ class DownloadQueueNotifier extends Notifier { 'ARTIST': track.artistName, 'ALBUM': track.albumName, }; + String formatIndexTag(int number, int? total) { + if (total != null && total > 0) { + return '$number/$total'; + } + return number.toString(); + } final albumArtist = _resolveAlbumArtistForMetadata(track, settings); metadata['ALBUMARTIST'] = albumArtist; if (track.trackNumber != null && track.trackNumber! > 0) { - metadata['TRACKNUMBER'] = track.trackNumber.toString(); - if (isFlac || isMp3) metadata['TRACK'] = track.trackNumber.toString(); + final trackTag = formatIndexTag(track.trackNumber!, track.totalTracks); + metadata['TRACKNUMBER'] = trackTag; + if (isFlac || isMp3) metadata['TRACK'] = trackTag; } if (track.discNumber != null && track.discNumber! > 0) { - metadata['DISCNUMBER'] = track.discNumber.toString(); - if (isFlac || isMp3) metadata['DISC'] = track.discNumber.toString(); + final discTag = formatIndexTag(track.discNumber!, track.totalDiscs); + metadata['DISCNUMBER'] = discTag; + if (isFlac || isMp3) metadata['DISC'] = discTag; } if (track.releaseDate != null) { metadata['DATE'] = track.releaseDate!; @@ -3353,6 +3363,9 @@ class DownloadQueueNotifier extends Notifier { if (copyright != null && copyright.isNotEmpty) { metadata['COPYRIGHT'] = copyright; } + if (track.composer != null && track.composer!.isNotEmpty) { + metadata['COMPOSER'] = track.composer!; + } // ── Lyrics ────────────────────────────────────────────────────── final lyricsMode = settings.lyricsMode; @@ -3875,7 +3888,11 @@ class DownloadQueueNotifier extends Notifier { (trackToDownload.isrc == null || trackToDownload.isrc!.isEmpty || trackToDownload.trackNumber == null || - trackToDownload.trackNumber == 0); + trackToDownload.trackNumber == 0 || + trackToDownload.totalTracks == null || + trackToDownload.totalTracks == 0 || + (trackToDownload.composer == null || + trackToDownload.composer!.isEmpty)); if (needsEnrichment) { try { @@ -3923,6 +3940,8 @@ class DownloadQueueNotifier extends Notifier { isrc: (data['isrc'] as String?) ?? trackToDownload.isrc, trackNumber: data['track_number'] as int?, discNumber: data['disc_number'] as int?, + totalDiscs: + data['total_discs'] as int? ?? trackToDownload.totalDiscs, releaseDate: data['release_date'] as String?, deezerId: rawId, availability: trackToDownload.availability, @@ -3931,6 +3950,8 @@ class DownloadQueueNotifier extends Notifier { trackToDownload.albumType, totalTracks: data['total_tracks'] as int? ?? trackToDownload.totalTracks, + composer: + data['composer']?.toString() ?? trackToDownload.composer, source: trackToDownload.source, ); _log.d( @@ -4101,7 +4122,12 @@ class DownloadQueueNotifier extends Notifier { trackData['release_date'] as String?, ); final provTrackNum = trackData['track_number'] as int?; + final provTotalTracks = trackData['total_tracks'] as int?; final provDiscNum = trackData['disc_number'] as int?; + final provTotalDiscs = trackData['total_discs'] as int?; + final provComposer = normalizeOptionalString( + trackData['composer'] as String?, + ); trackToDownload = Track( id: trackToDownload.id, @@ -4124,11 +4150,21 @@ class DownloadQueueNotifier extends Notifier { trackToDownload.discNumber! > 0) ? trackToDownload.discNumber : provDiscNum, + totalDiscs: + (trackToDownload.totalDiscs != null && + trackToDownload.totalDiscs! > 0) + ? trackToDownload.totalDiscs + : provTotalDiscs, releaseDate: trackToDownload.releaseDate ?? provReleaseDate, deezerId: trackToDownload.deezerId, availability: trackToDownload.availability, albumType: trackToDownload.albumType, - totalTracks: trackToDownload.totalTracks, + totalTracks: + (trackToDownload.totalTracks != null && + trackToDownload.totalTracks! > 0) + ? trackToDownload.totalTracks + : provTotalTracks, + composer: trackToDownload.composer ?? provComposer, source: trackToDownload.source, ); @@ -4198,7 +4234,12 @@ class DownloadQueueNotifier extends Notifier { trackData['isrc'] as String?, ); final deezerTrackNum = trackData['track_number'] as int?; + final deezerTotalTracks = trackData['total_tracks'] as int?; final deezerDiscNum = trackData['disc_number'] as int?; + final deezerTotalDiscs = trackData['total_discs'] as int?; + final deezerComposer = normalizeOptionalString( + trackData['composer'] as String?, + ); final needsEnrich = (trackToDownload.releaseDate == null && @@ -4210,10 +4251,21 @@ class DownloadQueueNotifier extends Notifier { trackToDownload.trackNumber! <= 0) && deezerTrackNum != null && deezerTrackNum > 0) || + ((trackToDownload.totalTracks == null || + trackToDownload.totalTracks! <= 0) && + deezerTotalTracks != null && + deezerTotalTracks > 0) || ((trackToDownload.discNumber == null || trackToDownload.discNumber! <= 0) && deezerDiscNum != null && - deezerDiscNum > 0); + deezerDiscNum > 0) || + ((trackToDownload.totalDiscs == null || + trackToDownload.totalDiscs! <= 0) && + deezerTotalDiscs != null && + deezerTotalDiscs > 0) || + ((trackToDownload.composer == null || + trackToDownload.composer!.isEmpty) && + deezerComposer != null); if (needsEnrich) { trackToDownload = Track( @@ -4239,11 +4291,21 @@ class DownloadQueueNotifier extends Notifier { trackToDownload.discNumber! > 0) ? trackToDownload.discNumber : deezerDiscNum, + totalDiscs: + (trackToDownload.totalDiscs != null && + trackToDownload.totalDiscs! > 0) + ? trackToDownload.totalDiscs + : deezerTotalDiscs, releaseDate: trackToDownload.releaseDate ?? deezerReleaseDate, deezerId: deezerTrackId, availability: trackToDownload.availability, albumType: trackToDownload.albumType, - totalTracks: trackToDownload.totalTracks, + totalTracks: + (trackToDownload.totalTracks != null && + trackToDownload.totalTracks! > 0) + ? trackToDownload.totalTracks + : deezerTotalTracks, + composer: trackToDownload.composer ?? deezerComposer, source: trackToDownload.source, ); _log.d( @@ -4390,6 +4452,8 @@ class DownloadQueueNotifier extends Notifier { metadataEmbeddingEnabled && settings.maxQualityCover, trackNumber: normalizedTrackNumber, discNumber: normalizedDiscNumber, + totalTracks: trackToDownload.totalTracks ?? 0, + totalDiscs: trackToDownload.totalDiscs ?? 0, releaseDate: trackToDownload.releaseDate ?? '', itemId: item.id, durationMs: trackToDownload.duration, @@ -4397,6 +4461,7 @@ class DownloadQueueNotifier extends Notifier { genre: genre ?? '', label: label ?? '', copyright: copyright ?? '', + composer: trackToDownload.composer ?? '', qobuzId: payloadQobuzId, tidalId: payloadTidalId, deezerId: deezerTrackId ?? '', diff --git a/lib/providers/track_provider.dart b/lib/providers/track_provider.dart index 3f6a3343..99a9e9e2 100644 --- a/lib/providers/track_provider.dart +++ b/lib/providers/track_provider.dart @@ -906,9 +906,11 @@ class TrackNotifier extends Notifier { duration: (durationMs / 1000).round(), trackNumber: data['track_number'] as int?, discNumber: data['disc_number'] as int?, + totalDiscs: data['total_discs'] as int?, releaseDate: data['release_date'] as String?, albumType: data['album_type'] as String?, totalTracks: data['total_tracks'] as int?, + composer: data['composer']?.toString(), ); } @@ -939,10 +941,12 @@ class TrackNotifier extends Notifier { duration: (durationMs / 1000).round(), trackNumber: data['track_number'] as int?, discNumber: data['disc_number'] as int?, + totalDiscs: data['total_discs'] as int?, releaseDate: data['release_date']?.toString(), totalTracks: data['total_tracks'] as int?, source: effectiveSource, albumType: data['album_type']?.toString(), + composer: data['composer']?.toString(), itemType: itemType, ); } diff --git a/lib/screens/album_screen.dart b/lib/screens/album_screen.dart index 9957c399..a08b1e91 100644 --- a/lib/screens/album_screen.dart +++ b/lib/screens/album_screen.dart @@ -299,9 +299,11 @@ class _AlbumScreenState extends ConsumerState { duration: ((data['duration_ms'] as int? ?? 0) / 1000).round(), trackNumber: data['track_number'] as int?, discNumber: data['disc_number'] as int?, + totalDiscs: data['total_discs'] as int?, releaseDate: data['release_date'] as String?, albumType: data['album_type'] as String?, totalTracks: data['total_tracks'] as int?, + composer: data['composer']?.toString(), ); } diff --git a/lib/screens/artist_screen.dart b/lib/screens/artist_screen.dart index b30e303c..7f61c302 100644 --- a/lib/screens/artist_screen.dart +++ b/lib/screens/artist_screen.dart @@ -410,9 +410,11 @@ class _ArtistScreenState extends ConsumerState { duration: (durationMs / 1000).round(), trackNumber: data['track_number'] as int?, discNumber: data['disc_number'] as int?, + totalDiscs: data['total_discs'] as int?, releaseDate: data['release_date']?.toString(), albumType: data['album_type']?.toString() ?? album?.albumType, totalTracks: data['total_tracks'] as int? ?? album?.totalTracks, + composer: data['composer']?.toString(), source: data['provider_id']?.toString() ?? widget.extensionId, ); } @@ -1129,9 +1131,11 @@ class _ArtistScreenState extends ConsumerState { trackNumber: data['track_position'] as int? ?? data['track_number'] as int?, discNumber: data['disk_number'] as int? ?? data['disc_number'] as int?, + totalDiscs: data['total_discs'] as int?, releaseDate: album.releaseDate, albumType: album.albumType, totalTracks: album.totalTracks, + composer: data['composer']?.toString(), ); } diff --git a/lib/screens/home_tab.dart b/lib/screens/home_tab.dart index 805ad025..f252f89f 100644 --- a/lib/screens/home_tab.dart +++ b/lib/screens/home_tab.dart @@ -1840,6 +1840,7 @@ class _HomeTabState extends ConsumerState duration: item.durationMs ~/ 1000, trackNumber: null, discNumber: null, + totalDiscs: null, isrc: null, releaseDate: item.releaseDate, coverUrl: item.coverUrl, @@ -4403,7 +4404,10 @@ class _ExtensionAlbumScreenState extends ConsumerState { duration: (durationMs / 1000).round(), trackNumber: data['track_number'] as int?, discNumber: data['disc_number'] as int?, + totalDiscs: data['total_discs'] as int?, releaseDate: data['release_date']?.toString(), + totalTracks: data['total_tracks'] as int?, + composer: data['composer']?.toString(), source: widget.extensionId, ); } @@ -4562,7 +4566,10 @@ class _ExtensionPlaylistScreenState duration: (durationMs / 1000).round(), trackNumber: data['track_number'] as int?, discNumber: data['disc_number'] as int?, + totalDiscs: data['total_discs'] as int?, releaseDate: data['release_date']?.toString(), + totalTracks: data['total_tracks'] as int?, + composer: data['composer']?.toString(), source: widget.extensionId, ); } @@ -4739,7 +4746,10 @@ class _ExtensionArtistScreenState extends ConsumerState { duration: (durationMs / 1000).round(), trackNumber: data['track_number'] as int?, discNumber: data['disc_number'] as int?, + totalDiscs: data['total_discs'] as int?, releaseDate: data['release_date']?.toString(), + totalTracks: data['total_tracks'] as int?, + composer: data['composer']?.toString(), source: (data['provider_id'] ?? widget.extensionId).toString(), ); } diff --git a/lib/screens/playlist_screen.dart b/lib/screens/playlist_screen.dart index 3f6a3015..d0fc5fb9 100644 --- a/lib/screens/playlist_screen.dart +++ b/lib/screens/playlist_screen.dart @@ -164,7 +164,10 @@ class _PlaylistScreenState extends ConsumerState { duration: (durationMs / 1000).round(), trackNumber: data['track_number'] as int?, discNumber: data['disc_number'] as int?, + totalDiscs: data['total_discs'] as int?, releaseDate: data['release_date']?.toString(), + totalTracks: data['total_tracks'] as int?, + composer: data['composer']?.toString(), ); } diff --git a/lib/screens/track_metadata_screen.dart b/lib/screens/track_metadata_screen.dart index bb8f8882..88e7a541 100644 --- a/lib/screens/track_metadata_screen.dart +++ b/lib/screens/track_metadata_screen.dart @@ -327,8 +327,23 @@ class _TrackMetadataScreenState extends ConsumerState { // Resolve label/copyright from file when the model doesn't carry them // (e.g. local library items, or download history items without these fields). + final resolvedTotalTracks = _readPositiveInt(metadata['total_tracks']); + final resolvedTotalDiscs = _readPositiveInt(metadata['total_discs']); + final resolvedComposer = metadata['composer']?.toString(); final resolvedLabel = metadata['label']?.toString(); final resolvedCopyright = metadata['copyright']?.toString(); + final needsTotalTracks = + resolvedTotalTracks != null && + resolvedTotalTracks > 0 && + totalTracks == null; + final needsTotalDiscs = + resolvedTotalDiscs != null && + resolvedTotalDiscs > 0 && + totalDiscs == null; + final needsComposer = + resolvedComposer != null && + resolvedComposer.isNotEmpty && + (composer == null || composer!.isEmpty); final needsLabel = resolvedLabel != null && resolvedLabel.isNotEmpty && @@ -348,6 +363,9 @@ class _TrackMetadataScreenState extends ConsumerState { resolvedSampleRate != null || needsAlbum || needsDuration || + needsTotalTracks || + needsTotalDiscs || + needsComposer || needsLabel || needsCopyright || isPlaceholderQualityLabel(_quality)) && @@ -361,6 +379,9 @@ class _TrackMetadataScreenState extends ConsumerState { if (resolvedSampleRate != null) 'sample_rate': resolvedSampleRate, if (needsAlbum) 'album': resolvedAlbum, if (needsDuration) 'duration': resolvedDuration, + if (needsTotalTracks) 'total_tracks': resolvedTotalTracks, + if (needsTotalDiscs) 'total_discs': resolvedTotalDiscs, + if (needsComposer) 'composer': resolvedComposer, if (needsLabel) 'label': resolvedLabel, if (needsCopyright) 'copyright': resolvedCopyright, }; @@ -482,6 +503,10 @@ class _TrackMetadataScreenState extends ConsumerState { : _downloadItem!.trackNumber; } + int? get totalTracks => + _readPositiveInt(_editedMetadata?['total_tracks']) ?? + (_isLocalItem ? _localLibraryItem!.totalTracks : null); + int? get discNumber { final edited = _editedMetadata?['disc_number']; if (edited != null) { @@ -493,6 +518,10 @@ class _TrackMetadataScreenState extends ConsumerState { : _downloadItem!.discNumber; } + int? get totalDiscs => + _readPositiveInt(_editedMetadata?['total_discs']) ?? + (_isLocalItem ? _localLibraryItem!.totalDiscs : null); + String? get releaseDate => _editedMetadata?['date']?.toString() ?? (_isLocalItem @@ -523,6 +552,9 @@ class _TrackMetadataScreenState extends ConsumerState { String? get copyright => _editedMetadata?['copyright']?.toString() ?? (_isLocalItem ? _localLibraryItem!.copyright : _downloadItem!.copyright); + String? get composer => + _editedMetadata?['composer']?.toString() ?? + (_isLocalItem ? _localLibraryItem!.composer : null); int? get duration => _readPositiveInt(_editedMetadata?['duration']) ?? (_isLocalItem ? _localLibraryItem!.duration : _downloadItem!.duration); @@ -1257,8 +1289,12 @@ class _TrackMetadataScreenState extends ConsumerState { _MetadataItem(context.l10n.trackAlbum, albumName), if (trackNumber != null && trackNumber! > 0) _MetadataItem(context.l10n.trackTrackNumber, trackNumber.toString()), + if (totalTracks != null && totalTracks! > 0) + _MetadataItem('Track Total', totalTracks.toString()), if (discNumber != null && discNumber! > 0) _MetadataItem(context.l10n.trackDiscNumber, discNumber.toString()), + if (totalDiscs != null && totalDiscs! > 0) + _MetadataItem('Disc Total', totalDiscs.toString()), if (duration != null) _MetadataItem(context.l10n.trackDuration, _formatDuration(duration!)), if (audioQualityStr != null) @@ -1271,6 +1307,8 @@ class _TrackMetadataScreenState extends ConsumerState { _MetadataItem(context.l10n.trackLabel, label!), if (copyright != null && copyright!.isNotEmpty) _MetadataItem(context.l10n.trackCopyright, copyright!), + if (composer != null && composer!.isNotEmpty) + _MetadataItem('Composer', composer!), if (isrc != null && isrc!.isNotEmpty) _MetadataItem('ISRC', isrc!), ]; @@ -2527,12 +2565,15 @@ class _TrackMetadataScreenState extends ConsumerState { 'album_name': albumName, 'album_artist': albumArtist ?? artistName, 'track_number': trackNumber ?? 0, + 'total_tracks': totalTracks ?? 0, 'disc_number': discNumber ?? 0, + 'total_discs': totalDiscs ?? 0, 'release_date': releaseDate ?? '', 'isrc': isrc ?? '', 'genre': genre ?? '', 'label': label ?? '', 'copyright': copyright ?? '', + 'composer': composer ?? '', 'duration_ms': durationMs, 'search_online': true, }; @@ -2550,11 +2591,14 @@ class _TrackMetadataScreenState extends ConsumerState { 'album_artist': enriched['album_artist'] ?? albumArtist, 'date': enriched['release_date'] ?? releaseDate, 'track_number': enriched['track_number'] ?? trackNumber, + 'total_tracks': enriched['total_tracks'] ?? totalTracks, 'disc_number': enriched['disc_number'] ?? discNumber, + 'total_discs': enriched['total_discs'] ?? totalDiscs, 'isrc': enriched['isrc'] ?? isrc, 'genre': enriched['genre'] ?? genre, 'label': enriched['label'] ?? label, 'copyright': enriched['copyright'] ?? copyright, + 'composer': enriched['composer'] ?? composer, }; }); } @@ -2991,19 +3035,29 @@ class _TrackMetadataScreenState extends ConsumerState { } Map _buildFallbackMetadata() { + String formatIndexTag(int number, int? total) { + if (total != null && total > 0) { + return '$number/$total'; + } + return number.toString(); + } + return { 'TITLE': trackName, 'ARTIST': artistName, 'ALBUM': albumName, if (albumArtist != null && albumArtist!.isNotEmpty) 'ALBUMARTIST': albumArtist!, - if (trackNumber != null) 'TRACKNUMBER': trackNumber.toString(), - if (discNumber != null) 'DISCNUMBER': discNumber.toString(), + if (trackNumber != null) + 'TRACKNUMBER': formatIndexTag(trackNumber!, totalTracks), + if (discNumber != null) + 'DISCNUMBER': formatIndexTag(discNumber!, totalDiscs), if (releaseDate != null && releaseDate!.isNotEmpty) 'DATE': releaseDate!, if (isrc != null && isrc!.isNotEmpty) 'ISRC': isrc!, if (genre != null && genre!.isNotEmpty) 'GENRE': genre!, if (label != null && label!.isNotEmpty) 'LABEL': label!, if (copyright != null && copyright!.isNotEmpty) 'COPYRIGHT': copyright!, + if (composer != null && composer!.isNotEmpty) 'COMPOSER': composer!, }; } @@ -3031,12 +3085,26 @@ class _TrackMetadataScreenState extends ConsumerState { put('UNSYNCEDLYRICS', source['lyrics']); final trackNumber = source['track_number']; + final totalTracks = source['total_tracks']; if (trackNumber != null && trackNumber.toString() != '0') { - put('TRACKNUMBER', trackNumber); + final trackTag = + totalTracks != null && + totalTracks.toString().isNotEmpty && + totalTracks.toString() != '0' + ? '${trackNumber.toString()}/${totalTracks.toString()}' + : trackNumber; + put('TRACKNUMBER', trackTag); } final discNumber = source['disc_number']; + final totalDiscs = source['total_discs']; if (discNumber != null && discNumber.toString() != '0') { - put('DISCNUMBER', discNumber); + final discTag = + totalDiscs != null && + totalDiscs.toString().isNotEmpty && + totalDiscs.toString() != '0' + ? '${discNumber.toString()}/${totalDiscs.toString()}' + : discNumber; + put('DISCNUMBER', discTag); } return mapped; @@ -4023,13 +4091,17 @@ class _TrackMetadataScreenState extends ConsumerState { 'date': val('date', releaseDate), 'track_number': (fileMetadata?['track_number'] ?? trackNumber ?? '') .toString(), + 'total_tracks': (fileMetadata?['total_tracks'] ?? totalTracks ?? '') + .toString(), 'disc_number': (fileMetadata?['disc_number'] ?? discNumber ?? '') .toString(), + 'total_discs': (fileMetadata?['total_discs'] ?? totalDiscs ?? '') + .toString(), 'genre': val('genre', genre), 'isrc': val('isrc', isrc), 'label': val('label', label), 'copyright': val('copyright', copyright), - 'composer': fileMetadata?['composer']?.toString() ?? '', + 'composer': val('composer', composer), 'comment': fileMetadata?['comment']?.toString() ?? '', }; @@ -4316,11 +4388,14 @@ class _EditMetadataSheetState extends State<_EditMetadataSheet> { 'album_artist': 'album_artist', 'date': 'date', 'track_number': 'track_number', + 'total_tracks': 'total_tracks', 'disc_number': 'disc_number', + 'total_discs': 'total_discs', 'genre': 'genre', 'isrc': 'isrc', 'label': 'label', 'copyright': 'copyright', + 'composer': 'composer', 'cover': 'cover', }; @@ -4330,7 +4405,9 @@ class _EditMetadataSheetState extends State<_EditMetadataSheet> { late final TextEditingController _albumArtistCtrl; late final TextEditingController _dateCtrl; late final TextEditingController _trackNumCtrl; + late final TextEditingController _trackTotalCtrl; late final TextEditingController _discNumCtrl; + late final TextEditingController _discTotalCtrl; late final TextEditingController _genreCtrl; late final TextEditingController _isrcCtrl; late final TextEditingController _labelCtrl; @@ -4518,8 +4595,12 @@ class _EditMetadataSheetState extends State<_EditMetadataSheet> { return l10n.editMetadataFieldDate; case 'track_number': return l10n.editMetadataFieldTrackNum; + case 'total_tracks': + return 'Track Total'; case 'disc_number': return l10n.editMetadataFieldDiscNum; + case 'total_discs': + return 'Disc Total'; case 'genre': return l10n.editMetadataFieldGenre; case 'isrc': @@ -4528,6 +4609,8 @@ class _EditMetadataSheetState extends State<_EditMetadataSheet> { return l10n.editMetadataFieldLabel; case 'copyright': return l10n.editMetadataFieldCopyright; + case 'composer': + return 'Composer'; case 'cover': return l10n.editMetadataFieldCover; default: @@ -4549,8 +4632,12 @@ class _EditMetadataSheetState extends State<_EditMetadataSheet> { return _dateCtrl; case 'track_number': return _trackNumCtrl; + case 'total_tracks': + return _trackTotalCtrl; case 'disc_number': return _discNumCtrl; + case 'total_discs': + return _discTotalCtrl; case 'genre': return _genreCtrl; case 'isrc': @@ -4559,6 +4646,8 @@ class _EditMetadataSheetState extends State<_EditMetadataSheet> { return _labelCtrl; case 'copyright': return _copyrightCtrl; + case 'composer': + return _composerCtrl; default: return null; } @@ -4724,11 +4813,14 @@ class _EditMetadataSheetState extends State<_EditMetadataSheet> { put('album_artist', track['album_artist']); put('date', track['release_date']); put('track_number', track['track_number']); + put('total_tracks', track['total_tracks']); put('disc_number', track['disc_number']); + put('total_discs', track['total_discs']); put('isrc', track['isrc']); put('genre', track['genre']); put('label', track['label']); put('copyright', track['copyright']); + put('composer', track['composer']); } Future<_ResolvedAutoFillTrack?> _resolveAutoFillTrackFromIdentifiers( @@ -4929,8 +5021,11 @@ class _EditMetadataSheetState extends State<_EditMetadataSheet> { 'album_artist': (selectedBest['album_artist'] ?? '').toString(), 'date': (selectedBest['release_date'] ?? '').toString(), 'track_number': (selectedBest['track_number'] ?? '').toString(), + 'total_tracks': (selectedBest['total_tracks'] ?? '').toString(), 'disc_number': (selectedBest['disc_number'] ?? '').toString(), + 'total_discs': (selectedBest['total_discs'] ?? '').toString(), 'isrc': (selectedBest['isrc'] ?? '').toString(), + 'composer': (selectedBest['composer'] ?? '').toString(), }; _mergeOnlineTrackData(enriched, selectedBest); @@ -4939,7 +5034,8 @@ class _EditMetadataSheetState extends State<_EditMetadataSheet> { final needsExtended = _autoFillFields.contains('genre') || _autoFillFields.contains('label') || - _autoFillFields.contains('copyright'); + _autoFillFields.contains('copyright') || + _autoFillFields.contains('composer'); final rawSpotifyId = _extractRawSpotifyTrackId(selectedBest); @@ -5101,7 +5197,9 @@ class _EditMetadataSheetState extends State<_EditMetadataSheet> { _albumArtistCtrl = TextEditingController(text: v['album_artist'] ?? ''); _dateCtrl = TextEditingController(text: v['date'] ?? ''); _trackNumCtrl = TextEditingController(text: v['track_number'] ?? ''); + _trackTotalCtrl = TextEditingController(text: v['total_tracks'] ?? ''); _discNumCtrl = TextEditingController(text: v['disc_number'] ?? ''); + _discTotalCtrl = TextEditingController(text: v['total_discs'] ?? ''); _genreCtrl = TextEditingController(text: v['genre'] ?? ''); _isrcCtrl = TextEditingController(text: v['isrc'] ?? ''); _labelCtrl = TextEditingController(text: v['label'] ?? ''); @@ -5121,7 +5219,9 @@ class _EditMetadataSheetState extends State<_EditMetadataSheet> { _albumArtistCtrl.dispose(); _dateCtrl.dispose(); _trackNumCtrl.dispose(); + _trackTotalCtrl.dispose(); _discNumCtrl.dispose(); + _discTotalCtrl.dispose(); _genreCtrl.dispose(); _isrcCtrl.dispose(); _labelCtrl.dispose(); @@ -5141,7 +5241,9 @@ class _EditMetadataSheetState extends State<_EditMetadataSheet> { 'album_artist': _albumArtistCtrl.text, 'date': _dateCtrl.text, 'track_number': _trackNumCtrl.text, + 'track_total': _trackTotalCtrl.text, 'disc_number': _discNumCtrl.text, + 'disc_total': _discTotalCtrl.text, 'genre': _genreCtrl.text, 'isrc': _isrcCtrl.text, 'label': _labelCtrl.text, @@ -5193,12 +5295,18 @@ class _EditMetadataSheetState extends State<_EditMetadataSheet> { 'TRACKNUMBER': (metadata['track_number']?.isNotEmpty == true && metadata['track_number'] != '0') - ? metadata['track_number']! + ? (metadata['track_total']?.isNotEmpty == true && + metadata['track_total'] != '0' + ? '${metadata['track_number']}/${metadata['track_total']}' + : metadata['track_number']!) : '', 'DISCNUMBER': (metadata['disc_number']?.isNotEmpty == true && metadata['disc_number'] != '0') - ? metadata['disc_number']! + ? (metadata['disc_total']?.isNotEmpty == true && + metadata['disc_total'] != '0' + ? '${metadata['disc_number']}/${metadata['disc_total']}' + : metadata['disc_number']!) : '', 'GENRE': metadata['genre'] ?? '', 'ISRC': metadata['isrc'] ?? '', @@ -5411,6 +5519,18 @@ class _EditMetadataSheetState extends State<_EditMetadataSheet> { ), ), const SizedBox(width: 12), + Expanded( + child: _field( + 'Track Total', + _trackTotalCtrl, + keyboard: TextInputType.number, + ), + ), + ], + ), + const SizedBox(height: 12), + Row( + children: [ Expanded( child: _field( 'Disc #', @@ -5418,6 +5538,14 @@ class _EditMetadataSheetState extends State<_EditMetadataSheet> { keyboard: TextInputType.number, ), ), + const SizedBox(width: 12), + Expanded( + child: _field( + 'Disc Total', + _discTotalCtrl, + keyboard: TextInputType.number, + ), + ), ], ), _field('Genre', _genreCtrl), diff --git a/lib/services/download_request_payload.dart b/lib/services/download_request_payload.dart index fc0e25f0..39675dfe 100644 --- a/lib/services/download_request_payload.dart +++ b/lib/services/download_request_payload.dart @@ -17,6 +17,7 @@ class DownloadRequestPayload { final int trackNumber; final int discNumber; final int totalTracks; + final int totalDiscs; final String releaseDate; final String itemId; final int durationMs; @@ -24,6 +25,7 @@ class DownloadRequestPayload { final String genre; final String label; final String copyright; + final String composer; final String tidalId; final String qobuzId; final String deezerId; @@ -56,6 +58,7 @@ class DownloadRequestPayload { this.trackNumber = 0, this.discNumber = 0, this.totalTracks = 1, + this.totalDiscs = 0, this.releaseDate = '', this.itemId = '', this.durationMs = 0, @@ -63,6 +66,7 @@ class DownloadRequestPayload { this.genre = '', this.label = '', this.copyright = '', + this.composer = '', this.tidalId = '', this.qobuzId = '', this.deezerId = '', @@ -97,6 +101,7 @@ class DownloadRequestPayload { 'track_number': trackNumber, 'disc_number': discNumber, 'total_tracks': totalTracks, + 'total_discs': totalDiscs, 'release_date': releaseDate, 'item_id': itemId, 'duration_ms': durationMs, @@ -104,6 +109,7 @@ class DownloadRequestPayload { 'genre': genre, 'label': label, 'copyright': copyright, + 'composer': composer, 'tidal_id': tidalId, 'qobuz_id': qobuzId, 'deezer_id': deezerId, @@ -142,6 +148,7 @@ class DownloadRequestPayload { trackNumber: trackNumber, discNumber: discNumber, totalTracks: totalTracks, + totalDiscs: totalDiscs, releaseDate: releaseDate, itemId: itemId, durationMs: durationMs, @@ -149,6 +156,7 @@ class DownloadRequestPayload { genre: genre, label: label, copyright: copyright, + composer: composer, tidalId: tidalId, qobuzId: qobuzId, deezerId: deezerId, diff --git a/lib/services/library_database.dart b/lib/services/library_database.dart index c09694f6..cf8ffd4a 100644 --- a/lib/services/library_database.dart +++ b/lib/services/library_database.dart @@ -20,13 +20,16 @@ class LocalLibraryItem { final int? fileModTime; final String? isrc; final int? trackNumber; + final int? totalTracks; final int? discNumber; + final int? totalDiscs; final int? duration; final String? releaseDate; final int? bitDepth; final int? sampleRate; final int? bitrate; // kbps, for lossy formats (mp3, opus, ogg) final String? genre; + final String? composer; final String? label; final String? copyright; final String? format; // flac, mp3, opus, m4a @@ -43,13 +46,16 @@ class LocalLibraryItem { this.fileModTime, this.isrc, this.trackNumber, + this.totalTracks, this.discNumber, + this.totalDiscs, this.duration, this.releaseDate, this.bitDepth, this.sampleRate, this.bitrate, this.genre, + this.composer, this.label, this.copyright, this.format, @@ -67,13 +73,16 @@ class LocalLibraryItem { 'fileModTime': fileModTime, 'isrc': isrc, 'trackNumber': trackNumber, + 'totalTracks': totalTracks, 'discNumber': discNumber, + 'totalDiscs': totalDiscs, 'duration': duration, 'releaseDate': releaseDate, 'bitDepth': bitDepth, 'sampleRate': sampleRate, 'bitrate': bitrate, 'genre': genre, + 'composer': composer, 'label': label, 'copyright': copyright, 'format': format, @@ -91,14 +100,17 @@ class LocalLibraryItem { scannedAt: DateTime.parse(json['scannedAt'] as String), fileModTime: (json['fileModTime'] as num?)?.toInt(), isrc: json['isrc'] as String?, - trackNumber: json['trackNumber'] as int?, - discNumber: json['discNumber'] as int?, - duration: json['duration'] as int?, + trackNumber: (json['trackNumber'] as num?)?.toInt(), + totalTracks: (json['totalTracks'] as num?)?.toInt(), + discNumber: (json['discNumber'] as num?)?.toInt(), + totalDiscs: (json['totalDiscs'] as num?)?.toInt(), + duration: (json['duration'] as num?)?.toInt(), releaseDate: json['releaseDate'] as String?, - bitDepth: json['bitDepth'] as int?, - sampleRate: json['sampleRate'] as int?, + bitDepth: (json['bitDepth'] as num?)?.toInt(), + sampleRate: (json['sampleRate'] as num?)?.toInt(), bitrate: (json['bitrate'] as num?)?.toInt(), genre: json['genre'] as String?, + composer: json['composer'] as String?, label: json['label'] as String?, copyright: json['copyright'] as String?, format: json['format'] as String?, @@ -130,7 +142,7 @@ class LibraryDatabase { return await openDatabase( path, - version: 5, + version: 6, onConfigure: (db) async { await db.rawQuery('PRAGMA journal_mode = WAL'); await db.execute('PRAGMA synchronous = NORMAL'); @@ -156,13 +168,16 @@ class LibraryDatabase { file_mod_time INTEGER, isrc TEXT, track_number INTEGER, + total_tracks INTEGER, disc_number INTEGER, + total_discs INTEGER, duration INTEGER, release_date TEXT, bit_depth INTEGER, sample_rate INTEGER, bitrate INTEGER, genre TEXT, + composer TEXT, label TEXT, copyright TEXT, format TEXT @@ -206,6 +221,13 @@ class LibraryDatabase { await db.execute('ALTER TABLE library ADD COLUMN copyright TEXT'); _log.i('Added label/copyright columns'); } + + if (oldVersion < 6) { + await db.execute('ALTER TABLE library ADD COLUMN total_tracks INTEGER'); + await db.execute('ALTER TABLE library ADD COLUMN total_discs INTEGER'); + await db.execute('ALTER TABLE library ADD COLUMN composer TEXT'); + _log.i('Added total_tracks/total_discs/composer columns'); + } } Map _jsonToDbRow(Map json) { @@ -221,13 +243,16 @@ class LibraryDatabase { 'file_mod_time': json['fileModTime'], 'isrc': json['isrc'], 'track_number': json['trackNumber'], + 'total_tracks': json['totalTracks'], 'disc_number': json['discNumber'], + 'total_discs': json['totalDiscs'], 'duration': json['duration'], 'release_date': json['releaseDate'], 'bit_depth': json['bitDepth'], 'sample_rate': json['sampleRate'], 'bitrate': json['bitrate'], 'genre': json['genre'], + 'composer': json['composer'], 'label': json['label'], 'copyright': json['copyright'], 'format': json['format'], @@ -247,13 +272,16 @@ class LibraryDatabase { 'fileModTime': row['file_mod_time'], 'isrc': row['isrc'], 'trackNumber': row['track_number'], + 'totalTracks': row['total_tracks'], 'discNumber': row['disc_number'], + 'totalDiscs': row['total_discs'], 'duration': row['duration'], 'releaseDate': row['release_date'], 'bitDepth': row['bit_depth'], 'sampleRate': row['sample_rate'], 'bitrate': row['bitrate'], 'genre': row['genre'], + 'composer': row['composer'], 'label': row['label'], 'copyright': row['copyright'], 'format': row['format'], diff --git a/lib/services/local_track_redownload_service.dart b/lib/services/local_track_redownload_service.dart index d03c6c8e..8a06050a 100644 --- a/lib/services/local_track_redownload_service.dart +++ b/lib/services/local_track_redownload_service.dart @@ -142,8 +142,10 @@ class LocalTrackRedownloadService { duration: (durationMs / 1000).round(), trackNumber: data['track_number'] as int?, discNumber: data['disc_number'] as int?, + totalDiscs: data['total_discs'] as int?, releaseDate: data['release_date']?.toString(), totalTracks: data['total_tracks'] as int?, + composer: data['composer']?.toString(), source: data['source']?.toString() ?? data['provider_id']?.toString(), albumType: data['album_type']?.toString(), itemType: itemType, diff --git a/lib/utils/lyrics_metadata_helper.dart b/lib/utils/lyrics_metadata_helper.dart index c20eb411..b9893934 100644 --- a/lib/utils/lyrics_metadata_helper.dart +++ b/lib/utils/lyrics_metadata_helper.dart @@ -100,12 +100,28 @@ void mergePlatformMetadataForTagEmbed({ put('UNSYNCEDLYRICS', source['lyrics']); final trackNumber = source['track_number']; + final totalTracks = source['total_tracks']; if (trackNumber != null && trackNumber.toString() != '0') { - put('TRACKNUMBER', trackNumber); + put( + 'TRACKNUMBER', + totalTracks != null && + totalTracks.toString().isNotEmpty && + totalTracks.toString() != '0' + ? '${trackNumber.toString()}/${totalTracks.toString()}' + : trackNumber, + ); } final discNumber = source['disc_number']; + final totalDiscs = source['total_discs']; if (discNumber != null && discNumber.toString() != '0') { - put('DISCNUMBER', discNumber); + put( + 'DISCNUMBER', + totalDiscs != null && + totalDiscs.toString().isNotEmpty && + totalDiscs.toString() != '0' + ? '${discNumber.toString()}/${totalDiscs.toString()}' + : discNumber, + ); } }