feat(offline): #427 S4a — Shuffle all + offline system-playlist gate
Headline of S4. "Shuffle all" is always present (home app-bar shuffle icon); the pool degrades with reachability: - online → GET /api/library/shuffle?limit=N (new): N random library tracks server-side, per-user quarantine filtered (ListRandomTracksForUser, ORDER BY random()). - offline → ShuffleSource shuffles the whole local cache index (audio_cache_index ∩ cached_tracks, names from cached_artists/ albums) — a UNION over liked AND recently-played, since the two-bucket split is storage-only and never filters playback. Offline gating: refreshable (singleton) system playlists need the live build/shuffle endpoints, so their tile play is disabled when offlineProvider is true — Shuffle all is the offline path. User playlists still play from cache. playlist_card now reads offlineProvider in build → wrapped the direct-render widget test in ProviderScope (offlineProvider is smoke-safe from S1: tracked timers, no connectivity mount). S4b (offline Recently-played / Liked browsable surfaces over the cache index) next. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -85,6 +85,19 @@ class LibraryApi {
|
|||||||
.toList(growable: false);
|
.toList(growable: false);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// GET /api/library/shuffle?limit=N (#427 S4). N random library
|
||||||
|
/// tracks — the online source for "Shuffle all". Bare JSON array.
|
||||||
|
Future<List<TrackRef>> shuffle({int limit = 100}) async {
|
||||||
|
final r = await _dio.get<List<dynamic>>(
|
||||||
|
'/api/library/shuffle',
|
||||||
|
queryParameters: {'limit': limit},
|
||||||
|
);
|
||||||
|
final raw = r.data ?? const [];
|
||||||
|
return raw
|
||||||
|
.map((e) => TrackRef.fromJson((e as Map).cast<String, dynamic>()))
|
||||||
|
.toList(growable: false);
|
||||||
|
}
|
||||||
|
|
||||||
/// GET /api/artists/{id}/tracks. Server emits a bare JSON array, so we
|
/// GET /api/artists/{id}/tracks. Server emits a bare JSON array, so we
|
||||||
/// type the response as `List<dynamic>` rather than a Map envelope.
|
/// type the response as `List<dynamic>` rather than a Map envelope.
|
||||||
Future<List<TrackRef>> getArtistTracks(String id) async {
|
Future<List<TrackRef>> getArtistTracks(String id) async {
|
||||||
|
|||||||
+68
@@ -0,0 +1,68 @@
|
|||||||
|
// "Shuffle all" source (#427 S4). Always-present; the pool degrades
|
||||||
|
// gracefully with reachability:
|
||||||
|
// online → GET /api/library/shuffle (random over the whole library)
|
||||||
|
// offline → a client shuffle over the entire local cache index
|
||||||
|
//
|
||||||
|
// Offline is a UNION over every cached track regardless of storage
|
||||||
|
// bucket — liked AND recently-played both included. The two-bucket
|
||||||
|
// split (S2) is storage/eviction-only and never filters playback,
|
||||||
|
// which is exactly the property this relies on.
|
||||||
|
|
||||||
|
import 'dart:math';
|
||||||
|
|
||||||
|
import 'package:drift/drift.dart';
|
||||||
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
|
||||||
|
import '../library/library_providers.dart' show libraryApiProvider;
|
||||||
|
import '../models/track.dart';
|
||||||
|
import 'adapters.dart';
|
||||||
|
import 'audio_cache_manager.dart' show appDbProvider;
|
||||||
|
import 'offline_provider.dart';
|
||||||
|
|
||||||
|
class ShuffleSource {
|
||||||
|
ShuffleSource(this._ref);
|
||||||
|
final Ref _ref;
|
||||||
|
|
||||||
|
/// Returns up to [limit] tracks to play as a shuffle-all. Online:
|
||||||
|
/// server-random. Offline: every cached track (liked + recent),
|
||||||
|
/// shuffled client-side. Empty offline → nothing is cached yet.
|
||||||
|
Future<List<TrackRef>> tracks({int limit = 100}) async {
|
||||||
|
if (_ref.read(offlineProvider)) {
|
||||||
|
return _offline(limit);
|
||||||
|
}
|
||||||
|
final api = await _ref.read(libraryApiProvider.future);
|
||||||
|
return api.shuffle(limit: limit);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<List<TrackRef>> _offline(int limit) async {
|
||||||
|
final db = _ref.read(appDbProvider);
|
||||||
|
final cachedIds =
|
||||||
|
(await db.select(db.audioCacheIndex).get()).map((r) => r.trackId).toSet();
|
||||||
|
if (cachedIds.isEmpty) return const [];
|
||||||
|
|
||||||
|
final meta = await (db.select(db.cachedTracks)
|
||||||
|
..where((t) => t.id.isIn(cachedIds.toList())))
|
||||||
|
.get();
|
||||||
|
if (meta.isEmpty) return const [];
|
||||||
|
|
||||||
|
final artistName = {
|
||||||
|
for (final a in await db.select(db.cachedArtists).get()) a.id: a.name
|
||||||
|
};
|
||||||
|
final albumTitle = {
|
||||||
|
for (final a in await db.select(db.cachedAlbums).get()) a.id: a.title
|
||||||
|
};
|
||||||
|
|
||||||
|
final refs = [
|
||||||
|
for (final t in meta)
|
||||||
|
t.toRef(
|
||||||
|
artistName: artistName[t.artistId] ?? '',
|
||||||
|
albumTitle: albumTitle[t.albumId] ?? '',
|
||||||
|
)
|
||||||
|
];
|
||||||
|
refs.shuffle(Random());
|
||||||
|
return refs.length > limit ? refs.sublist(0, limit) : refs;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
final shuffleSourceProvider =
|
||||||
|
Provider<ShuffleSource>((ref) => ShuffleSource(ref));
|
||||||
@@ -4,7 +4,9 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
|
|||||||
import 'package:go_router/go_router.dart';
|
import 'package:go_router/go_router.dart';
|
||||||
|
|
||||||
import '../api/errors.dart';
|
import '../api/errors.dart';
|
||||||
|
import '../cache/shuffle_source.dart';
|
||||||
import '../cache/tile_providers.dart';
|
import '../cache/tile_providers.dart';
|
||||||
|
import '../player/player_provider.dart';
|
||||||
import '../models/playlist.dart';
|
import '../models/playlist.dart';
|
||||||
import '../models/system_playlists_status.dart';
|
import '../models/system_playlists_status.dart';
|
||||||
import '../models/track.dart';
|
import '../models/track.dart';
|
||||||
@@ -41,7 +43,28 @@ class HomeScreen extends ConsumerWidget {
|
|||||||
backgroundColor: fs.obsidian,
|
backgroundColor: fs.obsidian,
|
||||||
elevation: 0,
|
elevation: 0,
|
||||||
title: Text('Minstrel', style: TextStyle(color: fs.parchment)),
|
title: Text('Minstrel', style: TextStyle(color: fs.parchment)),
|
||||||
actions: const [MainAppBarActions(currentRoute: '/home')],
|
actions: [
|
||||||
|
IconButton(
|
||||||
|
key: const Key('shuffle_all_button'),
|
||||||
|
tooltip: 'Shuffle all',
|
||||||
|
icon: Icon(Icons.shuffle, color: fs.parchment),
|
||||||
|
onPressed: () async {
|
||||||
|
final messenger = ScaffoldMessenger.of(context);
|
||||||
|
final refs =
|
||||||
|
await ref.read(shuffleSourceProvider).tracks();
|
||||||
|
if (refs.isEmpty) {
|
||||||
|
messenger.showSnackBar(const SnackBar(
|
||||||
|
content: Text('Nothing to shuffle yet'),
|
||||||
|
));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await ref
|
||||||
|
.read(playerActionsProvider)
|
||||||
|
.playTracks(refs, shuffle: true);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
const MainAppBarActions(currentRoute: '/home'),
|
||||||
|
],
|
||||||
),
|
),
|
||||||
body: SafeArea(
|
body: SafeArea(
|
||||||
child: index.when(
|
child: index.when(
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
|
|||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
import 'package:go_router/go_router.dart';
|
import 'package:go_router/go_router.dart';
|
||||||
|
|
||||||
|
import '../../cache/offline_provider.dart';
|
||||||
import '../../library/widgets/play_circle_button.dart';
|
import '../../library/widgets/play_circle_button.dart';
|
||||||
import '../../models/playlist.dart';
|
import '../../models/playlist.dart';
|
||||||
import '../../models/track.dart';
|
import '../../models/track.dart';
|
||||||
@@ -23,7 +24,12 @@ class PlaylistCard extends ConsumerWidget {
|
|||||||
@override
|
@override
|
||||||
Widget build(BuildContext context, WidgetRef ref) {
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
final fs = Theme.of(context).extension<FabledSwordTheme>()!;
|
final fs = Theme.of(context).extension<FabledSwordTheme>()!;
|
||||||
final hasTracks = playlist.trackCount > 0;
|
// #427 S4: refreshable system playlists need the live build/
|
||||||
|
// shuffle endpoints — unavailable offline. Disable their play
|
||||||
|
// (use Shuffle all instead). User playlists play from cache.
|
||||||
|
final offline = ref.watch(offlineProvider);
|
||||||
|
final hasTracks = playlist.trackCount > 0 &&
|
||||||
|
!(offline && playlist.refreshable);
|
||||||
return SizedBox(
|
return SizedBox(
|
||||||
width: 176,
|
width: 176,
|
||||||
child: Material(
|
child: Material(
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
import 'package:flutter_test/flutter_test.dart';
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
|
||||||
import 'package:minstrel/models/playlist.dart';
|
import 'package:minstrel/models/playlist.dart';
|
||||||
@@ -35,44 +36,48 @@ const _forYou = Playlist(
|
|||||||
|
|
||||||
void main() {
|
void main() {
|
||||||
testWidgets('renders name and no badge for user playlist', (tester) async {
|
testWidgets('renders name and no badge for user playlist', (tester) async {
|
||||||
await tester.pumpWidget(MaterialApp(
|
await tester.pumpWidget(ProviderScope(
|
||||||
|
child: MaterialApp(
|
||||||
theme: buildThemeData(),
|
theme: buildThemeData(),
|
||||||
home: const Scaffold(
|
home: const Scaffold(
|
||||||
body: PlaylistCard(playlist: _userPlaylist),
|
body: PlaylistCard(playlist: _userPlaylist),
|
||||||
),
|
),
|
||||||
));
|
)));
|
||||||
expect(find.text('Road trip'), findsOneWidget);
|
expect(find.text('Road trip'), findsOneWidget);
|
||||||
expect(find.text('for you'), findsNothing);
|
expect(find.text('for you'), findsNothing);
|
||||||
});
|
});
|
||||||
|
|
||||||
testWidgets('renders system badge for system playlist', (tester) async {
|
testWidgets('renders system badge for system playlist', (tester) async {
|
||||||
await tester.pumpWidget(MaterialApp(
|
await tester.pumpWidget(ProviderScope(
|
||||||
|
child: MaterialApp(
|
||||||
theme: buildThemeData(),
|
theme: buildThemeData(),
|
||||||
home: const Scaffold(
|
home: const Scaffold(
|
||||||
body: PlaylistCard(playlist: _forYou),
|
body: PlaylistCard(playlist: _forYou),
|
||||||
),
|
),
|
||||||
));
|
)));
|
||||||
expect(find.text('For You'), findsOneWidget);
|
expect(find.text('For You'), findsOneWidget);
|
||||||
expect(find.text('for you'), findsOneWidget);
|
expect(find.text('for you'), findsOneWidget);
|
||||||
});
|
});
|
||||||
|
|
||||||
testWidgets('shows refresh kebab on system playlists', (tester) async {
|
testWidgets('shows refresh kebab on system playlists', (tester) async {
|
||||||
await tester.pumpWidget(MaterialApp(
|
await tester.pumpWidget(ProviderScope(
|
||||||
|
child: MaterialApp(
|
||||||
theme: buildThemeData(),
|
theme: buildThemeData(),
|
||||||
home: const Scaffold(
|
home: const Scaffold(
|
||||||
body: PlaylistCard(playlist: _forYou),
|
body: PlaylistCard(playlist: _forYou),
|
||||||
),
|
),
|
||||||
));
|
)));
|
||||||
expect(find.byIcon(Icons.more_vert), findsOneWidget);
|
expect(find.byIcon(Icons.more_vert), findsOneWidget);
|
||||||
});
|
});
|
||||||
|
|
||||||
testWidgets('no refresh kebab on user playlists', (tester) async {
|
testWidgets('no refresh kebab on user playlists', (tester) async {
|
||||||
await tester.pumpWidget(MaterialApp(
|
await tester.pumpWidget(ProviderScope(
|
||||||
|
child: MaterialApp(
|
||||||
theme: buildThemeData(),
|
theme: buildThemeData(),
|
||||||
home: const Scaffold(
|
home: const Scaffold(
|
||||||
body: PlaylistCard(playlist: _userPlaylist),
|
body: PlaylistCard(playlist: _userPlaylist),
|
||||||
),
|
),
|
||||||
));
|
)));
|
||||||
expect(find.byIcon(Icons.more_vert), findsNothing);
|
expect(find.byIcon(Icons.more_vert), findsNothing);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -74,6 +74,7 @@ func Mount(r chi.Router, pool *pgxpool.Pool, logger *slog.Logger, events *playev
|
|||||||
authed.Get("/artists/{id}/tracks", h.handleGetArtistTracks)
|
authed.Get("/artists/{id}/tracks", h.handleGetArtistTracks)
|
||||||
authed.Get("/albums/{id}", h.handleGetAlbum)
|
authed.Get("/albums/{id}", h.handleGetAlbum)
|
||||||
authed.Get("/albums/{id}/cover", h.handleGetCover)
|
authed.Get("/albums/{id}/cover", h.handleGetCover)
|
||||||
|
authed.Get("/library/shuffle", h.handleLibraryShuffle)
|
||||||
authed.Get("/library/albums", h.handleListLibraryAlbums)
|
authed.Get("/library/albums", h.handleListLibraryAlbums)
|
||||||
authed.Get("/library/sync", h.handleLibrarySync)
|
authed.Get("/library/sync", h.handleLibrarySync)
|
||||||
authed.Get("/tracks/{id}", h.handleGetTrack)
|
authed.Get("/tracks/{id}", h.handleGetTrack)
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package api
|
|||||||
import (
|
import (
|
||||||
"errors"
|
"errors"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
|
||||||
"github.com/jackc/pgx/v5"
|
"github.com/jackc/pgx/v5"
|
||||||
"github.com/jackc/pgx/v5/pgtype"
|
"github.com/jackc/pgx/v5/pgtype"
|
||||||
@@ -158,6 +159,43 @@ func (h *handlers) handleGetArtistTracks(w http.ResponseWriter, r *http.Request)
|
|||||||
writeJSON(w, http.StatusOK, out)
|
writeJSON(w, http.StatusOK, out)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// handleLibraryShuffle implements GET /api/library/shuffle?limit=N —
|
||||||
|
// the online source for the client's always-present "Shuffle all"
|
||||||
|
// (#427 S4). N random tracks across the whole library, per-user
|
||||||
|
// quarantine filtered. limit defaults to 100, clamped to 1..500.
|
||||||
|
// Offline, the client shuffles its local cache instead and never
|
||||||
|
// calls this.
|
||||||
|
func (h *handlers) handleLibraryShuffle(w http.ResponseWriter, r *http.Request) {
|
||||||
|
user, ok := requireUser(w, r)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
limit := 100
|
||||||
|
if v := r.URL.Query().Get("limit"); v != "" {
|
||||||
|
if n, err := strconv.Atoi(v); err == nil {
|
||||||
|
limit = n
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if limit < 1 {
|
||||||
|
limit = 1
|
||||||
|
}
|
||||||
|
if limit > 500 {
|
||||||
|
limit = 500
|
||||||
|
}
|
||||||
|
rows, err := dbq.New(h.pool).ListRandomTracksForUser(r.Context(),
|
||||||
|
dbq.ListRandomTracksForUserParams{UserID: user.ID, Limit: int32(limit)})
|
||||||
|
if err != nil {
|
||||||
|
h.logger.Error("api: library shuffle", "err", err)
|
||||||
|
writeErr(w, apierror.InternalMsg("shuffle failed", err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
out := make([]TrackRef, 0, len(rows))
|
||||||
|
for _, row := range rows {
|
||||||
|
out = append(out, trackRefFrom(row.Track, row.AlbumTitle, row.ArtistName))
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, out)
|
||||||
|
}
|
||||||
|
|
||||||
// handleListArtists implements GET /api/artists with two sort modes
|
// handleListArtists implements GET /api/artists with two sort modes
|
||||||
// (alpha|newest) and enveloped pagination. The alpha branch uses
|
// (alpha|newest) and enveloped pagination. The alpha branch uses
|
||||||
// ListArtistsAlphaWithCovers to ship cover_url + album_count in a
|
// ListArtistsAlphaWithCovers to ship cover_url + album_count in a
|
||||||
|
|||||||
@@ -255,6 +255,73 @@ func (q *Queries) ListArtistTracksForUser(ctx context.Context, arg ListArtistTra
|
|||||||
return items, nil
|
return items, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const listRandomTracksForUser = `-- name: ListRandomTracksForUser :many
|
||||||
|
SELECT t.id, t.title, t.album_id, t.artist_id, t.track_number, t.disc_number, t.duration_ms, t.file_path, t.file_size, t.file_format, t.bitrate, t.mbid, t.genre, t.added_at, t.updated_at,
|
||||||
|
albums.title AS album_title,
|
||||||
|
artists.name AS artist_name
|
||||||
|
FROM tracks t
|
||||||
|
JOIN albums ON albums.id = t.album_id
|
||||||
|
JOIN artists ON artists.id = t.artist_id
|
||||||
|
WHERE NOT EXISTS (
|
||||||
|
SELECT 1 FROM lidarr_quarantine q
|
||||||
|
WHERE q.user_id = $1 AND q.track_id = t.id
|
||||||
|
)
|
||||||
|
ORDER BY random()
|
||||||
|
LIMIT $2
|
||||||
|
`
|
||||||
|
|
||||||
|
type ListRandomTracksForUserParams struct {
|
||||||
|
UserID pgtype.UUID
|
||||||
|
Limit int32
|
||||||
|
}
|
||||||
|
|
||||||
|
type ListRandomTracksForUserRow struct {
|
||||||
|
Track Track
|
||||||
|
AlbumTitle string
|
||||||
|
ArtistName string
|
||||||
|
}
|
||||||
|
|
||||||
|
// #427 S4: backing query for GET /api/library/shuffle — the online
|
||||||
|
// source for "Shuffle all". N random tracks across the whole
|
||||||
|
// library, per-user-quarantine filtered. $1 user_id, $2 limit.
|
||||||
|
func (q *Queries) ListRandomTracksForUser(ctx context.Context, arg ListRandomTracksForUserParams) ([]ListRandomTracksForUserRow, error) {
|
||||||
|
rows, err := q.db.Query(ctx, listRandomTracksForUser, arg.UserID, arg.Limit)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
var items []ListRandomTracksForUserRow
|
||||||
|
for rows.Next() {
|
||||||
|
var i ListRandomTracksForUserRow
|
||||||
|
if err := rows.Scan(
|
||||||
|
&i.Track.ID,
|
||||||
|
&i.Track.Title,
|
||||||
|
&i.Track.AlbumID,
|
||||||
|
&i.Track.ArtistID,
|
||||||
|
&i.Track.TrackNumber,
|
||||||
|
&i.Track.DiscNumber,
|
||||||
|
&i.Track.DurationMs,
|
||||||
|
&i.Track.FilePath,
|
||||||
|
&i.Track.FileSize,
|
||||||
|
&i.Track.FileFormat,
|
||||||
|
&i.Track.Bitrate,
|
||||||
|
&i.Track.Mbid,
|
||||||
|
&i.Track.Genre,
|
||||||
|
&i.Track.AddedAt,
|
||||||
|
&i.Track.UpdatedAt,
|
||||||
|
&i.AlbumTitle,
|
||||||
|
&i.ArtistName,
|
||||||
|
); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
items = append(items, i)
|
||||||
|
}
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return items, nil
|
||||||
|
}
|
||||||
|
|
||||||
const listTracksByAlbum = `-- name: ListTracksByAlbum :many
|
const listTracksByAlbum = `-- name: ListTracksByAlbum :many
|
||||||
SELECT id, title, album_id, artist_id, track_number, disc_number, duration_ms, file_path, file_size, file_format, bitrate, mbid, genre, added_at, updated_at FROM tracks
|
SELECT id, title, album_id, artist_id, track_number, disc_number, duration_ms, file_path, file_size, file_format, bitrate, mbid, genre, added_at, updated_at FROM tracks
|
||||||
WHERE album_id = $1
|
WHERE album_id = $1
|
||||||
|
|||||||
@@ -81,6 +81,23 @@ WHERE t.artist_id = $1
|
|||||||
ORDER BY albums.release_date NULLS LAST, albums.sort_title,
|
ORDER BY albums.release_date NULLS LAST, albums.sort_title,
|
||||||
t.disc_number NULLS FIRST, t.track_number NULLS FIRST, t.id;
|
t.disc_number NULLS FIRST, t.track_number NULLS FIRST, t.id;
|
||||||
|
|
||||||
|
-- name: ListRandomTracksForUser :many
|
||||||
|
-- #427 S4: backing query for GET /api/library/shuffle — the online
|
||||||
|
-- source for "Shuffle all". N random tracks across the whole
|
||||||
|
-- library, per-user-quarantine filtered. $1 user_id, $2 limit.
|
||||||
|
SELECT sqlc.embed(t),
|
||||||
|
albums.title AS album_title,
|
||||||
|
artists.name AS artist_name
|
||||||
|
FROM tracks t
|
||||||
|
JOIN albums ON albums.id = t.album_id
|
||||||
|
JOIN artists ON artists.id = t.artist_id
|
||||||
|
WHERE NOT EXISTS (
|
||||||
|
SELECT 1 FROM lidarr_quarantine q
|
||||||
|
WHERE q.user_id = $1 AND q.track_id = t.id
|
||||||
|
)
|
||||||
|
ORDER BY random()
|
||||||
|
LIMIT $2;
|
||||||
|
|
||||||
-- name: CountTracksByArtist :one
|
-- name: CountTracksByArtist :one
|
||||||
-- Used by request-progress reporting to count tracks ingested under a
|
-- Used by request-progress reporting to count tracks ingested under a
|
||||||
-- matched artist (sum across all the artist's albums) while a request
|
-- matched artist (sum across all the artist's albums) while a request
|
||||||
|
|||||||
Reference in New Issue
Block a user