diff --git a/docs/superpowers/plans/2026-08-26-youtube-gast-zugang.md b/docs/superpowers/plans/2026-08-26-youtube-gast-zugang.md new file mode 100644 index 0000000..5274632 --- /dev/null +++ b/docs/superpowers/plans/2026-08-26-youtube-gast-zugang.md @@ -0,0 +1,1781 @@ +# 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 analyze` muss nach jedem Task sauber sein. +- `flutter test --no-pub` muss 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": ""}`, kein Auth + nötig. Anfragen mit Gast-Zugang senden `X-Guest-Token: ` statt + `Authorization: Bearer …`. Erfolgsantwort von `POST /api/yt-dl` trägt bei + Gast-Zugriff zusätzlich `"guest_remaining": `. +- Neuer Branch für dieses Feature: `feature/youtube-gast-zugang` (Projektregel: + `fix/xxx` oder `feature/xxx`) — vor Task 1 von `fix/p0-vollwertigkeit` + abzweigen. +- Vor dem Abschluss-Commit (Task 7): `/code-review` auf 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`, `SicherenSpeicher` aus `lib/services/baka_auth.dart` + (bereits vorhanden, keine Änderung nötig). +- Produces (für Task 2, 3, 5, 6): + - `class GastZugang extends ChangeNotifier` + - `GastZugang({http.Client? client, TokenSpeicher? speicher})` + - `bool get hatToken` + - `int? get verbleibend` + - `Map get gastHeader` + - `Future laden()` + - `Future holeToken()` — `null` bei Erfolg, sonst Fehlertext + - `void merkeVerbleibend(int n)` + +- [ ] **Step 0: Feature-Branch anlegen** + +```bash +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`: + +```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 werte = {}; + @override + Future lesen(String key) async => werte[key]; + @override + Future schreiben(String key, String wert) async => werte[key] = wert; + @override + Future loeschen(String key) async => werte.remove(key); +} + +void main() { + test('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`: + +```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 get gastHeader => + {if (hatToken) 'X-Guest-Token': _token!}; + + /// Stellt einen früher geholten Gast-Token wieder her (App-Start). + Future 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 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 daten; + try { + daten = jsonDecode(antwort.body) as Map; + } 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** + +```bash +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 +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: `GastZugang` aus Task 1 (`hatToken`, `gastHeader`, `holeToken()`, `merkeVerbleibend(int)`). +- Produces (für Task 4, 5, 6): `YtDownloadService({required BakaAuth auth, GastZugang? gast, http.Client? client})` + — `gast` ist optional und rückwärtskompatibel (Default `null`, 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): + +```dart +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: + +```dart + 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.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';`): + +```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;`): + +```dart +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: + +```dart + 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: + +```dart + 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: + +```dart + if (antwort.statusCode == 401) { + _scheitere('Anmeldung abgelaufen — bitte neu anmelden'); + return null; + } +``` + +neu: + +```dart + 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: + +```dart + 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: + +```dart + final mp3 = await _abbrechbar( + _client + .get(Uri.parse('$proxyUrl$mp3Url'), headers: auth.authHeader) + .timeout(const Duration(seconds: 180)), + abbruch, + ); +``` + +neu: + +```dart + 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** + +```bash +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 +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: `GastZugang` aus 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: + +```dart +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: + +```dart + 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.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';`): + +```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;`: + +```dart +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: + +```dart + 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: + +```dart + 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** + +```bash +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 +EOF +)" +``` + +--- + +## Task 4: `GastZugang` in `main.dart` verdrahten + +**Files:** +- Modify: `lib/main.dart` + +**Interfaces:** +- Consumes: `GastZugang` (Task 1), `YtDownloadService`/`YtSearchService` mit + `gast`-Parameter (Task 2, 3). +- Produces (für Task 5, 6): App-weiter `GastZugang`-Provider, erreichbar + über `context.watch()`/`context.read()`. + +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';`: + +```dart +import 'services/geteilte_adresse.dart'; +import 'services/gast_zugang.dart'; +``` + +- [ ] **Step 2: Late-Field ergänzen** + +Nach `late final BakaAuth _bakaAuth;`: + +```dart +late final BakaAuth _bakaAuth; +late final GastZugang _gastZugang; +``` + +- [ ] **Step 3: Instanziierung + Verdrahtung in `main()`** + +Bisher: + +```dart + _bakaAuth = BakaAuth(); + await _bakaAuth.laden(); + _ytDownload = YtDownloadService(auth: _bakaAuth); + _ytSearch = YtSearchService(auth: _bakaAuth); +``` + +neu: + +```dart + _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.value(value: _bakaAuth),`: + +```dart + ChangeNotifierProvider.value(value: _bakaAuth), + ChangeNotifierProvider.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** + +```bash +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 +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: `GastZugang` aus dem Provider-Baum (Task 4), `pruefeServerUser` + (bereits vorhanden aus `baka_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';`): + +```dart +import 'package:melo/services/download_service.dart'; +import 'package:melo/services/gast_zugang.dart'; +``` + +`_wrap` ersetzen — bisher: + +```dart +Widget _wrap(BakaAuth auth, + {required MeloDb db, + required CategoryService categories, + NavidromeService? navidrome}) { + return MultiProvider( + providers: [ + Provider.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.value(value: categories), + ChangeNotifierProvider.value(value: auth), + ChangeNotifierProvider( + 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( + create: (_) => DownloadService( + db: db, navidrome: navidrome ?? NavidromeService()), + ), + ], + child: const MaterialApp(home: Scaffold(body: DownloadsScreen())), + ); +} +``` + +neu: + +```dart +Widget _wrap(BakaAuth auth, + {required MeloDb db, + required CategoryService categories, + NavidromeService? navidrome, + GastZugang? gast}) { + final gastZugang = gast ?? GastZugang(); + return MultiProvider( + providers: [ + Provider.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.value(value: categories), + ChangeNotifierProvider.value(value: auth), + ChangeNotifierProvider.value(value: gastZugang), + ChangeNotifierProvider( + 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( + 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: + +```dart + 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';`): + +```dart +import '../services/download_service.dart'; +import '../services/gast_zugang.dart'; +``` + +In `_YouTubeBereichState`, neues Feld nach `bool _autoLoginFehlgeschlagen = false;`: + +```dart + /// 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()`: + +```dart + Future _anmelden() async { + await showDialog( + context: context, + builder: (_) => const _AnmeldeDialog(), + ); + } + + Future _alsGastFortfahren() async { + final gast = context.read(); + setState(() => _gastFehler = null); + final fehler = await gast.holeToken(); + if (!mounted) return; + if (fehler != null) setState(() => _gastFehler = fehler); + } +``` + +`build()` komplett ersetzen — bisher: + +```dart + @override + Widget build(BuildContext context) { + final auth = context.watch(); + final dienst = context.watch(); + // 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): + +```dart + @override + Widget build(BuildContext context) { + final auth = context.watch(); + final gast = context.watch(); + final dienst = context.watch(); + // 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** + +```bash +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 +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: `GastZugang` aus 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: + +```dart +import 'package:melo/services/download_service.dart'; +import 'package:melo/services/gast_zugang.dart'; +import 'package:melo/services/navidrome_service.dart'; +``` + +`_wrap` ersetzen — bisher: + +```dart +Widget _wrap({ + required MeloDb db, + required BakaAuth auth, + required YtSearchService search, + required YtDownloadService download, + NavidromeService? navidrome, +}) { + return MultiProvider( + providers: [ + Provider.value(value: db), + ChangeNotifierProvider.value(value: LibraryService(db)), + ChangeNotifierProvider.value(value: auth), + ChangeNotifierProvider.value(value: search), + ChangeNotifierProvider.value(value: download), + // Nur für die Server-User-Prüfung gebraucht (siehe + // _pruefeServerUser) — ohne gespeicherte Zugangsdaten bleibt sie ein + // Gast. + ChangeNotifierProvider( + create: (_) => DownloadService( + db: db, navidrome: navidrome ?? NavidromeService()), + ), + ], + child: const MaterialApp(home: Scaffold(body: YoutubeSearchScreen())), + ); +} +``` + +neu: + +```dart +Widget _wrap({ + required MeloDb db, + required BakaAuth auth, + required YtSearchService search, + required YtDownloadService download, + NavidromeService? navidrome, + GastZugang? gast, +}) { + return MultiProvider( + providers: [ + Provider.value(value: db), + ChangeNotifierProvider.value(value: LibraryService(db)), + ChangeNotifierProvider.value(value: auth), + ChangeNotifierProvider.value(value: gast ?? GastZugang()), + ChangeNotifierProvider.value(value: search), + ChangeNotifierProvider.value(value: download), + // Nur für die Server-User-Prüfung gebraucht (siehe + // _pruefeServerUser) — ohne gespeicherte Zugangsdaten bleibt sie ein + // Gast. + ChangeNotifierProvider( + 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: + +```dart + 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';`): + +```dart +import '../services/download_service.dart'; +import '../services/gast_zugang.dart'; +``` + +Neues Feld + neue Methode in `_YoutubeSearchScreenState`, nach +`bool _autoLoginFehlgeschlagen = false;`: + +```dart + /// 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()`: + +```dart + Future _alsGastFortfahren() async { + final gast = context.read(); + setState(() => _gastFehler = null); + final fehler = await gast.holeToken(); + if (!mounted) return; + if (fehler != null) setState(() => _gastFehler = fehler); + } +``` + +`build()` ersetzen — bisher: + +```dart + @override + Widget build(BuildContext context) { + final auth = context.watch(); + final suche = context.watch(); + 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): + +```dart + @override + Widget build(BuildContext context) { + final auth = context.watch(); + final gast = context.watch(); + final suche = context.watch(); + 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** + +```bash +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 +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: `### ()`, 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** + +```bash +git add CHANGELOG.md +git commit -m "$(cat <<'EOF' +CHANGELOG: YouTube-Gast-Zugang (App-Seite) + +Co-Authored-By: Claude Sonnet 5 +EOF +)" +``` + +- [ ] **Step 6: Push** + +```bash +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.