mobile-music/lib/library/library_index.dart
2026-07-29 22:41:38 -04:00

193 lines
6.2 KiB
Dart

import 'dart:convert';
import 'dart:io';
import 'package:crypto/crypto.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:path_provider/path_provider.dart';
import '../subsonic/models.dart';
import '../subsonic/subsonic_client.dart';
/// Snapshot of the library-song index: the flat song list plus build progress.
class LibraryIndexState {
const LibraryIndexState({
this.songs = const [],
this.building = false,
this.done = 0,
this.total = 0,
this.error,
});
final List<Song> songs;
final bool building;
/// Albums crawled so far / total albums (drives "Indexing 120/340…").
final int done;
final int total;
final String? error;
bool get isEmpty => songs.isEmpty;
LibraryIndexState copyWith({
List<Song>? songs,
bool? building,
int? done,
int? total,
String? error,
}) =>
LibraryIndexState(
songs: songs ?? this.songs,
building: building ?? this.building,
done: done ?? this.done,
total: total ?? this.total,
error: error,
);
}
/// Builds and caches a flat "all songs" index — Subsonic has no all-songs
/// endpoint, so we crawl every album (mirrors Timbre's `library_index` /
/// `fetch_all_library_songs`). The cache is keyed to the server so switching
/// servers never serves a stale catalog, and writes are atomic so an
/// interrupted crawl can't leave a truncated file.
class LibraryIndexController extends StateNotifier<LibraryIndexState> {
LibraryIndexController(this._clientGetter) : super(const LibraryIndexState());
final SubsonicClient? Function() _clientGetter;
static const int _albumParallelism = 12; // Timbre's album_parallelism.
static const int _pageSize = 500; // Subsonic getAlbumList2 cap.
static const Duration _ttl = Duration(hours: 24);
/// Bumped whenever the server changes / disconnects. Every async step checks
/// it and bails, so a build against the old server can't clobber the new one.
int _generation = 0;
/// Server changed or went offline: cancel any in-flight build and drop the
/// in-memory index. Wired to the connection listener in `providers.dart`.
void onConnectionChanged() {
_generation++;
state = const LibraryIndexState();
}
String _keyFor(SubsonicClient c) =>
md5.convert(utf8.encode('${c.baseUrl}|${c.username}')).toString();
Future<File> _cacheFile(SubsonicClient c) async {
final dir = await getApplicationSupportDirectory();
return File('${dir.path}/library_index_${_keyFor(c)}.json');
}
/// Load from a fresh, server-matching cache if present, otherwise [build].
/// Called by the Tracks view on first display; a no-op if already built or
/// building.
Future<void> ensureBuilt() async {
if (state.building || state.songs.isNotEmpty) return;
final client = _clientGetter();
if (client == null) return;
final gen = _generation;
try {
final file = await _cacheFile(client);
if (await file.exists()) {
final fresh =
DateTime.now().difference((await file.stat()).modified) < _ttl;
if (fresh) {
final raw = jsonDecode(await file.readAsString());
if (raw is List && gen == _generation) {
state = state.copyWith(
songs: raw
.whereType<Map>()
.map((e) => Song.fromJson(e.cast<String, dynamic>()))
.toList(),
);
return;
}
}
}
} catch (_) {
// Missing/corrupt cache is non-fatal — fall through to a rebuild.
}
if (gen != _generation) return;
await build();
}
/// Force a rebuild, ignoring the cache (the refresh affordance).
Future<void> refresh() async {
state = const LibraryIndexState();
await build();
}
Future<void> build() async {
final client = _clientGetter();
if (client == null) return;
final gen = _generation;
state = const LibraryIndexState(building: true);
try {
// 1. Page through every album (no silent cap — loop until a short page).
final albums = <Album>[];
var offset = 0;
while (true) {
if (gen != _generation) return;
final page = await client.getAlbumList2(size: _pageSize, offset: offset);
albums.addAll(page);
if (page.length < _pageSize) break;
offset += _pageSize;
}
if (gen != _generation) return;
state = state.copyWith(total: albums.length);
// 2. Fetch each album's songs with bounded concurrency; dedupe by id.
final byId = <String, Song>{};
var done = 0;
for (var i = 0; i < albums.length; i += _albumParallelism) {
if (gen != _generation) return;
final batch = albums.skip(i).take(_albumParallelism).toList();
final results = await Future.wait(batch.map((a) async {
try {
return (await client.getAlbum(a.id)).songs;
} catch (_) {
return const <Song>[]; // soft-fail a single album, keep crawling.
}
}));
for (final songs in results) {
for (final s in songs) {
if (s.id.isNotEmpty) byId[s.id] = s;
}
}
done += batch.length;
if (gen != _generation) return;
state = state.copyWith(done: done);
}
final songs = byId.values.toList()
..sort((a, b) => (a.title ?? '')
.toLowerCase()
.compareTo((b.title ?? '').toLowerCase()));
if (gen != _generation) return;
state = state.copyWith(songs: songs, building: false, done: done);
await _persist(client, songs, gen);
} catch (e) {
if (gen != _generation) return;
state = state.copyWith(building: false, error: e.toString());
}
}
/// Atomic write: temp file then rename, so a crash mid-write can't truncate
/// the cache. Soft-fail — a missing cache just means a rebuild next launch.
Future<void> _persist(
SubsonicClient client,
List<Song> songs,
int gen,
) async {
try {
if (gen != _generation) return;
final file = await _cacheFile(client);
final tmp = File('${file.path}.tmp');
await tmp.writeAsString(jsonEncode(songs.map((s) => s.toJson()).toList()));
await tmp.rename(file.path);
} catch (_) {}
}
}