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
1782 lines
58 KiB
Markdown
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.
|