From dde9fd2a4abc21f46c2afc526d0d27a9b8f2c002 Mon Sep 17 00:00:00 2001 From: Snitch Team Date: Wed, 6 May 2026 10:14:41 +0200 Subject: [PATCH] feat(metadata): optional 'feat.' move from artist into title Adds opt-in setting 'moveFeaturedArtistsToTitle' (default off) that, for multi-artist tracks, keeps the lead artist in ARTIST/TPE1 and appends '(feat. ...)' to the title for both the file name and the embedded TITLE tag. Album artist is untouched. Closes #839 --- app.go | 7 +++++ backend/artist_format.go | 33 ++++++++++++++++++++++++ backend/config.go | 10 +++++++ frontend/src/components/SettingsPage.tsx | 10 +++++++ frontend/src/lib/settings.ts | 5 ++++ 5 files changed, 65 insertions(+) diff --git a/app.go b/app.go index 32c77d8f..630e5e1f 100644 --- a/app.go +++ b/app.go @@ -596,6 +596,8 @@ func (a *App) DownloadTrack(req DownloadRequest) (DownloadResponse, error) { } } + req.ArtistName, req.TrackName = backend.MoveFeaturedArtistsToTitle(req.ArtistName, req.TrackName, metadataSeparator) + if req.TrackName != "" && req.ArtistName != "" { expectedFilename := backend.BuildExpectedFilename(req.TrackName, req.ArtistName, req.AlbumName, req.AlbumArtist, req.ReleaseDate, req.FilenameFormat, req.PlaylistName, req.PlaylistOwner, req.TrackNumber, req.Position, req.SpotifyDiscNumber, req.UseAlbumTrackNumber, req.ISRC) expectedPath := filepath.Join(req.OutputDir, expectedFilename) @@ -1889,6 +1891,7 @@ func (a *App) CheckFilesExistence(outputDir string, rootDir string, tracks []Che defaultFilenameFormat := "title-artist" redownloadWithSuffix := backend.GetRedownloadWithSuffixSetting() existingFileCheckMode := backend.GetExistingFileCheckModeSetting() + existenceSeparator := backend.GetSeparator() scanRoot := outputDir if rootDir != "" { scanRoot = rootDir @@ -1923,6 +1926,10 @@ func (a *App) CheckFilesExistence(outputDir string, rootDir string, tracks []Che return } + t.ArtistName, t.TrackName = backend.MoveFeaturedArtistsToTitle(t.ArtistName, t.TrackName, existenceSeparator) + res.TrackName = t.TrackName + res.ArtistName = t.ArtistName + filenameFormat := t.FilenameFormat if filenameFormat == "" { filenameFormat = defaultFilenameFormat diff --git a/backend/artist_format.go b/backend/artist_format.go index 29c4f266..a1371cbe 100644 --- a/backend/artist_format.go +++ b/backend/artist_format.go @@ -66,6 +66,39 @@ func SplitArtistCredits(artistStr, separator string) []string { return result } +// MoveFeaturedArtistsToTitle, when the "moveFeaturedArtistsToTitle" setting is +// enabled and the track has multiple credited artists, returns the lead artist +// alone alongside a title that has " (feat. ...)" appended for the remaining +// artists. When the setting is disabled, only one artist is credited, or the +// title already advertises a feature, the values are returned unchanged +// (except that, when the setting is on, the artist is still narrowed to the +// lead). The setting is read inside this helper so call sites stay simple. +func MoveFeaturedArtistsToTitle(artist, title, separator string) (string, string) { + if !GetMoveFeaturedArtistsToTitleSetting() { + return artist, title + } + + credits := SplitArtistCredits(artist, separator) + if len(credits) <= 1 { + return artist, title + } + + main := credits[0] + feats := credits[1:] + + lowerTitle := strings.ToLower(title) + featMarkers := []string{"(feat.", "(ft.", "(featuring", "[feat.", "[ft.", "[featuring"} + for _, marker := range featMarkers { + if strings.Contains(lowerTitle, marker) { + return main, title + } + } + + suffix := " (feat. " + strings.Join(feats, " & ") + ")" + newTitle := strings.TrimRight(title, " \t") + suffix + return main, newTitle +} + func SplitMetadataValues(value, separator string) []string { rawParts := splitArtistSegment(value, separator) if len(rawParts) == 0 { diff --git a/backend/config.go b/backend/config.go index 15a9dcec..3c8c7c2b 100644 --- a/backend/config.go +++ b/backend/config.go @@ -60,6 +60,16 @@ func GetRedownloadWithSuffixSetting() bool { return enabled } +func GetMoveFeaturedArtistsToTitleSetting() bool { + settings, err := LoadConfigSettings() + if err != nil || settings == nil { + return false + } + + enabled, _ := settings["moveFeaturedArtistsToTitle"].(bool) + return enabled +} + func GetCustomTidalAPISetting() string { settings, err := LoadConfigSettings() if err != nil || settings == nil { diff --git a/frontend/src/components/SettingsPage.tsx b/frontend/src/components/SettingsPage.tsx index 96077c38..dd785a9f 100644 --- a/frontend/src/components/SettingsPage.tsx +++ b/frontend/src/components/SettingsPage.tsx @@ -763,6 +763,16 @@ export function SettingsPage({ onUnsavedChangesChange, onResetRequest, }: Settin +
+ setTempSettings((prev) => ({ + ...prev, + moveFeaturedArtistsToTitle: checked, + }))}/> + +
+ diff --git a/frontend/src/lib/settings.ts b/frontend/src/lib/settings.ts index 306b58ef..5ce37153 100644 --- a/frontend/src/lib/settings.ts +++ b/frontend/src/lib/settings.ts @@ -54,6 +54,7 @@ export interface Settings { useSingleGenre: boolean; embedGenre: boolean; redownloadWithSuffix: boolean; + moveFeaturedArtistsToTitle: boolean; separator: "comma" | "semicolon"; } export const FOLDER_PRESETS: Record