From 81b0eede8cbaec717cd59f9833dbc74268801011 Mon Sep 17 00:00:00 2001 From: zarzet Date: Sun, 1 Feb 2026 20:11:16 +0700 Subject: [PATCH] v3.3.5: Same as 3.3.1 but fixes crash issues caused by FFmpeg Changes: - Fix FFmpeg crash issues during M4A to MP3/Opus conversion - Add format picker (MP3/Opus) when selecting Tidal Lossy 320kbps - Fix Deezer album blank screen when opened from home - LRC file generation now follows lyrics mode setting - Version bump to 3.3.5 (build 70) --- CHANGELOG.md | 10 ++ go_backend/amazon.go | 3 - go_backend/deezer.go | 3 - go_backend/duplicate.go | 19 +--- go_backend/exports.go | 77 ++------------- go_backend/extension_manager.go | 7 -- go_backend/extension_manifest.go | 26 ----- go_backend/extension_runtime.go | 2 - go_backend/extension_runtime_file.go | 4 - go_backend/extension_runtime_http.go | 7 -- go_backend/extension_settings.go | 15 --- go_backend/extension_store.go | 52 ++++------ go_backend/httputil.go | 28 +----- go_backend/lyrics.go | 39 -------- go_backend/metadata.go | 3 - go_backend/parallel.go | 26 +---- go_backend/progress.go | 13 --- go_backend/qobuz.go | 24 ----- go_backend/songlink.go | 36 ------- go_backend/spotify.go | 2 - go_backend/tidal.go | 38 ++++++-- lib/constants/app_info.dart | 4 +- lib/providers/download_queue_provider.dart | 73 +++++++++----- lib/screens/album_screen.dart | 9 +- lib/services/ffmpeg_service.dart | 55 +---------- lib/utils/logger.dart | 10 +- lib/widgets/download_service_picker.dart | 105 ++++++++++++++++++++- pubspec.yaml | 2 +- 28 files changed, 236 insertions(+), 456 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4e2a71c8..b8b77cf3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,15 @@ # Changelog +## [3.3.5] - 2026-02-01 + +Same as 3.3.1 but fixes crash issues caused by FFmpeg. + +### Fixed + +- **FFmpeg Crash**: Fixed crash issues during M4A to MP3/Opus conversion + +--- + ## [3.3.1] - 2026-02-01 ### Added diff --git a/go_backend/amazon.go b/go_backend/amazon.go index f4fb9ed8..ac90429e 100644 --- a/go_backend/amazon.go +++ b/go_backend/amazon.go @@ -54,8 +54,6 @@ func NewAmazonDownloader() *AmazonDownloader { return globalAmazonDownloader } -// downloadFromAfkarXYZ downloads a track using AfkarXYZ API -// Returns: downloadURL, fileName, error func (a *AmazonDownloader) downloadFromAfkarXYZ(amazonURL string) (string, string, error) { // AfkarXYZ API endpoint apiURL := "https://amazon.afkarxyz.fun/convert?url=" + url.QueryEscape(amazonURL) @@ -206,7 +204,6 @@ type AmazonDownloadResult struct { ISRC string } -// downloadFromAmazon uses AfkarXYZ API to download from Amazon Music func downloadFromAmazon(req DownloadRequest) (AmazonDownloadResult, error) { downloader := NewAmazonDownloader() diff --git a/go_backend/deezer.go b/go_backend/deezer.go index c8ecc229..effa2437 100644 --- a/go_backend/deezer.go +++ b/go_backend/deezer.go @@ -425,7 +425,6 @@ func (c *DeezerClient) SearchAll(ctx context.Context, query string, trackLimit, return result, nil } -// GetTrack fetches a single track by Deezer ID func (c *DeezerClient) GetTrack(ctx context.Context, trackID string) (*TrackResponse, error) { trackURL := fmt.Sprintf(deezerTrackURL, trackID) @@ -975,7 +974,6 @@ func (c *DeezerClient) GetAlbumExtendedMetadata(ctx context.Context, albumID str return result, nil } -// GetTrackAlbumID fetches the album ID for a Deezer track func (c *DeezerClient) GetTrackAlbumID(ctx context.Context, trackID string) (string, error) { trackURL := fmt.Sprintf(deezerTrackURL, trackID) @@ -1046,7 +1044,6 @@ func (c *DeezerClient) getJSON(ctx context.Context, endpoint string, dst interfa return json.Unmarshal(body, dst) } -// parseDeezerURL is internal function, returns type and ID func parseDeezerURL(input string) (string, string, error) { trimmed := strings.TrimSpace(input) if trimmed == "" { diff --git a/go_backend/duplicate.go b/go_backend/duplicate.go index 15e80370..cd922285 100644 --- a/go_backend/duplicate.go +++ b/go_backend/duplicate.go @@ -10,7 +10,6 @@ import ( "time" ) -// ISRCIndex holds a cached map of ISRC -> file path for fast duplicate checking type ISRCIndex struct { index map[string]string // ISRC (uppercase) -> file path outputDir string @@ -25,8 +24,6 @@ var ( isrcIndexTTL = 5 * time.Minute ) -// GetISRCIndex returns or builds an ISRC index for the given directory -// Uses per-directory mutex to prevent concurrent builds (race condition fix) func GetISRCIndex(outputDir string) *ISRCIndex { // Fast path: check cache first isrcIndexCacheMu.RLock() @@ -56,7 +53,6 @@ func GetISRCIndex(outputDir string) *ISRCIndex { return buildISRCIndex(outputDir) } -// buildISRCIndex scans a directory and builds a map of ISRC -> file path func buildISRCIndex(outputDir string) *ISRCIndex { idx := &ISRCIndex{ index: make(map[string]string), @@ -91,7 +87,7 @@ func buildISRCIndex(outputDir string) *ISRCIndex { return nil }) - fmt.Printf("[ISRCIndex] Built index for %s: %d files in %v\n", + fmt.Printf("[ISRCIndex] Built index for %s: %d files in %v\n", outputDir, fileCount, time.Since(startTime).Round(time.Millisecond)) isrcIndexCacheMu.Lock() @@ -113,7 +109,6 @@ func (idx *ISRCIndex) lookup(isrc string) (string, bool) { return path, exists } -// remove deletes an ISRC entry from the index (internal use) func (idx *ISRCIndex) remove(isrc string) { if isrc == "" { return @@ -125,14 +120,11 @@ func (idx *ISRCIndex) remove(isrc string) { delete(idx.index, strings.ToUpper(isrc)) } -// Lookup checks if an ISRC exists in the index (gomobile compatible) -// Returns filepath if found, empty string if not found func (idx *ISRCIndex) Lookup(isrc string) (string, error) { path, _ := idx.lookup(isrc) return path, nil } -// Add adds a new ISRC to the index (call after successful download) func (idx *ISRCIndex) Add(isrc, filePath string) { if isrc == "" || filePath == "" { return @@ -144,15 +136,12 @@ func (idx *ISRCIndex) Add(isrc, filePath string) { idx.index[strings.ToUpper(isrc)] = filePath } -// InvalidateCache clears the ISRC index cache for a directory func InvalidateISRCCache(outputDir string) { isrcIndexCacheMu.Lock() delete(isrcIndexCache, outputDir) isrcIndexCacheMu.Unlock() } -// checkISRCExistsInternal checks if a file with the given ISRC exists (internal use) -// Uses ISRC index for fast lookup func checkISRCExistsInternal(outputDir, isrc string) (string, bool) { if isrc == "" || outputDir == "" { return "", false @@ -173,13 +162,11 @@ func checkISRCExistsInternal(outputDir, isrc string) (string, bool) { return filePath, true } -// CheckISRCExists is the exported version for gomobile (returns string, error) func CheckISRCExists(outputDir, isrc string) (string, error) { filepath, _ := checkISRCExistsInternal(outputDir, isrc) return filepath, nil } -// CheckFileExists checks if a file with the given name exists func CheckFileExists(filePath string) bool { info, err := os.Stat(filePath) if err != nil { @@ -188,7 +175,6 @@ func CheckFileExists(filePath string) bool { return !info.IsDir() && info.Size() > 0 } -// FileExistenceResult represents the result of checking if a file exists type FileExistenceResult struct { ISRC string `json:"isrc"` Exists bool `json:"exists"` @@ -249,8 +235,6 @@ func CheckFilesExistParallel(outputDir string, tracksJSON string) (string, error return string(resultJSON), nil } -// PreBuildISRCIndex pre-builds the ISRC index for a directory -// Call this when app starts or when entering album/playlist screen func PreBuildISRCIndex(outputDir string) error { if outputDir == "" { return fmt.Errorf("output directory is required") @@ -260,7 +244,6 @@ func PreBuildISRCIndex(outputDir string) error { return nil } -// AddToISRCIndex adds a new file to the ISRC index after successful download func AddToISRCIndex(outputDir, isrc, filePath string) { if outputDir == "" || isrc == "" || filePath == "" { return diff --git a/go_backend/exports.go b/go_backend/exports.go index 4b887db1..6d39d8ea 100644 --- a/go_backend/exports.go +++ b/go_backend/exports.go @@ -148,17 +148,16 @@ type DownloadRequest struct { LyricsMode string `json:"lyrics_mode,omitempty"` } -// DownloadResponse represents the result of a download 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"` // "not_found", "rate_limit", "network", "unknown" + ErrorType string `json:"error_type,omitempty"` AlreadyExists bool `json:"already_exists,omitempty"` ActualBitDepth int `json:"actual_bit_depth,omitempty"` ActualSampleRate int `json:"actual_sample_rate,omitempty"` - Service string `json:"service,omitempty"` // Actual service used (for fallback) + Service string `json:"service,omitempty"` Title string `json:"title,omitempty"` Artist string `json:"artist,omitempty"` Album string `json:"album,omitempty"` @@ -172,6 +171,7 @@ type DownloadResponse struct { Label string `json:"label,omitempty"` Copyright string `json:"copyright,omitempty"` SkipMetadataEnrichment bool `json:"skip_metadata_enrichment,omitempty"` + LyricsLRC string `json:"lyrics_lrc,omitempty"` } type DownloadResult struct { @@ -185,6 +185,7 @@ type DownloadResult struct { TrackNumber int DiscNumber int ISRC string + LyricsLRC string } func DownloadTrack(requestJSON string) (string, error) { @@ -222,6 +223,7 @@ func DownloadTrack(requestJSON string) (string, error) { TrackNumber: tidalResult.TrackNumber, DiscNumber: tidalResult.DiscNumber, ISRC: tidalResult.ISRC, + LyricsLRC: tidalResult.LyricsLRC, } } err = tidalErr @@ -317,6 +319,7 @@ func DownloadTrack(requestJSON string) (string, error) { TrackNumber: result.TrackNumber, DiscNumber: result.DiscNumber, ISRC: result.ISRC, + LyricsLRC: result.LyricsLRC, } jsonBytes, _ := json.Marshal(resp) @@ -380,6 +383,7 @@ func DownloadWithFallback(requestJSON string) (string, error) { TrackNumber: tidalResult.TrackNumber, DiscNumber: tidalResult.DiscNumber, ISRC: tidalResult.ISRC, + LyricsLRC: tidalResult.LyricsLRC, } } else if !errors.Is(tidalErr, ErrDownloadCancelled) { GoLog("[DownloadWithFallback] Tidal error: %v\n", tidalErr) @@ -452,6 +456,7 @@ func DownloadWithFallback(requestJSON string) (string, error) { TrackNumber: result.TrackNumber, DiscNumber: result.DiscNumber, ISRC: result.ISRC, + LyricsLRC: result.LyricsLRC, } jsonBytes, _ := json.Marshal(resp) return string(jsonBytes), nil @@ -480,6 +485,7 @@ func DownloadWithFallback(requestJSON string) (string, error) { TrackNumber: result.TrackNumber, DiscNumber: result.DiscNumber, ISRC: result.ISRC, + LyricsLRC: result.LyricsLRC, } jsonBytes, _ := json.Marshal(resp) return string(jsonBytes), nil @@ -631,14 +637,11 @@ func FetchLyrics(spotifyID, trackName, artistName string, durationMs int64) (str } func GetLyricsLRC(spotifyID, trackName, artistName string, filePath string, durationMs int64) (string, error) { - // If filePath is provided, ONLY check file - don't fallback to online - // This allows Flutter to distinguish between "from file" vs "from online" if filePath != "" { lyrics, err := ExtractLyrics(filePath) if err == nil && lyrics != "" { return lyrics, nil } - // File has no lyrics - return empty, let Flutter call again without filePath return "", nil } @@ -649,7 +652,6 @@ func GetLyricsLRC(spotifyID, trackName, artistName string, filePath string, dura return "", err } - // Return special marker for instrumental tracks if lyricsData.Instrumental { return "[instrumental:true]", nil } @@ -735,8 +737,6 @@ func SearchDeezerAll(query string, trackLimit, artistLimit int, filter string) ( } // GetDeezerMetadata fetches metadata from Deezer URL or ID -// resourceType: track, album, artist, playlist -// resourceID: Deezer ID func GetDeezerMetadata(resourceType, resourceID string) (string, error) { ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) defer cancel() @@ -770,7 +770,6 @@ func GetDeezerMetadata(resourceType, resourceID string) (string, error) { return string(jsonBytes), nil } -// ParseDeezerURLExport parses a Deezer URL and returns type and ID func ParseDeezerURLExport(url string) (string, error) { resourceType, resourceID, err := parseDeezerURL(url) if err != nil { @@ -790,9 +789,6 @@ func ParseDeezerURLExport(url string) (string, error) { return string(jsonBytes), nil } -// GetDeezerExtendedMetadata fetches genre and label from Deezer album -// trackID: Deezer track ID (will look up album ID from track) -// Returns JSON with genre, label fields func GetDeezerExtendedMetadata(trackID string) (string, error) { if trackID == "" { return "", fmt.Errorf("empty track ID") @@ -821,7 +817,6 @@ func GetDeezerExtendedMetadata(trackID string) (string, error) { return string(jsonBytes), nil } -// SearchDeezerByISRC searches for a track by ISRC on Deezer func SearchDeezerByISRC(isrc string) (string, error) { ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) defer cancel() @@ -949,9 +944,6 @@ func CheckAvailabilityFromDeezerID(deezerTrackID string) (string, error) { } // CheckAvailabilityByPlatformID checks track availability using any platform as source -// platform: "spotify", "deezer", "tidal", "amazonMusic", "appleMusic", "youtube" -// entityType: "song" or "album" -// entityID: the ID on that platform func CheckAvailabilityByPlatformID(platform, entityType, entityID string) (string, error) { client := NewSongLinkClient() availability, err := client.CheckAvailabilityByPlatform(platform, entityType, entityID) @@ -967,19 +959,16 @@ func CheckAvailabilityByPlatformID(platform, entityType, entityID string) (strin return string(jsonBytes), nil } -// GetSpotifyIDFromDeezerTrack converts a Deezer track ID to Spotify track ID func GetSpotifyIDFromDeezerTrack(deezerTrackID string) (string, error) { client := NewSongLinkClient() return client.GetSpotifyIDFromDeezer(deezerTrackID) } -// GetTidalURLFromDeezerTrack converts a Deezer track ID to Tidal URL func GetTidalURLFromDeezerTrack(deezerTrackID string) (string, error) { client := NewSongLinkClient() return client.GetTidalURLFromDeezer(deezerTrackID) } -// GetAmazonURLFromDeezerTrack converts a Deezer track ID to Amazon Music URL func GetAmazonURLFromDeezerTrack(deezerTrackID string) (string, error) { client := NewSongLinkClient() return client.GetAmazonURLFromDeezer(deezerTrackID) @@ -1029,7 +1018,6 @@ func errorResponse(msg string) (string, error) { // ==================== EXTENSION SYSTEM ==================== -// InitExtensionSystem initializes the extension system with directories func InitExtensionSystem(extensionsDir, dataDir string) error { manager := GetExtensionManager() if err := manager.SetDirectories(extensionsDir, dataDir); err != nil { @@ -1044,7 +1032,6 @@ func InitExtensionSystem(extensionsDir, dataDir string) error { return nil } -// LoadExtensionsFromDir loads all extensions from a directory func LoadExtensionsFromDir(dirPath string) (string, error) { manager := GetExtensionManager() loaded, errors := manager.LoadExtensionsFromDirectory(dirPath) @@ -1066,7 +1053,6 @@ func LoadExtensionsFromDir(dirPath string) (string, error) { return string(jsonBytes), nil } -// LoadExtensionFromPath loads a single extension from a .spotiflac-ext file func LoadExtensionFromPath(filePath string) (string, error) { manager := GetExtensionManager() ext, err := manager.LoadExtensionFromFile(filePath) @@ -1096,19 +1082,16 @@ func LoadExtensionFromPath(filePath string) (string, error) { return string(jsonBytes), nil } -// UnloadExtensionByID unloads an extension func UnloadExtensionByID(extensionID string) error { manager := GetExtensionManager() return manager.UnloadExtension(extensionID) } -// RemoveExtensionByID completely removes an extension (unload + delete files) func RemoveExtensionByID(extensionID string) error { manager := GetExtensionManager() return manager.RemoveExtension(extensionID) } -// UpgradeExtensionFromPath upgrades an existing extension from a new package file func UpgradeExtensionFromPath(filePath string) (string, error) { manager := GetExtensionManager() ext, err := manager.UpgradeExtension(filePath) @@ -1137,25 +1120,21 @@ func UpgradeExtensionFromPath(filePath string) (string, error) { return string(jsonBytes), nil } -// CheckExtensionUpgradeFromPath checks if a package file is an upgrade for an existing extension func CheckExtensionUpgradeFromPath(filePath string) (string, error) { manager := GetExtensionManager() return manager.CheckExtensionUpgradeJSON(filePath) } -// GetInstalledExtensions returns all installed extensions as JSON func GetInstalledExtensions() (string, error) { manager := GetExtensionManager() return manager.GetInstalledExtensionsJSON() } -// SetExtensionEnabledByID enables or disables an extension func SetExtensionEnabledByID(extensionID string, enabled bool) error { manager := GetExtensionManager() return manager.SetExtensionEnabled(extensionID, enabled) } -// SetProviderPriorityJSON sets the provider priority order from JSON array func SetProviderPriorityJSON(priorityJSON string) error { var priority []string if err := json.Unmarshal([]byte(priorityJSON), &priority); err != nil { @@ -1166,7 +1145,6 @@ func SetProviderPriorityJSON(priorityJSON string) error { return nil } -// GetProviderPriorityJSON returns the provider priority order as JSON func GetProviderPriorityJSON() (string, error) { priority := GetProviderPriority() jsonBytes, err := json.Marshal(priority) @@ -1176,7 +1154,6 @@ func GetProviderPriorityJSON() (string, error) { return string(jsonBytes), nil } -// SetMetadataProviderPriorityJSON sets the metadata provider priority order from JSON array func SetMetadataProviderPriorityJSON(priorityJSON string) error { var priority []string if err := json.Unmarshal([]byte(priorityJSON), &priority); err != nil { @@ -1187,7 +1164,6 @@ func SetMetadataProviderPriorityJSON(priorityJSON string) error { return nil } -// GetMetadataProviderPriorityJSON returns the metadata provider priority order as JSON func GetMetadataProviderPriorityJSON() (string, error) { priority := GetMetadataProviderPriority() jsonBytes, err := json.Marshal(priority) @@ -1197,7 +1173,6 @@ func GetMetadataProviderPriorityJSON() (string, error) { return string(jsonBytes), nil } -// GetExtensionSettingsJSON returns settings for an extension as JSON func GetExtensionSettingsJSON(extensionID string) (string, error) { store := GetExtensionSettingsStore() settings := store.GetAll(extensionID) @@ -1210,7 +1185,6 @@ func GetExtensionSettingsJSON(extensionID string) (string, error) { return string(jsonBytes), nil } -// SetExtensionSettingsJSON sets settings for an extension from JSON func SetExtensionSettingsJSON(extensionID, settingsJSON string) error { var settings map[string]interface{} if err := json.Unmarshal([]byte(settingsJSON), &settings); err != nil { @@ -1226,7 +1200,6 @@ func SetExtensionSettingsJSON(extensionID, settingsJSON string) error { return manager.InitializeExtension(extensionID, settings) } -// SearchTracksWithExtensionsJSON searches all extension metadata providers func SearchTracksWithExtensionsJSON(query string, limit int) (string, error) { manager := GetExtensionManager() tracks, err := manager.SearchTracksWithExtensions(query, limit) @@ -1242,7 +1215,6 @@ func SearchTracksWithExtensionsJSON(query string, limit int) (string, error) { return string(jsonBytes), nil } -// DownloadWithExtensionsJSON downloads using extension providers with fallback func DownloadWithExtensionsJSON(requestJSON string) (string, error) { var req DownloadRequest if err := json.Unmarshal([]byte(requestJSON), &req); err != nil { @@ -1262,14 +1234,11 @@ func DownloadWithExtensionsJSON(requestJSON string) (string, error) { return string(jsonBytes), nil } -// CleanupExtensions unloads all extensions gracefully func CleanupExtensions() { manager := GetExtensionManager() manager.UnloadAllExtensions() } -// InvokeExtensionActionJSON invokes a custom action on an extension (e.g., button click handler) -// actionName is the JS function name to call (e.g., "startLogin", "authenticate", etc.) func InvokeExtensionActionJSON(extensionID, actionName string) (string, error) { manager := GetExtensionManager() result, err := manager.InvokeAction(extensionID, actionName) @@ -1285,7 +1254,6 @@ func InvokeExtensionActionJSON(extensionID, actionName string) (string, error) { return string(jsonBytes), nil } -// GetExtensionPendingAuthJSON returns pending auth request for an extension func GetExtensionPendingAuthJSON(extensionID string) (string, error) { req := GetPendingAuthRequest(extensionID) if req == nil { @@ -1306,12 +1274,10 @@ func GetExtensionPendingAuthJSON(extensionID string) (string, error) { return string(jsonBytes), nil } -// SetExtensionAuthCodeByID sets auth code for an extension (called from Flutter after OAuth callback) func SetExtensionAuthCodeByID(extensionID, authCode string) { SetExtensionAuthCode(extensionID, authCode) } -// SetExtensionTokensByID sets tokens for an extension func SetExtensionTokensByID(extensionID, accessToken, refreshToken string, expiresIn int) { var expiresAt time.Time if expiresIn > 0 { @@ -1320,12 +1286,10 @@ func SetExtensionTokensByID(extensionID, accessToken, refreshToken string, expir SetExtensionTokens(extensionID, accessToken, refreshToken, expiresAt) } -// ClearExtensionPendingAuthByID clears pending auth request for an extension func ClearExtensionPendingAuthByID(extensionID string) { ClearPendingAuthRequest(extensionID) } -// IsExtensionAuthenticatedByID checks if an extension is authenticated func IsExtensionAuthenticatedByID(extensionID string) bool { extensionAuthStateMu.RLock() defer extensionAuthStateMu.RUnlock() @@ -1342,7 +1306,6 @@ func IsExtensionAuthenticatedByID(extensionID string) bool { return state.IsAuthenticated } -// GetAllPendingAuthRequestsJSON returns all pending auth requests func GetAllPendingAuthRequestsJSON() (string, error) { pendingAuthRequestsMu.RLock() defer pendingAuthRequestsMu.RUnlock() @@ -1386,12 +1349,10 @@ func GetPendingFFmpegCommandJSON(commandID string) (string, error) { return string(jsonBytes), nil } -// SetFFmpegCommandResultByID sets the result of an FFmpeg command func SetFFmpegCommandResultByID(commandID string, success bool, output, errorMsg string) { SetFFmpegCommandResult(commandID, success, output, errorMsg) } -// GetAllPendingFFmpegCommandsJSON returns all pending FFmpeg commands func GetAllPendingFFmpegCommandsJSON() (string, error) { ffmpegCommandsMu.RLock() defer ffmpegCommandsMu.RUnlock() @@ -1417,8 +1378,6 @@ func GetAllPendingFFmpegCommandsJSON() (string, error) { // ==================== EXTENSION CUSTOM SEARCH ==================== -// EnrichTrackWithExtensionJSON enriches track metadata using the source extension -// This is called lazily before download starts, allowing extension to fetch real ISRC etc. func EnrichTrackWithExtensionJSON(extensionID, trackJSON string) (string, error) { manager := GetExtensionManager() ext, err := manager.GetExtension(extensionID) @@ -1449,7 +1408,6 @@ func EnrichTrackWithExtensionJSON(extensionID, trackJSON string) (string, error) return string(jsonBytes), nil } -// CustomSearchWithExtensionJSON performs custom search using an extension func CustomSearchWithExtensionJSON(extensionID, query string, optionsJSON string) (string, error) { manager := GetExtensionManager() ext, err := manager.GetExtension(extensionID) @@ -1502,7 +1460,6 @@ func CustomSearchWithExtensionJSON(extensionID, query string, optionsJSON string return string(jsonBytes), nil } -// GetSearchProvidersJSON returns all extensions that provide custom search func GetSearchProvidersJSON() (string, error) { manager := GetExtensionManager() providers := manager.GetSearchProviders() @@ -1666,8 +1623,6 @@ func HandleURLWithExtensionJSON(url string) (string, error) { return string(jsonBytes), nil } -// FindURLHandlerJSON finds an extension that can handle the given URL -// Returns extension ID or empty string if none found func FindURLHandlerJSON(url string) string { manager := GetExtensionManager() handler := manager.FindURLHandler(url) @@ -1677,7 +1632,6 @@ func FindURLHandlerJSON(url string) string { return handler.extension.ID } -// GetAlbumWithExtensionJSON gets album tracks using an extension func GetAlbumWithExtensionJSON(extensionID, albumID string) (string, error) { manager := GetExtensionManager() ext, err := manager.GetExtension(extensionID) @@ -1752,7 +1706,6 @@ func GetAlbumWithExtensionJSON(extensionID, albumID string) (string, error) { return string(jsonBytes), nil } -// GetPlaylistWithExtensionJSON gets playlist tracks using an extension func GetPlaylistWithExtensionJSON(extensionID, playlistID string) (string, error) { manager := GetExtensionManager() ext, err := manager.GetExtension(extensionID) @@ -1844,7 +1797,6 @@ func GetPlaylistWithExtensionJSON(extensionID, playlistID string) (string, error return string(jsonBytes), nil } -// GetArtistWithExtensionJSON gets artist info and albums using an extension func GetArtistWithExtensionJSON(extensionID, artistID string) (string, error) { manager := GetExtensionManager() ext, err := manager.GetExtension(extensionID) @@ -1928,7 +1880,6 @@ func GetArtistWithExtensionJSON(extensionID, artistID string) (string, error) { return string(jsonBytes), nil } -// GetURLHandlersJSON returns all extensions that handle custom URLs func GetURLHandlersJSON() (string, error) { manager := GetExtensionManager() handlers := manager.GetURLHandlers() @@ -1972,7 +1923,6 @@ func RunPostProcessingJSON(filePath, metadataJSON string) (string, error) { return string(jsonBytes), nil } -// GetPostProcessingProvidersJSON returns all extensions that provide post-processing func GetPostProcessingProvidersJSON() (string, error) { manager := GetExtensionManager() providers := manager.GetPostProcessingProviders() @@ -2005,13 +1955,11 @@ func GetPostProcessingProvidersJSON() (string, error) { return string(jsonBytes), nil } -// InitExtensionStoreJSON initializes the extension store with cache directory func InitExtensionStoreJSON(cacheDir string) error { InitExtensionStore(cacheDir) return nil } -// GetStoreExtensionsJSON returns all extensions from the store with installation status func GetStoreExtensionsJSON(forceRefresh bool) (string, error) { store := GetExtensionStore() if store == nil { @@ -2035,7 +1983,6 @@ func GetStoreExtensionsJSON(forceRefresh bool) (string, error) { return string(jsonBytes), nil } -// SearchStoreExtensionsJSON searches extensions in the store func SearchStoreExtensionsJSON(query, category string) (string, error) { store := GetExtensionStore() if store == nil { @@ -2055,7 +2002,6 @@ func SearchStoreExtensionsJSON(query, category string) (string, error) { return string(jsonBytes), nil } -// GetStoreCategoriesJSON returns all available categories func GetStoreCategoriesJSON() (string, error) { store := GetExtensionStore() if store == nil { @@ -2071,8 +2017,6 @@ func GetStoreCategoriesJSON() (string, error) { return string(jsonBytes), nil } -// DownloadStoreExtensionJSON downloads an extension from the store -// Returns the path to the downloaded file func DownloadStoreExtensionJSON(extensionID, destDir string) (string, error) { store := GetExtensionStore() if store == nil { @@ -2088,7 +2032,6 @@ func DownloadStoreExtensionJSON(extensionID, destDir string) (string, error) { return destPath, nil } -// ClearStoreCacheJSON clears the store cache func ClearStoreCacheJSON() error { store := GetExtensionStore() if store == nil { @@ -2139,12 +2082,10 @@ func callExtensionFunctionJSON(extensionID, functionName string, timeout time.Du return string(jsonBytes), nil } -// GetExtensionHomeFeedJSON calls getHomeFeed on any extension that supports it func GetExtensionHomeFeedJSON(extensionID string) (string, error) { return callExtensionFunctionJSON(extensionID, "getHomeFeed", 60*time.Second) } -// GetExtensionBrowseCategoriesJSON calls getBrowseCategories on any extension that supports it func GetExtensionBrowseCategoriesJSON(extensionID string) (string, error) { return callExtensionFunctionJSON(extensionID, "getBrowseCategories", 30*time.Second) } diff --git a/go_backend/extension_manager.go b/go_backend/extension_manager.go index 706a32b9..16c16b78 100644 --- a/go_backend/extension_manager.go +++ b/go_backend/extension_manager.go @@ -55,7 +55,6 @@ type LoadedExtension struct { IconPath string `json:"icon_path"` } -// ExtensionManager manages all loaded extensions type ExtensionManager struct { mu sync.RWMutex extensions map[string]*LoadedExtension @@ -283,7 +282,6 @@ func (m *ExtensionManager) initializeVM(ext *LoadedExtension) error { return nil } -// UnloadExtension unloads an extension by ID func (m *ExtensionManager) UnloadExtension(extensionID string) error { m.mu.Lock() defer m.mu.Unlock() @@ -323,7 +321,6 @@ func (m *ExtensionManager) GetExtension(extensionID string) (*LoadedExtension, e return ext, nil } -// GetAllExtensions returns all loaded extensions func (m *ExtensionManager) GetAllExtensions() []*LoadedExtension { m.mu.RLock() defer m.mu.RUnlock() @@ -356,7 +353,6 @@ func (m *ExtensionManager) SetExtensionEnabled(extensionID string, enabled bool) return nil } -// LoadExtensionsFromDirectory scans a directory and loads all valid extensions func (m *ExtensionManager) LoadExtensionsFromDirectory(dirPath string) ([]string, []error) { var loaded []string var errors []error @@ -456,7 +452,6 @@ func (m *ExtensionManager) loadExtensionFromDirectory(dirPath string) (*LoadedEx return ext, nil } -// RemoveExtension completely removes an extension (unload + delete files) func (m *ExtensionManager) RemoveExtension(extensionID string) error { ext, err := m.GetExtension(extensionID) if err != nil { @@ -714,7 +709,6 @@ func (m *ExtensionManager) CheckExtensionUpgradeJSON(filePath string) (string, e return string(jsonBytes), nil } -// GetInstalledExtensionsJSON returns all extensions as JSON for Flutter func (m *ExtensionManager) GetInstalledExtensionsJSON() (string, error) { extensions := m.GetAllExtensions() @@ -923,7 +917,6 @@ func (m *ExtensionManager) CleanupExtension(extensionID string) error { return nil } -// UnloadAllExtensions unloads all extensions gracefully func (m *ExtensionManager) UnloadAllExtensions() { m.mu.Lock() extensionIDs := make([]string, 0, len(m.extensions)) diff --git a/go_backend/extension_manifest.go b/go_backend/extension_manifest.go index 83a0609b..34b89fe6 100644 --- a/go_backend/extension_manifest.go +++ b/go_backend/extension_manifest.go @@ -7,7 +7,6 @@ import ( "strings" ) -// ExtensionType represents the type of extension type ExtensionType string const ( @@ -15,7 +14,6 @@ const ( ExtensionTypeDownloadProvider ExtensionType = "download_provider" ) -// SettingType represents the type of a setting field type SettingType string const ( @@ -26,14 +24,12 @@ const ( SettingTypeButton SettingType = "button" // Action button that calls a JS function ) -// ExtensionPermissions defines what resources an extension can access type ExtensionPermissions struct { Network []string `json:"network"` // List of allowed domains Storage bool `json:"storage"` // Whether extension can use storage API File bool `json:"file"` // Whether extension can use file API } -// ExtensionSetting defines a configurable setting for an extension type ExtensionSetting struct { Key string `json:"key"` Type SettingType `json:"type"` @@ -46,7 +42,6 @@ type ExtensionSetting struct { Action string `json:"action,omitempty"` // For button type: JS function name to call (e.g., "startLogin") } -// QualityOption represents a quality option for download providers type QualityOption struct { ID string `json:"id"` // Unique identifier (e.g., "mp3_320", "opus_128") Label string `json:"label"` // Display name (e.g., "MP3 320kbps") @@ -54,7 +49,6 @@ type QualityOption struct { Settings []QualitySpecificSetting `json:"settings,omitempty"` // Quality-specific settings } -// QualitySpecificSetting represents a setting that's specific to a quality option type QualitySpecificSetting struct { Key string `json:"key"` Type SettingType `json:"type"` @@ -66,14 +60,12 @@ type QualitySpecificSetting struct { Options []string `json:"options,omitempty"` // For select type } -// SearchFilter defines a filter option for search type SearchFilter struct { ID string `json:"id"` // Filter identifier (e.g., "track", "album", "artist", "playlist") Label string `json:"label,omitempty"` // Display label (e.g., "Songs", "Albums", "Artists", "Playlists") Icon string `json:"icon,omitempty"` // Optional icon name } -// SearchBehaviorConfig defines custom search behavior for an extension type SearchBehaviorConfig struct { Enabled bool `json:"enabled"` // Whether extension provides custom search Placeholder string `json:"placeholder,omitempty"` // Placeholder text for search box @@ -85,20 +77,17 @@ type SearchBehaviorConfig struct { Filters []SearchFilter `json:"filters,omitempty"` // Available search filters (e.g., track, album, artist, playlist) } -// URLHandlerConfig defines custom URL handling for an extension type URLHandlerConfig struct { Enabled bool `json:"enabled"` // Whether extension handles URLs Patterns []string `json:"patterns,omitempty"` // URL patterns to match (e.g., "music.youtube.com", "soundcloud.com") } -// TrackMatchingConfig defines custom track matching behavior type TrackMatchingConfig struct { CustomMatching bool `json:"customMatching"` // Whether extension handles matching Strategy string `json:"strategy,omitempty"` // "isrc", "name", "duration", "custom" DurationTolerance int `json:"durationTolerance,omitempty"` // Tolerance in seconds for duration matching } -// PostProcessingHook defines a post-processing hook type PostProcessingHook struct { ID string `json:"id"` // Unique identifier Name string `json:"name"` // Display name @@ -107,13 +96,11 @@ type PostProcessingHook struct { SupportedFormats []string `json:"supportedFormats,omitempty"` // Supported file formats (e.g., ["flac", "mp3"]) } -// PostProcessingConfig defines post-processing capabilities type PostProcessingConfig struct { Enabled bool `json:"enabled"` // Whether extension provides post-processing Hooks []PostProcessingHook `json:"hooks,omitempty"` // Available hooks } -// ExtensionManifest represents the manifest.json of an extension type ExtensionManifest struct { Name string `json:"name"` DisplayName string `json:"displayName"` @@ -136,7 +123,6 @@ type ExtensionManifest struct { Capabilities map[string]interface{} `json:"capabilities,omitempty"` // Extension capabilities (homeFeed, browseCategories, etc.) } -// ManifestValidationError represents a validation error in the manifest type ManifestValidationError struct { Field string Message string @@ -146,7 +132,6 @@ func (e *ManifestValidationError) Error() string { return fmt.Sprintf("manifest validation error: %s - %s", e.Field, e.Message) } -// ParseManifest parses and validates a manifest from JSON bytes func ParseManifest(data []byte) (*ExtensionManifest, error) { var manifest ExtensionManifest if err := json.Unmarshal(data, &manifest); err != nil { @@ -225,7 +210,6 @@ func (m *ExtensionManifest) Validate() error { return nil } -// HasType checks if the extension has a specific type func (m *ExtensionManifest) HasType(t ExtensionType) bool { for _, et := range m.Types { if et == t { @@ -235,17 +219,14 @@ func (m *ExtensionManifest) HasType(t ExtensionType) bool { return false } -// IsMetadataProvider returns true if extension provides metadata func (m *ExtensionManifest) IsMetadataProvider() bool { return m.HasType(ExtensionTypeMetadataProvider) } -// IsDownloadProvider returns true if extension provides downloads func (m *ExtensionManifest) IsDownloadProvider() bool { return m.HasType(ExtensionTypeDownloadProvider) } -// IsDomainAllowed checks if a domain is in the allowed network permissions func (m *ExtensionManifest) IsDomainAllowed(domain string) bool { domain = strings.ToLower(strings.TrimSpace(domain)) for _, allowed := range m.Permissions.Network { @@ -264,27 +245,22 @@ func (m *ExtensionManifest) IsDomainAllowed(domain string) bool { return false } -// HasCustomSearch returns true if extension provides custom search func (m *ExtensionManifest) HasCustomSearch() bool { return m.SearchBehavior != nil && m.SearchBehavior.Enabled } -// HasCustomMatching returns true if extension provides custom track matching func (m *ExtensionManifest) HasCustomMatching() bool { return m.TrackMatching != nil && m.TrackMatching.CustomMatching } -// HasPostProcessing returns true if extension provides post-processing func (m *ExtensionManifest) HasPostProcessing() bool { return m.PostProcessing != nil && m.PostProcessing.Enabled } -// HasURLHandler returns true if extension handles custom URLs func (m *ExtensionManifest) HasURLHandler() bool { return m.URLHandler != nil && m.URLHandler.Enabled && len(m.URLHandler.Patterns) > 0 } -// MatchesURL checks if a URL matches any of the extension's URL patterns func (m *ExtensionManifest) MatchesURL(urlStr string) bool { if !m.HasURLHandler() { return false @@ -301,7 +277,6 @@ func (m *ExtensionManifest) MatchesURL(urlStr string) bool { return false } -// GetPostProcessingHooks returns all post-processing hooks func (m *ExtensionManifest) GetPostProcessingHooks() []PostProcessingHook { if m.PostProcessing == nil { return nil @@ -309,7 +284,6 @@ func (m *ExtensionManifest) GetPostProcessingHooks() []PostProcessingHook { return m.PostProcessing.Hooks } -// ToJSON serializes the manifest to JSON func (m *ExtensionManifest) ToJSON() ([]byte, error) { return json.Marshal(m) } diff --git a/go_backend/extension_runtime.go b/go_backend/extension_runtime.go index c01d2665..559f10d6 100644 --- a/go_backend/extension_runtime.go +++ b/go_backend/extension_runtime.go @@ -39,7 +39,6 @@ var ( pendingAuthRequestsMu sync.RWMutex ) -// GetPendingAuthRequest returns pending auth request for an extension (called from Flutter) func GetPendingAuthRequest(extensionID string) *PendingAuthRequest { pendingAuthRequestsMu.RLock() defer pendingAuthRequestsMu.RUnlock() @@ -201,7 +200,6 @@ func (r *ExtensionRuntime) SetSettings(settings map[string]interface{}) { r.settings = settings } -// RegisterAPIs registers all sandboxed APIs to the Goja VM func (r *ExtensionRuntime) RegisterAPIs(vm *goja.Runtime) { r.vm = vm diff --git a/go_backend/extension_runtime_file.go b/go_backend/extension_runtime_file.go index 20b720df..6914230a 100644 --- a/go_backend/extension_runtime_file.go +++ b/go_backend/extension_runtime_file.go @@ -15,7 +15,6 @@ import ( // ==================== File API (Sandboxed) ==================== -// List of allowed directories for file operations (set by Go backend for download operations) var ( allowedDownloadDirs []string allowedDownloadDirsMu sync.RWMutex @@ -49,9 +48,6 @@ func isPathInAllowedDirs(absPath string) bool { return false } -// validatePath checks if the path is within the extension's sandbox -// Security: Absolute paths are BLOCKED unless they're in allowed download directories -// Extensions should use relative paths for their own data storage func (r *ExtensionRuntime) validatePath(path string) (string, error) { // Check if extension has file permission if !r.manifest.Permissions.File { diff --git a/go_backend/extension_runtime_http.go b/go_backend/extension_runtime_http.go index a87365c8..42460937 100644 --- a/go_backend/extension_runtime_http.go +++ b/go_backend/extension_runtime_http.go @@ -14,14 +14,12 @@ import ( // ==================== HTTP API (Sandboxed) ==================== -// HTTPResponse represents the response from an HTTP request type HTTPResponse struct { StatusCode int `json:"statusCode"` Body string `json:"body"` Headers map[string]string `json:"headers"` } -// validateDomain checks if the domain is allowed by the extension's permissions func (r *ExtensionRuntime) validateDomain(urlStr string) error { parsed, err := url.Parse(urlStr) if err != nil { @@ -42,7 +40,6 @@ func (r *ExtensionRuntime) validateDomain(urlStr string) error { return nil } -// httpGet performs a GET request (sandboxed) func (r *ExtensionRuntime) httpGet(call goja.FunctionCall) goja.Value { if len(call.Arguments) < 1 { return r.vm.ToValue(map[string]interface{}{ @@ -120,7 +117,6 @@ func (r *ExtensionRuntime) httpGet(call goja.FunctionCall) goja.Value { }) } -// httpPost performs a POST request (sandboxed) func (r *ExtensionRuntime) httpPost(call goja.FunctionCall) goja.Value { if len(call.Arguments) < 1 { return r.vm.ToValue(map[string]interface{}{ @@ -347,7 +343,6 @@ func (r *ExtensionRuntime) httpPut(call goja.FunctionCall) goja.Value { return r.httpMethodShortcut("PUT", call) } -// httpDelete performs a DELETE request (shortcut for http.request with method: "DELETE") func (r *ExtensionRuntime) httpDelete(call goja.FunctionCall) goja.Value { return r.httpMethodShortcut("DELETE", call) } @@ -356,8 +351,6 @@ func (r *ExtensionRuntime) httpPatch(call goja.FunctionCall) goja.Value { return r.httpMethodShortcut("PATCH", call) } -// httpMethodShortcut is a helper for PUT/DELETE/PATCH shortcuts -// Signature: http.put(url, body, headers) / http.delete(url, headers) / http.patch(url, body, headers) func (r *ExtensionRuntime) httpMethodShortcut(method string, call goja.FunctionCall) goja.Value { if len(call.Arguments) < 1 { return r.vm.ToValue(map[string]interface{}{ diff --git a/go_backend/extension_settings.go b/go_backend/extension_settings.go index f76514b3..aec2cd43 100644 --- a/go_backend/extension_settings.go +++ b/go_backend/extension_settings.go @@ -9,7 +9,6 @@ import ( "sync" ) -// ExtensionSettingsStore manages settings for all extensions type ExtensionSettingsStore struct { mu sync.RWMutex dataDir string @@ -22,7 +21,6 @@ var ( globalSettingsStoreOnce sync.Once ) -// GetExtensionSettingsStore returns the global settings store func GetExtensionSettingsStore() *ExtensionSettingsStore { globalSettingsStoreOnce.Do(func() { globalSettingsStore = &ExtensionSettingsStore{ @@ -32,7 +30,6 @@ func GetExtensionSettingsStore() *ExtensionSettingsStore { return globalSettingsStore } -// SetDataDir sets the data directory for settings storage func (s *ExtensionSettingsStore) SetDataDir(dataDir string) error { s.mu.Lock() defer s.mu.Unlock() @@ -45,12 +42,10 @@ func (s *ExtensionSettingsStore) SetDataDir(dataDir string) error { return s.loadAllSettings() } -// getSettingsPath returns the path to an extension's settings file func (s *ExtensionSettingsStore) getSettingsPath(extensionID string) string { return filepath.Join(s.dataDir, extensionID, "settings.json") } -// loadAllSettings loads settings for all extensions from disk func (s *ExtensionSettingsStore) loadAllSettings() error { entries, err := os.ReadDir(s.dataDir) if err != nil { @@ -75,7 +70,6 @@ func (s *ExtensionSettingsStore) loadAllSettings() error { return nil } -// loadSettings loads settings for a specific extension func (s *ExtensionSettingsStore) loadSettings(extensionID string) (map[string]interface{}, error) { settingsPath := s.getSettingsPath(extensionID) data, err := os.ReadFile(settingsPath) @@ -94,7 +88,6 @@ func (s *ExtensionSettingsStore) loadSettings(extensionID string) (map[string]in return settings, nil } -// saveSettings saves settings for a specific extension func (s *ExtensionSettingsStore) saveSettings(extensionID string, settings map[string]interface{}) error { settingsPath := s.getSettingsPath(extensionID) @@ -111,8 +104,6 @@ func (s *ExtensionSettingsStore) saveSettings(extensionID string, settings map[s return os.WriteFile(settingsPath, data, 0644) } -// Get retrieves a setting value for an extension -// Returns error if extension or key not found (gomobile compatible) func (s *ExtensionSettingsStore) Get(extensionID, key string) (interface{}, error) { s.mu.RLock() defer s.mu.RUnlock() @@ -129,7 +120,6 @@ func (s *ExtensionSettingsStore) Get(extensionID, key string) (interface{}, erro return value, nil } -// GetAll retrieves all settings for an extension func (s *ExtensionSettingsStore) GetAll(extensionID string) map[string]interface{} { s.mu.RLock() defer s.mu.RUnlock() @@ -147,7 +137,6 @@ func (s *ExtensionSettingsStore) GetAll(extensionID string) map[string]interface return result } -// Set stores a setting value for an extension func (s *ExtensionSettingsStore) Set(extensionID, key string, value interface{}) error { s.mu.Lock() defer s.mu.Unlock() @@ -161,7 +150,6 @@ func (s *ExtensionSettingsStore) Set(extensionID, key string, value interface{}) return s.saveSettings(extensionID, s.settings[extensionID]) } -// SetAll stores all settings for an extension func (s *ExtensionSettingsStore) SetAll(extensionID string, settings map[string]interface{}) error { s.mu.Lock() defer s.mu.Unlock() @@ -172,7 +160,6 @@ func (s *ExtensionSettingsStore) SetAll(extensionID string, settings map[string] return s.saveSettings(extensionID, settings) } -// Remove removes a setting for an extension func (s *ExtensionSettingsStore) Remove(extensionID, key string) error { s.mu.Lock() defer s.mu.Unlock() @@ -188,7 +175,6 @@ func (s *ExtensionSettingsStore) Remove(extensionID, key string) error { return s.saveSettings(extensionID, extSettings) } -// RemoveAll removes all settings for an extension func (s *ExtensionSettingsStore) RemoveAll(extensionID string) error { s.mu.Lock() defer s.mu.Unlock() @@ -203,7 +189,6 @@ func (s *ExtensionSettingsStore) RemoveAll(extensionID string) error { return nil } -// GetAllExtensionSettings returns settings for all extensions as JSON func (s *ExtensionSettingsStore) GetAllExtensionSettingsJSON() (string, error) { s.mu.RLock() defer s.mu.RUnlock() diff --git a/go_backend/extension_store.go b/go_backend/extension_store.go index 370046d7..cc83da09 100644 --- a/go_backend/extension_store.go +++ b/go_backend/extension_store.go @@ -20,28 +20,26 @@ const ( CategoryIntegration = "integration" ) -// StoreExtension represents an extension in the store type StoreExtension struct { - ID string `json:"id"` - Name string `json:"name"` - DisplayName string `json:"display_name,omitempty"` - Version string `json:"version"` - Author string `json:"author"` - Description string `json:"description"` - DownloadURL string `json:"download_url,omitempty"` - IconURL string `json:"icon_url,omitempty"` - Category string `json:"category"` - Tags []string `json:"tags,omitempty"` - Downloads int `json:"downloads"` - UpdatedAt string `json:"updated_at"` - MinAppVersion string `json:"min_app_version,omitempty"` - DisplayNameAlt string `json:"displayName,omitempty"` - DownloadURLAlt string `json:"downloadUrl,omitempty"` - IconURLAlt string `json:"iconUrl,omitempty"` - MinAppVersionAlt string `json:"minAppVersion,omitempty"` + ID string `json:"id"` + Name string `json:"name"` + DisplayName string `json:"display_name,omitempty"` + Version string `json:"version"` + Author string `json:"author"` + Description string `json:"description"` + DownloadURL string `json:"download_url,omitempty"` + IconURL string `json:"icon_url,omitempty"` + Category string `json:"category"` + Tags []string `json:"tags,omitempty"` + Downloads int `json:"downloads"` + UpdatedAt string `json:"updated_at"` + MinAppVersion string `json:"min_app_version,omitempty"` + DisplayNameAlt string `json:"displayName,omitempty"` + DownloadURLAlt string `json:"downloadUrl,omitempty"` + IconURLAlt string `json:"iconUrl,omitempty"` + MinAppVersionAlt string `json:"minAppVersion,omitempty"` } -// getDisplayName returns display name, falling back to name (private to avoid gomobile conflict) func (e *StoreExtension) getDisplayName() string { if e.DisplayName != "" { return e.DisplayName @@ -52,7 +50,6 @@ func (e *StoreExtension) getDisplayName() string { return e.Name } -// getDownloadURL returns download URL from either field (private to avoid gomobile conflict) func (e *StoreExtension) getDownloadURL() string { if e.DownloadURL != "" { return e.DownloadURL @@ -60,7 +57,6 @@ func (e *StoreExtension) getDownloadURL() string { return e.DownloadURLAlt } -// getIconURL returns icon URL from either field (private to avoid gomobile conflict) func (e *StoreExtension) getIconURL() string { if e.IconURL != "" { return e.IconURL @@ -68,7 +64,6 @@ func (e *StoreExtension) getIconURL() string { return e.IconURLAlt } -// getMinAppVersion returns min app version from either field (private to avoid gomobile conflict) func (e *StoreExtension) getMinAppVersion() string { if e.MinAppVersion != "" { return e.MinAppVersion @@ -76,7 +71,6 @@ func (e *StoreExtension) getMinAppVersion() string { return e.MinAppVersionAlt } -// StoreRegistry represents the extension registry type StoreRegistry struct { Version int `json:"version"` UpdatedAt string `json:"updated_at"` @@ -103,7 +97,6 @@ type StoreExtensionResponse struct { HasUpdate bool `json:"has_update"` } -// ToResponse converts StoreExtension to normalized response func (e *StoreExtension) ToResponse() StoreExtensionResponse { return StoreExtensionResponse{ ID: e.ID, @@ -122,7 +115,6 @@ func (e *StoreExtension) ToResponse() StoreExtensionResponse { } } -// ExtensionStore manages the extension store type ExtensionStore struct { registryURL string cacheDir string @@ -143,7 +135,6 @@ const ( cacheFileName = "store_cache.json" ) -// InitExtensionStore initializes the extension store func InitExtensionStore(cacheDir string) *ExtensionStore { extensionStoreMu.Lock() defer extensionStoreMu.Unlock() @@ -160,14 +151,12 @@ func InitExtensionStore(cacheDir string) *ExtensionStore { return extensionStore } -// GetExtensionStore returns the singleton store instance func GetExtensionStore() *ExtensionStore { extensionStoreMu.Lock() defer extensionStoreMu.Unlock() return extensionStore } -// loadDiskCache loads cached registry from disk func (s *ExtensionStore) loadDiskCache() { if s.cacheDir == "" { return @@ -193,7 +182,6 @@ func (s *ExtensionStore) loadDiskCache() { LogDebug("ExtensionStore", "Loaded %d extensions from disk cache", len(s.cache.Extensions)) } -// saveDiskCache saves registry to disk cache func (s *ExtensionStore) saveDiskCache() { if s.cacheDir == "" || s.cache == nil { return @@ -216,7 +204,6 @@ func (s *ExtensionStore) saveDiskCache() { os.WriteFile(cachePath, data, 0644) } -// FetchRegistry fetches the extension registry from GitHub func (s *ExtensionStore) FetchRegistry(forceRefresh bool) (*StoreRegistry, error) { s.cacheMu.Lock() defer s.cacheMu.Unlock() @@ -267,7 +254,6 @@ func (s *ExtensionStore) FetchRegistry(forceRefresh bool) (*StoreRegistry, error return ®istry, nil } -// GetExtensionsWithStatus returns extensions with installation status func (s *ExtensionStore) GetExtensionsWithStatus() ([]StoreExtensionResponse, error) { registry, err := s.FetchRegistry(false) if err != nil { @@ -299,7 +285,6 @@ func (s *ExtensionStore) GetExtensionsWithStatus() ([]StoreExtensionResponse, er return result, nil } -// DownloadExtension downloads an extension package to the specified path func (s *ExtensionStore) DownloadExtension(extensionID string, destPath string) error { registry, err := s.FetchRegistry(false) if err != nil { @@ -347,7 +332,6 @@ func (s *ExtensionStore) DownloadExtension(extensionID string, destPath string) return nil } -// GetCategories returns all available categories func (s *ExtensionStore) GetCategories() []string { return []string{ CategoryMetadata, @@ -358,7 +342,6 @@ func (s *ExtensionStore) GetCategories() []string { } } -// SearchExtensions searches extensions by query func (s *ExtensionStore) SearchExtensions(query string, category string) ([]StoreExtensionResponse, error) { extensions, err := s.GetExtensionsWithStatus() if err != nil { @@ -404,7 +387,6 @@ func (s *ExtensionStore) SearchExtensions(query string, category string) ([]Stor return result, nil } -// ClearCache clears the in-memory and disk cache func (s *ExtensionStore) ClearCache() { s.cacheMu.Lock() defer s.cacheMu.Unlock() diff --git a/go_backend/httputil.go b/go_backend/httputil.go index be0f75de..824e0685 100644 --- a/go_backend/httputil.go +++ b/go_backend/httputil.go @@ -15,9 +15,6 @@ import ( "time" ) -// getRandomUserAgent generates a random Windows Chrome User-Agent string -// Uses modern Chrome format with build and patch numbers -// Windows 11 still reports as "Windows NT 10.0" for compatibility func getRandomUserAgent() string { // Chrome version 120-145 (modern range) chromeVersion := rand.Intn(26) + 120 @@ -38,10 +35,9 @@ const ( SongLinkTimeout = 30 * time.Second DefaultMaxRetries = 3 DefaultRetryDelay = 1 * time.Second - Second = time.Second // Exported for use in other files + Second = time.Second ) -// Shared transport with connection pooling to prevent TCP exhaustion var sharedTransport = &http.Transport{ DialContext: (&net.Dialer{ Timeout: 30 * time.Second, @@ -85,7 +81,6 @@ func GetDownloadClient() *http.Client { return downloadClient } -// CloseIdleConnections closes idle connections in the shared transport func CloseIdleConnections() { sharedTransport.CloseIdleConnections() } @@ -117,9 +112,6 @@ func DefaultRetryConfig() RetryConfig { } } -// DoRequestWithRetry executes an HTTP request with retry logic and exponential backoff -// Handles 429 (Too Many Requests) responses with Retry-After header -// Also detects and logs ISP blocking func DoRequestWithRetry(client *http.Client, req *http.Request, config RetryConfig) (*http.Response, error) { var lastErr error delay := config.InitialDelay @@ -149,12 +141,10 @@ func DoRequestWithRetry(client *http.Client, req *http.Request, config RetryConf continue } - // Success if resp.StatusCode >= 200 && resp.StatusCode < 300 { return resp, nil } - // Handle rate limiting (429) if resp.StatusCode == 429 { resp.Body.Close() retryAfter := getRetryAfterDuration(resp) @@ -194,7 +184,6 @@ func DoRequestWithRetry(client *http.Client, req *http.Request, config RetryConf } } - // Server errors (5xx) - retry if resp.StatusCode >= 500 { resp.Body.Close() lastErr = fmt.Errorf("server error: HTTP %d", resp.StatusCode) @@ -206,7 +195,6 @@ func DoRequestWithRetry(client *http.Client, req *http.Request, config RetryConf continue } - // Client errors (4xx except 429) - don't retry return resp, nil } @@ -225,12 +213,10 @@ func getRetryAfterDuration(resp *http.Response) time.Duration { return 60 * time.Second // Default wait time } - // Try parsing as seconds if seconds, err := strconv.Atoi(retryAfter); err == nil { return time.Duration(seconds) * time.Second } - // Try parsing as HTTP date if t, err := http.ParseTime(retryAfter); err == nil { duration := time.Until(t) if duration > 0 { @@ -241,8 +227,6 @@ func getRetryAfterDuration(resp *http.Response) time.Duration { return 60 * time.Second // Default } -// ReadResponseBody reads and returns the response body -// Returns error if body is empty func ReadResponseBody(resp *http.Response) ([]byte, error) { if resp == nil { return nil, fmt.Errorf("response is nil") @@ -272,14 +256,12 @@ func ValidateResponse(resp *http.Response) error { return nil } -// BuildErrorMessage creates a detailed error message for API failures func BuildErrorMessage(apiURL string, statusCode int, responsePreview string) string { msg := fmt.Sprintf("API %s failed", apiURL) if statusCode > 0 { msg += fmt.Sprintf(" (HTTP %d)", statusCode) } if responsePreview != "" { - // Truncate preview if too long if len(responsePreview) > 100 { responsePreview = responsePreview[:100] + "..." } @@ -298,18 +280,14 @@ func (e *ISPBlockingError) Error() string { return fmt.Sprintf("ISP blocking detected for %s: %s", e.Domain, e.Reason) } -// IsISPBlocking checks if an error is likely caused by ISP blocking -// Returns the ISPBlockingError if detected, nil otherwise func IsISPBlocking(err error, requestURL string) *ISPBlockingError { if err == nil { return nil } - // Extract domain from URL domain := extractDomain(requestURL) errStr := strings.ToLower(err.Error()) - // Check for DNS resolution failure (common ISP blocking method) var dnsErr *net.DNSError if errors.As(err, &dnsErr) { if dnsErr.IsNotFound || dnsErr.IsTemporary { @@ -321,11 +299,9 @@ func IsISPBlocking(err error, requestURL string) *ISPBlockingError { } } - // Check for connection refused (ISP firewall blocking) var opErr *net.OpError if errors.As(err, &opErr) { if opErr.Op == "dial" { - // Check for specific syscall errors var syscallErr syscall.Errno if errors.As(opErr.Err, &syscallErr) { switch syscallErr { @@ -364,7 +340,6 @@ func IsISPBlocking(err error, requestURL string) *ISPBlockingError { } } - // Check for TLS handshake failure (ISP MITM or blocking HTTPS) var tlsErr *tls.RecordHeaderError if errors.As(err, &tlsErr) { return &ISPBlockingError{ @@ -425,7 +400,6 @@ func extractDomain(rawURL string) string { parsed, err := url.Parse(rawURL) if err != nil { - // Try to extract domain manually rawURL = strings.TrimPrefix(rawURL, "https://") rawURL = strings.TrimPrefix(rawURL, "http://") if idx := strings.Index(rawURL, "/"); idx > 0 { diff --git a/go_backend/lyrics.go b/go_backend/lyrics.go index c82ed04a..723dadf5 100644 --- a/go_backend/lyrics.go +++ b/go_backend/lyrics.go @@ -238,12 +238,9 @@ func (c *LyricsClient) durationMatches(lrcDuration, targetDuration float64) bool return diff <= durationToleranceSec } -// durationSec: track duration in seconds for matching, use 0 to skip duration matching func (c *LyricsClient) FetchLyricsAllSources(spotifyID, trackName, artistName string, durationSec float64) (*LyricsResponse, error) { - // Normalize artist name - take first artist before comma/semicolon for better matching primaryArtist := normalizeArtistName(artistName) - // Check cache first (use original artist name for cache key) if cached, found := globalLyricsCache.Get(artistName, trackName, durationSec); found { fmt.Printf("[Lyrics] Cache hit for: %s - %s\n", artistName, trackName) cachedCopy := *cached @@ -254,12 +251,10 @@ func (c *LyricsClient) FetchLyricsAllSources(spotifyID, trackName, artistName st var lyrics *LyricsResponse var err error - // Helper to check if lyrics result is valid (has lines OR is instrumental) isValidResult := func(l *LyricsResponse) bool { return l != nil && (len(l.Lines) > 0 || l.Instrumental) } - // Try exact match first with primary artist lyrics, err = c.FetchLyricsWithMetadata(primaryArtist, trackName) if err == nil && isValidResult(lyrics) { lyrics.Source = "LRCLIB" @@ -267,7 +262,6 @@ func (c *LyricsClient) FetchLyricsAllSources(spotifyID, trackName, artistName st return lyrics, nil } - // Try with full artist name if different from primary if primaryArtist != artistName { lyrics, err = c.FetchLyricsWithMetadata(artistName, trackName) if err == nil && isValidResult(lyrics) { @@ -277,7 +271,6 @@ func (c *LyricsClient) FetchLyricsAllSources(spotifyID, trackName, artistName st } } - // Try with simplified track name simplifiedTrack := simplifyTrackName(trackName) if simplifiedTrack != trackName { lyrics, err = c.FetchLyricsWithMetadata(primaryArtist, simplifiedTrack) @@ -288,7 +281,6 @@ func (c *LyricsClient) FetchLyricsAllSources(spotifyID, trackName, artistName st } } - // Search with duration matching (use primary artist for search) query := primaryArtist + " " + trackName lyrics, err = c.FetchLyricsFromLRCLibSearch(query, durationSec) if err == nil && isValidResult(lyrics) { @@ -297,7 +289,6 @@ func (c *LyricsClient) FetchLyricsAllSources(spotifyID, trackName, artistName st return lyrics, nil } - // Search with simplified name and duration matching if simplifiedTrack != trackName { query = primaryArtist + " " + simplifiedTrack lyrics, err = c.FetchLyricsFromLRCLibSearch(query, durationSec) @@ -393,32 +384,6 @@ func msToLRCTimestamp(ms int64) string { return fmt.Sprintf("[%02d:%02d.%02d]", minutes, seconds, centiseconds) } -// Use convertToLRCWithMetadata for full LRC with headers -// Kept for potential future use -// func convertToLRC(lyrics *LyricsResponse) string { -// if lyrics == nil || len(lyrics.Lines) == 0 { -// return "" -// } -// -// var builder strings.Builder -// -// if lyrics.SyncType == "LINE_SYNCED" { -// for _, line := range lyrics.Lines { -// timestamp := msToLRCTimestamp(line.StartTimeMs) -// builder.WriteString(timestamp) -// builder.WriteString(line.Words) -// builder.WriteString("\n") -// } -// } else { -// for _, line := range lyrics.Lines { -// builder.WriteString(line.Words) -// builder.WriteString("\n") -// } -// } -// -// return builder.String() -// } - func convertToLRCWithMetadata(lyrics *LyricsResponse, trackName, artistName string) string { if lyrics == nil || len(lyrics.Lines) == 0 { return "" @@ -480,11 +445,7 @@ func simplifyTrackName(name string) string { return strings.TrimSpace(result) } -// normalizeArtistName extracts the primary artist from multi-artist strings -// e.g., "HOYO-MiX, AURORA" -> "HOYO-MiX" -// e.g., "Artist1; Artist2" -> "Artist1" func normalizeArtistName(name string) string { - // Split by common separators: ", " or "; " or " & " or " feat. " or " ft. " separators := []string{", ", "; ", " & ", " feat. ", " ft. ", " featuring ", " with "} result := name diff --git a/go_backend/metadata.go b/go_backend/metadata.go index e636a802..17c94910 100644 --- a/go_backend/metadata.go +++ b/go_backend/metadata.go @@ -238,7 +238,6 @@ func EmbedMetadataWithCoverData(filePath string, metadata Metadata, coverData [] return f.Save(filePath) } -// ReadMetadata reads metadata from a FLAC file func ReadMetadata(filePath string) (*Metadata, error) { f, err := flac.ParseFile(filePath) if err != nil { @@ -336,7 +335,6 @@ func fileExists(path string) bool { return err == nil } -// ExtractCoverArt extracts cover art from a FLAC file func ExtractCoverArt(filePath string) ([]byte, error) { f, err := flac.ParseFile(filePath) if err != nil { @@ -453,7 +451,6 @@ func EmbedGenreLabel(filePath string, genre, label string) error { return f.Save(filePath) } -// ExtractLyrics extracts embedded lyrics from a FLAC file func ExtractLyrics(filePath string) (string, error) { f, err := flac.ParseFile(filePath) if err != nil { diff --git a/go_backend/parallel.go b/go_backend/parallel.go index 99e9cd1e..9f7c7030 100644 --- a/go_backend/parallel.go +++ b/go_backend/parallel.go @@ -14,10 +14,9 @@ type TrackIDCacheEntry struct { } type TrackIDCache struct { - cache map[string]*TrackIDCacheEntry - mu sync.RWMutex - ttl time.Duration - // Cleanup is triggered on writes at a fixed interval to avoid unbounded growth. + cache map[string]*TrackIDCacheEntry + mu sync.RWMutex + ttl time.Duration lastCleanup time.Time cleanupInterval time.Duration } @@ -52,7 +51,6 @@ func (c *TrackIDCache) Get(isrc string) *TrackIDCacheEntry { return entry } - // Lazily delete expired entry. c.mu.Lock() entry, exists = c.cache[isrc] if exists && time.Now().After(entry.ExpiresAt) { @@ -139,7 +137,6 @@ func (c *TrackIDCache) Size() int { return len(c.cache) } -// ParallelDownloadResult holds results from parallel operations type ParallelDownloadResult struct { CoverData []byte LyricsData *LyricsResponse @@ -164,14 +161,11 @@ func FetchCoverAndLyricsParallel( wg.Add(1) go func() { defer wg.Done() - fmt.Println("[Parallel] Starting cover download...") data, err := downloadCoverToMemory(coverURL, maxQualityCover) if err != nil { result.CoverErr = err - fmt.Printf("[Parallel] Cover download failed: %v\n", err) } else { result.CoverData = data - fmt.Printf("[Parallel] Cover downloaded: %d bytes\n", len(data)) } }() } @@ -180,20 +174,16 @@ func FetchCoverAndLyricsParallel( wg.Add(1) go func() { defer wg.Done() - fmt.Println("[Parallel] Starting lyrics fetch...") client := NewLyricsClient() durationSec := float64(durationMs) / 1000.0 lyrics, err := client.FetchLyricsAllSources(spotifyID, trackName, artistName, durationSec) if err != nil { result.LyricsErr = err - fmt.Printf("[Parallel] Lyrics fetch failed: %v\n", err) } else if lyrics != nil && len(lyrics.Lines) > 0 { result.LyricsData = lyrics result.LyricsLRC = convertToLRCWithMetadata(lyrics, trackName, artistName) - fmt.Printf("[Parallel] Lyrics fetched: %d lines\n", len(lyrics.Lines)) } else { result.LyricsErr = fmt.Errorf("no lyrics found") - fmt.Println("[Parallel] No lyrics found") } }() } @@ -206,8 +196,8 @@ type PreWarmCacheRequest struct { ISRC string TrackName string ArtistName string - SpotifyID string // Needed for Amazon (SongLink lookup) - Service string // "tidal", "qobuz", "amazon" + SpotifyID string + Service string } func PreWarmTrackCache(requests []PreWarmCacheRequest) { @@ -215,7 +205,6 @@ func PreWarmTrackCache(requests []PreWarmCacheRequest) { return } - fmt.Printf("[Cache] Pre-warming cache for %d tracks...\n", len(requests)) cache := GetTrackIDCache() semaphore := make(chan struct{}, 3) @@ -244,7 +233,6 @@ func PreWarmTrackCache(requests []PreWarmCacheRequest) { } wg.Wait() - fmt.Printf("[Cache] Pre-warm complete. Cache size: %d\n", cache.Size()) } func preWarmTidalCache(isrc, _, _ string) { @@ -252,7 +240,6 @@ func preWarmTidalCache(isrc, _, _ string) { track, err := downloader.SearchTrackByISRC(isrc) if err == nil && track != nil { GetTrackIDCache().SetTidal(isrc, track.ID) - fmt.Printf("[Cache] Cached Tidal ID for ISRC %s: %d\n", isrc, track.ID) } } @@ -261,7 +248,6 @@ func preWarmQobuzCache(isrc string) { track, err := downloader.SearchTrackByISRC(isrc) if err == nil && track != nil { GetTrackIDCache().SetQobuz(isrc, track.ID) - fmt.Printf("[Cache] Cached Qobuz ID for ISRC %s: %d\n", isrc, track.ID) } } @@ -270,7 +256,6 @@ func preWarmAmazonCache(isrc, spotifyID string) { availability, err := client.CheckTrackAvailability(spotifyID, isrc) if err == nil && availability != nil && availability.Amazon { GetTrackIDCache().SetAmazon(isrc, availability.AmazonURL) - fmt.Printf("[Cache] Cached Amazon URL for ISRC %s\n", isrc) } } @@ -283,7 +268,6 @@ func PreWarmCache(tracksJSON string) error { func ClearTrackCache() { GetTrackIDCache().Clear() - fmt.Println("[Cache] Track ID cache cleared") } func GetCacheSize() int { diff --git a/go_backend/progress.go b/go_backend/progress.go index 159f4d6c..8960dddb 100644 --- a/go_backend/progress.go +++ b/go_backend/progress.go @@ -78,7 +78,6 @@ func GetItemProgress(itemID string) string { return "{}" } -// StartItemProgress initializes progress tracking for an item func StartItemProgress(itemID string) { multiMu.Lock() defer multiMu.Unlock() @@ -93,7 +92,6 @@ func StartItemProgress(itemID string) { } } -// SetItemBytesTotal sets total bytes for an item func SetItemBytesTotal(itemID string, total int64) { multiMu.Lock() defer multiMu.Unlock() @@ -103,7 +101,6 @@ func SetItemBytesTotal(itemID string, total int64) { } } -// SetItemBytesReceived sets bytes received for an item func SetItemBytesReceived(itemID string, received int64) { multiMu.Lock() defer multiMu.Unlock() @@ -116,7 +113,6 @@ func SetItemBytesReceived(itemID string, received int64) { } } -// SetItemBytesReceivedWithSpeed sets bytes received and speed for an item func SetItemBytesReceivedWithSpeed(itemID string, received int64, speedMBps float64) { multiMu.Lock() defer multiMu.Unlock() @@ -130,7 +126,6 @@ func SetItemBytesReceivedWithSpeed(itemID string, received int64, speedMBps floa } } -// CompleteItemProgress marks an item as complete func CompleteItemProgress(itemID string) { multiMu.Lock() defer multiMu.Unlock() @@ -142,7 +137,6 @@ func CompleteItemProgress(itemID string) { } } -// SetItemProgress sets progress for an item directly func SetItemProgress(itemID string, progress float64, bytesReceived, bytesTotal int64) { multiMu.Lock() defer multiMu.Unlock() @@ -158,7 +152,6 @@ func SetItemProgress(itemID string, progress float64, bytesReceived, bytesTotal } } -// SetItemFinalizing marks an item as finalizing (embedding metadata) func SetItemFinalizing(itemID string) { multiMu.Lock() defer multiMu.Unlock() @@ -169,7 +162,6 @@ func SetItemFinalizing(itemID string) { } } -// RemoveItemProgress removes progress tracking for an item func RemoveItemProgress(itemID string) { multiMu.Lock() defer multiMu.Unlock() @@ -177,7 +169,6 @@ func RemoveItemProgress(itemID string) { delete(multiProgress.Items, itemID) } -// ClearAllItemProgress clears all item progress func ClearAllItemProgress() { multiMu.Lock() defer multiMu.Unlock() @@ -185,7 +176,6 @@ func ClearAllItemProgress() { multiProgress.Items = make(map[string]*ItemProgress) } -// setDownloadDir sets the default download directory func setDownloadDir(path string) error { downloadDirMu.Lock() defer downloadDirMu.Unlock() @@ -193,7 +183,6 @@ func setDownloadDir(path string) error { return nil } -// ItemProgressWriter wraps io.Writer to track download progress for a specific item type ItemProgressWriter struct { writer interface{ Write([]byte) (int, error) } itemID string @@ -206,7 +195,6 @@ type ItemProgressWriter struct { const progressUpdateThreshold = 64 * 1024 // Update progress every 64KB -// NewItemProgressWriter creates a new progress writer for a specific item func NewItemProgressWriter(w interface{ Write([]byte) (int, error) }, itemID string) *ItemProgressWriter { now := time.Now() return &ItemProgressWriter{ @@ -220,7 +208,6 @@ func NewItemProgressWriter(w interface{ Write([]byte) (int, error) }, itemID str } } -// Write implements io.Writer with threshold-based progress updates and speed tracking func (pw *ItemProgressWriter) Write(p []byte) (int, error) { if pw.itemID != "" && isDownloadCancelled(pw.itemID) { return 0, ErrDownloadCancelled diff --git a/go_backend/qobuz.go b/go_backend/qobuz.go index 2e1d7e64..22174483 100644 --- a/go_backend/qobuz.go +++ b/go_backend/qobuz.go @@ -112,8 +112,6 @@ func qobuzSplitArtists(artists string) []string { return result } -// qobuzSameWordsUnordered checks if two strings have the same words regardless of order -// Useful for Japanese names: "Sawano Hiroyuki" vs "Hiroyuki Sawano" func qobuzSameWordsUnordered(a, b string) bool { wordsA := strings.Fields(a) wordsB := strings.Fields(b) @@ -194,7 +192,6 @@ func qobuzTitlesMatch(expectedTitle, foundTitle string) bool { return false } -// qobuzExtractCoreTitle extracts the main title before any parentheses or brackets func qobuzExtractCoreTitle(title string) string { // Find first occurrence of ( or [ parenIdx := strings.Index(title, "(") @@ -281,9 +278,6 @@ func qobuzCleanTitle(title string) string { return strings.TrimSpace(cleaned) } -// qobuzIsLatinScript checks if a string is primarily Latin script -// Returns true for ASCII and Latin Extended characters (European languages) -// Returns false for CJK, Arabic, Cyrillic, etc. func qobuzIsLatinScript(s string) bool { for _, r := range s { // Skip common punctuation and numbers @@ -312,18 +306,6 @@ func qobuzIsLatinScript(s string) bool { return true } -// qobuzIsASCIIString checks if a string contains only ASCII characters -// Kept for potential future use -// func qobuzIsASCIIString(s string) bool { -// for _, r := range s { -// if r > 127 { -// return false -// } -// } -// return true -// } - -// containsQueryQobuz checks if a query already exists in the list func containsQueryQobuz(queries []string, query string) bool { for _, q := range queries { if q == query { @@ -371,8 +353,6 @@ func (q *QobuzDownloader) GetTrackByID(trackID int64) (*QobuzTrack, error) { return &track, nil } -// GetAvailableAPIs returns list of available Qobuz APIs -// Uses same APIs as PC version for compatibility func (q *QobuzDownloader) GetAvailableAPIs() []string { // Same APIs as PC version (referensi/backend/qobuz.go) // Primary: dab.yeet.su, Fallback: dabmusic.xyz, qobuz.squid.wtf @@ -394,7 +374,6 @@ func (q *QobuzDownloader) GetAvailableAPIs() []string { return apis } -// mapJumoQuality maps Qobuz quality codes to Jumo format func mapJumoQuality(quality string) int { switch quality { case "6": @@ -408,7 +387,6 @@ func mapJumoQuality(quality string) int { } } -// decodeXOR decodes XOR-encoded response from Jumo API func decodeXOR(data []byte) string { text := string(data) runes := []rune(text) @@ -420,7 +398,6 @@ func decodeXOR(data []byte) string { return string(result) } -// downloadFromJumo gets download URL from Jumo API (fallback) func (q *QobuzDownloader) downloadFromJumo(trackID int64, quality string) (string, error) { formatID := mapJumoQuality(quality) region := "US" @@ -933,7 +910,6 @@ func (q *QobuzDownloader) GetDownloadURL(trackID int64, quality string) (string, return "", fmt.Errorf("all Qobuz APIs and Jumo fallback failed: %w", err) } -// DownloadFile downloads a file from URL with User-Agent and progress tracking func (q *QobuzDownloader) DownloadFile(downloadURL, outputPath, itemID string) error { ctx := context.Background() diff --git a/go_backend/songlink.go b/go_backend/songlink.go index 0fc526f9..e9f40773 100644 --- a/go_backend/songlink.go +++ b/go_backend/songlink.go @@ -43,33 +43,6 @@ func NewSongLinkClient() *SongLinkClient { } func (s *SongLinkClient) CheckTrackAvailability(spotifyTrackID string, isrc string) (*TrackAvailability, error) { - if spotifyTrackID == "" { - return nil, fmt.Errorf("spotify track ID is empty") - } - - // Try SongLink first - availability, err := s.checkTrackAvailabilitySongLink(spotifyTrackID) - if err != nil { - // Fallback to IDHS if SongLink fails - LogWarn("SongLink", "SongLink failed, trying IDHS fallback: %v", err) - idhsClient := NewIDHSClient() - availability, err = idhsClient.GetAvailabilityFromSpotify(spotifyTrackID) - if err != nil { - return nil, fmt.Errorf("both SongLink and IDHS failed: %w", err) - } - LogInfo("SongLink", "IDHS fallback successful for %s", spotifyTrackID) - } - - // Check Qobuz availability separately via ISRC - if isrc != "" { - availability.Qobuz = checkQobuzAvailability(isrc) - } - - return availability, nil -} - -// checkTrackAvailabilitySongLink is the original SongLink implementation -func (s *SongLinkClient) checkTrackAvailabilitySongLink(spotifyTrackID string) (*TrackAvailability, error) { songLinkRateLimiter.WaitForSlot() spotifyBase, _ := base64.StdEncoding.DecodeString("aHR0cHM6Ly9vcGVuLnNwb3RpZnkuY29tL3RyYWNrLw==") @@ -227,10 +200,8 @@ type AlbumAvailability struct { } func (s *SongLinkClient) CheckAlbumAvailability(spotifyAlbumID string) (*AlbumAvailability, error) { - // Use global rate limiter songLinkRateLimiter.WaitForSlot() - // Build API URL for album spotifyBase, _ := base64.StdEncoding.DecodeString("aHR0cHM6Ly9vcGVuLnNwb3RpZnkuY29tL2FsYnVtLw==") spotifyURL := fmt.Sprintf("%s%s", string(spotifyBase), spotifyAlbumID) @@ -301,10 +272,8 @@ func (s *SongLinkClient) CheckAvailabilityFromDeezer(deezerTrackID string) (*Tra return nil, fmt.Errorf("deezer track ID is empty") } - // Try SongLink first availability, err := s.checkAvailabilityFromDeezerSongLink(deezerTrackID) if err != nil { - // Fallback to IDHS if SongLink fails LogWarn("SongLink", "SongLink failed for Deezer, trying IDHS fallback: %v", err) idhsClient := NewIDHSClient() availability, err = idhsClient.GetAvailabilityFromDeezer(deezerTrackID) @@ -338,7 +307,6 @@ func (s *SongLinkClient) checkAvailabilityFromDeezerSongLink(deezerTrackID strin } defer resp.Body.Close() - // Handle specific error codes if resp.StatusCode == 400 { return nil, fmt.Errorf("track not found on SongLink (invalid Deezer ID)") } @@ -407,11 +375,8 @@ func (s *SongLinkClient) CheckAvailabilityByPlatform(platform, entityType, entit return nil, fmt.Errorf("%s ID is empty", platform) } - // Use global rate limiter songLinkRateLimiter.WaitForSlot() - // Build API URL using platform, type, and id parameters (as per API docs) - // https://api.song.link/v1-alpha.1/links?platform=deezer&type=song&id=123456 apiURL := fmt.Sprintf("https://api.song.link/v1-alpha.1/links?platform=%s&type=%s&id=%s&userCountry=US", url.QueryEscape(platform), url.QueryEscape(entityType), @@ -429,7 +394,6 @@ func (s *SongLinkClient) CheckAvailabilityByPlatform(platform, entityType, entit } defer resp.Body.Close() - // Handle specific error codes if resp.StatusCode == 400 { return nil, fmt.Errorf("track not found on SongLink (invalid %s ID)", platform) } diff --git a/go_backend/spotify.go b/go_backend/spotify.go index d776768a..a81151ef 100644 --- a/go_backend/spotify.go +++ b/go_backend/spotify.go @@ -66,7 +66,6 @@ var ( // ErrNoSpotifyCredentials is returned when Spotify credentials are not configured var ErrNoSpotifyCredentials = errors.New("Spotify credentials not configured. Please set your own Client ID and Secret in Settings, or use Deezer as metadata source (free, no credentials required)") -// SetSpotifyCredentials sets custom Spotify API credentials func SetSpotifyCredentials(clientID, clientSecret string) { credentialsMu.Lock() defer credentialsMu.Unlock() @@ -89,7 +88,6 @@ func HasSpotifyCredentials() bool { return false } -// getCredentials returns the current credentials or error if not configured func getCredentials() (string, string, error) { credentialsMu.RLock() defer credentialsMu.RUnlock() diff --git a/go_backend/tidal.go b/go_backend/tidal.go index 598b458f..4af36d3f 100644 --- a/go_backend/tidal.go +++ b/go_backend/tidal.go @@ -119,7 +119,6 @@ func NewTidalDownloader() *TidalDownloader { return globalTidalDownloader } -// GetAvailableAPIs returns list of available Tidal APIs func (t *TidalDownloader) GetAvailableAPIs() []string { encodedAPIs := []string{ "dGlkYWwtYXBpLmJpbmltdW0ub3Jn", // tidal-api.binimum.org (priority) @@ -251,7 +250,6 @@ func (t *TidalDownloader) GetTrackIDFromURL(tidalURL string) (int64, error) { return trackID, nil } -// GetTrackInfoByID gets track info by Tidal track ID func (t *TidalDownloader) GetTrackInfoByID(trackID int64) (*TidalTrack, error) { token, err := t.GetAccessToken() if err != nil { @@ -797,7 +795,6 @@ func parseManifest(manifestB64 string) (directURL string, initURL string, mediaU return "", initURL, mediaURLs, nil } -// DownloadFile downloads a file from URL with progress tracking func (t *TidalDownloader) DownloadFile(downloadURL, outputPath, itemID string) error { ctx := context.Background() @@ -1104,6 +1101,7 @@ type TidalDownloadResult struct { TrackNumber int DiscNumber int ISRC string + LyricsLRC string // LRC content for embedding in converted files } func artistsMatch(spotifyArtist, tidalArtist string) bool { @@ -1683,14 +1681,24 @@ func downloadFromTidal(req DownloadRequest) (TidalDownloadResult, error) { if quality == "HIGH" { GoLog("[Tidal] HIGH quality M4A - skipping metadata embedding (file from server is already valid)\n") - // Only save external LRC file for lyrics + // Handle lyrics based on lyricsMode setting if req.EmbedLyrics && parallelResult != nil && parallelResult.LyricsLRC != "" { - GoLog("[Tidal] Saving external LRC file for M4A...\n") - if lrcPath, lrcErr := SaveLRCFile(actualOutputPath, parallelResult.LyricsLRC); lrcErr != nil { - GoLog("[Tidal] Warning: failed to save LRC file: %v\n", lrcErr) - } else { - GoLog("[Tidal] LRC file saved: %s\n", lrcPath) + lyricsMode := req.LyricsMode + if lyricsMode == "" { + lyricsMode = "embed" // default } + + // Save external LRC file if mode is "external" or "both" + if lyricsMode == "external" || lyricsMode == "both" { + GoLog("[Tidal] Saving external LRC file for M4A (mode: %s)...\n", lyricsMode) + if lrcPath, lrcErr := SaveLRCFile(actualOutputPath, parallelResult.LyricsLRC); lrcErr != nil { + GoLog("[Tidal] Warning: failed to save LRC file: %v\n", lrcErr) + } else { + GoLog("[Tidal] LRC file saved: %s\n", lrcPath) + } + } + // Note: For "embed" or "both" modes, LyricsLRC will be returned to Flutter + // for embedding into the converted MP3/Opus file } } else { fmt.Println("[Tidal] Skipping metadata embedding for M4A file (will be handled after FFmpeg conversion)") @@ -1702,10 +1710,21 @@ func downloadFromTidal(req DownloadRequest) (TidalDownloadResult, error) { // For HIGH quality (AAC), set appropriate values bitDepth := downloadInfo.BitDepth sampleRate := downloadInfo.SampleRate + lyricsLRC := "" if quality == "HIGH" { // AAC 320kbps doesn't have traditional bit depth bitDepth = 0 sampleRate = 44100 + // Return lyrics for Flutter to embed in converted MP3/Opus + if parallelResult != nil && parallelResult.LyricsLRC != "" { + lyricsMode := req.LyricsMode + if lyricsMode == "" { + lyricsMode = "embed" + } + if lyricsMode == "embed" || lyricsMode == "both" { + lyricsLRC = parallelResult.LyricsLRC + } + } } return TidalDownloadResult{ @@ -1719,5 +1738,6 @@ func downloadFromTidal(req DownloadRequest) (TidalDownloadResult, error) { TrackNumber: actualTrackNumber, DiscNumber: actualDiscNumber, ISRC: track.ISRC, + LyricsLRC: lyricsLRC, }, nil } diff --git a/lib/constants/app_info.dart b/lib/constants/app_info.dart index d82f0856..30df8638 100644 --- a/lib/constants/app_info.dart +++ b/lib/constants/app_info.dart @@ -1,8 +1,8 @@ /// App version and info constants /// Update version here only - all other files will reference this class AppInfo { -static const String version = '3.3.1'; - static const String buildNumber = '68'; +static const String version = '3.3.5'; + static const String buildNumber = '70'; static const String fullVersion = '$version+$buildNumber'; diff --git a/lib/providers/download_queue_provider.dart b/lib/providers/download_queue_provider.dart index 773fea44..5f2f0703 100644 --- a/lib/providers/download_queue_provider.dart +++ b/lib/providers/download_queue_provider.dart @@ -150,15 +150,12 @@ class DownloadHistoryState { .map((item) => MapEntry(item.isrc!, item)), ); - /// O(1) check if spotify_id exists bool isDownloaded(String spotifyId) => _downloadedSpotifyIds.contains(spotifyId); - - /// O(1) lookup by spotify_id + DownloadHistoryItem? getBySpotifyId(String spotifyId) => _bySpotifyId[spotifyId]; - - /// O(1) lookup by ISRC + DownloadHistoryItem? getByIsrc(String isrc) => _byIsrc[isrc]; @@ -177,7 +174,6 @@ class DownloadHistoryNotifier extends Notifier { return DownloadHistoryState(); } - /// Synchronously schedule load - ensures it runs before any UI renders void _loadFromDatabaseSync() { if (_isLoaded) return; _isLoaded = true; @@ -193,7 +189,6 @@ class DownloadHistoryNotifier extends Notifier { _historyLog.i('Migrated history from SharedPreferences to SQLite'); } - // Migrate iOS paths if container UUID changed after app update if (Platform.isIOS) { final pathsMigrated = await _db.migrateIosContainerPaths(); if (pathsMigrated) { @@ -264,12 +259,10 @@ class DownloadHistoryNotifier extends Notifier { return state.getBySpotifyId(spotifyId); } - /// O(1) lookup by ISRC DownloadHistoryItem? getByIsrc(String isrc) { return state.getByIsrc(isrc); } - /// Async version with database lookup (for cases where in-memory might be stale) Future getBySpotifyIdAsync(String spotifyId) async { final inMemory = state.getBySpotifyId(spotifyId); if (inMemory != null) return inMemory; @@ -286,7 +279,6 @@ class DownloadHistoryNotifier extends Notifier { }); } - /// Get database stats for debugging Future getDatabaseCount() async { return await _db.getCount(); } @@ -722,7 +714,6 @@ class DownloadQueueNotifier extends Notifier { final isSingle = track.isSingle; final artistName = _sanitizeFolderName(albumArtist); - // New option: Singles folder inside Artist folder if (albumFolderStructure == 'artist_album_singles') { if (isSingle) { final singlesPath = '$baseDir${Platform.pathSeparator}$artistName${Platform.pathSeparator}Singles'; @@ -736,7 +727,6 @@ class DownloadQueueNotifier extends Notifier { } } - // Existing behavior: Separate Albums/ and Singles/ at root if (isSingle) { final singlesPath = '$baseDir${Platform.pathSeparator}Singles'; await _ensureDirExists(singlesPath, label: 'Singles folder'); @@ -804,7 +794,6 @@ class DownloadQueueNotifier extends Notifier { .trim(); } - /// Extract year from release date (format: "2005-06-13" or "2005") String? _extractYear(String? releaseDate) { if (releaseDate == null || releaseDate.isEmpty) return null; final match = _yearRegex.firstMatch(releaseDate); @@ -1075,7 +1064,6 @@ class DownloadQueueNotifier extends Notifier { } } - /// Same logic as Go backend cover.go String _upgradeToMaxQualityCover(String coverUrl) { const spotifySize300 = 'ab67616d00001e02'; const spotifySize640 = 'ab67616d0000b273'; @@ -1192,7 +1180,6 @@ class DownloadQueueNotifier extends Notifier { durationMs: durationMs, ); - // Skip instrumental tracks (no lyrics to embed) if (lrcContent.isNotEmpty && lrcContent != '[instrumental:true]') { metadata['LYRICS'] = lrcContent; metadata['UNSYNCEDLYRICS'] = lrcContent; @@ -1323,7 +1310,11 @@ class DownloadQueueNotifier extends Notifier { _log.d('MP3 Metadata map content: $metadata'); - if (settings.embedLyrics) { + final lyricsMode = settings.lyricsMode; + final shouldEmbed = lyricsMode == 'embed' || lyricsMode == 'both'; + final shouldSaveExternal = lyricsMode == 'external' || lyricsMode == 'both'; + + if (settings.embedLyrics && (shouldEmbed || shouldSaveExternal)) { try { final durationMs = track.duration * 1000; @@ -1336,12 +1327,24 @@ class DownloadQueueNotifier extends Notifier { ); if (lrcContent.isNotEmpty) { - metadata['LYRICS'] = lrcContent; - metadata['UNSYNCEDLYRICS'] = lrcContent; - _log.d('Lyrics fetched for MP3 embedding (${lrcContent.length} chars)'); + if (shouldEmbed) { + metadata['LYRICS'] = lrcContent; + metadata['UNSYNCEDLYRICS'] = lrcContent; + _log.d('Lyrics fetched for MP3 embedding (${lrcContent.length} chars)'); + } + + if (shouldSaveExternal) { + try { + final lrcPath = mp3Path.replaceAll(RegExp(r'\.mp3$', caseSensitive: false), '.lrc'); + await File(lrcPath).writeAsString(lrcContent); + _log.d('External LRC file saved: $lrcPath'); + } catch (e) { + _log.w('Failed to save external LRC file: $e'); + } + } } } catch (e) { - _log.w('Failed to fetch lyrics for MP3 embedding: $e'); + _log.w('Failed to fetch lyrics for MP3: $e'); } } @@ -1461,7 +1464,12 @@ class DownloadQueueNotifier extends Notifier { _log.d('Opus Metadata map content: $metadata'); - if (settings.embedLyrics) { + // Handle lyrics based on lyricsMode setting + final lyricsMode = settings.lyricsMode; + final shouldEmbed = lyricsMode == 'embed' || lyricsMode == 'both'; + final shouldSaveExternal = lyricsMode == 'external' || lyricsMode == 'both'; + + if (settings.embedLyrics && (shouldEmbed || shouldSaveExternal)) { try { final durationMs = track.duration * 1000; @@ -1474,11 +1482,25 @@ class DownloadQueueNotifier extends Notifier { ); if (lrcContent.isNotEmpty) { - metadata['LYRICS'] = lrcContent; - _log.d('Lyrics fetched for Opus embedding (${lrcContent.length} chars)'); + // Embed lyrics in file metadata if mode is 'embed' or 'both' + if (shouldEmbed) { + metadata['LYRICS'] = lrcContent; + _log.d('Lyrics fetched for Opus embedding (${lrcContent.length} chars)'); + } + + // Save external LRC file if mode is 'external' or 'both' + if (shouldSaveExternal) { + try { + final lrcPath = opusPath.replaceAll(RegExp(r'\.opus$', caseSensitive: false), '.lrc'); + await File(lrcPath).writeAsString(lrcContent); + _log.d('External LRC file saved: $lrcPath'); + } catch (e) { + _log.w('Failed to save external LRC file: $e'); + } + } } } catch (e) { - _log.w('Failed to fetch lyrics for Opus embedding: $e'); + _log.w('Failed to fetch lyrics for Opus: $e'); } } @@ -1805,7 +1827,6 @@ class DownloadQueueNotifier extends Notifier { final quality = item.qualityOverride ?? state.audioQuality; -// Fetch extended metadata (genre, label) from Deezer if available String? genre; String? label; @@ -2148,7 +2169,7 @@ class DownloadQueueNotifier extends Notifier { } catch (e) { _log.w('FFmpeg conversion process failed: $e, keeping M4A file'); } - } // end else (not HIGH quality) + } } final itemAfterDownload = state.items.firstWhere( diff --git a/lib/screens/album_screen.dart b/lib/screens/album_screen.dart index 28f0ab6f..7235a26b 100644 --- a/lib/screens/album_screen.dart +++ b/lib/screens/album_screen.dart @@ -92,10 +92,15 @@ class _AlbumScreenState extends ConsumerState { ); }); - _tracks = widget.tracks ?? _AlbumCache.get(widget.albumId); + // Use provided tracks if not empty, otherwise try cache + if (widget.tracks != null && widget.tracks!.isNotEmpty) { + _tracks = widget.tracks; + } else { + _tracks = _AlbumCache.get(widget.albumId); + } _artistId = widget.artistId; // Use provided artist ID if available - if (_tracks == null) { + if (_tracks == null || _tracks!.isEmpty) { _fetchTracks(); } diff --git a/lib/services/ffmpeg_service.dart b/lib/services/ffmpeg_service.dart index 6b7ad4fa..5c0bae15 100644 --- a/lib/services/ffmpeg_service.dart +++ b/lib/services/ffmpeg_service.dart @@ -9,8 +9,6 @@ import 'package:spotiflac_android/utils/logger.dart'; final _log = AppLogger('FFmpeg'); -/// FFmpeg service for audio conversion and remuxing -/// Uses ffmpeg_kit_flutter_new_audio plugin class FFmpegService { static Future _execute(String command) async { try { @@ -48,16 +46,12 @@ class FFmpegService { return null; } - /// Convert M4A (AAC) to lossy format (MP3 or Opus) - /// format: 'mp3' or 'opus' - /// bitrate: e.g., 'mp3_320', 'opus_128' - extracts the kbps value static Future convertM4aToLossy( String inputPath, { required String format, String? bitrate, bool deleteOriginal = true, }) async { - // Extract bitrate value from format like 'mp3_320' -> '320k' String bitrateValue = format == 'opus' ? '128k' : '320k'; if (bitrate != null && bitrate.contains('_')) { final parts = bitrate.split('_'); @@ -71,11 +65,9 @@ class FFmpegService { String command; if (format == 'opus') { - // M4A -> Opus conversion command = '-i "$inputPath" -codec:a libopus -b:a $bitrateValue -vbr on -compression_level 10 -map 0:a "$outputPath" -y'; } else { - // M4A -> MP3 conversion command = '-i "$inputPath" -codec:a libmp3lame -b:a $bitrateValue -map 0:a -id3v2_version 3 "$outputPath" -y'; } @@ -127,7 +119,6 @@ class FFmpegService { }) async { final outputPath = inputPath.replaceAll('.flac', '.opus'); - // Opus in OGG container with VBR final command = '-i "$inputPath" -codec:a libopus -b:a $bitrate -vbr on -compression_level 10 -map 0:a -map_metadata 0 "$outputPath" -y'; @@ -146,17 +137,13 @@ class FFmpegService { return null; } - /// Convert FLAC to lossy format based on format parameter - /// format: 'mp3' or 'opus' - /// bitrate: e.g., 'mp3_320', 'opus_128' - extracts the kbps value static Future convertFlacToLossy( String inputPath, { required String format, String? bitrate, bool deleteOriginal = true, }) async { - // Extract bitrate value from format like 'mp3_320' -> '320k' - String bitrateValue = '320k'; // default for mp3 + String bitrateValue = '320k'; if (bitrate != null && bitrate.contains('_')) { final parts = bitrate.split('_'); if (parts.length == 2) { @@ -385,8 +372,6 @@ class FFmpegService { return null; } - /// Embed metadata to Opus file - /// Uses METADATA_BLOCK_PICTURE tag for cover art (OGG/Vorbis standard) static Future embedMetadataToOpus({ required String opusPath, String? coverPath, @@ -401,7 +386,6 @@ class FFmpegService { cmdBuffer.write('-map 0:a '); cmdBuffer.write('-c:a copy '); - // Embed metadata tags (Vorbis comments) if (metadata != null) { metadata.forEach((key, value) { final sanitizedValue = value.replaceAll('"', '\\"'); @@ -409,12 +393,10 @@ class FFmpegService { }); } - // Embed cover art using METADATA_BLOCK_PICTURE if (coverPath != null) { try { final pictureBlock = await _createMetadataBlockPicture(coverPath); if (pictureBlock != null) { - // Escape special characters for shell final escapedBlock = pictureBlock.replaceAll('"', '\\"'); cmdBuffer.write('-metadata METADATA_BLOCK_PICTURE="$escapedBlock" '); _log.d('Created METADATA_BLOCK_PICTURE for Opus (${pictureBlock.length} chars)'); @@ -471,19 +453,6 @@ class FFmpegService { return null; } - /// Create METADATA_BLOCK_PICTURE base64 string for OGG/Opus cover art - /// Format follows FLAC picture block specification: - /// - 4 bytes: picture type (3 = front cover) - /// - 4 bytes: MIME type length - /// - n bytes: MIME type string - /// - 4 bytes: description length - /// - n bytes: description string - /// - 4 bytes: width - /// - 4 bytes: height - /// - 4 bytes: color depth - /// - 4 bytes: colors used (0 for non-indexed) - /// - 4 bytes: picture data length - /// - n bytes: picture data static Future _createMetadataBlockPicture(String imagePath) async { try { final file = File(imagePath); @@ -494,7 +463,6 @@ class FFmpegService { final imageData = await file.readAsBytes(); - // Detect MIME type from file extension or magic bytes String mimeType; if (imagePath.toLowerCase().endsWith('.png')) { mimeType = 'image/png'; @@ -502,7 +470,6 @@ class FFmpegService { imagePath.toLowerCase().endsWith('.jpeg')) { mimeType = 'image/jpeg'; } else { - // Check magic bytes if (imageData.length >= 8 && imageData[0] == 0x89 && imageData[1] == 0x50 && imageData[2] == 0x4E && imageData[3] == 0x47) { @@ -511,75 +478,61 @@ class FFmpegService { imageData[0] == 0xFF && imageData[1] == 0xD8) { mimeType = 'image/jpeg'; } else { - mimeType = 'image/jpeg'; // Default to JPEG + mimeType = 'image/jpeg'; } } final mimeBytes = utf8.encode(mimeType); - const description = ''; // Empty description + const description = ''; final descBytes = utf8.encode(description); - // Build the FLAC picture block - // Total size: 4 + 4 + mimeLen + 4 + descLen + 4 + 4 + 4 + 4 + 4 + imageLen final blockSize = 4 + 4 + mimeBytes.length + 4 + descBytes.length + 4 + 4 + 4 + 4 + 4 + imageData.length; final buffer = ByteData(blockSize); var offset = 0; - // Picture type: 3 = Front cover buffer.setUint32(offset, 3, Endian.big); offset += 4; - // MIME type length buffer.setUint32(offset, mimeBytes.length, Endian.big); offset += 4; - // MIME type string final blockBytes = Uint8List(blockSize); blockBytes.setRange(0, offset, buffer.buffer.asUint8List()); blockBytes.setRange(offset, offset + mimeBytes.length, mimeBytes); offset += mimeBytes.length; - // Description length final tempBuffer = ByteData(4); tempBuffer.setUint32(0, descBytes.length, Endian.big); blockBytes.setRange(offset, offset + 4, tempBuffer.buffer.asUint8List()); offset += 4; - // Description string blockBytes.setRange(offset, offset + descBytes.length, descBytes); offset += descBytes.length; - // Width (0 = unknown) tempBuffer.setUint32(0, 0, Endian.big); blockBytes.setRange(offset, offset + 4, tempBuffer.buffer.asUint8List()); offset += 4; - // Height (0 = unknown) tempBuffer.setUint32(0, 0, Endian.big); blockBytes.setRange(offset, offset + 4, tempBuffer.buffer.asUint8List()); offset += 4; - // Color depth (0 = unknown) tempBuffer.setUint32(0, 0, Endian.big); blockBytes.setRange(offset, offset + 4, tempBuffer.buffer.asUint8List()); offset += 4; - // Colors used (0 for non-indexed) tempBuffer.setUint32(0, 0, Endian.big); blockBytes.setRange(offset, offset + 4, tempBuffer.buffer.asUint8List()); offset += 4; - // Picture data length tempBuffer.setUint32(0, imageData.length, Endian.big); blockBytes.setRange(offset, offset + 4, tempBuffer.buffer.asUint8List()); offset += 4; - // Picture data blockBytes.setRange(offset, offset + imageData.length, imageData); - // Base64 encode the entire block final base64String = base64Encode(blockBytes); return base64String; @@ -596,7 +549,6 @@ class FFmpegService { final key = entry.key.toUpperCase(); final value = entry.value; - // Map Vorbis comments to ID3v2 frame names switch (key) { case 'TITLE': id3Map['title'] = value; @@ -630,7 +582,6 @@ class FFmpegService { id3Map['lyrics'] = value; break; default: - // Pass through other tags as-is id3Map[key.toLowerCase()] = value; } } diff --git a/lib/utils/logger.dart b/lib/utils/logger.dart index 0208d070..1b676c36 100644 --- a/lib/utils/logger.dart +++ b/lib/utils/logger.dart @@ -10,7 +10,7 @@ class LogEntry { final String tag; final String message; final String? error; - final bool isFromGo; // Track if this log came from Go backend + final bool isFromGo; LogEntry({ required this.timestamp, @@ -47,8 +47,6 @@ class LogBuffer extends ChangeNotifier { Timer? _goLogTimer; int _lastGoLogIndex = 0; - /// Whether logging is enabled (controlled by settings) - /// User must enable "Detailed Logging" in settings to capture logs static bool _loggingEnabled = false; static bool get loggingEnabled => _loggingEnabled; static set loggingEnabled(bool value) { @@ -64,7 +62,6 @@ class LogBuffer extends ChangeNotifier { int get length => _entries.length; void add(LogEntry entry) { - // Skip adding if logging is disabled (except for errors which are always logged) if (!_loggingEnabled && entry.level != 'ERROR' && entry.level != 'FATAL') { return; } @@ -76,7 +73,6 @@ class LogBuffer extends ChangeNotifier { notifyListeners(); } - /// Start polling Go backend logs void startGoLogPolling() { _goLogTimer?.cancel(); _goLogTimer = Timer.periodic(const Duration(milliseconds: 500), (_) async { @@ -84,13 +80,11 @@ class LogBuffer extends ChangeNotifier { }); } - /// Stop polling Go backend logs void stopGoLogPolling() { _goLogTimer?.cancel(); _goLogTimer = null; } - /// Fetch logs from Go backend since last index Future _fetchGoLogs() async { try { final result = await PlatformBridge.getGoLogsSince(_lastGoLogIndex); @@ -103,7 +97,6 @@ class LogBuffer extends ChangeNotifier { final tag = log['tag'] as String? ?? 'Go'; final message = log['message'] as String? ?? ''; - // Parse timestamp (format: "15:04:05.000") DateTime parsedTime = DateTime.now(); if (timestamp.isNotEmpty) { try { @@ -221,7 +214,6 @@ class BufferedOutput extends LogOutput { } } -/// Global logger instance for the app final log = Logger( printer: PrettyPrinter( methodCount: 0, diff --git a/lib/widgets/download_service_picker.dart b/lib/widgets/download_service_picker.dart index ef485998..2a59dd95 100644 --- a/lib/widgets/download_service_picker.dart +++ b/lib/widgets/download_service_picker.dart @@ -218,8 +218,13 @@ class _DownloadServicePickerState extends ConsumerState { subtitle: quality.description ?? '', icon: _getQualityIcon(quality.id), onTap: () { - Navigator.pop(context); - widget.onSelect(quality.id, _selectedService); + // For Tidal HIGH quality, show format picker first + if (_selectedService == 'tidal' && quality.id == 'HIGH') { + _showLossyFormatPicker(context); + } else { + Navigator.pop(context); + widget.onSelect(quality.id, _selectedService); + } }, ), @@ -250,6 +255,102 @@ class _DownloadServicePickerState extends ConsumerState { return Icons.music_note; } } + + void _showLossyFormatPicker(BuildContext context) { + final colorScheme = Theme.of(context).colorScheme; + final settings = ref.read(settingsProvider); + final currentFormat = settings.tidalHighFormat; + + showModalBottomSheet( + context: context, + backgroundColor: colorScheme.surfaceContainerHigh, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.vertical(top: Radius.circular(28)), + ), + builder: (modalContext) => SafeArea( + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const SizedBox(height: 8), + Center( + child: Container( + width: 40, + height: 4, + decoration: BoxDecoration( + color: colorScheme.onSurfaceVariant.withValues(alpha: 0.4), + borderRadius: BorderRadius.circular(2), + ), + ), + ), + Padding( + padding: const EdgeInsets.fromLTRB(24, 16, 24, 8), + child: Text( + 'Select Lossy Format', + style: Theme.of(context).textTheme.titleLarge?.copyWith( + fontWeight: FontWeight.bold, + ), + ), + ), + Padding( + padding: const EdgeInsets.fromLTRB(24, 0, 24, 16), + child: Text( + 'Choose output format for 320kbps lossy download', + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + color: colorScheme.onSurfaceVariant, + ), + ), + ), + ListTile( + contentPadding: const EdgeInsets.symmetric(horizontal: 24), + leading: Container( + padding: const EdgeInsets.all(10), + decoration: BoxDecoration( + color: colorScheme.primaryContainer, + borderRadius: BorderRadius.circular(12), + ), + child: Icon(Icons.audiotrack, color: colorScheme.onPrimaryContainer, size: 20), + ), + title: const Text('MP3 320kbps'), + subtitle: const Text('Best compatibility, ~10MB per track'), + trailing: currentFormat == 'mp3_320' + ? Icon(Icons.check_circle, color: colorScheme.primary) + : null, + onTap: () { + ref.read(settingsProvider.notifier).setTidalHighFormat('mp3_320'); + Navigator.pop(modalContext); // Close format picker + Navigator.pop(context); // Close service picker + widget.onSelect('HIGH', _selectedService); + }, + ), + ListTile( + contentPadding: const EdgeInsets.symmetric(horizontal: 24), + leading: Container( + padding: const EdgeInsets.all(10), + decoration: BoxDecoration( + color: colorScheme.primaryContainer, + borderRadius: BorderRadius.circular(12), + ), + child: Icon(Icons.graphic_eq, color: colorScheme.onPrimaryContainer, size: 20), + ), + title: const Text('Opus 128kbps'), + subtitle: const Text('Modern codec, ~4MB per track'), + trailing: currentFormat == 'opus_128' + ? Icon(Icons.check_circle, color: colorScheme.primary) + : null, + onTap: () { + ref.read(settingsProvider.notifier).setTidalHighFormat('opus_128'); + Navigator.pop(modalContext); // Close format picker + Navigator.pop(context); // Close service picker + widget.onSelect('HIGH', _selectedService); + }, + ), + const SizedBox(height: 16), + ], + ), + ), + ); + } } diff --git a/pubspec.yaml b/pubspec.yaml index c5b17031..a8ea5b0a 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,7 +1,7 @@ name: spotiflac_android description: Download Spotify tracks in FLAC from Tidal, Qobuz & Amazon Music publish_to: "none" -version: 3.3.1+68 +version: 3.3.5+70 environment: sdk: ^3.10.0