Serverseitig: Uploads landen in der Navidrome-Bibliothek

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
This commit is contained in:
Hermes (Server)
2026-08-21 08:56:44 +02:00
co-authored by Claude Opus 5
parent 9fa027fca1
commit cd79213bb3
6 changed files with 246 additions and 17 deletions
+49
View File
@@ -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
+40 -9
View File
@@ -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].<Endung>` 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<bool> herunterladen(String cloudId, File ziel) async {
Future<File?> 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;
}
}
+33 -7
View File
@@ -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;
}
@@ -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(
+22
View File
@@ -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);
+84 -1
View File
@@ -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 = <Map<String, dynamic>>[];
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 = <String>[];
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<String, dynamic>)['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,