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:
@@ -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();
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user