Auf Wunsch: was vom Handy hochgeladen wird, soll auch in Navidrome auftauchen. Die Subsonic-API kennt keinen Upload — also erledigt es die Melo-Cloud selbst. Server (/home/dustin/scripts/melo_cloud.py, nicht versioniert — Backup als melo_cloud.py.bak-20260821-084506): - verknuepfe_navidrome(): Hardlink der Registry-Datei nach /home/dustin/navidrome/music (Bind-Mount des Containers). Gleiche Partition -> kein zusaetzlicher Speicher. Fallback: Kopie. - rescan_navidrome(): Subsonic startScan.view ueber Navidromes AuthProxyHeader (TrustedSources 127.0.0.1) — kein Passwort im Code. Nur zur Beschleunigung, der Watcher findet neue Dateien ohnehin. - upload() verknuepft neue UND bereits bekannte Titel (heilt Bestand). - handle_delete() entfernt die Datei aus Registry + Navidrome, sobald kein Konto sie mehr aktiv hat. Der Registry-EINTRAG bleibt stehen: handle_list haengt die Grabsteine daran (JOIN registry) — ohne ihn erfuehren die anderen Geraete nie von der Loeschung (Zombie-Song). - registry_pfad(): gemeinsame Dateisuche ueber alle Audio-Endungen, mit Rueckfall auf den Navidrome-Ordner. Nebenbei repariert: - Alle 325 Cloud-Dateien lagen nur noch im Navidrome-Ordner, REG war leer: jeder Download antwortete "File missing". Per Hardlink zurueckverknuepft (scripts/melo_cloud_migriere_registry.py). - upload() legte jede Datei als ".mp3" ab, auch m4a/flac. Jetzt echte Endung; _download liefert passenden Content-Type und einen Dateinamen ohne doppelte Endung. App: - MeloCloudService.herunterladen() gibt die geschriebene Datei zurueck und leitet die Endung aus dem Content-Type ab (endungFuer) — sonst landet eine M4A als .mp3 auf dem Handy und Android ordnet sie falsch ein. - loeschBremseGreift(): der Abgleich reicht keine Loeschwelle mehr zum Server durch (>10 Titel UND >1/3 des Serverbestands). Ohne die Bremse haette eine nicht eingehaengte Speicherkarte die Sammlung auf allen Geraeten geloescht. 276 Tests gruen (1 uebersprungen), flutter analyze ohne Befund, plus ein Durchlauf gegen den echten Server (scripts/test_melo_cloud_navidrome.py): Upload -> Registry + Navidrome (ein Hardlink) -> Navidrome liest ein -> Loeschen entfernt beides, Grabstein bleibt. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FpPu4nuKjKKeX1RpdDeX81
175 lines
5.4 KiB
Dart
175 lines
5.4 KiB
Dart
import 'package:flutter_test/flutter_test.dart';
|
|
import 'package:melo/library/database.dart';
|
|
import 'package:melo/services/melo_cloud_service.dart';
|
|
import 'package:melo/services/sync_service.dart';
|
|
|
|
Song _song(
|
|
String id, {
|
|
String? cloudId,
|
|
bool deleted = false,
|
|
String titel = 'Lied',
|
|
}) =>
|
|
Song(
|
|
id: id,
|
|
path: '/musik/$id.mp3',
|
|
title: titel,
|
|
cloudId: cloudId,
|
|
dateAddedMs: 0,
|
|
updatedAtMs: 0,
|
|
deleted: deleted,
|
|
playCount: 0,
|
|
categoriesEdited: false,
|
|
metadataEdited: false,
|
|
);
|
|
|
|
CloudSong _cloud(String id, {bool geloescht = false}) =>
|
|
CloudSong(id: id, titel: 'Lied $id', geloescht: geloescht);
|
|
|
|
void main() {
|
|
group('planeSync', () {
|
|
test('ohne beides passiert nichts', () {
|
|
final plan = planeSync(lokal: const [], server: const []);
|
|
expect(plan.istLeer, isTrue);
|
|
});
|
|
|
|
test('ein Titel nur auf dem Gerät geht zum Server', () {
|
|
final plan = planeSync(lokal: [_song('a')], server: const []);
|
|
|
|
expect(plan.hochladen.map((s) => s.id), ['a']);
|
|
expect(plan.herunterladen, isEmpty);
|
|
expect(plan.lokalLoeschen, isEmpty);
|
|
});
|
|
|
|
test('ein Titel nur am Server kommt aufs Gerät', () {
|
|
final plan = planeSync(lokal: const [], server: [_cloud('c1')]);
|
|
|
|
expect(plan.herunterladen.map((s) => s.id), ['c1']);
|
|
expect(plan.hochladen, isEmpty);
|
|
});
|
|
|
|
test('ein beidseitig bekannter Titel bleibt unangetastet', () {
|
|
final plan = planeSync(
|
|
lokal: [_song('a', cloudId: 'c1')],
|
|
server: [_cloud('c1')],
|
|
);
|
|
|
|
expect(plan.istLeer, isTrue);
|
|
});
|
|
|
|
test('ein am Server gelöschter Titel verschwindet auch hier', () {
|
|
final plan = planeSync(
|
|
lokal: [_song('a', cloudId: 'c1')],
|
|
server: [_cloud('c1', geloescht: true)],
|
|
);
|
|
|
|
expect(plan.lokalLoeschen.map((s) => s.id), ['a']);
|
|
// Nicht erneut herunterladen — sonst kommt der Titel ewig zurück.
|
|
expect(plan.herunterladen, isEmpty);
|
|
});
|
|
|
|
test('ein am Server gelöschter Titel wird nicht wieder hochgeladen', () {
|
|
// Der Grabstein zählt als „bekannt": ohne diese Regel lädt jedes Gerät
|
|
// den gelöschten Titel beim nächsten Abgleich erneut hoch.
|
|
final plan = planeSync(
|
|
lokal: [_song('a', cloudId: 'c1', deleted: true)],
|
|
server: [_cloud('c1', geloescht: true)],
|
|
);
|
|
|
|
expect(plan.istLeer, isTrue);
|
|
});
|
|
|
|
test('ein lokal gelöschter Titel geht nicht zum Server', () {
|
|
final plan = planeSync(lokal: [_song('a', deleted: true)], server: const []);
|
|
|
|
expect(plan.hochladen, isEmpty);
|
|
expect(plan.serverLoeschen, isEmpty);
|
|
});
|
|
|
|
test('eine Löschung hier wird dem Server gemeldet', () {
|
|
final plan = planeSync(
|
|
lokal: [_song('a', cloudId: 'c1', deleted: true)],
|
|
server: [_cloud('c1')],
|
|
);
|
|
|
|
expect(plan.serverLoeschen.map((s) => s.id), ['a']);
|
|
expect(plan.herunterladen, isEmpty);
|
|
});
|
|
|
|
test('eine beidseitig bekannte Löschung wird nicht erneut gemeldet', () {
|
|
final plan = planeSync(
|
|
lokal: [_song('a', cloudId: 'c1', deleted: true)],
|
|
server: [_cloud('c1', geloescht: true)],
|
|
);
|
|
|
|
expect(plan.serverLoeschen, isEmpty);
|
|
expect(plan.istLeer, isTrue);
|
|
});
|
|
|
|
test('kennt der Server die Verknüpfung nicht mehr, geht der Titel erneut '
|
|
'hoch statt verloren', () {
|
|
final plan = planeSync(
|
|
lokal: [_song('a', cloudId: 'verwaist')],
|
|
server: [_cloud('c1')],
|
|
);
|
|
|
|
expect(plan.hochladen.map((s) => s.id), ['a']);
|
|
expect(plan.herunterladen.map((s) => s.id), ['c1']);
|
|
});
|
|
|
|
test('gemischter Bestand wird vollständig aufgeteilt', () {
|
|
final plan = planeSync(
|
|
lokal: [
|
|
_song('a', cloudId: 'c1'),
|
|
_song('b'),
|
|
_song('c', cloudId: 'c3'),
|
|
],
|
|
server: [_cloud('c1'), _cloud('c3', geloescht: true), _cloud('c9')],
|
|
);
|
|
|
|
expect(plan.hochladen.map((s) => s.id), ['b']);
|
|
expect(plan.lokalLoeschen.map((s) => s.id), ['c']);
|
|
expect(plan.herunterladen.map((s) => s.id), ['c9']);
|
|
expect(plan.gesamt, 3);
|
|
});
|
|
});
|
|
|
|
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);
|
|
|
|
test('beim allerersten Mal wird abgeglichen', () {
|
|
expect(sollAutoSync(null, jetzt), isTrue);
|
|
});
|
|
|
|
test('direkt nach einem Lauf nicht noch einmal', () {
|
|
expect(sollAutoSync(jetzt.subtract(const Duration(minutes: 1)), jetzt),
|
|
isFalse);
|
|
});
|
|
|
|
test('nach dem Abstand wieder', () {
|
|
expect(sollAutoSync(jetzt.subtract(autoSyncAbstand), jetzt), isTrue);
|
|
});
|
|
});
|
|
}
|