fix,add: TXXX adding, delete option, dictionnary, M4A support
This commit is contained in:
117
http/id3_clear.go
Normal file
117
http/id3_clear.go
Normal file
@@ -0,0 +1,117 @@
|
||||
package fbhttp
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"unicode"
|
||||
id3v2 "github.com/bogem/id3v2/v2"
|
||||
)
|
||||
|
||||
// clearID3v2Frames removes specific ID3v2 frames from an MP3 file.
|
||||
// frames: list of standard frame IDs (e.g., TIT2, TPE1, TPE2, TRCK, TPOS, TCON, TDRC, COMM, USLT)
|
||||
// txxxDescs: list of UserDefinedText (TXXX) descriptions to remove (e.g., "Artists", "AlbumArtists").
|
||||
func clearID3v2Frames(path string, frames []string, txxxDescs []string, removeNumericEmpty bool) error {
|
||||
tag, err := id3v2.Open(path, id3v2.Options{Parse: true})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer tag.Close()
|
||||
|
||||
// Delete standard frames
|
||||
for _, id := range frames {
|
||||
tag.DeleteFrames(id)
|
||||
}
|
||||
|
||||
// Delete matching TXXX frames by description (preserve others)
|
||||
if len(txxxDescs) > 0 {
|
||||
fs := tag.GetFrames("TXXX")
|
||||
keep := []id3v2.UserDefinedTextFrame{}
|
||||
for _, f := range fs {
|
||||
udf, ok := f.(id3v2.UserDefinedTextFrame)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
remove := false
|
||||
for _, d := range txxxDescs {
|
||||
if strings.EqualFold(udf.Description, d) {
|
||||
remove = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !remove && removeNumericEmpty {
|
||||
desc := strings.TrimSpace(udf.Description)
|
||||
if desc == "" {
|
||||
remove = true
|
||||
} else {
|
||||
// numeric-only descriptions like "0", "1", "2"
|
||||
allDigits := true
|
||||
for _, r := range desc {
|
||||
if !unicode.IsDigit(r) {
|
||||
allDigits = false
|
||||
break
|
||||
}
|
||||
}
|
||||
if allDigits {
|
||||
remove = true
|
||||
}
|
||||
}
|
||||
}
|
||||
if !remove {
|
||||
keep = append(keep, udf)
|
||||
}
|
||||
}
|
||||
// Remove all TXXX frames, then re-add the ones we keep
|
||||
tag.DeleteFrames("TXXX")
|
||||
for _, udf := range keep {
|
||||
tag.AddFrame("TXXX", udf)
|
||||
}
|
||||
}
|
||||
|
||||
return tag.Save()
|
||||
}
|
||||
|
||||
// cleanupNumericEmptyTXXX removes any TXXX frames whose descriptions are empty
|
||||
// or purely numeric (e.g., "0", "1", "2"…), which are common artifacts.
|
||||
func cleanupNumericEmptyTXXX(path string) error {
|
||||
tag, err := id3v2.Open(path, id3v2.Options{Parse: true})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer tag.Close()
|
||||
|
||||
fs := tag.GetFrames("TXXX")
|
||||
keep := []id3v2.UserDefinedTextFrame{}
|
||||
for _, f := range fs {
|
||||
udf, ok := f.(id3v2.UserDefinedTextFrame)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
desc := strings.TrimSpace(udf.Description)
|
||||
remove := false
|
||||
if desc == "" {
|
||||
remove = true
|
||||
} else {
|
||||
allDigits := true
|
||||
for _, r := range desc {
|
||||
if !unicode.IsDigit(r) {
|
||||
allDigits = false
|
||||
break
|
||||
}
|
||||
}
|
||||
if allDigits {
|
||||
remove = true
|
||||
}
|
||||
}
|
||||
if !remove {
|
||||
keep = append(keep, udf)
|
||||
}
|
||||
}
|
||||
// If nothing to change, return early
|
||||
if len(keep) == len(fs) {
|
||||
return nil
|
||||
}
|
||||
tag.DeleteFrames("TXXX")
|
||||
for _, udf := range keep {
|
||||
tag.AddFrame("TXXX", udf)
|
||||
}
|
||||
return tag.Save()
|
||||
}
|
||||
243
http/metadata.go
Normal file
243
http/metadata.go
Normal file
@@ -0,0 +1,243 @@
|
||||
package fbhttp
|
||||
|
||||
import (
|
||||
"strings"
|
||||
)
|
||||
|
||||
// canonicalMap maps various user-entered or localized keys to canonical tag names.
|
||||
// Canonical names are English, PascalCase where appropriate (e.g., AlbumArtist).
|
||||
var canonicalMap = map[string]string{
|
||||
// title
|
||||
"title": "Title",
|
||||
"titre": "Title",
|
||||
"song": "Title",
|
||||
|
||||
// artist (main)
|
||||
"artist": "Artist",
|
||||
"artiste": "Artist",
|
||||
"artists": "Artist",
|
||||
|
||||
// album
|
||||
"album": "Album",
|
||||
|
||||
// album artist
|
||||
"albumartist": "AlbumArtist",
|
||||
"album artist": "AlbumArtist",
|
||||
"album_artist": "AlbumArtist",
|
||||
"artistesdelalbum": "AlbumArtist",
|
||||
"artistealbum": "AlbumArtist",
|
||||
|
||||
// composer / author
|
||||
"composer": "Composer",
|
||||
"auteur": "Composer",
|
||||
|
||||
// track
|
||||
"track": "Track",
|
||||
"tracknumber": "Track",
|
||||
"trackno": "Track",
|
||||
"piste": "Track",
|
||||
|
||||
// disc
|
||||
"disc": "Disc",
|
||||
"discnumber": "Disc",
|
||||
"disque": "Disc",
|
||||
|
||||
// genre
|
||||
"genre": "Genre",
|
||||
|
||||
// year/date
|
||||
"year": "Year",
|
||||
"annee": "Year",
|
||||
"année": "Year",
|
||||
"date": "Date",
|
||||
|
||||
// comment
|
||||
"comment": "Comment",
|
||||
"commentaire": "Comment",
|
||||
|
||||
// lyrics
|
||||
"lyrics": "Lyrics",
|
||||
}
|
||||
|
||||
// ffmpegKey maps canonical names to ffmpeg metadata keys.
|
||||
// FFmpeg expects lower-case keys; AlbumArtist becomes album_artist, Year maps to date.
|
||||
var ffmpegKey = map[string]string{
|
||||
"Title": "title",
|
||||
"Artist": "artist",
|
||||
"Artists": "artist",
|
||||
"Album": "album",
|
||||
"AlbumArtist": "album_artist",
|
||||
"AlbumArtists": "album_artist",
|
||||
"Composer": "composer",
|
||||
"Track": "track",
|
||||
"TrackNumber": "track",
|
||||
"Disc": "disc",
|
||||
"DiscNumber": "disc",
|
||||
"Genre": "genre",
|
||||
"Date": "date",
|
||||
"Year": "date", // prefer date for ffmpeg
|
||||
"Comment": "comment",
|
||||
"Lyrics": "lyrics",
|
||||
}
|
||||
|
||||
// allowedCanonicals is the set of canonical tags we accept for writes.
|
||||
var allowedCanonicals = func() map[string]struct{} {
|
||||
m := make(map[string]struct{}, len(ffmpegKey))
|
||||
for k := range ffmpegKey {
|
||||
m[k] = struct{}{}
|
||||
}
|
||||
return m
|
||||
}()
|
||||
|
||||
// normalizeKey reduces a user-provided key to a lookup token: lowercased, trimmed
|
||||
// and with common separators removed.
|
||||
func normalizeKey(s string) string {
|
||||
s = strings.TrimSpace(s)
|
||||
s = strings.ToLower(s)
|
||||
// normalize separators so that "Album Artist", "album-artist", "album_artist" match
|
||||
s = strings.ReplaceAll(s, "_", " ")
|
||||
s = strings.ReplaceAll(s, "-", " ")
|
||||
s = strings.Join(strings.Fields(s), " ") // collapse multiple spaces
|
||||
return s
|
||||
}
|
||||
|
||||
// normalizeAndMapToFFmpeg takes arbitrary incoming tags, applies key normalization
|
||||
// and synonym mapping, filters to allowed canonical keys, and returns a map of
|
||||
// ffmpeg-ready metadata keys with their values (empty values are dropped).
|
||||
func normalizeAndMapToFFmpeg(in map[string]string) map[string]string {
|
||||
out := map[string]string{}
|
||||
for k, v := range in {
|
||||
if strings.TrimSpace(v) == "" {
|
||||
continue
|
||||
}
|
||||
token := normalizeKey(k)
|
||||
canonical, ok := canonicalMap[token]
|
||||
if !ok {
|
||||
// If user already provided a canonical name, accept it
|
||||
// (e.g., Title, Artist, AlbumArtist)
|
||||
c2 := strings.TrimSpace(k)
|
||||
if _, allowed := allowedCanonicals[c2]; allowed {
|
||||
canonical = c2
|
||||
} else {
|
||||
// unrecognized key: skip
|
||||
continue
|
||||
}
|
||||
}
|
||||
ffk, ok := ffmpegKey[canonical]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
out[ffk] = v
|
||||
}
|
||||
return out
|
||||
}
|
||||
// normalizeMultiToFFmpeg maps multi-valued canonical keys to ffmpeg keys, preserving arrays.
|
||||
func normalizeMultiToFFmpeg(in map[string][]string) map[string][]string {
|
||||
out := map[string][]string{}
|
||||
for k, arr := range in {
|
||||
token := normalizeKey(k)
|
||||
canonical, ok := canonicalMap[token]
|
||||
if !ok {
|
||||
// if already canonical, keep as-is
|
||||
if _, allowed := allowedCanonicals[k]; allowed {
|
||||
canonical = k
|
||||
} else {
|
||||
continue
|
||||
}
|
||||
}
|
||||
ffk, ok := ffmpegKey[canonical]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
vals := []string{}
|
||||
for _, v := range arr {
|
||||
v = strings.TrimSpace(v)
|
||||
if v == "" {
|
||||
continue
|
||||
}
|
||||
vals = append(vals, v)
|
||||
}
|
||||
if len(vals) > 0 {
|
||||
out[ffk] = vals
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// mapClearCanonicalsToFFmpeg converts a list of canonical names to ffmpeg keys
|
||||
// intended to be cleared by setting them to empty value.
|
||||
func mapClearCanonicalsToFFmpeg(keys []string) map[string]struct{} {
|
||||
out := map[string]struct{}{}
|
||||
for _, k := range keys {
|
||||
token := normalizeKey(k)
|
||||
canonical, ok := canonicalMap[token]
|
||||
if !ok {
|
||||
// if already canonical and allowed, keep
|
||||
if _, allowed := allowedCanonicals[k]; allowed {
|
||||
canonical = k
|
||||
} else {
|
||||
continue
|
||||
}
|
||||
}
|
||||
if ffk, ok := ffmpegKey[canonical]; ok {
|
||||
out[ffk] = struct{}{}
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// mapClearsToID3Frames converts canonical clear keys to ID3v2 frame IDs
|
||||
func mapClearsToID3Frames(keys []string) []string {
|
||||
out := []string{}
|
||||
for _, k := range keys {
|
||||
token := normalizeKey(k)
|
||||
canonical, ok := canonicalMap[token]
|
||||
if !ok {
|
||||
if _, allowed := allowedCanonicals[k]; allowed {
|
||||
canonical = k
|
||||
} else {
|
||||
continue
|
||||
}
|
||||
}
|
||||
switch canonical {
|
||||
case "Title":
|
||||
out = append(out, "TIT2")
|
||||
case "Artist", "Artists":
|
||||
out = append(out, "TPE1")
|
||||
case "Album":
|
||||
out = append(out, "TALB")
|
||||
case "AlbumArtist", "AlbumArtists":
|
||||
out = append(out, "TPE2")
|
||||
case "Composer":
|
||||
out = append(out, "TCOM")
|
||||
case "Track", "TrackNumber":
|
||||
out = append(out, "TRCK")
|
||||
case "Disc", "DiscNumber":
|
||||
out = append(out, "TPOS")
|
||||
case "Genre":
|
||||
out = append(out, "TCON")
|
||||
case "Date", "Year":
|
||||
// Prefer TDRC; TYER used historically
|
||||
out = append(out, "TDRC", "TYER")
|
||||
case "Comment":
|
||||
out = append(out, "COMM")
|
||||
case "Lyrics":
|
||||
out = append(out, "USLT")
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func mapClearsToTXXX(keys []string) []string {
|
||||
out := []string{}
|
||||
for _, k := range keys {
|
||||
token := normalizeKey(k)
|
||||
switch token {
|
||||
case "artists":
|
||||
out = append(out, "Artists", "ARTISTS")
|
||||
case "albumartists":
|
||||
out = append(out, "AlbumArtists", "ALBUMARTISTS")
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
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