193 lines
6.2 KiB
Dart
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 Ratune'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; // Ratune'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 (_) {}
|
|
}
|
|
}
|