Files
minstrel/flutter_client/lib/api/endpoints/discover.dart
T
bvandeusen 47572b2e95 feat(flutter): Discover suggestions feed — web parity
The Flutter Discover screen was Lidarr-search-only; its empty state
showed a "Type to search" placeholder while web's /discover renders
the LB-derived out-of-library artist SuggestionFeed as its default
surface. Parity gap that slipped #356.

- ArtistSuggestion/SeedContribution model mirroring web types, with
  attributionText() matching web's "Because you liked/played X[, Y, and
  Z]." (Oxford comma, max 3).
- DiscoverApi.listSuggestions() → GET /api/discover/suggestions
  (image_url already resolved server-side from Lidarr, a7bea43).
- discover_screen: empty search box → suggestions feed (artist art +
  name + attribution + Request, reusing the existing createRequest +
  mutation-queue-replay flow with optimistic hide); typing → Lidarr
  search replaces; clearing → suggestions return. Mirrors web exactly.

Flutter-only; server endpoint unchanged.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 20:48:54 -04:00

74 lines
2.4 KiB
Dart

import 'package:dio/dio.dart';
import '../../models/artist_suggestion.dart';
import '../../models/lidarr.dart';
class DiscoverApi {
DiscoverApi(this._dio);
final Dio _dio;
/// GET /api/discover/suggestions — out-of-library artist suggestions
/// (ListenBrainz-derived; image_url resolved on-demand from Lidarr,
/// may be empty). The server already filters in-library and
/// non-terminal-request candidates.
Future<List<ArtistSuggestion>> listSuggestions() async {
final r = await _dio.get<List<dynamic>>('/api/discover/suggestions');
final raw = r.data ?? const [];
return raw
.map((e) =>
ArtistSuggestion.fromJson((e as Map).cast<String, dynamic>()))
.toList(growable: false);
}
/// GET /api/lidarr/search?q=...&kind=artist|album|track. Server has a
/// 60s LRU cache for repeat queries so re-typing the same string in
/// quick succession is cheap.
Future<List<LidarrSearchResult>> search(
String query,
LidarrRequestKind kind,
) async {
final r = await _dio.get<List<dynamic>>(
'/api/lidarr/search',
queryParameters: {'q': query, 'kind': kind.wire},
);
final raw = r.data ?? const [];
return raw
.map((e) =>
LidarrSearchResult.fromJson((e as Map).cast<String, dynamic>()))
.toList(growable: false);
}
/// POST /api/requests. Returns the newly-created LidarrRequest body
/// — we don't expose a typed wrapper for that yet on mobile (admin
/// reviews requests; user just kicks them off), so we discard the
/// response and surface success/failure to the caller.
Future<void> createRequest({
required LidarrRequestKind kind,
required String artistMbid,
required String artistName,
String? albumMbid,
String? albumTitle,
String? trackMbid,
String? trackTitle,
}) async {
final body = <String, dynamic>{
'kind': kind.wire,
'lidarr_artist_mbid': artistMbid,
'artist_name': artistName,
};
if (albumMbid != null && albumMbid.isNotEmpty) {
body['lidarr_album_mbid'] = albumMbid;
}
if (trackMbid != null && trackMbid.isNotEmpty) {
body['lidarr_track_mbid'] = trackMbid;
}
if (albumTitle != null && albumTitle.isNotEmpty) {
body['album_title'] = albumTitle;
}
if (trackTitle != null && trackTitle.isNotEmpty) {
body['track_title'] = trackTitle;
}
await _dio.post<Map<String, dynamic>>('/api/requests', data: body);
}
}