From 6200c7761493ba54d0fae601ab6be1719e3e95aa Mon Sep 17 00:00:00 2001 From: "Hermes (Server)" Date: Thu, 20 Aug 2026 19:13:55 +0200 Subject: [PATCH] YouTube-Downloader ueber den Baka-Proxy inkl. Anmeldung und MediaStore Neuer YouTube-Bereich im Online-Tab: Adresse einfuegen, herunterladen, der Titel landet in Meine Musik. - BakaAuth: Anmeldung gegen baka-net.de/auth, Token verschluesselt auf dem Geraet. Der Server meldet Fehler mit HTTP 200 und Text im Rumpf, deshalb wird der Inhalt geprueft statt nur der Statuscode. - YtDownloadService: Proxy fragen, MP3 abholen, halbe Dateien aufraeumen. Kuenstler nur aus "Kuenstler - Lied", sonst leer statt geraten. - MediaStoreBridge (Kotlin): legt die Datei in Music/Melo ab und meldet sie dem MediaStore. Noetig, weil der Android-Scan sonst beim naechsten Lauf alles wieder als verschwunden markiert. - SubTabs nach shared/ gezogen (jetzt in Meine Musik und Online genutzt). 177 Tests gruen, flutter analyze ohne Befund, Release-APK baut. Der Weg ueber den echten Proxy ist noch nicht auf dem Geraet erprobt. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_018NLW1q1hzEC9goDQ1dJ98d --- CHANGELOG.md | 39 +++ ...otlin-compiler-11005204419413496908.salive | 0 android/app/src/main/AndroidManifest.xml | 4 + .../main/kotlin/de/baka/melo/MainActivity.kt | 6 + .../kotlin/de/baka/melo/MediaStoreBridge.kt | 130 ++++++++++ lib/downloads/downloads_screen.dart | 228 +++++++++++++++++- lib/library/my_music_screen.dart | 50 +--- lib/main.dart | 9 + lib/services/baka_auth.dart | 110 +++++++++ lib/services/media_store.dart | 37 +++ lib/services/yt_download_service.dart | 162 +++++++++++++ lib/shared/sub_tabs.dart | 52 ++++ test/downloads/online_screen_test.dart | 83 +++++++ test/home_shell_test.dart | 16 ++ test/services/baka_auth_test.dart | 106 ++++++++ test/services/media_store_test.dart | 47 ++++ test/services/yt_download_test.dart | 182 ++++++++++++++ 17 files changed, 1210 insertions(+), 51 deletions(-) delete mode 100644 android/.kotlin/sessions/kotlin-compiler-11005204419413496908.salive create mode 100644 android/app/src/main/kotlin/de/baka/melo/MediaStoreBridge.kt create mode 100644 lib/services/baka_auth.dart create mode 100644 lib/services/media_store.dart create mode 100644 lib/services/yt_download_service.dart create mode 100644 lib/shared/sub_tabs.dart create mode 100644 test/downloads/online_screen_test.dart create mode 100644 test/services/baka_auth_test.dart create mode 100644 test/services/media_store_test.dart create mode 100644 test/services/yt_download_test.dart diff --git a/CHANGELOG.md b/CHANGELOG.md index 8059b2a..301884b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,45 @@ Format angelehnt an [Keep a Changelog](https://keepachangelog.com/de/1.0.0/). ## [Unreleased] +### ⬇️ YouTube-Downloader mit Baka-Anmeldung (2026-08-20) +- 🎬 **Musik von YouTube holen:** Im Tab **Online → YouTube** eine Adresse + einfügen, auf *Herunterladen* tippen — fertig. Der Titel landet danach + automatisch in **Meine Musik**. +- 🔑 **Warum eine Anmeldung nötig ist:** Das Herunterladen erledigt der + Baka-Server (yt.baka-net.de), und der nimmt nur Anfragen von angemeldeten + Konten an. Deshalb gibt es im YouTube-Bereich einen Anmelde-Knopf für dein + **Baka-Konto**. Die Anmeldung wird **verschlüsselt auf dem Gerät** + gespeichert und gilt auch nach dem Neustart. + Läuft sie ab, sagt Melo das im Klartext ("Anmeldung abgelaufen — bitte neu + anmelden") statt eine Fehlernummer zu zeigen. +- 📂 **Wo die Musik landet:** In `Music/Melo` — dem **öffentlichen** + Musikordner. Das ist wichtig: Melo liest seine Bibliothek auf Android über + den MediaStore. Eine Datei, die nur in einem App-Ordner läge, wäre beim + nächsten Scan wieder verschwunden. Nebeneffekt: die Musik ist auch in + anderen Apps da. +- 🧹 **Keine Bruchstücke:** Der Download geht erst in einen Zwischenordner. + Erst wenn die Datei vollständig da ist, wandert sie in den Musikordner. + Bricht etwas ab, wird die halbe Datei gelöscht statt als kaputter Titel in + der Bibliothek zu landen. +- 🎤 **Künstler:** Steht der Titel als "Künstler - Lied" da, wird der + Künstler übernommen. Steht da nichts dergleichen, bleibt das Feld leer — + geraten wird nicht. +- 🔧 **Details:** + - Neu: `services/baka_auth.dart` (6 Tests), `services/yt_download_service.dart` + (8 Tests), `services/media_store.dart` (2 Tests) und die native Brücke + `MediaStoreBridge.kt` (MediaStore ab Android 10, Dateisystem + Scanner + davor). + - `WRITE_EXTERNAL_STORAGE` (nur bis Android 9) im Manifest ergänzt. + - Der Online-Tab hat jetzt die Unterreiter **YouTube · Server** (3 Tests); + `SubTabs` liegt dafür in `shared/sub_tabs.dart`. + - ⚠️ Der Anmelde-Server meldet Fehler mit Status 200 und Text im Rumpf — + der Code prüft deshalb den Inhalt, nicht nur den Statuscode. +- ✅ **Getestet:** 177 Tests grün (19 neue), `flutter analyze` ohne Befund, + Release-APK baut (22,8 MB). + ⚠️ **Ehrlich dazugesagt:** Der Weg über den echten Proxy und das Schreiben + in den Musikordner sind **noch nicht auf dem Handy ausprobiert** — das + geht nur auf dem Gerät. Alles davor ist mit Tests abgedeckt. + ### 🧭 Neue Tab-Struktur nach deinen Referenzbildern (2026-08-20) - 🔀 **Untere Leiste wie auf deinem Bild:** Die Reihenfolge ist jetzt **Meine Musik · Online · Suchen · Favoriten**. Der frühere Reiter diff --git a/android/.kotlin/sessions/kotlin-compiler-11005204419413496908.salive b/android/.kotlin/sessions/kotlin-compiler-11005204419413496908.salive deleted file mode 100644 index e69de29..0000000 diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index d940a27..958f33d 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -13,6 +13,10 @@ + + ("sourcePath") + if (quelle == null) { + result.error("args", "sourcePath fehlt", null) + return + } + try { + result.success( + publish( + File(quelle), + call.argument("title") ?: File(quelle).nameWithoutExtension, + call.argument("artist") + ) + ) + } catch (e: Exception) { + result.error("io", e.message, null) + } + } + + private fun publish(quelle: File, titel: String, kuenstler: String?): String { + return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + publishViaMediaStore(quelle, titel, kuenstler) + } else { + publishViaDateisystem(quelle, titel, kuenstler) + } + } + + /** Android 10+: Scoped Storage — der MediaStore vergibt den Platz. */ + private fun publishViaMediaStore(quelle: File, titel: String, kuenstler: String?): String { + val werte = ContentValues().apply { + put(MediaStore.Audio.Media.DISPLAY_NAME, quelle.name) + put(MediaStore.Audio.Media.MIME_TYPE, "audio/mpeg") + put(MediaStore.Audio.Media.TITLE, titel) + put(MediaStore.Audio.Media.IS_MUSIC, 1) + kuenstler?.takeIf { it.isNotBlank() } + ?.let { put(MediaStore.Audio.Media.ARTIST, it) } + put( + MediaStore.Audio.Media.RELATIVE_PATH, + "${Environment.DIRECTORY_MUSIC}/$UNTERORDNER" + ) + // Solange 1, sieht keine andere App die halbfertige Datei. + put(MediaStore.Audio.Media.IS_PENDING, 1) + } + + val aufloeser = context.contentResolver + val ziel = aufloeser.insert(MediaStore.Audio.Media.EXTERNAL_CONTENT_URI, werte) + ?: throw IllegalStateException("MediaStore nahm den Eintrag nicht an") + + try { + aufloeser.openOutputStream(ziel).use { aus -> + requireNotNull(aus) { "Kein Schreibzugriff auf den Eintrag" } + quelle.inputStream().use { ein -> ein.copyTo(aus) } + } + } catch (e: Exception) { + aufloeser.delete(ziel, null, null) + throw e + } + + aufloeser.update( + ziel, + ContentValues().apply { put(MediaStore.Audio.Media.IS_PENDING, 0) }, + null, + null + ) + quelle.delete() + + // Der Scan gleicht ueber den Dateipfad ab, nicht ueber die URI — + // deshalb den echten Pfad nachschlagen. + aufloeser.query(ziel, arrayOf(MediaStore.Audio.Media.DATA), null, null, null) + ?.use { c -> + if (c.moveToFirst()) { + c.getString(0)?.let { return it } + } + } + return ziel.toString() + } + + /** Android 9 und aelter: direkt in den Musikordner, dann scannen lassen. */ + private fun publishViaDateisystem(quelle: File, titel: String, kuenstler: String?): String { + val ordner = File( + Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_MUSIC), + UNTERORDNER + ) + ordner.mkdirs() + val ziel = File(ordner, quelle.name) + quelle.copyTo(ziel, overwrite = true) + quelle.delete() + + val werte = ContentValues().apply { + put(MediaStore.Audio.Media.DATA, ziel.absolutePath) + put(MediaStore.Audio.Media.DISPLAY_NAME, ziel.name) + put(MediaStore.Audio.Media.MIME_TYPE, "audio/mpeg") + put(MediaStore.Audio.Media.TITLE, titel) + put(MediaStore.Audio.Media.IS_MUSIC, 1) + kuenstler?.takeIf { it.isNotBlank() } + ?.let { put(MediaStore.Audio.Media.ARTIST, it) } + } + context.contentResolver.insert(MediaStore.Audio.Media.EXTERNAL_CONTENT_URI, werte) + return ziel.absolutePath + } +} diff --git a/lib/downloads/downloads_screen.dart b/lib/downloads/downloads_screen.dart index d8006b2..1afe111 100644 --- a/lib/downloads/downloads_screen.dart +++ b/lib/downloads/downloads_screen.dart @@ -1,23 +1,38 @@ +import 'dart:io'; + import 'package:audio_service/audio_service.dart'; import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; +import '../library/library_service.dart'; import '../player/audio_handler.dart'; +import '../services/baka_auth.dart'; +import '../services/media_store.dart'; import '../services/navidrome_service.dart'; +import '../services/yt_download_service.dart'; +import '../shared/sub_tabs.dart'; /// Online-Tab: durchsucht den verbundenen Navidrome-Server. Abgespielte /// Titel werden vom [MeloAudioHandler] automatisch lokal zwischengespeichert /// und stehen danach offline zur Verfügung. -class DownloadsScreen extends StatelessWidget { +class DownloadsScreen extends StatefulWidget { const DownloadsScreen({super.key}); + @override + State createState() => _DownloadsScreenState(); +} + +class _DownloadsScreenState extends State { + /// 0 = YouTube, 1 = Server. + int _subTab = 0; + @override Widget build(BuildContext context) { - return const SafeArea( + return SafeArea( bottom: false, child: Column( children: [ - Padding( + const Padding( padding: EdgeInsets.fromLTRB(20, 16, 20, 4), child: Align( alignment: Alignment.centerLeft, @@ -25,7 +40,14 @@ class DownloadsScreen extends StatelessWidget { style: TextStyle(fontSize: 24, fontWeight: FontWeight.w700)), ), ), - Expanded(child: _ServerBrowser()), + SubTabs( + labels: const ['YouTube', 'Server'], + index: _subTab, + onChanged: (i) => setState(() => _subTab = i), + ), + Expanded( + child: _subTab == 0 ? const _YouTubeBereich() : const _ServerBrowser(), + ), ], ), ); @@ -303,3 +325,201 @@ class _ServerBrowserState extends State<_ServerBrowser> { } } } + +/// YouTube-Bereich: Anmeldung am Baka-Konto und Adressfeld für den Download. +class _YouTubeBereich extends StatefulWidget { + const _YouTubeBereich(); + + @override + State<_YouTubeBereich> createState() => _YouTubeBereichState(); +} + +class _YouTubeBereichState extends State<_YouTubeBereich> { + final _url = TextEditingController(); + String? _meldung; + + @override + void dispose() { + _url.dispose(); + super.dispose(); + } + + Future _anmelden() async { + final auth = context.read(); + final daten = await showDialog<(String, String)>( + context: context, + builder: (_) => const _AnmeldeDialog(), + ); + if (daten == null || !mounted) return; + final fehler = await auth.anmelden(daten.$1, daten.$2); + if (!mounted) return; + setState(() => _meldung = fehler); + } + + Future _herunterladen() async { + final dienst = context.read(); + final lib = context.read(); + final url = _url.text.trim(); + if (url.isEmpty) return; + + setState(() => _meldung = null); + // Erst in einen App-eigenen Zwischenordner — erst wenn die Datei + // vollständig da ist, wandert sie in den öffentlichen Musikordner. + final zwischen = Directory.systemTemp.createTempSync('melo_yt').path; + final ergebnis = + await dienst.herunterladen(url, zielOrdner: zwischen); + if (!mounted) return; + + if (ergebnis == null) { + setState(() => _meldung = dienst.fehler); + return; + } + + final pfad = await const MediaStore().veroeffentliche( + quellPfad: ergebnis.dateiPfad, + titel: ergebnis.titel, + kuenstler: ergebnis.kuenstler, + ); + if (!mounted) return; + if (pfad == null) { + setState(() => _meldung = + 'Heruntergeladen, aber nicht im Musikordner ablegbar'); + return; + } + + _url.clear(); + setState(() => _meldung = '✅ ${ergebnis.titel} — wird eingelesen …'); + // Der MediaStore kennt die Datei jetzt; ein Scan holt sie in die Bibliothek. + await lib.rescan(); + if (mounted) { + setState(() => _meldung = '✅ ${ergebnis.titel} ist in deiner Musik'); + } + } + + @override + Widget build(BuildContext context) { + final auth = context.watch(); + final dienst = context.watch(); + + return ListView( + padding: const EdgeInsets.fromLTRB(16, 12, 16, 24), + children: [ + if (!auth.istAngemeldet) ...[ + const Text( + 'Der Downloader läuft über den Baka-Server. Dafür brauchst du ' + 'deine Baka-Anmeldung.', + style: TextStyle(color: Colors.white54, fontSize: 13), + ), + const SizedBox(height: 12), + FilledButton.icon( + icon: const Icon(Icons.login), + label: const Text('Beim Baka-Konto anmelden'), + onPressed: _anmelden, + ), + ] else ...[ + Row( + children: [ + const Icon(Icons.verified_user, size: 18, color: Colors.white38), + const SizedBox(width: 8), + Expanded( + child: Text('Angemeldet als ${auth.benutzer}', + style: const TextStyle(color: Colors.white54, fontSize: 13)), + ), + TextButton( + onPressed: auth.abmelden, + child: const Text('Abmelden'), + ), + ], + ), + const SizedBox(height: 8), + TextField( + controller: _url, + enabled: !dienst.laeuft, + decoration: const InputDecoration( + labelText: 'YouTube-Adresse', + hintText: 'https://youtu.be/…', + border: OutlineInputBorder(), + ), + ), + const SizedBox(height: 12), + FilledButton.icon( + icon: const Icon(Icons.download), + label: const Text('Herunterladen'), + onPressed: dienst.laeuft ? null : _herunterladen, + ), + ], + if (dienst.laeuft) ...[ + const SizedBox(height: 16), + const LinearProgressIndicator(), + const SizedBox(height: 8), + Text(dienst.status ?? 'Läuft …', + style: const TextStyle(color: Colors.white54, fontSize: 12)), + ], + if (_meldung != null) ...[ + const SizedBox(height: 16), + Text(_meldung!, + style: const TextStyle(color: Colors.white70, fontSize: 13)), + ], + ], + ); + } +} + +/// Fragt Benutzername und Passwort ab und gibt beides zurück. +class _AnmeldeDialog extends StatefulWidget { + const _AnmeldeDialog(); + + @override + State<_AnmeldeDialog> createState() => _AnmeldeDialogState(); +} + +class _AnmeldeDialogState extends State<_AnmeldeDialog> { + final _benutzer = TextEditingController(text: 'Baka'); + final _passwort = TextEditingController(); + bool _sichtbar = false; + + @override + void dispose() { + _benutzer.dispose(); + _passwort.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return AlertDialog( + title: const Text('Baka-Konto'), + content: Column( + mainAxisSize: MainAxisSize.min, + children: [ + TextField( + controller: _benutzer, + decoration: const InputDecoration(labelText: 'Benutzername'), + ), + TextField( + controller: _passwort, + obscureText: !_sichtbar, + decoration: InputDecoration( + labelText: 'Passwort', + suffixIcon: IconButton( + icon: Icon(_sichtbar ? Icons.visibility_off : Icons.visibility), + onPressed: () => setState(() => _sichtbar = !_sichtbar), + ), + ), + ), + ], + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context), + child: const Text('Abbrechen'), + ), + FilledButton( + onPressed: () => Navigator.pop( + context, (_benutzer.text.trim(), _passwort.text)), + child: const Text('Anmelden'), + ), + ], + ); + } +} diff --git a/lib/library/my_music_screen.dart b/lib/library/my_music_screen.dart index 37595a2..ee65bfa 100644 --- a/lib/library/my_music_screen.dart +++ b/lib/library/my_music_screen.dart @@ -5,6 +5,7 @@ import '../playlists/playlists_screen.dart'; import '../settings/settings_screen.dart'; import '../shared/cover.dart'; import '../shared/sort_store.dart'; +import '../shared/sub_tabs.dart'; import '../shared/sortable_song_list.dart'; import '../shared/theme.dart'; import 'album_list.dart'; @@ -82,7 +83,8 @@ class _MyMusicScreenState extends State { ), ), _QuickAccessRow(onFavoritesTap: widget.onFavoritesTap), - _SubTabs( + SubTabs( + labels: const ['Songs', 'Künstler', 'Alben'], index: _subTab, onChanged: (i) => setState(() => _subTab = i), ), @@ -171,52 +173,6 @@ class _Header extends StatelessWidget { } } -/// Unterreiter im Stil der Referenz-UI: der aktive Reiter ist eine helle -/// Pille, die übrigen sind nur Text. -class _SubTabs extends StatelessWidget { - const _SubTabs({required this.index, required this.onChanged}); - - final int index; - final ValueChanged onChanged; - - static const _labels = ['Songs', 'Künstler', 'Alben']; - - @override - Widget build(BuildContext context) { - return Padding( - padding: const EdgeInsets.fromLTRB(12, 12, 12, 4), - child: Row( - children: [ - for (var i = 0; i < _labels.length; i++) - Padding( - padding: const EdgeInsets.only(right: 8), - child: GestureDetector( - onTap: () => onChanged(i), - child: Container( - padding: - const EdgeInsets.symmetric(horizontal: 18, vertical: 9), - decoration: BoxDecoration( - color: i == index ? Colors.white : Colors.transparent, - borderRadius: BorderRadius.circular(20), - ), - child: Text( - _labels[i], - style: TextStyle( - color: i == index ? Colors.black : Colors.white70, - fontSize: 15, - fontWeight: - i == index ? FontWeight.w600 : FontWeight.w400, - ), - ), - ), - ), - ), - ], - ), - ); - } -} - /// Waagerecht scrollende Schnellzugriffe (Favoriten, Wiedergabelisten, …). class _QuickAccessRow extends StatelessWidget { const _QuickAccessRow({this.onFavoritesTap}); diff --git a/lib/main.dart b/lib/main.dart index b9b7d80..3caf192 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -13,8 +13,10 @@ import 'library/search_screen.dart'; import 'player/audio_effects.dart'; import 'player/audio_handler.dart'; import 'player/mini_player.dart'; +import 'services/baka_auth.dart'; import 'services/logger_service.dart'; import 'services/offline_mode.dart'; +import 'services/yt_download_service.dart'; import 'settings/app_settings.dart'; import 'shared/theme.dart'; @@ -26,6 +28,8 @@ late final OfflineMode _offlineMode; late final CategoryService _categories; late final AppSettings _settings; late final AudioEffects _effects; +late final BakaAuth _bakaAuth; +late final YtDownloadService _ytDownload; Future main() async { WidgetsFlutterBinding.ensureInitialized(); @@ -49,6 +53,9 @@ Future main() async { ); _effects = AudioEffects(); await _effects.init(); + _bakaAuth = BakaAuth(); + await _bakaAuth.laden(); + _ytDownload = YtDownloadService(auth: _bakaAuth); // Die Audio-Session steht erst beim Abspielen fest und kann wechseln — // die Klangeffekte müssen dann neu daran gehängt werden. _handler.androidAudioSessionIdStream.listen((id) { @@ -80,6 +87,8 @@ class MeloApp extends StatelessWidget { ChangeNotifierProvider.value(value: _categories), ChangeNotifierProvider.value(value: _settings), ChangeNotifierProvider.value(value: _effects), + ChangeNotifierProvider.value(value: _bakaAuth), + ChangeNotifierProvider.value(value: _ytDownload), ], child: MaterialApp( title: 'Melo', diff --git a/lib/services/baka_auth.dart b/lib/services/baka_auth.dart new file mode 100644 index 0000000..fada530 --- /dev/null +++ b/lib/services/baka_auth.dart @@ -0,0 +1,110 @@ +import 'dart:convert'; + +import 'package:flutter/foundation.dart'; +import 'package:flutter_secure_storage/flutter_secure_storage.dart'; +import 'package:http/http.dart' as http; + +/// Ablage für den Anmelde-Token. Als Schnittstelle, damit Tests ohne die +/// Plattform-Kanäle von FlutterSecureStorage auskommen. +abstract class TokenSpeicher { + Future lesen(String key); + Future schreiben(String key, String wert); + Future loeschen(String key); +} + +/// Produktions-Ablage: verschlüsselter Gerätespeicher. +class SicherenSpeicher implements TokenSpeicher { + const SicherenSpeicher(); + + static const _secure = FlutterSecureStorage(); + + @override + Future lesen(String key) => _secure.read(key: key); + + @override + Future schreiben(String key, String wert) => + _secure.write(key: key, value: wert); + + @override + Future loeschen(String key) => _secure.delete(key: key); +} + +/// Anmeldung am Baka-Konto. Der YouTube-Proxy (yt.baka-net.de) akzeptiert +/// nur Anfragen mit dem hier erzeugten Bearer-Token. +class BakaAuth extends ChangeNotifier { + BakaAuth({http.Client? client, TokenSpeicher? speicher}) + : _client = client ?? http.Client(), + _speicher = speicher ?? const SicherenSpeicher(); + + static const basisUrl = 'https://baka-net.de/auth'; + static const _tokenKey = 'baka_token'; + static const _userKey = 'baka_user'; + + final http.Client _client; + final TokenSpeicher _speicher; + + String? _token; + String _benutzer = ''; + + bool get istAngemeldet => _token != null && _token!.isNotEmpty; + String get benutzer => _benutzer; + + /// Header für Anfragen an den Proxy. Ohne Anmeldung ohne `Authorization`. + Map get authHeader => { + if (istAngemeldet) 'Authorization': 'Bearer $_token', + }; + + /// Stellt eine frühere Anmeldung wieder her (App-Start). + Future laden() async { + _token = await _speicher.lesen(_tokenKey); + _benutzer = await _speicher.lesen(_userKey) ?? ''; + notifyListeners(); + } + + /// Meldet an. Gibt `null` bei Erfolg zurück, sonst den Fehlertext. + Future anmelden(String benutzer, String passwort) async { + final http.Response antwort; + try { + antwort = await _client + .post( + Uri.parse('$basisUrl/login'), + headers: {'Content-Type': 'application/json'}, + body: jsonEncode({'username': benutzer, 'password': passwort}), + ) + .timeout(const Duration(seconds: 15)); + } catch (e) { + debugPrint('Baka-Anmeldung fehlgeschlagen: $e'); + return 'Keine Verbindung zum Anmelde-Server'; + } + + Map daten; + try { + daten = jsonDecode(antwort.body) as Map; + } catch (_) { + return 'Anmelde-Server antwortet unverständlich (${antwort.statusCode})'; + } + + final token = daten['token'] as String?; + if (token != null && token.isNotEmpty) { + _token = token; + _benutzer = benutzer; + await _speicher.schreiben(_tokenKey, token); + await _speicher.schreiben(_userKey, benutzer); + notifyListeners(); + return null; + } + + // Der Server meldet Fehler mit Status 200 und einem Text im Rumpf. + return daten['message'] as String? ?? + daten['error'] as String? ?? + 'Anmeldung fehlgeschlagen (${antwort.statusCode})'; + } + + Future abmelden() async { + _token = null; + _benutzer = ''; + await _speicher.loeschen(_tokenKey); + await _speicher.loeschen(_userKey); + notifyListeners(); + } +} diff --git a/lib/services/media_store.dart b/lib/services/media_store.dart new file mode 100644 index 0000000..cb7f673 --- /dev/null +++ b/lib/services/media_store.dart @@ -0,0 +1,37 @@ +import 'package:flutter/foundation.dart'; +import 'package:flutter/services.dart'; + +/// Trägt fertige Musikdateien in den öffentlichen Musikordner von Android ein. +/// +/// Nötig, weil der Bibliotheks-Scan auf Android über den MediaStore läuft: +/// Songs, die dort nicht stehen, werden beim nächsten Scan als verschwunden +/// markiert. Nebeneffekt: heruntergeladene Musik ist auch in anderen Apps da. +class MediaStore { + const MediaStore({this.channel = _standardKanal}); + + static const _standardKanal = MethodChannel('de.baka.melo/media_store'); + + final MethodChannel channel; + + /// Verschiebt [quellPfad] nach `Music/Melo` und meldet die Datei dem + /// MediaStore. Gibt den neuen Pfad zurück, oder `null` wenn es nicht klappt. + Future veroeffentliche({ + required String quellPfad, + required String titel, + String? kuenstler, + }) async { + try { + return await channel.invokeMethod('publishAudio', { + 'sourcePath': quellPfad, + 'title': titel, + 'artist': kuenstler, + }); + } on PlatformException catch (e) { + debugPrint('MediaStore-Eintrag fehlgeschlagen: ${e.message}'); + return null; + } on MissingPluginException { + // Desktop/Tests: dort gibt es keinen MediaStore. + return null; + } + } +} diff --git a/lib/services/yt_download_service.dart b/lib/services/yt_download_service.dart new file mode 100644 index 0000000..c55b68f --- /dev/null +++ b/lib/services/yt_download_service.dart @@ -0,0 +1,162 @@ +import 'dart:convert'; +import 'dart:io'; + +import 'package:flutter/foundation.dart'; +import 'package:http/http.dart' as http; + +import 'baka_auth.dart'; + +/// Entfernt alles, was in einem Dateinamen Ärger macht. +String sichererDateiname(String titel) => + titel.replaceAll(RegExp(r'[\\/:*?"<>|]'), '_').trim(); + +/// Ergebnis eines geglückten Downloads. +class YtErgebnis { + const YtErgebnis({ + required this.dateiPfad, + required this.titel, + required this.kuenstler, + required this.dauerSekunden, + }); + + final String dateiPfad; + final String titel; + final String kuenstler; + final int dauerSekunden; +} + +/// Lädt Musik über den Baka-YouTube-Proxy herunter. Der Proxy erledigt die +/// eigentliche Arbeit und liefert eine fertige MP3; die App speichert sie nur. +class YtDownloadService extends ChangeNotifier { + YtDownloadService({required this.auth, http.Client? client}) + : _client = client ?? http.Client(); + + static const proxyUrl = 'https://yt.baka-net.de'; + + final BakaAuth auth; + final http.Client _client; + + bool _laeuft = false; + String? _status; + String? _fehler; + + bool get laeuft => _laeuft; + + /// Was gerade passiert — für die Anzeige während des Downloads. + String? get status => _status; + String? get fehler => _fehler; + + void _melde(String text) { + _status = text; + notifyListeners(); + } + + void _scheitere(String text) { + _fehler = text; + _status = null; + _laeuft = false; + notifyListeners(); + } + + /// Lädt [url] herunter und legt die MP3 in [zielOrdner] ab. + /// Gibt bei Erfolg das Ergebnis zurück, sonst `null` (siehe [fehler]). + Future herunterladen(String url, + {required String zielOrdner}) async { + _fehler = null; + _laeuft = true; + notifyListeners(); + + if (!auth.istAngemeldet) { + _scheitere('Bitte zuerst beim Baka-Konto anmelden'); + return null; + } + if (!url.contains('youtube.com') && !url.contains('youtu.be')) { + _scheitere('Das ist keine YouTube-Adresse'); + return null; + } + + // ─── 1/2: Proxy fragen — er lädt das Video und wandelt es um ─── + _melde('Proxy wird gefragt …'); + final http.Response antwort; + try { + antwort = await _client + .post( + Uri.parse('$proxyUrl/api/yt-dl'), + headers: {...auth.authHeader, 'Content-Type': 'application/json'}, + body: jsonEncode({'url': url}), + ) + .timeout(const Duration(seconds: 180)); + } catch (e) { + debugPrint('YT-Proxy nicht erreichbar: $e'); + _scheitere('Proxy nicht erreichbar'); + return null; + } + + if (antwort.statusCode == 401) { + _scheitere('Anmeldung abgelaufen — bitte neu anmelden'); + return null; + } + if (antwort.statusCode != 200) { + _scheitere(_fehlerText(antwort)); + return null; + } + + final daten = jsonDecode(antwort.body) as Map; + final titel = daten['titel'] as String? ?? 'Unbekannt'; + final dauer = (daten['dauer'] as int?) ?? 0; + final mp3Url = daten['mp3_url'] as String? ?? '/api/dl/audio.mp3'; + + // ─── 2/2: fertige MP3 abholen und speichern ─── + _melde('Lade „$titel“ …'); + final ordner = Directory(zielOrdner); + if (!await ordner.exists()) { + await ordner.create(recursive: true); + } + final datei = File('${ordner.path}/${sichererDateiname(titel)}.mp3'); + + try { + final mp3 = await _client + .get(Uri.parse('$proxyUrl$mp3Url'), headers: auth.authHeader) + .timeout(const Duration(seconds: 180)); + if (mp3.statusCode != 200) { + _scheitere('Herunterladen fehlgeschlagen (${mp3.statusCode})'); + return null; + } + await datei.writeAsBytes(mp3.bodyBytes); + } catch (e) { + debugPrint('MP3-Abruf fehlgeschlagen: $e'); + // Halbe Dateien nicht liegen lassen — sonst taucht Müll in der + // Bibliothek auf, sobald der Ordner das nächste Mal gescannt wird. + if (await datei.exists()) { + await datei.delete(); + } + _scheitere('Herunterladen fehlgeschlagen'); + return null; + } + + _laeuft = false; + _status = null; + notifyListeners(); + + // yt-dlp liefert nur einen Titel. "Künstler - Lied" ist die übliche + // Form; steht da nichts dergleichen, wird nichts geraten. + final teile = titel.split(' - '); + return YtErgebnis( + dateiPfad: datei.path, + titel: titel, + kuenstler: teile.length >= 2 ? teile.first.trim() : '', + dauerSekunden: dauer, + ); + } + + String _fehlerText(http.Response antwort) { + try { + final j = jsonDecode(antwort.body) as Map; + final text = j['error'] as String?; + if (text != null && text.isNotEmpty) return text; + } catch (_) { + // Kein JSON — dann eben der Statuscode. + } + return 'Proxy-Fehler (${antwort.statusCode})'; + } +} diff --git a/lib/shared/sub_tabs.dart b/lib/shared/sub_tabs.dart new file mode 100644 index 0000000..10b48ff --- /dev/null +++ b/lib/shared/sub_tabs.dart @@ -0,0 +1,52 @@ +import 'package:flutter/material.dart'; + +/// Unterreiter im Stil der Referenz-UI: der aktive Reiter ist eine helle +/// Pille, die übrigen sind nur Text. +class SubTabs extends StatelessWidget { + const SubTabs({ + super.key, + required this.labels, + required this.index, + required this.onChanged, + }); + + final List labels; + + final int index; + final ValueChanged onChanged; + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.fromLTRB(12, 12, 12, 4), + child: Row( + children: [ + for (var i = 0; i < labels.length; i++) + Padding( + padding: const EdgeInsets.only(right: 8), + child: GestureDetector( + onTap: () => onChanged(i), + child: Container( + padding: + const EdgeInsets.symmetric(horizontal: 18, vertical: 9), + decoration: BoxDecoration( + color: i == index ? Colors.white : Colors.transparent, + borderRadius: BorderRadius.circular(20), + ), + child: Text( + labels[i], + style: TextStyle( + color: i == index ? Colors.black : Colors.white70, + fontSize: 15, + fontWeight: + i == index ? FontWeight.w600 : FontWeight.w400, + ), + ), + ), + ), + ), + ], + ), + ); + } +} diff --git a/test/downloads/online_screen_test.dart b/test/downloads/online_screen_test.dart new file mode 100644 index 0000000..5d32166 --- /dev/null +++ b/test/downloads/online_screen_test.dart @@ -0,0 +1,83 @@ +import 'dart:convert'; + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:http/http.dart' as http; +import 'package:http/testing.dart'; +import 'package:melo/downloads/downloads_screen.dart'; +import 'package:melo/services/baka_auth.dart'; +import 'package:melo/services/yt_download_service.dart'; +import 'package:provider/provider.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +class _MemorySpeicher implements TokenSpeicher { + final Map werte = {}; + @override + Future lesen(String key) async => werte[key]; + @override + Future schreiben(String key, String wert) async => werte[key] = wert; + @override + Future loeschen(String key) async => werte.remove(key); +} + +Widget _wrap(BakaAuth auth) { + return MultiProvider( + providers: [ + ChangeNotifierProvider.value(value: auth), + ChangeNotifierProvider( + create: (_) => YtDownloadService( + auth: auth, + client: MockClient((_) async => http.Response('', 500)), + ), + ), + ], + child: const MaterialApp(home: Scaffold(body: DownloadsScreen())), + ); +} + +void main() { + setUp(() => SharedPreferences.setMockInitialValues({})); + + testWidgets('Online-Tab hat die Unterreiter YouTube und Server', + (tester) async { + final auth = + BakaAuth(client: MockClient((_) async => http.Response('', 500)), + speicher: _MemorySpeicher()); + + await tester.pumpWidget(_wrap(auth)); + await tester.pump(); + + expect(find.text('YouTube'), findsOneWidget); + expect(find.text('Server'), findsOneWidget); + }); + + testWidgets('Ohne Anmeldung fordert der YouTube-Bereich zum Anmelden auf', + (tester) async { + final auth = + BakaAuth(client: MockClient((_) async => http.Response('', 500)), + speicher: _MemorySpeicher()); + + await tester.pumpWidget(_wrap(auth)); + await tester.pump(); + + expect(find.text('Beim Baka-Konto anmelden'), findsOneWidget); + expect(find.byType(TextField), findsNothing); + }); + + testWidgets('Angemeldet zeigt der YouTube-Bereich das Adressfeld', + (tester) async { + final auth = BakaAuth( + client: MockClient( + (_) async => http.Response(jsonEncode({'token': 'jwt'}), 200)), + speicher: _MemorySpeicher(), + ); + await auth.anmelden('Baka', 'geheim'); + + await tester.pumpWidget(_wrap(auth)); + await tester.pump(); + + expect(find.text('Angemeldet als Baka'), findsOneWidget); + expect(find.byType(TextField), findsOneWidget); + expect(find.text('Herunterladen'), findsOneWidget); + }); +} diff --git a/test/home_shell_test.dart b/test/home_shell_test.dart index d1ea59c..1089b0f 100644 --- a/test/home_shell_test.dart +++ b/test/home_shell_test.dart @@ -7,11 +7,23 @@ import 'package:melo/library/library_service.dart'; import 'package:melo/library/playlist_service.dart'; import 'package:melo/main.dart'; import 'package:melo/player/audio_handler.dart'; +import 'package:melo/services/baka_auth.dart'; import 'package:melo/services/offline_mode.dart'; +import 'package:melo/services/yt_download_service.dart'; import 'package:melo/settings/app_settings.dart'; import 'package:provider/provider.dart'; import 'package:shared_preferences/shared_preferences.dart'; +class _MemorySpeicher implements TokenSpeicher { + final Map werte = {}; + @override + Future lesen(String key) async => werte[key]; + @override + Future schreiben(String key, String wert) async => werte[key] = wert; + @override + Future loeschen(String key) async => werte.remove(key); +} + void main() { setUp(() => SharedPreferences.setMockInitialValues({})); @@ -21,6 +33,7 @@ void main() { final lib = LibraryService(db); final playlists = PlaylistService(db); final handler = MeloAudioHandler(db: db); + final auth = BakaAuth(speicher: _MemorySpeicher()); await tester.pumpWidget(MultiProvider( providers: [ @@ -31,6 +44,9 @@ void main() { ChangeNotifierProvider(create: (_) => CategoryService(db)), ChangeNotifierProvider(create: (_) => AppSettings()), ChangeNotifierProvider(create: (_) => OfflineMode()), + ChangeNotifierProvider(create: (_) => auth), + ChangeNotifierProvider( + create: (_) => YtDownloadService(auth: auth)), ], child: const MaterialApp(home: HomeShell()), )); diff --git a/test/services/baka_auth_test.dart b/test/services/baka_auth_test.dart new file mode 100644 index 0000000..e0c8d63 --- /dev/null +++ b/test/services/baka_auth_test.dart @@ -0,0 +1,106 @@ +import 'dart:convert'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:http/http.dart' as http; +import 'package:http/testing.dart'; +import 'package:melo/services/baka_auth.dart'; + +/// Einfacher Speicher im Arbeitsspeicher — FlutterSecureStorage braucht +/// Plattform-Kanäle und ist im Test nicht verfügbar. +class _MemorySpeicher implements TokenSpeicher { + final Map werte = {}; + + @override + Future lesen(String key) async => werte[key]; + + @override + Future schreiben(String key, String wert) async => werte[key] = wert; + + @override + Future loeschen(String key) async => werte.remove(key); +} + +void main() { + test('Anmeldung mit gültigen Daten speichert den Token', () async { + final speicher = _MemorySpeicher(); + late String gesendet; + final client = MockClient((req) async { + gesendet = req.body; + return http.Response(jsonEncode({'token': 'jwt-abc'}), 200); + }); + final auth = BakaAuth(client: client, speicher: speicher); + + final fehler = await auth.anmelden('Baka', 'geheim'); + + expect(fehler, isNull); + expect(auth.istAngemeldet, isTrue); + expect(auth.benutzer, 'Baka'); + expect(speicher.werte['baka_token'], 'jwt-abc'); + expect(jsonDecode(gesendet), {'username': 'Baka', 'password': 'geheim'}); + }); + + test('Auth-Header trägt den Token als Bearer', () async { + final client = MockClient( + (_) async => http.Response(jsonEncode({'token': 'jwt-abc'}), 200)); + final auth = BakaAuth(client: client, speicher: _MemorySpeicher()); + await auth.anmelden('Baka', 'geheim'); + + expect(auth.authHeader['Authorization'], 'Bearer jwt-abc'); + }); + + test('Falsches Passwort liefert die Server-Meldung, obwohl HTTP 200 kommt', + () async { + // Der Baka-Auth-Server antwortet auch bei Fehlern mit Status 200. + final client = MockClient((_) async => http.Response( + jsonEncode({'status': 'error', 'message': 'Falscher Benutzer'}), 200)); + final auth = BakaAuth(client: client, speicher: _MemorySpeicher()); + + final fehler = await auth.anmelden('Baka', 'falsch'); + + expect(fehler, 'Falscher Benutzer'); + expect(auth.istAngemeldet, isFalse); + }); + + test('Nicht erreichbarer Server meldet einen Verbindungsfehler', () async { + final client = MockClient((_) async => throw const SocketAusfall()); + final auth = BakaAuth(client: client, speicher: _MemorySpeicher()); + + final fehler = await auth.anmelden('Baka', 'geheim'); + + expect(fehler, 'Keine Verbindung zum Anmelde-Server'); + expect(auth.istAngemeldet, isFalse); + }); + + test('laden() stellt einen gespeicherten Token wieder her', () async { + final speicher = _MemorySpeicher(); + speicher.werte['baka_token'] = 'jwt-alt'; + speicher.werte['baka_user'] = 'Baka'; + final auth = BakaAuth( + client: MockClient((_) async => http.Response('', 500)), + speicher: speicher); + + await auth.laden(); + + expect(auth.istAngemeldet, isTrue); + expect(auth.benutzer, 'Baka'); + }); + + test('Abmelden löscht Token und Benutzer aus dem Speicher', () async { + final speicher = _MemorySpeicher(); + final auth = BakaAuth( + client: MockClient( + (_) async => http.Response(jsonEncode({'token': 'jwt-abc'}), 200)), + speicher: speicher); + await auth.anmelden('Baka', 'geheim'); + + await auth.abmelden(); + + expect(auth.istAngemeldet, isFalse); + expect(speicher.werte, isEmpty); + }); +} + +/// Steht im Test für einen Netzwerkausfall. +class SocketAusfall implements Exception { + const SocketAusfall(); +} diff --git a/test/services/media_store_test.dart b/test/services/media_store_test.dart new file mode 100644 index 0000000..93e7e20 --- /dev/null +++ b/test/services/media_store_test.dart @@ -0,0 +1,47 @@ +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:melo/services/media_store.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + const kanal = MethodChannel('de.baka.melo/media_store'); + final messenger = + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger; + + tearDown(() => messenger.setMockMethodCallHandler(kanal, null)); + + test('veroeffentliche reicht Pfad, Titel und Künstler nach Android durch', + () async { + MethodCall? gesehen; + messenger.setMockMethodCallHandler(kanal, (call) async { + gesehen = call; + return '/storage/emulated/0/Music/Melo/Roses.mp3'; + }); + + final pfad = await const MediaStore().veroeffentliche( + quellPfad: '/tmp/Roses.mp3', + titel: 'Roses', + kuenstler: 'Neoni', + ); + + expect(pfad, '/storage/emulated/0/Music/Melo/Roses.mp3'); + expect(gesehen!.method, 'publishAudio'); + expect(gesehen!.arguments, { + 'sourcePath': '/tmp/Roses.mp3', + 'title': 'Roses', + 'artist': 'Neoni', + }); + }); + + test('Scheitert die Android-Seite, gibt es null statt einer Ausnahme', + () async { + messenger.setMockMethodCallHandler( + kanal, (_) async => throw PlatformException(code: 'io')); + + final pfad = await const MediaStore() + .veroeffentliche(quellPfad: '/tmp/x.mp3', titel: 'X'); + + expect(pfad, isNull); + }); +} diff --git a/test/services/yt_download_test.dart b/test/services/yt_download_test.dart new file mode 100644 index 0000000..1f091e0 --- /dev/null +++ b/test/services/yt_download_test.dart @@ -0,0 +1,182 @@ +import 'dart:convert'; +import 'dart:io'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:http/http.dart' as http; +import 'package:http/testing.dart'; +import 'package:melo/services/baka_auth.dart'; +import 'package:melo/services/yt_download_service.dart'; + +class _MemorySpeicher implements TokenSpeicher { + final Map werte = {}; + @override + Future lesen(String key) async => werte[key]; + @override + Future schreiben(String key, String wert) async => werte[key] = wert; + @override + Future loeschen(String key) async => werte.remove(key); +} + +Future _angemeldeteAuth() async { + final auth = BakaAuth( + client: MockClient( + (_) async => http.Response(jsonEncode({'token': 'jwt-abc'}), 200)), + speicher: _MemorySpeicher(), + ); + await auth.anmelden('Baka', 'geheim'); + return auth; +} + +void main() { + late Directory ziel; + + setUp(() => ziel = Directory.systemTemp.createTempSync('melo_yt_test')); + tearDown(() => ziel.deleteSync(recursive: true)); + + test('sichererDateiname entfernt Zeichen, die kein Dateisystem mag', () { + expect(sichererDateiname('AC/DC: Thunder*struck?'), + 'AC_DC_ Thunder_struck_'); + }); + + test('Nicht-YouTube-URL wird abgelehnt, ohne den Proxy zu fragen', () async { + var aufrufe = 0; + final dienst = YtDownloadService( + auth: await _angemeldeteAuth(), + client: MockClient((_) async { + aufrufe++; + return http.Response('', 200); + }), + ); + + final ergebnis = await dienst.herunterladen('https://example.com/lied', + zielOrdner: ziel.path); + + expect(ergebnis, isNull); + expect(dienst.fehler, 'Das ist keine YouTube-Adresse'); + expect(aufrufe, 0); + }); + + test('Erfolgreicher Download legt die MP3 im Zielordner ab', () async { + final dienst = YtDownloadService( + auth: await _angemeldeteAuth(), + client: MockClient((req) async { + if (req.url.path == '/api/yt-dl') { + expect(req.headers['Authorization'], 'Bearer jwt-abc'); + return http.Response( + jsonEncode({ + 'titel': 'Neoni - Roses', + 'dauer': 221, + 'dateiname': 'roses.mp3', + 'mp3_url': '/api/dl/roses.mp3', + 'filesize': 4, + }), + 200); + } + return http.Response.bytes([1, 2, 3, 4], 200); + }), + ); + + final ergebnis = await dienst.herunterladen( + 'https://youtu.be/abc123', + zielOrdner: ziel.path); + + expect(ergebnis, isNotNull); + expect(ergebnis!.titel, 'Neoni - Roses'); + expect(ergebnis.kuenstler, 'Neoni'); + expect(ergebnis.dauerSekunden, 221); + expect(File(ergebnis.dateiPfad).readAsBytesSync(), [1, 2, 3, 4]); + expect(ergebnis.dateiPfad, '${ziel.path}/Neoni - Roses.mp3'); + expect(dienst.fehler, isNull); + expect(dienst.laeuft, isFalse); + }); + + test('Ohne " - " im Titel bleibt der Künstler leer statt geraten zu werden', + () async { + final dienst = YtDownloadService( + auth: await _angemeldeteAuth(), + client: MockClient((req) async { + if (req.url.path == '/api/yt-dl') { + return http.Response( + jsonEncode({ + 'titel': 'Irgendein Mitschnitt', + 'dauer': 10, + 'mp3_url': '/api/dl/x.mp3', + }), + 200); + } + return http.Response.bytes([9], 200); + }), + ); + + final ergebnis = await dienst.herunterladen('https://youtu.be/x', + zielOrdner: ziel.path); + + expect(ergebnis!.kuenstler, ''); + }); + + test('Abgelaufener Token (401) bittet um erneute Anmeldung', () async { + final dienst = YtDownloadService( + auth: await _angemeldeteAuth(), + client: MockClient((_) async => http.Response('Unauthorized', 401)), + ); + + final ergebnis = await dienst.herunterladen('https://youtu.be/abc', + zielOrdner: ziel.path); + + expect(ergebnis, isNull); + expect(dienst.fehler, 'Anmeldung abgelaufen — bitte neu anmelden'); + }); + + test('Ohne Anmeldung wird gar nicht erst gefragt', () async { + final dienst = YtDownloadService( + auth: BakaAuth( + client: MockClient((_) async => http.Response('', 500)), + speicher: _MemorySpeicher()), + client: MockClient((_) async => http.Response('', 200)), + ); + + final ergebnis = await dienst.herunterladen('https://youtu.be/abc', + zielOrdner: ziel.path); + + expect(ergebnis, isNull); + expect(dienst.fehler, 'Bitte zuerst beim Baka-Konto anmelden'); + }); + + test('Proxy-Fehlermeldung wird im Klartext durchgereicht', () async { + final dienst = YtDownloadService( + auth: await _angemeldeteAuth(), + client: MockClient((_) async => http.Response( + jsonEncode({'error': 'Video ist gesperrt'}), 500)), + ); + + final ergebnis = await dienst.herunterladen('https://youtu.be/abc', + zielOrdner: ziel.path); + + expect(ergebnis, isNull); + expect(dienst.fehler, 'Video ist gesperrt'); + }); + + test('Scheitert der MP3-Abruf, bleibt keine halbe Datei liegen', () async { + final dienst = YtDownloadService( + auth: await _angemeldeteAuth(), + client: MockClient((req) async { + if (req.url.path == '/api/yt-dl') { + return http.Response( + jsonEncode({ + 'titel': 'Kaputt', + 'dauer': 1, + 'mp3_url': '/api/dl/k.mp3', + }), + 200); + } + return http.Response('weg', 404); + }), + ); + + final ergebnis = await dienst.herunterladen('https://youtu.be/abc', + zielOrdner: ziel.path); + + expect(ergebnis, isNull); + expect(ziel.listSync(), isEmpty); + }); +}