From 03027813c1932d9b84b0ec67ff2b2d2aaa969267 Mon Sep 17 00:00:00 2001 From: zarzet Date: Tue, 20 Jan 2026 02:10:10 +0700 Subject: [PATCH] chore: cleanup unused code and dead imports --- go_backend/amazon.go | 25 +-- go_backend/cover.go | 17 +- go_backend/deezer.go | 40 +--- go_backend/duplicate.go | 6 - go_backend/exports.go | 157 ++++------------ go_backend/extension_manager.go | 26 +-- go_backend/extension_manifest.go | 20 -- go_backend/extension_runtime.go | 29 +-- go_backend/extension_runtime_auth.go | 29 +-- go_backend/extension_runtime_ffmpeg.go | 6 - go_backend/extension_runtime_file.go | 40 +--- go_backend/extension_runtime_http.go | 16 -- go_backend/extension_runtime_storage.go | 4 - go_backend/extension_runtime_utils.go | 3 - go_backend/extension_settings.go | 4 - go_backend/extension_store.go | 2 - go_backend/filename.go | 4 - go_backend/httputil.go | 46 ----- go_backend/logbuffer.go | 4 - go_backend/lyrics.go | 35 +--- go_backend/metadata.go | 31 +-- go_backend/parallel.go | 40 +--- go_backend/progress.go | 24 +-- go_backend/qobuz.go | 42 +---- go_backend/ratelimit.go | 8 - go_backend/romaji.go | 11 -- go_backend/songlink.go | 15 -- go_backend/spotify.go | 73 ++------ go_backend/tidal.go | 176 ++---------------- lib/main.dart | 2 - lib/models/download_item.dart | 15 +- lib/models/settings.dart | 86 ++++----- lib/models/theme_settings.dart | 6 - lib/models/track.dart | 13 +- lib/providers/download_queue_provider.dart | 99 +++------- lib/providers/extension_provider.dart | 55 ++---- lib/providers/recent_access_provider.dart | 2 - lib/providers/settings_provider.dart | 3 - lib/providers/store_provider.dart | 7 - lib/providers/theme_provider.dart | 1 - lib/providers/track_provider.dart | 19 +- lib/screens/album_screen.dart | 13 +- lib/screens/artist_screen.dart | 4 - lib/screens/downloaded_album_screen.dart | 4 - lib/screens/home_tab.dart | 44 +---- lib/screens/main_shell.dart | 6 - lib/screens/playlist_screen.dart | 2 - lib/screens/queue_tab.dart | 10 - lib/screens/settings/about_page.dart | 6 +- .../settings/appearance_settings_page.dart | 6 +- .../settings/download_settings_page.dart | 2 +- lib/screens/setup_screen.dart | 2 +- lib/screens/store_tab.dart | 2 +- lib/screens/track_metadata_screen.dart | 10 +- lib/services/cover_cache_manager.dart | 6 - lib/services/csv_import_service.dart | 4 - lib/services/ffmpeg_service.dart | 19 +- lib/services/platform_bridge.dart | 124 +----------- lib/services/share_intent_service.dart | 10 - lib/theme/app_theme.dart | 11 -- lib/utils/logger.dart | 7 - lib/widgets/cached_cover_image.dart | 6 - 62 files changed, 213 insertions(+), 1326 deletions(-) diff --git a/go_backend/amazon.go b/go_backend/amazon.go index cb130bd4..c16d30eb 100644 --- a/go_backend/amazon.go +++ b/go_backend/amazon.go @@ -17,13 +17,12 @@ import ( "time" ) -// AmazonDownloader handles Amazon Music downloads using DoubleDouble service (same as PC) type AmazonDownloader struct { client *http.Client - regions []string // us, eu regions for DoubleDouble service - lastAPICallTime time.Time // Rate limiting: track last API call - apiCallCount int // Rate limiting: counter per minute - apiCallResetTime time.Time // Rate limiting: reset time + regions []string + lastAPICallTime time.Time + apiCallCount int + apiCallResetTime time.Time } var ( @@ -38,7 +37,6 @@ type DoubleDoubleSubmitResponse struct { ID string `json:"id"` } -// DoubleDoubleStatusResponse is the response from DoubleDouble status endpoint type DoubleDoubleStatusResponse struct { Status string `json:"status"` FriendlyStatus string `json:"friendlyStatus"` @@ -49,7 +47,6 @@ type DoubleDoubleStatusResponse struct { } `json:"current"` } -// amazonArtistsMatch checks if the artist names are similar enough func amazonArtistsMatch(expectedArtist, foundArtist string) bool { normExpected := strings.ToLower(strings.TrimSpace(expectedArtist)) normFound := strings.ToLower(strings.TrimSpace(foundArtist)) @@ -90,7 +87,6 @@ func amazonArtistsMatch(expectedArtist, foundArtist string) bool { return false } -// amazonIsASCIIString checks if a string contains only ASCII characters func amazonIsASCIIString(s string) bool { for _, r := range s { if r > 127 { @@ -100,7 +96,6 @@ func amazonIsASCIIString(s string) bool { return true } -// NewAmazonDownloader creates a new Amazon downloader (returns singleton for connection reuse) func NewAmazonDownloader() *AmazonDownloader { amazonDownloaderOnce.Do(func() { globalAmazonDownloader = &AmazonDownloader{ @@ -113,7 +108,6 @@ func NewAmazonDownloader() *AmazonDownloader { } // waitForRateLimit implements rate limiting similar to PC version -// Max 9 requests per minute with 7 second delay between requests func (a *AmazonDownloader) waitForRateLimit() { amazonRateLimitMu.Lock() defer amazonRateLimitMu.Unlock() @@ -125,7 +119,6 @@ func (a *AmazonDownloader) waitForRateLimit() { a.apiCallResetTime = now } - // If we've hit the limit (9 requests per minute), wait until next minute if a.apiCallCount >= 9 { waitTime := time.Minute - now.Sub(a.apiCallResetTime) if waitTime > 0 { @@ -136,7 +129,6 @@ func (a *AmazonDownloader) waitForRateLimit() { } } - // Add delay between requests (7 seconds like PC version) if !a.lastAPICallTime.IsZero() { timeSinceLastCall := now.Sub(a.lastAPICallTime) minDelay := 7 * time.Second @@ -151,7 +143,6 @@ func (a *AmazonDownloader) waitForRateLimit() { a.apiCallCount++ } -// GetAvailableAPIs returns list of available DoubleDouble regions // Uses same service as PC version (doubledouble.top) func (a *AmazonDownloader) GetAvailableAPIs() []string { // DoubleDouble service regions (same as PC) @@ -176,11 +167,9 @@ func (a *AmazonDownloader) downloadFromDoubleDoubleService(amazonURL, _ string) serviceDomain, _ := base64.StdEncoding.DecodeString("LmRvdWJsZWRvdWJsZS50b3A=") // .doubledouble.top baseURL := fmt.Sprintf("%s%s%s", string(serviceBase), region, string(serviceDomain)) - // Step 1: Submit download request with rate limiting encodedURL := url.QueryEscape(amazonURL) submitURL := fmt.Sprintf("%s/dl?url=%s", baseURL, encodedURL) - // Apply rate limiting before request (like PC version) a.waitForRateLimit() req, err := http.NewRequest("GET", submitURL, nil) @@ -334,7 +323,6 @@ func (a *AmazonDownloader) downloadFromDoubleDoubleService(amazonURL, _ string) return "", "", "", fmt.Errorf("all regions failed. Last error: %v", lastError) } -// DownloadFile downloads a file from URL with User-Agent and progress tracking func (a *AmazonDownloader) DownloadFile(downloadURL, outputPath, itemID string) error { ctx := context.Background() @@ -434,7 +422,6 @@ type AmazonDownloadResult struct { ISRC string } -// downloadFromAmazon downloads a track using the request parameters // Uses DoubleDouble service (same as PC version) func downloadFromAmazon(req DownloadRequest) (AmazonDownloadResult, error) { downloader := NewAmazonDownloader() @@ -580,15 +567,12 @@ func downloadFromAmazon(req DownloadRequest) (AmazonDownloadResult, error) { fmt.Printf("Warning: failed to embed metadata: %v\n", err) } - // Handle lyrics based on LyricsMode setting - // Mode: "embed" (default), "external" (.lrc file), "both" if req.EmbedLyrics && parallelResult != nil && parallelResult.LyricsLRC != "" { lyricsMode := req.LyricsMode if lyricsMode == "" { lyricsMode = "embed" // default } - // Save external .lrc file if mode is "external" or "both" if lyricsMode == "external" || lyricsMode == "both" { GoLog("[Amazon] Saving external LRC file...\n") if lrcPath, lrcErr := SaveLRCFile(outputPath, parallelResult.LyricsLRC); lrcErr != nil { @@ -598,7 +582,6 @@ func downloadFromAmazon(req DownloadRequest) (AmazonDownloadResult, error) { } } - // Embed lyrics if mode is "embed" or "both" if lyricsMode == "embed" || lyricsMode == "both" { GoLog("[Amazon] Embedding parallel-fetched lyrics (%d lines)...\n", len(parallelResult.LyricsData.Lines)) if embedErr := EmbedLyrics(outputPath, parallelResult.LyricsLRC); embedErr != nil { diff --git a/go_backend/cover.go b/go_backend/cover.go index 88d4d29b..fd0ed822 100644 --- a/go_backend/cover.go +++ b/go_backend/cover.go @@ -8,18 +8,15 @@ import ( "strings" ) -// Spotify image size codes (same as PC version) const ( - spotifySize300 = "ab67616d00001e02" // 300x300 (small) - spotifySize640 = "ab67616d0000b273" // 640x640 (medium) - spotifySizeMax = "ab67616d000082c1" // Max resolution (~2000x2000) + spotifySize300 = "ab67616d00001e02" + spotifySize640 = "ab67616d0000b273" + spotifySizeMax = "ab67616d000082c1" ) // Deezer CDN supports these sizes: 56, 250, 500, 1000, 1400, 1800 var deezerSizeRegex = regexp.MustCompile(`/(\d+)x(\d+)-\d+-\d+-\d+-\d+\.jpg$`) -// convertSmallToMedium upgrades 300x300 cover URL to 640x640 -// Same logic as PC version for consistency func convertSmallToMedium(imageURL string) string { if strings.Contains(imageURL, spotifySize300) { return strings.Replace(imageURL, spotifySize300, spotifySize640, 1) @@ -27,8 +24,6 @@ func convertSmallToMedium(imageURL string) string { return imageURL } -// downloadCoverToMemory downloads cover art and returns as bytes (no file creation) -// This avoids file permission issues on Android func downloadCoverToMemory(coverURL string, maxQuality bool) ([]byte, error) { if coverURL == "" { return nil, fmt.Errorf("no cover URL provided") @@ -90,8 +85,6 @@ func downloadCoverToMemory(coverURL string, maxQuality bool) ([]byte, error) { return data, nil } -// upgradeToMaxQuality upgrades cover URL to maximum quality -// Supports both Spotify and Deezer CDNs func upgradeToMaxQuality(coverURL string) string { // Spotify CDN upgrade if strings.Contains(coverURL, spotifySize640) { @@ -106,9 +99,6 @@ func upgradeToMaxQuality(coverURL string) string { return coverURL } -// upgradeDeezerCover upgrades Deezer cover URL to maximum quality (1800x1800) -// Deezer CDN format: https://cdn-images.dzcdn.net/images/cover/{hash}/{size}x{size}-000000-80-0-0.jpg -// Available sizes: 56, 250, 500, 1000, 1400, 1800 func upgradeDeezerCover(coverURL string) string { if !strings.Contains(coverURL, "cdn-images.dzcdn.net") { return coverURL @@ -122,7 +112,6 @@ func upgradeDeezerCover(coverURL string) string { return upgraded } -// GetCoverFromSpotify gets cover URL from Spotify metadata func GetCoverFromSpotify(imageURL string, maxQuality bool) string { if imageURL == "" { return "" diff --git a/go_backend/deezer.go b/go_backend/deezer.go index 9b6ff111..cb82a7f4 100644 --- a/go_backend/deezer.go +++ b/go_backend/deezer.go @@ -25,13 +25,12 @@ const ( deezerMaxParallelISRC = 10 ) -// DeezerClient handles Deezer API interactions (no auth required) type DeezerClient struct { httpClient *http.Client searchCache map[string]*cacheEntry albumCache map[string]*cacheEntry artistCache map[string]*cacheEntry - isrcCache map[string]string // trackID -> ISRC cache + isrcCache map[string]string cacheMu sync.RWMutex } @@ -40,7 +39,6 @@ var ( deezerClientOnce sync.Once ) -// GetDeezerClient returns singleton Deezer client func GetDeezerClient() *DeezerClient { deezerClientOnce.Do(func() { deezerClient = &DeezerClient{ @@ -54,7 +52,6 @@ func GetDeezerClient() *DeezerClient { return deezerClient } -// Deezer API response types type deezerTrack struct { ID int64 `json:"id"` Title string `json:"title"` @@ -63,7 +60,7 @@ type deezerTrack struct { DiskNumber int `json:"disk_number"` ISRC string `json:"isrc"` Link string `json:"link"` - ReleaseDate string `json:"release_date"` // Sometimes at track level + ReleaseDate string `json:"release_date"` Artist deezerArtist `json:"artist"` Album deezerAlbumSimple `json:"album"` Contributors []deezerArtist `json:"contributors"` @@ -86,8 +83,8 @@ type deezerAlbumSimple struct { CoverMedium string `json:"cover_medium"` CoverBig string `json:"cover_big"` CoverXL string `json:"cover_xl"` - ReleaseDate string `json:"release_date"` // Sometimes at album level - RecordType string `json:"record_type"` // album, single, ep, compile + ReleaseDate string `json:"release_date"` + RecordType string `json:"record_type"` } func (c *DeezerClient) convertTrack(track deezerTrack) TrackMetadata { @@ -146,8 +143,8 @@ type deezerAlbumFull struct { CoverXL string `json:"cover_xl"` ReleaseDate string `json:"release_date"` NbTracks int `json:"nb_tracks"` - RecordType string `json:"record_type"` // album, single, ep, compile - Label string `json:"label"` // Record label name + RecordType string `json:"record_type"` + Label string `json:"label"` Genres struct { Data []deezerGenre `json:"data"` } `json:"genres"` @@ -185,7 +182,6 @@ type deezerPlaylistFull struct { } `json:"tracks"` } -// SearchAll searches for tracks and artists on Deezer // NOTE: ISRC is NOT fetched during search for performance - use GetTrackISRC when needed for download func (c *DeezerClient) SearchAll(ctx context.Context, query string, trackLimit, artistLimit int) (*SearchAllResult, error) { GoLog("[Deezer] SearchAll: query=%q, trackLimit=%d, artistLimit=%d\n", query, trackLimit, artistLimit) @@ -230,11 +226,9 @@ func (c *DeezerClient) SearchAll(ctx context.Context, query string, trackLimit, GoLog("[Deezer] Got %d tracks from API\n", len(trackResp.Data)) for _, track := range trackResp.Data { - // Convert directly without fetching ISRC - much faster result.Tracks = append(result.Tracks, c.convertTrack(track)) } - // Search artists artistURL := fmt.Sprintf("%s/artist?q=%s&limit=%d", deezerSearchURL, url.QueryEscape(query), artistLimit) GoLog("[Deezer] Fetching artists from: %s\n", artistURL) @@ -267,7 +261,6 @@ func (c *DeezerClient) SearchAll(ctx context.Context, query string, trackLimit, GoLog("[Deezer] SearchAll complete: %d tracks, %d artists\n", len(result.Tracks), len(result.Artists)) - // Cache result c.cacheMu.Lock() c.searchCache[cacheKey] = &cacheEntry{ data: result, @@ -292,7 +285,6 @@ func (c *DeezerClient) GetTrack(ctx context.Context, trackID string) (*TrackResp }, nil } -// GetAlbum fetches album with tracks // ISRC is fetched in parallel for better performance func (c *DeezerClient) GetAlbum(ctx context.Context, albumID string) (*AlbumResponsePayload, error) { c.cacheMu.RLock() @@ -338,7 +330,6 @@ func (c *DeezerClient) GetAlbum(ctx context.Context, albumID string) (*AlbumResp Label: album.Label, // From Deezer album } - // Fetch ISRCs in parallel isrcMap := c.fetchISRCsParallel(ctx, album.Tracks.Data) tracks := make([]AlbumTrackMetadata, 0, len(album.Tracks.Data)) @@ -386,7 +377,6 @@ func (c *DeezerClient) GetAlbum(ctx context.Context, albumID string) (*AlbumResp return result, nil } -// GetArtist fetches artist with albums func (c *DeezerClient) GetArtist(ctx context.Context, artistID string) (*ArtistResponsePayload, error) { c.cacheMu.RLock() if entry, ok := c.artistCache[artistID]; ok && !entry.isExpired() { @@ -472,8 +462,6 @@ func (c *DeezerClient) GetArtist(ctx context.Context, artistID string) (*ArtistR return result, nil } -// GetPlaylist fetches playlist with tracks -// ISRC is fetched in parallel for better performance func (c *DeezerClient) GetPlaylist(ctx context.Context, playlistID string) (*PlaylistResponsePayload, error) { playlistURL := fmt.Sprintf(deezerPlaylistURL, playlistID) @@ -496,7 +484,6 @@ func (c *DeezerClient) GetPlaylist(ctx context.Context, playlistID string) (*Pla info.Owner.Name = playlist.Title info.Owner.Images = playlistImage - // Fetch ISRCs in parallel isrcMap := c.fetchISRCsParallel(ctx, playlist.Tracks.Data) tracks := make([]AlbumTrackMetadata, 0, len(playlist.Tracks.Data)) @@ -535,15 +522,11 @@ func (c *DeezerClient) GetPlaylist(ctx context.Context, playlistID string) (*Pla }, nil } -// SearchByISRC searches for a track by ISRC using direct endpoint func (c *DeezerClient) SearchByISRC(ctx context.Context, isrc string) (*TrackMetadata, error) { - // Use direct ISRC endpoint (API 2.0) - // https://api.deezer.com/2.0/track/isrc:{ISRC} directURL := fmt.Sprintf("%s/track/isrc:%s", deezerBaseURL, isrc) var track deezerTrack if err := c.getJSON(ctx, directURL, &track); err != nil { - // Fallback to search if direct endpoint fails searchURL := fmt.Sprintf("%s/track?q=isrc:%s&limit=1", deezerSearchURL, isrc) var resp struct { Data []deezerTrack `json:"data"` @@ -623,7 +606,6 @@ func (c *DeezerClient) fetchISRCsParallel(ctx context.Context, tracks []deezerTr go func(t deezerTrack) { defer wg.Done() - // Acquire semaphore select { case sem <- struct{}{}: defer func() { <-sem }() @@ -652,7 +634,6 @@ func (c *DeezerClient) fetchISRCsParallel(ctx context.Context, tracks []deezerTr return result } -// GetTrackISRC fetches ISRC for a single track (with caching) // Use this when you need ISRC for download func (c *DeezerClient) GetTrackISRC(ctx context.Context, trackID string) (string, error) { c.cacheMu.RLock() @@ -662,13 +643,11 @@ func (c *DeezerClient) GetTrackISRC(ctx context.Context, trackID string) (string } c.cacheMu.RUnlock() - // Fetch from API fullTrack, err := c.fetchFullTrack(ctx, trackID) if err != nil { return "", err } - // Cache the result c.cacheMu.Lock() c.isrcCache[trackID] = fullTrack.ISRC c.cacheMu.Unlock() @@ -715,20 +694,17 @@ func (c *DeezerClient) getBestAlbumImage(album deezerAlbumFull) string { return album.Cover } -// AlbumExtendedMetadata contains genre and label information from an album type AlbumExtendedMetadata struct { Genre string // Comma-separated list of genres Label string // Record label name } -// GetAlbumExtendedMetadata fetches genre and label from a Deezer album // Uses the album ID from a track to fetch extended metadata func (c *DeezerClient) GetAlbumExtendedMetadata(ctx context.Context, albumID string) (*AlbumExtendedMetadata, error) { if albumID == "" { return nil, fmt.Errorf("empty album ID") } - // Check cache first cacheKey := fmt.Sprintf("album_meta:%s", albumID) c.cacheMu.RLock() if entry, ok := c.searchCache[cacheKey]; ok && !entry.isExpired() { @@ -744,7 +720,6 @@ func (c *DeezerClient) GetAlbumExtendedMetadata(ctx context.Context, albumID str return nil, fmt.Errorf("failed to fetch album: %w", err) } - // Extract genres as comma-separated string var genres []string for _, g := range album.Genres.Data { if g.Name != "" { @@ -757,7 +732,6 @@ func (c *DeezerClient) GetAlbumExtendedMetadata(ctx context.Context, albumID str Label: album.Label, } - // Cache the result c.cacheMu.Lock() c.searchCache[cacheKey] = &cacheEntry{ data: result, @@ -782,7 +756,6 @@ func (c *DeezerClient) GetTrackAlbumID(ctx context.Context, trackID string) (str return fmt.Sprintf("%d", track.Album.ID), nil } -// GetExtendedMetadataByTrackID fetches genre and label using a Deezer track ID // This is a convenience function that first gets the album ID, then fetches album metadata func (c *DeezerClient) GetExtendedMetadataByTrackID(ctx context.Context, trackID string) (*AlbumExtendedMetadata, error) { albumID, err := c.GetTrackAlbumID(ctx, trackID) @@ -837,7 +810,6 @@ func parseDeezerURL(input string) (string, string, error) { parts := strings.Split(strings.Trim(parsed.Path, "/"), "/") - // Skip language prefix if present (e.g., /en/, /fr/) if len(parts) > 0 && len(parts[0]) == 2 { parts = parts[1:] } diff --git a/go_backend/duplicate.go b/go_backend/duplicate.go index 6d31705e..15e80370 100644 --- a/go_backend/duplicate.go +++ b/go_backend/duplicate.go @@ -158,7 +158,6 @@ func checkISRCExistsInternal(outputDir, isrc string) (string, bool) { return "", false } - // Use index for fast lookup idx := GetISRCIndex(outputDir) filePath, exists := idx.lookup(isrc) if !exists { @@ -175,7 +174,6 @@ func checkISRCExistsInternal(outputDir, isrc string) (string, bool) { } // CheckISRCExists is the exported version for gomobile (returns string, error) -// Returns the filepath if exists, empty string if not func CheckISRCExists(outputDir, isrc string) (string, error) { filepath, _ := checkISRCExistsInternal(outputDir, isrc) return filepath, nil @@ -199,9 +197,6 @@ type FileExistenceResult struct { ArtistName string `json:"artist_name,omitempty"` } -// CheckFilesExistParallel checks if multiple files exist in parallel -// It builds an ISRC index from the output directory once, then checks all tracks against it -// Same implementation as PC version for consistency func CheckFilesExistParallel(outputDir string, tracksJSON string) (string, error) { var tracks []struct { ISRC string `json:"isrc"` @@ -266,7 +261,6 @@ func PreBuildISRCIndex(outputDir string) error { } // AddToISRCIndex adds a new file to the ISRC index after successful download -// This avoids rebuilding the entire index func AddToISRCIndex(outputDir, isrc, filePath string) { if outputDir == "" || isrc == "" || filePath == "" { return diff --git a/go_backend/exports.go b/go_backend/exports.go index d5268f56..17112d93 100644 --- a/go_backend/exports.go +++ b/go_backend/exports.go @@ -13,8 +13,6 @@ import ( "github.com/dop251/goja" ) -// ParseSpotifyURL parses and validates a Spotify URL -// Returns JSON with type (track/album/playlist) and ID func ParseSpotifyURL(url string) (string, error) { parsed, err := parseSpotifyURI(url) if err != nil { @@ -34,19 +32,14 @@ func ParseSpotifyURL(url string) (string, error) { return string(jsonBytes), nil } -// SetSpotifyAPICredentials sets custom Spotify API credentials from Flutter func SetSpotifyAPICredentials(clientID, clientSecret string) { SetSpotifyCredentials(clientID, clientSecret) } -// CheckSpotifyCredentials checks if Spotify credentials are configured -// Returns true if credentials are available (custom or env vars) func CheckSpotifyCredentials() bool { return HasSpotifyCredentials() } -// GetSpotifyMetadata fetches metadata from Spotify URL -// Returns JSON with track/album/playlist data func GetSpotifyMetadata(spotifyURL string) (string, error) { ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) defer cancel() @@ -68,8 +61,6 @@ func GetSpotifyMetadata(spotifyURL string) (string, error) { return string(jsonBytes), nil } -// SearchSpotify searches for tracks on Spotify -// Returns JSON array of track results func SearchSpotify(query string, limit int) (string, error) { ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) defer cancel() @@ -91,8 +82,6 @@ func SearchSpotify(query string, limit int) (string, error) { return string(jsonBytes), nil } -// SearchSpotifyAll searches for tracks and artists on Spotify -// Returns JSON with tracks and artists arrays func SearchSpotifyAll(query string, trackLimit, artistLimit int) (string, error) { ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) defer cancel() @@ -114,8 +103,6 @@ func SearchSpotifyAll(query string, trackLimit, artistLimit int) (string, error) return string(jsonBytes), nil } -// CheckAvailability checks track availability on streaming services -// Returns JSON with availability info for Tidal, Qobuz, Amazon func CheckAvailability(spotifyID, isrc string) (string, error) { client := NewSongLinkClient() availability, err := client.CheckTrackAvailability(spotifyID, isrc) @@ -131,7 +118,6 @@ func CheckAvailability(spotifyID, isrc string) (string, error) { return string(jsonBytes), nil } -// DownloadRequest represents a download request from Flutter type DownloadRequest struct { ISRC string `json:"isrc"` Service string `json:"service"` @@ -143,58 +129,51 @@ type DownloadRequest struct { CoverURL string `json:"cover_url"` OutputDir string `json:"output_dir"` FilenameFormat string `json:"filename_format"` - Quality string `json:"quality"` // LOSSLESS, HI_RES, HI_RES_LOSSLESS + Quality string `json:"quality"` EmbedLyrics bool `json:"embed_lyrics"` EmbedMaxQualityCover bool `json:"embed_max_quality_cover"` TrackNumber int `json:"track_number"` DiscNumber int `json:"disc_number"` TotalTracks int `json:"total_tracks"` ReleaseDate string `json:"release_date"` - ItemID string `json:"item_id"` // Unique ID for progress tracking - DurationMS int `json:"duration_ms"` // Expected duration in milliseconds (for verification) - Source string `json:"source"` // Extension ID that provided this track (prioritize this extension) - // Extended metadata from Deezer for FLAC tagging - Genre string `json:"genre,omitempty"` // Music genre(s), comma-separated - Label string `json:"label,omitempty"` // Record label name - Copyright string `json:"copyright,omitempty"` // Copyright information - // Enriched IDs from Odesli/song.link - used to skip search and directly fetch - TidalID string `json:"tidal_id,omitempty"` - QobuzID string `json:"qobuz_id,omitempty"` - DeezerID string `json:"deezer_id,omitempty"` - // Lyrics mode: "embed" (default), "external" (.lrc file), "both" - LyricsMode string `json:"lyrics_mode,omitempty"` + ItemID string `json:"item_id"` + DurationMS int `json:"duration_ms"` + Source string `json:"source"` + Genre string `json:"genre,omitempty"` + Label string `json:"label,omitempty"` + Copyright string `json:"copyright,omitempty"` + TidalID string `json:"tidal_id,omitempty"` + QobuzID string `json:"qobuz_id,omitempty"` + DeezerID string `json:"deezer_id,omitempty"` + 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" - AlreadyExists bool `json:"already_exists,omitempty"` - // Actual quality info from the source - ActualBitDepth int `json:"actual_bit_depth,omitempty"` - ActualSampleRate int `json:"actual_sample_rate,omitempty"` - Service string `json:"service,omitempty"` // Actual service used (for fallback) - Title string `json:"title,omitempty"` - Artist string `json:"artist,omitempty"` - Album string `json:"album,omitempty"` - AlbumArtist string `json:"album_artist,omitempty"` - ReleaseDate string `json:"release_date,omitempty"` - TrackNumber int `json:"track_number,omitempty"` - DiscNumber int `json:"disc_number,omitempty"` - ISRC string `json:"isrc,omitempty"` - CoverURL string `json:"cover_url,omitempty"` - // Extended metadata for FLAC tagging (passed to Flutter for M4A->FLAC conversion) - Genre string `json:"genre,omitempty"` // Music genre(s) - Label string `json:"label,omitempty"` // Record label - Copyright string `json:"copyright,omitempty"` // Copyright info - // If true, skip metadata enrichment from Deezer/Spotify (extension already provides metadata) - SkipMetadataEnrichment bool `json:"skip_metadata_enrichment,omitempty"` + 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" + 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) + Title string `json:"title,omitempty"` + Artist string `json:"artist,omitempty"` + Album string `json:"album,omitempty"` + AlbumArtist string `json:"album_artist,omitempty"` + ReleaseDate string `json:"release_date,omitempty"` + TrackNumber int `json:"track_number,omitempty"` + DiscNumber int `json:"disc_number,omitempty"` + ISRC string `json:"isrc,omitempty"` + CoverURL string `json:"cover_url,omitempty"` + Genre string `json:"genre,omitempty"` + Label string `json:"label,omitempty"` + Copyright string `json:"copyright,omitempty"` + SkipMetadataEnrichment bool `json:"skip_metadata_enrichment,omitempty"` } -// DownloadResult is a generic result type for all downloaders type DownloadResult struct { FilePath string BitDepth int @@ -208,9 +187,6 @@ type DownloadResult struct { ISRC string } -// DownloadTrack downloads a track from the specified service -// requestJSON is a JSON string of DownloadRequest -// Returns JSON string of DownloadResponse func DownloadTrack(requestJSON string) (string, error) { var req DownloadRequest if err := json.Unmarshal([]byte(requestJSON), &req); err != nil { @@ -224,7 +200,6 @@ func DownloadTrack(requestJSON string) (string, error) { req.AlbumArtist = strings.TrimSpace(req.AlbumArtist) req.OutputDir = strings.TrimSpace(req.OutputDir) - // Add output directory to allowed download dirs for extensions if req.OutputDir != "" { AddAllowedDownloadDir(req.OutputDir) } @@ -348,22 +323,18 @@ func DownloadTrack(requestJSON string) (string, error) { return string(jsonBytes), nil } -// DownloadWithFallback tries to download from services in order -// Starts with the preferred service from request, then tries others func DownloadWithFallback(requestJSON string) (string, error) { var req DownloadRequest if err := json.Unmarshal([]byte(requestJSON), &req); err != nil { return errorResponse("Invalid request: " + err.Error()) } - // Trim whitespace from string fields to prevent filename/path issues req.TrackName = strings.TrimSpace(req.TrackName) req.ArtistName = strings.TrimSpace(req.ArtistName) req.AlbumName = strings.TrimSpace(req.AlbumName) req.AlbumArtist = strings.TrimSpace(req.AlbumArtist) req.OutputDir = strings.TrimSpace(req.OutputDir) - // Add output directory to allowed download dirs for extensions if req.OutputDir != "" { AddAllowedDownloadDir(req.OutputDir) } @@ -520,47 +491,36 @@ func DownloadWithFallback(requestJSON string) (string, error) { return errorResponse("All services failed. Last error: " + lastErr.Error()) } -// GetDownloadProgress returns current download progress func GetDownloadProgress() string { progress := getProgress() jsonBytes, _ := json.Marshal(progress) return string(jsonBytes) } -// GetAllDownloadProgress returns progress for all active downloads (concurrent mode) func GetAllDownloadProgress() string { return GetMultiProgress() } -// InitItemProgress initializes progress tracking for a download item func InitItemProgress(itemID string) { StartItemProgress(itemID) } -// FinishItemProgress marks a download item as complete and removes tracking func FinishItemProgress(itemID string) { CompleteItemProgress(itemID) } -// ClearItemProgress removes progress tracking for a specific item func ClearItemProgress(itemID string) { RemoveItemProgress(itemID) } -// CancelDownload cancels an in-progress download for the given item. func CancelDownload(itemID string) { cancelDownload(itemID) } -// CleanupConnections closes idle HTTP connections -// Call this periodically during large batch downloads to prevent TCP exhaustion func CleanupConnections() { CloseIdleConnections() } -// ReadFileMetadata reads metadata directly from a FLAC file -// Returns JSON with all embedded metadata (title, artist, album, track number, etc.) -// This is useful for displaying accurate metadata in the UI without relying on cached data func ReadFileMetadata(filePath string) (string, error) { metadata, err := ReadMetadata(filePath) if err != nil { @@ -600,12 +560,10 @@ func ReadFileMetadata(filePath string) (string, error) { return string(jsonBytes), nil } -// SetDownloadDirectory sets the default download directory func SetDownloadDirectory(path string) error { return setDownloadDir(path) } -// CheckDuplicate checks if a file with the given ISRC exists func CheckDuplicate(outputDir, isrc string) (string, error) { existingFile, exists := CheckISRCExists(outputDir, isrc) @@ -622,26 +580,18 @@ func CheckDuplicate(outputDir, isrc string) (string, error) { return string(jsonBytes), nil } -// CheckDuplicatesBatch checks multiple files for duplicates in parallel -// Uses ISRC index for fast lookup (builds index once, checks all tracks) -// tracksJSON format: [{"isrc": "...", "track_name": "...", "artist_name": "..."}, ...] -// Returns JSON array of results func CheckDuplicatesBatch(outputDir, tracksJSON string) (string, error) { return CheckFilesExistParallel(outputDir, tracksJSON) } -// PreBuildDuplicateIndex pre-builds the ISRC index for a directory -// Call this when entering album/playlist screen for faster duplicate checking func PreBuildDuplicateIndex(outputDir string) error { return PreBuildISRCIndex(outputDir) } -// InvalidateDuplicateIndex clears the ISRC index cache for a directory func InvalidateDuplicateIndex(outputDir string) { InvalidateISRCCache(outputDir) } -// BuildFilename builds a filename from template and metadata func BuildFilename(template string, metadataJSON string) (string, error) { var metadata map[string]interface{} if err := json.Unmarshal([]byte(metadataJSON), &metadata); err != nil { @@ -652,14 +602,10 @@ func BuildFilename(template string, metadataJSON string) (string, error) { return filename, nil } -// SanitizeFilename removes invalid characters from filename func SanitizeFilename(filename string) string { return sanitizeFilename(filename) } -// FetchLyrics fetches lyrics for a track from LRCLIB -// Returns JSON with lyrics data -// durationMs: track duration in milliseconds for matching, use 0 to skip duration matching func FetchLyrics(spotifyID, trackName, artistName string, durationMs int64) (string, error) { client := NewLyricsClient() durationSec := float64(durationMs) / 1000.0 @@ -683,9 +629,6 @@ func FetchLyrics(spotifyID, trackName, artistName string, durationMs int64) (str return string(jsonBytes), nil } -// GetLyricsLRC fetches lyrics and converts to LRC format string with metadata headers -// First tries to extract from file, then falls back to fetching from internet -// durationMs: track duration in milliseconds for matching, use 0 to skip duration matching func GetLyricsLRC(spotifyID, trackName, artistName string, filePath string, durationMs int64) (string, error) { if filePath != "" { lyrics, err := ExtractLyrics(filePath) @@ -705,7 +648,6 @@ func GetLyricsLRC(spotifyID, trackName, artistName string, filePath string, dura return lrcContent, nil } -// EmbedLyricsToFile embeds lyrics into an existing FLAC file func EmbedLyricsToFile(filePath, lyrics string) (string, error) { err := EmbedLyrics(filePath, lyrics) if err != nil { @@ -721,9 +663,6 @@ func EmbedLyricsToFile(filePath, lyrics string) (string, error) { return string(jsonBytes), nil } -// PreWarmTrackCacheJSON pre-warms the track ID cache for album/playlist tracks -// tracksJSON is a JSON array of objects with: isrc, track_name, artist_name, spotify_id, service -// This runs in background and returns immediately func PreWarmTrackCacheJSON(tracksJSON string) (string, error) { var tracks []struct { ISRC string `json:"isrc"` @@ -759,20 +698,14 @@ func PreWarmTrackCacheJSON(tracksJSON string) (string, error) { return string(jsonBytes), nil } -// GetTrackCacheSize returns the current track ID cache size func GetTrackCacheSize() int { return GetCacheSize() } -// ClearTrackIDCache clears the track ID cache func ClearTrackIDCache() { ClearTrackCache() } -// ==================== DEEZER API ==================== - -// SearchDeezerAll searches for tracks and artists on Deezer (no API key required) -// Returns JSON with tracks and artists arrays func SearchDeezerAll(query string, trackLimit, artistLimit int) (string, error) { ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) defer cancel() @@ -990,10 +923,6 @@ func GetSpotifyMetadataWithDeezerFallback(spotifyURL string) (string, error) { return "", fmt.Errorf("spotify rate limited. Playlists are user-specific and require Spotify API") } -// ==================== SONGLINK DEEZER SUPPORT ==================== - -// CheckAvailabilityFromDeezerID checks track availability using Deezer track ID as source -// Returns JSON with availability info for Spotify, Tidal, Amazon, etc. func CheckAvailabilityFromDeezerID(deezerTrackID string) (string, error) { client := NewSongLinkClient() availability, err := client.CheckAvailabilityFromDeezer(deezerTrackID) @@ -1177,14 +1106,12 @@ func UpgradeExtensionFromPath(filePath string) (string, error) { return "", err } - // Initialize with saved settings settingsStore := GetExtensionSettingsStore() settings := settingsStore.GetAll(ext.ID) if len(settings) > 0 { manager.InitializeExtension(ext.ID, settings) } - // Return extension info as JSON result := map[string]interface{}{ "id": ext.ID, "display_name": ext.Manifest.DisplayName, @@ -1348,8 +1275,6 @@ func InvokeExtensionActionJSON(extensionID, actionName string) (string, error) { return string(jsonBytes), nil } -// ==================== EXTENSION AUTH API ==================== - // GetExtensionPendingAuthJSON returns pending auth request for an extension func GetExtensionPendingAuthJSON(extensionID string) (string, error) { req := GetPendingAuthRequest(extensionID) @@ -1429,9 +1354,6 @@ func GetAllPendingAuthRequestsJSON() (string, error) { return string(jsonBytes), nil } -// ==================== EXTENSION FFMPEG API ==================== - -// GetPendingFFmpegCommandJSON returns a pending FFmpeg command for Flutter to execute func GetPendingFFmpegCommandJSON(commandID string) (string, error) { cmd := GetPendingFFmpegCommand(commandID) if cmd == nil { @@ -1491,7 +1413,6 @@ func EnrichTrackWithExtensionJSON(extensionID, trackJSON string) (string, error) manager := GetExtensionManager() ext, err := manager.GetExtension(extensionID) if err != nil { - // Extension not found, return original track return trackJSON, nil } @@ -1595,10 +1516,6 @@ func GetSearchProvidersJSON() (string, error) { return string(jsonBytes), nil } -// ==================== EXTENSION URL HANDLER ==================== - -// HandleURLWithExtensionJSON tries to handle a URL with any matching extension -// Returns JSON with type, tracks, album info, etc. func HandleURLWithExtensionJSON(url string) (string, error) { manager := GetExtensionManager() resultWithID, err := manager.HandleURLWithExtension(url) @@ -1860,7 +1777,6 @@ func GetPlaylistWithExtensionJSON(extensionID, playlistID string) (string, error return "", fmt.Errorf("failed to marshal result: %w", err) } - // Parse into album metadata (same structure) var album ExtAlbumMetadata if err := json.Unmarshal(jsonBytes, &album); err != nil { return "", fmt.Errorf("failed to parse playlist: %w", err) @@ -1961,7 +1877,6 @@ func GetArtistWithExtensionJSON(extensionID, artistID string) (string, error) { response["header_image"] = artist.HeaderImage } - // Add listeners if present if artist.Listeners > 0 { response["listeners"] = artist.Listeners } @@ -2019,9 +1934,6 @@ func GetURLHandlersJSON() (string, error) { return string(jsonBytes), nil } -// ==================== EXTENSION POST-PROCESSING ==================== - -// RunPostProcessingJSON runs post-processing hooks on a file func RunPostProcessingJSON(filePath, metadataJSON string) (string, error) { var metadata map[string]interface{} if metadataJSON != "" { @@ -2077,8 +1989,6 @@ func GetPostProcessingProvidersJSON() (string, error) { return string(jsonBytes), nil } -// ==================== EXTENSION STORE ==================== - // InitExtensionStoreJSON initializes the extension store with cache directory func InitExtensionStoreJSON(cacheDir string) error { InitExtensionStore(cacheDir) @@ -2092,7 +2002,6 @@ func GetStoreExtensionsJSON(forceRefresh bool) (string, error) { return "", fmt.Errorf("extension store not initialized") } - // Force refresh if requested if forceRefresh { store.FetchRegistry(true) } diff --git a/go_backend/extension_manager.go b/go_backend/extension_manager.go index 9ab396aa..baa4ba72 100644 --- a/go_backend/extension_manager.go +++ b/go_backend/extension_manager.go @@ -1,4 +1,3 @@ -// Package gobackend provides extension management functionality package gobackend import ( @@ -15,8 +14,6 @@ import ( "github.com/dop251/goja" ) -// compareVersions compares two semantic version strings -// Returns: -1 if v1 < v2, 0 if v1 == v2, 1 if v1 > v2 func compareVersions(v1, v2 string) int { parts1 := strings.Split(strings.TrimPrefix(v1, "v"), ".") parts2 := strings.Split(strings.TrimPrefix(v2, "v"), ".") @@ -46,7 +43,6 @@ func compareVersions(v1, v2 string) int { return 0 } -// LoadedExtension represents an extension that has been loaded into memory type LoadedExtension struct { ID string `json:"id"` Manifest *ExtensionManifest `json:"manifest"` @@ -72,7 +68,6 @@ var ( globalExtManagerOnce sync.Once ) -// GetExtensionManager returns the global extension manager instance func GetExtensionManager() *ExtensionManager { globalExtManagerOnce.Do(func() { globalExtManager = &ExtensionManager{ @@ -82,7 +77,6 @@ func GetExtensionManager() *ExtensionManager { return globalExtManager } -// SetDirectories sets the extensions and data directories func (m *ExtensionManager) SetDirectories(extensionsDir, dataDir string) error { m.mu.Lock() defer m.mu.Unlock() @@ -100,9 +94,7 @@ func (m *ExtensionManager) SetDirectories(extensionsDir, dataDir string) error { return nil } -// LoadExtensionFromFile loads an extension from a .spotiflac-ext file func (m *ExtensionManager) LoadExtensionFromFile(filePath string) (*LoadedExtension, error) { - // Validate file extension if !strings.HasSuffix(strings.ToLower(filePath), ".spotiflac-ext") { return nil, fmt.Errorf("Invalid file format. Please select a .spotiflac-ext file") } @@ -181,14 +173,11 @@ func (m *ExtensionManager) LoadExtensionFromFile(filePath string) (*LoadedExtens return nil, fmt.Errorf("failed to create extension directory: %w", err) } - // Extract all files (preserving directory structure) for _, file := range zipReader.File { if file.FileInfo().IsDir() { continue } - // Preserve relative path within the zip (support subdirectories) - // Clean the path to prevent path traversal attacks relPath := filepath.Clean(file.Name) if strings.HasPrefix(relPath, "..") || filepath.IsAbs(relPath) { GoLog("[Extension] Skipping unsafe path in archive: %s\n", file.Name) @@ -246,7 +235,6 @@ func (m *ExtensionManager) LoadExtensionFromFile(filePath string) (*LoadedExtens return ext, nil } -// initializeVM creates and initializes the Goja VM for an extension func (m *ExtensionManager) initializeVM(ext *LoadedExtension) error { vm := goja.New() ext.VM = vm @@ -323,7 +311,6 @@ func (m *ExtensionManager) UnloadExtension(extensionID string) error { return nil } -// GetExtension returns a loaded extension by ID // Returns error if extension not found (gomobile compatible) func (m *ExtensionManager) GetExtension(extensionID string) (*LoadedExtension, error) { m.mu.RLock() @@ -348,7 +335,6 @@ func (m *ExtensionManager) GetAllExtensions() []*LoadedExtension { return result } -// SetExtensionEnabled enables or disables an extension func (m *ExtensionManager) SetExtensionEnabled(extensionID string, enabled bool) error { m.mu.Lock() defer m.mu.Unlock() @@ -409,7 +395,6 @@ func (m *ExtensionManager) LoadExtensionsFromDirectory(dirPath string) ([]string return loaded, errors } -// loadExtensionFromDirectory loads an extension from an already extracted directory func (m *ExtensionManager) loadExtensionFromDirectory(dirPath string) (*LoadedExtension, error) { m.mu.Lock() defer m.mu.Unlock() @@ -498,7 +483,6 @@ func (m *ExtensionManager) RemoveExtension(extensionID string) error { return nil } -// UpgradeExtension upgrades an existing extension from a new package file // Only allows upgrades (new version > current version), not downgrades func (m *ExtensionManager) UpgradeExtension(filePath string) (*LoadedExtension, error) { // Validate file extension @@ -645,7 +629,6 @@ func (m *ExtensionManager) UpgradeExtension(filePath string) (*LoadedExtension, return ext, nil } -// ExtensionUpgradeInfo holds information about extension upgrade check type ExtensionUpgradeInfo struct { ExtensionID string `json:"extension_id"` CurrentVersion string `json:"current_version"` @@ -717,7 +700,6 @@ func (m *ExtensionManager) checkExtensionUpgradeInternal(filePath string) (*Exte return info, nil } -// CheckExtensionUpgradeJSON checks if a package file is an upgrade and returns JSON func (m *ExtensionManager) CheckExtensionUpgradeJSON(filePath string) (string, error) { info, err := m.checkExtensionUpgradeInternal(filePath) if err != nil { @@ -827,7 +809,6 @@ func (m *ExtensionManager) GetInstalledExtensionsJSON() (string, error) { // ==================== Extension Lifecycle ==================== -// InitializeExtension calls the extension's initialize method with settings func (m *ExtensionManager) InitializeExtension(extensionID string, settings map[string]interface{}) error { m.mu.Lock() defer m.mu.Unlock() @@ -889,7 +870,6 @@ func (m *ExtensionManager) InitializeExtension(extensionID string, settings map[ return nil } -// CleanupExtension calls the extension's cleanup method func (m *ExtensionManager) CleanupExtension(extensionID string) error { m.mu.Lock() defer m.mu.Unlock() @@ -900,10 +880,9 @@ func (m *ExtensionManager) CleanupExtension(extensionID string) error { } if ext.VM == nil { - return nil // No VM, nothing to cleanup + return nil } - // Call cleanup function script := ` (function() { if (typeof extension !== 'undefined' && typeof extension.cleanup === 'function') { @@ -952,16 +931,13 @@ func (m *ExtensionManager) UnloadAllExtensions() { m.mu.Unlock() for _, id := range extensionIDs { - // Call cleanup first m.CleanupExtension(id) - // Then unload m.UnloadExtension(id) } GoLog("[Extension] All extensions unloaded\n") } -// InvokeAction calls a custom action function on an extension (e.g., for button settings) // The function is called as extension.() and can return a result func (m *ExtensionManager) InvokeAction(extensionID string, actionName string) (map[string]interface{}, error) { m.mu.Lock() diff --git a/go_backend/extension_manifest.go b/go_backend/extension_manifest.go index 0a4fce24..7a850a55 100644 --- a/go_backend/extension_manifest.go +++ b/go_backend/extension_manifest.go @@ -151,9 +151,7 @@ func ParseManifest(data []byte) (*ExtensionManifest, error) { return &manifest, nil } -// Validate checks if the manifest has all required fields and valid values func (m *ExtensionManifest) Validate() error { - // Check required fields if strings.TrimSpace(m.Name) == "" { return &ManifestValidationError{Field: "name", Message: "name is required"} } @@ -174,7 +172,6 @@ func (m *ExtensionManifest) Validate() error { return &ManifestValidationError{Field: "type", Message: "at least one type is required"} } - // Validate extension types for _, t := range m.Types { if t != ExtensionTypeMetadataProvider && t != ExtensionTypeDownloadProvider { return &ManifestValidationError{ @@ -200,21 +197,6 @@ func (m *ExtensionManifest) Validate() error { } } - // Validate setting type - validTypes := map[SettingType]bool{ - SettingTypeString: true, - SettingTypeNumber: true, - SettingTypeBool: true, - SettingTypeSelect: true, - SettingTypeButton: true, - } - if !validTypes[setting.Type] { - return &ManifestValidationError{ - Field: fmt.Sprintf("settings[%d].type", i), - Message: fmt.Sprintf("invalid setting type: %s", setting.Type), - } - } - // Select type requires options if setting.Type == SettingTypeSelect && len(setting.Options) == 0 { return &ManifestValidationError{ @@ -223,7 +205,6 @@ func (m *ExtensionManifest) Validate() error { } } - // Button type requires action if setting.Type == SettingTypeButton && setting.Action == "" { return &ManifestValidationError{ Field: fmt.Sprintf("settings[%d].action", i), @@ -300,7 +281,6 @@ func (m *ExtensionManifest) MatchesURL(urlStr string) bool { return false } - // Parse URL to get host urlStr = strings.ToLower(strings.TrimSpace(urlStr)) for _, pattern := range m.URLHandler.Patterns { pattern = strings.ToLower(strings.TrimSpace(pattern)) diff --git a/go_backend/extension_runtime.go b/go_backend/extension_runtime.go index 33de3653..c01d2665 100644 --- a/go_backend/extension_runtime.go +++ b/go_backend/extension_runtime.go @@ -1,4 +1,3 @@ -// Package gobackend provides extension runtime with sandboxed execution package gobackend import ( @@ -17,7 +16,6 @@ var ( extensionAuthStateMu sync.RWMutex ) -// ExtensionAuthState holds auth state for an extension type ExtensionAuthState struct { PendingAuthURL string AuthCode string @@ -30,7 +28,6 @@ type ExtensionAuthState struct { PKCEChallenge string } -// PendingAuthRequest holds a pending OAuth request that needs Flutter to open URL type PendingAuthRequest struct { ExtensionID string AuthURL string @@ -55,7 +52,6 @@ func ClearPendingAuthRequest(extensionID string) { delete(pendingAuthRequests, extensionID) } -// SetExtensionAuthCode sets auth code for an extension (called from Flutter after OAuth callback) func SetExtensionAuthCode(extensionID string, authCode string) { extensionAuthStateMu.Lock() defer extensionAuthStateMu.Unlock() @@ -68,7 +64,6 @@ func SetExtensionAuthCode(extensionID string, authCode string) { state.AuthCode = authCode } -// SetExtensionTokens sets access/refresh tokens for an extension func SetExtensionTokens(extensionID string, accessToken, refreshToken string, expiresAt time.Time) { extensionAuthStateMu.Lock() defer extensionAuthStateMu.Unlock() @@ -84,7 +79,6 @@ func SetExtensionTokens(extensionID string, accessToken, refreshToken string, ex state.IsAuthenticated = accessToken != "" } -// ExtensionRuntime provides sandboxed APIs for extensions type ExtensionRuntime struct { extensionID string manifest *ExtensionManifest @@ -95,7 +89,6 @@ type ExtensionRuntime struct { vm *goja.Runtime } -// NewExtensionRuntime creates a new runtime for an extension func NewExtensionRuntime(ext *LoadedExtension) *ExtensionRuntime { jar, _ := newSimpleCookieJar() @@ -108,7 +101,6 @@ func NewExtensionRuntime(ext *LoadedExtension) *ExtensionRuntime { vm: ext.VM, } - // Create HTTP client with redirect validation to prevent SSRF via open redirect client := &http.Client{ Timeout: 30 * time.Second, Jar: jar, @@ -119,7 +111,6 @@ func NewExtensionRuntime(ext *LoadedExtension) *ExtensionRuntime { GoLog("[Extension:%s] Redirect blocked: domain '%s' not in allowed list\n", ext.ID, domain) return &RedirectBlockedError{Domain: domain} } - // Also block redirects to private/local networks (SSRF protection) if isPrivateIP(domain) { GoLog("[Extension:%s] Redirect blocked: private IP '%s'\n", ext.ID, domain) return &RedirectBlockedError{Domain: domain, IsPrivate: true} @@ -136,7 +127,6 @@ func NewExtensionRuntime(ext *LoadedExtension) *ExtensionRuntime { return runtime } -// RedirectBlockedError is returned when a redirect is blocked due to domain validation type RedirectBlockedError struct { Domain string IsPrivate bool @@ -162,10 +152,10 @@ func isPrivateIP(host string) bool { "172.24.", "172.25.", "172.26.", "172.27.", "172.28.", "172.29.", "172.30.", "172.31.", "192.168.", - "169.254.", // Link-local - "::1", // IPv6 localhost - "fc00:", // IPv6 private - "fe80:", // IPv6 link-local + "169.254.", + "::1", + "fc00:", + "fe80:", } hostLower := host @@ -183,7 +173,6 @@ func isPrivateIP(host string) bool { return false } -// simpleCookieJar is a simple in-memory cookie jar type simpleCookieJar struct { cookies map[string][]*http.Cookie mu sync.RWMutex @@ -208,7 +197,6 @@ func (j *simpleCookieJar) Cookies(u *url.URL) []*http.Cookie { return j.cookies[u.Host] } -// SetSettings updates the runtime settings func (r *ExtensionRuntime) SetSettings(settings map[string]interface{}) { r.settings = settings } @@ -228,7 +216,6 @@ func (r *ExtensionRuntime) RegisterAPIs(vm *goja.Runtime) { httpObj.Set("clearCookies", r.httpClearCookies) vm.Set("http", httpObj) - // Storage API storageObj := vm.NewObject() storageObj.Set("get", r.storageGet) storageObj.Set("set", r.storageSet) @@ -243,7 +230,6 @@ func (r *ExtensionRuntime) RegisterAPIs(vm *goja.Runtime) { credentialsObj.Set("has", r.credentialsHas) vm.Set("credentials", credentialsObj) - // Auth API (for OAuth and other auth flows) authObj := vm.NewObject() authObj.Set("openAuthUrl", r.authOpenUrl) authObj.Set("getAuthCode", r.authGetCode) @@ -270,7 +256,6 @@ func (r *ExtensionRuntime) RegisterAPIs(vm *goja.Runtime) { fileObj.Set("getSize", r.fileGetSize) vm.Set("file", fileObj) - // FFmpeg API (for post-processing) ffmpegObj := vm.NewObject() ffmpegObj.Set("execute", r.ffmpegExecute) ffmpegObj.Set("getInfo", r.ffmpegGetInfo) @@ -284,7 +269,6 @@ func (r *ExtensionRuntime) RegisterAPIs(vm *goja.Runtime) { matchingObj.Set("normalizeString", r.matchingNormalizeString) vm.Set("matching", matchingObj) - // Utilities utilsObj := vm.NewObject() utilsObj.Set("base64Encode", r.base64Encode) utilsObj.Set("base64Decode", r.base64Decode) @@ -310,7 +294,6 @@ func (r *ExtensionRuntime) RegisterAPIs(vm *goja.Runtime) { logObj.Set("error", r.logError) vm.Set("log", logObj) - // Go backend functions gobackendObj := vm.NewObject() gobackendObj.Set("sanitizeFilename", r.sanitizeFilenameWrapper) vm.Set("gobackend", gobackendObj) @@ -321,16 +304,12 @@ func (r *ExtensionRuntime) RegisterAPIs(vm *goja.Runtime) { // Global fetch() - Promise-style HTTP API (browser-compatible) vm.Set("fetch", r.fetchPolyfill) - // Global atob/btoa - Base64 encoding (browser-compatible) vm.Set("atob", r.atobPolyfill) vm.Set("btoa", r.btoaPolyfill) - // TextEncoder/TextDecoder constructors r.registerTextEncoderDecoder(vm) - // URL class for URL parsing r.registerURLClass(vm) - // JSON global (browser-compatible) r.registerJSONGlobal(vm) } diff --git a/go_backend/extension_runtime_auth.go b/go_backend/extension_runtime_auth.go index 4e5102ef..ce63b1d8 100644 --- a/go_backend/extension_runtime_auth.go +++ b/go_backend/extension_runtime_auth.go @@ -18,7 +18,6 @@ import ( // ==================== Auth API (OAuth Support) ==================== -// authOpenUrl requests Flutter to open an OAuth URL func (r *ExtensionRuntime) authOpenUrl(call goja.FunctionCall) goja.Value { if len(call.Arguments) < 1 { return r.vm.ToValue(map[string]interface{}{ @@ -33,7 +32,6 @@ func (r *ExtensionRuntime) authOpenUrl(call goja.FunctionCall) goja.Value { callbackURL = call.Arguments[1].String() } - // Store pending auth request for Flutter to pick up pendingAuthRequestsMu.Lock() pendingAuthRequests[r.extensionID] = &PendingAuthRequest{ ExtensionID: r.extensionID, @@ -42,7 +40,6 @@ func (r *ExtensionRuntime) authOpenUrl(call goja.FunctionCall) goja.Value { } pendingAuthRequestsMu.Unlock() - // Update auth state extensionAuthStateMu.Lock() state, exists := extensionAuthState[r.extensionID] if !exists { @@ -50,7 +47,7 @@ func (r *ExtensionRuntime) authOpenUrl(call goja.FunctionCall) goja.Value { extensionAuthState[r.extensionID] = state } state.PendingAuthURL = authURL - state.AuthCode = "" // Clear any previous auth code + state.AuthCode = "" extensionAuthStateMu.Unlock() GoLog("[Extension:%s] Auth URL requested: %s\n", r.extensionID, authURL) @@ -61,7 +58,6 @@ func (r *ExtensionRuntime) authOpenUrl(call goja.FunctionCall) goja.Value { }) } -// authGetCode gets the auth code (set by Flutter after OAuth callback) func (r *ExtensionRuntime) authGetCode(call goja.FunctionCall) goja.Value { extensionAuthStateMu.RLock() defer extensionAuthStateMu.RUnlock() @@ -114,7 +110,6 @@ func (r *ExtensionRuntime) authSetCode(call goja.FunctionCall) goja.Value { return r.vm.ToValue(true) } -// authClear clears all auth state for the extension func (r *ExtensionRuntime) authClear(call goja.FunctionCall) goja.Value { extensionAuthStateMu.Lock() delete(extensionAuthState, r.extensionID) @@ -138,7 +133,6 @@ func (r *ExtensionRuntime) authIsAuthenticated(call goja.FunctionCall) goja.Valu return r.vm.ToValue(false) } - // Check if token is expired if state.IsAuthenticated && !state.ExpiresAt.IsZero() && time.Now().After(state.ExpiresAt) { return r.vm.ToValue(false) } @@ -146,7 +140,6 @@ func (r *ExtensionRuntime) authIsAuthenticated(call goja.FunctionCall) goja.Valu return r.vm.ToValue(state.IsAuthenticated) } -// authGetTokens returns current tokens (for extension to use in API calls) func (r *ExtensionRuntime) authGetTokens(call goja.FunctionCall) goja.Value { extensionAuthStateMu.RLock() defer extensionAuthStateMu.RUnlock() @@ -182,16 +175,13 @@ func generatePKCEVerifier(length int) (string, error) { length = 128 } - // Generate random bytes bytes := make([]byte, length) if _, err := rand.Read(bytes); err != nil { return "", err } - // Use base64url encoding without padding (RFC 7636 compliant) verifier := base64.RawURLEncoding.EncodeToString(bytes) - // Trim to exact length if len(verifier) > length { verifier = verifier[:length] } @@ -199,15 +189,12 @@ func generatePKCEVerifier(length int) (string, error) { return verifier, nil } -// generatePKCEChallenge generates a code challenge from verifier using S256 method func generatePKCEChallenge(verifier string) string { hash := sha256.Sum256([]byte(verifier)) // Base64url encode without padding (RFC 7636) return base64.RawURLEncoding.EncodeToString(hash[:]) } -// authGeneratePKCE generates a PKCE code verifier and challenge pair -// Returns: { verifier: string, challenge: string, method: "S256" } func (r *ExtensionRuntime) authGeneratePKCE(call goja.FunctionCall) goja.Value { // Default length is 64 characters length := 64 @@ -227,7 +214,6 @@ func (r *ExtensionRuntime) authGeneratePKCE(call goja.FunctionCall) goja.Value { challenge := generatePKCEChallenge(verifier) - // Store in auth state for later use extensionAuthStateMu.Lock() state, exists := extensionAuthState[r.extensionID] if !exists { @@ -247,7 +233,6 @@ func (r *ExtensionRuntime) authGeneratePKCE(call goja.FunctionCall) goja.Value { }) } -// authGetPKCE returns the current PKCE verifier and challenge (if generated) func (r *ExtensionRuntime) authGetPKCE(call goja.FunctionCall) goja.Value { extensionAuthStateMu.RLock() defer extensionAuthStateMu.RUnlock() @@ -405,7 +390,6 @@ func (r *ExtensionRuntime) authExchangeCodeWithPKCE(call goja.FunctionCall) goja }) } - // Get stored PKCE verifier extensionAuthStateMu.RLock() state, exists := extensionAuthState[r.extensionID] var verifier string @@ -421,7 +405,6 @@ func (r *ExtensionRuntime) authExchangeCodeWithPKCE(call goja.FunctionCall) goja }) } - // Validate domain if err := r.validateDomain(tokenURL); err != nil { return r.vm.ToValue(map[string]interface{}{ "success": false, @@ -429,7 +412,6 @@ func (r *ExtensionRuntime) authExchangeCodeWithPKCE(call goja.FunctionCall) goja }) } - // Build token request body formData := url.Values{} formData.Set("grant_type", "authorization_code") formData.Set("client_id", clientID) @@ -439,14 +421,12 @@ func (r *ExtensionRuntime) authExchangeCodeWithPKCE(call goja.FunctionCall) goja formData.Set("redirect_uri", redirectURI) } - // Add extra params if extraParams, ok := config["extraParams"].(map[string]interface{}); ok { for k, v := range extraParams { formData.Set(k, fmt.Sprintf("%v", v)) } } - // Make token request req, err := http.NewRequest("POST", tokenURL, strings.NewReader(formData.Encode())) if err != nil { return r.vm.ToValue(map[string]interface{}{ @@ -475,7 +455,6 @@ func (r *ExtensionRuntime) authExchangeCodeWithPKCE(call goja.FunctionCall) goja }) } - // Parse response var tokenResp map[string]interface{} if err := json.Unmarshal(body, &tokenResp); err != nil { return r.vm.ToValue(map[string]interface{}{ @@ -485,7 +464,6 @@ func (r *ExtensionRuntime) authExchangeCodeWithPKCE(call goja.FunctionCall) goja }) } - // Check for error in response if errMsg, ok := tokenResp["error"].(string); ok { errDesc, _ := tokenResp["error_description"].(string) return r.vm.ToValue(map[string]interface{}{ @@ -495,7 +473,6 @@ func (r *ExtensionRuntime) authExchangeCodeWithPKCE(call goja.FunctionCall) goja }) } - // Extract tokens accessToken, _ := tokenResp["access_token"].(string) refreshToken, _ := tokenResp["refresh_token"].(string) expiresIn, _ := tokenResp["expires_in"].(float64) @@ -508,7 +485,6 @@ func (r *ExtensionRuntime) authExchangeCodeWithPKCE(call goja.FunctionCall) goja }) } - // Store tokens in auth state extensionAuthStateMu.Lock() state, exists = extensionAuthState[r.extensionID] if !exists { @@ -521,14 +497,12 @@ func (r *ExtensionRuntime) authExchangeCodeWithPKCE(call goja.FunctionCall) goja if expiresIn > 0 { state.ExpiresAt = time.Now().Add(time.Duration(expiresIn) * time.Second) } - // Clear PKCE after successful exchange state.PKCEVerifier = "" state.PKCEChallenge = "" extensionAuthStateMu.Unlock() GoLog("[Extension:%s] PKCE token exchange successful\n", r.extensionID) - // Return full token response result := map[string]interface{}{ "success": true, "access_token": accessToken, @@ -538,7 +512,6 @@ func (r *ExtensionRuntime) authExchangeCodeWithPKCE(call goja.FunctionCall) goja if expiresIn > 0 { result["expires_in"] = expiresIn } - // Include any additional fields from response if scope, ok := tokenResp["scope"].(string); ok { result["scope"] = scope } diff --git a/go_backend/extension_runtime_ffmpeg.go b/go_backend/extension_runtime_ffmpeg.go index 889456bb..f5a5b578 100644 --- a/go_backend/extension_runtime_ffmpeg.go +++ b/go_backend/extension_runtime_ffmpeg.go @@ -31,14 +31,12 @@ var ( ffmpegCommandID int64 ) -// GetPendingFFmpegCommand returns a pending FFmpeg command (called from Flutter) func GetPendingFFmpegCommand(commandID string) *FFmpegCommand { ffmpegCommandsMu.RLock() defer ffmpegCommandsMu.RUnlock() return ffmpegCommands[commandID] } -// SetFFmpegCommandResult sets the result of an FFmpeg command (called from Flutter) func SetFFmpegCommandResult(commandID string, success bool, output, errorMsg string) { ffmpegCommandsMu.Lock() defer ffmpegCommandsMu.Unlock() @@ -50,14 +48,12 @@ func SetFFmpegCommandResult(commandID string, success bool, output, errorMsg str } } -// ClearFFmpegCommand removes a completed FFmpeg command func ClearFFmpegCommand(commandID string) { ffmpegCommandsMu.Lock() defer ffmpegCommandsMu.Unlock() delete(ffmpegCommands, commandID) } -// ffmpegExecute queues an FFmpeg command for execution by Flutter func (r *ExtensionRuntime) ffmpegExecute(call goja.FunctionCall) goja.Value { if len(call.Arguments) < 1 { return r.vm.ToValue(map[string]interface{}{ @@ -118,7 +114,6 @@ func (r *ExtensionRuntime) ffmpegExecute(call goja.FunctionCall) goja.Value { } } -// ffmpegGetInfo gets audio file information using FFprobe func (r *ExtensionRuntime) ffmpegGetInfo(call goja.FunctionCall) goja.Value { if len(call.Arguments) < 1 { return r.vm.ToValue(map[string]interface{}{ @@ -147,7 +142,6 @@ func (r *ExtensionRuntime) ffmpegGetInfo(call goja.FunctionCall) goja.Value { }) } -// ffmpegConvert is a helper for common conversion operations func (r *ExtensionRuntime) ffmpegConvert(call goja.FunctionCall) goja.Value { if len(call.Arguments) < 2 { return r.vm.ToValue(map[string]interface{}{ diff --git a/go_backend/extension_runtime_file.go b/go_backend/extension_runtime_file.go index 82ccec3b..20b720df 100644 --- a/go_backend/extension_runtime_file.go +++ b/go_backend/extension_runtime_file.go @@ -21,8 +21,6 @@ var ( allowedDownloadDirsMu sync.RWMutex ) -// SetAllowedDownloadDirs sets the list of directories where extensions can write files -// This should be called by the Go backend when setting up download paths func SetAllowedDownloadDirs(dirs []string) { allowedDownloadDirsMu.Lock() defer allowedDownloadDirsMu.Unlock() @@ -30,7 +28,6 @@ func SetAllowedDownloadDirs(dirs []string) { GoLog("[Extension] Allowed download directories set: %v\n", dirs) } -// AddAllowedDownloadDir adds a directory to the allowed list func AddAllowedDownloadDir(dir string) { allowedDownloadDirsMu.Lock() defer allowedDownloadDirsMu.Unlock() @@ -40,7 +37,6 @@ func AddAllowedDownloadDir(dir string) { } } -// isPathInAllowedDirs checks if an absolute path is within any allowed directory func isPathInAllowedDirs(absPath string) bool { allowedDownloadDirsMu.RLock() defer allowedDownloadDirsMu.RUnlock() @@ -62,36 +58,28 @@ func (r *ExtensionRuntime) validatePath(path string) (string, error) { return "", fmt.Errorf("file access denied: extension does not have 'file' permission") } - // Clean and resolve the path cleanPath := filepath.Clean(path) - // SECURITY: Block absolute paths by default - // Only allow if path is in explicitly allowed download directories if filepath.IsAbs(cleanPath) { absPath, err := filepath.Abs(cleanPath) if err != nil { return "", fmt.Errorf("invalid path: %w", err) } - // Check if path is in allowed download directories if isPathInAllowedDirs(absPath) { return absPath, nil } - // Block all other absolute paths return "", fmt.Errorf("file access denied: absolute paths are not allowed. Use relative paths within extension sandbox") } - // For relative paths, join with data directory (extension's sandbox) fullPath := filepath.Join(r.dataDir, cleanPath) - // Resolve to absolute path absPath, err := filepath.Abs(fullPath) if err != nil { return "", fmt.Errorf("invalid path: %w", err) } - // Ensure path is within data directory (prevent path traversal) absDataDir, _ := filepath.Abs(r.dataDir) if !strings.HasPrefix(absPath, absDataDir) { return "", fmt.Errorf("file access denied: path '%s' is outside sandbox", path) @@ -100,8 +88,6 @@ func (r *ExtensionRuntime) validatePath(path string) (string, error) { return absPath, nil } -// fileDownload downloads a file from URL to the specified path -// Supports progress callback via options.onProgress func (r *ExtensionRuntime) fileDownload(call goja.FunctionCall) goja.Value { if len(call.Arguments) < 2 { return r.vm.ToValue(map[string]interface{}{ @@ -113,7 +99,6 @@ func (r *ExtensionRuntime) fileDownload(call goja.FunctionCall) goja.Value { urlStr := call.Arguments[0].String() outputPath := call.Arguments[1].String() - // Validate domain if err := r.validateDomain(urlStr); err != nil { return r.vm.ToValue(map[string]interface{}{ "success": false, @@ -121,7 +106,6 @@ func (r *ExtensionRuntime) fileDownload(call goja.FunctionCall) goja.Value { }) } - // Validate output path (allows absolute paths for download queue) fullPath, err := r.validatePath(outputPath) if err != nil { return r.vm.ToValue(map[string]interface{}{ @@ -130,20 +114,17 @@ func (r *ExtensionRuntime) fileDownload(call goja.FunctionCall) goja.Value { }) } - // Get options if provided var onProgress goja.Callable var headers map[string]string if len(call.Arguments) > 2 && !goja.IsUndefined(call.Arguments[2]) && !goja.IsNull(call.Arguments[2]) { optionsObj := call.Arguments[2].Export() if opts, ok := optionsObj.(map[string]interface{}); ok { - // Extract headers if h, ok := opts["headers"].(map[string]interface{}); ok { headers = make(map[string]string) for k, v := range h { headers[k] = fmt.Sprintf("%v", v) } } - // Extract onProgress callback if progressVal, ok := opts["onProgress"]; ok { if callable, ok := goja.AssertFunction(r.vm.ToValue(progressVal)); ok { onProgress = callable @@ -152,7 +133,6 @@ func (r *ExtensionRuntime) fileDownload(call goja.FunctionCall) goja.Value { } } - // Create directory if needed dir := filepath.Dir(fullPath) if err := os.MkdirAll(dir, 0755); err != nil { return r.vm.ToValue(map[string]interface{}{ @@ -161,7 +141,6 @@ func (r *ExtensionRuntime) fileDownload(call goja.FunctionCall) goja.Value { }) } - // Create HTTP request req, err := http.NewRequest("GET", urlStr, nil) if err != nil { return r.vm.ToValue(map[string]interface{}{ @@ -170,7 +149,6 @@ func (r *ExtensionRuntime) fileDownload(call goja.FunctionCall) goja.Value { }) } - // Set headers for k, v := range headers { req.Header.Set(k, v) } @@ -178,7 +156,6 @@ func (r *ExtensionRuntime) fileDownload(call goja.FunctionCall) goja.Value { req.Header.Set("User-Agent", "SpotiFLAC-Extension/1.0") } - // Download file resp, err := r.httpClient.Do(req) if err != nil { return r.vm.ToValue(map[string]interface{}{ @@ -195,7 +172,6 @@ func (r *ExtensionRuntime) fileDownload(call goja.FunctionCall) goja.Value { }) } - // Create output file out, err := os.Create(fullPath) if err != nil { return r.vm.ToValue(map[string]interface{}{ @@ -205,12 +181,10 @@ func (r *ExtensionRuntime) fileDownload(call goja.FunctionCall) goja.Value { } defer out.Close() - // Get content length for progress contentLength := resp.ContentLength - // Copy content with progress reporting var written int64 - buf := make([]byte, 32*1024) // 32KB buffer + buf := make([]byte, 32*1024) for { nr, er := resp.Body.Read(buf) if nr > 0 { @@ -235,7 +209,6 @@ func (r *ExtensionRuntime) fileDownload(call goja.FunctionCall) goja.Value { }) } - // Report progress if onProgress != nil && contentLength > 0 { _, _ = onProgress(goja.Undefined(), r.vm.ToValue(written), r.vm.ToValue(contentLength)) } @@ -260,7 +233,6 @@ func (r *ExtensionRuntime) fileDownload(call goja.FunctionCall) goja.Value { }) } -// fileExists checks if a file exists in the sandbox func (r *ExtensionRuntime) fileExists(call goja.FunctionCall) goja.Value { if len(call.Arguments) < 1 { return r.vm.ToValue(false) @@ -276,7 +248,6 @@ func (r *ExtensionRuntime) fileExists(call goja.FunctionCall) goja.Value { return r.vm.ToValue(err == nil) } -// fileDelete deletes a file in the sandbox func (r *ExtensionRuntime) fileDelete(call goja.FunctionCall) goja.Value { if len(call.Arguments) < 1 { return r.vm.ToValue(map[string]interface{}{ @@ -306,7 +277,6 @@ func (r *ExtensionRuntime) fileDelete(call goja.FunctionCall) goja.Value { }) } -// fileRead reads a file from the sandbox func (r *ExtensionRuntime) fileRead(call goja.FunctionCall) goja.Value { if len(call.Arguments) < 1 { return r.vm.ToValue(map[string]interface{}{ @@ -338,7 +308,6 @@ func (r *ExtensionRuntime) fileRead(call goja.FunctionCall) goja.Value { }) } -// fileWrite writes data to a file in the sandbox func (r *ExtensionRuntime) fileWrite(call goja.FunctionCall) goja.Value { if len(call.Arguments) < 2 { return r.vm.ToValue(map[string]interface{}{ @@ -380,7 +349,6 @@ func (r *ExtensionRuntime) fileWrite(call goja.FunctionCall) goja.Value { }) } -// fileCopy copies a file within the sandbox func (r *ExtensionRuntime) fileCopy(call goja.FunctionCall) goja.Value { if len(call.Arguments) < 2 { return r.vm.ToValue(map[string]interface{}{ @@ -408,7 +376,6 @@ func (r *ExtensionRuntime) fileCopy(call goja.FunctionCall) goja.Value { }) } - // Read source file data, err := os.ReadFile(fullSrc) if err != nil { return r.vm.ToValue(map[string]interface{}{ @@ -417,7 +384,6 @@ func (r *ExtensionRuntime) fileCopy(call goja.FunctionCall) goja.Value { }) } - // Create destination directory if needed dir := filepath.Dir(fullDst) if err := os.MkdirAll(dir, 0755); err != nil { return r.vm.ToValue(map[string]interface{}{ @@ -426,7 +392,6 @@ func (r *ExtensionRuntime) fileCopy(call goja.FunctionCall) goja.Value { }) } - // Write to destination if err := os.WriteFile(fullDst, data, 0644); err != nil { return r.vm.ToValue(map[string]interface{}{ "success": false, @@ -440,7 +405,6 @@ func (r *ExtensionRuntime) fileCopy(call goja.FunctionCall) goja.Value { }) } -// fileMove moves/renames a file within the sandbox func (r *ExtensionRuntime) fileMove(call goja.FunctionCall) goja.Value { if len(call.Arguments) < 2 { return r.vm.ToValue(map[string]interface{}{ @@ -468,7 +432,6 @@ func (r *ExtensionRuntime) fileMove(call goja.FunctionCall) goja.Value { }) } - // Create destination directory if needed dir := filepath.Dir(fullDst) if err := os.MkdirAll(dir, 0755); err != nil { return r.vm.ToValue(map[string]interface{}{ @@ -490,7 +453,6 @@ func (r *ExtensionRuntime) fileMove(call goja.FunctionCall) goja.Value { }) } -// fileGetSize returns the size of a file in bytes func (r *ExtensionRuntime) fileGetSize(call goja.FunctionCall) goja.Value { if len(call.Arguments) < 1 { return r.vm.ToValue(map[string]interface{}{ diff --git a/go_backend/extension_runtime_http.go b/go_backend/extension_runtime_http.go index 61c7b36c..a87365c8 100644 --- a/go_backend/extension_runtime_http.go +++ b/go_backend/extension_runtime_http.go @@ -52,7 +52,6 @@ func (r *ExtensionRuntime) httpGet(call goja.FunctionCall) goja.Value { urlStr := call.Arguments[0].String() - // Validate domain if err := r.validateDomain(urlStr); err != nil { GoLog("[Extension:%s] HTTP blocked: %v\n", r.extensionID, err) return r.vm.ToValue(map[string]interface{}{ @@ -60,7 +59,6 @@ func (r *ExtensionRuntime) httpGet(call goja.FunctionCall) goja.Value { }) } - // Get headers if provided headers := make(map[string]string) if len(call.Arguments) > 1 && !goja.IsUndefined(call.Arguments[1]) && !goja.IsNull(call.Arguments[1]) { headersObj := call.Arguments[1].Export() @@ -71,7 +69,6 @@ func (r *ExtensionRuntime) httpGet(call goja.FunctionCall) goja.Value { } } - // Create request req, err := http.NewRequest("GET", urlStr, nil) if err != nil { return r.vm.ToValue(map[string]interface{}{ @@ -97,7 +94,6 @@ func (r *ExtensionRuntime) httpGet(call goja.FunctionCall) goja.Value { } defer resp.Body.Close() - // Read body body, err := io.ReadAll(resp.Body) if err != nil { return r.vm.ToValue(map[string]interface{}{ @@ -134,7 +130,6 @@ func (r *ExtensionRuntime) httpPost(call goja.FunctionCall) goja.Value { urlStr := call.Arguments[0].String() - // Validate domain if err := r.validateDomain(urlStr); err != nil { GoLog("[Extension:%s] HTTP blocked: %v\n", r.extensionID, err) return r.vm.ToValue(map[string]interface{}{ @@ -175,7 +170,6 @@ func (r *ExtensionRuntime) httpPost(call goja.FunctionCall) goja.Value { } } - // Create request req, err := http.NewRequest("POST", urlStr, strings.NewReader(bodyStr)) if err != nil { return r.vm.ToValue(map[string]interface{}{ @@ -204,7 +198,6 @@ func (r *ExtensionRuntime) httpPost(call goja.FunctionCall) goja.Value { } defer resp.Body.Close() - // Read body body, err := io.ReadAll(resp.Body) if err != nil { return r.vm.ToValue(map[string]interface{}{ @@ -231,8 +224,6 @@ func (r *ExtensionRuntime) httpPost(call goja.FunctionCall) goja.Value { }) } -// httpRequest performs a generic HTTP request (GET, POST, PUT, DELETE, etc.) -// Usage: http.request(url, options) where options = { method, body, headers } func (r *ExtensionRuntime) httpRequest(call goja.FunctionCall) goja.Value { if len(call.Arguments) < 1 { return r.vm.ToValue(map[string]interface{}{ @@ -242,7 +233,6 @@ func (r *ExtensionRuntime) httpRequest(call goja.FunctionCall) goja.Value { urlStr := call.Arguments[0].String() - // Validate domain if err := r.validateDomain(urlStr); err != nil { GoLog("[Extension:%s] HTTP blocked: %v\n", r.extensionID, err) return r.vm.ToValue(map[string]interface{}{ @@ -326,7 +316,6 @@ func (r *ExtensionRuntime) httpRequest(call goja.FunctionCall) goja.Value { } defer resp.Body.Close() - // Read body body, err := io.ReadAll(resp.Body) if err != nil { return r.vm.ToValue(map[string]interface{}{ @@ -354,7 +343,6 @@ func (r *ExtensionRuntime) httpRequest(call goja.FunctionCall) goja.Value { }) } -// httpPut performs a PUT request (shortcut for http.request with method: "PUT") func (r *ExtensionRuntime) httpPut(call goja.FunctionCall) goja.Value { return r.httpMethodShortcut("PUT", call) } @@ -364,7 +352,6 @@ func (r *ExtensionRuntime) httpDelete(call goja.FunctionCall) goja.Value { return r.httpMethodShortcut("DELETE", call) } -// httpPatch performs a PATCH request (shortcut for http.request with method: "PATCH") func (r *ExtensionRuntime) httpPatch(call goja.FunctionCall) goja.Value { return r.httpMethodShortcut("PATCH", call) } @@ -380,7 +367,6 @@ func (r *ExtensionRuntime) httpMethodShortcut(method string, call goja.FunctionC urlStr := call.Arguments[0].String() - // Validate domain if err := r.validateDomain(urlStr); err != nil { GoLog("[Extension:%s] HTTP blocked: %v\n", r.extensionID, err) return r.vm.ToValue(map[string]interface{}{ @@ -465,7 +451,6 @@ func (r *ExtensionRuntime) httpMethodShortcut(method string, call goja.FunctionC } defer resp.Body.Close() - // Read body body, err := io.ReadAll(resp.Body) if err != nil { return r.vm.ToValue(map[string]interface{}{ @@ -492,7 +477,6 @@ func (r *ExtensionRuntime) httpMethodShortcut(method string, call goja.FunctionC }) } -// httpClearCookies clears all cookies for this extension func (r *ExtensionRuntime) httpClearCookies(call goja.FunctionCall) goja.Value { if jar, ok := r.cookieJar.(*simpleCookieJar); ok { jar.mu.Lock() diff --git a/go_backend/extension_runtime_storage.go b/go_backend/extension_runtime_storage.go index a44bfd33..73108e20 100644 --- a/go_backend/extension_runtime_storage.go +++ b/go_backend/extension_runtime_storage.go @@ -143,19 +143,16 @@ func (r *ExtensionRuntime) getSaltPath() string { func (r *ExtensionRuntime) getOrCreateSalt() ([]byte, error) { saltPath := r.getSaltPath() - // Try to read existing salt salt, err := os.ReadFile(saltPath) if err == nil && len(salt) == 32 { return salt, nil } - // Generate new random salt (32 bytes) salt = make([]byte, 32) if _, err := io.ReadFull(rand.Reader, salt); err != nil { return nil, fmt.Errorf("failed to generate salt: %w", err) } - // Save salt to file if err := os.WriteFile(saltPath, salt, 0600); err != nil { return nil, fmt.Errorf("failed to save salt: %w", err) } @@ -214,7 +211,6 @@ func (r *ExtensionRuntime) saveCredentials(creds map[string]interface{}) error { return err } - // Encrypt the data key, err := r.getEncryptionKey() if err != nil { return fmt.Errorf("failed to get encryption key: %w", err) diff --git a/go_backend/extension_runtime_utils.go b/go_backend/extension_runtime_utils.go index abed98b4..37d86920 100644 --- a/go_backend/extension_runtime_utils.go +++ b/go_backend/extension_runtime_utils.go @@ -94,7 +94,6 @@ func (r *ExtensionRuntime) hmacSHA1(call goja.FunctionCall) goja.Value { return r.vm.ToValue([]byte{}) } - // Get key - can be string or array of bytes var keyBytes []byte keyArg := call.Arguments[0].Export() switch k := keyArg.(type) { @@ -113,7 +112,6 @@ func (r *ExtensionRuntime) hmacSHA1(call goja.FunctionCall) goja.Value { return r.vm.ToValue([]byte{}) } - // Get message - can be string or array of bytes var msgBytes []byte msgArg := call.Arguments[1].Export() switch m := msgArg.(type) { @@ -136,7 +134,6 @@ func (r *ExtensionRuntime) hmacSHA1(call goja.FunctionCall) goja.Value { mac.Write(msgBytes) result := mac.Sum(nil) - // Convert to array of numbers for JavaScript jsArray := make([]interface{}, len(result)) for i, b := range result { jsArray[i] = int(b) diff --git a/go_backend/extension_settings.go b/go_backend/extension_settings.go index 6f46773c..f76514b3 100644 --- a/go_backend/extension_settings.go +++ b/go_backend/extension_settings.go @@ -42,7 +42,6 @@ func (s *ExtensionSettingsStore) SetDataDir(dataDir string) error { return fmt.Errorf("failed to create settings directory: %w", err) } - // Load all existing settings return s.loadAllSettings() } @@ -99,7 +98,6 @@ func (s *ExtensionSettingsStore) loadSettings(extensionID string) (map[string]in func (s *ExtensionSettingsStore) saveSettings(extensionID string, settings map[string]interface{}) error { settingsPath := s.getSettingsPath(extensionID) - // Create directory if needed dir := filepath.Dir(settingsPath) if err := os.MkdirAll(dir, 0755); err != nil { return err @@ -160,7 +158,6 @@ func (s *ExtensionSettingsStore) Set(extensionID, key string, value interface{}) s.settings[extensionID][key] = value - // Persist to disk return s.saveSettings(extensionID, s.settings[extensionID]) } @@ -198,7 +195,6 @@ func (s *ExtensionSettingsStore) RemoveAll(extensionID string) error { delete(s.settings, extensionID) - // Remove settings file settingsPath := s.getSettingsPath(extensionID) if err := os.Remove(settingsPath); err != nil && !os.IsNotExist(err) { return err diff --git a/go_backend/extension_store.go b/go_backend/extension_store.go index 2a2e1097..370046d7 100644 --- a/go_backend/extension_store.go +++ b/go_backend/extension_store.go @@ -35,7 +35,6 @@ type StoreExtension struct { Downloads int `json:"downloads"` UpdatedAt string `json:"updated_at"` MinAppVersion string `json:"min_app_version,omitempty"` - // Alternative camelCase fields (for flexibility) DisplayNameAlt string `json:"displayName,omitempty"` DownloadURLAlt string `json:"downloadUrl,omitempty"` IconURLAlt string `json:"iconUrl,omitempty"` @@ -332,7 +331,6 @@ func (s *ExtensionStore) DownloadExtension(extensionID string, destPath string) return fmt.Errorf("download returned HTTP %d", resp.StatusCode) } - // Create destination file out, err := os.Create(destPath) if err != nil { return fmt.Errorf("failed to create file: %w", err) diff --git a/go_backend/filename.go b/go_backend/filename.go index 2be92b20..94a17cf8 100644 --- a/go_backend/filename.go +++ b/go_backend/filename.go @@ -6,10 +6,8 @@ import ( "strings" ) -// Invalid filename characters for Android/Windows/Linux var invalidChars = regexp.MustCompile(`[<>:"/\\|?*\x00-\x1f]`) -// sanitizeFilename removes invalid characters from filename func sanitizeFilename(filename string) string { sanitized := invalidChars.ReplaceAllString(filename, "_") @@ -30,7 +28,6 @@ func sanitizeFilename(filename string) string { return sanitized } -// buildFilenameFromTemplate builds a filename from template and metadata func buildFilenameFromTemplate(template string, metadata map[string]interface{}) string { if template == "" { template = "{artist} - {title}" @@ -91,7 +88,6 @@ func formatDiscNumber(n int) string { return fmt.Sprintf("%d", n) } -// extractYear extracts year from date string (YYYY-MM-DD or YYYY) func extractYear(date string) string { if len(date) >= 4 { return date[:4] diff --git a/go_backend/httputil.go b/go_backend/httputil.go index 3a9e2b80..fcd0377d 100644 --- a/go_backend/httputil.go +++ b/go_backend/httputil.go @@ -15,8 +15,6 @@ import ( "time" ) -// HTTP utility functions for consistent request handling across all downloaders - // 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 @@ -34,41 +32,6 @@ func getRandomUserAgent() string { ) } -// getRandomMacUserAgent generates a random Mac Chrome User-Agent string -// Alternative format matching referensi/backend/spotify_metadata.go exactly -// func getRandomMacUserAgent() string { -// macMajor := rand.Intn(4) + 11 // macOS 11-14 -// macMinor := rand.Intn(5) + 4 // Minor 4-8 -// webkitMajor := rand.Intn(7) + 530 -// webkitMinor := rand.Intn(7) + 30 -// chromeMajor := rand.Intn(25) + 80 -// chromeBuild := rand.Intn(1500) + 3000 -// chromePatch := rand.Intn(65) + 60 -// safariMajor := rand.Intn(7) + 530 -// safariMinor := rand.Intn(6) + 30 -// -// return fmt.Sprintf( -// "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_%d_%d) AppleWebKit/%d.%d (KHTML, like Gecko) Chrome/%d.0.%d.%d Safari/%d.%d", -// macMajor, -// macMinor, -// webkitMajor, -// webkitMinor, -// chromeMajor, -// chromeBuild, -// chromePatch, -// safariMajor, -// safariMinor, -// ) -// } - -// getRandomDesktopUserAgent randomly picks between Windows and Mac User-Agent -// func getRandomDesktopUserAgent() string { -// if rand.Intn(2) == 0 { -// return getRandomUserAgent() // Windows -// } -// return getRandomMacUserAgent() // Mac -// } - const ( DefaultTimeout = 60 * time.Second DownloadTimeout = 120 * time.Second @@ -106,7 +69,6 @@ var downloadClient = &http.Client{ Timeout: DownloadTimeout, } -// NewHTTPClientWithTimeout creates an HTTP client with specified timeout func NewHTTPClientWithTimeout(timeout time.Duration) *http.Client { return &http.Client{ Transport: sharedTransport, @@ -127,7 +89,6 @@ func CloseIdleConnections() { sharedTransport.CloseIdleConnections() } -// DoRequestWithUserAgent executes an HTTP request with a random User-Agent header // Also checks for ISP blocking on errors func DoRequestWithUserAgent(client *http.Client, req *http.Request) (*http.Response, error) { req.Header.Set("User-Agent", getRandomUserAgent()) @@ -146,7 +107,6 @@ type RetryConfig struct { BackoffFactor float64 } -// DefaultRetryConfig returns default retry configuration func DefaultRetryConfig() RetryConfig { return RetryConfig{ MaxRetries: DefaultMaxRetries, @@ -252,13 +212,11 @@ func DoRequestWithRetry(client *http.Client, req *http.Request, config RetryConf return nil, fmt.Errorf("request failed after %d retries: %w", config.MaxRetries+1, lastErr) } -// calculateNextDelay calculates the next delay with exponential backoff func calculateNextDelay(currentDelay time.Duration, config RetryConfig) time.Duration { nextDelay := time.Duration(float64(currentDelay) * config.BackoffFactor) return min(nextDelay, config.MaxDelay) } -// getRetryAfterDuration parses Retry-After header and returns duration // Returns 60 seconds as default if header is missing or invalid func getRetryAfterDuration(resp *http.Response) time.Duration { retryAfter := resp.Header.Get("Retry-After") @@ -301,7 +259,6 @@ func ReadResponseBody(resp *http.Response) ([]byte, error) { return body, nil } -// ValidateResponse checks if response is valid (non-nil, status 2xx) func ValidateResponse(resp *http.Response) error { if resp == nil { return fmt.Errorf("response is nil") @@ -330,7 +287,6 @@ func BuildErrorMessage(apiURL string, statusCode int, responsePreview string) st return msg } -// ISPBlockingError represents an error caused by ISP blocking type ISPBlockingError struct { Domain string Reason string @@ -446,7 +402,6 @@ func IsISPBlocking(err error, requestURL string) *ISPBlockingError { return nil } -// CheckAndLogISPBlocking checks for ISP blocking and logs if detected // Returns true if ISP blocking was detected func CheckAndLogISPBlocking(err error, requestURL string, tag string) bool { ispErr := IsISPBlocking(err, requestURL) @@ -484,7 +439,6 @@ func extractDomain(rawURL string) string { return "unknown" } -// WrapErrorWithISPCheck wraps an error with ISP blocking detection // If ISP blocking is detected, returns a more descriptive error func WrapErrorWithISPCheck(err error, requestURL string, tag string) error { if err == nil { diff --git a/go_backend/logbuffer.go b/go_backend/logbuffer.go index 5c08b03c..7537a782 100644 --- a/go_backend/logbuffer.go +++ b/go_backend/logbuffer.go @@ -8,7 +8,6 @@ import ( "time" ) -// LogEntry represents a single log entry type LogEntry struct { Timestamp string `json:"timestamp"` Level string `json:"level"` @@ -16,7 +15,6 @@ type LogEntry struct { Message string `json:"message"` } -// LogBuffer stores logs in a circular buffer for retrieval by Flutter type LogBuffer struct { entries []LogEntry maxSize int @@ -41,7 +39,6 @@ func GetLogBuffer() *LogBuffer { return globalLogBuffer } -// SetLoggingEnabled enables or disables logging func (lb *LogBuffer) SetLoggingEnabled(enabled bool) { lb.mu.Lock() defer lb.mu.Unlock() @@ -55,7 +52,6 @@ func (lb *LogBuffer) IsLoggingEnabled() bool { return lb.loggingEnabled } -// Add adds a log entry to the buffer func (lb *LogBuffer) Add(level, tag, message string) { lb.mu.Lock() defer lb.mu.Unlock() diff --git a/go_backend/lyrics.go b/go_backend/lyrics.go index 46d959d7..895da25a 100644 --- a/go_backend/lyrics.go +++ b/go_backend/lyrics.go @@ -15,13 +15,9 @@ import ( "time" ) -// ======================================== -// Lyrics Cache with TTL -// ======================================== - const ( - lyricsCacheTTL = 24 * time.Hour // Cache lyrics for 24 hours - durationToleranceSec = 10.0 // Duration matching tolerance in seconds + lyricsCacheTTL = 24 * time.Hour + durationToleranceSec = 10.0 ) type lyricsCacheEntry struct { @@ -39,10 +35,8 @@ var globalLyricsCache = &lyricsCache{ } func (c *lyricsCache) generateKey(artist, track string, durationSec float64) string { - // Normalize key: lowercase, trim spaces normalizedArtist := strings.ToLower(strings.TrimSpace(artist)) normalizedTrack := strings.ToLower(strings.TrimSpace(track)) - // Round duration to nearest 10 seconds for cache key roundedDuration := math.Round(durationSec/10) * 10 return fmt.Sprintf("%s|%s|%.0f", normalizedArtist, normalizedTrack, roundedDuration) } @@ -57,7 +51,6 @@ func (c *lyricsCache) Get(artist, track string, durationSec float64) (*LyricsRes return nil, false } - // Check if expired if time.Now().After(entry.expiresAt) { return nil, false } @@ -76,7 +69,6 @@ func (c *lyricsCache) Set(artist, track string, durationSec float64, response *L } } -// CleanExpired removes expired entries from cache func (c *lyricsCache) CleanExpired() int { c.mu.Lock() defer c.mu.Unlock() @@ -92,7 +84,6 @@ func (c *lyricsCache) CleanExpired() int { return cleaned } -// Size returns current cache size func (c *lyricsCache) Size() int { c.mu.RLock() defer c.mu.RUnlock() @@ -174,8 +165,6 @@ func (c *LyricsClient) FetchLyricsWithMetadata(artist, track string) (*LyricsRes return c.parseLRCLibResponse(&lrcResp), nil } -// FetchLyricsFromLRCLibSearch searches lyrics with optional duration matching -// durationSec: track duration in seconds, use 0 to skip duration matching func (c *LyricsClient) FetchLyricsFromLRCLibSearch(query string, durationSec float64) (*LyricsResponse, error) { baseURL := "https://lrclib.net/api/search" params := url.Values{} @@ -208,13 +197,11 @@ func (c *LyricsClient) FetchLyricsFromLRCLibSearch(query string, durationSec flo return nil, fmt.Errorf("no lyrics found") } - // Filter and score results based on duration matching and synced lyrics bestMatch := c.findBestMatch(results, durationSec) if bestMatch != nil { return c.parseLRCLibResponse(bestMatch), nil } - // Fallback: return first result with synced lyrics for _, result := range results { if result.SyncedLyrics != "" { return c.parseLRCLibResponse(&result), nil @@ -224,7 +211,6 @@ func (c *LyricsClient) FetchLyricsFromLRCLibSearch(query string, durationSec flo return c.parseLRCLibResponse(&results[0]), nil } -// findBestMatch finds the best matching lyrics based on duration and sync status func (c *LyricsClient) findBestMatch(results []LRCLibResponse, targetDurationSec float64) *LRCLibResponse { var bestSynced *LRCLibResponse var bestPlain *LRCLibResponse @@ -232,11 +218,9 @@ func (c *LyricsClient) findBestMatch(results []LRCLibResponse, targetDurationSec for i := range results { result := &results[i] - // Check duration match if target duration is provided durationMatches := targetDurationSec == 0 || c.durationMatches(result.Duration, targetDurationSec) if durationMatches { - // Prefer synced lyrics over plain if result.SyncedLyrics != "" && bestSynced == nil { bestSynced = result } else if result.PlainLyrics != "" && bestPlain == nil { @@ -245,20 +229,17 @@ func (c *LyricsClient) findBestMatch(results []LRCLibResponse, targetDurationSec } } - // Return synced first, then plain if bestSynced != nil { return bestSynced } return bestPlain } -// durationMatches checks if two durations are within tolerance func (c *LyricsClient) durationMatches(lrcDuration, targetDuration float64) bool { diff := math.Abs(lrcDuration - targetDuration) return diff <= durationToleranceSec } -// FetchLyricsAllSources fetches lyrics from multiple sources with caching and duration matching // 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) { // Check cache first @@ -396,7 +377,6 @@ func msToLRCTimestamp(ms int64) string { return fmt.Sprintf("[%02d:%02d.%02d]", minutes, seconds, centiseconds) } -// convertToLRC converts lyrics to LRC format string (without metadata headers) // Use convertToLRCWithMetadata for full LRC with headers // Kept for potential future use // func convertToLRC(lyrics *LyricsResponse) string { @@ -423,8 +403,6 @@ func msToLRCTimestamp(ms int64) string { // return builder.String() // } -// convertToLRCWithMetadata converts lyrics to LRC format with metadata headers -// Includes [ti:], [ar:], [by:] headers func convertToLRCWithMetadata(lyrics *LyricsResponse, trackName, artistName string) string { if lyrics == nil || len(lyrics.Lines) == 0 { return "" @@ -432,13 +410,11 @@ func convertToLRCWithMetadata(lyrics *LyricsResponse, trackName, artistName stri var builder strings.Builder - // Add metadata headers builder.WriteString(fmt.Sprintf("[ti:%s]\n", trackName)) builder.WriteString(fmt.Sprintf("[ar:%s]\n", artistName)) builder.WriteString("[by:SpotiFLAC-Mobile]\n") builder.WriteString("\n") - // Add lyrics lines if lyrics.SyncType == "LINE_SYNCED" { for _, line := range lyrics.Lines { if line.Words == "" { @@ -488,24 +464,17 @@ func simplifyTrackName(name string) string { return strings.TrimSpace(result) } -// SaveLRCFile saves lyrics as a .lrc file next to the audio file -// audioFilePath: path to the audio file (e.g., /path/to/song.flac) -// lrcContent: the LRC format lyrics content -// Returns the path to the saved .lrc file, or error func SaveLRCFile(audioFilePath, lrcContent string) (string, error) { if lrcContent == "" { return "", fmt.Errorf("empty LRC content") } - // Get the directory and base name without extension dir := filepath.Dir(audioFilePath) ext := filepath.Ext(audioFilePath) baseName := strings.TrimSuffix(filepath.Base(audioFilePath), ext) - // Create the .lrc file path lrcFilePath := filepath.Join(dir, baseName+".lrc") - // Write the LRC content to the file if err := os.WriteFile(lrcFilePath, []byte(lrcContent), 0644); err != nil { return "", fmt.Errorf("failed to write LRC file: %w", err) } diff --git a/go_backend/metadata.go b/go_backend/metadata.go index e6f96fdc..a30fc684 100644 --- a/go_backend/metadata.go +++ b/go_backend/metadata.go @@ -11,7 +11,6 @@ import ( "github.com/go-flac/go-flac" ) -// Metadata represents track metadata for embedding type Metadata struct { Title string Artist string @@ -24,12 +23,11 @@ type Metadata struct { ISRC string Description string Lyrics string - Genre string // Music genre (e.g., "Rock", "Pop", "Electronic") - Label string // Record label (ORGANIZATION tag in Vorbis) - Copyright string // Copyright information + Genre string + Label string + Copyright string } -// EmbedMetadata embeds metadata into a FLAC file func EmbedMetadata(filePath string, metadata Metadata, coverPath string) error { f, err := flac.ParseFile(filePath) if err != nil { @@ -138,8 +136,6 @@ func EmbedMetadata(filePath string, metadata Metadata, coverPath string) error { return f.Save(filePath) } -// EmbedMetadataWithCoverData embeds metadata into a FLAC file with cover data as bytes -// This avoids file permission issues on Android by not requiring a temp file func EmbedMetadataWithCoverData(filePath string, metadata Metadata, coverData []byte) error { f, err := flac.ParseFile(filePath) if err != nil { @@ -337,7 +333,6 @@ func fileExists(path string) bool { return err == nil } -// EmbedLyrics embeds lyrics into a FLAC file as a separate operation func EmbedLyrics(filePath string, lyrics string) error { f, err := flac.ParseFile(filePath) if err != nil { @@ -375,11 +370,9 @@ func EmbedLyrics(filePath string, lyrics string) error { return f.Save(filePath) } -// EmbedGenreLabel embeds genre and label into a FLAC file as a separate operation -// This is used for extension downloads where the file is already downloaded func EmbedGenreLabel(filePath string, genre, label string) error { if genre == "" && label == "" { - return nil // Nothing to embed + return nil } f, err := flac.ParseFile(filePath) @@ -451,16 +444,12 @@ func ExtractLyrics(filePath string) (string, error) { return "", fmt.Errorf("no lyrics found in file") } -// AudioQuality represents audio quality info from a FLAC file type AudioQuality struct { BitDepth int `json:"bit_depth"` SampleRate int `json:"sample_rate"` TotalSamples int64 `json:"total_samples"` } -// GetAudioQuality reads bit depth and sample rate from a FLAC file's StreamInfo block -// FLAC StreamInfo is always the first metadata block after the 4-byte "fLaC" marker -// For M4A files, it delegates to GetM4AQuality func GetAudioQuality(filePath string) (AudioQuality, error) { file, err := os.Open(filePath) if err != nil { @@ -597,7 +586,6 @@ func EmbedM4AMetadata(filePath string, metadata Metadata, coverData []byte) erro return nil } -// findAtom finds an atom by name starting from offset func findAtom(data []byte, name string, offset int) int { for i := offset; i < len(data)-8; { size := int(uint32(data[i])<<24 | uint32(data[i+1])<<16 | uint32(data[i+2])<<8 | uint32(data[i+3])) @@ -689,7 +677,6 @@ func buildMetaAtom(metadata Metadata, coverData []byte) []byte { return metaAtom } -// buildTextAtom builds a text metadata atom (©nam, ©ART, etc.) func buildTextAtom(name, value string) []byte { valueBytes := []byte(value) @@ -741,7 +728,6 @@ func buildTrackNumberAtom(track, total int) []byte { return atom } -// buildDiscNumberAtom builds disk atom func buildDiscNumberAtom(disc, total int) []byte { dataAtom := []byte{ 0, 0, 0, 22, // size @@ -767,9 +753,9 @@ func buildDiscNumberAtom(disc, total int) []byte { // buildCoverAtom builds covr atom with image data func buildCoverAtom(coverData []byte) []byte { - imageType := byte(13) // default JPEG + imageType := byte(13) if len(coverData) > 8 && coverData[0] == 0x89 && coverData[1] == 'P' && coverData[2] == 'N' && coverData[3] == 'G' { - imageType = 14 // PNG + imageType = 14 } dataSize := 16 + len(coverData) @@ -779,8 +765,8 @@ func buildCoverAtom(coverData []byte) []byte { dataAtom[2] = byte(dataSize >> 8) dataAtom[3] = byte(dataSize) dataAtom = append(dataAtom, []byte("data")...) - dataAtom = append(dataAtom, 0, 0, 0, imageType) // type = JPEG or PNG - dataAtom = append(dataAtom, 0, 0, 0, 0) // locale + dataAtom = append(dataAtom, 0, 0, 0, imageType) + dataAtom = append(dataAtom, 0, 0, 0, 0) dataAtom = append(dataAtom, coverData...) atomSize := 8 + len(dataAtom) @@ -795,7 +781,6 @@ func buildCoverAtom(coverData []byte) []byte { return atom } -// GetM4AQuality reads audio quality from M4A file func GetM4AQuality(filePath string) (AudioQuality, error) { data, err := os.ReadFile(filePath) if err != nil { diff --git a/go_backend/parallel.go b/go_backend/parallel.go index 88eee90a..2481ecfe 100644 --- a/go_backend/parallel.go +++ b/go_backend/parallel.go @@ -6,11 +6,6 @@ import ( "time" ) -// ======================================== -// ISRC to Track ID Cache -// ======================================== - -// TrackIDCacheEntry holds cached track ID with metadata type TrackIDCacheEntry struct { TidalTrackID int64 QobuzTrackID int64 @@ -18,7 +13,6 @@ type TrackIDCacheEntry struct { ExpiresAt time.Time } -// TrackIDCache caches ISRC to track ID mappings type TrackIDCache struct { cache map[string]*TrackIDCacheEntry mu sync.RWMutex @@ -30,7 +24,6 @@ var ( trackIDCacheOnce sync.Once ) -// GetTrackIDCache returns the global track ID cache func GetTrackIDCache() *TrackIDCache { trackIDCacheOnce.Do(func() { globalTrackIDCache = &TrackIDCache{ @@ -41,7 +34,6 @@ func GetTrackIDCache() *TrackIDCache { return globalTrackIDCache } -// Get retrieves a cached entry by ISRC func (c *TrackIDCache) Get(isrc string) *TrackIDCacheEntry { c.mu.RLock() defer c.mu.RUnlock() @@ -53,7 +45,6 @@ func (c *TrackIDCache) Get(isrc string) *TrackIDCacheEntry { return entry } -// SetTidal caches Tidal track ID for an ISRC func (c *TrackIDCache) SetTidal(isrc string, trackID int64) { c.mu.Lock() defer c.mu.Unlock() @@ -67,7 +58,6 @@ func (c *TrackIDCache) SetTidal(isrc string, trackID int64) { entry.ExpiresAt = time.Now().Add(c.ttl) } -// SetQobuz caches Qobuz track ID for an ISRC func (c *TrackIDCache) SetQobuz(isrc string, trackID int64) { c.mu.Lock() defer c.mu.Unlock() @@ -81,7 +71,6 @@ func (c *TrackIDCache) SetQobuz(isrc string, trackID int64) { entry.ExpiresAt = time.Now().Add(c.ttl) } -// SetAmazon caches Amazon track ID for an ISRC func (c *TrackIDCache) SetAmazon(isrc string, trackID string) { c.mu.Lock() defer c.mu.Unlock() @@ -95,24 +84,18 @@ func (c *TrackIDCache) SetAmazon(isrc string, trackID string) { entry.ExpiresAt = time.Now().Add(c.ttl) } -// Clear removes all cached entries func (c *TrackIDCache) Clear() { c.mu.Lock() defer c.mu.Unlock() c.cache = make(map[string]*TrackIDCacheEntry) } -// Size returns the number of cached entries func (c *TrackIDCache) Size() int { c.mu.RLock() defer c.mu.RUnlock() return len(c.cache) } -// ======================================== -// Parallel Download Helper -// ======================================== - // ParallelDownloadResult holds results from parallel operations type ParallelDownloadResult struct { CoverData []byte @@ -122,9 +105,6 @@ type ParallelDownloadResult struct { LyricsErr error } -// FetchCoverAndLyricsParallel downloads cover and fetches lyrics in parallel -// This runs while the main audio download is happening -// durationMs: track duration in milliseconds for lyrics matching func FetchCoverAndLyricsParallel( coverURL string, maxQualityCover bool, @@ -153,7 +133,6 @@ func FetchCoverAndLyricsParallel( }() } - // Fetch lyrics in parallel if embedLyrics { wg.Add(1) go func() { @@ -180,11 +159,6 @@ func FetchCoverAndLyricsParallel( return result } -// ======================================== -// Pre-warm Cache for Album/Playlist -// ======================================== - -// PreWarmCacheRequest represents a track to pre-warm cache for type PreWarmCacheRequest struct { ISRC string TrackName string @@ -193,8 +167,6 @@ type PreWarmCacheRequest struct { Service string // "tidal", "qobuz", "amazon" } -// PreWarmTrackCache pre-fetches track IDs for multiple tracks (for album/playlist) -// This runs in background while user is viewing the track list func PreWarmTrackCache(requests []PreWarmCacheRequest) { if len(requests) == 0 { return @@ -214,8 +186,8 @@ func PreWarmTrackCache(requests []PreWarmCacheRequest) { wg.Add(1) go func(r PreWarmCacheRequest) { defer wg.Done() - semaphore <- struct{}{} // Acquire - defer func() { <-semaphore }() // Release + semaphore <- struct{}{} + defer func() { <-semaphore }() switch r.Service { case "tidal": @@ -259,12 +231,6 @@ func preWarmAmazonCache(isrc, spotifyID string) { } } -// ======================================== -// Exported Functions for Flutter -// ======================================== - -// PreWarmCache is called from Flutter to pre-warm cache for album/playlist tracks -// tracksJSON is a JSON array of {isrc, track_name, artist_name, service} func PreWarmCache(tracksJSON string) error { var requests []PreWarmCacheRequest @@ -272,13 +238,11 @@ func PreWarmCache(tracksJSON string) error { return nil } -// ClearTrackCache clears the track ID cache func ClearTrackCache() { GetTrackIDCache().Clear() fmt.Println("[Cache] Track ID cache cleared") } -// GetCacheSize returns the current cache size func GetCacheSize() int { return GetTrackIDCache().Size() } diff --git a/go_backend/progress.go b/go_backend/progress.go index cf18b5ee..159f4d6c 100644 --- a/go_backend/progress.go +++ b/go_backend/progress.go @@ -6,8 +6,6 @@ import ( "time" ) -// DownloadProgress represents current download progress -// Now unified - returns data from multi-progress system type DownloadProgress struct { CurrentFile string `json:"current_file"` Progress float64 `json:"progress"` @@ -15,21 +13,19 @@ type DownloadProgress struct { BytesTotal int64 `json:"bytes_total"` BytesReceived int64 `json:"bytes_received"` IsDownloading bool `json:"is_downloading"` - Status string `json:"status"` // "downloading", "finalizing", "completed" + Status string `json:"status"` } -// ItemProgress represents progress for a single download item type ItemProgress struct { ItemID string `json:"item_id"` BytesTotal int64 `json:"bytes_total"` BytesReceived int64 `json:"bytes_received"` - Progress float64 `json:"progress"` // 0.0 to 1.0 - SpeedMBps float64 `json:"speed_mbps"` // Download speed in MB/s + Progress float64 `json:"progress"` + SpeedMBps float64 `json:"speed_mbps"` IsDownloading bool `json:"is_downloading"` - Status string `json:"status"` // "downloading", "finalizing", "completed" + Status string `json:"status"` } -// MultiProgress holds progress for multiple concurrent downloads type MultiProgress struct { Items map[string]*ItemProgress `json:"items"` } @@ -38,12 +34,10 @@ var ( downloadDir string downloadDirMu sync.RWMutex - // Multi-download progress tracking (unified system) multiProgress = MultiProgress{Items: make(map[string]*ItemProgress)} multiMu sync.RWMutex ) -// getProgress returns current download progress from multi-progress system func getProgress() DownloadProgress { multiMu.RLock() defer multiMu.RUnlock() @@ -62,7 +56,6 @@ func getProgress() DownloadProgress { return DownloadProgress{} } -// GetMultiProgress returns progress for all active downloads as JSON func GetMultiProgress() string { multiMu.RLock() defer multiMu.RUnlock() @@ -74,7 +67,6 @@ func GetMultiProgress() string { return string(jsonBytes) } -// GetItemProgress returns progress for a specific item as JSON func GetItemProgress(itemID string) string { multiMu.RLock() defer multiMu.RUnlock() @@ -201,14 +193,6 @@ func setDownloadDir(path string) error { return nil } -// getDownloadDir returns the default download directory -// Kept for potential future use -// func getDownloadDir() string { -// downloadDirMu.RLock() -// defer downloadDirMu.RUnlock() -// return downloadDir -// } - // ItemProgressWriter wraps io.Writer to track download progress for a specific item type ItemProgressWriter struct { writer interface{ Write([]byte) (int, error) } diff --git a/go_backend/qobuz.go b/go_backend/qobuz.go index 0adb4a9d..d3a777ac 100644 --- a/go_backend/qobuz.go +++ b/go_backend/qobuz.go @@ -17,7 +17,6 @@ import ( "time" ) -// QobuzDownloader handles Qobuz downloads type QobuzDownloader struct { client *http.Client appID string @@ -29,7 +28,6 @@ var ( qobuzDownloaderOnce sync.Once ) -// QobuzTrack represents a Qobuz track type QobuzTrack struct { ID int64 `json:"id"` Title string `json:"title"` @@ -50,7 +48,6 @@ type QobuzTrack struct { } `json:"performer"` } -// qobuzArtistsMatch checks if the artist names are similar enough func qobuzArtistsMatch(expectedArtist, foundArtist string) bool { normExpected := strings.ToLower(strings.TrimSpace(expectedArtist)) normFound := strings.ToLower(strings.TrimSpace(foundArtist)) @@ -93,9 +90,7 @@ func qobuzArtistsMatch(expectedArtist, foundArtist string) bool { return false } -// qobuzSplitArtists splits artist string by common separators func qobuzSplitArtists(artists string) []string { - // Replace common separators with a standard one normalized := artists normalized = strings.ReplaceAll(normalized, " feat. ", "|") normalized = strings.ReplaceAll(normalized, " feat ", "|") @@ -154,7 +149,6 @@ func qobuzSameWordsUnordered(a, b string) bool { return true } -// qobuzTitlesMatch checks if track titles are similar enough func qobuzTitlesMatch(expectedTitle, foundTitle string) bool { normExpected := strings.ToLower(strings.TrimSpace(expectedTitle)) normFound := strings.ToLower(strings.TrimSpace(foundTitle)) @@ -164,12 +158,10 @@ func qobuzTitlesMatch(expectedTitle, foundTitle string) bool { return true } - // Check if one contains the other if strings.Contains(normExpected, normFound) || strings.Contains(normFound, normExpected) { return true } - // Clean BOTH titles and compare (removes suffixes like remaster, remix, etc) cleanExpected := qobuzCleanTitle(normExpected) cleanFound := qobuzCleanTitle(normFound) @@ -177,14 +169,12 @@ func qobuzTitlesMatch(expectedTitle, foundTitle string) bool { return true } - // Check if cleaned versions contain each other if cleanExpected != "" && cleanFound != "" { if strings.Contains(cleanExpected, cleanFound) || strings.Contains(cleanFound, cleanExpected) { return true } } - // Extract core title (before any parentheses/brackets) coreExpected := qobuzExtractCoreTitle(normExpected) coreFound := qobuzExtractCoreTitle(normFound) @@ -225,19 +215,15 @@ func qobuzExtractCoreTitle(title string) string { return strings.TrimSpace(title[:cutIdx]) } -// qobuzCleanTitle removes common suffixes from track titles for comparison func qobuzCleanTitle(title string) string { cleaned := title - // Remove content in parentheses/brackets that are version indicators - // This helps match "Song (Remastered)" with "Song" or "Song (2024 Remaster)" versionPatterns := []string{ "remaster", "remastered", "deluxe", "bonus", "single", "album version", "radio edit", "original mix", "extended", "club mix", "remix", "live", "acoustic", "demo", } - // Remove parenthetical content if it contains version indicators for { startParen := strings.LastIndex(cleaned, "(") endParen := strings.LastIndex(cleaned, ")") @@ -258,7 +244,6 @@ func qobuzCleanTitle(title string) string { break } - // Same for brackets for { startBracket := strings.LastIndex(cleaned, "[") endBracket := strings.LastIndex(cleaned, "]") @@ -279,7 +264,6 @@ func qobuzCleanTitle(title string) string { break } - // Remove trailing " - version" patterns dashPatterns := []string{ " - remaster", " - remastered", " - single version", " - radio edit", " - live", " - acoustic", " - demo", " - remix", @@ -290,7 +274,6 @@ func qobuzCleanTitle(title string) string { } } - // Remove multiple spaces for strings.Contains(cleaned, " ") { cleaned = strings.ReplaceAll(cleaned, " ", " ") } @@ -350,7 +333,6 @@ func containsQueryQobuz(queries []string, query string) bool { return false } -// NewQobuzDownloader creates a new Qobuz downloader (returns singleton for connection reuse) func NewQobuzDownloader() *QobuzDownloader { qobuzDownloaderOnce.Do(func() { globalQobuzDownloader = &QobuzDownloader{ @@ -361,7 +343,6 @@ func NewQobuzDownloader() *QobuzDownloader { return globalQobuzDownloader } -// GetTrackByID fetches track info directly by Qobuz track ID func (q *QobuzDownloader) GetTrackByID(trackID int64) (*QobuzTrack, error) { // Qobuz API: /track/get?track_id=XXX apiBase, _ := base64.StdEncoding.DecodeString("aHR0cHM6Ly93d3cucW9idXouY29tL2FwaS5qc29uLzAuMi90cmFjay9nZXQ/dHJhY2tfaWQ9") @@ -412,7 +393,6 @@ func (q *QobuzDownloader) GetAvailableAPIs() []string { return apis } -// SearchTrackByISRC searches for a track by ISRC func (q *QobuzDownloader) SearchTrackByISRC(isrc string) (*QobuzTrack, error) { apiBase, _ := base64.StdEncoding.DecodeString("aHR0cHM6Ly93d3cucW9idXouY29tL2FwaS5qc29uLzAuMi90cmFjay9zZWFyY2g/cXVlcnk9") searchURL := fmt.Sprintf("%s%s&limit=50&app_id=%s", string(apiBase), url.QueryEscape(isrc), q.appID) @@ -455,7 +435,6 @@ func (q *QobuzDownloader) SearchTrackByISRC(isrc string) (*QobuzTrack, error) { return nil, fmt.Errorf("no exact ISRC match found for: %s", isrc) } -// SearchTrackByISRCWithTitle searches for a track by ISRC with duration verification // expectedDurationSec is the expected duration in seconds (0 to skip verification) func (q *QobuzDownloader) SearchTrackByISRCWithDuration(isrc string, expectedDurationSec int) (*QobuzTrack, error) { GoLog("[Qobuz] Searching by ISRC: %s\n", isrc) @@ -500,7 +479,6 @@ func (q *QobuzDownloader) SearchTrackByISRCWithDuration(isrc string, expectedDur GoLog("[Qobuz] Found %d exact ISRC matches\n", len(isrcMatches)) if len(isrcMatches) > 0 { - // Verify duration if provided if expectedDurationSec > 0 { var durationVerifiedMatches []*QobuzTrack for _, track := range isrcMatches { @@ -508,7 +486,6 @@ func (q *QobuzDownloader) SearchTrackByISRCWithDuration(isrc string, expectedDur if durationDiff < 0 { durationDiff = -durationDiff } - // Allow 10 seconds tolerance if durationDiff <= 10 { durationVerifiedMatches = append(durationVerifiedMatches, track) } @@ -520,14 +497,12 @@ func (q *QobuzDownloader) SearchTrackByISRCWithDuration(isrc string, expectedDur return durationVerifiedMatches[0], nil } - // ISRC matches but duration doesn't GoLog("[Qobuz] WARNING: ISRC %s found but duration mismatch. Expected=%ds, Found=%ds. Rejecting.\n", isrc, expectedDurationSec, isrcMatches[0].Duration) return nil, fmt.Errorf("ISRC found but duration mismatch: expected %ds, found %ds (likely different version)", expectedDurationSec, isrcMatches[0].Duration) } - // No duration to verify, return first match GoLog("[Qobuz] ISRC match (no duration verification): '%s'\n", isrcMatches[0].Title) return isrcMatches[0], nil } @@ -539,17 +514,14 @@ func (q *QobuzDownloader) SearchTrackByISRCWithDuration(isrc string, expectedDur return nil, fmt.Errorf("no exact ISRC match found for: %s", isrc) } -// SearchTrackByISRCWithTitle is deprecated, use SearchTrackByISRCWithDuration instead func (q *QobuzDownloader) SearchTrackByISRCWithTitle(isrc, expectedTitle string) (*QobuzTrack, error) { return q.SearchTrackByISRCWithDuration(isrc, 0) } -// SearchTrackByMetadata searches for a track using artist name and track name func (q *QobuzDownloader) SearchTrackByMetadata(trackName, artistName string) (*QobuzTrack, error) { return q.SearchTrackByMetadataWithDuration(trackName, artistName, 0) } -// SearchTrackByMetadataWithDuration searches for a track with duration verification // Now includes romaji conversion for Japanese text (same as Tidal) // Also includes title verification to prevent wrong song downloads func (q *QobuzDownloader) SearchTrackByMetadataWithDuration(trackName, artistName string, expectedDurationSec int) (*QobuzTrack, error) { @@ -688,7 +660,6 @@ func (q *QobuzDownloader) SearchTrackByMetadataWithDuration(trackName, artistNam } if len(durationMatches) > 0 { - // Return best quality among duration matches for _, track := range durationMatches { if track.MaximumBitDepth >= 24 { GoLog("[Qobuz] ✓ Match found: '%s' by '%s' (title+duration verified, hi-res)\n", @@ -701,7 +672,6 @@ func (q *QobuzDownloader) SearchTrackByMetadataWithDuration(trackName, artistNam return durationMatches[0], nil } - // No duration match found return nil, fmt.Errorf("no tracks found with matching title and duration (expected '%s', %ds)", trackName, expectedDurationSec) } @@ -731,8 +701,6 @@ type qobuzAPIResult struct { duration time.Duration } -// getQobuzDownloadURLParallel requests download URL from all APIs in parallel -// "Siapa cepat dia dapat" - first successful response wins func getQobuzDownloadURLParallel(apis []string, trackID int64, quality string) (string, string, error) { if len(apis) == 0 { return "", "", fmt.Errorf("no APIs available") @@ -839,8 +807,6 @@ func getQobuzDownloadURLParallel(apis []string, trackID int64, quality string) ( return "", "", fmt.Errorf("all %d Qobuz APIs failed. Errors: %v", len(apis), errors) } -// GetDownloadURL gets download URL for a track - tries ALL APIs in parallel -// "Siapa cepat dia dapat" - first successful response wins func (q *QobuzDownloader) GetDownloadURL(trackID int64, quality string) (string, error) { apis := q.GetAvailableAPIs() if len(apis) == 0 { @@ -938,7 +904,6 @@ func (q *QobuzDownloader) DownloadFile(downloadURL, outputPath, itemID string) e return nil } -// QobuzDownloadResult contains download result with quality info type QobuzDownloadResult struct { FilePath string BitDepth int @@ -952,7 +917,6 @@ type QobuzDownloadResult struct { ISRC string } -// downloadFromQobuz downloads a track using the request parameters func downloadFromQobuz(req DownloadRequest) (QobuzDownloadResult, error) { downloader := NewQobuzDownloader() @@ -1135,15 +1099,12 @@ func downloadFromQobuz(req DownloadRequest) (QobuzDownloadResult, error) { fmt.Printf("Warning: failed to embed metadata: %v\n", err) } - // Handle lyrics based on LyricsMode setting - // Mode: "embed" (default), "external" (.lrc file), "both" if req.EmbedLyrics && parallelResult != nil && parallelResult.LyricsLRC != "" { lyricsMode := req.LyricsMode if lyricsMode == "" { - lyricsMode = "embed" // default + lyricsMode = "embed" } - // Save external .lrc file if mode is "external" or "both" if lyricsMode == "external" || lyricsMode == "both" { GoLog("[Qobuz] Saving external LRC file...\n") if lrcPath, lrcErr := SaveLRCFile(outputPath, parallelResult.LyricsLRC); lrcErr != nil { @@ -1153,7 +1114,6 @@ func downloadFromQobuz(req DownloadRequest) (QobuzDownloadResult, error) { } } - // Embed lyrics if mode is "embed" or "both" if lyricsMode == "embed" || lyricsMode == "both" { GoLog("[Qobuz] Embedding parallel-fetched lyrics (%d lines)...\n", len(parallelResult.LyricsData.Lines)) if embedErr := EmbedLyrics(outputPath, parallelResult.LyricsLRC); embedErr != nil { diff --git a/go_backend/ratelimit.go b/go_backend/ratelimit.go index 1caa54d2..1f2ac1f6 100644 --- a/go_backend/ratelimit.go +++ b/go_backend/ratelimit.go @@ -5,7 +5,6 @@ import ( "time" ) -// RateLimiter implements a sliding window rate limiter type RateLimiter struct { mu sync.Mutex maxRequests int @@ -13,7 +12,6 @@ type RateLimiter struct { timestamps []time.Time } -// NewRateLimiter creates a new rate limiter with specified max requests per window func NewRateLimiter(maxRequests int, window time.Duration) *RateLimiter { return &RateLimiter{ maxRequests: maxRequests, @@ -22,8 +20,6 @@ func NewRateLimiter(maxRequests int, window time.Duration) *RateLimiter { } } -// WaitForSlot blocks until a request is allowed under the rate limit -// Returns immediately if under the limit, otherwise waits until a slot is available func (r *RateLimiter) WaitForSlot() { r.mu.Lock() defer r.mu.Unlock() @@ -70,8 +66,6 @@ func (r *RateLimiter) cleanOldTimestamps(now time.Time) { } } -// TryAcquire attempts to acquire a slot without blocking -// Returns true if successful, false if rate limit would be exceeded func (r *RateLimiter) TryAcquire() bool { r.mu.Lock() defer r.mu.Unlock() @@ -87,7 +81,6 @@ func (r *RateLimiter) TryAcquire() bool { return false } -// Available returns the number of requests available in the current window func (r *RateLimiter) Available() int { r.mu.Lock() defer r.mu.Unlock() @@ -99,7 +92,6 @@ func (r *RateLimiter) Available() int { // Global SongLink rate limiter - 9 requests per minute (to be safe, limit is 10) var songLinkRateLimiter = NewRateLimiter(9, time.Minute) -// GetSongLinkRateLimiter returns the global SongLink rate limiter func GetSongLinkRateLimiter() *RateLimiter { return songLinkRateLimiter } diff --git a/go_backend/romaji.go b/go_backend/romaji.go index 1e1516e3..d5a73963 100644 --- a/go_backend/romaji.go +++ b/go_backend/romaji.go @@ -5,7 +5,6 @@ import ( "unicode" ) -// Hiragana to Romaji mapping var hiraganaToRomaji = map[rune]string{ 'あ': "a", 'い': "i", 'う': "u", 'え': "e", 'お': "o", 'か': "ka", 'き': "ki", 'く': "ku", 'け': "ke", 'こ': "ko", @@ -30,7 +29,6 @@ var hiraganaToRomaji = map[rune]string{ 'ぁ': "a", 'ぃ': "i", 'ぅ': "u", 'ぇ': "e", 'ぉ': "o", } -// Katakana to Romaji mapping var katakanaToRomaji = map[rune]string{ 'ア': "a", 'イ': "i", 'ウ': "u", 'エ': "e", 'オ': "o", 'カ': "ka", 'キ': "ki", 'ク': "ku", 'ケ': "ke", 'コ': "ko", @@ -58,7 +56,6 @@ var katakanaToRomaji = map[rune]string{ 'ヴ': "vu", } -// Combination mappings for きゃ, しゃ, etc. var combinationHiragana = map[string]string{ "きゃ": "kya", "きゅ": "kyu", "きょ": "kyo", "しゃ": "sha", "しゅ": "shu", "しょ": "sho", @@ -91,7 +88,6 @@ var combinationKatakana = map[string]string{ "ウィ": "wi", "ウェ": "we", "ウォ": "wo", } -// ContainsJapanese checks if a string contains Japanese characters func ContainsJapanese(s string) bool { for _, r := range s { if isHiragana(r) || isKatakana(r) || isKanji(r) { @@ -114,8 +110,6 @@ func isKanji(r rune) bool { (r >= 0x3400 && r <= 0x4DBF) // CJK Unified Ideographs Extension A } -// JapaneseToRomaji converts Japanese text (hiragana/katakana) to romaji -// Note: Kanji cannot be converted without a dictionary, so they are kept as-is func JapaneseToRomaji(text string) string { if !ContainsJapanese(text) { return text @@ -175,8 +169,6 @@ func JapaneseToRomaji(text string) string { return result.String() } -// BuildSearchQuery creates a search query from track name and artist -// Converts Japanese to romaji if present func BuildSearchQuery(trackName, artistName string) string { // Convert Japanese to romaji trackRomaji := JapaneseToRomaji(trackName) @@ -189,7 +181,6 @@ func BuildSearchQuery(trackName, artistName string) string { return strings.TrimSpace(artistClean + " " + trackClean) } -// cleanSearchQuery removes special characters that might interfere with search func cleanSearchQuery(s string) string { var result strings.Builder for _, r := range s { @@ -202,8 +193,6 @@ func cleanSearchQuery(s string) string { return strings.TrimSpace(result.String()) } -// CleanToASCII removes all non-ASCII characters and keeps only letters, numbers, spaces -// This is useful for creating search queries that work better with Tidal's search func CleanToASCII(s string) string { var result strings.Builder for _, r := range s { diff --git a/go_backend/songlink.go b/go_backend/songlink.go index 63e1bbab..f898af9a 100644 --- a/go_backend/songlink.go +++ b/go_backend/songlink.go @@ -11,12 +11,10 @@ import ( "time" ) -// SongLinkClient handles song.link API interactions type SongLinkClient struct { client *http.Client } -// TrackAvailability represents track availability on different platforms type TrackAvailability struct { SpotifyID string `json:"spotify_id"` Tidal bool `json:"tidal"` @@ -35,7 +33,6 @@ var ( songLinkClientOnce sync.Once ) -// NewSongLinkClient creates a new SongLink client (returns singleton for connection reuse) func NewSongLinkClient() *SongLinkClient { songLinkClientOnce.Do(func() { globalSongLinkClient = &SongLinkClient{ @@ -45,7 +42,6 @@ func NewSongLinkClient() *SongLinkClient { return globalSongLinkClient } -// CheckTrackAvailability checks track availability on streaming platforms func (s *SongLinkClient) CheckTrackAvailability(spotifyTrackID string, isrc string) (*TrackAvailability, error) { if spotifyTrackID == "" { return nil, fmt.Errorf("spotify track ID is empty") @@ -126,7 +122,6 @@ func (s *SongLinkClient) CheckTrackAvailability(spotifyTrackID string, isrc stri return availability, nil } -// GetStreamingURLs gets streaming URLs for a Spotify track func (s *SongLinkClient) GetStreamingURLs(spotifyTrackID string) (map[string]string, error) { availability, err := s.CheckTrackAvailability(spotifyTrackID, "") if err != nil { @@ -191,7 +186,6 @@ func extractDeezerIDFromURL(deezerURL string) string { return "" } -// GetDeezerIDFromSpotify converts a Spotify track ID to Deezer track ID using SongLink func (s *SongLinkClient) GetDeezerIDFromSpotify(spotifyTrackID string) (string, error) { availability, err := s.CheckTrackAvailability(spotifyTrackID, "") if err != nil { @@ -213,7 +207,6 @@ type AlbumAvailability struct { DeezerID string `json:"deezer_id,omitempty"` } -// CheckAlbumAvailability checks album availability on streaming platforms using SongLink func (s *SongLinkClient) CheckAlbumAvailability(spotifyAlbumID string) (*AlbumAvailability, error) { // Use global rate limiter songLinkRateLimiter.WaitForSlot() @@ -283,11 +276,6 @@ func (s *SongLinkClient) GetDeezerAlbumIDFromSpotify(spotifyAlbumID string) (str return availability.DeezerID, nil } -// ======================================== -// Deezer ID Support - Query SongLink using Deezer as source -// ======================================== - -// CheckAvailabilityFromDeezer checks track availability using Deezer track ID as source // This is useful when we have Deezer metadata and want to find the track on other platforms func (s *SongLinkClient) CheckAvailabilityFromDeezer(deezerTrackID string) (*TrackAvailability, error) { if deezerTrackID == "" { @@ -374,7 +362,6 @@ func (s *SongLinkClient) CheckAvailabilityFromDeezer(deezerTrackID string) (*Tra return availability, nil } -// CheckAvailabilityByPlatform checks track availability using any supported platform // platform: "spotify", "deezer", "tidal", "amazonMusic", "appleMusic", "youtube", etc. // entityType: "song" or "album" // entityID: the ID on that platform @@ -472,7 +459,6 @@ func extractSpotifyIDFromURL(spotifyURL string) string { return "" } -// GetSpotifyIDFromDeezer converts a Deezer track ID to Spotify track ID using SongLink func (s *SongLinkClient) GetSpotifyIDFromDeezer(deezerTrackID string) (string, error) { availability, err := s.CheckAvailabilityFromDeezer(deezerTrackID) if err != nil { @@ -500,7 +486,6 @@ func (s *SongLinkClient) GetTidalURLFromDeezer(deezerTrackID string) (string, er return availability.TidalURL, nil } -// GetAmazonURLFromDeezer converts a Deezer track ID to Amazon Music URL using SongLink func (s *SongLinkClient) GetAmazonURLFromDeezer(deezerTrackID string) (string, error) { availability, err := s.CheckAvailabilityFromDeezer(deezerTrackID) if err != nil { diff --git a/go_backend/spotify.go b/go_backend/spotify.go index 22f4cf99..65b37143 100644 --- a/go_backend/spotify.go +++ b/go_backend/spotify.go @@ -24,7 +24,6 @@ const ( artistAlbumsURL = "https://api.spotify.com/v1/artists/%s/albums" searchBaseURL = "https://api.spotify.com/v1/search" - // Cache TTL settings artistCacheTTL = 10 * time.Minute searchCacheTTL = 5 * time.Minute albumCacheTTL = 10 * time.Minute @@ -32,7 +31,6 @@ const ( var errInvalidSpotifyURL = errors.New("invalid or unsupported Spotify URL") -// cacheEntry holds cached data with expiration type cacheEntry struct { data interface{} expiresAt time.Time @@ -42,26 +40,23 @@ func (e *cacheEntry) isExpired() bool { return time.Now().After(e.expiresAt) } -// SpotifyMetadataClient handles Spotify API interactions type SpotifyMetadataClient struct { httpClient *http.Client clientID string clientSecret string cachedToken string tokenExpiresAt time.Time - tokenMu sync.Mutex // Protects token cache for concurrent access + tokenMu sync.Mutex rng *rand.Rand rngMu sync.Mutex userAgent string - // Caches to reduce API calls - artistCache map[string]*cacheEntry // key: artistID - searchCache map[string]*cacheEntry // key: query+type - albumCache map[string]*cacheEntry // key: albumID + artistCache map[string]*cacheEntry + searchCache map[string]*cacheEntry + albumCache map[string]*cacheEntry cacheMu sync.RWMutex } -// Custom credentials storage (set from Flutter) var ( customClientID string customClientSecret string @@ -79,7 +74,6 @@ func SetSpotifyCredentials(clientID, clientSecret string) { customClientSecret = clientSecret } -// HasSpotifyCredentials checks if Spotify credentials are configured func HasSpotifyCredentials() bool { credentialsMu.RLock() defer credentialsMu.RUnlock() @@ -114,8 +108,6 @@ func getCredentials() (string, string, error) { return "", "", ErrNoSpotifyCredentials } -// NewSpotifyMetadataClient creates a new Spotify client -// Returns error if credentials are not configured func NewSpotifyMetadataClient() (*SpotifyMetadataClient, error) { clientID, clientSecret, err := getCredentials() if err != nil { @@ -137,7 +129,6 @@ func NewSpotifyMetadataClient() (*SpotifyMetadataClient, error) { return c, nil } -// TrackMetadata represents track information type TrackMetadata struct { SpotifyID string `json:"spotify_id,omitempty"` Artists string `json:"artists"` @@ -155,7 +146,6 @@ type TrackMetadata struct { AlbumType string `json:"album_type,omitempty"` // album, single, ep, compilation } -// AlbumTrackMetadata holds per-track info for album/playlist type AlbumTrackMetadata struct { SpotifyID string `json:"spotify_id,omitempty"` Artists string `json:"artists"` @@ -172,28 +162,25 @@ type AlbumTrackMetadata struct { ISRC string `json:"isrc"` AlbumID string `json:"album_id,omitempty"` AlbumURL string `json:"album_url,omitempty"` - AlbumType string `json:"album_type,omitempty"` // album, single, ep, compilation + AlbumType string `json:"album_type,omitempty"` } -// AlbumInfoMetadata holds album information type AlbumInfoMetadata struct { TotalTracks int `json:"total_tracks"` Name string `json:"name"` ReleaseDate string `json:"release_date"` Artists string `json:"artists"` Images string `json:"images"` - Genre string `json:"genre,omitempty"` // Music genre(s), comma-separated - Label string `json:"label,omitempty"` // Record label name - Copyright string `json:"copyright,omitempty"` // Copyright information + Genre string `json:"genre,omitempty"` + Label string `json:"label,omitempty"` + Copyright string `json:"copyright,omitempty"` } -// AlbumResponsePayload is the response for album requests type AlbumResponsePayload struct { AlbumInfo AlbumInfoMetadata `json:"album_info"` TrackList []AlbumTrackMetadata `json:"track_list"` } -// PlaylistInfoMetadata holds playlist information type PlaylistInfoMetadata struct { Tracks struct { Total int `json:"total"` @@ -205,13 +192,11 @@ type PlaylistInfoMetadata struct { } `json:"owner"` } -// PlaylistResponsePayload is the response for playlist requests type PlaylistResponsePayload struct { PlaylistInfo PlaylistInfoMetadata `json:"playlist_info"` TrackList []AlbumTrackMetadata `json:"track_list"` } -// ArtistInfoMetadata holds artist information type ArtistInfoMetadata struct { ID string `json:"id"` Name string `json:"name"` @@ -220,7 +205,6 @@ type ArtistInfoMetadata struct { Popularity int `json:"popularity"` } -// ArtistAlbumMetadata holds album info for artist discography type ArtistAlbumMetadata struct { ID string `json:"id"` Name string `json:"name"` @@ -231,24 +215,20 @@ type ArtistAlbumMetadata struct { Artists string `json:"artists"` } -// ArtistResponsePayload is the response for artist requests type ArtistResponsePayload struct { ArtistInfo ArtistInfoMetadata `json:"artist_info"` Albums []ArtistAlbumMetadata `json:"albums"` } -// TrackResponse is the response for single track requests type TrackResponse struct { Track TrackMetadata `json:"track"` } -// SearchResult represents search results type SearchResult struct { Tracks []TrackMetadata `json:"tracks"` Total int `json:"total"` } -// SearchArtistResult represents an artist in search results type SearchArtistResult struct { ID string `json:"id"` Name string `json:"name"` @@ -257,7 +237,6 @@ type SearchArtistResult struct { Popularity int `json:"popularity"` } -// SearchAllResult represents combined search results for tracks and artists type SearchAllResult struct { Tracks []TrackMetadata `json:"tracks"` Artists []SearchArtistResult `json:"artists"` @@ -274,7 +253,6 @@ type accessTokenResponse struct { TokenType string `json:"token_type"` } -// Internal API response types type image struct { URL string `json:"url"` } @@ -300,7 +278,7 @@ type albumSimplified struct { Images []image `json:"images"` ExternalURL externalURL `json:"external_urls"` Artists []artist `json:"artists"` - AlbumType string `json:"album_type"` // album, single, compilation + AlbumType string `json:"album_type"` } type trackFull struct { @@ -315,7 +293,6 @@ type trackFull struct { Artists []artist `json:"artists"` } -// GetFilteredData fetches and formats Spotify data func (c *SpotifyMetadataClient) GetFilteredData(ctx context.Context, spotifyURL string, batch bool, delay time.Duration) (interface{}, error) { parsed, err := parseSpotifyURI(spotifyURL) if err != nil { @@ -341,7 +318,6 @@ func (c *SpotifyMetadataClient) GetFilteredData(ctx context.Context, spotifyURL } } -// SearchTracks searches for tracks on Spotify func (c *SpotifyMetadataClient) SearchTracks(ctx context.Context, query string, limit int) (*SearchResult, error) { token, err := c.getAccessToken(ctx) if err != nil { @@ -388,7 +364,6 @@ func (c *SpotifyMetadataClient) SearchTracks(ctx context.Context, query string, return result, nil } -// SearchAll searches for tracks and artists on Spotify func (c *SpotifyMetadataClient) SearchAll(ctx context.Context, query string, trackLimit, artistLimit int) (*SearchAllResult, error) { cacheKey := fmt.Sprintf("all:%s:%d:%d", query, trackLimit, artistLimit) @@ -510,7 +485,6 @@ func (c *SpotifyMetadataClient) fetchAlbum(ctx context.Context, albumID, token s } c.cacheMu.RUnlock() - // Track item structure for pagination type trackItem struct { ID string `json:"id"` Name string `json:"name"` @@ -546,11 +520,9 @@ func (c *SpotifyMetadataClient) fetchAlbum(ctx context.Context, albumID, token s Images: albumImage, } - // Collect all tracks (including paginated) allTrackItems := data.Tracks.Items nextURL := data.Tracks.Next - // Fetch remaining tracks using pagination (no limit) for nextURL != "" { var pageData struct { Items []trackItem `json:"items"` @@ -572,7 +544,6 @@ func (c *SpotifyMetadataClient) fetchAlbum(ctx context.Context, albumID, token s trackIDs[i] = item.ID } - // Fetch ISRCs in parallel for ALL tracks (like Deezer implementation) isrcMap := c.fetchISRCsParallel(ctx, trackIDs, token) tracks := make([]AlbumTrackMetadata, 0, len(allTrackItems)) @@ -612,10 +583,8 @@ func (c *SpotifyMetadataClient) fetchAlbum(ctx context.Context, albumID, token s return result, nil } -// fetchISRCsParallel fetches ISRCs for multiple tracks in parallel -// Similar to Deezer implementation for consistency func (c *SpotifyMetadataClient) fetchISRCsParallel(ctx context.Context, trackIDs []string, token string) map[string]string { - const maxParallelISRC = 10 // Max concurrent ISRC fetches + const maxParallelISRC = 10 result := make(map[string]string) var resultMu sync.Mutex @@ -624,7 +593,6 @@ func (c *SpotifyMetadataClient) fetchISRCsParallel(ctx context.Context, trackIDs return result } - // Use semaphore to limit concurrent requests sem := make(chan struct{}, maxParallelISRC) var wg sync.WaitGroup @@ -633,7 +601,6 @@ func (c *SpotifyMetadataClient) fetchISRCsParallel(ctx context.Context, trackIDs go func(id string) { defer wg.Done() - // Acquire semaphore select { case sem <- struct{}{}: defer func() { <-sem }() @@ -654,7 +621,6 @@ func (c *SpotifyMetadataClient) fetchISRCsParallel(ctx context.Context, trackIDs } func (c *SpotifyMetadataClient) fetchPlaylist(ctx context.Context, playlistID, token string) (*PlaylistResponsePayload, error) { - // First request to get playlist info and first batch of tracks var data struct { Name string `json:"name"` Images []image `json:"images"` @@ -680,10 +646,8 @@ func (c *SpotifyMetadataClient) fetchPlaylist(ctx context.Context, playlistID, t info.Owner.Name = data.Name info.Owner.Images = firstImageURL(data.Images) - // Pre-allocate with expected capacity tracks := make([]AlbumTrackMetadata, 0, data.Tracks.Total) - // Add first batch of tracks for _, item := range data.Tracks.Items { if item.Track == nil { continue @@ -707,7 +671,6 @@ func (c *SpotifyMetadataClient) fetchPlaylist(ctx context.Context, playlistID, t }) } - // Fetch remaining tracks using pagination (NO LIMIT - fetch all tracks) nextURL := data.Tracks.Next for nextURL != "" { @@ -719,7 +682,6 @@ func (c *SpotifyMetadataClient) fetchPlaylist(ctx context.Context, playlistID, t } if err := c.getJSON(ctx, nextURL, token, &pageData); err != nil { - // Log error but return what we have so far fmt.Printf("[Spotify] Warning: failed to fetch page, returning %d tracks: %v\n", len(tracks), err) break } @@ -766,7 +728,6 @@ func (c *SpotifyMetadataClient) fetchArtist(ctx context.Context, artistID, token } c.cacheMu.RUnlock() - // Fetch artist info var artistData struct { ID string `json:"id"` Name string `json:"name"` @@ -789,7 +750,6 @@ func (c *SpotifyMetadataClient) fetchArtist(ctx context.Context, artistID, token Popularity: artistData.Popularity, } - // Fetch artist albums (all types: album, single, compilation) albums := make([]ArtistAlbumMetadata, 0) offset := 0 limit := 50 @@ -829,13 +789,11 @@ func (c *SpotifyMetadataClient) fetchArtist(ctx context.Context, artistID, token }) } - // Check if there are more albums if albumsData.Next == "" || len(albumsData.Items) < limit { break } offset += limit - // Safety limit to prevent infinite loops if offset > 500 { break } @@ -916,7 +874,6 @@ func (c *SpotifyMetadataClient) getJSON(ctx context.Context, endpoint, token str return err } - // Set headers (same as PC version baseHeaders) req.Header.Set("User-Agent", c.userAgent) req.Header.Set("Accept", "application/json") req.Header.Set("Accept-Language", "en-US,en;q=0.9") @@ -952,8 +909,7 @@ func (c *SpotifyMetadataClient) randomUserAgent() string { c.rngMu.Lock() defer c.rngMu.Unlock() - // Use Mac User-Agent format (same as PC version) - macMajor := c.rng.Intn(4) + 11 // 11-14 + macMajor := c.rng.Intn(4) + 11 macMinor := c.rng.Intn(5) + 4 // 4-8 webkitMajor := c.rng.Intn(7) + 530 // 530-536 webkitMinor := c.rng.Intn(7) + 30 // 30-36 @@ -978,7 +934,6 @@ func parseSpotifyURI(input string) (spotifyURI, error) { return spotifyURI{}, errInvalidSpotifyURL } - // Handle spotify: URI format if strings.HasPrefix(trimmed, "spotify:") { parts := strings.Split(trimmed, ":") if len(parts) == 3 { @@ -989,13 +944,11 @@ func parseSpotifyURI(input string) (spotifyURI, error) { } } - // Handle URL format parsed, err := url.Parse(trimmed) if err != nil { return spotifyURI{}, err } - // Handle embed.spotify.com URLs if parsed.Host == "embed.spotify.com" { if parsed.RawQuery == "" { return spotifyURI{}, errInvalidSpotifyURL @@ -1008,7 +961,6 @@ func parseSpotifyURI(input string) (spotifyURI, error) { return parseSpotifyURI(embedded) } - // Handle plain ID (no scheme/host) - defaults to playlist if parsed.Scheme == "" && parsed.Host == "" { id := strings.Trim(strings.TrimSpace(parsed.Path), "/") if id == "" { @@ -1034,7 +986,6 @@ func parseSpotifyURI(input string) (spotifyURI, error) { return spotifyURI{}, errInvalidSpotifyURL } - // Skip intl- prefix if present if strings.HasPrefix(parts[0], "intl-") { parts = parts[1:] } @@ -1042,7 +993,6 @@ func parseSpotifyURI(input string) (spotifyURI, error) { return spotifyURI{}, errInvalidSpotifyURL } - // Handle standard URLs: /album/{id}, /track/{id}, /playlist/{id}, /artist/{id} if len(parts) == 2 { switch parts[0] { case "album", "track", "playlist", "artist": @@ -1050,7 +1000,6 @@ func parseSpotifyURI(input string) (spotifyURI, error) { } } - // Handle nested playlist URLs: /user/{user}/playlist/{id} if len(parts) == 4 && parts[2] == "playlist" { return spotifyURI{Type: "playlist", ID: parts[3]}, nil } diff --git a/go_backend/tidal.go b/go_backend/tidal.go index aeeef441..8c245b64 100644 --- a/go_backend/tidal.go +++ b/go_backend/tidal.go @@ -19,7 +19,6 @@ import ( "time" ) -// TidalDownloader handles Tidal downloads type TidalDownloader struct { client *http.Client clientID string @@ -35,7 +34,6 @@ var ( tidalDownloaderOnce sync.Once ) -// TidalTrack represents a Tidal track type TidalTrack struct { ID int64 `json:"id"` Title string `json:"title"` @@ -60,7 +58,6 @@ type TidalTrack struct { } `json:"mediaMetadata"` } -// TidalAPIResponseV2 is the new API response format (version 2.0) type TidalAPIResponseV2 struct { Version string `json:"version"` Data struct { @@ -76,7 +73,6 @@ type TidalAPIResponseV2 struct { } `json:"data"` } -// TidalBTSManifest is the BTS (application/vnd.tidal.bts) manifest format type TidalBTSManifest struct { MimeType string `json:"mimeType"` Codecs string `json:"codecs"` @@ -84,7 +80,6 @@ type TidalBTSManifest struct { URLs []string `json:"urls"` } -// MPD represents DASH manifest structure type MPD struct { XMLName xml.Name `xml:"MPD"` Period struct { @@ -105,7 +100,6 @@ type MPD struct { } `xml:"Period"` } -// NewTidalDownloader creates a new Tidal downloader (returns singleton for token reuse) func NewTidalDownloader() *TidalDownloader { tidalDownloaderOnce.Do(func() { clientID, _ := base64.StdEncoding.DecodeString("NkJEU1JkcEs5aHFFQlRnVQ==") @@ -150,7 +144,6 @@ func (t *TidalDownloader) GetAvailableAPIs() []string { return apis } -// GetAccessToken gets Tidal access token (with caching) func (t *TidalDownloader) GetAccessToken() (string, error) { t.tokenMu.Lock() defer t.tokenMu.Unlock() @@ -199,7 +192,6 @@ func (t *TidalDownloader) GetAccessToken() (string, error) { return result.AccessToken, nil } -// GetTidalURLFromSpotify gets Tidal URL from Spotify track ID using SongLink func (t *TidalDownloader) GetTidalURLFromSpotify(spotifyTrackID string) (string, error) { spotifyBase, _ := base64.StdEncoding.DecodeString("aHR0cHM6Ly9vcGVuLnNwb3RpZnkuY29tL3RyYWNrLw==") spotifyURL := fmt.Sprintf("%s%s", string(spotifyBase), spotifyTrackID) @@ -239,7 +231,6 @@ func (t *TidalDownloader) GetTidalURLFromSpotify(spotifyTrackID string) (string, return tidalLink.URL, nil } -// GetTrackIDFromURL extracts track ID from Tidal URL func (t *TidalDownloader) GetTrackIDFromURL(tidalURL string) (int64, error) { parts := strings.Split(tidalURL, "/track/") if len(parts) < 2 { @@ -293,7 +284,6 @@ func (t *TidalDownloader) GetTrackInfoByID(trackID int64) (*TidalTrack, error) { return &trackInfo, nil } -// SearchTrackByISRC searches for a track by ISRC func (t *TidalDownloader) SearchTrackByISRC(isrc string) (*TidalTrack, error) { token, err := t.GetAccessToken() if err != nil { @@ -341,30 +331,7 @@ func (t *TidalDownloader) SearchTrackByISRC(isrc string) (*TidalTrack, error) { return nil, fmt.Errorf("no exact ISRC match found for: %s", isrc) } -// normalizeTitle normalizes a track title for comparison -// Kept for potential future use -// func normalizeTitle(title string) string { -// normalized := strings.ToLower(strings.TrimSpace(title)) -// -// // Remove common suffixes in parentheses or brackets -// suffixPatterns := []string{ -// " (remaster)", " (remastered)", " (deluxe)", " (deluxe edition)", -// " (bonus track)", " (single)", " (album version)", " (radio edit)", -// " [remaster]", " [remastered]", " [deluxe]", " [bonus track]", -// } -// for _, suffix := range suffixPatterns { -// normalized = strings.TrimSuffix(normalized, suffix) -// } -// -// // Remove multiple spaces -// for strings.Contains(normalized, " ") { -// normalized = strings.ReplaceAll(normalized, " ", " ") -// } -// -// return normalized -// } -// SearchTrackByMetadataWithISRC searches for a track with ISRC matching priority // Now includes romaji conversion for Japanese text (4 search strategies like PC) func (t *TidalDownloader) SearchTrackByMetadataWithISRC(trackName, artistName, spotifyISRC string, expectedDuration int) (*TidalTrack, error) { token, err := t.GetAccessToken() @@ -466,7 +433,6 @@ func (t *TidalDownloader) SearchTrackByMetadataWithISRC(trackName, artistName, s if len(result.Items) > 0 { GoLog("[Tidal] Found %d results for '%s'\n", len(result.Items), cleanQuery) - // OPTIMIZATION: If ISRC provided, check for match immediately and return early if spotifyISRC != "" { for i := range result.Items { if result.Items[i].ISRC == spotifyISRC { @@ -592,7 +558,6 @@ func (t *TidalDownloader) SearchTrackByMetadataWithISRC(trackName, artistName, s return bestMatch, nil } -// containsQuery checks if a query already exists in the list func containsQuery(queries []string, query string) bool { for _, q := range queries { if q == query { @@ -602,7 +567,6 @@ func containsQuery(queries []string, query string) bool { return false } -// SearchTrackByMetadata searches for a track using artist name and track name func (t *TidalDownloader) SearchTrackByMetadata(trackName, artistName string) (*TidalTrack, error) { return t.SearchTrackByMetadataWithISRC(trackName, artistName, "", 0) } @@ -614,7 +578,6 @@ type TidalDownloadInfo struct { SampleRate int } -// tidalAPIResult holds the result from a parallel API request type tidalAPIResult struct { apiURL string info TidalDownloadInfo @@ -622,9 +585,7 @@ type tidalAPIResult struct { duration time.Duration } -// getDownloadURLParallel requests download URL from all APIs in parallel // Returns the first successful result (supports both v1 and v2 API formats) -// "Siapa cepat dia dapat" - first success wins func getDownloadURLParallel(apis []string, trackID int64, quality string) (string, TidalDownloadInfo, error) { if len(apis) == 0 { return "", TidalDownloadInfo{}, fmt.Errorf("no APIs available") @@ -671,8 +632,7 @@ func getDownloadURLParallel(apis []string, trackID int64, quality string) (strin var v2Response TidalAPIResponseV2 if err := json.Unmarshal(body, &v2Response); err == nil && v2Response.Data.Manifest != "" { - // IMPORTANT: Reject PREVIEW responses - we need FULL tracks - if v2Response.Data.AssetPresentation == "PREVIEW" { + if v2Response.Data.AssetPresentation == "PREVIEW" { resultChan <- tidalAPIResult{apiURL: api, err: fmt.Errorf("returned PREVIEW instead of FULL"), duration: time.Since(reqStart)} return } @@ -715,7 +675,6 @@ func getDownloadURLParallel(apis []string, trackID int64, quality string) (strin GoLog("[Tidal] [Parallel] ✓ Got response from %s (%d-bit/%dHz) in %v\n", result.apiURL, result.info.BitDepth, result.info.SampleRate, result.duration) - // Don't return immediately - drain remaining results to avoid goroutine leaks go func(remaining int) { for j := 0; j < remaining; j++ { <-resultChan @@ -736,8 +695,6 @@ func getDownloadURLParallel(apis []string, trackID int64, quality string) (strin return "", TidalDownloadInfo{}, fmt.Errorf("all %d Tidal APIs failed. Errors: %v", len(apis), errors) } -// GetDownloadURL gets download URL for a track - tries ALL APIs in parallel -// "Siapa cepat dia dapat" - first successful response wins func (t *TidalDownloader) GetDownloadURL(trackID int64, quality string) (TidalDownloadInfo, error) { apis := t.GetAvailableAPIs() if len(apis) == 0 { @@ -752,7 +709,6 @@ func (t *TidalDownloader) GetDownloadURL(trackID int64, quality string) (TidalDo return info, nil } -// parseManifest parses Tidal manifest (supports both BTS and DASH formats) func parseManifest(manifestB64 string) (directURL string, initURL string, mediaURLs []string, err error) { manifestBytes, err := base64.StdEncoding.DecodeString(manifestB64) if err != nil { @@ -859,7 +815,6 @@ func (t *TidalDownloader) DownloadFile(downloadURL, outputPath, itemID string) e return t.downloadFromManifest(ctx, strings.TrimPrefix(downloadURL, "MANIFEST:"), outputPath, itemID) } - // Initialize item progress for direct downloads if itemID != "" { StartItemProgress(itemID) defer CompleteItemProgress(itemID) @@ -952,9 +907,7 @@ func (t *TidalDownloader) downloadFromManifest(ctx context.Context, manifestB64, if directURL != "" { GoLog("[Tidal] BTS format - downloading from direct URL: %s...\n", directURL[:min(80, len(directURL))]) - // Note: Progress tracking is initialized by the caller (DownloadFile) - - if isDownloadCancelled(itemID) { + if isDownloadCancelled(itemID) { return ErrDownloadCancelled } @@ -1135,7 +1088,6 @@ func (t *TidalDownloader) downloadFromManifest(ctx context.Context, manifestB64, return nil } -// TidalDownloadResult contains download result with quality info type TidalDownloadResult struct { FilePath string BitDepth int @@ -1149,12 +1101,10 @@ type TidalDownloadResult struct { ISRC string } -// artistsMatch checks if the artist names are similar enough func artistsMatch(spotifyArtist, tidalArtist string) bool { normSpotify := strings.ToLower(strings.TrimSpace(spotifyArtist)) normTidal := strings.ToLower(strings.TrimSpace(tidalArtist)) - // Exact match if normSpotify == normTidal { return true } @@ -1164,22 +1114,17 @@ func artistsMatch(spotifyArtist, tidalArtist string) bool { return true } - // Split artists by common separators (comma, feat, ft., &, and) - // e.g., "RADWIMPS, Toko Miura" or "RADWIMPS feat. Toko Miura" spotifyArtists := splitArtists(normSpotify) tidalArtists := splitArtists(normTidal) - // Check if ANY expected artist matches ANY found artist for _, exp := range spotifyArtists { for _, fnd := range tidalArtists { if exp == fnd { return true } - // Also check contains for partial matches if strings.Contains(exp, fnd) || strings.Contains(fnd, exp) { return true } - // Check same words different order if sameWordsUnordered(exp, fnd) { GoLog("[Tidal] Artist names have same words in different order: '%s' vs '%s'\n", exp, fnd) return true @@ -1187,9 +1132,6 @@ func artistsMatch(spotifyArtist, tidalArtist string) bool { } } - // If scripts are TRULY different (Latin vs CJK/Arabic/Cyrillic), assume match (transliteration) - // Don't treat Latin Extended (Polish, French, etc.) as different script - // This handles cases like "鈴木雅之" vs "Masayuki Suzuki" spotifyLatin := isLatinScript(spotifyArtist) tidalLatin := isLatinScript(tidalArtist) if spotifyLatin != tidalLatin { @@ -1200,9 +1142,7 @@ func artistsMatch(spotifyArtist, tidalArtist string) bool { return false } -// splitArtists splits artist string by common separators func splitArtists(artists string) []string { - // Replace common separators with a standard one normalized := artists normalized = strings.ReplaceAll(normalized, " feat. ", "|") normalized = strings.ReplaceAll(normalized, " feat ", "|") @@ -1224,8 +1164,6 @@ func splitArtists(artists string) []string { return result } -// sameWordsUnordered checks if two strings have the same words regardless of order -// Useful for Japanese names: "Sawano Hiroyuki" vs "Hiroyuki Sawano" func sameWordsUnordered(a, b string) bool { wordsA := strings.Fields(a) wordsB := strings.Fields(b) @@ -1235,13 +1173,11 @@ func sameWordsUnordered(a, b string) bool { return false } - // Sort and compare sortedA := make([]string, len(wordsA)) sortedB := make([]string, len(wordsB)) copy(sortedA, wordsA) copy(sortedB, wordsB) - // Simple bubble sort (usually just 2-3 words) for i := 0; i < len(sortedA)-1; i++ { for j := i + 1; j < len(sortedA); j++ { if sortedA[i] > sortedA[j] { @@ -1261,7 +1197,6 @@ func sameWordsUnordered(a, b string) bool { return true } -// titlesMatch checks if track titles are similar enough func titlesMatch(expectedTitle, foundTitle string) bool { normExpected := strings.ToLower(strings.TrimSpace(expectedTitle)) normFound := strings.ToLower(strings.TrimSpace(foundTitle)) @@ -1271,7 +1206,6 @@ func titlesMatch(expectedTitle, foundTitle string) bool { return true } - // Check if one contains the other if strings.Contains(normExpected, normFound) || strings.Contains(normFound, normExpected) { return true } @@ -1284,7 +1218,6 @@ func titlesMatch(expectedTitle, foundTitle string) bool { return true } - // Check if cleaned versions contain each other if cleanExpected != "" && cleanFound != "" { if strings.Contains(cleanExpected, cleanFound) || strings.Contains(cleanFound, cleanExpected) { return true @@ -1299,7 +1232,6 @@ func titlesMatch(expectedTitle, foundTitle string) bool { return true } - // If scripts are TRULY different (Latin vs CJK/Arabic/Cyrillic), assume match (transliteration) // Don't treat Latin Extended (Polish, French, etc.) as different script expectedLatin := isLatinScript(expectedTitle) foundLatin := isLatinScript(foundTitle) @@ -1311,9 +1243,7 @@ func titlesMatch(expectedTitle, foundTitle string) bool { return false } -// extractCoreTitle extracts the main title before any parentheses or brackets func extractCoreTitle(title string) string { - // Find first occurrence of ( or [ parenIdx := strings.Index(title, "(") bracketIdx := strings.Index(title, "[") dashIdx := strings.Index(title, " - ") @@ -1332,18 +1262,15 @@ func extractCoreTitle(title string) string { return strings.TrimSpace(title[:cutIdx]) } -// cleanTitle removes common suffixes from track titles for comparison func cleanTitle(title string) string { cleaned := title - // Version indicators to remove from parentheses/brackets versionPatterns := []string{ "remaster", "remastered", "deluxe", "bonus", "single", "album version", "radio edit", "original mix", "extended", "club mix", "remix", "live", "acoustic", "demo", } - // Remove parenthetical content if it contains version indicators for { startParen := strings.LastIndex(cleaned, "(") endParen := strings.LastIndex(cleaned, ")") @@ -1364,7 +1291,6 @@ func cleanTitle(title string) string { break } - // Same for brackets for { startBracket := strings.LastIndex(cleaned, "[") endBracket := strings.LastIndex(cleaned, "]") @@ -1385,7 +1311,6 @@ func cleanTitle(title string) string { break } - // Remove trailing " - version" patterns dashPatterns := []string{ " - remaster", " - remastered", " - single version", " - radio edit", " - live", " - acoustic", " - demo", " - remix", @@ -1396,7 +1321,6 @@ func cleanTitle(title string) string { } } - // Remove multiple spaces for strings.Contains(cleaned, " ") { cleaned = strings.ReplaceAll(cleaned, " ", " ") } @@ -1404,48 +1328,29 @@ func cleanTitle(title string) string { return strings.TrimSpace(cleaned) } -// isLatinScript 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 isLatinScript(s string) bool { for _, r := range s { - // Skip common punctuation and numbers if r < 128 { continue } - // Latin Extended-A: U+0100 to U+017F (Polish, Czech, etc.) - // Latin Extended-B: U+0180 to U+024F - // Latin Extended Additional: U+1E00 to U+1EFF - if (r >= 0x0100 && r <= 0x024F) || // Latin Extended A & B - (r >= 0x1E00 && r <= 0x1EFF) || // Latin Extended Additional - (r >= 0x00C0 && r <= 0x00FF) { // Latin-1 Supplement (accented chars) + if (r >= 0x0100 && r <= 0x024F) || + (r >= 0x1E00 && r <= 0x1EFF) || + (r >= 0x00C0 && r <= 0x00FF) { continue } - // CJK ranges - definitely different script - if (r >= 0x4E00 && r <= 0x9FFF) || // CJK Unified Ideographs - (r >= 0x3040 && r <= 0x309F) || // Hiragana - (r >= 0x30A0 && r <= 0x30FF) || // Katakana - (r >= 0xAC00 && r <= 0xD7AF) || // Hangul (Korean) - (r >= 0x0600 && r <= 0x06FF) || // Arabic - (r >= 0x0400 && r <= 0x04FF) { // Cyrillic + if (r >= 0x4E00 && r <= 0x9FFF) || + (r >= 0x3040 && r <= 0x309F) || + (r >= 0x30A0 && r <= 0x30FF) || + (r >= 0xAC00 && r <= 0xD7AF) || + (r >= 0x0600 && r <= 0x06FF) || + (r >= 0x0400 && r <= 0x04FF) { return false } } return true } -// isASCIIString checks if a string contains only ASCII characters -// Kept for potential future use -// func isASCIIString(s string) bool { -// for _, r := range s { -// if r > 127 { -// return false -// } -// } -// return true -// } -// downloadFromTidal downloads a track using the request parameters func downloadFromTidal(req DownloadRequest) (TidalDownloadResult, error) { downloader := NewTidalDownloader() @@ -1453,16 +1358,13 @@ func downloadFromTidal(req DownloadRequest) (TidalDownloadResult, error) { return TidalDownloadResult{FilePath: "EXISTS:" + existingFile}, nil } - // Convert expected duration from ms to seconds expectedDurationSec := req.DurationMS / 1000 var track *TidalTrack var err error - // STRATEGY 0: Use pre-fetched Tidal ID from Odesli enrichment (highest priority) if req.TidalID != "" { GoLog("[Tidal] Using Tidal ID from Odesli enrichment: %s\n", req.TidalID) - // Parse track ID (could be a number or extracted from URL) var trackID int64 if _, parseErr := fmt.Sscanf(req.TidalID, "%d", &trackID); parseErr == nil && trackID > 0 { track, err = downloader.GetTrackInfoByID(trackID) @@ -1475,7 +1377,6 @@ func downloadFromTidal(req DownloadRequest) (TidalDownloadResult, error) { } } - // OPTIMIZATION: Check cache first for track ID if track == nil && req.ISRC != "" { if cached := GetTrackIDCache().Get(req.ISRC); cached != nil && cached.TidalTrackID > 0 { GoLog("[Tidal] Cache hit! Using cached track ID: %d\n", cached.TidalTrackID) @@ -1487,8 +1388,6 @@ func downloadFromTidal(req DownloadRequest) (TidalDownloadResult, error) { } } - // OPTIMIZED: Try ISRC search with metadata (search by name, filter by ISRC) - // Strategy 1: Search by metadata, match by ISRC (most accurate) if track == nil && req.ISRC != "" { GoLog("[Tidal] Trying ISRC search: %s\n", req.ISRC) track, err = downloader.SearchTrackByMetadataWithISRC(req.TrackName, req.ArtistName, req.ISRC, expectedDurationSec) @@ -1510,7 +1409,6 @@ func downloadFromTidal(req DownloadRequest) (TidalDownloadResult, error) { } } - // Strategy 2: Try SongLink if we have Spotify ID if track == nil && req.SpotifyID != "" { GoLog("[Tidal] ISRC search failed, trying SongLink...\n") var tidalURL string @@ -1545,13 +1443,11 @@ func downloadFromTidal(req DownloadRequest) (TidalDownloadResult, error) { track = nil } - // Verify duration if we have expected duration if track != nil && expectedDurationSec > 0 { durationDiff := track.Duration - expectedDurationSec if durationDiff < 0 { durationDiff = -durationDiff } - // Allow 3 seconds tolerance (same as PC version) if durationDiff > 3 { GoLog("[Tidal] Duration mismatch from SongLink: expected %ds, got %ds. Rejecting.\n", expectedDurationSec, track.Duration) @@ -1563,11 +1459,9 @@ func downloadFromTidal(req DownloadRequest) (TidalDownloadResult, error) { } } - // Strategy 3: Search by metadata only (no ISRC requirement) - last resort if track == nil { GoLog("[Tidal] Trying metadata search as last resort...\n") track, err = downloader.SearchTrackByMetadataWithISRC(req.TrackName, req.ArtistName, "", expectedDurationSec) - // Verify artist AND title for metadata search if track != nil { tidalArtist := track.Artist.Name if len(track.Artists) > 0 { @@ -1578,7 +1472,6 @@ func downloadFromTidal(req DownloadRequest) (TidalDownloadResult, error) { tidalArtist = strings.Join(artistNames, ", ") } - // Verify title first if !titlesMatch(req.TrackName, track.Title) { GoLog("[Tidal] Title mismatch from metadata search: expected '%s', got '%s'. Rejecting.\n", req.TrackName, track.Title) @@ -1599,7 +1492,6 @@ func downloadFromTidal(req DownloadRequest) (TidalDownloadResult, error) { return TidalDownloadResult{}, fmt.Errorf("tidal search failed: %s", errMsg) } - // Final verification logging tidalArtist := track.Artist.Name if len(track.Artists) > 0 { var artistNames []string @@ -1633,7 +1525,6 @@ func downloadFromTidal(req DownloadRequest) (TidalDownloadResult, error) { return TidalDownloadResult{FilePath: "EXISTS:" + m4aPath}, nil } - // Clean up any leftover .tmp files from previous failed downloads tmpPath := outputPath + ".m4a.tmp" if _, err := os.Stat(tmpPath); err == nil { GoLog("[Tidal] Cleaning up leftover temp file: %s\n", tmpPath) @@ -1651,10 +1542,8 @@ func downloadFromTidal(req DownloadRequest) (TidalDownloadResult, error) { return TidalDownloadResult{}, fmt.Errorf("failed to get download URL: %w", err) } - // Log actual quality received GoLog("[Tidal] Actual quality: %d-bit/%dHz\n", downloadInfo.BitDepth, downloadInfo.SampleRate) - // START PARALLEL: Fetch cover and lyrics while downloading audio var parallelResult *ParallelDownloadResult parallelDone := make(chan struct{}) go func() { @@ -1670,7 +1559,6 @@ func downloadFromTidal(req DownloadRequest) (TidalDownloadResult, error) { ) }() - // Download audio file with item ID for progress tracking GoLog("[Tidal] Starting download to: %s\n", outputPath) GoLog("[Tidal] Download URL type: %s\n", func() string { if strings.HasPrefix(downloadInfo.URL, "MANIFEST:") { @@ -1688,7 +1576,6 @@ func downloadFromTidal(req DownloadRequest) (TidalDownloadResult, error) { } fmt.Println("[Tidal] Download completed successfully") - // Wait for parallel operations to complete <-parallelDone if req.ItemID != "" { @@ -1701,12 +1588,9 @@ func downloadFromTidal(req DownloadRequest) (TidalDownloadResult, error) { actualOutputPath = m4aPath GoLog("[Tidal] File saved as M4A (DASH stream): %s\n", actualOutputPath) } else if _, err := os.Stat(outputPath); err != nil { - // Neither FLAC nor M4A exists return TidalDownloadResult{}, fmt.Errorf("download completed but file not found at %s or %s", outputPath, m4aPath) } - // Embed metadata using parallel-fetched cover data - // Use release date from Tidal API if not provided in request releaseDate := req.ReleaseDate if releaseDate == "" && track.Album.ReleaseDate != "" { releaseDate = track.Album.ReleaseDate @@ -1719,13 +1603,13 @@ func downloadFromTidal(req DownloadRequest) (TidalDownloadResult, error) { Album: req.AlbumName, AlbumArtist: req.AlbumArtist, Date: releaseDate, - TrackNumber: track.TrackNumber, // Use actual track number from Tidal + TrackNumber: track.TrackNumber, TotalTracks: req.TotalTracks, - DiscNumber: track.VolumeNumber, // Use actual disc number from Tidal - ISRC: track.ISRC, // Use actual ISRC from Tidal - Genre: req.Genre, // From Deezer album metadata - Label: req.Label, // From Deezer album metadata - Copyright: req.Copyright, // From Deezer album metadata + DiscNumber: track.VolumeNumber, + ISRC: track.ISRC, + Genre: req.Genre, + Label: req.Label, + Copyright: req.Copyright, } var coverData []byte @@ -1734,21 +1618,17 @@ func downloadFromTidal(req DownloadRequest) (TidalDownloadResult, error) { GoLog("[Tidal] Using parallel-fetched cover (%d bytes)\n", len(coverData)) } - // Embed metadata based on file type if strings.HasSuffix(actualOutputPath, ".flac") { if err := EmbedMetadataWithCoverData(actualOutputPath, metadata, coverData); err != nil { fmt.Printf("Warning: failed to embed metadata: %v\n", err) } - // Handle lyrics based on LyricsMode setting - // Mode: "embed" (default), "external" (.lrc file), "both" if req.EmbedLyrics && parallelResult != nil && parallelResult.LyricsLRC != "" { lyricsMode := req.LyricsMode if lyricsMode == "" { - lyricsMode = "embed" // default + lyricsMode = "embed" } - // Save external .lrc file if mode is "external" or "both" if lyricsMode == "external" || lyricsMode == "both" { GoLog("[Tidal] Saving external LRC file...\n") if lrcPath, lrcErr := SaveLRCFile(actualOutputPath, parallelResult.LyricsLRC); lrcErr != nil { @@ -1758,7 +1638,6 @@ func downloadFromTidal(req DownloadRequest) (TidalDownloadResult, error) { } } - // Embed lyrics if mode is "embed" or "both" if lyricsMode == "embed" || lyricsMode == "both" { GoLog("[Tidal] Embedding parallel-fetched lyrics (%d lines)...\n", len(parallelResult.LyricsData.Lines)) if embedErr := EmbedLyrics(actualOutputPath, parallelResult.LyricsLRC); embedErr != nil { @@ -1771,28 +1650,9 @@ func downloadFromTidal(req DownloadRequest) (TidalDownloadResult, error) { fmt.Println("[Tidal] No lyrics available from parallel fetch") } } else if strings.HasSuffix(actualOutputPath, ".m4a") { - // Embed metadata to M4A file - // GoLog("[Tidal] Embedding metadata to M4A file...\n") - - // Add lyrics to metadata if available - // if req.EmbedLyrics && parallelResult != nil && parallelResult.LyricsLRC != "" { - // metadata.Lyrics = parallelResult.LyricsLRC - // } - - // SKIP metadata embedding for M4A to prevent issues with FFmpeg conversion - // M4A files from DASH are often fragmented and editing metadata might corrupt the container - // structure that FFmpeg expects. Metadata will be re-embedded after conversion to FLAC in Flutter. - fmt.Println("[Tidal] Skipping metadata embedding for M4A file (will be handled after FFmpeg conversion)") - - // if err := EmbedM4AMetadata(actualOutputPath, metadata, coverData); err != nil { - // GoLog("[Tidal] Warning: failed to embed M4A metadata: %v\n", err) - // } else { - // fmt.Println("[Tidal] M4A metadata embedded successfully") - // } } - // Add to ISRC index for fast duplicate checking AddToISRCIndex(req.OutputDir, req.ISRC, actualOutputPath) return TidalDownloadResult{ diff --git a/lib/main.dart b/lib/main.dart index 411235ea..37602511 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -12,11 +12,9 @@ import 'package:spotiflac_android/services/cover_cache_manager.dart'; void main() async { WidgetsFlutterBinding.ensureInitialized(); - // Initialize services - CoverCacheManager MUST complete before app starts await CoverCacheManager.initialize(); debugPrint('CoverCacheManager initialized: ${CoverCacheManager.isInitialized}'); - // These can run in parallel await Future.wait([ NotificationService().initialize(), ShareIntentService().initialize(), diff --git a/lib/models/download_item.dart b/lib/models/download_item.dart index 2deb8f8c..fc17705c 100644 --- a/lib/models/download_item.dart +++ b/lib/models/download_item.dart @@ -3,23 +3,21 @@ import 'package:spotiflac_android/models/track.dart'; part 'download_item.g.dart'; -/// Download status enum enum DownloadStatus { queued, downloading, - finalizing, // Embedding metadata, cover, lyrics + finalizing, completed, failed, skipped, } -/// Error type enum for better error handling enum DownloadErrorType { unknown, - notFound, // Track not found on any service - rateLimit, // Rate limited by service - network, // Network/connection error - permission, // File/folder permission error + notFound, + rateLimit, + network, + permission, } @JsonSerializable() @@ -29,7 +27,7 @@ class DownloadItem { final String service; final DownloadStatus status; final double progress; - final double speedMBps; // Download speed in MB/s + final double speedMBps; final String? filePath; final String? error; final DownloadErrorType? errorType; @@ -78,7 +76,6 @@ class DownloadItem { ); } - /// Get user-friendly error message based on error type String get errorMessage { if (error == null) return ''; diff --git a/lib/models/settings.dart b/lib/models/settings.dart index 00e308d0..892fd528 100644 --- a/lib/models/settings.dart +++ b/lib/models/settings.dart @@ -12,27 +12,27 @@ class AppSettings { final bool embedLyrics; final bool maxQualityCover; final bool isFirstLaunch; - final int concurrentDownloads; // 1 = sequential (default), max 3 - final bool checkForUpdates; // Check for updates on app start - final String updateChannel; // stable, preview - final bool hasSearchedBefore; // Hide helper text after first search - final String folderOrganization; // none, artist, album, artist_album - final String historyViewMode; // list, grid - final String historyFilterMode; // all, albums, singles - final bool askQualityBeforeDownload; // Show quality picker before each download - final String spotifyClientId; // Custom Spotify client ID (empty = use default) - final String spotifyClientSecret; // Custom Spotify client secret (empty = use default) - final bool useCustomSpotifyCredentials; // Whether to use custom credentials (if set) - final String metadataSource; // spotify, deezer - source for search and metadata - final bool enableLogging; // Enable detailed logging for debugging - final bool useExtensionProviders; // Use extension providers for downloads when available - final String? searchProvider; // null/empty = default (Deezer/Spotify), otherwise extension ID - final bool separateSingles; // Separate singles/EPs into their own folder - final String albumFolderStructure; // artist_album, album_only, artist_year_album, year_album - final bool showExtensionStore; // Show Extension Store tab in navigation - final String locale; // App language: 'system', 'en', 'id', etc. - final bool enableMp3Option; // Enable MP3 quality option (default off, requires FFmpeg conversion) - final String lyricsMode; // embed, external, both - how to save lyrics + final int concurrentDownloads; + final bool checkForUpdates; + final String updateChannel; + final bool hasSearchedBefore; + final String folderOrganization; + final String historyViewMode; + final String historyFilterMode; + final bool askQualityBeforeDownload; + final String spotifyClientId; + final String spotifyClientSecret; + final bool useCustomSpotifyCredentials; + final String metadataSource; + final bool enableLogging; + final bool useExtensionProviders; + final String? searchProvider; + final bool separateSingles; + final String albumFolderStructure; + final bool showExtensionStore; + final String locale; + final bool enableMp3Option; + final String lyricsMode; const AppSettings({ this.defaultService = 'tidal', @@ -43,27 +43,27 @@ class AppSettings { this.embedLyrics = true, this.maxQualityCover = true, this.isFirstLaunch = true, - this.concurrentDownloads = 1, // Default: sequential (off) - this.checkForUpdates = true, // Default: enabled - this.updateChannel = 'stable', // Default: stable releases only - this.hasSearchedBefore = false, // Default: show helper text - this.folderOrganization = 'none', // Default: no folder organization - this.historyViewMode = 'grid', // Default: grid view - this.historyFilterMode = 'all', // Default: show all - this.askQualityBeforeDownload = true, // Default: ask quality before download - this.spotifyClientId = '', // Default: use built-in credentials - this.spotifyClientSecret = '', // Default: use built-in credentials - this.useCustomSpotifyCredentials = true, // Default: use custom if set - this.metadataSource = 'deezer', // Default: Deezer (no rate limit) - this.enableLogging = false, // Default: disabled for performance - this.useExtensionProviders = true, // Default: use extensions when available - this.searchProvider, // Default: null (use Deezer/Spotify) - this.separateSingles = false, // Default: disabled - this.albumFolderStructure = 'artist_album', // Default: Albums/Artist/Album - this.showExtensionStore = true, // Default: show store - this.locale = 'system', // Default: follow system language - this.enableMp3Option = false, // Default: disabled - this.lyricsMode = 'embed', // Default: embed lyrics into file + this.concurrentDownloads = 1, + this.checkForUpdates = true, + this.updateChannel = 'stable', + this.hasSearchedBefore = false, + this.folderOrganization = 'none', + this.historyViewMode = 'grid', + this.historyFilterMode = 'all', + this.askQualityBeforeDownload = true, + this.spotifyClientId = '', + this.spotifyClientSecret = '', + this.useCustomSpotifyCredentials = true, + this.metadataSource = 'deezer', + this.enableLogging = false, + this.useExtensionProviders = true, + this.searchProvider, + this.separateSingles = false, + this.albumFolderStructure = 'artist_album', + this.showExtensionStore = true, + this.locale = 'system', + this.enableMp3Option = false, + this.lyricsMode = 'embed', }); AppSettings copyWith({ @@ -90,7 +90,7 @@ class AppSettings { bool? enableLogging, bool? useExtensionProviders, String? searchProvider, - bool clearSearchProvider = false, // Set to true to clear searchProvider to null + bool clearSearchProvider = false, bool? separateSingles, String? albumFolderStructure, bool? showExtensionStore, diff --git a/lib/models/theme_settings.dart b/lib/models/theme_settings.dart index 55381fab..6860c67e 100644 --- a/lib/models/theme_settings.dart +++ b/lib/models/theme_settings.dart @@ -9,7 +9,6 @@ const String kUseAmoledKey = 'use_amoled'; /// Default Spotify green color for fallback const int kDefaultSeedColor = 0xFF1DB954; -/// Theme settings model for Material Expressive 3 class ThemeSettings { final ThemeMode themeMode; final bool useDynamicColor; @@ -23,10 +22,8 @@ class ThemeSettings { this.useAmoled = false, }); - /// Get seed color as Color object Color get seedColor => Color(seedColorValue); - /// Create a copy with updated values ThemeSettings copyWith({ ThemeMode? themeMode, bool? useDynamicColor, @@ -41,7 +38,6 @@ class ThemeSettings { ); } - /// Convert to JSON map for persistence Map toJson() => { kThemeModeKey: themeMode.name, kUseDynamicColorKey: useDynamicColor, @@ -49,7 +45,6 @@ class ThemeSettings { kUseAmoledKey: useAmoled, }; - /// Create from JSON map factory ThemeSettings.fromJson(Map json) { return ThemeSettings( themeMode: _themeModeFromString(json[kThemeModeKey] as String?), @@ -74,7 +69,6 @@ class ThemeSettings { themeMode.hashCode ^ useDynamicColor.hashCode ^ seedColorValue.hashCode ^ useAmoled.hashCode; } -/// Helper to convert string to ThemeMode ThemeMode _themeModeFromString(String? value) { if (value == null) return ThemeMode.system; return ThemeMode.values.firstWhere( diff --git a/lib/models/track.dart b/lib/models/track.dart index dda110b7..d2ab69fe 100644 --- a/lib/models/track.dart +++ b/lib/models/track.dart @@ -2,7 +2,6 @@ import 'package:json_annotation/json_annotation.dart'; part 'track.g.dart'; -/// Track model representing a music track @JsonSerializable() class Track { final String id; @@ -18,9 +17,9 @@ class Track { final String? releaseDate; final String? deezerId; final ServiceAvailability? availability; - final String? source; // Extension ID that provided this track (null for built-in sources) - final String? albumType; // album, single, ep, compilation (from metadata API) - final String? itemType; // track, album, playlist - for extension search results + final String? source; + final String? albumType; + final String? itemType; const Track({ required this.id, @@ -41,25 +40,19 @@ class Track { this.itemType, }); - /// Check if this track is a single (based on album_type metadata) bool get isSingle => albumType == 'single' || albumType == 'ep'; - /// Check if this is an album item (not a track) bool get isAlbumItem => itemType == 'album'; - /// Check if this is a playlist item (not a track) bool get isPlaylistItem => itemType == 'playlist'; - /// Check if this is an artist item (not a track) bool get isArtistItem => itemType == 'artist'; - /// Check if this is a collection (album, playlist, or artist) bool get isCollection => isAlbumItem || isPlaylistItem || isArtistItem; factory Track.fromJson(Map json) => _$TrackFromJson(json); Map toJson() => _$TrackToJson(this); - /// Check if this track is from an extension bool get isFromExtension => source != null && source!.isNotEmpty; } diff --git a/lib/providers/download_queue_provider.dart b/lib/providers/download_queue_provider.dart index e1e27bdc..ab7c7eaa 100644 --- a/lib/providers/download_queue_provider.dart +++ b/lib/providers/download_queue_provider.dart @@ -125,7 +125,7 @@ class DownloadHistoryItem { class DownloadHistoryState { final List items; - final Set _downloadedSpotifyIds; // Cache for O(1) lookup + final Set _downloadedSpotifyIds; DownloadHistoryState({this.items = const []}) : _downloadedSpotifyIds = items @@ -133,7 +133,6 @@ class DownloadHistoryState { .map((item) => item.spotifyId!) .toSet(); - /// Check if a track has been downloaded (by Spotify ID) bool isDownloaded(String spotifyId) => _downloadedSpotifyIds.contains(spotifyId); @@ -188,7 +187,6 @@ class DownloadHistoryNotifier extends Notifier { } } - /// Deduplicate history items by spotifyId, deezerId, or ISRC /// Keeps the most recent entry (first occurrence since list is sorted by date desc) List _deduplicateHistory(List items) { final seen = {}; // key -> index of first occurrence @@ -234,7 +232,6 @@ class DownloadHistoryNotifier extends Notifier { } } - /// Force reload from storage (useful after app restart) Future reloadFromStorage() async { await _loadFromStorage(); } @@ -285,7 +282,6 @@ class DownloadHistoryNotifier extends Notifier { _saveToStorage(); } - /// Remove item from history by Spotify ID void removeBySpotifyId(String spotifyId) { state = state.copyWith( items: state.items.where((item) => item.spotifyId != spotifyId).toList(), @@ -294,7 +290,6 @@ class DownloadHistoryNotifier extends Notifier { _historyLog.d('Removed item with spotifyId: $spotifyId'); } - /// Get history item by Spotify ID DownloadHistoryItem? getBySpotifyId(String spotifyId) { return state.items.where((item) => item.spotifyId == spotifyId).firstOrNull; } @@ -314,12 +309,12 @@ class DownloadQueueState { final List items; final DownloadItem? currentDownload; final bool isProcessing; - final bool isPaused; // NEW: pause state + final bool isPaused; final String outputDir; final String filenameFormat; - final String audioQuality; // LOSSLESS, HI_RES, HI_RES_LOSSLESS + final String audioQuality; final bool autoFallback; - final int concurrentDownloads; // 1 = sequential, max 3 + final int concurrentDownloads; const DownloadQueueState({ this.items = const [], @@ -386,14 +381,13 @@ class _ProgressUpdate { class DownloadQueueNotifier extends Notifier { Timer? _progressTimer; - int _downloadCount = 0; // Counter for connection cleanup - static const _cleanupInterval = 50; // Cleanup every 50 downloads - static const _queueStorageKey = - 'download_queue'; // Storage key for queue persistence + int _downloadCount = 0; + static const _cleanupInterval = 50; + static const _queueStorageKey = 'download_queue'; final NotificationService _notificationService = NotificationService(); - int _totalQueuedAtStart = 0; // Track total items when queue started - int _completedInSession = 0; // Track completed downloads in current session - int _failedInSession = 0; // Track failed downloads in current session + int _totalQueuedAtStart = 0; + int _completedInSession = 0; + int _failedInSession = 0; bool _isLoaded = false; final Set _ensuredDirs = {}; @@ -411,7 +405,6 @@ class DownloadQueueNotifier extends Notifier { return const DownloadQueueState(); } - /// Load persisted queue from storage (for app restart recovery) Future _loadQueueFromStorage() async { if (_isLoaded) return; _isLoaded = true; @@ -453,7 +446,6 @@ class DownloadQueueNotifier extends Notifier { } } - /// Save current queue to storage (only pending items) Future _saveQueueToStorage() async { try { final prefs = await SharedPreferences.getInstance(); @@ -479,7 +471,6 @@ class DownloadQueueNotifier extends Notifier { } } - /// Start multi-progress polling for all downloads (sequential and parallel) void _startMultiProgressPolling() { _progressTimer?.cancel(); _progressTimer = Timer.periodic(const Duration(milliseconds: 500), ( @@ -607,7 +598,7 @@ class DownloadQueueNotifier extends Notifier { trackName: finalizingTrackName, artistName: finalizingArtistName ?? '', ); - return; // Don't show download progress notification + return; } if (items.isNotEmpty) { @@ -651,14 +642,11 @@ class DownloadQueueNotifier extends Notifier { progress: notifProgress, total: notifTotal > 0 ? notifTotal : 1, queueCount: state.queuedCount, - ).catchError((_) {}); // Ignore errors + ).catchError((_) {}); } } } - } catch (e) { - // Silently ignore polling errors to avoid spamming logs - // Polling is not critical and will retry on next interval - } + } catch (_) {} }); } @@ -725,7 +713,6 @@ class DownloadQueueNotifier extends Notifier { state = state.copyWith(outputDir: dir); } - /// Build output directory based on folder organization setting and separateSingles Future _buildOutputDir(Track track, String folderOrganization, {bool separateSingles = false, String albumFolderStructure = 'artist_album'}) async { String baseDir = state.outputDir; final albumArtist = _normalizeOptionalString(track.albumArtist) ?? track.artistName; @@ -794,7 +781,6 @@ class DownloadQueueNotifier extends Notifier { return baseDir; } - /// Sanitize folder names (remove invalid characters) String _sanitizeFolderName(String name) { return name .replaceAll(RegExp(r'[<>:"/\\|?*]'), '_') @@ -866,7 +852,7 @@ class DownloadQueueNotifier extends Notifier { }).toList(); state = state.copyWith(items: [...state.items, ...newItems]); - _saveQueueToStorage(); // Persist queue + _saveQueueToStorage(); if (!state.isProcessing) { Future.microtask(() => _processQueue()); @@ -951,15 +937,14 @@ class DownloadQueueNotifier extends Notifier { .toList(); state = state.copyWith(items: items); - _saveQueueToStorage(); // Persist queue + _saveQueueToStorage(); } void clearAll() { state = state.copyWith(items: [], isPaused: false); - _saveQueueToStorage(); // Clear persisted queue + _saveQueueToStorage(); } - /// Pause the download queue void pauseQueue() { if (state.isProcessing && !state.isPaused) { state = state.copyWith(isPaused: true); @@ -968,7 +953,6 @@ class DownloadQueueNotifier extends Notifier { } } - /// Resume the download queue void resumeQueue() { if (state.isPaused) { state = state.copyWith(isPaused: false); @@ -979,7 +963,6 @@ class DownloadQueueNotifier extends Notifier { } } - /// Toggle pause/resume void togglePause() { if (state.isPaused) { resumeQueue(); @@ -988,7 +971,6 @@ class DownloadQueueNotifier extends Notifier { } } - /// Retry a failed or skipped download void retryItem(String id) { final item = state.items.where((i) => i.id == id).firstOrNull; if (item == null) { @@ -1025,14 +1007,12 @@ class DownloadQueueNotifier extends Notifier { } } - /// Remove a specific item from queue void removeItem(String id) { final items = state.items.where((item) => item.id != id).toList(); state = state.copyWith(items: items); - _saveQueueToStorage(); // Persist queue + _saveQueueToStorage(); } - /// Run post-processing hooks on a downloaded file Future _runPostProcessingHooks(String filePath, Track track) async { try { final settings = ref.read(settingsProvider); @@ -1079,7 +1059,6 @@ class DownloadQueueNotifier extends Notifier { } } - /// Upgrade Spotify cover URL to max quality (~2000x2000) /// Same logic as Go backend cover.go String _upgradeToMaxQualityCover(String coverUrl) { const spotifySize300 = 'ab67616d00001e02'; // 300x300 (small) @@ -1098,7 +1077,6 @@ class DownloadQueueNotifier extends Notifier { return result; } - /// Embed metadata and cover to a FLAC file after M4A conversion Future _embedMetadataAndCover( String flacPath, Track track, { @@ -1155,12 +1133,12 @@ class DownloadQueueNotifier extends Notifier { if (track.trackNumber != null) { metadata['TRACKNUMBER'] = track.trackNumber.toString(); - metadata['TRACK'] = track.trackNumber.toString(); // Compatibility + metadata['TRACK'] = track.trackNumber.toString(); } if (track.discNumber != null) { metadata['DISCNUMBER'] = track.discNumber.toString(); - metadata['DISC'] = track.discNumber.toString(); // Compatibility + metadata['DISC'] = track.discNumber.toString(); } if (track.releaseDate != null) { @@ -1172,7 +1150,6 @@ class DownloadQueueNotifier extends Notifier { metadata['ISRC'] = track.isrc!; } - // Extended metadata from enrichment (genre, label, copyright) if (genre != null && genre.isNotEmpty) { metadata['GENRE'] = genre; _log.d('Adding GENRE: $genre'); @@ -1189,20 +1166,19 @@ class DownloadQueueNotifier extends Notifier { _log.d('Metadata map content: $metadata'); try { - // Convert duration from seconds to milliseconds for better lyrics matching final durationMs = track.duration * 1000; final lrcContent = await PlatformBridge.getLyricsLRC( - track.id, // spotifyID + track.id, track.name, track.artistName, - filePath: '', // No local file path yet (processed in memory) + filePath: '', durationMs: durationMs, ); if (lrcContent.isNotEmpty) { metadata['LYRICS'] = lrcContent; - metadata['UNSYNCEDLYRICS'] = lrcContent; // Fallback for some players + metadata['UNSYNCEDLYRICS'] = lrcContent; _log.d('Lyrics fetched for embedding (${lrcContent.length} chars)'); } } catch (e) { @@ -1240,7 +1216,6 @@ class DownloadQueueNotifier extends Notifier { } } - /// Embed metadata, lyrics, and cover to a MP3 file Future _embedMetadataToMp3(String mp3Path, Track track) async { final settings = ref.read(settingsProvider); @@ -1310,7 +1285,6 @@ class DownloadQueueNotifier extends Notifier { _log.d('MP3 Metadata map content: $metadata'); - // Fetch lyrics if embedLyrics is enabled if (settings.embedLyrics) { try { final durationMs = track.duration * 1000; @@ -1365,7 +1339,7 @@ class DownloadQueueNotifier extends Notifier { } Future _processQueue() async { - if (state.isProcessing) return; // Prevent multiple concurrent processing + if (state.isProcessing) return; state = state.copyWith(isProcessing: true); _log.i('Starting queue processing...'); @@ -1462,7 +1436,6 @@ class DownloadQueueNotifier extends Notifier { } } - /// Sequential download processing (uses multi-progress system with single item) Future _processQueueSequential() async { _startMultiProgressPolling(); @@ -1508,10 +1481,10 @@ class DownloadQueueNotifier extends Notifier { _stopProgressPolling(); } - /// Parallel download processing with worker pool Future _processQueueParallel() async { final maxConcurrent = state.concurrentDownloads; - final activeDownloads = >{}; // Map item ID to future + final activeDownloads = >{}; + _startMultiProgressPolling(); @@ -1565,7 +1538,6 @@ class DownloadQueueNotifier extends Notifier { _stopProgressPolling(); } - /// Download a single item (used by both sequential and parallel processing) Future _downloadSingleItem(DownloadItem item) async { _log.d('Processing: ${item.track.name} by ${item.track.artistName}'); _log.d('Cover URL: ${item.track.coverUrl}'); @@ -1628,7 +1600,6 @@ class DownloadQueueNotifier extends Notifier { trackToDownload.albumName, albumArtist: data['album_artist'] as String?, coverUrl: data['images'] as String?, - // duration_ms from Go is in milliseconds, Track.duration is in seconds duration: ((data['duration_ms'] as int?) ?? (trackToDownload.duration * 1000)) ~/ @@ -1675,7 +1646,6 @@ class DownloadQueueNotifier extends Notifier { String? genre; String? label; - // Try to get Deezer track ID from various sources String? deezerTrackId = trackToDownload.deezerId; if (deezerTrackId == null && trackToDownload.id.startsWith('deezer:')) { deezerTrackId = trackToDownload.id.split(':')[1]; @@ -1696,7 +1666,6 @@ class DownloadQueueNotifier extends Notifier { } } catch (e) { _log.w('Failed to fetch extended metadata from Deezer: $e'); - // Continue without extended metadata } } @@ -1728,7 +1697,7 @@ class DownloadQueueNotifier extends Notifier { releaseDate: trackToDownload.releaseDate, itemId: item.id, durationMs: trackToDownload.duration, - source: trackToDownload.source, // Pass extension ID that provided this track + source: trackToDownload.source, genre: genre, label: label, lyricsMode: settings.lyricsMode, @@ -1754,9 +1723,8 @@ class DownloadQueueNotifier extends Notifier { discNumber: trackToDownload.discNumber ?? 1, releaseDate: trackToDownload.releaseDate, preferredService: item.service, - itemId: item.id, // Pass item ID for progress tracking - durationMs: - trackToDownload.duration, // Duration in ms for verification + itemId: item.id, + durationMs: trackToDownload.duration, genre: genre, label: label, lyricsMode: settings.lyricsMode, @@ -1809,8 +1777,6 @@ class DownloadQueueNotifier extends Notifier { if (result['success'] == true) { var filePath = result['file_path'] as String?; - // Track if this was an existing file (not a new download) - // This is important to prevent converting existing FLAC files to MP3 final wasExisting = filePath != null && filePath.startsWith('EXISTS:'); if (wasExisting) { filePath = filePath.substring(7); // Remove "EXISTS:" prefix @@ -1912,7 +1878,6 @@ class DownloadQueueNotifier extends Notifier { ); } - // Get extended metadata from backend response final backendGenre = result['genre'] as String?; final backendLabel = result['label'] as String?; final backendCopyright = result['copyright'] as String?; @@ -1962,15 +1927,9 @@ class DownloadQueueNotifier extends Notifier { return; } - // Convert FLAC to MP3 if MP3 quality was selected - // IMPORTANT: Only convert NEW downloads, never convert existing files - // to prevent overwriting the user's existing FLAC files if (quality == 'MP3' && filePath != null && filePath.endsWith('.flac')) { if (wasExisting) { - // User wanted MP3 but an existing FLAC file was found - // Do NOT convert it - that would delete their existing FLAC _log.i('MP3 requested but existing FLAC found - skipping conversion to preserve original file'); - // Keep the existing FLAC file as-is } else { _log.i('MP3 quality selected, converting FLAC to MP3...'); updateItemStatus( @@ -1991,7 +1950,6 @@ class DownloadQueueNotifier extends Notifier { actualQuality = 'MP3 320kbps'; _log.i('Successfully converted to MP3: $mp3Path'); - // Embed metadata, lyrics, and cover to the MP3 file _log.i('Embedding metadata to MP3...'); updateItemStatus( item.id, @@ -2050,7 +2008,6 @@ class DownloadQueueNotifier extends Notifier { ? normalizedAlbumArtist : null; - // For MP3 files, don't save FLAC bitDepth/sampleRate - they're not applicable final isMp3 = filePath.endsWith('.mp3'); final historyBitDepth = isMp3 ? null : backendBitDepth; final historySampleRate = isMp3 ? null : backendSampleRate; diff --git a/lib/providers/extension_provider.dart b/lib/providers/extension_provider.dart index 3eb6f444..6f74d25b 100644 --- a/lib/providers/extension_provider.dart +++ b/lib/providers/extension_provider.dart @@ -5,7 +5,6 @@ import 'package:spotiflac_android/providers/settings_provider.dart'; final _log = AppLogger('ExtensionProvider'); -/// Represents an installed extension class Extension { final String id; final String name; @@ -14,19 +13,19 @@ class Extension { final String author; final String description; final bool enabled; - final String status; // 'loaded', 'error', 'disabled' + final String status; final String? errorMessage; - final String? iconPath; // Path to extension icon + final String? iconPath; final List permissions; final List settings; - final List qualityOptions; // Custom quality options for download providers + final List qualityOptions; final bool hasMetadataProvider; final bool hasDownloadProvider; final bool skipMetadataEnrichment; // If true, use metadata from extension instead of enriching - final SearchBehavior? searchBehavior; // Custom search behavior - final URLHandler? urlHandler; // Custom URL handling - final TrackMatching? trackMatching; // Custom track matching - final PostProcessing? postProcessing; // Post-processing hooks + final SearchBehavior? searchBehavior; + final URLHandler? urlHandler; + final TrackMatching? trackMatching; + final PostProcessing? postProcessing; const Extension({ required this.id, @@ -140,7 +139,6 @@ class Extension { bool get hasPostProcessing => postProcessing?.enabled ?? false; } -/// Custom search behavior configuration class SearchBehavior { final bool enabled; final String? placeholder; @@ -172,8 +170,6 @@ class SearchBehavior { ); } - /// Get thumbnail size based on configuration - /// Returns (width, height) tuple (double, double) getThumbnailSize({double defaultSize = 56}) { if (thumbnailWidth != null && thumbnailHeight != null) { return (thumbnailWidth!.toDouble(), thumbnailHeight!.toDouble()); @@ -191,11 +187,10 @@ class SearchBehavior { } } -/// Custom track matching configuration class TrackMatching { final bool customMatching; - final String? strategy; // "isrc", "name", "duration", "custom" - final int durationTolerance; // in seconds + final String? strategy; + final int durationTolerance; const TrackMatching({ required this.customMatching, @@ -212,7 +207,6 @@ class TrackMatching { } } -/// Post-processing configuration class PostProcessing { final bool enabled; final List hooks; @@ -262,7 +256,6 @@ class URLHandler { } } -/// A post-processing hook class PostProcessingHook { final String id; final String name; @@ -289,12 +282,11 @@ class PostProcessingHook { } } -/// Represents a quality option for download providers class QualityOption { final String id; final String label; final String? description; - final List settings; // Quality-specific settings + final List settings; const QualityOption({ required this.id, @@ -315,14 +307,13 @@ class QualityOption { } } -/// Represents a setting that's specific to a quality option class QualitySpecificSetting { final String key; final String label; - final String type; // 'string', 'number', 'boolean', 'select' + final String type; final dynamic defaultValue; final String? description; - final List? options; // For select type + final List? options; final bool required; final bool secret; @@ -351,16 +342,15 @@ class QualitySpecificSetting { } } -/// Represents a setting field for an extension class ExtensionSetting { final String key; final String label; - final String type; // 'string', 'number', 'boolean', 'select', 'button' + final String type; final dynamic defaultValue; final String? description; - final List? options; // For select type + final List? options; final bool required; - final String? action; // For button type: JS function name to call + final String? action; const ExtensionSetting({ required this.key, @@ -387,7 +377,6 @@ class ExtensionSetting { } } -/// State for extension management class ExtensionState { final List extensions; final List providerPriority; @@ -425,7 +414,6 @@ class ExtensionState { } -/// Provider for managing extensions class ExtensionNotifier extends Notifier { @override ExtensionState build() { @@ -451,7 +439,6 @@ class ExtensionNotifier extends Notifier { } } - /// Load all extensions from directory Future loadExtensions(String dirPath) async { state = state.copyWith(isLoading: true, error: null); @@ -486,12 +473,10 @@ class ExtensionNotifier extends Notifier { } } - /// Clear any error state void clearError() { state = state.copyWith(error: null); } - /// Install extension from file (auto-upgrades if already installed with newer version) Future installExtension(String filePath) async { state = state.copyWith(isLoading: true, error: null); @@ -508,8 +493,6 @@ class ExtensionNotifier extends Notifier { } } - /// Check if a package file is an upgrade for an existing extension - /// Returns: {extension_id, current_version, new_version, can_upgrade, is_installed} Future> checkExtensionUpgrade(String filePath) async { try { return await PlatformBridge.checkExtensionUpgrade(filePath); @@ -519,7 +502,6 @@ class ExtensionNotifier extends Notifier { } } - /// Upgrade an existing extension from a new package file Future upgradeExtension(String filePath) async { state = state.copyWith(isLoading: true, error: null); @@ -553,7 +535,6 @@ class ExtensionNotifier extends Notifier { } } - /// Enable or disable an extension Future setExtensionEnabled(String extensionId, bool enabled) async { try { await PlatformBridge.setExtensionEnabled(extensionId, enabled); @@ -600,7 +581,6 @@ class ExtensionNotifier extends Notifier { } } - /// Update settings for an extension Future setExtensionSettings(String extensionId, Map settings) async { try { await PlatformBridge.setExtensionSettings(extensionId, settings); @@ -621,7 +601,6 @@ class ExtensionNotifier extends Notifier { } } - /// Set provider priority order Future setProviderPriority(List priority) async { try { await PlatformBridge.setProviderPriority(priority); @@ -643,7 +622,6 @@ class ExtensionNotifier extends Notifier { } } - /// Set metadata provider priority order Future setMetadataProviderPriority(List priority) async { try { await PlatformBridge.setMetadataProviderPriority(priority); @@ -665,7 +643,6 @@ class ExtensionNotifier extends Notifier { } } - /// Get extension by ID Extension? getExtension(String extensionId) { try { return state.extensions.firstWhere((ext) => ext.id == extensionId); @@ -679,7 +656,6 @@ class ExtensionNotifier extends Notifier { return state.extensions.where((ext) => ext.enabled).toList(); } - /// Get all download providers (built-in + extensions) List getAllDownloadProviders() { final providers = ['tidal', 'qobuz', 'amazon']; for (final ext in state.extensions) { @@ -700,7 +676,6 @@ class ExtensionNotifier extends Notifier { } return providers; } - /// Get all extensions that provide custom search List get searchProviders { return state.extensions.where((ext) => ext.enabled && ext.hasCustomSearch).toList(); } diff --git a/lib/providers/recent_access_provider.dart b/lib/providers/recent_access_provider.dart index 1671eda4..ab0b1466 100644 --- a/lib/providers/recent_access_provider.dart +++ b/lib/providers/recent_access_provider.dart @@ -121,7 +121,6 @@ class RecentAccessNotifier extends Notifier { .map((e) => RecentAccessItem.fromJson(e as Map)) .toList(); } catch (e) { - // Ignore parse errors } } @@ -266,7 +265,6 @@ class RecentAccessNotifier extends Notifier { } } -/// Provider instance final recentAccessProvider = NotifierProvider( RecentAccessNotifier.new, ); diff --git a/lib/providers/settings_provider.dart b/lib/providers/settings_provider.dart index 2de3d839..1e7830f4 100644 --- a/lib/providers/settings_provider.dart +++ b/lib/providers/settings_provider.dart @@ -30,7 +30,6 @@ class SettingsNotifier extends Notifier { } } - /// Run one-time migrations for settings Future _runMigrations(SharedPreferences prefs) async { final lastMigration = prefs.getInt(_migrationVersionKey) ?? 0; @@ -51,7 +50,6 @@ class SettingsNotifier extends Notifier { await prefs.setString(_settingsKey, jsonEncode(state.toJson())); } - /// Apply current Spotify credentials to Go backend Future _applySpotifyCredentials() async { if (state.spotifyClientId.isNotEmpty && state.spotifyClientSecret.isNotEmpty) { @@ -93,7 +91,6 @@ class SettingsNotifier extends Notifier { } void setLyricsMode(String mode) { - // Valid modes: embed, external, both if (mode == 'embed' || mode == 'external' || mode == 'both') { state = state.copyWith(lyricsMode: mode); _saveSettings(); diff --git a/lib/providers/store_provider.dart b/lib/providers/store_provider.dart index fe067e5e..6a314cab 100644 --- a/lib/providers/store_provider.dart +++ b/lib/providers/store_provider.dart @@ -52,7 +52,6 @@ class StoreCategory { } } -/// Represents an extension in the store class StoreExtension { final String id; final String name; @@ -118,7 +117,6 @@ class StoreExtension { } } -/// State for extension store class StoreState { final List extensions; final String? selectedCategory; @@ -200,7 +198,6 @@ class StoreNotifier extends Notifier { return const StoreState(); } - /// Initialize the store Future initialize(String cacheDir) async { if (state.isInitialized) return; @@ -234,7 +231,6 @@ class StoreNotifier extends Notifier { } } - /// Set category filter void setCategory(String? category) { if (category == null) { state = state.copyWith(clearCategory: true); @@ -248,7 +244,6 @@ class StoreNotifier extends Notifier { state = state.copyWith(searchQuery: query); } - /// Clear search void clearSearch() { state = state.copyWith(searchQuery: '', clearCategory: true); } @@ -279,7 +274,6 @@ class StoreNotifier extends Notifier { } } - /// Update an installed extension Future updateExtension(String extensionId, String tempDir) async { state = state.copyWith(isDownloading: true, downloadingId: extensionId, clearError: true); @@ -305,7 +299,6 @@ class StoreNotifier extends Notifier { } } - /// Clear error void clearError() { state = state.copyWith(clearError: true); } diff --git a/lib/providers/theme_provider.dart b/lib/providers/theme_provider.dart index ca40c032..f1a3e728 100644 --- a/lib/providers/theme_provider.dart +++ b/lib/providers/theme_provider.dart @@ -34,7 +34,6 @@ class ThemeNotifier extends Notifier { ); } catch (e) { debugPrint('Error loading theme settings: $e'); - // Keep default state on error } } diff --git a/lib/providers/track_provider.dart b/lib/providers/track_provider.dart index cb0fad0b..074fede8 100644 --- a/lib/providers/track_provider.dart +++ b/lib/providers/track_provider.dart @@ -89,7 +89,6 @@ class TrackState { } } -/// Represents an album in artist discography class ArtistAlbum { final String id; final String name; @@ -112,7 +111,6 @@ class ArtistAlbum { }); } -/// Represents an artist in search results class SearchArtist { final String id; final String name; @@ -130,7 +128,6 @@ class SearchArtist { } class TrackNotifier extends Notifier { - /// Request ID to track and cancel outdated requests int _currentRequestId = 0; @override @@ -213,14 +210,8 @@ class TrackNotifier extends Notifier { Map metadata; try { - // ignore: avoid_print - print('[FetchURL] Fetching $type with Deezer fallback enabled...'); metadata = await PlatformBridge.getSpotifyMetadataWithFallback(url); - // ignore: avoid_print - print('[FetchURL] Metadata fetch success'); } catch (e) { - // ignore: avoid_print - print('[FetchURL] Metadata fetch failed: $e'); rethrow; } @@ -263,7 +254,7 @@ class TrackNotifier extends Notifier { final albumsList = metadata['albums'] as List; final albums = albumsList.map((a) => _parseArtistAlbum(a as Map)).toList(); state = TrackState( - tracks: [], // No tracks for artist view + tracks: [], isLoading: false, artistId: artistInfo['id'] as String?, artistName: artistInfo['name'] as String?, @@ -397,7 +388,6 @@ class TrackNotifier extends Notifier { } } - /// Perform custom search using a specific extension Future customSearch(String extensionId, String query, {Map? options}) async { final requestId = ++_currentRequestId; @@ -429,7 +419,7 @@ class TrackNotifier extends Notifier { state = TrackState( tracks: tracks, - searchArtists: [], // Custom search doesn't return artists + searchArtists: [], isLoading: false, hasSearchText: state.hasSearchText, searchExtensionId: extensionId, // Store which extension was used @@ -477,8 +467,6 @@ class TrackNotifier extends Notifier { tracks[index] = updatedTrack; state = state.copyWith(tracks: tracks); } catch (e) { - // Silently ignore availability check errors - // This is a background operation that shouldn't disrupt the user } } @@ -494,7 +482,6 @@ class TrackNotifier extends Notifier { state = state.copyWith(hasSearchText: hasText); } - /// Set recent access mode state void setShowingRecentAccess(bool showing) { state = state.copyWith(isShowingRecentAccess: showing); } @@ -584,8 +571,6 @@ class TrackNotifier extends Notifier { ); } - /// Pre-warm track ID cache for faster downloads - /// Runs in background, doesn't block UI void _preWarmCacheForTracks(List tracks) { final tracksWithIsrc = tracks.where((t) => t.isrc != null && t.isrc!.isNotEmpty).toList(); if (tracksWithIsrc.isEmpty) return; diff --git a/lib/screens/album_screen.dart b/lib/screens/album_screen.dart index 5eede561..86db03de 100644 --- a/lib/screens/album_screen.dart +++ b/lib/screens/album_screen.dart @@ -13,7 +13,6 @@ import 'package:spotiflac_android/providers/recent_access_provider.dart'; import 'package:spotiflac_android/services/platform_bridge.dart'; import 'package:spotiflac_android/widgets/download_service_picker.dart'; -/// Simple in-memory cache for album tracks class _AlbumCache { static final Map _cache = {}; static const Duration _ttl = Duration(minutes: 10); @@ -39,7 +38,6 @@ class _CacheEntry { _CacheEntry(this.tracks, this.expiresAt); } -/// Album detail screen with Material Expressive 3 design class AlbumScreen extends ConsumerStatefulWidget { final String albumId; final String albumName; @@ -99,7 +97,6 @@ class _AlbumScreenState extends ConsumerState { } void _onScroll() { - // Show title in AppBar when scrolled past the header (320 - kToolbarHeight + info card top) final shouldShow = _scrollController.offset > 280; if (shouldShow != _showTitleInAppBar) { setState(() => _showTitleInAppBar = shouldShow); @@ -121,7 +118,6 @@ class _AlbumScreenState extends ConsumerState { }); } } catch (_) { - // Ignore palette extraction errors } } @@ -132,12 +128,8 @@ class _AlbumScreenState extends ConsumerState { if (widget.albumId.startsWith('deezer:')) { final deezerAlbumId = widget.albumId.replaceFirst('deezer:', ''); - // ignore: avoid_print - print('[AlbumScreen] Fetching from Deezer: $deezerAlbumId'); metadata = await PlatformBridge.getDeezerMetadata('album', deezerAlbumId); } else { - // ignore: avoid_print - print('[AlbumScreen] Fetching from Spotify with fallback: ${widget.albumId}'); final url = 'https://open.spotify.com/album/${widget.albumId}'; metadata = await PlatformBridge.getSpotifyMetadataWithFallback(url); } @@ -219,7 +211,7 @@ class _AlbumScreenState extends ConsumerState { expandedHeight: 320, pinned: true, stretch: true, - backgroundColor: colorScheme.surface, // Use theme color for collapsed state + backgroundColor: colorScheme.surface, surfaceTintColor: Colors.transparent, title: AnimatedOpacity( duration: const Duration(milliseconds: 200), @@ -261,7 +253,6 @@ class _AlbumScreenState extends ConsumerState { ), ), ), - // Cover image centered - fade out when collapsing AnimatedOpacity( duration: const Duration(milliseconds: 150), opacity: showContent ? 1.0 : 0.0, @@ -449,7 +440,6 @@ class _AlbumScreenState extends ConsumerState { } } - /// Build error widget with special handling for rate limit (429) Widget _buildErrorWidget(String error, ColorScheme colorScheme) { final isRateLimit = error.contains('429') || error.toLowerCase().contains('rate limit') || @@ -512,7 +502,6 @@ class _AlbumScreenState extends ConsumerState { } } -/// Separate Consumer widget for each track - only rebuilds when this specific track's status changes class _AlbumTrackItem extends ConsumerWidget { final Track track; final VoidCallback onDownload; diff --git a/lib/screens/artist_screen.dart b/lib/screens/artist_screen.dart index 7a7a35a4..5dda70bf 100644 --- a/lib/screens/artist_screen.dart +++ b/lib/screens/artist_screen.dart @@ -97,7 +97,6 @@ class _ArtistScreenState extends ConsumerState { int? _monthlyListeners; String? _error; - // Sticky title state bool _showTitleInAppBar = false; final ScrollController _scrollController = ScrollController(); @@ -310,7 +309,6 @@ return Scaffold( ); } - /// Build Spotify-style header with full-width image and artist name overlay Widget _buildHeader(BuildContext context, ColorScheme colorScheme) { String? imageUrl = _headerImageUrl; if (imageUrl == null || imageUrl.isEmpty) { @@ -479,7 +477,6 @@ if (hasValidImage) ); } - /// Build a single popular track item with dynamic download status Widget _buildPopularTrackItem(int rank, Track track, ColorScheme colorScheme) { final queueItem = ref.watch( downloadQueueLookupProvider.select((lookup) => lookup.byTrackId[track.id]), @@ -608,7 +605,6 @@ if (hasValidImage) _downloadTrack(track); } - /// Build download button with status indicator for popular tracks Widget _buildPopularDownloadButton({ required Track track, required ColorScheme colorScheme, diff --git a/lib/screens/downloaded_album_screen.dart b/lib/screens/downloaded_album_screen.dart index 830dd8c3..6cc2f621 100644 --- a/lib/screens/downloaded_album_screen.dart +++ b/lib/screens/downloaded_album_screen.dart @@ -77,7 +77,6 @@ class _DownloadedAlbumScreenState extends ConsumerState { }); } } catch (_) { - // Ignore palette extraction errors } } @@ -508,9 +507,7 @@ class _DownloadedAlbumScreenState extends ConsumerState { Widget _buildTrackList(BuildContext context, ColorScheme colorScheme, List tracks) { final discMap = _groupTracksByDisc(tracks); - // Single disc - use simple list if (discMap.length <= 1) { - // Single disc - use simple list return SliverList( delegate: SliverChildBuilderDelegate( (context, index) { @@ -525,7 +522,6 @@ class _DownloadedAlbumScreenState extends ConsumerState { ); } - // Multiple discs - build list with separators final discNumbers = discMap.keys.toList()..sort(); final List children = []; diff --git a/lib/screens/home_tab.dart b/lib/screens/home_tab.dart index c692f6db..7339bbba 100644 --- a/lib/screens/home_tab.dart +++ b/lib/screens/home_tab.dart @@ -75,7 +75,6 @@ class _HomeTabState extends ConsumerState with AutomaticKeepAliveClient } } - /// Called when trackState changes - used to sync search bar with state void _onTrackStateChanged(TrackState? previous, TrackState next) { if (previous != null && !next.hasContent && @@ -96,7 +95,6 @@ class _HomeTabState extends ConsumerState with AutomaticKeepAliveClient if (searchProvider == null || searchProvider.isEmpty) return false; - // Check if the extension is enabled and has search capability final extension = extState.extensions.where((e) => e.id == searchProvider && e.enabled).firstOrNull; return extension != null; } @@ -130,10 +128,7 @@ class _HomeTabState extends ConsumerState with AutomaticKeepAliveClient } } - /// Execute live search with concurrency protection - /// Prevents race conditions in extensions by ensuring only one search runs at a time Future _executeLiveSearch(String query) async { - // If a search is already in progress, queue this one if (_isLiveSearchInProgress) { _pendingLiveSearchQuery = query; return; @@ -151,13 +146,10 @@ class _HomeTabState extends ConsumerState with AutomaticKeepAliveClient final pending = _pendingLiveSearchQuery; _pendingLiveSearchQuery = null; - // Execute pending query if it's different from what we just searched - // and still matches current text field content if (pending != null && pending != query && mounted && _urlController.text.trim() == pending) { - // Small delay to let extension's state settle await Future.delayed(const Duration(milliseconds: 100)); if (mounted && _urlController.text.trim() == pending) { _executeLiveSearch(pending); @@ -224,7 +216,6 @@ class _HomeTabState extends ConsumerState with AutomaticKeepAliveClient ref.read(settingsProvider.notifier).setHasSearchedBefore(); } - /// Navigate to detail screen based on fetched content type void _navigateToDetailIfNeeded() { final trackState = ref.read(trackProvider); @@ -356,7 +347,6 @@ class _HomeTabState extends ConsumerState with AutomaticKeepAliveClient // ignore: use_build_context_synchronously final l10n = context.l10n; - // Show quality picker if enabled in settings if (settings.askQualityBeforeDownload) { DownloadServicePicker.show( this.context, @@ -676,7 +666,6 @@ class _HomeTabState extends ConsumerState with AutomaticKeepAliveClient ); } - /// Build recent access history section (shown when search focused) Widget _buildRecentAccess(List items, ColorScheme colorScheme) { final historyItems = ref.read(downloadHistoryProvider).items; @@ -690,9 +679,6 @@ class _HomeTabState extends ConsumerState with AutomaticKeepAliveClient albumGroups.putIfAbsent(albumKey, () => []).add(h); } - // Convert to RecentAccessItem based on track count: - // - 1 track: show as individual Track - // - 2+ tracks: show as Album final downloadItems = []; for (final entry in albumGroups.entries) { final tracks = entry.value; @@ -703,7 +689,6 @@ class _HomeTabState extends ConsumerState with AutomaticKeepAliveClient : mostRecent.artistName; if (tracks.length == 1) { - // Single track - show as Track downloadItems.add(RecentAccessItem( id: mostRecent.spotifyId ?? mostRecent.id, name: mostRecent.trackName, @@ -714,7 +699,6 @@ class _HomeTabState extends ConsumerState with AutomaticKeepAliveClient providerId: 'download', )); } else { - // Multiple tracks - show as Album downloadItems.add(RecentAccessItem( id: '${mostRecent.albumName}|$artistForKey', name: mostRecent.albumName, @@ -727,10 +711,8 @@ class _HomeTabState extends ConsumerState with AutomaticKeepAliveClient } } - // Sort by most recent and take top 10 downloadItems.sort((a, b) => b.accessedAt.compareTo(a.accessedAt)); - - // Filter out hidden downloads (use ref.watch for reactivity) + final hiddenIds = ref.watch(recentAccessProvider.select((s) => s.hiddenDownloadIds)); final visibleDownloads = downloadItems .where((item) => !hiddenIds.contains(item.id)) @@ -768,11 +750,9 @@ class _HomeTabState extends ConsumerState with AutomaticKeepAliveClient if (uniqueItems.isNotEmpty) TextButton( onPressed: () { - // Hide ALL download items (not just visible ones) for (final item in downloadItems) { ref.read(recentAccessProvider.notifier).hideDownloadFromRecents(item.id); } - // Clear non-download recent history ref.read(recentAccessProvider.notifier).clearHistory(); }, child: Text( @@ -784,7 +764,6 @@ class _HomeTabState extends ConsumerState with AutomaticKeepAliveClient ), const SizedBox(height: 8), if (uniqueItems.isEmpty && hasHiddenDownloads) - // Show "Show All" button when recents is empty but there are hidden downloads Center( child: Padding( padding: const EdgeInsets.symmetric(vertical: 24), @@ -897,10 +876,8 @@ class _HomeTabState extends ConsumerState with AutomaticKeepAliveClient icon: Icon(Icons.close, size: 20, color: colorScheme.onSurfaceVariant), onPressed: () { if (item.providerId == 'download') { - // For download items, hide from recents without deleting the file ref.read(recentAccessProvider.notifier).hideDownloadFromRecents(item.id); } else { - // For other items, remove from recent history ref.read(recentAccessProvider.notifier).removeItem(item); } }, @@ -936,7 +913,6 @@ class _HomeTabState extends ConsumerState with AutomaticKeepAliveClient )); } case RecentAccessType.album: - // Handle downloaded albums - navigate to DownloadedAlbumScreen if (item.providerId == 'download') { Navigator.push(context, MaterialPageRoute( builder: (context) => DownloadedAlbumScreen( @@ -1000,7 +976,6 @@ class _HomeTabState extends ConsumerState with AutomaticKeepAliveClient ); } - /// Build error widget with special handling for rate limit (429) Widget _buildErrorWidget(String error, ColorScheme colorScheme) { final isRateLimit = error.contains('429') || error.toLowerCase().contains('rate limit') || @@ -1427,7 +1402,6 @@ class _HomeTabState extends ConsumerState with AutomaticKeepAliveClient )); } - /// Get search hint based on selected provider String _getSearchHint() { final settings = ref.read(settingsProvider); final searchProvider = settings.searchProvider; @@ -1474,11 +1448,8 @@ class _HomeTabState extends ConsumerState with AutomaticKeepAliveClient ), prefixIcon: _SearchProviderDropdown( onProviderChanged: () { - // Reset search state when provider changes _lastSearchQuery = null; - // Force rebuild to update hint text setState(() {}); - // Re-trigger search if there's text final text = _urlController.text.trim(); if (text.isNotEmpty && text.length >= _minLiveSearchChars) { _performSearch(text); @@ -1514,9 +1485,7 @@ class _HomeTabState extends ConsumerState with AutomaticKeepAliveClient ); } - /// Handle Enter key press - search or fetch URL void _onSearchSubmitted() { - // Cancel any pending live search since user explicitly pressed enter _liveSearchDebounce?.cancel(); _pendingLiveSearchQuery = null; @@ -1549,13 +1518,11 @@ class _SearchProviderDropdown extends ConsumerWidget { final extState = ref.watch(extensionProvider); final colorScheme = Theme.of(context).colorScheme; - // Get current provider info final currentProvider = settings.searchProvider; final searchProviders = extState.extensions .where((ext) => ext.enabled && ext.hasCustomSearch) .toList(); - // Find current provider extension Extension? currentExt; if (currentProvider != null && currentProvider.isNotEmpty) { currentExt = searchProviders.where((e) => e.id == currentProvider).firstOrNull; @@ -1567,12 +1534,10 @@ class _SearchProviderDropdown extends ConsumerWidget { if (currentExt != null) { iconPath = currentExt.iconPath; if (currentExt.searchBehavior?.icon != null) { - // Use search behavior icon if available displayIcon = _getIconFromName(currentExt.searchBehavior!.icon!); } } - // Don't show dropdown if no custom search providers available if (searchProviders.isEmpty) { return const Icon(Icons.search); } @@ -1608,15 +1573,13 @@ class _SearchProviderDropdown extends ConsumerWidget { offset: const Offset(0, 40), shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), onSelected: (String providerId) { - // Empty string means default (Deezer/Spotify) final provider = providerId.isEmpty ? null : providerId; ref.read(settingsProvider.notifier).setSearchProvider(provider); onProviderChanged?.call(); }, itemBuilder: (context) => [ - // Default option (Deezer/Spotify based on metadata source) PopupMenuItem( - value: '', // Empty string = default provider + value: '', child: Row( children: [ Icon( @@ -1716,7 +1679,6 @@ class _SearchProviderDropdown extends ConsumerWidget { } } -/// Separate Consumer widget for each track item - only rebuilds when this specific track's status changes class _TrackItemWithStatus extends ConsumerWidget { final Track track; final int index; @@ -2028,7 +1990,6 @@ class _CollectionItemWidget extends StatelessWidget { } } -/// Screen for viewing extension album with track fetching class ExtensionAlbumScreen extends ConsumerStatefulWidget { final String extensionId; final String albumId; @@ -2299,7 +2260,6 @@ class _ExtensionPlaylistScreenState extends ConsumerState { } } - /// Handle back press with double-tap to exit void _handleBackPress() { final trackState = ref.read(trackProvider); @@ -174,9 +173,6 @@ class _MainShellState extends ConsumerState { final isKeyboardVisible = MediaQuery.of(context).viewInsets.bottom > 0; - // Determine if we can pop (for predictive back animation) - // canPop is true when we're at root with no content - enables predictive back gesture - // IMPORTANT: Never allow pop when keyboard is visible to prevent accidental navigation final canPop = _currentIndex == 0 && !trackState.hasSearchText && !trackState.hasContent && @@ -250,8 +246,6 @@ class _MainShellState extends ConsumerState { canPop: canPop, onPopInvokedWithResult: (didPop, result) async { if (didPop) { - // System handled the pop - this means predictive back completed - // We need to handle double-tap to exit here return; } diff --git a/lib/screens/playlist_screen.dart b/lib/screens/playlist_screen.dart index a62af731..e64c8daf 100644 --- a/lib/screens/playlist_screen.dart +++ b/lib/screens/playlist_screen.dart @@ -11,7 +11,6 @@ import 'package:spotiflac_android/providers/download_queue_provider.dart'; import 'package:spotiflac_android/providers/settings_provider.dart'; import 'package:spotiflac_android/widgets/download_service_picker.dart'; -/// Playlist detail screen with Material Expressive 3 design class PlaylistScreen extends ConsumerStatefulWidget { final String playlistName; final String? coverUrl; @@ -69,7 +68,6 @@ class _PlaylistScreenState extends ConsumerState { }); } } catch (_) { - // Ignore palette extraction errors } } diff --git a/lib/screens/queue_tab.dart b/lib/screens/queue_tab.dart index 1017cc29..c8bd4f7e 100644 --- a/lib/screens/queue_tab.dart +++ b/lib/screens/queue_tab.dart @@ -13,7 +13,6 @@ import 'package:spotiflac_android/providers/settings_provider.dart'; import 'package:spotiflac_android/screens/track_metadata_screen.dart'; import 'package:spotiflac_android/screens/downloaded_album_screen.dart'; -/// Grouped album data for history display class _GroupedAlbum { final String albumName; final String artistName; @@ -108,7 +107,6 @@ class _QueueTabState extends ConsumerState { ); } - /// Enter selection mode with initial item void _enterSelectionMode(String itemId) { HapticFeedback.mediumImpact(); setState(() { @@ -125,7 +123,6 @@ class _QueueTabState extends ConsumerState { }); } - /// Toggle item selection void _toggleSelection(String itemId) { setState(() { if (_selectedIds.contains(itemId)) { @@ -146,7 +143,6 @@ class _QueueTabState extends ConsumerState { }); } - /// Delete selected items Future _deleteSelected() async { final count = _selectedIds.length; final confirmed = await showDialog( @@ -307,9 +303,6 @@ class _QueueTabState extends ConsumerState { ); } - /// Filter history items based on current filter mode - /// Album = track yang albumnya punya >1 track di history - /// Single = track yang albumnya cuma 1 track di history List _filterHistoryItems( List items, String filterMode, @@ -725,7 +718,6 @@ class _QueueTabState extends ConsumerState { ); } - /// Build content for each filter tab Widget _buildFilterContent({ required BuildContext context, required ColorScheme colorScheme, @@ -931,7 +923,6 @@ class _QueueTabState extends ConsumerState { ); } - /// Build album grid item for grouped albums view Widget _buildAlbumGridItem( BuildContext context, _GroupedAlbum album, @@ -1745,7 +1736,6 @@ child: CachedNetworkImage( } } -/// Filter chip widget for history filtering class _FilterChip extends StatelessWidget { final String label; final int count; diff --git a/lib/screens/settings/about_page.dart b/lib/screens/settings/about_page.dart index 3df2dd2c..a68d1f03 100644 --- a/lib/screens/settings/about_page.dart +++ b/lib/screens/settings/about_page.dart @@ -15,7 +15,7 @@ class AboutPage extends StatelessWidget { final topPadding = MediaQuery.of(context).padding.top; return PopScope( - canPop: true, // Always allow back gesture + canPop: true, child: Scaffold( body: CustomScrollView( slivers: [ @@ -253,9 +253,9 @@ class _AppHeaderCard extends StatelessWidget { color: colorScheme.primary, shape: BoxShape.circle, ), - child: Image.asset( + child: Image.asset( 'assets/images/logo-transparant.png', - color: colorScheme.onPrimary, // Tint with onPrimary color + color: colorScheme.onPrimary, fit: BoxFit.contain, errorBuilder: (_, _, _) => ClipRRect( borderRadius: BorderRadius.circular(24), diff --git a/lib/screens/settings/appearance_settings_page.dart b/lib/screens/settings/appearance_settings_page.dart index 4e039a85..92ca5ff0 100644 --- a/lib/screens/settings/appearance_settings_page.dart +++ b/lib/screens/settings/appearance_settings_page.dart @@ -17,7 +17,7 @@ class AppearanceSettingsPage extends ConsumerWidget { final topPadding = MediaQuery.of(context).padding.top; return PopScope( - canPop: true, // Always allow back gesture + canPop: true, child: Scaffold( body: CustomScrollView( slivers: [ @@ -161,7 +161,7 @@ class _ThemePreviewCard extends StatelessWidget { width: double.infinity, decoration: BoxDecoration( color: colorScheme - .surfaceContainerHighest, // Background similar to reference + .surfaceContainerHighest, borderRadius: BorderRadius.circular(28), ), clipBehavior: Clip.antiAlias, @@ -203,7 +203,7 @@ class _ThemePreviewCard extends StatelessWidget { boxShadow: [ BoxShadow( color: Colors.black.withValues(alpha: 0.1), - blurRadius: 12, // Reduced from 20 for performance + blurRadius: 12, offset: const Offset(0, 8), ), ], diff --git a/lib/screens/settings/download_settings_page.dart b/lib/screens/settings/download_settings_page.dart index 14b782a2..f408c64d 100644 --- a/lib/screens/settings/download_settings_page.dart +++ b/lib/screens/settings/download_settings_page.dart @@ -22,7 +22,7 @@ class DownloadSettingsPage extends ConsumerWidget { final isBuiltInService = _builtInServices.contains(settings.defaultService); return PopScope( - canPop: true, // Always allow back gesture + canPop: true, child: Scaffold( body: CustomScrollView( slivers: [ diff --git a/lib/screens/setup_screen.dart b/lib/screens/setup_screen.dart index 3c603de6..7c9036db 100644 --- a/lib/screens/setup_screen.dart +++ b/lib/screens/setup_screen.dart @@ -581,7 +581,7 @@ class _SetupScreenState extends ConsumerState { switch (step) { case 0: return _storagePermissionGranted; case 1: return _selectedDirectory != null; - case 2: return false; // Spotify step never shows checkmark (optional) + case 2: return false; } } return false; diff --git a/lib/screens/store_tab.dart b/lib/screens/store_tab.dart index 9053f546..2114777f 100644 --- a/lib/screens/store_tab.dart +++ b/lib/screens/store_tab.dart @@ -122,7 +122,7 @@ class _StoreTabState extends ConsumerState { ), onChanged: (value) { ref.read(storeProvider.notifier).setSearchQuery(value); - setState(() {}); // Update suffix icon + setState(() {}); }, ), ), diff --git a/lib/screens/track_metadata_screen.dart b/lib/screens/track_metadata_screen.dart index 4e236f0c..caaa6343 100644 --- a/lib/screens/track_metadata_screen.dart +++ b/lib/screens/track_metadata_screen.dart @@ -13,8 +13,6 @@ import 'package:spotiflac_android/providers/download_queue_provider.dart'; import 'package:spotiflac_android/services/platform_bridge.dart'; import 'package:spotiflac_android/l10n/l10n.dart'; -/// Screen to display detailed metadata for a downloaded track -/// Designed with Material Expressive 3 style class TrackMetadataScreen extends ConsumerStatefulWidget { final DownloadHistoryItem item; @@ -101,7 +99,6 @@ class _TrackMetadataScreenState extends ConsumerState { }); } } catch (_) { - // Ignore palette extraction errors } } @@ -263,7 +260,6 @@ class _TrackMetadataScreenState extends ConsumerState { return Stack( fit: StackFit.expand, children: [ - // Background with dominant color AnimatedContainer( duration: const Duration(milliseconds: 500), decoration: BoxDecoration( @@ -280,7 +276,6 @@ class _TrackMetadataScreenState extends ConsumerState { ), ), - // Cover image centered - fade out when collapsing AnimatedOpacity( duration: const Duration(milliseconds: 150), opacity: showContent ? 1.0 : 0.0, @@ -683,7 +678,6 @@ class _TrackMetadataScreenState extends ConsumerState { ), ), ), - // Show 320kbps for MP3, bit depth/sample rate for FLAC if (fileExtension == 'MP3') Container( padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), @@ -1057,8 +1051,8 @@ class _TrackMetadataScreenState extends ConsumerState { ref.read(downloadHistoryProvider.notifier).removeFromHistory(item.id); if (context.mounted) { - Navigator.pop(context); // Close dialog - Navigator.pop(context); // Go back to history + Navigator.pop(context); + Navigator.pop(context); } }, child: Text(context.l10n.dialogDelete, style: TextStyle(color: colorScheme.error)), diff --git a/lib/services/cover_cache_manager.dart b/lib/services/cover_cache_manager.dart index 37d2fc3f..288dad2f 100644 --- a/lib/services/cover_cache_manager.dart +++ b/lib/services/cover_cache_manager.dart @@ -18,8 +18,6 @@ class CoverCacheManager { static bool _initialized = false; static String? _cachePath; - /// Get the singleton cache manager instance. - /// Must call [initialize] before using this. static CacheManager get instance { if (!_initialized || _instance == null) { // Fallback to default cache manager if not initialized @@ -32,8 +30,6 @@ class CoverCacheManager { /// Check if cache manager is initialized static bool get isInitialized => _initialized && _instance != null; - /// Initialize the cache manager with persistent storage path. - /// Call this once during app startup (in main.dart). static Future initialize() async { if (_initialized) return; @@ -73,7 +69,6 @@ class CoverCacheManager { await _instance!.emptyCache(); } - /// Get cache statistics static Future getStats() async { if (!_initialized || _cachePath == null) { return const CacheStats(fileCount: 0, totalSizeBytes: 0); @@ -113,7 +108,6 @@ class CacheStats { required this.totalSizeBytes, }); - /// Get human-readable size string String get formattedSize { if (totalSizeBytes < 1024) { return '$totalSizeBytes B'; diff --git a/lib/services/csv_import_service.dart b/lib/services/csv_import_service.dart index b2ef0a1c..0687f385 100644 --- a/lib/services/csv_import_service.dart +++ b/lib/services/csv_import_service.dart @@ -7,8 +7,6 @@ import 'package:spotiflac_android/utils/logger.dart'; class CsvImportService { static final _log = AppLogger('CsvImportService'); - /// Pick and parse CSV file, then enrich metadata from Deezer - /// [onProgress] callback receives (current, total) for progress updates static Future> pickAndParseCsv({ void Function(int current, int total)? onProgress, }) async { @@ -34,8 +32,6 @@ class CsvImportService { return []; } - /// Enrich tracks with metadata from Deezer using ISRC or search - /// This fetches cover URL, duration, and other metadata that CSV doesn't have static Future> _enrichTracksMetadata( List tracks, { void Function(int current, int total)? onProgress, diff --git a/lib/services/ffmpeg_service.dart b/lib/services/ffmpeg_service.dart index 3ed2ced7..6243d41e 100644 --- a/lib/services/ffmpeg_service.dart +++ b/lib/services/ffmpeg_service.dart @@ -10,7 +10,6 @@ final _log = AppLogger('FFmpeg'); class FFmpegService { static const _channel = MethodChannel('com.zarz.spotiflac/ffmpeg'); - /// Execute FFmpeg command and return result static Future _execute(String command) async { try { final result = await _channel.invokeMethod('execute', {'command': command}); @@ -26,8 +25,6 @@ class FFmpegService { } } - /// Convert M4A (DASH segments) to FLAC - /// Returns the output file path on success, null on failure static Future convertM4aToFlac(String inputPath) async { final outputPath = inputPath.replaceAll('.m4a', '.flac'); @@ -47,14 +44,11 @@ class FFmpegService { return null; } - /// Convert FLAC to MP3 - /// If deleteOriginal is true, deletes the FLAC file after conversion static Future convertFlacToMp3( String inputPath, { String bitrate = '320k', bool deleteOriginal = true, }) async { - // Convert in same folder, just change extension final outputPath = inputPath.replaceAll('.flac', '.mp3'); final command = @@ -63,7 +57,6 @@ class FFmpegService { final result = await _execute(command); if (result.success) { - // Delete original FLAC if requested if (deleteOriginal) { try { await File(inputPath).delete(); @@ -76,7 +69,6 @@ class FFmpegService { return null; } - /// Convert FLAC to M4A (AAC or ALAC) static Future convertFlacToM4a( String inputPath, { String codec = 'aac', @@ -110,7 +102,6 @@ class FFmpegService { return null; } - /// Check if FFmpeg is available static Future isAvailable() async { try { final version = await _channel.invokeMethod('getVersion'); @@ -120,7 +111,6 @@ class FFmpegService { } } - /// Get FFmpeg version info static Future getVersion() async { try { final version = await _channel.invokeMethod('getVersion'); @@ -130,8 +120,6 @@ class FFmpegService { } } - /// Embed metadata and cover art to FLAC file - /// Returns the file path on success, null on failure static Future embedMetadata({ required String flacPath, String? coverPath, @@ -211,8 +199,6 @@ class FFmpegService { return null; } - /// Embed metadata and cover art to MP3 file using ID3v2 tags - /// Returns the file path on success, null on failure static Future embedMetadataToMp3({ required String mp3Path, String? coverPath, @@ -242,7 +228,6 @@ class FFmpegService { cmdBuffer.write('-c:a copy '); if (metadata != null) { - // Convert FLAC/Vorbis tags to ID3v2 tags for MP3 final id3Metadata = _convertToId3Tags(metadata); id3Metadata.forEach((key, value) { final sanitizedValue = value.replaceAll('"', '\\"'); @@ -295,7 +280,6 @@ class FFmpegService { return null; } - /// Convert FLAC/Vorbis comment tags to ID3v2 compatible tags static Map _convertToId3Tags(Map vorbisMetadata) { final id3Map = {}; @@ -330,7 +314,7 @@ class FFmpegService { id3Map['date'] = value; break; case 'ISRC': - id3Map['TSRC'] = value; // ID3v2 ISRC frame + id3Map['TSRC'] = value; break; case 'LYRICS': case 'UNSYNCEDLYRICS': @@ -346,7 +330,6 @@ class FFmpegService { } } -/// Result of FFmpeg command execution class FFmpegResult { final bool success; final int returnCode; diff --git a/lib/services/platform_bridge.dart b/lib/services/platform_bridge.dart index 8658a6d7..f0895bcd 100644 --- a/lib/services/platform_bridge.dart +++ b/lib/services/platform_bridge.dart @@ -4,25 +4,21 @@ import 'package:spotiflac_android/utils/logger.dart'; final _log = AppLogger('PlatformBridge'); -/// Bridge to communicate with Go backend via platform channels class PlatformBridge { static const _channel = MethodChannel('com.zarz.spotiflac/backend'); - /// Parse and validate Spotify URL static Future> parseSpotifyUrl(String url) async { _log.d('parseSpotifyUrl: $url'); final result = await _channel.invokeMethod('parseSpotifyUrl', {'url': url}); return jsonDecode(result as String) as Map; } - /// Get Spotify metadata from URL static Future> getSpotifyMetadata(String url) async { _log.d('getSpotifyMetadata: $url'); final result = await _channel.invokeMethod('getSpotifyMetadata', {'url': url}); return jsonDecode(result as String) as Map; } - /// Search Spotify static Future> searchSpotify(String query, {int limit = 10}) async { _log.d('searchSpotify: "$query" (limit: $limit)'); final result = await _channel.invokeMethod('searchSpotify', { @@ -32,7 +28,6 @@ class PlatformBridge { return jsonDecode(result as String) as Map; } - /// Search Spotify for tracks and artists static Future> searchSpotifyAll(String query, {int trackLimit = 15, int artistLimit = 3}) async { _log.d('searchSpotifyAll: "$query"'); final result = await _channel.invokeMethod('searchSpotifyAll', { @@ -43,7 +38,6 @@ class PlatformBridge { return jsonDecode(result as String) as Map; } - /// Check track availability on streaming services static Future> checkAvailability(String spotifyId, String isrc) async { _log.d('checkAvailability: $spotifyId (ISRC: $isrc)'); final result = await _channel.invokeMethod('checkAvailability', { @@ -53,7 +47,6 @@ class PlatformBridge { return jsonDecode(result as String) as Map; } - /// Download a track from specific service static Future> downloadTrack({ required String isrc, required String service, @@ -108,7 +101,6 @@ class PlatformBridge { return response; } - /// Download with automatic fallback to other services static Future> downloadWithFallback({ required String isrc, required String spotifyId, @@ -129,11 +121,9 @@ class PlatformBridge { String preferredService = 'tidal', String? itemId, int durationMs = 0, - // Extended metadata for FLAC tagging String? genre, String? label, String? copyright, - // Lyrics mode: "embed" (default), "external" (.lrc file), "both" String lyricsMode = 'embed', }) async { _log.i('downloadWithFallback: "$trackName" by $artistName (preferred: $preferredService)'); @@ -157,11 +147,9 @@ class PlatformBridge { 'release_date': releaseDate ?? '', 'item_id': itemId ?? '', 'duration_ms': durationMs, - // Extended metadata 'genre': genre ?? '', 'label': label ?? '', 'copyright': copyright ?? '', - // Lyrics mode 'lyrics_mode': lyricsMode, }); @@ -184,44 +172,36 @@ class PlatformBridge { return response; } - /// Get download progress (legacy single download) static Future> getDownloadProgress() async { final result = await _channel.invokeMethod('getDownloadProgress'); return jsonDecode(result as String) as Map; } - /// Get progress for all active downloads (concurrent mode) static Future> getAllDownloadProgress() async { final result = await _channel.invokeMethod('getAllDownloadProgress'); return jsonDecode(result as String) as Map; } - /// Initialize progress tracking for a download item static Future initItemProgress(String itemId) async { await _channel.invokeMethod('initItemProgress', {'item_id': itemId}); } - /// Finish progress tracking for a download item static Future finishItemProgress(String itemId) async { await _channel.invokeMethod('finishItemProgress', {'item_id': itemId}); } - /// Clear progress tracking for a download item static Future clearItemProgress(String itemId) async { await _channel.invokeMethod('clearItemProgress', {'item_id': itemId}); } - /// Cancel an in-progress download static Future cancelDownload(String itemId) async { await _channel.invokeMethod('cancelDownload', {'item_id': itemId}); } - /// Set download directory static Future setDownloadDirectory(String path) async { await _channel.invokeMethod('setDownloadDirectory', {'path': path}); } - /// Check if file with ISRC already exists static Future> checkDuplicate(String outputDir, String isrc) async { final result = await _channel.invokeMethod('checkDuplicate', { 'output_dir': outputDir, @@ -230,7 +210,6 @@ class PlatformBridge { return jsonDecode(result as String) as Map; } - /// Build filename from template static Future buildFilename(String template, Map metadata) async { final result = await _channel.invokeMethod('buildFilename', { 'template': template, @@ -239,7 +218,6 @@ class PlatformBridge { return result as String; } - /// Sanitize filename static Future sanitizeFilename(String filename) async { final result = await _channel.invokeMethod('sanitizeFilename', { 'filename': filename, @@ -247,8 +225,6 @@ class PlatformBridge { return result as String; } - /// Fetch lyrics for a track - /// [durationMs] is the track duration in milliseconds for better matching static Future> fetchLyrics( String spotifyId, String trackName, @@ -264,9 +240,6 @@ class PlatformBridge { return jsonDecode(result as String) as Map; } - /// Get lyrics in LRC format - /// First tries to extract from embedded file, then falls back to internet - /// [durationMs] is the track duration in milliseconds for better matching static Future getLyricsLRC( String spotifyId, String trackName, @@ -284,7 +257,6 @@ class PlatformBridge { return result as String; } - /// Embed lyrics into an existing FLAC file static Future> embedLyricsToFile( String filePath, String lyrics, @@ -296,15 +268,10 @@ class PlatformBridge { return jsonDecode(result as String) as Map; } - /// Cleanup idle HTTP connections to prevent TCP exhaustion - /// Call this periodically during large batch downloads static Future cleanupConnections() async { await _channel.invokeMethod('cleanupConnections'); } - /// Read metadata directly from a FLAC file - /// Returns all embedded metadata (title, artist, album, track number, etc.) - /// This reads from the actual file, not from cached/database data static Future> readFileMetadata(String filePath) async { final result = await _channel.invokeMethod('readFileMetadata', { 'file_path': filePath, @@ -312,7 +279,6 @@ class PlatformBridge { return jsonDecode(result as String) as Map; } - /// Start foreground download service to keep downloads running in background static Future startDownloadService({ String trackName = '', String artistName = '', @@ -325,12 +291,10 @@ class PlatformBridge { }); } - /// Stop foreground download service static Future stopDownloadService() async { await _channel.invokeMethod('stopDownloadService'); } - /// Update download service notification progress static Future updateDownloadServiceProgress({ required String trackName, required String artistName, @@ -347,13 +311,11 @@ class PlatformBridge { }); } - /// Check if download service is running static Future isDownloadServiceRunning() async { final result = await _channel.invokeMethod('isDownloadServiceRunning'); return result as bool; } - /// Set custom Spotify API credentials static Future setSpotifyCredentials(String clientId, String clientSecret) async { await _channel.invokeMethod('setSpotifyCredentials', { 'client_id': clientId, @@ -361,35 +323,26 @@ class PlatformBridge { }); } - /// Check if Spotify credentials are configured /// Returns true if credentials are available (custom or env vars) static Future hasSpotifyCredentials() async { final result = await _channel.invokeMethod('hasSpotifyCredentials'); return result as bool; } - /// Pre-warm track ID cache for album/playlist tracks - /// This runs in background and returns immediately - /// Speeds up subsequent downloads by caching ISRC → Track ID mappings static Future preWarmTrackCache(List> tracks) async { final tracksJson = jsonEncode(tracks); await _channel.invokeMethod('preWarmTrackCache', {'tracks': tracksJson}); } - /// Get current track cache size static Future getTrackCacheSize() async { final result = await _channel.invokeMethod('getTrackCacheSize'); return result as int; } - /// Clear track ID cache static Future clearTrackCache() async { await _channel.invokeMethod('clearTrackCache'); } - // ==================== DEEZER API ==================== - - /// Search Deezer for tracks and artists (no API key required) static Future> searchDeezerAll(String query, {int trackLimit = 15, int artistLimit = 3}) async { final result = await _channel.invokeMethod('searchDeezerAll', { 'query': query, @@ -399,7 +352,6 @@ class PlatformBridge { return jsonDecode(result as String) as Map; } - /// Get Deezer metadata by type and ID static Future> getDeezerMetadata(String resourceType, String resourceId) async { final result = await _channel.invokeMethod('getDeezerMetadata', { 'resource_type': resourceType, @@ -411,20 +363,16 @@ class PlatformBridge { return jsonDecode(result as String) as Map; } - /// Parse Deezer URL and return type and ID static Future> parseDeezerUrl(String url) async { final result = await _channel.invokeMethod('parseDeezerUrl', {'url': url}); return jsonDecode(result as String) as Map; } - /// Search Deezer by ISRC static Future> searchDeezerByISRC(String isrc) async { final result = await _channel.invokeMethod('searchDeezerByISRC', {'isrc': isrc}); return jsonDecode(result as String) as Map; } - /// Get extended metadata (genre, label) from Deezer using track ID - /// Returns {"genre": "...", "label": "..."} or null if not found static Future?> getDeezerExtendedMetadata(String trackId) async { try { final result = await _channel.invokeMethod('getDeezerExtendedMetadata', { @@ -442,7 +390,6 @@ class PlatformBridge { } } - /// Convert Spotify track to Deezer and get metadata (for rate limit fallback) static Future> convertSpotifyToDeezer(String resourceType, String spotifyId) async { final result = await _channel.invokeMethod('convertSpotifyToDeezer', { 'resource_type': resourceType, @@ -451,15 +398,11 @@ class PlatformBridge { return jsonDecode(result as String) as Map; } - /// Get Spotify metadata with automatic Deezer fallback on rate limit static Future> getSpotifyMetadataWithFallback(String url) async { final result = await _channel.invokeMethod('getSpotifyMetadataWithFallback', {'url': url}); return jsonDecode(result as String) as Map; } - // ==================== GO BACKEND LOGS ==================== - - /// Get all logs from Go backend static Future>> getGoLogs() async { final result = await _channel.invokeMethod('getLogs'); final logs = jsonDecode(result as String) as List; @@ -472,25 +415,20 @@ class PlatformBridge { return jsonDecode(result as String) as Map; } - /// Clear Go backend logs static Future clearGoLogs() async { await _channel.invokeMethod('clearLogs'); } - /// Get Go backend log count static Future getGoLogCount() async { final result = await _channel.invokeMethod('getLogCount'); return result as int; } - /// Enable or disable Go backend logging static Future setGoLoggingEnabled(bool enabled) async { await _channel.invokeMethod('setLoggingEnabled', {'enabled': enabled}); } - // ==================== EXTENSION SYSTEM ==================== - /// Initialize the extension system static Future initExtensionSystem(String extensionsDir, String dataDir) async { _log.d('initExtensionSystem: $extensionsDir, $dataDir'); await _channel.invokeMethod('initExtensionSystem', { @@ -499,7 +437,6 @@ class PlatformBridge { }); } - /// Load all extensions from directory static Future> loadExtensionsFromDir(String dirPath) async { _log.d('loadExtensionsFromDir: $dirPath'); final result = await _channel.invokeMethod('loadExtensionsFromDir', { @@ -508,7 +445,6 @@ class PlatformBridge { return jsonDecode(result as String) as Map; } - /// Load a single extension from file static Future> loadExtensionFromPath(String filePath) async { _log.d('loadExtensionFromPath: $filePath'); final result = await _channel.invokeMethod('loadExtensionFromPath', { @@ -517,7 +453,6 @@ class PlatformBridge { return jsonDecode(result as String) as Map; } - /// Unload an extension static Future unloadExtension(String extensionId) async { _log.d('unloadExtension: $extensionId'); await _channel.invokeMethod('unloadExtension', { @@ -525,7 +460,6 @@ class PlatformBridge { }); } - /// Remove an extension completely (unload + delete files) static Future removeExtension(String extensionId) async { _log.d('removeExtension: $extensionId'); await _channel.invokeMethod('removeExtension', { @@ -533,7 +467,6 @@ class PlatformBridge { }); } - /// Upgrade an existing extension from a new package file static Future> upgradeExtension(String filePath) async { _log.d('upgradeExtension: $filePath'); final result = await _channel.invokeMethod('upgradeExtension', { @@ -542,7 +475,6 @@ class PlatformBridge { return jsonDecode(result as String) as Map; } - /// Check if a package file is an upgrade for an existing extension static Future> checkExtensionUpgrade(String filePath) async { _log.d('checkExtensionUpgrade: $filePath'); final result = await _channel.invokeMethod('checkExtensionUpgrade', { @@ -551,14 +483,12 @@ class PlatformBridge { return jsonDecode(result as String) as Map; } - /// Get all installed extensions static Future>> getInstalledExtensions() async { final result = await _channel.invokeMethod('getInstalledExtensions'); final list = jsonDecode(result as String) as List; return list.map((e) => e as Map).toList(); } - /// Enable or disable an extension static Future setExtensionEnabled(String extensionId, bool enabled) async { _log.d('setExtensionEnabled: $extensionId = $enabled'); await _channel.invokeMethod('setExtensionEnabled', { @@ -567,7 +497,6 @@ class PlatformBridge { }); } - /// Set provider priority order static Future setProviderPriority(List providerIds) async { _log.d('setProviderPriority: $providerIds'); await _channel.invokeMethod('setProviderPriority', { @@ -575,14 +504,12 @@ class PlatformBridge { }); } - /// Get provider priority order static Future> getProviderPriority() async { final result = await _channel.invokeMethod('getProviderPriority'); final list = jsonDecode(result as String) as List; return list.map((e) => e as String).toList(); } - /// Set metadata provider priority order static Future setMetadataProviderPriority(List providerIds) async { _log.d('setMetadataProviderPriority: $providerIds'); await _channel.invokeMethod('setMetadataProviderPriority', { @@ -590,14 +517,12 @@ class PlatformBridge { }); } - /// Get metadata provider priority order static Future> getMetadataProviderPriority() async { final result = await _channel.invokeMethod('getMetadataProviderPriority'); final list = jsonDecode(result as String) as List; return list.map((e) => e as String).toList(); } - /// Get extension settings static Future> getExtensionSettings(String extensionId) async { final result = await _channel.invokeMethod('getExtensionSettings', { 'extension_id': extensionId, @@ -605,7 +530,6 @@ class PlatformBridge { return jsonDecode(result as String) as Map; } - /// Set extension settings static Future setExtensionSettings(String extensionId, Map settings) async { _log.d('setExtensionSettings: $extensionId'); await _channel.invokeMethod('setExtensionSettings', { @@ -614,8 +538,6 @@ class PlatformBridge { }); } - /// Invoke an action on an extension (e.g., button click handler like "startLogin") - /// Returns the result from the JS function static Future> invokeExtensionAction(String extensionId, String actionName) async { _log.d('invokeExtensionAction: $extensionId.$actionName'); final result = await _channel.invokeMethod('invokeExtensionAction', { @@ -628,7 +550,6 @@ class PlatformBridge { return jsonDecode(result) as Map; } - /// Search tracks using extension providers static Future>> searchTracksWithExtensions(String query, {int limit = 20}) async { _log.d('searchTracksWithExtensions: "$query"'); final result = await _channel.invokeMethod('searchTracksWithExtensions', { @@ -639,7 +560,6 @@ class PlatformBridge { return list.map((e) => e as Map).toList(); } - /// Download with extension providers (includes fallback) static Future> downloadWithExtensions({ required String isrc, required String spotifyId, @@ -659,10 +579,9 @@ class PlatformBridge { String? releaseDate, String? itemId, int durationMs = 0, - String? source, // Extension ID that provided this track (prioritize this extension) + String? source, String? genre, String? label, - // Lyrics mode: "embed" (default), "external" (.lrc file), "both" String lyricsMode = 'embed', }) async { _log.i('downloadWithExtensions: "$trackName" by $artistName${source != null ? ' (source: $source)' : ''}'); @@ -685,10 +604,9 @@ class PlatformBridge { 'release_date': releaseDate ?? '', 'item_id': itemId ?? '', 'duration_ms': durationMs, - 'source': source ?? '', // Extension ID that provided this track + 'source': source ?? '', 'genre': genre ?? '', 'label': label ?? '', - // Lyrics mode 'lyrics_mode': lyricsMode, }); @@ -696,15 +614,11 @@ class PlatformBridge { return jsonDecode(result as String) as Map; } - /// Cleanup all extensions (call on app close) static Future cleanupExtensions() async { _log.d('cleanupExtensions'); await _channel.invokeMethod('cleanupExtensions'); } - // ==================== EXTENSION AUTH API ==================== - - /// Get pending auth request for an extension (if any) static Future?> getExtensionPendingAuth(String extensionId) async { final result = await _channel.invokeMethod('getExtensionPendingAuth', { 'extension_id': extensionId, @@ -713,7 +627,6 @@ class PlatformBridge { return jsonDecode(result as String) as Map; } - /// Set auth code for an extension (after OAuth callback) static Future setExtensionAuthCode(String extensionId, String authCode) async { _log.d('setExtensionAuthCode: $extensionId'); await _channel.invokeMethod('setExtensionAuthCode', { @@ -722,7 +635,6 @@ class PlatformBridge { }); } - /// Set tokens for an extension (after token exchange) static Future setExtensionTokens( String extensionId, { required String accessToken, @@ -738,14 +650,12 @@ class PlatformBridge { }); } - /// Clear pending auth request for an extension static Future clearExtensionPendingAuth(String extensionId) async { await _channel.invokeMethod('clearExtensionPendingAuth', { 'extension_id': extensionId, }); } - /// Check if extension is authenticated static Future isExtensionAuthenticated(String extensionId) async { final result = await _channel.invokeMethod('isExtensionAuthenticated', { 'extension_id': extensionId, @@ -753,16 +663,12 @@ class PlatformBridge { return result as bool; } - /// Get all pending auth requests (for polling) static Future>> getAllPendingAuthRequests() async { final result = await _channel.invokeMethod('getAllPendingAuthRequests'); final list = jsonDecode(result as String) as List; return list.map((e) => e as Map).toList(); } - // ==================== EXTENSION FFMPEG API ==================== - - /// Get pending FFmpeg command for execution static Future?> getPendingFFmpegCommand(String commandId) async { final result = await _channel.invokeMethod('getPendingFFmpegCommand', { 'command_id': commandId, @@ -771,7 +677,6 @@ class PlatformBridge { return jsonDecode(result as String) as Map; } - /// Set FFmpeg command result static Future setFFmpegCommandResult( String commandId, { required bool success, @@ -786,16 +691,12 @@ class PlatformBridge { }); } - /// Get all pending FFmpeg commands static Future>> getAllPendingFFmpegCommands() async { final result = await _channel.invokeMethod('getAllPendingFFmpegCommands'); final list = jsonDecode(result as String) as List; return list.map((e) => e as Map).toList(); } - // ==================== EXTENSION CUSTOM SEARCH ==================== - - /// Perform custom search using an extension static Future>> customSearchWithExtension( String extensionId, String query, { @@ -810,17 +711,12 @@ class PlatformBridge { return list.map((e) => e as Map).toList(); } - /// Get all extensions that provide custom search static Future>> getSearchProviders() async { final result = await _channel.invokeMethod('getSearchProviders'); final list = jsonDecode(result as String) as List; return list.map((e) => e as Map).toList(); } - // ==================== EXTENSION URL HANDLER ==================== - - /// Handle a URL with any matching extension - /// Returns null if no extension can handle the URL static Future?> handleURLWithExtension(String url) async { try { final result = await _channel.invokeMethod('handleURLWithExtension', { @@ -833,8 +729,6 @@ class PlatformBridge { } } - /// Find an extension that can handle the given URL - /// Returns extension ID or null if none found static Future findURLHandler(String url) async { final result = await _channel.invokeMethod('findURLHandler', { 'url': url, @@ -843,14 +737,12 @@ class PlatformBridge { return result as String; } - /// Get all extensions that handle custom URLs static Future>> getURLHandlers() async { final result = await _channel.invokeMethod('getURLHandlers'); final list = jsonDecode(result as String) as List; return list.map((e) => e as Map).toList(); } - /// Get album tracks using an extension static Future?> getAlbumWithExtension( String extensionId, String albumId, @@ -868,7 +760,6 @@ class PlatformBridge { } } - /// Get playlist tracks using an extension static Future?> getPlaylistWithExtension( String extensionId, String playlistId, @@ -886,7 +777,6 @@ class PlatformBridge { } } - /// Get artist info and albums using an extension static Future?> getArtistWithExtension( String extensionId, String artistId, @@ -904,9 +794,7 @@ class PlatformBridge { } } - // ==================== EXTENSION POST-PROCESSING ==================== - /// Run post-processing hooks on a file static Future> runPostProcessing( String filePath, { Map? metadata, @@ -918,22 +806,18 @@ class PlatformBridge { return jsonDecode(result as String) as Map; } - /// Get all extensions that provide post-processing static Future>> getPostProcessingProviders() async { final result = await _channel.invokeMethod('getPostProcessingProviders'); final list = jsonDecode(result as String) as List; return list.map((e) => e as Map).toList(); } - // ==================== EXTENSION STORE ==================== - /// Initialize extension store static Future initExtensionStore(String cacheDir) async { _log.d('initExtensionStore: $cacheDir'); await _channel.invokeMethod('initExtensionStore', {'cache_dir': cacheDir}); } - /// Get all extensions from store with installation status static Future>> getStoreExtensions({bool forceRefresh = false}) async { _log.d('getStoreExtensions (forceRefresh: $forceRefresh)'); final result = await _channel.invokeMethod('getStoreExtensions', { @@ -943,7 +827,6 @@ class PlatformBridge { return list.map((e) => e as Map).toList(); } - /// Search extensions in store static Future>> searchStoreExtensions(String query, {String? category}) async { _log.d('searchStoreExtensions: "$query" (category: $category)'); final result = await _channel.invokeMethod('searchStoreExtensions', { @@ -954,14 +837,12 @@ class PlatformBridge { return list.map((e) => e as Map).toList(); } - /// Get store categories static Future> getStoreCategories() async { final result = await _channel.invokeMethod('getStoreCategories'); final list = jsonDecode(result as String) as List; return list.cast(); } - /// Download extension from store static Future downloadStoreExtension(String extensionId, String destDir) async { _log.i('downloadStoreExtension: $extensionId to $destDir'); final result = await _channel.invokeMethod('downloadStoreExtension', { @@ -971,7 +852,6 @@ class PlatformBridge { return result as String; } - /// Clear store cache static Future clearStoreCache() async { _log.d('clearStoreCache'); await _channel.invokeMethod('clearStoreCache'); diff --git a/lib/services/share_intent_service.dart b/lib/services/share_intent_service.dart index 257e057c..36b032ec 100644 --- a/lib/services/share_intent_service.dart +++ b/lib/services/share_intent_service.dart @@ -4,7 +4,6 @@ import 'package:spotiflac_android/utils/logger.dart'; final _log = AppLogger('ShareIntent'); -/// Service to handle incoming share intents from other apps (e.g., Spotify) class ShareIntentService { static final ShareIntentService _instance = ShareIntentService._internal(); factory ShareIntentService() => _instance; @@ -15,17 +14,14 @@ class ShareIntentService { bool _initialized = false; String? _pendingUrl; // Store URL received before listener is ready - /// Stream of shared Spotify URLs Stream get sharedUrlStream => _sharedUrlController.stream; - /// Get pending URL that was received before listener was ready String? consumePendingUrl() { final url = _pendingUrl; _pendingUrl = null; return url; } - /// Initialize the service and start listening for share intents Future initialize() async { if (_initialized) return; _initialized = true; @@ -58,11 +54,6 @@ class ShareIntentService { } } - /// Extract Spotify URL from shared text - /// Handles various formats: - /// - Direct URL: https://open.spotify.com/track/xxx - /// - With text: "Check out this song! https://open.spotify.com/track/xxx" - /// - Spotify URI: spotify:track:xxx String? _extractSpotifyUrl(String text) { if (text.isEmpty) return null; @@ -83,7 +74,6 @@ class ShareIntentService { return null; } - /// Dispose resources void dispose() { _mediaSubscription?.cancel(); _sharedUrlController.close(); diff --git a/lib/theme/app_theme.dart b/lib/theme/app_theme.dart index 619abf83..2aba9e85 100644 --- a/lib/theme/app_theme.dart +++ b/lib/theme/app_theme.dart @@ -1,12 +1,10 @@ import 'package:flutter/material.dart'; import 'package:spotiflac_android/models/theme_settings.dart'; -/// App theme configuration for Material Expressive 3 class AppTheme { /// Default seed color (Spotify green) static const Color defaultSeedColor = Color(kDefaultSeedColor); - /// Create light theme static ThemeData light({ColorScheme? dynamicScheme, Color? seedColor}) { final scheme = dynamicScheme ?? @@ -73,7 +71,6 @@ class AppTheme { ); } - /// AppBar theme static AppBarTheme _appBarTheme( ColorScheme scheme, { bool isAmoled = false, @@ -101,7 +98,6 @@ class AppTheme { surfaceTintColor: scheme.surfaceTint, ); - /// Elevated button theme static ElevatedButtonThemeData _elevatedButtonTheme(ColorScheme scheme) => ElevatedButtonThemeData( style: ElevatedButton.styleFrom( @@ -124,7 +120,6 @@ class AppTheme { ), ); - /// Outlined button theme static OutlinedButtonThemeData _outlinedButtonTheme(ColorScheme scheme) => OutlinedButtonThemeData( style: OutlinedButton.styleFrom( @@ -146,7 +141,6 @@ class AppTheme { ), ); - /// FAB theme static FloatingActionButtonThemeData _fabTheme(ColorScheme scheme) => FloatingActionButtonThemeData( elevation: 3, @@ -184,7 +178,6 @@ class AppTheme { ), // consistent padding ); - /// List tile theme static ListTileThemeData _listTileTheme(ColorScheme scheme) => ListTileThemeData( shape: RoundedRectangleBorder( @@ -193,7 +186,6 @@ class AppTheme { contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 4), ); - /// Dialog theme static DialogThemeData _dialogTheme(ColorScheme scheme) => DialogThemeData( elevation: 6, shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(28)), @@ -213,7 +205,6 @@ class AppTheme { labelBehavior: NavigationDestinationLabelBehavior.alwaysShow, ); - /// SnackBar theme static SnackBarThemeData _snackBarTheme(ColorScheme scheme) => SnackBarThemeData( behavior: SnackBarBehavior.floating, @@ -231,7 +222,6 @@ class AppTheme { circularTrackColor: scheme.surfaceContainerHighest, ); - /// Switch theme static SwitchThemeData _switchTheme(ColorScheme scheme) => SwitchThemeData( thumbColor: WidgetStateProperty.resolveWith((states) { if (states.contains(WidgetState.selected)) { @@ -260,7 +250,6 @@ class AppTheme { selectedColor: scheme.secondaryContainer, ); - /// Divider theme static DividerThemeData _dividerTheme(ColorScheme scheme) => DividerThemeData(color: scheme.outlineVariant, thickness: 1, space: 1); } diff --git a/lib/utils/logger.dart b/lib/utils/logger.dart index bf6f0bec..3e69757b 100644 --- a/lib/utils/logger.dart +++ b/lib/utils/logger.dart @@ -4,7 +4,6 @@ import 'package:flutter/foundation.dart'; import 'package:logger/logger.dart'; import 'package:spotiflac_android/services/platform_bridge.dart'; -/// Log entry with timestamp and level class LogEntry { final DateTime timestamp; final String level; @@ -38,7 +37,6 @@ class LogEntry { } } -/// Circular buffer for storing logs in memory class LogBuffer extends ChangeNotifier { static final LogBuffer _instance = LogBuffer._internal(); factory LogBuffer() => _instance; @@ -134,7 +132,6 @@ class LogBuffer extends ChangeNotifier { _lastGoLogIndex = nextIndex; } catch (e) { - // Ignore errors - Go backend might not be ready if (kDebugMode) { debugPrint('Failed to fetch Go logs: $e'); } @@ -180,7 +177,6 @@ class LogBuffer extends ChangeNotifier { } } -/// Custom log output that writes to both console and buffer class BufferedOutput extends LogOutput { final String tag; @@ -236,9 +232,6 @@ final log = Logger( level: Level.debug, ); -/// Logger with class/tag prefix for better traceability -/// Now also writes to LogBuffer for in-app viewing -/// Works in both debug and release mode class AppLogger { final String _tag; late final Logger? _logger; diff --git a/lib/widgets/cached_cover_image.dart b/lib/widgets/cached_cover_image.dart index 6c01e415..a983d818 100644 --- a/lib/widgets/cached_cover_image.dart +++ b/lib/widgets/cached_cover_image.dart @@ -2,10 +2,6 @@ import 'package:flutter/material.dart'; import 'package:cached_network_image/cached_network_image.dart'; import 'package:spotiflac_android/services/cover_cache_manager.dart'; -/// A wrapper around CachedNetworkImage that uses persistent cache storage. -/// -/// This ensures cover images are cached to disk and persist across app restarts, -/// instead of being stored in the temporary directory that can be cleared by the OS. class CachedCoverImage extends StatelessWidget { final String imageUrl; final double? width; @@ -57,8 +53,6 @@ class CachedCoverImage extends StatelessWidget { } } -/// Provider for CachedNetworkImageProvider that uses persistent cache. -/// Use this for precacheImage() calls. CachedNetworkImageProvider cachedCoverImageProvider(String url) { return CachedNetworkImageProvider( url,