Compare commits

..

5 Commits

Author SHA1 Message Date
61d76695bf Merge branch 'metadata-fix' into 'master'
fix: multivalue tags

See merge request joelilas/filebrowser!2
2026-02-08 15:55:13 +00:00
8993c8031a fix: multivalue tags 2026-02-08 16:50:29 +01:00
41ecbc5f73 Merge branch 'metadata-fix' into 'master'
Metadata fix

See merge request joelilas/filebrowser!1
2026-01-30 08:52:51 +00:00
b59d30784f fix,add: TXXX adding, delete option, dictionnary, M4A support 2026-01-30 09:50:55 +01:00
b91b7c431c fix: ffmpeg added to Dockerfile.full, id3v2 to go.mod 2026-01-29 18:57:17 +01:00
11 changed files with 1507 additions and 38 deletions

86
Dockerfile.full Normal file
View File

@@ -0,0 +1,86 @@
# Multi-stage build: compile frontend + backend, then package
# This is a full build Dockerfile for filebrowser from source
##############################################################################
# Stage 1: Build frontend (Node.js / pnpm)
##############################################################################
FROM node:20-alpine AS frontend-builder
RUN corepack enable && corepack prepare pnpm@latest --activate
WORKDIR /app/frontend
COPY frontend/package.json frontend/pnpm-lock.yaml ./
RUN pnpm install --frozen-lockfile
COPY frontend/ ./
RUN pnpm run build
##############################################################################
# Stage 2: Build backend (Go)
##############################################################################
FROM golang:1.25-alpine AS backend-builder
RUN apk add --no-cache git
WORKDIR /app
COPY . .
COPY --from=frontend-builder /app/frontend/dist ./frontend/dist
RUN go mod tidy && go mod download
ARG VERSION=dev
ARG GIT_COMMIT=unknown
RUN CGO_ENABLED=0 go build \
-ldflags="-s -w -X 'github.com/filebrowser/filebrowser/v2/version.Version=${VERSION}' -X 'github.com/filebrowser/filebrowser/v2/version.CommitSHA=${GIT_COMMIT}'" \
-o filebrowser .
##############################################################################
# Stage 3: Fetch runtime dependencies
##############################################################################
FROM alpine:3.23 AS fetcher
RUN apk update && \
apk --no-cache add ca-certificates mailcap tini-static && \
wget -O /JSON.sh https://raw.githubusercontent.com/dominictarr/JSON.sh/0d5e5c77365f63809bf6e77ef44a1f34b0e05840/JSON.sh
##############################################################################
# Stage 4: Final runtime image (Alpine with ffmpeg)
##############################################################################
FROM alpine:3.23
ENV UID=1000
ENV GID=1000
RUN addgroup -g $GID user && \
adduser -D -u $UID -G user user && \
apk add --no-cache ffmpeg flac
# Copy compiled binary from backend-builder
COPY --from=backend-builder --chown=user:user /app/filebrowser /bin/filebrowser
# Copy scripts and configs from repo
COPY --chown=user:user docker/common/ /
COPY --chown=user:user docker/alpine/ /
# Copy runtime deps from fetcher
COPY --chown=user:user --from=fetcher /sbin/tini-static /bin/tini
COPY --from=fetcher /JSON.sh /JSON.sh
COPY --from=fetcher /etc/ca-certificates.conf /etc/ca-certificates.conf
COPY --from=fetcher /etc/ca-certificates /etc/ca-certificates
COPY --from=fetcher /etc/mime.types /etc/mime.types
COPY --from=fetcher /etc/ssl /etc/ssl
RUN mkdir -p /config /database /srv && \
chown -R user:user /config /database /srv && \
chmod +x /healthcheck.sh
HEALTHCHECK --start-period=2s --interval=5s --timeout=3s CMD /healthcheck.sh
USER user
VOLUME /srv /config /database
EXPOSE 80
ENTRYPOINT [ "tini", "--", "/init.sh" ]

View File

@@ -5,6 +5,8 @@ import (
"encoding/base64" "encoding/base64"
"io" "io"
"log" "log"
"os/exec"
"path/filepath"
"strings" "strings"
"github.com/dhowden/tag" "github.com/dhowden/tag"
@@ -13,6 +15,7 @@ import (
// ReadAudioTags extracts common audio metadata from the file and stores it in // ReadAudioTags extracts common audio metadata from the file and stores it in
// the FileInfo.Tags map. It attempts to extract common fields and includes // the FileInfo.Tags map. It attempts to extract common fields and includes
// embedded picture(s) as base64 where present. // embedded picture(s) as base64 where present.
// Multi-valued tags like ARTISTS and ALBUMARTISTS are extracted as arrays.
func (i *FileInfo) ReadAudioTags() error { func (i *FileInfo) ReadAudioTags() error {
if i.IsDir { if i.IsDir {
return nil return nil
@@ -76,7 +79,20 @@ func (i *FileInfo) ReadAudioTags() error {
} }
// Keep raw metadata map if available (some formats expose additional fields) // Keep raw metadata map if available (some formats expose additional fields)
// Also extract multi-valued tags like ARTISTS and ALBUMARTISTS
if raw := m.Raw(); raw != nil { if raw := m.Raw(); raw != nil {
// First pass: collect multi-valued tags (ARTISTS, ALBUMARTISTS)
artists := extractMultiValuedTag(raw, "ARTISTS", "artists")
albumArtists := extractMultiValuedTag(raw, "ALBUMARTISTS", "albumartists")
// Store multi-valued tags as arrays if present
if len(artists) > 0 {
tags["artists"] = artists
}
if len(albumArtists) > 0 {
tags["albumartists"] = albumArtists
}
for k, v := range raw { for k, v := range raw {
// Skip raw APIC entries (attached picture frame) to avoid // Skip raw APIC entries (attached picture frame) to avoid
// exposing large binary blobs. We already expose a friendly // exposing large binary blobs. We already expose a friendly
@@ -84,6 +100,11 @@ func (i *FileInfo) ReadAudioTags() error {
if strings.EqualFold(k, "APIC") { if strings.EqualFold(k, "APIC") {
continue continue
} }
// Skip multi-valued tags we already handled
kLower := strings.ToLower(k)
if kLower == "artists" || kLower == "albumartists" {
continue
}
// avoid overwriting already set common fields // avoid overwriting already set common fields
if _, ok := tags[k]; !ok { if _, ok := tags[k]; !ok {
tags[k] = v tags[k] = v
@@ -91,6 +112,19 @@ func (i *FileInfo) ReadAudioTags() error {
} }
} }
// For FLAC files, use metaflac to read multi-valued tags properly
// since dhowden/tag doesn't handle them correctly
if realPath := i.RealPath(); realPath != "" && strings.EqualFold(filepath.Ext(realPath), ".flac") {
if multiTags := readMultiValuedTagsWithMetaflac(realPath); multiTags != nil {
if artists, ok := multiTags["ARTISTS"]; ok && len(artists) > 0 {
tags["artists"] = artists
}
if albumArtists, ok := multiTags["ALBUMARTISTS"]; ok && len(albumArtists) > 0 {
tags["albumartists"] = albumArtists
}
}
}
// Attach tags map // Attach tags map
if len(tags) > 0 { if len(tags) > 0 {
i.Tags = tags i.Tags = tags
@@ -98,3 +132,90 @@ func (i *FileInfo) ReadAudioTags() error {
return nil return nil
} }
// extractMultiValuedTag extracts values for a multi-valued tag from raw metadata.
// It handles both Vorbis-style (multiple entries with same key) and ID3-style
// (single string that may need splitting) formats.
func extractMultiValuedTag(raw map[string]interface{}, keys ...string) []string {
var result []string
seen := make(map[string]bool)
for _, key := range keys {
for rawKey, rawVal := range raw {
if !strings.EqualFold(rawKey, key) {
continue
}
// Handle different value types returned by the tag library
switch v := rawVal.(type) {
case string:
v = strings.TrimSpace(v)
if v != "" && !seen[v] {
result = append(result, v)
seen[v] = true
}
case []string:
for _, s := range v {
s = strings.TrimSpace(s)
if s != "" && !seen[s] {
result = append(result, s)
seen[s] = true
}
}
case []interface{}:
for _, item := range v {
if s, ok := item.(string); ok {
s = strings.TrimSpace(s)
if s != "" && !seen[s] {
result = append(result, s)
seen[s] = true
}
}
}
}
}
}
return result
}
// readMultiValuedTagsWithMetaflac uses metaflac to read Vorbis comments from FLAC files.
// This properly handles multi-valued tags (multiple entries with same key).
func readMultiValuedTagsWithMetaflac(filepath string) map[string][]string {
if _, err := exec.LookPath("metaflac"); err != nil {
return nil
}
// Use metaflac to export all tags
cmd := exec.Command("metaflac", "--export-tags-to=-", filepath)
out, err := cmd.Output()
if err != nil {
log.Printf("metaflac export failed for %s: %v", filepath, err)
return nil
}
// Parse output: each line is TAG=VALUE
result := make(map[string][]string)
for _, line := range strings.Split(string(out), "\n") {
line = strings.TrimSpace(line)
if line == "" {
continue
}
idx := strings.Index(line, "=")
if idx < 0 {
continue
}
key := strings.ToUpper(strings.TrimSpace(line[:idx]))
value := strings.TrimSpace(line[idx+1:])
if value == "" {
continue
}
// Collect multi-valued artist tags (ARTISTS, ARTIST, ALBUMARTISTS)
// Normalize key variations
if key == "ARTISTS" || key == "ARTIST" {
result["ARTISTS"] = append(result["ARTISTS"], value)
} else if key == "ALBUMARTISTS" || key == "ALBUMARTIST" || key == "ALBUM ARTIST" || key == "ALBUM_ARTIST" {
result["ALBUMARTISTS"] = append(result["ALBUMARTISTS"], value)
}
}
return result
}

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) { export async function updateMetadata(url: string, content: any) {
// Update metadata for a resource. Backend must support PATCH with action=metadata. // 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)); return resourceAction(`${url}?action=metadata`, "PATCH", JSON.stringify(content));
} }

View File

@@ -40,7 +40,11 @@
v-model="addFieldKey" v-model="addFieldKey"
type="text" type="text"
:placeholder="trans('prompts.newFieldName','Field name')" :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 <input
v-model="addFieldValue" v-model="addFieldValue"
type="text" type="text"
@@ -80,22 +84,76 @@
</thead> </thead>
<tbody> <tbody>
<tr v-for="field in fields" :key="field" class="metadata-row"> <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>
<span v-if="isMultiValued(field)" class="multi-value-hint">{{ trans('prompts.multiValueHint','(multi-valued)') }}</span>
</td>
<td class="metadata-current"> <td class="metadata-current">
<template v-if="isPicture(field)"> <template v-if="isPicture(field)">
<img :src="pictureSrc(field)" alt="cover" class="cover-preview" v-if="pictureSrc(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> <span v-else class="metadata-muted">{{ trans('prompts.noCover','No cover') }}</span>
</template> </template>
<template v-else-if="isMultiValued(field)">
<div class="multi-value-display">
<div v-for="(val, idx) in displayCurrentArray(field)" :key="idx" class="multi-value-item">
{{ val }}
</div>
<span v-if="displayCurrentArray(field).length === 0" class="metadata-muted"></span>
</div>
</template>
<template v-else> <template v-else>
{{ displayCurrent(field) }} {{ displayCurrent(field) }}
</template> </template>
</td> </td>
<td class="metadata-edit-cell"> <td class="metadata-edit-cell">
<input <div class="edit-with-actions">
v-model="newValues[field]" <template v-if="isMultiValued(field)">
:placeholder="placeholders[field] || ''" <div class="multi-value-editor">
type="text" <div v-for="(val, idx) in getMultiValueEntries(field)" :key="idx" class="multi-value-entry">
/> <input
:value="val"
@input="updateMultiValueEntry(field, idx, $event.target.value)"
:placeholder="trans('prompts.artistName','Artist name')"
type="text"
class="multi-value-input"
/>
<button
type="button"
class="button button--flat button--red icon-button mini-button"
:aria-label="trans('buttons.removeEntry','Remove')"
:title="trans('buttons.removeEntry','Remove')"
@click="removeMultiValueEntry(field, idx)"
>
<i class="material-icons">close</i>
</button>
</div>
<button
type="button"
class="button button--flat button--small add-entry-btn"
@click="addMultiValueEntry(field)"
>
<i class="material-icons">add</i>
{{ trans('buttons.addEntry','Add entry') }}
</button>
</div>
</template>
<template v-else>
<input
v-model="newValues[field]"
:placeholder="placeholders[field] || ''"
type="text"
/>
</template>
<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> </td>
</tr> </tr>
</tbody> </tbody>
@@ -135,6 +193,7 @@ import { mapActions, mapState } from "pinia";
import { useFileStore } from "@/stores/file"; import { useFileStore } from "@/stores/file";
import { useLayoutStore } from "@/stores/layout"; import { useLayoutStore } from "@/stores/layout";
import { metadata as apiMetadata, updateMetadata } from "@/api/files"; import { metadata as apiMetadata, updateMetadata } from "@/api/files";
import { fieldLabelFor, normalizeChanges, canonicalizeKeys, canonicalizeKey, navidromeTags, isMultiValuedTag, isMultiValuedKey, splitMultiValues, normalizeMixedChanges, formatMultiValueDisplay, parseMultiValueInput } from "@/utils/metadata";
export default { export default {
name: "modifyMetadata", name: "modifyMetadata",
@@ -147,10 +206,12 @@ export default {
placeholders: {}, placeholders: {},
metadataList: [], metadataList: [],
newValues: {}, newValues: {},
toClear: new Set(),
// add field UI state // add field UI state
addFieldVisible: false, addFieldVisible: false,
addFieldKey: "", addFieldKey: "",
addFieldValue: "", addFieldValue: "",
tagSuggestions: navidromeTags,
}; };
}, },
computed: { computed: {
@@ -158,6 +219,9 @@ export default {
}, },
methods: { methods: {
...mapActions(useLayoutStore, ["closeHovers"]), ...mapActions(useLayoutStore, ["closeHovers"]),
fieldLabel(key) {
return fieldLabelFor(key);
},
async mountedFetch() { async mountedFetch() {
if (!this.req) return; if (!this.req) return;
@@ -177,17 +241,49 @@ export default {
Object.keys(m).forEach((k) => keys.add(k)); Object.keys(m).forEach((k) => keys.add(k));
}); });
// Ensure stable ordering: put common fields first // Ensure stable ordering: put common fields first (including multi-valued artists/albumartists)
const preferred = ["title", "artist", "album", "track", "genre", "date", "comment", "disc", "composer", "year", "albumartist", "picture", "cover"]; const preferred = ["title", "artist", "artists", "album", "track", "genre", "date", "comment", "disc", "composer", "year", "albumartist", "albumartists", "picture", "cover"];
const rest = Array.from(keys).filter((k) => !preferred.includes(k)).sort(); const rest = Array.from(keys).filter((k) => !preferred.includes(k)).sort();
this.fields = preferred.filter((k) => keys.has(k)).concat(rest); this.fields = preferred.filter((k) => keys.has(k)).concat(rest);
// Setup field labels (use translation when available, otherwise humanize) // Setup field labels (use translation when available, otherwise humanize)
// For multi-valued fields, initialize newValues appropriately
this.fields.forEach((f) => { this.fields.forEach((f) => {
this.fieldLabels[f] = this.trans(`prompts.${f}`, this.humanize(f)); this.fieldLabels[f] = this.trans(`prompts.${f}`, this.humanize(f));
this.newValues[f] = ""; if (isMultiValuedKey(f)) {
// Initialize with current values or empty array
const current = this.getCurrentMultiValues(f);
this.newValues[f] = current.length > 0 ? [...current] : [];
} else {
this.newValues[f] = "";
}
}); });
}, },
// Get current multi-values directly from metadataList (for initialization)
getCurrentMultiValues(field) {
if (!this.metadataList || this.metadataList.length === 0) return [];
const allValues = [];
const seen = new Set();
for (const m of this.metadataList) {
const v = m[field];
if (Array.isArray(v)) {
for (const item of v) {
const s = String(item || '').trim();
if (s && !seen.has(s)) {
allValues.push(s);
seen.add(s);
}
}
} else if (v !== undefined && v !== null) {
const s = String(v).trim();
if (s && !seen.has(s)) {
allValues.push(s);
seen.add(s);
}
}
}
return allValues;
},
toggleAddField() { toggleAddField() {
this.addFieldVisible = !this.addFieldVisible; this.addFieldVisible = !this.addFieldVisible;
if (this.addFieldVisible) { if (this.addFieldVisible) {
@@ -207,13 +303,88 @@ export default {
// If field doesn't exist yet, add it to the top for visibility // If field doesn't exist yet, add it to the top for visibility
if (!this.fields.includes(key)) { if (!this.fields.includes(key)) {
this.fields.unshift(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 // Initialize newValues entry and set initial value if provided
if (!(key in this.newValues)) this.newValues[key] = ""; if (isMultiValuedTag(canonicalizeKey(key))) {
if (val !== "") this.newValues[key] = val; // For multi-valued tags, initialize as array
if (val) {
this.newValues[key] = splitMultiValues(val);
} else {
this.newValues[key] = [''];
}
} else {
if (!(key in this.newValues)) this.newValues[key] = "";
if (val !== "") this.newValues[key] = val;
}
this.cancelAddField(); this.cancelAddField();
}, },
// Check if a field is multi-valued (ARTISTS, ALBUMARTISTS)
isMultiValued(field) {
return isMultiValuedKey(field);
},
// Get current values as array for multi-valued display
displayCurrentArray(field) {
return this.getCurrentMultiValues(field);
},
// Get entries for editing multi-valued field
getMultiValueEntries(field) {
const val = this.newValues[field];
if (Array.isArray(val)) {
return val;
}
// Initialize from current if not yet edited
if (!val || val === '') {
const current = this.getCurrentMultiValues(field);
if (current.length > 0) {
this.newValues[field] = [...current];
return this.newValues[field];
}
// Return empty array with one entry for new field
this.newValues[field] = [''];
return this.newValues[field];
}
// Handle legacy string format
const arr = splitMultiValues(String(val));
this.newValues[field] = arr.length > 0 ? arr : [''];
return this.newValues[field];
},
// Update a single entry in a multi-valued field
updateMultiValueEntry(field, index, value) {
if (!Array.isArray(this.newValues[field])) {
this.newValues[field] = this.getMultiValueEntries(field);
}
this.newValues[field][index] = value;
},
// Remove an entry from a multi-valued field
removeMultiValueEntry(field, index) {
if (!Array.isArray(this.newValues[field])) {
this.newValues[field] = this.getMultiValueEntries(field);
}
this.newValues[field].splice(index, 1);
// Keep at least one empty entry for UX
if (this.newValues[field].length === 0) {
this.newValues[field] = [''];
}
},
// Add a new entry to a multi-valued field
addMultiValueEntry(field) {
if (!Array.isArray(this.newValues[field])) {
this.newValues[field] = this.getMultiValueEntries(field);
}
this.newValues[field].push('');
},
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) { displayCurrent(field) {
if (!this.metadataList || this.metadataList.length === 0) return ""; if (!this.metadataList || this.metadataList.length === 0) return "";
@@ -297,13 +468,21 @@ export default {
const changes = {}; const changes = {};
Object.keys(this.newValues).forEach((k) => { Object.keys(this.newValues).forEach((k) => {
const v = this.newValues[k]; const v = this.newValues[k];
if (v !== undefined && v !== null && String(v).trim() !== "") { const canon = canonicalizeKey(k);
changes[k] = v; if (isMultiValuedTag(canon)) {
// For multi-valued tags, filter out empty entries
let arr = Array.isArray(v) ? v : splitMultiValues(String(v || ""));
arr = arr.map(s => String(s || '').trim()).filter(Boolean);
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) { // If no value changes but some fields were removed, proceed
// nothing to do const hasValueChanges = Object.keys(changes).length > 0;
const hasClears = this.toClear && this.toClear.size > 0;
if (!hasValueChanges && !hasClears) {
return; return;
} }
@@ -311,13 +490,16 @@ export default {
try { try {
// apply changes to each file // apply changes to each file
await Promise.all( // Normalize keys on client before sending
files.map((u) => updateMetadata(u, changes)) 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 // refresh listing
const fileStore = useFileStore(); const fileStore = useFileStore();
fileStore.reload = true; fileStore.reload = true;
this.closeHovers(); this.closeHovers();
this.toClear = new Set();
} catch (e) { } catch (e) {
this.$showError(e); this.$showError(e);
} }
@@ -394,6 +576,23 @@ export default {
flex: 1 1 auto; 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 { .metadata-table-wrapper {
overflow-x: auto; overflow-x: auto;
width: 100%; width: 100%;
@@ -549,4 +748,87 @@ export default {
.metadata-edit-row td { .metadata-edit-row td {
background: rgba(0,0,0,0.02); background: rgba(0,0,0,0.02);
} }
/* Multi-valued tag styling */
.multi-value-hint {
display: block;
font-size: 0.75em;
color: var(--color-muted, #888);
font-weight: normal;
margin-top: 0.15em;
}
.multi-value-display {
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.multi-value-item {
background: rgba(0,0,0,0.04);
padding: 0.25rem 0.5rem;
border-radius: 4px;
font-size: 0.9em;
}
.multi-value-editor {
display: flex;
flex-direction: column;
gap: 0.35rem;
flex: 1;
}
.multi-value-entry {
display: flex;
align-items: center;
gap: 0.25rem;
}
.multi-value-input {
flex: 1;
box-sizing: border-box;
border-radius: 6px;
border: 1px solid rgba(0,0,0,0.12);
padding: 0.35rem 0.5rem;
font-size: 0.9em;
}
.mini-button {
padding: 0.15em 0.25em !important;
min-width: auto !important;
}
.mini-button .material-icons {
font-size: 16px !important;
}
.add-entry-btn {
align-self: flex-start;
display: inline-flex;
align-items: center;
gap: 0.25rem;
font-size: 0.85em;
padding: 0.25rem 0.5rem;
margin-top: 0.15rem;
}
.add-entry-btn .material-icons {
font-size: 16px;
}
/* Ensure multi-value editor aligns well in the edit cell */
.edit-with-actions {
display: flex;
align-items: flex-start;
gap: 0.5rem;
}
.edit-with-actions > .multi-value-editor {
flex: 1;
}
.edit-with-actions > .icon-button {
flex-shrink: 0;
margin-top: 0.25rem;
}
</style> </style>

View File

@@ -0,0 +1,241 @@
// 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
export type MixedChanges = Record<string, string | string[]>; // combined type
// 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));
// Multi-valued tag canonical names
export const multiValuedTags = new Set<string>([
Canonical.Artists,
Canonical.AlbumArtists,
]);
// 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 multiValuedTags.has(canon);
}
// Check if a key (after canonicalization) is a multi-valued tag
export function isMultiValuedKey(key: string): boolean {
const canon = canonicalizeKey(key);
return isMultiValuedTag(canon);
}
// Format multi-valued array for display (join with line breaks)
export function formatMultiValueDisplay(values: string[]): string {
if (!values || values.length === 0) return "";
return values.join("\n");
}
// Parse display string back to array (split by newlines)
export function parseMultiValueInput(input: string): string[] {
if (!input) return [];
return input
.split(/\n/)
.map((s) => s.trim())
.filter(Boolean);
}
// 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 name = fileStore.req!.items[i].name || "";
const idx = name.lastIndexOf("."); const idx = name.lastIndexOf(".");
const ext = idx === -1 ? "" : name.substring(idx).toLowerCase(); const ext = idx === -1 ? "" : name.substring(idx).toLowerCase();
return [".mp3", ".flac"].includes(ext); return [".mp3", ".flac", ".m4a", ".m4b", ".mp4"].includes(ext);
}), }),
}; };
}); });

2
go.mod
View File

@@ -42,7 +42,7 @@ require (
github.com/cpuguy83/go-md2man/v2 v2.0.6 // indirect github.com/cpuguy83/go-md2man/v2 v2.0.6 // indirect
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
github.com/dhowden/tag v0.0.0-20240417053706-3d75831295e8 // indirect github.com/dhowden/tag v0.0.0-20240417053706-3d75831295e8 // indirect
github.com/bogem/id3v2/v2 v2.0.9 github.com/bogem/id3v2/v2 v2.1.4
github.com/dsnet/compress v0.0.2-0.20230904184137-39efe44ab707 // indirect github.com/dsnet/compress v0.0.2-0.20230904184137-39efe44ab707 // indirect
github.com/dsoprea/go-logging v0.0.0-20200710184922-b02d349568dd // indirect github.com/dsoprea/go-logging v0.0.0-20200710184922-b02d349568dd // indirect
github.com/dsoprea/go-utility/v2 v2.0.0-20221003172846-a3e1774ef349 // indirect github.com/dsoprea/go-utility/v2 v2.0.0-20221003172846-a3e1774ef349 // indirect

View File

@@ -48,9 +48,32 @@ func (d *data) Check(path string) bool {
} }
func handle(fn handleFunc, prefix string, store *storage.Storage, server *settings.Server) http.Handler { func handle(fn handleFunc, prefix string, store *storage.Storage, server *settings.Server) http.Handler {
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { // trackingWriter wraps the ResponseWriter to detect if headers/body were already written.
type trackingWriter struct {
http.ResponseWriter
wroteHeader bool
wroteBody bool
}
func (tw *trackingWriter) WriteHeader(code int) {
if !tw.wroteHeader {
tw.wroteHeader = true
tw.ResponseWriter.WriteHeader(code)
}
}
func (tw *trackingWriter) Write(b []byte) (int, error) {
// Any Write implies headers committed (status 200 if none set)
if !tw.wroteHeader {
tw.wroteHeader = true
}
tw.wroteBody = true
return tw.ResponseWriter.Write(b)
}
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Wrap writer to prevent superfluous WriteHeader calls
tw := &trackingWriter{ResponseWriter: w}
for k, v := range globalHeaders { for k, v := range globalHeaders {
w.Header().Set(k, v) tw.Header().Set(k, v)
} }
settings, err := store.Settings.Get() settings, err := store.Settings.Get()
@@ -59,7 +82,7 @@ func handle(fn handleFunc, prefix string, store *storage.Storage, server *settin
return return
} }
status, err := fn(w, r, &data{ status, err := fn(tw, r, &data{
Runner: &runner.Runner{Enabled: server.EnableExec, Settings: settings}, Runner: &runner.Runner{Enabled: server.EnableExec, Settings: settings},
store: store, store: store,
settings: settings, settings: settings,
@@ -76,7 +99,10 @@ func handle(fn handleFunc, prefix string, store *storage.Storage, server *settin
if status == http.StatusBadRequest && err != nil { if status == http.StatusBadRequest && err != nil {
txt += " (" + err.Error() + ")" txt += " (" + err.Error() + ")"
} }
http.Error(w, strconv.Itoa(status)+" "+txt, status) // Only write an error response if nothing was written yet.
if !tw.wroteHeader && !tw.wroteBody {
http.Error(tw, strconv.Itoa(status)+" "+txt, status)
}
return return
} }
}) })

117
http/id3_clear.go Normal file
View 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()
}

257
http/metadata.go Normal file
View File

@@ -0,0 +1,257 @@
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",
"Album": "album",
"AlbumArtist": "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",
}
// ffmpegMultiKey maps multi-valued canonical names to ffmpeg/Vorbis keys.
// For FLAC/Vorbis, use uppercase ARTISTS/ALBUMARTISTS for proper multi-value support.
var ffmpegMultiKey = map[string]string{
"Artists": "ARTISTS",
"AlbumArtists": "ALBUMARTISTS",
}
// allowedCanonicals is the set of canonical tags we accept for writes.
var allowedCanonicals = func() map[string]struct{} {
m := make(map[string]struct{}, len(ffmpegKey)+len(ffmpegMultiKey))
for k := range ffmpegKey {
m[k] = struct{}{}
}
for k := range ffmpegMultiKey {
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.
// Uses ffmpegMultiKey for proper Vorbis comment names (ARTISTS, ALBUMARTISTS).
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
}
}
// First check if this is a multi-valued tag
ffk, ok := ffmpegMultiKey[canonical]
if !ok {
// Fall back to regular ffmpeg key
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
}

View File

@@ -245,16 +245,77 @@ func resourcePatchHandler(fileCache FileCache) handleFunc {
} }
var tags map[string]string var tags map[string]string
multi := map[string][]string{}
var clear []string
body, err := io.ReadAll(r.Body) body, err := io.ReadAll(r.Body)
if err != nil { if err != nil {
return http.StatusBadRequest, err return http.StatusBadRequest, err
} }
if err := json.Unmarshal(body, &tags); err != nil { // Accept both plain tag maps and objects with __clear__ array
return http.StatusBadRequest, err 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
}
} }
// If the source is a directory, apply changes to all audio files within.
// Otherwise, apply to the single file.
err = d.RunHook(func() error { err = d.RunHook(func() error {
return applyMetadataWithFFmpeg(r.Context(), d, src, tags) fi, statErr := files.NewFileInfo(&files.FileOptions{
Fs: d.user.Fs,
Path: src,
Modify: d.user.Perm.Modify,
Expand: false,
ReadHeader: false,
Checker: d,
})
if statErr != nil {
return statErr
}
if fi.IsDir {
return applyMetadataToDir(r.Context(), d, src, tags, multi, clear)
}
return applyMetadataWithFFmpeg(r.Context(), d, src, tags, multi, clear)
}, action, src, dst, d.user) }, action, src, dst, d.user)
return errToStatus(err), err return errToStatus(err), err
@@ -271,7 +332,9 @@ func resourcePatchHandler(fileCache FileCache) handleFunc {
// applyMetadataWithFFmpeg attempts to write metadata using ffmpeg by creating // applyMetadataWithFFmpeg attempts to write metadata using ffmpeg by creating
// a temporary file and replacing the original. This requires that the // a temporary file and replacing the original. This requires that the
// underlying filesystem exposes a real path (see FileInfo.RealPath()). // underlying filesystem exposes a real path (see FileInfo.RealPath()).
func applyMetadataWithFFmpeg(ctx context.Context, d *data, src string, tags map[string]string) error { // For FLAC files with multi-valued tags (ARTISTS, ALBUMARTISTS), it uses
// metaflac directly since ffmpeg doesn't support true multi-valued Vorbis comments.
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{ fi, err := files.NewFileInfo(&files.FileOptions{
Fs: d.user.Fs, Fs: d.user.Fs,
Path: src, Path: src,
@@ -313,18 +376,127 @@ func applyMetadataWithFFmpeg(ctx context.Context, d *data, src string, tags map[
// provided non-empty fields. We explicitly map input global metadata (0) // provided non-empty fields. We explicitly map input global metadata (0)
// to output, and avoid `-map_metadata -1` which would clear everything. // to output, and avoid `-map_metadata -1` which would clear everything.
args := []string{"-y", "-i", real, "-map_metadata", "0", "-c", "copy"} args := []string{"-y", "-i", real, "-map_metadata", "0", "-c", "copy"}
changes := 0 // For MP3, prefer ID3v2.4 to support multi-valued tags properly
for k, v := range tags { isMP3 := strings.EqualFold(ext, ".mp3")
if strings.TrimSpace(v) == "" { // Treat M4A/MP4 similarly for metadata handling specifics
continue isMP4 := strings.EqualFold(ext, ".m4a") || strings.EqualFold(ext, ".m4b") || strings.EqualFold(ext, ".mp4")
} // FLAC supports true multi-valued Vorbis comments via metaflac
args = append(args, "-metadata", fmt.Sprintf("%s=%s", k, v)) isFLAC := strings.EqualFold(ext, ".flac")
changes++ if isMP3 {
args = append(args, "-id3v2_version", "4")
} }
// If no non-empty changes were requested, do nothing // For FLAC files with multi-valued tags, use metaflac which properly supports
// multiple values for the same tag (Vorbis comments).
if isFLAC && len(multi) > 0 {
if _, err := exec.LookPath("metaflac"); err == nil {
// Use metaflac for multi-valued tags
if err := applyMultiValuedTagsWithMetaflac(ctx, real, tags, multi, clear); err != nil {
log.Printf("metaflac failed, falling back to ffmpeg: %v", err)
// Fall through to ffmpeg
} else {
return nil
}
}
}
// 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)
if len(multi) > 0 {
mm := normalizeMultiToFFmpeg(multi)
for ffk, vals := range mm {
// MP4/M4A expects a single value; join for compatibility
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
}
// MP3 (ID3): prefer writing consolidated artist/album_artist rather than TXXX ARTISTS
if isMP3 {
if ffk == "ARTISTS" {
if len(vals) > 0 {
joined := strings.Join(vals, "; ")
args = append(args, "-metadata", fmt.Sprintf("%s=%s", "artist", joined))
changes++
setKeys["artist"] = struct{}{}
}
continue
}
if ffk == "ALBUMARTISTS" {
if len(vals) > 0 {
joined := strings.Join(vals, "; ")
args = append(args, "-metadata", fmt.Sprintf("%s=%s", "album_artist", joined))
changes++
setKeys["album_artist"] = struct{}{}
}
continue
}
}
// Default: add repeated metadata entries for formats that support it (e.g., Vorbis)
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 { 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) args = append(args, tmp)
@@ -339,7 +511,72 @@ func applyMetadataWithFFmpeg(ctx context.Context, d *data, src string, tags map[
if err := os.Rename(tmp, real); err != nil { if err := os.Rename(tmp, real); err != nil {
return err return err
} }
if isMP3 {
// Post-write cleanup for numeric/empty TXXX artifacts
_ = cleanupNumericEmptyTXXX(real)
}
return nil
}
// applyMetadataToDir iterates all immediate files in a directory and applies
// metadata changes to each supported audio file.
func applyMetadataToDir(ctx context.Context, d *data, dir string, tags map[string]string, multi map[string][]string, clear []string) error {
// List directory entries
f, err := d.user.Fs.Open(dir)
if err != nil {
return err
}
defer f.Close()
names, err := f.Readdirnames(0)
if err != nil {
return err
}
// Supported audio extensions for metadata updates
isAudio := func(ext string) bool {
ext = strings.ToLower(ext)
switch ext {
case ".mp3", ".flac", ".m4a", ".mp4", ".ogg", ".wav":
return true
default:
return false
}
}
processed := 0
failed := 0
for _, name := range names {
p := filepath.Join(dir, name)
info, err := d.user.Fs.Stat(p)
if err != nil {
// skip entries we can't stat
log.Printf("metadata: skip %s: %v", p, err)
continue
}
if info.IsDir() {
continue
}
if !isAudio(filepath.Ext(name)) {
continue
}
if err := applyMetadataWithFFmpeg(ctx, d, p, tags, multi, clear); err != nil {
failed++
log.Printf("metadata: failed for %s: %v", p, err)
continue
}
processed++
}
if processed == 0 {
return fmt.Errorf("no audio files found in album: %w", fberrors.ErrInvalidRequestParams)
}
if failed > 0 {
// Do not fail entire album operation; return success and rely on logs
// to indicate which files failed. This avoids a 500 when some files
// are temporarily locked or have unsupported formats.
return nil
}
return nil return nil
} }
@@ -493,3 +730,94 @@ var diskUsage = withUser(func(w http.ResponseWriter, r *http.Request, d *data) (
Used: usage.Used, Used: usage.Used,
}) })
}) })
// applyMultiValuedTagsWithMetaflac uses metaflac to write tags to FLAC files.
// Unlike ffmpeg, metaflac properly supports multiple values for the same tag.
func applyMultiValuedTagsWithMetaflac(ctx context.Context, filepath string, tags map[string]string, multi map[string][]string, clear []string) error {
// Normalize tags and multi to proper Vorbis comment names
norm := normalizeAndMapToFFmpeg(tags)
mm := normalizeMultiToFFmpeg(multi)
// Build metaflac arguments
// First, remove tags that we're going to set (to avoid duplicates)
removeArgs := []string{}
for k := range norm {
upperK := strings.ToUpper(k)
removeArgs = append(removeArgs, "--remove-tag="+upperK)
// Also handle ALBUM_ARTIST vs "ALBUM ARTIST" variants
if upperK == "ALBUM_ARTIST" {
removeArgs = append(removeArgs, "--remove-tag=ALBUM ARTIST")
removeArgs = append(removeArgs, "--remove-tag=ALBUMARTIST")
}
}
for k := range mm {
removeArgs = append(removeArgs, "--remove-tag="+k)
}
// Also remove cleared tags
for _, c := range clear {
token := normalizeKey(c)
canonical, ok := canonicalMap[token]
if !ok {
if _, allowed := allowedCanonicals[c]; allowed {
canonical = c
} else {
continue
}
}
// Check multi-valued tags first
if ffk, ok := ffmpegMultiKey[canonical]; ok {
removeArgs = append(removeArgs, "--remove-tag="+ffk)
}
// Also check regular tags
if ffk, ok := ffmpegKey[canonical]; ok {
upperK := strings.ToUpper(ffk)
removeArgs = append(removeArgs, "--remove-tag="+upperK)
// Handle all variants of album artist
if canonical == "AlbumArtist" {
removeArgs = append(removeArgs, "--remove-tag=ALBUM ARTIST")
removeArgs = append(removeArgs, "--remove-tag=ALBUMARTIST")
removeArgs = append(removeArgs, "--remove-tag=ALBUM_ARTIST")
}
// Handle artist variants (lowercase/uppercase)
if canonical == "Artist" {
removeArgs = append(removeArgs, "--remove-tag=artist")
removeArgs = append(removeArgs, "--remove-tag=ARTIST")
}
}
}
// Execute removal first if needed
if len(removeArgs) > 0 {
removeArgs = append(removeArgs, filepath)
cmd := exec.CommandContext(ctx, "metaflac", removeArgs...)
if out, err := cmd.CombinedOutput(); err != nil {
return fmt.Errorf("metaflac remove failed: %w: %s", err, string(out))
}
}
// Now set the new tags
setArgs := []string{}
for k, v := range norm {
// Use uppercase for Vorbis comments (standard convention)
setArgs = append(setArgs, fmt.Sprintf("--set-tag=%s=%s", strings.ToUpper(k), v))
}
// Add multi-valued tags (each value as a separate --set-tag)
for k, vals := range mm {
for _, v := range vals {
setArgs = append(setArgs, fmt.Sprintf("--set-tag=%s=%s", k, v))
}
}
// If nothing to set, we're done (removal was already executed above)
if len(setArgs) == 0 {
return nil
}
setArgs = append(setArgs, filepath)
cmd := exec.CommandContext(ctx, "metaflac", setArgs...)
if out, err := cmd.CombinedOutput(); err != nil {
return fmt.Errorf("metaflac set failed: %w: %s", err, string(out))
}
return nil
}