Automatische Erkennung: neue Dateien auf dem Geraet und neue Alben am Server

- AutoScan: beim Zurueckkehren in die App wird die Zahl der Musikdateien
  mit der Bibliothek verglichen; nur bei Abweichung laeuft ein Scan, und
  hoechstens alle 5 Minuten. Gleich viele Loeschungen wie Neuzugaenge
  bleiben unbemerkt - dafuer gibt es den Scan von Hand.
- ServerNeuheiten: meldet neu auf Navidrome aufgetauchte Alben im
  Online-Tab. Der erste Abruf meldet bewusst nichts, sonst waere die ganze
  Sammlung "neu"; er nutzt die ohnehin geladenen Alben statt eines zweiten
  Serveraufrufs.
- Neu: MeloDb.countSongs(), countAndroidMediaStoreSongs().

189 Tests gruen, flutter analyze ohne Befund.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018NLW1q1hzEC9goDQ1dJ98d
This commit is contained in:
Hermes (Server)
2026-08-20 19:19:04 +02:00
co-authored by Claude Opus 5
parent 6200c77614
commit 15e3d44453
10 changed files with 405 additions and 1 deletions
+26
View File
@@ -9,8 +9,10 @@ import '../player/audio_handler.dart';
import '../services/baka_auth.dart';
import '../services/media_store.dart';
import '../services/navidrome_service.dart';
import '../services/server_neuheiten.dart';
import '../services/yt_download_service.dart';
import '../shared/sub_tabs.dart';
import '../shared/theme.dart';
/// Online-Tab: durchsucht den verbundenen Navidrome-Server. Abgespielte
/// Titel werden vom [MeloAudioHandler] automatisch lokal zwischengespeichert
@@ -63,6 +65,9 @@ class _ServerBrowser extends StatefulWidget {
class _ServerBrowserState extends State<_ServerBrowser> {
late final NavidromeService _nav = NavidromeService();
// Die Alben sind beim Prüfen schon geladen — kein zweiter Serveraufruf.
late final ServerNeuheiten _neuheiten =
ServerNeuheiten(holeAlben: () async => _albums);
List<SubsonicAlbum> _albums = [];
List<SubsonicArtist> _artists = [];
bool _loading = false;
@@ -94,6 +99,9 @@ class _ServerBrowserState extends State<_ServerBrowser> {
_albums = alben;
_loading = false;
});
// Erst jetzt prüfen — _albums ist gefüllt.
await _neuheiten.pruefe();
if (mounted) setState(() {});
}
} catch (e) {
if (mounted) {
@@ -187,6 +195,24 @@ class _ServerBrowserState extends State<_ServerBrowser> {
return Column(
children: [
if (_neuheiten.neue.isNotEmpty)
MaterialBanner(
backgroundColor: MeloTheme.surfaceHigh,
content: Text(
_neuheiten.neue.length == 1
? 'Neu auf dem Server: ${_neuheiten.neue.first.name}'
: '${_neuheiten.neue.length} neue Alben auf dem Server',
),
actions: [
TextButton(
onPressed: () async {
await _neuheiten.alsGesehenMarkieren();
if (mounted) setState(() {});
},
child: const Text('Verstanden'),
),
],
),
Padding(
padding: const EdgeInsets.all(12),
child: Row(
+8
View File
@@ -11,6 +11,14 @@ import 'database.dart';
const _uuid = Uuid();
/// Zählt die Musikdateien, die der MediaStore kennt. Grundlage für die
/// automatische Erkennung: weicht die Zahl von der Bibliothek ab, ist etwas
/// dazugekommen oder verschwunden.
Future<int> countAndroidMediaStoreSongs() async {
final all = await OnAudioQuery().querySongs(uriType: UriType.EXTERNAL);
return all.where((s) => s.isMusic ?? true).length;
}
/// Android-Scan über MediaStore (der einzige zuverlässige Weg ab Android 11,
/// da Scoped Storage direkten Dateizugriff blockiert). Findet automatisch alle
/// Musik auf dem Gerät. Schreibt in dieselbe DB wie der Desktop-Scan,
+61
View File
@@ -0,0 +1,61 @@
import 'package:flutter/foundation.dart';
/// Erkennt von allein, wenn sich der Musikbestand des Geräts geändert hat —
/// etwa weil Dateien per Kabel übertragen oder heruntergeladen wurden — und
/// stößt dann einen Scan an.
///
/// Bewusst nur ein Zählvergleich: der Scan selbst ist der teure Teil und soll
/// nicht bei jedem Wechsel in den Vordergrund laufen. Werden gleich viele
/// Titel gelöscht wie hinzugefügt, merkt das erst der nächste Scan von Hand.
class AutoScan {
AutoScan({
required this.geraeteAnzahl,
required this.bekannteAnzahl,
required this.scanne,
this.mindestAbstand = const Duration(minutes: 5),
DateTime Function()? jetzt,
}) : _jetzt = jetzt ?? DateTime.now;
/// Wie viele Musikdateien das Gerät meldet.
final Future<int> Function() geraeteAnzahl;
/// Wie viele Titel die Bibliothek kennt.
final Future<int> Function() bekannteAnzahl;
final Future<void> Function() scanne;
/// So lange wird nach einer Prüfung gar nicht erst wieder gezählt.
final Duration mindestAbstand;
final DateTime Function() _jetzt;
DateTime? _zuletzt;
bool _laeuft = false;
/// Prüft, ob sich etwas geändert hat, und scannt gegebenenfalls.
/// Gibt zurück, ob ein Scan angestoßen wurde.
Future<bool> pruefe() async {
if (_laeuft) return false;
final jetzt = _jetzt();
final zuletzt = _zuletzt;
if (zuletzt != null && jetzt.difference(zuletzt) < mindestAbstand) {
return false;
}
_zuletzt = jetzt;
_laeuft = true;
try {
final auf = await geraeteAnzahl();
final bekannt = await bekannteAnzahl();
if (auf == bekannt) return false;
await scanne();
return true;
} catch (e) {
// Kein Grund, die App zu stören — der Knopf zum Scannen bleibt ja da.
debugPrint('Automatische Erkennung fehlgeschlagen: $e');
return false;
} finally {
_laeuft = false;
}
}
}
+10
View File
@@ -188,6 +188,16 @@ class MeloDb extends _$MeloDb {
/// Alle Songs inkl. getombstonte. Intern für ID-Stabilität beim Scan.
Future<List<Song>> allSongs() => select(songs).get();
/// Wie viele Titel die Bibliothek kennt (ohne getombstonte).
Future<int> countSongs() async {
final anzahl = songs.id.count();
final zeile = await (selectOnly(songs)
..addColumns([anzahl])
..where(songs.deleted.equals(false)))
.getSingle();
return zeile.read(anzahl) ?? 0;
}
/// Upsert Songs nach Pfad. Neue Songs bekommen UUID; bekannte erhalten sie zurück.
/// Setzt [updatedAtMs] auf die Werte der Companions (normalerweise Scan-Startzeit).
Future<void> upsertSongs(List<SongsCompanion> items) async {
+33 -1
View File
@@ -1,8 +1,12 @@
import 'dart:io';
import 'package:audio_service/audio_service.dart';
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'downloads/downloads_screen.dart';
import 'library/android_scan.dart';
import 'library/auto_scan.dart';
import 'library/category_service.dart';
import 'library/database.dart';
import 'library/favorites_screen.dart';
@@ -107,11 +111,39 @@ class HomeShell extends StatefulWidget {
State<HomeShell> createState() => _HomeShellState();
}
class _HomeShellState extends State<HomeShell> {
class _HomeShellState extends State<HomeShell> with WidgetsBindingObserver {
int _index = 0;
late final AutoScan _autoScan;
void _goTo(int index) => setState(() => _index = index);
@override
void initState() {
super.initState();
final db = context.read<MeloDb>();
final lib = context.read<LibraryService>();
_autoScan = AutoScan(
geraeteAnzahl: countAndroidMediaStoreSongs,
bekannteAnzahl: db.countSongs,
scanne: lib.rescan,
);
WidgetsBinding.instance.addObserver(this);
}
@override
void dispose() {
WidgetsBinding.instance.removeObserver(this);
super.dispose();
}
@override
void didChangeAppLifecycleState(AppLifecycleState state) {
// Zurück in der App: nachsehen, ob inzwischen Musik dazugekommen ist.
if (state == AppLifecycleState.resumed && Platform.isAndroid) {
_autoScan.pruefe();
}
}
@override
Widget build(BuildContext context) {
final tabs = <Widget>[
+55
View File
@@ -0,0 +1,55 @@
import 'package:flutter/foundation.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'navidrome_service.dart';
/// Merkt sich, welche Alben auf dem Musikserver schon bekannt waren, und
/// meldet, was seitdem dazugekommen ist.
class ServerNeuheiten extends ChangeNotifier {
ServerNeuheiten({required this.holeAlben});
static const _key = 'server_bekannte_alben';
/// Holt die neuesten Alben vom Server.
final Future<List<SubsonicAlbum>> Function() holeAlben;
List<SubsonicAlbum> _neue = const [];
/// Alben, die seit dem letzten Nachsehen dazugekommen sind.
List<SubsonicAlbum> get neue => _neue;
Future<void> pruefe() async {
final List<SubsonicAlbum> alben;
try {
alben = await holeAlben();
} catch (e) {
debugPrint('Server-Neuheiten konnten nicht geladen werden: $e');
return;
}
final prefs = await SharedPreferences.getInstance();
final bekannt = prefs.getStringList(_key);
// Beim allerersten Mal ist noch nichts bekannt. Alles als neu zu melden
// wäre unbrauchbar — also erst einmal nur den Stand festhalten.
if (bekannt == null) {
await prefs.setStringList(_key, alben.map((a) => a.id).toList());
return;
}
final gesehen = bekannt.toSet();
_neue = alben.where((a) => !gesehen.contains(a.id)).toList();
notifyListeners();
}
/// Nimmt die gemeldeten Alben zur Kenntnis — sie tauchen nicht wieder auf.
Future<void> alsGesehenMarkieren() async {
if (_neue.isEmpty) return;
final prefs = await SharedPreferences.getInstance();
final bekannt = (prefs.getStringList(_key) ?? const <String>[]).toSet()
..addAll(_neue.map((a) => a.id));
await prefs.setStringList(_key, bekannt.toList());
_neue = const [];
notifyListeners();
}
}