mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-07-06 04:37:59 +02:00
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:
+14
-1
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user