fix: multivalue tags
This commit is contained in:
@@ -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
|
||||||
|
|||||||
121
files/tags.go
121
files/tags.go
@@ -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
|
||||||
|
}
|
||||||
|
|||||||
@@ -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">
|
||||||
<input
|
<template v-if="isMultiValued(field)">
|
||||||
v-model="newValues[field]"
|
<div class="multi-value-editor">
|
||||||
:placeholder="placeholders[field] || ''"
|
<div v-for="(val, idx) in getMultiValueEntries(field)" :key="idx" class="multi-value-entry">
|
||||||
type="text"
|
<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
|
<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));
|
||||||
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) {
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
34
http/data.go
34
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 {
|
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
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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,9 +154,14 @@ 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 {
|
if !ok {
|
||||||
continue
|
// Fall back to regular ffmpeg key
|
||||||
|
ffk, ok = ffmpegKey[canonical]
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
}
|
}
|
||||||
vals := []string{}
|
vals := []string{}
|
||||||
for _, v := range arr {
|
for _, v := range arr {
|
||||||
|
|||||||
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 {
|
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
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user