fix,add: TXXX adding, delete option, dictionnary, M4A support
This commit is contained in:
@@ -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));
|
||||
}
|
||||
|
||||
|
||||
@@ -40,7 +40,11 @@
|
||||
v-model="addFieldKey"
|
||||
type="text"
|
||||
:placeholder="trans('prompts.newFieldName','Field name')"
|
||||
list="tag-suggestions"
|
||||
/>
|
||||
<datalist id="tag-suggestions">
|
||||
<option v-for="s in tagSuggestions" :key="s" :value="s">{{ s }}</option>
|
||||
</datalist>
|
||||
<input
|
||||
v-model="addFieldValue"
|
||||
type="text"
|
||||
@@ -80,7 +84,9 @@
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="field in fields" :key="field" class="metadata-row">
|
||||
<td class="metadata-title"><strong>{{ fieldLabels[field] || humanize(field) }}</strong></td>
|
||||
<td class="metadata-title">
|
||||
<strong>{{ fieldLabel(field) }}</strong>
|
||||
</td>
|
||||
<td class="metadata-current">
|
||||
<template v-if="isPicture(field)">
|
||||
<img :src="pictureSrc(field)" alt="cover" class="cover-preview" v-if="pictureSrc(field)" />
|
||||
@@ -91,11 +97,22 @@
|
||||
</template>
|
||||
</td>
|
||||
<td class="metadata-edit-cell">
|
||||
<input
|
||||
v-model="newValues[field]"
|
||||
:placeholder="placeholders[field] || ''"
|
||||
type="text"
|
||||
/>
|
||||
<div class="edit-with-actions">
|
||||
<input
|
||||
v-model="newValues[field]"
|
||||
:placeholder="placeholders[field] || ''"
|
||||
type="text"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
class="button button--flat button--red icon-button"
|
||||
:aria-label="trans('buttons.remove','Remove field')"
|
||||
:title="trans('buttons.remove','Remove field')"
|
||||
@click="removeField(field)"
|
||||
>
|
||||
<i class="material-icons">delete</i>
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
@@ -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%;
|
||||
|
||||
213
frontend/src/utils/metadata.ts
Normal file
213
frontend/src/utils/metadata.ts
Normal file
@@ -0,0 +1,213 @@
|
||||
// Frontend helpers for metadata fields: canonicalization, labels, and payload normalization
|
||||
|
||||
export type Changes = Record<string, string>;
|
||||
export type MultiChanges = Record<string, string[]>; // 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<string>(Object.values(Canonical));
|
||||
|
||||
// Map various inputs (singular/plural/localized) to canonical names
|
||||
const canonicalMap: Record<string, string> = {
|
||||
// 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<string, any>): Record<string, any> {
|
||||
const out: Record<string, any> = {};
|
||||
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;
|
||||
}
|
||||
@@ -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);
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
117
http/id3_clear.go
Normal file
117
http/id3_clear.go
Normal file
@@ -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()
|
||||
}
|
||||
243
http/metadata.go
Normal file
243
http/metadata.go
Normal file
@@ -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
|
||||
}
|
||||
148
http/resource.go
148
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
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user