fix: multivalue tags
This commit is contained in:
@@ -54,7 +54,7 @@ ENV GID=1000
|
||||
|
||||
RUN addgroup -g $GID 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 --from=backend-builder --chown=user:user /app/filebrowser /bin/filebrowser
|
||||
|
||||
121
files/tags.go
121
files/tags.go
@@ -5,6 +5,8 @@ import (
|
||||
"encoding/base64"
|
||||
"io"
|
||||
"log"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/dhowden/tag"
|
||||
@@ -13,6 +15,7 @@ import (
|
||||
// ReadAudioTags extracts common audio metadata from the file and stores it in
|
||||
// the FileInfo.Tags map. It attempts to extract common fields and includes
|
||||
// embedded picture(s) as base64 where present.
|
||||
// Multi-valued tags like ARTISTS and ALBUMARTISTS are extracted as arrays.
|
||||
func (i *FileInfo) ReadAudioTags() error {
|
||||
if i.IsDir {
|
||||
return nil
|
||||
@@ -76,7 +79,20 @@ func (i *FileInfo) ReadAudioTags() error {
|
||||
}
|
||||
|
||||
// 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 {
|
||||
// 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 {
|
||||
// Skip raw APIC entries (attached picture frame) to avoid
|
||||
// exposing large binary blobs. We already expose a friendly
|
||||
@@ -84,6 +100,11 @@ func (i *FileInfo) ReadAudioTags() error {
|
||||
if strings.EqualFold(k, "APIC") {
|
||||
continue
|
||||
}
|
||||
// Skip multi-valued tags we already handled
|
||||
kLower := strings.ToLower(k)
|
||||
if kLower == "artists" || kLower == "albumartists" {
|
||||
continue
|
||||
}
|
||||
// avoid overwriting already set common fields
|
||||
if _, ok := tags[k]; !ok {
|
||||
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
|
||||
if len(tags) > 0 {
|
||||
i.Tags = tags
|
||||
@@ -98,3 +132,90 @@ func (i *FileInfo) ReadAudioTags() error {
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
@@ -86,23 +86,64 @@
|
||||
<tr v-for="field in fields" :key="field" class="metadata-row">
|
||||
<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">
|
||||
<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-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>
|
||||
{{ displayCurrent(field) }}
|
||||
</template>
|
||||
</td>
|
||||
<td class="metadata-edit-cell">
|
||||
<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
|
||||
v-model="newValues[field]"
|
||||
:placeholder="placeholders[field] || ''"
|
||||
type="text"
|
||||
/>
|
||||
</template>
|
||||
<button
|
||||
type="button"
|
||||
class="button button--flat button--red icon-button"
|
||||
@@ -152,7 +193,7 @@ import { mapActions, mapState } from "pinia";
|
||||
import { useFileStore } from "@/stores/file";
|
||||
import { useLayoutStore } from "@/stores/layout";
|
||||
import { metadata as apiMetadata, updateMetadata } from "@/api/files";
|
||||
import { fieldLabelFor, normalizeChanges, canonicalizeKeys, canonicalizeKey, navidromeTags, isMultiValuedTag, splitMultiValues, normalizeMixedChanges } from "@/utils/metadata";
|
||||
import { fieldLabelFor, normalizeChanges, canonicalizeKeys, canonicalizeKey, navidromeTags, isMultiValuedTag, isMultiValuedKey, splitMultiValues, normalizeMixedChanges, formatMultiValueDisplay, parseMultiValueInput } from "@/utils/metadata";
|
||||
|
||||
export default {
|
||||
name: "modifyMetadata",
|
||||
@@ -200,17 +241,49 @@ export default {
|
||||
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"];
|
||||
// Ensure stable ordering: put common fields first (including multi-valued artists/albumartists)
|
||||
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();
|
||||
this.fields = preferred.filter((k) => keys.has(k)).concat(rest);
|
||||
|
||||
// Setup field labels (use translation when available, otherwise humanize)
|
||||
// For multi-valued fields, initialize newValues appropriately
|
||||
this.fields.forEach((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] = "";
|
||||
}
|
||||
});
|
||||
},
|
||||
// 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() {
|
||||
this.addFieldVisible = !this.addFieldVisible;
|
||||
if (this.addFieldVisible) {
|
||||
@@ -235,14 +308,73 @@ export default {
|
||||
}
|
||||
// Initialize newValues entry and set initial value if provided
|
||||
if (isMultiValuedTag(canonicalizeKey(key))) {
|
||||
// store raw string; we'll split on apply
|
||||
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();
|
||||
},
|
||||
// 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);
|
||||
@@ -338,7 +470,9 @@ export default {
|
||||
const v = this.newValues[k];
|
||||
const canon = canonicalizeKey(k);
|
||||
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;
|
||||
} else if (v !== undefined && v !== null && String(v).trim() !== "") {
|
||||
changes[canon] = v;
|
||||
@@ -614,4 +748,87 @@ export default {
|
||||
.metadata-edit-row td {
|
||||
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>
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
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 = {
|
||||
@@ -25,6 +26,12 @@ export const 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
|
||||
const canonicalMap: Record<string, string> = {
|
||||
// title
|
||||
@@ -172,7 +179,28 @@ export const navidromeTags: string[] = [
|
||||
];
|
||||
|
||||
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
|
||||
|
||||
32
http/data.go
32
http/data.go
@@ -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 {
|
||||
// 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 {
|
||||
w.Header().Set(k, v)
|
||||
tw.Header().Set(k, v)
|
||||
}
|
||||
|
||||
settings, err := store.Settings.Get()
|
||||
@@ -59,7 +82,7 @@ func handle(fn handleFunc, prefix string, store *storage.Storage, server *settin
|
||||
return
|
||||
}
|
||||
|
||||
status, err := fn(w, r, &data{
|
||||
status, err := fn(tw, r, &data{
|
||||
Runner: &runner.Runner{Enabled: server.EnableExec, Settings: settings},
|
||||
store: store,
|
||||
settings: settings,
|
||||
@@ -76,7 +99,10 @@ func handle(fn handleFunc, prefix string, store *storage.Storage, server *settin
|
||||
if status == http.StatusBadRequest && err != nil {
|
||||
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
|
||||
}
|
||||
})
|
||||
|
||||
@@ -64,10 +64,8 @@ var canonicalMap = map[string]string{
|
||||
var ffmpegKey = map[string]string{
|
||||
"Title": "title",
|
||||
"Artist": "artist",
|
||||
"Artists": "artist",
|
||||
"Album": "album",
|
||||
"AlbumArtist": "album_artist",
|
||||
"AlbumArtists": "album_artist",
|
||||
"Composer": "composer",
|
||||
"Track": "track",
|
||||
"TrackNumber": "track",
|
||||
@@ -80,12 +78,22 @@ var ffmpegKey = map[string]string{
|
||||
"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))
|
||||
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
|
||||
}()
|
||||
|
||||
@@ -132,6 +140,7 @@ func normalizeAndMapToFFmpeg(in map[string]string) map[string]string {
|
||||
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 {
|
||||
@@ -145,10 +154,15 @@ func normalizeMultiToFFmpeg(in map[string][]string) map[string][]string {
|
||||
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 {
|
||||
continue
|
||||
}
|
||||
}
|
||||
vals := []string{}
|
||||
for _, v := range arr {
|
||||
v = strings.TrimSpace(v)
|
||||
|
||||
214
http/resource.go
214
http/resource.go
@@ -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 {
|
||||
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)
|
||||
|
||||
@@ -316,6 +332,8 @@ func resourcePatchHandler(fileCache FileCache) handleFunc {
|
||||
// applyMetadataWithFFmpeg attempts to write metadata using ffmpeg by creating
|
||||
// a temporary file and replacing the original. This requires that the
|
||||
// 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 {
|
||||
fi, err := files.NewFileInfo(&files.FileOptions{
|
||||
Fs: d.user.Fs,
|
||||
@@ -362,10 +380,26 @@ func applyMetadataWithFFmpeg(ctx context.Context, d *data, src string, tags map[
|
||||
isMP3 := strings.EqualFold(ext, ".mp3")
|
||||
// Treat M4A/MP4 similarly for metadata handling specifics
|
||||
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 {
|
||||
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.
|
||||
// This avoids ffmpeg creating unwanted empty entries (e.g., TXXX artifacts).
|
||||
didID3Clear := false
|
||||
@@ -395,12 +429,11 @@ func applyMetadataWithFFmpeg(ctx context.Context, d *data, src string, tags map[
|
||||
changes++
|
||||
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 {
|
||||
mm := normalizeMultiToFFmpeg(multi)
|
||||
for ffk, vals := range mm {
|
||||
// MP4/M4A generally expects a single value for artist/album_artist.
|
||||
// Join multiple values for better compatibility with tag readers.
|
||||
// MP4/M4A expects a single value; join for compatibility
|
||||
if isMP4 && (ffk == "artist" || ffk == "album_artist") {
|
||||
if len(vals) > 0 {
|
||||
joined := strings.Join(vals, "; ")
|
||||
@@ -410,6 +443,28 @@ func applyMetadataWithFFmpeg(ctx context.Context, d *data, src string, tags map[
|
||||
}
|
||||
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++
|
||||
@@ -463,6 +518,68 @@ func applyMetadataWithFFmpeg(ctx context.Context, d *data, src string, tags map[
|
||||
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 {
|
||||
rel, err := filepath.Rel(src, dst)
|
||||
if err != nil {
|
||||
@@ -613,3 +730,94 @@ var diskUsage = withUser(func(w http.ResponseWriter, r *http.Request, d *data) (
|
||||
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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user