Compare commits

...

7 Commits

Author SHA1 Message Date
zarzet e9c7bf830e Update changelog for v2.1.5 2026-01-08 00:53:38 +07:00
zarzet 8bc97d5bd3 v2.1.5: Deezer API 2.0, Qobuz default, fetch ISRC for search results 2026-01-08 00:52:24 +07:00
zarzet f2c241c323 Fix .tmp permission issue on Android Music folder 2026-01-08 00:26:49 +07:00
zarzet 9c512ffe28 Add migration for Deezer default (skip if custom Spotify enabled) 2026-01-07 23:19:04 +07:00
zarzet 53a1da6249 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
2026-01-07 23:15:48 +07:00
Zarz Eleutherius d4274e8ca8 Include setup instructions for Spotify API usage
Added detailed instructions for setting up Spotify as a search source, including steps for creating a developer account and entering credentials.
2026-01-07 13:55:31 +07:00
Zarz Eleutherius 49a9f12841 Simplify README by removing Spotify setup details
Removed detailed instructions for using Spotify and support section.
2026-01-07 13:53:30 +07:00
13 changed files with 234 additions and 86 deletions
+22 -4
View File
@@ -1,6 +1,6 @@
# Changelog
## [2.1.5-preview] - 2026-01-07
## [2.1.5] - 2026-01-08
### Added
- **Deezer as Alternative Metadata Source**: Choose between Deezer or Spotify for search
@@ -11,13 +11,31 @@
- Uses SongLink/Odesli API to convert Spotify track/album ID to Deezer ID
- Fetches metadata from Deezer instead
- Works for tracks and albums (playlists are user-specific, artists require Spotify API)
- **Debug Logging for Search Source**: Console logs now show which metadata source is being used
- `[Search] Using metadata source: deezer/spotify for query: "..."`
- `[FetchURL] Fetching track with Deezer fallback enabled...`
### Changed
- **Default Download Service**: Changed from Tidal to Qobuz
- Fallback order is now: Qobuz → Tidal → Amazon
- **Deezer API Updated to v2.0**: More reliable and complete metadata
- Direct ISRC lookup via `/track/isrc:{ISRC}` endpoint
- Search results now fetch full track info to include ISRC
### 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
- File size is validated against server's Content-Length header
- Incomplete files are automatically deleted and error is reported
- Applies to all services: Tidal, Qobuz, and Amazon
- **ISRC Not Available from Deezer Search**: Search results now fetch full track details to get ISRC
- Improves track matching accuracy when downloading
### 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
- Deezer API base URL changed to `https://api.deezer.com/2.0`
## [2.1.0] - 2026-01-06
+2 -11
View File
@@ -32,9 +32,6 @@ SpotiFLAC supports two metadata sources for searching tracks:
| **Deezer** (Default) | No developer account needed, rate limit per user IP | Slightly less comprehensive catalog |
| **Spotify** | More comprehensive catalog, better search results | Requires developer API credentials to avoid rate limiting |
### Using Deezer (Recommended)
Works out of the box. No setup required.
### Using Spotify
To use Spotify as your search source without hitting rate limits:
1. Create a Spotify Developer account at [developer.spotify.com](https://developer.spotify.com)
@@ -43,19 +40,13 @@ To use Spotify as your search source without hitting rate limits:
4. Enter your Client ID and Secret
5. Change **Search Source** to Spotify
> **Note**: Spotify URLs (track, album) are always supported regardless of your search source setting. The app will automatically fall back to Deezer if Spotify API is rate limited.
## Support
If you find this app useful, consider supporting the development:
[![Ko-fi](https://img.shields.io/badge/Ko--fi-Support%20Me-FF5E5B?style=for-the-badge&logo=ko-fi&logoColor=white)](https://ko-fi.com/zarzet)
## Other project
### [SpotiFLAC (Desktop)](https://github.com/afkarxyz/SpotiFLAC)
Get Spotify tracks in true FLAC from Tidal, Qobuz & Amazon Music for Windows, macOS & Linux
[![Ko-fi](https://img.shields.io/badge/Ko--fi-Support%20Me-FF5E5B?style=for-the-badge&logo=ko-fi&logoColor=white)](https://ko-fi.com/zarzet)
## Disclaimer
> **iOS Support**: This app is primarily tested on Android. iOS support is experimental and may have bugs — the developer is too poor to afford an iPhone for proper testing. If you encounter issues on iOS, please report them!
+31 -11
View File
@@ -294,35 +294,55 @@ 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)
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(outputPath)
return fmt.Errorf("download interrupted: %w", err)
}
if flushErr != nil {
os.Remove(outputPath)
return fmt.Errorf("failed to flush buffer: %w", flushErr)
}
if closeErr != nil {
os.Remove(outputPath)
return fmt.Errorf("failed to close file: %w", closeErr)
}
// Verify file size if Content-Length was provided
if expectedSize > 0 && written != expectedSize {
os.Remove(outputPath)
return fmt.Errorf("incomplete download: expected %d bytes, got %d bytes", expectedSize, written)
}
fmt.Printf("\r[Amazon] Downloaded: %.2f MB (Complete)\n", float64(written)/(1024*1024))
return nil
}
+37 -16
View File
@@ -13,11 +13,12 @@ import (
)
const (
deezerSearchURL = "https://api.deezer.com/search"
deezerTrackURL = "https://api.deezer.com/track/%s"
deezerAlbumURL = "https://api.deezer.com/album/%s"
deezerArtistURL = "https://api.deezer.com/artist/%s"
deezerPlaylistURL = "https://api.deezer.com/playlist/%s"
deezerBaseURL = "https://api.deezer.com/2.0"
deezerSearchURL = deezerBaseURL + "/search"
deezerTrackURL = deezerBaseURL + "/track/%s"
deezerAlbumURL = deezerBaseURL + "/album/%s"
deezerArtistURL = deezerBaseURL + "/artist/%s"
deezerPlaylistURL = deezerBaseURL + "/playlist/%s"
deezerCacheTTL = 10 * time.Minute
)
@@ -152,7 +153,14 @@ func (c *DeezerClient) SearchAll(ctx context.Context, query string, trackLimit,
}
for _, track := range trackResp.Data {
result.Tracks = append(result.Tracks, c.convertTrack(track))
// Fetch full track info to get ISRC (search results don't include ISRC)
fullTrack, err := c.fetchFullTrack(ctx, fmt.Sprintf("%d", track.ID))
if err == nil && fullTrack != nil {
result.Tracks = append(result.Tracks, c.convertTrack(*fullTrack))
} else {
// Fallback to search result without ISRC
result.Tracks = append(result.Tracks, c.convertTrack(track))
}
}
// Search artists
@@ -423,23 +431,36 @@ func (c *DeezerClient) GetPlaylist(ctx context.Context, playlistID string) (*Pla
}, nil
}
// SearchByISRC searches for a track by ISRC
// SearchByISRC searches for a track by ISRC using direct endpoint
func (c *DeezerClient) SearchByISRC(ctx context.Context, isrc string) (*TrackMetadata, error) {
searchURL := fmt.Sprintf("%s/track?q=isrc:%s&limit=1", deezerSearchURL, isrc)
// 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 resp struct {
Data []deezerTrack `json:"data"`
}
if err := c.getJSON(ctx, searchURL, &resp); err != nil {
return nil, err
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"`
}
if err := c.getJSON(ctx, searchURL, &resp); err != nil {
return nil, err
}
if len(resp.Data) == 0 {
return nil, fmt.Errorf("no track found for ISRC: %s", isrc)
}
result := c.convertTrack(resp.Data[0])
return &result, nil
}
if len(resp.Data) == 0 {
// Check if we got a valid response (ID > 0)
if track.ID == 0 {
return nil, fmt.Errorf("no track found for ISRC: %s", isrc)
}
track := c.convertTrack(resp.Data[0])
return &track, nil
result := c.convertTrack(track)
return &result, nil
}
func (c *DeezerClient) fetchFullTrack(ctx context.Context, trackID string) (*deezerTrack, error) {
+7 -2
View File
@@ -276,12 +276,14 @@ func DownloadWithFallback(requestJSON string) (string, error) {
req.OutputDir = strings.TrimSpace(req.OutputDir)
// Build service order starting with preferred service
allServices := []string{"tidal", "qobuz", "amazon"}
allServices := []string{"qobuz", "tidal", "amazon"}
preferredService := req.Service
if preferredService == "" {
preferredService = "tidal"
preferredService = "qobuz"
}
fmt.Printf("[DownloadWithFallback] Preferred service from request: '%s'\n", req.Service)
// Create ordered list: preferred first, then others
services := []string{preferredService}
for _, s := range allServices {
@@ -290,9 +292,12 @@ func DownloadWithFallback(requestJSON string) (string, error) {
}
}
fmt.Printf("[DownloadWithFallback] Service order: %v\n", services)
var lastErr error
for _, service := range services {
fmt.Printf("[DownloadWithFallback] Trying service: %s\n", service)
req.Service = service
var result DownloadResult
+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
}
+32 -7
View File
@@ -473,30 +473,55 @@ 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)
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(outputPath)
return fmt.Errorf("download interrupted: %w", err)
}
if flushErr != nil {
os.Remove(outputPath)
return fmt.Errorf("failed to flush buffer: %w", flushErr)
}
if closeErr != nil {
os.Remove(outputPath)
return fmt.Errorf("failed to close file: %w", closeErr)
}
// Verify file size if Content-Length was provided
if expectedSize > 0 && written != expectedSize {
os.Remove(outputPath)
return fmt.Errorf("incomplete download: expected %d bytes, got %d bytes", expectedSize, written)
}
return nil
}
// QobuzDownloadResult contains download result with quality info
+57 -14
View File
@@ -746,30 +746,55 @@ 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)
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(outputPath)
return fmt.Errorf("download interrupted: %w", err)
}
if flushErr != nil {
os.Remove(outputPath)
return fmt.Errorf("failed to flush buffer: %w", flushErr)
}
if closeErr != nil {
os.Remove(outputPath)
return fmt.Errorf("failed to close file: %w", closeErr)
}
// Verify file size if Content-Length was provided
if expectedSize > 0 && written != expectedSize {
os.Remove(outputPath)
return fmt.Errorf("incomplete download: expected %d bytes, got %d bytes", expectedSize, written)
}
return nil
}
func (t *TidalDownloader) downloadFromManifest(manifestB64, outputPath, itemID string) error {
@@ -805,26 +830,44 @@ 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)
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(outputPath)
return fmt.Errorf("download interrupted: %w", err)
}
if closeErr != nil {
os.Remove(outputPath)
return fmt.Errorf("failed to close file: %w", closeErr)
}
// Verify file size if Content-Length was provided
if expectedSize > 0 && written != expectedSize {
os.Remove(outputPath)
return fmt.Errorf("incomplete download: expected %d bytes, got %d bytes", expectedSize, written)
}
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';
+1 -1
View File
@@ -25,7 +25,7 @@ class AppSettings {
final String metadataSource; // spotify, deezer - source for search and metadata
const AppSettings({
this.defaultService = 'tidal',
this.defaultService = 'qobuz',
this.audioQuality = 'LOSSLESS',
this.filenameFormat = '{title} - {artist}',
this.downloadDirectory = '',
+26
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;
class SettingsNotifier extends Notifier<AppSettings> {
@override
@@ -18,11 +20,35 @@ 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
// Only apply if user hasn't enabled custom Spotify credentials
// (users with custom credentials likely prefer Spotify)
if (!state.useCustomSpotifyCredentials) {
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
@@ -111,7 +111,7 @@ class PlatformBridge {
int discNumber = 1,
int totalTracks = 1,
String? releaseDate,
String preferredService = 'tidal',
String preferredService = 'qobuz',
String? itemId,
int durationMs = 0,
}) async {
+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