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);
+ });
+}