Files
Melo/docs/superpowers/plans/2026-08-26-youtube-gast-zugang.md
T
Hermes (Server)andClaude Sonnet 5 9f08286e50 Implementierungsplan: YouTube-Gast-Zugang (App-Seite)
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
2026-08-26 20:28:41 +02:00

1782 lines
58 KiB
Markdown

# 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": "<token>"}`, kein Auth
nötig. Anfragen mit Gast-Zugang senden `X-Guest-Token: <token>` statt
`Authorization: Bearer …`. Erfolgsantwort von `POST /api/yt-dl` trägt bei
Gast-Zugriff zusätzlich `"guest_remaining": <int>`.
- 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<String, String> get gastHeader`
- `Future<void> laden()`
- `Future<String?> 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<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`:
```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**
```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 <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: `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<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';`):
```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 <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: `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<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';`):
```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 <noreply@anthropic.com>
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<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';`:
```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<BakaAuth>.value(value: _bakaAuth),`:
```dart
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**
```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 <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: `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<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:
```dart
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:
```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<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:
```dart
@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):
```dart
@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**
```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 <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: `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<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:
```dart
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:
```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<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:
```dart
@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):
```dart
@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**
```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 <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**
```bash
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**
```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.