mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-10-18 13:41:34 +02:00
fix(playlists): preserve repeated tracks during imports
This commit is contained in:
1 parent
829aeb156e
commit
edf575e8fc
7 files changed
+125
-13
No files matched your search
@@ -23,6 +23,22 @@ String trackCollectionKey(Track track) => libraryTrackCollectionKey(
|
||||
isrc: track.isrc,
|
||||
);
|
||||
|
||||
/// Stable entry keys preserve repeated positions when importing a playlist.
|
||||
/// The first occurrence keeps the existing membership key, so re-importing
|
||||
/// fills missing occurrences without appending the entire playlist again.
|
||||
Iterable<String> playlistImportKeys(Iterable<Track> tracks) sync* {
|
||||
final occurrences = <String, int>{};
|
||||
for (final track in tracks) {
|
||||
final key = trackCollectionKey(track);
|
||||
final count = occurrences.update(
|
||||
key,
|
||||
(value) => value + 1,
|
||||
ifAbsent: () => 1,
|
||||
);
|
||||
yield count == 1 ? key : 'occurrence:$count:$key';
|
||||
}
|
||||
}
|
||||
|
||||
String collectionArtistId(String value) {
|
||||
final colonIndex = value.indexOf(':');
|
||||
if (colonIndex <= 0 || colonIndex == value.length - 1) {
|
||||
@@ -255,10 +271,14 @@ class PlaylistPickerSummaryRequest {
|
||||
|
||||
PlaylistPickerSummaryRequest._(this.trackKeys);
|
||||
|
||||
factory PlaylistPickerSummaryRequest.fromTracks(Iterable<Track> tracks) {
|
||||
factory PlaylistPickerSummaryRequest.fromTracks(
|
||||
Iterable<Track> tracks, {
|
||||
bool preserveOccurrences = false,
|
||||
}) {
|
||||
final keys =
|
||||
tracks
|
||||
.map(trackCollectionKey)
|
||||
(preserveOccurrences
|
||||
? playlistImportKeys(tracks)
|
||||
: tracks.map(trackCollectionKey))
|
||||
.where((key) => key.trim().isNotEmpty)
|
||||
.toSet()
|
||||
.toList(growable: false)
|
||||
|
||||
@@ -519,8 +519,9 @@ class LibraryCollectionsNotifier extends Notifier<LibraryCollectionsState> {
|
||||
|
||||
Future<PlaylistAddBatchResult> addTracksToPlaylist(
|
||||
String playlistId,
|
||||
Iterable<Track> tracks,
|
||||
) => _mutate(() async {
|
||||
Iterable<Track> tracks, {
|
||||
bool preserveOccurrences = false,
|
||||
}) => _mutate(() async {
|
||||
await ensurePlaylistLoaded(playlistId);
|
||||
if (!ref.mounted) {
|
||||
return const PlaylistAddBatchResult(
|
||||
@@ -541,6 +542,7 @@ class LibraryCollectionsNotifier extends Notifier<LibraryCollectionsState> {
|
||||
tracks: tracks,
|
||||
existingKeys: playlist.trackKeys,
|
||||
addedAt: now,
|
||||
preserveOccurrences: preserveOccurrences,
|
||||
);
|
||||
try {
|
||||
if (!ref.mounted || prepared.entries.isEmpty) {
|
||||
|
||||
@@ -558,6 +558,7 @@ class _PlaylistScreenState extends ConsumerState<PlaylistScreen>
|
||||
ref,
|
||||
_tracks,
|
||||
playlistNamePrefill: widget.playlistName,
|
||||
preserveOccurrences: true,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -44,6 +44,7 @@ Future<PreparedCollectionTrackBatch> prepareCollectionTrackBatch({
|
||||
required Iterable<Track> tracks,
|
||||
required Set<String> existingKeys,
|
||||
required DateTime addedAt,
|
||||
bool preserveOccurrences = false,
|
||||
bool? useNative,
|
||||
Directory? temporaryDirectory,
|
||||
}) async {
|
||||
@@ -58,10 +59,22 @@ Future<PreparedCollectionTrackBatch> prepareCollectionTrackBatch({
|
||||
}
|
||||
try {
|
||||
if (snapshot.length < _workerThreshold) {
|
||||
return _prepare(snapshot, existingKeys, addedAt, inputDirectory);
|
||||
return _prepare(
|
||||
snapshot,
|
||||
existingKeys,
|
||||
addedAt,
|
||||
inputDirectory,
|
||||
preserveOccurrences,
|
||||
);
|
||||
}
|
||||
return await Isolate.run(
|
||||
() => _prepare(snapshot, existingKeys, addedAt, inputDirectory),
|
||||
() => _prepare(
|
||||
snapshot,
|
||||
existingKeys,
|
||||
addedAt,
|
||||
inputDirectory,
|
||||
preserveOccurrences,
|
||||
),
|
||||
);
|
||||
} catch (_) {
|
||||
if (inputDirectory != null) await _removeDirectory(inputDirectory);
|
||||
@@ -74,12 +87,17 @@ PreparedCollectionTrackBatch _prepare(
|
||||
Set<String> existingKeys,
|
||||
DateTime addedAt,
|
||||
String? inputDirectory,
|
||||
bool preserveOccurrences,
|
||||
) {
|
||||
final keys = <String>{};
|
||||
final entries = <CollectionTrackEntry>[];
|
||||
var duplicates = 0;
|
||||
final importKeys = preserveOccurrences
|
||||
? playlistImportKeys(tracks).iterator
|
||||
: null;
|
||||
for (final track in tracks) {
|
||||
final key = trackCollectionKey(track);
|
||||
importKeys?.moveNext();
|
||||
final key = importKeys?.current ?? trackCollectionKey(track);
|
||||
if (existingKeys.contains(key) || !keys.add(key)) {
|
||||
duplicates++;
|
||||
} else {
|
||||
|
||||
@@ -29,6 +29,7 @@ Future<void> showAddTracksToPlaylistSheet(
|
||||
WidgetRef ref,
|
||||
List<Track> tracks, {
|
||||
String? playlistNamePrefill,
|
||||
bool preserveOccurrences = false,
|
||||
}) async {
|
||||
if (tracks.isEmpty) return;
|
||||
|
||||
@@ -44,6 +45,7 @@ Future<void> showAddTracksToPlaylistSheet(
|
||||
final content = _PlaylistPickerSheetContent(
|
||||
tracks: tracks,
|
||||
playlistNamePrefill: playlistNamePrefill,
|
||||
preserveOccurrences: preserveOccurrences,
|
||||
);
|
||||
return sheetContext.isMornye
|
||||
? MornyeGlassPanel(tintOpacity: 0.78, child: content)
|
||||
@@ -55,10 +57,12 @@ Future<void> showAddTracksToPlaylistSheet(
|
||||
class _PlaylistPickerSheetContent extends ConsumerStatefulWidget {
|
||||
final List<Track> tracks;
|
||||
final String? playlistNamePrefill;
|
||||
final bool preserveOccurrences;
|
||||
|
||||
const _PlaylistPickerSheetContent({
|
||||
required this.tracks,
|
||||
this.playlistNamePrefill,
|
||||
required this.preserveOccurrences,
|
||||
});
|
||||
|
||||
@override
|
||||
@@ -76,7 +80,10 @@ class _PlaylistPickerSheetContentState
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_summaryRequest = PlaylistPickerSummaryRequest.fromTracks(widget.tracks);
|
||||
_summaryRequest = PlaylistPickerSummaryRequest.fromTracks(
|
||||
widget.tracks,
|
||||
preserveOccurrences: widget.preserveOccurrences,
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _handleDone(List<PlaylistPickerSummary> playlists) async {
|
||||
@@ -96,7 +103,11 @@ class _PlaylistPickerSheetContentState
|
||||
|
||||
try {
|
||||
for (final playlistId in idsToAdd) {
|
||||
await notifier.addTracksToPlaylist(playlistId, widget.tracks);
|
||||
await notifier.addTracksToPlaylist(
|
||||
playlistId,
|
||||
widget.tracks,
|
||||
preserveOccurrences: widget.preserveOccurrences,
|
||||
);
|
||||
_committedPlaylistIds.add(playlistId);
|
||||
final playlistName = playlistNamesById[playlistId];
|
||||
if (playlistName != null && playlistName.isNotEmpty) {
|
||||
@@ -217,7 +228,11 @@ class _PlaylistPickerSheetContentState
|
||||
return;
|
||||
}
|
||||
final playlistId = await notifier.createPlaylist(name.trim());
|
||||
await notifier.addTracksToPlaylist(playlistId, widget.tracks);
|
||||
await notifier.addTracksToPlaylist(
|
||||
playlistId,
|
||||
widget.tracks,
|
||||
preserveOccurrences: widget.preserveOccurrences,
|
||||
);
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_committedPlaylistIds.add(playlistId);
|
||||
|
||||
@@ -249,6 +249,61 @@ ProviderContainer _container(_CollectionsDatabase db) {
|
||||
}
|
||||
|
||||
void main() {
|
||||
test(
|
||||
'playlist import retains all 202 positions and re-import is idempotent',
|
||||
() async {
|
||||
final db = _CollectionsDatabase();
|
||||
db.playlists['playlist'] = {
|
||||
'id': 'playlist',
|
||||
'name': 'Playlist',
|
||||
'created_at': '2026-01-01T00:00:00Z',
|
||||
'updated_at': '2026-01-01T00:00:00Z',
|
||||
};
|
||||
db.releasePlaylistRead.complete();
|
||||
db.releaseWrite.complete();
|
||||
final container = _container(db);
|
||||
final notifier = container.read(libraryCollectionsProvider.notifier);
|
||||
final tracks = [
|
||||
for (var i = 0; i < 202; i++)
|
||||
_track('$i').copyWith(isrc: 'RECORDING${i % 198}'),
|
||||
];
|
||||
final first = await notifier.addTracksToPlaylist(
|
||||
'playlist',
|
||||
tracks,
|
||||
preserveOccurrences: true,
|
||||
);
|
||||
expect(first.addedCount, 202);
|
||||
final imported = container
|
||||
.read(libraryCollectionsProvider)
|
||||
.playlists
|
||||
.single;
|
||||
expect(imported.trackCount, 202);
|
||||
expect(
|
||||
imported.tracks.map((entry) => entry.track.id),
|
||||
tracks.map((track) => track.id),
|
||||
);
|
||||
expect(db.playlistTracks['playlist'], hasLength(202));
|
||||
final request = PlaylistPickerSummaryRequest.fromTracks(
|
||||
tracks,
|
||||
preserveOccurrences: true,
|
||||
);
|
||||
expect(request.trackKeys.toSet(), imported.trackKeys);
|
||||
final second = await notifier.addTracksToPlaylist(
|
||||
'playlist',
|
||||
tracks,
|
||||
preserveOccurrences: true,
|
||||
);
|
||||
expect(second.addedCount, 0);
|
||||
expect(second.alreadyInPlaylistCount, 202);
|
||||
// Legacy playlists with the original 198 keys receive the four missing entries.
|
||||
final legacyKeys = tracks.map(trackCollectionKey).toSet();
|
||||
expect(
|
||||
request.trackKeys.where((key) => !legacyKeys.contains(key)),
|
||||
hasLength(4),
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
for (final failFirst in [false, true]) {
|
||||
test(
|
||||
'playlist bulk deletion snapshots IDs and serializes retries (fail: $failFirst)',
|
||||
|
||||
@@ -51,8 +51,9 @@ class _Collections extends LibraryCollectionsNotifier {
|
||||
@override
|
||||
Future<PlaylistAddBatchResult> addTracksToPlaylist(
|
||||
String playlistId,
|
||||
Iterable<Track> tracks,
|
||||
) async {
|
||||
Iterable<Track> tracks, {
|
||||
bool preserveOccurrences = false,
|
||||
}) async {
|
||||
additions.add(playlistId);
|
||||
if (pendingAdditions.isNotEmpty) {
|
||||
await pendingAdditions.removeAt(0).future;
|
||||
|
||||
Reference in new issue
Block a user