Add native MP3 metadata writer and prefer it over ffmpeg for .mp3 files

This commit is contained in:
vcadoux
2026-01-27 15:37:27 +01:00
parent 69b54f1ae8
commit a58f930d58
14 changed files with 681 additions and 74 deletions

View File

@@ -192,7 +192,13 @@ export async function metadata(url: string) {
// Fetch metadata for a resource. Backend support is required for this to work.
const res = await resourceAction(`${url}?metadata=1`, "GET");
try {
return await res.json();
const data = await res.json();
// backend returns a FileInfo object; prefer returning the audio tags map when present
if (data && typeof data === "object" && data.tags) {
return data.tags;
}
// fallback: if server returned just the tags map or other structure
return data;
} catch (e) {
return null;
}

View File

@@ -14,22 +14,42 @@
</template>
<template v-else>
<div v-for="field in fields" :key="field" class="metadata-field">
<p class="metadata-title"><strong>{{ fieldLabels[field] || field }}</strong></p>
<p class="metadata-current">{{ displayCurrent(field) }}</p>
<div class="metadata-edit">
<input
v-model="newValues[field]"
:placeholder="placeholders[field] || ''"
type="text"
/>
<button
class="button button--flat"
@click.prevent="applyField(field)"
>
{{ $t('buttons.apply') }}
</button>
</div>
<div class="metadata-table-wrapper">
<table class="metadata-table">
<colgroup>
<col class="col-label" />
<col class="col-current" />
<col class="col-new" />
</colgroup>
<thead>
<tr>
<th class="hdr-field">{{ trans('prompts.field','Field') }}</th>
<th class="hdr-current">{{ trans('prompts.current','Current') }}</th>
<th class="hdr-new">{{ trans('prompts.new','New') }}</th>
</tr>
</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-current">
<template v-if="isPicture(field)">
<img :src="pictureSrc(field)" alt="cover" class="cover-preview" v-if="pictureSrc(field)" />
<span v-else class="metadata-muted">{{ trans('prompts.noCover','No cover') }}</span>
</template>
<template v-else>
{{ displayCurrent(field) }}
</template>
</td>
<td class="metadata-edit-cell">
<input
v-model="newValues[field]"
:placeholder="placeholders[field] || ''"
type="text"
/>
</td>
</tr>
</tbody>
</table>
</div>
</template>
</div>
@@ -37,13 +57,24 @@
<div class="card-action">
<button
id="focus-prompt"
type="submit"
type="button"
@click="closeHovers"
class="button button--flat"
:aria-label="$t('buttons.ok')"
:title="$t('buttons.ok')"
:aria-label="trans('buttons.ok','OK')"
:title="trans('buttons.ok','OK')"
>
{{ $t('buttons.ok') }}
{{ trans('buttons.ok','OK') }}
</button>
<button
id="apply-metadata"
type="button"
@click.prevent="applyAll"
class="button button--primary"
:aria-label="trans('buttons.apply','Apply')"
:title="trans('buttons.apply','Apply')"
>
{{ trans('buttons.apply','Apply') }}
</button>
</div>
</div>
@@ -58,32 +89,22 @@ import { metadata as apiMetadata, updateMetadata } from "@/api/files";
export default {
name: "modifyMetadata",
inject: ["$showError"],
data() {
return {
fields: ["title", "artist", "album", "track", "genre", "date", "comment"],
fieldLabels: {
title: this.$t ? this.$t('prompts.title') : 'title',
artist: this.$t ? this.$t('prompts.artist') : 'artist',
album: this.$t ? this.$t('prompts.album') : 'album',
track: this.$t ? this.$t('prompts.track') : 'track',
genre: this.$t ? this.$t('prompts.genre') : 'genre',
date: this.$t ? this.$t('prompts.date') : 'date',
comment: this.$t ? this.$t('prompts.comment') : 'comment',
},
placeholders: {},
metadataList: [],
newValues: {},
};
},
data() {
return {
// fields will be derived dynamically from metadata across selected files
fields: [],
fieldLabels: {},
placeholders: {},
metadataList: [],
newValues: {},
};
},
computed: {
...mapState(useFileStore, ["req", "selected", "selectedCount"]),
},
methods: {
...mapActions(useLayoutStore, ["closeHovers"]),
async mountedFetch() {
// Initialize newValues
this.fields.forEach((f) => (this.newValues[f] = ""));
if (!this.req) return;
const files = this.selected.map((i) => this.req.items[i].url);
@@ -93,29 +114,127 @@ export default {
const promises = files.map((u) => apiMetadata(u));
this.metadataList = (await Promise.all(promises)).map((m) => m || {});
} catch (e) {
// If fetching metadata fails, just keep empty list
this.metadataList = [];
}
// Build union of fields
const keys = new Set();
this.metadataList.forEach((m) => {
Object.keys(m).forEach((k) => keys.add(k));
});
// Ensure stable ordering: put common fields first
const preferred = ["title", "artist", "album", "track", "genre", "date", "comment", "disc", "composer", "year", "albumartist", "picture", "cover"];
const rest = Array.from(keys).filter((k) => !preferred.includes(k)).sort();
this.fields = preferred.filter((k) => keys.has(k)).concat(rest);
// Setup field labels (use translation when available, otherwise humanize)
this.fields.forEach((f) => {
this.fieldLabels[f] = this.trans(`prompts.${f}`, this.humanize(f));
this.newValues[f] = "";
});
},
displayCurrent(field) {
if (!this.metadataList || this.metadataList.length === 0) return "";
const vals = this.metadataList.map((m) => m[field] ?? "");
const allEqual = vals.every((v) => v === vals[0]);
if (allEqual) return vals[0];
return this.$t ? this.$t('prompts.multipleValues') : '(multiple values)';
const vals = this.metadataList.map((m) => {
const v = m[field];
if (v === undefined || v === null) return "";
if (typeof v === "object") return JSON.stringify(v);
return String(v);
});
const nonEmpty = vals.filter((v) => v !== "");
if (nonEmpty.length === 0) return "";
const allEqual = nonEmpty.every((v) => v === nonEmpty[0]);
if (allEqual) return nonEmpty[0];
// Count how many distinct values
const distinct = new Set(nonEmpty);
// If fields are objects (e.g., picture) avoid returning huge JSON blobs.
// Prefer a short summary.
const sample = nonEmpty[0];
if (typeof sample === 'string') {
return this.trans('prompts.multipleValuesCount', `(different on ${distinct.size} files)`).replace('{count}', String(distinct.size));
}
try {
const s = JSON.stringify(sample);
return s.length > 200 ? s.slice(0, 200) + '…' : s;
} catch (e) {
return this.trans('prompts.multipleValuesCount', `(different on ${distinct.size} files)`).replace('{count}', String(distinct.size));
}
},
async applyField(field) {
isPicture(field) {
if (!this.metadataList || this.metadataList.length === 0) return false;
for (const m of this.metadataList) {
const v = m[field];
if (v && typeof v === 'object') {
if (v.data && typeof v.data === 'string') return true;
if (v.picture && v.picture.data) return true;
}
}
return false;
},
pictureSrc(field) {
if (!this.metadataList) return null;
for (const m of this.metadataList) {
const v = m[field];
if (v && typeof v === 'object') {
if (v.data && typeof v.data === 'string') return v.data;
if (v.picture && v.picture.data) return v.picture.data;
// sometimes library stores a raw buffer as base64 directly in 'data'
}
}
return null;
},
trans(key, fallback) {
// Fallback to a human readable string if translation key is missing
try {
if (this.$te && this.$te(key)) return this.$t(key);
} catch (e) {
// ignore
}
return fallback;
},
humanize(str) {
if (!str) return str;
// replace camelCase / snake_case / dots with spaces and capitalize
const s = String(str)
.replace(/([a-z0-9])([A-Z])/g, "$1 $2")
.replace(/[_\.\-]+/g, " ");
return s.charAt(0).toUpperCase() + s.slice(1);
},
async applyAll() {
if (!this.req) return;
const value = this.newValues[field];
if (value === undefined || value === null || value === "") return;
// collect changed fields
const changes = {};
Object.keys(this.newValues).forEach((k) => {
const v = this.newValues[k];
if (v !== undefined && v !== null && String(v).trim() !== "") {
changes[k] = v;
}
});
if (Object.keys(changes).length === 0) {
// nothing to do
return;
}
const files = this.selected.map((i) => this.req.items[i].url);
try {
await Promise.all(files.map((u) => updateMetadata(u, { [field]: value })));
// trigger a refresh of listing
// apply changes to each file
await Promise.all(
files.map((u) => updateMetadata(u, changes))
);
// refresh listing
const fileStore = useFileStore();
fileStore.reload = true;
this.closeHovers();
@@ -123,6 +242,7 @@ export default {
this.$showError(e);
}
},
// per-field apply removed in favor of global applyAll
},
mounted() {
this.mountedFetch();
@@ -131,6 +251,26 @@ export default {
</script>
<style scoped>
.metadata-card {
max-height: calc(100vh - 120px);
display: flex;
flex-direction: column;
box-sizing: border-box;
/* prefer a wider layout on large screens but stay responsive */
/* increase target width to give more horizontal space */
width: min(1400px, 98vw);
max-width: 98vw;
min-width: 680px;
}
/* Override global small max-width for floating cards for this metadata modal */
.card.floating.metadata-card {
/* remove the global max-width:25em constraint */
max-width: none !important;
width: min(1400px, 98vw) !important;
left: 50% !important;
transform: translate(-50%, -50%) !important;
}
.metadata-field {
margin-bottom: 1rem;
}
@@ -148,4 +288,117 @@ export default {
.metadata-edit input {
flex: 1 1 auto;
}
.metadata-table-wrapper {
overflow-x: auto;
width: 100%;
padding: 0 1rem; /* add horizontal padding so left column isn't flush to modal edge */
}
.metadata-table {
border-collapse: collapse;
width: auto; /* allow table to size to content */
/* allow a comfortable minimum width for readable columns on very large screens
but let it shrink responsively in smaller viewports */
min-width: 0;
max-width: 100%;
table-layout: auto;
}
.metadata-table td, .metadata-table th { word-break: break-word; }
.metadata-table th,
.metadata-table td {
border: 1px solid rgba(0,0,0,0.08);
padding: 0.5rem;
text-align: left;
vertical-align: top;
}
/* extra left padding for the first column so labels don't touch the modal edge */
.metadata-table td:first-child,
.metadata-table th:first-child,
.metadata-title {
padding-left: 1rem;
}
/* Use CSS Grid per row so columns adapt to content without one taking all space */
.metadata-table thead tr,
.metadata-table tbody tr {
display: grid;
grid-template-columns: minmax(120px, max-content) minmax(200px, 1fr) minmax(200px, 1fr);
gap: 1rem;
align-items: start;
}
.metadata-table thead tr {
border-bottom: none;
}
.metadata-table thead th,
.metadata-table tbody td {
display: block; /* make cells behave as grid items */
}
.metadata-table thead th { font-weight: 600; }
/* column sizing helpers */
.metadata-table .col-label { width: 1%; }
.metadata-table .col-current { width: auto; }
.metadata-table .col-new { width: auto; }
.metadata-edit-cell input {
width: 100%;
box-sizing: border-box;
}
/* On small screens, stack rows to improve readability */
@media (max-width: 640px) {
.metadata-table {
min-width: 0;
}
.metadata-table thead {
display: none;
}
.metadata-table tr {
display: block;
margin-bottom: 0.75rem;
border: 1px solid rgba(0,0,0,0.06);
padding: 0.5rem;
}
.metadata-table td {
display: block;
width: 100%;
padding: 0.25rem 0;
}
.metadata-title {
font-weight: 600;
}
}
/* On medium screens allow a comfortable min width; on very large screens keep roomy layout */
@media (min-width: 1200px) {
.metadata-table {
min-width: 100ch;
}
}
/* Make sure table cells wrap content and images don't overflow */
.metadata-table td, .metadata-table th {
overflow-wrap: anywhere;
word-break: break-word;
hyphens: auto;
}
.metadata-table img {
max-width: 100%;
height: auto;
display: block;
}
.cover-preview {
max-width: 160px;
max-height: 160px;
object-fit: contain;
border-radius: 4px;
box-shadow: 0 1px 3px rgba(0,0,0,0.12);
}
.metadata-muted { color: var(--color-muted, #666); font-size: 0.9em; }
.metadata-current-row td {
color: var(--color-muted, #666);
}
.metadata-edit-row td {
background: rgba(0,0,0,0.02);
}
</style>

View File

@@ -22,6 +22,7 @@
"new": "New",
"next": "Next",
"ok": "OK",
"apply": "Apply",
"permalink": "Get Permanent Link",
"previous": "Previous",
"preview": "Preview",
@@ -159,7 +160,18 @@
"uploadMessage": "Select an option to upload.",
"optionalPassword": "Optional password",
"resolution": "Resolution",
"discardEditorChanges": "Are you sure you wish to discard the changes you've made?"
"discardEditorChanges": "Are you sure you wish to discard the changes you've made?",
"field": "Field",
"current": "Current",
"new": "New",
"multipleValuesCount": "Different on {count} files",
"noCover": "No cover"
},
"metadata": {
"field": "Field",
"current": "Current",
"new": "New",
"multipleValuesCount": "Different on {count} files"
},
"search": {
"images": "Images",

View File

@@ -22,6 +22,7 @@
"new": "Nouveau",
"next": "Suivant",
"ok": "OK",
"apply": "Appliquer",
"permalink": "Obtenir le lien permanent",
"previous": "Précédent",
"preview": "Prévisualiser",
@@ -159,7 +160,18 @@
"uploadMessage": "Sélectionnez une option d'import.",
"optionalPassword": "Mot de passe optionnel",
"resolution": "Résolution",
"discardEditorChanges": "Êtes-vous sûr de vouloir annuler les modifications apportées ?"
"discardEditorChanges": "Êtes-vous sûr de vouloir annuler les modifications apportées ?",
"field": "Champ",
"current": "Actuel",
"new": "Nouveau",
"multipleValuesCount": "Différent sur {count} fichiers",
"noCover": "Pas de pochette"
},
"metadata": {
"field": "Champ",
"current": "Actuel",
"new": "Nouveau",
"multipleValuesCount": "Différent sur {count} fichiers"
},
"search": {
"images": "Images",