From 15e3d44453f32e5263bffb39ddca5b406c675e44 Mon Sep 17 00:00:00 2001 From: "Hermes (Server)" Date: Thu, 20 Aug 2026 19:19:04 +0200 Subject: [PATCH] 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 Claude-Session: https://claude.ai/code/session_018NLW1q1hzEC9goDQ1dJ98d --- CHANGELOG.md | 26 +++++++ lib/downloads/downloads_screen.dart | 26 +++++++ lib/library/android_scan.dart | 8 ++ lib/library/auto_scan.dart | 61 +++++++++++++++ lib/library/database.dart | 10 +++ lib/main.dart | 34 ++++++++- lib/services/server_neuheiten.dart | 55 ++++++++++++++ test/library/auto_scan_test.dart | 94 ++++++++++++++++++++++++ test/library/count_songs_test.dart | 28 +++++++ test/services/server_neuheiten_test.dart | 64 ++++++++++++++++ 10 files changed, 405 insertions(+), 1 deletion(-) create mode 100644 lib/library/auto_scan.dart create mode 100644 lib/services/server_neuheiten.dart create mode 100644 test/library/auto_scan_test.dart create mode 100644 test/library/count_songs_test.dart create mode 100644 test/services/server_neuheiten_test.dart diff --git a/CHANGELOG.md b/CHANGELOG.md index 301884b..6858f33 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,32 @@ Format angelehnt an [Keep a Changelog](https://keepachangelog.com/de/1.0.0/). ## [Unreleased] +### 🔎 Melo merkt selbst, wenn neue Musik da ist (2026-08-20) +- 📂 **Neue Dateien auf dem Handy:** Kommst du in die App zurĂŒck, sieht Melo + nach, ob sich der Musikbestand geĂ€ndert hat — und liest neue Titel von + allein ein. Musik per Kabel ĂŒberspielt oder anderswo heruntergeladen? Sie + ist einfach da, ohne dass du "Musik scannen" antippen musst. + 🔧 **Wie sparsam das ist:** Melo vergleicht nur die **Anzahl** der Dateien + und prĂŒft höchstens alle 5 Minuten. Der eigentliche Scan lĂ€uft nur, wenn + die Zahl abweicht — sonst passiert gar nichts. + ⚠ **Grenze, ehrlich gesagt:** Werden genauso viele Titel gelöscht wie + hinzukommen, bleibt die Zahl gleich und Melo merkt es nicht. DafĂŒr gibt es + weiterhin den Knopf zum Scannen von Hand. +- 🌐 **Neues auf dem Musikserver:** Im Tab **Online → Server** erscheint ein + Hinweis, wenn auf Navidrome Alben dazugekommen sind ("3 neue Alben auf dem + Server"). Ein Tipp auf *Verstanden* und der Hinweis ist weg — dieselben + Alben melden sich nicht noch einmal. + 🔧 Beim allerersten Mal meldet Melo bewusst **nichts**: sonst wĂ€re deine + ganze Sammlung auf einmal "neu". Melo merkt sich still den Stand und + meldet ab dann nur noch, was wirklich dazukommt. Der Hinweis kostet keinen + zusĂ€tzlichen Serveraufruf — er nutzt die Alben, die ohnehin geladen werden. +- 🔧 **Details:** + - Neu: `library/auto_scan.dart` (6 Tests), `services/server_neuheiten.dart` + (5 Tests), `MeloDb.countSongs()` (1 Test), + `countAndroidMediaStoreSongs()`. + - `HomeShell` hört jetzt auf den App-Lebenszyklus (`resumed`). +- ✅ **Getestet:** 189 Tests grĂŒn (12 neue), `flutter analyze` ohne Befund. + ### âŹ‡ïž YouTube-Downloader mit Baka-Anmeldung (2026-08-20) - 🎬 **Musik von YouTube holen:** Im Tab **Online → YouTube** eine Adresse einfĂŒgen, auf *Herunterladen* tippen — fertig. Der Titel landet danach diff --git a/lib/downloads/downloads_screen.dart b/lib/downloads/downloads_screen.dart index 1afe111..b25228e 100644 --- a/lib/downloads/downloads_screen.dart +++ b/lib/downloads/downloads_screen.dart @@ -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 _albums = []; List _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( diff --git a/lib/library/android_scan.dart b/lib/library/android_scan.dart index 5434b43..edb5652 100644 --- a/lib/library/android_scan.dart +++ b/lib/library/android_scan.dart @@ -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 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, diff --git a/lib/library/auto_scan.dart b/lib/library/auto_scan.dart new file mode 100644 index 0000000..48db055 --- /dev/null +++ b/lib/library/auto_scan.dart @@ -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 Function() geraeteAnzahl; + + /// Wie viele Titel die Bibliothek kennt. + final Future Function() bekannteAnzahl; + + final Future 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 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; + } + } +} diff --git a/lib/library/database.dart b/lib/library/database.dart index 7a78201..acc8224 100644 --- a/lib/library/database.dart +++ b/lib/library/database.dart @@ -188,6 +188,16 @@ class MeloDb extends _$MeloDb { /// Alle Songs inkl. getombstonte. Intern fĂŒr ID-StabilitĂ€t beim Scan. Future> allSongs() => select(songs).get(); + /// Wie viele Titel die Bibliothek kennt (ohne getombstonte). + Future 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 upsertSongs(List items) async { diff --git a/lib/main.dart b/lib/main.dart index 3caf192..8ee6aa7 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -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 createState() => _HomeShellState(); } -class _HomeShellState extends State { +class _HomeShellState extends State 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(); + final lib = context.read(); + _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 = [ diff --git a/lib/services/server_neuheiten.dart b/lib/services/server_neuheiten.dart new file mode 100644 index 0000000..ba34590 --- /dev/null +++ b/lib/services/server_neuheiten.dart @@ -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> Function() holeAlben; + + List _neue = const []; + + /// Alben, die seit dem letzten Nachsehen dazugekommen sind. + List get neue => _neue; + + Future pruefe() async { + final List 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 alsGesehenMarkieren() async { + if (_neue.isEmpty) return; + final prefs = await SharedPreferences.getInstance(); + final bekannt = (prefs.getStringList(_key) ?? const []).toSet() + ..addAll(_neue.map((a) => a.id)); + await prefs.setStringList(_key, bekannt.toList()); + _neue = const []; + notifyListeners(); + } +} diff --git a/test/library/auto_scan_test.dart b/test/library/auto_scan_test.dart new file mode 100644 index 0000000..682d5e0 --- /dev/null +++ b/test/library/auto_scan_test.dart @@ -0,0 +1,94 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:melo/library/auto_scan.dart'; + +void main() { + test('Mehr Dateien auf dem GerĂ€t als in der Bibliothek löst einen Scan aus', + () async { + var gescannt = 0; + final auto = AutoScan( + geraeteAnzahl: () async => 12, + bekannteAnzahl: () async => 10, + scanne: () async => gescannt++, + ); + + expect(await auto.pruefe(), isTrue); + expect(gescannt, 1); + }); + + test('Gleich viele Dateien lassen die Bibliothek in Ruhe', () async { + var gescannt = 0; + final auto = AutoScan( + geraeteAnzahl: () async => 10, + bekannteAnzahl: () async => 10, + scanne: () async => gescannt++, + ); + + expect(await auto.pruefe(), isFalse); + expect(gescannt, 0); + }); + + test('Auch gelöschte Dateien (weniger auf dem GerĂ€t) lösen einen Scan aus', + () async { + var gescannt = 0; + final auto = AutoScan( + geraeteAnzahl: () async => 8, + bekannteAnzahl: () async => 10, + scanne: () async => gescannt++, + ); + + expect(await auto.pruefe(), isTrue); + expect(gescannt, 1); + }); + + test('Zwei PrĂŒfungen kurz hintereinander zĂ€hlen nur einmal', () async { + var gezaehlt = 0; + var jetzt = DateTime(2026, 8, 20, 12, 0); + final auto = AutoScan( + geraeteAnzahl: () async { + gezaehlt++; + return 12; + }, + bekannteAnzahl: () async => 10, + scanne: () async {}, + mindestAbstand: const Duration(minutes: 5), + jetzt: () => jetzt, + ); + + await auto.pruefe(); + jetzt = jetzt.add(const Duration(minutes: 1)); + expect(await auto.pruefe(), isFalse); + expect(gezaehlt, 1); + }); + + test('Nach Ablauf des Mindestabstands wird wieder geprĂŒft', () async { + var gezaehlt = 0; + var jetzt = DateTime(2026, 8, 20, 12, 0); + final auto = AutoScan( + geraeteAnzahl: () async { + gezaehlt++; + return 12; + }, + bekannteAnzahl: () async => 10, + scanne: () async {}, + mindestAbstand: const Duration(minutes: 5), + jetzt: () => jetzt, + ); + + await auto.pruefe(); + jetzt = jetzt.add(const Duration(minutes: 6)); + expect(await auto.pruefe(), isTrue); + expect(gezaehlt, 2); + }); + + test('Ein Fehler beim ZĂ€hlen bleibt folgenlos', () async { + var gescannt = 0; + final auto = AutoScan( + geraeteAnzahl: () async => throw Exception('MediaStore streikt'), + bekannteAnzahl: () async => 10, + scanne: () async => gescannt++, + ); + + expect(await auto.pruefe(), isFalse); + expect(gescannt, 0); + }); +} diff --git a/test/library/count_songs_test.dart b/test/library/count_songs_test.dart new file mode 100644 index 0000000..1a94a65 --- /dev/null +++ b/test/library/count_songs_test.dart @@ -0,0 +1,28 @@ +import 'package:drift/drift.dart'; +import 'package:drift/native.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:melo/library/database.dart'; + +Future _insert(MeloDb db, String id, {bool deleted = false}) { + return db.into(db.songs).insert(SongsCompanion.insert( + id: id, + path: '/$id.mp3', + title: id, + dateAddedMs: 0, + updatedAtMs: 0, + deleted: Value(deleted), + )); +} + +void main() { + test('countSongs zĂ€hlt nur die Titel, die noch da sind', () async { + final db = MeloDb(NativeDatabase.memory()); + await _insert(db, 'a'); + await _insert(db, 'b'); + await _insert(db, 'c', deleted: true); + + expect(await db.countSongs(), 2); + + await db.close(); + }); +} diff --git a/test/services/server_neuheiten_test.dart b/test/services/server_neuheiten_test.dart new file mode 100644 index 0000000..4b04ce1 --- /dev/null +++ b/test/services/server_neuheiten_test.dart @@ -0,0 +1,64 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:melo/services/navidrome_service.dart'; +import 'package:melo/services/server_neuheiten.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +SubsonicAlbum _album(String id) => SubsonicAlbum(id: id, name: 'Album $id'); + +void main() { + setUp(() => SharedPreferences.setMockInitialValues({})); + + test('Der erste Abruf meldet nichts Neues — sonst wĂ€re alles neu', () async { + final n = ServerNeuheiten( + holeAlben: () async => [_album('1'), _album('2')]); + + await n.pruefe(); + + expect(n.neue, isEmpty); + }); + + test('Ein danach hinzugekommenes Album wird gemeldet', () async { + var alben = [_album('1'), _album('2')]; + final n = ServerNeuheiten(holeAlben: () async => alben); + await n.pruefe(); + + alben = [_album('3'), _album('1'), _album('2')]; + await n.pruefe(); + + expect(n.neue.map((a) => a.id), ['3']); + }); + + test('Ohne Änderung bleibt die Liste leer', () async { + final n = ServerNeuheiten( + holeAlben: () async => [_album('1'), _album('2')]); + await n.pruefe(); + await n.pruefe(); + + expect(n.neue, isEmpty); + }); + + test('Als gesehen markieren rĂ€umt die Liste weg und wiederholt sich nicht', + () async { + var alben = [_album('1')]; + final n = ServerNeuheiten(holeAlben: () async => alben); + await n.pruefe(); + alben = [_album('1'), _album('2')]; + await n.pruefe(); + expect(n.neue, isNotEmpty); + + await n.alsGesehenMarkieren(); + expect(n.neue, isEmpty); + + await n.pruefe(); + expect(n.neue, isEmpty); + }); + + test('Ein Serverfehler bleibt folgenlos', () async { + final n = ServerNeuheiten( + holeAlben: () async => throw Exception('Server weg')); + + await n.pruefe(); + + expect(n.neue, isEmpty); + }); +}