diff --git a/frontend/src/api/files.ts b/frontend/src/api/files.ts index 1facfe0a..546b5c06 100644 --- a/frontend/src/api/files.ts +++ b/frontend/src/api/files.ts @@ -204,8 +204,19 @@ export async function metadata(url: string) { } } +import { normalizeChanges, normalizeMixedChanges } from "@/utils/metadata"; + export async function updateMetadata(url: string, content: any) { // Update metadata for a resource. Backend must support PATCH with action=metadata. + // Normalize outgoing changes to canonical keys so backend gets stable names, preserving arrays for multi-valued tags. + if (content && content.__clear__) { + const clear = content.__clear__; + const { __clear__, ...rest } = content; + content = normalizeMixedChanges(rest || {}); + content.__clear__ = clear; + } else { + content = normalizeMixedChanges(content || {}); + } return resourceAction(`${url}?action=metadata`, "PATCH", JSON.stringify(content)); } diff --git a/frontend/src/components/prompts/ModifyMetadata.vue b/frontend/src/components/prompts/ModifyMetadata.vue index 707423fd..c8eb917b 100644 --- a/frontend/src/components/prompts/ModifyMetadata.vue +++ b/frontend/src/components/prompts/ModifyMetadata.vue @@ -40,7 +40,11 @@ v-model="addFieldKey" type="text" :placeholder="trans('prompts.newFieldName','Field name')" + list="tag-suggestions" /> + + + - {{ fieldLabels[field] || humanize(field) }} + + {{ fieldLabel(field) }} + - +
+ + +
@@ -135,6 +152,7 @@ import { mapActions, mapState } from "pinia"; import { useFileStore } from "@/stores/file"; import { useLayoutStore } from "@/stores/layout"; import { metadata as apiMetadata, updateMetadata } from "@/api/files"; +import { fieldLabelFor, normalizeChanges, canonicalizeKeys, canonicalizeKey, navidromeTags, isMultiValuedTag, splitMultiValues, normalizeMixedChanges } from "@/utils/metadata"; export default { name: "modifyMetadata", @@ -147,10 +165,12 @@ export default { placeholders: {}, metadataList: [], newValues: {}, + toClear: new Set(), // add field UI state addFieldVisible: false, addFieldKey: "", addFieldValue: "", + tagSuggestions: navidromeTags, }; }, computed: { @@ -158,6 +178,9 @@ export default { }, methods: { ...mapActions(useLayoutStore, ["closeHovers"]), + fieldLabel(key) { + return fieldLabelFor(key); + }, async mountedFetch() { if (!this.req) return; @@ -207,13 +230,29 @@ export default { // If field doesn't exist yet, add it to the top for visibility if (!this.fields.includes(key)) { this.fields.unshift(key); - this.fieldLabels[key] = this.trans(`prompts.${key}`, this.humanize(key)); + // keep labels in English technical form + // label is computed via fieldLabel() } // Initialize newValues entry and set initial value if provided - if (!(key in this.newValues)) this.newValues[key] = ""; - if (val !== "") this.newValues[key] = val; + if (isMultiValuedTag(canonicalizeKey(key))) { + // store raw string; we'll split on apply + this.newValues[key] = val; + } else { + if (!(key in this.newValues)) this.newValues[key] = ""; + if (val !== "") this.newValues[key] = val; + } this.cancelAddField(); }, + removeField(field) { + // Remove an added or existing field from the edit list (UI only) + this.fields = this.fields.filter((f) => f !== field); + delete this.newValues[field]; + delete this.placeholders[field]; + delete this.fieldLabels[field]; + // Mark field for clearing on apply + const canon = canonicalizeKey(field); + this.toClear.add(canon); + }, displayCurrent(field) { if (!this.metadataList || this.metadataList.length === 0) return ""; @@ -297,13 +336,19 @@ export default { const changes = {}; Object.keys(this.newValues).forEach((k) => { const v = this.newValues[k]; - if (v !== undefined && v !== null && String(v).trim() !== "") { - changes[k] = v; + const canon = canonicalizeKey(k); + if (isMultiValuedTag(canon)) { + const arr = Array.isArray(v) ? v : splitMultiValues(String(v || "")); + if (arr.length > 0) changes[canon] = arr; + } else if (v !== undefined && v !== null && String(v).trim() !== "") { + changes[canon] = v; } }); - if (Object.keys(changes).length === 0) { - // nothing to do + // If no value changes but some fields were removed, proceed + const hasValueChanges = Object.keys(changes).length > 0; + const hasClears = this.toClear && this.toClear.size > 0; + if (!hasValueChanges && !hasClears) { return; } @@ -311,13 +356,16 @@ export default { try { // apply changes to each file - await Promise.all( - files.map((u) => updateMetadata(u, changes)) - ); + // Normalize keys on client before sending + const toSend = normalizeMixedChanges(changes); + // Attach clear list (canonicalized) so backend can remove tags + toSend.__clear__ = canonicalizeKeys(Array.from(this.toClear)); + await Promise.all(files.map((u) => updateMetadata(u, toSend))); // refresh listing const fileStore = useFileStore(); fileStore.reload = true; this.closeHovers(); + this.toClear = new Set(); } catch (e) { this.$showError(e); } @@ -394,6 +442,23 @@ export default { flex: 1 1 auto; } +.edit-with-actions { + display: flex; + align-items: center; + gap: 0.5rem; +} + +.icon-button { + padding: 0.25em 0.4em; + display: inline-flex; + align-items: center; + justify-content: center; +} +.icon-button .material-icons { + font-size: 18px; + line-height: 1; +} + .metadata-table-wrapper { overflow-x: auto; width: 100%; diff --git a/frontend/src/utils/metadata.ts b/frontend/src/utils/metadata.ts new file mode 100644 index 00000000..3f0a57f8 --- /dev/null +++ b/frontend/src/utils/metadata.ts @@ -0,0 +1,213 @@ +// Frontend helpers for metadata fields: canonicalization, labels, and payload normalization + +export type Changes = Record; +export type MultiChanges = Record; // for multi-valued tags + +// Canonical names used for UI display +export const Canonical = { + Title: "Title", + Artist: "Artist", + Artists: "Artists", + Album: "Album", + AlbumArtist: "AlbumArtist", + AlbumArtists: "AlbumArtists", + Composer: "Composer", + Track: "Track", + TrackNumber: "TrackNumber", + Disc: "Disc", + DiscNumber: "DiscNumber", + Genre: "Genre", + Date: "Date", + Year: "Year", + Comment: "Comment", + Lyrics: "Lyrics", +} as const; + +const allowedCanonicals = new Set(Object.values(Canonical)); + +// Map various inputs (singular/plural/localized) to canonical names +const canonicalMap: Record = { + // title + "title": Canonical.Title, + "titre": Canonical.Title, + "song": Canonical.Title, + + // artist + "artist": Canonical.Artist, + "artiste": Canonical.Artist, + // explicit multi-valued + "artists": Canonical.Artists, + + // album + "album": Canonical.Album, + + // album artist + "albumartist": Canonical.AlbumArtist, + "album artist": Canonical.AlbumArtist, + "album_artist": Canonical.AlbumArtist, + "artistesdelalbum": Canonical.AlbumArtist, + "artistealbum": Canonical.AlbumArtist, + // explicit multi-valued + "albumartists": Canonical.AlbumArtists, + + // composer + "composer": Canonical.Composer, + "auteur": Canonical.Composer, + + // track + "track": Canonical.Track, + "tracknumber": Canonical.Track, + "trackno": Canonical.Track, + "piste": Canonical.Track, + "track number": Canonical.TrackNumber, + "track_number": Canonical.TrackNumber, + + // disc + "disc": Canonical.Disc, + "discnumber": Canonical.Disc, + "disque": Canonical.Disc, + "disc number": Canonical.DiscNumber, + "disc_number": Canonical.DiscNumber, + + // genre + "genre": Canonical.Genre, + + // date/year + "date": Canonical.Date, + "year": Canonical.Year, + "annee": Canonical.Year, + "année": Canonical.Year, + + // comment + "comment": Canonical.Comment, + "commentaire": Canonical.Comment, + + // lyrics + "lyrics": Canonical.Lyrics, +}; + +function normalizeToken(key: string): string { + return key + .trim() + .toLowerCase() + .replace(/[_-]+/g, " ") + .replace(/\s+/g, " "); +} + +// Canonicalize a user-entered key (or backend key) to our display canonical +export function canonicalizeKey(key: string): string { + const token = normalizeToken(key); + const mapped = canonicalMap[token]; + if (mapped) return mapped; + // If user already provided a canonical we accept, keep it + if (allowedCanonicals.has(key)) return key; + return key; // unknown: keep as-is +} + +// Given a backend key like "albumartist" or "title", return English display label +export function fieldLabelFor(key: string): string { + const canon = canonicalizeKey(key); + // ensure nice spacing for camel case labels + if (allowedCanonicals.has(canon)) return canon; + // fallback: humanize + return humanize(key); +} + +export function humanize(str: string): string { + if (!str) return str; + const s = String(str) + .replace(/([a-z0-9])([A-Z])/g, "$1 $2") + .replace(/[_\.\-]+/g, " "); + return s.charAt(0).toUpperCase() + s.slice(1); +} + +// Normalize outgoing payload so backend gets canonical/expected keys +export function normalizeChanges(changes: Changes): Changes { + const out: Changes = {}; + for (const [k, v] of Object.entries(changes)) { + const val = String(v ?? "").trim(); + if (!val) continue; + const canon = canonicalizeKey(k); + if (allowedCanonicals.has(canon)) { + out[canon] = val; + } else { + // keep unknowns but trimmed; backend will filter if unsupported + out[k] = val; + } + } + return out; +} + +export function canonicalizeKeys(keys: string[]): string[] { + const out: string[] = []; + for (const k of keys) { + const canon = canonicalizeKey(k); + if (allowedCanonicals.has(canon)) out.push(canon); + } + return out; +} + +// Recommended Navidrome-sensitive tags for Add Field suggestions +export const navidromeTags: string[] = [ + Canonical.Title, + Canonical.Artist, + Canonical.Artists, + Canonical.Album, + Canonical.AlbumArtist, + Canonical.AlbumArtists, + Canonical.Track, + Canonical.TrackNumber, + Canonical.Disc, + Canonical.DiscNumber, + Canonical.Genre, + Canonical.Date, + Canonical.Year, + Canonical.Composer, + Canonical.Lyrics, + // common MusicBrainz IDs (Vorbis comments) + "MUSICBRAINZ_ARTISTID", + "MUSICBRAINZ_ALBUMID", + "MUSICBRAINZ_TRACKID", + "MUSICBRAINZ_RELEASEGROUPID", +]; + +export function isMultiValuedTag(canon: string): boolean { + return canon === Canonical.Artists || canon === Canonical.AlbumArtists; +} + +// Split a user-entered multi-valued string into individual entries using +// safe separators. Avoid splitting on plain '/' to not break names like AC/DC; +// prefer ' / ' with spaces, ';', and ' feat. '. +export function splitMultiValues(input: string): string[] { + let s = (input || "").trim(); + if (!s) return []; + const parts: string[] = []; + const separators = [/\s;\s?/, /\s\/\s/, /\sfeat\.\s/i]; + // Normalize separators to a unified delimiter + for (const sep of separators) { + s = s.replace(sep, "|||"); + } + s.split("|||").forEach((p) => { + const v = p.trim(); + if (v) parts.push(v); + }); + return parts; +} + +// Normalize mixed changes (strings + arrays) for multi-valued tags. +export function normalizeMixedChanges(changes: Record): Record { + const out: Record = {}; + for (const [k, v] of Object.entries(changes)) { + const canon = canonicalizeKey(k); + if (Array.isArray(v)) { + const values = v.map((x) => String(x || "").trim()).filter(Boolean); + if (values.length === 0) continue; + out[canon] = values; + continue; + } + const val = String(v ?? "").trim(); + if (!val) continue; + out[canon] = val; + } + return out; +} diff --git a/frontend/src/views/files/FileListing.vue b/frontend/src/views/files/FileListing.vue index 7125e518..dfd2f94e 100644 --- a/frontend/src/views/files/FileListing.vue +++ b/frontend/src/views/files/FileListing.vue @@ -502,7 +502,7 @@ const headerButtons = computed(() => { const name = fileStore.req!.items[i].name || ""; const idx = name.lastIndexOf("."); const ext = idx === -1 ? "" : name.substring(idx).toLowerCase(); - return [".mp3", ".flac"].includes(ext); + return [".mp3", ".flac", ".m4a", ".m4b", ".mp4"].includes(ext); }), }; }); diff --git a/http/id3_clear.go b/http/id3_clear.go new file mode 100644 index 00000000..f4e660d8 --- /dev/null +++ b/http/id3_clear.go @@ -0,0 +1,117 @@ +package fbhttp + +import ( + "strings" + "unicode" + id3v2 "github.com/bogem/id3v2/v2" +) + +// clearID3v2Frames removes specific ID3v2 frames from an MP3 file. +// frames: list of standard frame IDs (e.g., TIT2, TPE1, TPE2, TRCK, TPOS, TCON, TDRC, COMM, USLT) +// txxxDescs: list of UserDefinedText (TXXX) descriptions to remove (e.g., "Artists", "AlbumArtists"). +func clearID3v2Frames(path string, frames []string, txxxDescs []string, removeNumericEmpty bool) error { + tag, err := id3v2.Open(path, id3v2.Options{Parse: true}) + if err != nil { + return err + } + defer tag.Close() + + // Delete standard frames + for _, id := range frames { + tag.DeleteFrames(id) + } + + // Delete matching TXXX frames by description (preserve others) + if len(txxxDescs) > 0 { + fs := tag.GetFrames("TXXX") + keep := []id3v2.UserDefinedTextFrame{} + for _, f := range fs { + udf, ok := f.(id3v2.UserDefinedTextFrame) + if !ok { + continue + } + remove := false + for _, d := range txxxDescs { + if strings.EqualFold(udf.Description, d) { + remove = true + break + } + } + if !remove && removeNumericEmpty { + desc := strings.TrimSpace(udf.Description) + if desc == "" { + remove = true + } else { + // numeric-only descriptions like "0", "1", "2" + allDigits := true + for _, r := range desc { + if !unicode.IsDigit(r) { + allDigits = false + break + } + } + if allDigits { + remove = true + } + } + } + if !remove { + keep = append(keep, udf) + } + } + // Remove all TXXX frames, then re-add the ones we keep + tag.DeleteFrames("TXXX") + for _, udf := range keep { + tag.AddFrame("TXXX", udf) + } + } + + return tag.Save() +} + +// cleanupNumericEmptyTXXX removes any TXXX frames whose descriptions are empty +// or purely numeric (e.g., "0", "1", "2"…), which are common artifacts. +func cleanupNumericEmptyTXXX(path string) error { + tag, err := id3v2.Open(path, id3v2.Options{Parse: true}) + if err != nil { + return err + } + defer tag.Close() + + fs := tag.GetFrames("TXXX") + keep := []id3v2.UserDefinedTextFrame{} + for _, f := range fs { + udf, ok := f.(id3v2.UserDefinedTextFrame) + if !ok { + continue + } + desc := strings.TrimSpace(udf.Description) + remove := false + if desc == "" { + remove = true + } else { + allDigits := true + for _, r := range desc { + if !unicode.IsDigit(r) { + allDigits = false + break + } + } + if allDigits { + remove = true + } + } + if !remove { + keep = append(keep, udf) + } + } + // If nothing to change, return early + if len(keep) == len(fs) { + return nil + } + tag.DeleteFrames("TXXX") + for _, udf := range keep { + tag.AddFrame("TXXX", udf) + } + return tag.Save() +} diff --git a/http/metadata.go b/http/metadata.go new file mode 100644 index 00000000..b2628e62 --- /dev/null +++ b/http/metadata.go @@ -0,0 +1,243 @@ +package fbhttp + +import ( + "strings" +) + +// canonicalMap maps various user-entered or localized keys to canonical tag names. +// Canonical names are English, PascalCase where appropriate (e.g., AlbumArtist). +var canonicalMap = map[string]string{ + // title + "title": "Title", + "titre": "Title", + "song": "Title", + + // artist (main) + "artist": "Artist", + "artiste": "Artist", + "artists": "Artist", + + // album + "album": "Album", + + // album artist + "albumartist": "AlbumArtist", + "album artist": "AlbumArtist", + "album_artist": "AlbumArtist", + "artistesdelalbum": "AlbumArtist", + "artistealbum": "AlbumArtist", + + // composer / author + "composer": "Composer", + "auteur": "Composer", + + // track + "track": "Track", + "tracknumber": "Track", + "trackno": "Track", + "piste": "Track", + + // disc + "disc": "Disc", + "discnumber": "Disc", + "disque": "Disc", + + // genre + "genre": "Genre", + + // year/date + "year": "Year", + "annee": "Year", + "année": "Year", + "date": "Date", + + // comment + "comment": "Comment", + "commentaire": "Comment", + + // lyrics + "lyrics": "Lyrics", +} + +// ffmpegKey maps canonical names to ffmpeg metadata keys. +// FFmpeg expects lower-case keys; AlbumArtist becomes album_artist, Year maps to date. +var ffmpegKey = map[string]string{ + "Title": "title", + "Artist": "artist", + "Artists": "artist", + "Album": "album", + "AlbumArtist": "album_artist", + "AlbumArtists": "album_artist", + "Composer": "composer", + "Track": "track", + "TrackNumber": "track", + "Disc": "disc", + "DiscNumber": "disc", + "Genre": "genre", + "Date": "date", + "Year": "date", // prefer date for ffmpeg + "Comment": "comment", + "Lyrics": "lyrics", +} + +// allowedCanonicals is the set of canonical tags we accept for writes. +var allowedCanonicals = func() map[string]struct{} { + m := make(map[string]struct{}, len(ffmpegKey)) + for k := range ffmpegKey { + m[k] = struct{}{} + } + return m +}() + +// normalizeKey reduces a user-provided key to a lookup token: lowercased, trimmed +// and with common separators removed. +func normalizeKey(s string) string { + s = strings.TrimSpace(s) + s = strings.ToLower(s) + // normalize separators so that "Album Artist", "album-artist", "album_artist" match + s = strings.ReplaceAll(s, "_", " ") + s = strings.ReplaceAll(s, "-", " ") + s = strings.Join(strings.Fields(s), " ") // collapse multiple spaces + return s +} + +// normalizeAndMapToFFmpeg takes arbitrary incoming tags, applies key normalization +// and synonym mapping, filters to allowed canonical keys, and returns a map of +// ffmpeg-ready metadata keys with their values (empty values are dropped). +func normalizeAndMapToFFmpeg(in map[string]string) map[string]string { + out := map[string]string{} + for k, v := range in { + if strings.TrimSpace(v) == "" { + continue + } + token := normalizeKey(k) + canonical, ok := canonicalMap[token] + if !ok { + // If user already provided a canonical name, accept it + // (e.g., Title, Artist, AlbumArtist) + c2 := strings.TrimSpace(k) + if _, allowed := allowedCanonicals[c2]; allowed { + canonical = c2 + } else { + // unrecognized key: skip + continue + } + } + ffk, ok := ffmpegKey[canonical] + if !ok { + continue + } + out[ffk] = v + } + return out +} +// normalizeMultiToFFmpeg maps multi-valued canonical keys to ffmpeg keys, preserving arrays. +func normalizeMultiToFFmpeg(in map[string][]string) map[string][]string { + out := map[string][]string{} + for k, arr := range in { + token := normalizeKey(k) + canonical, ok := canonicalMap[token] + if !ok { + // if already canonical, keep as-is + if _, allowed := allowedCanonicals[k]; allowed { + canonical = k + } else { + continue + } + } + ffk, ok := ffmpegKey[canonical] + if !ok { + continue + } + vals := []string{} + for _, v := range arr { + v = strings.TrimSpace(v) + if v == "" { + continue + } + vals = append(vals, v) + } + if len(vals) > 0 { + out[ffk] = vals + } + } + return out +} + +// mapClearCanonicalsToFFmpeg converts a list of canonical names to ffmpeg keys +// intended to be cleared by setting them to empty value. +func mapClearCanonicalsToFFmpeg(keys []string) map[string]struct{} { + out := map[string]struct{}{} + for _, k := range keys { + token := normalizeKey(k) + canonical, ok := canonicalMap[token] + if !ok { + // if already canonical and allowed, keep + if _, allowed := allowedCanonicals[k]; allowed { + canonical = k + } else { + continue + } + } + if ffk, ok := ffmpegKey[canonical]; ok { + out[ffk] = struct{}{} + } + } + return out +} + +// mapClearsToID3Frames converts canonical clear keys to ID3v2 frame IDs +func mapClearsToID3Frames(keys []string) []string { + out := []string{} + for _, k := range keys { + token := normalizeKey(k) + canonical, ok := canonicalMap[token] + if !ok { + if _, allowed := allowedCanonicals[k]; allowed { + canonical = k + } else { + continue + } + } + switch canonical { + case "Title": + out = append(out, "TIT2") + case "Artist", "Artists": + out = append(out, "TPE1") + case "Album": + out = append(out, "TALB") + case "AlbumArtist", "AlbumArtists": + out = append(out, "TPE2") + case "Composer": + out = append(out, "TCOM") + case "Track", "TrackNumber": + out = append(out, "TRCK") + case "Disc", "DiscNumber": + out = append(out, "TPOS") + case "Genre": + out = append(out, "TCON") + case "Date", "Year": + // Prefer TDRC; TYER used historically + out = append(out, "TDRC", "TYER") + case "Comment": + out = append(out, "COMM") + case "Lyrics": + out = append(out, "USLT") + } + } + return out +} + +func mapClearsToTXXX(keys []string) []string { + out := []string{} + for _, k := range keys { + token := normalizeKey(k) + switch token { + case "artists": + out = append(out, "Artists", "ARTISTS") + case "albumartists": + out = append(out, "AlbumArtists", "ALBUMARTISTS") + } + } + return out +} diff --git a/http/resource.go b/http/resource.go index 2d47dc07..98944377 100644 --- a/http/resource.go +++ b/http/resource.go @@ -245,16 +245,61 @@ func resourcePatchHandler(fileCache FileCache) handleFunc { } var tags map[string]string + multi := map[string][]string{} + var clear []string body, err := io.ReadAll(r.Body) if err != nil { return http.StatusBadRequest, err } - if err := json.Unmarshal(body, &tags); err != nil { - return http.StatusBadRequest, err + // Accept both plain tag maps and objects with __clear__ array + var raw map[string]any + if err := json.Unmarshal(body, &raw); err == nil && raw != nil { + // extract string-valued entries into tags + tags = map[string]string{} + for k, v := range raw { + if k == "__clear__" { + // parse array of strings + if arr, ok := v.([]any); ok { + for _, itm := range arr { + if s, ok := itm.(string); ok { + s = strings.TrimSpace(s) + if s != "" { + clear = append(clear, s) + } + } + } + } + continue + } + if sv, ok := v.(string); ok { + tags[k] = sv + continue + } + if arr, ok := v.([]any); ok { + vals := []string{} + for _, itm := range arr { + if s, ok := itm.(string); ok { + s = strings.TrimSpace(s) + if s != "" { + vals = append(vals, s) + } + } + } + if len(vals) > 0 { + multi[k] = vals + } + } + } + } else { + // fallback to simple map + tags = map[string]string{} + if err := json.Unmarshal(body, &tags); err != nil { + return http.StatusBadRequest, err + } } err = d.RunHook(func() error { - return applyMetadataWithFFmpeg(r.Context(), d, src, tags) + return applyMetadataWithFFmpeg(r.Context(), d, src, tags, multi, clear) }, action, src, dst, d.user) return errToStatus(err), err @@ -271,7 +316,7 @@ func resourcePatchHandler(fileCache FileCache) handleFunc { // applyMetadataWithFFmpeg attempts to write metadata using ffmpeg by creating // a temporary file and replacing the original. This requires that the // underlying filesystem exposes a real path (see FileInfo.RealPath()). -func applyMetadataWithFFmpeg(ctx context.Context, d *data, src string, tags map[string]string) error { +func applyMetadataWithFFmpeg(ctx context.Context, d *data, src string, tags map[string]string, multi map[string][]string, clear []string) error { fi, err := files.NewFileInfo(&files.FileOptions{ Fs: d.user.Fs, Path: src, @@ -313,18 +358,90 @@ func applyMetadataWithFFmpeg(ctx context.Context, d *data, src string, tags map[ // provided non-empty fields. We explicitly map input global metadata (0) // to output, and avoid `-map_metadata -1` which would clear everything. args := []string{"-y", "-i", real, "-map_metadata", "0", "-c", "copy"} - changes := 0 - for k, v := range tags { - if strings.TrimSpace(v) == "" { - continue - } - args = append(args, "-metadata", fmt.Sprintf("%s=%s", k, v)) - changes++ + // For MP3, prefer ID3v2.4 to support multi-valued tags properly + isMP3 := strings.EqualFold(ext, ".mp3") + // Treat M4A/MP4 similarly for metadata handling specifics + isMP4 := strings.EqualFold(ext, ".m4a") || strings.EqualFold(ext, ".m4b") || strings.EqualFold(ext, ".mp4") + if isMP3 { + args = append(args, "-id3v2_version", "4") } - // If no non-empty changes were requested, do nothing + // If MP3 and there are clears, perform ID3v2 in-place removal first. + // This avoids ffmpeg creating unwanted empty entries (e.g., TXXX artifacts). + didID3Clear := false + if isMP3 && len(clear) > 0 { + frames := mapClearsToID3Frames(clear) + txxx := mapClearsToTXXX(clear) + removeNumeric := false + for _, c := range clear { + tok := normalizeKey(c) + if tok == "artists" || tok == "albumartists" { + removeNumeric = true + break + } + } + if err := clearID3v2Frames(real, frames, txxx, removeNumeric); err == nil { + didID3Clear = true + } + // If ID3 clear failed, we'll still try ffmpeg below; but we won't add ffmpeg clears for MP3. + } + + // Normalize incoming keys and map to ffmpeg keys + norm := normalizeAndMapToFFmpeg(tags) + changes := 0 + setKeys := map[string]struct{}{} + for k, v := range norm { + args = append(args, "-metadata", fmt.Sprintf("%s=%s", k, v)) + changes++ + setKeys[k] = struct{}{} + } + // Handle multi-valued tags (e.g., Artists, AlbumArtists) by adding repeated metadata entries + if len(multi) > 0 { + mm := normalizeMultiToFFmpeg(multi) + for ffk, vals := range mm { + // MP4/M4A generally expects a single value for artist/album_artist. + // Join multiple values for better compatibility with tag readers. + if isMP4 && (ffk == "artist" || ffk == "album_artist") { + if len(vals) > 0 { + joined := strings.Join(vals, "; ") + args = append(args, "-metadata", fmt.Sprintf("%s=%s", ffk, joined)) + changes++ + setKeys[ffk] = struct{}{} + } + continue + } + for _, v := range vals { + args = append(args, "-metadata", fmt.Sprintf("%s=%s", ffk, v)) + changes++ + } + setKeys[ffk] = struct{}{} + } + } + // Map cleared canonical keys to ffmpeg keys and set to empty value. + // For MP3, we skip ffmpeg-based clears because ID3v2 in-place removal is preferred. + if len(clear) > 0 && !isMP3 { + for ffk := range mapClearCanonicalsToFFmpeg(clear) { + // Avoid clearing a key we are explicitly setting in this operation, + // which could result in an empty extra value (e.g., trailing NUL). + if _, isSet := setKeys[ffk]; isSet { + continue + } + args = append(args, "-metadata", fmt.Sprintf("%s=", ffk)) + changes++ + } + } + + // If no ffmpeg changes are needed (e.g., only clears on MP3), we can return. if changes == 0 { - return nil + // If we performed ID3 clear or there was simply nothing to change, exit early. + if didID3Clear || len(clear) == 0 { + if isMP3 { + // Best-effort cleanup of numeric/empty TXXX artifacts + _ = cleanupNumericEmptyTXXX(real) + } + return nil + } + // Otherwise continue and let ffmpeg run (non-MP3 clears already added above). } args = append(args, tmp) @@ -339,7 +456,10 @@ func applyMetadataWithFFmpeg(ctx context.Context, d *data, src string, tags map[ if err := os.Rename(tmp, real); err != nil { return err } - + if isMP3 { + // Post-write cleanup for numeric/empty TXXX artifacts + _ = cleanupNumericEmptyTXXX(real) + } return nil }