Merge branch 'metadata-fix' into 'master'

Metadata fix

See merge request joelilas/filebrowser!1
This commit is contained in:
2026-01-30 08:52:51 +00:00
9 changed files with 887 additions and 32 deletions

86
Dockerfile.full Normal file
View File

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

View File

@@ -204,8 +204,19 @@ export async function metadata(url: string) {
}
}
import { normalizeChanges, normalizeMixedChanges } from "@/utils/metadata";
export async function updateMetadata(url: string, content: any) {
// Update metadata for a resource. Backend must support PATCH with action=metadata.
// Normalize outgoing changes to canonical keys so backend gets stable names, preserving arrays for multi-valued tags.
if (content && content.__clear__) {
const clear = content.__clear__;
const { __clear__, ...rest } = content;
content = normalizeMixedChanges(rest || {});
content.__clear__ = clear;
} else {
content = normalizeMixedChanges(content || {});
}
return resourceAction(`${url}?action=metadata`, "PATCH", JSON.stringify(content));
}

View File

@@ -40,7 +40,11 @@
v-model="addFieldKey"
type="text"
:placeholder="trans('prompts.newFieldName','Field name')"
list="tag-suggestions"
/>
<datalist id="tag-suggestions">
<option v-for="s in tagSuggestions" :key="s" :value="s">{{ s }}</option>
</datalist>
<input
v-model="addFieldValue"
type="text"
@@ -80,7 +84,9 @@
</thead>
<tbody>
<tr v-for="field in fields" :key="field" class="metadata-row">
<td class="metadata-title"><strong>{{ fieldLabels[field] || humanize(field) }}</strong></td>
<td class="metadata-title">
<strong>{{ fieldLabel(field) }}</strong>
</td>
<td class="metadata-current">
<template v-if="isPicture(field)">
<img :src="pictureSrc(field)" alt="cover" class="cover-preview" v-if="pictureSrc(field)" />
@@ -91,11 +97,22 @@
</template>
</td>
<td class="metadata-edit-cell">
<div class="edit-with-actions">
<input
v-model="newValues[field]"
:placeholder="placeholders[field] || ''"
type="text"
/>
<button
type="button"
class="button button--flat button--red icon-button"
:aria-label="trans('buttons.remove','Remove field')"
:title="trans('buttons.remove','Remove field')"
@click="removeField(field)"
>
<i class="material-icons">delete</i>
</button>
</div>
</td>
</tr>
</tbody>
@@ -135,6 +152,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";
export default {
name: "modifyMetadata",
@@ -147,10 +165,12 @@ export default {
placeholders: {},
metadataList: [],
newValues: {},
toClear: new Set(),
// add field UI state
addFieldVisible: false,
addFieldKey: "",
addFieldValue: "",
tagSuggestions: navidromeTags,
};
},
computed: {
@@ -158,6 +178,9 @@ export default {
},
methods: {
...mapActions(useLayoutStore, ["closeHovers"]),
fieldLabel(key) {
return fieldLabelFor(key);
},
async mountedFetch() {
if (!this.req) return;
@@ -207,13 +230,29 @@ export default {
// If field doesn't exist yet, add it to the top for visibility
if (!this.fields.includes(key)) {
this.fields.unshift(key);
this.fieldLabels[key] = this.trans(`prompts.${key}`, this.humanize(key));
// keep labels in English technical form
// label is computed via fieldLabel()
}
// Initialize newValues entry and set initial value if provided
if (isMultiValuedTag(canonicalizeKey(key))) {
// store raw string; we'll split on apply
this.newValues[key] = val;
} else {
if (!(key in this.newValues)) this.newValues[key] = "";
if (val !== "") this.newValues[key] = val;
}
this.cancelAddField();
},
removeField(field) {
// Remove an added or existing field from the edit list (UI only)
this.fields = this.fields.filter((f) => f !== field);
delete this.newValues[field];
delete this.placeholders[field];
delete this.fieldLabels[field];
// Mark field for clearing on apply
const canon = canonicalizeKey(field);
this.toClear.add(canon);
},
displayCurrent(field) {
if (!this.metadataList || this.metadataList.length === 0) return "";
@@ -297,13 +336,19 @@ export default {
const changes = {};
Object.keys(this.newValues).forEach((k) => {
const v = this.newValues[k];
if (v !== undefined && v !== null && String(v).trim() !== "") {
changes[k] = v;
const canon = canonicalizeKey(k);
if (isMultiValuedTag(canon)) {
const arr = Array.isArray(v) ? v : splitMultiValues(String(v || ""));
if (arr.length > 0) changes[canon] = arr;
} else if (v !== undefined && v !== null && String(v).trim() !== "") {
changes[canon] = v;
}
});
if (Object.keys(changes).length === 0) {
// nothing to do
// If no value changes but some fields were removed, proceed
const hasValueChanges = Object.keys(changes).length > 0;
const hasClears = this.toClear && this.toClear.size > 0;
if (!hasValueChanges && !hasClears) {
return;
}
@@ -311,13 +356,16 @@ export default {
try {
// apply changes to each file
await Promise.all(
files.map((u) => updateMetadata(u, changes))
);
// Normalize keys on client before sending
const toSend = normalizeMixedChanges(changes);
// Attach clear list (canonicalized) so backend can remove tags
toSend.__clear__ = canonicalizeKeys(Array.from(this.toClear));
await Promise.all(files.map((u) => updateMetadata(u, toSend)));
// refresh listing
const fileStore = useFileStore();
fileStore.reload = true;
this.closeHovers();
this.toClear = new Set();
} catch (e) {
this.$showError(e);
}
@@ -394,6 +442,23 @@ export default {
flex: 1 1 auto;
}
.edit-with-actions {
display: flex;
align-items: center;
gap: 0.5rem;
}
.icon-button {
padding: 0.25em 0.4em;
display: inline-flex;
align-items: center;
justify-content: center;
}
.icon-button .material-icons {
font-size: 18px;
line-height: 1;
}
.metadata-table-wrapper {
overflow-x: auto;
width: 100%;

View File

@@ -0,0 +1,213 @@
// Frontend helpers for metadata fields: canonicalization, labels, and payload normalization
export type Changes = Record<string, string>;
export type MultiChanges = Record<string, string[]>; // for multi-valued tags
// Canonical names used for UI display
export const Canonical = {
Title: "Title",
Artist: "Artist",
Artists: "Artists",
Album: "Album",
AlbumArtist: "AlbumArtist",
AlbumArtists: "AlbumArtists",
Composer: "Composer",
Track: "Track",
TrackNumber: "TrackNumber",
Disc: "Disc",
DiscNumber: "DiscNumber",
Genre: "Genre",
Date: "Date",
Year: "Year",
Comment: "Comment",
Lyrics: "Lyrics",
} as const;
const allowedCanonicals = new Set<string>(Object.values(Canonical));
// Map various inputs (singular/plural/localized) to canonical names
const canonicalMap: Record<string, string> = {
// title
"title": Canonical.Title,
"titre": Canonical.Title,
"song": Canonical.Title,
// artist
"artist": Canonical.Artist,
"artiste": Canonical.Artist,
// explicit multi-valued
"artists": Canonical.Artists,
// album
"album": Canonical.Album,
// album artist
"albumartist": Canonical.AlbumArtist,
"album artist": Canonical.AlbumArtist,
"album_artist": Canonical.AlbumArtist,
"artistesdelalbum": Canonical.AlbumArtist,
"artistealbum": Canonical.AlbumArtist,
// explicit multi-valued
"albumartists": Canonical.AlbumArtists,
// composer
"composer": Canonical.Composer,
"auteur": Canonical.Composer,
// track
"track": Canonical.Track,
"tracknumber": Canonical.Track,
"trackno": Canonical.Track,
"piste": Canonical.Track,
"track number": Canonical.TrackNumber,
"track_number": Canonical.TrackNumber,
// disc
"disc": Canonical.Disc,
"discnumber": Canonical.Disc,
"disque": Canonical.Disc,
"disc number": Canonical.DiscNumber,
"disc_number": Canonical.DiscNumber,
// genre
"genre": Canonical.Genre,
// date/year
"date": Canonical.Date,
"year": Canonical.Year,
"annee": Canonical.Year,
"année": Canonical.Year,
// comment
"comment": Canonical.Comment,
"commentaire": Canonical.Comment,
// lyrics
"lyrics": Canonical.Lyrics,
};
function normalizeToken(key: string): string {
return key
.trim()
.toLowerCase()
.replace(/[_-]+/g, " ")
.replace(/\s+/g, " ");
}
// Canonicalize a user-entered key (or backend key) to our display canonical
export function canonicalizeKey(key: string): string {
const token = normalizeToken(key);
const mapped = canonicalMap[token];
if (mapped) return mapped;
// If user already provided a canonical we accept, keep it
if (allowedCanonicals.has(key)) return key;
return key; // unknown: keep as-is
}
// Given a backend key like "albumartist" or "title", return English display label
export function fieldLabelFor(key: string): string {
const canon = canonicalizeKey(key);
// ensure nice spacing for camel case labels
if (allowedCanonicals.has(canon)) return canon;
// fallback: humanize
return humanize(key);
}
export function humanize(str: string): string {
if (!str) return str;
const s = String(str)
.replace(/([a-z0-9])([A-Z])/g, "$1 $2")
.replace(/[_\.\-]+/g, " ");
return s.charAt(0).toUpperCase() + s.slice(1);
}
// Normalize outgoing payload so backend gets canonical/expected keys
export function normalizeChanges(changes: Changes): Changes {
const out: Changes = {};
for (const [k, v] of Object.entries(changes)) {
const val = String(v ?? "").trim();
if (!val) continue;
const canon = canonicalizeKey(k);
if (allowedCanonicals.has(canon)) {
out[canon] = val;
} else {
// keep unknowns but trimmed; backend will filter if unsupported
out[k] = val;
}
}
return out;
}
export function canonicalizeKeys(keys: string[]): string[] {
const out: string[] = [];
for (const k of keys) {
const canon = canonicalizeKey(k);
if (allowedCanonicals.has(canon)) out.push(canon);
}
return out;
}
// Recommended Navidrome-sensitive tags for Add Field suggestions
export const navidromeTags: string[] = [
Canonical.Title,
Canonical.Artist,
Canonical.Artists,
Canonical.Album,
Canonical.AlbumArtist,
Canonical.AlbumArtists,
Canonical.Track,
Canonical.TrackNumber,
Canonical.Disc,
Canonical.DiscNumber,
Canonical.Genre,
Canonical.Date,
Canonical.Year,
Canonical.Composer,
Canonical.Lyrics,
// common MusicBrainz IDs (Vorbis comments)
"MUSICBRAINZ_ARTISTID",
"MUSICBRAINZ_ALBUMID",
"MUSICBRAINZ_TRACKID",
"MUSICBRAINZ_RELEASEGROUPID",
];
export function isMultiValuedTag(canon: string): boolean {
return canon === Canonical.Artists || canon === Canonical.AlbumArtists;
}
// Split a user-entered multi-valued string into individual entries using
// safe separators. Avoid splitting on plain '/' to not break names like AC/DC;
// prefer ' / ' with spaces, ';', and ' feat. '.
export function splitMultiValues(input: string): string[] {
let s = (input || "").trim();
if (!s) return [];
const parts: string[] = [];
const separators = [/\s;\s?/, /\s\/\s/, /\sfeat\.\s/i];
// Normalize separators to a unified delimiter
for (const sep of separators) {
s = s.replace(sep, "|||");
}
s.split("|||").forEach((p) => {
const v = p.trim();
if (v) parts.push(v);
});
return parts;
}
// Normalize mixed changes (strings + arrays) for multi-valued tags.
export function normalizeMixedChanges(changes: Record<string, any>): Record<string, any> {
const out: Record<string, any> = {};
for (const [k, v] of Object.entries(changes)) {
const canon = canonicalizeKey(k);
if (Array.isArray(v)) {
const values = v.map((x) => String(x || "").trim()).filter(Boolean);
if (values.length === 0) continue;
out[canon] = values;
continue;
}
const val = String(v ?? "").trim();
if (!val) continue;
out[canon] = val;
}
return out;
}

View File

@@ -502,7 +502,7 @@ const headerButtons = computed(() => {
const name = fileStore.req!.items[i].name || "";
const idx = name.lastIndexOf(".");
const ext = idx === -1 ? "" : name.substring(idx).toLowerCase();
return [".mp3", ".flac"].includes(ext);
return [".mp3", ".flac", ".m4a", ".m4b", ".mp4"].includes(ext);
}),
};
});

2
go.mod
View File

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

117
http/id3_clear.go Normal file
View File

@@ -0,0 +1,117 @@
package fbhttp
import (
"strings"
"unicode"
id3v2 "github.com/bogem/id3v2/v2"
)
// clearID3v2Frames removes specific ID3v2 frames from an MP3 file.
// frames: list of standard frame IDs (e.g., TIT2, TPE1, TPE2, TRCK, TPOS, TCON, TDRC, COMM, USLT)
// txxxDescs: list of UserDefinedText (TXXX) descriptions to remove (e.g., "Artists", "AlbumArtists").
func clearID3v2Frames(path string, frames []string, txxxDescs []string, removeNumericEmpty bool) error {
tag, err := id3v2.Open(path, id3v2.Options{Parse: true})
if err != nil {
return err
}
defer tag.Close()
// Delete standard frames
for _, id := range frames {
tag.DeleteFrames(id)
}
// Delete matching TXXX frames by description (preserve others)
if len(txxxDescs) > 0 {
fs := tag.GetFrames("TXXX")
keep := []id3v2.UserDefinedTextFrame{}
for _, f := range fs {
udf, ok := f.(id3v2.UserDefinedTextFrame)
if !ok {
continue
}
remove := false
for _, d := range txxxDescs {
if strings.EqualFold(udf.Description, d) {
remove = true
break
}
}
if !remove && removeNumericEmpty {
desc := strings.TrimSpace(udf.Description)
if desc == "" {
remove = true
} else {
// numeric-only descriptions like "0", "1", "2"
allDigits := true
for _, r := range desc {
if !unicode.IsDigit(r) {
allDigits = false
break
}
}
if allDigits {
remove = true
}
}
}
if !remove {
keep = append(keep, udf)
}
}
// Remove all TXXX frames, then re-add the ones we keep
tag.DeleteFrames("TXXX")
for _, udf := range keep {
tag.AddFrame("TXXX", udf)
}
}
return tag.Save()
}
// cleanupNumericEmptyTXXX removes any TXXX frames whose descriptions are empty
// or purely numeric (e.g., "0", "1", "2"…), which are common artifacts.
func cleanupNumericEmptyTXXX(path string) error {
tag, err := id3v2.Open(path, id3v2.Options{Parse: true})
if err != nil {
return err
}
defer tag.Close()
fs := tag.GetFrames("TXXX")
keep := []id3v2.UserDefinedTextFrame{}
for _, f := range fs {
udf, ok := f.(id3v2.UserDefinedTextFrame)
if !ok {
continue
}
desc := strings.TrimSpace(udf.Description)
remove := false
if desc == "" {
remove = true
} else {
allDigits := true
for _, r := range desc {
if !unicode.IsDigit(r) {
allDigits = false
break
}
}
if allDigits {
remove = true
}
}
if !remove {
keep = append(keep, udf)
}
}
// If nothing to change, return early
if len(keep) == len(fs) {
return nil
}
tag.DeleteFrames("TXXX")
for _, udf := range keep {
tag.AddFrame("TXXX", udf)
}
return tag.Save()
}

243
http/metadata.go Normal file
View File

@@ -0,0 +1,243 @@
package fbhttp
import (
"strings"
)
// canonicalMap maps various user-entered or localized keys to canonical tag names.
// Canonical names are English, PascalCase where appropriate (e.g., AlbumArtist).
var canonicalMap = map[string]string{
// title
"title": "Title",
"titre": "Title",
"song": "Title",
// artist (main)
"artist": "Artist",
"artiste": "Artist",
"artists": "Artist",
// album
"album": "Album",
// album artist
"albumartist": "AlbumArtist",
"album artist": "AlbumArtist",
"album_artist": "AlbumArtist",
"artistesdelalbum": "AlbumArtist",
"artistealbum": "AlbumArtist",
// composer / author
"composer": "Composer",
"auteur": "Composer",
// track
"track": "Track",
"tracknumber": "Track",
"trackno": "Track",
"piste": "Track",
// disc
"disc": "Disc",
"discnumber": "Disc",
"disque": "Disc",
// genre
"genre": "Genre",
// year/date
"year": "Year",
"annee": "Year",
"année": "Year",
"date": "Date",
// comment
"comment": "Comment",
"commentaire": "Comment",
// lyrics
"lyrics": "Lyrics",
}
// ffmpegKey maps canonical names to ffmpeg metadata keys.
// FFmpeg expects lower-case keys; AlbumArtist becomes album_artist, Year maps to date.
var ffmpegKey = map[string]string{
"Title": "title",
"Artist": "artist",
"Artists": "artist",
"Album": "album",
"AlbumArtist": "album_artist",
"AlbumArtists": "album_artist",
"Composer": "composer",
"Track": "track",
"TrackNumber": "track",
"Disc": "disc",
"DiscNumber": "disc",
"Genre": "genre",
"Date": "date",
"Year": "date", // prefer date for ffmpeg
"Comment": "comment",
"Lyrics": "lyrics",
}
// allowedCanonicals is the set of canonical tags we accept for writes.
var allowedCanonicals = func() map[string]struct{} {
m := make(map[string]struct{}, len(ffmpegKey))
for k := range ffmpegKey {
m[k] = struct{}{}
}
return m
}()
// normalizeKey reduces a user-provided key to a lookup token: lowercased, trimmed
// and with common separators removed.
func normalizeKey(s string) string {
s = strings.TrimSpace(s)
s = strings.ToLower(s)
// normalize separators so that "Album Artist", "album-artist", "album_artist" match
s = strings.ReplaceAll(s, "_", " ")
s = strings.ReplaceAll(s, "-", " ")
s = strings.Join(strings.Fields(s), " ") // collapse multiple spaces
return s
}
// normalizeAndMapToFFmpeg takes arbitrary incoming tags, applies key normalization
// and synonym mapping, filters to allowed canonical keys, and returns a map of
// ffmpeg-ready metadata keys with their values (empty values are dropped).
func normalizeAndMapToFFmpeg(in map[string]string) map[string]string {
out := map[string]string{}
for k, v := range in {
if strings.TrimSpace(v) == "" {
continue
}
token := normalizeKey(k)
canonical, ok := canonicalMap[token]
if !ok {
// If user already provided a canonical name, accept it
// (e.g., Title, Artist, AlbumArtist)
c2 := strings.TrimSpace(k)
if _, allowed := allowedCanonicals[c2]; allowed {
canonical = c2
} else {
// unrecognized key: skip
continue
}
}
ffk, ok := ffmpegKey[canonical]
if !ok {
continue
}
out[ffk] = v
}
return out
}
// normalizeMultiToFFmpeg maps multi-valued canonical keys to ffmpeg keys, preserving arrays.
func normalizeMultiToFFmpeg(in map[string][]string) map[string][]string {
out := map[string][]string{}
for k, arr := range in {
token := normalizeKey(k)
canonical, ok := canonicalMap[token]
if !ok {
// if already canonical, keep as-is
if _, allowed := allowedCanonicals[k]; allowed {
canonical = k
} else {
continue
}
}
ffk, ok := ffmpegKey[canonical]
if !ok {
continue
}
vals := []string{}
for _, v := range arr {
v = strings.TrimSpace(v)
if v == "" {
continue
}
vals = append(vals, v)
}
if len(vals) > 0 {
out[ffk] = vals
}
}
return out
}
// mapClearCanonicalsToFFmpeg converts a list of canonical names to ffmpeg keys
// intended to be cleared by setting them to empty value.
func mapClearCanonicalsToFFmpeg(keys []string) map[string]struct{} {
out := map[string]struct{}{}
for _, k := range keys {
token := normalizeKey(k)
canonical, ok := canonicalMap[token]
if !ok {
// if already canonical and allowed, keep
if _, allowed := allowedCanonicals[k]; allowed {
canonical = k
} else {
continue
}
}
if ffk, ok := ffmpegKey[canonical]; ok {
out[ffk] = struct{}{}
}
}
return out
}
// mapClearsToID3Frames converts canonical clear keys to ID3v2 frame IDs
func mapClearsToID3Frames(keys []string) []string {
out := []string{}
for _, k := range keys {
token := normalizeKey(k)
canonical, ok := canonicalMap[token]
if !ok {
if _, allowed := allowedCanonicals[k]; allowed {
canonical = k
} else {
continue
}
}
switch canonical {
case "Title":
out = append(out, "TIT2")
case "Artist", "Artists":
out = append(out, "TPE1")
case "Album":
out = append(out, "TALB")
case "AlbumArtist", "AlbumArtists":
out = append(out, "TPE2")
case "Composer":
out = append(out, "TCOM")
case "Track", "TrackNumber":
out = append(out, "TRCK")
case "Disc", "DiscNumber":
out = append(out, "TPOS")
case "Genre":
out = append(out, "TCON")
case "Date", "Year":
// Prefer TDRC; TYER used historically
out = append(out, "TDRC", "TYER")
case "Comment":
out = append(out, "COMM")
case "Lyrics":
out = append(out, "USLT")
}
}
return out
}
func mapClearsToTXXX(keys []string) []string {
out := []string{}
for _, k := range keys {
token := normalizeKey(k)
switch token {
case "artists":
out = append(out, "Artists", "ARTISTS")
case "albumartists":
out = append(out, "AlbumArtists", "ALBUMARTISTS")
}
}
return out
}

View File

@@ -245,16 +245,61 @@ func resourcePatchHandler(fileCache FileCache) handleFunc {
}
var tags map[string]string
multi := map[string][]string{}
var clear []string
body, err := io.ReadAll(r.Body)
if err != nil {
return http.StatusBadRequest, err
}
// Accept both plain tag maps and objects with __clear__ array
var raw map[string]any
if err := json.Unmarshal(body, &raw); err == nil && raw != nil {
// extract string-valued entries into tags
tags = map[string]string{}
for k, v := range raw {
if k == "__clear__" {
// parse array of strings
if arr, ok := v.([]any); ok {
for _, itm := range arr {
if s, ok := itm.(string); ok {
s = strings.TrimSpace(s)
if s != "" {
clear = append(clear, s)
}
}
}
}
continue
}
if sv, ok := v.(string); ok {
tags[k] = sv
continue
}
if arr, ok := v.([]any); ok {
vals := []string{}
for _, itm := range arr {
if s, ok := itm.(string); ok {
s = strings.TrimSpace(s)
if s != "" {
vals = append(vals, s)
}
}
}
if len(vals) > 0 {
multi[k] = vals
}
}
}
} else {
// fallback to simple map
tags = map[string]string{}
if err := json.Unmarshal(body, &tags); err != nil {
return http.StatusBadRequest, err
}
}
err = d.RunHook(func() error {
return applyMetadataWithFFmpeg(r.Context(), d, src, tags)
return applyMetadataWithFFmpeg(r.Context(), d, src, tags, multi, clear)
}, action, src, dst, d.user)
return errToStatus(err), err
@@ -271,7 +316,7 @@ 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()).
func applyMetadataWithFFmpeg(ctx context.Context, d *data, src string, tags map[string]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{
Fs: d.user.Fs,
Path: src,
@@ -313,19 +358,91 @@ func applyMetadataWithFFmpeg(ctx context.Context, d *data, src string, tags map[
// provided non-empty fields. We explicitly map input global metadata (0)
// to output, and avoid `-map_metadata -1` which would clear everything.
args := []string{"-y", "-i", real, "-map_metadata", "0", "-c", "copy"}
changes := 0
for k, v := range tags {
if strings.TrimSpace(v) == "" {
continue
}
args = append(args, "-metadata", fmt.Sprintf("%s=%s", k, v))
changes++
// For MP3, prefer ID3v2.4 to support multi-valued tags properly
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")
if isMP3 {
args = append(args, "-id3v2_version", "4")
}
// If no non-empty changes were requested, do nothing
// If MP3 and there are clears, perform ID3v2 in-place removal first.
// This avoids ffmpeg creating unwanted empty entries (e.g., TXXX artifacts).
didID3Clear := false
if isMP3 && len(clear) > 0 {
frames := mapClearsToID3Frames(clear)
txxx := mapClearsToTXXX(clear)
removeNumeric := false
for _, c := range clear {
tok := normalizeKey(c)
if tok == "artists" || tok == "albumartists" {
removeNumeric = true
break
}
}
if err := clearID3v2Frames(real, frames, txxx, removeNumeric); err == nil {
didID3Clear = true
}
// If ID3 clear failed, we'll still try ffmpeg below; but we won't add ffmpeg clears for MP3.
}
// Normalize incoming keys and map to ffmpeg keys
norm := normalizeAndMapToFFmpeg(tags)
changes := 0
setKeys := map[string]struct{}{}
for k, v := range norm {
args = append(args, "-metadata", fmt.Sprintf("%s=%s", k, v))
changes++
setKeys[k] = struct{}{}
}
// Handle multi-valued tags (e.g., Artists, AlbumArtists) by adding repeated metadata entries
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.
if isMP4 && (ffk == "artist" || ffk == "album_artist") {
if len(vals) > 0 {
joined := strings.Join(vals, "; ")
args = append(args, "-metadata", fmt.Sprintf("%s=%s", ffk, joined))
changes++
setKeys[ffk] = struct{}{}
}
continue
}
for _, v := range vals {
args = append(args, "-metadata", fmt.Sprintf("%s=%s", ffk, v))
changes++
}
setKeys[ffk] = struct{}{}
}
}
// Map cleared canonical keys to ffmpeg keys and set to empty value.
// For MP3, we skip ffmpeg-based clears because ID3v2 in-place removal is preferred.
if len(clear) > 0 && !isMP3 {
for ffk := range mapClearCanonicalsToFFmpeg(clear) {
// Avoid clearing a key we are explicitly setting in this operation,
// which could result in an empty extra value (e.g., trailing NUL).
if _, isSet := setKeys[ffk]; isSet {
continue
}
args = append(args, "-metadata", fmt.Sprintf("%s=", ffk))
changes++
}
}
// If no ffmpeg changes are needed (e.g., only clears on MP3), we can return.
if changes == 0 {
// If we performed ID3 clear or there was simply nothing to change, exit early.
if didID3Clear || len(clear) == 0 {
if isMP3 {
// Best-effort cleanup of numeric/empty TXXX artifacts
_ = cleanupNumericEmptyTXXX(real)
}
return nil
}
// Otherwise continue and let ffmpeg run (non-MP3 clears already added above).
}
args = append(args, tmp)
cmd := exec.CommandContext(ctx, "ffmpeg", args...)
@@ -339,7 +456,10 @@ func applyMetadataWithFFmpeg(ctx context.Context, d *data, src string, tags map[
if err := os.Rename(tmp, real); err != nil {
return err
}
if isMP3 {
// Post-write cleanup for numeric/empty TXXX artifacts
_ = cleanupNumericEmptyTXXX(real)
}
return nil
}