diff --git a/lib/models/library_collections.dart b/lib/models/library_collections.dart new file mode 100644 index 00000000..5d30b831 --- /dev/null +++ b/lib/models/library_collections.dart @@ -0,0 +1,432 @@ +import 'dart:collection'; + +import 'package:flutter/foundation.dart'; +import 'package:spotiflac_android/models/track.dart'; + +/// Shared identity for catalog, downloaded, and local playlist entries. +String libraryTrackCollectionKey({ + required String id, + String? source, + String? isrc, +}) { + final trimmedIsrc = isrc?.trim(); + if (trimmedIsrc != null && trimmedIsrc.isNotEmpty) { + return 'isrc:${trimmedIsrc.toUpperCase()}'; + } + final trimmedSource = source?.trim(); + return '${trimmedSource?.isNotEmpty == true ? trimmedSource : 'builtin'}:$id'; +} + +String trackCollectionKey(Track track) => libraryTrackCollectionKey( + id: track.id, + source: track.source, + isrc: track.isrc, +); + +String collectionArtistId(String value) { + final colonIndex = value.indexOf(':'); + if (colonIndex <= 0 || colonIndex == value.length - 1) { + return value.trim(); + } + return value.substring(colonIndex + 1).trim(); +} + +String artistCollectionKey({ + required String artistId, + required String? providerId, +}) { + final trimmedArtistId = artistId.trim(); + final trimmedProviderId = providerId?.trim(); + final source = trimmedProviderId != null && trimmedProviderId.isNotEmpty + ? trimmedProviderId.toLowerCase() + : (trimmedArtistId.contains(':') + ? trimmedArtistId.split(':').first.toLowerCase() + : 'builtin'); + return '$source:${collectionArtistId(trimmedArtistId)}'; +} + +class CollectionTrackEntry { + final String key; + final Track track; + final DateTime addedAt; + + const CollectionTrackEntry({ + required this.key, + required this.track, + required this.addedAt, + }); + + Map toJson() => { + 'key': key, + 'track': track.toJson(), + 'addedAt': addedAt.toIso8601String(), + }; + + factory CollectionTrackEntry.fromJson(Map json) { + final addedAtRaw = json['addedAt'] as String?; + return CollectionTrackEntry( + key: json['key'] as String, + track: Track.fromJson(Map.from(json['track'] as Map)), + addedAt: DateTime.tryParse(addedAtRaw ?? '') ?? DateTime.now(), + ); + } +} + +class CollectionArtistEntry { + final String key; + final String artistId; + final String? providerId; + final String name; + final String? imageUrl; + final DateTime addedAt; + + const CollectionArtistEntry({ + required this.key, + required this.artistId, + required this.providerId, + required this.name, + this.imageUrl, + required this.addedAt, + }); + + Map toJson() => { + 'key': key, + 'artistId': artistId, + 'providerId': providerId, + 'name': name, + 'imageUrl': imageUrl, + 'addedAt': addedAt.toIso8601String(), + }; + + factory CollectionArtistEntry.fromJson(Map json) { + final artistId = json['artistId'] as String; + final providerId = json['providerId'] as String?; + final addedAtRaw = json['addedAt'] as String?; + return CollectionArtistEntry( + key: + json['key'] as String? ?? + artistCollectionKey(artistId: artistId, providerId: providerId), + artistId: artistId, + providerId: providerId, + name: json['name'] as String? ?? '', + imageUrl: json['imageUrl'] as String?, + addedAt: DateTime.tryParse(addedAtRaw ?? '') ?? DateTime.now(), + ); + } +} + +class UserPlaylistCollection { + final String id; + final String name; + final String? coverImagePath; + final DateTime createdAt; + final DateTime updatedAt; + final List tracks; + final String? previewCover; + final bool tracksLoaded; + final Set _trackKeys; + + UserPlaylistCollection({ + required this.id, + required this.name, + this.coverImagePath, + required this.createdAt, + required this.updatedAt, + required this.tracks, + this.previewCover, + this.tracksLoaded = true, + Set? trackKeys, + }) : _trackKeys = trackKeys ?? tracks.map((entry) => entry.key).toSet(); + + UserPlaylistCollection copyWith({ + String? id, + String? name, + String? Function()? coverImagePath, + DateTime? createdAt, + DateTime? updatedAt, + List? tracks, + String? previewCover, + bool? tracksLoaded, + }) { + final nextTracks = tracks ?? this.tracks; + final keepTrackIndex = identical(nextTracks, this.tracks); + return UserPlaylistCollection( + id: id ?? this.id, + name: name ?? this.name, + coverImagePath: coverImagePath != null + ? coverImagePath() + : this.coverImagePath, + createdAt: createdAt ?? this.createdAt, + updatedAt: updatedAt ?? this.updatedAt, + tracks: nextTracks, + previewCover: previewCover ?? this.previewCover, + tracksLoaded: + tracksLoaded ?? + (identical(nextTracks, this.tracks) ? this.tracksLoaded : true), + trackKeys: keepTrackIndex ? _trackKeys : null, + ); + } + + bool containsTrackKey(String trackKey) { + return _trackKeys.contains(trackKey); + } + + Set get trackKeys => UnmodifiableSetView(_trackKeys); + int get trackCount => _trackKeys.length; + + Map toJson() => { + 'id': id, + 'name': name, + if (coverImagePath != null) 'coverImagePath': coverImagePath, + 'createdAt': createdAt.toIso8601String(), + 'updatedAt': updatedAt.toIso8601String(), + 'tracks': tracks.map((e) => e.toJson()).toList(), + }; + + factory UserPlaylistCollection.fromJson(Map json) { + final createdAtRaw = json['createdAt'] as String?; + final updatedAtRaw = json['updatedAt'] as String?; + final createdAt = DateTime.tryParse(createdAtRaw ?? '') ?? DateTime.now(); + final updatedAt = DateTime.tryParse(updatedAtRaw ?? '') ?? createdAt; + final tracksRaw = (json['tracks'] as List?) ?? const []; + return UserPlaylistCollection( + id: json['id'] as String, + name: json['name'] as String? ?? '', + coverImagePath: json['coverImagePath'] as String?, + createdAt: createdAt, + updatedAt: updatedAt, + tracks: tracksRaw + .whereType>() + .map( + (e) => CollectionTrackEntry.fromJson(Map.from(e)), + ) + .toList(growable: false), + ); + } +} + +/// A bounded database projection shared directly with playlist picker UI. +class PlaylistPickerSummary { + final String id; + final String name; + final String? coverImagePath; + final String? previewCover; + final DateTime createdAt; + final DateTime updatedAt; + final int trackCount; + final bool containsAllRequestedTracks; + + const PlaylistPickerSummary({ + required this.id, + required this.name, + this.coverImagePath, + this.previewCover, + required this.createdAt, + required this.updatedAt, + required this.trackCount, + required this.containsAllRequestedTracks, + }); +} + +class PlaylistPickerSummaryRequest { + final List trackKeys; + + PlaylistPickerSummaryRequest._(this.trackKeys); + + factory PlaylistPickerSummaryRequest.fromTracks(Iterable tracks) { + final keys = + tracks + .map(trackCollectionKey) + .where((key) => key.trim().isNotEmpty) + .toSet() + .toList(growable: false) + ..sort(); + return PlaylistPickerSummaryRequest._(List.unmodifiable(keys)); + } + + @override + bool operator ==(Object other) => + identical(this, other) || + other is PlaylistPickerSummaryRequest && + listEquals(trackKeys, other.trackKeys); + + @override + int get hashCode => Object.hashAll(trackKeys); +} + +class LibraryCollectionsState { + final List wishlist; + final List loved; + final List playlists; + final List favoriteArtists; + final bool isLoaded; + final Set _wishlistKeys; + final Set _lovedKeys; + final Set _favoriteArtistKeys; + final Map _playlistsById; + final Set _allPlaylistTrackKeys; + + LibraryCollectionsState({ + this.wishlist = const [], + this.loved = const [], + this.playlists = const [], + this.favoriteArtists = const [], + this.isLoaded = false, + Set? wishlistKeys, + Set? lovedKeys, + Set? favoriteArtistKeys, + Map? playlistsById, + Set? allPlaylistTrackKeys, + }) : _wishlistKeys = + wishlistKeys ?? wishlist.map((entry) => entry.key).toSet(), + _lovedKeys = lovedKeys ?? loved.map((entry) => entry.key).toSet(), + _favoriteArtistKeys = + favoriteArtistKeys ?? + favoriteArtists.map((entry) => entry.key).toSet(), + _playlistsById = + playlistsById ?? + Map.fromEntries( + playlists.map((playlist) => MapEntry(playlist.id, playlist)), + ), + _allPlaylistTrackKeys = + allPlaylistTrackKeys ?? _buildPlaylistTrackKeys(playlists); + + int get wishlistCount => wishlist.length; + int get lovedCount => loved.length; + int get playlistCount => playlists.length; + int get favoriteArtistCount => favoriteArtists.length; + + bool isInWishlist(Track track) { + final key = trackCollectionKey(track); + return _wishlistKeys.contains(key); + } + + bool isLoved(Track track) { + final key = trackCollectionKey(track); + return _lovedKeys.contains(key); + } + + bool containsWishlistKey(String trackKey) { + return _wishlistKeys.contains(trackKey); + } + + bool containsLovedKey(String trackKey) { + return _lovedKeys.contains(trackKey); + } + + bool isFavoriteArtist({ + required String artistId, + required String? providerId, + }) { + final key = artistCollectionKey(artistId: artistId, providerId: providerId); + return _favoriteArtistKeys.contains(key); + } + + bool containsFavoriteArtistKey(String artistKey) { + return _favoriteArtistKeys.contains(artistKey); + } + + UserPlaylistCollection? playlistById(String playlistId) { + return _playlistsById[playlistId]; + } + + bool isTrackInAnyPlaylist(String trackKey) { + return _allPlaylistTrackKeys.contains(trackKey); + } + + bool get hasPlaylistTracks => _allPlaylistTrackKeys.isNotEmpty; + + LibraryCollectionsState copyWith({ + List? wishlist, + List? loved, + List? playlists, + List? favoriteArtists, + bool? isLoaded, + }) { + final nextWishlist = wishlist ?? this.wishlist; + final nextLoved = loved ?? this.loved; + final nextPlaylists = playlists ?? this.playlists; + final nextFavoriteArtists = favoriteArtists ?? this.favoriteArtists; + final keepWishlistIndex = identical(nextWishlist, this.wishlist); + final keepLovedIndex = identical(nextLoved, this.loved); + final keepPlaylistIndex = identical(nextPlaylists, this.playlists); + final keepFavoriteArtistIndex = identical( + nextFavoriteArtists, + this.favoriteArtists, + ); + + return LibraryCollectionsState( + wishlist: nextWishlist, + loved: nextLoved, + playlists: nextPlaylists, + favoriteArtists: nextFavoriteArtists, + isLoaded: isLoaded ?? this.isLoaded, + wishlistKeys: keepWishlistIndex ? _wishlistKeys : null, + lovedKeys: keepLovedIndex ? _lovedKeys : null, + favoriteArtistKeys: keepFavoriteArtistIndex ? _favoriteArtistKeys : null, + playlistsById: keepPlaylistIndex ? _playlistsById : null, + allPlaylistTrackKeys: keepPlaylistIndex ? _allPlaylistTrackKeys : null, + ); + } + + Map toJson() => { + 'wishlist': wishlist.map((e) => e.toJson()).toList(), + 'loved': loved.map((e) => e.toJson()).toList(), + 'playlists': playlists.map((e) => e.toJson()).toList(), + 'favoriteArtists': favoriteArtists.map((e) => e.toJson()).toList(), + }; + + factory LibraryCollectionsState.fromJson(Map json) { + final wishlistRaw = (json['wishlist'] as List?) ?? const []; + final lovedRaw = (json['loved'] as List?) ?? const []; + final playlistsRaw = (json['playlists'] as List?) ?? const []; + final favoriteArtistsRaw = (json['favoriteArtists'] as List?) ?? const []; + + return LibraryCollectionsState( + wishlist: wishlistRaw + .whereType>() + .map( + (e) => CollectionTrackEntry.fromJson(Map.from(e)), + ) + .toList(growable: false), + loved: lovedRaw + .whereType>() + .map( + (e) => CollectionTrackEntry.fromJson(Map.from(e)), + ) + .toList(growable: false), + playlists: playlistsRaw + .whereType>() + .map( + (e) => + UserPlaylistCollection.fromJson(Map.from(e)), + ) + .toList(growable: false), + favoriteArtists: favoriteArtistsRaw + .whereType>() + .map( + (e) => CollectionArtistEntry.fromJson(Map.from(e)), + ) + .toList(growable: false), + isLoaded: true, + ); + } +} + +Set _buildPlaylistTrackKeys(List playlists) { + final keys = {}; + for (final playlist in playlists) { + keys.addAll(playlist._trackKeys); + } + return keys; +} + +class PlaylistAddBatchResult { + final int addedCount; + final int alreadyInPlaylistCount; + + const PlaylistAddBatchResult({ + required this.addedCount, + required this.alreadyInPlaylistCount, + }); +} diff --git a/lib/models/unified_library_item.dart b/lib/models/unified_library_item.dart index 14a3d73f..ad37a49a 100644 --- a/lib/models/unified_library_item.dart +++ b/lib/models/unified_library_item.dart @@ -1,6 +1,7 @@ +import 'package:spotiflac_android/models/library_collections.dart'; import 'package:spotiflac_android/models/track.dart'; -import 'package:spotiflac_android/providers/download_history_provider.dart'; -import 'package:spotiflac_android/services/library_database.dart'; +import 'package:spotiflac_android/models/download_history.dart'; +import 'package:spotiflac_android/services/library_database_models.dart'; import 'package:spotiflac_android/utils/audio_quality_badge_policy.dart'; import 'package:spotiflac_android/utils/string_utils.dart'; @@ -155,23 +156,23 @@ class UnifiedLibraryItem { '${albumName.toLowerCase()}|${artistName.toLowerCase()}'; /// Returns the collection key used to match this item against playlist - /// entries. Uses the same logic as [trackCollectionKey] from the collections - /// provider: prefer ISRC, fall back to source:id. + /// entries: prefer ISRC, fall back to source:id. String get collectionKey { if (historyItem != null) { - final isrc = historyItem!.isrc?.trim(); - if (isrc != null && isrc.isNotEmpty) return 'isrc:${isrc.toUpperCase()}'; - final source = historyItem!.service.trim().isNotEmpty - ? historyItem!.service.trim() - : 'builtin'; - return '$source:${historyItem!.id}'; + return libraryTrackCollectionKey( + id: historyItem!.id, + source: historyItem!.service, + isrc: historyItem!.isrc, + ); } if (localItem != null) { - final isrc = localItem!.isrc?.trim(); - if (isrc != null && isrc.isNotEmpty) return 'isrc:${isrc.toUpperCase()}'; - return 'local:${localItem!.id}'; + return libraryTrackCollectionKey( + id: localItem!.id, + source: 'local', + isrc: localItem!.isrc, + ); } - return 'builtin:$id'; + return libraryTrackCollectionKey(id: id); } Track toTrack() { diff --git a/lib/providers/library_collections_provider.dart b/lib/providers/library_collections_provider.dart index e0daceb8..f38f11f3 100644 --- a/lib/providers/library_collections_provider.dart +++ b/lib/providers/library_collections_provider.dart @@ -1,440 +1,49 @@ import 'dart:convert'; import 'dart:io'; -import 'package:flutter/foundation.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:path/path.dart' as p; import 'package:path_provider/path_provider.dart'; +import 'package:spotiflac_android/models/library_collections.dart'; import 'package:spotiflac_android/models/track.dart'; import 'package:spotiflac_android/services/ffmpeg_service.dart'; import 'package:spotiflac_android/services/library_collections_database.dart'; +import 'package:spotiflac_android/utils/logger.dart'; + +export 'package:spotiflac_android/models/library_collections.dart'; const _playlistCoverMaxDimension = 1024; const _playlistCoverMaxStoredBytes = 2 * 1024 * 1024; - -String trackCollectionKey(Track track) { - final isrc = track.isrc?.trim(); - if (isrc != null && isrc.isNotEmpty) { - return 'isrc:${isrc.toUpperCase()}'; - } - final source = (track.source?.trim().isNotEmpty ?? false) - ? track.source!.trim() - : 'builtin'; - return '$source:${track.id}'; -} - -String _stripCollectionResourcePrefix(String value) { - final colonIndex = value.indexOf(':'); - if (colonIndex <= 0 || colonIndex == value.length - 1) { - return value.trim(); - } - return value.substring(colonIndex + 1).trim(); -} - -String artistCollectionKey({ - required String artistId, - required String? providerId, -}) { - final trimmedArtistId = artistId.trim(); - final trimmedProviderId = providerId?.trim(); - final source = trimmedProviderId != null && trimmedProviderId.isNotEmpty - ? trimmedProviderId.toLowerCase() - : (trimmedArtistId.contains(':') - ? trimmedArtistId.split(':').first.toLowerCase() - : 'builtin'); - return '$source:${_stripCollectionResourcePrefix(trimmedArtistId)}'; -} - -class CollectionTrackEntry { - final String key; - final Track track; - final DateTime addedAt; - - const CollectionTrackEntry({ - required this.key, - required this.track, - required this.addedAt, - }); - - Map toJson() => { - 'key': key, - 'track': track.toJson(), - 'addedAt': addedAt.toIso8601String(), - }; - - factory CollectionTrackEntry.fromJson(Map json) { - final addedAtRaw = json['addedAt'] as String?; - return CollectionTrackEntry( - key: json['key'] as String, - track: Track.fromJson(Map.from(json['track'] as Map)), - addedAt: DateTime.tryParse(addedAtRaw ?? '') ?? DateTime.now(), - ); - } -} - -class CollectionArtistEntry { - final String key; - final String artistId; - final String? providerId; - final String name; - final String? imageUrl; - final DateTime addedAt; - - const CollectionArtistEntry({ - required this.key, - required this.artistId, - required this.providerId, - required this.name, - this.imageUrl, - required this.addedAt, - }); - - Map toJson() => { - 'key': key, - 'artistId': artistId, - 'providerId': providerId, - 'name': name, - 'imageUrl': imageUrl, - 'addedAt': addedAt.toIso8601String(), - }; - - factory CollectionArtistEntry.fromJson(Map json) { - final artistId = json['artistId'] as String; - final providerId = json['providerId'] as String?; - final addedAtRaw = json['addedAt'] as String?; - return CollectionArtistEntry( - key: - json['key'] as String? ?? - artistCollectionKey(artistId: artistId, providerId: providerId), - artistId: artistId, - providerId: providerId, - name: json['name'] as String? ?? '', - imageUrl: json['imageUrl'] as String?, - addedAt: DateTime.tryParse(addedAtRaw ?? '') ?? DateTime.now(), - ); - } -} - -class UserPlaylistCollection { - final String id; - final String name; - final String? coverImagePath; - final DateTime createdAt; - final DateTime updatedAt; - final List tracks; - final String? previewCover; - final bool tracksLoaded; - final Set _trackKeys; - - UserPlaylistCollection({ - required this.id, - required this.name, - this.coverImagePath, - required this.createdAt, - required this.updatedAt, - required this.tracks, - this.previewCover, - this.tracksLoaded = true, - Set? trackKeys, - }) : _trackKeys = trackKeys ?? tracks.map((entry) => entry.key).toSet(); - - UserPlaylistCollection copyWith({ - String? id, - String? name, - String? Function()? coverImagePath, - DateTime? createdAt, - DateTime? updatedAt, - List? tracks, - String? previewCover, - bool? tracksLoaded, - }) { - final nextTracks = tracks ?? this.tracks; - final keepTrackIndex = identical(nextTracks, this.tracks); - return UserPlaylistCollection( - id: id ?? this.id, - name: name ?? this.name, - coverImagePath: coverImagePath != null - ? coverImagePath() - : this.coverImagePath, - createdAt: createdAt ?? this.createdAt, - updatedAt: updatedAt ?? this.updatedAt, - tracks: nextTracks, - previewCover: previewCover ?? this.previewCover, - tracksLoaded: - tracksLoaded ?? - (identical(nextTracks, this.tracks) ? this.tracksLoaded : true), - trackKeys: keepTrackIndex ? _trackKeys : null, - ); - } - - bool containsTrackKey(String trackKey) { - return _trackKeys.contains(trackKey); - } - - int get trackCount => _trackKeys.length; - - Map toJson() => { - 'id': id, - 'name': name, - if (coverImagePath != null) 'coverImagePath': coverImagePath, - 'createdAt': createdAt.toIso8601String(), - 'updatedAt': updatedAt.toIso8601String(), - 'tracks': tracks.map((e) => e.toJson()).toList(), - }; - - factory UserPlaylistCollection.fromJson(Map json) { - final createdAtRaw = json['createdAt'] as String?; - final updatedAtRaw = json['updatedAt'] as String?; - final createdAt = DateTime.tryParse(createdAtRaw ?? '') ?? DateTime.now(); - final updatedAt = DateTime.tryParse(updatedAtRaw ?? '') ?? createdAt; - final tracksRaw = (json['tracks'] as List?) ?? const []; - return UserPlaylistCollection( - id: json['id'] as String, - name: json['name'] as String? ?? '', - coverImagePath: json['coverImagePath'] as String?, - createdAt: createdAt, - updatedAt: updatedAt, - tracks: tracksRaw - .whereType>() - .map( - (e) => CollectionTrackEntry.fromJson(Map.from(e)), - ) - .toList(growable: false), - ); - } -} - -class PlaylistPickerSummary { - final String id; - final String name; - final String? coverImagePath; - final String? previewCover; - final DateTime createdAt; - final DateTime updatedAt; - final int trackCount; - final bool containsAllRequestedTracks; - - const PlaylistPickerSummary({ - required this.id, - required this.name, - this.coverImagePath, - this.previewCover, - required this.createdAt, - required this.updatedAt, - required this.trackCount, - required this.containsAllRequestedTracks, - }); -} - -class PlaylistPickerSummaryRequest { - final List trackKeys; - - PlaylistPickerSummaryRequest._(this.trackKeys); - - factory PlaylistPickerSummaryRequest.fromTracks(Iterable tracks) { - final keys = - tracks - .map(trackCollectionKey) - .where((key) => key.trim().isNotEmpty) - .toSet() - .toList(growable: false) - ..sort(); - return PlaylistPickerSummaryRequest._(keys); - } - - @override - bool operator ==(Object other) => - identical(this, other) || - other is PlaylistPickerSummaryRequest && - listEquals(trackKeys, other.trackKeys); - - @override - int get hashCode => Object.hashAll(trackKeys); -} - -class LibraryCollectionsState { - final List wishlist; - final List loved; - final List playlists; - final List favoriteArtists; - final bool isLoaded; - final Set _wishlistKeys; - final Set _lovedKeys; - final Set _favoriteArtistKeys; - final Map _playlistsById; - final Set _allPlaylistTrackKeys; - - LibraryCollectionsState({ - this.wishlist = const [], - this.loved = const [], - this.playlists = const [], - this.favoriteArtists = const [], - this.isLoaded = false, - Set? wishlistKeys, - Set? lovedKeys, - Set? favoriteArtistKeys, - Map? playlistsById, - Set? allPlaylistTrackKeys, - }) : _wishlistKeys = - wishlistKeys ?? wishlist.map((entry) => entry.key).toSet(), - _lovedKeys = lovedKeys ?? loved.map((entry) => entry.key).toSet(), - _favoriteArtistKeys = - favoriteArtistKeys ?? - favoriteArtists.map((entry) => entry.key).toSet(), - _playlistsById = - playlistsById ?? - Map.fromEntries( - playlists.map((playlist) => MapEntry(playlist.id, playlist)), - ), - _allPlaylistTrackKeys = - allPlaylistTrackKeys ?? _buildPlaylistTrackKeys(playlists); - - int get wishlistCount => wishlist.length; - int get lovedCount => loved.length; - int get playlistCount => playlists.length; - int get favoriteArtistCount => favoriteArtists.length; - - bool isInWishlist(Track track) { - final key = trackCollectionKey(track); - return _wishlistKeys.contains(key); - } - - bool isLoved(Track track) { - final key = trackCollectionKey(track); - return _lovedKeys.contains(key); - } - - bool containsWishlistKey(String trackKey) { - return _wishlistKeys.contains(trackKey); - } - - bool containsLovedKey(String trackKey) { - return _lovedKeys.contains(trackKey); - } - - bool isFavoriteArtist({ - required String artistId, - required String? providerId, - }) { - final key = artistCollectionKey(artistId: artistId, providerId: providerId); - return _favoriteArtistKeys.contains(key); - } - - bool containsFavoriteArtistKey(String artistKey) { - return _favoriteArtistKeys.contains(artistKey); - } - - UserPlaylistCollection? playlistById(String playlistId) { - return _playlistsById[playlistId]; - } - - bool isTrackInAnyPlaylist(String trackKey) { - return _allPlaylistTrackKeys.contains(trackKey); - } - - bool get hasPlaylistTracks => _allPlaylistTrackKeys.isNotEmpty; - - LibraryCollectionsState copyWith({ - List? wishlist, - List? loved, - List? playlists, - List? favoriteArtists, - bool? isLoaded, - }) { - final nextWishlist = wishlist ?? this.wishlist; - final nextLoved = loved ?? this.loved; - final nextPlaylists = playlists ?? this.playlists; - final nextFavoriteArtists = favoriteArtists ?? this.favoriteArtists; - final keepWishlistIndex = identical(nextWishlist, this.wishlist); - final keepLovedIndex = identical(nextLoved, this.loved); - final keepPlaylistIndex = identical(nextPlaylists, this.playlists); - final keepFavoriteArtistIndex = identical( - nextFavoriteArtists, - this.favoriteArtists, - ); - - return LibraryCollectionsState( - wishlist: nextWishlist, - loved: nextLoved, - playlists: nextPlaylists, - favoriteArtists: nextFavoriteArtists, - isLoaded: isLoaded ?? this.isLoaded, - wishlistKeys: keepWishlistIndex ? _wishlistKeys : null, - lovedKeys: keepLovedIndex ? _lovedKeys : null, - favoriteArtistKeys: keepFavoriteArtistIndex ? _favoriteArtistKeys : null, - playlistsById: keepPlaylistIndex ? _playlistsById : null, - allPlaylistTrackKeys: keepPlaylistIndex ? _allPlaylistTrackKeys : null, - ); - } - - Map toJson() => { - 'wishlist': wishlist.map((e) => e.toJson()).toList(), - 'loved': loved.map((e) => e.toJson()).toList(), - 'playlists': playlists.map((e) => e.toJson()).toList(), - 'favoriteArtists': favoriteArtists.map((e) => e.toJson()).toList(), - }; - - factory LibraryCollectionsState.fromJson(Map json) { - final wishlistRaw = (json['wishlist'] as List?) ?? const []; - final lovedRaw = (json['loved'] as List?) ?? const []; - final playlistsRaw = (json['playlists'] as List?) ?? const []; - final favoriteArtistsRaw = (json['favoriteArtists'] as List?) ?? const []; - - return LibraryCollectionsState( - wishlist: wishlistRaw - .whereType>() - .map( - (e) => CollectionTrackEntry.fromJson(Map.from(e)), - ) - .toList(growable: false), - loved: lovedRaw - .whereType>() - .map( - (e) => CollectionTrackEntry.fromJson(Map.from(e)), - ) - .toList(growable: false), - playlists: playlistsRaw - .whereType>() - .map( - (e) => - UserPlaylistCollection.fromJson(Map.from(e)), - ) - .toList(growable: false), - favoriteArtists: favoriteArtistsRaw - .whereType>() - .map( - (e) => CollectionArtistEntry.fromJson(Map.from(e)), - ) - .toList(growable: false), - isLoaded: true, - ); - } -} - -Set _buildPlaylistTrackKeys(List playlists) { - final keys = {}; - for (final playlist in playlists) { - keys.addAll(playlist._trackKeys); - } - return keys; -} - -class PlaylistAddBatchResult { - final int addedCount; - final int alreadyInPlaylistCount; - - const PlaylistAddBatchResult({ - required this.addedCount, - required this.alreadyInPlaylistCount, - }); -} +final _log = AppLogger('LibraryCollections'); class LibraryCollectionsNotifier extends Notifier { - final LibraryCollectionsDatabase _db = LibraryCollectionsDatabase.instance; + LibraryCollectionsNotifier({LibraryCollectionsDatabase? database}) + : _db = database ?? LibraryCollectionsDatabase.instance; + + final LibraryCollectionsDatabase _db; Future? _loadFuture; final Map> _playlistLoadFutures = {}; + Future _mutationTail = Future.value(); + + /// Evaluate membership against the preceding completed write, then publish + /// only after persistence succeeds. A failed write does not block the next. + Future _mutate(Future Function() mutation) { + final operation = _mutationTail.then((_) async { + await _ensureLoaded(); + if (!ref.mounted) { + throw StateError('Library collections provider was disposed'); + } + return mutation(); + }); + _mutationTail = operation.then( + (_) {}, + onError: (Object error, StackTrace stack) {}, + ); + return operation; + } void _invalidatePlaylistPickerSummaries() { + if (!ref.mounted) return; ref.invalidate(libraryPlaylistPickerSummariesProvider); } @@ -447,7 +56,9 @@ class LibraryCollectionsNotifier extends Notifier { Future _load() async { try { await _db.migrateFromSharedPreferences(); + if (!ref.mounted) return; final snapshot = await _db.loadSnapshot(); + if (!ref.mounted) return; final wishlist = []; for (final row in snapshot.wishlistRows) { @@ -525,18 +136,21 @@ class LibraryCollectionsNotifier extends Notifier { favoriteArtists: favoriteArtists, isLoaded: true, ); - } catch (_) { - state = state.copyWith(isLoaded: true); + } catch (error, stack) { + _log.e('Failed to load library collections', error, stack); + if (ref.mounted) state = state.copyWith(isLoaded: true); } } Future _ensureLoaded() async { + if (!ref.mounted) return; if (state.isLoaded) return; await (_loadFuture ?? _load()); } Future ensurePlaylistLoaded(String playlistId) async { await _ensureLoaded(); + if (!ref.mounted) return; final playlist = state.playlistById(playlistId); if (playlist == null || playlist.tracksLoaded) return; @@ -544,6 +158,7 @@ class LibraryCollectionsNotifier extends Notifier { if (pending != null) return pending; final load = () async { final rows = await _db.loadPlaylistTracks(playlistId); + if (!ref.mounted) return; final tracks = rows .map(_parseTrackEntryRow) .whereType() @@ -618,6 +233,7 @@ class LibraryCollectionsNotifier extends Notifier { String playlistId, UserPlaylistCollection Function(UserPlaylistCollection playlist) update, ) { + if (!ref.mounted) return false; final playlist = state.playlistById(playlistId); if (playlist == null) return false; @@ -647,11 +263,11 @@ class LibraryCollectionsNotifier extends Notifier { required String addedAt, }) dbUpsert, - }) async { - await _ensureLoaded(); + }) => _mutate(() async { final key = trackCollectionKey(track); if (contains(key)) { await dbDelete(key); + if (!ref.mounted) return false; state = withList( select( state, @@ -670,9 +286,9 @@ class LibraryCollectionsNotifier extends Notifier { trackJson: jsonEncode(track.toJson()), addedAt: entry.addedAt.toIso8601String(), ); - state = withList([entry, ...select(state)]); + if (ref.mounted) state = withList([entry, ...select(state)]); return true; - } + }); Future toggleWishlist(Track track) => _toggleTrackEntry( track, @@ -697,8 +313,7 @@ class LibraryCollectionsNotifier extends Notifier { required String? providerId, required String name, String? imageUrl, - }) async { - await _ensureLoaded(); + }) => _mutate(() async { final key = artistCollectionKey(artistId: artistId, providerId: providerId); final sourceSeparator = key.indexOf(':'); final source = sourceSeparator > 0 ? key.substring(0, sourceSeparator) : ''; @@ -708,13 +323,19 @@ class LibraryCollectionsNotifier extends Notifier { ? trimmedProviderId : (source.isNotEmpty && source != 'builtin' ? source : null); if (state.containsFavoriteArtistKey(key)) { - await removeFavoriteArtist(key); + await _db.deleteFavoriteArtistEntry(key); + if (!ref.mounted) return false; + state = state.copyWith( + favoriteArtists: state.favoriteArtists + .where((entry) => entry.key != key) + .toList(growable: false), + ); return false; } final entry = CollectionArtistEntry( key: key, - artistId: _stripCollectionResourcePrefix(artistId), + artistId: collectionArtistId(artistId), providerId: effectiveProviderId, name: name, imageUrl: imageUrl, @@ -725,10 +346,13 @@ class LibraryCollectionsNotifier extends Notifier { artistJson: jsonEncode(entry.toJson()), addedAt: entry.addedAt.toIso8601String(), ); - final updated = [entry, ...state.favoriteArtists]; - state = state.copyWith(favoriteArtists: updated); + if (ref.mounted) { + state = state.copyWith( + favoriteArtists: [entry, ...state.favoriteArtists], + ); + } return true; - } + }); Future _removeEntry( String key, { @@ -737,17 +361,17 @@ class LibraryCollectionsNotifier extends Notifier { required String Function(T entry) keyOf, required LibraryCollectionsState Function(List list) withList, required Future Function(String key) dbDelete, - }) async { - await _ensureLoaded(); + }) => _mutate(() async { if (!contains(key)) return; await dbDelete(key); + if (!ref.mounted) return; state = withList( select( state, ).where((entry) => keyOf(entry) != key).toList(growable: false), ); - } + }); Future removeFavoriteArtist(String artistKey) => _removeEntry( artistKey, @@ -776,8 +400,7 @@ class LibraryCollectionsNotifier extends Notifier { dbDelete: _db.deleteLovedEntry, ); - Future createPlaylist(String name) async { - await _ensureLoaded(); + Future createPlaylist(String name) => _mutate(() async { final now = DateTime.now(); final id = 'pl_${now.microsecondsSinceEpoch}'; final trimmedName = name.trim(); @@ -797,79 +420,89 @@ class LibraryCollectionsNotifier extends Notifier { createdAt: now.toIso8601String(), updatedAt: now.toIso8601String(), ); + if (!ref.mounted) return id; state = state.copyWith(playlists: [playlist, ...state.playlists]); _invalidatePlaylistPickerSummaries(); return id; - } + }); - Future renamePlaylist(String playlistId, String newName) async { - await _ensureLoaded(); - final trimmed = newName.trim(); - if (trimmed.isEmpty) return; - final playlist = state.playlistById(playlistId); - if (playlist == null || playlist.name == trimmed) return; + Future renamePlaylist(String playlistId, String newName) => + _mutate(() async { + final trimmed = newName.trim(); + if (trimmed.isEmpty) return; + final playlist = state.playlistById(playlistId); + if (playlist == null || playlist.name == trimmed) return; - final now = DateTime.now(); - await _db.renamePlaylist( - playlistId: playlistId, - name: trimmed, - updatedAt: now.toIso8601String(), - ); - _replacePlaylistById(playlistId, (playlist) { - return playlist.copyWith(name: trimmed, updatedAt: now); - }); - _invalidatePlaylistPickerSummaries(); - } + final now = DateTime.now(); + await _db.renamePlaylist( + playlistId: playlistId, + name: trimmed, + updatedAt: now.toIso8601String(), + ); + _replacePlaylistById(playlistId, (playlist) { + return playlist.copyWith(name: trimmed, updatedAt: now); + }); + _invalidatePlaylistPickerSummaries(); + }); - Future deletePlaylist(String playlistId) async { - await _ensureLoaded(); - final playlistIndex = state.playlists.indexWhere((p) => p.id == playlistId); - if (playlistIndex < 0) return; + Future deletePlaylist(String playlistId) => _mutate(() async { + if (state.playlistById(playlistId) == null) return; await _db.deletePlaylist(playlistId); - final updatedPlaylists = [...state.playlists]..removeAt(playlistIndex); - state = state.copyWith(playlists: updatedPlaylists); - _invalidatePlaylistPickerSummaries(); - } - - Future addTrackToPlaylist(String playlistId, Track track) async { - await _ensureLoaded(); - var playlist = state.playlistById(playlistId); - if (playlist == null) return false; - - final key = trackCollectionKey(track); - if (playlist.containsTrackKey(key)) return false; - await ensurePlaylistLoaded(playlistId); - playlist = state.playlistById(playlistId); - if (playlist == null) return false; - - final now = DateTime.now(); - final entry = CollectionTrackEntry(key: key, track: track, addedAt: now); - await _db.upsertPlaylistTrack( - playlistId: playlistId, - trackKey: key, - trackJson: jsonEncode(track.toJson()), - addedAt: entry.addedAt.toIso8601String(), - playlistUpdatedAt: now.toIso8601String(), + if (!ref.mounted) return; + state = state.copyWith( + playlists: state.playlists + .where((playlist) => playlist.id != playlistId) + .toList(growable: false), ); - final changed = _replacePlaylistById(playlistId, (playlist) { - if (playlist.containsTrackKey(key)) return playlist; - return playlist.copyWith( - tracks: [...playlist.tracks, entry], - updatedAt: now, - ); - }); - if (!changed) return false; _invalidatePlaylistPickerSummaries(); - return true; - } + }); + + Future addTrackToPlaylist(String playlistId, Track track) => _mutate( + () async { + var playlist = state.playlistById(playlistId); + if (playlist == null) return false; + + final key = trackCollectionKey(track); + if (playlist.containsTrackKey(key)) return false; + await ensurePlaylistLoaded(playlistId); + if (!ref.mounted) return false; + playlist = state.playlistById(playlistId); + if (playlist == null) return false; + + final now = DateTime.now(); + final entry = CollectionTrackEntry(key: key, track: track, addedAt: now); + await _db.upsertPlaylistTrack( + playlistId: playlistId, + trackKey: key, + trackJson: jsonEncode(track.toJson()), + addedAt: entry.addedAt.toIso8601String(), + playlistUpdatedAt: now.toIso8601String(), + ); + final changed = _replacePlaylistById(playlistId, (playlist) { + if (playlist.containsTrackKey(key)) return playlist; + return playlist.copyWith( + tracks: [...playlist.tracks, entry], + updatedAt: now, + ); + }); + if (!changed) return false; + _invalidatePlaylistPickerSummaries(); + return true; + }, + ); Future addTracksToPlaylist( String playlistId, Iterable tracks, - ) async { - await _ensureLoaded(); + ) => _mutate(() async { await ensurePlaylistLoaded(playlistId); + if (!ref.mounted) { + return const PlaylistAddBatchResult( + addedCount: 0, + alreadyInPlaylistCount: 0, + ); + } final playlist = state.playlistById(playlistId); if (playlist == null) { return const PlaylistAddBatchResult( @@ -879,7 +512,7 @@ class LibraryCollectionsNotifier extends Notifier { } final now = DateTime.now(); - final knownKeys = {...playlist._trackKeys}; + final knownKeys = {...playlist.trackKeys}; final entriesToAdd = []; var alreadyInPlaylistCount = 0; @@ -933,34 +566,32 @@ class LibraryCollectionsNotifier extends Notifier { addedCount: entriesToAdd.length, alreadyInPlaylistCount: alreadyInPlaylistCount, ); - } + }); - Future removeTrackFromPlaylist( - String playlistId, - String trackKey, - ) async { - await _ensureLoaded(); - var playlist = state.playlistById(playlistId); - if (playlist == null || !playlist.containsTrackKey(trackKey)) return; - await ensurePlaylistLoaded(playlistId); - playlist = state.playlistById(playlistId); - if (playlist == null) return; + Future removeTrackFromPlaylist(String playlistId, String trackKey) => + _mutate(() async { + var playlist = state.playlistById(playlistId); + if (playlist == null || !playlist.containsTrackKey(trackKey)) return; + await ensurePlaylistLoaded(playlistId); + if (!ref.mounted) return; + playlist = state.playlistById(playlistId); + if (playlist == null) return; - final now = DateTime.now(); - await _db.deletePlaylistTrack( - playlistId: playlistId, - trackKey: trackKey, - playlistUpdatedAt: now.toIso8601String(), - ); - _replacePlaylistById(playlistId, (playlist) { - final nextTracks = playlist.tracks - .where((entry) => entry.key != trackKey) - .toList(growable: false); - if (nextTracks.length == playlist.tracks.length) return playlist; - return playlist.copyWith(tracks: nextTracks, updatedAt: now); - }); - _invalidatePlaylistPickerSummaries(); - } + final now = DateTime.now(); + await _db.deletePlaylistTrack( + playlistId: playlistId, + trackKey: trackKey, + playlistUpdatedAt: now.toIso8601String(), + ); + _replacePlaylistById(playlistId, (playlist) { + final nextTracks = playlist.tracks + .where((entry) => entry.key != trackKey) + .toList(growable: false); + if (nextTracks.length == playlist.tracks.length) return playlist; + return playlist.copyWith(tracks: nextTracks, updatedAt: now); + }); + _invalidatePlaylistPickerSummaries(); + }); Future _playlistCoversDir() async { final appDir = await getApplicationSupportDirectory(); @@ -971,38 +602,38 @@ class LibraryCollectionsNotifier extends Notifier { return dir; } - Future setPlaylistCover( - String playlistId, - String sourceFilePath, - ) async { - await _ensureLoaded(); - final playlist = state.playlistById(playlistId); - if (playlist == null) return; + Future setPlaylistCover(String playlistId, String sourceFilePath) => + _mutate(() async { + final playlist = state.playlistById(playlistId); + if (playlist == null) return; - final previousCoverPath = playlist.coverImagePath; - final destPath = await _normalizePlaylistCoverFile( - playlistId, - sourceFilePath, - ); + final previousCoverPath = playlist.coverImagePath; + final destPath = await _normalizePlaylistCoverFile( + playlistId, + sourceFilePath, + ); - final now = DateTime.now(); - await _db.updatePlaylistCover( - playlistId: playlistId, - coverImagePath: destPath, - updatedAt: now.toIso8601String(), - ); - _replacePlaylistById(playlistId, (playlist) { - if (playlist.coverImagePath == destPath) return playlist; - return playlist.copyWith(coverImagePath: () => destPath, updatedAt: now); - }); - _invalidatePlaylistPickerSummaries(); - if (previousCoverPath != null && previousCoverPath != destPath) { - try { - final previous = File(previousCoverPath); - if (await previous.exists()) await previous.delete(); - } catch (_) {} - } - } + final now = DateTime.now(); + await _db.updatePlaylistCover( + playlistId: playlistId, + coverImagePath: destPath, + updatedAt: now.toIso8601String(), + ); + _replacePlaylistById(playlistId, (playlist) { + if (playlist.coverImagePath == destPath) return playlist; + return playlist.copyWith( + coverImagePath: () => destPath, + updatedAt: now, + ); + }); + _invalidatePlaylistPickerSummaries(); + if (previousCoverPath != null && previousCoverPath != destPath) { + try { + final previous = File(previousCoverPath); + if (await previous.exists()) await previous.delete(); + } catch (_) {} + } + }); Future _normalizePlaylistCoverFile( String playlistId, @@ -1070,8 +701,7 @@ class LibraryCollectionsNotifier extends Notifier { dimensions.height > _playlistCoverMaxDimension; } - Future removePlaylistCover(String playlistId) async { - await _ensureLoaded(); + Future removePlaylistCover(String playlistId) => _mutate(() async { final playlist = state.playlistById(playlistId); if (playlist == null || playlist.coverImagePath == null) return; @@ -1094,7 +724,7 @@ class LibraryCollectionsNotifier extends Notifier { return playlist.copyWith(coverImagePath: () => null, updatedAt: now); }); _invalidatePlaylistPickerSummaries(); - } + }); /// Returns the full collections snapshot (wishlist, loved, playlists, /// favorite artists) for a backup, ensuring data is loaded first. @@ -1170,7 +800,7 @@ class LibraryCollectionsNotifier extends Notifier { Future restoreFromBackup( Map collectionsJson, { Map? coverImages, - }) async { + }) => _mutate(() async { final normalized = Map.from(collectionsJson); final coversDir = await _playlistCoversDir(); @@ -1231,7 +861,7 @@ class LibraryCollectionsNotifier extends Notifier { await _db.replaceAllFromBackup(normalized); await _load(); _invalidatePlaylistPickerSummaries(); - } + }); } final libraryCollectionsProvider = @@ -1246,19 +876,5 @@ final libraryPlaylistPickerSummariesProvider = FutureProvider.autoDispose ) async { final db = LibraryCollectionsDatabase.instance; await db.migrateFromSharedPreferences(); - final rows = await db.loadPlaylistPickerSummaries(request.trackKeys); - return rows - .map( - (row) => PlaylistPickerSummary( - id: row.id, - name: row.name, - coverImagePath: row.coverImagePath, - previewCover: row.previewCover, - createdAt: row.createdAt, - updatedAt: row.updatedAt, - trackCount: row.trackCount, - containsAllRequestedTracks: row.containsAllRequestedTracks, - ), - ) - .toList(growable: false); + return db.loadPlaylistPickerSummaries(request.trackKeys); }); diff --git a/lib/services/library_collections_database.dart b/lib/services/library_collections_database.dart index 65f0f99c..861304cf 100644 --- a/lib/services/library_collections_database.dart +++ b/lib/services/library_collections_database.dart @@ -2,6 +2,7 @@ import 'dart:convert'; import 'package:shared_preferences/shared_preferences.dart'; import 'package:sqflite/sqflite.dart'; +import 'package:spotiflac_android/models/library_collections.dart'; import 'package:spotiflac_android/services/sqlite_helpers.dart' as sqlite; import 'package:spotiflac_android/utils/logger.dart'; @@ -35,28 +36,6 @@ class LibraryCollectionsSnapshot { }); } -class PlaylistPickerSummaryRow { - final String id; - final String name; - final String? coverImagePath; - final String? previewCover; - final DateTime createdAt; - final DateTime updatedAt; - final int trackCount; - final bool containsAllRequestedTracks; - - const PlaylistPickerSummaryRow({ - required this.id, - required this.name, - this.coverImagePath, - this.previewCover, - required this.createdAt, - required this.updatedAt, - required this.trackCount, - required this.containsAllRequestedTracks, - }); -} - class LibraryCollectionsDatabase { static final LibraryCollectionsDatabase instance = LibraryCollectionsDatabase._init(); @@ -308,7 +287,7 @@ class LibraryCollectionsDatabase { ); } - Future> loadPlaylistPickerSummaries( + Future> loadPlaylistPickerSummaries( List requestedTrackKeys, ) async => readPlaylistPickerSummaries(await database, requestedTrackKeys); @@ -596,7 +575,7 @@ class LibraryCollectionsDatabase { } /// Reads picker rows with bounded selection queries, preserving playlist order. -Future> readPlaylistPickerSummaries( +Future> readPlaylistPickerSummaries( DatabaseExecutor db, List requestedTrackKeys, ) async { @@ -668,7 +647,7 @@ Future> readPlaylistPickerSummaries( } } catch (_) {} } - return PlaylistPickerSummaryRow( + return PlaylistPickerSummary( id: id, name: row['name']?.toString() ?? '', coverImagePath: row['cover_image_path'] as String?, diff --git a/lib/services/weekly_releases.dart b/lib/services/weekly_releases.dart index df7d8828..fb12b2f5 100644 --- a/lib/services/weekly_releases.dart +++ b/lib/services/weekly_releases.dart @@ -1,7 +1,7 @@ import 'dart:convert'; import 'package:shared_preferences/shared_preferences.dart'; -import 'package:spotiflac_android/providers/library_collections_provider.dart'; +import 'package:spotiflac_android/models/library_collections.dart'; import 'package:spotiflac_android/utils/string_utils.dart'; class WeeklyRelease { diff --git a/test/library_collections_models_test.dart b/test/library_collections_models_test.dart new file mode 100644 index 00000000..c325f5bc --- /dev/null +++ b/test/library_collections_models_test.dart @@ -0,0 +1,109 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:spotiflac_android/models/library_collections.dart'; +import 'package:spotiflac_android/models/track.dart'; +import 'package:spotiflac_android/models/unified_library_item.dart'; +import 'package:spotiflac_android/providers/download_history_provider.dart'; +import 'package:spotiflac_android/services/library_database.dart'; + +Track _track(String id, {String? source, String? isrc}) => Track( + id: id, + name: 'Song', + artistName: 'Artist', + albumName: 'Album', + duration: 180, + source: source, + isrc: isrc, +); + +void main() { + test('catalog and library collection identity agree across sources', () { + for (final isrc in [null, '', ' ', ' us-example-1 ']) { + for (final service in ['example', ' Example ', ' ']) { + final downloaded = UnifiedLibraryItem.fromDownloadHistory( + DownloadHistoryItem( + id: 'song', + trackName: 'Song', + artistName: 'Artist', + albumName: 'Album', + filePath: '/music/song.flac', + downloadedAt: DateTime.utc(2026), + service: service, + isrc: isrc, + ), + ); + expect( + downloaded.collectionKey, + trackCollectionKey(_track('song', source: service, isrc: isrc)), + ); + } + final local = UnifiedLibraryItem.fromLocalLibrary( + LocalLibraryItem( + id: 'song', + trackName: 'Song', + artistName: 'Artist', + albumName: 'Album', + filePath: '/music/song.flac', + scannedAt: DateTime.utc(2026), + isrc: isrc, + ), + ); + expect( + local.collectionKey, + trackCollectionKey(_track('song', source: 'local', isrc: isrc)), + ); + } + expect(trackCollectionKey(_track('song')), 'builtin:song'); + expect( + trackCollectionKey(_track('song', isrc: ' us-example-1 ')), + 'isrc:US-EXAMPLE-1', + ); + expect( + trackCollectionKey(_track('song', source: 'example')), + isNot(trackCollectionKey(_track('song', source: 'local'))), + ); + }); + + test( + 'picker requests remain stable keys after deduplication and sorting', + () { + final request = PlaylistPickerSummaryRequest.fromTracks([ + _track('second', source: 'example'), + _track('first', isrc: 'shared'), + _track('third', isrc: ' SHARED '), + ]); + final equivalent = PlaylistPickerSummaryRequest.fromTracks([ + _track('other-id', isrc: 'shared'), + _track('second', source: 'example'), + ]); + expect(request, equivalent); + expect(request.hashCode, equivalent.hashCode); + final hash = request.hashCode; + expect(() => request.trackKeys[0] = 'changed', throwsUnsupportedError); + expect(request.hashCode, hash); + expect(request, equivalent); + }, + ); + + test('unloaded playlist membership survives metadata-only copies', () { + final playlist = UserPlaylistCollection( + id: 'playlist', + name: 'Playlist', + createdAt: DateTime.utc(2026), + updatedAt: DateTime.utc(2026), + tracks: const [], + tracksLoaded: false, + trackKeys: {'example:song'}, + ); + final renamed = playlist.copyWith(name: 'Renamed'); + expect(renamed.tracksLoaded, isFalse); + expect(renamed.trackCount, 1); + expect(renamed.containsTrackKey('example:song'), isTrue); + expect(() => renamed.trackKeys.clear(), throwsUnsupportedError); + final state = LibraryCollectionsState(playlists: [renamed]); + expect(state.isTrackInAnyPlaylist('example:song'), isTrue); + expect(state.copyWith(isLoaded: true).hasPlaylistTracks, isTrue); + final loaded = renamed.copyWith(tracks: []); + expect(loaded.tracksLoaded, isTrue); + expect(loaded.trackCount, 0); + }); +} diff --git a/test/library_collections_mutation_test.dart b/test/library_collections_mutation_test.dart new file mode 100644 index 00000000..df7980e7 --- /dev/null +++ b/test/library_collections_mutation_test.dart @@ -0,0 +1,262 @@ +import 'dart:async'; + +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:spotiflac_android/models/track.dart'; +import 'package:spotiflac_android/providers/library_collections_provider.dart'; +import 'package:spotiflac_android/services/library_collections_database.dart'; + +class _CollectionsDatabase implements LibraryCollectionsDatabase { + final wishlist = >{}; + final loved = >{}; + final artists = >{}; + final playlists = >{}; + final events = []; + final writeStarted = Completer(); + final releaseWrite = Completer(); + bool failNextWrite = false; + + Future _write(String event, void Function() persist) async { + events.add(event); + final fail = failNextWrite; + failNextWrite = false; + // Only the first write waits, so an unserialized later write can overtake it. + if (!writeStarted.isCompleted) { + writeStarted.complete(); + await releaseWrite.future; + } + if (fail) throw StateError('database write failed'); + persist(); + } + + @override + Future migrateFromSharedPreferences() async => false; + + @override + Future loadSnapshot() async => + LibraryCollectionsSnapshot( + wishlistRows: wishlist.values.toList(), + lovedRows: loved.values.toList(), + playlistRows: playlists.values.toList(), + playlistTrackRows: const [], + favoriteArtistRows: artists.values.toList(), + ); + + Future _upsertTrack( + Map> rows, + String collection, + String key, + String json, + String date, + ) => _write('insert:$collection:$key', () { + rows[key] = {'track_key': key, 'track_json': json, 'added_at': date}; + }); + + @override + Future upsertWishlistEntry({ + required String trackKey, + required String trackJson, + required String addedAt, + }) => _upsertTrack(wishlist, 'wishlist', trackKey, trackJson, addedAt); + + @override + Future deleteWishlistEntry(String trackKey) => + _write('delete:wishlist:$trackKey', () => wishlist.remove(trackKey)); + + @override + Future upsertLovedEntry({ + required String trackKey, + required String trackJson, + required String addedAt, + }) => _upsertTrack(loved, 'loved', trackKey, trackJson, addedAt); + + @override + Future deleteLovedEntry(String trackKey) => + _write('delete:loved:$trackKey', () => loved.remove(trackKey)); + + @override + Future upsertFavoriteArtistEntry({ + required String artistKey, + required String artistJson, + required String addedAt, + }) => _write('insert:artists:$artistKey', () { + artists[artistKey] = { + 'artist_key': artistKey, + 'artist_json': artistJson, + 'added_at': addedAt, + }; + }); + + @override + Future deleteFavoriteArtistEntry(String artistKey) => + _write('delete:artists:$artistKey', () => artists.remove(artistKey)); + + @override + Future upsertPlaylist({ + required String id, + required String name, + required String createdAt, + required String updatedAt, + String? coverImagePath, + }) => _write('insert:playlist:$id', () { + playlists[id] = { + 'id': id, + 'name': name, + 'created_at': createdAt, + 'updated_at': updatedAt, + 'cover_image_path': coverImagePath, + }; + }); + + @override + Future deletePlaylist(String playlistId) => + _write('delete:playlist:$playlistId', () => playlists.remove(playlistId)); + + @override + dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); +} + +Track _track(String id) => Track( + id: id, + name: 'Song', + artistName: 'Artist', + albumName: 'Album', + duration: 180, + source: 'example', +); + +ProviderContainer _container(_CollectionsDatabase db) { + final container = ProviderContainer( + overrides: [ + libraryCollectionsProvider.overrideWith( + () => LibraryCollectionsNotifier(database: db), + ), + ], + ); + addTearDown(container.dispose); + return container; +} + +void main() { + test( + 'late write completion does not publish or start a disposed mutation', + () async { + final db = _CollectionsDatabase(); + final container = _container(db); + final notifier = container.read(libraryCollectionsProvider.notifier); + await notifier.ensurePlaylistsLoaded(const []); + final active = notifier.toggleWishlist(_track('active')); + await db.writeStarted.future; + final queued = notifier.toggleWishlist(_track('queued')); + final cancelled = expectLater(queued, throwsStateError); + container.dispose(); + db.releaseWrite.complete(); + expect(await active, isTrue); + await cancelled; + expect(db.events, ['insert:wishlist:example:active']); + expect(db.wishlist.keys, ['example:active']); + }, + ); + + for (final kind in ['wishlist', 'loved', 'artists']) { + test('two overlapping $kind toggles persist insert then delete', () async { + final db = _CollectionsDatabase(); + final container = _container(db); + final notifier = container.read(libraryCollectionsProvider.notifier); + await notifier.ensurePlaylistsLoaded(const []); + Future toggle() => switch (kind) { + 'wishlist' => notifier.toggleWishlist(_track('song')), + 'loved' => notifier.toggleLoved(_track('song')), + _ => notifier.toggleFavoriteArtist( + artistId: 'artist', + providerId: 'example', + name: 'Artist', + ), + }; + final key = kind == 'artists' ? 'example:artist' : 'example:song'; + final first = toggle(); + await db.writeStarted.future; + final second = toggle(); + await Future.delayed(Duration.zero); + expect(db.events, ['insert:$kind:$key']); + final pending = container.read(libraryCollectionsProvider); + expect(pending.wishlist, isEmpty); + expect(pending.loved, isEmpty); + expect(pending.favoriteArtists, isEmpty); + db.releaseWrite.complete(); + expect(await Future.wait([first, second]), [true, false]); + expect(db.events, ['insert:$kind:$key', 'delete:$kind:$key']); + expect(db.wishlist, isEmpty); + expect(db.loved, isEmpty); + expect(db.artists, isEmpty); + final state = container.read(libraryCollectionsProvider); + expect(state.wishlist, isEmpty); + expect(state.loved, isEmpty); + expect(state.favoriteArtists, isEmpty); + }); + } + + test( + 'playlist deletion cannot remove a concurrently created playlist', + () async { + final db = _CollectionsDatabase(); + for (final id in ['target', 'keep']) { + db.playlists[id] = { + 'id': id, + 'name': id, + 'created_at': '2026-01-01T00:00:00Z', + 'updated_at': '2026-01-01T00:00:00Z', + }; + } + final container = _container(db); + final notifier = container.read(libraryCollectionsProvider.notifier); + await notifier.ensurePlaylistsLoaded(const []); + final deletion = notifier.deletePlaylist('target'); + await db.writeStarted.future; + final creation = notifier.createPlaylist('New'); + await Future.delayed(Duration.zero); + expect(db.events, ['delete:playlist:target']); + expect( + container.read(libraryCollectionsProvider).playlists.map((p) => p.id), + ['target', 'keep'], + ); + db.releaseWrite.complete(); + await deletion; + final createdId = await creation; + expect(db.events, [ + 'delete:playlist:target', + 'insert:playlist:$createdId', + ]); + expect(db.playlists.keys.toSet(), {'keep', createdId}); + expect( + container.read(libraryCollectionsProvider).playlists.map((p) => p.id), + [createdId, 'keep'], + ); + }, + ); + + test( + 'failed writes retain published state and release the next mutation', + () async { + final db = _CollectionsDatabase()..failNextWrite = true; + final container = _container(db); + final notifier = container.read(libraryCollectionsProvider.notifier); + await notifier.ensurePlaylistsLoaded(const []); + final failed = notifier.toggleWishlist(_track('failed')); + final failure = expectLater(failed, throwsStateError); + await db.writeStarted.future; + final successful = notifier.toggleWishlist(_track('successful')); + await Future.delayed(Duration.zero); + expect(db.events, ['insert:wishlist:example:failed']); + expect(container.read(libraryCollectionsProvider).wishlist, isEmpty); + db.releaseWrite.complete(); + await failure; + expect(await successful, isTrue); + expect(db.wishlist.keys, ['example:successful']); + expect( + container.read(libraryCollectionsProvider).wishlist.map((e) => e.key), + ['example:successful'], + ); + }, + ); +}