fix(flutter): restore artist coverUrl on drift-cached ArtistRef

Multi-artist surfaces (home Rediscover, Library Artists tab, Liked
Artists carousel) were all rendering the music-notes placeholder
instead of real artist covers. Root cause:

CachedArtist.toRef() returned an ArtistRef with empty coverUrl —
the cache doesn't store the representative album id the server
derives at query time, and the adapter never reconstructed it.
The artist detail screen worked coincidentally because it derives
its header cover from `artist.albums[0].id` directly rather than
the ArtistRef's coverUrl field.

Fix:
* CachedArtistAdapter.toRef() now accepts an optional coverAlbumId
  and reconstructs `/api/albums/<id>/cover` when given. Matches the
  pattern AlbumRef uses (deterministic URL from entity id).
* artistTileProvider, libraryArtistsProvider, and artistProvider
  (single-artist) each LEFT JOIN cached_albums ordered by sort_title.
  First row per artist carries the alphabetically-first album id;
  toRef projects that into the cover URL.
* Multi-artist queries dedup in toResult since the join multiplies
  rows by album count.

Artists with no albums yet in drift come through with empty
coverUrl — UI falls back to the music-notes placeholder, same
behavior as before for that legitimately-coverless state.
This commit is contained in:
2026-05-14 12:01:02 -04:00
parent 2a18e91c39
commit 8cb9a8b797
4 changed files with 78 additions and 13 deletions
+12 -1
View File
@@ -17,10 +17,21 @@ import '../models/track.dart';
import 'db.dart';
extension CachedArtistAdapter on CachedArtist {
ArtistRef toRef() => ArtistRef(
/// `coverAlbumId` lets the caller pass a representative album id so
/// the ArtistRef carries a reconstructed `/api/albums/<id>/cover`
/// URL. cached_artists doesn't store this directly — the server
/// derives it from the most-recent album at query time — so drift
/// readers that want the cover URL join cached_albums and pass the
/// first album's id through. Empty string yields empty coverUrl,
/// matching the server's behavior for endpoints that don't carry
/// the lookup (artist detail, search, raw cached_artists row reads).
ArtistRef toRef({String coverAlbumId = ''}) => ArtistRef(
id: id,
name: name,
sortName: sortName,
coverUrl: coverAlbumId.isNotEmpty
? '/api/albums/$coverAlbumId/cover'
: '',
);
}
+19 -2
View File
@@ -18,6 +18,7 @@
// subscriptions; drift handles this fine in practice but worth
// measuring if a future surface scales to hundreds.
import 'package:drift/drift.dart' as drift show OrderingTerm;
import 'package:drift/drift.dart' show leftOuterJoin;
import 'package:flutter_riverpod/flutter_riverpod.dart';
@@ -63,6 +64,12 @@ final albumTileProvider =
/// Watches the cached_artists row for [id]. Triggers background
/// hydration on miss; yields the row once it lands.
///
/// LEFT JOIN cached_albums ordered by sort_title so the first row per
/// artist carries a representative album id; toRef() reconstructs
/// `/api/albums/<id>/cover` from it. Without this join the ArtistRef
/// has an empty coverUrl and tiles fall back to the music-notes
/// placeholder even though the server has the cover available.
final artistTileProvider =
StreamProvider.family<ArtistRef?, String>((ref, id) async* {
if (id.isEmpty) {
@@ -70,7 +77,12 @@ final artistTileProvider =
return;
}
final db = ref.watch(appDbProvider);
final query = db.select(db.cachedArtists)..where((t) => t.id.equals(id));
final query = (db.select(db.cachedArtists)..where((t) => t.id.equals(id)))
.join([
leftOuterJoin(db.cachedAlbums,
db.cachedAlbums.artistId.equalsExp(db.cachedArtists.id)),
])
..orderBy([drift.OrderingTerm.asc(db.cachedAlbums.sortTitle)]);
var enqueued = false;
await for (final rows in query.watch()) {
if (rows.isEmpty) {
@@ -83,7 +95,12 @@ final artistTileProvider =
yield null;
continue;
}
yield rows.first.toRef();
// First row has the alphabetically-first album (or null if artist
// has no albums in drift yet — yields a coverless ArtistRef which
// the UI falls back to the placeholder icon for).
final artist = rows.first.readTable(db.cachedArtists);
final firstAlbum = rows.first.readTableOrNull(db.cachedAlbums);
yield artist.toRef(coverAlbumId: firstAlbum?.id ?? '');
}
});
@@ -233,17 +233,28 @@ Map<String, dynamic> _trackToJson(TrackRef t) => {
final artistProvider =
StreamProvider.family<ArtistRef, String>((ref, id) {
final db = ref.watch(appDbProvider);
return cacheFirst<CachedArtist, ArtistRef>(
driftStream: (db.select(db.cachedArtists)..where((t) => t.id.equals(id)))
.watch(),
// LEFT JOIN cached_albums for cover-URL reconstruction (see
// CachedArtistAdapter.toRef). First row carries the alphabetically-
// first album.
final query = (db.select(db.cachedArtists)..where((t) => t.id.equals(id)))
.join([
drift.leftOuterJoin(db.cachedAlbums,
db.cachedAlbums.artistId.equalsExp(db.cachedArtists.id)),
])
..orderBy([drift.OrderingTerm.asc(db.cachedAlbums.sortTitle)]);
return cacheFirst<drift.TypedResult, ArtistRef>(
driftStream: query.watch(),
fetchAndPopulate: () async {
final api = await ref.read(libraryApiProvider.future);
final fresh = await api.getArtist(id);
await db.into(db.cachedArtists).insertOnConflictUpdate(fresh.toDrift());
},
toResult: (rows) => rows.isEmpty
? const ArtistRef(id: '', name: '')
: rows.first.toRef(),
toResult: (rows) {
if (rows.isEmpty) return const ArtistRef(id: '', name: '');
final artist = rows.first.readTable(db.cachedArtists);
final firstAlbum = rows.first.readTableOrNull(db.cachedAlbums);
return artist.toRef(coverAlbumId: firstAlbum?.id ?? '');
},
isOnline: () async => (await ref
.read(connectivityProvider.future)
.timeout(const Duration(seconds: 3), onTimeout: () => true)),
+30 -4
View File
@@ -62,9 +62,19 @@ final _meApiProvider = FutureProvider<MeApi>((ref) async {
/// via /api/library/sync.
final _libraryArtistsProvider = StreamProvider<List<ArtistRef>>((ref) {
final db = ref.watch(appDbProvider);
final query = db.select(db.cachedArtists)
..orderBy([(t) => drift.OrderingTerm.asc(t.sortName)]);
return cacheFirst<CachedArtist, List<ArtistRef>>(
// LEFT JOIN cached_albums so each artist row carries a representative
// album id for cover-URL reconstruction. Sorted by artist sortName
// then album sortTitle: the first row per artist gets the
// alphabetically-first album. Dedup happens in toResult.
final query = db.select(db.cachedArtists).join([
drift.leftOuterJoin(db.cachedAlbums,
db.cachedAlbums.artistId.equalsExp(db.cachedArtists.id)),
])
..orderBy([
drift.OrderingTerm.asc(db.cachedArtists.sortName),
drift.OrderingTerm.asc(db.cachedAlbums.sortTitle),
]);
return cacheFirst<drift.TypedResult, List<ArtistRef>>(
driftStream: query.watch(),
fetchAndPopulate: () async {
// Cold-cache fallback: sync should have populated drift already,
@@ -80,7 +90,23 @@ final _libraryArtistsProvider = StreamProvider<List<ArtistRef>>((ref) {
);
});
},
toResult: (rows) => rows.map((r) => r.toRef()).toList(),
toResult: (rows) {
// The join multiplies rows by album count; dedup by artist id
// keeping the first occurrence so we get one ArtistRef per
// artist with the alphabetically-first album's id for the
// cover-URL projection. Artists with no albums in drift yet
// come through as a single row with null cachedAlbums and an
// empty coverUrl — UI falls back to the placeholder icon.
final seen = <String>{};
final out = <ArtistRef>[];
for (final r in rows) {
final a = r.readTable(db.cachedArtists);
if (!seen.add(a.id)) continue;
final album = r.readTableOrNull(db.cachedAlbums);
out.add(a.toRef(coverAlbumId: album?.id ?? ''));
}
return out;
},
isOnline: () async => (await ref
.read(connectivityProvider.future)
.timeout(const Duration(seconds: 3), onTimeout: () => true)),