From 39bfda8f3d3b9a3e4f1c5c3035aaf06334993ed7 Mon Sep 17 00:00:00 2001 From: zarzet <42882290+zarzet@users.noreply.github.com> Date: Fri, 2 Oct 2026 18:13:39 +0700 Subject: [PATCH] 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. --- lib/l10n/arb/app_en.arb | 7 + lib/l10n/arb/app_id.arb | 6 + lib/providers/weekly_releases_provider.dart | 81 +++++++++ lib/screens/favorite_artists_screen.dart | 12 ++ lib/screens/weekly_releases_screen.dart | 147 +++++++++++++++ lib/services/weekly_releases.dart | 188 ++++++++++++++++++++ test/weekly_releases_test.dart | 117 ++++++++++++ 7 files changed, 558 insertions(+) create mode 100644 lib/providers/weekly_releases_provider.dart create mode 100644 lib/screens/weekly_releases_screen.dart create mode 100644 lib/services/weekly_releases.dart create mode 100644 test/weekly_releases_test.dart diff --git a/lib/l10n/arb/app_en.arb b/lib/l10n/arb/app_en.arb index 3c1c3243..571bbb74 100644 --- a/lib/l10n/arb/app_en.arb +++ b/lib/l10n/arb/app_en.arb @@ -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": { diff --git a/lib/l10n/arb/app_id.arb b/lib/l10n/arb/app_id.arb index d6781a44..a77101a5 100644 --- a/lib/l10n/arb/app_id.arb +++ b/lib/l10n/arb/app_id.arb @@ -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." } diff --git a/lib/providers/weekly_releases_provider.dart b/lib/providers/weekly_releases_provider.dart new file mode 100644 index 00000000..9a625ff2 --- /dev/null +++ b/lib/providers/weekly_releases_provider.dart @@ -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((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); + }); diff --git a/lib/screens/favorite_artists_screen.dart b/lib/screens/favorite_artists_screen.dart index a655b3e1..ec5366e4 100644 --- a/lib/screens/favorite_artists_screen.dart +++ b/lib/screens/favorite_artists_screen.dart @@ -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(page: const WeeklyReleasesScreen())), + ), + ), if (artists.isEmpty) SliverFillRemaining( hasScrollBody: false, diff --git a/lib/screens/weekly_releases_screen.dart b/lib/screens/weekly_releases_screen.dart new file mode 100644 index 00000000..373a5d49 --- /dev/null +++ b/lib/screens/weekly_releases_screen.dart @@ -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 createState() => + _WeeklyReleasesScreenState(); +} + +class _WeeklyReleasesScreenState extends ConsumerState { + int _days = 7; + bool _refresh = false; + + Future _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), + ), + ], + ), + ), + ); + } +} diff --git a/lib/services/weekly_releases.dart b/lib/services/weekly_releases.dart new file mode 100644 index 00000000..df7d8828 --- /dev/null +++ b/lib/services/weekly_releases.dart @@ -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 toJson() => { + 'id': id, + 'name': name, + 'artists': artist, + 'provider_id': providerId, + 'release_date': date.toIso8601String(), + 'cover_url': coverUrl, + }; + + static WeeklyRelease? fromMetadata( + Map 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 releases; + final List unavailableArtists; + + List 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 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 preferences; + + Future load( + List artists, { + bool refresh = false, + DateTime? now, + }) async { + final date = now ?? DateTime.now(); + final prefs = await preferences; + final releases = {}; + final unavailable = []; + var next = 0; + Future worker() async { + while (next < artists.length) { + final artist = artists[next++]; + final key = 'weekly_releases_v1:${artist.key}'; + var cached = []; + DateTime? checkedAt; + try { + final raw = prefs.getString(key); + if (raw != null) { + final json = jsonDecode(raw) as Map; + 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 _parseCatalog( + Map data, + String artist, + String providerId, + ) { + final releases = {}; + 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.from(value), + providerId: providerId, + artist: artist, + ); + if (release != null) { + releases['${release.providerId}:${release.id}'] = release; + } + } + } + return releases.values.toList(growable: false); + } +} diff --git a/test/weekly_releases_test.dart b/test/weekly_releases_test.dart new file mode 100644 index 00000000..860bf43b --- /dev/null +++ b/test/weekly_releases_test.dart @@ -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: { + '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: { + 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.delayed(Duration.zero); + running--; + return ( + providerId: 'provider-a', + metadata: {'albums': []}, + ); + }, + ); + await service.load([for (var i = 0; i < 10; i++) _artist('artist-$i')]); + expect(peak, 2); + }); +}