mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-07-30 07:48:51 +02:00
Dart: - notification_service: single _details() builder replaces 13 copies of the NotificationDetails block - platform_bridge: _invokeMap() for 34 invoke+decode call sites, _cachedInvoke() unifies the three TTL/in-flight cache scaffolds - ffmpeg_service: _promoteTempOutput(), _appendCoverInputArgs(), single _writeReplayGainTags() and _convertToLossless() for the ALAC/FLAC twins - sqlite_helpers.dart: shared openAppDatabase/path-key/migration helpers for the three database classes - library_collections: parametrized wishlist/loved/favorite CRUD - extension_provider: one predicate-based replacedBuiltIn* lookup Go: - extension runtime: parseGojaHeaders/coerceGojaBody/doExtensionHTTP shared by httpGet/httpPost/httpRequest/shortcuts/fetch - exports_metadata: applyAudioMetadataToResult + successMethodJSON, APE edit path reuses audioMetadataFromEditFields - lyrics: lrclibGet() for both LRCLib fetchers - extension_store: drop hand-rolled strings helpers
573 lines
19 KiB
Go
573 lines
19 KiB
Go
package gobackend
|
|
|
|
import (
|
|
"encoding/json"
|
|
"fmt"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"time"
|
|
)
|
|
|
|
func applyAudioMetadataToResult(result map[string]any, meta *AudioMetadata) {
|
|
result["title"] = meta.Title
|
|
result["artist"] = meta.Artist
|
|
result["album"] = meta.Album
|
|
result["album_artist"] = meta.AlbumArtist
|
|
result["date"] = meta.Date
|
|
if meta.Date == "" {
|
|
result["date"] = meta.Year
|
|
}
|
|
result["track_number"] = meta.TrackNumber
|
|
result["total_tracks"] = meta.TotalTracks
|
|
result["disc_number"] = meta.DiscNumber
|
|
result["total_discs"] = meta.TotalDiscs
|
|
result["isrc"] = meta.ISRC
|
|
result["lyrics"] = meta.Lyrics
|
|
result["genre"] = meta.Genre
|
|
result["label"] = meta.Label
|
|
result["copyright"] = meta.Copyright
|
|
result["composer"] = meta.Composer
|
|
result["comment"] = meta.Comment
|
|
result["replaygain_track_gain"] = meta.ReplayGainTrackGain
|
|
result["replaygain_track_peak"] = meta.ReplayGainTrackPeak
|
|
result["replaygain_album_gain"] = meta.ReplayGainAlbumGain
|
|
result["replaygain_album_peak"] = meta.ReplayGainAlbumPeak
|
|
}
|
|
|
|
func successMethodJSON(method string) (string, error) {
|
|
return marshalJSONString(map[string]any{"success": true, "method": method})
|
|
}
|
|
|
|
func ReadFileMetadata(filePath string) (string, error) {
|
|
lower := strings.ToLower(filePath)
|
|
isFlac := strings.HasSuffix(lower, ".flac")
|
|
isM4A := strings.HasSuffix(lower, ".m4a") || strings.HasSuffix(lower, ".mp4") || strings.HasSuffix(lower, ".aac")
|
|
isMp3 := strings.HasSuffix(lower, ".mp3")
|
|
isOgg := strings.HasSuffix(lower, ".opus") || strings.HasSuffix(lower, ".ogg")
|
|
isApe := strings.HasSuffix(lower, ".ape")
|
|
isWv := strings.HasSuffix(lower, ".wv")
|
|
isMpc := strings.HasSuffix(lower, ".mpc")
|
|
isWav := strings.HasSuffix(lower, ".wav")
|
|
isAiff := strings.HasSuffix(lower, ".aiff") || strings.HasSuffix(lower, ".aif") || strings.HasSuffix(lower, ".aifc")
|
|
|
|
result := map[string]any{
|
|
"title": "",
|
|
"artist": "",
|
|
"album": "",
|
|
"album_artist": "",
|
|
"date": "",
|
|
"track_number": 0,
|
|
"total_tracks": 0,
|
|
"disc_number": 0,
|
|
"total_discs": 0,
|
|
"isrc": "",
|
|
"lyrics": "",
|
|
"genre": "",
|
|
"label": "",
|
|
"copyright": "",
|
|
"composer": "",
|
|
"comment": "",
|
|
"duration": 0,
|
|
"format": "",
|
|
"audio_codec": "",
|
|
}
|
|
|
|
if isFlac {
|
|
result["format"] = "flac"
|
|
result["audio_codec"] = "flac"
|
|
metadata, err := ReadMetadata(filePath)
|
|
if err != nil {
|
|
// File may have wrong extension (e.g. opus saved as .flac).
|
|
// Try Ogg/Opus parser as fallback before giving up.
|
|
GoLog("[ReadFileMetadata] FLAC parse failed for %s, trying Ogg fallback: %v\n", filePath, err)
|
|
oggMeta, oggErr := ReadOggVorbisComments(filePath)
|
|
if oggErr == nil && oggMeta != nil {
|
|
result["title"] = oggMeta.Title
|
|
result["artist"] = oggMeta.Artist
|
|
result["album"] = oggMeta.Album
|
|
result["album_artist"] = oggMeta.AlbumArtist
|
|
result["date"] = oggMeta.Date
|
|
if oggMeta.Date == "" {
|
|
result["date"] = oggMeta.Year
|
|
}
|
|
result["track_number"] = oggMeta.TrackNumber
|
|
result["total_tracks"] = oggMeta.TotalTracks
|
|
result["disc_number"] = oggMeta.DiscNumber
|
|
result["total_discs"] = oggMeta.TotalDiscs
|
|
result["isrc"] = oggMeta.ISRC
|
|
result["lyrics"] = oggMeta.Lyrics
|
|
result["genre"] = oggMeta.Genre
|
|
result["composer"] = oggMeta.Composer
|
|
result["comment"] = oggMeta.Comment
|
|
quality, qualityErr := GetOggQuality(filePath)
|
|
if qualityErr == nil {
|
|
result["sample_rate"] = quality.SampleRate
|
|
result["duration"] = quality.Duration
|
|
if quality.Bitrate > 0 {
|
|
result["bitrate"] = quality.Bitrate / 1000
|
|
}
|
|
}
|
|
result["format"] = "opus"
|
|
result["audio_codec"] = "opus"
|
|
} else {
|
|
return "", fmt.Errorf("failed to read metadata: %w", err)
|
|
}
|
|
} else {
|
|
result["title"] = metadata.Title
|
|
result["artist"] = metadata.Artist
|
|
result["album"] = metadata.Album
|
|
result["album_artist"] = metadata.AlbumArtist
|
|
result["date"] = metadata.Date
|
|
result["track_number"] = metadata.TrackNumber
|
|
result["total_tracks"] = metadata.TotalTracks
|
|
result["disc_number"] = metadata.DiscNumber
|
|
result["total_discs"] = metadata.TotalDiscs
|
|
result["isrc"] = metadata.ISRC
|
|
result["lyrics"] = metadata.Lyrics
|
|
result["genre"] = metadata.Genre
|
|
result["label"] = metadata.Label
|
|
result["copyright"] = metadata.Copyright
|
|
result["composer"] = metadata.Composer
|
|
result["comment"] = metadata.Comment
|
|
result["replaygain_track_gain"] = metadata.ReplayGainTrackGain
|
|
result["replaygain_track_peak"] = metadata.ReplayGainTrackPeak
|
|
result["replaygain_album_gain"] = metadata.ReplayGainAlbumGain
|
|
result["replaygain_album_peak"] = metadata.ReplayGainAlbumPeak
|
|
|
|
quality, qualityErr := GetAudioQuality(filePath)
|
|
if qualityErr == nil {
|
|
result["bit_depth"] = quality.BitDepth
|
|
result["sample_rate"] = quality.SampleRate
|
|
if quality.Codec != "" {
|
|
result["audio_codec"] = quality.Codec
|
|
}
|
|
if quality.SampleRate > 0 && quality.TotalSamples > 0 {
|
|
result["duration"] = int(quality.TotalSamples / int64(quality.SampleRate))
|
|
}
|
|
}
|
|
}
|
|
} else if isM4A {
|
|
result["format"] = "m4a"
|
|
meta, err := ReadM4ATags(filePath)
|
|
if err == nil && meta != nil {
|
|
applyAudioMetadataToResult(result, meta)
|
|
}
|
|
quality, qualityErr := GetM4AQuality(filePath)
|
|
if qualityErr == nil {
|
|
result["bit_depth"] = quality.BitDepth
|
|
result["sample_rate"] = quality.SampleRate
|
|
result["duration"] = quality.Duration
|
|
result["audio_codec"] = quality.Codec
|
|
if format := libraryFormatForM4ACodec(quality.Codec); format != "" {
|
|
result["format"] = format
|
|
}
|
|
if quality.Bitrate > 0 && !isLosslessLibraryFormat(fmt.Sprint(result["format"])) {
|
|
result["bitrate"] = quality.Bitrate
|
|
}
|
|
}
|
|
} else if isMp3 {
|
|
result["format"] = "mp3"
|
|
result["audio_codec"] = "mp3"
|
|
meta, err := ReadID3Tags(filePath)
|
|
if err == nil && meta != nil {
|
|
applyAudioMetadataToResult(result, meta)
|
|
}
|
|
quality, qualityErr := GetMP3Quality(filePath)
|
|
if qualityErr == nil {
|
|
result["bit_depth"] = quality.BitDepth
|
|
result["sample_rate"] = quality.SampleRate
|
|
result["duration"] = quality.Duration
|
|
if quality.Bitrate > 0 {
|
|
result["bitrate"] = quality.Bitrate / 1000
|
|
}
|
|
}
|
|
} else if isOgg {
|
|
result["format"] = "opus"
|
|
result["audio_codec"] = "opus"
|
|
meta, err := ReadOggVorbisComments(filePath)
|
|
if err == nil && meta != nil {
|
|
applyAudioMetadataToResult(result, meta)
|
|
}
|
|
quality, qualityErr := GetOggQuality(filePath)
|
|
if qualityErr == nil {
|
|
result["sample_rate"] = quality.SampleRate
|
|
result["duration"] = quality.Duration
|
|
if quality.Bitrate > 0 {
|
|
result["bitrate"] = quality.Bitrate / 1000
|
|
}
|
|
}
|
|
} else if isApe || isWv || isMpc {
|
|
result["format"] = strings.TrimPrefix(filepath.Ext(filePath), ".")
|
|
result["audio_codec"] = result["format"]
|
|
apeTag, apeErr := ReadAPETags(filePath)
|
|
if apeErr == nil && apeTag != nil {
|
|
meta := APETagToAudioMetadata(apeTag)
|
|
if meta != nil {
|
|
applyAudioMetadataToResult(result, meta)
|
|
}
|
|
}
|
|
} else if isWav || isAiff {
|
|
var meta *AudioMetadata
|
|
var quality *WAVQuality
|
|
var qualityErr error
|
|
if isAiff {
|
|
result["format"] = "aiff"
|
|
result["audio_codec"] = "pcm"
|
|
meta, _ = ReadAIFFTags(filePath)
|
|
quality, qualityErr = GetAIFFQuality(filePath)
|
|
} else {
|
|
result["format"] = "wav"
|
|
result["audio_codec"] = "pcm"
|
|
meta, _ = ReadWAVTags(filePath)
|
|
quality, qualityErr = GetWAVQuality(filePath)
|
|
}
|
|
if meta != nil {
|
|
applyAudioMetadataToResult(result, meta)
|
|
}
|
|
if qualityErr == nil && quality != nil {
|
|
result["bit_depth"] = quality.BitDepth
|
|
result["sample_rate"] = quality.SampleRate
|
|
result["duration"] = quality.Duration
|
|
}
|
|
} else {
|
|
return "", fmt.Errorf("unsupported file format: %s", filePath)
|
|
}
|
|
|
|
return marshalJSONString(result)
|
|
}
|
|
|
|
// ParseCueSheet is called from Dart to get track listing and timing data for CUE splitting.
|
|
// audioDir, if non-empty, overrides the directory used for resolving the
|
|
// referenced audio file (useful for SAF temp file scenarios).
|
|
func ParseCueSheet(cuePath string, audioDir string) (string, error) {
|
|
return ParseCueFileJSON(cuePath, audioDir)
|
|
}
|
|
|
|
// ScanCueSheetForLibrary parses a .cue file and returns a JSON array of
|
|
// LibraryScanResult entries (one per track). This is the SAF-friendly variant:
|
|
// - audioDir overrides where the referenced audio file is resolved
|
|
// - virtualPathPrefix replaces cuePath in filePath / id fields (e.g. a content:// URI)
|
|
// - fileModTime is stamped on every result (pass 0 to stat cuePath instead)
|
|
func ScanCueSheetForLibrary(cuePath, audioDir, virtualPathPrefix string, fileModTime int64) (string, error) {
|
|
scanTime := time.Now().UTC().Format(time.RFC3339)
|
|
results, err := ScanCueFileForLibraryExt(cuePath, audioDir, virtualPathPrefix, fileModTime, scanTime)
|
|
if err != nil {
|
|
return "[]", err
|
|
}
|
|
jsonBytes, err := json.Marshal(results)
|
|
if err != nil {
|
|
return "[]", fmt.Errorf("failed to marshal cue scan results: %w", err)
|
|
}
|
|
return string(jsonBytes), nil
|
|
}
|
|
|
|
func ScanCueSheetForLibraryWithCoverCacheKey(cuePath, audioDir, virtualPathPrefix string, fileModTime int64, coverCacheKey string) (string, error) {
|
|
scanTime := time.Now().UTC().Format(time.RFC3339)
|
|
results, err := ScanCueFileForLibraryExtWithCoverCacheKey(
|
|
cuePath,
|
|
audioDir,
|
|
virtualPathPrefix,
|
|
fileModTime,
|
|
coverCacheKey,
|
|
scanTime,
|
|
)
|
|
if err != nil {
|
|
return "[]", err
|
|
}
|
|
jsonBytes, err := json.Marshal(results)
|
|
if err != nil {
|
|
return "[]", fmt.Errorf("failed to marshal cue scan results: %w", err)
|
|
}
|
|
return string(jsonBytes), nil
|
|
}
|
|
|
|
// WriteM4AFreeformTags writes ISRC and label into an M4A/MP4 file as iTunes
|
|
// freeform atoms. FFmpeg's MP4 muxer ignores these keys, so they must be
|
|
// written natively after the FFmpeg metadata pass for the values to persist.
|
|
// Only keys present in the JSON are touched; an empty value clears the tag.
|
|
func WriteM4AFreeformTags(filePath, metadataJSON string) (string, error) {
|
|
var fields map[string]string
|
|
if err := json.Unmarshal([]byte(metadataJSON), &fields); err != nil {
|
|
return "", fmt.Errorf("invalid metadata JSON: %w", err)
|
|
}
|
|
|
|
if err := EditM4AFreeformText(filePath, fields); err != nil {
|
|
return "", fmt.Errorf("failed to write M4A freeform tags: %w", err)
|
|
}
|
|
|
|
return successMethodJSON("native_m4a_freeform")
|
|
}
|
|
|
|
// EnsureAC4Config normalizes a decrypted AC-4 file to a standards-compliant ISO
|
|
// MP4 and injects the dac4 configuration box copied from sourcePath. No-op when
|
|
// the file is not AC-4.
|
|
func EnsureAC4Config(filePath, sourcePath string) (string, error) {
|
|
if err := EnsureAC4ConfigBox(filePath, sourcePath); err != nil {
|
|
return "", fmt.Errorf("failed to finalize AC-4 container: %w", err)
|
|
}
|
|
return `{"success":true}`, nil
|
|
}
|
|
|
|
// WriteAC4Metadata writes iTunes-style metadata into an AC-4 MP4. The JSON
|
|
// "handled" field reports whether the file was AC-4 (true) so the caller can
|
|
// skip the FFmpeg metadata pass that would re-wrap it as QuickTime.
|
|
func WriteAC4Metadata(filePath, metadataJSON, coverPath string) (string, error) {
|
|
handled, err := WriteAC4MetadataIfApplicable(filePath, metadataJSON, coverPath)
|
|
if err != nil {
|
|
return "", fmt.Errorf("failed to write AC-4 metadata: %w", err)
|
|
}
|
|
resp := map[string]any{"success": true, "handled": handled}
|
|
s, _ := marshalJSONString(resp)
|
|
return s, nil
|
|
}
|
|
|
|
// EditFileMetadata writes audio file tags: FLAC via native Go library, MP3/Opus returns map for Dart/FFmpeg.
|
|
func EditFileMetadata(filePath, metadataJSON string) (string, error) {
|
|
var fields map[string]string
|
|
if err := json.Unmarshal([]byte(metadataJSON), &fields); err != nil {
|
|
return "", fmt.Errorf("invalid metadata JSON: %w", err)
|
|
}
|
|
|
|
lower := strings.ToLower(filePath)
|
|
isFlac := strings.HasSuffix(lower, ".flac")
|
|
isApeFile := strings.HasSuffix(lower, ".ape") || strings.HasSuffix(lower, ".wv") || strings.HasSuffix(lower, ".mpc")
|
|
isM4AFile := strings.HasSuffix(lower, ".m4a") || strings.HasSuffix(lower, ".mp4") || strings.HasSuffix(lower, ".m4b")
|
|
isWavFile := strings.HasSuffix(lower, ".wav")
|
|
isAiffFile := strings.HasSuffix(lower, ".aiff") || strings.HasSuffix(lower, ".aif") || strings.HasSuffix(lower, ".aifc")
|
|
coverPath := strings.TrimSpace(fields["cover_path"])
|
|
|
|
if hasOnlyM4AReplayGainFields(fields) && (isM4AFile || isMP4ContainerFile(filePath)) {
|
|
if err := EditM4AReplayGain(filePath, fields); err != nil {
|
|
return "", fmt.Errorf("failed to write M4A metadata: %w", err)
|
|
}
|
|
|
|
return successMethodJSON("native_m4a_replaygain")
|
|
}
|
|
|
|
if isFlac {
|
|
// A .flac name does not guarantee FLAC content: providers sometimes
|
|
// deliver an MP4/M4A stream that ends up under the requested name.
|
|
// The FLAC writer would fail "fLaC head incorrect" on every attempt,
|
|
// so detect the mismatch up front and say what is actually wrong.
|
|
if isMP4ContainerFile(filePath) {
|
|
return "", fmt.Errorf(
|
|
"failed to write FLAC metadata: file is an MP4/M4A stream under a .flac name; rename it to .m4a",
|
|
)
|
|
}
|
|
if err := EditFlacFields(filePath, fields); err != nil {
|
|
return "", fmt.Errorf("failed to write FLAC metadata: %w", err)
|
|
}
|
|
|
|
return successMethodJSON("native")
|
|
}
|
|
|
|
// WAV / AIFF: write tags into an embedded ID3v2.4 chunk natively.
|
|
if isWavFile {
|
|
if err := WriteWAVTags(filePath, fields); err != nil {
|
|
return "", fmt.Errorf("failed to write WAV metadata: %w", err)
|
|
}
|
|
return successMethodJSON("native_wav")
|
|
}
|
|
if isAiffFile {
|
|
if err := WriteAIFFTags(filePath, fields); err != nil {
|
|
return "", fmt.Errorf("failed to write AIFF metadata: %w", err)
|
|
}
|
|
return successMethodJSON("native_aiff")
|
|
}
|
|
|
|
if isApeFile {
|
|
meta := audioMetadataFromEditFields(fields)
|
|
|
|
newItems := AudioMetadataToAPEItems(meta)
|
|
|
|
// If a cover image was provided, embed it as a binary APE item.
|
|
// APEv2 cover format: "cover.jpg\0<binary image data>", flagged binary.
|
|
if coverPath != "" {
|
|
coverData, coverErr := os.ReadFile(coverPath)
|
|
if coverErr == nil && len(coverData) > 0 {
|
|
// The value is "filename\0" + raw bytes. We store the
|
|
// description as the Value field, but since the item is
|
|
// flagged binary, the writer serializes it verbatim.
|
|
desc := "cover.jpg\x00"
|
|
binaryValue := desc + string(coverData)
|
|
newItems = append(newItems, APETagItem{
|
|
Key: "Cover Art (Front)",
|
|
Value: binaryValue,
|
|
Flags: apeItemFlagBinary,
|
|
})
|
|
}
|
|
}
|
|
|
|
// Build the set of APE keys that the edit explicitly controls.
|
|
// Even if the value is empty (user cleared the field), the old
|
|
// value must be removed during merge.
|
|
overrideKeys := apeKeysFromFields(fields)
|
|
if coverPath != "" {
|
|
overrideKeys["COVER ART (FRONT)"] = struct{}{}
|
|
}
|
|
|
|
// Read existing tags so we can merge rather than replace.
|
|
// This preserves cover art and custom items not in the edit set.
|
|
existingTag, _ := ReadAPETags(filePath)
|
|
var finalItems []APETagItem
|
|
if existingTag != nil && len(existingTag.Items) > 0 {
|
|
finalItems = MergeAPEItems(existingTag.Items, newItems, overrideKeys)
|
|
} else {
|
|
finalItems = newItems
|
|
}
|
|
|
|
tag := &APETag{
|
|
Version: apeTagVersion2,
|
|
Items: finalItems,
|
|
}
|
|
|
|
if err := WriteAPETags(filePath, tag); err != nil {
|
|
return "", fmt.Errorf("failed to write APE tags: %w", err)
|
|
}
|
|
|
|
return successMethodJSON("native_ape")
|
|
}
|
|
|
|
// MP3, Ogg/Opus, and M4A have native editors that preserve foreign
|
|
// tags and skip the ffmpeg remux. Any failure falls back to the ffmpeg
|
|
// response so callers keep the old behavior for exotic files.
|
|
isMp3 := strings.HasSuffix(lower, ".mp3")
|
|
isOggFile := strings.HasSuffix(lower, ".opus") || strings.HasSuffix(lower, ".ogg")
|
|
|
|
if isMp3 {
|
|
if err := EditMP3Fields(filePath, fields); err != nil {
|
|
GoLog("[Metadata] Native MP3 edit failed, falling back to ffmpeg: %v\n", err)
|
|
} else {
|
|
return successMethodJSON("native_mp3")
|
|
}
|
|
}
|
|
if isOggFile {
|
|
if err := EditOggFields(filePath, fields); err != nil {
|
|
GoLog("[Metadata] Native Ogg edit failed, falling back to ffmpeg: %v\n", err)
|
|
} else {
|
|
return successMethodJSON("native_ogg")
|
|
}
|
|
}
|
|
if isM4AFile || isMP4ContainerFile(filePath) {
|
|
if err := EditM4AFields(filePath, fields); err != nil {
|
|
GoLog("[Metadata] Native M4A edit failed, falling back to ffmpeg: %v\n", err)
|
|
} else {
|
|
return successMethodJSON("native_m4a")
|
|
}
|
|
}
|
|
|
|
resp := map[string]any{
|
|
"success": true,
|
|
"method": "ffmpeg",
|
|
"fields": fields,
|
|
}
|
|
s, _ := marshalJSONString(resp)
|
|
return s, nil
|
|
}
|
|
|
|
func isMP4ContainerFile(filePath string) bool {
|
|
f, err := os.Open(filePath)
|
|
if err != nil {
|
|
return false
|
|
}
|
|
defer f.Close()
|
|
|
|
header := make([]byte, 12)
|
|
n, err := f.Read(header)
|
|
if err != nil || n < 8 {
|
|
return false
|
|
}
|
|
return string(header[4:8]) == "ftyp"
|
|
}
|
|
|
|
func hasOnlyM4AReplayGainFields(fields map[string]string) bool {
|
|
allowed := map[string]struct{}{
|
|
"replaygain_track_gain": {},
|
|
"replaygain_track_peak": {},
|
|
"replaygain_album_gain": {},
|
|
"replaygain_album_peak": {},
|
|
}
|
|
|
|
hasReplayGain := false
|
|
for key, value := range fields {
|
|
if strings.TrimSpace(value) == "" {
|
|
continue
|
|
}
|
|
if _, ok := allowed[strings.ToLower(strings.TrimSpace(key))]; ok {
|
|
hasReplayGain = true
|
|
continue
|
|
}
|
|
return false
|
|
}
|
|
|
|
return hasReplayGain
|
|
}
|
|
|
|
// RewriteSplitArtistTagsExport rewrites ARTIST and ALBUMARTIST Vorbis
|
|
// comments in a FLAC file as multiple separate entries (one per artist).
|
|
// Call this after FFmpeg metadata embedding to fix split artist tags,
|
|
// since FFmpeg deduplicates -metadata keys and only keeps the last value.
|
|
func RewriteSplitArtistTagsExport(filePath, artist, albumArtist string) (string, error) {
|
|
err := RewriteSplitArtistTags(filePath, artist, albumArtist)
|
|
if err != nil {
|
|
return errorResponse("Failed to rewrite artist tags: " + err.Error())
|
|
}
|
|
|
|
resp := map[string]any{
|
|
"success": true,
|
|
"message": "Split artist tags written successfully",
|
|
}
|
|
|
|
s, _ := marshalJSONString(resp)
|
|
return s, nil
|
|
}
|
|
|
|
func DownloadCoverToFile(coverURL string, outputPath string, maxQuality bool) error {
|
|
if coverURL == "" {
|
|
return fmt.Errorf("no cover URL provided")
|
|
}
|
|
|
|
data, err := downloadCoverToMemory(coverURL, maxQuality)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to download cover: %w", err)
|
|
}
|
|
|
|
if err := os.WriteFile(outputPath, data, 0644); err != nil {
|
|
return fmt.Errorf("failed to write cover file: %w", err)
|
|
}
|
|
|
|
GoLog("[Cover] Downloaded cover to: %s (%d KB)\n", outputPath, len(data)/1024)
|
|
return nil
|
|
}
|
|
|
|
func ExtractCoverToFile(audioPath string, outputPath string) error {
|
|
lower := strings.ToLower(audioPath)
|
|
|
|
var coverData []byte
|
|
var err error
|
|
|
|
if strings.HasSuffix(lower, ".flac") {
|
|
coverData, err = ExtractCoverArt(audioPath)
|
|
} else if strings.HasSuffix(lower, ".m4a") || strings.HasSuffix(lower, ".aac") {
|
|
coverData, err = extractCoverFromM4A(audioPath)
|
|
} else if strings.HasSuffix(lower, ".mp3") {
|
|
coverData, _, err = extractMP3CoverArt(audioPath)
|
|
} else if strings.HasSuffix(lower, ".opus") || strings.HasSuffix(lower, ".ogg") {
|
|
coverData, _, err = extractOggCoverArt(audioPath)
|
|
} else {
|
|
return fmt.Errorf("unsupported audio format for cover extraction")
|
|
}
|
|
|
|
if err != nil {
|
|
return fmt.Errorf("failed to extract cover: %w", err)
|
|
}
|
|
|
|
if err := os.WriteFile(outputPath, coverData, 0644); err != nil {
|
|
return fmt.Errorf("failed to write cover file: %w", err)
|
|
}
|
|
|
|
GoLog("[Cover] Extracted cover art to: %s (%d KB)\n", outputPath, len(coverData)/1024)
|
|
return nil
|
|
}
|