Files
bvandeusen 8cb9a8b797 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.
2026-05-14 12:01:02 -04:00

134 lines
4.7 KiB
Dart

// Drift row → model adapters and reverse-write adapters for populating
// drift from REST responses (#357 plan C).
//
// The cache loses some server-derived fields:
// - ArtistRef.coverUrl (server computes from most-recent album)
// - AlbumRef.coverUrl (server-emitted derived path)
// - TrackRef.streamUrl (could be reconstructed but kept empty for clarity)
// UI already handles empty coverUrl/streamUrl gracefully. REST cold-cache
// fallback briefly shows the real values before drift takes over.
import 'package:drift/drift.dart' as drift;
import '../models/album.dart';
import '../models/artist.dart';
import '../models/playlist.dart';
import '../models/track.dart';
import 'db.dart';
extension CachedArtistAdapter on CachedArtist {
/// `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'
: '',
);
}
extension ArtistRefDriftWrite on ArtistRef {
CachedArtistsCompanion toDrift() => CachedArtistsCompanion.insert(
id: id,
name: name,
sortName: sortName.isNotEmpty ? sortName : name,
);
}
extension CachedAlbumAdapter on CachedAlbum {
/// `artistName` is supplied by the joined CachedArtists row at query time.
/// `coverUrl` is reconstructed deterministically from the album id —
/// the server emits the same shape (see internal/api/convert.go:69).
/// We don't need to persist it, so AlbumRef.coverUrl is non-empty
/// even when the row was populated from a sync that didn't carry the
/// derived URL.
AlbumRef toRef({String artistName = ''}) => AlbumRef(
id: id,
title: title,
sortTitle: sortTitle,
artistId: artistId,
artistName: artistName,
coverUrl: '/api/albums/$id/cover',
);
}
extension AlbumRefDriftWrite on AlbumRef {
CachedAlbumsCompanion toDrift() => CachedAlbumsCompanion.insert(
id: id,
artistId: artistId,
title: title,
sortTitle: sortTitle.isNotEmpty ? sortTitle : title,
);
}
extension CachedTrackAdapter on CachedTrack {
/// `artistName` and `albumTitle` come from joined rows.
TrackRef toRef({String artistName = '', String albumTitle = ''}) => TrackRef(
id: id,
title: title,
albumId: albumId,
albumTitle: albumTitle,
artistId: artistId,
artistName: artistName,
trackNumber: trackNumber,
discNumber: discNumber,
durationSec: durationMs ~/ 1000,
);
}
extension TrackRefDriftWrite on TrackRef {
CachedTracksCompanion toDrift() => CachedTracksCompanion.insert(
id: id,
albumId: albumId,
artistId: artistId,
title: title,
durationMs: drift.Value(durationSec * 1000),
trackNumber: drift.Value(trackNumber),
discNumber: drift.Value(discNumber),
);
}
extension CachedPlaylistAdapter on CachedPlaylist {
/// `ownerUsername` is server-derived; cache stores the userId only.
/// Pass empty unless a join supplies it.
/// `coverUrl` is reconstructed deterministically from the playlist
/// id — the server serves the cached collage at this path
/// (handleGetPlaylistCover). Mirrors the album-cover trick. When
/// the server hasn't built a collage yet (system playlists with no
/// tracks at build time), the endpoint 404s and PlaylistCard's
/// ServerImage falls back to its slate placeholder.
Playlist toRef({String ownerUsername = ''}) => Playlist(
id: id,
userId: userId,
name: name,
description: description,
isPublic: isPublic,
systemVariant: systemVariant,
trackCount: trackCount,
coverUrl: '/api/playlists/$id/cover',
ownerUsername: ownerUsername,
createdAt: '',
updatedAt: '',
);
}
extension PlaylistDriftWrite on Playlist {
CachedPlaylistsCompanion toDrift() => CachedPlaylistsCompanion.insert(
id: id,
userId: userId,
name: name,
description: drift.Value(description),
isPublic: drift.Value(isPublic),
trackCount: drift.Value(trackCount),
systemVariant: drift.Value(systemVariant),
);
}