Add native MP3 metadata writer and prefer it over ffmpeg for .mp3 files
This commit is contained in:
5
.gitignore
vendored
5
.gitignore
vendored
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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
100
files/tags.go
Normal 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
66
files/write_mp3.go
Normal 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
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
2
go.mod
@@ -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
2
go.sum
@@ -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
12
go1.25.linux-amd64.tar.gz
Normal 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>That’s an error.</ins>
|
||||
<p> <ins>That’s all we know.</ins>
|
||||
143
http/resource.go
143
http/resource.go
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user