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
|
filebrowser.pid
|
||||||
frontend-dev.log
|
frontend-dev.log
|
||||||
frontend.pid
|
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
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
if header == "" && defaultAuther != nil {
|
if header == "" && defaultAuther != nil {
|
||||||
header = defaultAuther["header"].(string)
|
header = defaultAuther["header"].(string)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -35,22 +35,24 @@ var (
|
|||||||
// FileInfo describes a file.
|
// FileInfo describes a file.
|
||||||
type FileInfo struct {
|
type FileInfo struct {
|
||||||
*Listing
|
*Listing
|
||||||
Fs afero.Fs `json:"-"`
|
Fs afero.Fs `json:"-"`
|
||||||
Path string `json:"path"`
|
Path string `json:"path"`
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
Size int64 `json:"size"`
|
Size int64 `json:"size"`
|
||||||
Extension string `json:"extension"`
|
Extension string `json:"extension"`
|
||||||
ModTime time.Time `json:"modified"`
|
ModTime time.Time `json:"modified"`
|
||||||
Mode os.FileMode `json:"mode"`
|
Mode os.FileMode `json:"mode"`
|
||||||
IsDir bool `json:"isDir"`
|
IsDir bool `json:"isDir"`
|
||||||
IsSymlink bool `json:"isSymlink"`
|
IsSymlink bool `json:"isSymlink"`
|
||||||
Type string `json:"type"`
|
Type string `json:"type"`
|
||||||
Subtitles []string `json:"subtitles,omitempty"`
|
Subtitles []string `json:"subtitles,omitempty"`
|
||||||
Content string `json:"content,omitempty"`
|
Content string `json:"content,omitempty"`
|
||||||
Checksums map[string]string `json:"checksums,omitempty"`
|
Checksums map[string]string `json:"checksums,omitempty"`
|
||||||
Token string `json:"token,omitempty"`
|
// Tags holds audio metadata (ID3, VorbisComment, MP4 tags, ...)
|
||||||
currentDir []os.FileInfo `json:"-"`
|
Tags map[string]interface{} `json:"tags,omitempty"`
|
||||||
Resolution *ImageResolution `json:"resolution,omitempty"`
|
Token string `json:"token,omitempty"`
|
||||||
|
currentDir []os.FileInfo `json:"-"`
|
||||||
|
Resolution *ImageResolution `json:"resolution,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// FileOptions are the options when getting a file info.
|
// 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.
|
// Fetch metadata for a resource. Backend support is required for this to work.
|
||||||
const res = await resourceAction(`${url}?metadata=1`, "GET");
|
const res = await resourceAction(`${url}?metadata=1`, "GET");
|
||||||
try {
|
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) {
|
} catch (e) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,22 +14,42 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<template v-else>
|
<template v-else>
|
||||||
<div v-for="field in fields" :key="field" class="metadata-field">
|
<div class="metadata-table-wrapper">
|
||||||
<p class="metadata-title"><strong>{{ fieldLabels[field] || field }}</strong></p>
|
<table class="metadata-table">
|
||||||
<p class="metadata-current">{{ displayCurrent(field) }}</p>
|
<colgroup>
|
||||||
<div class="metadata-edit">
|
<col class="col-label" />
|
||||||
<input
|
<col class="col-current" />
|
||||||
v-model="newValues[field]"
|
<col class="col-new" />
|
||||||
:placeholder="placeholders[field] || ''"
|
</colgroup>
|
||||||
type="text"
|
<thead>
|
||||||
/>
|
<tr>
|
||||||
<button
|
<th class="hdr-field">{{ trans('prompts.field','Field') }}</th>
|
||||||
class="button button--flat"
|
<th class="hdr-current">{{ trans('prompts.current','Current') }}</th>
|
||||||
@click.prevent="applyField(field)"
|
<th class="hdr-new">{{ trans('prompts.new','New') }}</th>
|
||||||
>
|
</tr>
|
||||||
{{ $t('buttons.apply') }}
|
</thead>
|
||||||
</button>
|
<tbody>
|
||||||
</div>
|
<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>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
@@ -37,13 +57,24 @@
|
|||||||
<div class="card-action">
|
<div class="card-action">
|
||||||
<button
|
<button
|
||||||
id="focus-prompt"
|
id="focus-prompt"
|
||||||
type="submit"
|
type="button"
|
||||||
@click="closeHovers"
|
@click="closeHovers"
|
||||||
class="button button--flat"
|
class="button button--flat"
|
||||||
:aria-label="$t('buttons.ok')"
|
:aria-label="trans('buttons.ok','OK')"
|
||||||
:title="$t('buttons.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>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -58,32 +89,22 @@ import { metadata as apiMetadata, updateMetadata } from "@/api/files";
|
|||||||
export default {
|
export default {
|
||||||
name: "modifyMetadata",
|
name: "modifyMetadata",
|
||||||
inject: ["$showError"],
|
inject: ["$showError"],
|
||||||
data() {
|
data() {
|
||||||
return {
|
return {
|
||||||
fields: ["title", "artist", "album", "track", "genre", "date", "comment"],
|
// fields will be derived dynamically from metadata across selected files
|
||||||
fieldLabels: {
|
fields: [],
|
||||||
title: this.$t ? this.$t('prompts.title') : 'title',
|
fieldLabels: {},
|
||||||
artist: this.$t ? this.$t('prompts.artist') : 'artist',
|
placeholders: {},
|
||||||
album: this.$t ? this.$t('prompts.album') : 'album',
|
metadataList: [],
|
||||||
track: this.$t ? this.$t('prompts.track') : 'track',
|
newValues: {},
|
||||||
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: {},
|
|
||||||
};
|
|
||||||
},
|
|
||||||
computed: {
|
computed: {
|
||||||
...mapState(useFileStore, ["req", "selected", "selectedCount"]),
|
...mapState(useFileStore, ["req", "selected", "selectedCount"]),
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
...mapActions(useLayoutStore, ["closeHovers"]),
|
...mapActions(useLayoutStore, ["closeHovers"]),
|
||||||
async mountedFetch() {
|
async mountedFetch() {
|
||||||
// Initialize newValues
|
|
||||||
this.fields.forEach((f) => (this.newValues[f] = ""));
|
|
||||||
|
|
||||||
if (!this.req) return;
|
if (!this.req) return;
|
||||||
|
|
||||||
const files = this.selected.map((i) => this.req.items[i].url);
|
const files = this.selected.map((i) => this.req.items[i].url);
|
||||||
@@ -93,29 +114,127 @@ export default {
|
|||||||
const promises = files.map((u) => apiMetadata(u));
|
const promises = files.map((u) => apiMetadata(u));
|
||||||
this.metadataList = (await Promise.all(promises)).map((m) => m || {});
|
this.metadataList = (await Promise.all(promises)).map((m) => m || {});
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
// If fetching metadata fails, just keep empty list
|
|
||||||
this.metadataList = [];
|
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) {
|
displayCurrent(field) {
|
||||||
if (!this.metadataList || this.metadataList.length === 0) return "";
|
if (!this.metadataList || this.metadataList.length === 0) return "";
|
||||||
|
|
||||||
const vals = this.metadataList.map((m) => m[field] ?? "");
|
const vals = this.metadataList.map((m) => {
|
||||||
const allEqual = vals.every((v) => v === vals[0]);
|
const v = m[field];
|
||||||
if (allEqual) return vals[0];
|
if (v === undefined || v === null) return "";
|
||||||
return this.$t ? this.$t('prompts.multipleValues') : '(multiple values)';
|
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;
|
if (!this.req) return;
|
||||||
|
|
||||||
const value = this.newValues[field];
|
// collect changed fields
|
||||||
if (value === undefined || value === null || value === "") return;
|
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);
|
const files = this.selected.map((i) => this.req.items[i].url);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await Promise.all(files.map((u) => updateMetadata(u, { [field]: value })));
|
// apply changes to each file
|
||||||
// trigger a refresh of listing
|
await Promise.all(
|
||||||
|
files.map((u) => updateMetadata(u, changes))
|
||||||
|
);
|
||||||
|
// refresh listing
|
||||||
const fileStore = useFileStore();
|
const fileStore = useFileStore();
|
||||||
fileStore.reload = true;
|
fileStore.reload = true;
|
||||||
this.closeHovers();
|
this.closeHovers();
|
||||||
@@ -123,6 +242,7 @@ export default {
|
|||||||
this.$showError(e);
|
this.$showError(e);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
// per-field apply removed in favor of global applyAll
|
||||||
},
|
},
|
||||||
mounted() {
|
mounted() {
|
||||||
this.mountedFetch();
|
this.mountedFetch();
|
||||||
@@ -131,6 +251,26 @@ export default {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<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 {
|
.metadata-field {
|
||||||
margin-bottom: 1rem;
|
margin-bottom: 1rem;
|
||||||
}
|
}
|
||||||
@@ -148,4 +288,117 @@ export default {
|
|||||||
.metadata-edit input {
|
.metadata-edit input {
|
||||||
flex: 1 1 auto;
|
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>
|
</style>
|
||||||
|
|||||||
@@ -22,6 +22,7 @@
|
|||||||
"new": "New",
|
"new": "New",
|
||||||
"next": "Next",
|
"next": "Next",
|
||||||
"ok": "OK",
|
"ok": "OK",
|
||||||
|
"apply": "Apply",
|
||||||
"permalink": "Get Permanent Link",
|
"permalink": "Get Permanent Link",
|
||||||
"previous": "Previous",
|
"previous": "Previous",
|
||||||
"preview": "Preview",
|
"preview": "Preview",
|
||||||
@@ -159,7 +160,18 @@
|
|||||||
"uploadMessage": "Select an option to upload.",
|
"uploadMessage": "Select an option to upload.",
|
||||||
"optionalPassword": "Optional password",
|
"optionalPassword": "Optional password",
|
||||||
"resolution": "Resolution",
|
"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": {
|
"search": {
|
||||||
"images": "Images",
|
"images": "Images",
|
||||||
|
|||||||
@@ -22,6 +22,7 @@
|
|||||||
"new": "Nouveau",
|
"new": "Nouveau",
|
||||||
"next": "Suivant",
|
"next": "Suivant",
|
||||||
"ok": "OK",
|
"ok": "OK",
|
||||||
|
"apply": "Appliquer",
|
||||||
"permalink": "Obtenir le lien permanent",
|
"permalink": "Obtenir le lien permanent",
|
||||||
"previous": "Précédent",
|
"previous": "Précédent",
|
||||||
"preview": "Prévisualiser",
|
"preview": "Prévisualiser",
|
||||||
@@ -159,7 +160,18 @@
|
|||||||
"uploadMessage": "Sélectionnez une option d'import.",
|
"uploadMessage": "Sélectionnez une option d'import.",
|
||||||
"optionalPassword": "Mot de passe optionnel",
|
"optionalPassword": "Mot de passe optionnel",
|
||||||
"resolution": "Résolution",
|
"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": {
|
"search": {
|
||||||
"images": "Images",
|
"images": "Images",
|
||||||
|
|||||||
2
go.mod
2
go.mod
@@ -41,6 +41,8 @@ require (
|
|||||||
github.com/bodgit/windows v1.0.1 // indirect
|
github.com/bodgit/windows v1.0.1 // indirect
|
||||||
github.com/cpuguy83/go-md2man/v2 v2.0.6 // 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/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/dsnet/compress v0.0.2-0.20230904184137-39efe44ab707 // indirect
|
||||||
github.com/dsoprea/go-logging v0.0.0-20200710184922-b02d349568dd // indirect
|
github.com/dsoprea/go-logging v0.0.0-20200710184922-b02d349568dd // indirect
|
||||||
github.com/dsoprea/go-utility/v2 v2.0.0-20221003172846-a3e1774ef349 // 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.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 h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
|
||||||
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
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 h1:w1LecBlG2Lnp8B3jk5zSuNqd7b4DXhcjwek1ei82L+c=
|
||||||
github.com/disintegration/imaging v1.6.2/go.mod h1:44/5580QXChDfwIclfc/PCwrr44amcmDAg8hxG0Ewe4=
|
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=
|
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 (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
@@ -10,9 +11,11 @@ import (
|
|||||||
"net/http"
|
"net/http"
|
||||||
"net/url"
|
"net/url"
|
||||||
"os"
|
"os"
|
||||||
|
"os/exec"
|
||||||
"path"
|
"path"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/shirou/gopsutil/v4/disk"
|
"github.com/shirou/gopsutil/v4/disk"
|
||||||
"github.com/spf13/afero"
|
"github.com/spf13/afero"
|
||||||
@@ -54,6 +57,13 @@ var resourceGetHandler = withUser(func(w http.ResponseWriter, r *http.Request, d
|
|||||||
file.Content = ""
|
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)
|
return renderJSON(w, r, file)
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -201,14 +211,20 @@ func resourcePatchHandler(fileCache FileCache) handleFunc {
|
|||||||
return http.StatusForbidden, nil
|
return http.StatusForbidden, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
err = checkParent(src, dst)
|
// Only check parent relationship when a destination was provided.
|
||||||
if err != nil {
|
// Some actions (like metadata) don't provide a destination and calling
|
||||||
return http.StatusBadRequest, err
|
// 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"
|
override := r.URL.Query().Get("override") == "true"
|
||||||
rename := r.URL.Query().Get("rename") == "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 {
|
if _, err = d.user.Fs.Stat(dst); err == nil {
|
||||||
return http.StatusConflict, nil
|
return http.StatusConflict, nil
|
||||||
}
|
}
|
||||||
@@ -222,6 +238,28 @@ func resourcePatchHandler(fileCache FileCache) handleFunc {
|
|||||||
return http.StatusForbidden, nil
|
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 {
|
err = d.RunHook(func() error {
|
||||||
return patchAction(r.Context(), action, src, dst, d, fileCache)
|
return patchAction(r.Context(), action, src, dst, d, fileCache)
|
||||||
}, action, src, dst, d.user)
|
}, 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 {
|
func checkParent(src, dst string) error {
|
||||||
rel, err := filepath.Rel(src, dst)
|
rel, err := filepath.Rel(src, dst)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
Reference in New Issue
Block a user