fix,add: TXXX adding, delete option, dictionnary, M4A support

This commit is contained in:
2026-01-30 09:50:55 +01:00
parent b91b7c431c
commit b59d30784f
7 changed files with 800 additions and 31 deletions

View File

@@ -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));
}

View File

@@ -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%;

View 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;
}

View File

@@ -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);
}),
};
});