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);