mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-10-21 23:11:45 +02:00
feat(library): show recent releases from favorite artists
Use installed extensions through the existing artist metadata API. Cache catalogs daily, cap concurrency, preserve previous results on provider failures and filter exact release dates into seven- and thirty-day views.
This commit is contained in:
1 parent
575f3fc1bd
commit
39bfda8f3d
7 files changed
+558
No files matched your search
@@ -7039,6 +7039,13 @@
|
||||
"relatedLibraryDescription": "Songs you already have with related artist, album or genre tags. No online music is searched.",
|
||||
"relatedLibraryEmpty": "No related songs found in your Library.",
|
||||
"relatedLibraryLoadError": "Could not find related songs. Try again.",
|
||||
"listeningStatsDays": "{days} days",
|
||||
"@listeningStatsDays": { "placeholders": { "days": { "type": "int" } } },
|
||||
"weeklyReleases": "New releases",
|
||||
"weeklyReleasesDescription": "Recent albums and singles from your favorite artists, supplied by installed metadata extensions.",
|
||||
"weeklyReleasesEmpty": "No dated releases found in this period. Add favorite artists or try a longer period.",
|
||||
"weeklyReleasesUnavailable": "Some artists could not be checked. Their saved results are kept. Providers may not supply artist catalogs or exact release dates.",
|
||||
"weeklyReleasesLoadError": "Could not check new releases. Pull to refresh.",
|
||||
"motionArtworkDescription": "Loop video covers supplied by metadata providers. Turn off to use still artwork and reduce battery use.",
|
||||
"optionsEmbeddedCoverSizeOriginal": "Original resolution",
|
||||
"@optionsEmbeddedCoverSizeOriginal": {
|
||||
|
||||
@@ -6958,5 +6958,11 @@
|
||||
"relatedLibraryDescription": "Lagu yang sudah Anda miliki dengan tag artis, album, atau genre terkait. Tidak mencari musik online.",
|
||||
"relatedLibraryEmpty": "Tidak ada lagu terkait di Library Anda.",
|
||||
"relatedLibraryLoadError": "Tidak dapat mencari lagu terkait. Coba lagi.",
|
||||
"listeningStatsDays": "{days} hari",
|
||||
"weeklyReleases": "Rilis baru",
|
||||
"weeklyReleasesDescription": "Album dan single terbaru dari artis favorit, melalui extension metadata yang terpasang.",
|
||||
"weeklyReleasesEmpty": "Tidak ditemukan rilis dengan tanggal dalam periode ini. Tambahkan artis favorit atau coba periode lebih panjang.",
|
||||
"weeklyReleasesUnavailable": "Beberapa artis tidak dapat diperiksa. Hasil tersimpan tetap dipertahankan. Penyedia mungkin tidak memberikan katalog artis atau tanggal rilis lengkap.",
|
||||
"weeklyReleasesLoadError": "Rilis baru tidak dapat diperiksa. Tarik untuk memuat ulang.",
|
||||
"motionArtworkDescription": "Putar berulang cover video dari penyedia metadata. Matikan untuk memakai cover diam dan menghemat baterai."
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
import 'package:spotiflac_android/providers/extension_provider.dart';
|
||||
import 'package:spotiflac_android/providers/library_collections_provider.dart';
|
||||
import 'package:spotiflac_android/providers/settings_provider.dart';
|
||||
import 'package:spotiflac_android/services/platform_bridge.dart';
|
||||
import 'package:spotiflac_android/services/weekly_releases.dart';
|
||||
import 'package:spotiflac_android/utils/provider_resource_ids.dart';
|
||||
|
||||
final weeklyReleasesProvider = FutureProvider.autoDispose
|
||||
.family<WeeklyReleaseFeed, bool>((ref, refresh) async {
|
||||
final collections = ref.watch(libraryCollectionsProvider);
|
||||
final extensions = ref.watch(extensionProvider);
|
||||
final preferred = ref.watch(
|
||||
settingsProvider.select((s) => s.searchProvider),
|
||||
);
|
||||
if (!collections.isLoaded || !extensions.isInitialized) {
|
||||
return const WeeklyReleaseFeed([]);
|
||||
}
|
||||
final providers = extensions.extensions
|
||||
.where(
|
||||
(extension) => extension.enabled && extension.hasMetadataProvider,
|
||||
)
|
||||
.toList(growable: false);
|
||||
final service = WeeklyReleaseService(
|
||||
preferences: SharedPreferences.getInstance(),
|
||||
loadArtist: (artist) async {
|
||||
final directProvider = artist.providerId;
|
||||
if (directProvider != null &&
|
||||
providers.any((p) => p.id == directProvider)) {
|
||||
return (
|
||||
providerId: directProvider,
|
||||
metadata: await PlatformBridge.getProviderMetadata(
|
||||
directProvider,
|
||||
'artist',
|
||||
stripPrefixedResourceId(artist.artistId),
|
||||
),
|
||||
);
|
||||
}
|
||||
// Older favorites may not have a provider ID. Resolve them by an
|
||||
// exact artist name in a search-capable metadata extension.
|
||||
final candidates = providers.where((p) => p.hasCustomSearch).toList()
|
||||
..sort(
|
||||
(a, b) => a.id == preferred
|
||||
? -1
|
||||
: b.id == preferred
|
||||
? 1
|
||||
: 0,
|
||||
);
|
||||
for (final provider in candidates) {
|
||||
try {
|
||||
final matches = await PlatformBridge.customSearchWithExtension(
|
||||
provider.id,
|
||||
artist.name,
|
||||
options: {'filter': 'artist', 'limit': 10},
|
||||
);
|
||||
for (final match in matches) {
|
||||
if (match['name']?.toString().trim().toLowerCase() !=
|
||||
artist.name.trim().toLowerCase()) {
|
||||
continue;
|
||||
}
|
||||
final id = match['id']?.toString() ?? '';
|
||||
if (id.isEmpty) continue;
|
||||
return (
|
||||
providerId: provider.id,
|
||||
metadata: await PlatformBridge.getProviderMetadata(
|
||||
provider.id,
|
||||
'artist',
|
||||
stripPrefixedResourceId(id),
|
||||
),
|
||||
);
|
||||
}
|
||||
} catch (_) {
|
||||
// Try the next installed metadata provider.
|
||||
}
|
||||
}
|
||||
throw StateError('No installed provider can resolve this artist');
|
||||
},
|
||||
);
|
||||
return service.load(collections.favoriteArtists, refresh: refresh);
|
||||
});
|
||||
@@ -4,6 +4,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:spotiflac_android/l10n/l10n.dart';
|
||||
import 'package:spotiflac_android/providers/library_collections_provider.dart';
|
||||
import 'package:spotiflac_android/screens/artist_screen.dart';
|
||||
import 'package:spotiflac_android/screens/weekly_releases_screen.dart';
|
||||
import 'package:spotiflac_android/services/cover_cache_manager.dart';
|
||||
import 'package:spotiflac_android/utils/adaptive_layout.dart';
|
||||
import 'package:spotiflac_android/utils/nav_bar_inset.dart';
|
||||
@@ -25,6 +26,17 @@ class FavoriteArtistsScreen extends ConsumerWidget {
|
||||
body: CustomScrollView(
|
||||
slivers: [
|
||||
AppSliverHeader.page(title: context.l10n.collectionFavoriteArtists),
|
||||
SliverToBoxAdapter(
|
||||
child: ListTile(
|
||||
leading: const Icon(Icons.new_releases_outlined),
|
||||
title: Text(context.l10n.weeklyReleases),
|
||||
subtitle: Text(context.l10n.weeklyReleasesDescription),
|
||||
trailing: const Icon(Icons.chevron_right),
|
||||
onTap: () => Navigator.of(
|
||||
context,
|
||||
).push(slidePageRoute<void>(page: const WeeklyReleasesScreen())),
|
||||
),
|
||||
),
|
||||
if (artists.isEmpty)
|
||||
SliverFillRemaining(
|
||||
hasScrollBody: false,
|
||||
|
||||
@@ -0,0 +1,147 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:intl/intl.dart';
|
||||
import 'package:spotiflac_android/l10n/l10n.dart';
|
||||
import 'package:spotiflac_android/providers/weekly_releases_provider.dart';
|
||||
import 'package:spotiflac_android/utils/clickable_metadata.dart';
|
||||
import 'package:spotiflac_android/utils/nav_bar_inset.dart';
|
||||
import 'package:spotiflac_android/widgets/app_choice_chip.dart';
|
||||
import 'package:spotiflac_android/widgets/app_loading_indicator.dart';
|
||||
import 'package:spotiflac_android/widgets/app_sliver_header.dart';
|
||||
import 'package:spotiflac_android/widgets/cached_cover_image.dart';
|
||||
|
||||
class WeeklyReleasesScreen extends ConsumerStatefulWidget {
|
||||
const WeeklyReleasesScreen({super.key});
|
||||
|
||||
@override
|
||||
ConsumerState<WeeklyReleasesScreen> createState() =>
|
||||
_WeeklyReleasesScreenState();
|
||||
}
|
||||
|
||||
class _WeeklyReleasesScreenState extends ConsumerState<WeeklyReleasesScreen> {
|
||||
int _days = 7;
|
||||
bool _refresh = false;
|
||||
|
||||
Future<void> _reload() async {
|
||||
if (_refresh) return;
|
||||
setState(() => _refresh = true);
|
||||
try {
|
||||
await ref.refresh(weeklyReleasesProvider(true).future).then((_) {});
|
||||
if (mounted) ref.invalidate(weeklyReleasesProvider(false));
|
||||
} catch (_) {
|
||||
// The provider displays the failure without leaving refresh latched on.
|
||||
} finally {
|
||||
if (mounted) setState(() => _refresh = false);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final feed = ref.watch(weeklyReleasesProvider(_refresh));
|
||||
return Scaffold(
|
||||
body: RefreshIndicator(
|
||||
onRefresh: _reload,
|
||||
child: CustomScrollView(
|
||||
physics: const AlwaysScrollableScrollPhysics(),
|
||||
slivers: [
|
||||
AppSliverHeader.page(title: context.l10n.weeklyReleases),
|
||||
SliverToBoxAdapter(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(context.l10n.weeklyReleasesDescription),
|
||||
const SizedBox(height: 16),
|
||||
Wrap(
|
||||
spacing: 8,
|
||||
children: [
|
||||
for (final days in [7, 30])
|
||||
AppChoiceChip(
|
||||
label: Text(context.l10n.listeningStatsDays(days)),
|
||||
selected: _days == days,
|
||||
onSelected: (_) => setState(() => _days = days),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
...feed.when(
|
||||
loading: () => [
|
||||
const SliverToBoxAdapter(
|
||||
child: Center(child: AppLoadingIndicator()),
|
||||
),
|
||||
],
|
||||
error: (_, _) => [
|
||||
SliverToBoxAdapter(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: Text(context.l10n.weeklyReleasesLoadError),
|
||||
),
|
||||
),
|
||||
],
|
||||
data: (data) {
|
||||
final releases = data.inPeriod(DateTime.now(), _days);
|
||||
return [
|
||||
if (data.unavailableArtists.isNotEmpty)
|
||||
SliverToBoxAdapter(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Text(
|
||||
'${context.l10n.weeklyReleasesUnavailable}\n'
|
||||
'${data.unavailableArtists.join(', ')}',
|
||||
),
|
||||
),
|
||||
),
|
||||
if (releases.isEmpty)
|
||||
SliverToBoxAdapter(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: Text(context.l10n.weeklyReleasesEmpty),
|
||||
),
|
||||
),
|
||||
SliverList.list(
|
||||
children: [
|
||||
for (final release in releases)
|
||||
ListTile(
|
||||
leading: release.coverUrl == null
|
||||
? const SizedBox(
|
||||
width: 56,
|
||||
height: 56,
|
||||
child: Icon(Icons.album_outlined),
|
||||
)
|
||||
: CachedCoverImage(
|
||||
imageUrl: release.coverUrl!,
|
||||
width: 56,
|
||||
height: 56,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
title: Text(release.name),
|
||||
subtitle: Text(
|
||||
'${release.artist}\n${DateFormat.yMMMd(Localizations.localeOf(context).toLanguageTag()).format(release.date)}',
|
||||
),
|
||||
onTap: () => navigateToAlbum(
|
||||
context,
|
||||
albumName: release.name,
|
||||
albumId: release.id,
|
||||
artistName: release.artist,
|
||||
coverUrl: release.coverUrl,
|
||||
extensionId: release.providerId,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
];
|
||||
},
|
||||
),
|
||||
SliverToBoxAdapter(
|
||||
child: SizedBox(height: context.navBarBottomInset),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,188 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
import 'package:spotiflac_android/providers/library_collections_provider.dart';
|
||||
import 'package:spotiflac_android/utils/string_utils.dart';
|
||||
|
||||
class WeeklyRelease {
|
||||
const WeeklyRelease({
|
||||
required this.id,
|
||||
required this.name,
|
||||
required this.artist,
|
||||
required this.providerId,
|
||||
required this.date,
|
||||
this.coverUrl,
|
||||
});
|
||||
|
||||
final String id;
|
||||
final String name;
|
||||
final String artist;
|
||||
final String providerId;
|
||||
final DateTime date;
|
||||
final String? coverUrl;
|
||||
|
||||
Map<String, dynamic> toJson() => {
|
||||
'id': id,
|
||||
'name': name,
|
||||
'artists': artist,
|
||||
'provider_id': providerId,
|
||||
'release_date': date.toIso8601String(),
|
||||
'cover_url': coverUrl,
|
||||
};
|
||||
|
||||
static WeeklyRelease? fromMetadata(
|
||||
Map<String, dynamic> metadata, {
|
||||
required String providerId,
|
||||
required String artist,
|
||||
}) {
|
||||
final rawDate = metadata['release_date']?.toString() ?? '';
|
||||
// Year/month-only dates are not precise enough for a weekly feed.
|
||||
if (!RegExp(r'^\d{4}-\d{2}-\d{2}(?:$|T)').hasMatch(rawDate)) return null;
|
||||
final parsed = DateTime.tryParse(rawDate);
|
||||
if (parsed == null) return null;
|
||||
final date = DateTime(parsed.year, parsed.month, parsed.day);
|
||||
if (rawDate.substring(0, 10) != date.toIso8601String().substring(0, 10)) {
|
||||
return null;
|
||||
}
|
||||
final id = metadata['id']?.toString().trim() ?? '';
|
||||
final name = metadata['name']?.toString().trim() ?? '';
|
||||
if (id.isEmpty || name.isEmpty) return null;
|
||||
return WeeklyRelease(
|
||||
id: id,
|
||||
name: name,
|
||||
artist: metadata['artists']?.toString().trim().isNotEmpty == true
|
||||
? metadata['artists'].toString()
|
||||
: artist,
|
||||
providerId: metadata['provider_id']?.toString() ?? providerId,
|
||||
date: date,
|
||||
coverUrl: normalizeCoverReference(
|
||||
(metadata['cover_url'] ?? metadata['images'])?.toString(),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class WeeklyReleaseFeed {
|
||||
const WeeklyReleaseFeed(this.releases, {this.unavailableArtists = const []});
|
||||
|
||||
final List<WeeklyRelease> releases;
|
||||
final List<String> unavailableArtists;
|
||||
|
||||
List<WeeklyRelease> inPeriod(DateTime now, int days) {
|
||||
final tomorrow = DateTime(now.year, now.month, now.day + 1);
|
||||
final start = DateTime(now.year, now.month, now.day - days + 1);
|
||||
return releases
|
||||
.where(
|
||||
(release) =>
|
||||
!release.date.isBefore(start) && release.date.isBefore(tomorrow),
|
||||
)
|
||||
.toList(growable: false);
|
||||
}
|
||||
}
|
||||
|
||||
typedef ArtistReleaseLoader =
|
||||
Future<({String providerId, Map<String, dynamic> metadata})> Function(
|
||||
CollectionArtistEntry artist,
|
||||
);
|
||||
|
||||
/// No provider code is embedded in the app. The existing artist metadata
|
||||
/// contract supplies the catalog; failures keep the last successful catalog.
|
||||
class WeeklyReleaseService {
|
||||
WeeklyReleaseService({required this.loadArtist, required this.preferences});
|
||||
|
||||
final ArtistReleaseLoader loadArtist;
|
||||
final Future<SharedPreferences> preferences;
|
||||
|
||||
Future<WeeklyReleaseFeed> load(
|
||||
List<CollectionArtistEntry> artists, {
|
||||
bool refresh = false,
|
||||
DateTime? now,
|
||||
}) async {
|
||||
final date = now ?? DateTime.now();
|
||||
final prefs = await preferences;
|
||||
final releases = <String, WeeklyRelease>{};
|
||||
final unavailable = <String>[];
|
||||
var next = 0;
|
||||
Future<void> worker() async {
|
||||
while (next < artists.length) {
|
||||
final artist = artists[next++];
|
||||
final key = 'weekly_releases_v1:${artist.key}';
|
||||
var cached = <WeeklyRelease>[];
|
||||
DateTime? checkedAt;
|
||||
try {
|
||||
final raw = prefs.getString(key);
|
||||
if (raw != null) {
|
||||
final json = jsonDecode(raw) as Map<String, dynamic>;
|
||||
checkedAt = DateTime.tryParse(json['checked_at'] as String? ?? '');
|
||||
cached = _parseCatalog(
|
||||
json,
|
||||
artist.name,
|
||||
json['provider_id']?.toString() ?? '',
|
||||
);
|
||||
}
|
||||
} catch (_) {
|
||||
// An invalid cache is rebuilt; it must not break other artists.
|
||||
}
|
||||
var catalog = cached;
|
||||
final age = checkedAt == null ? null : date.difference(checkedAt);
|
||||
if (refresh || age == null || age.isNegative || age.inHours >= 24) {
|
||||
try {
|
||||
final result = await loadArtist(
|
||||
artist,
|
||||
).timeout(const Duration(seconds: 25));
|
||||
final metadata = result.metadata;
|
||||
// Missing catalogs mean unsupported, rather than an empty result
|
||||
// that silently replaces a previously successful cache.
|
||||
if (metadata['albums'] is! List && metadata['releases'] is! List) {
|
||||
throw StateError('Artist catalog is unavailable');
|
||||
}
|
||||
catalog = _parseCatalog(metadata, artist.name, result.providerId);
|
||||
await prefs.setString(
|
||||
key,
|
||||
jsonEncode({
|
||||
'checked_at': date.toIso8601String(),
|
||||
'provider_id': result.providerId,
|
||||
'albums': catalog.map((release) => release.toJson()).toList(),
|
||||
}),
|
||||
);
|
||||
} catch (_) {
|
||||
unavailable.add(artist.name);
|
||||
}
|
||||
}
|
||||
for (final release in catalog) {
|
||||
releases['${release.providerId}:${release.id}'] = release;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Keep large favorite lists from issuing a burst of extension requests.
|
||||
await Future.wait([worker(), worker()]);
|
||||
final sorted = releases.values.toList()
|
||||
..sort((a, b) => b.date.compareTo(a.date));
|
||||
return WeeklyReleaseFeed(sorted, unavailableArtists: unavailable);
|
||||
}
|
||||
|
||||
List<WeeklyRelease> _parseCatalog(
|
||||
Map<String, dynamic> data,
|
||||
String artist,
|
||||
String providerId,
|
||||
) {
|
||||
final releases = <String, WeeklyRelease>{};
|
||||
for (final field in ['albums', 'releases']) {
|
||||
final raw = data[field];
|
||||
if (raw is! List) continue;
|
||||
for (final value in raw.take(300)) {
|
||||
if (value is! Map) continue;
|
||||
final release = WeeklyRelease.fromMetadata(
|
||||
Map<String, dynamic>.from(value),
|
||||
providerId: providerId,
|
||||
artist: artist,
|
||||
);
|
||||
if (release != null) {
|
||||
releases['${release.providerId}:${release.id}'] = release;
|
||||
}
|
||||
}
|
||||
}
|
||||
return releases.values.toList(growable: false);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
import 'package:spotiflac_android/providers/library_collections_provider.dart';
|
||||
import 'package:spotiflac_android/services/weekly_releases.dart';
|
||||
|
||||
CollectionArtistEntry _artist(String id) => CollectionArtistEntry(
|
||||
key: 'provider-a:$id',
|
||||
artistId: id,
|
||||
providerId: 'provider-a',
|
||||
name: id,
|
||||
addedAt: DateTime(2026),
|
||||
);
|
||||
|
||||
void main() {
|
||||
TestWidgetsFlutterBinding.ensureInitialized();
|
||||
setUp(() => SharedPreferences.setMockInitialValues({}));
|
||||
|
||||
test('weekly feed rejects incomplete/invalid dates and future releases', () {
|
||||
WeeklyRelease? parse(String date) => WeeklyRelease.fromMetadata(
|
||||
{'id': date, 'name': 'Release', 'release_date': date},
|
||||
providerId: 'provider-a',
|
||||
artist: 'Artist',
|
||||
);
|
||||
expect(parse('2026'), isNull);
|
||||
expect(parse('2026-10'), isNull);
|
||||
expect(parse('2026-02-31'), isNull);
|
||||
final feed = WeeklyReleaseFeed([
|
||||
parse('2026-09-26')!,
|
||||
parse('2026-10-02')!,
|
||||
parse('2026-09-25')!,
|
||||
parse('2026-10-03')!,
|
||||
]);
|
||||
expect(feed.inPeriod(DateTime(2026, 10, 2), 7).length, 2);
|
||||
});
|
||||
|
||||
test(
|
||||
'catalog is deduplicated, cached daily and retained on refresh failure',
|
||||
() async {
|
||||
var calls = 0;
|
||||
var fail = false;
|
||||
final service = WeeklyReleaseService(
|
||||
preferences: SharedPreferences.getInstance(),
|
||||
loadArtist: (artist) async {
|
||||
calls++;
|
||||
if (fail) throw StateError('provider unavailable');
|
||||
final album = {
|
||||
'id': 'release',
|
||||
'name': 'Release',
|
||||
'release_date': '2026-10-02',
|
||||
};
|
||||
return (
|
||||
providerId: 'provider-a',
|
||||
metadata: <String, dynamic>{
|
||||
'albums': [album],
|
||||
'releases': [album],
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
final artists = [_artist('Artist')];
|
||||
final date = DateTime(2026, 10, 2);
|
||||
expect((await service.load(artists, now: date)).releases.length, 1);
|
||||
expect((await service.load(artists, now: date)).releases.length, 1);
|
||||
expect(calls, 1);
|
||||
fail = true;
|
||||
final offline = await service.load(artists, now: date, refresh: true);
|
||||
expect(offline.releases.length, 1);
|
||||
expect(offline.unavailableArtists, ['Artist']);
|
||||
expect(calls, 2);
|
||||
},
|
||||
);
|
||||
|
||||
test('unsupported artist catalogs do not erase saved releases', () async {
|
||||
var unsupported = false;
|
||||
final service = WeeklyReleaseService(
|
||||
preferences: SharedPreferences.getInstance(),
|
||||
loadArtist: (_) async => (
|
||||
providerId: 'provider-a',
|
||||
metadata: <String, dynamic>{
|
||||
if (!unsupported)
|
||||
'albums': [
|
||||
{
|
||||
'id': 'release',
|
||||
'name': 'Release',
|
||||
'release_date': '2026-10-02',
|
||||
},
|
||||
],
|
||||
},
|
||||
),
|
||||
);
|
||||
await service.load([_artist('Artist')]);
|
||||
unsupported = true;
|
||||
final result = await service.load([_artist('Artist')], refresh: true);
|
||||
expect(result.releases.length, 1);
|
||||
expect(result.unavailableArtists, ['Artist']);
|
||||
});
|
||||
|
||||
test('limits concurrent requests for a large favorites list', () async {
|
||||
var running = 0;
|
||||
var peak = 0;
|
||||
final service = WeeklyReleaseService(
|
||||
preferences: SharedPreferences.getInstance(),
|
||||
loadArtist: (_) async {
|
||||
running++;
|
||||
if (running > peak) peak = running;
|
||||
await Future<void>.delayed(Duration.zero);
|
||||
running--;
|
||||
return (
|
||||
providerId: 'provider-a',
|
||||
metadata: <String, dynamic>{'albums': <Object>[]},
|
||||
);
|
||||
},
|
||||
);
|
||||
await service.load([for (var i = 0; i < 10; i++) _artist('artist-$i')]);
|
||||
expect(peak, 2);
|
||||
});
|
||||
}
|
||||
Reference in new issue
Block a user