fix,add: TXXX adding, delete option, dictionnary, M4A support
This commit is contained in:
148
http/resource.go
148
http/resource.go
@@ -245,16 +245,61 @@ func resourcePatchHandler(fileCache FileCache) handleFunc {
|
||||
}
|
||||
|
||||
var tags map[string]string
|
||||
multi := map[string][]string{}
|
||||
var clear []string
|
||||
body, err := io.ReadAll(r.Body)
|
||||
if err != nil {
|
||||
return http.StatusBadRequest, err
|
||||
}
|
||||
if err := json.Unmarshal(body, &tags); err != nil {
|
||||
return http.StatusBadRequest, err
|
||||
// Accept both plain tag maps and objects with __clear__ array
|
||||
var raw map[string]any
|
||||
if err := json.Unmarshal(body, &raw); err == nil && raw != nil {
|
||||
// extract string-valued entries into tags
|
||||
tags = map[string]string{}
|
||||
for k, v := range raw {
|
||||
if k == "__clear__" {
|
||||
// parse array of strings
|
||||
if arr, ok := v.([]any); ok {
|
||||
for _, itm := range arr {
|
||||
if s, ok := itm.(string); ok {
|
||||
s = strings.TrimSpace(s)
|
||||
if s != "" {
|
||||
clear = append(clear, s)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
continue
|
||||
}
|
||||
if sv, ok := v.(string); ok {
|
||||
tags[k] = sv
|
||||
continue
|
||||
}
|
||||
if arr, ok := v.([]any); ok {
|
||||
vals := []string{}
|
||||
for _, itm := range arr {
|
||||
if s, ok := itm.(string); ok {
|
||||
s = strings.TrimSpace(s)
|
||||
if s != "" {
|
||||
vals = append(vals, s)
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(vals) > 0 {
|
||||
multi[k] = vals
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// fallback to simple map
|
||||
tags = map[string]string{}
|
||||
if err := json.Unmarshal(body, &tags); err != nil {
|
||||
return http.StatusBadRequest, err
|
||||
}
|
||||
}
|
||||
|
||||
err = d.RunHook(func() error {
|
||||
return applyMetadataWithFFmpeg(r.Context(), d, src, tags)
|
||||
return applyMetadataWithFFmpeg(r.Context(), d, src, tags, multi, clear)
|
||||
}, action, src, dst, d.user)
|
||||
|
||||
return errToStatus(err), err
|
||||
@@ -271,7 +316,7 @@ func resourcePatchHandler(fileCache FileCache) handleFunc {
|
||||
// applyMetadataWithFFmpeg attempts to write metadata using ffmpeg by creating
|
||||
// a temporary file and replacing the original. This requires that the
|
||||
// underlying filesystem exposes a real path (see FileInfo.RealPath()).
|
||||
func applyMetadataWithFFmpeg(ctx context.Context, d *data, src string, tags map[string]string) error {
|
||||
func applyMetadataWithFFmpeg(ctx context.Context, d *data, src string, tags map[string]string, multi map[string][]string, clear []string) error {
|
||||
fi, err := files.NewFileInfo(&files.FileOptions{
|
||||
Fs: d.user.Fs,
|
||||
Path: src,
|
||||
@@ -313,18 +358,90 @@ func applyMetadataWithFFmpeg(ctx context.Context, d *data, src string, tags map[
|
||||
// provided non-empty fields. We explicitly map input global metadata (0)
|
||||
// to output, and avoid `-map_metadata -1` which would clear everything.
|
||||
args := []string{"-y", "-i", real, "-map_metadata", "0", "-c", "copy"}
|
||||
changes := 0
|
||||
for k, v := range tags {
|
||||
if strings.TrimSpace(v) == "" {
|
||||
continue
|
||||
}
|
||||
args = append(args, "-metadata", fmt.Sprintf("%s=%s", k, v))
|
||||
changes++
|
||||
// For MP3, prefer ID3v2.4 to support multi-valued tags properly
|
||||
isMP3 := strings.EqualFold(ext, ".mp3")
|
||||
// Treat M4A/MP4 similarly for metadata handling specifics
|
||||
isMP4 := strings.EqualFold(ext, ".m4a") || strings.EqualFold(ext, ".m4b") || strings.EqualFold(ext, ".mp4")
|
||||
if isMP3 {
|
||||
args = append(args, "-id3v2_version", "4")
|
||||
}
|
||||
|
||||
// If no non-empty changes were requested, do nothing
|
||||
// If MP3 and there are clears, perform ID3v2 in-place removal first.
|
||||
// This avoids ffmpeg creating unwanted empty entries (e.g., TXXX artifacts).
|
||||
didID3Clear := false
|
||||
if isMP3 && len(clear) > 0 {
|
||||
frames := mapClearsToID3Frames(clear)
|
||||
txxx := mapClearsToTXXX(clear)
|
||||
removeNumeric := false
|
||||
for _, c := range clear {
|
||||
tok := normalizeKey(c)
|
||||
if tok == "artists" || tok == "albumartists" {
|
||||
removeNumeric = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if err := clearID3v2Frames(real, frames, txxx, removeNumeric); err == nil {
|
||||
didID3Clear = true
|
||||
}
|
||||
// If ID3 clear failed, we'll still try ffmpeg below; but we won't add ffmpeg clears for MP3.
|
||||
}
|
||||
|
||||
// Normalize incoming keys and map to ffmpeg keys
|
||||
norm := normalizeAndMapToFFmpeg(tags)
|
||||
changes := 0
|
||||
setKeys := map[string]struct{}{}
|
||||
for k, v := range norm {
|
||||
args = append(args, "-metadata", fmt.Sprintf("%s=%s", k, v))
|
||||
changes++
|
||||
setKeys[k] = struct{}{}
|
||||
}
|
||||
// Handle multi-valued tags (e.g., Artists, AlbumArtists) by adding repeated metadata entries
|
||||
if len(multi) > 0 {
|
||||
mm := normalizeMultiToFFmpeg(multi)
|
||||
for ffk, vals := range mm {
|
||||
// MP4/M4A generally expects a single value for artist/album_artist.
|
||||
// Join multiple values for better compatibility with tag readers.
|
||||
if isMP4 && (ffk == "artist" || ffk == "album_artist") {
|
||||
if len(vals) > 0 {
|
||||
joined := strings.Join(vals, "; ")
|
||||
args = append(args, "-metadata", fmt.Sprintf("%s=%s", ffk, joined))
|
||||
changes++
|
||||
setKeys[ffk] = struct{}{}
|
||||
}
|
||||
continue
|
||||
}
|
||||
for _, v := range vals {
|
||||
args = append(args, "-metadata", fmt.Sprintf("%s=%s", ffk, v))
|
||||
changes++
|
||||
}
|
||||
setKeys[ffk] = struct{}{}
|
||||
}
|
||||
}
|
||||
// Map cleared canonical keys to ffmpeg keys and set to empty value.
|
||||
// For MP3, we skip ffmpeg-based clears because ID3v2 in-place removal is preferred.
|
||||
if len(clear) > 0 && !isMP3 {
|
||||
for ffk := range mapClearCanonicalsToFFmpeg(clear) {
|
||||
// Avoid clearing a key we are explicitly setting in this operation,
|
||||
// which could result in an empty extra value (e.g., trailing NUL).
|
||||
if _, isSet := setKeys[ffk]; isSet {
|
||||
continue
|
||||
}
|
||||
args = append(args, "-metadata", fmt.Sprintf("%s=", ffk))
|
||||
changes++
|
||||
}
|
||||
}
|
||||
|
||||
// If no ffmpeg changes are needed (e.g., only clears on MP3), we can return.
|
||||
if changes == 0 {
|
||||
return nil
|
||||
// If we performed ID3 clear or there was simply nothing to change, exit early.
|
||||
if didID3Clear || len(clear) == 0 {
|
||||
if isMP3 {
|
||||
// Best-effort cleanup of numeric/empty TXXX artifacts
|
||||
_ = cleanupNumericEmptyTXXX(real)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
// Otherwise continue and let ffmpeg run (non-MP3 clears already added above).
|
||||
}
|
||||
args = append(args, tmp)
|
||||
|
||||
@@ -339,7 +456,10 @@ func applyMetadataWithFFmpeg(ctx context.Context, d *data, src string, tags map[
|
||||
if err := os.Rename(tmp, real); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if isMP3 {
|
||||
// Post-write cleanup for numeric/empty TXXX artifacts
|
||||
_ = cleanupNumericEmptyTXXX(real)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user