diff --git a/CHANGELOG.md b/CHANGELOG.md index 97c55e9f..c22a84f6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,6 @@ # Changelog -## [2.1.5-preview] - 2026-01-07 +## [2.1.5] - 2026-01-07 ### Added - **Deezer as Alternative Metadata Source**: Choose between Deezer or Spotify for search @@ -15,9 +15,22 @@ - `[Search] Using metadata source: deezer/spotify for query: "..."` - `[FetchURL] Fetching track with Deezer fallback enabled...` +### Fixed +- **Progress Bar Not Updating**: Fixed bug where download progress jumped from 1% directly to 100% + - Progress now updates smoothly every 64KB of data received + - First progress update happens immediately when download starts +- **Incomplete Downloads**: Fixed bug where interrupted downloads could result in corrupted/incomplete files + - Downloads now use temporary files (`.tmp`) during transfer + - File size is validated against server's Content-Length header + - Incomplete files are automatically deleted and error is reported + - Final file is only created after successful download completion + - Applies to all services: Tidal, Qobuz, and Amazon + ### Technical - New settings field: `metadataSource` in `lib/models/settings.dart` - New UI: Search Source selector in Options Settings page +- Improved `ItemProgressWriter` with threshold-based progress updates +- Download functions now properly handle network interruptions ## [2.1.0] - 2026-01-06 diff --git a/go_backend/amazon.go b/go_backend/amazon.go index 4331afdd..bd5619f7 100644 --- a/go_backend/amazon.go +++ b/go_backend/amazon.go @@ -294,35 +294,63 @@ func (a *AmazonDownloader) DownloadFile(downloadURL, outputPath, itemID string) return fmt.Errorf("download failed: HTTP %d", resp.StatusCode) } + expectedSize := resp.ContentLength // Set total bytes if available - if resp.ContentLength > 0 && itemID != "" { - SetItemBytesTotal(itemID, resp.ContentLength) + if expectedSize > 0 && itemID != "" { + SetItemBytesTotal(itemID, expectedSize) } - out, err := os.Create(outputPath) + // Use temp file to avoid incomplete downloads + tempPath := outputPath + ".tmp" + out, err := os.Create(tempPath) if err != nil { return err } - defer out.Close() // Use buffered writer for better performance (256KB buffer) bufWriter := bufio.NewWriterSize(out, 256*1024) - defer bufWriter.Flush() // Use item progress writer with buffered output - var bytesWritten int64 + var written int64 if itemID != "" { pw := NewItemProgressWriter(bufWriter, itemID) - bytesWritten, err = io.Copy(pw, resp.Body) + written, err = io.Copy(pw, resp.Body) } else { // Fallback: direct copy without progress tracking - bytesWritten, err = io.Copy(bufWriter, resp.Body) - } - if err != nil { - return fmt.Errorf("failed to write file: %w", err) + written, err = io.Copy(bufWriter, resp.Body) } - fmt.Printf("\r[Amazon] Downloaded: %.2f MB (Complete)\n", float64(bytesWritten)/(1024*1024)) + // Flush buffer before checking for errors + flushErr := bufWriter.Flush() + closeErr := out.Close() + + // Check for any errors + if err != nil { + os.Remove(tempPath) + return fmt.Errorf("download interrupted: %w", err) + } + if flushErr != nil { + os.Remove(tempPath) + return fmt.Errorf("failed to flush buffer: %w", flushErr) + } + if closeErr != nil { + os.Remove(tempPath) + return fmt.Errorf("failed to close file: %w", closeErr) + } + + // Verify file size if Content-Length was provided + if expectedSize > 0 && written != expectedSize { + os.Remove(tempPath) + return fmt.Errorf("incomplete download: expected %d bytes, got %d bytes", expectedSize, written) + } + + // Rename temp file to final path + if err := os.Rename(tempPath, outputPath); err != nil { + os.Remove(tempPath) + return fmt.Errorf("failed to rename temp file: %w", err) + } + + fmt.Printf("\r[Amazon] Downloaded: %.2f MB (Complete)\n", float64(written)/(1024*1024)) return nil } diff --git a/go_backend/progress.go b/go_backend/progress.go index 4b1c13c8..72badb8b 100644 --- a/go_backend/progress.go +++ b/go_backend/progress.go @@ -195,39 +195,38 @@ func getDownloadDir() string { } // ItemProgressWriter wraps io.Writer to track download progress for a specific item -// Uses buffered writing for better performance type ItemProgressWriter struct { - writer interface{ Write([]byte) (int, error) } - itemID string - current int64 - buffer []byte - bufPos int + writer interface{ Write([]byte) (int, error) } + itemID string + current int64 + lastReported int64 // Track last reported bytes for threshold-based updates } -const progressWriterBufferSize = 256 * 1024 // 256KB buffer for faster writes +const progressUpdateThreshold = 64 * 1024 // Update progress every 64KB // NewItemProgressWriter creates a new progress writer for a specific item func NewItemProgressWriter(w interface{ Write([]byte) (int, error) }, itemID string) *ItemProgressWriter { return &ItemProgressWriter{ - writer: w, - itemID: itemID, - current: 0, - buffer: make([]byte, progressWriterBufferSize), - bufPos: 0, + writer: w, + itemID: itemID, + current: 0, + lastReported: 0, } } -// Write implements io.Writer with buffering +// Write implements io.Writer with threshold-based progress updates func (pw *ItemProgressWriter) Write(p []byte) (int, error) { n, err := pw.writer.Write(p) if err != nil { return n, err } pw.current += int64(n) - - // Update progress less frequently (every 64KB) to reduce lock contention - if pw.current%(64*1024) == 0 || pw.current == 0 { + + // Update progress when we've received at least 64KB since last update + // Also update on first write to show download has started + if pw.lastReported == 0 || pw.current-pw.lastReported >= progressUpdateThreshold { SetItemBytesReceived(pw.itemID, pw.current) + pw.lastReported = pw.current } return n, nil } diff --git a/go_backend/qobuz.go b/go_backend/qobuz.go index 12bcee4c..023ff72c 100644 --- a/go_backend/qobuz.go +++ b/go_backend/qobuz.go @@ -473,30 +473,63 @@ func (q *QobuzDownloader) DownloadFile(downloadURL, outputPath, itemID string) e return fmt.Errorf("download failed: HTTP %d", resp.StatusCode) } + expectedSize := resp.ContentLength // Set total bytes if available - if resp.ContentLength > 0 && itemID != "" { - SetItemBytesTotal(itemID, resp.ContentLength) + if expectedSize > 0 && itemID != "" { + SetItemBytesTotal(itemID, expectedSize) } - out, err := os.Create(outputPath) + // Use temp file to avoid incomplete downloads + tempPath := outputPath + ".tmp" + out, err := os.Create(tempPath) if err != nil { return err } - defer out.Close() // Use buffered writer for better performance (256KB buffer) bufWriter := bufio.NewWriterSize(out, 256*1024) - defer bufWriter.Flush() // Use item progress writer with buffered output + var written int64 if itemID != "" { progressWriter := NewItemProgressWriter(bufWriter, itemID) - _, err = io.Copy(progressWriter, resp.Body) + written, err = io.Copy(progressWriter, resp.Body) } else { // Fallback: direct copy without progress tracking - _, err = io.Copy(bufWriter, resp.Body) + written, err = io.Copy(bufWriter, resp.Body) } - return err + + // Flush buffer before checking for errors + flushErr := bufWriter.Flush() + closeErr := out.Close() + + // Check for any errors + if err != nil { + os.Remove(tempPath) + return fmt.Errorf("download interrupted: %w", err) + } + if flushErr != nil { + os.Remove(tempPath) + return fmt.Errorf("failed to flush buffer: %w", flushErr) + } + if closeErr != nil { + os.Remove(tempPath) + return fmt.Errorf("failed to close file: %w", closeErr) + } + + // Verify file size if Content-Length was provided + if expectedSize > 0 && written != expectedSize { + os.Remove(tempPath) + return fmt.Errorf("incomplete download: expected %d bytes, got %d bytes", expectedSize, written) + } + + // Rename temp file to final path + if err := os.Rename(tempPath, outputPath); err != nil { + os.Remove(tempPath) + return fmt.Errorf("failed to rename temp file: %w", err) + } + + return nil } // QobuzDownloadResult contains download result with quality info diff --git a/go_backend/tidal.go b/go_backend/tidal.go index b1bd47ca..940bd6c7 100644 --- a/go_backend/tidal.go +++ b/go_backend/tidal.go @@ -746,30 +746,63 @@ func (t *TidalDownloader) DownloadFile(downloadURL, outputPath, itemID string) e return fmt.Errorf("download failed: HTTP %d", resp.StatusCode) } + expectedSize := resp.ContentLength // Set total bytes if available - if resp.ContentLength > 0 && itemID != "" { - SetItemBytesTotal(itemID, resp.ContentLength) + if expectedSize > 0 && itemID != "" { + SetItemBytesTotal(itemID, expectedSize) } - out, err := os.Create(outputPath) + // Use temp file to avoid incomplete downloads + tempPath := outputPath + ".tmp" + out, err := os.Create(tempPath) if err != nil { return err } - defer out.Close() // Use buffered writer for better performance (256KB buffer) bufWriter := bufio.NewWriterSize(out, 256*1024) - defer bufWriter.Flush() // Use item progress writer with buffered output + var written int64 if itemID != "" { progressWriter := NewItemProgressWriter(bufWriter, itemID) - _, err = io.Copy(progressWriter, resp.Body) + written, err = io.Copy(progressWriter, resp.Body) } else { // Fallback: direct copy without progress tracking - _, err = io.Copy(bufWriter, resp.Body) + written, err = io.Copy(bufWriter, resp.Body) } - return err + + // Flush buffer before checking for errors + flushErr := bufWriter.Flush() + closeErr := out.Close() + + // Check for any errors + if err != nil { + os.Remove(tempPath) + return fmt.Errorf("download interrupted: %w", err) + } + if flushErr != nil { + os.Remove(tempPath) + return fmt.Errorf("failed to flush buffer: %w", flushErr) + } + if closeErr != nil { + os.Remove(tempPath) + return fmt.Errorf("failed to close file: %w", closeErr) + } + + // Verify file size if Content-Length was provided + if expectedSize > 0 && written != expectedSize { + os.Remove(tempPath) + return fmt.Errorf("incomplete download: expected %d bytes, got %d bytes", expectedSize, written) + } + + // Rename temp file to final path + if err := os.Rename(tempPath, outputPath); err != nil { + os.Remove(tempPath) + return fmt.Errorf("failed to rename temp file: %w", err) + } + + return nil } func (t *TidalDownloader) downloadFromManifest(manifestB64, outputPath, itemID string) error { @@ -805,26 +838,52 @@ func (t *TidalDownloader) downloadFromManifest(manifestB64, outputPath, itemID s return fmt.Errorf("download failed with status %d", resp.StatusCode) } + expectedSize := resp.ContentLength // Set total bytes for progress tracking - if resp.ContentLength > 0 && itemID != "" { - SetItemBytesTotal(itemID, resp.ContentLength) + if expectedSize > 0 && itemID != "" { + SetItemBytesTotal(itemID, expectedSize) } - out, err := os.Create(outputPath) + // Use temp file to avoid incomplete downloads + tempPath := outputPath + ".tmp" + out, err := os.Create(tempPath) if err != nil { return fmt.Errorf("failed to create file: %w", err) } - defer out.Close() // Use item progress writer + var written int64 if itemID != "" { progressWriter := NewItemProgressWriter(out, itemID) - _, err = io.Copy(progressWriter, resp.Body) + written, err = io.Copy(progressWriter, resp.Body) } else { - // Fallback: direct copy without progress tracking - _, err = io.Copy(out, resp.Body) + written, err = io.Copy(out, resp.Body) } - return err + + closeErr := out.Close() + + if err != nil { + os.Remove(tempPath) + return fmt.Errorf("download interrupted: %w", err) + } + if closeErr != nil { + os.Remove(tempPath) + return fmt.Errorf("failed to close file: %w", closeErr) + } + + // Verify file size if Content-Length was provided + if expectedSize > 0 && written != expectedSize { + os.Remove(tempPath) + return fmt.Errorf("incomplete download: expected %d bytes, got %d bytes", expectedSize, written) + } + + // Rename temp file to final path + if err := os.Rename(tempPath, outputPath); err != nil { + os.Remove(tempPath) + return fmt.Errorf("failed to rename temp file: %w", err) + } + + return nil } // DASH format - download segments to temporary file diff --git a/lib/constants/app_info.dart b/lib/constants/app_info.dart index f919f177..2b87ca36 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 = '2.1.5-preview'; - static const String buildNumber = '42'; + static const String version = '2.1.5'; + static const String buildNumber = '43'; static const String fullVersion = '$version+$buildNumber'; diff --git a/lib/providers/settings_provider.dart b/lib/providers/settings_provider.dart index 9fadea1c..d237d17f 100644 --- a/lib/providers/settings_provider.dart +++ b/lib/providers/settings_provider.dart @@ -5,6 +5,8 @@ import 'package:spotiflac_android/models/settings.dart'; import 'package:spotiflac_android/services/platform_bridge.dart'; const _settingsKey = 'app_settings'; +const _migrationVersionKey = 'settings_migration_version'; +const _currentMigrationVersion = 1; // Increment this when adding new migrations class SettingsNotifier extends Notifier { @override @@ -18,11 +20,32 @@ class SettingsNotifier extends Notifier { final json = prefs.getString(_settingsKey); if (json != null) { state = AppSettings.fromJson(jsonDecode(json)); + + // Run migrations if needed + await _runMigrations(prefs); + // Apply Spotify credentials to Go backend on load _applySpotifyCredentials(); } } + /// Run one-time migrations for settings + Future _runMigrations(SharedPreferences prefs) async { + final lastMigration = prefs.getInt(_migrationVersionKey) ?? 0; + + if (lastMigration < 1) { + // Migration 1: Set metadataSource to 'deezer' for existing users + // This ensures users updating from older versions get Deezer as default + state = state.copyWith(metadataSource: 'deezer'); + await _saveSettings(); + } + + // Save current migration version + if (lastMigration < _currentMigrationVersion) { + await prefs.setInt(_migrationVersionKey, _currentMigrationVersion); + } + } + Future _saveSettings() async { final prefs = await SharedPreferences.getInstance(); await prefs.setString(_settingsKey, jsonEncode(state.toJson())); diff --git a/pubspec.yaml b/pubspec.yaml index a4a191f7..0b70217d 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: 2.1.5-preview+42 +version: 2.1.5+43 environment: sdk: ^3.10.0