7 Tasks nach TDD: GastZugang-Service, Gast-Unterstützung in YtDownloadService/YtSearchService, Verdrahtung in main.dart, UI in Download-Tab und YT-Suche-Tab (bewusste Wahl Anmelden/Als-Gast- fortfahren + Tages-Zähler), Abschluss-Verifikation. Umsetzung auf neuem Branch feature/youtube-gast-zugang (Task 1, Step 0). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CcDiyJdVRqh1TtJk5JiabX
58 KiB
YouTube-Gast-Zugang (App-Seite) Implementation Plan
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: Gäste ohne Cloud-Account können den YouTube-Downloader/die YT-Suche über einen anonymen, geräte-gebundenen Gast-Token nutzen — mit einem serverseitig durchgesetzten Tages-Limit (5 Downloads), ohne Cookies, nach bewusster Wahl in der UI.
Architecture: Ein neuer ChangeNotifier-Service GastZugang (analog zu
BakaAuth, aber ohne Passwort) hält einen optionalen Gast-Token. Die
bestehenden YtDownloadService/YtSearchService bekommen einen optionalen
gast-Parameter und wählen pro Anfrage zwischen Baka-Auth-Bearer-Header und
X-Guest-Token-Header. Die YouTube-Bildschirme zeigen echten Gästen zwei
Buttons ("Anmelden" / "Als Gast fortfahren") statt nur einem.
Tech Stack: Flutter/Dart, provider (State), http + http/testing
(Netzwerk/Tests), flutter_secure_storage (Token-Ablage, über die
bestehende TokenSpeicher-Abstraktion aus baka_auth.dart), flutter_test
(Widget-/Unit-Tests).
Spec: docs/superpowers/specs/2026-08-26-youtube-guest-quota-design.md
— beschreibt auch den Server-Vertrag (POST /api/guest-token,
X-Guest-Token-Header, guest_remaining-Feld, Cookie-Ausschluss für
Gäste). Der Server-Teil (yt_proxy.py, claude-server-Worktree) ist NICHT
Teil dieses Plans — dieser Plan deckt ausschließlich die App-Seite ab und
entwickelt/testet gegen einen gemockten Proxy (wie im gesamten
services/-Verzeichnis schon üblich).
Global Constraints
- Build ohne
--dart-define, keine Secrets im Code (bestehende Projektregel). - Kein Merge-Marker-Code,
flutter analyzemuss nach jedem Task sauber sein. flutter test --no-pubmuss nach jedem Task grün sein — bei 550+ Tests im Hintergrund laufen lassen (run_in_background), nicht mit kurzem Foreground-Timeout killen.- Server-Vertrag (aus der Spec, exakt so von der App erwartet):
POST /api/guest-token→200 {"guest_token": "<token>"}, kein Auth nötig. Anfragen mit Gast-Zugang sendenX-Guest-Token: <token>stattAuthorization: Bearer …. Erfolgsantwort vonPOST /api/yt-dlträgt bei Gast-Zugriff zusätzlich"guest_remaining": <int>. - Neuer Branch für dieses Feature:
feature/youtube-gast-zugang(Projektregel:fix/xxxoderfeature/xxx) — vor Task 1 vonfix/p0-vollwertigkeitabzweigen. - Vor dem Abschluss-Commit (Task 7):
/code-reviewauf den vollen Diff, gefundene echte Probleme fixen (siehe Vorgehen in diesem Repo bisher). - CHANGELOG.md nach Abschluss aktualisieren (Pflicht laut CLAUDE.md).
Task 1: GastZugang-Service
Files:
- Create:
lib/services/gast_zugang.dart - Test:
test/services/gast_zugang_test.dart
Interfaces:
-
Consumes:
TokenSpeicher,SicherenSpeicherauslib/services/baka_auth.dart(bereits vorhanden, keine Änderung nötig). -
Produces (für Task 2, 3, 5, 6):
class GastZugang extends ChangeNotifierGastZugang({http.Client? client, TokenSpeicher? speicher})bool get hatTokenint? get verbleibendMap<String, String> get gastHeaderFuture<void> laden()Future<String?> holeToken()—nullbei Erfolg, sonst Fehlertextvoid merkeVerbleibend(int n)
-
Step 0: Feature-Branch anlegen
git checkout -b feature/youtube-gast-zugang
Ab hier alle Commits dieses Plans auf diesem Branch, nicht auf
fix/p0-vollwertigkeit.
- Step 1: Write the failing test
Create test/services/gast_zugang_test.dart:
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';
import 'package:melo/services/gast_zugang.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() {
test('Ohne Token ist gastHeader leer', () {
final gast = GastZugang(
client: MockClient((_) async => http.Response('', 500)),
speicher: _MemorySpeicher(),
);
expect(gast.hatToken, isFalse);
expect(gast.gastHeader, isEmpty);
});
test('holeToken() speichert den Token bei Erfolg', () async {
final speicher = _MemorySpeicher();
final gast = GastZugang(
client: MockClient(
(_) async => http.Response(jsonEncode({'guest_token': 'g-1'}), 200)),
speicher: speicher,
);
final fehler = await gast.holeToken();
expect(fehler, isNull);
expect(gast.hatToken, isTrue);
expect(gast.gastHeader, {'X-Guest-Token': 'g-1'});
expect(speicher.werte['guest_token'], 'g-1');
});
test('holeToken() meldet einen Fehler bei einer toten Verbindung',
() async {
final gast = GastZugang(
client: MockClient((_) async => throw Exception('down')),
speicher: _MemorySpeicher(),
);
final fehler = await gast.holeToken();
expect(fehler, 'Proxy nicht erreichbar');
expect(gast.hatToken, isFalse);
});
test('holeToken() meldet einen Fehler bei einem Server-Fehlerstatus',
() async {
final gast = GastZugang(
client: MockClient((_) async => http.Response('', 500)),
speicher: _MemorySpeicher(),
);
final fehler = await gast.holeToken();
expect(fehler, 'Gast-Zugang fehlgeschlagen (500)');
expect(gast.hatToken, isFalse);
});
test('holeToken() meldet einen Fehler bei unlesbarer Antwort', () async {
final gast = GastZugang(
client: MockClient((_) async => http.Response('nicht json', 200)),
speicher: _MemorySpeicher(),
);
final fehler = await gast.holeToken();
expect(fehler, 'Proxy antwortet unverständlich (200)');
expect(gast.hatToken, isFalse);
});
test('holeToken() meldet einen Fehler ohne guest_token-Feld', () async {
final gast = GastZugang(
client: MockClient((_) async => http.Response(jsonEncode({}), 200)),
speicher: _MemorySpeicher(),
);
final fehler = await gast.holeToken();
expect(fehler, 'Proxy hat keinen Gast-Token geliefert');
expect(gast.hatToken, isFalse);
});
test('laden() stellt einen gespeicherten Gast-Token wieder her', () async {
final speicher = _MemorySpeicher();
speicher.werte['guest_token'] = 'g-alt';
final gast = GastZugang(
client: MockClient((_) async => http.Response('', 500)),
speicher: speicher,
);
await gast.laden();
expect(gast.hatToken, isTrue);
expect(gast.gastHeader, {'X-Guest-Token': 'g-alt'});
});
test('merkeVerbleibend übernimmt den zuletzt gemeldeten Stand', () {
final gast = GastZugang(
client: MockClient((_) async => http.Response('', 500)),
speicher: _MemorySpeicher(),
);
gast.merkeVerbleibend(3);
expect(gast.verbleibend, 3);
});
}
- Step 2: Run test to verify it fails
Run: /home/dustin/development/flutter/bin/flutter test --no-pub test/services/gast_zugang_test.dart
Expected: FAIL — Target of URI doesn't exist: 'package:melo/services/gast_zugang.dart'
(die Datei existiert noch nicht).
- Step 3: Write minimal implementation
Create lib/services/gast_zugang.dart:
import 'dart:convert';
import 'package:flutter/foundation.dart';
import 'package:http/http.dart' as http;
import 'baka_auth.dart' show TokenSpeicher, SicherenSpeicher;
import 'yt_download_service.dart';
/// Anonymer Gast-Zugang zum YouTube-Proxy: kein Passwort, aber mit
/// serverseitig durchgesetztem Tages-Limit (der Proxy zählt und meldet den
/// Stand über `guest_remaining` in der yt-dl-Antwort zurück). Ergänzt
/// [BakaAuth]/`baka_auth.dart`, ersetzt es nicht.
class GastZugang extends ChangeNotifier {
GastZugang({http.Client? client, TokenSpeicher? speicher})
: _client = client ?? http.Client(),
_speicher = speicher ?? const SicherenSpeicher();
static const proxyUrl = YtDownloadService.proxyUrl;
static const _tokenKey = 'guest_token';
final http.Client _client;
final TokenSpeicher _speicher;
String? _token;
int? _verbleibend;
bool get hatToken => _token != null && _token!.isNotEmpty;
/// Zuletzt vom Server gemeldeter Kontingent-Stand — nur fürs Anzeigen,
/// die Wahrheit bleibt beim Server. `null` vor dem ersten Download.
int? get verbleibend => _verbleibend;
/// Header für Anfragen an den Proxy. Ohne Token leer.
Map<String, String> get gastHeader =>
{if (hatToken) 'X-Guest-Token': _token!};
/// Stellt einen früher geholten Gast-Token wieder her (App-Start).
Future<void> laden() async {
_token = await _speicher.lesen(_tokenKey);
notifyListeners();
}
/// Holt einen neuen Gast-Token vom Proxy. Gibt `null` bei Erfolg zurück,
/// sonst den Fehlertext.
Future<String?> holeToken() async {
final http.Response antwort;
try {
antwort = await _client
.post(Uri.parse('$proxyUrl/api/guest-token'))
.timeout(const Duration(seconds: 15));
} catch (e) {
debugPrint('Gast-Token nicht erreichbar: $e');
return 'Proxy nicht erreichbar';
}
if (antwort.statusCode != 200) {
return 'Gast-Zugang fehlgeschlagen (${antwort.statusCode})';
}
Map<String, dynamic> daten;
try {
daten = jsonDecode(antwort.body) as Map<String, dynamic>;
} catch (_) {
return 'Proxy antwortet unverständlich (${antwort.statusCode})';
}
final token = daten['guest_token'] as String?;
if (token == null || token.isEmpty) {
return 'Proxy hat keinen Gast-Token geliefert';
}
_token = token;
await _speicher.schreiben(_tokenKey, token);
notifyListeners();
return null;
}
/// Übernimmt den zuletzt vom Server gemeldeten Kontingent-Stand.
void merkeVerbleibend(int n) {
_verbleibend = n;
notifyListeners();
}
}
- Step 4: Run test to verify it passes
Run: /home/dustin/development/flutter/bin/flutter test --no-pub test/services/gast_zugang_test.dart
Expected: 00:0X +8: All tests passed!
- Step 5: Run analyze
Run: timeout 300 /home/dustin/development/flutter/bin/flutter analyze --no-pub
Expected: No issues found!
- Step 6: Commit
git add lib/services/gast_zugang.dart test/services/gast_zugang_test.dart
git commit -m "$(cat <<'EOF'
Neuer GastZugang-Service für den anonymen YouTube-Gast-Zugang
Analog zu BakaAuth, aber ohne Passwort: holt/speichert einen
geräte-gebundenen Gast-Token vom neuen /api/guest-token-Endpunkt
(siehe docs/superpowers/specs/2026-08-26-youtube-guest-quota-design.md).
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
EOF
)"
Task 2: YtDownloadService um Gast-Zugang erweitern
Files:
- Modify:
lib/services/yt_download_service.dart - Test:
test/services/yt_download_test.dart
Interfaces:
-
Consumes:
GastZugangaus Task 1 (hatToken,gastHeader,holeToken(),merkeVerbleibend(int)). -
Produces (für Task 4, 5, 6):
YtDownloadService({required BakaAuth auth, GastZugang? gast, http.Client? client})—gastist optional und rückwärtskompatibel (Defaultnull, bestehendes Verhalten unverändert, wenn nicht gesetzt). -
Step 1: Write the failing tests
In test/services/yt_download_test.dart, Import ergänzen (nach der
bestehenden baka_auth.dart-Zeile):
import 'package:melo/services/baka_auth.dart';
import 'package:melo/services/gast_zugang.dart';
import 'package:melo/services/yt_download_service.dart';
Am Ende der Datei, vor der letzten schließenden } von main(), folgende
neue group einfügen:
group('Gast-Zugang', () {
test('Mit Gast-Token statt Anmeldung wird der Proxy trotzdem gefragt',
() async {
final speicher = _MemorySpeicher();
speicher.werte['guest_token'] = 'gast-xyz';
final gast = GastZugang(
client: MockClient((_) async => http.Response('', 500)),
speicher: speicher,
);
await gast.laden();
final dienst = YtDownloadService(
auth: BakaAuth(
client: MockClient((_) async => http.Response('', 500)),
speicher: _MemorySpeicher()),
gast: gast,
client: MockClient((req) async {
if (req.url.path == '/api/yt-dl') {
expect(req.headers['X-Guest-Token'], 'gast-xyz');
expect(req.headers.containsKey('Authorization'), isFalse);
return http.Response(
jsonEncode({
'titel': 'Gast-Lied',
'dauer': 30,
'mp3_url': '/api/dl/g.mp3',
'guest_remaining': 4,
}),
200);
}
return http.Response.bytes([1], 200);
}),
);
final ergebnis = await dienst.herunterladen('https://youtu.be/abc',
zielOrdner: ziel.path);
expect(ergebnis, isNotNull);
expect(gast.verbleibend, 4);
});
test('Ohne Anmeldung UND ohne Gast-Token wird gar nicht erst gefragt',
() async {
var aufrufe = 0;
final dienst = YtDownloadService(
auth: BakaAuth(
client: MockClient((_) async => http.Response('', 500)),
speicher: _MemorySpeicher()),
client: MockClient((_) async {
aufrufe++;
return 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');
expect(aufrufe, 0);
});
test('401 bei Gast-Zugang holt automatisch einen neuen Gast-Token',
() async {
var tokenAufrufe = 0;
final speicher = _MemorySpeicher();
speicher.werte['guest_token'] = 'alter-token';
final gast = GastZugang(
client: MockClient((_) async {
tokenAufrufe++;
return http.Response(
jsonEncode({'guest_token': 'neuer-token'}), 200);
}),
speicher: speicher,
);
await gast.laden();
final dienst = YtDownloadService(
auth: BakaAuth(
client: MockClient((_) async => http.Response('', 500)),
speicher: _MemorySpeicher()),
gast: gast,
client: MockClient((_) async => http.Response('Unauthorized', 401)),
);
final ergebnis = await dienst.herunterladen('https://youtu.be/abc',
zielOrdner: ziel.path);
// Das Token-Nachholen läuft unawaited im Hintergrund.
await Future<void>.delayed(Duration.zero);
expect(ergebnis, isNull);
expect(dienst.fehler, 'Gast-Zugang abgelaufen — bitte erneut versuchen');
expect(tokenAufrufe, 1);
});
});
- Step 2: Run tests to verify they fail
Run: /home/dustin/development/flutter/bin/flutter test --no-pub test/services/yt_download_test.dart
Expected: FAIL — The named parameter 'gast' isn't defined (Compile-Fehler,
YtDownloadService kennt gast noch nicht).
- Step 3: Write minimal implementation
In lib/services/yt_download_service.dart:
Import ergänzen (nach import 'baka_auth.dart';):
import 'baka_auth.dart';
import 'gast_zugang.dart';
Konstruktor und Feld ändern (bisher YtDownloadService({required this.auth, http.Client? client}) mit Feld final BakaAuth auth;):
class YtDownloadService extends ChangeNotifier {
YtDownloadService({required this.auth, this.gast, http.Client? client})
: _client = client ?? http.Client();
static const proxyUrl = 'https://yt.baka-net.de';
final BakaAuth auth;
final GastZugang? gast;
final http.Client _client;
Den Anfang von herunterladen() ersetzen — bisher:
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 _abbrechbar(
_client
.post(
Uri.parse('$proxyUrl/api/yt-dl'),
headers: {
...auth.authHeader,
'Content-Type': 'application/json'
},
body: jsonEncode({'url': url, 'cookies': cookies}),
)
.timeout(const Duration(seconds: 180)),
abbruch,
);
neu:
final gastAktiv = !auth.istAngemeldet && (gast?.hatToken ?? false);
if (!auth.istAngemeldet && !gastAktiv) {
_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;
}
final zugangsHeader = gastAktiv ? gast!.gastHeader : auth.authHeader;
// ─── 1/2: Proxy fragen — er lädt das Video und wandelt es um ───
_melde('Proxy wird gefragt …');
final http.Response antwort;
try {
antwort = await _abbrechbar(
_client
.post(
Uri.parse('$proxyUrl/api/yt-dl'),
headers: {
...zugangsHeader,
'Content-Type': 'application/json'
},
body: jsonEncode({'url': url, 'cookies': cookies}),
)
.timeout(const Duration(seconds: 180)),
abbruch,
);
Die 401-Behandlung ersetzen — bisher:
if (antwort.statusCode == 401) {
_scheitere('Anmeldung abgelaufen — bitte neu anmelden');
return null;
}
neu:
if (antwort.statusCode == 401) {
if (gastAktiv) unawaited(gast!.holeToken());
_scheitere(gastAktiv
? 'Gast-Zugang abgelaufen — bitte erneut versuchen'
: 'Anmeldung abgelaufen — bitte neu anmelden');
return null;
}
Nach dem Auslesen von mp3Url (bisher endet der Block mit
final mp3Url = daten['mp3_url'] as String? ?? '/api/dl/audio.mp3'; gefolgt
von der Leerzeile vor // ─── 2/2: ...), zwei Zeilen einfügen:
final mp3Url = daten['mp3_url'] as String? ?? '/api/dl/audio.mp3';
final gastRest = daten['guest_remaining'] as int?;
if (gastAktiv && gastRest != null) {
gast!.merkeVerbleibend(gastRest);
}
// ─── 2/2: fertige MP3 abholen und speichern ───
Den MP3-Abruf-Header ersetzen — bisher:
final mp3 = await _abbrechbar(
_client
.get(Uri.parse('$proxyUrl$mp3Url'), headers: auth.authHeader)
.timeout(const Duration(seconds: 180)),
abbruch,
);
neu:
final mp3 = await _abbrechbar(
_client
.get(Uri.parse('$proxyUrl$mp3Url'), headers: zugangsHeader)
.timeout(const Duration(seconds: 180)),
abbruch,
);
- Step 4: Run tests to verify they pass
Run: /home/dustin/development/flutter/bin/flutter test --no-pub test/services/yt_download_test.dart
Expected: alle Tests grün (bestehende 15 + 3 neue = 18).
- Step 5: Run analyze
Run: timeout 300 /home/dustin/development/flutter/bin/flutter analyze --no-pub
Expected: No issues found!
- Step 6: Commit
git add lib/services/yt_download_service.dart test/services/yt_download_test.dart
git commit -m "$(cat <<'EOF'
YtDownloadService: Gast-Zugang neben Baka-Auth unterstützen
Optionaler gast-Parameter (GastZugang) — sendet bei fehlender
Baka-Anmeldung X-Guest-Token statt Authorization, übernimmt
guest_remaining aus der Erfolgsantwort, holt bei 401 automatisch
einen neuen Gast-Token nach.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
EOF
)"
Task 3: YtSearchService um Gast-Zugang erweitern
Files:
- Modify:
lib/services/yt_search_service.dart - Test:
test/services/yt_search_service_test.dart
Interfaces:
-
Consumes:
GastZugangaus Task 1 (gleiche Methoden wie Task 2). -
Produces (für Task 4, 6):
YtSearchService({required BakaAuth auth, GastZugang? gast, http.Client? client}). -
Step 1: Write the failing tests
In test/services/yt_search_service_test.dart, Import ergänzen:
import 'package:melo/services/baka_auth.dart';
import 'package:melo/services/gast_zugang.dart';
import 'package:melo/services/yt_search_service.dart';
Innerhalb von group('YtSearchService.suchen', () { ... }), vor dem
schließenden }); der Gruppe, zwei neue Tests einfügen:
test('Mit Gast-Token statt Anmeldung wird trotzdem gesucht', () async {
final speicher = _MemorySpeicher();
speicher.werte['guest_token'] = 'gast-xyz';
final gast = GastZugang(
client: MockClient((_) async => http.Response('', 500)),
speicher: speicher,
);
await gast.laden();
final dienst = YtSearchService(
auth: BakaAuth(
client: MockClient((_) async => http.Response('', 500)),
speicher: _MemorySpeicher()),
gast: gast,
client: MockClient((req) async {
expect(req.headers['X-Guest-Token'], 'gast-xyz');
expect(req.headers.containsKey('Authorization'), isFalse);
return http.Response('[]', 200);
}),
);
await dienst.suchen('roses');
expect(dienst.fehler, isNull);
});
test('401 bei Gast-Zugang holt automatisch einen neuen Gast-Token',
() async {
var tokenAufrufe = 0;
final speicher = _MemorySpeicher();
speicher.werte['guest_token'] = 'alter-token';
final gast = GastZugang(
client: MockClient((_) async {
tokenAufrufe++;
return http.Response(
jsonEncode({'guest_token': 'neuer-token'}), 200);
}),
speicher: speicher,
);
await gast.laden();
final dienst = YtSearchService(
auth: BakaAuth(
client: MockClient((_) async => http.Response('', 500)),
speicher: _MemorySpeicher()),
gast: gast,
client: MockClient((_) async => http.Response('Unauthorized', 401)),
);
await dienst.suchen('roses');
await Future<void>.delayed(Duration.zero);
expect(dienst.fehler, 'Gast-Zugang abgelaufen — bitte erneut versuchen');
expect(tokenAufrufe, 1);
});
- Step 2: Run tests to verify they fail
Run: /home/dustin/development/flutter/bin/flutter test --no-pub test/services/yt_search_service_test.dart
Expected: FAIL — The named parameter 'gast' isn't defined.
- Step 3: Write minimal implementation
In lib/services/yt_search_service.dart:
Import ergänzen (nach import 'baka_auth.dart';):
import 'baka_auth.dart';
import 'gast_zugang.dart';
import 'yt_download_service.dart';
Konstruktor und Feld ändern — bisher YtSearchService({required this.auth, http.Client? client}) mit final BakaAuth auth;:
class YtSearchService extends ChangeNotifier {
YtSearchService({required this.auth, this.gast, http.Client? client})
: _client = client ?? http.Client();
static const proxyUrl = YtDownloadService.proxyUrl;
final BakaAuth auth;
final GastZugang? gast;
final http.Client _client;
suchen() anpassen — bisher:
if (!auth.istAngemeldet) {
_treffer = const [];
_fehler = 'Bitte zuerst beim Baka-Konto anmelden';
notifyListeners();
return;
}
_laeuft = true;
_fehler = null;
notifyListeners();
final uri = Uri.parse('$proxyUrl/api/search')
.replace(queryParameters: {'q': q});
final http.Response antwort;
try {
antwort = await _client
.get(uri, headers: auth.authHeader)
.timeout(const Duration(seconds: 20));
} on TimeoutException {
_scheitere('Die Suche braucht zu lange — bitte später erneut versuchen');
return;
} catch (e) {
debugPrint('YT-Suche nicht erreichbar: $e');
_scheitere('Proxy nicht erreichbar');
return;
}
if (antwort.statusCode == 401) {
_scheitere('Anmeldung abgelaufen — bitte neu anmelden');
return;
}
neu:
final gastAktiv = !auth.istAngemeldet && (gast?.hatToken ?? false);
if (!auth.istAngemeldet && !gastAktiv) {
_treffer = const [];
_fehler = 'Bitte zuerst beim Baka-Konto anmelden';
notifyListeners();
return;
}
_laeuft = true;
_fehler = null;
notifyListeners();
final uri = Uri.parse('$proxyUrl/api/search')
.replace(queryParameters: {'q': q});
final http.Response antwort;
try {
antwort = await _client
.get(uri, headers: gastAktiv ? gast!.gastHeader : auth.authHeader)
.timeout(const Duration(seconds: 20));
} on TimeoutException {
_scheitere('Die Suche braucht zu lange — bitte später erneut versuchen');
return;
} catch (e) {
debugPrint('YT-Suche nicht erreichbar: $e');
_scheitere('Proxy nicht erreichbar');
return;
}
if (antwort.statusCode == 401) {
if (gastAktiv) unawaited(gast!.holeToken());
_scheitere(gastAktiv
? 'Gast-Zugang abgelaufen — bitte erneut versuchen'
: 'Anmeldung abgelaufen — bitte neu anmelden');
return;
}
- Step 4: Run tests to verify they pass
Run: /home/dustin/development/flutter/bin/flutter test --no-pub test/services/yt_search_service_test.dart
Expected: alle Tests grün (bestehende 11 + 2 neue = 13).
- Step 5: Run analyze
Run: timeout 300 /home/dustin/development/flutter/bin/flutter analyze --no-pub
Expected: No issues found!
- Step 6: Commit
git add lib/services/yt_search_service.dart test/services/yt_search_service_test.dart
git commit -m "$(cat <<'EOF'
YtSearchService: Gast-Zugang neben Baka-Auth unterstützen
Analog zu YtDownloadService: optionaler gast-Parameter, X-Guest-
Token statt Authorization, automatisches Token-Nachholen bei 401.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
EOF
)"
Task 4: GastZugang in main.dart verdrahten
Files:
- Modify:
lib/main.dart
Interfaces:
- Consumes:
GastZugang(Task 1),YtDownloadService/YtSearchServicemitgast-Parameter (Task 2, 3). - Produces (für Task 5, 6): App-weiter
GastZugang-Provider, erreichbar übercontext.watch<GastZugang>()/context.read<GastZugang>().
Kein eigener Test — reine Verdrahtung, wird durch die Widget-Tests in
Task 5/6 (die GastZugang aus dem Provider-Baum lesen) sowie den
bestehenden home_shell_test.dart/App-Start-Test indirekt abgedeckt.
- Step 1: Import ergänzen
In lib/main.dart, nach import 'services/geteilte_adresse.dart';:
import 'services/geteilte_adresse.dart';
import 'services/gast_zugang.dart';
- Step 2: Late-Field ergänzen
Nach late final BakaAuth _bakaAuth;:
late final BakaAuth _bakaAuth;
late final GastZugang _gastZugang;
- Step 3: Instanziierung + Verdrahtung in
main()
Bisher:
_bakaAuth = BakaAuth();
await _bakaAuth.laden();
_ytDownload = YtDownloadService(auth: _bakaAuth);
_ytSearch = YtSearchService(auth: _bakaAuth);
neu:
_bakaAuth = BakaAuth();
await _bakaAuth.laden();
_gastZugang = GastZugang();
await _gastZugang.laden();
_ytDownload = YtDownloadService(auth: _bakaAuth, gast: _gastZugang);
_ytSearch = YtSearchService(auth: _bakaAuth, gast: _gastZugang);
- Step 4: Provider ergänzen
In MeloApp.build(), nach
ChangeNotifierProvider<BakaAuth>.value(value: _bakaAuth),:
ChangeNotifierProvider<BakaAuth>.value(value: _bakaAuth),
ChangeNotifierProvider<GastZugang>.value(value: _gastZugang),
- Step 5: Run analyze
Run: timeout 300 /home/dustin/development/flutter/bin/flutter analyze --no-pub
Expected: No issues found!
- Step 6: Vollen Testlauf im Hintergrund starten
Run (im Hintergrund, siehe Global Constraints):
/home/dustin/development/flutter/bin/flutter test --no-pub
Expected: alle bisherigen Tests weiterhin grün — main.dart wird nicht
direkt getestet, aber ein Compile-Fehler dort lässt JEDEN Widget-Test
fehlschlagen, das ist der eigentliche Schutz hier.
- Step 7: Commit
git add lib/main.dart
git commit -m "$(cat <<'EOF'
main.dart: GastZugang app-weit verdrahten
Neue Instanz wird beim Start geladen (gespeicherten Gast-Token
wiederherstellen), an YtDownloadService/YtSearchService gereicht
und als Provider bereitgestellt.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
EOF
)"
Task 5: UI im Download-Tab (downloads_screen.dart)
Files:
- Modify:
lib/downloads/downloads_screen.dart - Test:
test/downloads/online_screen_test.dart
Interfaces:
-
Consumes:
GastZugangaus dem Provider-Baum (Task 4),pruefeServerUser(bereits vorhanden ausbaka_auth.dart). -
Produces: nichts, das andere Tasks konsumieren — UI-Endpunkt.
-
Step 1: Write the failing tests
In test/downloads/online_screen_test.dart, Import ergänzen (nach
import 'package:melo/services/download_service.dart';):
import 'package:melo/services/download_service.dart';
import 'package:melo/services/gast_zugang.dart';
_wrap ersetzen — bisher:
Widget _wrap(BakaAuth auth,
{required MeloDb db,
required CategoryService categories,
NavidromeService? navidrome}) {
return MultiProvider(
providers: [
Provider<MeloDb>.value(value: db),
// Bewusst .value: der Test besitzt den Dienst und schließt ihn im
// tearDown. Würde ihn der Baum beim Abbauen schließen, bliebe ein
// Aufräum-Timer von drift im Test-Rahmen hängen.
ChangeNotifierProvider<CategoryService>.value(value: categories),
ChangeNotifierProvider<BakaAuth>.value(value: auth),
ChangeNotifierProvider<YtDownloadService>(
create: (_) => YtDownloadService(
auth: auth,
client: MockClient((_) async => http.Response('', 500)),
),
),
// Nur fürs Badge im Unterreiter-Kopf gebraucht (dienst.laeuft) — der
// Download-Tab beobachtet ihn app-weit, unabhängig vom Unterreiter.
ChangeNotifierProvider<DownloadService>(
create: (_) => DownloadService(
db: db, navidrome: navidrome ?? NavidromeService()),
),
],
child: const MaterialApp(home: Scaffold(body: DownloadsScreen())),
);
}
neu:
Widget _wrap(BakaAuth auth,
{required MeloDb db,
required CategoryService categories,
NavidromeService? navidrome,
GastZugang? gast}) {
final gastZugang = gast ?? GastZugang();
return MultiProvider(
providers: [
Provider<MeloDb>.value(value: db),
// Bewusst .value: der Test besitzt den Dienst und schließt ihn im
// tearDown. Würde ihn der Baum beim Abbauen schließen, bliebe ein
// Aufräum-Timer von drift im Test-Rahmen hängen.
ChangeNotifierProvider<CategoryService>.value(value: categories),
ChangeNotifierProvider<BakaAuth>.value(value: auth),
ChangeNotifierProvider<GastZugang>.value(value: gastZugang),
ChangeNotifierProvider<YtDownloadService>(
create: (_) => YtDownloadService(
auth: auth,
gast: gastZugang,
client: MockClient((_) async => http.Response('', 500)),
),
),
// Nur fürs Badge im Unterreiter-Kopf gebraucht (dienst.laeuft) — der
// Download-Tab beobachtet ihn app-weit, unabhängig vom Unterreiter.
ChangeNotifierProvider<DownloadService>(
create: (_) => DownloadService(
db: db, navidrome: navidrome ?? NavidromeService()),
),
],
child: const MaterialApp(home: Scaffold(body: DownloadsScreen())),
);
}
Am Ende der Datei, vor dem letzten schließenden } von main(), drei neue
Tests einfügen:
testWidgets('Gast sieht zwei Buttons: Anmelden und Als Gast fortfahren',
(tester) async {
final auth = BakaAuth(
client: MockClient((_) async => http.Response('', 500)),
speicher: _MemorySpeicher());
await tester.pumpWidget(_wrap(auth, db: db, categories: categories));
await tester.pumpAndSettle();
expect(find.text('Beim Baka-Konto anmelden'), findsOneWidget);
expect(
find.text('Als Gast fortfahren (5 Downloads/Tag)'), findsOneWidget);
expect(find.text('YouTube-Adresse'), findsNothing);
});
testWidgets(
'Als Gast fortfahren holt einen Token und schaltet das Adressfeld frei',
(tester) async {
final auth = BakaAuth(
client: MockClient((_) async => http.Response('', 500)),
speicher: _MemorySpeicher());
final gast = GastZugang(
client: MockClient((_) async =>
http.Response(jsonEncode({'guest_token': 'g-1'}), 200)),
speicher: _MemorySpeicher(),
);
await tester.pumpWidget(
_wrap(auth, db: db, categories: categories, gast: gast));
await tester.pumpAndSettle();
await tester.tap(find.text('Als Gast fortfahren (5 Downloads/Tag)'));
await tester.pumpAndSettle();
expect(find.text('YouTube-Adresse'), findsOneWidget);
expect(find.text('Als Gast unterwegs (5 Downloads/Tag)'), findsOneWidget);
expect(find.text('YouTube-Cookies des Servers verwenden'), findsNothing);
});
testWidgets('Fehler beim Gast-Token wird angezeigt', (tester) async {
final auth = BakaAuth(
client: MockClient((_) async => http.Response('', 500)),
speicher: _MemorySpeicher());
final gast = GastZugang(
client: MockClient((_) async => http.Response('', 500)),
speicher: _MemorySpeicher(),
);
await tester.pumpWidget(
_wrap(auth, db: db, categories: categories, gast: gast));
await tester.pumpAndSettle();
await tester.tap(find.text('Als Gast fortfahren (5 Downloads/Tag)'));
await tester.pumpAndSettle();
expect(find.text('Gast-Zugang fehlgeschlagen (500)'), findsOneWidget);
expect(find.text('YouTube-Adresse'), findsNothing);
});
- Step 2: Run tests to verify they fail
Run: /home/dustin/development/flutter/bin/flutter test --no-pub test/downloads/online_screen_test.dart
Expected: FAIL — entweder Compile-Fehler (gast unbekannter Parameter,
falls Step 3 hier vor der UI-Änderung läuft) oder findsOneWidget
schlägt fehl, weil der neue Button/Zähler-Text noch nicht existiert.
- Step 3: Write minimal implementation
In lib/downloads/downloads_screen.dart, Import ergänzen (nach
import '../services/download_service.dart';):
import '../services/download_service.dart';
import '../services/gast_zugang.dart';
In _YouTubeBereichState, neues Feld nach bool _autoLoginFehlgeschlagen = false;:
/// Die automatische Anmeldung ist fehlgeschlagen (z. B. abweichendes
/// Baka-Passwort) — dann bleibt der manuelle Anmelde-Weg als Rückfalloption
/// sichtbar, sonst käme so ein User nie mehr an den Dialog heran.
bool _autoLoginFehlgeschlagen = false;
/// Fehlertext, falls das Holen des Gast-Tokens scheitert.
String? _gastFehler;
Neue Methode nach _anmelden():
Future<void> _anmelden() async {
await showDialog(
context: context,
builder: (_) => const _AnmeldeDialog(),
);
}
Future<void> _alsGastFortfahren() async {
final gast = context.read<GastZugang>();
setState(() => _gastFehler = null);
final fehler = await gast.holeToken();
if (!mounted) return;
if (fehler != null) setState(() => _gastFehler = fehler);
}
build() komplett ersetzen — bisher:
@override
Widget build(BuildContext context) {
final auth = context.watch<BakaAuth>();
final dienst = context.watch<YtDownloadService>();
// Server-User gelten sofort als berechtigt — die Anmeldung passiert für
// sie im Hintergrund (_pruefeServerUser). Nur wenn die automatische
// Anmeldung nachweislich fehlgeschlagen ist, bleibt der manuelle Weg
// als Rückfalloption sichtbar — sonst gäbe es keinen Weg mehr zurück.
final zugriffOk =
auth.istAngemeldet || (_serverUser && !_autoLoginFehlgeschlagen);
return ListView(
padding: const EdgeInsets.fromLTRB(16, 12, 16, 24),
children: [
if (!zugriffOk) ...[
Text(
_autoLoginFehlgeschlagen
? 'Die automatische Anmeldung hat nicht geklappt — vermutlich '
'weicht dein Server-Passwort vom Baka-Konto ab. Einmal '
'manuell mit dem aktuellen Baka-Passwort anmelden, dann '
'gleicht sich das Passwort wieder ab.'
: 'Der Downloader läuft über den Baka-Server. Dafür brauchst '
'du deine Baka-Anmeldung.',
style: const TextStyle(color: MeloTheme.text2, fontSize: 13),
),
const SizedBox(height: 12),
SizedBox(
width: double.infinity,
height: MeloTheme.minTouchTarget + 4,
child: FilledButton.icon(
icon: const Icon(Icons.login),
label: const Text('Beim Baka-Konto anmelden',
style: TextStyle(fontSize: 16)),
onPressed: _anmelden,
),
),
] else ...[
if (!_serverUser) ...[
Row(
children: [
const Icon(Icons.verified_user, size: 18, color: MeloTheme.text3),
const SizedBox(width: 8),
Expanded(
child: Text('Angemeldet als ${auth.benutzer}',
style: const TextStyle(color: MeloTheme.text2, 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),
_KategorieFeld(controller: _kategorie, aktiv: !dienst.laeuft),
SwitchListTile(
dense: true,
contentPadding: EdgeInsets.zero,
title: const Text('YouTube-Cookies des Servers verwenden'),
subtitle: const Text('Nötig für altersbeschränkte Videos'),
value: _cookies,
onChanged: dienst.laeuft ? null : _setzeCookies,
),
if (_orte.length > 1) ...[
neu (der restliche Teil ab if (_orte.length > 1) ...[ bis zum Ende der
Methode bleibt unverändert stehen):
@override
Widget build(BuildContext context) {
final auth = context.watch<BakaAuth>();
final gast = context.watch<GastZugang>();
final dienst = context.watch<YtDownloadService>();
// Server-User gelten sofort als berechtigt — die Anmeldung passiert für
// sie im Hintergrund (_pruefeServerUser). Nur wenn die automatische
// Anmeldung nachweislich fehlgeschlagen ist, bleibt der manuelle Weg
// als Rückfalloption sichtbar — sonst gäbe es keinen Weg mehr zurück.
// Ein Gast-Token zählt ebenfalls als Zugang, nur mit Tages-Limit.
final zugriffOk = auth.istAngemeldet ||
(_serverUser && !_autoLoginFehlgeschlagen) ||
gast.hatToken;
final istGastModus = !_serverUser && !auth.istAngemeldet && gast.hatToken;
return ListView(
padding: const EdgeInsets.fromLTRB(16, 12, 16, 24),
children: [
if (!zugriffOk) ...[
Text(
_autoLoginFehlgeschlagen
? 'Die automatische Anmeldung hat nicht geklappt — vermutlich '
'weicht dein Server-Passwort vom Baka-Konto ab. Einmal '
'manuell mit dem aktuellen Baka-Passwort anmelden, dann '
'gleicht sich das Passwort wieder ab.'
: 'Der Downloader läuft über den Baka-Server. Dafür brauchst '
'du deine Baka-Anmeldung.',
style: const TextStyle(color: MeloTheme.text2, fontSize: 13),
),
const SizedBox(height: 12),
SizedBox(
width: double.infinity,
height: MeloTheme.minTouchTarget + 4,
child: FilledButton.icon(
icon: const Icon(Icons.login),
label: const Text('Beim Baka-Konto anmelden',
style: TextStyle(fontSize: 16)),
onPressed: _anmelden,
),
),
if (!_serverUser) ...[
const SizedBox(height: 8),
SizedBox(
width: double.infinity,
height: MeloTheme.minTouchTarget + 4,
child: OutlinedButton.icon(
icon: const Icon(Icons.person_outline),
label: const Text('Als Gast fortfahren (5 Downloads/Tag)',
style: TextStyle(fontSize: 16)),
onPressed: _alsGastFortfahren,
),
),
if (_gastFehler != null) ...[
const SizedBox(height: 8),
Text(_gastFehler!,
style: const TextStyle(color: MeloTheme.red, fontSize: 13)),
],
],
] else ...[
if (!_serverUser && auth.istAngemeldet) ...[
Row(
children: [
const Icon(Icons.verified_user, size: 18, color: MeloTheme.text3),
const SizedBox(width: 8),
Expanded(
child: Text('Angemeldet als ${auth.benutzer}',
style: const TextStyle(color: MeloTheme.text2, fontSize: 13)),
),
TextButton(
onPressed: auth.abmelden,
child: const Text('Abmelden'),
),
],
),
const SizedBox(height: 8),
],
if (istGastModus) ...[
Row(
children: [
const Icon(Icons.person_outline, size: 18, color: MeloTheme.text3),
const SizedBox(width: 8),
Expanded(
child: Text(
gast.verbleibend == null
? 'Als Gast unterwegs (5 Downloads/Tag)'
: 'Als Gast unterwegs — noch ${gast.verbleibend} von 5 heute',
style: const TextStyle(color: MeloTheme.text2, fontSize: 13),
),
),
],
),
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),
_KategorieFeld(controller: _kategorie, aktiv: !dienst.laeuft),
if (!istGastModus)
SwitchListTile(
dense: true,
contentPadding: EdgeInsets.zero,
title: const Text('YouTube-Cookies des Servers verwenden'),
subtitle: const Text('Nötig für altersbeschränkte Videos'),
value: _cookies,
onChanged: dienst.laeuft ? null : _setzeCookies,
),
if (_orte.length > 1) ...[
Wichtig: Nur der gezeigte Anfang von build() ändert sich (bis
einschließlich des SwitchListTile/if (!istGastModus)-Blocks). Der Rest
der Methode (if (_orte.length > 1) ...[ bis zum schließenden } von
build(), inklusive FilledButton.icon „Herunterladen“ und dem
dienst.laeuft-Fortschrittsblock) bleibt exakt wie vorher stehen —
insbesondere endet der else-Zweig weiterhin mit demselben schließenden
], wie zuvor.
- Step 4: Run tests to verify they pass
Run: /home/dustin/development/flutter/bin/flutter test --no-pub test/downloads/online_screen_test.dart
Expected: alle Tests grün (bestehende 12 + 3 neue = 15).
- Step 5: Run analyze
Run: timeout 300 /home/dustin/development/flutter/bin/flutter analyze --no-pub
Expected: No issues found!
- Step 6: Commit
git add lib/downloads/downloads_screen.dart test/downloads/online_screen_test.dart
git commit -m "$(cat <<'EOF'
Download-Tab: Gast-Zugang mit bewusster Wahl + Tages-Zähler
Echte Gäste sehen jetzt zwei Buttons: "Beim Baka-Konto anmelden"
und "Als Gast fortfahren (5 Downloads/Tag)". Im Gast-Modus wird
der Cookie-Schalter ausgeblendet (Server erzwingt cookies=false
für Gast-Zugriffe ohnehin) und ein Zähler "noch N von 5 heute"
gezeigt, sobald der Server ihn meldet.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
EOF
)"
Task 6: UI im YT-Suche-Tab (youtube_search_screen.dart)
Files:
- Modify:
lib/downloads/youtube_search_screen.dart - Test:
test/downloads/youtube_search_screen_test.dart
Interfaces:
-
Consumes:
GastZugangaus dem Provider-Baum (Task 4). -
Produces: nichts, das andere Tasks konsumieren — UI-Endpunkt.
-
Step 1: Write the failing tests
In test/downloads/youtube_search_screen_test.dart, Import ergänzen:
import 'package:melo/services/download_service.dart';
import 'package:melo/services/gast_zugang.dart';
import 'package:melo/services/navidrome_service.dart';
_wrap ersetzen — bisher:
Widget _wrap({
required MeloDb db,
required BakaAuth auth,
required YtSearchService search,
required YtDownloadService download,
NavidromeService? navidrome,
}) {
return MultiProvider(
providers: [
Provider<MeloDb>.value(value: db),
ChangeNotifierProvider<LibraryService>.value(value: LibraryService(db)),
ChangeNotifierProvider<BakaAuth>.value(value: auth),
ChangeNotifierProvider<YtSearchService>.value(value: search),
ChangeNotifierProvider<YtDownloadService>.value(value: download),
// Nur für die Server-User-Prüfung gebraucht (siehe
// _pruefeServerUser) — ohne gespeicherte Zugangsdaten bleibt sie ein
// Gast.
ChangeNotifierProvider<DownloadService>(
create: (_) => DownloadService(
db: db, navidrome: navidrome ?? NavidromeService()),
),
],
child: const MaterialApp(home: Scaffold(body: YoutubeSearchScreen())),
);
}
neu:
Widget _wrap({
required MeloDb db,
required BakaAuth auth,
required YtSearchService search,
required YtDownloadService download,
NavidromeService? navidrome,
GastZugang? gast,
}) {
return MultiProvider(
providers: [
Provider<MeloDb>.value(value: db),
ChangeNotifierProvider<LibraryService>.value(value: LibraryService(db)),
ChangeNotifierProvider<BakaAuth>.value(value: auth),
ChangeNotifierProvider<GastZugang>.value(value: gast ?? GastZugang()),
ChangeNotifierProvider<YtSearchService>.value(value: search),
ChangeNotifierProvider<YtDownloadService>.value(value: download),
// Nur für die Server-User-Prüfung gebraucht (siehe
// _pruefeServerUser) — ohne gespeicherte Zugangsdaten bleibt sie ein
// Gast.
ChangeNotifierProvider<DownloadService>(
create: (_) => DownloadService(
db: db, navidrome: navidrome ?? NavidromeService()),
),
],
child: const MaterialApp(home: Scaffold(body: YoutubeSearchScreen())),
);
}
Am Ende der Datei, vor dem letzten schließenden } von main(), zwei neue
Tests einfügen:
testWidgets('Gast sieht den Button "Als Gast fortfahren"', (tester) async {
final db = MeloDb(NativeDatabase.memory());
final auth = BakaAuth(
client: MockClient((_) async => http.Response('', 500)),
speicher: _MemorySpeicher());
final search = YtSearchService(auth: auth);
final download = YtDownloadService(auth: auth);
await tester.pumpWidget(_wrap(
db: db, auth: auth, search: search, download: download));
await tester.pumpAndSettle();
expect(
find.text('Als Gast fortfahren (5 Downloads/Tag)'), findsOneWidget);
await db.close();
});
testWidgets(
'Als Gast fortfahren blendet den Baka-Hinweis aus und zeigt den Zähler',
(tester) async {
final db = MeloDb(NativeDatabase.memory());
final auth = BakaAuth(
client: MockClient((_) async => http.Response('', 500)),
speicher: _MemorySpeicher());
final search = YtSearchService(auth: auth);
final download = YtDownloadService(auth: auth);
final gast = GastZugang(
client: MockClient((_) async =>
http.Response(jsonEncode({'guest_token': 'g-1'}), 200)),
speicher: _MemorySpeicher(),
);
await tester.pumpWidget(_wrap(
db: db, auth: auth, search: search, download: download, gast: gast));
await tester.pumpAndSettle();
await tester.tap(find.text('Als Gast fortfahren (5 Downloads/Tag)'));
await tester.pumpAndSettle();
expect(find.textContaining('Baka-Anmeldung'), findsNothing);
expect(find.text('Als Gast unterwegs (5 Downloads/Tag)'), findsOneWidget);
await db.close();
});
- Step 2: Run tests to verify they fail
Run: /home/dustin/development/flutter/bin/flutter test --no-pub test/downloads/youtube_search_screen_test.dart
Expected: FAIL — findsOneWidget schlägt fehl, der neue Button existiert
noch nicht.
- Step 3: Write minimal implementation
In lib/downloads/youtube_search_screen.dart, Import ergänzen (nach
import '../services/download_service.dart';):
import '../services/download_service.dart';
import '../services/gast_zugang.dart';
Neues Feld + neue Methode in _YoutubeSearchScreenState, nach
bool _autoLoginFehlgeschlagen = false;:
/// Die automatische Anmeldung ist fehlgeschlagen — dann bleibt der
/// Hinweis auf die manuelle Anmeldung (siehe Download-Tab) sichtbar.
bool _autoLoginFehlgeschlagen = false;
/// Fehlertext, falls das Holen des Gast-Tokens scheitert.
String? _gastFehler;
Nach der _pruefeServerUser()-Methode, vor dispose():
Future<void> _alsGastFortfahren() async {
final gast = context.read<GastZugang>();
setState(() => _gastFehler = null);
final fehler = await gast.holeToken();
if (!mounted) return;
if (fehler != null) setState(() => _gastFehler = fehler);
}
build() ersetzen — bisher:
@override
Widget build(BuildContext context) {
final auth = context.watch<BakaAuth>();
final suche = context.watch<YtSearchService>();
final zugriffOk =
auth.istAngemeldet || (_serverUser && !_autoLoginFehlgeschlagen);
return SafeArea(
child: Column(
children: [
Padding(
padding: const EdgeInsets.fromLTRB(16, 12, 16, 8),
child: Row(
children: [
Expanded(
child: TextField(
controller: _query,
textInputAction: TextInputAction.search,
onSubmitted: (_) => _suchen(),
decoration: const InputDecoration(
hintText: 'YouTube durchsuchen …',
prefixIcon: Icon(Icons.search),
),
),
),
const SizedBox(width: 8),
FilledButton(
onPressed: _suchen,
child: const Text('Suchen'),
),
],
),
),
if (!zugriffOk)
const Padding(
padding: EdgeInsets.symmetric(horizontal: 16, vertical: 4),
child: Text(
'Die Suche läuft über den Baka-Server. Dafür brauchst du '
'deine Baka-Anmeldung (siehe Download-Tab).',
style: TextStyle(color: MeloTheme.text2, fontSize: 13),
),
),
if (suche.laeuft) const LinearProgressIndicator(),
neu (der Rest der Methode ab if (suche.laeuft) const LinearProgressIndicator(),
bleibt unverändert):
@override
Widget build(BuildContext context) {
final auth = context.watch<BakaAuth>();
final gast = context.watch<GastZugang>();
final suche = context.watch<YtSearchService>();
final zugriffOk = auth.istAngemeldet ||
(_serverUser && !_autoLoginFehlgeschlagen) ||
gast.hatToken;
final istGastModus = !_serverUser && !auth.istAngemeldet && gast.hatToken;
return SafeArea(
child: Column(
children: [
Padding(
padding: const EdgeInsets.fromLTRB(16, 12, 16, 8),
child: Row(
children: [
Expanded(
child: TextField(
controller: _query,
textInputAction: TextInputAction.search,
onSubmitted: (_) => _suchen(),
decoration: const InputDecoration(
hintText: 'YouTube durchsuchen …',
prefixIcon: Icon(Icons.search),
),
),
),
const SizedBox(width: 8),
FilledButton(
onPressed: _suchen,
child: const Text('Suchen'),
),
],
),
),
if (!zugriffOk) ...[
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 4),
child: Text(
'Die Suche läuft über den Baka-Server. Dafür brauchst du '
'deine Baka-Anmeldung (siehe Download-Tab).',
style: const TextStyle(color: MeloTheme.text2, fontSize: 13),
),
),
if (!_serverUser) ...[
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: SizedBox(
width: double.infinity,
height: MeloTheme.minTouchTarget + 4,
child: OutlinedButton.icon(
icon: const Icon(Icons.person_outline),
label: const Text('Als Gast fortfahren (5 Downloads/Tag)',
style: TextStyle(fontSize: 16)),
onPressed: _alsGastFortfahren,
),
),
),
if (_gastFehler != null)
Padding(
padding:
const EdgeInsets.symmetric(horizontal: 16, vertical: 4),
child: Text(_gastFehler!,
style:
const TextStyle(color: MeloTheme.red, fontSize: 13)),
),
],
],
if (istGastModus)
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 4),
child: Text(
gast.verbleibend == null
? 'Als Gast unterwegs (5 Downloads/Tag)'
: 'Als Gast unterwegs — noch ${gast.verbleibend} von 5 heute',
style: const TextStyle(color: MeloTheme.text2, fontSize: 13),
),
),
if (suche.laeuft) const LinearProgressIndicator(),
- Step 4: Run tests to verify they pass
Run: /home/dustin/development/flutter/bin/flutter test --no-pub test/downloads/youtube_search_screen_test.dart
Expected: alle Tests grün (bestehende 4 + 2 neue = 6).
- Step 5: Run analyze
Run: timeout 300 /home/dustin/development/flutter/bin/flutter analyze --no-pub
Expected: No issues found!
- Step 6: Commit
git add lib/downloads/youtube_search_screen.dart test/downloads/youtube_search_screen_test.dart
git commit -m "$(cat <<'EOF'
YT-Suche-Tab: Gast-Zugang mit bewusster Wahl + Tages-Zähler
Analog zum Download-Tab: echte Gäste sehen zusätzlich zum
bestehenden Hinweis den Button "Als Gast fortfahren (5
Downloads/Tag)"; im Gast-Modus ersetzt ein Zähler den Hinweis.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
EOF
)"
Task 7: Gesamt-Verifikation, Code-Review, CHANGELOG
Files:
-
Modify:
CHANGELOG.md -
Step 1: Vollen Testlauf im Hintergrund
Run: /home/dustin/development/flutter/bin/flutter test --no-pub (im
Hintergrund, siehe Global Constraints)
Expected: alle Tests grün, Gesamtzahl = vorherige Gesamtzahl + 8 (Task 1)
- 3 (Task 2) + 2 (Task 3) + 3 (Task 5) + 2 (Task 6) = +18 gegenüber dem Stand vor diesem Plan.
- Step 2: Analyze
Run: timeout 300 /home/dustin/development/flutter/bin/flutter analyze --no-pub
Expected: No issues found!
- Step 3: Code-Review
/code-review auf den vollen Diff seit dem Abzweigen von
fix/p0-vollwertigkeit laufen lassen. Echte, bestätigte Funde beheben
(mit eigenem kleinen Commit je Fund, wie in diesem Repo üblich), triviale/
falsch-positive Funde begründet verwerfen.
- Step 4: CHANGELOG.md aktualisieren
Neuen Abschnitt ganz oben unter ## [Unreleased] einfügen (Format wie die
bestehenden Einträge: ### <Emoji> <Titel> (<Datum>), Stichpunkte mit
✅/🔧/🐛), der beschreibt:
-
Gast-Zugang mit bewusster Wahl in Download-Tab und YT-Suche-Tab
-
5 Downloads/Tag-Limit (serverseitig durchgesetzt, App zeigt nur den vom Server gemeldeten Stand an)
-
Cookie-Schalter für Gäste ausgeblendet
-
Verweis auf die Spec (
docs/superpowers/specs/2026-08-26-youtube-guest-quota-design.md) und den Hinweis, dass der Server-Teil (yt_proxy.py) separat läuft und vor dem echten Test auf dem Gerät fertig sein muss -
Test-Gesamtzahl (aus Step 1)
-
Step 5: Commit
git add CHANGELOG.md
git commit -m "$(cat <<'EOF'
CHANGELOG: YouTube-Gast-Zugang (App-Seite)
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
EOF
)"
- Step 6: Push
git push origin feature/youtube-gast-zugang
Hinweis für den Ausführenden: Die App-Seite ist damit fertig und
getestet, funktioniert aber gegen den echten Proxy erst, sobald
POST /api/guest-token und die erweiterte _checke_auth()-Logik in
yt_proxy.py existieren (siehe Spec, Abschnitt „Server-seitige
Komponenten“) — das macht die claude-server-Session/Hermes. Vor einem
echten APK-Test auf dem Gerät das mit Dustin abstimmen.