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 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? 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 { 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 _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 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((e) => Song.fromJson(e.cast())) .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 refresh() async { state = const LibraryIndexState(); await build(); } Future 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 = []; 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 = {}; 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 []; // 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 _persist( SubsonicClient client, List 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 (_) {} } }