YouTube-Gast-Zugang (App-Seite): 5 Downloads/Tag ohne Konto #4

Closed
dustin wants to merge 12 commits from feature/youtube-gast-zugang into fix/p0-vollwertigkeit
9 changed files with 204 additions and 0 deletions
Showing only changes of commit d03a4e93a0 - Show all commits
+36
View File
@@ -5,6 +5,42 @@ Format angelehnt an [Keep a Changelog](https://keepachangelog.com/de/1.0.0/).
## [Unreleased] ## [Unreleased]
### ☁️ Cloud-Account-Kontingent: App-Seite für das 100/Tag-Limit (2026-08-29)
- ⚠️ **Reine App-Seite, ohne Wirkung bis der Server mitzieht.** Die
Server-Zählung (`cloud_quota`, 100/Tag je Nutzername) in `yt_proxy.py`
ist NICHT Teil dieser Änderung — das macht die `claude-server`-Session
separat (siehe Nachtrag "100/Tag-Limit auch für Cloud-Accounts" in
`docs/superpowers/specs/2026-08-26-youtube-guest-quota-design.md`). Bis
der Server das neue Feld `cloud_remaining` liefert, bleibt
`auth.verbleibend` einfach `null` — kein Fehler, kein sichtbarer
Unterschied zu heute.
- 🔢 **`BakaAuth` bekommt einen Kontingent-Stand** — analog zu
`GastZugang.verbleibend`/`.merkeVerbleibend(n)`: neues Feld `int?
verbleibend` (Getter) und `void merkeVerbleibend(int n)`
(`lib/services/baka_auth.dart`).
- 🔌 **`YtDownloadService.herunterladen()` liest `cloud_remaining`** aus
einer erfolgreichen `POST /api/yt-dl`-Antwort und ruft bei
Cloud-Zugriff (`!gastAktiv`) `auth.merkeVerbleibend(...)` auf — genau
wie beim bestehenden `guest_remaining`/`gast.merkeVerbleibend(...)`
fürs Gast-Kontingent, aber strikt getrennt (ein Gast-Zugriff liest das
Feld nie, ein Cloud-Zugriff nie `guest_remaining`).
(`lib/services/yt_download_service.dart`)
- 📟 **Angemeldete Cloud-Accounts sehen jetzt „Noch N von 100 heute“**,
sobald der Server den Stand gemeldet hat — an vergleichbarer Stelle wie
der bestehende Gast-Zähler, aber nur bei `auth.istAngemeldet` (Gäste
haben weiter ihren eigenen Text, beide schließen sich gegenseitig aus).
(`lib/downloads/downloads_screen.dart`,
`lib/downloads/youtube_search_screen.dart`)
-**Getestet (TDD, RED→GREEN):** `merkeVerbleibend` in
`test/services/baka_auth_test.dart`; Erfolgsfall mit `cloud_remaining`
plus eine Trennschärfe-Gegenprobe (Gast-Zugriff liest `cloud_remaining`
nie, selbst wenn es im Body steht) in
`test/services/yt_download_test.dart`; je ein Widget-Test für den neuen
„Noch N von 100 heute“-Text in `test/downloads/online_screen_test.dart`
und `test/downloads/youtube_search_screen_test.dart`. Volle Suite
grün, `flutter analyze` ohne Befund.
### 🐛 Gast-Zugang: Fixe aus dem Branch-Abschluss-Review (2026-08-26) ### 🐛 Gast-Zugang: Fixe aus dem Branch-Abschluss-Review (2026-08-26)
- 🔓 **Gast-Modus war eine Einbahnstraße.** Einmal "Als Gast fortfahren" - 🔓 **Gast-Modus war eine Einbahnstraße.** Einmal "Als Gast fortfahren"
+15
View File
@@ -831,6 +831,21 @@ class _YouTubeBereichState extends State<_YouTubeBereich> {
), ),
const SizedBox(height: 8), const SizedBox(height: 8),
], ],
if (auth.istAngemeldet && auth.verbleibend != null) ...[
Row(
children: [
const Icon(Icons.cloud_outlined, size: 18, color: MeloTheme.text3),
const SizedBox(width: 8),
Expanded(
child: Text(
'Noch ${auth.verbleibend} von 100 heute',
style: const TextStyle(color: MeloTheme.text2, fontSize: 13),
),
),
],
),
const SizedBox(height: 8),
],
if (istGastModus) ...[ if (istGastModus) ...[
Row( Row(
children: [ children: [
+16
View File
@@ -159,6 +159,22 @@ class _YoutubeSearchScreenState extends State<YoutubeSearchScreen> {
), ),
], ],
], ],
if (auth.istAngemeldet && auth.verbleibend != null)
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 4),
child: Row(
children: [
const Icon(Icons.cloud_outlined, size: 18, color: MeloTheme.text3),
const SizedBox(width: 8),
Expanded(
child: Text(
'Noch ${auth.verbleibend} von 100 heute',
style: const TextStyle(color: MeloTheme.text2, fontSize: 13),
),
),
],
),
),
if (istGastModus) if (istGastModus)
Padding( Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 4), padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 4),
+12
View File
@@ -47,10 +47,16 @@ class BakaAuth extends ChangeNotifier {
String? _token; String? _token;
String _benutzer = ''; String _benutzer = '';
int? _verbleibend;
bool get istAngemeldet => _token != null && _token!.isNotEmpty; bool get istAngemeldet => _token != null && _token!.isNotEmpty;
String get benutzer => _benutzer; String get benutzer => _benutzer;
/// Zuletzt vom Server gemeldeter Kontingent-Stand (100/Tag) — nur fürs
/// Anzeigen, die Wahrheit bleibt beim Server. `null` vor dem ersten
/// Download in dieser Session.
int? get verbleibend => _verbleibend;
/// Header für Anfragen an den Proxy. Ohne Anmeldung ohne `Authorization`. /// Header für Anfragen an den Proxy. Ohne Anmeldung ohne `Authorization`.
Map<String, String> get authHeader => { Map<String, String> get authHeader => {
if (istAngemeldet) 'Authorization': 'Bearer $_token', if (istAngemeldet) 'Authorization': 'Bearer $_token',
@@ -111,6 +117,12 @@ class BakaAuth extends ChangeNotifier {
await anmelden(benutzer, passwort); await anmelden(benutzer, passwort);
} }
/// Übernimmt den zuletzt vom Server gemeldeten Kontingent-Stand.
void merkeVerbleibend(int n) {
_verbleibend = n;
notifyListeners();
}
Future<void> abmelden() async { Future<void> abmelden() async {
_token = null; _token = null;
_benutzer = ''; _benutzer = '';
+4
View File
@@ -160,6 +160,10 @@ class YtDownloadService extends ChangeNotifier {
if (gastAktiv && gastRest != null) { if (gastAktiv && gastRest != null) {
gast!.merkeVerbleibend(gastRest); gast!.merkeVerbleibend(gastRest);
} }
final cloudRest = daten['cloud_remaining'] as int?;
if (!gastAktiv && cloudRest != null) {
auth.merkeVerbleibend(cloudRest);
}
// ─── 2/2: fertige MP3 abholen und speichern ─── // ─── 2/2: fertige MP3 abholen und speichern ───
_melde('Lade „$titel“ …'); _melde('Lade „$titel“ …');
+15
View File
@@ -253,6 +253,21 @@ void main() {
findsOneWidget); findsOneWidget);
}); });
testWidgets(
'Angemeldeter Cloud-Account sieht das Kontingent nach einem Download',
(tester) async {
final auth = await _angemeldet();
await tester.pumpWidget(_wrap(auth, db: db, categories: categories));
await tester.pumpAndSettle();
expect(find.textContaining('von 100 heute'), findsNothing);
auth.merkeVerbleibend(63);
await tester.pump();
expect(find.text('Noch 63 von 100 heute'), findsOneWidget);
});
testWidgets( testWidgets(
'Server-User meldet sich im Hintergrund mit denselben Zugangsdaten ' 'Server-User meldet sich im Hintergrund mit denselben Zugangsdaten '
'beim Baka-Konto an', (tester) async { 'beim Baka-Konto an', (tester) async {
@@ -176,6 +176,26 @@ void main() {
await db.close(); await db.close();
}); });
testWidgets('Angemeldeter Cloud-Account sieht das Kontingent nach Download',
(tester) async {
final db = MeloDb(NativeDatabase.memory());
final auth = await _angemeldeteAuth();
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.textContaining('von 100 heute'), findsNothing);
auth.merkeVerbleibend(98);
await tester.pump();
expect(find.text('Noch 98 von 100 heute'), findsOneWidget);
await db.close();
});
// Kein Widget-Test für den Download-Knopf selbst: YtDownloadService. // Kein Widget-Test für den Download-Knopf selbst: YtDownloadService.
// herunterladen() kombiniert Future.any mit .timeout() — dessen Timer // herunterladen() kombiniert Future.any mit .timeout() — dessen Timer
// bleibt unter Flutters fake_async-Testbindung immer als "pending" stehen // bleibt unter Flutters fake_async-Testbindung immer als "pending" stehen
+14
View File
@@ -129,6 +129,20 @@ void main() {
expect(auth.istAngemeldet, isFalse); expect(auth.istAngemeldet, isFalse);
}); });
test('merkeVerbleibend übernimmt den Kontingent-Stand und meldet Listener',
() async {
final auth = BakaAuth(
client: MockClient((_) async => http.Response('', 500)),
speicher: _MemorySpeicher());
var benachrichtigt = 0;
auth.addListener(() => benachrichtigt++);
auth.merkeVerbleibend(37);
expect(auth.verbleibend, 37);
expect(benachrichtigt, 1);
});
test('Abmelden löscht Token und Benutzer aus dem Speicher', () async { test('Abmelden löscht Token und Benutzer aus dem Speicher', () async {
final speicher = _MemorySpeicher(); final speicher = _MemorySpeicher();
final auth = BakaAuth( final auth = BakaAuth(
+72
View File
@@ -380,4 +380,76 @@ void main() {
expect(tokenAufrufe, 1); expect(tokenAufrufe, 1);
}); });
}); });
group('Cloud-Kontingent', () {
test('cloud_remaining aus der Erfolgsantwort landet in auth.verbleibend',
() async {
final auth = await _angemeldeteAuth();
final dienst = YtDownloadService(
auth: auth,
client: MockClient((req) async {
if (req.url.path == '/api/yt-dl') {
return http.Response(
jsonEncode({
'titel': 'Cloud-Lied',
'dauer': 30,
'mp3_url': '/api/dl/c.mp3',
'cloud_remaining': 99,
}),
200);
}
return http.Response.bytes([1], 200);
}),
);
final ergebnis = await dienst.herunterladen('https://youtu.be/abc',
zielOrdner: ziel.path);
expect(ergebnis, isNotNull);
expect(auth.verbleibend, 99);
});
test(
'Bei Gast-Zugriff wird cloud_remaining nicht gelesen, selbst wenn es '
'im Body steht', () 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 auth = BakaAuth(
client: MockClient((_) async => http.Response('', 500)),
speicher: _MemorySpeicher());
final dienst = YtDownloadService(
auth: auth,
gast: gast,
client: MockClient((req) async {
if (req.url.path == '/api/yt-dl') {
return http.Response(
jsonEncode({
'titel': 'Gast-Lied',
'dauer': 30,
'mp3_url': '/api/dl/g.mp3',
'guest_remaining': 4,
// Sollte serverseitig nie gleichzeitig vorkommen, aber
// falls doch: darf nicht in auth.verbleibend landen.
'cloud_remaining': 77,
}),
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);
expect(auth.verbleibend, isNull);
});
});
} }