v2.1.5: Fix progress bar and incomplete downloads

- Fix progress bar jumping from 1% to 100% (threshold-based updates)
- Fix incomplete downloads with temp file + size validation
- Applies to Tidal, Qobuz, and Amazon services
This commit is contained in:
zarzet
2026-01-07 23:15:14 +07:00
parent d4274e8ca8
commit 53a1da6249
8 changed files with 211 additions and 56 deletions
+14 -1
View File
@@ -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
+40 -12
View File
@@ -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
}
+15 -16
View File
@@ -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
}
+41 -8
View File
@@ -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
+75 -16
View File
@@ -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
+2 -2
View File
@@ -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';
+23
View File
@@ -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<AppSettings> {
@override
@@ -18,11 +20,32 @@ class SettingsNotifier extends Notifier<AppSettings> {
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<void> _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<void> _saveSettings() async {
final prefs = await SharedPreferences.getInstance();
await prefs.setString(_settingsKey, jsonEncode(state.toJson()));
+1 -1
View File
@@ -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