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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018NLW1q1hzEC9goDQ1dJ98d
This commit is contained in:
Hermes (Server)
2026-08-20 19:13:55 +02:00
co-authored by Claude Opus 5
parent a625d70da8
commit 6200c77614
17 changed files with 1210 additions and 51 deletions
+39
View File
@@ -5,6 +5,45 @@ Format angelehnt an [Keep a Changelog](https://keepachangelog.com/de/1.0.0/).
## [Unreleased] ## [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) ### 🧭 Neue Tab-Struktur nach deinen Referenzbildern (2026-08-20)
- 🔀 **Untere Leiste wie auf deinem Bild:** Die Reihenfolge ist jetzt - 🔀 **Untere Leiste wie auf deinem Bild:** Die Reihenfolge ist jetzt
**Meine Musik · Online · Suchen · Favoriten**. Der frühere Reiter **Meine Musik · Online · Suchen · Favoriten**. Der frühere Reiter
+4
View File
@@ -13,6 +13,10 @@
<uses-permission android:name="android.permission.READ_MEDIA_AUDIO"/> <uses-permission android:name="android.permission.READ_MEDIA_AUDIO"/>
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" <uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE"
android:maxSdkVersion="32"/> android:maxSdkVersion="32"/>
<!-- Heruntergeladene Musik in Music/Melo ablegen. Ab Android 10 uebernimmt
das der MediaStore ohne Berechtigung, davor braucht es Schreibrechte. -->
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE"
android:maxSdkVersion="28"/>
<application <application
android:label="melo" android:label="melo"
android:name="${applicationName}" android:name="${applicationName}"
@@ -15,6 +15,12 @@ class MainActivity : AudioServiceActivity() {
flutterEngine.dartExecutor.binaryMessenger, flutterEngine.dartExecutor.binaryMessenger,
AudioEffectsBridge.CHANNEL AudioEffectsBridge.CHANNEL
).setMethodCallHandler(bridge::handle) ).setMethodCallHandler(bridge::handle)
val mediaStore = MediaStoreBridge(applicationContext)
MethodChannel(
flutterEngine.dartExecutor.binaryMessenger,
MediaStoreBridge.CHANNEL
).setMethodCallHandler(mediaStore::handle)
} }
override fun onDestroy() { override fun onDestroy() {
@@ -0,0 +1,130 @@
package de.baka.melo
import android.content.ContentValues
import android.content.Context
import android.os.Build
import android.os.Environment
import android.provider.MediaStore
import io.flutter.plugin.common.MethodCall
import io.flutter.plugin.common.MethodChannel
import java.io.File
/**
* Traegt heruntergeladene Musik in den oeffentlichen Musikordner ein
* (Music/Melo) und meldet sie dem MediaStore.
*
* Warum ueberhaupt: der Bibliotheks-Scan liest auf Android den MediaStore.
* Eine Datei, die dort nicht steht, waere beim naechsten Scan wieder weg.
*/
class MediaStoreBridge(private val context: Context) {
companion object {
const val CHANNEL = "de.baka.melo/media_store"
private const val UNTERORDNER = "Melo"
}
fun handle(call: MethodCall, result: MethodChannel.Result) {
if (call.method != "publishAudio") {
result.notImplemented()
return
}
val quelle = call.argument<String>("sourcePath")
if (quelle == null) {
result.error("args", "sourcePath fehlt", null)
return
}
try {
result.success(
publish(
File(quelle),
call.argument<String>("title") ?: File(quelle).nameWithoutExtension,
call.argument<String>("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
}
}
+224 -4
View File
@@ -1,23 +1,38 @@
import 'dart:io';
import 'package:audio_service/audio_service.dart'; import 'package:audio_service/audio_service.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import '../library/library_service.dart';
import '../player/audio_handler.dart'; import '../player/audio_handler.dart';
import '../services/baka_auth.dart';
import '../services/media_store.dart';
import '../services/navidrome_service.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 /// Online-Tab: durchsucht den verbundenen Navidrome-Server. Abgespielte
/// Titel werden vom [MeloAudioHandler] automatisch lokal zwischengespeichert /// Titel werden vom [MeloAudioHandler] automatisch lokal zwischengespeichert
/// und stehen danach offline zur Verfügung. /// und stehen danach offline zur Verfügung.
class DownloadsScreen extends StatelessWidget { class DownloadsScreen extends StatefulWidget {
const DownloadsScreen({super.key}); const DownloadsScreen({super.key});
@override
State<DownloadsScreen> createState() => _DownloadsScreenState();
}
class _DownloadsScreenState extends State<DownloadsScreen> {
/// 0 = YouTube, 1 = Server.
int _subTab = 0;
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return const SafeArea( return SafeArea(
bottom: false, bottom: false,
child: Column( child: Column(
children: [ children: [
Padding( const Padding(
padding: EdgeInsets.fromLTRB(20, 16, 20, 4), padding: EdgeInsets.fromLTRB(20, 16, 20, 4),
child: Align( child: Align(
alignment: Alignment.centerLeft, alignment: Alignment.centerLeft,
@@ -25,7 +40,14 @@ class DownloadsScreen extends StatelessWidget {
style: TextStyle(fontSize: 24, fontWeight: FontWeight.w700)), 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<void> _anmelden() async {
final auth = context.read<BakaAuth>();
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<void> _herunterladen() async {
final dienst = context.read<YtDownloadService>();
final lib = context.read<LibraryService>();
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<BakaAuth>();
final dienst = context.watch<YtDownloadService>();
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'),
),
],
);
}
}
+3 -47
View File
@@ -5,6 +5,7 @@ import '../playlists/playlists_screen.dart';
import '../settings/settings_screen.dart'; import '../settings/settings_screen.dart';
import '../shared/cover.dart'; import '../shared/cover.dart';
import '../shared/sort_store.dart'; import '../shared/sort_store.dart';
import '../shared/sub_tabs.dart';
import '../shared/sortable_song_list.dart'; import '../shared/sortable_song_list.dart';
import '../shared/theme.dart'; import '../shared/theme.dart';
import 'album_list.dart'; import 'album_list.dart';
@@ -82,7 +83,8 @@ class _MyMusicScreenState extends State<MyMusicScreen> {
), ),
), ),
_QuickAccessRow(onFavoritesTap: widget.onFavoritesTap), _QuickAccessRow(onFavoritesTap: widget.onFavoritesTap),
_SubTabs( SubTabs(
labels: const ['Songs', 'Künstler', 'Alben'],
index: _subTab, index: _subTab,
onChanged: (i) => setState(() => _subTab = i), 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<int> 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, …). /// Waagerecht scrollende Schnellzugriffe (Favoriten, Wiedergabelisten, …).
class _QuickAccessRow extends StatelessWidget { class _QuickAccessRow extends StatelessWidget {
const _QuickAccessRow({this.onFavoritesTap}); const _QuickAccessRow({this.onFavoritesTap});
+9
View File
@@ -13,8 +13,10 @@ import 'library/search_screen.dart';
import 'player/audio_effects.dart'; import 'player/audio_effects.dart';
import 'player/audio_handler.dart'; import 'player/audio_handler.dart';
import 'player/mini_player.dart'; import 'player/mini_player.dart';
import 'services/baka_auth.dart';
import 'services/logger_service.dart'; import 'services/logger_service.dart';
import 'services/offline_mode.dart'; import 'services/offline_mode.dart';
import 'services/yt_download_service.dart';
import 'settings/app_settings.dart'; import 'settings/app_settings.dart';
import 'shared/theme.dart'; import 'shared/theme.dart';
@@ -26,6 +28,8 @@ late final OfflineMode _offlineMode;
late final CategoryService _categories; late final CategoryService _categories;
late final AppSettings _settings; late final AppSettings _settings;
late final AudioEffects _effects; late final AudioEffects _effects;
late final BakaAuth _bakaAuth;
late final YtDownloadService _ytDownload;
Future<void> main() async { Future<void> main() async {
WidgetsFlutterBinding.ensureInitialized(); WidgetsFlutterBinding.ensureInitialized();
@@ -49,6 +53,9 @@ Future<void> main() async {
); );
_effects = AudioEffects(); _effects = AudioEffects();
await _effects.init(); await _effects.init();
_bakaAuth = BakaAuth();
await _bakaAuth.laden();
_ytDownload = YtDownloadService(auth: _bakaAuth);
// Die Audio-Session steht erst beim Abspielen fest und kann wechseln — // Die Audio-Session steht erst beim Abspielen fest und kann wechseln —
// die Klangeffekte müssen dann neu daran gehängt werden. // die Klangeffekte müssen dann neu daran gehängt werden.
_handler.androidAudioSessionIdStream.listen((id) { _handler.androidAudioSessionIdStream.listen((id) {
@@ -80,6 +87,8 @@ class MeloApp extends StatelessWidget {
ChangeNotifierProvider<CategoryService>.value(value: _categories), ChangeNotifierProvider<CategoryService>.value(value: _categories),
ChangeNotifierProvider<AppSettings>.value(value: _settings), ChangeNotifierProvider<AppSettings>.value(value: _settings),
ChangeNotifierProvider<AudioEffects>.value(value: _effects), ChangeNotifierProvider<AudioEffects>.value(value: _effects),
ChangeNotifierProvider<BakaAuth>.value(value: _bakaAuth),
ChangeNotifierProvider<YtDownloadService>.value(value: _ytDownload),
], ],
child: MaterialApp( child: MaterialApp(
title: 'Melo', title: 'Melo',
+110
View File
@@ -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<String?> lesen(String key);
Future<void> schreiben(String key, String wert);
Future<void> loeschen(String key);
}
/// Produktions-Ablage: verschlüsselter Gerätespeicher.
class SicherenSpeicher implements TokenSpeicher {
const SicherenSpeicher();
static const _secure = FlutterSecureStorage();
@override
Future<String?> lesen(String key) => _secure.read(key: key);
@override
Future<void> schreiben(String key, String wert) =>
_secure.write(key: key, value: wert);
@override
Future<void> 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<String, String> get authHeader => {
if (istAngemeldet) 'Authorization': 'Bearer $_token',
};
/// Stellt eine frühere Anmeldung wieder her (App-Start).
Future<void> 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<String?> 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<String, dynamic> daten;
try {
daten = jsonDecode(antwort.body) as Map<String, dynamic>;
} 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<void> abmelden() async {
_token = null;
_benutzer = '';
await _speicher.loeschen(_tokenKey);
await _speicher.loeschen(_userKey);
notifyListeners();
}
}
+37
View File
@@ -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<String?> veroeffentliche({
required String quellPfad,
required String titel,
String? kuenstler,
}) async {
try {
return await channel.invokeMethod<String>('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;
}
}
}
+162
View File
@@ -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<YtErgebnis?> 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<String, dynamic>;
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<String, dynamic>;
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})';
}
}
+52
View File
@@ -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<String> labels;
final int index;
final ValueChanged<int> 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,
),
),
),
),
),
],
),
);
}
}
+83
View File
@@ -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<String, String> werte = {};
@override
Future<String?> lesen(String key) async => werte[key];
@override
Future<void> schreiben(String key, String wert) async => werte[key] = wert;
@override
Future<void> loeschen(String key) async => werte.remove(key);
}
Widget _wrap(BakaAuth auth) {
return MultiProvider(
providers: [
ChangeNotifierProvider<BakaAuth>.value(value: auth),
ChangeNotifierProvider<YtDownloadService>(
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);
});
}
+16
View File
@@ -7,11 +7,23 @@ import 'package:melo/library/library_service.dart';
import 'package:melo/library/playlist_service.dart'; import 'package:melo/library/playlist_service.dart';
import 'package:melo/main.dart'; import 'package:melo/main.dart';
import 'package:melo/player/audio_handler.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/offline_mode.dart';
import 'package:melo/services/yt_download_service.dart';
import 'package:melo/settings/app_settings.dart'; import 'package:melo/settings/app_settings.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import 'package:shared_preferences/shared_preferences.dart'; import 'package:shared_preferences/shared_preferences.dart';
class _MemorySpeicher implements TokenSpeicher {
final Map<String, String> werte = {};
@override
Future<String?> lesen(String key) async => werte[key];
@override
Future<void> schreiben(String key, String wert) async => werte[key] = wert;
@override
Future<void> loeschen(String key) async => werte.remove(key);
}
void main() { void main() {
setUp(() => SharedPreferences.setMockInitialValues({})); setUp(() => SharedPreferences.setMockInitialValues({}));
@@ -21,6 +33,7 @@ void main() {
final lib = LibraryService(db); final lib = LibraryService(db);
final playlists = PlaylistService(db); final playlists = PlaylistService(db);
final handler = MeloAudioHandler(db: db); final handler = MeloAudioHandler(db: db);
final auth = BakaAuth(speicher: _MemorySpeicher());
await tester.pumpWidget(MultiProvider( await tester.pumpWidget(MultiProvider(
providers: [ providers: [
@@ -31,6 +44,9 @@ void main() {
ChangeNotifierProvider<CategoryService>(create: (_) => CategoryService(db)), ChangeNotifierProvider<CategoryService>(create: (_) => CategoryService(db)),
ChangeNotifierProvider<AppSettings>(create: (_) => AppSettings()), ChangeNotifierProvider<AppSettings>(create: (_) => AppSettings()),
ChangeNotifierProvider<OfflineMode>(create: (_) => OfflineMode()), ChangeNotifierProvider<OfflineMode>(create: (_) => OfflineMode()),
ChangeNotifierProvider<BakaAuth>(create: (_) => auth),
ChangeNotifierProvider<YtDownloadService>(
create: (_) => YtDownloadService(auth: auth)),
], ],
child: const MaterialApp(home: HomeShell()), child: const MaterialApp(home: HomeShell()),
)); ));
+106
View File
@@ -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<String, String> werte = {};
@override
Future<String?> lesen(String key) async => werte[key];
@override
Future<void> schreiben(String key, String wert) async => werte[key] = wert;
@override
Future<void> 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();
}
+47
View File
@@ -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);
});
}
+182
View File
@@ -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<String, String> werte = {};
@override
Future<String?> lesen(String key) async => werte[key];
@override
Future<void> schreiben(String key, String wert) async => werte[key] = wert;
@override
Future<void> loeschen(String key) async => werte.remove(key);
}
Future<BakaAuth> _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);
});
}