diff --git a/Dockerfile.full b/Dockerfile.full
new file mode 100644
index 00000000..79e0ace9
--- /dev/null
+++ b/Dockerfile.full
@@ -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" ]
diff --git a/frontend/src/api/files.ts b/frontend/src/api/files.ts
index 1facfe0a..546b5c06 100644
--- a/frontend/src/api/files.ts
+++ b/frontend/src/api/files.ts
@@ -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));
}
diff --git a/frontend/src/components/prompts/ModifyMetadata.vue b/frontend/src/components/prompts/ModifyMetadata.vue
index 707423fd..c8eb917b 100644
--- a/frontend/src/components/prompts/ModifyMetadata.vue
+++ b/frontend/src/components/prompts/ModifyMetadata.vue
@@ -40,7 +40,11 @@
v-model="addFieldKey"
type="text"
:placeholder="trans('prompts.newFieldName','Field name')"
+ list="tag-suggestions"
/>
+
- | {{ fieldLabels[field] || humanize(field) }} |
+
+ {{ fieldLabel(field) }}
+ |
@@ -91,11 +97,22 @@
|
-
+
+
+
+
|
@@ -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 (!(key in this.newValues)) this.newValues[key] = "";
- if (val !== "") this.newValues[key] = val;
+ 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%;
diff --git a/frontend/src/utils/metadata.ts b/frontend/src/utils/metadata.ts
new file mode 100644
index 00000000..3f0a57f8
--- /dev/null
+++ b/frontend/src/utils/metadata.ts
@@ -0,0 +1,213 @@
+// Frontend helpers for metadata fields: canonicalization, labels, and payload normalization
+
+export type Changes = Record;
+export type MultiChanges = Record; // 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(Object.values(Canonical));
+
+// Map various inputs (singular/plural/localized) to canonical names
+const canonicalMap: Record = {
+ // 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): Record {
+ const out: Record = {};
+ 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;
+}
diff --git a/frontend/src/views/files/FileListing.vue b/frontend/src/views/files/FileListing.vue
index 7125e518..dfd2f94e 100644
--- a/frontend/src/views/files/FileListing.vue
+++ b/frontend/src/views/files/FileListing.vue
@@ -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);
}),
};
});
diff --git a/go.mod b/go.mod
index 7e6a3486..d280193a 100644
--- a/go.mod
+++ b/go.mod
@@ -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
diff --git a/http/id3_clear.go b/http/id3_clear.go
new file mode 100644
index 00000000..f4e660d8
--- /dev/null
+++ b/http/id3_clear.go
@@ -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()
+}
diff --git a/http/metadata.go b/http/metadata.go
new file mode 100644
index 00000000..b2628e62
--- /dev/null
+++ b/http/metadata.go
@@ -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
+}
diff --git a/http/resource.go b/http/resource.go
index 2d47dc07..98944377 100644
--- a/http/resource.go
+++ b/http/resource.go
@@ -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
}
- if err := json.Unmarshal(body, &tags); 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,18 +358,90 @@ 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 {
- return nil
+ // If we performed ID3 clear or there was simply nothing to change, exit early.
+ if didID3Clear || len(clear) == 0 {
+ if isMP3 {
+ // Best-effort cleanup of numeric/empty TXXX artifacts
+ _ = cleanupNumericEmptyTXXX(real)
+ }
+ return nil
+ }
+ // Otherwise continue and let ffmpeg run (non-MP3 clears already added above).
}
args = append(args, tmp)
@@ -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
}