fix(playlists): serialize collection changes and isolate domain models

This commit is contained in:
zarzet committed 2026-10-06 21:59:54 +07:00
1 parent cd313e1bfd
commit 8f021b01c7
7 files changed
+1011 -612

No files matched your search

+432
View File
@@ -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<String, dynamic> toJson() => {
'key': key,
'track': track.toJson(),
'addedAt': addedAt.toIso8601String(),
};
factory CollectionTrackEntry.fromJson(Map<String, dynamic> json) {
final addedAtRaw = json['addedAt'] as String?;
return CollectionTrackEntry(
key: json['key'] as String,
track: Track.fromJson(Map<String, dynamic>.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<String, dynamic> toJson() => {
'key': key,
'artistId': artistId,
'providerId': providerId,
'name': name,
'imageUrl': imageUrl,
'addedAt': addedAt.toIso8601String(),
};
factory CollectionArtistEntry.fromJson(Map<String, dynamic> 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<CollectionTrackEntry> tracks;
final String? previewCover;
final bool tracksLoaded;
final Set<String> _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<String>? trackKeys,
}) : _trackKeys = trackKeys ?? tracks.map((entry) => entry.key).toSet();
UserPlaylistCollection copyWith({
String? id,
String? name,
String? Function()? coverImagePath,
DateTime? createdAt,
DateTime? updatedAt,
List<CollectionTrackEntry>? 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<String> get trackKeys => UnmodifiableSetView(_trackKeys);
int get trackCount => _trackKeys.length;
Map<String, dynamic> 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<String, dynamic> 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<Object?, Object?>>()
.map(
(e) => CollectionTrackEntry.fromJson(Map<String, dynamic>.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<String> trackKeys;
PlaylistPickerSummaryRequest._(this.trackKeys);
factory PlaylistPickerSummaryRequest.fromTracks(Iterable<Track> 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<CollectionTrackEntry> wishlist;
final List<CollectionTrackEntry> loved;
final List<UserPlaylistCollection> playlists;
final List<CollectionArtistEntry> favoriteArtists;
final bool isLoaded;
final Set<String> _wishlistKeys;
final Set<String> _lovedKeys;
final Set<String> _favoriteArtistKeys;
final Map<String, UserPlaylistCollection> _playlistsById;
final Set<String> _allPlaylistTrackKeys;
LibraryCollectionsState({
this.wishlist = const [],
this.loved = const [],
this.playlists = const [],
this.favoriteArtists = const [],
this.isLoaded = false,
Set<String>? wishlistKeys,
Set<String>? lovedKeys,
Set<String>? favoriteArtistKeys,
Map<String, UserPlaylistCollection>? playlistsById,
Set<String>? 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<CollectionTrackEntry>? wishlist,
List<CollectionTrackEntry>? loved,
List<UserPlaylistCollection>? playlists,
List<CollectionArtistEntry>? 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<String, dynamic> 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<String, dynamic> 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<Object?, Object?>>()
.map(
(e) => CollectionTrackEntry.fromJson(Map<String, dynamic>.from(e)),
)
.toList(growable: false),
loved: lovedRaw
.whereType<Map<Object?, Object?>>()
.map(
(e) => CollectionTrackEntry.fromJson(Map<String, dynamic>.from(e)),
)
.toList(growable: false),
playlists: playlistsRaw
.whereType<Map<Object?, Object?>>()
.map(
(e) =>
UserPlaylistCollection.fromJson(Map<String, dynamic>.from(e)),
)
.toList(growable: false),
favoriteArtists: favoriteArtistsRaw
.whereType<Map<Object?, Object?>>()
.map(
(e) => CollectionArtistEntry.fromJson(Map<String, dynamic>.from(e)),
)
.toList(growable: false),
isLoaded: true,
);
}
}
Set<String> _buildPlaylistTrackKeys(List<UserPlaylistCollection> playlists) {
final keys = <String>{};
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,
});
}
+15 -14
View File
@@ -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() {
+188 -572
View File
@@ -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<String, dynamic> toJson() => {
'key': key,
'track': track.toJson(),
'addedAt': addedAt.toIso8601String(),
};
factory CollectionTrackEntry.fromJson(Map<String, dynamic> json) {
final addedAtRaw = json['addedAt'] as String?;
return CollectionTrackEntry(
key: json['key'] as String,
track: Track.fromJson(Map<String, dynamic>.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<String, dynamic> toJson() => {
'key': key,
'artistId': artistId,
'providerId': providerId,
'name': name,
'imageUrl': imageUrl,
'addedAt': addedAt.toIso8601String(),
};
factory CollectionArtistEntry.fromJson(Map<String, dynamic> 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<CollectionTrackEntry> tracks;
final String? previewCover;
final bool tracksLoaded;
final Set<String> _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<String>? trackKeys,
}) : _trackKeys = trackKeys ?? tracks.map((entry) => entry.key).toSet();
UserPlaylistCollection copyWith({
String? id,
String? name,
String? Function()? coverImagePath,
DateTime? createdAt,
DateTime? updatedAt,
List<CollectionTrackEntry>? 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<String, dynamic> 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<String, dynamic> 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<Object?, Object?>>()
.map(
(e) => CollectionTrackEntry.fromJson(Map<String, dynamic>.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<String> trackKeys;
PlaylistPickerSummaryRequest._(this.trackKeys);
factory PlaylistPickerSummaryRequest.fromTracks(Iterable<Track> 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<CollectionTrackEntry> wishlist;
final List<CollectionTrackEntry> loved;
final List<UserPlaylistCollection> playlists;
final List<CollectionArtistEntry> favoriteArtists;
final bool isLoaded;
final Set<String> _wishlistKeys;
final Set<String> _lovedKeys;
final Set<String> _favoriteArtistKeys;
final Map<String, UserPlaylistCollection> _playlistsById;
final Set<String> _allPlaylistTrackKeys;
LibraryCollectionsState({
this.wishlist = const [],
this.loved = const [],
this.playlists = const [],
this.favoriteArtists = const [],
this.isLoaded = false,
Set<String>? wishlistKeys,
Set<String>? lovedKeys,
Set<String>? favoriteArtistKeys,
Map<String, UserPlaylistCollection>? playlistsById,
Set<String>? 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<CollectionTrackEntry>? wishlist,
List<CollectionTrackEntry>? loved,
List<UserPlaylistCollection>? playlists,
List<CollectionArtistEntry>? 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<String, dynamic> 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<String, dynamic> 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<Object?, Object?>>()
.map(
(e) => CollectionTrackEntry.fromJson(Map<String, dynamic>.from(e)),
)
.toList(growable: false),
loved: lovedRaw
.whereType<Map<Object?, Object?>>()
.map(
(e) => CollectionTrackEntry.fromJson(Map<String, dynamic>.from(e)),
)
.toList(growable: false),
playlists: playlistsRaw
.whereType<Map<Object?, Object?>>()
.map(
(e) =>
UserPlaylistCollection.fromJson(Map<String, dynamic>.from(e)),
)
.toList(growable: false),
favoriteArtists: favoriteArtistsRaw
.whereType<Map<Object?, Object?>>()
.map(
(e) => CollectionArtistEntry.fromJson(Map<String, dynamic>.from(e)),
)
.toList(growable: false),
isLoaded: true,
);
}
}
Set<String> _buildPlaylistTrackKeys(List<UserPlaylistCollection> playlists) {
final keys = <String>{};
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<LibraryCollectionsState> {
final LibraryCollectionsDatabase _db = LibraryCollectionsDatabase.instance;
LibraryCollectionsNotifier({LibraryCollectionsDatabase? database})
: _db = database ?? LibraryCollectionsDatabase.instance;
final LibraryCollectionsDatabase _db;
Future<void>? _loadFuture;
final Map<String, Future<void>> _playlistLoadFutures = {};
Future<void> _mutationTail = Future<void>.value();
/// Evaluate membership against the preceding completed write, then publish
/// only after persistence succeeds. A failed write does not block the next.
Future<T> _mutate<T>(Future<T> Function() mutation) {
final operation = _mutationTail.then((_) async {
await _ensureLoaded();
if (!ref.mounted) {
throw StateError('Library collections provider was disposed');
}
return mutation();
});
_mutationTail = operation.then<void>(
(_) {},
onError: (Object error, StackTrace stack) {},
);
return operation;
}
void _invalidatePlaylistPickerSummaries() {
if (!ref.mounted) return;
ref.invalidate(libraryPlaylistPickerSummariesProvider);
}
@@ -447,7 +56,9 @@ class LibraryCollectionsNotifier extends Notifier<LibraryCollectionsState> {
Future<void> _load() async {
try {
await _db.migrateFromSharedPreferences();
if (!ref.mounted) return;
final snapshot = await _db.loadSnapshot();
if (!ref.mounted) return;
final wishlist = <CollectionTrackEntry>[];
for (final row in snapshot.wishlistRows) {
@@ -525,18 +136,21 @@ class LibraryCollectionsNotifier extends Notifier<LibraryCollectionsState> {
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<void> _ensureLoaded() async {
if (!ref.mounted) return;
if (state.isLoaded) return;
await (_loadFuture ?? _load());
}
Future<void> 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<LibraryCollectionsState> {
if (pending != null) return pending;
final load = () async {
final rows = await _db.loadPlaylistTracks(playlistId);
if (!ref.mounted) return;
final tracks = rows
.map(_parseTrackEntryRow)
.whereType<CollectionTrackEntry>()
@@ -618,6 +233,7 @@ class LibraryCollectionsNotifier extends Notifier<LibraryCollectionsState> {
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<LibraryCollectionsState> {
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<LibraryCollectionsState> {
trackJson: jsonEncode(track.toJson()),
addedAt: entry.addedAt.toIso8601String(),
);
state = withList([entry, ...select(state)]);
if (ref.mounted) state = withList([entry, ...select(state)]);
return true;
}
});
Future<bool> toggleWishlist(Track track) => _toggleTrackEntry(
track,
@@ -697,8 +313,7 @@ class LibraryCollectionsNotifier extends Notifier<LibraryCollectionsState> {
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<LibraryCollectionsState> {
? 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<LibraryCollectionsState> {
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<void> _removeEntry<T>(
String key, {
@@ -737,17 +361,17 @@ class LibraryCollectionsNotifier extends Notifier<LibraryCollectionsState> {
required String Function(T entry) keyOf,
required LibraryCollectionsState Function(List<T> list) withList,
required Future<void> 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<void> removeFavoriteArtist(String artistKey) => _removeEntry(
artistKey,
@@ -776,8 +400,7 @@ class LibraryCollectionsNotifier extends Notifier<LibraryCollectionsState> {
dbDelete: _db.deleteLovedEntry,
);
Future<String> createPlaylist(String name) async {
await _ensureLoaded();
Future<String> 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<LibraryCollectionsState> {
createdAt: now.toIso8601String(),
updatedAt: now.toIso8601String(),
);
if (!ref.mounted) return id;
state = state.copyWith(playlists: [playlist, ...state.playlists]);
_invalidatePlaylistPickerSummaries();
return id;
}
});
Future<void> 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<void> 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<void> deletePlaylist(String playlistId) async {
await _ensureLoaded();
final playlistIndex = state.playlists.indexWhere((p) => p.id == playlistId);
if (playlistIndex < 0) return;
Future<void> 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<bool> 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<bool> 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<PlaylistAddBatchResult> addTracksToPlaylist(
String playlistId,
Iterable<Track> 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<LibraryCollectionsState> {
}
final now = DateTime.now();
final knownKeys = <String>{...playlist._trackKeys};
final knownKeys = <String>{...playlist.trackKeys};
final entriesToAdd = <CollectionTrackEntry>[];
var alreadyInPlaylistCount = 0;
@@ -933,34 +566,32 @@ class LibraryCollectionsNotifier extends Notifier<LibraryCollectionsState> {
addedCount: entriesToAdd.length,
alreadyInPlaylistCount: alreadyInPlaylistCount,
);
}
});
Future<void> 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<void> 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<Directory> _playlistCoversDir() async {
final appDir = await getApplicationSupportDirectory();
@@ -971,38 +602,38 @@ class LibraryCollectionsNotifier extends Notifier<LibraryCollectionsState> {
return dir;
}
Future<void> setPlaylistCover(
String playlistId,
String sourceFilePath,
) async {
await _ensureLoaded();
final playlist = state.playlistById(playlistId);
if (playlist == null) return;
Future<void> 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<String> _normalizePlaylistCoverFile(
String playlistId,
@@ -1070,8 +701,7 @@ class LibraryCollectionsNotifier extends Notifier<LibraryCollectionsState> {
dimensions.height > _playlistCoverMaxDimension;
}
Future<void> removePlaylistCover(String playlistId) async {
await _ensureLoaded();
Future<void> 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<LibraryCollectionsState> {
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<LibraryCollectionsState> {
Future<void> restoreFromBackup(
Map<String, dynamic> collectionsJson, {
Map<String, dynamic>? coverImages,
}) async {
}) => _mutate(() async {
final normalized = Map<String, dynamic>.from(collectionsJson);
final coversDir = await _playlistCoversDir();
@@ -1231,7 +861,7 @@ class LibraryCollectionsNotifier extends Notifier<LibraryCollectionsState> {
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);
});
+4 -25
View File
@@ -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<List<PlaylistPickerSummaryRow>> loadPlaylistPickerSummaries(
Future<List<PlaylistPickerSummary>> loadPlaylistPickerSummaries(
List<String> requestedTrackKeys,
) async => readPlaylistPickerSummaries(await database, requestedTrackKeys);
@@ -596,7 +575,7 @@ class LibraryCollectionsDatabase {
}
/// Reads picker rows with bounded selection queries, preserving playlist order.
Future<List<PlaylistPickerSummaryRow>> readPlaylistPickerSummaries(
Future<List<PlaylistPickerSummary>> readPlaylistPickerSummaries(
DatabaseExecutor db,
List<String> requestedTrackKeys,
) async {
@@ -668,7 +647,7 @@ Future<List<PlaylistPickerSummaryRow>> readPlaylistPickerSummaries(
}
} catch (_) {}
}
return PlaylistPickerSummaryRow(
return PlaylistPickerSummary(
id: id,
name: row['name']?.toString() ?? '',
coverImagePath: row['cover_image_path'] as String?,
+1 -1
View File
@@ -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 {
+109
View File
@@ -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);
});
}
+262
View File
@@ -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 = <String, Map<String, dynamic>>{};
final loved = <String, Map<String, dynamic>>{};
final artists = <String, Map<String, dynamic>>{};
final playlists = <String, Map<String, dynamic>>{};
final events = <String>[];
final writeStarted = Completer<void>();
final releaseWrite = Completer<void>();
bool failNextWrite = false;
Future<void> _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<bool> migrateFromSharedPreferences() async => false;
@override
Future<LibraryCollectionsSnapshot> loadSnapshot() async =>
LibraryCollectionsSnapshot(
wishlistRows: wishlist.values.toList(),
lovedRows: loved.values.toList(),
playlistRows: playlists.values.toList(),
playlistTrackRows: const [],
favoriteArtistRows: artists.values.toList(),
);
Future<void> _upsertTrack(
Map<String, Map<String, dynamic>> 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<void> upsertWishlistEntry({
required String trackKey,
required String trackJson,
required String addedAt,
}) => _upsertTrack(wishlist, 'wishlist', trackKey, trackJson, addedAt);
@override
Future<void> deleteWishlistEntry(String trackKey) =>
_write('delete:wishlist:$trackKey', () => wishlist.remove(trackKey));
@override
Future<void> upsertLovedEntry({
required String trackKey,
required String trackJson,
required String addedAt,
}) => _upsertTrack(loved, 'loved', trackKey, trackJson, addedAt);
@override
Future<void> deleteLovedEntry(String trackKey) =>
_write('delete:loved:$trackKey', () => loved.remove(trackKey));
@override
Future<void> 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<void> deleteFavoriteArtistEntry(String artistKey) =>
_write('delete:artists:$artistKey', () => artists.remove(artistKey));
@override
Future<void> 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<void> 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<bool> 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<void>.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<void>.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<void>.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'],
);
},
);
}