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);
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user