init
This commit is contained in:
commit
d205277cdd
182 changed files with 22978 additions and 0 deletions
193
lib/library/library_index.dart
Normal file
193
lib/library/library_index.dart
Normal file
|
|
@ -0,0 +1,193 @@
|
|||
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 (_) {}
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue