fix: multivalue tags

This commit is contained in:
2026-02-08 16:50:29 +01:00
parent b59d30784f
commit 8993c8031a
7 changed files with 640 additions and 26 deletions

View File

@@ -54,7 +54,7 @@ ENV GID=1000
RUN addgroup -g $GID user && \ RUN addgroup -g $GID user && \
adduser -D -u $UID -G user user && \ adduser -D -u $UID -G user user && \
apk add --no-cache ffmpeg apk add --no-cache ffmpeg flac
# Copy compiled binary from backend-builder # Copy compiled binary from backend-builder
COPY --from=backend-builder --chown=user:user /app/filebrowser /bin/filebrowser COPY --from=backend-builder --chown=user:user /app/filebrowser /bin/filebrowser

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

@@ -86,23 +86,64 @@
<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"> <td class="metadata-title">
<strong>{{ fieldLabel(field) }}</strong> <strong>{{ fieldLabel(field) }}</strong>
<span v-if="isMultiValued(field)" class="multi-value-hint">{{ trans('prompts.multiValueHint','(multi-valued)') }}</span>
</td> </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">
<div class="edit-with-actions"> <div class="edit-with-actions">
<template v-if="isMultiValued(field)">
<div class="multi-value-editor">
<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 <input
v-model="newValues[field]" v-model="newValues[field]"
:placeholder="placeholders[field] || ''" :placeholder="placeholders[field] || ''"
type="text" type="text"
/> />
</template>
<button <button
type="button" type="button"
class="button button--flat button--red icon-button" class="button button--flat button--red icon-button"
@@ -152,7 +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, splitMultiValues, normalizeMixedChanges } from "@/utils/metadata"; import { fieldLabelFor, normalizeChanges, canonicalizeKeys, canonicalizeKey, navidromeTags, isMultiValuedTag, isMultiValuedKey, splitMultiValues, normalizeMixedChanges, formatMultiValueDisplay, parseMultiValueInput } from "@/utils/metadata";
export default { export default {
name: "modifyMetadata", name: "modifyMetadata",
@@ -200,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));
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] = ""; 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) {
@@ -235,14 +308,73 @@ export default {
} }
// Initialize newValues entry and set initial value if provided // Initialize newValues entry and set initial value if provided
if (isMultiValuedTag(canonicalizeKey(key))) { if (isMultiValuedTag(canonicalizeKey(key))) {
// store raw string; we'll split on apply // For multi-valued tags, initialize as array
this.newValues[key] = val; if (val) {
this.newValues[key] = splitMultiValues(val);
} else {
this.newValues[key] = [''];
}
} else { } else {
if (!(key in this.newValues)) this.newValues[key] = ""; if (!(key in this.newValues)) this.newValues[key] = "";
if (val !== "") this.newValues[key] = val; 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) { removeField(field) {
// Remove an added or existing field from the edit list (UI only) // Remove an added or existing field from the edit list (UI only)
this.fields = this.fields.filter((f) => f !== field); this.fields = this.fields.filter((f) => f !== field);
@@ -338,7 +470,9 @@ export default {
const v = this.newValues[k]; const v = this.newValues[k];
const canon = canonicalizeKey(k); const canon = canonicalizeKey(k);
if (isMultiValuedTag(canon)) { if (isMultiValuedTag(canon)) {
const arr = Array.isArray(v) ? v : splitMultiValues(String(v || "")); // 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; if (arr.length > 0) changes[canon] = arr;
} else if (v !== undefined && v !== null && String(v).trim() !== "") { } else if (v !== undefined && v !== null && String(v).trim() !== "") {
changes[canon] = v; changes[canon] = v;
@@ -614,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

@@ -2,6 +2,7 @@
export type Changes = Record<string, string>; export type Changes = Record<string, string>;
export type MultiChanges = Record<string, string[]>; // for multi-valued tags 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 // Canonical names used for UI display
export const Canonical = { export const Canonical = {
@@ -25,6 +26,12 @@ export const Canonical = {
const allowedCanonicals = new Set<string>(Object.values(Canonical)); 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 // Map various inputs (singular/plural/localized) to canonical names
const canonicalMap: Record<string, string> = { const canonicalMap: Record<string, string> = {
// title // title
@@ -172,7 +179,28 @@ export const navidromeTags: string[] = [
]; ];
export function isMultiValuedTag(canon: string): boolean { export function isMultiValuedTag(canon: string): boolean {
return canon === Canonical.Artists || canon === Canonical.AlbumArtists; 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 // Split a user-entered multi-valued string into individual entries using

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 {
// 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) { 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
} }
}) })

View File

@@ -64,10 +64,8 @@ var canonicalMap = map[string]string{
var ffmpegKey = map[string]string{ var ffmpegKey = map[string]string{
"Title": "title", "Title": "title",
"Artist": "artist", "Artist": "artist",
"Artists": "artist",
"Album": "album", "Album": "album",
"AlbumArtist": "album_artist", "AlbumArtist": "album_artist",
"AlbumArtists": "album_artist",
"Composer": "composer", "Composer": "composer",
"Track": "track", "Track": "track",
"TrackNumber": "track", "TrackNumber": "track",
@@ -80,12 +78,22 @@ var ffmpegKey = map[string]string{
"Lyrics": "lyrics", "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. // allowedCanonicals is the set of canonical tags we accept for writes.
var allowedCanonicals = func() map[string]struct{} { var allowedCanonicals = func() map[string]struct{} {
m := make(map[string]struct{}, len(ffmpegKey)) m := make(map[string]struct{}, len(ffmpegKey)+len(ffmpegMultiKey))
for k := range ffmpegKey { for k := range ffmpegKey {
m[k] = struct{}{} m[k] = struct{}{}
} }
for k := range ffmpegMultiKey {
m[k] = struct{}{}
}
return m return m
}() }()
@@ -132,6 +140,7 @@ func normalizeAndMapToFFmpeg(in map[string]string) map[string]string {
return out return out
} }
// normalizeMultiToFFmpeg maps multi-valued canonical keys to ffmpeg keys, preserving arrays. // 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 { func normalizeMultiToFFmpeg(in map[string][]string) map[string][]string {
out := map[string][]string{} out := map[string][]string{}
for k, arr := range in { for k, arr := range in {
@@ -145,10 +154,15 @@ func normalizeMultiToFFmpeg(in map[string][]string) map[string][]string {
continue continue
} }
} }
ffk, ok := ffmpegKey[canonical] // 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 { if !ok {
continue continue
} }
}
vals := []string{} vals := []string{}
for _, v := range arr { for _, v := range arr {
v = strings.TrimSpace(v) v = strings.TrimSpace(v)

View File

@@ -298,7 +298,23 @@ func resourcePatchHandler(fileCache FileCache) handleFunc {
} }
} }
// 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 {
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) return applyMetadataWithFFmpeg(r.Context(), d, src, tags, multi, clear)
}, action, src, dst, d.user) }, action, src, dst, d.user)
@@ -316,6 +332,8 @@ 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()).
// 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 { 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,
@@ -362,10 +380,26 @@ func applyMetadataWithFFmpeg(ctx context.Context, d *data, src string, tags map[
isMP3 := strings.EqualFold(ext, ".mp3") isMP3 := strings.EqualFold(ext, ".mp3")
// Treat M4A/MP4 similarly for metadata handling specifics // Treat M4A/MP4 similarly for metadata handling specifics
isMP4 := strings.EqualFold(ext, ".m4a") || strings.EqualFold(ext, ".m4b") || strings.EqualFold(ext, ".mp4") isMP4 := strings.EqualFold(ext, ".m4a") || strings.EqualFold(ext, ".m4b") || strings.EqualFold(ext, ".mp4")
// FLAC supports true multi-valued Vorbis comments via metaflac
isFLAC := strings.EqualFold(ext, ".flac")
if isMP3 { if isMP3 {
args = append(args, "-id3v2_version", "4") args = append(args, "-id3v2_version", "4")
} }
// 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. // If MP3 and there are clears, perform ID3v2 in-place removal first.
// This avoids ffmpeg creating unwanted empty entries (e.g., TXXX artifacts). // This avoids ffmpeg creating unwanted empty entries (e.g., TXXX artifacts).
didID3Clear := false didID3Clear := false
@@ -395,12 +429,11 @@ func applyMetadataWithFFmpeg(ctx context.Context, d *data, src string, tags map[
changes++ changes++
setKeys[k] = struct{}{} setKeys[k] = struct{}{}
} }
// Handle multi-valued tags (e.g., Artists, AlbumArtists) by adding repeated metadata entries // Handle multi-valued tags (e.g., Artists, AlbumArtists)
if len(multi) > 0 { if len(multi) > 0 {
mm := normalizeMultiToFFmpeg(multi) mm := normalizeMultiToFFmpeg(multi)
for ffk, vals := range mm { for ffk, vals := range mm {
// MP4/M4A generally expects a single value for artist/album_artist. // MP4/M4A expects a single value; join for compatibility
// Join multiple values for better compatibility with tag readers.
if isMP4 && (ffk == "artist" || ffk == "album_artist") { if isMP4 && (ffk == "artist" || ffk == "album_artist") {
if len(vals) > 0 { if len(vals) > 0 {
joined := strings.Join(vals, "; ") joined := strings.Join(vals, "; ")
@@ -410,6 +443,28 @@ func applyMetadataWithFFmpeg(ctx context.Context, d *data, src string, tags map[
} }
continue 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 { for _, v := range vals {
args = append(args, "-metadata", fmt.Sprintf("%s=%s", ffk, v)) args = append(args, "-metadata", fmt.Sprintf("%s=%s", ffk, v))
changes++ changes++
@@ -463,6 +518,68 @@ func applyMetadataWithFFmpeg(ctx context.Context, d *data, src string, tags map[
return nil 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
}
func checkParent(src, dst string) error { func checkParent(src, dst string) error {
rel, err := filepath.Rel(src, dst) rel, err := filepath.Rel(src, dst)
if err != nil { if err != nil {
@@ -613,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
}