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();
|
return rows.map((r) => Song.fromMap(r)).toList();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// YouTube-Quell-URL für einen Song speichern
|
/// Alle Songs MIT cloud_id (Server-Verknüpfung) — Grundlage für die
|
||||||
Future<void> ytUrlAktualisieren(int songId, String ytUrl) async {
|
/// YT-Quell-URL-Suche (F2): Treffer werden auf den SERVER hochgeladen
|
||||||
final d = await db;
|
/// (POST /api/v1/cloud/yt-url) statt lokal gespeichert.
|
||||||
await d.update('songs', {'yt_url': ytUrl},
|
Future<List<Song>> songsMitCloudId({int limit = 50}) async {
|
||||||
where: 'id = ?', whereArgs: [songId]);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Alle Songs ohne YouTube-Quell-URL abrufen (für Scanner-Suche)
|
|
||||||
Future<List<Song>> songsOhneYtUrl({int limit = 50}) async {
|
|
||||||
final d = await db;
|
final d = await db;
|
||||||
final rows = await d.query('songs',
|
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',
|
orderBy: 'hinzugefuegt_am DESC',
|
||||||
limit: limit);
|
limit: limit);
|
||||||
return rows.map((r) => Song.fromMap(r)).toList();
|
return rows.map((r) => Song.fromMap(r)).toList();
|
||||||
|
|||||||
@@ -274,6 +274,19 @@ class CloudService extends ChangeNotifier {
|
|||||||
return r?['status'] == 'ok';
|
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 ───
|
// ─── ✏️ Umbenennen ───
|
||||||
|
|
||||||
/// Song umbenennen (benutzerdefinierter Titel/Artist)
|
/// Song umbenennen (benutzerdefinierter Titel/Artist)
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import '../database/db_helper.dart';
|
|||||||
import '../utils/audio_validator.dart';
|
import '../utils/audio_validator.dart';
|
||||||
import 'melo_logger.dart';
|
import 'melo_logger.dart';
|
||||||
import 'auth_service.dart';
|
import 'auth_service.dart';
|
||||||
|
import 'cloud_service.dart';
|
||||||
import '../main.dart'; // für notificationsPlugin
|
import '../main.dart'; // für notificationsPlugin
|
||||||
import '../utils/sanitize.dart';
|
import '../utils/sanitize.dart';
|
||||||
|
|
||||||
@@ -370,7 +371,8 @@ class DownloadService extends ChangeNotifier {
|
|||||||
istHeruntergeladen: true,
|
istHeruntergeladen: true,
|
||||||
istKorrupt: istKorrupt,
|
istKorrupt: istKorrupt,
|
||||||
downloadQuelle: 'youtube',
|
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);
|
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 {
|
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();
|
_resetStatus();
|
||||||
try {
|
try {
|
||||||
_aktuellerTitel = '🔄 Lade neu: ${song.titel}';
|
_aktuellerTitel = '🔄 Lade neu: ${song.titel}';
|
||||||
@@ -505,8 +504,72 @@ class DownloadService extends ChangeNotifier {
|
|||||||
}
|
}
|
||||||
} catch (_) {}
|
} catch (_) {}
|
||||||
|
|
||||||
// Neu herunterladen mit der gespeicherten ytUrl
|
// 1) Server-Download versuchen (primäre Quelle)
|
||||||
return _downloadEinzeln(song.ytUrl!);
|
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 {
|
} finally {
|
||||||
_stopBackgroundService();
|
_stopBackgroundService();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import '../utils/audio_validator.dart';
|
|||||||
import 'id3_reader.dart';
|
import 'id3_reader.dart';
|
||||||
import '../services/melo_logger.dart';
|
import '../services/melo_logger.dart';
|
||||||
import 'auth_service.dart';
|
import 'auth_service.dart';
|
||||||
|
import 'cloud_service.dart';
|
||||||
|
|
||||||
class MusikScanner {
|
class MusikScanner {
|
||||||
static final MusikScanner _instanz = MusikScanner._();
|
static final MusikScanner _instanz = MusikScanner._();
|
||||||
@@ -483,17 +484,31 @@ class MusikScanner {
|
|||||||
return s.isEmpty ? null : s;
|
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 {
|
Future<void> _sucheYtUrls() async {
|
||||||
const maxSuchanfragen = 50;
|
const maxSuchanfragen = 50;
|
||||||
const suchUrlBasis = 'https://yt.baka-net.de/api/search';
|
const suchUrlBasis = 'https://yt.baka-net.de/api/search';
|
||||||
|
|
||||||
final songsOhneUrl = await _db.songsOhneYtUrl(limit: maxSuchanfragen);
|
final cloud = CloudService();
|
||||||
if (songsOhneUrl.isEmpty) return;
|
// 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;
|
int gefunden = 0;
|
||||||
for (final song in songsOhneUrl) {
|
for (final song in kandidaten) {
|
||||||
if (gefunden >= maxSuchanfragen) break;
|
if (gefunden >= maxSuchanfragen) break;
|
||||||
|
final cloudId = song.cloudId ?? '';
|
||||||
|
if (cloudId.isEmpty || serverMitYt.contains(cloudId)) continue;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
final query = '${song.kuenstler} ${song.titel}';
|
final query = '${song.kuenstler} ${song.titel}';
|
||||||
@@ -512,13 +527,16 @@ class MusikScanner {
|
|||||||
final erstes = daten[0];
|
final erstes = daten[0];
|
||||||
final ytUrl = erstes['url'] as String?;
|
final ytUrl = erstes['url'] as String?;
|
||||||
if (ytUrl != null && ytUrl.isNotEmpty) {
|
if (ytUrl != null && ytUrl.isNotEmpty) {
|
||||||
await _db.ytUrlAktualisieren(song.id!, ytUrl);
|
final ok = await cloud.ytUrlSetzen(cloudId, ytUrl);
|
||||||
gefunden++;
|
if (ok) {
|
||||||
MeloLogger().zustand('yt_suche_treffer', {
|
gefunden++;
|
||||||
'song_id': song.id,
|
MeloLogger().zustand('yt_suche_treffer', {
|
||||||
'titel': song.titel,
|
'song_id': song.id,
|
||||||
'yt_url': ytUrl,
|
'cloud_id': cloudId,
|
||||||
});
|
'titel': song.titel,
|
||||||
|
'yt_url': ytUrl,
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -71,8 +71,10 @@ class SongTile extends StatelessWidget {
|
|||||||
trailing: Row(
|
trailing: Row(
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
children: [
|
children: [
|
||||||
// ── Korrupt mit ytUrl: 🔄 Neu laden ──
|
// ── Korrupt: 🔄 Neu laden (Server-Quelle via cloud_id oder Legacy-ytUrl) ──
|
||||||
if (song.istKorrupt && song.ytUrl != null && onErneutHerunterladen != null)
|
if (song.istKorrupt &&
|
||||||
|
(song.cloudId != null || song.ytUrl != null) &&
|
||||||
|
onErneutHerunterladen != null)
|
||||||
InkWell(
|
InkWell(
|
||||||
borderRadius: BorderRadius.circular(50),
|
borderRadius: BorderRadius.circular(50),
|
||||||
onTap: () => onErneutHerunterladen!(song),
|
onTap: () => onErneutHerunterladen!(song),
|
||||||
@@ -231,8 +233,8 @@ class SongTile extends StatelessWidget {
|
|||||||
onFavoriteToggle(song);
|
onFavoriteToggle(song);
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
// ⬇️ Download – nur wenn eine YouTube-Quell-URL existiert
|
// ⬇️ Neu laden – wenn Server-Quelle (cloud_id) oder Legacy-ytUrl existiert
|
||||||
if (song.ytUrl != null && onErneutHerunterladen != null)
|
if ((song.cloudId != null || song.ytUrl != null) && onErneutHerunterladen != null)
|
||||||
ListTile(
|
ListTile(
|
||||||
leading: const Icon(Icons.download, color: MeloTheme.rot, size: 20),
|
leading: const Icon(Icons.download, color: MeloTheme.rot, size: 20),
|
||||||
title: Text(
|
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', () {
|
group('History & Songs', () {
|
||||||
test('getHistory parst history-Liste', () async {
|
test('getHistory parst history-Liste', () async {
|
||||||
final cloud = CloudService(
|
final cloud = CloudService(
|
||||||
|
|||||||
Reference in New Issue
Block a user