diff --git a/.gitignore b/.gitignore index b619ca6c..266334d0 100644 --- a/.gitignore +++ b/.gitignore @@ -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 diff --git a/SWAG.mp3 b/SWAG.mp3 deleted file mode 100644 index d3868992..00000000 Binary files a/SWAG.mp3 and /dev/null differ diff --git a/cmd/config.go b/cmd/config.go index 5b3314ed..201d1c51 100644 --- a/cmd/config.go +++ b/cmd/config.go @@ -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) } diff --git a/files/file.go b/files/file.go index 2ba432dc..2d5ba348 100644 --- a/files/file.go +++ b/files/file.go @@ -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. diff --git a/files/tags.go b/files/tags.go new file mode 100644 index 00000000..9ce74516 --- /dev/null +++ b/files/tags.go @@ -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 +} diff --git a/files/write_mp3.go b/files/write_mp3.go new file mode 100644 index 00000000..297872f1 --- /dev/null +++ b/files/write_mp3.go @@ -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 +} diff --git a/frontend/src/api/files.ts b/frontend/src/api/files.ts index 415711ea..1facfe0a 100644 --- a/frontend/src/api/files.ts +++ b/frontend/src/api/files.ts @@ -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; } diff --git a/frontend/src/components/prompts/ModifyMetadata.vue b/frontend/src/components/prompts/ModifyMetadata.vue index 16677ab1..71d38ee0 100644 --- a/frontend/src/components/prompts/ModifyMetadata.vue +++ b/frontend/src/components/prompts/ModifyMetadata.vue @@ -14,22 +14,42 @@ @@ -37,13 +57,24 @@
+ +
@@ -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 { diff --git a/frontend/src/i18n/en.json b/frontend/src/i18n/en.json index 3fcdf635..3be05eb1 100644 --- a/frontend/src/i18n/en.json +++ b/frontend/src/i18n/en.json @@ -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", diff --git a/frontend/src/i18n/fr.json b/frontend/src/i18n/fr.json index 798cee50..2d728342 100644 --- a/frontend/src/i18n/fr.json +++ b/frontend/src/i18n/fr.json @@ -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", diff --git a/go.mod b/go.mod index edf46277..a631adda 100644 --- a/go.mod +++ b/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 diff --git a/go.sum b/go.sum index 45dff859..133e880e 100644 --- a/go.sum +++ b/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= diff --git a/go1.25.linux-amd64.tar.gz b/go1.25.linux-amd64.tar.gz new file mode 100644 index 00000000..7d996939 --- /dev/null +++ b/go1.25.linux-amd64.tar.gz @@ -0,0 +1,12 @@ + + + + + + Error 404 (Not Found)!!1 + + +

404. That’s an error. +

That’s all we know. diff --git a/http/resource.go b/http/resource.go index 0a8da271..58832a05 100644 --- a/http/resource.go +++ b/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 {