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:
co-authored by
Claude Opus 5
parent
a625d70da8
commit
6200c77614
@@ -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
|
||||
|
||||
@@ -13,6 +13,10 @@
|
||||
<uses-permission android:name="android.permission.READ_MEDIA_AUDIO"/>
|
||||
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE"
|
||||
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
|
||||
android:label="melo"
|
||||
android:name="${applicationName}"
|
||||
|
||||
@@ -15,6 +15,12 @@ class MainActivity : AudioServiceActivity() {
|
||||
flutterEngine.dartExecutor.binaryMessenger,
|
||||
AudioEffectsBridge.CHANNEL
|
||||
).setMethodCallHandler(bridge::handle)
|
||||
|
||||
val mediaStore = MediaStoreBridge(applicationContext)
|
||||
MethodChannel(
|
||||
flutterEngine.dartExecutor.binaryMessenger,
|
||||
MediaStoreBridge.CHANNEL
|
||||
).setMethodCallHandler(mediaStore::handle)
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -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<DownloadsScreen> createState() => _DownloadsScreenState();
|
||||
}
|
||||
|
||||
class _DownloadsScreenState extends State<DownloadsScreen> {
|
||||
/// 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<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'),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<MyMusicScreen> {
|
||||
),
|
||||
),
|
||||
_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<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, …).
|
||||
class _QuickAccessRow extends StatelessWidget {
|
||||
const _QuickAccessRow({this.onFavoritesTap});
|
||||
|
||||
@@ -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<void> main() async {
|
||||
WidgetsFlutterBinding.ensureInitialized();
|
||||
@@ -49,6 +53,9 @@ Future<void> 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<CategoryService>.value(value: _categories),
|
||||
ChangeNotifierProvider<AppSettings>.value(value: _settings),
|
||||
ChangeNotifierProvider<AudioEffects>.value(value: _effects),
|
||||
ChangeNotifierProvider<BakaAuth>.value(value: _bakaAuth),
|
||||
ChangeNotifierProvider<YtDownloadService>.value(value: _ytDownload),
|
||||
],
|
||||
child: MaterialApp(
|
||||
title: 'Melo',
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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})';
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
@@ -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<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() {
|
||||
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<CategoryService>(create: (_) => CategoryService(db)),
|
||||
ChangeNotifierProvider<AppSettings>(create: (_) => AppSettings()),
|
||||
ChangeNotifierProvider<OfflineMode>(create: (_) => OfflineMode()),
|
||||
ChangeNotifierProvider<BakaAuth>(create: (_) => auth),
|
||||
ChangeNotifierProvider<YtDownloadService>(
|
||||
create: (_) => YtDownloadService(auth: auth)),
|
||||
],
|
||||
child: const MaterialApp(home: HomeShell()),
|
||||
));
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user