v2.52.1 — F2: YT-Links nur als Server-Fallback

## YT-Quell-URL (Server ist die Quelle)
- cloud_service.ytUrlSetzen(): POST /api/v1/cloud/yt-url (per-User UPDATE user_songs.yt_url)
- musik_scanner._sucheYtUrls(): durchsucht NUR Songs mit cloud_id, die auf dem Server noch keine yt_url haben; Treffer werden auf den Server hochgeladen statt lokal gespeichert
- db_helper: ytUrlAktualisieren/songsOhneYtUrl entfernt, songsMitCloudId() ergänzt
- download_service: yt_url wird beim Download nicht mehr lokal persistiert

## Fallback-Kette reDownload (korrupte/fehlende Datei)
- 1) Server-Download via cloud_id + Magic-Byte-Check (primäre Quelle)
- 2) Server-ytUrl (aus listSongs) → yt-proxy als letzte Chance
- 3) Legacy: lokal gespeicherte ytUrl (Bestandsdaten vor v2.52)
- song_tile: „Neu laden“ erscheint bei istKorrupt && (cloudId != null || ytUrl != null)
- 3 neue Tests (ytUrlSetzen: ok/error/401) → 111 Tests grün, analyze 0 Issues
This commit is contained in:
Dustin
2026-08-05 10:40:37 +02:00
parent 3b898e238e
commit bf0f544e4b
6 changed files with 158 additions and 35 deletions
+5 -10
View File
@@ -313,18 +313,13 @@ class DbHelper {
return rows.map((r) => Song.fromMap(r)).toList();
}
/// YouTube-Quell-URL für einen Song speichern
Future<void> 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<List<Song>> 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<List<Song>> 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();
+13
View File
@@ -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<bool> 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)
+73 -10
View File
@@ -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<Song?> 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();
}
+29 -11
View File
@@ -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<void> _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,
});
}
}
}
}
+6 -4
View File
@@ -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(
+32
View File
@@ -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<String, dynamic>;
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(