feat(download): preserve quality variants

This commit is contained in:
zarzet
2026-07-16 08:45:01 +07:00
parent 491998e5c5
commit 0961c09a6d
41 changed files with 545 additions and 75 deletions
@@ -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()
+1
View File
@@ -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"`
}
+1
View File
@@ -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)
+1
View File
@@ -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 {
+30
View File
@@ -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",
+24
View File
@@ -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:
+14
View File
@@ -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';
+14
View File
@@ -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';
+14
View File
@@ -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';
+14
View File
@@ -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';
+14
View File
@@ -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';
+14
View File
@@ -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';
+14
View File
@@ -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';
+14
View File
@@ -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';
+14
View File
@@ -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 => '대체 확장 프로그램';
+14
View File
@@ -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';
+14
View File
@@ -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';
+14
View File
@@ -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';
+14
View File
@@ -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';
+14
View File
@@ -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';
+14
View File
@@ -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';
+16
View File
@@ -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"
+16
View File
@@ -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"
}
}
+5
View File
@@ -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,
);
}
+2
View File
@@ -25,6 +25,7 @@ DownloadItem _$DownloadItemFromJson(Map<String, dynamic> 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<String, dynamic> _$DownloadItemToJson(DownloadItem instance) =>
@@ -45,6 +46,7 @@ Map<String, dynamic> _$DownloadItemToJson(DownloadItem instance) =>
'playlistName': instance.playlistName,
'playlistPosition': instance.playlistPosition,
'fromBatch': instance.fromBatch,
'preserveQualityVariant': instance.preserveQualityVariant,
};
const _$DownloadStatusEnumMap = {
+4
View File
@@ -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,
);
+2
View File
@@ -87,6 +87,7 @@ AppSettings _$AppSettingsFromJson(Map<String, dynamic> 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<String, dynamic> _$AppSettingsToJson(
'musixmatchLanguage': instance.musixmatchLanguage,
'lastSeenVersion': instance.lastSeenVersion,
'deduplicateDownloads': instance.deduplicateDownloads,
'allowQualityVariants': instance.allowQualityVariants,
'saveDownloadHistory': instance.saveDownloadHistory,
'playerMode': instance.playerMode,
};
+44 -7
View File
@@ -1028,17 +1028,35 @@ class DownloadHistoryNotifier extends Notifier<DownloadHistoryState> {
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<DownloadHistoryState> {
);
}
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(),
@@ -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<DownloadQueueState> {
item.service,
outputExt,
),
allowQualityVariant: item.preserveQualityVariant,
songLinkRegion: settings.songLinkRegion,
);
}
@@ -1221,6 +1226,7 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
qualityOverride: qualityOverride,
playlistName: playlistName,
playlistPosition: playlistPosition,
preserveQualityVariant: settings.allowQualityVariants,
);
state = state.copyWith(items: [...state.items, item]);
@@ -1269,6 +1275,7 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
explicitPosition ??
(shouldAssignPlaylistPositions ? index + 1 : null),
fromBatch: fromBatch,
preserveQualityVariant: settings.allowQualityVariants,
);
}).toList();
@@ -2541,6 +2548,7 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
effectiveFilenameFormat,
_filenameMetadataForTrack(
trackToDownload,
quality: quality,
playlistPosition: _validPlaylistPosition(item),
),
);
@@ -3831,6 +3839,7 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
label: effectiveLabel,
copyright: effectiveCopyright,
),
preserveTrackVariant: item.preserveQualityVariant,
);
}
@@ -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<String, dynamic>.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,
);
}
@@ -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<String, dynamic> _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,
};
}
}
+5
View File
@@ -713,6 +713,11 @@ class SettingsNotifier extends Notifier<AppSettings> {
_saveSettings();
}
void setAllowQualityVariants(bool enabled) {
state = state.copyWith(allowQualityVariants: enabled);
_saveSettings();
}
void setSaveDownloadHistory(bool enabled) {
state = state.copyWith(saveDownloadHistory: enabled);
_saveSettings();
+15 -10
View File
@@ -873,7 +873,7 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen>
List<ArtistAlbum> 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<ArtistScreen>
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 <String>{};
final tracksToQueue = <Track>[];
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<ArtistScreen>
);
}
if (settings.askQualityBeforeDownload) {
if (settings.askQualityBeforeDownload || settings.allowQualityVariants) {
DownloadServicePicker.show(
context,
recommendedService: _recommendedDownloadService(),
+9 -5
View File
@@ -825,13 +825,13 @@ class _HomeTabState extends ConsumerState<HomeTab>
}
}
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<HomeTab>
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<HomeTab>
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<HomeTab>
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,
+30 -19
View File
@@ -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<String, (double, double)> 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),
);
}
}
+1 -1
View File
@@ -272,7 +272,7 @@ extension _QueueTabSelectionActions on _QueueTabState {
}
}
if (settings.askQualityBeforeDownload) {
if (settings.askQualityBeforeDownload || settings.allowQualityVariants) {
DownloadServicePicker.show(
context,
trackName: context.l10n.tracksCount(totalTracks),
@@ -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,
@@ -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,
);
}
@@ -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,
+20 -15
View File
@@ -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<void> 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 <String>{};
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 = <Track>[];
@@ -158,9 +163,9 @@ Future<void> 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<void> queueTracksSkippingDownloaded(
return;
}
if (settings.askQualityBeforeDownload) {
if (settings.askQualityBeforeDownload || settings.allowQualityVariants) {
DownloadServicePicker.show(
context,
trackName: '${tracksToQueue.length} tracks',
+11
View File
@@ -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);
});
});