diff --git a/lib/database/db_helper.dart b/lib/database/db_helper.dart index 84ac286..685d46c 100644 --- a/lib/database/db_helper.dart +++ b/lib/database/db_helper.dart @@ -313,18 +313,13 @@ class DbHelper { return rows.map((r) => Song.fromMap(r)).toList(); } - /// YouTube-Quell-URL für einen Song speichern - Future ytUrlAktualisieren(int songId, String ytUrl) async { - final d = await db; - await d.update('songs', {'yt_url': ytUrl}, - where: 'id = ?', whereArgs: [songId]); - } - - /// Alle Songs ohne YouTube-Quell-URL abrufen (für Scanner-Suche) - Future> songsOhneYtUrl({int limit = 50}) async { + /// Alle Songs MIT cloud_id (Server-Verknüpfung) — Grundlage für die + /// YT-Quell-URL-Suche (F2): Treffer werden auf den SERVER hochgeladen + /// (POST /api/v1/cloud/yt-url) statt lokal gespeichert. + Future> songsMitCloudId({int limit = 50}) async { final d = await db; final rows = await d.query('songs', - where: 'yt_url IS NULL OR yt_url = ""', + where: 'cloud_id IS NOT NULL AND cloud_id != ""', orderBy: 'hinzugefuegt_am DESC', limit: limit); return rows.map((r) => Song.fromMap(r)).toList(); diff --git a/lib/services/cloud_service.dart b/lib/services/cloud_service.dart index 625e0fa..54683e7 100644 --- a/lib/services/cloud_service.dart +++ b/lib/services/cloud_service.dart @@ -274,6 +274,19 @@ class CloudService extends ChangeNotifier { return r?['status'] == 'ok'; } + // ─── 🎬 YouTube-Quell-URL (Server-Fallback) ─── + + /// YT-Quell-URL eines Songs auf dem Server setzen (F2: Der Server ist die + /// Quelle für YT-Links — die App speichert `yt_url` nicht mehr lokal, + /// sondern lädt gefundene Treffer per Endpoint hoch). + Future ytUrlSetzen(String songId, String ytUrl) async { + final r = await _post('/api/v1/cloud/yt-url', { + 'song_id': songId, + 'yt_url': ytUrl, + }); + return r?['status'] == 'ok'; + } + // ─── ✏️ Umbenennen ─── /// Song umbenennen (benutzerdefinierter Titel/Artist) diff --git a/lib/services/download_service.dart b/lib/services/download_service.dart index b09b719..d53b0f3 100644 --- a/lib/services/download_service.dart +++ b/lib/services/download_service.dart @@ -11,6 +11,7 @@ import '../database/db_helper.dart'; import '../utils/audio_validator.dart'; import 'melo_logger.dart'; import 'auth_service.dart'; +import 'cloud_service.dart'; import '../main.dart'; // für notificationsPlugin import '../utils/sanitize.dart'; @@ -370,7 +371,8 @@ class DownloadService extends ChangeNotifier { istHeruntergeladen: true, istKorrupt: istKorrupt, downloadQuelle: 'youtube', - ytUrl: url, + // F2: yt_url wird NICHT mehr lokal persistiert — der Server ist die + // Quelle (CloudService.ytUrlSetzen beim Scanner; Fallback via listSongs). ); await _db.songEinfuegen(song); @@ -479,14 +481,11 @@ class DownloadService extends ChangeNotifier { } } - /// Korrupten Song mit bekannter ytUrl neu herunterladen + /// Korrupten Song neu herunterladen — Server ist die primäre Quelle (F2): + /// 1. Server-Download (cloud_id) mit Magic-Byte-Check + /// 2. Fallback: Server-ytUrl (aus listSongs) → yt-proxy als letzte Chance + /// 3. Legacy-Fallback: lokal gespeicherte ytUrl (Bestandsdaten vor v2.52) Future reDownload(Song song) async { - if (song.ytUrl == null || song.ytUrl!.isEmpty) { - _fehler = 'Keine YouTube-Quell-URL für diesen Song'; - notifyListeners(); - return null; - } - _resetStatus(); try { _aktuellerTitel = '🔄 Lade neu: ${song.titel}'; @@ -505,8 +504,72 @@ class DownloadService extends ChangeNotifier { } } catch (_) {} - // Neu herunterladen mit der gespeicherten ytUrl - return _downloadEinzeln(song.ytUrl!); + // 1) Server-Download versuchen (primäre Quelle) + final cloudId = song.cloudId; + if (cloudId != null && cloudId.isNotEmpty) { + final cloud = CloudService(); + final dir = Directory( + '${(await getApplicationDocumentsDirectory()).path}/music'); + if (!await dir.exists()) await dir.create(recursive: true); + final ext = song.dateiPfad.contains('.') + ? '.${song.dateiPfad.split('.').last}' + : '.mp3'; + final dest = '${dir.path}/${sanitizeDateiname(song.titel)}$ext'; + if (await cloud.download(cloudId, dest)) { + final file = File(dest); + if (hatValideMagicBytes(file)) { + final neu = Song( + titel: song.titel, + kuenstler: song.kuenstler, + album: song.album, + dauerSekunden: song.dauerSekunden, + dateiPfad: dest, + groesseBytes: await file.length(), + istHeruntergeladen: true, + istKorrupt: false, + downloadQuelle: 'server', + cloudId: cloudId, + ); + await _db.songEinfuegen(neu); + _ladt = false; + _aktuellerTitel = '✅ ${neu.titel}'; + notifyListeners(); + return neu; + } + // Server-Kopie korrupt → löschen, Fallback versuchen + try { + await file.delete(); + } catch (_) {} + } + + // 2) Server-ytUrl als letzte Chance (Server-Datei fehlt oder korrupt) + final serverSongs = await cloud.listSongs(); + for (final s in serverSongs) { + if ((s['id']?.toString() ?? '') == cloudId) { + final serverYt = s['ytUrl']?.toString() ?? ''; + if (serverYt.isNotEmpty) { + return _downloadEinzeln(serverYt); + } + break; + } + } + + // 3) Legacy: lokal gespeicherte ytUrl (Bestandsdaten vor v2.52) + if (song.ytUrl != null && song.ytUrl!.isNotEmpty) { + return _downloadEinzeln(song.ytUrl!); + } + _fehler = 'Kein Server-Download möglich und keine YouTube-Quelle'; + notifyListeners(); + return null; + } + + // Kein cloudId → nur Legacy-ytUrl + if (song.ytUrl != null && song.ytUrl!.isNotEmpty) { + return _downloadEinzeln(song.ytUrl!); + } + _fehler = 'Keine YouTube-Quell-URL für diesen Song'; + notifyListeners(); + return null; } finally { _stopBackgroundService(); } diff --git a/lib/services/musik_scanner.dart b/lib/services/musik_scanner.dart index 1acd9c3..6b3d7a5 100644 --- a/lib/services/musik_scanner.dart +++ b/lib/services/musik_scanner.dart @@ -11,6 +11,7 @@ import '../utils/audio_validator.dart'; import 'id3_reader.dart'; import '../services/melo_logger.dart'; import 'auth_service.dart'; +import 'cloud_service.dart'; class MusikScanner { static final MusikScanner _instanz = MusikScanner._(); @@ -483,17 +484,31 @@ class MusikScanner { return s.isEmpty ? null : s; } - /// Sucht YouTube-Quell-URLs für Songs ohne ytUrl (max 50 pro Scan, 1/s Rate-Limit) + /// Sucht YouTube-Quell-URLs für Songs MIT cloud_id, die auf dem Server + /// noch keine yt_url haben (max 50 pro Scan, 1/s Rate-Limit). Treffer + /// werden auf den SERVER hochgeladen (POST /api/v1/cloud/yt-url) statt + /// lokal gespeichert (F2: yt_url ist eine Server-Eigenschaft). Future _sucheYtUrls() async { const maxSuchanfragen = 50; const suchUrlBasis = 'https://yt.baka-net.de/api/search'; - final songsOhneUrl = await _db.songsOhneYtUrl(limit: maxSuchanfragen); - if (songsOhneUrl.isEmpty) return; + final cloud = CloudService(); + // Server-Songs, die bereits eine yt_url haben → nicht erneut suchen + final serverSongs = await cloud.listSongs(); + final serverMitYt = serverSongs + .where((s) => (s['ytUrl']?.toString() ?? '').isNotEmpty) + .map((s) => s['id']?.toString() ?? '') + .where((id) => id.isNotEmpty) + .toSet(); + + final kandidaten = await _db.songsMitCloudId(limit: maxSuchanfragen); + if (kandidaten.isEmpty) return; int gefunden = 0; - for (final song in songsOhneUrl) { + for (final song in kandidaten) { if (gefunden >= maxSuchanfragen) break; + final cloudId = song.cloudId ?? ''; + if (cloudId.isEmpty || serverMitYt.contains(cloudId)) continue; try { final query = '${song.kuenstler} ${song.titel}'; @@ -512,13 +527,16 @@ class MusikScanner { final erstes = daten[0]; final ytUrl = erstes['url'] as String?; if (ytUrl != null && ytUrl.isNotEmpty) { - await _db.ytUrlAktualisieren(song.id!, ytUrl); - gefunden++; - MeloLogger().zustand('yt_suche_treffer', { - 'song_id': song.id, - 'titel': song.titel, - 'yt_url': ytUrl, - }); + final ok = await cloud.ytUrlSetzen(cloudId, ytUrl); + if (ok) { + gefunden++; + MeloLogger().zustand('yt_suche_treffer', { + 'song_id': song.id, + 'cloud_id': cloudId, + 'titel': song.titel, + 'yt_url': ytUrl, + }); + } } } } diff --git a/lib/widgets/song_tile.dart b/lib/widgets/song_tile.dart index 6aba7cd..37ea2d9 100644 --- a/lib/widgets/song_tile.dart +++ b/lib/widgets/song_tile.dart @@ -71,8 +71,10 @@ class SongTile extends StatelessWidget { trailing: Row( mainAxisSize: MainAxisSize.min, children: [ - // ── Korrupt mit ytUrl: 🔄 Neu laden ── - if (song.istKorrupt && song.ytUrl != null && onErneutHerunterladen != null) + // ── Korrupt: 🔄 Neu laden (Server-Quelle via cloud_id oder Legacy-ytUrl) ── + if (song.istKorrupt && + (song.cloudId != null || song.ytUrl != null) && + onErneutHerunterladen != null) InkWell( borderRadius: BorderRadius.circular(50), onTap: () => onErneutHerunterladen!(song), @@ -231,8 +233,8 @@ class SongTile extends StatelessWidget { onFavoriteToggle(song); }, ), - // ⬇️ Download – nur wenn eine YouTube-Quell-URL existiert - if (song.ytUrl != null && onErneutHerunterladen != null) + // ⬇️ Neu laden – wenn Server-Quelle (cloud_id) oder Legacy-ytUrl existiert + if ((song.cloudId != null || song.ytUrl != null) && onErneutHerunterladen != null) ListTile( leading: const Icon(Icons.download, color: MeloTheme.rot, size: 20), title: Text( diff --git a/test/cloud_service_test.dart b/test/cloud_service_test.dart index d01a02e..4af0436 100644 --- a/test/cloud_service_test.dart +++ b/test/cloud_service_test.dart @@ -231,6 +231,38 @@ void main() { }); }); + group('ytUrlSetzen (F2: YT-Link → Server)', () { + test('POST /yt-url mit song_id + yt_url, status ok → true', () async { + http.Request? anfrage; + final cloud = CloudService( + client: MockClient((req) async { + anfrage = req; + return jsonOk({'status': 'ok'}); + }), + ); + final ok = await cloud.ytUrlSetzen('s1', 'https://youtu.be/abc'); + expect(ok, isTrue); + expect(anfrage!.url.path, '/api/v1/cloud/yt-url'); + final body = jsonDecode(anfrage!.body) as Map; + expect(body['song_id'], 's1'); + expect(body['yt_url'], 'https://youtu.be/abc'); + }); + + test('ytUrlSetzen ohne status ok → false', () async { + final cloud = CloudService( + client: MockClient((req) async => jsonOk({'status': 'error'})), + ); + expect(await cloud.ytUrlSetzen('s1', 'https://youtu.be/abc'), isFalse); + }); + + test('ytUrlSetzen 401 → false (Auth-Fehler)', () async { + final cloud = CloudService( + client: MockClient((req) async => http.Response('{}', 401)), + ); + expect(await cloud.ytUrlSetzen('s1', 'https://youtu.be/abc'), isFalse); + }); + }); + group('History & Songs', () { test('getHistory parst history-Liste', () async { final cloud = CloudService(