mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-07-25 21:40:57 +02:00
refactor(backend): split exports.go into themed files
Pure mechanical move within package gobackend — no signature or behavior
change, so the gomobile binding surface is identical. exports.go (4,141
lines) now holds only availability/dir/duplicate/filename exports; the
rest moved to exports_{musicbrainz,download,reenrich,metadata,lyrics,
deezer,extensions,store,library}.go along existing responsibility
boundaries.
This commit is contained in:
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,307 @@
|
||||
package gobackend
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
func PreWarmTrackCacheJSON(tracksJSON string) (string, error) {
|
||||
var tracks []struct {
|
||||
ISRC string `json:"isrc"`
|
||||
TrackName string `json:"track_name"`
|
||||
ArtistName string `json:"artist_name"`
|
||||
SpotifyID string `json:"spotify_id"`
|
||||
Service string `json:"service"`
|
||||
}
|
||||
|
||||
if err := json.Unmarshal([]byte(tracksJSON), &tracks); err != nil {
|
||||
return errorResponse("Invalid JSON: " + err.Error())
|
||||
}
|
||||
|
||||
requests := make([]PreWarmCacheRequest, len(tracks))
|
||||
for i, t := range tracks {
|
||||
requests[i] = PreWarmCacheRequest{
|
||||
ISRC: t.ISRC,
|
||||
TrackName: t.TrackName,
|
||||
ArtistName: t.ArtistName,
|
||||
SpotifyID: t.SpotifyID,
|
||||
Service: t.Service,
|
||||
}
|
||||
}
|
||||
|
||||
go PreWarmTrackCache(requests)
|
||||
|
||||
resp := map[string]interface{}{
|
||||
"success": true,
|
||||
"message": fmt.Sprintf("Pre-warming cache for %d tracks in background", len(tracks)),
|
||||
}
|
||||
|
||||
jsonBytes, _ := json.Marshal(resp)
|
||||
return string(jsonBytes), nil
|
||||
}
|
||||
|
||||
func GetTrackCacheSize() int {
|
||||
return GetCacheSize()
|
||||
}
|
||||
|
||||
func ClearTrackIDCache() {
|
||||
ClearTrackCache()
|
||||
}
|
||||
|
||||
func GetDeezerRelatedArtists(artistID string, limit int) (string, error) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second)
|
||||
defer cancel()
|
||||
|
||||
client := GetDeezerClient()
|
||||
artists, err := client.GetRelatedArtists(ctx, artistID, limit)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
resp := map[string]interface{}{
|
||||
"artists": artists,
|
||||
}
|
||||
jsonBytes, err := json.Marshal(resp)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return string(jsonBytes), nil
|
||||
}
|
||||
|
||||
func GetDeezerMetadata(resourceType, resourceID string) (string, error) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
defer cancel()
|
||||
|
||||
client := GetDeezerClient()
|
||||
var data interface{}
|
||||
var err error
|
||||
|
||||
switch resourceType {
|
||||
case "track":
|
||||
data, err = client.GetTrack(ctx, resourceID)
|
||||
case "album":
|
||||
data, err = client.GetAlbum(ctx, resourceID)
|
||||
case "artist":
|
||||
data, err = client.GetArtist(ctx, resourceID)
|
||||
case "playlist":
|
||||
data, err = client.GetPlaylist(ctx, resourceID)
|
||||
default:
|
||||
return "", fmt.Errorf("unsupported Deezer resource type: %s", resourceType)
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
jsonBytes, err := json.Marshal(data)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
return string(jsonBytes), nil
|
||||
}
|
||||
|
||||
func GetDeezerExtendedMetadata(trackID string) (string, error) {
|
||||
if trackID == "" {
|
||||
return "", fmt.Errorf("empty track ID")
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
|
||||
defer cancel()
|
||||
|
||||
client := GetDeezerClient()
|
||||
metadata, err := client.GetExtendedMetadataByTrackID(ctx, trackID)
|
||||
if err != nil {
|
||||
GoLog("[Deezer] Failed to get extended metadata: %v\n", err)
|
||||
return "", err
|
||||
}
|
||||
|
||||
result := buildDeezerExtendedMetadataResult(metadata)
|
||||
|
||||
jsonBytes, err := json.Marshal(result)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
return string(jsonBytes), nil
|
||||
}
|
||||
|
||||
func SearchDeezerByISRC(isrc string) (string, error) {
|
||||
return SearchDeezerByISRCForItemID(isrc, "")
|
||||
}
|
||||
|
||||
func SearchDeezerByISRCForItemID(isrc string, itemID string) (string, error) {
|
||||
parentCtx := context.Background()
|
||||
if itemID != "" {
|
||||
parentCtx = initDownloadCancel(itemID)
|
||||
defer clearDownloadCancel(itemID)
|
||||
if isDownloadCancelled(itemID) {
|
||||
return "", ErrDownloadCancelled
|
||||
}
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(parentCtx, 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
client := GetDeezerClient()
|
||||
track, err := client.SearchByISRC(ctx, isrc)
|
||||
if err != nil {
|
||||
if isDownloadCancelled(itemID) {
|
||||
return "", ErrDownloadCancelled
|
||||
}
|
||||
return "", err
|
||||
}
|
||||
if isDownloadCancelled(itemID) {
|
||||
return "", ErrDownloadCancelled
|
||||
}
|
||||
|
||||
result := buildDeezerISRCSearchResult(track)
|
||||
jsonBytes, err := json.Marshal(result)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
return string(jsonBytes), nil
|
||||
}
|
||||
|
||||
func buildDeezerExtendedMetadataResult(metadata *AlbumExtendedMetadata) map[string]string {
|
||||
if metadata == nil {
|
||||
return map[string]string{
|
||||
"genre": "",
|
||||
"label": "",
|
||||
"copyright": "",
|
||||
}
|
||||
}
|
||||
|
||||
return map[string]string{
|
||||
"genre": metadata.Genre,
|
||||
"label": metadata.Label,
|
||||
"copyright": metadata.Copyright,
|
||||
}
|
||||
}
|
||||
|
||||
func buildDeezerISRCSearchResult(track *TrackMetadata) map[string]interface{} {
|
||||
if track == nil {
|
||||
return map[string]interface{}{}
|
||||
}
|
||||
|
||||
result := map[string]interface{}{
|
||||
"spotify_id": track.SpotifyID,
|
||||
"artists": track.Artists,
|
||||
"name": track.Name,
|
||||
"album_name": track.AlbumName,
|
||||
"album_artist": track.AlbumArtist,
|
||||
"duration_ms": track.DurationMS,
|
||||
"images": track.Images,
|
||||
"release_date": track.ReleaseDate,
|
||||
"track_number": track.TrackNumber,
|
||||
"total_tracks": track.TotalTracks,
|
||||
"disc_number": track.DiscNumber,
|
||||
"total_discs": track.TotalDiscs,
|
||||
"external_urls": track.ExternalURL,
|
||||
"isrc": track.ISRC,
|
||||
"album_id": track.AlbumID,
|
||||
"artist_id": track.ArtistID,
|
||||
"album_type": track.AlbumType,
|
||||
"composer": track.Composer,
|
||||
}
|
||||
|
||||
if deezerID := strings.TrimSpace(strings.TrimPrefix(track.SpotifyID, "deezer:")); deezerID != "" {
|
||||
result["id"] = deezerID
|
||||
result["track_id"] = deezerID
|
||||
result["success"] = true
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
func ConvertSpotifyToDeezer(resourceType, spotifyID string) (string, error) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
defer cancel()
|
||||
|
||||
songlink := NewSongLinkClient()
|
||||
deezerClient := GetDeezerClient()
|
||||
|
||||
if resourceType == "track" {
|
||||
deezerID, err := songlink.GetDeezerIDFromSpotify(spotifyID)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("could not find Deezer equivalent: %w", err)
|
||||
}
|
||||
|
||||
trackResp, err := deezerClient.GetTrack(ctx, deezerID)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to fetch Deezer metadata: %w", err)
|
||||
}
|
||||
|
||||
jsonBytes, err := json.Marshal(trackResp)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
return string(jsonBytes), nil
|
||||
}
|
||||
|
||||
if resourceType == "album" {
|
||||
deezerID, err := songlink.GetDeezerAlbumIDFromSpotify(spotifyID)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("could not find Deezer album: %w", err)
|
||||
}
|
||||
|
||||
albumResp, err := deezerClient.GetAlbum(ctx, deezerID)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to fetch Deezer album metadata: %w", err)
|
||||
}
|
||||
|
||||
jsonBytes, err := json.Marshal(albumResp)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
return string(jsonBytes), nil
|
||||
}
|
||||
|
||||
return "", fmt.Errorf("spotify to Deezer conversion only supported for tracks and albums: please search by name for %s", resourceType)
|
||||
}
|
||||
|
||||
func CheckAvailabilityFromDeezerID(deezerTrackID string) (string, error) {
|
||||
client := NewSongLinkClient()
|
||||
availability, err := client.CheckAvailabilityFromDeezer(deezerTrackID)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
jsonBytes, err := json.Marshal(availability)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
return string(jsonBytes), nil
|
||||
}
|
||||
|
||||
func CheckAvailabilityByPlatformID(platform, entityType, entityID string) (string, error) {
|
||||
client := NewSongLinkClient()
|
||||
availability, err := client.CheckAvailabilityByPlatform(platform, entityType, entityID)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
jsonBytes, err := json.Marshal(availability)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
return string(jsonBytes), nil
|
||||
}
|
||||
|
||||
func GetSpotifyIDFromDeezerTrack(deezerTrackID string) (string, error) {
|
||||
client := NewSongLinkClient()
|
||||
return client.GetSpotifyIDFromDeezer(deezerTrackID)
|
||||
}
|
||||
|
||||
func GetTidalURLFromDeezerTrack(deezerTrackID string) (string, error) {
|
||||
client := NewSongLinkClient()
|
||||
return client.GetTidalURLFromDeezer(deezerTrackID)
|
||||
}
|
||||
@@ -0,0 +1,491 @@
|
||||
package gobackend
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
type DownloadRequest struct {
|
||||
ContractVersion int `json:"contract_version,omitempty"`
|
||||
ISRC string `json:"isrc"`
|
||||
Service string `json:"service"`
|
||||
SpotifyID string `json:"spotify_id"`
|
||||
TrackName string `json:"track_name"`
|
||||
ArtistName string `json:"artist_name"`
|
||||
AlbumName string `json:"album_name"`
|
||||
AlbumArtist string `json:"album_artist"`
|
||||
CoverURL string `json:"cover_url"`
|
||||
OutputDir string `json:"output_dir"`
|
||||
OutputPath string `json:"output_path,omitempty"`
|
||||
OutputFD int `json:"output_fd,omitempty"`
|
||||
OutputExt string `json:"output_ext,omitempty"`
|
||||
FilenameFormat string `json:"filename_format"`
|
||||
Quality string `json:"quality"`
|
||||
EmbedMetadata bool `json:"embed_metadata"`
|
||||
ArtistTagMode string `json:"artist_tag_mode,omitempty"`
|
||||
EmbedLyrics bool `json:"embed_lyrics"`
|
||||
EmbedMaxQualityCover bool `json:"embed_max_quality_cover"`
|
||||
EmbedReplayGain bool `json:"embed_replaygain,omitempty"`
|
||||
PostProcessingEnabled bool `json:"post_processing_enabled,omitempty"`
|
||||
TidalHighFormat string `json:"tidal_high_format,omitempty"`
|
||||
TrackNumber int `json:"track_number"`
|
||||
PlaylistPosition int `json:"playlist_position,omitempty"`
|
||||
DiscNumber int `json:"disc_number"`
|
||||
TotalTracks int `json:"total_tracks"`
|
||||
TotalDiscs int `json:"total_discs,omitempty"`
|
||||
ReleaseDate string `json:"release_date"`
|
||||
ItemID string `json:"item_id"`
|
||||
DurationMS int `json:"duration_ms"`
|
||||
Source string `json:"source"`
|
||||
Genre string `json:"genre,omitempty"`
|
||||
Label string `json:"label,omitempty"`
|
||||
Copyright string `json:"copyright,omitempty"`
|
||||
Composer string `json:"composer,omitempty"`
|
||||
TidalID string `json:"tidal_id,omitempty"`
|
||||
QobuzID string `json:"qobuz_id,omitempty"`
|
||||
DeezerID string `json:"deezer_id,omitempty"`
|
||||
LyricsMode string `json:"lyrics_mode,omitempty"`
|
||||
UseExtensions bool `json:"use_extensions,omitempty"`
|
||||
UseFallback bool `json:"use_fallback,omitempty"`
|
||||
RequiresContainerConversion bool `json:"requires_container_conversion,omitempty"`
|
||||
SongLinkRegion string `json:"songlink_region,omitempty"`
|
||||
}
|
||||
|
||||
type DownloadResponse struct {
|
||||
Success bool `json:"success"`
|
||||
Message string `json:"message"`
|
||||
FilePath string `json:"file_path,omitempty"`
|
||||
Error string `json:"error,omitempty"`
|
||||
ErrorType string `json:"error_type,omitempty"`
|
||||
RetryAfterSeconds int `json:"retry_after_seconds,omitempty"`
|
||||
AlreadyExists bool `json:"already_exists,omitempty"`
|
||||
ActualBitDepth int `json:"actual_bit_depth,omitempty"`
|
||||
ActualSampleRate int `json:"actual_sample_rate,omitempty"`
|
||||
AudioCodec string `json:"audio_codec,omitempty"`
|
||||
ActualExtension string `json:"actual_extension,omitempty"`
|
||||
ActualContainer string `json:"actual_container,omitempty"`
|
||||
RequiresContainerConversion bool `json:"requires_container_conversion,omitempty"`
|
||||
Service string `json:"service,omitempty"`
|
||||
Title string `json:"title,omitempty"`
|
||||
Artist string `json:"artist,omitempty"`
|
||||
Album string `json:"album,omitempty"`
|
||||
AlbumArtist string `json:"album_artist,omitempty"`
|
||||
ReleaseDate string `json:"release_date,omitempty"`
|
||||
TrackNumber int `json:"track_number,omitempty"`
|
||||
DiscNumber int `json:"disc_number,omitempty"`
|
||||
TotalTracks int `json:"total_tracks,omitempty"`
|
||||
TotalDiscs int `json:"total_discs,omitempty"`
|
||||
ISRC string `json:"isrc,omitempty"`
|
||||
CoverURL string `json:"cover_url,omitempty"`
|
||||
Genre string `json:"genre,omitempty"`
|
||||
Label string `json:"label,omitempty"`
|
||||
Copyright string `json:"copyright,omitempty"`
|
||||
Composer string `json:"composer,omitempty"`
|
||||
SkipMetadataEnrichment bool `json:"skip_metadata_enrichment,omitempty"`
|
||||
LyricsLRC string `json:"lyrics_lrc,omitempty"`
|
||||
DecryptionKey string `json:"decryption_key,omitempty"`
|
||||
Decryption *DownloadDecryptionInfo `json:"decryption,omitempty"`
|
||||
}
|
||||
|
||||
type DownloadResult struct {
|
||||
FilePath string
|
||||
BitDepth int
|
||||
SampleRate int
|
||||
AudioCodec string
|
||||
Title string
|
||||
Artist string
|
||||
Album string
|
||||
ReleaseDate string
|
||||
TrackNumber int
|
||||
TotalTracks int
|
||||
DiscNumber int
|
||||
TotalDiscs int
|
||||
ISRC string
|
||||
CoverURL string
|
||||
Genre string
|
||||
Label string
|
||||
Copyright string
|
||||
Composer string
|
||||
LyricsLRC string
|
||||
DecryptionKey string
|
||||
Decryption *DownloadDecryptionInfo
|
||||
ActualExtension string
|
||||
ActualContainer string
|
||||
RequiresContainerConversion bool
|
||||
}
|
||||
|
||||
func buildDownloadSuccessResponse(
|
||||
req DownloadRequest,
|
||||
result DownloadResult,
|
||||
service string,
|
||||
message string,
|
||||
filePath string,
|
||||
alreadyExists bool,
|
||||
) DownloadResponse {
|
||||
title := result.Title
|
||||
if title == "" {
|
||||
title = req.TrackName
|
||||
}
|
||||
|
||||
artist := result.Artist
|
||||
if artist == "" {
|
||||
artist = req.ArtistName
|
||||
}
|
||||
|
||||
// Preserve requested release metadata when available so mixed-provider
|
||||
// fallback downloads from the same source album do not get split into
|
||||
// different albums just because Tidal/Qobuz report variant titles/dates.
|
||||
album, releaseDate, trackNumber, discNumber := preferredReleaseMetadata(
|
||||
req,
|
||||
result.Album,
|
||||
result.ReleaseDate,
|
||||
result.TrackNumber,
|
||||
result.DiscNumber,
|
||||
)
|
||||
|
||||
isrc := result.ISRC
|
||||
if isrc == "" {
|
||||
isrc = req.ISRC
|
||||
}
|
||||
|
||||
genre := result.Genre
|
||||
if genre == "" {
|
||||
genre = req.Genre
|
||||
}
|
||||
|
||||
label := result.Label
|
||||
if label == "" {
|
||||
label = req.Label
|
||||
}
|
||||
|
||||
copyright := result.Copyright
|
||||
if copyright == "" {
|
||||
copyright = req.Copyright
|
||||
}
|
||||
|
||||
composer := result.Composer
|
||||
if composer == "" {
|
||||
composer = req.Composer
|
||||
}
|
||||
|
||||
coverURL := strings.TrimSpace(result.CoverURL)
|
||||
if coverURL == "" {
|
||||
coverURL = strings.TrimSpace(req.CoverURL)
|
||||
}
|
||||
|
||||
return DownloadResponse{
|
||||
Success: true,
|
||||
Message: message,
|
||||
FilePath: filePath,
|
||||
AlreadyExists: alreadyExists,
|
||||
ActualBitDepth: result.BitDepth,
|
||||
ActualSampleRate: result.SampleRate,
|
||||
AudioCodec: result.AudioCodec,
|
||||
ActualExtension: result.ActualExtension,
|
||||
ActualContainer: result.ActualContainer,
|
||||
RequiresContainerConversion: result.RequiresContainerConversion,
|
||||
Service: service,
|
||||
Title: title,
|
||||
Artist: artist,
|
||||
Album: album,
|
||||
AlbumArtist: req.AlbumArtist,
|
||||
ReleaseDate: releaseDate,
|
||||
TrackNumber: trackNumber,
|
||||
TotalTracks: req.TotalTracks,
|
||||
DiscNumber: discNumber,
|
||||
TotalDiscs: req.TotalDiscs,
|
||||
ISRC: isrc,
|
||||
CoverURL: coverURL,
|
||||
Genre: genre,
|
||||
Label: label,
|
||||
Copyright: copyright,
|
||||
Composer: composer,
|
||||
LyricsLRC: result.LyricsLRC,
|
||||
DecryptionKey: result.DecryptionKey,
|
||||
Decryption: normalizeDownloadDecryptionInfo(result.Decryption, result.DecryptionKey),
|
||||
}
|
||||
}
|
||||
|
||||
func shouldSkipQualityProbe(filePath string) bool {
|
||||
path := strings.TrimSpace(filePath)
|
||||
if path == "" {
|
||||
return true
|
||||
}
|
||||
if strings.HasPrefix(path, "/proc/self/fd/") {
|
||||
return true
|
||||
}
|
||||
// Content URI and other non-filesystem schemes cannot be read directly by os.Open.
|
||||
if strings.Contains(path, "://") {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func enrichResultQualityFromFile(result *DownloadResult) {
|
||||
if result == nil {
|
||||
return
|
||||
}
|
||||
|
||||
path := strings.TrimSpace(result.FilePath)
|
||||
if shouldSkipQualityProbe(path) {
|
||||
if strings.HasPrefix(path, "/proc/self/fd/") {
|
||||
LogDebug("Download", "Skipping quality probe for ephemeral SAF FD output: %s", path)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
quality, qErr := GetAudioQuality(path)
|
||||
if qErr == nil {
|
||||
result.BitDepth = quality.BitDepth
|
||||
result.SampleRate = quality.SampleRate
|
||||
result.AudioCodec = quality.Codec
|
||||
if quality.Codec != "" {
|
||||
GoLog("[Download] Actual quality from file: %s %d-bit/%dHz\n", quality.Codec, quality.BitDepth, quality.SampleRate)
|
||||
} else {
|
||||
GoLog("[Download] Actual quality from file: %d-bit/%dHz\n", quality.BitDepth, quality.SampleRate)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
LogDebug("Download", "Post-download quality probe unavailable for %s: %v", path, qErr)
|
||||
}
|
||||
|
||||
func applyExtendedMetadataFields(
|
||||
genre *string,
|
||||
label *string,
|
||||
copyright *string,
|
||||
extMeta *AlbumExtendedMetadata,
|
||||
) {
|
||||
if extMeta == nil {
|
||||
return
|
||||
}
|
||||
|
||||
if genre != nil && *genre == "" && extMeta.Genre != "" {
|
||||
*genre = extMeta.Genre
|
||||
}
|
||||
if label != nil && *label == "" && extMeta.Label != "" {
|
||||
*label = extMeta.Label
|
||||
}
|
||||
if copyright != nil && *copyright == "" && extMeta.Copyright != "" {
|
||||
*copyright = extMeta.Copyright
|
||||
}
|
||||
}
|
||||
|
||||
func enrichExtraMetadataByISRC(
|
||||
logPrefix string,
|
||||
isrc string,
|
||||
genre *string,
|
||||
label *string,
|
||||
copyright *string,
|
||||
) {
|
||||
normalizedISRC := strings.TrimSpace(isrc)
|
||||
if normalizedISRC == "" {
|
||||
return
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
extMeta, err := fetchDeezerExtendedMetadataByISRC(ctx, normalizedISRC)
|
||||
if err != nil {
|
||||
GoLog("[%s] Failed to get extended metadata from Deezer: %v\n", logPrefix, err)
|
||||
}
|
||||
applyExtendedMetadataFields(genre, label, copyright, extMeta)
|
||||
|
||||
if genre != nil && *genre == "" {
|
||||
musicBrainzGenre, err := fetchMusicBrainzGenreByISRC(normalizedISRC)
|
||||
if err != nil {
|
||||
GoLog("[%s] Failed to get genre from MusicBrainz: %v\n", logPrefix, err)
|
||||
} else if musicBrainzGenre != "" {
|
||||
*genre = musicBrainzGenre
|
||||
GoLog("[%s] Genre fallback from MusicBrainz: %s\n", logPrefix, *genre)
|
||||
}
|
||||
}
|
||||
|
||||
currentGenre := ""
|
||||
currentLabel := ""
|
||||
currentCopyright := ""
|
||||
if genre != nil {
|
||||
currentGenre = *genre
|
||||
}
|
||||
if label != nil {
|
||||
currentLabel = *label
|
||||
}
|
||||
if copyright != nil {
|
||||
currentCopyright = *copyright
|
||||
}
|
||||
if currentGenre != "" || currentLabel != "" || currentCopyright != "" {
|
||||
GoLog("[%s] Extended metadata ready: genre=%s, label=%s, copyright=%s\n", logPrefix, currentGenre, currentLabel, currentCopyright)
|
||||
}
|
||||
}
|
||||
|
||||
func enrichRequestExtendedMetadata(req *DownloadRequest) {
|
||||
if req == nil {
|
||||
return
|
||||
}
|
||||
|
||||
if req.ISRC == "" {
|
||||
return
|
||||
}
|
||||
|
||||
if strings.TrimSpace(req.AlbumArtist) == "" {
|
||||
albumArtist, err := fetchMusicBrainzAlbumArtistByISRC(req.ISRC, req.AlbumName)
|
||||
if err != nil {
|
||||
GoLog("[DownloadWithFallback] Failed to get album artist from MusicBrainz: %v\n", err)
|
||||
} else if strings.TrimSpace(albumArtist) != "" {
|
||||
req.AlbumArtist = strings.TrimSpace(albumArtist)
|
||||
GoLog("[DownloadWithFallback] Album artist fallback from MusicBrainz: %s\n", req.AlbumArtist)
|
||||
}
|
||||
}
|
||||
|
||||
if req.Genre == "" || req.Label == "" || req.Copyright == "" {
|
||||
enrichExtraMetadataByISRC(
|
||||
"DownloadWithFallback",
|
||||
req.ISRC,
|
||||
&req.Genre,
|
||||
&req.Label,
|
||||
&req.Copyright,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
func applySongLinkRegionFromRequest(req *DownloadRequest) {
|
||||
if req == nil {
|
||||
return
|
||||
}
|
||||
SetSongLinkRegion(req.SongLinkRegion)
|
||||
}
|
||||
|
||||
func DownloadTrack(requestJSON string) (string, error) {
|
||||
return errorResponse("Built-in download providers have been retired. Use downloadByStrategy with extension providers.")
|
||||
}
|
||||
|
||||
// DownloadByStrategy routes all download requests through extension providers.
|
||||
func DownloadByStrategy(requestJSON string) (string, error) {
|
||||
var req DownloadRequest
|
||||
if err := json.Unmarshal([]byte(requestJSON), &req); err != nil {
|
||||
return errorResponse("Invalid request: " + err.Error())
|
||||
}
|
||||
normalizedBytes, err := json.Marshal(req)
|
||||
if err != nil {
|
||||
return errorResponse("Invalid request: " + err.Error())
|
||||
}
|
||||
normalizedJSON := string(normalizedBytes)
|
||||
|
||||
if req.UseExtensions {
|
||||
resp, err := DownloadWithExtensionsJSON(normalizedJSON)
|
||||
if err != nil {
|
||||
return errorResponse(err.Error())
|
||||
}
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
return errorResponse("Extension providers are disabled; built-in download providers have been retired")
|
||||
}
|
||||
|
||||
func DownloadWithFallback(requestJSON string) (string, error) {
|
||||
return errorResponse("Built-in fallback has been retired. Use extension fallback through downloadByStrategy.")
|
||||
}
|
||||
|
||||
func GetDownloadProgress() string {
|
||||
progress := getProgress()
|
||||
jsonBytes, _ := json.Marshal(progress)
|
||||
return string(jsonBytes)
|
||||
}
|
||||
|
||||
func GetAllDownloadProgress() string {
|
||||
return GetMultiProgress()
|
||||
}
|
||||
|
||||
func GetAllDownloadProgressDelta(sinceSeq int64) string {
|
||||
return GetMultiProgressDelta(sinceSeq)
|
||||
}
|
||||
|
||||
func InitItemProgress(itemID string) {
|
||||
StartItemProgress(itemID)
|
||||
}
|
||||
|
||||
func FinishItemProgress(itemID string) {
|
||||
CompleteItemProgress(itemID)
|
||||
}
|
||||
|
||||
func ClearItemProgress(itemID string) {
|
||||
RemoveItemProgress(itemID)
|
||||
}
|
||||
|
||||
func CancelDownload(itemID string) {
|
||||
cancelDownload(itemID)
|
||||
}
|
||||
|
||||
func CleanupConnections() {
|
||||
CloseIdleConnections()
|
||||
}
|
||||
|
||||
func errorResponse(msg string) (string, error) {
|
||||
errorType := classifyDownloadErrorType(msg)
|
||||
|
||||
resp := DownloadResponse{
|
||||
Success: false,
|
||||
Error: msg,
|
||||
ErrorType: errorType,
|
||||
}
|
||||
jsonBytes, _ := json.Marshal(resp)
|
||||
return string(jsonBytes), nil
|
||||
}
|
||||
|
||||
func classifyDownloadErrorType(msg string) string {
|
||||
lowerMsg := strings.ToLower(msg)
|
||||
|
||||
if strings.Contains(lowerMsg, "isp blocking") ||
|
||||
strings.Contains(lowerMsg, "try using vpn") ||
|
||||
strings.Contains(lowerMsg, "change dns") {
|
||||
return "isp_blocked"
|
||||
} else if strings.Contains(lowerMsg, "cancel") {
|
||||
return "cancelled"
|
||||
} else if strings.Contains(lowerMsg, "verify_required") ||
|
||||
strings.Contains(lowerMsg, "verification_required") ||
|
||||
strings.Contains(lowerMsg, "verification required") ||
|
||||
strings.Contains(lowerMsg, "needs verification") ||
|
||||
strings.Contains(lowerMsg, "session is not authenticated") ||
|
||||
strings.Contains(lowerMsg, "signed session is not authenticated") ||
|
||||
strings.Contains(lowerMsg, "unauthorized") ||
|
||||
strings.Contains(lowerMsg, "precondition required") ||
|
||||
messageHasHTTPStatusCode(lowerMsg, "401") ||
|
||||
messageHasHTTPStatusCode(lowerMsg, "428") {
|
||||
return "verification_required"
|
||||
} else if strings.Contains(lowerMsg, "rate limit") ||
|
||||
messageHasHTTPStatusCode(lowerMsg, "429") ||
|
||||
strings.Contains(lowerMsg, "too many requests") {
|
||||
return "rate_limit"
|
||||
} else if strings.Contains(lowerMsg, "permission") ||
|
||||
strings.Contains(lowerMsg, "operation not permitted") ||
|
||||
strings.Contains(lowerMsg, "access denied") ||
|
||||
strings.Contains(lowerMsg, "failed to create file") ||
|
||||
strings.Contains(lowerMsg, "failed to create directory") {
|
||||
return "permission"
|
||||
} else if strings.Contains(lowerMsg, "not found") ||
|
||||
strings.Contains(lowerMsg, "not available") ||
|
||||
strings.Contains(lowerMsg, "no results") ||
|
||||
strings.Contains(lowerMsg, "track not found") ||
|
||||
strings.Contains(lowerMsg, "all services failed") {
|
||||
return "not_found"
|
||||
} else if strings.Contains(lowerMsg, "network") ||
|
||||
strings.Contains(lowerMsg, "connection") ||
|
||||
strings.Contains(lowerMsg, "timeout") ||
|
||||
strings.Contains(lowerMsg, "dial") {
|
||||
return "network"
|
||||
}
|
||||
|
||||
return "unknown"
|
||||
}
|
||||
|
||||
func messageHasHTTPStatusCode(lowerMsg, code string) bool {
|
||||
return strings.Contains(lowerMsg, "http "+code) ||
|
||||
strings.Contains(lowerMsg, "http status "+code) ||
|
||||
strings.Contains(lowerMsg, "status "+code) ||
|
||||
strings.Contains(lowerMsg, code+" for ") ||
|
||||
strings.Contains(lowerMsg, code+":") ||
|
||||
strings.Contains(lowerMsg, code+";")
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,37 @@
|
||||
package gobackend
|
||||
|
||||
func SetLibraryCoverCacheDirJSON(cacheDir string) {
|
||||
SetLibraryCoverCacheDir(cacheDir)
|
||||
}
|
||||
|
||||
func ScanLibraryFolderJSON(folderPath string) (string, error) {
|
||||
return ScanLibraryFolder(folderPath)
|
||||
}
|
||||
|
||||
func ScanLibraryFolderIncrementalJSON(folderPath, existingFilesJSON string) (string, error) {
|
||||
return ScanLibraryFolderIncremental(folderPath, existingFilesJSON)
|
||||
}
|
||||
|
||||
func ScanLibraryFolderIncrementalFromSnapshotJSON(folderPath, snapshotPath string) (string, error) {
|
||||
return ScanLibraryFolderIncrementalFromSnapshot(folderPath, snapshotPath)
|
||||
}
|
||||
|
||||
func GetLibraryScanProgressJSON() string {
|
||||
return GetLibraryScanProgress()
|
||||
}
|
||||
|
||||
func CancelLibraryScanJSON() {
|
||||
CancelLibraryScan()
|
||||
}
|
||||
|
||||
func ReadAudioMetadataJSON(filePath string) (string, error) {
|
||||
return ReadAudioMetadata(filePath)
|
||||
}
|
||||
|
||||
func ReadAudioMetadataWithHintJSON(filePath, displayName string) (string, error) {
|
||||
return ReadAudioMetadataWithDisplayName(filePath, displayName)
|
||||
}
|
||||
|
||||
func ReadAudioMetadataWithHintAndCoverCacheKeyJSON(filePath, displayName, coverCacheKey string) (string, error) {
|
||||
return ReadAudioMetadataWithDisplayNameAndCoverCacheKey(filePath, displayName, coverCacheKey)
|
||||
}
|
||||
@@ -0,0 +1,221 @@
|
||||
package gobackend
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func FetchLyrics(spotifyID, trackName, artistName string, durationMs int64) (string, error) {
|
||||
client := NewLyricsClient()
|
||||
durationSec := float64(durationMs) / 1000.0
|
||||
lyrics, err := client.FetchLyricsAllSources(spotifyID, trackName, artistName, durationSec)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
result := map[string]interface{}{
|
||||
"success": true,
|
||||
"source": lyrics.Source,
|
||||
"sync_type": lyrics.SyncType,
|
||||
"lines": lyrics.Lines,
|
||||
"instrumental": lyrics.Instrumental,
|
||||
}
|
||||
|
||||
jsonBytes, err := json.Marshal(result)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
return string(jsonBytes), nil
|
||||
}
|
||||
|
||||
func GetLyricsLRC(spotifyID, trackName, artistName string, filePath string, durationMs int64) (string, error) {
|
||||
if filePath != "" {
|
||||
lyrics, err := ExtractLyrics(filePath)
|
||||
if err == nil && lyrics != "" {
|
||||
return lyrics, nil
|
||||
}
|
||||
return "", nil
|
||||
}
|
||||
|
||||
client := NewLyricsClient()
|
||||
durationSec := float64(durationMs) / 1000.0
|
||||
lyricsData, err := client.FetchLyricsAllSources(spotifyID, trackName, artistName, durationSec)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
if lyricsData.Instrumental {
|
||||
return "[instrumental:true]", nil
|
||||
}
|
||||
|
||||
lrcContent := convertToLRCWithMetadata(lyricsData, trackName, artistName)
|
||||
return lrcContent, nil
|
||||
}
|
||||
|
||||
func GetLyricsLRCWithSource(spotifyID, trackName, artistName string, filePath string, durationMs int64) (string, error) {
|
||||
if filePath != "" {
|
||||
lyrics, err := ExtractLyrics(filePath)
|
||||
if err == nil && lyrics != "" {
|
||||
source := extractLyricsSourceFromLRC(lyrics)
|
||||
if source == "" {
|
||||
source = "Embedded"
|
||||
}
|
||||
result := map[string]interface{}{
|
||||
"lyrics": lyrics,
|
||||
"source": source,
|
||||
"sync_type": "EMBEDDED",
|
||||
"instrumental": false,
|
||||
}
|
||||
jsonBytes, err := json.Marshal(result)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return string(jsonBytes), nil
|
||||
}
|
||||
|
||||
result := map[string]interface{}{
|
||||
"lyrics": "",
|
||||
"source": "",
|
||||
"sync_type": "",
|
||||
"instrumental": false,
|
||||
}
|
||||
jsonBytes, err := json.Marshal(result)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return string(jsonBytes), nil
|
||||
}
|
||||
|
||||
client := NewLyricsClient()
|
||||
durationSec := float64(durationMs) / 1000.0
|
||||
lyricsData, err := client.FetchLyricsAllSources(spotifyID, trackName, artistName, durationSec)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
lrcContent := ""
|
||||
if lyricsData.Instrumental {
|
||||
lrcContent = "[instrumental:true]"
|
||||
} else {
|
||||
lrcContent = convertToLRCWithMetadata(lyricsData, trackName, artistName)
|
||||
}
|
||||
|
||||
result := map[string]interface{}{
|
||||
"lyrics": lrcContent,
|
||||
"source": lyricsData.Source,
|
||||
"sync_type": lyricsData.SyncType,
|
||||
"instrumental": lyricsData.Instrumental,
|
||||
}
|
||||
jsonBytes, err := json.Marshal(result)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
return string(jsonBytes), nil
|
||||
}
|
||||
|
||||
func EmbedLyricsToFile(filePath, lyrics string) (string, error) {
|
||||
err := EmbedLyrics(filePath, lyrics)
|
||||
if err != nil {
|
||||
return errorResponse("Failed to embed lyrics: " + err.Error())
|
||||
}
|
||||
|
||||
resp := map[string]interface{}{
|
||||
"success": true,
|
||||
"message": "Lyrics embedded successfully",
|
||||
}
|
||||
|
||||
jsonBytes, _ := json.Marshal(resp)
|
||||
return string(jsonBytes), nil
|
||||
}
|
||||
|
||||
func FetchAndSaveLyrics(trackName, artistName, spotifyID string, durationMs int64, outputPath string, audioFilePath string) error {
|
||||
// If the audio file already has embedded lyrics or a sidecar .lrc,
|
||||
// use those directly instead of making redundant network requests.
|
||||
if audioFilePath != "" {
|
||||
existing, err := ExtractLyrics(audioFilePath)
|
||||
if err == nil && strings.TrimSpace(existing) != "" {
|
||||
if err := os.WriteFile(outputPath, []byte(existing), 0644); err != nil {
|
||||
return fmt.Errorf("failed to write LRC file: %w", err)
|
||||
}
|
||||
GoLog("[Lyrics] Saved LRC from embedded/sidecar to: %s\n", outputPath)
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
client := NewLyricsClient()
|
||||
durationSec := float64(durationMs) / 1000.0
|
||||
|
||||
lyrics, err := client.FetchLyricsAllSources(spotifyID, trackName, artistName, durationSec)
|
||||
if err != nil {
|
||||
return fmt.Errorf("lyrics not found: %w", err)
|
||||
}
|
||||
|
||||
if lyrics.Instrumental {
|
||||
return fmt.Errorf("track is instrumental, no lyrics available")
|
||||
}
|
||||
|
||||
lrcContent := convertToLRCWithMetadata(lyrics, trackName, artistName)
|
||||
if lrcContent == "" {
|
||||
return fmt.Errorf("failed to generate LRC content")
|
||||
}
|
||||
|
||||
if err := os.WriteFile(outputPath, []byte(lrcContent), 0644); err != nil {
|
||||
return fmt.Errorf("failed to write LRC file: %w", err)
|
||||
}
|
||||
|
||||
GoLog("[Lyrics] Saved LRC to: %s (%d lines)\n", outputPath, len(lyrics.Lines))
|
||||
return nil
|
||||
}
|
||||
|
||||
func SetLyricsProvidersJSON(providersJSON string) error {
|
||||
var providers []string
|
||||
if err := json.Unmarshal([]byte(providersJSON), &providers); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
SetLyricsProviderOrder(providers)
|
||||
return nil
|
||||
}
|
||||
|
||||
func GetLyricsProvidersJSON() (string, error) {
|
||||
providers := GetLyricsProviderOrder()
|
||||
jsonBytes, err := json.Marshal(providers)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return string(jsonBytes), nil
|
||||
}
|
||||
|
||||
func GetAvailableLyricsProvidersJSON() (string, error) {
|
||||
providers := GetAvailableLyricsProviders()
|
||||
jsonBytes, err := json.Marshal(providers)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return string(jsonBytes), nil
|
||||
}
|
||||
|
||||
func SetLyricsFetchOptionsJSON(optionsJSON string) error {
|
||||
opts := GetLyricsFetchOptions()
|
||||
if strings.TrimSpace(optionsJSON) != "" {
|
||||
if err := json.Unmarshal([]byte(optionsJSON), &opts); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
SetLyricsFetchOptions(opts)
|
||||
return nil
|
||||
}
|
||||
|
||||
func GetLyricsFetchOptionsJSON() (string, error) {
|
||||
opts := GetLyricsFetchOptions()
|
||||
jsonBytes, err := json.Marshal(opts)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return string(jsonBytes), nil
|
||||
}
|
||||
@@ -0,0 +1,679 @@
|
||||
package gobackend
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
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]interface{}{
|
||||
"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 {
|
||||
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
|
||||
}
|
||||
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 {
|
||||
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
|
||||
}
|
||||
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 {
|
||||
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
|
||||
}
|
||||
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 {
|
||||
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
|
||||
}
|
||||
}
|
||||
} 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 {
|
||||
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
|
||||
}
|
||||
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)
|
||||
}
|
||||
|
||||
jsonBytes, err := json.Marshal(result)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
return string(jsonBytes), nil
|
||||
}
|
||||
|
||||
// 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)
|
||||
}
|
||||
|
||||
resp := map[string]any{"success": true, "method": "native_m4a_freeform"}
|
||||
jsonBytes, _ := json.Marshal(resp)
|
||||
return string(jsonBytes), nil
|
||||
}
|
||||
|
||||
// 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}
|
||||
jsonBytes, _ := json.Marshal(resp)
|
||||
return string(jsonBytes), 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)
|
||||
}
|
||||
|
||||
resp := map[string]any{
|
||||
"success": true,
|
||||
"method": "native_m4a_replaygain",
|
||||
}
|
||||
jsonBytes, _ := json.Marshal(resp)
|
||||
return string(jsonBytes), nil
|
||||
}
|
||||
|
||||
if isFlac {
|
||||
if err := EditFlacFields(filePath, fields); err != nil {
|
||||
return "", fmt.Errorf("failed to write FLAC metadata: %w", err)
|
||||
}
|
||||
|
||||
resp := map[string]any{
|
||||
"success": true,
|
||||
"method": "native",
|
||||
}
|
||||
jsonBytes, _ := json.Marshal(resp)
|
||||
return string(jsonBytes), nil
|
||||
}
|
||||
|
||||
// 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)
|
||||
}
|
||||
resp := map[string]any{"success": true, "method": "native_wav"}
|
||||
jsonBytes, _ := json.Marshal(resp)
|
||||
return string(jsonBytes), nil
|
||||
}
|
||||
if isAiffFile {
|
||||
if err := WriteAIFFTags(filePath, fields); err != nil {
|
||||
return "", fmt.Errorf("failed to write AIFF metadata: %w", err)
|
||||
}
|
||||
resp := map[string]any{"success": true, "method": "native_aiff"}
|
||||
jsonBytes, _ := json.Marshal(resp)
|
||||
return string(jsonBytes), nil
|
||||
}
|
||||
|
||||
if isApeFile {
|
||||
trackNum := 0
|
||||
totalTracks := 0
|
||||
discNum := 0
|
||||
totalDiscs := 0
|
||||
if v, ok := fields["track_number"]; ok && v != "" {
|
||||
fmt.Sscanf(v, "%d", &trackNum)
|
||||
}
|
||||
if v, ok := fields["track_total"]; ok && v != "" {
|
||||
fmt.Sscanf(v, "%d", &totalTracks)
|
||||
}
|
||||
if v, ok := fields["disc_number"]; ok && v != "" {
|
||||
fmt.Sscanf(v, "%d", &discNum)
|
||||
}
|
||||
if v, ok := fields["disc_total"]; ok && v != "" {
|
||||
fmt.Sscanf(v, "%d", &totalDiscs)
|
||||
}
|
||||
|
||||
meta := &AudioMetadata{
|
||||
Title: fields["title"],
|
||||
Artist: fields["artist"],
|
||||
Album: fields["album"],
|
||||
AlbumArtist: fields["album_artist"],
|
||||
Date: fields["date"],
|
||||
TrackNumber: trackNum,
|
||||
TotalTracks: totalTracks,
|
||||
DiscNumber: discNum,
|
||||
TotalDiscs: totalDiscs,
|
||||
ISRC: fields["isrc"],
|
||||
Lyrics: fields["lyrics"],
|
||||
Genre: fields["genre"],
|
||||
Label: fields["label"],
|
||||
Copyright: fields["copyright"],
|
||||
Composer: fields["composer"],
|
||||
Comment: fields["comment"],
|
||||
ReplayGainTrackGain: fields["replaygain_track_gain"],
|
||||
ReplayGainTrackPeak: fields["replaygain_track_peak"],
|
||||
ReplayGainAlbumGain: fields["replaygain_album_gain"],
|
||||
ReplayGainAlbumPeak: fields["replaygain_album_peak"],
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
resp := map[string]any{
|
||||
"success": true,
|
||||
"method": "native_ape",
|
||||
}
|
||||
jsonBytes, _ := json.Marshal(resp)
|
||||
return string(jsonBytes), nil
|
||||
}
|
||||
|
||||
resp := map[string]any{
|
||||
"success": true,
|
||||
"method": "ffmpeg",
|
||||
"fields": fields,
|
||||
}
|
||||
jsonBytes, _ := json.Marshal(resp)
|
||||
return string(jsonBytes), 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]interface{}{
|
||||
"success": true,
|
||||
"message": "Split artist tags written successfully",
|
||||
}
|
||||
|
||||
jsonBytes, _ := json.Marshal(resp)
|
||||
return string(jsonBytes), 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
|
||||
}
|
||||
@@ -0,0 +1,235 @@
|
||||
package gobackend
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"golang.org/x/text/cases"
|
||||
"golang.org/x/text/language"
|
||||
)
|
||||
|
||||
const musicBrainzAPIBase = "https://musicbrainz.org/ws/2"
|
||||
|
||||
type musicBrainzTag struct {
|
||||
Count int `json:"count"`
|
||||
Name string `json:"name"`
|
||||
}
|
||||
|
||||
type musicBrainzRecordingResponse struct {
|
||||
Recordings []struct {
|
||||
Tags []musicBrainzTag `json:"tags"`
|
||||
} `json:"recordings"`
|
||||
}
|
||||
|
||||
type musicBrainzArtistCredit struct {
|
||||
Name string `json:"name"`
|
||||
JoinPhrase string `json:"joinphrase"`
|
||||
}
|
||||
|
||||
type musicBrainzRelease struct {
|
||||
Title string `json:"title"`
|
||||
ArtistCredit []musicBrainzArtistCredit `json:"artist-credit"`
|
||||
}
|
||||
|
||||
type musicBrainzAlbumArtistResponse struct {
|
||||
Recordings []struct {
|
||||
Releases []musicBrainzRelease `json:"releases"`
|
||||
} `json:"recordings"`
|
||||
}
|
||||
|
||||
func formatMusicBrainzGenre(tags []musicBrainzTag) string {
|
||||
if len(tags) == 0 {
|
||||
return ""
|
||||
}
|
||||
|
||||
caser := cases.Title(language.English)
|
||||
seen := make(map[string]struct{}, len(tags))
|
||||
maxCount := -1
|
||||
bestTag := ""
|
||||
|
||||
for _, tag := range tags {
|
||||
name := strings.TrimSpace(tag.Name)
|
||||
if name == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
key := strings.ToLower(name)
|
||||
if _, exists := seen[key]; exists {
|
||||
continue
|
||||
}
|
||||
seen[key] = struct{}{}
|
||||
|
||||
formatted := caser.String(name)
|
||||
if tag.Count > maxCount {
|
||||
maxCount = tag.Count
|
||||
bestTag = formatted
|
||||
}
|
||||
}
|
||||
|
||||
return bestTag
|
||||
}
|
||||
|
||||
func formatMusicBrainzArtistCredit(credits []musicBrainzArtistCredit) string {
|
||||
var builder strings.Builder
|
||||
for _, credit := range credits {
|
||||
name := strings.TrimSpace(credit.Name)
|
||||
if name == "" {
|
||||
continue
|
||||
}
|
||||
builder.WriteString(name)
|
||||
builder.WriteString(credit.JoinPhrase)
|
||||
}
|
||||
return strings.TrimSpace(builder.String())
|
||||
}
|
||||
|
||||
func selectMusicBrainzAlbumArtist(releases []musicBrainzRelease, albumName string) string {
|
||||
if len(releases) == 0 {
|
||||
return ""
|
||||
}
|
||||
|
||||
normalizedAlbum := strings.ToLower(strings.TrimSpace(albumName))
|
||||
if normalizedAlbum != "" {
|
||||
for _, release := range releases {
|
||||
if strings.ToLower(strings.TrimSpace(release.Title)) != normalizedAlbum {
|
||||
continue
|
||||
}
|
||||
if albumArtist := formatMusicBrainzArtistCredit(release.ArtistCredit); albumArtist != "" {
|
||||
return albumArtist
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for _, release := range releases {
|
||||
if albumArtist := formatMusicBrainzArtistCredit(release.ArtistCredit); albumArtist != "" {
|
||||
return albumArtist
|
||||
}
|
||||
}
|
||||
|
||||
return ""
|
||||
}
|
||||
|
||||
func FetchMusicBrainzAlbumArtistByISRC(isrc string, albumName string) (string, error) {
|
||||
normalizedISRC := strings.ToUpper(strings.TrimSpace(isrc))
|
||||
if normalizedISRC == "" {
|
||||
return "", fmt.Errorf("no ISRC provided")
|
||||
}
|
||||
|
||||
client := NewMetadataHTTPClient(10 * time.Second)
|
||||
query := fmt.Sprintf("isrc:%s", normalizedISRC)
|
||||
reqURL := fmt.Sprintf(
|
||||
"%s/recording?query=%s&fmt=json&inc=releases+artist-credits",
|
||||
musicBrainzAPIBase,
|
||||
url.QueryEscape(query),
|
||||
)
|
||||
|
||||
req, err := http.NewRequest(http.MethodGet, reqURL, nil)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
req.Header.Set("User-Agent", getRandomUserAgent())
|
||||
|
||||
var resp *http.Response
|
||||
var lastErr error
|
||||
for attempt := 0; attempt < 3; attempt++ {
|
||||
resp, lastErr = client.Do(req)
|
||||
if lastErr == nil && resp.StatusCode == http.StatusOK {
|
||||
break
|
||||
}
|
||||
if resp != nil {
|
||||
resp.Body.Close()
|
||||
}
|
||||
if attempt < 2 {
|
||||
time.Sleep(2 * time.Second)
|
||||
}
|
||||
}
|
||||
|
||||
if lastErr != nil {
|
||||
return "", lastErr
|
||||
}
|
||||
if resp == nil {
|
||||
return "", fmt.Errorf("MusicBrainz request failed without response")
|
||||
}
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
resp.Body.Close()
|
||||
return "", fmt.Errorf("MusicBrainz API returned status: %d", resp.StatusCode)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
var payload musicBrainzAlbumArtistResponse
|
||||
if err := json.NewDecoder(resp.Body).Decode(&payload); err != nil {
|
||||
return "", err
|
||||
}
|
||||
for _, recording := range payload.Recordings {
|
||||
if albumArtist := selectMusicBrainzAlbumArtist(recording.Releases, albumName); albumArtist != "" {
|
||||
return albumArtist, nil
|
||||
}
|
||||
}
|
||||
|
||||
return "", fmt.Errorf("no MusicBrainz album artist found for ISRC: %s", normalizedISRC)
|
||||
}
|
||||
|
||||
func FetchMusicBrainzGenreByISRC(isrc string) (string, error) {
|
||||
normalizedISRC := strings.ToUpper(strings.TrimSpace(isrc))
|
||||
if normalizedISRC == "" {
|
||||
return "", fmt.Errorf("no ISRC provided")
|
||||
}
|
||||
|
||||
client := NewMetadataHTTPClient(10 * time.Second)
|
||||
query := fmt.Sprintf("isrc:%s", normalizedISRC)
|
||||
reqURL := fmt.Sprintf(
|
||||
"%s/recording?query=%s&fmt=json&inc=tags",
|
||||
musicBrainzAPIBase,
|
||||
url.QueryEscape(query),
|
||||
)
|
||||
|
||||
req, err := http.NewRequest(http.MethodGet, reqURL, nil)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
req.Header.Set("User-Agent", getRandomUserAgent())
|
||||
|
||||
var resp *http.Response
|
||||
var lastErr error
|
||||
for attempt := 0; attempt < 3; attempt++ {
|
||||
resp, lastErr = client.Do(req)
|
||||
if lastErr == nil && resp.StatusCode == http.StatusOK {
|
||||
break
|
||||
}
|
||||
if resp != nil {
|
||||
resp.Body.Close()
|
||||
}
|
||||
if attempt < 2 {
|
||||
time.Sleep(2 * time.Second)
|
||||
}
|
||||
}
|
||||
|
||||
if lastErr != nil {
|
||||
return "", lastErr
|
||||
}
|
||||
if resp == nil {
|
||||
return "", fmt.Errorf("MusicBrainz request failed without response")
|
||||
}
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
resp.Body.Close()
|
||||
return "", fmt.Errorf("MusicBrainz API returned status: %d", resp.StatusCode)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
var payload musicBrainzRecordingResponse
|
||||
if err := json.NewDecoder(resp.Body).Decode(&payload); err != nil {
|
||||
return "", err
|
||||
}
|
||||
if len(payload.Recordings) == 0 {
|
||||
return "", fmt.Errorf("no recordings found for ISRC: %s", normalizedISRC)
|
||||
}
|
||||
|
||||
genre := formatMusicBrainzGenre(payload.Recordings[0].Tags)
|
||||
if genre == "" {
|
||||
return "", fmt.Errorf("no MusicBrainz genre tags found for ISRC: %s", normalizedISRC)
|
||||
}
|
||||
return genre, nil
|
||||
}
|
||||
@@ -0,0 +1,764 @@
|
||||
package gobackend
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
var fetchDeezerExtendedMetadataByISRC = func(ctx context.Context, isrc string) (*AlbumExtendedMetadata, error) {
|
||||
return GetDeezerClient().GetExtendedMetadataByISRC(ctx, isrc)
|
||||
}
|
||||
|
||||
var fetchMusicBrainzGenreByISRC = FetchMusicBrainzGenreByISRC
|
||||
|
||||
var fetchMusicBrainzAlbumArtistByISRC = FetchMusicBrainzAlbumArtistByISRC
|
||||
|
||||
type reEnrichRequest struct {
|
||||
FilePath string `json:"file_path"`
|
||||
CoverURL string `json:"cover_url"`
|
||||
MaxQuality bool `json:"max_quality"`
|
||||
EmbedLyrics bool `json:"embed_lyrics"`
|
||||
LyricsMode string `json:"lyrics_mode,omitempty"`
|
||||
ArtistTagMode string `json:"artist_tag_mode,omitempty"`
|
||||
SpotifyID string `json:"spotify_id"`
|
||||
TrackName string `json:"track_name"`
|
||||
ArtistName string `json:"artist_name"`
|
||||
AlbumName string `json:"album_name"`
|
||||
AlbumArtist string `json:"album_artist"`
|
||||
TrackNumber int `json:"track_number"`
|
||||
DiscNumber int `json:"disc_number"`
|
||||
TotalTracks int `json:"total_tracks,omitempty"`
|
||||
TotalDiscs int `json:"total_discs,omitempty"`
|
||||
ReleaseDate string `json:"release_date"`
|
||||
ISRC string `json:"isrc"`
|
||||
Genre string `json:"genre"`
|
||||
Label string `json:"label"`
|
||||
Copyright string `json:"copyright"`
|
||||
Composer string `json:"composer"`
|
||||
DurationMs int64 `json:"duration_ms"`
|
||||
SearchOnline bool `json:"search_online"`
|
||||
UpdateFields []string `json:"update_fields,omitempty"`
|
||||
}
|
||||
|
||||
// shouldUpdateField returns true if the given field group should be updated.
|
||||
// When UpdateFields is empty/nil, all fields are updated (backward compatible).
|
||||
func (r *reEnrichRequest) shouldUpdateField(field string) bool {
|
||||
if len(r.UpdateFields) == 0 {
|
||||
return true
|
||||
}
|
||||
for _, f := range r.UpdateFields {
|
||||
if f == field {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// lyricsEmbedEnabled reports whether lyrics should be written into the audio
|
||||
// file's tags. It mirrors the download path semantics: 'embed' and 'both' embed,
|
||||
// 'external' does not. An empty mode keeps the legacy behavior (embed) so older
|
||||
// callers that do not send lyrics_mode are unaffected.
|
||||
func (r *reEnrichRequest) lyricsEmbedEnabled() bool {
|
||||
return strings.ToLower(strings.TrimSpace(r.LyricsMode)) != "external"
|
||||
}
|
||||
|
||||
// lyricsSidecarEnabled reports whether a .lrc sidecar file should be written
|
||||
// next to the audio file. Only 'external' and 'both' request a sidecar.
|
||||
func (r *reEnrichRequest) lyricsSidecarEnabled() bool {
|
||||
mode := strings.ToLower(strings.TrimSpace(r.LyricsMode))
|
||||
return mode == "external" || mode == "both"
|
||||
}
|
||||
|
||||
func applyReEnrichTrackMetadata(req *reEnrichRequest, track ExtTrackMetadata) {
|
||||
if req == nil {
|
||||
return
|
||||
}
|
||||
|
||||
if track.SpotifyID != "" {
|
||||
req.SpotifyID = track.SpotifyID
|
||||
} else if track.DeezerID != "" {
|
||||
req.SpotifyID = "deezer:" + track.DeezerID
|
||||
} else if track.QobuzID != "" {
|
||||
req.SpotifyID = "qobuz:" + track.QobuzID
|
||||
} else if track.TidalID != "" {
|
||||
req.SpotifyID = "tidal:" + track.TidalID
|
||||
} else if track.ID != "" {
|
||||
req.SpotifyID = track.ID
|
||||
}
|
||||
|
||||
if req.shouldUpdateField("basic_tags") {
|
||||
if track.Name != "" {
|
||||
req.TrackName = track.Name
|
||||
}
|
||||
if track.Artists != "" {
|
||||
req.ArtistName = track.Artists
|
||||
}
|
||||
if track.AlbumName != "" {
|
||||
req.AlbumName = track.AlbumName
|
||||
}
|
||||
if track.AlbumArtist != "" {
|
||||
req.AlbumArtist = track.AlbumArtist
|
||||
}
|
||||
}
|
||||
if req.shouldUpdateField("track_info") {
|
||||
if track.TrackNumber > 0 {
|
||||
req.TrackNumber = track.TrackNumber
|
||||
}
|
||||
if track.TotalTracks > 0 {
|
||||
req.TotalTracks = track.TotalTracks
|
||||
}
|
||||
if track.DiscNumber > 0 {
|
||||
req.DiscNumber = track.DiscNumber
|
||||
}
|
||||
if track.TotalDiscs > 0 {
|
||||
req.TotalDiscs = track.TotalDiscs
|
||||
}
|
||||
}
|
||||
if req.shouldUpdateField("release_info") {
|
||||
if track.ReleaseDate != "" {
|
||||
req.ReleaseDate = track.ReleaseDate
|
||||
}
|
||||
if track.ISRC != "" {
|
||||
req.ISRC = track.ISRC
|
||||
}
|
||||
}
|
||||
if req.shouldUpdateField("cover") {
|
||||
if coverURL := track.ResolvedCoverURL(); coverURL != "" {
|
||||
req.CoverURL = coverURL
|
||||
}
|
||||
}
|
||||
if track.DurationMS > 0 {
|
||||
req.DurationMs = int64(track.DurationMS)
|
||||
}
|
||||
if req.shouldUpdateField("extra") {
|
||||
if track.Genre != "" {
|
||||
req.Genre = track.Genre
|
||||
}
|
||||
if track.Label != "" {
|
||||
req.Label = track.Label
|
||||
}
|
||||
if track.Copyright != "" {
|
||||
req.Copyright = track.Copyright
|
||||
}
|
||||
if track.Composer != "" {
|
||||
req.Composer = track.Composer
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func isPlaceholderReEnrichValue(value string) bool {
|
||||
switch strings.ToLower(strings.TrimSpace(value)) {
|
||||
case "", "unknown", "unknown artist", "unknown title", "unknown album":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func buildReEnrichSearchQuery(req reEnrichRequest) string {
|
||||
parts := make([]string, 0, 2)
|
||||
if !isPlaceholderReEnrichValue(req.TrackName) {
|
||||
parts = append(parts, strings.TrimSpace(req.TrackName))
|
||||
}
|
||||
if !isPlaceholderReEnrichValue(req.ArtistName) {
|
||||
parts = append(parts, strings.TrimSpace(req.ArtistName))
|
||||
}
|
||||
if len(parts) == 0 && !isPlaceholderReEnrichValue(req.AlbumName) {
|
||||
parts = append(parts, strings.TrimSpace(req.AlbumName))
|
||||
}
|
||||
return strings.TrimSpace(strings.Join(parts, " "))
|
||||
}
|
||||
|
||||
func reEnrichDownloadRequest(req reEnrichRequest) DownloadRequest {
|
||||
return DownloadRequest{
|
||||
TrackName: req.TrackName,
|
||||
ArtistName: req.ArtistName,
|
||||
AlbumName: req.AlbumName,
|
||||
ReleaseDate: req.ReleaseDate,
|
||||
ISRC: req.ISRC,
|
||||
DurationMS: int(req.DurationMs),
|
||||
ArtistTagMode: req.ArtistTagMode,
|
||||
TrackNumber: req.TrackNumber,
|
||||
TotalTracks: req.TotalTracks,
|
||||
DiscNumber: req.DiscNumber,
|
||||
TotalDiscs: req.TotalDiscs,
|
||||
Composer: req.Composer,
|
||||
}
|
||||
}
|
||||
|
||||
func buildReEnrichFFmpegMetadata(req *reEnrichRequest, lyricsLRC string) map[string]string {
|
||||
metadata := map[string]string{}
|
||||
if req.shouldUpdateField("basic_tags") {
|
||||
if req.TrackName != "" {
|
||||
metadata["TITLE"] = req.TrackName
|
||||
}
|
||||
if req.ArtistName != "" {
|
||||
metadata["ARTIST"] = req.ArtistName
|
||||
}
|
||||
if req.AlbumName != "" {
|
||||
metadata["ALBUM"] = req.AlbumName
|
||||
}
|
||||
if req.AlbumArtist != "" {
|
||||
metadata["ALBUMARTIST"] = req.AlbumArtist
|
||||
}
|
||||
}
|
||||
if req.shouldUpdateField("release_info") {
|
||||
if req.ReleaseDate != "" {
|
||||
metadata["DATE"] = req.ReleaseDate
|
||||
}
|
||||
if req.ISRC != "" {
|
||||
metadata["ISRC"] = req.ISRC
|
||||
}
|
||||
}
|
||||
if req.shouldUpdateField("extra") {
|
||||
if req.Genre != "" {
|
||||
metadata["GENRE"] = req.Genre
|
||||
}
|
||||
if req.Label != "" {
|
||||
metadata["ORGANIZATION"] = req.Label
|
||||
}
|
||||
if req.Copyright != "" {
|
||||
metadata["COPYRIGHT"] = req.Copyright
|
||||
}
|
||||
if req.Composer != "" {
|
||||
metadata["COMPOSER"] = req.Composer
|
||||
}
|
||||
}
|
||||
if req.shouldUpdateField("track_info") {
|
||||
if req.TrackNumber > 0 {
|
||||
metadata["TRACKNUMBER"] = formatIndexValue(req.TrackNumber, req.TotalTracks)
|
||||
}
|
||||
if req.DiscNumber > 0 {
|
||||
metadata["DISCNUMBER"] = formatIndexValue(req.DiscNumber, req.TotalDiscs)
|
||||
}
|
||||
}
|
||||
if req.shouldUpdateField("lyrics") {
|
||||
if lyricsLRC != "" && req.lyricsEmbedEnabled() {
|
||||
metadata["LYRICS"] = lyricsLRC
|
||||
metadata["UNSYNCEDLYRICS"] = lyricsLRC
|
||||
}
|
||||
}
|
||||
return metadata
|
||||
}
|
||||
|
||||
func selectBestReEnrichTrack(req reEnrichRequest, tracks []ExtTrackMetadata) *ExtTrackMetadata {
|
||||
if len(tracks) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
downloadReq := reEnrichDownloadRequest(req)
|
||||
currentISRC := strings.TrimSpace(req.ISRC)
|
||||
currentAlbum := strings.TrimSpace(req.AlbumName)
|
||||
effectiveTrackName := req.TrackName
|
||||
if isPlaceholderReEnrichValue(effectiveTrackName) {
|
||||
effectiveTrackName = ""
|
||||
}
|
||||
effectiveArtistName := req.ArtistName
|
||||
if isPlaceholderReEnrichValue(effectiveArtistName) {
|
||||
effectiveArtistName = ""
|
||||
}
|
||||
var best *ExtTrackMetadata
|
||||
bestScore := -1 << 30
|
||||
|
||||
for i := range tracks {
|
||||
track := &tracks[i]
|
||||
score := 0
|
||||
exactISRCMatch := currentISRC != "" && strings.EqualFold(currentISRC, strings.TrimSpace(track.ISRC))
|
||||
titleMatches := effectiveTrackName != "" && track.Name != "" && titlesMatch(effectiveTrackName, track.Name)
|
||||
artistMatches := effectiveArtistName != "" && track.Artists != "" && artistsMatch(effectiveArtistName, track.Artists)
|
||||
albumMatches := currentAlbum != "" && track.AlbumName != "" && titlesMatch(currentAlbum, track.AlbumName)
|
||||
|
||||
resolved := resolvedTrackInfo{
|
||||
Title: track.Name,
|
||||
ArtistName: track.Artists,
|
||||
ISRC: track.ISRC,
|
||||
Duration: track.DurationMS / 1000,
|
||||
}
|
||||
verified := trackMatchesRequest(downloadReq, resolved, "ReEnrich")
|
||||
|
||||
if !exactISRCMatch {
|
||||
if effectiveTrackName != "" && !titleMatches {
|
||||
continue
|
||||
}
|
||||
if effectiveArtistName != "" && !artistMatches {
|
||||
continue
|
||||
}
|
||||
if effectiveTrackName == "" && effectiveArtistName == "" && currentAlbum != "" && !albumMatches {
|
||||
continue
|
||||
}
|
||||
if effectiveTrackName == "" && effectiveArtistName == "" && currentAlbum == "" && !verified {
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
if verified {
|
||||
score += 2000
|
||||
}
|
||||
|
||||
if exactISRCMatch {
|
||||
score += 10000
|
||||
}
|
||||
if titleMatches {
|
||||
score += 400
|
||||
}
|
||||
if artistMatches {
|
||||
score += 320
|
||||
}
|
||||
if currentAlbum != "" && track.AlbumName != "" {
|
||||
switch {
|
||||
case albumMatches:
|
||||
score += 120
|
||||
case strings.Contains(strings.ToLower(track.AlbumName), strings.ToLower(currentAlbum)),
|
||||
strings.Contains(strings.ToLower(currentAlbum), strings.ToLower(track.AlbumName)):
|
||||
score += 50
|
||||
}
|
||||
}
|
||||
|
||||
if req.DurationMs > 0 && track.DurationMS > 0 {
|
||||
diff := int(req.DurationMs/1000) - (track.DurationMS / 1000)
|
||||
if diff < 0 {
|
||||
diff = -diff
|
||||
}
|
||||
if diff <= 10 {
|
||||
score += 80
|
||||
}
|
||||
}
|
||||
|
||||
if track.ReleaseDate != "" {
|
||||
score += 70
|
||||
}
|
||||
if track.TrackNumber > 0 {
|
||||
score += 20
|
||||
}
|
||||
if track.DiscNumber > 0 {
|
||||
score += 10
|
||||
}
|
||||
if track.ISRC != "" {
|
||||
score += 40
|
||||
}
|
||||
|
||||
if best == nil || score > bestScore {
|
||||
best = track
|
||||
bestScore = score
|
||||
}
|
||||
}
|
||||
|
||||
return best
|
||||
}
|
||||
|
||||
func extTrackFromTrackMetadata(track *TrackMetadata, providerID string) *ExtTrackMetadata {
|
||||
if track == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
deezerID := strings.TrimSpace(strings.TrimPrefix(track.SpotifyID, "deezer:"))
|
||||
return &ExtTrackMetadata{
|
||||
ID: track.SpotifyID,
|
||||
Name: track.Name,
|
||||
Artists: track.Artists,
|
||||
AlbumName: track.AlbumName,
|
||||
AlbumArtist: track.AlbumArtist,
|
||||
DurationMS: track.DurationMS,
|
||||
CoverURL: track.Images,
|
||||
Images: track.Images,
|
||||
ReleaseDate: track.ReleaseDate,
|
||||
TrackNumber: track.TrackNumber,
|
||||
TotalTracks: track.TotalTracks,
|
||||
DiscNumber: track.DiscNumber,
|
||||
TotalDiscs: track.TotalDiscs,
|
||||
ISRC: track.ISRC,
|
||||
ProviderID: providerID,
|
||||
DeezerID: deezerID,
|
||||
SpotifyID: track.SpotifyID,
|
||||
Composer: track.Composer,
|
||||
Explicit: track.Explicit,
|
||||
}
|
||||
}
|
||||
|
||||
func normalizeReEnrichSpotifyTrackID(raw string) string {
|
||||
trimmed := strings.TrimSpace(raw)
|
||||
if trimmed == "" {
|
||||
return ""
|
||||
}
|
||||
if extracted := extractSpotifyIDFromURL(trimmed); extracted != "" {
|
||||
return extracted
|
||||
}
|
||||
if len(trimmed) == 22 && !strings.Contains(trimmed, ":") && !strings.Contains(trimmed, "/") {
|
||||
return trimmed
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func resolveReEnrichTrackFromIdentifiers(req reEnrichRequest) (*ExtTrackMetadata, error) {
|
||||
deezerClient := GetDeezerClient()
|
||||
downloadReq := reEnrichDownloadRequest(req)
|
||||
|
||||
if isrc := strings.TrimSpace(req.ISRC); isrc != "" {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
track, err := deezerClient.SearchByISRC(ctx, isrc)
|
||||
cancel()
|
||||
if err == nil && track != nil {
|
||||
resolved := resolvedTrackInfo{
|
||||
Title: track.Name,
|
||||
ArtistName: track.Artists,
|
||||
ISRC: track.ISRC,
|
||||
Duration: track.DurationMS / 1000,
|
||||
}
|
||||
if trackMatchesRequest(downloadReq, resolved, "ReEnrich") {
|
||||
return extTrackFromTrackMetadata(track, "deezer"), nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
sourceTrackID := strings.TrimSpace(req.SpotifyID)
|
||||
if sourceTrackID == "" {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
deezerID := strings.TrimSpace(strings.TrimPrefix(sourceTrackID, "deezer:"))
|
||||
if deezerID == sourceTrackID {
|
||||
deezerID = extractDeezerIDFromURL(sourceTrackID)
|
||||
}
|
||||
if deezerID == "" {
|
||||
spotifyID := normalizeReEnrichSpotifyTrackID(sourceTrackID)
|
||||
if spotifyID != "" {
|
||||
resolvedDeezerID, err := NewSongLinkClient().GetDeezerIDFromSpotify(spotifyID)
|
||||
if err == nil {
|
||||
deezerID = strings.TrimSpace(resolvedDeezerID)
|
||||
}
|
||||
}
|
||||
}
|
||||
if deezerID == "" {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
|
||||
defer cancel()
|
||||
|
||||
trackResp, err := deezerClient.GetTrack(ctx, deezerID)
|
||||
if err != nil || trackResp == nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
track := &trackResp.Track
|
||||
resolved := resolvedTrackInfo{
|
||||
Title: track.Name,
|
||||
ArtistName: track.Artists,
|
||||
ISRC: track.ISRC,
|
||||
Duration: track.DurationMS / 1000,
|
||||
}
|
||||
if !trackMatchesRequest(downloadReq, resolved, "ReEnrich") {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
return extTrackFromTrackMetadata(track, "deezer"), nil
|
||||
}
|
||||
|
||||
func preferredReleaseMetadata(
|
||||
req DownloadRequest,
|
||||
album string,
|
||||
releaseDate string,
|
||||
trackNumber int,
|
||||
discNumber int,
|
||||
) (string, string, int, int) {
|
||||
preferredAlbum := strings.TrimSpace(req.AlbumName)
|
||||
if preferredAlbum == "" {
|
||||
preferredAlbum = album
|
||||
}
|
||||
|
||||
preferredReleaseDate := strings.TrimSpace(req.ReleaseDate)
|
||||
if preferredReleaseDate == "" {
|
||||
preferredReleaseDate = releaseDate
|
||||
}
|
||||
|
||||
preferredTrackNumber := req.TrackNumber
|
||||
if preferredTrackNumber == 0 {
|
||||
preferredTrackNumber = trackNumber
|
||||
}
|
||||
|
||||
preferredDiscNumber := req.DiscNumber
|
||||
if preferredDiscNumber == 0 {
|
||||
preferredDiscNumber = discNumber
|
||||
}
|
||||
|
||||
return preferredAlbum, preferredReleaseDate, preferredTrackNumber, preferredDiscNumber
|
||||
}
|
||||
|
||||
// ReEnrichFile re-embeds metadata, cover art, and lyrics into an existing audio file.
|
||||
// When search_online is true, searches Spotify/Deezer by track name + artist to fetch
|
||||
// complete metadata from the internet before embedding.
|
||||
func ReEnrichFile(requestJSON string) (string, error) {
|
||||
var req reEnrichRequest
|
||||
|
||||
if err := json.Unmarshal([]byte(requestJSON), &req); err != nil {
|
||||
return "", fmt.Errorf("failed to parse request: %w", err)
|
||||
}
|
||||
|
||||
if req.FilePath == "" {
|
||||
return "", fmt.Errorf("file_path is required")
|
||||
}
|
||||
|
||||
GoLog("[ReEnrich] Starting re-enrichment for: %s\n", req.FilePath)
|
||||
|
||||
if req.SearchOnline {
|
||||
found := false
|
||||
|
||||
GoLog("[ReEnrich] Trying metadata providers in configured priority...\n")
|
||||
manager := getExtensionManager()
|
||||
if identifierTrack, err := resolveReEnrichTrackFromIdentifiers(req); err == nil && identifierTrack != nil {
|
||||
GoLog("[ReEnrich] Identifier-first metadata match (%s): %s - %s (album: %s, date: %s)\n",
|
||||
identifierTrack.ProviderID, identifierTrack.Name, identifierTrack.Artists, identifierTrack.AlbumName, identifierTrack.ReleaseDate)
|
||||
applyReEnrichTrackMetadata(&req, *identifierTrack)
|
||||
found = true
|
||||
}
|
||||
|
||||
searchQuery := buildReEnrichSearchQuery(req)
|
||||
if searchQuery != "" {
|
||||
GoLog("[ReEnrich] Searching online metadata for query: %s\n", searchQuery)
|
||||
tracks, searchErr := manager.SearchTracksWithMetadataProviders(searchQuery, 5, true)
|
||||
if searchErr == nil && len(tracks) > 0 {
|
||||
track := selectBestReEnrichTrack(req, tracks)
|
||||
if track != nil {
|
||||
GoLog("[ReEnrich] Metadata match (%s): %s - %s (album: %s, date: %s)\n",
|
||||
track.ProviderID, track.Name, track.Artists, track.AlbumName, track.ReleaseDate)
|
||||
applyReEnrichTrackMetadata(&req, *track)
|
||||
found = true
|
||||
}
|
||||
} else if searchErr != nil {
|
||||
GoLog("[ReEnrich] Metadata provider search failed: %v\n", searchErr)
|
||||
}
|
||||
} else {
|
||||
GoLog("[ReEnrich] Skipping provider search: no usable title/artist/album query\n")
|
||||
}
|
||||
|
||||
if req.shouldUpdateField("basic_tags") && req.AlbumArtist == "" && req.ISRC != "" {
|
||||
albumArtist, err := fetchMusicBrainzAlbumArtistByISRC(req.ISRC, req.AlbumName)
|
||||
if err != nil {
|
||||
GoLog("[ReEnrich] Failed to get album artist from MusicBrainz: %v\n", err)
|
||||
} else if strings.TrimSpace(albumArtist) != "" {
|
||||
req.AlbumArtist = strings.TrimSpace(albumArtist)
|
||||
GoLog("[ReEnrich] Album artist fallback from MusicBrainz: %s\n", req.AlbumArtist)
|
||||
found = true
|
||||
}
|
||||
}
|
||||
|
||||
// Try to enrich extra metadata from ISRC if not already set.
|
||||
if found && req.ISRC != "" && req.shouldUpdateField("extra") && (req.Genre == "" || req.Label == "" || req.Copyright == "") {
|
||||
enrichExtraMetadataByISRC("ReEnrich", req.ISRC, &req.Genre, &req.Label, &req.Copyright)
|
||||
}
|
||||
|
||||
if !found {
|
||||
GoLog("[ReEnrich] No online match found, using existing metadata\n")
|
||||
}
|
||||
}
|
||||
|
||||
GoLog("[ReEnrich] Metadata to embed: title=%s, artist=%s, album=%s, albumArtist=%s\n",
|
||||
req.TrackName, req.ArtistName, req.AlbumName, req.AlbumArtist)
|
||||
GoLog("[ReEnrich] track=%d, disc=%d, date=%s, isrc=%s, genre=%s, label=%s\n",
|
||||
req.TrackNumber, req.DiscNumber, req.ReleaseDate, req.ISRC, req.Genre, req.Label)
|
||||
|
||||
lower := strings.ToLower(req.FilePath)
|
||||
isFlac := strings.HasSuffix(lower, ".flac")
|
||||
|
||||
var coverTempPath string
|
||||
var coverDataBytes []byte
|
||||
if req.CoverURL != "" && req.shouldUpdateField("cover") {
|
||||
coverData, err := downloadCoverToMemory(req.CoverURL, req.MaxQuality)
|
||||
if err != nil {
|
||||
GoLog("[ReEnrich] Failed to download cover: %v\n", err)
|
||||
} else {
|
||||
coverDataBytes = coverData
|
||||
GoLog("[ReEnrich] Cover downloaded: %d KB\n", len(coverData)/1024)
|
||||
// MP3/Opus requires a real image file path for Dart FFmpeg.
|
||||
// FLAC uses in-memory embed and does not require temp files.
|
||||
if !isFlac {
|
||||
tmpFile, err := os.CreateTemp("", "reenrich_cover_*.jpg")
|
||||
if err != nil {
|
||||
fallbackDir := filepath.Dir(req.FilePath)
|
||||
if fallbackDir == "" || fallbackDir == "." {
|
||||
GoLog("[ReEnrich] Failed to create cover temp file: %v\n", err)
|
||||
} else {
|
||||
tmpFile, err = os.CreateTemp(fallbackDir, "reenrich_cover_*.jpg")
|
||||
if err != nil {
|
||||
GoLog("[ReEnrich] Failed to create cover temp file (fallback dir %s): %v\n", fallbackDir, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
if err == nil && tmpFile != nil {
|
||||
coverTempPath = tmpFile.Name()
|
||||
if _, writeErr := tmpFile.Write(coverData); writeErr != nil {
|
||||
GoLog("[ReEnrich] Failed writing cover temp file: %v\n", writeErr)
|
||||
tmpFile.Close()
|
||||
os.Remove(coverTempPath)
|
||||
coverTempPath = ""
|
||||
} else if closeErr := tmpFile.Close(); closeErr != nil {
|
||||
GoLog("[ReEnrich] Failed closing cover temp file: %v\n", closeErr)
|
||||
os.Remove(coverTempPath)
|
||||
coverTempPath = ""
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// Only cleanup cover temp for FLAC (native embed).
|
||||
// For MP3/Opus, Dart needs the file for FFmpeg — Dart handles cleanup.
|
||||
cleanupCover := true
|
||||
|
||||
defer func() {
|
||||
if cleanupCover && coverTempPath != "" {
|
||||
os.Remove(coverTempPath)
|
||||
}
|
||||
}()
|
||||
|
||||
// Preserve existing lyrics when online enrichment does not return a replacement.
|
||||
var lyricsLRC string
|
||||
if req.shouldUpdateField("lyrics") {
|
||||
existingLyrics, existingLyricsErr := ExtractLyrics(req.FilePath)
|
||||
if existingLyricsErr == nil && strings.TrimSpace(existingLyrics) != "" {
|
||||
lyricsLRC = existingLyrics
|
||||
GoLog("[ReEnrich] Preserving existing embedded/sidecar lyrics\n")
|
||||
}
|
||||
}
|
||||
|
||||
if req.EmbedLyrics && req.shouldUpdateField("lyrics") {
|
||||
client := NewLyricsClient()
|
||||
durationSec := float64(req.DurationMs) / 1000.0
|
||||
lyrics, err := client.FetchLyricsAllSources(req.SpotifyID, req.TrackName, req.ArtistName, durationSec)
|
||||
if err != nil {
|
||||
GoLog("[ReEnrich] Lyrics not found: %v\n", err)
|
||||
} else if !lyrics.Instrumental {
|
||||
lyricsLRC = convertToLRCWithMetadata(lyrics, req.TrackName, req.ArtistName)
|
||||
GoLog("[ReEnrich] Lyrics fetched: %d lines\n", len(lyrics.Lines))
|
||||
} else {
|
||||
GoLog("[ReEnrich] Track is instrumental\n")
|
||||
}
|
||||
}
|
||||
|
||||
// Build enrichedMeta map: only include fields from selected update groups
|
||||
// so that the caller (Dart) does not overwrite non-selected metadata in its
|
||||
// local library database with potentially stale cached values.
|
||||
enrichedMeta := map[string]interface{}{
|
||||
"spotify_id": req.SpotifyID,
|
||||
"duration_ms": req.DurationMs,
|
||||
}
|
||||
if req.shouldUpdateField("basic_tags") {
|
||||
enrichedMeta["track_name"] = req.TrackName
|
||||
enrichedMeta["artist_name"] = req.ArtistName
|
||||
enrichedMeta["album_name"] = req.AlbumName
|
||||
enrichedMeta["album_artist"] = req.AlbumArtist
|
||||
}
|
||||
if req.shouldUpdateField("track_info") {
|
||||
enrichedMeta["track_number"] = req.TrackNumber
|
||||
enrichedMeta["total_tracks"] = req.TotalTracks
|
||||
enrichedMeta["disc_number"] = req.DiscNumber
|
||||
enrichedMeta["total_discs"] = req.TotalDiscs
|
||||
}
|
||||
if req.shouldUpdateField("release_info") {
|
||||
enrichedMeta["release_date"] = req.ReleaseDate
|
||||
enrichedMeta["isrc"] = req.ISRC
|
||||
}
|
||||
if req.shouldUpdateField("cover") {
|
||||
enrichedMeta["cover_url"] = req.CoverURL
|
||||
}
|
||||
if req.shouldUpdateField("extra") {
|
||||
enrichedMeta["genre"] = req.Genre
|
||||
enrichedMeta["label"] = req.Label
|
||||
enrichedMeta["copyright"] = req.Copyright
|
||||
enrichedMeta["composer"] = req.Composer
|
||||
}
|
||||
|
||||
if isFlac {
|
||||
// Only populate Metadata fields for selected update groups; empty/zero
|
||||
// values cause EmbedMetadata's setComment() to skip those tags,
|
||||
// preserving whatever is already in the file.
|
||||
metadata := Metadata{
|
||||
ArtistTagMode: req.ArtistTagMode,
|
||||
}
|
||||
if req.shouldUpdateField("basic_tags") {
|
||||
metadata.Title = req.TrackName
|
||||
metadata.Artist = req.ArtistName
|
||||
metadata.Album = req.AlbumName
|
||||
metadata.AlbumArtist = req.AlbumArtist
|
||||
}
|
||||
if req.shouldUpdateField("track_info") {
|
||||
metadata.TrackNumber = req.TrackNumber
|
||||
metadata.TotalTracks = req.TotalTracks
|
||||
metadata.DiscNumber = req.DiscNumber
|
||||
metadata.TotalDiscs = req.TotalDiscs
|
||||
}
|
||||
if req.shouldUpdateField("release_info") {
|
||||
metadata.Date = req.ReleaseDate
|
||||
metadata.ISRC = req.ISRC
|
||||
}
|
||||
if req.shouldUpdateField("lyrics") {
|
||||
if req.lyricsEmbedEnabled() {
|
||||
metadata.Lyrics = lyricsLRC
|
||||
}
|
||||
}
|
||||
if req.shouldUpdateField("extra") {
|
||||
metadata.Genre = req.Genre
|
||||
metadata.Label = req.Label
|
||||
metadata.Copyright = req.Copyright
|
||||
metadata.Composer = req.Composer
|
||||
}
|
||||
|
||||
if len(coverDataBytes) > 0 {
|
||||
if err := EmbedMetadataWithCoverData(req.FilePath, metadata, coverDataBytes); err != nil {
|
||||
return "", fmt.Errorf("failed to embed metadata with cover: %w", err)
|
||||
}
|
||||
} else {
|
||||
if err := EmbedMetadata(req.FilePath, metadata, ""); err != nil {
|
||||
return "", fmt.Errorf("failed to embed metadata: %w", err)
|
||||
}
|
||||
}
|
||||
if len(coverDataBytes) > 0 {
|
||||
embeddedCover, err := ExtractCoverArt(req.FilePath)
|
||||
if err != nil || len(embeddedCover) == 0 {
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("metadata embedded but cover verification failed: %w", err)
|
||||
}
|
||||
return "", fmt.Errorf("metadata embedded but cover verification failed: empty embedded cover")
|
||||
}
|
||||
GoLog("[ReEnrich] Cover verified after embed (%d bytes)\n", len(embeddedCover))
|
||||
}
|
||||
|
||||
GoLog("[ReEnrich] FLAC metadata embedded successfully\n")
|
||||
|
||||
result := map[string]interface{}{
|
||||
"method": "native",
|
||||
"success": true,
|
||||
"enriched_metadata": enrichedMeta,
|
||||
"lyrics": lyricsLRC,
|
||||
"write_external_lrc": req.EmbedLyrics &&
|
||||
req.shouldUpdateField("lyrics") &&
|
||||
req.lyricsSidecarEnabled() &&
|
||||
strings.TrimSpace(lyricsLRC) != "",
|
||||
}
|
||||
jsonBytes, _ := json.Marshal(result)
|
||||
return string(jsonBytes), nil
|
||||
}
|
||||
|
||||
// Don't cleanup cover temp — Dart needs it for FFmpeg embed
|
||||
cleanupCover = false
|
||||
ffmpegMetadata := buildReEnrichFFmpegMetadata(&req, lyricsLRC)
|
||||
|
||||
result := map[string]interface{}{
|
||||
"method": "ffmpeg",
|
||||
"cover_path": coverTempPath,
|
||||
"lyrics": lyricsLRC,
|
||||
"enriched_metadata": enrichedMeta,
|
||||
"metadata": ffmpegMetadata,
|
||||
"write_external_lrc": req.EmbedLyrics &&
|
||||
req.shouldUpdateField("lyrics") &&
|
||||
req.lyricsSidecarEnabled() &&
|
||||
strings.TrimSpace(lyricsLRC) != "",
|
||||
}
|
||||
|
||||
jsonBytes, _ := json.Marshal(result)
|
||||
return string(jsonBytes), nil
|
||||
}
|
||||
@@ -0,0 +1,164 @@
|
||||
package gobackend
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func InitExtensionStoreJSON(cacheDir string) error {
|
||||
initExtensionStore(cacheDir)
|
||||
return nil
|
||||
}
|
||||
|
||||
func SetStoreRegistryURLJSON(registryURL string) error {
|
||||
store := getExtensionStore()
|
||||
if store == nil {
|
||||
return fmt.Errorf("extension store not initialized")
|
||||
}
|
||||
|
||||
resolved, err := resolveRegistryURL(registryURL)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := requireHTTPSURL(resolved, "registry"); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
store.setRegistryURL(resolved)
|
||||
return nil
|
||||
}
|
||||
|
||||
func ClearStoreRegistryURLJSON() error {
|
||||
store := getExtensionStore()
|
||||
if store == nil {
|
||||
return fmt.Errorf("extension store not initialized")
|
||||
}
|
||||
|
||||
store.setRegistryURL("")
|
||||
store.clearCache()
|
||||
return nil
|
||||
}
|
||||
|
||||
func GetStoreRegistryURLJSON() (string, error) {
|
||||
store := getExtensionStore()
|
||||
if store == nil {
|
||||
return "", fmt.Errorf("extension store not initialized")
|
||||
}
|
||||
|
||||
return store.getRegistryURL(), nil
|
||||
}
|
||||
|
||||
func GetStoreExtensionsJSON(forceRefresh bool) (string, error) {
|
||||
store := getExtensionStore()
|
||||
if store == nil {
|
||||
return "", fmt.Errorf("extension store not initialized")
|
||||
}
|
||||
|
||||
extensions, err := store.getExtensionsWithStatus(forceRefresh)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
jsonBytes, err := json.Marshal(extensions)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
return string(jsonBytes), nil
|
||||
}
|
||||
|
||||
func SearchStoreExtensionsJSON(query, category string) (string, error) {
|
||||
store := getExtensionStore()
|
||||
if store == nil {
|
||||
return "", fmt.Errorf("extension store not initialized")
|
||||
}
|
||||
|
||||
extensions, err := store.searchExtensions(query, category)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
jsonBytes, err := json.Marshal(extensions)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
return string(jsonBytes), nil
|
||||
}
|
||||
|
||||
func GetStoreCategoriesJSON() (string, error) {
|
||||
store := getExtensionStore()
|
||||
if store == nil {
|
||||
return "", fmt.Errorf("extension store not initialized")
|
||||
}
|
||||
|
||||
categories := store.getCategories()
|
||||
jsonBytes, err := json.Marshal(categories)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
return string(jsonBytes), nil
|
||||
}
|
||||
|
||||
func storeExtensionPackageSuffix(downloadURL string) string {
|
||||
rawPath := downloadURL
|
||||
if parsed, err := url.Parse(downloadURL); err == nil {
|
||||
rawPath = parsed.Path
|
||||
}
|
||||
|
||||
lowerPath := strings.ToLower(rawPath)
|
||||
if strings.HasSuffix(lowerPath, ".sflx") {
|
||||
return ".sflx"
|
||||
}
|
||||
if strings.HasSuffix(lowerPath, ".spotiflac-ext") {
|
||||
return ".spotiflac-ext"
|
||||
}
|
||||
return ".spotiflac-ext"
|
||||
}
|
||||
|
||||
func buildStoreExtensionDestPath(destDir, extensionID, downloadURL string) (string, error) {
|
||||
if strings.TrimSpace(extensionID) == "" {
|
||||
return "", fmt.Errorf("invalid extension id")
|
||||
}
|
||||
|
||||
safeExtensionID := sanitizeFilename(extensionID)
|
||||
return filepath.Join(destDir, safeExtensionID+storeExtensionPackageSuffix(downloadURL)), nil
|
||||
}
|
||||
|
||||
func DownloadStoreExtensionJSON(extensionID, destDir string) (string, error) {
|
||||
store := getExtensionStore()
|
||||
if store == nil {
|
||||
return "", fmt.Errorf("extension store not initialized")
|
||||
}
|
||||
|
||||
ext, err := store.findExtension(extensionID)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
destPath, err := buildStoreExtensionDestPath(destDir, extensionID, ext.getDownloadURL())
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
err = store.downloadExtension(extensionID, destPath)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
return destPath, nil
|
||||
}
|
||||
|
||||
func ClearStoreCacheJSON() error {
|
||||
store := getExtensionStore()
|
||||
if store == nil {
|
||||
return fmt.Errorf("extension store not initialized")
|
||||
}
|
||||
|
||||
store.clearCache()
|
||||
return nil
|
||||
}
|
||||
Reference in New Issue
Block a user