fix(playlists): preserve repeated tracks during imports

This commit is contained in:
zarzet committed 2026-10-10 02:16:22 +07:00
1 parent 829aeb156e
commit edf575e8fc
7 files changed
+125 -13

No files matched your search

+23 -3
View File
@@ -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) {
+1
View File
@@ -558,6 +558,7 @@ class _PlaylistScreenState extends ConsumerState<PlaylistScreen>
ref,
_tracks,
playlistNamePrefill: widget.playlistName,
preserveOccurrences: true,
),
);
}
+21 -3
View File
@@ -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 {
+18 -3
View File
@@ -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)',
+3 -2
View File
@@ -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;