diff --git a/CHANGELOG.md b/CHANGELOG.md index c125336..3fb57ef 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,55 @@ Format angelehnt an [Keep a Changelog](https://keepachangelog.com/de/1.0.0/). ## [Unreleased] +### 📚 Hochgeladene Titel landen jetzt auch in Navidrome (2026-08-21) + +- 🎵 **Was sich ändert:** Was du vom Handy hochlädst, taucht ab sofort **auch + in der Navidrome-Bibliothek** auf — also im Browser und in jeder anderen + Musik-App, die auf den Server zugreift. Vorher blieb es in der Melo-Cloud + liegen und war nur in Melo sichtbar. + 🔧 **Wie:** Die Melo-Cloud hängt die hochgeladene Datei zusätzlich in + Navidromes Musikordner (als *Hardlink* — dieselbe Datei unter zwei Namen, + also **kein zusätzlicher Speicherplatz**) und stößt einen Scan an. + Navidrome hat den Titel binnen Sekunden. Löschst du ihn, verschwindet er + auch aus Navidrome — aber nur, wenn ihn kein anderes Konto mehr hat. + ⚠️ **Zu wissen:** Navidrome hat **eine gemeinsame** Bibliothek. Was ein + Konto hochlädt, sehen dort alle Navidrome-Nutzer. In Melo bleibt die + Trennung pro Konto bestehen. +- 🩹 **Nebenbei repariert: Downloads vom Server gingen gar nicht.** Die 325 + Titel der Melo-Cloud lagen nur noch im Navidrome-Ordner — der Ablageordner + der Cloud war leer. Jeder Download antwortete deshalb mit *„File missing"*. + Alle Dateien sind zurückverknüpft, Downloads gehen wieder. +- 🏷️ **Nebenbei repariert: falsche Dateiendungen.** Hochgeladene Dateien + wurden immer als `.mp3` abgelegt, auch M4A und FLAC; heruntergeladene + bekamen den Namen `Lied.m4a.m4a`. Jetzt zählt der echte Dateityp — auf dem + Server wie auf dem Handy (wichtig, damit Android den Titel richtig + einordnet). +- 🛑 **Neu: Sicherheitsbremse gegen Massenlöschung.** Der Bibliotheks-Scan + markiert jeden Titel als gelöscht, dessen Datei er nicht findet. Ist die + Speicherkarte einmal nicht eingehängt oder die Berechtigung entzogen, + betrifft das die halbe Sammlung — und ohne Bremse hätte der Abgleich sie + am Server und damit auf **allen** Geräten gelöscht. Melo hält jetzt an, + wenn mehr als 10 Titel **und** mehr als ein Drittel des Serverbestands auf + einmal verschwinden sollen, und sagt warum. Einzelne Löschungen laufen + unverändert durch. +- 🔧 **Details für Technikinteressierte:** + - `melo_cloud.py`: `verknuepfe_navidrome()` (Hardlink, Fallback Kopie), + `loese_navidrome()`, `rescan_navidrome()` (Subsonic `startScan.view` über + Navidromes `AuthProxyHeader` — **kein Passwort im Code**), + `registry_pfad()` als gemeinsame Dateisuche über alle Audio-Endungen. + - `handle_delete()` räumt die Datei ab, sobald kein Konto sie mehr aktiv + hat — der Registry-**Eintrag** bleibt aber stehen, sonst verlöre + `/list` den Grabstein und andere Geräte bekämen den Titel als Zombie + zurück. + - Neu: `scripts/melo_cloud_migriere_registry.py` (einmalige Rückverknüpfung) + und `scripts/test_melo_cloud_navidrome.py` (Durchlauf-Test). + - App: `MeloCloudService.herunterladen()` gibt die geschriebene Datei + zurück und leitet die Endung aus dem Inhaltstyp ab (`endungFuer`); + `loeschBremseGreift()` in `sync_service.dart`. +- ✅ **Getestet:** 276 Tests grün (9 neue) plus ein Durchlauf gegen den + echten Server: Upload → Registry + Navidrome (ein Hardlink) → Navidrome + liest ein → Löschen entfernt beides, Grabstein bleibt. + ### 🔊 Lokale Lieder spielen wieder + Abgleich zwischen Handy und Server (2026-08-21) - ▶️ **Behoben: Musik vom Handy war nicht abspielbar.** Titel, die auf dem diff --git a/lib/services/melo_cloud_service.dart b/lib/services/melo_cloud_service.dart index 749558b..ff851ed 100644 --- a/lib/services/melo_cloud_service.dart +++ b/lib/services/melo_cloud_service.dart @@ -3,6 +3,7 @@ import 'dart:io'; import 'package:flutter/foundation.dart'; import 'package:http/http.dart' as http; +import 'package:path/path.dart' as p; import 'baka_auth.dart'; import 'logger_service.dart'; @@ -74,8 +75,9 @@ class CloudException implements Exception { /// Wiedergabe-Verlauf. /// /// Bewusst nicht Navidrome: die Subsonic-API kennt keinen Upload-Endpunkt, -/// eigene Dateien lassen sich darüber nicht zum Server bringen. Navidrome -/// bleibt die Streaming-Bibliothek, die Melo-Cloud ist der Sync-Speicher. +/// eigene Dateien lassen sich darüber nicht zum Server bringen. Die +/// Melo-Cloud hängt hochgeladene Titel aber serverseitig in die +/// Navidrome-Bibliothek ein — sie tauchen dort und im Web also trotzdem auf. class MeloCloudService { MeloCloudService({required this.auth, http.Client? client}) : _client = client ?? http.Client(); @@ -181,14 +183,41 @@ class MeloCloudService { return parseUpload(antwort.body); } - /// Holt den Titel [cloudId] und schreibt ihn nach [ziel]. + /// Dateiendung zum Inhaltstyp des Servers. Ohne bekannte Zuordnung `.mp3`. + /// + /// Nötig, weil die Bibliothek nicht nur MP3 enthält: eine als `.mp3` + /// abgelegte M4A bekäme vom Android-MediaStore den falschen Typ. + @visibleForTesting + static String endungFuer(String? contentType) { + final typ = (contentType ?? '').toLowerCase().split(';').first.trim(); + return const { + 'audio/mpeg': '.mp3', + 'audio/mp4': '.m4a', + 'audio/x-m4a': '.m4a', + 'audio/aac': '.aac', + 'audio/flac': '.flac', + 'audio/x-flac': '.flac', + 'audio/ogg': '.ogg', + 'audio/opus': '.opus', + 'audio/wav': '.wav', + 'audio/x-wav': '.wav', + }[typ] ?? + '.mp3'; + } + + /// Holt den Titel [cloudId] und legt ihn als `[basisName].` in + /// [ordner] ab. Gibt die geschriebene Datei zurück, oder `null`. /// /// Geschrieben wird erst in eine `.part`-Datei; nur der vollständige /// Download wird umbenannt. Ein Abbruch hinterlässt damit keine halbe /// Datei, die der Bibliotheks-Scan für Musik hielte. - Future herunterladen(String cloudId, File ziel) async { + Future herunterladen( + String cloudId, + Directory ordner, + String basisName, + ) async { _pruefeAnmeldung(); - final teil = File('${ziel.path}.part'); + final teil = File(p.join(ordner.path, '$basisName.part')); try { final anfrage = http.Request('GET', Uri.parse('$basisUrl/download/$cloudId')) ..headers.addAll(_kopf); @@ -196,17 +225,19 @@ class MeloCloudService { await _client.send(anfrage).timeout(const Duration(seconds: 180)); if (antwort.statusCode != 200) { await logger.error('Cloud-Download $cloudId: HTTP ${antwort.statusCode}'); - return false; + return null; } - await ziel.parent.create(recursive: true); + await ordner.create(recursive: true); await antwort.stream.pipe(teil.openWrite()); + final ziel = File(p.join(ordner.path, + '$basisName${endungFuer(antwort.headers['content-type'])}')); await teil.rename(ziel.path); - return true; + return ziel; } catch (e) { await logger.error('Cloud-Download $cloudId fehlgeschlagen: $e', e, StackTrace.current); if (await teil.exists()) await teil.delete(); - return false; + return null; } } diff --git a/lib/services/sync_service.dart b/lib/services/sync_service.dart index ba386c8..1db96f0 100644 --- a/lib/services/sync_service.dart +++ b/lib/services/sync_service.dart @@ -102,6 +102,16 @@ SyncPlan planeSync({ ); } +/// Ob eine Löschwelle gestoppt wird, statt sie zum Server durchzureichen. +/// +/// Hintergrund: Der Bibliotheks-Scan markiert jeden Titel als gelöscht, dessen +/// Datei er nicht mehr findet. Ist die Speicherkarte einmal nicht eingehängt +/// oder die Berechtigung entzogen, betrifft das auf einen Schlag die halbe +/// Sammlung — ohne Bremse würde der Abgleich sie am Server und damit auf allen +/// Geräten löschen. Einzelne Löschungen laufen weiterhin ungehindert durch. +bool loeschBremseGreift({required int zuLoeschen, required int bestand}) => + zuLoeschen > 10 && zuLoeschen * 3 > bestand; + /// Wie oft höchstens automatisch abgeglichen wird. Ein Abgleich beim /// Zurückkehren in die App darf nicht bei jedem Tab-Wechsel losrennen. const autoSyncAbstand = Duration(minutes: 15); @@ -176,15 +186,28 @@ class SyncService extends ChangeNotifier { _melde('Vergleiche mit dem Server …'); try { + final amServer = await cloud.liste(); final plan = planeSync( lokal: await db.allSongs(), - server: await cloud.liste(), + server: amServer, ); _gesamt = plan.gesamt; notifyListeners(); + final bestand = amServer.where((s) => !s.geloescht).length; + final bremse = loeschBremseGreift( + zuLoeschen: plan.serverLoeschen.length, + bestand: bestand, + ); + if (bremse) { + _fehler = 'Sicherheitsbremse: ${plan.serverLoeschen.length} von ' + '$bestand Titeln würden am Server gelöscht. Das sieht nach einem ' + 'Fehler aus (z. B. Speicherkarte nicht eingehängt) — es wurde ' + 'nichts gelöscht.'; + } + await _ziehLoeschungenNach(plan.lokalLoeschen); - await _meldeLoeschungen(plan.serverLoeschen); + if (!bremse) await _meldeLoeschungen(plan.serverLoeschen); await _ladeHerunter(plan.herunterladen); await _ladeHoch(plan.hochladen); await _gleicheFavoritenAb(); @@ -232,11 +255,14 @@ class SyncService extends ChangeNotifier { final ordner = await _musikOrdner(); for (final cloudSong in songs) { _melde('Lade „${cloudSong.titel}“ …'); - final datei = File(p.join( - ordner.path, - '${_sichererDateiname(cloudSong.titel)}-${cloudSong.id}.mp3', - )); - if (!await cloud.herunterladen(cloudSong.id, datei)) { + // Die Endung bestimmt der Server anhand des echten Dateityps — die + // Bibliothek enthält nicht nur MP3. + final datei = await cloud.herunterladen( + cloudSong.id, + ordner, + '${_sichererDateiname(cloudSong.titel)}-${cloudSong.id}', + ); + if (datei == null) { _erledigt++; continue; } diff --git a/test/services/melo_cloud_service_test.dart b/test/services/melo_cloud_service_test.dart index bf793ff..603ede6 100644 --- a/test/services/melo_cloud_service_test.dart +++ b/test/services/melo_cloud_service_test.dart @@ -101,6 +101,24 @@ void main() { }); }); + group('endungFuer', () { + test('erkennt die gängigen Formate der Bibliothek', () { + expect(MeloCloudService.endungFuer('audio/mpeg'), '.mp3'); + expect(MeloCloudService.endungFuer('audio/mp4'), '.m4a'); + expect(MeloCloudService.endungFuer('audio/flac'), '.flac'); + expect(MeloCloudService.endungFuer('audio/ogg'), '.ogg'); + }); + + test('ignoriert angehängte Parameter und Groß-/Kleinschreibung', () { + expect(MeloCloudService.endungFuer('Audio/MP4; charset=binary'), '.m4a'); + }); + + test('fällt ohne bekannte Zuordnung auf .mp3 zurück', () { + expect(MeloCloudService.endungFuer(null), '.mp3'); + expect(MeloCloudService.endungFuer('application/octet-stream'), '.mp3'); + }); + }); + group('CloudVerlauf', () { test('meldet die Zeit als ISO-Zeit ohne Bruchteile', () { final eintrag = CloudVerlauf( diff --git a/test/services/sync_plan_test.dart b/test/services/sync_plan_test.dart index e0c4ca8..16700fd 100644 --- a/test/services/sync_plan_test.dart +++ b/test/services/sync_plan_test.dart @@ -133,6 +133,28 @@ void main() { }); }); + group('loeschBremseGreift', () { + test('einzelne Löschungen laufen durch', () { + expect(loeschBremseGreift(zuLoeschen: 1, bestand: 300), isFalse); + expect(loeschBremseGreift(zuLoeschen: 10, bestand: 12), isFalse); + }); + + test('eine Löschwelle wird gestoppt', () { + // Typischer Unfall: Speicherkarte nicht eingehängt, der Scan meldet + // die halbe Sammlung als verschwunden. + expect(loeschBremseGreift(zuLoeschen: 300, bestand: 320), isTrue); + expect(loeschBremseGreift(zuLoeschen: 11, bestand: 30), isTrue); + }); + + test('viele Löschungen aus einem großen Bestand sind erlaubt', () { + expect(loeschBremseGreift(zuLoeschen: 20, bestand: 1000), isFalse); + }); + + test('ohne Löschungen greift nichts', () { + expect(loeschBremseGreift(zuLoeschen: 0, bestand: 0), isFalse); + }); + }); + group('sollAutoSync', () { final jetzt = DateTime(2026, 8, 21, 12); diff --git a/test/services/sync_service_test.dart b/test/services/sync_service_test.dart index 1fa27a7..f1a54b8 100644 --- a/test/services/sync_service_test.dart +++ b/test/services/sync_service_test.dart @@ -1,6 +1,7 @@ import 'dart:convert'; import 'dart:io'; +import 'package:drift/drift.dart' show Value; import 'package:drift/native.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:http/http.dart' as http; @@ -75,7 +76,8 @@ void main() { ); } if (pfad.contains('/download/')) { - return http.Response.bytes([1, 2, 3, 4], 200); + return http.Response.bytes([1, 2, 3, 4], 200, + headers: {'content-type': 'audio/mp4'}); } return http.Response(jsonEncode({'status': 'ok'}), 200); }); @@ -90,6 +92,8 @@ void main() { expect(songs.single.cloudId, 'c1'); expect(songs.single.durationMs, 200000); expect(await File(songs.single.path).readAsBytes(), [1, 2, 3, 4]); + // Endung kommt vom echten Dateityp des Servers, nicht pauschal .mp3. + expect(songs.single.path, endsWith('.m4a')); }); test('eine eigene Datei geht zum Server und bekommt die Server-ID', () async { @@ -197,6 +201,85 @@ void main() { expect(gemeldet, ['c5']); }); + test('eine Löschwelle wird nicht zum Server durchgereicht', () async { + // 12 lokal verschwundene Titel bei 12 am Server: sieht nach einem + // Speicherkarten-Unfall aus, nicht nach 12 Einzel-Löschungen. + final amServer = >[]; + for (var i = 0; i < 12; i++) { + await db.upsertSongs([ + SongsCompanion.insert( + id: 'lokal-$i', + path: '${tempDir.path}/weg$i.mp3', + title: 'Titel $i', + dateAddedMs: 0, + updatedAtMs: 0, + deleted: const Value(true), + ), + ]); + await db.setCloudId('lokal-$i', 'c$i'); + amServer.add({'id': 'c$i', 'title': 'Titel $i'}); + } + + var geloescht = 0; + final sync = await baue((anfrage) async { + final pfad = anfrage.url.path; + if (pfad.endsWith('/list')) { + return http.Response(jsonEncode({'songs': amServer}), 200); + } + if (pfad.endsWith('/delete')) geloescht++; + return http.Response(jsonEncode({'status': 'ok'}), 200); + }); + + await sync.synchronisiere(); + + expect(geloescht, 0); + expect(sync.fehler, contains('Sicherheitsbremse')); + }); + + test('einzelne Löschungen laufen weiterhin durch', () async { + await db.upsertSongs([ + SongsCompanion.insert( + id: 'lokal-1', + path: '${tempDir.path}/weg.mp3', + title: 'Weg', + dateAddedMs: 0, + updatedAtMs: 0, + deleted: const Value(true), + ), + ]); + await db.setCloudId('lokal-1', 'c1'); + + final gemeldet = []; + final sync = await baue((anfrage) async { + final pfad = anfrage.url.path; + if (pfad.endsWith('/list')) { + return http.Response( + jsonEncode({ + 'songs': [ + {'id': 'c1', 'title': 'Weg'}, + {'id': 'c2', 'title': 'Bleibt'}, + ] + }), + 200, + ); + } + if (pfad.endsWith('/delete')) { + gemeldet.add( + (jsonDecode(anfrage.body) as Map)['song_id'] as String); + } + if (pfad.contains('/download/')) { + return http.Response.bytes([1], 200, + headers: {'content-type': 'audio/mpeg'}); + } + return http.Response(jsonEncode({'status': 'ok'}), 200); + }); + + await sync.synchronisiere(); + + expect(gemeldet, ['c1']); + expect(sync.fehler, isNull); + }); + test('ohne Anmeldung passiert nichts und der Grund steht da', () async { final sync = SyncService( db: db,