diff --git a/.gitignore b/.gitignore index 1a452108..78436f9c 100644 --- a/.gitignore +++ b/.gitignore @@ -53,3 +53,6 @@ ios/.symlinks/ ios/Flutter/Flutter.framework/ ios/Flutter/Flutter.podspec android/app/libs/gobackend-sources.jar + +# Extension folder +extension/ diff --git a/CHANGELOG.md b/CHANGELOG.md index 1fcc2367..b54e5018 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,34 @@ # Changelog +## [3.0.0-beta.2] - 2026-01-13 + +### Fixed + +- **Max Resolution Cover Download**: Fixed cover not upgrading to max resolution on mobile + - Added missing `spotifySize300` constant (300x300 size code) + - Mobile now correctly upgrades 300x300 → 640x640 → max resolution (~2000x2000) + - Matches PC version behavior when "Download max resolution song cover" is enabled + +- **EXISTS: Prefix in File Path**: Fixed "File not found" error in metadata screen after download + - Duplicate detection was adding `EXISTS:` prefix to file paths + - Prefix now stripped before saving to download history + - Legacy history items with prefix are handled gracefully + +- **History Error Badge**: Fixed error badge showing on history items even when file exists + - `queue_tab.dart` now strips `EXISTS:` prefix before checking file existence + - File open and delete operations also use cleaned path + +- **Extension Artist URL Handler**: Fixed artist pages showing "0 releases" from extensions + - Extension `fetchArtist` now returns correct format: `{ type: "artist", artist: { albums } }` + - Go backend `HandleURLWithExtensionJSON` now includes albums in artist response + - Added `AlbumType` field to `ExtAlbumMetadata` struct + +- **Extension Artist Name in Logs**: Fixed empty artist name in extension track logs + - Now uses `firstArtist` + `otherArtists` instead of deprecated `artists.items` + - Logs correctly show "Fetched track: {title} by {artist}" + +--- + ## [3.0.0-beta.1] - 2026-01-13 ### Security diff --git a/go_backend/cover.go b/go_backend/cover.go index 36c83a90..7e2c5f33 100644 --- a/go_backend/cover.go +++ b/go_backend/cover.go @@ -9,10 +9,20 @@ import ( // Spotify image size codes (same as PC version) const ( - spotifySize640 = "ab67616d0000b273" // 640x640 + spotifySize300 = "ab67616d00001e02" // 300x300 (small) + spotifySize640 = "ab67616d0000b273" // 640x640 (medium) spotifySizeMax = "ab67616d000082c1" // Max resolution (~2000x2000) ) +// 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) + } + 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) { @@ -22,11 +32,17 @@ func downloadCoverToMemory(coverURL string, maxQuality bool) ([]byte, error) { fmt.Printf("[Cover] Downloading cover from: %s\n", coverURL) - // Upgrade to max quality if requested - downloadURL := coverURL + // First upgrade small (300) to medium (640) - always do this + downloadURL := convertSmallToMedium(coverURL) + if downloadURL != coverURL { + fmt.Printf("[Cover] Upgraded 300x300 to 640x640: %s\n", downloadURL) + } + + // Then upgrade to max quality if requested if maxQuality { - downloadURL = upgradeToMaxQuality(coverURL) - if downloadURL != coverURL { + maxURL := upgradeToMaxQuality(downloadURL) + if maxURL != downloadURL { + downloadURL = maxURL fmt.Printf("[Cover] Upgraded to max quality URL: %s\n", downloadURL) } } @@ -93,9 +109,12 @@ func GetCoverFromSpotify(imageURL string, maxQuality bool) string { return "" } + // Always upgrade small to medium first + result := convertSmallToMedium(imageURL) + if maxQuality { - return upgradeToMaxQuality(imageURL) + result = upgradeToMaxQuality(result) } - return imageURL + return result } diff --git a/go_backend/exports.go b/go_backend/exports.go index 0ec9c6b8..d9cde03b 100644 --- a/go_backend/exports.go +++ b/go_backend/exports.go @@ -1440,6 +1440,41 @@ func GetAllPendingFFmpegCommandsJSON() (string, error) { // ==================== EXTENSION CUSTOM SEARCH ==================== +// EnrichTrackWithExtensionJSON enriches track metadata using the source extension +// This is called lazily before download starts, allowing extension to fetch real ISRC etc. +func EnrichTrackWithExtensionJSON(extensionID, trackJSON string) (string, error) { + manager := GetExtensionManager() + ext, err := manager.GetExtension(extensionID) + if err != nil { + // Extension not found, return original track + return trackJSON, nil + } + + if !ext.Manifest.IsMetadataProvider() { + // Not a metadata provider, return original + return trackJSON, nil + } + + var track ExtTrackMetadata + if err := json.Unmarshal([]byte(trackJSON), &track); err != nil { + return trackJSON, fmt.Errorf("failed to parse track: %w", err) + } + + provider := NewExtensionProviderWrapper(ext) + enrichedTrack, err := provider.EnrichTrack(&track) + if err != nil { + // Error enriching, return original + return trackJSON, nil + } + + jsonBytes, err := json.Marshal(enrichedTrack) + if err != nil { + return trackJSON, nil + } + + return string(jsonBytes), nil +} + // CustomSearchWithExtensionJSON performs custom search using an extension func CustomSearchWithExtensionJSON(extensionID, query string, optionsJSON string) (string, error) { manager := GetExtensionManager() @@ -1597,11 +1632,34 @@ func HandleURLWithExtensionJSON(url string) (string, error) { // Add artist info if present if result.Artist != nil { - response["artist"] = map[string]interface{}{ + artistResponse := map[string]interface{}{ "id": result.Artist.ID, "name": result.Artist.Name, "image_url": result.Artist.ImageURL, } + + // Add albums if present + if len(result.Artist.Albums) > 0 { + albums := make([]map[string]interface{}, len(result.Artist.Albums)) + for i, album := range result.Artist.Albums { + albumType := album.AlbumType + if albumType == "" { + albumType = "album" + } + albums[i] = map[string]interface{}{ + "id": album.ID, + "name": album.Name, + "artists": album.Artists, + "images": album.CoverURL, + "release_date": album.ReleaseDate, + "total_tracks": album.TotalTracks, + "album_type": albumType, + } + } + artistResponse["albums"] = albums + } + + response["artist"] = artistResponse } jsonBytes, err := json.Marshal(response) diff --git a/go_backend/extension_providers.go b/go_backend/extension_providers.go index e98aac57..336fc1d8 100644 --- a/go_backend/extension_providers.go +++ b/go_backend/extension_providers.go @@ -47,6 +47,7 @@ type ExtAlbumMetadata struct { CoverURL string `json:"cover_url,omitempty"` ReleaseDate string `json:"release_date,omitempty"` TotalTracks int `json:"total_tracks"` + AlbumType string `json:"album_type,omitempty"` Tracks []ExtTrackMetadata `json:"tracks"` ProviderID string `json:"provider_id"` } @@ -314,6 +315,72 @@ func (p *ExtensionProviderWrapper) GetArtist(artistID string) (*ExtArtistMetadat return &artist, nil } +// EnrichTrack enriches track metadata before download (e.g., fetch real ISRC) +// This is called lazily when download starts, not when playlist/album is loaded +// Extension should implement enrichTrack(track) function that returns enriched track +func (p *ExtensionProviderWrapper) EnrichTrack(track *ExtTrackMetadata) (*ExtTrackMetadata, error) { + if !p.extension.Manifest.IsMetadataProvider() { + return track, nil // Not a metadata provider, return as-is + } + + if !p.extension.Enabled { + return track, nil // Extension disabled, return as-is + } + + // Convert track to JSON for passing to JS + trackJSON, err := json.Marshal(track) + if err != nil { + GoLog("[Extension] EnrichTrack: failed to marshal track: %v\n", err) + return track, nil // Return original on error + } + + script := fmt.Sprintf(` + (function() { + if (typeof extension !== 'undefined' && typeof extension.enrichTrack === 'function') { + var track = %s; + return extension.enrichTrack(track); + } + return null; + })() + `, string(trackJSON)) + + result, err := RunWithTimeoutAndRecover(p.vm, script, DefaultJSTimeout) + if err != nil { + if IsTimeoutError(err) { + GoLog("[Extension] EnrichTrack timeout for %s\n", p.extension.ID) + } else { + GoLog("[Extension] EnrichTrack error for %s: %v\n", p.extension.ID, err) + } + return track, nil // Return original on error + } + + // If extension doesn't implement enrichTrack or returns null, return original + if result == nil || goja.IsUndefined(result) || goja.IsNull(result) { + return track, nil + } + + exported := result.Export() + jsonBytes, err := json.Marshal(exported) + if err != nil { + GoLog("[Extension] EnrichTrack: failed to marshal result: %v\n", err) + return track, nil + } + + var enrichedTrack ExtTrackMetadata + if err := json.Unmarshal(jsonBytes, &enrichedTrack); err != nil { + GoLog("[Extension] EnrichTrack: failed to parse enriched track: %v\n", err) + return track, nil + } + + // Preserve provider ID + enrichedTrack.ProviderID = track.ProviderID + + GoLog("[Extension] EnrichTrack: enriched track from %s (ISRC: %s -> %s)\n", + p.extension.ID, track.ISRC, enrichedTrack.ISRC) + + return &enrichedTrack, nil +} + // ==================== Download Provider Methods ==================== // CheckAvailability checks if a track is available for download @@ -624,6 +691,45 @@ func DownloadWithExtensionFallback(req DownloadRequest) (*DownloadResponse, erro var lastErr error var skipBuiltIn bool // If source extension has skipBuiltInFallback, don't try built-in providers + // LAZY ENRICHMENT: If track came from an extension, try to enrich metadata (e.g., get real ISRC) + // This is done lazily at download time, not when playlist/album is loaded + if req.Source != "" && !isBuiltInProvider(req.Source) { + ext, err := extManager.GetExtension(req.Source) + if err == nil && ext.Enabled && ext.Error == "" && ext.Manifest.IsMetadataProvider() { + GoLog("[DownloadWithExtensionFallback] Enriching track from extension '%s'...\n", req.Source) + + provider := NewExtensionProviderWrapper(ext) + trackMeta := &ExtTrackMetadata{ + ID: req.SpotifyID, + Name: req.TrackName, + Artists: req.ArtistName, + AlbumName: req.AlbumName, + DurationMS: req.DurationMS, + ISRC: req.ISRC, + ReleaseDate: req.ReleaseDate, + TrackNumber: req.TrackNumber, + DiscNumber: req.DiscNumber, + ProviderID: req.Source, + } + + enrichedTrack, err := provider.EnrichTrack(trackMeta) + if err == nil && enrichedTrack != nil { + // Update request with enriched data + if enrichedTrack.ISRC != "" && enrichedTrack.ISRC != req.ISRC { + GoLog("[DownloadWithExtensionFallback] ISRC enriched: %s -> %s\n", req.ISRC, enrichedTrack.ISRC) + req.ISRC = enrichedTrack.ISRC + } + // Can also update other fields if needed + if enrichedTrack.Name != "" { + req.TrackName = enrichedTrack.Name + } + if enrichedTrack.Artists != "" { + req.ArtistName = enrichedTrack.Artists + } + } + } + } + // If source extension is specified, try it first before the priority list if req.Source != "" && !isBuiltInProvider(req.Source) { GoLog("[DownloadWithExtensionFallback] Track source is extension '%s', trying it first\n", req.Source) diff --git a/go_backend/tidal.go b/go_backend/tidal.go index 4e4cca08..df3d2826 100644 --- a/go_backend/tidal.go +++ b/go_backend/tidal.go @@ -1520,7 +1520,7 @@ func downloadFromTidal(req DownloadRequest) (TidalDownloadResult, error) { } } - // Strategy 2: Try SongLink only if ISRC search failed (slower but more accurate) + // 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 diff --git a/lib/constants/app_info.dart b/lib/constants/app_info.dart index 12129fa5..dd428f1e 100644 --- a/lib/constants/app_info.dart +++ b/lib/constants/app_info.dart @@ -1,8 +1,8 @@ /// App version and info constants /// Update version here only - all other files will reference this class AppInfo { - static const String version = '3.0.0-beta.1'; - static const String buildNumber = '54'; + static const String version = '3.0.0-beta.2'; + static const String buildNumber = '56'; static const String fullVersion = '$version+$buildNumber'; diff --git a/lib/providers/download_queue_provider.dart b/lib/providers/download_queue_provider.dart index 69b172d1..52d67db0 100644 --- a/lib/providers/download_queue_provider.dart +++ b/lib/providers/download_queue_provider.dart @@ -1557,6 +1557,12 @@ class DownloadQueueNotifier extends Notifier { if (result['success'] == true) { var filePath = result['file_path'] as String?; + + // Strip EXISTS: prefix from duplicate detection + if (filePath != null && filePath.startsWith('EXISTS:')) { + filePath = filePath.substring(7); // Remove "EXISTS:" prefix + } + _log.i('Download success, file: $filePath'); // Get actual quality from response (if available) diff --git a/lib/screens/queue_tab.dart b/lib/screens/queue_tab.dart index fca21484..a3674035 100644 --- a/lib/screens/queue_tab.dart +++ b/lib/screens/queue_tab.dart @@ -115,7 +115,8 @@ class _QueueTabState extends ConsumerState { final item = items.where((e) => e.id == id).firstOrNull; if (item != null) { try { - final file = File(item.filePath); + final cleanPath = _cleanFilePath(item.filePath); + final file = File(cleanPath); if (await file.exists()) { await file.delete(); } @@ -135,31 +136,43 @@ class _QueueTabState extends ConsumerState { } } + /// Strip EXISTS: prefix from file path (legacy history items) + String _cleanFilePath(String? filePath) { + if (filePath == null) return ''; + if (filePath.startsWith('EXISTS:')) { + return filePath.substring(7); + } + return filePath; + } + bool _checkFileExists(String? filePath) { if (filePath == null) return false; - if (_fileExistsCache.containsKey(filePath)) { - return _fileExistsCache[filePath]!; + final cleanPath = _cleanFilePath(filePath); + if (cleanPath.isEmpty) return false; + if (_fileExistsCache.containsKey(cleanPath)) { + return _fileExistsCache[cleanPath]!; } - if (_pendingChecks.contains(filePath)) { + if (_pendingChecks.contains(cleanPath)) { return true; } if (_fileExistsCache.length >= _maxCacheSize) { _fileExistsCache.remove(_fileExistsCache.keys.first); } - _pendingChecks.add(filePath); + _pendingChecks.add(cleanPath); Future.microtask(() async { - final exists = await File(filePath).exists(); - _pendingChecks.remove(filePath); - if (mounted && _fileExistsCache[filePath] != exists) { - setState(() => _fileExistsCache[filePath] = exists); + final exists = await File(cleanPath).exists(); + _pendingChecks.remove(cleanPath); + if (mounted && _fileExistsCache[cleanPath] != exists) { + setState(() => _fileExistsCache[cleanPath] = exists); } }); return true; } Future _openFile(String filePath) async { + final cleanPath = _cleanFilePath(filePath); try { - await OpenFilex.open(filePath); + await OpenFilex.open(cleanPath); } catch (e) { if (mounted) { ScaffoldMessenger.of(context).showSnackBar( diff --git a/lib/screens/track_metadata_screen.dart b/lib/screens/track_metadata_screen.dart index fec533b8..72e52492 100644 --- a/lib/screens/track_metadata_screen.dart +++ b/lib/screens/track_metadata_screen.dart @@ -34,7 +34,13 @@ class _TrackMetadataScreenState extends ConsumerState { } Future _checkFile() async { - final file = File(widget.item.filePath); + // Strip EXISTS: prefix from legacy history items + var filePath = widget.item.filePath; + if (filePath.startsWith('EXISTS:')) { + filePath = filePath.substring(7); + } + + final file = File(filePath); final exists = await file.exists(); int? size; @@ -67,6 +73,12 @@ class _TrackMetadataScreenState extends ConsumerState { int? get discNumber => item.discNumber; String? get releaseDate => item.releaseDate; String? get isrc => item.isrc; + + // Clean filePath - strip EXISTS: prefix from legacy history items + String get cleanFilePath { + final path = item.filePath; + return path.startsWith('EXISTS:') ? path.substring(7) : path; + } int? get bitDepth => item.bitDepth; int? get sampleRate => item.sampleRate; @@ -515,7 +527,7 @@ class _TrackMetadataScreenState extends ConsumerState { } Widget _buildFileInfoCard(BuildContext context, ColorScheme colorScheme, bool fileExists, int? fileSize) { - final fileName = item.filePath.split(Platform.pathSeparator).last; + final fileName = cleanFilePath.split(Platform.pathSeparator).last; final fileExtension = fileName.contains('.') ? fileName.split('.').last.toUpperCase() : 'Unknown'; return Card( @@ -631,7 +643,7 @@ class _TrackMetadataScreenState extends ConsumerState { // File path InkWell( - onTap: () => _copyToClipboard(context, item.filePath), + onTap: () => _copyToClipboard(context, cleanFilePath), borderRadius: BorderRadius.circular(12), child: Container( padding: const EdgeInsets.all(12), @@ -643,7 +655,7 @@ class _TrackMetadataScreenState extends ConsumerState { children: [ Expanded( child: Text( - item.filePath, + cleanFilePath, style: Theme.of(context).textTheme.bodySmall?.copyWith( fontFamily: 'monospace', color: colorScheme.onSurfaceVariant, @@ -776,7 +788,7 @@ class _TrackMetadataScreenState extends ConsumerState { item.spotifyId ?? '', item.trackName, item.artistName, - filePath: _fileExists ? item.filePath : null, // Try embedded lyrics first + filePath: _fileExists ? cleanFilePath : null, // Try embedded lyrics first ).timeout( const Duration(seconds: 20), onTimeout: () => '', // Return empty string on timeout @@ -833,7 +845,7 @@ class _TrackMetadataScreenState extends ConsumerState { Expanded( flex: 2, child: FilledButton.icon( - onPressed: fileExists ? () => _openFile(context, item.filePath) : null, + onPressed: fileExists ? () => _openFile(context, cleanFilePath) : null, icon: const Icon(Icons.play_arrow), label: const Text('Play'), style: FilledButton.styleFrom( @@ -890,7 +902,7 @@ class _TrackMetadataScreenState extends ConsumerState { title: const Text('Copy file path'), onTap: () { Navigator.pop(context); - _copyToClipboard(context, item.filePath); + _copyToClipboard(context, cleanFilePath); }, ), ListTile( @@ -933,7 +945,7 @@ class _TrackMetadataScreenState extends ConsumerState { onPressed: () async { // Delete the file first try { - final file = File(item.filePath); + final file = File(cleanFilePath); if (await file.exists()) { await file.delete(); } @@ -984,7 +996,7 @@ class _TrackMetadataScreenState extends ConsumerState { } Future _shareFile(BuildContext context) async { - final file = File(item.filePath); + final file = File(cleanFilePath); if (!await file.exists()) { if (context.mounted) { ScaffoldMessenger.of(context).showSnackBar( @@ -996,7 +1008,7 @@ class _TrackMetadataScreenState extends ConsumerState { await SharePlus.instance.share( ShareParams( - files: [XFile(item.filePath)], + files: [XFile(cleanFilePath)], text: '${item.trackName} - ${item.artistName}', ), ); diff --git a/pubspec.yaml b/pubspec.yaml index 348ab27c..a3e97e20 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,7 +1,7 @@ name: spotiflac_android description: Download Spotify tracks in FLAC from Tidal, Qobuz & Amazon Music publish_to: "none" -version: 3.0.0-beta.1+54 +version: 3.0.0-beta.2+56 environment: sdk: ^3.10.0 diff --git a/pubspec_ios.yaml b/pubspec_ios.yaml index a06e36bf..2864bbe2 100644 --- a/pubspec_ios.yaml +++ b/pubspec_ios.yaml @@ -1,7 +1,7 @@ name: spotiflac_android description: Download Spotify tracks in FLAC from Tidal, Qobuz & Amazon Music publish_to: "none" -version: 3.0.0-beta.1+54 +version: 3.0.0-beta.2+56 environment: sdk: ^3.10.0