Add native MP3 metadata writer and prefer it over ffmpeg for .mp3 files

This commit is contained in:
vcadoux
2026-01-27 15:37:27 +01:00
parent 69b54f1ae8
commit a58f930d58
14 changed files with 681 additions and 74 deletions

5
.gitignore vendored
View File

@@ -41,3 +41,8 @@ filebrowser.log
filebrowser.pid
frontend-dev.log
frontend.pid
SWAG.mp3
test/M5.flac
test/MALIBU.flac
test/SWAG.mp3
test

BIN
SWAG.mp3

Binary file not shown.

View File

@@ -99,7 +99,7 @@ func getProxyAuth(flags *pflag.FlagSet, defaultAuther map[string]interface{}) (a
return nil, err
}
if header == "" && defaultAuther != nil {
if header == "" && defaultAuther != nil {
header = defaultAuther["header"].(string)
}

View File

@@ -35,22 +35,24 @@ var (
// FileInfo describes a file.
type FileInfo struct {
*Listing
Fs afero.Fs `json:"-"`
Path string `json:"path"`
Name string `json:"name"`
Size int64 `json:"size"`
Extension string `json:"extension"`
ModTime time.Time `json:"modified"`
Mode os.FileMode `json:"mode"`
IsDir bool `json:"isDir"`
IsSymlink bool `json:"isSymlink"`
Type string `json:"type"`
Subtitles []string `json:"subtitles,omitempty"`
Content string `json:"content,omitempty"`
Checksums map[string]string `json:"checksums,omitempty"`
Token string `json:"token,omitempty"`
currentDir []os.FileInfo `json:"-"`
Resolution *ImageResolution `json:"resolution,omitempty"`
Fs afero.Fs `json:"-"`
Path string `json:"path"`
Name string `json:"name"`
Size int64 `json:"size"`
Extension string `json:"extension"`
ModTime time.Time `json:"modified"`
Mode os.FileMode `json:"mode"`
IsDir bool `json:"isDir"`
IsSymlink bool `json:"isSymlink"`
Type string `json:"type"`
Subtitles []string `json:"subtitles,omitempty"`
Content string `json:"content,omitempty"`
Checksums map[string]string `json:"checksums,omitempty"`
// Tags holds audio metadata (ID3, VorbisComment, MP4 tags, ...)
Tags map[string]interface{} `json:"tags,omitempty"`
Token string `json:"token,omitempty"`
currentDir []os.FileInfo `json:"-"`
Resolution *ImageResolution `json:"resolution,omitempty"`
}
// FileOptions are the options when getting a file info.

100
files/tags.go Normal file
View File

@@ -0,0 +1,100 @@
package files
import (
"bytes"
"encoding/base64"
"io"
"log"
"strings"
"github.com/dhowden/tag"
)
// ReadAudioTags extracts common audio metadata from the file and stores it in
// the FileInfo.Tags map. It attempts to extract common fields and includes
// embedded picture(s) as base64 where present.
func (i *FileInfo) ReadAudioTags() error {
if i.IsDir {
return nil
}
f, err := i.Fs.Open(i.Path)
if err != nil {
return err
}
defer f.Close()
m, err := tag.ReadFrom(f)
if err != nil {
// don't fail hard; log and return nil so callers still get file info
log.Printf("ReadAudioTags: failed to read tags for %s: %v", i.Path, err)
return nil
}
tags := map[string]interface{}{}
if v := m.Title(); v != "" {
tags["title"] = v
}
if v := m.Album(); v != "" {
tags["album"] = v
}
if v := m.Artist(); v != "" {
tags["artist"] = v
}
if v := m.AlbumArtist(); v != "" {
tags["albumartist"] = v
}
if v := m.Composer(); v != "" {
tags["composer"] = v
}
if v := m.Year(); v != 0 {
tags["year"] = v
}
if t, _ := m.Track(); t > 0 {
tags["track"] = t
}
if dnum, _ := m.Disc(); dnum > 0 {
tags["disc"] = dnum
}
if v := m.Genre(); v != "" {
tags["genre"] = v
}
// Pictures: encode as data URI (base64) so frontend can preview if needed.
if pic := m.Picture(); pic != nil {
// safe encode image bytes as base64
var buf bytes.Buffer
if _, err := io.Copy(&buf, bytes.NewReader(pic.Data)); err == nil {
b64 := base64.StdEncoding.EncodeToString(buf.Bytes())
dataURI := "data:" + pic.MIMEType + ";base64," + b64
tags["picture"] = map[string]interface{}{
"mime": pic.MIMEType,
"data": dataURI,
}
}
}
// Keep raw metadata map if available (some formats expose additional fields)
if raw := m.Raw(); raw != nil {
for k, v := range raw {
// Skip raw APIC entries (attached picture frame) to avoid
// exposing large binary blobs. We already expose a friendly
// `picture` key above when a picture exists.
if strings.EqualFold(k, "APIC") {
continue
}
// avoid overwriting already set common fields
if _, ok := tags[k]; !ok {
tags[k] = v
}
}
}
// Attach tags map
if len(tags) > 0 {
i.Tags = tags
}
return nil
}

66
files/write_mp3.go Normal file
View File

@@ -0,0 +1,66 @@
package files
import (
"fmt"
"strings"
id3v2 "github.com/bogem/id3v2/v2"
)
// applyMP3Tags writes only the provided non-empty tags to the MP3 file at
// realPath. It preserves existing frames when not overwritten.
func applyMP3Tags(realPath string, tags map[string]string) error {
// Open tag for read/write
tag, err := id3v2.Open(realPath, id3v2.Options{Parse: true})
if err != nil {
return fmt.Errorf("open id3v2: %w", err)
}
defer tag.Close()
// Helper to set a text frame (replace existing)
setFrame := func(frameID, value string) {
if strings.TrimSpace(value) == "" {
return
}
tag.DeleteAllFrames(frameID)
tag.AddTextFrame(frameID, tag.DefaultEncoding(), value)
}
for k, v := range tags {
if strings.TrimSpace(v) == "" {
continue
}
switch strings.ToLower(k) {
case "title":
setFrame("TIT2", v)
case "artist":
setFrame("TPE1", v)
case "album":
setFrame("TALB", v)
case "albumartist", "album artist":
setFrame("TPE2", v)
case "composer":
setFrame("TCOM", v)
case "year", "date":
setFrame("TDRC", v)
case "track":
setFrame("TRCK", v)
case "disc":
setFrame("TPOS", v)
case "genre":
setFrame("TCON", v)
default:
// write custom text frame under TXXX with description = key
// Use description to store the original key so we don't collide
// with standard frames.
// Note: bogem/id3v2 uses AddTextFrame for TXXX as well.
tag.AddTextFrame("TXXX", tag.DefaultEncoding(), v)
}
}
if err := tag.Save(); err != nil {
return fmt.Errorf("save id3v2: %w", err)
}
return nil
}

View File

@@ -192,7 +192,13 @@ export async function metadata(url: string) {
// Fetch metadata for a resource. Backend support is required for this to work.
const res = await resourceAction(`${url}?metadata=1`, "GET");
try {
return await res.json();
const data = await res.json();
// backend returns a FileInfo object; prefer returning the audio tags map when present
if (data && typeof data === "object" && data.tags) {
return data.tags;
}
// fallback: if server returned just the tags map or other structure
return data;
} catch (e) {
return null;
}

View File

@@ -14,22 +14,42 @@
</template>
<template v-else>
<div v-for="field in fields" :key="field" class="metadata-field">
<p class="metadata-title"><strong>{{ fieldLabels[field] || field }}</strong></p>
<p class="metadata-current">{{ displayCurrent(field) }}</p>
<div class="metadata-edit">
<input
v-model="newValues[field]"
:placeholder="placeholders[field] || ''"
type="text"
/>
<button
class="button button--flat"
@click.prevent="applyField(field)"
>
{{ $t('buttons.apply') }}
</button>
</div>
<div class="metadata-table-wrapper">
<table class="metadata-table">
<colgroup>
<col class="col-label" />
<col class="col-current" />
<col class="col-new" />
</colgroup>
<thead>
<tr>
<th class="hdr-field">{{ trans('prompts.field','Field') }}</th>
<th class="hdr-current">{{ trans('prompts.current','Current') }}</th>
<th class="hdr-new">{{ trans('prompts.new','New') }}</th>
</tr>
</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-current">
<template v-if="isPicture(field)">
<img :src="pictureSrc(field)" alt="cover" class="cover-preview" v-if="pictureSrc(field)" />
<span v-else class="metadata-muted">{{ trans('prompts.noCover','No cover') }}</span>
</template>
<template v-else>
{{ displayCurrent(field) }}
</template>
</td>
<td class="metadata-edit-cell">
<input
v-model="newValues[field]"
:placeholder="placeholders[field] || ''"
type="text"
/>
</td>
</tr>
</tbody>
</table>
</div>
</template>
</div>
@@ -37,13 +57,24 @@
<div class="card-action">
<button
id="focus-prompt"
type="submit"
type="button"
@click="closeHovers"
class="button button--flat"
:aria-label="$t('buttons.ok')"
:title="$t('buttons.ok')"
:aria-label="trans('buttons.ok','OK')"
:title="trans('buttons.ok','OK')"
>
{{ $t('buttons.ok') }}
{{ trans('buttons.ok','OK') }}
</button>
<button
id="apply-metadata"
type="button"
@click.prevent="applyAll"
class="button button--primary"
:aria-label="trans('buttons.apply','Apply')"
:title="trans('buttons.apply','Apply')"
>
{{ trans('buttons.apply','Apply') }}
</button>
</div>
</div>
@@ -58,32 +89,22 @@ import { metadata as apiMetadata, updateMetadata } from "@/api/files";
export default {
name: "modifyMetadata",
inject: ["$showError"],
data() {
return {
fields: ["title", "artist", "album", "track", "genre", "date", "comment"],
fieldLabels: {
title: this.$t ? this.$t('prompts.title') : 'title',
artist: this.$t ? this.$t('prompts.artist') : 'artist',
album: this.$t ? this.$t('prompts.album') : 'album',
track: this.$t ? this.$t('prompts.track') : 'track',
genre: this.$t ? this.$t('prompts.genre') : 'genre',
date: this.$t ? this.$t('prompts.date') : 'date',
comment: this.$t ? this.$t('prompts.comment') : 'comment',
},
placeholders: {},
metadataList: [],
newValues: {},
};
},
data() {
return {
// fields will be derived dynamically from metadata across selected files
fields: [],
fieldLabels: {},
placeholders: {},
metadataList: [],
newValues: {},
};
},
computed: {
...mapState(useFileStore, ["req", "selected", "selectedCount"]),
},
methods: {
...mapActions(useLayoutStore, ["closeHovers"]),
async mountedFetch() {
// Initialize newValues
this.fields.forEach((f) => (this.newValues[f] = ""));
if (!this.req) return;
const files = this.selected.map((i) => this.req.items[i].url);
@@ -93,29 +114,127 @@ export default {
const promises = files.map((u) => apiMetadata(u));
this.metadataList = (await Promise.all(promises)).map((m) => m || {});
} catch (e) {
// If fetching metadata fails, just keep empty list
this.metadataList = [];
}
// Build union of fields
const keys = new Set();
this.metadataList.forEach((m) => {
Object.keys(m).forEach((k) => keys.add(k));
});
// Ensure stable ordering: put common fields first
const preferred = ["title", "artist", "album", "track", "genre", "date", "comment", "disc", "composer", "year", "albumartist", "picture", "cover"];
const rest = Array.from(keys).filter((k) => !preferred.includes(k)).sort();
this.fields = preferred.filter((k) => keys.has(k)).concat(rest);
// Setup field labels (use translation when available, otherwise humanize)
this.fields.forEach((f) => {
this.fieldLabels[f] = this.trans(`prompts.${f}`, this.humanize(f));
this.newValues[f] = "";
});
},
displayCurrent(field) {
if (!this.metadataList || this.metadataList.length === 0) return "";
const vals = this.metadataList.map((m) => m[field] ?? "");
const allEqual = vals.every((v) => v === vals[0]);
if (allEqual) return vals[0];
return this.$t ? this.$t('prompts.multipleValues') : '(multiple values)';
const vals = this.metadataList.map((m) => {
const v = m[field];
if (v === undefined || v === null) return "";
if (typeof v === "object") return JSON.stringify(v);
return String(v);
});
const nonEmpty = vals.filter((v) => v !== "");
if (nonEmpty.length === 0) return "";
const allEqual = nonEmpty.every((v) => v === nonEmpty[0]);
if (allEqual) return nonEmpty[0];
// Count how many distinct values
const distinct = new Set(nonEmpty);
// If fields are objects (e.g., picture) avoid returning huge JSON blobs.
// Prefer a short summary.
const sample = nonEmpty[0];
if (typeof sample === 'string') {
return this.trans('prompts.multipleValuesCount', `(different on ${distinct.size} files)`).replace('{count}', String(distinct.size));
}
try {
const s = JSON.stringify(sample);
return s.length > 200 ? s.slice(0, 200) + '…' : s;
} catch (e) {
return this.trans('prompts.multipleValuesCount', `(different on ${distinct.size} files)`).replace('{count}', String(distinct.size));
}
},
async applyField(field) {
isPicture(field) {
if (!this.metadataList || this.metadataList.length === 0) return false;
for (const m of this.metadataList) {
const v = m[field];
if (v && typeof v === 'object') {
if (v.data && typeof v.data === 'string') return true;
if (v.picture && v.picture.data) return true;
}
}
return false;
},
pictureSrc(field) {
if (!this.metadataList) return null;
for (const m of this.metadataList) {
const v = m[field];
if (v && typeof v === 'object') {
if (v.data && typeof v.data === 'string') return v.data;
if (v.picture && v.picture.data) return v.picture.data;
// sometimes library stores a raw buffer as base64 directly in 'data'
}
}
return null;
},
trans(key, fallback) {
// Fallback to a human readable string if translation key is missing
try {
if (this.$te && this.$te(key)) return this.$t(key);
} catch (e) {
// ignore
}
return fallback;
},
humanize(str) {
if (!str) return str;
// replace camelCase / snake_case / dots with spaces and capitalize
const s = String(str)
.replace(/([a-z0-9])([A-Z])/g, "$1 $2")
.replace(/[_\.\-]+/g, " ");
return s.charAt(0).toUpperCase() + s.slice(1);
},
async applyAll() {
if (!this.req) return;
const value = this.newValues[field];
if (value === undefined || value === null || value === "") return;
// collect changed fields
const changes = {};
Object.keys(this.newValues).forEach((k) => {
const v = this.newValues[k];
if (v !== undefined && v !== null && String(v).trim() !== "") {
changes[k] = v;
}
});
if (Object.keys(changes).length === 0) {
// nothing to do
return;
}
const files = this.selected.map((i) => this.req.items[i].url);
try {
await Promise.all(files.map((u) => updateMetadata(u, { [field]: value })));
// trigger a refresh of listing
// apply changes to each file
await Promise.all(
files.map((u) => updateMetadata(u, changes))
);
// refresh listing
const fileStore = useFileStore();
fileStore.reload = true;
this.closeHovers();
@@ -123,6 +242,7 @@ export default {
this.$showError(e);
}
},
// per-field apply removed in favor of global applyAll
},
mounted() {
this.mountedFetch();
@@ -131,6 +251,26 @@ export default {
</script>
<style scoped>
.metadata-card {
max-height: calc(100vh - 120px);
display: flex;
flex-direction: column;
box-sizing: border-box;
/* prefer a wider layout on large screens but stay responsive */
/* increase target width to give more horizontal space */
width: min(1400px, 98vw);
max-width: 98vw;
min-width: 680px;
}
/* Override global small max-width for floating cards for this metadata modal */
.card.floating.metadata-card {
/* remove the global max-width:25em constraint */
max-width: none !important;
width: min(1400px, 98vw) !important;
left: 50% !important;
transform: translate(-50%, -50%) !important;
}
.metadata-field {
margin-bottom: 1rem;
}
@@ -148,4 +288,117 @@ export default {
.metadata-edit input {
flex: 1 1 auto;
}
.metadata-table-wrapper {
overflow-x: auto;
width: 100%;
padding: 0 1rem; /* add horizontal padding so left column isn't flush to modal edge */
}
.metadata-table {
border-collapse: collapse;
width: auto; /* allow table to size to content */
/* allow a comfortable minimum width for readable columns on very large screens
but let it shrink responsively in smaller viewports */
min-width: 0;
max-width: 100%;
table-layout: auto;
}
.metadata-table td, .metadata-table th { word-break: break-word; }
.metadata-table th,
.metadata-table td {
border: 1px solid rgba(0,0,0,0.08);
padding: 0.5rem;
text-align: left;
vertical-align: top;
}
/* extra left padding for the first column so labels don't touch the modal edge */
.metadata-table td:first-child,
.metadata-table th:first-child,
.metadata-title {
padding-left: 1rem;
}
/* Use CSS Grid per row so columns adapt to content without one taking all space */
.metadata-table thead tr,
.metadata-table tbody tr {
display: grid;
grid-template-columns: minmax(120px, max-content) minmax(200px, 1fr) minmax(200px, 1fr);
gap: 1rem;
align-items: start;
}
.metadata-table thead tr {
border-bottom: none;
}
.metadata-table thead th,
.metadata-table tbody td {
display: block; /* make cells behave as grid items */
}
.metadata-table thead th { font-weight: 600; }
/* column sizing helpers */
.metadata-table .col-label { width: 1%; }
.metadata-table .col-current { width: auto; }
.metadata-table .col-new { width: auto; }
.metadata-edit-cell input {
width: 100%;
box-sizing: border-box;
}
/* On small screens, stack rows to improve readability */
@media (max-width: 640px) {
.metadata-table {
min-width: 0;
}
.metadata-table thead {
display: none;
}
.metadata-table tr {
display: block;
margin-bottom: 0.75rem;
border: 1px solid rgba(0,0,0,0.06);
padding: 0.5rem;
}
.metadata-table td {
display: block;
width: 100%;
padding: 0.25rem 0;
}
.metadata-title {
font-weight: 600;
}
}
/* On medium screens allow a comfortable min width; on very large screens keep roomy layout */
@media (min-width: 1200px) {
.metadata-table {
min-width: 100ch;
}
}
/* Make sure table cells wrap content and images don't overflow */
.metadata-table td, .metadata-table th {
overflow-wrap: anywhere;
word-break: break-word;
hyphens: auto;
}
.metadata-table img {
max-width: 100%;
height: auto;
display: block;
}
.cover-preview {
max-width: 160px;
max-height: 160px;
object-fit: contain;
border-radius: 4px;
box-shadow: 0 1px 3px rgba(0,0,0,0.12);
}
.metadata-muted { color: var(--color-muted, #666); font-size: 0.9em; }
.metadata-current-row td {
color: var(--color-muted, #666);
}
.metadata-edit-row td {
background: rgba(0,0,0,0.02);
}
</style>

View File

@@ -22,6 +22,7 @@
"new": "New",
"next": "Next",
"ok": "OK",
"apply": "Apply",
"permalink": "Get Permanent Link",
"previous": "Previous",
"preview": "Preview",
@@ -159,7 +160,18 @@
"uploadMessage": "Select an option to upload.",
"optionalPassword": "Optional password",
"resolution": "Resolution",
"discardEditorChanges": "Are you sure you wish to discard the changes you've made?"
"discardEditorChanges": "Are you sure you wish to discard the changes you've made?",
"field": "Field",
"current": "Current",
"new": "New",
"multipleValuesCount": "Different on {count} files",
"noCover": "No cover"
},
"metadata": {
"field": "Field",
"current": "Current",
"new": "New",
"multipleValuesCount": "Different on {count} files"
},
"search": {
"images": "Images",

View File

@@ -22,6 +22,7 @@
"new": "Nouveau",
"next": "Suivant",
"ok": "OK",
"apply": "Appliquer",
"permalink": "Obtenir le lien permanent",
"previous": "Précédent",
"preview": "Prévisualiser",
@@ -159,7 +160,18 @@
"uploadMessage": "Sélectionnez une option d'import.",
"optionalPassword": "Mot de passe optionnel",
"resolution": "Résolution",
"discardEditorChanges": "Êtes-vous sûr de vouloir annuler les modifications apportées ?"
"discardEditorChanges": "Êtes-vous sûr de vouloir annuler les modifications apportées ?",
"field": "Champ",
"current": "Actuel",
"new": "Nouveau",
"multipleValuesCount": "Différent sur {count} fichiers",
"noCover": "Pas de pochette"
},
"metadata": {
"field": "Champ",
"current": "Actuel",
"new": "Nouveau",
"multipleValuesCount": "Différent sur {count} fichiers"
},
"search": {
"images": "Images",

2
go.mod
View File

@@ -41,6 +41,8 @@ require (
github.com/bodgit/windows v1.0.1 // indirect
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 v1.0.6
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

2
go.sum
View File

@@ -53,6 +53,8 @@ github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSs
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dhowden/tag v0.0.0-20240417053706-3d75831295e8 h1:OtSeLS5y0Uy01jaKK4mA/WVIYtpzVm63vLVAPzJXigg=
github.com/dhowden/tag v0.0.0-20240417053706-3d75831295e8/go.mod h1:apkPC/CR3s48O2D7Y++n1XWEpgPNNCjXYga3PPbJe2E=
github.com/disintegration/imaging v1.6.2 h1:w1LecBlG2Lnp8B3jk5zSuNqd7b4DXhcjwek1ei82L+c=
github.com/disintegration/imaging v1.6.2/go.mod h1:44/5580QXChDfwIclfc/PCwrr44amcmDAg8hxG0Ewe4=
github.com/dsnet/compress v0.0.2-0.20230904184137-39efe44ab707 h1:2tV76y6Q9BB+NEBasnqvs7e49aEBFI8ejC89PSnWH+4=

12
go1.25.linux-amd64.tar.gz Normal file
View File

@@ -0,0 +1,12 @@
<!DOCTYPE html>
<html lang=en>
<meta charset=utf-8>
<meta name=viewport content="initial-scale=1, minimum-scale=1, width=device-width">
<title>Error 404 (Not Found)!!1</title>
<style>
*{margin:0;padding:0}html,code{font:15px/22px arial,sans-serif}html{background:#fff;color:#222;padding:15px}body{margin:7% auto 0;max-width:390px;min-height:180px;padding:30px 0 15px}* > body{background:url(//www.google.com/images/errors/robot.png) 100% 5px no-repeat;padding-right:205px}p{margin:11px 0 22px;overflow:hidden}ins{color:#777;text-decoration:none}a img{border:0}@media screen and (max-width:772px){body{background:none;margin-top:0;max-width:none;padding-right:0}}#logo{background:url(//www.google.com/images/logos/errorpage/error_logo-150x54.png) no-repeat;margin-left:-5px}@media only screen and (min-resolution:192dpi){#logo{background:url(//www.google.com/images/logos/errorpage/error_logo-150x54-2x.png) no-repeat 0% 0%/100% 100%;-moz-border-image:url(//www.google.com/images/logos/errorpage/error_logo-150x54-2x.png) 0}}@media only screen and (-webkit-min-device-pixel-ratio:2){#logo{background:url(//www.google.com/images/logos/errorpage/error_logo-150x54-2x.png) no-repeat;-webkit-background-size:100% 100%}}#logo{display:inline-block;height:54px;width:150px}
</style>
<a href=//www.google.com/><span id=logo aria-label=Google></span></a>
<p><b>404.</b> <ins>Thats an error.</ins>
<p> <ins>Thats all we know.</ins>

View File

@@ -2,6 +2,7 @@ package fbhttp
import (
"context"
"encoding/json"
"errors"
"fmt"
"io"
@@ -10,9 +11,11 @@ import (
"net/http"
"net/url"
"os"
"os/exec"
"path"
"path/filepath"
"strings"
"time"
"github.com/shirou/gopsutil/v4/disk"
"github.com/spf13/afero"
@@ -54,6 +57,13 @@ var resourceGetHandler = withUser(func(w http.ResponseWriter, r *http.Request, d
file.Content = ""
}
// If metadata requested, attempt to read audio tags and attach them
if r.URL.Query().Get("metadata") == "1" {
if err := file.ReadAudioTags(); err != nil {
log.Printf("warning: failed reading audio tags for %s: %v", file.Path, err)
}
}
return renderJSON(w, r, file)
})
@@ -201,14 +211,20 @@ func resourcePatchHandler(fileCache FileCache) handleFunc {
return http.StatusForbidden, nil
}
err = checkParent(src, dst)
if err != nil {
return http.StatusBadRequest, err
// Only check parent relationship when a destination was provided.
// Some actions (like metadata) don't provide a destination and calling
// filepath.Rel with an empty dst returns an error "can't make relative to ...".
if dst != "" {
err = checkParent(src, dst)
if err != nil {
return http.StatusBadRequest, err
}
}
override := r.URL.Query().Get("override") == "true"
rename := r.URL.Query().Get("rename") == "true"
if !override && !rename {
// Only check destination existence when a destination was provided.
if dst != "" && !override && !rename {
if _, err = d.user.Fs.Stat(dst); err == nil {
return http.StatusConflict, nil
}
@@ -222,6 +238,28 @@ func resourcePatchHandler(fileCache FileCache) handleFunc {
return http.StatusForbidden, nil
}
// Special-case metadata action because it needs the request body
if action == "metadata" {
if !d.user.Perm.Modify {
return http.StatusForbidden, nil
}
var tags map[string]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
}
err = d.RunHook(func() error {
return applyMetadataWithFFmpeg(r.Context(), d, src, tags)
}, action, src, dst, d.user)
return errToStatus(err), err
}
err = d.RunHook(func() error {
return patchAction(r.Context(), action, src, dst, d, fileCache)
}, action, src, dst, d.user)
@@ -230,6 +268,103 @@ 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 {
fi, err := files.NewFileInfo(&files.FileOptions{
Fs: d.user.Fs,
Path: src,
Modify: d.user.Perm.Modify,
Expand: false,
ReadHeader: false,
Checker: d,
})
if err != nil {
return err
}
real := fi.RealPath()
// If RealPath returns the same virtual path, we cannot run native writers
// or ffmpeg on it.
if real == "" || real == fi.Path {
return fmt.Errorf("unable to obtain underlying real file path for %s: %w", fi.Path, fberrors.ErrInvalidRequestParams)
}
// If it's an MP3, try the native writer first to avoid ffmpeg and preserve
// existing tags accurately.
if strings.EqualFold(filepath.Ext(real), ".mp3") {
if err := files.applyMP3Tags(real, tags); err == nil {
return nil
} else {
// log and fall back to ffmpeg
log.Printf("applyMetadataWithFFmpeg: native mp3 writer failed: %v", err)
}
}
// Ensure ffmpeg is available
if _, err := exec.LookPath("ffmpeg"); err != nil {
return fmt.Errorf("ffmpeg not found: %w", err)
}
dir := filepath.Dir(real)
// Create a unique temporary filename in the same directory and keep the
// same extension as the original so ffmpeg can infer the output format.
ext := filepath.Ext(real)
tmp := filepath.Join(dir, fmt.Sprintf(".metadata_tmp_%d%s", time.Now().UnixNano(), ext))
// Ensure the temp file is removed on error
defer func() {
if _, statErr := os.Stat(tmp); statErr == nil {
_ = os.Remove(tmp)
}
}()
// Build metadata pairs but only include non-empty values so we don't
// overwrite existing tags with empty values coming from the client.
// Important: do NOT strip all metadata with -map_metadata -1 here —
// that would remove unmodified tags. Instead, keep existing metadata
// and only pass the -metadata pairs for the keys we want to change.
var metaPairs []string
for k, v := range tags {
if strings.TrimSpace(v) == "" {
continue
}
metaPairs = append(metaPairs, "-metadata", fmt.Sprintf("%s=%s", k, v))
}
// If there are no metadata pairs to set, there's nothing to do.
if len(metaPairs) == 0 {
return nil
}
args := []string{"-y", "-i", real, "-c", "copy"}
args = append(args, metaPairs...)
args = append(args, tmp)
cmd := exec.CommandContext(ctx, "ffmpeg", args...)
// Log the command (args) so we can debug unexpected metadata overwrites.
log.Printf("applyMetadataWithFFmpeg: ffmpeg %v", args)
// Capture combined output to provide actionable errors and log it for
// successful runs as well so we can inspect what ffmpeg actually wrote.
out, err := cmd.CombinedOutput()
if len(out) > 0 {
log.Printf("applyMetadataWithFFmpeg: ffmpeg output: %s", strings.TrimSpace(string(out)))
}
if err != nil {
return fmt.Errorf("ffmpeg error: %w: %s", err, strings.TrimSpace(string(out)))
}
// replace original
if err := os.Rename(tmp, real); err != nil {
return err
}
return nil
}
func checkParent(src, dst string) error {
rel, err := filepath.Rel(src, dst)
if err != nil {