From 0961c09a6d576a1a7d2c3b7cec3f1246ccba0d4d Mon Sep 17 00:00:00 2001 From: zarzet Date: Thu, 16 Jul 2026 08:45:01 +0700 Subject: [PATCH] feat(download): preserve quality variants --- .../zarz/spotiflac/NativeDownloadFinalizer.kt | 23 +++++++-- go_backend/exports_download.go | 1 + go_backend/extension_fallback.go | 1 + go_backend/filename.go | 1 + go_backend/filename_test.go | 30 +++++++++++ lib/l10n/app_localizations.dart | 24 +++++++++ lib/l10n/app_localizations_ar.dart | 14 +++++ lib/l10n/app_localizations_de.dart | 14 +++++ lib/l10n/app_localizations_en.dart | 14 +++++ lib/l10n/app_localizations_es.dart | 14 +++++ lib/l10n/app_localizations_fr.dart | 14 +++++ lib/l10n/app_localizations_hi.dart | 14 +++++ lib/l10n/app_localizations_id.dart | 14 +++++ lib/l10n/app_localizations_ja.dart | 14 +++++ lib/l10n/app_localizations_ko.dart | 14 +++++ lib/l10n/app_localizations_nl.dart | 14 +++++ lib/l10n/app_localizations_pt.dart | 14 +++++ lib/l10n/app_localizations_ru.dart | 14 +++++ lib/l10n/app_localizations_tr.dart | 14 +++++ lib/l10n/app_localizations_uk.dart | 14 +++++ lib/l10n/app_localizations_zh.dart | 14 +++++ lib/l10n/arb/app_en.arb | 16 ++++++ lib/l10n/arb/app_id.arb | 16 ++++++ lib/models/download_item.dart | 5 ++ lib/models/download_item.g.dart | 2 + lib/models/settings.dart | 4 ++ lib/models/settings.g.dart | 2 + lib/providers/download_history_provider.dart | 51 ++++++++++++++++--- lib/providers/download_queue_provider.dart | 9 ++++ ...download_queue_provider_native_worker.dart | 3 ++ .../download_queue_provider_paths.dart | 17 ++++--- lib/providers/settings_provider.dart | 5 ++ lib/screens/artist_screen.dart | 25 +++++---- lib/screens/home_tab.dart | 14 +++-- lib/screens/home_tab_widgets.dart | 49 +++++++++++------- lib/screens/queue_tab_selection.dart | 2 +- .../settings/metadata_settings_page.dart | 36 ++++++++++--- lib/services/download_request_payload.dart | 4 ++ .../track_collection_quick_actions.dart | 24 +++++++++ lib/widgets/track_detail_actions.dart | 35 +++++++------ test/models_and_utils_test.dart | 11 ++++ 41 files changed, 545 insertions(+), 75 deletions(-) diff --git a/android/app/src/main/kotlin/com/zarz/spotiflac/NativeDownloadFinalizer.kt b/android/app/src/main/kotlin/com/zarz/spotiflac/NativeDownloadFinalizer.kt index a835a744..761d9bfb 100644 --- a/android/app/src/main/kotlin/com/zarz/spotiflac/NativeDownloadFinalizer.kt +++ b/android/app/src/main/kotlin/com/zarz/spotiflac/NativeDownloadFinalizer.kt @@ -238,9 +238,20 @@ object NativeDownloadFinalizer { val saveDownloadHistory = parseObject(settingsJson) .optBoolean("save_download_history", true) - val history = if (saveDownloadHistory) { + val preserveQualityVariant = input.request + .optBoolean("allow_quality_variant", false) + val history = if ( + saveDownloadHistory && + !(preserveQualityVariant && result.optBoolean("already_exists", false)) + ) { try { - buildHistoryRow(effectiveInput, state).also { upsertHistory(context, it) } + buildHistoryRow(effectiveInput, state).also { + upsertHistory( + context, + it, + deduplicateTrack = !preserveQualityVariant, + ) + } } catch (e: Exception) { // History is bookkeeping; never fail (and never delete) a // finished download because the insert failed. @@ -1918,7 +1929,11 @@ object NativeDownloadFinalizer { return values } - private fun upsertHistory(context: Context, values: ContentValues) { + private fun upsertHistory( + context: Context, + values: ContentValues, + deduplicateTrack: Boolean = true, + ) { val dbFile = File(File(context.applicationInfo.dataDir, "app_flutter"), "history.db") dbFile.parentFile?.mkdirs() val db = SQLiteDatabase.openDatabase( @@ -2003,7 +2018,7 @@ object NativeDownloadFinalizer { db.execSQL("CREATE INDEX IF NOT EXISTS idx_history_isrc_norm ON history(isrc_norm)") db.execSQL("CREATE INDEX IF NOT EXISTS idx_history_match_key ON history(match_key)") if (db.version < HISTORY_SCHEMA_VERSION) db.version = HISTORY_SCHEMA_VERSION - deleteDuplicateHistoryRows(db, values) + if (deduplicateTrack) deleteDuplicateHistoryRows(db, values) db.insertWithOnConflict("history", null, values, SQLiteDatabase.CONFLICT_REPLACE) replaceHistoryPathKeys(db, values.getAsString("id"), values.getAsString("file_path")) db.setTransactionSuccessful() diff --git a/go_backend/exports_download.go b/go_backend/exports_download.go index c176d87d..6b64d1df 100644 --- a/go_backend/exports_download.go +++ b/go_backend/exports_download.go @@ -50,6 +50,7 @@ type DownloadRequest struct { UseExtensions bool `json:"use_extensions,omitempty"` UseFallback bool `json:"use_fallback,omitempty"` RequiresContainerConversion bool `json:"requires_container_conversion,omitempty"` + AllowQualityVariant bool `json:"allow_quality_variant,omitempty"` SongLinkRegion string `json:"songlink_region,omitempty"` } diff --git a/go_backend/extension_fallback.go b/go_backend/extension_fallback.go index bf210c3c..16610b24 100644 --- a/go_backend/extension_fallback.go +++ b/go_backend/extension_fallback.go @@ -889,6 +889,7 @@ func buildDownloadFilename(req DownloadRequest) string { "release_date": req.ReleaseDate, "isrc": req.ISRC, "composer": req.Composer, + "quality": req.Quality, } filename := buildFilenameFromTemplate(req.FilenameFormat, metadata) diff --git a/go_backend/filename.go b/go_backend/filename.go index 4fb2a316..033c80df 100644 --- a/go_backend/filename.go +++ b/go_backend/filename.go @@ -108,6 +108,7 @@ func buildFilenameFromTemplate(template string, metadata map[string]any) string "{date}": dateValue, "{disc}": formatDiscNumber(getInt(metadata, "disc")), "{disc_raw}": formatRawNumber(getInt(metadata, "disc")), + "{quality}": getString(metadata, "quality"), } for placeholder, value := range placeholders { diff --git a/go_backend/filename_test.go b/go_backend/filename_test.go index 2e2de26c..78161293 100644 --- a/go_backend/filename_test.go +++ b/go_backend/filename_test.go @@ -72,6 +72,36 @@ func TestBuildFilenameFromTemplate_PlaylistPositionFormatting(t *testing.T) { } } +func TestBuildFilenameFromTemplate_QualityVariant(t *testing.T) { + metadata := map[string]any{ + "artist": "Artist Name", + "title": "Song Name", + "quality": "HI_RES_LOSSLESS", + } + + formatted := buildFilenameFromTemplate( + "{artist} - {title} - {quality}", + metadata, + ) + if formatted != "Artist Name - Song Name - HI_RES_LOSSLESS" { + t.Fatalf("unexpected quality filename: %q", formatted) + } +} + +func TestBuildDownloadFilename_ProvidesRequestedQuality(t *testing.T) { + filename := buildDownloadFilename(DownloadRequest{ + TrackName: "Song Name", + ArtistName: "Artist Name", + FilenameFormat: "{artist} - {title} - {quality}", + Quality: "LOSSLESS", + OutputExt: ".flac", + }) + + if filename != "Artist Name - Song Name - LOSSLESS.flac" { + t.Fatalf("unexpected download filename: %q", filename) + } +} + func TestBuildFilenameFromTemplate_DateStrftimeFormatting(t *testing.T) { metadata := map[string]any{ "artist": "Artist Name", diff --git a/lib/l10n/app_localizations.dart b/lib/l10n/app_localizations.dart index 35900be9..e1accb72 100644 --- a/lib/l10n/app_localizations.dart +++ b/lib/l10n/app_localizations.dart @@ -6524,12 +6524,36 @@ abstract class AppLocalizations { /// **'Already-downloaded tracks will be skipped'** String get downloadDeduplicationEnabled; + /// Deduplication subtitle when separate quality versions are allowed + /// + /// In en, this message translates to: + /// **'Existing files at the selected quality will be skipped'** + String get downloadDeduplicationWithQualityVariants; + /// Subtitle when deduplication is off /// /// In en, this message translates to: /// **'All tracks will be downloaded regardless of history'** String get downloadDeduplicationDisabled; + /// Setting to retain multiple quality versions of the same track + /// + /// In en, this message translates to: + /// **'Allow different quality versions'** + String get downloadQualityVariants; + + /// Description for retaining multiple quality versions + /// + /// In en, this message translates to: + /// **'Add the selected quality to the filename and keep each version in download history'** + String get downloadQualityVariantsDescription; + + /// Track menu action to download another quality version + /// + /// In en, this message translates to: + /// **'Download another quality'** + String get trackOptionDownloadQualityVariant; + /// Settings item for configuring fallback extension providers /// /// In en, this message translates to: diff --git a/lib/l10n/app_localizations_ar.dart b/lib/l10n/app_localizations_ar.dart index 5edbd6b2..494ab072 100644 --- a/lib/l10n/app_localizations_ar.dart +++ b/lib/l10n/app_localizations_ar.dart @@ -3929,10 +3929,24 @@ class AppLocalizationsAr extends AppLocalizations { String get downloadDeduplicationEnabled => 'Already-downloaded tracks will be skipped'; + @override + String get downloadDeduplicationWithQualityVariants => + 'Existing files at the selected quality will be skipped'; + @override String get downloadDeduplicationDisabled => 'All tracks will be downloaded regardless of history'; + @override + String get downloadQualityVariants => 'Allow different quality versions'; + + @override + String get downloadQualityVariantsDescription => + 'Add the selected quality to the filename and keep each version in download history'; + + @override + String get trackOptionDownloadQualityVariant => 'Download another quality'; + @override String get downloadFallbackExtensions => 'Fallback Extensions'; diff --git a/lib/l10n/app_localizations_de.dart b/lib/l10n/app_localizations_de.dart index 9a08cef0..d24a8ab0 100644 --- a/lib/l10n/app_localizations_de.dart +++ b/lib/l10n/app_localizations_de.dart @@ -3975,10 +3975,24 @@ class AppLocalizationsDe extends AppLocalizations { String get downloadDeduplicationEnabled => 'Bereits heruntergeladene Titel werden übersprungen'; + @override + String get downloadDeduplicationWithQualityVariants => + 'Existing files at the selected quality will be skipped'; + @override String get downloadDeduplicationDisabled => 'Alle Titel werden unabhängig vom Verlauf heruntergeladen'; + @override + String get downloadQualityVariants => 'Allow different quality versions'; + + @override + String get downloadQualityVariantsDescription => + 'Add the selected quality to the filename and keep each version in download history'; + + @override + String get trackOptionDownloadQualityVariant => 'Download another quality'; + @override String get downloadFallbackExtensions => 'Fallback-Erweiterungen'; diff --git a/lib/l10n/app_localizations_en.dart b/lib/l10n/app_localizations_en.dart index db692d60..e0af703c 100644 --- a/lib/l10n/app_localizations_en.dart +++ b/lib/l10n/app_localizations_en.dart @@ -3929,10 +3929,24 @@ class AppLocalizationsEn extends AppLocalizations { String get downloadDeduplicationEnabled => 'Already-downloaded tracks will be skipped'; + @override + String get downloadDeduplicationWithQualityVariants => + 'Existing files at the selected quality will be skipped'; + @override String get downloadDeduplicationDisabled => 'All tracks will be downloaded regardless of history'; + @override + String get downloadQualityVariants => 'Allow different quality versions'; + + @override + String get downloadQualityVariantsDescription => + 'Add the selected quality to the filename and keep each version in download history'; + + @override + String get trackOptionDownloadQualityVariant => 'Download another quality'; + @override String get downloadFallbackExtensions => 'Fallback Extensions'; diff --git a/lib/l10n/app_localizations_es.dart b/lib/l10n/app_localizations_es.dart index ba1c1453..8fa91c95 100644 --- a/lib/l10n/app_localizations_es.dart +++ b/lib/l10n/app_localizations_es.dart @@ -3923,10 +3923,24 @@ class AppLocalizationsEs extends AppLocalizations { String get downloadDeduplicationEnabled => 'Already-downloaded tracks will be skipped'; + @override + String get downloadDeduplicationWithQualityVariants => + 'Existing files at the selected quality will be skipped'; + @override String get downloadDeduplicationDisabled => 'All tracks will be downloaded regardless of history'; + @override + String get downloadQualityVariants => 'Allow different quality versions'; + + @override + String get downloadQualityVariantsDescription => + 'Add the selected quality to the filename and keep each version in download history'; + + @override + String get trackOptionDownloadQualityVariant => 'Download another quality'; + @override String get downloadFallbackExtensions => 'Fallback Extensions'; diff --git a/lib/l10n/app_localizations_fr.dart b/lib/l10n/app_localizations_fr.dart index be7719da..5d8de352 100644 --- a/lib/l10n/app_localizations_fr.dart +++ b/lib/l10n/app_localizations_fr.dart @@ -4033,10 +4033,24 @@ class AppLocalizationsFr extends AppLocalizations { String get downloadDeduplicationEnabled => 'Les morceaux déjà téléchargés seront ignorés'; + @override + String get downloadDeduplicationWithQualityVariants => + 'Existing files at the selected quality will be skipped'; + @override String get downloadDeduplicationDisabled => 'Tous les morceaux seront téléchargés, quel que soit l\'historique'; + @override + String get downloadQualityVariants => 'Allow different quality versions'; + + @override + String get downloadQualityVariantsDescription => + 'Add the selected quality to the filename and keep each version in download history'; + + @override + String get trackOptionDownloadQualityVariant => 'Download another quality'; + @override String get downloadFallbackExtensions => 'Extensions de secours'; diff --git a/lib/l10n/app_localizations_hi.dart b/lib/l10n/app_localizations_hi.dart index 410e79c5..46b59f71 100644 --- a/lib/l10n/app_localizations_hi.dart +++ b/lib/l10n/app_localizations_hi.dart @@ -3929,10 +3929,24 @@ class AppLocalizationsHi extends AppLocalizations { String get downloadDeduplicationEnabled => 'Already-downloaded tracks will be skipped'; + @override + String get downloadDeduplicationWithQualityVariants => + 'Existing files at the selected quality will be skipped'; + @override String get downloadDeduplicationDisabled => 'All tracks will be downloaded regardless of history'; + @override + String get downloadQualityVariants => 'Allow different quality versions'; + + @override + String get downloadQualityVariantsDescription => + 'Add the selected quality to the filename and keep each version in download history'; + + @override + String get trackOptionDownloadQualityVariant => 'Download another quality'; + @override String get downloadFallbackExtensions => 'Fallback Extensions'; diff --git a/lib/l10n/app_localizations_id.dart b/lib/l10n/app_localizations_id.dart index fa80b934..033e0cd5 100644 --- a/lib/l10n/app_localizations_id.dart +++ b/lib/l10n/app_localizations_id.dart @@ -3932,10 +3932,24 @@ class AppLocalizationsId extends AppLocalizations { String get downloadDeduplicationEnabled => 'Already-downloaded tracks will be skipped'; + @override + String get downloadDeduplicationWithQualityVariants => + 'File yang sudah ada pada kualitas yang dipilih akan dilewati'; + @override String get downloadDeduplicationDisabled => 'All tracks will be downloaded regardless of history'; + @override + String get downloadQualityVariants => 'Izinkan versi dengan kualitas berbeda'; + + @override + String get downloadQualityVariantsDescription => + 'Tambahkan kualitas yang dipilih ke nama file dan simpan setiap versi di riwayat unduhan'; + + @override + String get trackOptionDownloadQualityVariant => 'Unduh kualitas lain'; + @override String get downloadFallbackExtensions => 'Fallback Extensions'; diff --git a/lib/l10n/app_localizations_ja.dart b/lib/l10n/app_localizations_ja.dart index 22f12cf0..fc0dad6d 100644 --- a/lib/l10n/app_localizations_ja.dart +++ b/lib/l10n/app_localizations_ja.dart @@ -3918,10 +3918,24 @@ class AppLocalizationsJa extends AppLocalizations { String get downloadDeduplicationEnabled => 'Already-downloaded tracks will be skipped'; + @override + String get downloadDeduplicationWithQualityVariants => + 'Existing files at the selected quality will be skipped'; + @override String get downloadDeduplicationDisabled => 'All tracks will be downloaded regardless of history'; + @override + String get downloadQualityVariants => 'Allow different quality versions'; + + @override + String get downloadQualityVariantsDescription => + 'Add the selected quality to the filename and keep each version in download history'; + + @override + String get trackOptionDownloadQualityVariant => 'Download another quality'; + @override String get downloadFallbackExtensions => 'Fallback Extensions'; diff --git a/lib/l10n/app_localizations_ko.dart b/lib/l10n/app_localizations_ko.dart index 815a1843..020b06fc 100644 --- a/lib/l10n/app_localizations_ko.dart +++ b/lib/l10n/app_localizations_ko.dart @@ -3810,9 +3810,23 @@ class AppLocalizationsKo extends AppLocalizations { @override String get downloadDeduplicationEnabled => '이미 다운로드된 트랙은 건너뜁니다'; + @override + String get downloadDeduplicationWithQualityVariants => + 'Existing files at the selected quality will be skipped'; + @override String get downloadDeduplicationDisabled => '기록과 관계없이 모든 트랙이 다운로드됩니다'; + @override + String get downloadQualityVariants => 'Allow different quality versions'; + + @override + String get downloadQualityVariantsDescription => + 'Add the selected quality to the filename and keep each version in download history'; + + @override + String get trackOptionDownloadQualityVariant => 'Download another quality'; + @override String get downloadFallbackExtensions => '대체 확장 프로그램'; diff --git a/lib/l10n/app_localizations_nl.dart b/lib/l10n/app_localizations_nl.dart index a6079dab..9f924164 100644 --- a/lib/l10n/app_localizations_nl.dart +++ b/lib/l10n/app_localizations_nl.dart @@ -3929,10 +3929,24 @@ class AppLocalizationsNl extends AppLocalizations { String get downloadDeduplicationEnabled => 'Already-downloaded tracks will be skipped'; + @override + String get downloadDeduplicationWithQualityVariants => + 'Existing files at the selected quality will be skipped'; + @override String get downloadDeduplicationDisabled => 'All tracks will be downloaded regardless of history'; + @override + String get downloadQualityVariants => 'Allow different quality versions'; + + @override + String get downloadQualityVariantsDescription => + 'Add the selected quality to the filename and keep each version in download history'; + + @override + String get trackOptionDownloadQualityVariant => 'Download another quality'; + @override String get downloadFallbackExtensions => 'Fallback Extensions'; diff --git a/lib/l10n/app_localizations_pt.dart b/lib/l10n/app_localizations_pt.dart index f25745bd..f8e6be61 100644 --- a/lib/l10n/app_localizations_pt.dart +++ b/lib/l10n/app_localizations_pt.dart @@ -3923,10 +3923,24 @@ class AppLocalizationsPt extends AppLocalizations { String get downloadDeduplicationEnabled => 'Already-downloaded tracks will be skipped'; + @override + String get downloadDeduplicationWithQualityVariants => + 'Existing files at the selected quality will be skipped'; + @override String get downloadDeduplicationDisabled => 'All tracks will be downloaded regardless of history'; + @override + String get downloadQualityVariants => 'Allow different quality versions'; + + @override + String get downloadQualityVariantsDescription => + 'Add the selected quality to the filename and keep each version in download history'; + + @override + String get trackOptionDownloadQualityVariant => 'Download another quality'; + @override String get downloadFallbackExtensions => 'Fallback Extensions'; diff --git a/lib/l10n/app_localizations_ru.dart b/lib/l10n/app_localizations_ru.dart index f7ae2224..a9f25deb 100644 --- a/lib/l10n/app_localizations_ru.dart +++ b/lib/l10n/app_localizations_ru.dart @@ -3960,10 +3960,24 @@ class AppLocalizationsRu extends AppLocalizations { String get downloadDeduplicationEnabled => 'Already-downloaded tracks will be skipped'; + @override + String get downloadDeduplicationWithQualityVariants => + 'Existing files at the selected quality will be skipped'; + @override String get downloadDeduplicationDisabled => 'All tracks will be downloaded regardless of history'; + @override + String get downloadQualityVariants => 'Allow different quality versions'; + + @override + String get downloadQualityVariantsDescription => + 'Add the selected quality to the filename and keep each version in download history'; + + @override + String get trackOptionDownloadQualityVariant => 'Download another quality'; + @override String get downloadFallbackExtensions => 'Fallback Extensions'; diff --git a/lib/l10n/app_localizations_tr.dart b/lib/l10n/app_localizations_tr.dart index ae2e9d90..f3731fd6 100644 --- a/lib/l10n/app_localizations_tr.dart +++ b/lib/l10n/app_localizations_tr.dart @@ -3959,10 +3959,24 @@ class AppLocalizationsTr extends AppLocalizations { String get downloadDeduplicationEnabled => 'Already-downloaded tracks will be skipped'; + @override + String get downloadDeduplicationWithQualityVariants => + 'Existing files at the selected quality will be skipped'; + @override String get downloadDeduplicationDisabled => 'All tracks will be downloaded regardless of history'; + @override + String get downloadQualityVariants => 'Allow different quality versions'; + + @override + String get downloadQualityVariantsDescription => + 'Add the selected quality to the filename and keep each version in download history'; + + @override + String get trackOptionDownloadQualityVariant => 'Download another quality'; + @override String get downloadFallbackExtensions => 'Fallback Extensions'; diff --git a/lib/l10n/app_localizations_uk.dart b/lib/l10n/app_localizations_uk.dart index 3fa535a9..97c3251e 100644 --- a/lib/l10n/app_localizations_uk.dart +++ b/lib/l10n/app_localizations_uk.dart @@ -3977,10 +3977,24 @@ class AppLocalizationsUk extends AppLocalizations { String get downloadDeduplicationEnabled => 'Already-downloaded tracks will be skipped'; + @override + String get downloadDeduplicationWithQualityVariants => + 'Existing files at the selected quality will be skipped'; + @override String get downloadDeduplicationDisabled => 'All tracks will be downloaded regardless of history'; + @override + String get downloadQualityVariants => 'Allow different quality versions'; + + @override + String get downloadQualityVariantsDescription => + 'Add the selected quality to the filename and keep each version in download history'; + + @override + String get trackOptionDownloadQualityVariant => 'Download another quality'; + @override String get downloadFallbackExtensions => 'Fallback Extensions'; diff --git a/lib/l10n/app_localizations_zh.dart b/lib/l10n/app_localizations_zh.dart index 0f9c43e7..0992d093 100644 --- a/lib/l10n/app_localizations_zh.dart +++ b/lib/l10n/app_localizations_zh.dart @@ -3923,10 +3923,24 @@ class AppLocalizationsZh extends AppLocalizations { String get downloadDeduplicationEnabled => 'Already-downloaded tracks will be skipped'; + @override + String get downloadDeduplicationWithQualityVariants => + 'Existing files at the selected quality will be skipped'; + @override String get downloadDeduplicationDisabled => 'All tracks will be downloaded regardless of history'; + @override + String get downloadQualityVariants => 'Allow different quality versions'; + + @override + String get downloadQualityVariantsDescription => + 'Add the selected quality to the filename and keep each version in download history'; + + @override + String get trackOptionDownloadQualityVariant => 'Download another quality'; + @override String get downloadFallbackExtensions => 'Fallback Extensions'; diff --git a/lib/l10n/arb/app_en.arb b/lib/l10n/arb/app_en.arb index 52632382..88b5dc8c 100644 --- a/lib/l10n/arb/app_en.arb +++ b/lib/l10n/arb/app_en.arb @@ -5108,10 +5108,26 @@ "@downloadDeduplicationEnabled": { "description": "Subtitle when deduplication is on" }, + "downloadDeduplicationWithQualityVariants": "Existing files at the selected quality will be skipped", + "@downloadDeduplicationWithQualityVariants": { + "description": "Deduplication subtitle when separate quality versions are allowed" + }, "downloadDeduplicationDisabled": "All tracks will be downloaded regardless of history", "@downloadDeduplicationDisabled": { "description": "Subtitle when deduplication is off" }, + "downloadQualityVariants": "Allow different quality versions", + "@downloadQualityVariants": { + "description": "Setting to retain multiple quality versions of the same track" + }, + "downloadQualityVariantsDescription": "Add the selected quality to the filename and keep each version in download history", + "@downloadQualityVariantsDescription": { + "description": "Description for retaining multiple quality versions" + }, + "trackOptionDownloadQualityVariant": "Download another quality", + "@trackOptionDownloadQualityVariant": { + "description": "Track menu action to download another quality version" + }, "downloadFallbackExtensions": "Fallback Extensions", "@downloadFallbackExtensions": { "description": "Settings item for configuring fallback extension providers" diff --git a/lib/l10n/arb/app_id.arb b/lib/l10n/arb/app_id.arb index 77a353fe..a73bb9c3 100644 --- a/lib/l10n/arb/app_id.arb +++ b/lib/l10n/arb/app_id.arb @@ -5915,5 +5915,21 @@ "type": "String" } } + }, + "downloadQualityVariants": "Izinkan versi dengan kualitas berbeda", + "@downloadQualityVariants": { + "description": "Pengaturan untuk menyimpan beberapa versi kualitas dari lagu yang sama" + }, + "downloadQualityVariantsDescription": "Tambahkan kualitas yang dipilih ke nama file dan simpan setiap versi di riwayat unduhan", + "@downloadQualityVariantsDescription": { + "description": "Deskripsi penyimpanan beberapa versi kualitas" + }, + "trackOptionDownloadQualityVariant": "Unduh kualitas lain", + "@trackOptionDownloadQualityVariant": { + "description": "Aksi menu lagu untuk mengunduh versi kualitas lain" + }, + "downloadDeduplicationWithQualityVariants": "File yang sudah ada pada kualitas yang dipilih akan dilewati", + "@downloadDeduplicationWithQualityVariants": { + "description": "Subtitle deduplikasi ketika versi kualitas terpisah diizinkan" } } diff --git a/lib/models/download_item.dart b/lib/models/download_item.dart index 3e1b0d68..1cb677a8 100644 --- a/lib/models/download_item.dart +++ b/lib/models/download_item.dart @@ -39,6 +39,7 @@ class DownloadItem { final String? playlistName; final int? playlistPosition; // 1-based position in the source playlist final bool fromBatch; + final bool preserveQualityVariant; const DownloadItem({ required this.id, @@ -57,6 +58,7 @@ class DownloadItem { this.playlistName, this.playlistPosition, this.fromBatch = false, + this.preserveQualityVariant = false, }); DownloadItem copyWith({ @@ -76,6 +78,7 @@ class DownloadItem { String? playlistName, int? playlistPosition, bool? fromBatch, + bool? preserveQualityVariant, }) { return DownloadItem( id: id ?? this.id, @@ -94,6 +97,8 @@ class DownloadItem { playlistName: playlistName ?? this.playlistName, playlistPosition: playlistPosition ?? this.playlistPosition, fromBatch: fromBatch ?? this.fromBatch, + preserveQualityVariant: + preserveQualityVariant ?? this.preserveQualityVariant, ); } diff --git a/lib/models/download_item.g.dart b/lib/models/download_item.g.dart index bb3e29f0..1445f854 100644 --- a/lib/models/download_item.g.dart +++ b/lib/models/download_item.g.dart @@ -25,6 +25,7 @@ DownloadItem _$DownloadItemFromJson(Map json) => DownloadItem( playlistName: json['playlistName'] as String?, playlistPosition: (json['playlistPosition'] as num?)?.toInt(), fromBatch: json['fromBatch'] as bool? ?? false, + preserveQualityVariant: json['preserveQualityVariant'] as bool? ?? false, ); Map _$DownloadItemToJson(DownloadItem instance) => @@ -45,6 +46,7 @@ Map _$DownloadItemToJson(DownloadItem instance) => 'playlistName': instance.playlistName, 'playlistPosition': instance.playlistPosition, 'fromBatch': instance.fromBatch, + 'preserveQualityVariant': instance.preserveQualityVariant, }; const _$DownloadStatusEnumMap = { diff --git a/lib/models/settings.dart b/lib/models/settings.dart index 0a409a0a..250a6c64 100644 --- a/lib/models/settings.dart +++ b/lib/models/settings.dart @@ -97,6 +97,7 @@ class AppSettings { lastSeenVersion; // Last app version the user has acknowledged (e.g. '3.7.0') final bool deduplicateDownloads; + final bool allowQualityVariants; final bool saveDownloadHistory; final String playerMode; @@ -165,6 +166,7 @@ class AppSettings { this.musixmatchLanguage = '', this.lastSeenVersion = '', this.deduplicateDownloads = true, + this.allowQualityVariants = false, this.saveDownloadHistory = true, this.playerMode = 'external', }); @@ -236,6 +238,7 @@ class AppSettings { String? musixmatchLanguage, String? lastSeenVersion, bool? deduplicateDownloads, + bool? allowQualityVariants, bool? saveDownloadHistory, String? playerMode, }) { @@ -329,6 +332,7 @@ class AppSettings { musixmatchLanguage: musixmatchLanguage ?? this.musixmatchLanguage, lastSeenVersion: lastSeenVersion ?? this.lastSeenVersion, deduplicateDownloads: deduplicateDownloads ?? this.deduplicateDownloads, + allowQualityVariants: allowQualityVariants ?? this.allowQualityVariants, saveDownloadHistory: saveDownloadHistory ?? this.saveDownloadHistory, playerMode: playerMode ?? this.playerMode, ); diff --git a/lib/models/settings.g.dart b/lib/models/settings.g.dart index 2ea7f751..a70a4842 100644 --- a/lib/models/settings.g.dart +++ b/lib/models/settings.g.dart @@ -87,6 +87,7 @@ AppSettings _$AppSettingsFromJson(Map json) => AppSettings( musixmatchLanguage: json['musixmatchLanguage'] as String? ?? '', lastSeenVersion: json['lastSeenVersion'] as String? ?? '', deduplicateDownloads: json['deduplicateDownloads'] as bool? ?? true, + allowQualityVariants: json['allowQualityVariants'] as bool? ?? false, saveDownloadHistory: json['saveDownloadHistory'] as bool? ?? true, playerMode: json['playerMode'] as String? ?? 'external', ); @@ -158,6 +159,7 @@ Map _$AppSettingsToJson( 'musixmatchLanguage': instance.musixmatchLanguage, 'lastSeenVersion': instance.lastSeenVersion, 'deduplicateDownloads': instance.deduplicateDownloads, + 'allowQualityVariants': instance.allowQualityVariants, 'saveDownloadHistory': instance.saveDownloadHistory, 'playerMode': instance.playerMode, }; diff --git a/lib/providers/download_history_provider.dart b/lib/providers/download_history_provider.dart index 690a5d1a..e0a266e3 100644 --- a/lib/providers/download_history_provider.dart +++ b/lib/providers/download_history_provider.dart @@ -1028,17 +1028,35 @@ class DownloadHistoryNotifier extends Notifier { return byId.values.toList(growable: false); } - void addToHistory(DownloadHistoryItem item) => - _persistHistoryItem(item, 'save to database'); + void addToHistory( + DownloadHistoryItem item, { + bool preserveTrackVariant = false, + }) => _persistHistoryItem( + item, + 'save to database', + preserveTrackVariant: preserveTrackVariant, + ); - void adoptNativeHistoryItem(DownloadHistoryItem item) => - _persistHistoryItem(item, 'adopt native history item'); + void adoptNativeHistoryItem( + DownloadHistoryItem item, { + bool preserveTrackVariant = false, + }) => _persistHistoryItem( + item, + 'adopt native history item', + preserveTrackVariant: preserveTrackVariant, + ); - void _persistHistoryItem(DownloadHistoryItem item, String action) { + void _persistHistoryItem( + DownloadHistoryItem item, + String action, { + required bool preserveTrackVariant, + }) { unawaited( () async { - final mergedItem = await _putInMemoryHistory(item); - await _db.upsert(mergedItem.toJson()); + final persistedItem = preserveTrackVariant + ? _putInMemoryTrackVariant(item) + : await _putInMemoryHistory(item); + await _db.upsert(persistedItem.toJson()); _bumpHistoryRevision(); }().catchError((Object e, StackTrace stack) { _historyLog.e('Failed to $action: $e', e, stack); @@ -1046,6 +1064,25 @@ class DownloadHistoryNotifier extends Notifier { ); } + DownloadHistoryItem _putInMemoryTrackVariant(DownloadHistoryItem item) { + final isReplacement = state.items.any((existing) => existing.id == item.id); + final items = [ + item, + ...state.items.where((existing) => existing.id != item.id), + ]; + final lookupItems = [ + item, + ...state.lookupItems.where((existing) => existing.id != item.id), + ]; + state = state.copyWith( + items: items, + totalCount: isReplacement ? state.totalCount : state.totalCount + 1, + lookupItems: lookupItems, + ); + _historyLog.d('Added independent history variant: ${item.trackName}'); + return item; + } + void removeFromHistory(String id) { state = state.copyWith( items: state.items.where((item) => item.id != id).toList(), diff --git a/lib/providers/download_queue_provider.dart b/lib/providers/download_queue_provider.dart index fd139dbd..6c0ef56c 100644 --- a/lib/providers/download_queue_provider.dart +++ b/lib/providers/download_queue_provider.dart @@ -68,6 +68,10 @@ final _batchUniqueFilenameTokenPattern = RegExp( r'\{(?:title|track(?:_raw)?|track:\d+|playlist_position(?:_raw)?|playlist_position:\d+|playlist position|playlistPosition|position(?::\d+)?)\}', caseSensitive: false, ); +final _qualityFilenameTokenPattern = RegExp( + r'\{quality\}', + caseSensitive: false, +); class DownloadQueueState { static const Object _noChange = Object(); @@ -1138,6 +1142,7 @@ class DownloadQueueNotifier extends Notifier { item.service, outputExt, ), + allowQualityVariant: item.preserveQualityVariant, songLinkRegion: settings.songLinkRegion, ); } @@ -1221,6 +1226,7 @@ class DownloadQueueNotifier extends Notifier { qualityOverride: qualityOverride, playlistName: playlistName, playlistPosition: playlistPosition, + preserveQualityVariant: settings.allowQualityVariants, ); state = state.copyWith(items: [...state.items, item]); @@ -1269,6 +1275,7 @@ class DownloadQueueNotifier extends Notifier { explicitPosition ?? (shouldAssignPlaylistPositions ? index + 1 : null), fromBatch: fromBatch, + preserveQualityVariant: settings.allowQualityVariants, ); }).toList(); @@ -2541,6 +2548,7 @@ class DownloadQueueNotifier extends Notifier { effectiveFilenameFormat, _filenameMetadataForTrack( trackToDownload, + quality: quality, playlistPosition: _validPlaylistPosition(item), ), ); @@ -3831,6 +3839,7 @@ class DownloadQueueNotifier extends Notifier { label: effectiveLabel, copyright: effectiveCopyright, ), + preserveTrackVariant: item.preserveQualityVariant, ); } diff --git a/lib/providers/download_queue_provider_native_worker.dart b/lib/providers/download_queue_provider_native_worker.dart index 09b7c29c..199e4ddd 100644 --- a/lib/providers/download_queue_provider_native_worker.dart +++ b/lib/providers/download_queue_provider_native_worker.dart @@ -622,6 +622,7 @@ extension _DownloadQueueNativeWorker on DownloadQueueNotifier { effectiveFilenameFormat, _filenameMetadataForTrack( item.track, + quality: quality, playlistPosition: _validPlaylistPosition(item), ), ); @@ -865,6 +866,7 @@ extension _DownloadQueueNativeWorker on DownloadQueueNotifier { DownloadHistoryItem.fromJson( Map.from(historyItem), ), + preserveTrackVariant: item.preserveQualityVariant, ); } catch (e) { _log.w('Failed to adopt native history item: $e'); @@ -1088,6 +1090,7 @@ extension _DownloadQueueNativeWorker on DownloadQueueNotifier { result['copyright'] as String?, ), ), + preserveTrackVariant: item.preserveQualityVariant, ); } diff --git a/lib/providers/download_queue_provider_paths.dart b/lib/providers/download_queue_provider_paths.dart index 58457546..fa0f8fca 100644 --- a/lib/providers/download_queue_provider_paths.dart +++ b/lib/providers/download_queue_provider_paths.dart @@ -532,21 +532,25 @@ extension _DownloadQueuePaths on DownloadQueueNotifier { } String _filenameFormatForItem(DownloadItem item, String baseFormat) { - if (!item.fromBatch) { - return baseFormat; - } final trimmed = baseFormat.trim(); if (trimmed.isEmpty) { return baseFormat; } - if (_batchUniqueFilenameTokenPattern.hasMatch(trimmed)) { - return baseFormat; + var effective = trimmed; + if (item.fromBatch && + !_batchUniqueFilenameTokenPattern.hasMatch(effective)) { + effective = '$effective - {track:02} - {title}'; } - return '$trimmed - {track:02} - {title}'; + if (item.preserveQualityVariant && + !_qualityFilenameTokenPattern.hasMatch(effective)) { + effective = '$effective - {quality}'; + } + return effective; } Map _filenameMetadataForTrack( Track track, { + required String quality, int playlistPosition = 0, }) { return { @@ -559,6 +563,7 @@ extension _DownloadQueuePaths on DownloadQueueNotifier { 'date': track.releaseDate ?? '', 'playlist_position': playlistPosition, 'playlistPosition': playlistPosition, + 'quality': quality, }; } } diff --git a/lib/providers/settings_provider.dart b/lib/providers/settings_provider.dart index 4adc9ede..8c598e14 100644 --- a/lib/providers/settings_provider.dart +++ b/lib/providers/settings_provider.dart @@ -713,6 +713,11 @@ class SettingsNotifier extends Notifier { _saveSettings(); } + void setAllowQualityVariants(bool enabled) { + state = state.copyWith(allowQualityVariants: enabled); + _saveSettings(); + } + void setSaveDownloadHistory(bool enabled) { state = state.copyWith(saveDownloadHistory: enabled); _saveSettings(); diff --git a/lib/screens/artist_screen.dart b/lib/screens/artist_screen.dart index 4166acb0..627237ae 100644 --- a/lib/screens/artist_screen.dart +++ b/lib/screens/artist_screen.dart @@ -873,7 +873,7 @@ class _ArtistScreenState extends ConsumerState List albums, ) async { final settings = ref.read(settingsProvider); - if (settings.askQualityBeforeDownload) { + if (settings.askQualityBeforeDownload || settings.allowQualityVariants) { DownloadServicePicker.show( context, recommendedService: _recommendedDownloadService(), @@ -993,22 +993,27 @@ class _ArtistScreenState extends ConsumerState return; } + final settings = ref.read(settingsProvider); + final skipExisting = + settings.deduplicateDownloads && !settings.allowQualityVariants; final historyLookups = allTracks .map(historyLookupForTrack) .toList(growable: false); - final existingHistoryKeys = await ref.read( - downloadHistoryBatchExistsProvider( - HistoryBatchLookupRequest(historyLookups), - ).future, - ); + final existingHistoryKeys = skipExisting + ? await ref.read( + downloadHistoryBatchExistsProvider( + HistoryBatchLookupRequest(historyLookups), + ).future, + ) + : const {}; final tracksToQueue = []; int skippedCount = 0; for (var i = 0; i < allTracks.length; i++) { final track = allTracks[i]; - final isDownloaded = existingHistoryKeys.contains( - historyLookups[i].lookupKey, - ); + final isDownloaded = + skipExisting && + existingHistoryKeys.contains(historyLookups[i].lookupKey); if (!isDownloaded) { tracksToQueue.add(track); @@ -1642,7 +1647,7 @@ class _ArtistScreenState extends ConsumerState ); } - if (settings.askQualityBeforeDownload) { + if (settings.askQualityBeforeDownload || settings.allowQualityVariants) { DownloadServicePicker.show( context, recommendedService: _recommendedDownloadService(), diff --git a/lib/screens/home_tab.dart b/lib/screens/home_tab.dart index c4c95ecd..fe63eed8 100644 --- a/lib/screens/home_tab.dart +++ b/lib/screens/home_tab.dart @@ -825,13 +825,13 @@ class _HomeTabState extends ConsumerState } } - void _downloadTrack(int index) { + void _downloadTrack(int index, {bool forceQualityPicker = false}) { final trackState = ref.read(trackProvider); if (index >= 0 && index < trackState.tracks.length) { final track = trackState.tracks[index]; final settings = ref.read(settingsProvider); - if (settings.askQualityBeforeDownload) { + if (settings.askQualityBeforeDownload || forceQualityPicker) { DownloadServicePicker.show( context, trackName: track.name, @@ -1062,7 +1062,8 @@ class _HomeTabState extends ConsumerState if (!mounted) return; - if (settings.askQualityBeforeDownload) { + if (settings.askQualityBeforeDownload || + settings.allowQualityVariants) { DownloadServicePicker.show( this.context, trackName: l10n.csvImportTracks(tracksToQueue.length), @@ -2171,7 +2172,7 @@ class _HomeTabState extends ConsumerState source: _providerIdForExploreItem(item), ); - if (settings.askQualityBeforeDownload) { + if (settings.askQualityBeforeDownload || settings.allowQualityVariants) { DownloadServicePicker.show( context, trackName: track.name, @@ -3019,7 +3020,10 @@ class _HomeTabState extends ConsumerState track: sortedTracks[index], index: sortedTrackIndexes[index], showDivider: showDivider, - onDownload: () => _downloadTrack(sortedTrackIndexes[index]), + onDownload: ({bool forceQualityPicker = false}) => _downloadTrack( + sortedTrackIndexes[index], + forceQualityPicker: forceQualityPicker, + ), searchExtensionId: searchExtensionId, showLocalLibraryIndicator: showLocalLibraryIndicator, thumbnailSizesByExtensionId: thumbnailSizesByExtensionId, diff --git a/lib/screens/home_tab_widgets.dart b/lib/screens/home_tab_widgets.dart index 23e2afb0..bb65b37e 100644 --- a/lib/screens/home_tab_widgets.dart +++ b/lib/screens/home_tab_widgets.dart @@ -206,7 +206,7 @@ class _TrackItemWithStatus extends ConsumerWidget { final Track track; final int index; final bool showDivider; - final VoidCallback onDownload; + final void Function({bool forceQualityPicker}) onDownload; final String? searchExtensionId; final bool showLocalLibraryIndicator; final Map thumbnailSizesByExtensionId; @@ -376,7 +376,11 @@ class _TrackItemWithStatus extends ConsumerWidget { }) async { if (isQueued) return; - if (isInLocalLibrary) { + final settings = ref.read(settingsProvider); + final allowExistingDownload = + settings.allowQualityVariants || !settings.deduplicateDownloads; + + if (!allowExistingDownload && isInLocalLibrary) { if (context.mounted) { ScaffoldMessenger.of(context).showSnackBar( SnackBar( @@ -387,27 +391,34 @@ class _TrackItemWithStatus extends ConsumerWidget { return; } - final historyNotifier = ref.read(downloadHistoryProvider.notifier); - final historyItem = await historyNotifier.findExistingTrackAsync( - historyLookupForTrack(track), - ); - if (historyItem != null) { - final exists = await fileExists(historyItem.filePath); - if (exists) { - if (context.mounted) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text(context.l10n.snackbarAlreadyDownloaded(track.name)), - ), - ); + if (!allowExistingDownload) { + final historyNotifier = ref.read(downloadHistoryProvider.notifier); + final historyItem = await historyNotifier.findExistingTrackAsync( + historyLookupForTrack(track), + ); + if (historyItem != null) { + final exists = await fileExists(historyItem.filePath); + if (exists) { + if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + context.l10n.snackbarAlreadyDownloaded(track.name), + ), + ), + ); + } + return; + } else { + historyNotifier.removeFromHistory(historyItem.id); } - return; - } else { - historyNotifier.removeFromHistory(historyItem.id); } } - onDownload(); + onDownload( + forceQualityPicker: + settings.allowQualityVariants && (isInHistory || isInLocalLibrary), + ); } } diff --git a/lib/screens/queue_tab_selection.dart b/lib/screens/queue_tab_selection.dart index 7f26f79f..d1b943e2 100644 --- a/lib/screens/queue_tab_selection.dart +++ b/lib/screens/queue_tab_selection.dart @@ -272,7 +272,7 @@ extension _QueueTabSelectionActions on _QueueTabState { } } - if (settings.askQualityBeforeDownload) { + if (settings.askQualityBeforeDownload || settings.allowQualityVariants) { DownloadServicePicker.show( context, trackName: context.l10n.tracksCount(totalTracks), diff --git a/lib/screens/settings/metadata_settings_page.dart b/lib/screens/settings/metadata_settings_page.dart index 637f8c4f..4caa2aa4 100644 --- a/lib/screens/settings/metadata_settings_page.dart +++ b/lib/screens/settings/metadata_settings_page.dart @@ -46,8 +46,11 @@ class MetadataSettingsPage extends ConsumerWidget { context, settings.artistTagMode, ), - onTap: () => - _showArtistTagModePicker(context, ref, settings.artistTagMode), + onTap: () => _showArtistTagModePicker( + context, + ref, + settings.artistTagMode, + ), ), SettingsSwitchItem( icon: Icons.image, @@ -111,12 +114,25 @@ class MetadataSettingsPage extends ConsumerWidget { icon: Icons.filter_list_outlined, title: context.l10n.downloadDeduplication, subtitle: settings.deduplicateDownloads - ? context.l10n.downloadDeduplicationEnabled + ? settings.allowQualityVariants + ? context + .l10n + .downloadDeduplicationWithQualityVariants + : context.l10n.downloadDeduplicationEnabled : context.l10n.downloadDeduplicationDisabled, value: settings.deduplicateDownloads, onChanged: (value) => ref .read(settingsProvider.notifier) .setDeduplicateDownloads(value), + ), + SettingsSwitchItem( + icon: Icons.library_music_outlined, + title: context.l10n.downloadQualityVariants, + subtitle: context.l10n.downloadQualityVariantsDescription, + value: settings.allowQualityVariants, + onChanged: (value) => ref + .read(settingsProvider.notifier) + .setAllowQualityVariants(value), showDivider: false, ), ], @@ -161,16 +177,18 @@ class MetadataSettingsPage extends ConsumerWidget { padding: const EdgeInsets.fromLTRB(24, 24, 24, 8), child: Text( context.l10n.optionsArtistTagMode, - style: Theme.of(context).textTheme.titleLarge - ?.copyWith(fontWeight: FontWeight.bold), + style: Theme.of( + context, + ).textTheme.titleLarge?.copyWith(fontWeight: FontWeight.bold), ), ), Padding( padding: const EdgeInsets.fromLTRB(24, 0, 24, 16), child: Text( context.l10n.optionsArtistTagModeDescription, - style: Theme.of(context).textTheme.bodyMedium - ?.copyWith(color: colorScheme.onSurfaceVariant), + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + color: colorScheme.onSurfaceVariant, + ), ), ), ListTile( @@ -190,7 +208,9 @@ class MetadataSettingsPage extends ConsumerWidget { ListTile( leading: const Icon(Icons.library_music_outlined), title: Text(context.l10n.optionsArtistTagModeSplitVorbis), - subtitle: Text(context.l10n.optionsArtistTagModeSplitVorbisSubtitle), + subtitle: Text( + context.l10n.optionsArtistTagModeSplitVorbisSubtitle, + ), trailing: currentMode == artistTagModeSplitVorbis ? const Icon(Icons.check) : null, diff --git a/lib/services/download_request_payload.dart b/lib/services/download_request_payload.dart index 5f4eb38a..d17e88d6 100644 --- a/lib/services/download_request_payload.dart +++ b/lib/services/download_request_payload.dart @@ -48,6 +48,7 @@ class DownloadRequestPayload { final bool stageSafOutput; final bool deferSafPublish; final bool requiresContainerConversion; + final bool allowQualityVariant; final String songLinkRegion; const DownloadRequestPayload({ @@ -98,6 +99,7 @@ class DownloadRequestPayload { this.stageSafOutput = false, this.deferSafPublish = false, this.requiresContainerConversion = false, + this.allowQualityVariant = false, this.songLinkRegion = 'US', }); @@ -150,6 +152,7 @@ class DownloadRequestPayload { 'stage_saf_output': stageSafOutput, 'defer_saf_publish': deferSafPublish, 'requires_container_conversion': requiresContainerConversion, + 'allow_quality_variant': allowQualityVariant, 'songlink_region': songLinkRegion, }; } @@ -206,6 +209,7 @@ class DownloadRequestPayload { stageSafOutput: stageSafOutput, deferSafPublish: deferSafPublish, requiresContainerConversion: requiresContainerConversion, + allowQualityVariant: allowQualityVariant, songLinkRegion: songLinkRegion, ); } diff --git a/lib/widgets/track_collection_quick_actions.dart b/lib/widgets/track_collection_quick_actions.dart index c833bd0d..944715fb 100644 --- a/lib/widgets/track_collection_quick_actions.dart +++ b/lib/widgets/track_collection_quick_actions.dart @@ -4,8 +4,10 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:spotiflac_android/l10n/l10n.dart'; import 'package:spotiflac_android/models/track.dart'; import 'package:spotiflac_android/providers/library_collections_provider.dart'; +import 'package:spotiflac_android/providers/settings_provider.dart'; import 'package:spotiflac_android/services/cover_cache_manager.dart'; import 'package:spotiflac_android/widgets/playlist_picker_sheet.dart'; +import 'package:spotiflac_android/widgets/track_detail_actions.dart'; import 'package:spotiflac_android/utils/clickable_metadata.dart'; class TrackCollectionQuickActions extends ConsumerWidget { @@ -64,6 +66,9 @@ class _TrackOptionsSheet extends ConsumerWidget { final isInWishlist = ref.watch( libraryCollectionsProvider.select((state) => state.isInWishlist(track)), ); + final allowQualityVariants = ref.watch( + settingsProvider.select((settings) => settings.allowQualityVariants), + ); return SafeArea( child: ConstrainedBox( @@ -163,6 +168,25 @@ class _TrackOptionsSheet extends ConsumerWidget { color: colorScheme.outlineVariant.withValues(alpha: 0.5), ), + if (allowQualityVariants) + _OptionTile( + icon: Icons.download_outlined, + title: context.l10n.trackOptionDownloadQualityVariant, + onTap: () { + final rootContext = Navigator.of( + context, + rootNavigator: true, + ).context; + Navigator.pop(context); + downloadSingleTrack( + rootContext, + ref, + track, + forceQualityPicker: true, + ); + }, + ), + _OptionTile( icon: isLoved ? Icons.favorite : Icons.favorite_border, iconColor: isLoved ? colorScheme.error : null, diff --git a/lib/widgets/track_detail_actions.dart b/lib/widgets/track_detail_actions.dart index 3be26bdd..3b2c1351 100644 --- a/lib/widgets/track_detail_actions.dart +++ b/lib/widgets/track_detail_actions.dart @@ -19,6 +19,7 @@ void downloadSingleTrack( String? recommendedService, String? playlistName, int? playlistPosition, + bool forceQualityPicker = false, }) { final settings = ref.read(settingsProvider); @@ -29,7 +30,7 @@ void downloadSingleTrack( ); } - if (settings.askQualityBeforeDownload) { + if (settings.askQualityBeforeDownload || forceQualityPicker) { DownloadServicePicker.show( context, trackName: track.name, @@ -115,9 +116,7 @@ void showQueuedSnackbar(BuildContext context, int added, int skipped) { final message = skipped > 0 ? context.l10n.discographySkippedDownloaded(added, skipped) : context.l10n.snackbarAddedTracksToQueue(added); - ScaffoldMessenger.of( - context, - ).showSnackBar(SnackBar(content: Text(message))); + ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(message))); } /// Shared batch "add to queue" flow for detail screens: skips tracks already @@ -139,18 +138,24 @@ Future queueTracksSkippingDownloaded( }) async { if (tracks.isEmpty) return; + final settings = ref.read(settingsProvider); + final skipExisting = + settings.deduplicateDownloads && !settings.allowQualityVariants; final historyLookups = tracks .map(historyLookupForTrack) .toList(growable: false); - final existingHistoryKeys = await ref.read( - downloadHistoryBatchExistsProvider( - HistoryBatchLookupRequest(historyLookups), - ).future, - ); + final existingHistoryKeys = skipExisting + ? await ref.read( + downloadHistoryBatchExistsProvider( + HistoryBatchLookupRequest(historyLookups), + ).future, + ) + : const {}; if (!context.mounted) return; - final settings = ref.read(settingsProvider); final localLibState = - (settings.localLibraryEnabled && settings.localLibraryShowDuplicates) + (skipExisting && + settings.localLibraryEnabled && + settings.localLibraryShowDuplicates) ? ref.read(localLibraryProvider) : null; final tracksToQueue = []; @@ -158,9 +163,9 @@ Future queueTracksSkippingDownloaded( for (var i = 0; i < tracks.length; i++) { final track = tracks[i]; - final isInHistory = existingHistoryKeys.contains( - historyLookups[i].lookupKey, - ); + final isInHistory = + skipExisting && + existingHistoryKeys.contains(historyLookups[i].lookupKey); final isInLocal = localLibState?.existsInLibrary( isrc: track.isrc, @@ -187,7 +192,7 @@ Future queueTracksSkippingDownloaded( return; } - if (settings.askQualityBeforeDownload) { + if (settings.askQualityBeforeDownload || settings.allowQualityVariants) { DownloadServicePicker.show( context, trackName: '${tracksToQueue.length} tracks', diff --git a/test/models_and_utils_test.dart b/test/models_and_utils_test.dart index da8e27a1..729a6eb4 100644 --- a/test/models_and_utils_test.dart +++ b/test/models_and_utils_test.dart @@ -152,6 +152,7 @@ void main() { bytesTotal: 1024, qualityOverride: 'HI_RES', playlistName: 'Favorites', + preserveQualityVariant: true, ); expect(item.status, DownloadStatus.queued); @@ -165,6 +166,7 @@ void main() { expect(updated.bytesTotal, 1024); expect(updated.qualityOverride, 'HI_RES'); expect(updated.playlistName, 'Favorites'); + expect(updated.preserveQualityVariant, isTrue); }); test('maps typed errors to user-facing messages', () { @@ -216,6 +218,7 @@ void main() { expect(item.errorType, DownloadErrorType.network); expect(item.progress, 0); expect(item.bytesReceived, 0); + expect(item.preserveQualityVariant, isFalse); expect(item.toJson()['status'], 'failed'); expect(item.toJson()['errorType'], 'network'); }); @@ -274,6 +277,7 @@ void main() { expect(settings.lyricsProviders, ['lrclib', 'apple_music']); expect(settings.lyricsAppleElrcWordSync, isFalse); expect(settings.deduplicateDownloads, isTrue); + expect(settings.allowQualityVariants, isFalse); }); test('copyWith updates values and can clear nullable provider fields', () { @@ -289,6 +293,7 @@ void main() { lyricsProviders: ['apple_music'], lyricsAppleElrcWordSync: true, deduplicateDownloads: false, + allowQualityVariants: true, clearDownloadFallbackExtensionIds: true, clearSearchProvider: true, clearHomeFeedProvider: true, @@ -299,6 +304,7 @@ void main() { expect(updated.lyricsProviders, ['apple_music']); expect(updated.lyricsAppleElrcWordSync, isTrue); expect(updated.deduplicateDownloads, isFalse); + expect(updated.allowQualityVariants, isTrue); expect(updated.downloadFallbackExtensionIds, isNull); expect(updated.searchProvider, isNull); expect(updated.homeFeedProvider, isNull); @@ -323,6 +329,7 @@ void main() { lyricsAppleElrcWordSync: true, lastSeenVersion: '4.5.0', deduplicateDownloads: false, + allowQualityVariants: true, nativeDownloadWorkerEnabled: true, ); @@ -344,6 +351,7 @@ void main() { expect(decoded.lyricsAppleElrcWordSync, isTrue); expect(decoded.lastSeenVersion, '4.5.0'); expect(decoded.deduplicateDownloads, isFalse); + expect(decoded.allowQualityVariants, isTrue); expect(decoded.nativeDownloadWorkerEnabled, isTrue); }); }); @@ -418,6 +426,7 @@ void main() { safFileName: 'Song.flac', safOutputExt: 'flac', outputExt: '.flac', + allowQualityVariant: true, songLinkRegion: 'ID', ); @@ -469,6 +478,7 @@ void main() { 'stage_saf_output': false, 'defer_saf_publish': false, 'requires_container_conversion': false, + 'allow_quality_variant': true, 'songlink_region': 'ID', }); }); @@ -490,6 +500,7 @@ void main() { expect(updated.useFallback, isTrue); expect(updated.trackName, payload.trackName); expect(updated.filenameFormat, payload.filenameFormat); + expect(updated.allowQualityVariant, payload.allowQualityVariant); }); });