nkbmusic/lib/data/catalog_repository.dart
metatroncubeswdev d994fc733f Cover photos for albums and songs: upload in NK Admin, shown in the app
Songs without their own cover use their album's. Covers live in a public
'covers' bucket that only admins can write to.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-30 14:23:43 -04:00

173 lines
6.3 KiB
Dart

import 'dart:convert';
import 'package:flutter/services.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:supabase_flutter/supabase_flutter.dart';
import '../core/env.dart';
import '../core/models.dart';
import '../core/search_normalize.dart';
class Catalog {
Catalog({required List<Track> tracks, required List<Artist> artists, List<Album> albums = const []})
: tracks = List.unmodifiable(tracks),
artistsById = {for (final a in artists) a.id: a},
albumsById = {for (final a in albums) a.id: a};
/// Newest first.
final List<Track> tracks;
final Map<String, Artist> artistsById;
final Map<String, Album> albumsById;
/// Albums with songs, biggest first.
late final List<Album> albums = albumsById.values.where((a) => byAlbum(a.id).isNotEmpty).toList()
..sort((a, b) => byAlbum(b.id).length.compareTo(byAlbum(a.id).length));
late final Map<String, List<Track>> _byAlbum = () {
final map = <String, List<Track>>{};
// Oldest first, so an album plays in the order it was imported (its track order).
for (final t in tracks.reversed) {
if (t.albumId != null) map.putIfAbsent(t.albumId!, () => []).add(t);
}
return map;
}();
List<Track> byAlbum(String albumId) => _byAlbum[albumId] ?? const [];
Album? albumOf(Track t) => t.albumId == null ? null : albumsById[t.albumId];
List<Artist> get artists => artistsById.values.toList();
List<Artist> artistsOf(Track t) =>
t.artistIds.map((id) => artistsById[id]).whereType<Artist>().toList();
String artistLine(Track t) => artistsOf(t).map((a) => a.name).join(', ');
/// For display: many legacy songs have no singer credited yet.
String creditLine(Track t) {
final line = artistLine(t);
return line.isEmpty ? 'Singer not credited yet' : line;
}
List<Track> byOccasion(Occasion o) => tracks.where((t) => t.occasion == o).toList();
List<Track> byArtist(String artistId) =>
tracks.where((t) => t.artistIds.contains(artistId)).toList();
Track? trackById(String id) {
for (final t in tracks) {
if (t.id == id) return t;
}
return null;
}
List<Track> searchTracks(String query) => tracks
.where((t) => matchesSearch(query, [
t.titleLatin,
t.titleTamil,
t.meaning,
t.occasion.label,
artistLine(t),
albumOf(t)?.title,
]))
.toList();
List<Album> searchAlbums(String query) =>
albums.where((a) => matchesSearch(query, [a.title])).toList();
List<Artist> searchArtists(String query) =>
artists.where((a) => matchesSearch(query, [a.name, a.hatti])).toList();
}
abstract interface class CatalogRepository {
Future<Catalog> load();
}
/// Reads the bundled demo catalog. Used until Supabase is configured.
class AssetCatalogRepository implements CatalogRepository {
const AssetCatalogRepository([this.path = 'assets/catalog/demo_catalog.json']);
final String path;
@override
Future<Catalog> load() async => parseCatalogJson(await rootBundle.loadString(path));
}
Catalog parseCatalogJson(String raw) {
final json = jsonDecode(raw) as Map<String, dynamic>;
return Catalog(
tracks: (json['tracks'] as List).map((e) => Track.fromJson(e as Map<String, dynamic>)).toList(),
artists: (json['artists'] as List).map((e) => Artist.fromJson(e as Map<String, dynamic>)).toList(),
);
}
/// Reads approved songs from Supabase (schema: supabase/migrations/0001_init.sql).
/// Row-level security already hides anything that is not approved.
class SupabaseCatalogRepository implements CatalogRepository {
SupabaseCatalogRepository(this.client);
final SupabaseClient client;
@override
Future<Catalog> load() async {
final artistRows = await client.from('artists').select('id, name_latin, hatti:hattis(name_latin), photo_url, bio');
final albumRows = await client.from('albums').select('id, title_latin, year, cover_url');
final trackRows = await client
.from('tracks')
.select('id, title_latin, title_tamil, title_en_meaning, album_id, occasion, duration_ms, '
'audio_key_128, cover_url, tier, track_artists(artist_id, position, role)')
// Admins may read unapproved songs too; the app only ever lists approved ones.
.eq('status', 'approved')
.order('created_at', ascending: false);
final artists = [
for (final r in artistRows)
Artist(
id: r['id'] as String,
name: r['name_latin'] as String,
hatti: (r['hatti'] as Map?)?['name_latin'] as String?,
photoUrl: r['photo_url'] as String?,
bio: r['bio'] as String?,
),
];
// Songs without their own cover show their album's.
final albumCovers = {for (final r in albumRows) r['id'] as String: r['cover_url'] as String?};
final tracks = [
for (final r in trackRows)
Track(
id: r['id'] as String,
titleLatin: r['title_latin'] as String,
titleTamil: r['title_tamil'] as String?,
meaning: r['title_en_meaning'] as String?,
artistIds: ([...(r['track_artists'] as List).where((ta) => ta['role'] == 'singer')]
..sort((a, b) => (a['position'] as int).compareTo(b['position'] as int)))
.map((ta) => ta['artist_id'] as String)
.toList(),
albumId: r['album_id'] as String?,
occasion: Occasion.fromId(r['occasion'] as String?),
duration: Duration(milliseconds: (r['duration_ms'] as num?)?.toInt() ?? 0),
audioUrl: '${Env.mediaBaseUrl}/${r['audio_key_128']}',
coverUrl: r['cover_url'] as String? ?? albumCovers[r['album_id']],
tier: r['tier'] == 'premium' ? Tier.premium : Tier.free,
),
];
final albums = [
for (final r in albumRows)
Album(
id: r['id'] as String,
title: r['title_latin'] as String,
year: (r['year'] as num?)?.toInt(),
coverUrl: r['cover_url'] as String?,
),
];
return Catalog(tracks: tracks, artists: artists, albums: albums);
}
}
final catalogRepositoryProvider = Provider<CatalogRepository>((ref) {
if (Env.hasSupabase) return SupabaseCatalogRepository(Supabase.instance.client);
return const AssetCatalogRepository();
});
final catalogProvider = FutureProvider<Catalog>((ref) => ref.watch(catalogRepositoryProvider).load());